yaafcli 2026.2.4.180929__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
yaaf/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """yaaf package exports with lazy loading to avoid circular imports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from typing import Any
7
+
8
+ __all__ = ["App", "Request", "Response", "app"]
9
+
10
+
11
+ def __getattr__(name: str) -> Any:
12
+ if name in {"App", "Request", "app"}:
13
+ app_module = importlib.import_module(f"{__name__}.app")
14
+ return getattr(app_module, name)
15
+ if name == "Response":
16
+ from .responses import Response
17
+
18
+ return Response
19
+ raise AttributeError(f"module {__name__} has no attribute {name}")
20
+
21
+
22
+ def __dir__() -> list[str]:
23
+ return sorted(__all__)
yaaf/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
yaaf/app.py ADDED
@@ -0,0 +1,109 @@
1
+ """Filesystem-routed ASGI app for yaaf."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from .di import DependencyResolver
10
+ from .loader import discover_routes
11
+ from .responses import Response, as_response
12
+ from .types import ASGIScope, ASGIReceive, ASGISend, Params
13
+
14
+
15
+ @dataclass
16
+ class Request:
17
+ """Represents an HTTP request within the ASGI app."""
18
+ scope: ASGIScope
19
+ body: bytes
20
+ path_params: Params
21
+
22
+ @property
23
+ def method(self) -> str:
24
+ """Return the HTTP method."""
25
+ return self.scope.get("method", "").upper()
26
+
27
+ @property
28
+ def path(self) -> str:
29
+ """Return the request path."""
30
+ return self.scope.get("path", "")
31
+
32
+ @property
33
+ def headers(self) -> dict[str, str]:
34
+ """Return decoded request headers."""
35
+ raw = self.scope.get("headers", [])
36
+ return {k.decode(): v.decode() for k, v in raw}
37
+
38
+ def text(self) -> str:
39
+ """Return the request body as text."""
40
+ return self.body.decode()
41
+
42
+
43
+ class App:
44
+ """Filesystem-routed ASGI interface."""
45
+
46
+ def __init__(self, consumers_dir: str = "consumers") -> None:
47
+ """Initialize the app by discovering filesystem routes."""
48
+ self._consumers_dir = consumers_dir
49
+ self._routes = None
50
+ self._registry = None
51
+ self._resolver = None
52
+
53
+ def _ensure_routes(self) -> None:
54
+ if self._routes is None or self._registry is None or self._resolver is None:
55
+ self._routes, self._registry = discover_routes(self._consumers_dir)
56
+ self._resolver = DependencyResolver(self._registry)
57
+
58
+ async def __call__(self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None:
59
+ """ASGI entrypoint."""
60
+ self._ensure_routes()
61
+ if scope.get("type") != "http":
62
+ response = Response.text("Unsupported scope type", status=500)
63
+ await response.send(send)
64
+ return
65
+
66
+ method = scope.get("method", "").upper()
67
+ path = scope.get("path", "")
68
+ match = None
69
+ for route in self._routes or []:
70
+ if method not in route.handlers:
71
+ continue
72
+ result = route.pattern.match(path)
73
+ if result is None:
74
+ continue
75
+ match = (route, result.groups())
76
+ break
77
+
78
+ if match is None:
79
+ response = Response.text("Not Found", status=404)
80
+ await response.send(send)
81
+ return
82
+ route, groups = match
83
+
84
+ body = b""
85
+ more_body = True
86
+ while more_body:
87
+ message = await receive()
88
+ if message.get("type") != "http.request":
89
+ break
90
+ body += message.get("body", b"")
91
+ more_body = message.get("more_body", False)
92
+
93
+ path_params = dict(zip(route.param_names, groups))
94
+ request = Request(scope=scope, body=body, path_params=path_params)
95
+ handler = route.handlers[method]
96
+ context = {
97
+ "request": request,
98
+ "params": path_params,
99
+ "path_params": path_params,
100
+ }
101
+ result = self._resolver.call(handler, context)
102
+ if inspect.isawaitable(result):
103
+ result = await result
104
+
105
+ response = as_response(result)
106
+ await response.send(send)
107
+
108
+
109
+ app = App()
yaaf/cli.py ADDED
@@ -0,0 +1,45 @@
1
+ """Command-line entrypoint for running the ASGI app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import uvicorn
8
+
9
+ from .gen_services import generate_services
10
+
11
+
12
+ def main() -> None:
13
+ """CLI entrypoint for running a yaaf ASGI app."""
14
+ parser = argparse.ArgumentParser(prog="yaaf", description="Run a yaaf ASGI app")
15
+ subparsers = parser.add_subparsers(dest="command")
16
+ parser.set_defaults(command="serve")
17
+
18
+ serve_parser = subparsers.add_parser("serve", help="Run the ASGI server")
19
+ serve_parser.add_argument("--app", default="yaaf.app:app", help="ASGI app path, e.g. module:app")
20
+ serve_parser.add_argument("--host", default="127.0.0.1")
21
+ serve_parser.add_argument("--port", default=8000, type=int)
22
+ serve_parser.add_argument("--reload", action="store_true")
23
+ serve_parser.add_argument("--consumers-dir", default="consumers")
24
+ serve_parser.set_defaults(command="serve")
25
+
26
+ gen_parser = subparsers.add_parser("gen-services", help="Generate consumers/services.py")
27
+ gen_parser.add_argument("--consumers-dir", default="consumers")
28
+ gen_parser.add_argument("--output", default=None)
29
+ gen_parser.set_defaults(command="gen-services")
30
+
31
+ if len(sys.argv) > 1 and sys.argv[1] == "gen-services":
32
+ args = parser.parse_args()
33
+ else:
34
+ args = parser.parse_args(["serve", *sys.argv[1:]])
35
+
36
+ if args.command == "gen-services":
37
+ generate_services(consumers_dir=args.consumers_dir, output_path=args.output)
38
+ return
39
+
40
+ generate_services(consumers_dir=args.consumers_dir)
41
+ uvicorn.run(args.app, host=args.host, port=args.port, reload=args.reload)
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
yaaf/di.py ADDED
@@ -0,0 +1,65 @@
1
+ """Simple dependency injection for yaaf services and handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from dataclasses import dataclass
7
+ from collections.abc import Mapping
8
+ from typing import Any, Callable, TypeVar
9
+
10
+ T = TypeVar("T")
11
+
12
+ @dataclass
13
+ class ServiceRegistry:
14
+ """Global registry for services, keyed by type and name variants."""
15
+ by_type: dict[type[Any], Any]
16
+ by_alias: dict[str, Any]
17
+
18
+ def register(self, instance: T, aliases: list[str]) -> T:
19
+ """Register a service instance by type and alias names."""
20
+ self.by_type[type(instance)] = instance
21
+ type_name = type(instance).__name__
22
+ if type_name:
23
+ self.by_alias[type_name] = instance
24
+ for alias in aliases:
25
+ self.by_alias[alias] = instance
26
+ return instance
27
+
28
+ def resolve(self, annotation: type | None) -> Any | None:
29
+ """Resolve a service by type annotation."""
30
+ if isinstance(annotation, str):
31
+ return self.by_alias.get(annotation)
32
+ if annotation and annotation in self.by_type:
33
+ return self.by_type[annotation]
34
+ if annotation is not None:
35
+ alias = getattr(annotation, "__name__", "")
36
+ if alias and alias in self.by_alias:
37
+ return self.by_alias[alias]
38
+ return None
39
+
40
+
41
+ class DependencyResolver:
42
+ """Resolve function arguments from a registry and contextual values."""
43
+ def __init__(self, registry: ServiceRegistry) -> None:
44
+ """Create a resolver bound to a service registry."""
45
+ self.registry = registry
46
+
47
+ def call(self, func: Callable[..., Any], context: Mapping[str, Any]) -> Any:
48
+ """Call a function, injecting dependencies from context or registry."""
49
+ signature = inspect.signature(func)
50
+ kwargs: dict[str, Any] = {}
51
+ for name, param in signature.parameters.items():
52
+ if name in context:
53
+ kwargs[name] = context[name]
54
+ continue
55
+ annotation = None
56
+ if param.annotation is not inspect._empty:
57
+ annotation = param.annotation
58
+ resolved = self.registry.resolve(annotation)
59
+ if resolved is not None:
60
+ kwargs[name] = resolved
61
+ continue
62
+ if param.default is not inspect._empty:
63
+ continue
64
+ raise TypeError(f"Cannot resolve dependency '{name}' for {func}")
65
+ return func(**kwargs)
yaaf/gen_services.py ADDED
@@ -0,0 +1,80 @@
1
+ """Generate a consumers/services.py typing module from filesystem routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Iterable, Protocol
7
+
8
+ HEADER = """\
9
+ \"\"\"Generated service type aliases for consumers.\n\nDo not edit by hand. Regenerate via `yaaf` CLI.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Any, Protocol, TYPE_CHECKING\n\n"""
10
+
11
+
12
+ def _is_identifier(segment: str) -> bool:
13
+ return segment.isidentifier()
14
+
15
+
16
+ def _camel_case(parts: Iterable[str]) -> str:
17
+ return "".join(part[:1].upper() + part[1:] for part in parts if part)
18
+
19
+
20
+ def _strip_dynamic(segment: str) -> str:
21
+ if segment.startswith("[") and segment.endswith("]"):
22
+ return segment[1:-1]
23
+ return segment
24
+
25
+
26
+ def _service_alias(route_parts: list[str]) -> str:
27
+ normalized = [_strip_dynamic(part) for part in route_parts]
28
+ safe_parts = [part for part in normalized if _is_identifier(part)]
29
+ base = _camel_case(safe_parts) or "Route"
30
+ return f"{base}Service"
31
+
32
+
33
+ def generate_services(consumers_dir: str = "consumers", output_path: str | None = None) -> Path:
34
+ base = Path(consumers_dir)
35
+ out_path = Path(output_path) if output_path else base / "services.py"
36
+
37
+ if not base.exists():
38
+ out_path.write_text(HEADER + "\n__all__ = []\n")
39
+ return out_path
40
+
41
+ aliases: list[tuple[str, str]] = []
42
+ dynamic_aliases: list[str] = []
43
+ for root in base.rglob("_service.py"):
44
+ if "api" not in root.parts:
45
+ continue
46
+ api_index = root.parts.index("api")
47
+ route_parts = list(root.parts[api_index + 1 : -1])
48
+ if any(part.startswith("[") and part.endswith("]") for part in route_parts):
49
+ dynamic_aliases.append(_service_alias(route_parts))
50
+ continue
51
+ if not all(_is_identifier(part) for part in route_parts):
52
+ continue
53
+ module_path = ".".join([base.name, "api", *route_parts, "_service"])
54
+ alias = _service_alias(route_parts)
55
+ aliases.append((alias, module_path))
56
+
57
+ aliases.sort(key=lambda item: item[0].lower())
58
+
59
+ lines = [HEADER]
60
+ lines.append("if TYPE_CHECKING:\n")
61
+ if aliases:
62
+ for alias, module_path in aliases:
63
+ lines.append(f" from {module_path} import Service as {alias}\n")
64
+ else:
65
+ lines.append(" pass\n")
66
+ lines.append("else:\n")
67
+ for alias, _ in aliases:
68
+ lines.append(f" class {alias}:\n ...\n")
69
+
70
+ for alias in dynamic_aliases:
71
+ lines.append(f"\nclass {alias}(Protocol):\n ...\n")
72
+
73
+ exports = ", ".join([f"'{alias}'" for alias, _ in aliases] + [f"'{alias}'" for alias in dynamic_aliases])
74
+ lines.append(f"\n__all__ = [{exports}]\n")
75
+
76
+ if dynamic_aliases:
77
+ lines.append("\n# Dynamic routes use Protocol stubs (invalid import paths).\n")
78
+
79
+ out_path.write_text("".join(lines))
80
+ return out_path
yaaf/loader.py ADDED
@@ -0,0 +1,193 @@
1
+ """Filesystem route discovery and module loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import os
7
+ import re
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from types import ModuleType
12
+ from typing import Any
13
+
14
+ from .di import DependencyResolver, ServiceRegistry
15
+ from .types import Handler
16
+
17
+
18
+ @dataclass
19
+ class RouteTarget:
20
+ """A discovered filesystem route and its handlers/services."""
21
+ pattern: re.Pattern[str]
22
+ route_parts: list[str]
23
+ param_names: list[str]
24
+ handlers: dict[str, Handler]
25
+ services: ServiceRegistry
26
+ service: Any | None
27
+ static_count: int
28
+ segment_count: int
29
+
30
+
31
+ def _load_module(path: Path, name_prefix: str) -> ModuleType:
32
+ """Load a Python module from an explicit file path."""
33
+ module_name = f"yaaf_{name_prefix}_{abs(hash(path))}"
34
+ spec = importlib.util.spec_from_file_location(module_name, path)
35
+ if spec is None or spec.loader is None:
36
+ raise ImportError(f"Could not load module from {path}")
37
+ module = importlib.util.module_from_spec(spec)
38
+ spec.loader.exec_module(module)
39
+ return module
40
+
41
+
42
+ def _collect_services(module: ModuleType, resolver: DependencyResolver) -> Any | None:
43
+ """Create a service from a module, if it exposes one."""
44
+ if hasattr(module, "service"):
45
+ instance = getattr(module, "service")
46
+ if instance is not None:
47
+ if callable(instance):
48
+ return resolver.call(instance, {})
49
+ return instance
50
+ if hasattr(module, "get_service") and callable(module.get_service):
51
+ return resolver.call(module.get_service, {})
52
+ if hasattr(module, "Service"):
53
+ service_cls = module.Service
54
+ if callable(service_cls):
55
+ return resolver.call(service_cls, {})
56
+ return None
57
+
58
+
59
+ def discover_routes(consumers_dir: str) -> tuple[list[RouteTarget], ServiceRegistry]:
60
+ """Discover route handlers and services rooted under a consumers directory."""
61
+ base = Path(consumers_dir)
62
+ if not base.exists():
63
+ return [], ServiceRegistry(by_type={}, by_alias={})
64
+
65
+ base_parent = str(base.parent)
66
+ if base_parent not in sys.path:
67
+ sys.path.insert(0, base_parent)
68
+
69
+ targets: list[tuple[Path, list[str], list[str], str, int, int]] = []
70
+ service_modules: dict[Path, ModuleType] = {}
71
+ server_modules: dict[Path, ModuleType] = {}
72
+ service_aliases: dict[Path, list[str]] = {}
73
+
74
+ for root, _dirs, files in os.walk(base):
75
+ if "_server.py" not in files or "_service.py" not in files:
76
+ continue
77
+
78
+ root_path = Path(root)
79
+ parts = root_path.parts
80
+ if "api" not in parts:
81
+ continue
82
+ api_index = parts.index("api")
83
+ route_parts = list(parts[api_index + 1 :])
84
+ pattern, param_names, static_count, segment_count = build_pattern(route_parts, prefix="api")
85
+ targets.append((root_path, route_parts, param_names, pattern, static_count, segment_count))
86
+
87
+ route_key = "_".join(route_parts)
88
+ aliases = [route_key, _service_alias(route_parts)]
89
+ if route_parts:
90
+ aliases.append(route_parts[-1])
91
+ service_aliases[root_path] = [alias for alias in aliases if alias]
92
+
93
+ service_modules[root_path] = _load_module(root_path / "_service.py", "service")
94
+ server_modules[root_path] = _load_module(root_path / "_server.py", "server")
95
+
96
+ registry = ServiceRegistry(by_type={}, by_alias={})
97
+ resolver = DependencyResolver(registry)
98
+
99
+ service_instances: dict[Path, Any] = {}
100
+ unresolved = list(service_modules.items())
101
+ while unresolved:
102
+ progress = False
103
+ remaining: list[tuple[Path, ModuleType]] = []
104
+ for path, module in unresolved:
105
+ try:
106
+ instance = _collect_services(module, resolver)
107
+ except TypeError:
108
+ instance = None
109
+ if instance is None:
110
+ remaining.append((path, module))
111
+ continue
112
+ service_instances[path] = instance
113
+ registry.register(instance, aliases=service_aliases.get(path, []))
114
+ progress = True
115
+ if not progress:
116
+ missing = [str(path) for path, _ in remaining]
117
+ raise RuntimeError(f"Unresolved service dependencies in: {', '.join(missing)}")
118
+ unresolved = remaining
119
+
120
+ routes: list[RouteTarget] = []
121
+ for root_path, route_parts, param_names, pattern, static_count, segment_count in targets:
122
+ server_module = server_modules[root_path]
123
+ handlers: dict[str, Handler] = {}
124
+ for method in ("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"):
125
+ func = getattr(server_module, method.lower(), None)
126
+ if callable(func):
127
+ handlers[method] = func
128
+ compiled = re.compile(pattern)
129
+ service_instance = service_instances.get(root_path)
130
+ routes.append(
131
+ RouteTarget(
132
+ pattern=compiled,
133
+ route_parts=route_parts,
134
+ param_names=param_names,
135
+ handlers=handlers,
136
+ services=registry,
137
+ service=service_instance,
138
+ static_count=static_count,
139
+ segment_count=segment_count,
140
+ )
141
+ )
142
+
143
+ routes.sort(key=lambda route: (route.static_count, route.segment_count), reverse=True)
144
+
145
+ static_routes = [route for route in routes if not route.param_names]
146
+ dynamic_routes = [route for route in routes if route.param_names]
147
+ for dyn in dynamic_routes:
148
+ for stat in static_routes:
149
+ if dyn.segment_count != stat.segment_count:
150
+ continue
151
+ candidate = "/api/" + "/".join(stat.route_parts)
152
+ if dyn.pattern.match(candidate):
153
+ print(
154
+ f"Warning: dynamic route /api/{'/'.join(dyn.route_parts)} matches "
155
+ f"static route /api/{'/'.join(stat.route_parts)}"
156
+ )
157
+ break
158
+ return routes, registry
159
+
160
+
161
+ def build_pattern(route_parts: list[str], prefix: str) -> tuple[str, list[str], int, int]:
162
+ """Build a regex pattern and metadata for a route path."""
163
+ if not route_parts:
164
+ return rf"^/{re.escape(prefix)}$", [], 0, 0
165
+
166
+ param_names: list[str] = []
167
+ pattern_parts: list[str] = []
168
+ static_count = 0
169
+ for part in route_parts:
170
+ if part.startswith("[") and part.endswith("]"):
171
+ name = part[1:-1]
172
+ if not name:
173
+ raise ValueError("Empty dynamic route segment")
174
+ param_names.append(name)
175
+ pattern_parts.append(r"([^/]+)")
176
+ else:
177
+ pattern_parts.append(re.escape(part))
178
+ static_count += 1
179
+
180
+ pattern = "^/" + re.escape(prefix) + "/" + "/".join(pattern_parts) + "$"
181
+ return pattern, param_names, static_count, len(route_parts)
182
+
183
+
184
+ def _service_alias(route_parts: list[str]) -> str:
185
+ def strip_dynamic(segment: str) -> str:
186
+ if segment.startswith("[") and segment.endswith("]"):
187
+ return segment[1:-1]
188
+ return segment
189
+
190
+ parts = [strip_dynamic(part) for part in route_parts]
191
+ safe = [part for part in parts if part.isidentifier()]
192
+ base = "".join(part[:1].upper() + part[1:] for part in safe) or "Route"
193
+ return f"{base}Service"
yaaf/py.typed ADDED
File without changes
yaaf/responses.py ADDED
@@ -0,0 +1,69 @@
1
+ """Response helpers for yaaf."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import Any, Iterable, Tuple
8
+
9
+ from .types import ASGISend
10
+
11
+
12
+ @dataclass
13
+ class Response:
14
+ """A minimal HTTP response object for ASGI."""
15
+ body: bytes
16
+ status: int = 200
17
+ headers: list[tuple[bytes, bytes]] | None = None
18
+
19
+ @classmethod
20
+ def text(cls, content: str, status: int = 200, headers: Iterable[Tuple[str, str]] | None = None) -> "Response":
21
+ """Create a text/plain response."""
22
+ return cls._with_type(content.encode(), "text/plain; charset=utf-8", status, headers)
23
+
24
+ @classmethod
25
+ def json(cls, content: Any, status: int = 200, headers: Iterable[Tuple[str, str]] | None = None) -> "Response":
26
+ """Create an application/json response."""
27
+ payload = json.dumps(content, ensure_ascii=True).encode()
28
+ return cls._with_type(payload, "application/json", status, headers)
29
+
30
+ @classmethod
31
+ def _with_type(
32
+ cls,
33
+ body: bytes,
34
+ media_type: str,
35
+ status: int,
36
+ headers: Iterable[Tuple[str, str]] | None,
37
+ ) -> "Response":
38
+ """Create a response and attach content-type + length headers."""
39
+ base_headers = [("content-type", media_type)]
40
+ if headers:
41
+ base_headers.extend(headers)
42
+ encoded = [(k.encode(), v.encode()) for k, v in base_headers]
43
+ encoded.append((b"content-length", str(len(body)).encode()))
44
+ return cls(body=body, status=status, headers=encoded)
45
+
46
+ async def send(self, send: ASGISend) -> None:
47
+ """Send the response through an ASGI send callable."""
48
+ await send({"type": "http.response.start", "status": self.status, "headers": self.headers or []})
49
+ await send({"type": "http.response.body", "body": self.body})
50
+
51
+ def with_status(self, status: int) -> "Response":
52
+ """Return a new response with a different status code."""
53
+ return Response(body=self.body, status=status, headers=self.headers)
54
+
55
+
56
+ def as_response(value: Any) -> Response:
57
+ """Normalize handler return values into a Response."""
58
+ if isinstance(value, Response):
59
+ return value
60
+ if isinstance(value, bytes):
61
+ return Response._with_type(value, "application/octet-stream", 200, None)
62
+ if isinstance(value, str):
63
+ return Response.text(value)
64
+ if isinstance(value, tuple) and len(value) == 2:
65
+ body, status = value
66
+ return as_response(body).with_status(int(status))
67
+ if isinstance(value, (dict, list)):
68
+ return Response.json(value)
69
+ return Response.text(str(value))
yaaf/types.py ADDED
@@ -0,0 +1,44 @@
1
+ """Shared type aliases and protocols for yaaf."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Awaitable, Callable, Protocol, TYPE_CHECKING, TypeAlias, TypeVar
6
+
7
+ if TYPE_CHECKING:
8
+ from .responses import Response
9
+ from .app import Request
10
+
11
+ ASGIScope: TypeAlias = dict[str, Any]
12
+ ASGIReceive: TypeAlias = Callable[[], Awaitable[dict[str, Any]]]
13
+ ASGISend: TypeAlias = Callable[[dict[str, Any]], Awaitable[None]]
14
+
15
+ Params: TypeAlias = dict[str, str]
16
+ ResponseLike: TypeAlias = "Response | str | bytes | dict[str, Any] | list[Any] | tuple[Any, int]"
17
+ Handler: TypeAlias = Callable[..., ResponseLike | Awaitable[ResponseLike]]
18
+
19
+
20
+ class ServiceProtocol(Protocol):
21
+ """Marker protocol for services registered with yaaf."""
22
+
23
+
24
+ ServiceT = TypeVar("ServiceT", bound=ServiceProtocol)
25
+
26
+
27
+ class ServerHandler(Protocol):
28
+ """Preferred handler signature for type checking."""
29
+
30
+ def __call__(
31
+ self,
32
+ request: "Request",
33
+ params: Params,
34
+ path_params: Params,
35
+ service: Any | None,
36
+ ) -> ResponseLike | Awaitable[ResponseLike]:
37
+ ...
38
+
39
+
40
+ class ServiceFactory(Protocol):
41
+ """Callable factory that builds a service with DI-injected args."""
42
+
43
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
44
+ ...
@@ -0,0 +1,206 @@
1
+ Metadata-Version: 2.4
2
+ Name: yaafcli
3
+ Version: 2026.2.4.180929
4
+ Summary: Minimal ASGI app scaffold
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Classifier: License :: OSI Approved :: MIT License
28
+ Classifier: Programming Language :: Python :: 3
29
+ Classifier: Programming Language :: Python :: 3.13
30
+ Classifier: Operating System :: OS Independent
31
+ Classifier: Framework :: AsyncIO
32
+ Classifier: Topic :: Internet :: WWW/HTTP
33
+ Classifier: Topic :: Software Development :: Libraries
34
+ Requires-Python: >=3.13
35
+ Description-Content-Type: text/markdown
36
+ License-File: LICENSE
37
+ Requires-Dist: uvicorn>=0.23
38
+ Provides-Extra: test
39
+ Requires-Dist: pytest>=7.4; extra == "test"
40
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
41
+ Dynamic: license-file
42
+
43
+ # yaaf
44
+
45
+ YAAF stands for "Yet Another ASGI Framework".
46
+
47
+ A minimal Python ASGI app scaffold that discovers routes from the filesystem. It includes a tiny router and a CLI wrapper around `uvicorn`.
48
+
49
+ ## Design Goals and Opinions
50
+
51
+ - **Filesystem-first routing.** Routes are inferred from the directory structure under `consumers/**/api` rather than declared with decorators. This keeps routing discoverable by looking at the tree.
52
+ - **Explicit endpoint files.** Each route has `_server.py` and `_service.py` to separate request handling from domain logic.
53
+ - **Dependency injection without wiring.** Services are registered automatically and injected by name/type, so handlers and services focus on behavior, not setup.
54
+ - **Static-first routing precedence.** Static routes always win over dynamic segments, with warnings when a dynamic route would overlap a static route.
55
+ - **Minimal core.** The framework is intentionally small and opinionated, leaving room for you to add auth, middleware, validation, etc.
56
+
57
+ ## Quickstart
58
+
59
+ ```bash
60
+ python -m venv .venv
61
+ source .venv/bin/activate
62
+ pip install -e .
63
+
64
+ # Run the built-in example routes
65
+ yaaf --reload
66
+ ```
67
+
68
+ Example routes:
69
+
70
+ - `GET /api/hello`
71
+ - `GET /api/<name>` (dynamic segment)
72
+
73
+ ## Routing Model
74
+
75
+ Routes are inferred from the directory structure under any `consumers/**/api` directory.
76
+
77
+ - Every route directory must contain `_server.py` and `_service.py`.
78
+ - The route path is `/api/...` plus the sub-path after `api/`.
79
+ - Dynamic segments use `[param]` directory names and are exposed as `params`/`path_params`.
80
+
81
+ Example layout:
82
+
83
+ ```text
84
+ consumers/
85
+ api/
86
+ users/
87
+ _server.py
88
+ _service.py
89
+ hello/
90
+ _server.py
91
+ _service.py
92
+ [name]/
93
+ _server.py
94
+ _service.py
95
+ ```
96
+
97
+ ## Handlers and Services
98
+
99
+ In `_server.py`, export functions named after HTTP methods (lowercase): `get`, `post`, etc. The function signature is resolved via dependency injection:
100
+
101
+ - `request` gives you the `yaaf.Request` object.
102
+ - `params` or `path_params` provides dynamic route parameters.
103
+ - Services are injected by type annotations.
104
+
105
+ Example `_server.py`:
106
+
107
+ ```python
108
+ from consumers.services import HelloService
109
+ from yaaf import Request
110
+ from yaaf.types import Params
111
+
112
+
113
+ async def get(request: Request, service: HelloService, params: Params):
114
+ return {"message": service.message(), "path": request.path, "params": params}
115
+ ```
116
+
117
+ In `_service.py`, expose a module-level `service` instance (or a callable like `Service` or `get_service`). Services are registered and can be injected into other services or handlers:
118
+
119
+ ```python
120
+ from consumers.services import UsersService
121
+
122
+
123
+ class Service:
124
+ def __init__(self, users: UsersService) -> None:
125
+ self._users = users
126
+
127
+ def message(self) -> str:
128
+ user = self._users.get_user("1")
129
+ return f"Hello from yaaf, {user['name']}"
130
+
131
+ service = Service
132
+ ```
133
+
134
+ ## Service-to-Service Injection
135
+
136
+ Services can depend on other services via type annotations. Example layout:
137
+
138
+ ```text
139
+ consumers/
140
+ api/
141
+ users/
142
+ _service.py
143
+ _server.py
144
+ hello/
145
+ _service.py
146
+ _server.py
147
+ ```
148
+
149
+ `consumers/api/users/_service.py`
150
+ ```python
151
+ class Service:
152
+ def get_user(self, user_id: str) -> dict:
153
+ return {"id": user_id, "name": "Austin"}
154
+
155
+ service = Service()
156
+ ```
157
+
158
+ `consumers/api/hello/_service.py`
159
+ ```python
160
+ from yaaf.services import UsersService
161
+
162
+
163
+ class Service:
164
+ def __init__(self, users: UsersService) -> None:
165
+ self._users = users
166
+
167
+ def message(self) -> str:
168
+ user = self._users.get_user("1")
169
+ return f"Hello from yaaf, {user['name']}"
170
+
171
+ service = Service
172
+ ```
173
+
174
+ `consumers/api/hello/_server.py`
175
+ ```python
176
+ from consumers.services import HelloService
177
+ from yaaf import Request
178
+
179
+
180
+ async def get(request: Request, service: HelloService):
181
+ return {"message": service.message(), "path": request.path}
182
+ ```
183
+
184
+ ## Running Another App
185
+
186
+ ```bash
187
+ yaaf --app your_package.app:app
188
+ ```
189
+
190
+ ## Versioning
191
+
192
+ This project uses calendar-based versions with a timestamp (UTC). To bump the version:
193
+
194
+ ```bash
195
+ python scripts/bump_version.py
196
+ ```
197
+
198
+ ## Service Type Generation
199
+
200
+ Every `yaaf` command regenerates `consumers/services.py` for type-checking. You can also run it explicitly:
201
+
202
+ ```bash
203
+ yaaf gen-services
204
+ ```
205
+
206
+ Dynamic route segments like `[name]` get Protocol stubs in the generated file since they are not valid import paths.
@@ -0,0 +1,16 @@
1
+ yaaf/__init__.py,sha256=mH1F6chTA4kRIaYK-yA1E7pBMOyReKi3rbrMiqKTxUY,602
2
+ yaaf/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ yaaf/app.py,sha256=7YrPl2_OZ-8NUy1RvUEBAbSntcqlP_in-2TLrKmGooE,3339
4
+ yaaf/cli.py,sha256=X8YmeozhWjNtbEiyg_wTK75f_d-5LPKYkQmkU6v8dm0,1672
5
+ yaaf/di.py,sha256=ZrGNBswJT4a3shYLgqd6hhqWii1UmiVmZ93B-G2sHWE,2448
6
+ yaaf/gen_services.py,sha256=Gov7IlSLMG5Oo8RoRGHCbxHSsb_qecoh7IBZPWZKEe8,2869
7
+ yaaf/loader.py,sha256=OzN2J5xl_sWGyNEaPz2LcuSEGfRPWHAGAs7QoK2JUdY,7215
8
+ yaaf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ yaaf/responses.py,sha256=iXZ0vDYfGO-BEUBBsobCR4JfWLRx8zhwYD9taQ8Sat4,2616
10
+ yaaf/types.py,sha256=BAD3Wkjqi4NKxNz2bT1xggPx0jU5Sz_hOvsHXmGszig,1262
11
+ yaafcli-2026.2.4.180929.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
12
+ yaafcli-2026.2.4.180929.dist-info/METADATA,sha256=weCYQZBxNrbDD9H7gKcP0nGkBW2F84DN3aIiwpQ43iQ,6248
13
+ yaafcli-2026.2.4.180929.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
14
+ yaafcli-2026.2.4.180929.dist-info/entry_points.txt,sha256=dh9ijq3gnFIr46RFjPjGR48PHQMUxwHopj3b9JipCRQ,39
15
+ yaafcli-2026.2.4.180929.dist-info/top_level.txt,sha256=6WqEmcTdZROTeK4-7HzkB5h9JMBgs51otonH6vnjcAc,5
16
+ yaafcli-2026.2.4.180929.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ yaaf = yaaf.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ yaaf