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 +100 -0
- nyxa/abort.py +105 -0
- nyxa/app.py +85 -0
- nyxa/auth_stubs.py +34 -0
- nyxa/config.py +277 -0
- nyxa/container.py +219 -0
- nyxa/context.py +105 -0
- nyxa/cors.py +81 -0
- nyxa/env.py +97 -0
- nyxa/errors.py +126 -0
- nyxa/events.py +41 -0
- nyxa/generate.py +76 -0
- nyxa/helpers.py +175 -0
- nyxa/init_cmd.py +754 -0
- nyxa/list_cmd.py +78 -0
- nyxa/logging_config.py +106 -0
- nyxa/make.py +505 -0
- nyxa/paths.py +40 -0
- nyxa/providers.py +129 -0
- nyxa/registry.py +166 -0
- nyxa/requests.py +32 -0
- nyxa/response.py +101 -0
- nyxa/routing.py +418 -0
- nyxa/templates.py +1005 -0
- nyxa-0.1.0.dist-info/METADATA +397 -0
- nyxa-0.1.0.dist-info/RECORD +28 -0
- nyxa-0.1.0.dist-info/WHEEL +4 -0
- nyxa-0.1.0.dist-info/entry_points.txt +5 -0
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)
|