diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..d8f82ad --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,144 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: # Allow manual triggering + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Code Formatting & Linting + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install pre-commit + run: pip install pre-commit + + - name: Cache pre-commit hooks + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: | + pre-commit- + + - name: Run pre-commit on all files + run: pre-commit run --all-files --show-diff-on-failure + + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: pip-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }} + restore-keys: | + pip-${{ runner.os }}-${{ matrix.python-version }}- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run pytest with coverage + run: | + pytest tests/ -v --cov=injectq --cov-report=xml --cov-report=term-missing --ignore=tests/test_benchmarks.py -k "not test_async_cascading_dependencies" + + - name: Upload coverage reports to Codecov + if: matrix.python-version == '3.12' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + type-check: + name: Type Checking + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run mypy + run: mypy injectq --ignore-missing-imports --no-error-summary + continue-on-error: true + + pr-summary: + name: PR Summary + runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' && !cancelled() }} + needs: [lint, test, type-check] + permissions: + pull-requests: write + steps: + - name: Post PR Comment + if: always() + uses: actions/github-script@v7 + with: + script: | + const lintStatus = '${{ needs.lint.result }}'; + const testStatus = '${{ needs.test.result }}'; + const typeCheckStatus = '${{ needs.type-check.result }}'; + + const statusIcon = (status) => status === 'success' ? '✓' : '✗'; + const statusText = (status) => status === 'success' ? 'Passed' : 'Failed'; + + const allPassed = lintStatus === 'success' && testStatus === 'success' && typeCheckStatus === 'success'; + + const comment = `## CI Results Summary + + | Check | Status | + |-------|--------| + | Code Formatting & Linting | ${statusIcon(lintStatus)} ${statusText(lintStatus)} | + | Tests (Python 3.10-3.13) | ${statusIcon(testStatus)} ${statusText(testStatus)} | + | Type Checking | ${statusIcon(typeCheckStatus)} ${statusText(typeCheckStatus)} | + + **Overall Status:** ${allPassed ? 'All checks passed' : 'Some checks failed'} + + ${allPassed ? 'This PR is ready for review.' : 'Please fix the failing checks before merging.'}`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); diff --git a/injectq/decorators/inject.py b/injectq/decorators/inject.py index b6ce4cb..2992948 100644 --- a/injectq/decorators/inject.py +++ b/injectq/decorators/inject.py @@ -341,13 +341,14 @@ def __bool__(self) -> bool: getattr(self.service_type, "__name__", str(self.service_type)), result, ) - return result except DependencyNotFoundError: _logger.warning( "Dependency not found for __bool__ check: %s", getattr(self.service_type, "__name__", str(self.service_type)), ) return False + else: + return result def __eq__(self, other: object) -> bool: """Compares the resolved object to another object.""" diff --git a/injectq/diagnostics/visualization.py b/injectq/diagnostics/visualization.py index 32ba79f..5ac73ea 100644 --- a/injectq/diagnostics/visualization.py +++ b/injectq/diagnostics/visualization.py @@ -1,10 +1,7 @@ """Dependency graph visualization and analysis.""" -import logging - -_logger = logging.getLogger("injectq.diagnostics") -_logger.debug("visualization module initialized") import inspect +import logging from collections import defaultdict from pathlib import Path from typing import Any @@ -14,6 +11,10 @@ from injectq.utils.types import ServiceKey +_logger = logging.getLogger("injectq.diagnostics") +_logger.debug("visualization module initialized") + + class VisualizationError(InjectQError): """Errors related to dependency visualization.""" diff --git a/injectq/integrations/fastapi.py b/injectq/integrations/fastapi.py index 7faf04f..bcd5af1 100644 --- a/injectq/integrations/fastapi.py +++ b/injectq/integrations/fastapi.py @@ -109,10 +109,10 @@ def inject_dependency() -> T: _HAS_FASTAPI = False if _HAS_FASTAPI: - BaseHTTPMiddlewareBase = BaseHTTPMiddleware # type: ignore[assignment] + BaseHTTPMiddlewareBase: Any = BaseHTTPMiddleware else: - class BaseHTTPMiddlewareBase: # pragma: no cover - fallback base + class BaseHTTPMiddlewareBase: # type: ignore[no-redef] # pragma: no cover - fallback base def __init__(self, app: Any) -> None: self.app = app @@ -165,7 +165,7 @@ def setup_fastapi(container: "InjectQ", app: Any) -> None: "setup_fastapi requires the 'fastapi' package. Install with " "'pip install injectq[fastapi]' or 'pip install fastapi'." ) - _logger.error(msg) + _logger.exception(msg) raise RuntimeError(msg) from exc app.add_middleware(InjectQRequestMiddleware, container=container) diff --git a/injectq/integrations/fastmcp.py b/injectq/integrations/fastmcp.py index a7e05fc..ba4d528 100644 --- a/injectq/integrations/fastmcp.py +++ b/injectq/integrations/fastmcp.py @@ -52,7 +52,7 @@ def get_container_mcp() -> "InjectQ": _logger.error(msg) raise InjectionError(msg) _logger.debug("MCP container retrieved from call context") - return container # type: ignore[return-value] + return container # type: ignore[no-any-return] def InjectMCP(interface: type[T]) -> T: # noqa: N802 @@ -81,7 +81,7 @@ async def get_users(): return await service.get_all_users() ``` """ - return get_container_mcp().get(interface) + return get_container_mcp().get(interface) # type: ignore[no-any-return] # --------------------------------------------------------------------------- @@ -114,7 +114,10 @@ class InjectQMCPMiddleware(Middleware): from injectq.integrations.fastmcp import InjectQMCPMiddleware container = InjectQ() - mcp = FastMCP("MyServer", middleware=[InjectQMCPMiddleware(container=container)]) + mcp = FastMCP( + "MyServer", + middleware=[InjectQMCPMiddleware(container=container)], + ) ``` """ @@ -134,7 +137,7 @@ async def on_message(self, context: Any, call_next: Any) -> Any: class InjectQMCPMiddleware: # type: ignore[no-redef] """Placeholder when fastmcp is not installed.""" - def __init__(self, *, container: Any) -> None: + def __init__(self, *, container: Any) -> None: # noqa: ARG002 msg = ( "InjectQMCPMiddleware requires the 'fastmcp' package. Install with " "'pip install injectq[fastmcp]' or 'pip install fastmcp'." @@ -171,7 +174,7 @@ def setup_mcp(container: "InjectQ", mcp: Any) -> None: "setup_mcp requires the 'fastmcp' package. Install with " "'pip install injectq[fastmcp]' or 'pip install fastmcp'." ) - _logger.error(msg) + _logger.exception(msg) raise RuntimeError(msg) from exc mcp.add_middleware(InjectQMCPMiddleware(container=container)) diff --git a/injectq/integrations/taskiq.py b/injectq/integrations/taskiq.py index e2885d6..b825569 100644 --- a/injectq/integrations/taskiq.py +++ b/injectq/integrations/taskiq.py @@ -51,7 +51,7 @@ def get_injector_instance_taskiq(state: "TaskiqState") -> "InjectQ": _logger.exception(msg) raise InjectionError(msg) _logger.debug("Taskiq container retrieved from task context") - return container # type: ignore[return-value] + return container # type: ignore[no-any-return] def _attach_injectq_taskiq(state: "TaskiqState", container: "InjectQ") -> None: @@ -103,7 +103,7 @@ async def my_task( def inject_into_task( context: Annotated[Context, TaskiqDepends()], ) -> T: - return get_injector_instance_taskiq(context.state).get(interface) + return get_injector_instance_taskiq(context.state).get(interface) # type: ignore[no-any-return] # Ensure annotations are accessible for inspection inject_into_task.__annotations__ = { @@ -111,7 +111,7 @@ def inject_into_task( "return": interface, } - return TaskiqDepends(inject_into_task) # type: ignore[return-value] + return TaskiqDepends(inject_into_task) # type: ignore[no-any-return] # Alias for backwards compatibility diff --git a/pyproject.toml b/pyproject.toml index c5b3450..3061014 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dev = [ "pytest>=6.0.0", "pytest-asyncio>=0.21.0", "pytest-cov>=3.0.0", + "pytest-benchmark>=4.0.0", "mypy>=1.0.0", "black>=22.0.0", "flake8>=4.0.0",