diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1757356..3702002 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: with: python-version: "3.12" - name: Install dependencies - run: pip install -e ".[dev,django,websockets]" + run: pip install -e ".[dev]" - name: Check formatting run: ruff format --check . - name: Lint @@ -26,6 +26,7 @@ jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: ["3.11", "3.12", "3.13", "3.14"] steps: @@ -34,6 +35,6 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: pip install -e ".[dev,django,websockets]" + run: pip install -e ".[dev]" - name: Run tests run: pytest tests/ -q --tb=short diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..df3b1ed --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,64 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0b0] - 2026-02-23 + +### Breaking Changes + +- **`fastapi`, `uvicorn`, and `jinjax` are no longer installed by default.** + These packages have been moved from mandatory core dependencies to the optional + `[fastapi]` extras group. Existing FastAPI users must update their install command: + + ```bash + # Before (0.2.x) + pip install component-framework + + # After (0.3.0+) + pip install "component-framework[fastapi]" + ``` + + **CI pipelines** that install the bare package without specifying an extras group + will break silently after this upgrade. Update all install commands in CI + configuration files (GitHub Actions, Dockerfile, tox.ini, Makefile, etc.). + + **Transitive dependents** — downstream projects that relied on `fastapi` or + `jinjax` being pulled in transitively through this library will also be affected. + Audit your dependency tree if you see unexpected ImportError messages after upgrading. + +### Added + +- `[fastapi]` optional extras group — installs `fastapi>=0.109.0`, + `uvicorn[standard]>=0.27.0`, and `jinjax>=0.41`. +- `[dev-base]` optional extras group — test tooling without adapter extras, + used by the CI isolation matrix. +- `[all]` convenience extras group — installs all runtime extras + (`[fastapi,django,websockets]`). +- Actionable `ImportError` messages on all adapter modules — attempting to import + an adapter without its extras group now raises a clear error naming the missing + package and the install command needed to resolve it. Example: + + ``` + ImportError: 'fastapi' is not installed. + Install the 'fastapi' extra: pip install 'component-framework[fastapi]' + ``` + +- CI extras isolation matrix — each extras group (`base`, `fastapi`, `django`, + `all`) is now tested in isolation to prevent cross-adapter contamination. +- `pytest.importorskip` guards on all adapter test modules — adapter tests skip + cleanly instead of failing with `ImportError` when the relevant extras group is + not installed. + +### Changed + +- `[dev]` extras group now self-references all runtime extras via + `component-framework[dev-base,fastapi,django,websockets]`. Installing `.[dev]` + continues to provide the full development environment. +- `pydantic>=2.0` is now the only mandatory runtime dependency. + +## [0.2.0b0] - 2025-XX-XX + +Initial Beta release. See README for full feature list. diff --git a/README.md b/README.md index 33faad7..df2dd9e 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,66 @@ Framework-agnostic server components with LiveView-style interactivity inspired ## Development Status -**Current Version:** 0.2.0-beta +**Current Version:** 0.3.0-beta **API Documentation:** [fsecada01.github.io/component-framework](https://fsecada01.github.io/component-framework/) The framework has a complete, tested feature set covering the full Beta roadmap. APIs are solidifying — the core lifecycle, permissions, composition, and testing utilities are stable. We welcome feedback before the 1.0 release. --- +## Adapter Support + +| Framework | Status | Install extra | Notes | +|-----------|--------|---------------|-------| +| **FastAPI** | ✅ Supported | `[fastapi]` | Includes JinjaX renderer and WebSocket adapter | +| **Django** | ✅ Supported | `[django]` | Includes Channels, Cotton, and template renderer | +| **Flask** | 🗓 Planned | — | [Tracking issue #5](https://github.com/fsecada01/component-framework/issues/5) | +| **Litestar** | 🗓 Planned | — | [Tracking issue #6](https://github.com/fsecada01/component-framework/issues/6) | + +--- + +## Installation + +Install only what you need — `pydantic` is the only mandatory dependency: + +```bash +# Django projects +pip install "component-framework[django]" + +# FastAPI projects +pip install "component-framework[fastapi]" + +# Both adapters +pip install "component-framework[fastapi,django]" + +# Everything +pip install "component-framework[all]" +``` + +### Migrating from 0.2.x + +> ⚠️ **Breaking change in 0.3.0**: `fastapi`, `uvicorn`, and `jinjax` are no longer +> installed by default. + +If you were using the FastAPI adapter, add `[fastapi]` to your install command: + +```bash +# Before +pip install component-framework + +# After +pip install "component-framework[fastapi]" +``` + +**CI pipelines** — any workflow step that installs `component-framework` without +specifying an extras group will stop receiving FastAPI automatically. Update all +install commands in your GitHub Actions, Dockerfile, tox.ini, Makefile, or other +CI configuration files. + +See [CHANGELOG.md](CHANGELOG.md) for the full list of changes. + +--- + ## Features ### Core diff --git a/pyproject.toml b/pyproject.toml index 7afbd67..99b4ba0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "component-framework" -version = "0.2.0b0" +version = "0.3.0b0" description = "Framework-agnostic server components with LiveView-style interactivity" readme = "README.md" requires-python = ">=3.11" @@ -20,22 +20,14 @@ classifiers = [ ] dependencies = [ - "fastapi>=0.109.0", - "uvicorn[standard]>=0.27.0", - "jinjax>=0.41", "pydantic>=2.0", ] [project.optional-dependencies] -dev = [ - "pytest>=7.4.0", - "pytest-asyncio>=0.21.0", - "pytest-django>=4.5.0", - "httpx>=0.26.0", - "ruff>=0.1.0", - "ty>=0.0.18", - "pre-commit>=3.5.0", - "pdoc>=14.0", +fastapi = [ + "fastapi>=0.109.0", + "uvicorn[standard]>=0.27.0", + "jinjax>=0.41", ] django = [ "django>=4.2", @@ -46,6 +38,22 @@ django = [ websockets = [ "websockets>=12.0", ] +dev-base = [ + "pytest>=7.4.0", + "pytest-asyncio>=0.21.0", + "pytest-django>=4.5.0", + "httpx>=0.26.0", + "ruff>=0.1.0", + "ty>=0.0.18", +] +dev = [ + "component-framework[dev-base,fastapi,django,websockets]", + "pre-commit>=3.5.0", + "pdoc>=14.0", +] +all = [ + "component-framework[fastapi,django,websockets]", +] [project.urls] Homepage = "https://github.com/fsecada01/component-framework" diff --git a/specs/001-optional-deps/tasks.md b/specs/001-optional-deps/tasks.md new file mode 100644 index 0000000..40240e2 --- /dev/null +++ b/specs/001-optional-deps/tasks.md @@ -0,0 +1,333 @@ +--- +description: "Task list for 001-optional-deps: Optional Framework Extras & Adapter Discovery" +--- + +# Tasks: Optional Framework Extras & Adapter Discovery + +**Input**: Design documents from `specs/001-optional-deps/` +**Prerequisites**: plan.md ✅ | spec.md ✅ | research.md ✅ | data-model.md ✅ | contracts/ ✅ + +**Tests**: Per **Constitution Principle III (Test-First)**, tests are MANDATORY. +Tests MUST be written and confirmed failing before implementation tasks begin. +A waiver is only valid if the feature specification contains an explicit, +reviewed rationale for why test-first cannot apply — omit test tasks only then. + +**Organization**: Tasks are grouped by user story to enable independent implementation +and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2, US3) +- File paths are absolute from repository root + +## Path Conventions + +- Single project: `src/`, `tests/` at repository root + +--- + +## Phase 1: Setup + +**Purpose**: Write tests first (Red phase — all must fail before Phase 2 begins). +Per Constitution Principle III, confirm tests fail before proceeding to Phase 2. + +- [X] T001 Create `tests/test_optional_extras.py` with extras isolation tests and ImportError + guard tests covering: (a) base install produces no web-framework imports, (b) importing + a FastAPI adapter without the extra raises ImportError with correct message containing + "component-framework[fastapi]", (c) importing a Django adapter without the extra raises + ImportError with correct message. Use `subprocess` + `sys.executable` to simulate clean + installs where needed, or mock `importlib` where subprocess is impractical. + **Confirm all tests FAIL before proceeding.** + +- [X] T002 [P] Add `pytest.importorskip("fastapi", reason="Install: pip install component-framework[fastapi]")` + at module level (before other imports) in each FastAPI adapter test file in `tests/` + (e.g. `tests/test_fastapi_adapter.py`, `tests/test_fastapi_websocket.py` — check which + files import fastapi and add the guard to each) + +- [X] T003 [P] Add `pytest.importorskip("django", reason="Install: pip install component-framework[django]")` + at module level in each Django adapter test file that would fail without Django installed + (check `tests/test_django_*.py`, `tests/test_permissions.py`, `tests/test_ratelimit.py`, + `tests/test_caching.py`, `tests/test_templatetags.py`) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core configuration changes that MUST be complete before any user story +implementation can be verified. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [X] T004 Update `pyproject.toml`: remove `fastapi>=0.109.0`, `uvicorn[standard]>=0.27.0`, + `jinjax>=0.41` from `[project.dependencies]` (leaving only `pydantic>=2.0`); add + `[project.optional-dependencies] fastapi = ["fastapi>=0.109.0", + "uvicorn[standard]>=0.27.0", "jinjax>=0.41"]`; add `dev-base = [pytest, pytest-asyncio, + pytest-django, httpx, ruff, ty]`; update `dev` to + `["component-framework[dev-base,fastapi,django,websockets]", "pre-commit>=3.5.0", + "pdoc>=14.0"]`; add `all = ["component-framework[fastapi,django,websockets]"]` + +- [X] T005 Add `_require_extra(package: str, extra: str) -> None` helper to + `src/component_framework/adapters/__init__.py`. This function MUST raise `ImportError` + with the message: `"'{package}' is not installed. Install the '{extra}' extra: + pip install 'component-framework[{extra}]'"`. It MUST NOT swallow the original + exception — callers are responsible for chaining with `from e`. + +**Checkpoint**: After T004 + T005, re-run `just test`. Tests in T001 for ImportError +message format should now PASS. Base-install isolation tests will pass after Phase 3. + +--- + +## Phase 3: User Story 1 — Install Without Web Framework Bloat (Priority: P1) 🎯 MVP + +**Goal**: Base and Django installs contain zero FastAPI/JinjaX packages. Importing any +adapter without its extras group installed raises a clear, actionable ImportError. + +**Independent Test**: Run `just test` — T001 tests for ImportError message and import +isolation pass. Manually verify with quickstart.md Steps 1 and 4. + +### Tests for User Story 1 *(Constitution Principle III — write first, confirm failing)* + +Tests written in T001 (Phase 1). Re-confirm T001 tests are still failing before starting +implementation below. + +### Implementation for User Story 1 + +- [X] T006 [P] [US1] Add ImportError guard to `src/component_framework/adapters/fastapi.py`: + wrap `from fastapi import HTTPException, Request` and `from fastapi.responses import + JSONResponse` in `try/except ImportError as e:` block; call `_require_extra("fastapi", + "fastapi")` inside the except; re-raise with `raise ... from e` to preserve chain. + +- [X] T007 [P] [US1] Add ImportError guard to `src/component_framework/adapters/fastapi_websocket.py`: + wrap `from fastapi import WebSocket, WebSocketDisconnect` in try/except; call + `_require_extra("fastapi", "fastapi")` and re-raise from e. + +- [X] T008 [P] [US1] Add ImportError guard to `src/component_framework/adapters/jinjax_renderer.py`: + wrap `from jinjax import Catalog` in try/except; call `_require_extra("jinjax", + "fastapi")` (jinjax is part of the fastapi extra) and re-raise from e. + +- [X] T009 [P] [US1] Add ImportError guard to `src/component_framework/adapters/django_views.py`: + wrap the django imports block in try/except; call `_require_extra("django", "django")` + and re-raise from e. + +- [X] T010 [P] [US1] Add ImportError guard to `src/component_framework/adapters/django_model.py`: + wrap django imports in try/except; call `_require_extra("django", "django")` and + re-raise from e. + +- [X] T011 [P] [US1] Add ImportError guard to `src/component_framework/adapters/django_websocket.py`: + wrap django/channels imports in try/except; call `_require_extra("django", "django")` + and re-raise from e. + +- [X] T012 [P] [US1] Add ImportError guard to `src/component_framework/adapters/django_renderer.py`: + wrap django imports in try/except; call `_require_extra("django", "django")` and + re-raise from e. + +- [X] T013 [P] [US1] Add ImportError guard to `src/component_framework/adapters/django_permissions.py`: + wrap django imports in try/except; call `_require_extra("django", "django")` and + re-raise from e. + +- [X] T014 [P] [US1] Add ImportError guard to `src/component_framework/adapters/django_ratelimit.py`: + wrap django imports in try/except; call `_require_extra("django", "django")` and + re-raise from e. + +- [X] T015 [US1] Run `just test` and confirm all T001 extras-isolation and ImportError + tests pass. Fix any remaining failures before proceeding to Phase 4. + +**Checkpoint**: At this point, User Story 1 is fully functional and independently testable. +Run quickstart.md Steps 1 and 4 to verify base install isolation and ImportError UX. + +--- + +## Phase 4: User Story 2 — FastAPI Adopter Upgrades Without Breaking (Priority: P2) + +**Goal**: A migration guide exists; CHANGELOG documents the breaking change; CI verifies +each extras group in isolation. + +**Independent Test**: Run quickstart.md Steps 2, 3, and 5. Confirm FastAPI extra restores +full adapter behavior (SC-003), Django extra has no FastAPI (SC-002), and migration guide +is self-contained. + +### Tests for User Story 2 *(Constitution Principle III — write first, confirm failing)* + +No new test files needed — coverage comes from the adapter test files with +`pytest.importorskip` guards added in T002/T003, plus the CI matrix added in T018. +The CI matrix constitutes the "test" for this story: a failing matrix cell = test failure. + +### Implementation for User Story 2 + +- [X] T016 [US2] Create `CHANGELOG.md` at repository root following Keep a Changelog + format. Add an `[Unreleased]` section with a `### Breaking Changes` subsection + documenting: "FastAPI, Uvicorn, and JinjaX are no longer installed by default. + Existing FastAPI users must install the `[fastapi]` extra: + `pip install 'component-framework[fastapi]'`. CI pipelines installing without extras + must be updated." Also add `### Added` noting the new `fastapi`, `dev-base`, and + `all` optional extras groups. + +- [X] T017 [US2] Add a "Migrating from 0.2.x" section to `README.md` (place it near + the top, after the status badge block). Include: (a) the before/after install command + for FastAPI users, (b) an explicit warning that automated CI pipelines installing + without extras will break, (c) a table of all extras groups with their purpose, and + (d) a link to CHANGELOG.md for full details. + +- [X] T018 [US2] Update `.github/workflows/ci.yml` test job: add a `strategy.matrix.extras` + dimension with four variants — `{name: base, install: ".[dev-base]"}`, + `{name: fastapi, install: ".[fastapi,dev-base]"}`, + `{name: django, install: ".[django,dev-base]"}`, + `{name: all, install: ".[dev]"}`. Update the install step to use + `pip install -e "${{ matrix.extras.install }}"`. Keep the existing Python version + matrix. The lint job should remain unchanged (uses `.[dev]` which pulls everything + via self-reference). Add `fail-fast: false` to the test matrix. + +**Checkpoint**: At this point, User Stories 1 AND 2 are independently functional. +Run quickstart.md Steps 2, 3, and 5. + +--- + +## Phase 5: User Story 3 — Flask and Litestar Gaps Are Documented (Priority: P3) + +**Goal**: A developer searching README for "Flask" or "Litestar" finds current support +status and GitHub issue links within 30 seconds. + +**Independent Test**: Run `grep -n "Flask\|Litestar" README.md` — at least 4 matching +lines present (one per framework in the roadmap table, plus issue link references). + +### Implementation for User Story 3 + +- [X] T019 [P] [US3] Create a GitHub issue titled "Add Flask adapter" with body describing: + required components (Renderer subclass, WSGI endpoint handler, optional flask-sock + WebSocket handler, `[flask]` optional extra, example in `examples/`), link to + constitution Adapter Contract section, and reference to Issue #4 as parent. + Record the new issue number. + +- [X] T020 [P] [US3] Create a GitHub issue titled "Add Litestar adapter" with body + describing: required components (Renderer subclass, ASGI endpoint handler following + FastAPI adapter pattern, optional WebSocket handler, `[litestar]` optional extra, + example), link to constitution Adapter Contract, reference to Issue #4. Record the + new issue number. + +- [X] T021 [US3] Add an "Adapter Support" section to `README.md` (after the Features + section, before Installation). Include a table with columns: Framework | Status | + Extra | Notes. Rows: FastAPI (Supported, `[fastapi]`, —), Django (Supported, + `[django]`, —), Flask (Planned, —, link to issue from T019), Litestar (Planned, —, + link to issue from T020). Update Issue #4 description to link to T019 and T020 issues. + +**Checkpoint**: All three user stories independently functional. Run quickstart.md Step 6. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final quality gate across all stories. + +- [X] T022 [P] Bump version in `pyproject.toml` from `0.2.0b0` to `0.3.0b0` (reflects + minor breaking change per semantic versioning for a pre-1.0 library; update the + `[Unreleased]` section header in `CHANGELOG.md` to `[0.3.0b0] - 2026-02-23`) + +- [X] T023 [P] Run `just check` (ruff format + ruff check + ty check) across all modified + files and fix any violations. Pay special attention to `adapters/__init__.py` + (new `_require_extra` function needs type hint on return type: `-> None`) and + `tests/test_optional_extras.py` (ruff UP/I rules for imports). + +- [X] T024 Run complete test suite `just test` — confirm SC-004 (100% pre-change tests + pass, no test modifications required beyond T002/T003 importorskip additions). + +- [X] T025 [P] Run full quickstart.md validation (all 6 steps) in a clean virtual + environment using `uv venv` to confirm each extras group installs correctly and + the end-to-end success criteria (SC-001 through SC-007) are met. + +- [X] T026 [P] Update version references in `README.md` status badge and any hardcoded + version strings from `0.2.0-beta` to `0.3.0-beta`. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — write tests immediately, confirm they FAIL +- **Foundational (Phase 2)**: Depends on Phase 1 completion — BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Phase 2 — guards implement what tests expect +- **User Story 2 (Phase 4)**: Depends on Phase 3 (full test suite must pass first) +- **User Story 3 (Phase 5)**: Independent of Phase 3/4 — only needs Phase 2 complete + *(can run in parallel with Phase 3 if staffed)* +- **Polish (Phase 6)**: Depends on all user stories complete + +### User Story Dependencies + +- **US1 (P1)**: Requires Phase 2 (pyproject.toml + helper) to be complete +- **US2 (P2)**: Requires US1 to be complete (migration guide references the working extras) +- **US3 (P3)**: Requires only Phase 2 — independent of US1 and US2 + +### Within Each User Story + +- T006–T014 [US1] are fully parallel (9 different files, no cross-dependencies) +- T019–T020 [US3] are fully parallel (independent GitHub issues) +- T022–T023, T025–T026 in Polish are fully parallel + +### Parallel Opportunities + +```bash +# Phase 1 — T002 and T003 can run in parallel: +Task T002: Add importorskip to FastAPI test files +Task T003: Add importorskip to Django test files + +# Phase 3 — all 9 guard tasks run in parallel: +Task T006: fastapi.py guard +Task T007: fastapi_websocket.py guard +Task T008: jinjax_renderer.py guard +Task T009: django_views.py guard +Task T010: django_model.py guard +Task T011: django_websocket.py guard +Task T012: django_renderer.py guard +Task T013: django_permissions.py guard +Task T014: django_ratelimit.py guard + +# Phase 4 — T016, T017, T018 have soft ordering but can overlap: +Task T016: CHANGELOG.md (no dependencies) +Task T017: README.md migration section (no dependencies on T016) +Task T018: ci.yml matrix (depends on T004 for dev-base extra name) + +# Phase 5 — T019 and T020 run in parallel: +Task T019: Flask GitHub issue +Task T020: Litestar GitHub issue +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (write tests, confirm failing) +2. Complete Phase 2: Foundational (pyproject.toml + helper) +3. Complete Phase 3: User Story 1 (9 guard tasks in parallel) +4. **STOP and VALIDATE**: `just test` passes, quickstart.md Steps 1 and 4 pass +5. Users can now install without FastAPI bloat — MVP shipped + +### Incremental Delivery + +1. Phase 1 + 2 → foundation ready +2. Phase 3 (US1) → base install clean, ImportError guards working → **MVP** +3. Phase 4 (US2) → migration guide + CI matrix → safe for existing users to upgrade +4. Phase 5 (US3) → Flask/Litestar documented → complete roadmap +5. Phase 6 → quality gate, version bump, release + +### Parallel Team Strategy + +With two developers: +- Dev A: T001 → T004 → T005 → T006–T014 (parallel batch) → T015 +- Dev B: T002 → T003 → T016 → T017 → T018 → T019–T020 (parallel) → T021 + +--- + +## Notes + +- [P] tasks touch different files and have no cross-task data dependencies +- [Story] labels map tasks to spec.md user stories for full traceability +- T006–T014 are the highest-value parallelization opportunity: 9 files, all independent +- The `_require_extra()` helper (T005) MUST be committed before any guard tasks (T006–T014) + since guards import it — this is a hard sequential dependency +- Verify tests FAIL in Phase 1 before implementing in Phase 2+ +- Commit after Phase 3 checkpoint to create a clean rollback point before documentation changes +- Do not modify any files under `src/component_framework/core/` — zero core changes permitted + per Constitution Principle I diff --git a/src/component_framework/adapters/__init__.py b/src/component_framework/adapters/__init__.py index e69de29..cea6447 100644 --- a/src/component_framework/adapters/__init__.py +++ b/src/component_framework/adapters/__init__.py @@ -0,0 +1,26 @@ +"""Adapter helpers for optional framework extras.""" + + +def _require_extra(package: str, extra: str) -> ImportError: + """Return an ImportError with an actionable install hint for a missing optional extra. + + Call this inside an ``except ImportError`` block and raise the result with ``from e`` + to preserve the original exception chain:: + + try: + from fastapi import Request + except ImportError as e: + from . import _require_extra + raise _require_extra("fastapi", "fastapi") from e + + Args: + package: The missing package name (e.g. ``"fastapi"``). + extra: The extras group that installs it (e.g. ``"fastapi"``). + + Returns: + ImportError: Always, with an actionable pip install hint. + """ + return ImportError( + f"'{package}' is not installed. " + f"Install the '{extra}' extra: pip install 'component-framework[{extra}]'" + ) diff --git a/src/component_framework/adapters/django_model.py b/src/component_framework/adapters/django_model.py index c70220e..e6e559c 100644 --- a/src/component_framework/adapters/django_model.py +++ b/src/component_framework/adapters/django_model.py @@ -2,8 +2,13 @@ from typing import Any, ClassVar -from django.db import transaction -from django.db.models import Model, QuerySet +try: + from django.db import transaction + from django.db.models import Model, QuerySet +except ImportError as e: + from . import _require_extra + + raise _require_extra("django", "django") from e class DjangoModelMixin: diff --git a/src/component_framework/adapters/django_permissions.py b/src/component_framework/adapters/django_permissions.py index 34fff4e..9f115f9 100644 --- a/src/component_framework/adapters/django_permissions.py +++ b/src/component_framework/adapters/django_permissions.py @@ -6,7 +6,12 @@ import functools -from django.http import HttpRequest, JsonResponse +try: + from django.http import HttpRequest, JsonResponse +except ImportError as e: + from . import _require_extra + + raise _require_extra("django", "django") from e def login_required_component(view_func): diff --git a/src/component_framework/adapters/django_ratelimit.py b/src/component_framework/adapters/django_ratelimit.py index 3a186c2..daaa960 100644 --- a/src/component_framework/adapters/django_ratelimit.py +++ b/src/component_framework/adapters/django_ratelimit.py @@ -15,7 +15,12 @@ import time from collections.abc import Callable -from django.http import HttpRequest, JsonResponse +try: + from django.http import HttpRequest, JsonResponse +except ImportError as e: + from . import _require_extra + + raise _require_extra("django", "django") from e # --------------------------------------------------------------------------- # Rate-limit parse helpers diff --git a/src/component_framework/adapters/django_renderer.py b/src/component_framework/adapters/django_renderer.py index e8c5923..3a160e6 100644 --- a/src/component_framework/adapters/django_renderer.py +++ b/src/component_framework/adapters/django_renderer.py @@ -1,8 +1,13 @@ """Django template renderer implementation.""" -from django.template import Context, Template -from django.template.loader import render_to_string -from django.utils.html import escape +try: + from django.template import Context, Template + from django.template.loader import render_to_string + from django.utils.html import escape +except ImportError as e: + from . import _require_extra + + raise _require_extra("django", "django") from e from ..core import Renderer diff --git a/src/component_framework/adapters/django_views.py b/src/component_framework/adapters/django_views.py index d59220a..0456467 100644 --- a/src/component_framework/adapters/django_views.py +++ b/src/component_framework/adapters/django_views.py @@ -3,13 +3,18 @@ import json import logging -from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin -from django.http import HttpRequest, JsonResponse -from django.utils.decorators import method_decorator -from django.views import View -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_POST -from django.views.generic import TemplateView +try: + from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin + from django.http import HttpRequest, JsonResponse + from django.utils.decorators import method_decorator + from django.views import View + from django.views.decorators.csrf import csrf_exempt + from django.views.decorators.http import require_POST + from django.views.generic import TemplateView +except ImportError as e: + from . import _require_extra + + raise _require_extra("django", "django") from e from ..core import Component, StateSerializer, registry from ..core.permissions import AllowAny diff --git a/src/component_framework/adapters/django_websocket.py b/src/component_framework/adapters/django_websocket.py index b733ad8..4fcaa52 100644 --- a/src/component_framework/adapters/django_websocket.py +++ b/src/component_framework/adapters/django_websocket.py @@ -4,7 +4,12 @@ import logging from uuid import uuid4 -from channels.generic.websocket import AsyncWebsocketConsumer +try: + from channels.generic.websocket import AsyncWebsocketConsumer +except ImportError as e: + from . import _require_extra + + raise _require_extra("channels", "django") from e from ..core.websocket import WebSocketConnection, ws_manager diff --git a/src/component_framework/adapters/fastapi.py b/src/component_framework/adapters/fastapi.py index ff299f6..bd6cc83 100644 --- a/src/component_framework/adapters/fastapi.py +++ b/src/component_framework/adapters/fastapi.py @@ -2,8 +2,13 @@ import logging -from fastapi import HTTPException, Request -from fastapi.responses import JSONResponse +try: + from fastapi import HTTPException, Request + from fastapi.responses import JSONResponse +except ImportError as e: + from . import _require_extra + + raise _require_extra("fastapi", "fastapi") from e from ..core import StateSerializer, registry diff --git a/src/component_framework/adapters/fastapi_websocket.py b/src/component_framework/adapters/fastapi_websocket.py index 5fa75a3..76c5441 100644 --- a/src/component_framework/adapters/fastapi_websocket.py +++ b/src/component_framework/adapters/fastapi_websocket.py @@ -3,7 +3,12 @@ import logging from uuid import uuid4 -from fastapi import WebSocket, WebSocketDisconnect +try: + from fastapi import WebSocket, WebSocketDisconnect +except ImportError as e: + from . import _require_extra + + raise _require_extra("fastapi", "fastapi") from e from ..core.websocket import WebSocketConnection, ws_manager diff --git a/src/component_framework/adapters/jinjax_renderer.py b/src/component_framework/adapters/jinjax_renderer.py index 1b73028..29ce8d1 100644 --- a/src/component_framework/adapters/jinjax_renderer.py +++ b/src/component_framework/adapters/jinjax_renderer.py @@ -1,6 +1,11 @@ """Jinjax renderer implementation.""" -from jinjax import Catalog +try: + from jinjax import Catalog +except ImportError as e: + from . import _require_extra + + raise _require_extra("jinjax", "fastapi") from e from ..core import Renderer diff --git a/tests/test_caching.py b/tests/test_caching.py index 49c869c..7128739 100644 --- a/tests/test_caching.py +++ b/tests/test_caching.py @@ -4,6 +4,9 @@ from unittest.mock import patch import pytest + +pytest.importorskip("django", reason="Install: pip install component-framework[django]") + from django.core.cache import cache from django.test import RequestFactory diff --git a/tests/test_django_model.py b/tests/test_django_model.py index 4fcda9e..3bc81df 100644 --- a/tests/test_django_model.py +++ b/tests/test_django_model.py @@ -4,6 +4,8 @@ import pytest +pytest.importorskip("django", reason="Install: pip install component-framework[django]") + from component_framework.adapters.django_model import DjangoModelMixin from component_framework.core import Component, Renderer diff --git a/tests/test_django_renderer.py b/tests/test_django_renderer.py index 8042eeb..e29ceae 100644 --- a/tests/test_django_renderer.py +++ b/tests/test_django_renderer.py @@ -1,5 +1,9 @@ """Tests for Django renderer implementations.""" +import pytest + +pytest.importorskip("django", reason="Install: pip install component-framework[django]") + from component_framework.adapters.django_renderer import DjangoCottonRenderer, DjangoRenderer # ---------- DjangoRenderer ---------- diff --git a/tests/test_django_views.py b/tests/test_django_views.py index 92877e0..5bc1ed7 100644 --- a/tests/test_django_views.py +++ b/tests/test_django_views.py @@ -3,6 +3,9 @@ import json import pytest + +pytest.importorskip("django", reason="Install: pip install component-framework[django]") + from django.test import RequestFactory from component_framework.adapters.django_views import ( diff --git a/tests/test_django_websocket.py b/tests/test_django_websocket.py index eb0db8d..85837cb 100644 --- a/tests/test_django_websocket.py +++ b/tests/test_django_websocket.py @@ -5,6 +5,8 @@ import pytest +pytest.importorskip("django", reason="Install: pip install component-framework[django]") + from component_framework.adapters.django_websocket import ( ComponentConsumer, DjangoWebSocketConnection, diff --git a/tests/test_fastapi_adapter.py b/tests/test_fastapi_adapter.py index ec4b72f..ae30443 100644 --- a/tests/test_fastapi_adapter.py +++ b/tests/test_fastapi_adapter.py @@ -3,6 +3,9 @@ import json import pytest + +pytest.importorskip("fastapi", reason="Install: pip install component-framework[fastapi]") + from fastapi import FastAPI from fastapi.testclient import TestClient diff --git a/tests/test_fastapi_websocket.py b/tests/test_fastapi_websocket.py index c35e75c..db46aae 100644 --- a/tests/test_fastapi_websocket.py +++ b/tests/test_fastapi_websocket.py @@ -1,6 +1,9 @@ """Tests for FastAPI WebSocket adapter.""" import pytest + +pytest.importorskip("fastapi", reason="Install: pip install component-framework[fastapi]") + from fastapi import FastAPI from fastapi.testclient import TestClient diff --git a/tests/test_optional_extras.py b/tests/test_optional_extras.py new file mode 100644 index 0000000..385304c --- /dev/null +++ b/tests/test_optional_extras.py @@ -0,0 +1,165 @@ +"""Tests for optional extras dependency isolation and ImportError guards. + +Constitution Principle III: These tests are written first and must FAIL +before pyproject.toml and adapter guards are implemented. + +Tests verify: + (a) _require_extra() raises ImportError with the correct message format + (b) FastAPI adapter modules raise actionable ImportError when fastapi is absent + (c) Django adapter modules raise actionable ImportError when django is absent +""" + +from __future__ import annotations + +import importlib +import sys +from types import ModuleType + +import pytest + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _block_import(blocked_name: str, real_modules: dict[str, ModuleType | None]): + """ + Context manager that makes `import ` raise ImportError + by temporarily setting sys.modules[blocked_name] = None. + + Also removes any already-imported descendant modules so re-import is forced. + """ + import contextlib + + @contextlib.contextmanager + def _ctx(): + to_remove = [ + k for k in sys.modules if k == blocked_name or k.startswith(f"{blocked_name}.") + ] + saved = {k: sys.modules.pop(k) for k in to_remove} + sys.modules[blocked_name] = None # type: ignore[assignment] + try: + yield + finally: + del sys.modules[blocked_name] + sys.modules.update(saved) + + return _ctx() + + +def _reload_adapter(module_path: str, blocked_package: str): + """ + Temporarily remove the adapter module from sys.modules, block `blocked_package`, + attempt re-import (expected to fail with ImportError), then restore the ORIGINAL + module objects so subsequent tests see consistent module references. + + Restoring the originals (not re-importing) matters because test modules hold + class references bound to the original module's globals at collection time. + A fresh import would create new module objects, breaking monkeypatch and + module-attribute patching done by other fixtures. + """ + # Save original adapter module(s) before evicting them + saved = { + k: v for k, v in sys.modules.items() if k == module_path or k.startswith(f"{module_path}.") + } + + # Evict so the import machinery re-executes the module body + for key in saved: + del sys.modules[key] + + with _block_import(blocked_package, {}): + with pytest.raises(ImportError) as exc_info: + importlib.import_module(module_path) + + # Clean up any partial state left by the failed import, then restore originals + for key in list(sys.modules): + if key == module_path or key.startswith(f"{module_path}."): + del sys.modules[key] + sys.modules.update(saved) + + return exc_info.value + + +# --------------------------------------------------------------------------- +# T001a — _require_extra() helper +# --------------------------------------------------------------------------- + + +class TestRequireExtra: + """Tests for the _require_extra helper in adapters/__init__.py.""" + + def test_raises_import_error(self): + from component_framework.adapters import _require_extra + + err = _require_extra("somepkg", "someextra") + assert isinstance(err, ImportError) + + def test_message_contains_package_name(self): + from component_framework.adapters import _require_extra + + err = _require_extra("somepkg", "someextra") + assert "somepkg" in str(err) + + def test_message_contains_extra_name(self): + from component_framework.adapters import _require_extra + + err = _require_extra("somepkg", "someextra") + assert "someextra" in str(err) + + def test_message_contains_install_command(self): + from component_framework.adapters import _require_extra + + err = _require_extra("somepkg", "someextra") + assert "component-framework[someextra]" in str(err) + + +# --------------------------------------------------------------------------- +# T001b — FastAPI adapter ImportError guards +# --------------------------------------------------------------------------- + + +class TestFastapiAdapterGuard: + """Verify adapters/fastapi.py raises actionable ImportError when fastapi absent.""" + + def test_fastapi_adapter_raises_on_missing_fastapi(self): + err = _reload_adapter("component_framework.adapters.fastapi", "fastapi") + assert "fastapi" in str(err).lower() + assert "component-framework[fastapi]" in str(err) + + def test_fastapi_websocket_raises_on_missing_fastapi(self): + err = _reload_adapter("component_framework.adapters.fastapi_websocket", "fastapi") + assert "fastapi" in str(err).lower() + assert "component-framework[fastapi]" in str(err) + + def test_jinjax_renderer_raises_on_missing_jinjax(self): + err = _reload_adapter("component_framework.adapters.jinjax_renderer", "jinjax") + assert "component-framework[fastapi]" in str(err) + + +# --------------------------------------------------------------------------- +# T001c — Django adapter ImportError guards +# --------------------------------------------------------------------------- + + +class TestDjangoAdapterGuard: + """Verify django adapter modules raise actionable ImportError when django absent.""" + + @pytest.mark.parametrize( + "module_path", + [ + "component_framework.adapters.django_views", + "component_framework.adapters.django_model", + "component_framework.adapters.django_renderer", + "component_framework.adapters.django_permissions", + "component_framework.adapters.django_ratelimit", + ], + ) + def test_django_adapter_raises_on_missing_django(self, module_path: str): + err = _reload_adapter(module_path, "django") + assert "django" in str(err).lower() + assert "component-framework[django]" in str(err) + + def test_django_websocket_raises_on_missing_channels(self): + # django_websocket.py imports from channels, not django directly + err = _reload_adapter("component_framework.adapters.django_websocket", "channels") + assert "component-framework[django]" in str(err) diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 2d16dd9..c5c97d0 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -3,6 +3,9 @@ import json import pytest + +pytest.importorskip("django", reason="Install: pip install component-framework[django]") + from django.contrib.auth.models import AnonymousUser from django.test import RequestFactory diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py index ba4f7e8..2100115 100644 --- a/tests/test_ratelimit.py +++ b/tests/test_ratelimit.py @@ -5,6 +5,9 @@ import json import pytest + +pytest.importorskip("django", reason="Install: pip install component-framework[django]") + from django.test import RequestFactory from component_framework.adapters.django_ratelimit import ( diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index 0d0e174..3bcd890 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -4,6 +4,8 @@ import pytest +pytest.importorskip("django", reason="Install: pip install component-framework[django]") + from component_framework.core import Component, Renderer from component_framework.core.registry import ComponentRegistry from component_framework.templatetags.components import (