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 +23 -0
- yaaf/__main__.py +4 -0
- yaaf/app.py +109 -0
- yaaf/cli.py +45 -0
- yaaf/di.py +65 -0
- yaaf/gen_services.py +80 -0
- yaaf/loader.py +193 -0
- yaaf/py.typed +0 -0
- yaaf/responses.py +69 -0
- yaaf/types.py +44 -0
- yaafcli-2026.2.4.180929.dist-info/METADATA +206 -0
- yaafcli-2026.2.4.180929.dist-info/RECORD +16 -0
- yaafcli-2026.2.4.180929.dist-info/WHEEL +5 -0
- yaafcli-2026.2.4.180929.dist-info/entry_points.txt +2 -0
- yaafcli-2026.2.4.180929.dist-info/licenses/LICENSE +21 -0
- yaafcli-2026.2.4.180929.dist-info/top_level.txt +1 -0
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
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,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
|