Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ jobs:
- name: Run ruff code analysis
run: uv run ruff check .

- name: Run import-linter
run: uv run lint-imports

- name: Run ty code analysis
run: uv run ty check .

Expand Down
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ just build # build dev image (run first, and after dependency chang
just start # run app with autoreload on localhost:8000
just test # run full suite with coverage (html report -> htmlcov/)
just test "tests/test_queries.py::test_heroes_query" # run a single test
just lint # ruff check --fix
just lint # ruff check --fix + import-linter contracts
just format # ruff format
just check # ty type checker
just check app/graphql # ty on a subpath
Expand All @@ -30,6 +30,12 @@ just exec "<command>" # run an arbitrary command in the app container

Hexagonal-lite, dependencies flow inward only, in `app/`:

```
main → graphql | adapters → logging_config → settings → domain
```

Enforced by import-linter (`[tool.importlinter]` in `pyproject.toml`, `uv run lint-imports`, run by `just lint` and CI): `graphql` and `adapters` never import each other (so only `main` imports the concrete adapter), `domain` imports no framework or I/O library, and there are no import cycles between sibling modules. A new top-level module under `app/` must be added to the `layers` contract (`exhaustive = true`).

- **`app/domain/`** — plain frozen dataclasses (`models.py`) and a single `typing.Protocol` port (`ports.py`, `OverFastPort`). No framework imports. Hero/map/gamemode keys are plain `str` on purpose (new Blizzard content flows through without a schema change); only closed sets (`RoleKey`, `Platform`, `PlayerGamemode`) are `StrEnum`. `exceptions.py::UpstreamError` is raised both by the adapter (unexpected upstream status) and by `graphql/types.py` (an upstream response that violates an invariant, e.g. a hero referencing a role that doesn't exist) — it always means "upstream data didn't match our assumptions," never a client-input problem.
- **`app/adapters/overfast_client.py`** — the only implementation of `OverFastPort`. Owns HTTP (httpx2), caching, request coalescing, pacing, and REST→domain parsing (`_parse_*` functions at the bottom of the file).
- **`app/graphql/`** — `query.py` (root `Query` type / resolvers), `types.py` (registers domain dataclasses as strawberry types), `context.py` (`get_client(info)` — the DI seam), `schema.py` (assembles `strawberry.Schema` with guardrail extensions).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ Requirements: `docker`, `docker compose` and [`just`](https://github.com/casey/j
just build # build Docker images (required first)
just start # run the app with autoreload on localhost:8000
just test # run tests with coverage
just lint # ruff linter
just lint # ruff linter + import-linter contracts
just format # ruff formatter
just check # ty type checker
just lock # update uv.lock
Expand Down
1 change: 1 addition & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ check checker_args="":
lint:
@echo "Running linter..."
uv run ruff check --fix --exit-non-zero-on-fix
uv run lint-imports
Comment on lines 34 to +35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (bug_risk): just lint does not run lint-imports whenever Ruff applies an autofix, because the preceding ruff check --fix --exit-non-zero-on-fix command exits nonzero and stops the recipe before reaching this line.

Triggers: When the tree contains any Ruff issue that is automatically fixable.

Suggested fix: Run the import-linter command independently of Ruff's fix status, for example by separating the commands with a shell construct that preserves execution while still returning Ruff's failure status.

Suggested change
uv run ruff check --fix --exit-non-zero-on-fix
uv run lint-imports
uv run ruff check --fix --exit-non-zero-on-fix || (ruff_status=$?; uv run lint-imports; exit $ruff_status)
uv run lint-imports


# run formatter
format:
Expand Down
34 changes: 34 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Issues = "https://github.com/TeKrop/overgraphql-api/issues"

[dependency-groups]
dev = [
"import-linter==2.15.*",
"pytest==9.1.*",
"pytest-asyncio==1.4.*",
"pytest-cov==7.1.*",
Expand Down Expand Up @@ -135,3 +136,36 @@ known-first-party = ["app"]

[tool.ruff.lint.pylint]
max-args = 6 # Hero.stats/get_hero_stats mirror OverFast's 5-filter /heroes/stats endpoint

# Architecture contracts, checked by `uv run lint-imports` (just lint, CI)
[tool.importlinter]
root_package = "app"
include_external_packages = true

[[tool.importlinter.contracts]]
id = "layers"
name = "Hexagonal layers: dependencies flow inward only"
type = "layers"
containers = ["app"]
layers = [
"main",
"graphql | adapters", # independent: graphql reaches adapters only through OverFastPort
"logging_config",
"settings",
"domain",
]
exhaustive = true

[[tool.importlinter.contracts]]
id = "domain-pure"
name = "Domain stays framework- and I/O-agnostic"
type = "forbidden"
source_modules = ["app.domain"]
forbidden_modules = ["strawberry", "starlette", "httpx2", "cachetools", "pydantic_settings"]
broken_contract_guidance = "Keep app.domain to stdlib dataclasses/enums: reach upstream through app.domain.ports.OverFastPort, raise app.domain.exceptions.UpstreamError, do GraphQL mapping in app.graphql.types."

[[tool.importlinter.contracts]]
id = "acyclic"
name = "No dependency cycles between sibling packages"
type = "acyclic_siblings"
ancestors = ["app"]
Loading