diff --git a/docs/modularity-review/2026-05-29/modularity-review.md b/docs/modularity-review/2026-05-29/modularity-review.md new file mode 100644 index 0000000..7a1e49d --- /dev/null +++ b/docs/modularity-review/2026-05-29/modularity-review.md @@ -0,0 +1,109 @@ +# Modularity Review + +**Scope**: `python-cli-hexagonal` template — entire codebase (`domain/`, `adapters/`, `entrypoint/`, wiring, and the test doubles that integrate with the ports) +**Date**: 2026-05-29 + +## Executive Summary + +`python-cli-hexagonal` is a copy-and-extend **template** for building Typer-based command-line tools with a hexagonal (ports & adapters) layout. Its real deliverable is not the bundled `greet` example — that is a placeholder — but the *structure and its guardrails*: the `domain → adapters → entrypoint` dependency direction enforced by `tach`, the `Protocol`-based ports, and the documented recipe for adding new commands. As a piece of [modular design](https://coupling.dev/posts/core-concepts/modularity/), the template is largely **healthy**: integration strength is deliberately kept low (frozen dataclasses and a structural `Protocol`), distance is low (one package, one process), and the example's volatility is negligible, so most integrations are [balanced](https://coupling.dev/posts/core-concepts/balance/) by construction. The most important finding is that the template's central promise — that adapters conform to domain ports — relies on [contract coupling](https://coupling.dev/posts/dimensions-of-coupling/integration-strength/) that **no tool in the toolchain actually verifies**: `tach` checks import direction, `ruff` checks style, `pytest` checks the one example's behavior, but nothing checks that an adapter satisfies its port. Because adding ports and adapters is exactly what adopters do most, that unverified contract sits in the template's highest-[volatility](https://coupling.dev/posts/dimensions-of-coupling/volatility/) area. + +## Coupling Overview + +| Integration | [Strength](https://coupling.dev/posts/dimensions-of-coupling/integration-strength/) | [Distance](https://coupling.dev/posts/dimensions-of-coupling/distance/) | [Volatility](https://coupling.dev/posts/dimensions-of-coupling/volatility/) | [Balanced?](https://coupling.dev/posts/core-concepts/balance/) | +| ----------- | ----------- | -------- | ---------- | --------- | +| `adapters.ConsoleGreeter` → `domain.ports.GreeterPort` (structural conformance) | [Contract](https://coupling.dev/posts/dimensions-of-coupling/integration-strength/) — but **implicit / unenforced** | Cross-module, same package/process | **High** (adopters add ports & adapters constantly) | ⚠️ **No** — implicit coupling in a volatile area | +| `tests.FakeGreeter` → `domain.ports.GreeterPort` (structural conformance) | [Contract](https://coupling.dev/posts/dimensions-of-coupling/integration-strength/) — implicit / unenforced | Cross-module (tests ↔ src) | **High** | ⚠️ **No** — same root cause as above | +| `entrypoint.commands.greet` → `entrypoint.wiring.build_greeter` | [Functional](https://coupling.dev/posts/dimensions-of-coupling/integration-strength/) — command performs its own composition | Same package | Moderate (grows with every new command) | ⚠️ **Partial** — composition concern scatters as commands multiply | +| `domain.use_cases.greet` → `domain.ports.GreeterPort` (`deliver` side effect) | [Functional](https://coupling.dev/posts/dimensions-of-coupling/integration-strength/) — computation + I/O orchestration fused | Same module | Low (for the example) | ▲ Balanced today; pattern risk at scale | +| `entrypoint.commands.greet` → `domain.use_cases` / `domain.models` | [Model](https://coupling.dev/posts/dimensions-of-coupling/integration-strength/) — shares `GreetingRequest`/`Greeting` | Cross-module, same process | Moderate | ✅ Balanced | +| `entrypoint.wiring.build_greeter` → `adapters.ConsoleGreeter` | [Model](https://coupling.dev/posts/dimensions-of-coupling/integration-strength/) — constructs concrete, returns as port | Cross-module | Low | ✅ Balanced (correct dependency inversion) | +| `entrypoint.cli.main` → `domain.errors.DomainError` (catch-all → exit 1) | [Contract](https://coupling.dev/posts/dimensions-of-coupling/integration-strength/) — base-class catch only | Cross-module | Low | ✅ Balanced (mapping isolated to one place) | + +The last three rows are the template doing its job well and are recorded for completeness; the issues below cover the three flagged rows. + +## Issue: The port↔adapter contract is implicit and unverified by the toolchain + +**Integration**: `adapters.ConsoleGreeter` (and `tests.FakeGreeter`) → `domain.ports.GreeterPort` +**Severity**: Significant + +### Knowledge Leakage + +The whole point of a `typing.Protocol` port is to make the integration a *explicit* [contract](https://coupling.dev/posts/dimensions-of-coupling/integration-strength/): the domain publishes `GreeterPort.deliver(self, greeting: Greeting) -> None`, and adapters conform structurally without inheriting. In principle this is the weakest, most desirable form of [integration strength](https://coupling.dev/posts/dimensions-of-coupling/integration-strength/). + +The problem is *enforcement*. Structural conformance is asserted nowhere a machine can see it. `ConsoleGreeter` never references `GreeterPort`; `wiring.build_greeter()` returns it under the `GreeterPort` annotation, but no type checker runs against that site. The dev dependencies (`ruff`, `pytest`, `pytest-cov`, `tach`, `pre-commit`) contain **no type checker** (no `mypy`, `pyright`, or `ty`); CI runs `ruff check`, `ruff format --check`, `tach check`, `pytest`; pre-commit runs `ruff` + `tach`. `tach` enforces *import direction* (`domain` may not import `adapters`), not *type conformance*. The result: a contract that *looks* explicit is in practice **implicit** — its satisfaction is a human promise. Implicit coupling that should be explicit is the dangerous kind, because the integration point is invisible until it breaks at runtime. + +### Complexity Impact + +Implicit coupling makes change outcomes unpredictable, which is the definition of [complexity](https://coupling.dev/posts/core-concepts/complexity/). When the shared knowledge (the port signature) is not machine-checked, a developer editing the port has no signal telling them which adapters and test doubles must move in lockstep. They must hold the full set of conformers in their head — and the working-memory budget of 4±1 items is exhausted the moment a real project has a handful of ports each with two or three adapters plus fakes. The template *teaches* a pattern whose safety net is missing, so every project cloned from it inherits the blind spot. + +### Cascading Changes + +- A developer renames `deliver` to `emit`, or changes its signature to `deliver(self, greeting: Greeting, *, stream) -> None`. `ConsoleGreeter` and `FakeGreeter` still *parse* and still *import-check* under `tach`. The unit tests pass (they call the fake directly), and the break only surfaces when the real CLI path executes the stale adapter at runtime — an `AttributeError`/`TypeError` discovered in production rather than in CI. +- An adopter adds a second adapter (`LoggingGreeter`) and forgets one method, or gets an argument name wrong. Nothing fails until that adapter is wired and invoked. + +Because the integration lives in the template's **highest-volatility** region — adding ports/adapters is the primary growth activity under "varies widely" adoption — these silent breaks are not rare edge cases; they are the expected failure mode of the template's day-to-day use. + +### Recommended Improvement + +Make the existing contract *explicit to tooling* rather than changing the design — the [strength](https://coupling.dev/posts/dimensions-of-coupling/integration-strength/) is already where you want it; only its verification is missing. + +1. Add a static type checker (`ty`, `mypy`, or `pyright`) to the dev dependencies, the pre-commit hooks, and the CI workflow, configured to check `src/` *and* `tests/`. This turns structural conformance into a checked, build-breaking contract — closing the gap without raising strength or distance. +2. Optionally, add one assertion site that pins the relationship the type checker keys on — e.g. in `wiring.py` the return is already annotated `-> GreeterPort`, so a checker will flag a non-conforming `ConsoleGreeter` there. Document in the "how to add a command" recipe that *every* adapter must be returned through a `GreeterPort`-annotated factory so the checker has an anchor. + +**Trade-off**: a type checker adds a CI step, some annotation discipline, and occasional friction with dynamic code. For a template that markets itself on ports & adapters correctness, that cost is plainly worth it — without it, the headline feature is unguarded precisely where adopters lean on it hardest. + +## Issue: Composition is performed inside commands, fragmenting the composition root + +**Integration**: `entrypoint.commands.greet.run` → `entrypoint.wiring.build_greeter` +**Severity**: Significant + +### Knowledge Leakage + +`wiring.py`'s own docstring states its purpose: *"Centralizar el wiring aqui permite cambiar adapters … modificando un unico archivo."* But the *selection and construction* of the adapter happens inside `greet.run()`, which calls `build_greeter()` itself. The command therefore carries [functional](https://coupling.dev/posts/dimensions-of-coupling/integration-strength/) knowledge — "to greet, you assemble a greeter" — that the design intends to keep in the composition root. Step 6 of the README recipe instructs every new command to call its own `build_*()` inside `run()`, so this is a *prescribed* pattern, not a one-off. + +### Complexity Impact + +For a single command the duplication is invisible. The cost appears as commands multiply: the composition root is no longer a place, it is a scattering of `build_*()` calls across N command modules. Any cross-cutting change to *how* dependencies are assembled (environment-based adapter selection, swapping in test doubles at the boundary, threading a shared resource like an HTTP client or DB session through several commands) must be reasoned about across every command file rather than in one root — exceeding the cognitive budget the centralized-wiring design was meant to protect. This is [low cohesion](https://coupling.dev/posts/core-concepts/balance/) of the composition concern: a single responsibility (object assembly) smeared across many low-volatility command modules. + +### Cascading Changes + +- Adopter wants all commands to use a `LoggingGreeter` in one environment and `ConsoleGreeter` in another. With composition inlined per command, the conditional must be added to (or imported into) each command, or each `build_*` must grow environment logic. With a true root, it is one change. +- Adopter wants integration tests to inject fakes at the composition boundary instead of monkeypatching. Today there is no single seam to inject at — each command reaches into `wiring` on its own. + +The distance between the scattered call sites is low (same package), so the changes are not *expensive* per file — but they are *many* and *repetitive*, which is exactly the drift toward a [big ball of mud](https://coupling.dev/posts/core-concepts/balance/) that low cohesion produces. + +### Recommended Improvement + +Keep `wiring.py` as the composition root and stop commands from constructing their own dependencies. Two viable shapes: + +- **Inject the port into the command**: have `cli.py` (the true root) build the greeter via `wiring` and pass it to `run(greeter, name=...)`, so the command receives its collaborators rather than fetching them. Distance and strength stay low; the composition concern returns to one place. +- **Or** make `wiring.py` expose a single `build_dependencies()`/container that `cli.py` assembles once at startup and hands to commands. + +**Trade-off**: Typer commands receiving injected dependencies need a small amount of plumbing (a partial, a closure, or a context object) because Typer wants to own the callback signature. That is a modest, one-time cost in the template's `cli.py`, paid back every time an adopter adds a command or needs an environment-specific or test wiring — and it makes the template's stated "single file to swap adapters" promise actually true. + +## Issue: Use cases fuse pure computation with output delivery + +**Integration**: `domain.use_cases.greet` → `domain.ports.GreeterPort.deliver` +**Severity**: Minor + +### Knowledge Leakage + +`greet()` both *computes* the result (`Greeting(message=f"Hola, {name}!")`) and *performs the side effect* (`greeter.deliver(greeting)`) before returning. The pure transformation and the I/O orchestration are [functionally coupled](https://coupling.dev/posts/dimensions-of-coupling/integration-strength/) inside one function. A use case can no longer be evaluated for its result without also triggering delivery through whatever port is supplied. + +### Complexity Impact + +At the current size this is fully [balanced](https://coupling.dev/posts/core-concepts/balance/) — the distance is a single module and the example's volatility is negligible. The concern is that, as the *documented pattern of the template*, it pushes output orchestration into the domain. A use case that wants to call another use case inherits its delivery side effect; composing two domain operations forces a decision about which deliveries fire. That is how I/O responsibilities accrete in a layer that is supposed to stay pure and composable. + +### Cascading Changes + +- An adopter adds a `report` use case that internally needs `greet`'s computed message but should emit it through a *different* channel (a file, an aggregated report). Today they cannot reuse `greet` without also triggering its `deliver`; they must duplicate the message-building logic or refactor the side effect out under pressure. + +### Recommended Improvement + +This is a *pattern* recommendation for the template, not an urgent fix to the example. Consider establishing the convention that **use cases return domain results and the entrypoint performs delivery** — i.e. `greet()` returns `Greeting`, and `commands/greet.py` (or the injected port from the previous issue) calls `deliver`. Keep `deliver`-from-use-case available where a use case genuinely orchestrates streaming/long-running output, but make "compute, return, let the edge render" the default the recipe teaches. + +**Trade-off**: moving delivery to the edge means the entrypoint, not the use case, owns the "and then show it" step — a slightly thicker command layer. Given that the template's purpose is to model clean separation for arbitrary adopters, teaching composable pure use cases is worth that small shift. Because the imbalance is low-volatility today, this can be addressed opportunistically rather than urgently. + +--- + +_This analysis was performed using the [Balanced Coupling](https://coupling.dev) model by [Vlad Khononov](https://vladikk.com)._ diff --git a/python-cli-hexagonal/.github/workflows/ci.yml b/python-cli-hexagonal/.github/workflows/ci.yml index db865f8..451b5d9 100644 --- a/python-cli-hexagonal/.github/workflows/ci.yml +++ b/python-cli-hexagonal/.github/workflows/ci.yml @@ -22,6 +22,8 @@ jobs: run: uv run ruff check . - name: Ruff format run: uv run ruff format --check . + - name: Ty (type check) + run: uv run ty check - name: Tach (architecture) run: uv run tach check - name: Pytest diff --git a/python-cli-hexagonal/.pre-commit-config.yaml b/python-cli-hexagonal/.pre-commit-config.yaml index 824541d..80e1dac 100644 --- a/python-cli-hexagonal/.pre-commit-config.yaml +++ b/python-cli-hexagonal/.pre-commit-config.yaml @@ -5,6 +5,10 @@ repos: - id: ruff args: [--fix] - id: ruff-format + - repo: https://github.com/astral-sh/ty-pre-commit + rev: v0.0.1a16 + hooks: + - id: ty - repo: https://github.com/gauge-sh/tach-pre-commit rev: v0.20.0 hooks: diff --git a/python-cli-hexagonal/CLAUDE.md b/python-cli-hexagonal/CLAUDE.md index 2051e4a..6968d76 100644 --- a/python-cli-hexagonal/CLAUDE.md +++ b/python-cli-hexagonal/CLAUDE.md @@ -21,13 +21,31 @@ code review. Estas reglas las **fuerza `tach check`**. No deben relajarse: - `app.domain` **nunca** importa de `app.adapters` ni `app.entrypoint`. +- `app.domain.model` no importa de ninguna otra capa (ni siquiera del + kernel `app.domain`). +- `app.domain.usecase` depende **solo** de `app.domain.model` y del + kernel `app.domain`; nunca al reves (`model` no conoce `usecase`). - `app.adapters` no importa de `app.entrypoint`. - No se permiten dependencias circulares entre módulos. -- Cualquier paquete nuevo bajo `src/app/` debe declararse en - `tach.toml` con sus `depends_on` explícitos. +- Una **capa nueva** de primer nivel bajo `src/app/` debe declararse en + `tach.toml` con sus `depends_on` explícitos. Las features nuevas + dentro de `model/` o `usecase/` no requieren tocar `tach.toml`. Reglas adicionales no automatizadas: +- El **gateway (puerto) vive junto a su modelo** en + `domain/model//gateways.py`, no en un módulo de puertos + aparte. +- Los **use cases son puros**: reciben la solicitud y **retornan** un + resultado de dominio. **No** entregan IO (no llaman al puerto). La + entrega ocurre en `entrypoint/`. +- Los **errores específicos** de una feature viven junto a su use case + (`domain/usecase//errors.py`) y heredan de + `app.domain.errors.DomainError` (kernel compartido). +- El **composition root es único**: `wiring.build_dependencies` es el + único lugar donde se instancian adapters; se invoca una sola vez en el + callback raíz de `entrypoint/cli.py` y se inyecta vía `ctx.obj`. Los + comandos **no** construyen dependencias. - Las dependencias externas (typer, requests, sqlalchemy, etc.) viven solo en `adapters/` o `entrypoint/`, **nunca** en `domain/`. - Los fakes/mocks de test viven en `tests/conftest.py` o en archivos @@ -36,6 +54,15 @@ Reglas adicionales no automatizadas: salida, colores, formatos) vive **solo** en `entrypoint/cli.py`. El dominio no conoce `typer.Exit`. +### Verificación de tipos (`ty`) + +- La conformidad estructural de los adapters con el `Protocol` de su + gateway la verifica **`ty check`** (no `ruff` ni `tach`). Es parte del + toolchain obligatorio (pre-commit + CI). +- El ancla de conformidad es `wiring.py`: cada adapter se devuelve + tipado como su puerto, de modo que `ty` detecta cualquier divergencia + de firma antes de runtime. + ## 2. Power features prohibidas (Google §2.19) - No usar metaclases salvo justificación arquitectónica explícita. @@ -159,8 +186,10 @@ reglas son cualitativas: Cuando se añada un nuevo caso de uso o adapter: 1. Seguir los pasos de "Cómo añadir un nuevo comando" del README. -2. Si se introduce un paquete nuevo bajo `src/app/`, registrarlo en - `tach.toml` con sus `depends_on` explícitos. Sin esto, `tach check` - falla. -3. **Nunca** añadir `app.adapters` o `app.entrypoint` a `depends_on` - de `app.domain`. Esa es la invariante que protege la arquitectura. +2. Las features nuevas dentro de `domain/model/` o `domain/usecase/` no + requieren tocar `tach.toml` (pertenecen a la capa ya declarada). Solo + si se introduce una **capa nueva** de primer nivel bajo `src/app/` + hay que registrarla con sus `depends_on` explícitos. +3. **Nunca** invertir el flujo de capas: `model` no depende de + `usecase`, y `domain` no depende de `adapters` ni `entrypoint`. Esa + es la invariante que protege la arquitectura. diff --git a/python-cli-hexagonal/README.md b/python-cli-hexagonal/README.md index 09c47a6..465bed1 100644 --- a/python-cli-hexagonal/README.md +++ b/python-cli-hexagonal/README.md @@ -6,27 +6,49 @@ hexagonal** (ports & adapters), inspirada en el ejemplo de pero adaptada a una interfaz de línea de comandos con **Typer**. Gestiona dependencias con **uv**, aplica lint y format con **ruff** -(reglas alineadas al Google Python Style Guide) y protege la +(reglas alineadas al Google Python Style Guide), verifica tipos y la +conformidad estructural de los puertos con **ty**, y protege la arquitectura con **tach** (equivalente Python de ArchUnit). ## Arquitectura +El dominio se organiza en dos capas al estilo del scaffold de +[Bancolombia](https://bancolombia.github.io/scaffold-clean-architecture/): +una capa `model/` donde cada modelo vive en su propio paquete junto a su +*gateway* (puerto), y una capa `usecase/` separada que depende de los +modelos. + ``` - +-----------------------------+ - | entrypoint/ | CLI (Typer), wiring, error map - +--------------+--------------+ - | depende de - +--------------v--------------+ - | domain/ | modelos, puertos, use cases - +--------------+--------------+ - ^ implementa (estructuralmente) - +--------------+--------------+ - | adapters/ | IO concreto (consola, fs, http) - +-----------------------------+ + +-----------------------------------+ + | entrypoint/ | CLI (Typer), composition + | | root, mapeo de errores + +----------------+-----------------+ + | depende de + +----------------v-----------------+ + | domain/usecase/ | funciones puras; retornan + | | resultados de dominio + +----------------+-----------------+ + | depende de + +----------------v-----------------+ + | domain/model/ | entidades + gateways + | | (puertos) por feature + +----------------+-----------------+ + ^ implementa (estructuralmente) + +----------------+-----------------+ + | adapters/ | IO concreto (consola, fs) + +-----------------------------------+ ``` -**Regla de oro:** `domain/` no importa de `adapters/` ni de -`entrypoint/`. `tach check` lo verifica en cada commit y en CI. +**Reglas de oro** (verificadas por `tach check` en cada commit y en CI): + +- `domain/model/` no importa de ninguna otra capa. +- `domain/usecase/` depende solo de `domain/model/` (y del kernel + compartido `domain/errors.py`), nunca al reves. +- `domain/` nunca importa de `adapters/` ni de `entrypoint/`. + +Los **use cases son puros**: construyen y retornan un resultado de +dominio; la entrega por un puerto (p. ej. escribir a consola) ocurre en +el `entrypoint/`, no en el dominio. ## Estructura de directorios @@ -40,15 +62,20 @@ python-cli-hexagonal/ ├── .github/workflows/ci.yml # lint + format + tach + pytest ├── src/app/ │ ├── domain/ # NÚCLEO puro, sin dependencias externas -│ │ ├── ports.py # typing.Protocol → GreeterPort -│ │ ├── models.py # @dataclass(frozen=True) -│ │ ├── use_cases.py # funciones puras -│ │ └── errors.py # DomainError jerarquía +│ │ ├── errors.py # DomainError base (shared kernel) +│ │ ├── model/ # capa de modelos: entidad + gateway +│ │ │ └── greeting/ +│ │ │ ├── models.py # @dataclass(frozen=True) +│ │ │ └── gateways.py # typing.Protocol → GreeterPort +│ │ └── usecase/ # capa de casos de uso (puros) +│ │ └── greet/ +│ │ ├── use_case.py # greet(request) -> Greeting +│ │ └── errors.py # EmptyNameError(DomainError) │ ├── adapters/ # implementaciones concretas │ │ └── console_greeter.py │ └── entrypoint/ # composition root + CLI Typer │ ├── cli.py # typer.Typer() + mapeo DomainError -│ ├── wiring.py # factories de puertos +│ ├── wiring.py # Dependencies + build_dependencies() │ ├── __main__.py # python -m app.entrypoint │ └── commands/ │ └── greet.py # subcomando `greet` @@ -94,8 +121,9 @@ uv run app greet --name " " uv run pytest ``` -Coverage activo por defecto. Tests divididos en `unit/` (dominio + fake -adapter) e `integration/` (CLI end-to-end con `CliRunner`). +Coverage activo por defecto. Tests divididos en `unit/` (use case puro y +entrega vía fake adapter) e `integration/` (CLI end-to-end con +`CliRunner`). ## Lint y format @@ -109,6 +137,18 @@ Las reglas de `ruff` están alineadas al Google Python Style Guide. Las reglas del guide que ruff no puede expresar están en [`CLAUDE.md`](./CLAUDE.md) y deben respetarse en code review. +## Type checking + +```bash +uv run ty check # verifica tipos y conformidad de puertos +``` + +`ty` (de Astral, mismo ecosistema que `uv` y `ruff`) comprueba que cada +adapter satisface estructuralmente el `Protocol` de su gateway. Sin esto, +un cambio en la firma de un puerto pasaría lint y tests y solo fallaría +en runtime. La conformidad se ancla en `wiring.py`, donde el adapter se +devuelve tipado como el puerto. + ## Enforcement arquitectónico ```bash @@ -131,20 +171,36 @@ A partir de ahí, cada `git commit` corre ruff + tach. ## Cómo añadir un nuevo comando -1. Definir el contrato en `src/app/domain/ports.py` (nuevo `Protocol`) - si el comando necesita un canal de IO nuevo. -2. Añadir modelos en `src/app/domain/models.py`. -3. Escribir el use case puro en `src/app/domain/use_cases.py`. -4. Implementar el adapter en `src/app/adapters/.py`. -5. Registrar la factory del adapter en `src/app/entrypoint/wiring.py`. -6. Crear el subcomando en - `src/app/entrypoint/commands/.py` con una función `run(...)`. -7. Registrarlo en `src/app/entrypoint/cli.py`: +1. Crear el paquete de modelo en + `src/app/domain/model//`: + - `models.py` con los `@dataclass(frozen=True)` de la feature. + - `gateways.py` con el `Protocol` del puerto, **junto al modelo**, + si el comando necesita un canal de IO nuevo. +2. Crear el paquete de caso de uso en + `src/app/domain/usecase//`: + - `use_case.py` con una **función pura** que reciba la solicitud y + **retorne** un resultado de dominio (no entrega IO). + - `errors.py` con los errores específicos de la feature, que heredan + de `app.domain.errors.DomainError`. +3. Implementar el adapter en `src/app/adapters/.py` que cumpla + estructuralmente el `Protocol` del gateway. +4. Añadir el puerto como campo de `Dependencies` y construirlo en + `build_dependencies()` dentro de `src/app/entrypoint/wiring.py` + (composition root único). +5. Crear el subcomando en + `src/app/entrypoint/commands/.py` con una función + `run(ctx: typer.Context, ...)` que: lea `ctx.obj` (las + `Dependencies`), invoque el use case puro y luego entregue el + resultado por el puerto. +6. Registrarlo en `src/app/entrypoint/cli.py`: `app.command(name="")(_cmd.run)`. -8. Tests: unitarios con fake adapter en `tests/unit/`; integración con +7. Tests: unitarios del use case puro (afirman sobre el valor retornado) + y del puerto con un fake en `tests/unit/`; integración con `CliRunner` en `tests/integration/`. -9. Si el comando introduce un **paquete nuevo** bajo `src/app/`, - añadirlo a `tach.toml` con sus `depends_on` explícitos. +8. **No** suele hacer falta tocar `tach.toml`: las features nuevas viven + dentro de las capas ya declaradas (`app.domain.model`, + `app.domain.usecase`). Solo se añade a `tach.toml` si introduces una + **capa nueva** de primer nivel bajo `src/app/`. ## Cómo renombrar el paquete `app` @@ -175,9 +231,10 @@ uv run greet --name Mundo uv sync uv run app greet --name Mundo # → "Hola, Mundo!" verde, exit 0 uv run app greet --name " " # → "Error: ..." rojo, exit 1 -uv run pytest # 5 tests passing +uv run pytest # 6 tests passing uv run ruff check . # All checks passed! uv run ruff format --check . # sin cambios pendientes +uv run ty check # All checks passed! uv run tach check # ✓ All modules validated! uv build # genera dist/app-0.1.0-*.whl ``` diff --git a/python-cli-hexagonal/pyproject.toml b/python-cli-hexagonal/pyproject.toml index bebbd1b..23a1a66 100644 --- a/python-cli-hexagonal/pyproject.toml +++ b/python-cli-hexagonal/pyproject.toml @@ -8,6 +8,7 @@ authors = [{ name = "StackAI" }] license = { text = "MIT" } dependencies = [ "typer>=0.12", + "pydantic>=2.0", ] [project.scripts] @@ -16,6 +17,7 @@ app = "app.entrypoint.cli:main" [dependency-groups] dev = [ "ruff>=0.6", + "ty>=0.0.1a1", "pytest>=8.0", "pytest-cov>=5.0", "tach>=0.20", @@ -105,3 +107,11 @@ addopts = [ "--cov=app", "--cov-report=term-missing", ] + +# ---------- ty (type checker) ---------- +[tool.ty.environment] +python-version = "3.13" +root = ["./src"] + +[tool.ty.src] +include = ["src", "tests"] diff --git a/python-cli-hexagonal/src/app/adapters/console_greeter.py b/python-cli-hexagonal/src/app/adapters/console_greeter.py index 0af4158..267622e 100644 --- a/python-cli-hexagonal/src/app/adapters/console_greeter.py +++ b/python-cli-hexagonal/src/app/adapters/console_greeter.py @@ -4,14 +4,14 @@ import typer -from app.domain.models import Greeting +from app.domain.model.greeting.models import Greeting @dataclass(frozen=True, slots=True) class ConsoleGreeter: """Entrega saludos a stdout coloreados con Typer. - Cumple estructuralmente con `app.domain.ports.GreeterPort`. + Cumple estructuralmente con `app.domain.model.greeting.gateways.GreeterPort`. Attributes: color: Color de Typer aplicado al texto del saludo. diff --git a/python-cli-hexagonal/src/app/domain/errors.py b/python-cli-hexagonal/src/app/domain/errors.py index f45fc3c..a3aeb49 100644 --- a/python-cli-hexagonal/src/app/domain/errors.py +++ b/python-cli-hexagonal/src/app/domain/errors.py @@ -1,9 +1,9 @@ -"""Jerarquia de errores de dominio.""" +"""Jerarquia de errores de dominio (shared kernel). + +Solo vive aqui la base `DomainError`. Los errores especificos de cada +caso de uso viven junto a el (p. ej. `usecase/greet/errors.py`). +""" class DomainError(Exception): """Error base del dominio. Todos los errores propios heredan de aqui.""" - - -class EmptyNameError(DomainError): - """Se intento saludar a un destinatario sin nombre.""" diff --git a/python-cli-hexagonal/src/app/domain/model/__init__.py b/python-cli-hexagonal/src/app/domain/model/__init__.py new file mode 100644 index 0000000..6fb1bd8 --- /dev/null +++ b/python-cli-hexagonal/src/app/domain/model/__init__.py @@ -0,0 +1 @@ +"""Capa de modelos de dominio: entidades y sus gateways (puertos).""" diff --git a/python-cli-hexagonal/src/app/domain/model/greeting/__init__.py b/python-cli-hexagonal/src/app/domain/model/greeting/__init__.py new file mode 100644 index 0000000..25780ee --- /dev/null +++ b/python-cli-hexagonal/src/app/domain/model/greeting/__init__.py @@ -0,0 +1 @@ +"""Modelo `greeting`: entidades del saludo y su gateway de salida.""" diff --git a/python-cli-hexagonal/src/app/domain/model/greeting/gateways.py b/python-cli-hexagonal/src/app/domain/model/greeting/gateways.py new file mode 100644 index 0000000..1eb54ac --- /dev/null +++ b/python-cli-hexagonal/src/app/domain/model/greeting/gateways.py @@ -0,0 +1,22 @@ +"""Gateways del modelo `greeting`: contratos que el dominio espera del exterior. + +Usamos `typing.Protocol` (tipado estructural) para que los adapters no +necesiten heredar explicitamente. El gateway vive junto al modelo que sirve. +""" + +from typing import Protocol, runtime_checkable + +from app.domain.model.greeting.models import Greeting + + +@runtime_checkable +class GreeterPort(Protocol): + """Canal de salida para entregar un saludo al usuario. + + `runtime_checkable` permite verificar la conformidad con `isinstance` + (lo aprovecha el contenedor `Dependencies` de pydantic en el wiring). + """ + + def deliver(self, greeting: Greeting) -> None: + """Entrega el saludo por el canal concreto (stdout, log, etc.).""" + ... diff --git a/python-cli-hexagonal/src/app/domain/models.py b/python-cli-hexagonal/src/app/domain/model/greeting/models.py similarity index 90% rename from python-cli-hexagonal/src/app/domain/models.py rename to python-cli-hexagonal/src/app/domain/model/greeting/models.py index 1191caf..98dedbe 100644 --- a/python-cli-hexagonal/src/app/domain/models.py +++ b/python-cli-hexagonal/src/app/domain/model/greeting/models.py @@ -1,4 +1,4 @@ -"""Modelos de dominio inmutables.""" +"""Modelos de dominio inmutables del saludo.""" from dataclasses import dataclass diff --git a/python-cli-hexagonal/src/app/domain/ports.py b/python-cli-hexagonal/src/app/domain/ports.py deleted file mode 100644 index ee35a32..0000000 --- a/python-cli-hexagonal/src/app/domain/ports.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Puertos: contratos que el dominio espera del exterior. - -Usamos `typing.Protocol` (tipado estructural) para que los adapters no -necesiten heredar explicitamente. Esto mantiene el dominio desacoplado -de las implementaciones concretas. -""" - -from typing import Protocol - -from app.domain.models import Greeting - - -class GreeterPort(Protocol): - """Canal de salida para entregar un saludo al usuario.""" - - def deliver(self, greeting: Greeting) -> None: - """Entrega el saludo por el canal concreto (stdout, log, etc.).""" - ... diff --git a/python-cli-hexagonal/src/app/domain/use_cases.py b/python-cli-hexagonal/src/app/domain/use_cases.py deleted file mode 100644 index 63028fd..0000000 --- a/python-cli-hexagonal/src/app/domain/use_cases.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Use cases: funciones puras que orquestan dominio + puertos.""" - -from app.domain.errors import EmptyNameError -from app.domain.models import Greeting, GreetingRequest -from app.domain.ports import GreeterPort - - -def greet(request: GreetingRequest, greeter: GreeterPort) -> Greeting: - """Construye y entrega un saludo. - - Args: - request: Solicitud validada con el nombre del destinatario. - greeter: Puerto de salida que materializa la entrega. - - Returns: - El `Greeting` finalmente entregado (util para tests y logs). - - Raises: - EmptyNameError: Si el nombre esta vacio o solo contiene espacios. - """ - name = request.name.strip() - if not name: - msg = "El nombre del destinatario no puede estar vacio." - raise EmptyNameError(msg) - - greeting = Greeting(message=f"Hola, {name}!") - greeter.deliver(greeting) - return greeting diff --git a/python-cli-hexagonal/src/app/domain/usecase/__init__.py b/python-cli-hexagonal/src/app/domain/usecase/__init__.py new file mode 100644 index 0000000..2b0756a --- /dev/null +++ b/python-cli-hexagonal/src/app/domain/usecase/__init__.py @@ -0,0 +1 @@ +"""Capa de casos de uso: funciones puras que orquestan el dominio.""" diff --git a/python-cli-hexagonal/src/app/domain/usecase/greet/__init__.py b/python-cli-hexagonal/src/app/domain/usecase/greet/__init__.py new file mode 100644 index 0000000..1ad5c34 --- /dev/null +++ b/python-cli-hexagonal/src/app/domain/usecase/greet/__init__.py @@ -0,0 +1 @@ +"""Caso de uso `greet`: construye un saludo a partir de una solicitud.""" diff --git a/python-cli-hexagonal/src/app/domain/usecase/greet/errors.py b/python-cli-hexagonal/src/app/domain/usecase/greet/errors.py new file mode 100644 index 0000000..70c7db7 --- /dev/null +++ b/python-cli-hexagonal/src/app/domain/usecase/greet/errors.py @@ -0,0 +1,7 @@ +"""Errores especificos del caso de uso `greet`.""" + +from app.domain.errors import DomainError + + +class EmptyNameError(DomainError): + """Se intento saludar a un destinatario sin nombre.""" diff --git a/python-cli-hexagonal/src/app/domain/usecase/greet/use_case.py b/python-cli-hexagonal/src/app/domain/usecase/greet/use_case.py new file mode 100644 index 0000000..6876173 --- /dev/null +++ b/python-cli-hexagonal/src/app/domain/usecase/greet/use_case.py @@ -0,0 +1,27 @@ +"""Caso de uso `greet`: funcion pura que construye un saludo.""" + +from app.domain.model.greeting.models import Greeting, GreetingRequest +from app.domain.usecase.greet.errors import EmptyNameError + + +def greet(request: GreetingRequest) -> Greeting: + """Construye un saludo a partir de la solicitud. + + Funcion pura: no produce efectos de salida. La entrega del saludo es + responsabilidad del entrypoint, que invoca el `GreeterPort`. + + Args: + request: Solicitud con el nombre del destinatario. + + Returns: + El `Greeting` construido. + + Raises: + EmptyNameError: Si el nombre esta vacio o solo contiene espacios. + """ + name = request.name.strip() + if not name: + msg = "El nombre del destinatario no puede estar vacio." + raise EmptyNameError(msg) + + return Greeting(message=f"Hola, {name}!") diff --git a/python-cli-hexagonal/src/app/entrypoint/cli.py b/python-cli-hexagonal/src/app/entrypoint/cli.py index e65ca8d..0bb8bbf 100644 --- a/python-cli-hexagonal/src/app/entrypoint/cli.py +++ b/python-cli-hexagonal/src/app/entrypoint/cli.py @@ -6,6 +6,7 @@ from app.domain.errors import DomainError from app.entrypoint.commands import greet as greet_cmd +from app.entrypoint.wiring import build_dependencies app = typer.Typer( name="app", @@ -16,8 +17,9 @@ @app.callback() -def _root() -> None: - """Punto de entrada raiz (placeholder para flags globales futuros).""" +def _root(ctx: typer.Context) -> None: + """Construye las dependencias una sola vez (composition root).""" + ctx.obj = build_dependencies() app.command(name="greet")(greet_cmd.run) diff --git a/python-cli-hexagonal/src/app/entrypoint/commands/greet.py b/python-cli-hexagonal/src/app/entrypoint/commands/greet.py index b7b3275..da42c4a 100644 --- a/python-cli-hexagonal/src/app/entrypoint/commands/greet.py +++ b/python-cli-hexagonal/src/app/entrypoint/commands/greet.py @@ -4,18 +4,19 @@ import typer -from app.domain.models import GreetingRequest -from app.domain.use_cases import greet -from app.entrypoint.wiring import build_greeter +from app.domain.model.greeting.models import GreetingRequest +from app.domain.usecase.greet.use_case import greet +from app.entrypoint.wiring import Dependencies def run( + ctx: typer.Context, name: Annotated[ str, typer.Option("--name", "-n", help="Nombre del destinatario."), ] = "Mundo", ) -> None: """Saluda al destinatario indicado.""" - greeter = build_greeter() - request = GreetingRequest(name=name) - greet(request, greeter) + deps: Dependencies = ctx.obj + greeting = greet(GreetingRequest(name=name)) + deps.greeter.deliver(greeting) diff --git a/python-cli-hexagonal/src/app/entrypoint/wiring.py b/python-cli-hexagonal/src/app/entrypoint/wiring.py index 33a768d..7954bab 100644 --- a/python-cli-hexagonal/src/app/entrypoint/wiring.py +++ b/python-cli-hexagonal/src/app/entrypoint/wiring.py @@ -1,13 +1,31 @@ -"""Composition root: construye instancias de puertos concretas. +"""Composition root: construye las dependencias concretas una sola vez. -Centralizar el wiring aqui permite cambiar adapters (por ejemplo, swap a -un adapter de logging o de tests) modificando un unico archivo. +`build_dependencies` es el unico lugar donde se eligen e instancian los +adapters. Cambiar un adapter (por ejemplo, swap a uno de logging o de +tests) se hace modificando este unico archivo. """ +from pydantic import BaseModel, ConfigDict + from app.adapters.console_greeter import ConsoleGreeter -from app.domain.ports import GreeterPort +from app.domain.model.greeting.gateways import GreeterPort + + +class Dependencies(BaseModel): + """Contenedor de puertos concretos resueltos para la ejecucion. + + Es un modelo pydantic inmutable: valida en construccion que cada + puerto inyectado cumpla su `Protocol` (via `runtime_checkable`). + + Attributes: + greeter: Implementacion de `GreeterPort` para entregar saludos. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + greeter: GreeterPort -def build_greeter() -> GreeterPort: - """Devuelve la implementacion por defecto de `GreeterPort`.""" - return ConsoleGreeter() +def build_dependencies() -> Dependencies: + """Resuelve e instancia las dependencias por defecto de la app.""" + return Dependencies(greeter=ConsoleGreeter()) diff --git a/python-cli-hexagonal/tach.toml b/python-cli-hexagonal/tach.toml index 7a27f22..9bd2b47 100644 --- a/python-cli-hexagonal/tach.toml +++ b/python-cli-hexagonal/tach.toml @@ -7,10 +7,18 @@ forbid_circular_dependencies = true path = "app.domain" depends_on = [] +[[modules]] +path = "app.domain.model" +depends_on = [] + +[[modules]] +path = "app.domain.usecase" +depends_on = ["app.domain.model", "app.domain"] + [[modules]] path = "app.adapters" -depends_on = ["app.domain"] +depends_on = ["app.domain.model"] [[modules]] path = "app.entrypoint" -depends_on = ["app.adapters", "app.domain"] +depends_on = ["app.adapters", "app.domain.model", "app.domain.usecase", "app.domain"] diff --git a/python-cli-hexagonal/tests/conftest.py b/python-cli-hexagonal/tests/conftest.py index 3abe0ed..9b8d9bc 100644 --- a/python-cli-hexagonal/tests/conftest.py +++ b/python-cli-hexagonal/tests/conftest.py @@ -4,7 +4,7 @@ import pytest -from app.domain.models import Greeting +from app.domain.model.greeting.models import Greeting @dataclass diff --git a/python-cli-hexagonal/tests/unit/test_delivery.py b/python-cli-hexagonal/tests/unit/test_delivery.py new file mode 100644 index 0000000..f27d63f --- /dev/null +++ b/python-cli-hexagonal/tests/unit/test_delivery.py @@ -0,0 +1,16 @@ +"""Test del patron de entrega via el `GreeterPort` con un fake adapter. + +El caso de uso ya no entrega el saludo (es puro); la entrega ocurre en el +borde. Este test ejercita ese contrato con `FakeGreeter`. +""" + +from app.domain.model.greeting.models import GreetingRequest +from app.domain.usecase.greet.use_case import greet + + +def test_greeting_is_delivered_through_port(fake_greeter): + greeting = greet(GreetingRequest(name="Ada")) + + fake_greeter.deliver(greeting) + + assert fake_greeter.delivered == [greeting] diff --git a/python-cli-hexagonal/tests/unit/test_use_cases.py b/python-cli-hexagonal/tests/unit/test_use_cases.py index cba544d..a3677f2 100644 --- a/python-cli-hexagonal/tests/unit/test_use_cases.py +++ b/python-cli-hexagonal/tests/unit/test_use_cases.py @@ -1,27 +1,24 @@ -"""Tests unitarios del use case `greet` con un fake adapter.""" +"""Tests unitarios del caso de uso puro `greet`.""" import pytest -from app.domain.errors import EmptyNameError -from app.domain.models import GreetingRequest -from app.domain.use_cases import greet +from app.domain.model.greeting.models import Greeting, GreetingRequest +from app.domain.usecase.greet.errors import EmptyNameError +from app.domain.usecase.greet.use_case import greet -def test_greet_delivers_message(fake_greeter): - result = greet(GreetingRequest(name="Ada"), fake_greeter) +def test_greet_builds_message(): + result = greet(GreetingRequest(name="Ada")) + + assert result == Greeting(message="Hola, Ada!") - assert result.message == "Hola, Ada!" - assert len(fake_greeter.delivered) == 1 - assert fake_greeter.delivered[0].message == "Hola, Ada!" +def test_greet_trims_whitespace(): + result = greet(GreetingRequest(name=" Ada ")) -def test_greet_trims_whitespace(fake_greeter): - result = greet(GreetingRequest(name=" Ada "), fake_greeter) assert result.message == "Hola, Ada!" -def test_greet_rejects_empty_name(fake_greeter): +def test_greet_rejects_empty_name(): with pytest.raises(EmptyNameError): - greet(GreetingRequest(name=" "), fake_greeter) - - assert fake_greeter.delivered == [] + greet(GreetingRequest(name=" ")) diff --git a/python-cli-hexagonal/tests/unit/test_wiring.py b/python-cli-hexagonal/tests/unit/test_wiring.py new file mode 100644 index 0000000..7fa06eb --- /dev/null +++ b/python-cli-hexagonal/tests/unit/test_wiring.py @@ -0,0 +1,25 @@ +"""Tests del composition root `Dependencies` (modelo pydantic).""" + +import pytest +from pydantic import ValidationError + +from app.entrypoint.wiring import Dependencies, build_dependencies + + +def test_build_dependencies_returns_valid_container(): + deps = build_dependencies() + + assert hasattr(deps.greeter, "deliver") + + +def test_dependencies_rejects_non_conforming_greeter(): + with pytest.raises(ValidationError): + # Tipo invalido a proposito: validamos el rechazo en runtime. + Dependencies(greeter=object()) # ty: ignore[invalid-argument-type] + + +def test_dependencies_is_frozen(fake_greeter): + deps = Dependencies(greeter=fake_greeter) + + with pytest.raises(ValidationError): + deps.greeter = fake_greeter diff --git a/python-cli-hexagonal/uv.lock b/python-cli-hexagonal/uv.lock index 61babf3..3a4fbbe 100644 --- a/python-cli-hexagonal/uv.lock +++ b/python-cli-hexagonal/uv.lock @@ -11,11 +11,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "app" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "pydantic" }, { name = "typer" }, ] @@ -26,10 +36,14 @@ dev = [ { name = "pytest-cov" }, { name = "ruff" }, { name = "tach" }, + { name = "ty" }, ] [package.metadata] -requires-dist = [{ name = "typer", specifier = ">=0.12" }] +requires-dist = [ + { name = "pydantic", specifier = ">=2.0" }, + { name = "typer", specifier = ">=0.12" }, +] [package.metadata.requires-dev] dev = [ @@ -38,6 +52,7 @@ dev = [ { name = "pytest-cov", specifier = ">=5.0" }, { name = "ruff", specifier = ">=0.6" }, { name = "tach", specifier = ">=0.20" }, + { name = "ty", specifier = ">=0.0.1a1" }, ] [[package]] @@ -281,6 +296,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + [[package]] name = "pydot" version = "4.0.1" @@ -524,6 +610,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "ty" +version = "0.0.40" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/f8/a754c96967b71de8723f88be17df8738216bd382ffed229cd500b7a24d13/ty-0.0.40.tar.gz", hash = "sha256:883b53dd98f6e5b33ab1c8e1a3cd94b0f29c762ef22cdf1e86aaffb4fd711c67", size = 5726484, upload-time = "2026-05-27T17:55:43.615Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/42/d029a72165ad39f95228b67355927fbd35c821dc8e3e475d49f47c2eeb1e/ty-0.0.40-py3-none-linux_armv6l.whl", hash = "sha256:9defb4742450e569a6a09de286a04008d6c2e815112da4362c88b6eaa2f52a36", size = 11406372, upload-time = "2026-05-27T17:55:49.633Z" }, + { url = "https://files.pythonhosted.org/packages/23/99/7f8ea09b7e49afbf795cb3341a3217f30f228db7e62a2268ed8cbbf813d6/ty-0.0.40-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:868258a3330db88b683fcafe2c4e936d6226a6312799bf15b585d93557b2d38c", size = 11159782, upload-time = "2026-05-27T17:55:47.405Z" }, + { url = "https://files.pythonhosted.org/packages/04/d8/1ea745ee97a98b26ae9564d19a430a76a35297cd450e84dcaad22e1f7ee8/ty-0.0.40-py3-none-macosx_11_0_arm64.whl", hash = "sha256:589c81060cf1e7a9ffa2f45bfa35ffd9b9fbd214104e3f13959f113627efcd91", size = 10594139, upload-time = "2026-05-27T17:55:37.206Z" }, + { url = "https://files.pythonhosted.org/packages/39/1a/fbef21273c6617ff4715b4827ee1c0b6550aa7d1df4b8c43b325545c1cf4/ty-0.0.40-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b06108990cb338d941c315ae6e9ba2fff8f518bc15d3f33e5619ff6a6c9beab", size = 11114156, upload-time = "2026-05-27T17:55:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/389fc4976d7ec016a7473cf1274bf9c4f491bb54c66649bd022bff9f2b6a/ty-0.0.40-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3913ef37336bec4f96bd2512f8c3a543ca34c259b7170f7eb5adf75b3ed7f04c", size = 11189050, upload-time = "2026-05-27T17:55:54.099Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a9/4ecabbf4bdda7df0d99d8d3892c6edac0efc8c4cae756a5109178a3d0e86/ty-0.0.40-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8fd1486bd5fe48779a8aa857137f3642a0a9161f5cf57d4380f4a0ecea01c8f3", size = 11664266, upload-time = "2026-05-27T17:55:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/02/0aa78730116507c265afb1d6d5961c583b49d4c2e368c4a49fd81bcae6dc/ty-0.0.40-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1668364d5254a734329917ee66c2c5fdd5665389d41043f6fce0f22ddb32b749", size = 12187743, upload-time = "2026-05-27T17:56:04.337Z" }, + { url = "https://files.pythonhosted.org/packages/e6/68/ccabf2d173523598271a385c1d3f864dbda23e5ebdc67f5969b9e830ea05/ty-0.0.40-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43f77a73edb91e5dfa2ab9af7c4cac64614f8cc121f38a8875f22e830d3aba6a", size = 11862999, upload-time = "2026-05-27T17:55:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/6d7ec22771bb23d534797cdb446eb644bccfe7a62b729bb99e7235a02fc3/ty-0.0.40-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1274ce0212ecbfed01bda7c3659c46e8bd0068e32d00c46c790466a95274c3df", size = 11743896, upload-time = "2026-05-27T17:56:00.017Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a4/f9fa076b010c91cb249b1fcc3476569b7b8462cb4b688da2d04c23a0622f/ty-0.0.40-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5ee1261dbc363e5cc1a0c5bb0c8612c192bfe53491214df8bc85a540835685f9", size = 11883581, upload-time = "2026-05-27T17:56:02.319Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0f/5b776a2328c756d574dd4d6afbd30fc24e1ab4b76935c7c3c23f27ebbcb9/ty-0.0.40-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6220e2cd5cdc4683dd87fb150d195bbd9f1a021395e04cb08bd3c66ea6da6ef8", size = 11093946, upload-time = "2026-05-27T17:55:33.284Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/eb23154bae83ad7c2935e9e5916660fb3e31598a92ee232aebd79410480c/ty-0.0.40-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:46b9ed69d01d98ef046afac9983c68336f572605ea2a27b90fbe6f80bfc8d6b7", size = 11210737, upload-time = "2026-05-27T17:55:45.523Z" }, + { url = "https://files.pythonhosted.org/packages/ff/19/1fb2529703f708cacfd13a89f98613cae2907dfa941b26976467e6119803/ty-0.0.40-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ddbca9fab4406260f141674ab5efcfe7b02bd468e6985e4cdde0a21626e69ffe", size = 11332563, upload-time = "2026-05-27T17:55:41.674Z" }, + { url = "https://files.pythonhosted.org/packages/87/69/b3f5a8ef26c31204e0391147b3adcdb0674eda3e7d99868478ef168a41c6/ty-0.0.40-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1fcc082a749e6dc11b68fe9aab0420238bbf2a2374c2c7aa3c22e8c1618b136", size = 11843216, upload-time = "2026-05-27T17:55:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e8/20193069d32787f3e1a6ec8940aaa3759d3de8f48f9281bcc0c5cb0939da/ty-0.0.40-py3-none-win32.whl", hash = "sha256:75feb115b3587824c5bdf8f8305e9547b0d1e398e3077b0addc7a1988ea9bb50", size = 10670731, upload-time = "2026-05-27T17:55:31.316Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f9/8b2aa4da61db81322d4a2f9db227afeb48110ca15ae31d380f64c64ceb63/ty-0.0.40-py3-none-win_amd64.whl", hash = "sha256:b0f905edaad788bd61f779a85801b60a267a25ed57fca05aaddd168d9d8896be", size = 11766211, upload-time = "2026-05-27T17:55:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/04/87/369056ed46f1b235130ec0595393262f9cd2061ca3dab276d490980f9343/ty-0.0.40-py3-none-win_arm64.whl", hash = "sha256:07da2b09d9130e2c9a257d2a29beb53105835b0256ee5fdb288fe1aab83fee47", size = 11117369, upload-time = "2026-05-27T17:55:39.329Z" }, +] + [[package]] name = "typer" version = "0.26.1" @@ -539,6 +650,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/27/8a22d4833fe8aa0836ce7fa59096ad50d7e93b83be6d5383f11f9a140d54/typer-0.26.1-py3-none-any.whl", hash = "sha256:933e4f0083521f3c57d6a5aedf3b073271b2f95a19761b171b494dd6fdb21ff6", size = 123097, upload-time = "2026-05-26T17:49:09.065Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "virtualenv" version = "21.3.3"