diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4dd5a2d..db024b2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -28,7 +28,8 @@ "extensions": [ "charliermarsh.ruff", "ms-azuretools.vscode-docker", - "ms-python.mypy-type-checker", + "ms-python.python", + "ms-python.vscode-pylance", "tamasfe.even-better-toml" ], "settings": { @@ -48,7 +49,9 @@ "python.testing.pytestArgs": [ "src/tests" ], - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "python.analysis.typeCheckingMode": "standard", + "python.analysis.autoImportCompletions": true } }, "black-formatter.args": [ diff --git a/.dockerignore b/.dockerignore index 1889b90..f1e12e7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,7 +14,7 @@ Dockerfile docker-compose.yaml # python related -.mypy_cache +.pyright_cache .pytest_cache *.pyc *.pyo diff --git a/.gitignore b/.gitignore index b073d48..dd862bb 100755 --- a/.gitignore +++ b/.gitignore @@ -25,10 +25,11 @@ var/ .venv logs/* .ptpython-history -.mypy_cache +.pyright_cache .pytest_cache .coverage *.coverage +.python-version .devcontainer/commandhistory !.devcontainer/commandhistory/.gitkeep diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0dcb817..66f697f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ default_stages: [pre-commit, pre-push] files: ^src/|^alembic/versions/ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -12,7 +12,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.5.5 + rev: v0.11.3 hooks: # Run the linter. - id: ruff @@ -20,13 +20,30 @@ repos: # Run the formatter. - id: ruff-format - - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.8.0' + - repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.402 hooks: - - id: mypy + - id: pyright exclude: ^src/alembic/|^scripts/|^\\.devcontainer/|^\\.github/ additional_dependencies: [ + fastapi==0.115.12, pydantic==2.10.6, - SQLAlchemy==2.0.39, - types-mock==5.2.0.20250306 + pydantic-settings==2.8.1, + sqlalchemy==2.0.39, + asyncpg==0.30.0, + structlog==25.3.0, + sqladmin==0.20.1, + fastapi-pagination==0.12.34, + opentelemetry-api==1.31.1, + opentelemetry-sdk==1.31.1, + opentelemetry-instrumentation-fastapi==0.52b1, + opentelemetry-instrumentation-sqlalchemy==0.52b1, + opentelemetry-exporter-otlp==1.31.1, + celery==5.4.0, + python-jose==3.4.0, + passlib==1.7.4, + uvicorn==0.34.0, + httpx==0.28.1, + pytest==8.3.5, + ptpython==3.0.29 ] diff --git a/README.md b/README.md index bff7036..66988e3 100755 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ We use Alembic as database migration tool. You can run migration commands direct Linters, formatters, etc. - **ruff**: Linter and formatter -- **mypy**: Static type checker +- **pyright**: Static type checker ### pre-commit diff --git a/pyproject.toml b/pyproject.toml index c904764..c442c8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,8 +39,7 @@ dev = [ "coverage>=7.7.1,<8", "flower>=2.0.1,<3", "mock>=5.2.0,<6", - "mypy>=1.15.0,<2", - "mypy-extensions>=1.0.0,<2", + "pyright>=1.1.402,<2", "pytest>=8.3.5,<9", "pytest-asyncio==0.26.0", "pre-commit>=4.2.0,<5", @@ -68,6 +67,7 @@ include = ["src/*"] [tool.hatch.build.targets.wheel.sources] "src/*" = "*" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -93,34 +93,37 @@ known-third-party = ["fastapi", "sqlalchemy", "pydantic"] force-single-line = false combine-as-imports = true -[tool.mypy] -plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"] -ignore_missing_imports = true -disallow_untyped_defs = true -warn_unused_ignores = false -no_implicit_optional = true -implicit_reexport = true -explicit_package_bases = true -namespace_packages = true -follow_imports = "silent" -warn_redundant_casts = true -check_untyped_defs = true -no_implicit_reexport = true -disable_error_code = ["name-defined", "call-arg", "attr-defined"] - -[[tool.mypy.overrides]] -module = "starlette_context.plugins" -implicit_reexport = true - -[[tool.mypy.overrides]] -module = "app.middlewares.logging_middleware" -warn_unused_ignores = false - -[tool.pydantic-mypy] -init_forbid_extra = true -init_typed = true -warn_required_dynamic_aliases = false -warn_untyped_fields = true +[tool.pyright] +include = ["src"] +exclude = ["src/alembic/versions", "**/__pycache__", "scripts"] +reportMissingImports = true +reportMissingTypeStubs = false +pythonVersion = "3.13" +typeCheckingMode = "standard" +useLibraryCodeForTypes = true +strictListInference = true +strictDictionaryInference = true +strictSetInference = true +analyzeUnannotatedFunctions = true +strictParameterNoneValue = true +enableTypeIgnoreComments = true +reportGeneralTypeIssues = true +reportOptionalSubscript = true +reportOptionalMemberAccess = true +reportOptionalCall = true +reportOptionalIterable = true +reportOptionalContextManager = true +reportOptionalOperand = true +reportTypedDictNotRequiredAccess = false +reportPrivateUsage = false +reportUnknownArgumentType = false +reportUnknownLambdaType = false +reportUnknownMemberType = false +reportUnknownParameterType = false +reportUnknownVariableType = false +reportUnnecessaryIsInstance = false +reportUnnecessaryCast = false +reportUnnecessaryComparison = false [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/scripts/format.sh b/scripts/format.sh index 3bd134b..a878121 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -1,10 +1,10 @@ #!/bin/bash -printf "\nRunning mypy...\n" -uv run python -m mypy src +printf "\nRunning pyright...\n" +uv run pyright src printf "\nRunning ruff check...\n" -ruff check --fix +uv run ruff check --fix printf "\nRunning ruff format...\n" -ruff format +uv run ruff format diff --git a/src/admin.py b/src/admin.py index 1efcdc7..f60294e 100755 --- a/src/admin.py +++ b/src/admin.py @@ -36,6 +36,8 @@ async def authenticate(self, request: Request) -> RedirectResponse | bool: token = request.session.get(AdminAuth.cookie_name) if not token: return failed_auth_response + + session = None try: async_session = async_session_generator() async with async_session() as session: @@ -44,13 +46,14 @@ async def authenticate(self, request: Request) -> RedirectResponse | bool: except Exception: return failed_auth_response finally: - await session.close() + if session is not None: + await session.close() if not user.is_superuser: return failed_auth_response return True -class UserAdmin(ModelView, model=User): +class UserAdmin(ModelView, model=User): # type: ignore[misc] column_list = [ User.email, User.created_at, @@ -59,7 +62,7 @@ class UserAdmin(ModelView, model=User): column_searchable_list = [User.id, User.email] -class ItemAdmin(ModelView, model=Item): +class ItemAdmin(ModelView, model=Item): # type: ignore[misc] column_list = [ Item.name, Item.description, diff --git a/src/alembic/env.py b/src/alembic/env.py index 041b4a7..4114c9e 100755 --- a/src/alembic/env.py +++ b/src/alembic/env.py @@ -1,11 +1,11 @@ import asyncio from logging.config import fileConfig -from alembic import context -from sqlalchemy import engine_from_config, pool +from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config +from alembic import context from src import models # noqa F401 from src.core.config import settings from src.core.database import SQLBase diff --git a/src/api/dependencies.py b/src/api/dependencies.py index 7289ab8..246bed3 100755 --- a/src/api/dependencies.py +++ b/src/api/dependencies.py @@ -8,15 +8,18 @@ async def db_session() -> AsyncIterator[AsyncSession]: + session = None try: async_session = async_session_generator() async with async_session() as session: yield session except Exception: - await session.rollback() + if session is not None: + await session.rollback() raise finally: - await session.close() + if session is not None: + await session.close() async def get_user(request: Request, session: AsyncSession = Depends(db_session)) -> User: diff --git a/src/api/v1/routers/task.py b/src/api/v1/routers/task.py index b1cfa47..fde242d 100644 --- a/src/api/v1/routers/task.py +++ b/src/api/v1/routers/task.py @@ -14,11 +14,11 @@ # you have to implement the task logic in the src/task_queue/task.py file @router.post("/add", response_model=Task) def add_task(payload: TaskCreate = Body(...)) -> Any: - task = add.delay(payload.delay, payload.x, payload.y) + task = add.delay(payload.delay, payload.x, payload.y) # type: ignore[reportFunctionMemberAccess] return {"task_id": task.id} @router.get("/task/{task_id}", response_model=TaskResult) def get_task_result(task_id: str = Path(...)) -> Any: - task: AsyncResult = add.AsyncResult(task_id) + task: AsyncResult = add.AsyncResult(task_id) # type: ignore[reportFunctionMemberAccess] return {"task_id": task.id, "task_status": task.status, "task_result": task.result} diff --git a/src/core/config.py b/src/core/config.py index abd9f9e..f42fa5b 100755 --- a/src/core/config.py +++ b/src/core/config.py @@ -27,6 +27,10 @@ class LogSettings(BaseSettings): structured_log: bool | Literal["auto"] = "auto" cache_loggers: bool = True + def __hash__(self) -> int: + """Make LogSettings hashable so it can be used as a dict key""" + return hash((self.log_level, self.structured_log, self.cache_loggers)) + @property def enable_structured_log(self) -> bool: return not sys.stdout.isatty() if self.structured_log == "auto" else self.structured_log @@ -42,7 +46,7 @@ def parse_log_level(cls, data: Any) -> Any: class Settings(BaseSettings): # Makes the settings immutable and hashable (can be used as dicts key) - model_config = SettingsConfigDict(frozen=True) + model_config = SettingsConfigDict(frozen=True, env_file=".env", env_file_encoding="utf-8") env: str = "dev" project_name: str @@ -72,4 +76,4 @@ class Settings(BaseSettings): otel_exporter_otlp_endpoint: str -settings = Settings() +settings = Settings() # type: ignore[reportCallIssue] diff --git a/src/helpers/shell.py b/src/helpers/shell.py index 8eae9d6..e4cccbf 100644 --- a/src/helpers/shell.py +++ b/src/helpers/shell.py @@ -1,3 +1,4 @@ +import asyncio import inspect from typing import Dict, Type @@ -5,7 +6,7 @@ from src import models from src.core.config import settings -from src.core.database import SessionLocal, SQLBase +from src.core.database import SQLBase, async_session_generator def _get_models() -> Dict[str, Type[SQLBase]]: @@ -16,12 +17,26 @@ def _get_models() -> Dict[str, Type[SQLBase]]: return models_dict -def _start_shell() -> None: +async def _start_shell_async() -> None: + """Async shell function that properly handles the async session""" models = _get_models() - with SessionLocal() as session: + async_session = async_session_generator() + async with async_session() as session: locals = {"session": session, "settings": settings, **models} embed(locals=locals, history_filename=".ptpython-history") +def _start_shell() -> None: + """Start the shell - wrapper to handle async context""" + try: + # Try to use existing event loop if available + asyncio.get_running_loop() + # If we're already in an async context, we can't use asyncio.run + asyncio.create_task(_start_shell_async()) + except RuntimeError: + # No event loop running, start new one + asyncio.run(_start_shell_async()) + + if __name__ == "__main__": _start_shell() diff --git a/src/tests/base.py b/src/tests/base.py index b43601d..b3c7c6c 100755 --- a/src/tests/base.py +++ b/src/tests/base.py @@ -25,15 +25,18 @@ def async_session_generator() -> async_sessionmaker[AsyncSession]: async def override_get_db() -> AsyncIterator[AsyncSession]: + session = None try: async_session = async_session_generator() async with async_session() as session: yield session except Exception: - await session.rollback() + if session is not None: + await session.rollback() raise finally: - await session.close() + if session is not None: + await session.close() app.dependency_overrides[db_session] = override_get_db diff --git a/src/tests/test_user.py b/src/tests/test_user.py index ee6499f..fbe5c31 100755 --- a/src/tests/test_user.py +++ b/src/tests/test_user.py @@ -4,7 +4,6 @@ from httpx import AsyncClient, Response from jose import jwt -from mock import patch from src.core.config import settings from src.core.security import AuthManager, PasswordManager diff --git a/uv.lock b/uv.lock index 19b5865..13edafb 100644 --- a/uv.lock +++ b/uv.lock @@ -644,25 +644,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/d9/617e6af809bf3a1d468e0d58c3997b1dc219a9a9202e650d30c2fc85d481/mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f", size = 31617, upload-time = "2025-03-03T12:31:41.518Z" }, ] -[[package]] -name = "mypy" -version = "1.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mypy-extensions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload-time = "2025-02-05T03:50:34.655Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592, upload-time = "2025-02-05T03:48:55.789Z" }, - { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611, upload-time = "2025-02-05T03:48:44.581Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443, upload-time = "2025-02-05T03:49:25.514Z" }, - { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541, upload-time = "2025-02-05T03:49:57.623Z" }, - { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348, upload-time = "2025-02-05T03:48:52.361Z" }, - { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648, upload-time = "2025-02-05T03:49:11.395Z" }, - { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload-time = "2025-02-05T03:50:08.348Z" }, -] - [[package]] name = "mypy-extensions" version = "1.0.0" @@ -1056,6 +1037,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "pyright" +version = "1.1.402" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/04/ce0c132d00e20f2d2fb3b3e7c125264ca8b909e693841210534b1ea1752f/pyright-1.1.402.tar.gz", hash = "sha256:85a33c2d40cd4439c66aa946fd4ce71ab2f3f5b8c22ce36a623f59ac22937683", size = 3888207, upload-time = "2025-06-11T08:48:35.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/37/1a1c62d955e82adae588be8e374c7f77b165b6cb4203f7d581269959abbc/pyright-1.1.402-py3-none-any.whl", hash = "sha256:2c721f11869baac1884e846232800fe021c33f1b4acb3929cff321f7ea4e2982", size = 5624004, upload-time = "2025-06-11T08:48:33.998Z" }, +] + [[package]] name = "pytest" version = "8.3.5" @@ -1164,9 +1158,8 @@ dev = [ { name = "coverage" }, { name = "flower" }, { name = "mock" }, - { name = "mypy" }, - { name = "mypy-extensions" }, { name = "pre-commit" }, + { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -1215,9 +1208,8 @@ dev = [ { name = "coverage", specifier = ">=7.7.1,<8" }, { name = "flower", specifier = ">=2.0.1,<3" }, { name = "mock", specifier = ">=5.2.0,<6" }, - { name = "mypy", specifier = ">=1.15.0,<2" }, - { name = "mypy-extensions", specifier = ">=1.0.0,<2" }, { name = "pre-commit", specifier = ">=4.2.0,<5" }, + { name = "pyright", specifier = ">=1.1.402,<2" }, { name = "pytest", specifier = ">=8.3.5,<9" }, { name = "pytest-asyncio", specifier = "==0.26.0" }, { name = "ruff", specifier = ">=0.11.3,<0.12" },