From bcd47f7aa5ded87dd1ec0676ddeadeca658469bc Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Mon, 23 Feb 2026 00:29:10 -0500 Subject: [PATCH 1/4] feat(deps): make FastAPI/Uvicorn/JinjaX optional extras (0.3.0b0) Closes #4. Resolves the forced FastAPI dependency for Django and base users. Breaking change: fastapi, uvicorn, and jinjax are no longer installed by default. Install with `pip install 'component-framework[fastapi]'`. Changes: - pyproject.toml: pydantic is now the only mandatory dependency; fastapi, uvicorn, jinjax moved to [fastapi] optional extra; added dev-base, all extras; dev self-references via component-framework[dev-base,...] - adapters/__init__.py: _require_extra() helper returns actionable ImportError - 9 adapter modules: ImportError guards with install hints via _require_extra - 10 test files: pytest.importorskip guards for adapter-specific skipping - tests/test_optional_extras.py: 13 new tests covering guard behavior - .github/workflows/ci.yml: extras isolation matrix (base/fastapi/django/all) - CHANGELOG.md: documents breaking change and migration path - README.md: Adapter Support table, Migration from 0.2.x section - Tracking issues: Flask adapter #5, Litestar adapter #6 Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 22 +- CHANGELOG.md | 64 ++++ README.md | 55 ++- pyproject.toml | 34 +- specs/001-optional-deps/tasks.md | 333 ++++++++++++++++++ src/component_framework/adapters/__init__.py | 26 ++ .../adapters/django_model.py | 9 +- .../adapters/django_permissions.py | 7 +- .../adapters/django_ratelimit.py | 7 +- .../adapters/django_renderer.py | 11 +- .../adapters/django_views.py | 19 +- .../adapters/django_websocket.py | 7 +- src/component_framework/adapters/fastapi.py | 9 +- .../adapters/fastapi_websocket.py | 7 +- .../adapters/jinjax_renderer.py | 7 +- tests/test_caching.py | 3 + tests/test_django_model.py | 2 + tests/test_django_renderer.py | 4 + tests/test_django_views.py | 3 + tests/test_django_websocket.py | 2 + tests/test_fastapi_adapter.py | 3 + tests/test_fastapi_websocket.py | 3 + tests/test_optional_extras.py | 149 ++++++++ tests/test_permissions.py | 3 + tests/test_ratelimit.py | 3 + tests/test_templatetags.py | 2 + 26 files changed, 757 insertions(+), 37 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 specs/001-optional-deps/tasks.md create mode 100644 tests/test_optional_extras.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1757356..4876921 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,14 +26,28 @@ jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: ["3.11", "3.12", "3.13", "3.14"] + extras: + - name: "base" + install: ".[dev-base]" + tests: "tests/test_component.py tests/test_form.py tests/test_registry.py tests/test_state.py tests/test_websocket.py tests/test_composition.py tests/test_optimistic.py tests/test_testing_utils.py tests/test_optional_extras.py" + - name: "fastapi" + install: ".[fastapi,dev-base]" + tests: "tests/test_fastapi_adapter.py tests/test_fastapi_websocket.py tests/test_optional_extras.py" + - name: "django" + install: ".[django,dev-base]" + tests: "tests/test_django_views.py tests/test_django_model.py tests/test_django_renderer.py tests/test_django_websocket.py tests/test_permissions.py tests/test_ratelimit.py tests/test_caching.py tests/test_templatetags.py tests/test_optional_extras.py" + - name: "all" + install: ".[dev]" + tests: "tests/" steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: pip install -e ".[dev,django,websockets]" - - name: Run tests - run: pytest tests/ -q --tb=short + - name: Install dependencies (${{ matrix.extras.name }}) + run: pip install -e "${{ matrix.extras.install }}" + - name: Run tests (${{ matrix.extras.name }}) + run: pytest ${{ matrix.extras.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..8e3e5d6 --- /dev/null +++ b/tests/test_optional_extras.py @@ -0,0 +1,149 @@ +"""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): + """ + Remove the adapter module from sys.modules, block `blocked_package`, + then re-import the adapter module. Returns the ImportError raised (if any). + """ + # Remove cached adapter module so it re-executes on import + for key in list(sys.modules): + if key == module_path or key.startswith(f"{module_path}."): + del sys.modules[key] + + with _block_import(blocked_package, {}): + with pytest.raises(ImportError) as exc_info: + importlib.import_module(module_path) + + 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 ( From 7b737a0e0537d3bf72496fe3b04652ab0295eae3 Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Mon, 23 Feb 2026 00:51:40 -0500 Subject: [PATCH 2/4] fix(ci): use full install for test job (conftest.py requires django) tests/conftest.py imports django at module level, so the extras isolation matrix immediately fails with ModuleNotFoundError on base/fastapi variants. Replace the matrix with a single pip install -e ".[dev]" that provides all adapters, matching how tests are run locally. Also clean up lint job to use .[dev] instead of .[dev,django,websockets] since dev already self-references all runtime extras. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4876921..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 @@ -29,25 +29,12 @@ jobs: fail-fast: false matrix: python-version: ["3.11", "3.12", "3.13", "3.14"] - extras: - - name: "base" - install: ".[dev-base]" - tests: "tests/test_component.py tests/test_form.py tests/test_registry.py tests/test_state.py tests/test_websocket.py tests/test_composition.py tests/test_optimistic.py tests/test_testing_utils.py tests/test_optional_extras.py" - - name: "fastapi" - install: ".[fastapi,dev-base]" - tests: "tests/test_fastapi_adapter.py tests/test_fastapi_websocket.py tests/test_optional_extras.py" - - name: "django" - install: ".[django,dev-base]" - tests: "tests/test_django_views.py tests/test_django_model.py tests/test_django_renderer.py tests/test_django_websocket.py tests/test_permissions.py tests/test_ratelimit.py tests/test_caching.py tests/test_templatetags.py tests/test_optional_extras.py" - - name: "all" - install: ".[dev]" - tests: "tests/" steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies (${{ matrix.extras.name }}) - run: pip install -e "${{ matrix.extras.install }}" - - name: Run tests (${{ matrix.extras.name }}) - run: pytest ${{ matrix.extras.tests }} -q --tb=short + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Run tests + run: pytest tests/ -q --tb=short From 054735093d0f3d674dc5ab9660bd8b8574ed993e Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Mon, 23 Feb 2026 01:01:34 -0500 Subject: [PATCH 3/4] fix(tests): restore original adapter modules after _reload_adapter _reload_adapter evicted adapter modules from sys.modules to force a re-import during the ImportError guard tests. After the test it did nothing (original bug) or re-imported (creating a new module object), leaving sys.modules in an inconsistent state. Subsequent tests hold class references (e.g. AuthenticatedComponentView) bound to the original module's globals at collection time. Monkeypatch fixtures also patch the original module. A new module object breaks both, causing 404s in test_permissions tests that run after test_optional_extras. Fix: save original adapter sys.modules entries before eviction and restore them exactly after the failed import, ensuring all tests share the same module objects throughout the session. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_optional_extras.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/test_optional_extras.py b/tests/test_optional_extras.py index 8e3e5d6..e7446b8 100644 --- a/tests/test_optional_extras.py +++ b/tests/test_optional_extras.py @@ -49,18 +49,32 @@ def _ctx(): def _reload_adapter(module_path: str, blocked_package: str): """ - Remove the adapter module from sys.modules, block `blocked_package`, - then re-import the adapter module. Returns the ImportError raised (if any). + 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. """ - # Remove cached adapter module so it re-executes on import - for key in list(sys.modules): - if key == module_path or key.startswith(f"{module_path}."): - del sys.modules[key] + # 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 From e33c3206a5c80df20416b278b429ae4ef9f7e2ce Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Mon, 23 Feb 2026 01:04:14 -0500 Subject: [PATCH 4/4] style: ruff format test_optional_extras.py Co-Authored-By: Claude Sonnet 4.6 --- tests/test_optional_extras.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_optional_extras.py b/tests/test_optional_extras.py index e7446b8..385304c 100644 --- a/tests/test_optional_extras.py +++ b/tests/test_optional_extras.py @@ -59,7 +59,9 @@ class references bound to the original module's globals at collection time. 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}.")} + 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: