nyxa 0.1.0__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.

Potentially problematic release.


This version of nyxa might be problematic. Click here for more details.

nyxa/__init__.py ADDED
@@ -0,0 +1,100 @@
1
+ """nyxa — FastAPI scaffolding CLI and shared runtime bases."""
2
+
3
+ from nyxa.abort import (
4
+ abort,
5
+ conflict,
6
+ forbidden,
7
+ gone,
8
+ not_found,
9
+ too_many_requests,
10
+ unauthorized,
11
+ validation_error,
12
+ )
13
+ from nyxa.auth_stubs import check, current_user, guest, user
14
+ from nyxa.container import (
15
+ Container,
16
+ ContainerError,
17
+ bind,
18
+ container,
19
+ has,
20
+ inject,
21
+ instance,
22
+ make,
23
+ resolve,
24
+ singleton,
25
+ )
26
+ from nyxa.context import ContextMiddleware, context
27
+ from nyxa.cors import configure_cors
28
+ from nyxa.errors import (
29
+ AppError,
30
+ ConflictError,
31
+ ForbiddenError,
32
+ GoneError,
33
+ NotFoundError,
34
+ TooManyRequestsError,
35
+ UnauthorizedError,
36
+ ValidationError,
37
+ )
38
+ from nyxa.events import EventDispatcher, dispatcher
39
+ from nyxa.helpers import clear_config_cache, config, env, settings
40
+ from nyxa.logging_config import configure_logging
41
+ from nyxa.providers import ServiceProvider
42
+ from nyxa.registry import register
43
+ from nyxa.requests import FormRequest
44
+ from nyxa.response import accepted, created, json_response, no_content, success
45
+ from nyxa.routing import Controller, Route, route
46
+
47
+ __all__ = [
48
+ "AppError",
49
+ "ConflictError",
50
+ "Container",
51
+ "ContainerError",
52
+ "ContextMiddleware",
53
+ "Controller",
54
+ "EventDispatcher",
55
+ "ForbiddenError",
56
+ "FormRequest",
57
+ "GoneError",
58
+ "NotFoundError",
59
+ "Route",
60
+ "ServiceProvider",
61
+ "TooManyRequestsError",
62
+ "UnauthorizedError",
63
+ "ValidationError",
64
+ "abort",
65
+ "accepted",
66
+ "bind",
67
+ "check",
68
+ "clear_config_cache",
69
+ "config",
70
+ "configure_cors",
71
+ "configure_logging",
72
+ "conflict",
73
+ "container",
74
+ "context",
75
+ "created",
76
+ "current_user",
77
+ "dispatcher",
78
+ "env",
79
+ "forbidden",
80
+ "gone",
81
+ "guest",
82
+ "has",
83
+ "inject",
84
+ "instance",
85
+ "json_response",
86
+ "make",
87
+ "no_content",
88
+ "not_found",
89
+ "register",
90
+ "resolve",
91
+ "route",
92
+ "settings",
93
+ "singleton",
94
+ "success",
95
+ "too_many_requests",
96
+ "unauthorized",
97
+ "user",
98
+ "validation_error",
99
+ ]
100
+ __version__ = "0.1.0"
nyxa/abort.py ADDED
@@ -0,0 +1,105 @@
1
+ """Abort helpers that raise :class:`~nyxa.errors.AppError` subclasses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, NoReturn
6
+
7
+ from nyxa.errors import (
8
+ AppError,
9
+ ConflictError,
10
+ ForbiddenError,
11
+ GoneError,
12
+ NotFoundError,
13
+ TooManyRequestsError,
14
+ UnauthorizedError,
15
+ ValidationError,
16
+ )
17
+
18
+
19
+ def unauthorized(message: str | None = None, *, code: str | None = None) -> NoReturn:
20
+ """Abort with HTTP 401 (:class:`~nyxa.errors.UnauthorizedError`).
21
+
22
+ Args:
23
+ message: Optional override for the default message.
24
+ code: Optional override for the default error code.
25
+ """
26
+ raise UnauthorizedError(message, code=code)
27
+
28
+
29
+ def forbidden(message: str | None = None, *, code: str | None = None) -> NoReturn:
30
+ """Abort with HTTP 403 (:class:`~nyxa.errors.ForbiddenError`).
31
+
32
+ Args:
33
+ message: Optional override for the default message.
34
+ code: Optional override for the default error code.
35
+ """
36
+ raise ForbiddenError(message, code=code)
37
+
38
+
39
+ def not_found(message: str | None = None, *, code: str | None = None) -> NoReturn:
40
+ """Abort with HTTP 404 (:class:`~nyxa.errors.NotFoundError`).
41
+
42
+ Args:
43
+ message: Optional override for the default message.
44
+ code: Optional override for the default error code.
45
+ """
46
+ raise NotFoundError(message, code=code)
47
+
48
+
49
+ def conflict(message: str | None = None, *, code: str | None = None) -> NoReturn:
50
+ """Abort with HTTP 409 (:class:`~nyxa.errors.ConflictError`).
51
+
52
+ Args:
53
+ message: Optional override for the default message.
54
+ code: Optional override for the default error code.
55
+ """
56
+ raise ConflictError(message, code=code)
57
+
58
+
59
+ def gone(message: str | None = None, *, code: str | None = None) -> NoReturn:
60
+ """Abort with HTTP 410 (:class:`~nyxa.errors.GoneError`).
61
+
62
+ Args:
63
+ message: Optional override for the default message.
64
+ code: Optional override for the default error code.
65
+ """
66
+ raise GoneError(message, code=code)
67
+
68
+
69
+ def validation_error(
70
+ errors: Any,
71
+ message: str | None = None,
72
+ *,
73
+ code: str | None = None,
74
+ ) -> NoReturn:
75
+ """Abort with HTTP 422 (:class:`~nyxa.errors.ValidationError`).
76
+
77
+ Args:
78
+ errors: Field-level validation details included in the JSON body.
79
+ message: Optional override for the default message.
80
+ code: Optional override for the default error code.
81
+ """
82
+ raise ValidationError(errors, message, code=code)
83
+
84
+
85
+ def too_many_requests(message: str | None = None, *, code: str | None = None) -> NoReturn:
86
+ """Abort with HTTP 429 (:class:`~nyxa.errors.TooManyRequestsError`).
87
+
88
+ Args:
89
+ message: Optional override for the default message.
90
+ code: Optional override for the default error code.
91
+ """
92
+ raise TooManyRequestsError(message, code=code)
93
+
94
+
95
+ def abort(status: int, message: str | None = None, *, code: str | None = None) -> NoReturn:
96
+ """Abort with an arbitrary HTTP status via :class:`~nyxa.errors.AppError`.
97
+
98
+ Args:
99
+ status: HTTP status code for the response.
100
+ message: Optional message (defaults to ``Application error``).
101
+ code: Optional error code (defaults to ``app_error``).
102
+ """
103
+ exc = AppError(message, code=code)
104
+ exc.status_code = status
105
+ raise exc
nyxa/app.py ADDED
@@ -0,0 +1,85 @@
1
+ """nyxa CLI entrypoints (`nyxa`, `artisan`, and `dev`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from importlib import import_module
7
+
8
+ import typer
9
+ import uvicorn
10
+
11
+ from nyxa import init_cmd, list_cmd
12
+ from nyxa.config import clear_settings_cache, load_settings
13
+
14
+ # ``nyxa.make`` CLI module (not the container ``make`` function exported from ``nyxa``).
15
+ make_cmd = import_module("nyxa.make")
16
+
17
+ app = typer.Typer(
18
+ name="nyxa",
19
+ help="Batteries-included scaffolding CLI for Python API backends on FastAPI.",
20
+ no_args_is_help=True,
21
+ add_completion=False,
22
+ )
23
+
24
+ init_cmd.register(app)
25
+ make_cmd.register(app)
26
+ list_cmd.register(app)
27
+
28
+ try:
29
+ from nyxa_db.cli import register as register_db
30
+
31
+ register_db(app)
32
+ except ImportError:
33
+ pass
34
+
35
+ try:
36
+ from nyxa_auth.cli import register as register_auth
37
+
38
+ register_auth(app)
39
+ except ImportError:
40
+ pass
41
+
42
+ try:
43
+ from nyxa_queue.cli import register as register_queue
44
+
45
+ register_queue(app)
46
+ except ImportError:
47
+ pass
48
+
49
+ try:
50
+ from nyxa_cache.cli import register as register_cache
51
+
52
+ register_cache(app)
53
+ except ImportError:
54
+ pass
55
+
56
+ try:
57
+ from nyxa_mail.cli import register as register_mail
58
+
59
+ register_mail(app)
60
+ except ImportError:
61
+ pass
62
+
63
+
64
+ def _clear_terminal() -> None:
65
+ """Clear the screen and scrollback buffer."""
66
+ sys.stdout.write("\033[2J\033[3J\033[H")
67
+ sys.stdout.flush()
68
+
69
+
70
+ def dev() -> None:
71
+ """Run the API with settings from optional `nyxa.toml`."""
72
+ clear_settings_cache()
73
+ settings = load_settings()
74
+ dev_cfg = settings.resolved_dev()
75
+ _clear_terminal()
76
+ uvicorn.run(
77
+ dev_cfg.module,
78
+ host=dev_cfg.host,
79
+ port=dev_cfg.port,
80
+ reload=dev_cfg.reload,
81
+ )
82
+
83
+
84
+ if __name__ == "__main__":
85
+ app()
nyxa/auth_stubs.py ADDED
@@ -0,0 +1,34 @@
1
+ """Auth convenience stubs (current user / id / check) over request context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from nyxa.context import USER_CONTEXT_KEY, context
8
+
9
+
10
+ def current_user() -> Any | None:
11
+ """Return the authenticated user from request context, or ``None`` if guest.
12
+
13
+ ``nyxa-auth`` writes the user into context when resolving JWT auth.
14
+ Apps without auth never set the key and always see a guest.
15
+
16
+ Returns:
17
+ User object stored under ``USER_CONTEXT_KEY``, or ``None``.
18
+ """
19
+ return context.get(USER_CONTEXT_KEY)
20
+
21
+
22
+ def user() -> Any | None:
23
+ """Alias of :func:`current_user`."""
24
+ return current_user()
25
+
26
+
27
+ def guest() -> bool:
28
+ """Return ``True`` when no authenticated user is in context."""
29
+ return current_user() is None
30
+
31
+
32
+ def check() -> bool:
33
+ """Return ``True`` when an authenticated user is in context."""
34
+ return current_user() is not None
nyxa/config.py ADDED
@@ -0,0 +1,277 @@
1
+ """Load optional project-root `nyxa.toml` with built-in defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass, field, replace
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ DEFAULT_PATHS: dict[str, str] = {
13
+ "errors": "http/exception/errors",
14
+ "handlers": "http/exception/handlers",
15
+ "requests": "http/requests",
16
+ "responses": "http/responses",
17
+ "middleware": "http/middleware",
18
+ "controllers": "http/controllers",
19
+ "dependencies": "http/dependencies",
20
+ "services": "services",
21
+ "schemas": "schemas",
22
+ "enums": "domain/enums",
23
+ "config": "config",
24
+ "events": "events",
25
+ "listeners": "listeners",
26
+ "jobs": "jobs",
27
+ "models": "models",
28
+ "policies": "policies",
29
+ "providers": "providers",
30
+ "tests": "tests",
31
+ }
32
+
33
+ DEFAULT_ERROR_BASE = "nyxa.errors:AppError"
34
+ DEFAULT_ENV_FILE = ".env"
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class DevSettings:
39
+ """Uvicorn settings for `nyxa` / `dev`."""
40
+
41
+ module: str
42
+ host: str = "0.0.0.0"
43
+ port: int = 8000
44
+ reload: bool = True
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class CorsSettings:
49
+ """CORS middleware settings from ``[cors]`` or ``CORS_ORIGINS``."""
50
+
51
+ origins: list[str] = field(default_factory=list)
52
+ allow_credentials: bool = False
53
+ allow_methods: list[str] = field(default_factory=lambda: ["*"])
54
+ allow_headers: list[str] = field(default_factory=lambda: ["*"])
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class LoggingSettings:
59
+ """Logging settings from ``[logging]`` or ``NYXA_LOG_*`` env."""
60
+
61
+ json: bool = False
62
+ level: str = "INFO"
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class NyxaSettings:
67
+ """Resolved project settings for generators and runtime registration."""
68
+
69
+ root: Path
70
+ app: str
71
+ src: str = "src"
72
+ paths: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_PATHS))
73
+ error_base: str = DEFAULT_ERROR_BASE
74
+ env_file: str = DEFAULT_ENV_FILE
75
+ env_environment: str | None = None
76
+ middleware_global: list[str] = field(default_factory=list)
77
+ providers_boot: list[str] = field(default_factory=list)
78
+ cors: CorsSettings | None = None
79
+ logging: LoggingSettings | None = None
80
+ dev: DevSettings | None = None
81
+
82
+ @property
83
+ def app_dir(self) -> Path:
84
+ """Filesystem path to the application package."""
85
+ return self.root / self.src / self.app
86
+
87
+ @property
88
+ def tests_dir(self) -> Path:
89
+ """Filesystem path to the tests directory."""
90
+ return self.root / self.paths.get("tests", "tests")
91
+
92
+ def path(self, key: str) -> Path:
93
+ """Return an absolute path under the app package for a `[paths]` key."""
94
+ relative = self.paths.get(key, DEFAULT_PATHS[key])
95
+ return self.app_dir / relative
96
+
97
+ def package(self, key: str) -> str:
98
+ """Return a dotted package name for a `[paths]` key under the app."""
99
+ relative = self.paths.get(key, DEFAULT_PATHS[key])
100
+ return f"{self.app}." + relative.replace("/", ".").replace("\\", ".")
101
+
102
+ def error_base_parts(self) -> tuple[str, str]:
103
+ """Split `error_base` into `(module, attr)`."""
104
+ module, _, attr = self.error_base.partition(":")
105
+ if not module or not attr:
106
+ raise ValueError(f"Invalid [bases].error value: {self.error_base!r}")
107
+ return module, attr
108
+
109
+ def resolved_dev(self) -> DevSettings:
110
+ """Return dev settings, defaulting module to `{app}.main:app`."""
111
+ if self.dev is not None:
112
+ return self.dev
113
+ return DevSettings(module=f"{self.app}.main:app")
114
+
115
+
116
+ def find_project_root(start: Path | None = None) -> Path:
117
+ """Walk upward to the consumer app root.
118
+
119
+ Prefers an ancestor with ``nyxa.toml`` so commands work from nested
120
+ package dirs in a monorepo. Falls back to the nearest ``pyproject.toml``.
121
+ """
122
+ current = (start or Path.cwd()).resolve()
123
+ ancestors = [current, *current.parents]
124
+
125
+ for path in ancestors:
126
+ if (path / "nyxa.toml").is_file():
127
+ return path
128
+
129
+ for path in ancestors:
130
+ if (path / "pyproject.toml").is_file():
131
+ return path
132
+
133
+ raise FileNotFoundError(
134
+ "Could not find project root (nyxa.toml or pyproject.toml)."
135
+ )
136
+
137
+
138
+ def _read_toml(path: Path) -> dict[str, Any]:
139
+ if not path.is_file():
140
+ return {}
141
+ with path.open("rb") as handle:
142
+ return tomllib.load(handle)
143
+
144
+
145
+ def _app_from_pyproject(root: Path) -> str | None:
146
+ data = _read_toml(root / "pyproject.toml")
147
+ tool = data.get("tool", {})
148
+ for key in ("nyxa", "artisan"):
149
+ app = tool.get(key, {}).get("app")
150
+ if isinstance(app, str) and app:
151
+ return app
152
+ return None
153
+
154
+
155
+ @lru_cache(maxsize=8)
156
+ def load_settings(start: str | None = None) -> NyxaSettings:
157
+ """Load settings from `nyxa.toml` / pyproject.
158
+
159
+ Pass *start* as a string path for cacheability; use ``None`` for cwd.
160
+ """
161
+ root = find_project_root(Path(start) if start else None)
162
+ file_data = _read_toml(root / "nyxa.toml")
163
+
164
+ app = file_data.get("app") or _app_from_pyproject(root)
165
+ if not app:
166
+ # Last resort: first package under src/ that isn't a known tool package
167
+ src_dir = root / str(file_data.get("src", "src"))
168
+ candidates = [
169
+ p.name
170
+ for p in src_dir.iterdir()
171
+ if p.is_dir() and (p / "__init__.py").exists() and p.name not in {"nyxa", "cli"}
172
+ ] if src_dir.is_dir() else []
173
+ if not candidates:
174
+ raise RuntimeError(
175
+ "Could not determine app package name. "
176
+ "Set `app` in nyxa.toml or run `nyxa init <name>`."
177
+ )
178
+ app = sorted(candidates)[0]
179
+
180
+ src = str(file_data.get("src", "src"))
181
+ paths = dict(DEFAULT_PATHS)
182
+ paths.update({k: str(v) for k, v in (file_data.get("paths") or {}).items()})
183
+
184
+ bases = file_data.get("bases") or {}
185
+ error_base = str(bases.get("error", DEFAULT_ERROR_BASE))
186
+
187
+ env_data = file_data.get("env") or {}
188
+ env_file = str(env_data.get("file", DEFAULT_ENV_FILE))
189
+ raw_environment = env_data.get("environment")
190
+ env_environment = str(raw_environment) if raw_environment else None
191
+
192
+ middleware_data = file_data.get("middleware") or {}
193
+ raw_global = middleware_data.get("global") or []
194
+ if isinstance(raw_global, str):
195
+ middleware_global = [raw_global]
196
+ else:
197
+ middleware_global = [str(item) for item in raw_global]
198
+
199
+ providers_data = file_data.get("providers") or {}
200
+ raw_boot = providers_data.get("boot") or []
201
+ if isinstance(raw_boot, str):
202
+ providers_boot = [raw_boot]
203
+ else:
204
+ providers_boot = [str(item) for item in raw_boot]
205
+
206
+ cors_data = file_data.get("cors") or {}
207
+ cors: CorsSettings | None = None
208
+ if cors_data:
209
+ raw_origins = cors_data.get("origins") or []
210
+ if isinstance(raw_origins, str):
211
+ origins = [raw_origins]
212
+ else:
213
+ origins = [str(item) for item in raw_origins]
214
+ raw_methods = cors_data.get("allow_methods")
215
+ raw_headers = cors_data.get("allow_headers")
216
+ cors = CorsSettings(
217
+ origins=origins,
218
+ allow_credentials=bool(cors_data.get("allow_credentials", False)),
219
+ allow_methods=(
220
+ [str(m) for m in raw_methods]
221
+ if isinstance(raw_methods, list)
222
+ else (["*"] if raw_methods is None else [str(raw_methods)])
223
+ ),
224
+ allow_headers=(
225
+ [str(h) for h in raw_headers]
226
+ if isinstance(raw_headers, list)
227
+ else (["*"] if raw_headers is None else [str(raw_headers)])
228
+ ),
229
+ )
230
+
231
+ logging_data = file_data.get("logging") or {}
232
+ logging_settings: LoggingSettings | None = None
233
+ if logging_data:
234
+ logging_settings = LoggingSettings(
235
+ json=bool(logging_data.get("json", False)),
236
+ level=str(logging_data.get("level", "INFO")),
237
+ )
238
+
239
+ dev_data = file_data.get("dev") or {}
240
+ module = dev_data.get("module") or f"{app}.main:app"
241
+ dev = DevSettings(
242
+ module=str(module),
243
+ host=str(dev_data.get("host", "0.0.0.0")),
244
+ port=int(dev_data.get("port", 8000)),
245
+ reload=bool(dev_data.get("reload", True)),
246
+ )
247
+
248
+ return NyxaSettings(
249
+ root=root,
250
+ app=str(app),
251
+ src=src,
252
+ paths=paths,
253
+ error_base=error_base,
254
+ env_file=env_file,
255
+ env_environment=env_environment,
256
+ middleware_global=middleware_global,
257
+ providers_boot=providers_boot,
258
+ cors=cors,
259
+ logging=logging_settings,
260
+ dev=dev,
261
+ )
262
+
263
+
264
+ def clear_settings_cache() -> None:
265
+ """Clear the cached settings (useful after init / tests)."""
266
+ load_settings.cache_clear()
267
+ try:
268
+ from nyxa.helpers import clear_config_cache
269
+
270
+ clear_config_cache()
271
+ except ImportError:
272
+ pass
273
+
274
+
275
+ def with_overrides(settings: NyxaSettings, **kwargs: Any) -> NyxaSettings:
276
+ """Return a copy of *settings* with selected fields replaced."""
277
+ return replace(settings, **kwargs)