Skip to content

Add generic typing to Container.make() so type checkers resolve concrete types - #219

Merged
tmgbedu merged 1 commit into
mainfrom
task/container-make-generics
Sep 13, 2026
Merged

tmgbedu merged 1 commit into
mainfrom
task/container-make-generics

Conversation

@tmgbedu

@tmgbedu tmgbedu commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Problem

Container.make() was unannotated, so pyright/basedpyright inferred:

Type of "make" is "(name: Unknown, *arguments: Unknown) -> (Unknown | Any | None)"  [reportUnknownMemberType]

That fired on every app.make(...) call site, and every resolved service degraded to Unknown — no completion, no checking downstream.

Change (typing-only)

Container.make() now has two overloads:

@overload
def make(self, name: type[T], *arguments: Any) -> T: ...
@overload
def make(self, name: str, *arguments: Any) -> Any: ...
  • make(SomeClass)SomeClass
  • make("config") / make("db")Any — a string key carries no static type information, so callers keep annotating the binding themselves (config: Config = app.make("config")). This is what every existing call site already does, so there is no churn.

Also annotated _instance / set_instance() / instance(), so Container.instance().make(...) resolves as well (previously instance() inferred None from _instance = None).

On the | None in the old return type

Deliberately dropped. It came from self.swaps.get(name)dict.get is Optional — inside a branch already guarded by name in self.swaps, so it was never reachable as None. make() raises MissingContainerBindingNotFound on a miss rather than returning None. Keeping Optional would have forced an assert or narrowing at every call site for a value that cannot be None; not keeping it costs nothing.

Application inherits make() from Container and does not override it, so it picks up the overloads automatically. No .pyi stub mirrors the make signature (Hash.make / Response.make_headers are unrelated facade methods), so no stubs needed updating.

No runtime logic changed — the diff is imports, a TypeVar, overloads, annotations and a docstring.

Verification

pyright, sample call site (c.make(MyService), c.make("config")), # pyright: strict:

before after
svc Unknown | Any | None MyService
cfg Unknown | Any | None Any
make (name: Unknown, *arguments: Unknown) -> (Unknown | Any | None) Overload[(name: type[T], *arguments: Any) -> T, (name: str, *arguments: Any) -> Any]
reportUnknownMemberType 3 0

Full-repo pyright (uv run pyright, project config): 652 errors → 622 errors, warnings unchanged at 113. 30 pre-existing errors fixed, none introduced.

Tests: uv run pytest --ignore=tests/masoniteorm/postgres --cov2192 passed, 7 skipped, coverage 84.40% (threshold 80).

New: tests/core/test_container_typing.pyassert_type() assertions for make(Class), unbound make(Class), make("str") and Container.instance(), each paired with a runtime assertion. Statically verified with uv run pyright tests/core/test_container_typing.py → 0 errors.

Downstream example/ and application/ call sites all use string keys, so none are affected.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EohxFkN7w7tuq71KmkUp1B

…te types

Container.make() was unannotated, so pyright/basedpyright inferred
`(name: Unknown, *arguments: Unknown) -> (Unknown | Any | None)` and every
resolved service degraded to Unknown at the call site.

Add overloads: a class key returns that class (`make(Foo) -> Foo`), a string
key stays `Any` since it carries no static type information. The `| None` in
the old inferred return came from `dict.get` on the swaps lookup, not from a
reachable code path — make() raises on a missing key — so the typed signature
is non-optional and call sites need no narrowing.

Also annotate `_instance` / `set_instance` / `instance` so
`Container.instance().make(...)` resolves too.

Typing-only: no runtime logic changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EohxFkN7w7tuq71KmkUp1B
@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tmgbedu

tmgbedu commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Arbitration: PR #219 vs #221KEEP #219

Judged on correctness only. Evidence below; all commands re-run locally.

1. No undisclosed runtime change (cleared for both PRs)

git show origin/main:.../container/container.pymake() on main already ends with:

raise MissingContainerBindingNotFound("{0} key was not found in the container".format(name))

and dispatches on a membership check (if name in self.objects), not a None sentinel. Neither PR adds the raise. Non-Optional return is accurate and honest — no runtime change in either PR.

The | None in the original -> (Unknown | Any | None) diagnostic comes from return self.swaps.get(name) (dict.get() infers Any | None), not from a reachable missing-key path.

On the "bound None" nuance (bind("x", None)make("x") returns None): real, but harmless. That path is only reachable through the str overload, which returns Any in both PRs, and Any already absorbs None. No Optional is warranted; adding it would force needless narrowing on every call site. Best documented, not typed.

2. Typing-only: confirmed

No edits to the bodies of make/bind/resolve or the on_bind/on_make/on_resolve hooks. _instance: ClassVar["Container | None"] = None is an annotation on an unchanged value.

3. Application.make + .pyi audit — both claims verified TRUE

  • git grep -n "def make" -- srconly container/container.py:106. Application(Container, Generic[TConfig]) has no override; it inherits the overloads.
  • Only .pyi files with make are Hash.pyi (Hash.make(string)) and Response.pyi (make_headers) — unrelated, neither mirrors Container.make.

Verified against a real call site on this branch:

Type of "app.make(Mailer)"   is "Mailer"
Type of "app.make("config")" is "Any"
0 errors, 0 warnings, 2 informations

The user's original app.make(...) squiggle is fixed. ✅

4. Tests genuinely fail-first ✅

Against unfixed main with only this PR's test file applied:

error: "assert_type" mismatch: expected "Mailer"    but received "Unknown | Any | None"  (:35)
error: "assert_type" mismatch: expected "Mailer"    but received "Unknown | Any | None"  (:42)
error: "assert_type" mismatch: expected "Any"       but received "Unknown | Any | None"  (:50)
error: "assert_type" mismatch: expected "Container" but received "Unknown"               (:63)
4 errors

Post-fix on this branch: 0 errors. Reproduces the reported symptom exactly and each assert_type is paired with a runtime assertion. This is the standard #219 clears and #221 does not.

5. Why #219 over #221

6. make(str) -> Any is the right call

Real per-key typing would need a key→type registry that doesn't exist here. A Literal overload set would have to be hand-maintained and would break the open bind(name: str, ...) contract. Any is correct, and both PRs document it. Keep the docstring as written.

7. Callers relying on make() returning None: none

Repo + example/ + application/ swept. The only assertIsNone(... .make(...)) hits are ChannelFactory.make / DriverFactory.make — unrelated classes. The three app.make(...) sites (inertia-pingcrm-app, vite-app, agents) use the value directly.

8. My numbers on this branch

uv run pytest --ignore=tests/masoniteorm/postgres --cov --cov-report=term-missing
2192 passed, 7 skipped, 11 subtests passed in 101.31s
Total coverage: 84.40%  (threshold 80% reached)

uv run pyright src/fastapi_startkit/container/   →  0 errors, 0 warnings

Follow-ups before merge (neither is a correctness blocker)

  1. The typing tests are inert in CI. pyproject.toml [tool.pyright] sets exclude = ["**/tests"], which suppresses the tests dir even when the file is named explicitly on the command line. Canary proof — appending _x: int = "definitely a string" to tests/core/test_container_typing.py and running uv run pyright tests/core/test_container_typing.py reports 0 errors. So every assert_type here is currently unenforced. Either add tests/core/test_container_typing.py back via an include/strict entry, or add a CI step that type-checks it outside the project config. The file's own docstring advertises uv run pyright tests/core/test_container_typing.py, which today is a silent no-op — worth fixing so the advice is true.
  2. Port Add generic typing to Container.make() so basedpyright resolves concrete types #221's Application.make test. Add generic typing to Container.make() so type checkers resolve concrete types #219 has no coverage asserting the overloads survive inheritance, and that is precisely the user-facing path that prompted this work. Add generic typing to Container.make() so basedpyright resolves concrete types #221's version is sound (its app is properly typed) and passes:
    app = Application(base_path=tmp_path, env="testing")
    assert_type(app.make(ServiceA), ServiceA)
    assert_type(app.make("service_a"), Any)

Not merging or closing — @pm owns that.

@tmgbedu

tmgbedu commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Verification detail

Type checker: before / after

basedpyright is not installed in this repo. pyright is a dev dependency (pyright>=1.1.411) and is already configured in pyproject.toml under [tool.pyright], so I used it with a # pyright: strict pragma — that emits the same reportUnknownMemberType / reportUnknownVariableType rules the original report cites, and it reproduced the reported message verbatim.

Sample call site used for the measurement:

# pyright: strict
from fastapi_startkit.application import app
from fastapi_startkit.container import Container


class MyService:
    def greet(self) -> str:
        return "hi"


def use() -> None:
    c = Container()
    svc = c.make(MyService)
    reveal_type(svc)
    cfg = c.make("config")
    reveal_type(cfg)
    reveal_type(app().make)
before after
svc Unknown | Any | None MyService
cfg Unknown | Any | None Any
app().make (name: Unknown, *arguments: Unknown) -> (Unknown | Any | None) Overload[(name: type[T@make], *arguments: Any) -> T@make, (name: str, *arguments: Any) -> Any]
reportUnknownMemberType 3 0
reportUnknownVariableType 2 0

The "before" run reproduced the exact reported diagnostic:

Type of "make" is partially unknown
  Type of "make" is "(name: Unknown, *arguments: Unknown) -> (Unknown | Any | None)"   [reportUnknownMemberType]

Full-repo pyright

uv run pyright (project config, typeCheckingMode = "standard", include = ["src/fastapi_startkit"]):

errors warnings informations
before 652 113 0
after 622 113 0

30 pre-existing errors fixed as a side effect, none introduced. The remaining 622 are pre-existing and unrelated to make().

Typing assertions

$ uv run pyright tests/core/test_container_typing.py
0 errors, 0 warnings, 0 informations

This is what actually validates the assert_type() calls in the new test — they are no-ops at runtime, so each one is paired with a runtime assertion as well.

pytest + coverage

$ uv run pytest --ignore=tests/masoniteorm/postgres --cov --cov-report=term-missing
...
TOTAL                     11884   1854    84%
Required test coverage of 80.0% reached. Total coverage: 84.40%
2192 passed, 7 skipped, 11 subtests passed in 98.82s (0:01:38)
metric value
passed 2192
skipped 7
subtests passed 11
failed 0
coverage 84.40% (statements 11884, missed 1854)
fail_under 80

Container suite on its own:

$ uv run pytest tests/core/ -q
129 passed in 0.38s

Note: the task brief quoted fail_under = 40; the current value in pyproject.toml is 80. Either way the run is above threshold.

Lint

$ uv run ruff check src tests
All checks passed!
$ uv run ruff format --check src/fastapi_startkit/container/container.py tests/core/test_container_typing.py
2 files already formatted

Downstream call sites

All .make(...) call sites in example/ and application/ use string keys and are therefore unaffected:

example/inertia-pingcrm-app/providers/fastapi_provider.py:25:  self.app.make("inertia")
example/agents/app/agents/job_search_graph.py:141:            app().make("checkpointer")
example/vite-app/routes/web.py:11:                            app().make("templates")

Note on uv.lock

fastapi_startkit/uv.lock is stale on main — it records version = "0.51.0" while pyproject.toml says 0.56.0, so any uv run rewrites it. I reverted it out of this branch to keep the diff typing-only; it needs a separate lock-refresh commit.

@tmgbedu
tmgbedu merged commit f1ca6fa into main Sep 13, 2026
6 checks passed
@tmgbedu
tmgbedu deleted the task/container-make-generics branch September 13, 2026 00:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant