astris-python 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.
astris/config.py ADDED
@@ -0,0 +1,50 @@
1
+ from functools import lru_cache
2
+ from typing import Literal
3
+
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ """Centralized application and framework configuration."""
9
+
10
+ model_config = SettingsConfigDict(
11
+ env_file=".env",
12
+ env_file_encoding="utf-8",
13
+ extra="ignore",
14
+ case_sensitive=False,
15
+ )
16
+
17
+ # Core Application
18
+ app_name: str = "Astris Application"
19
+ app_env: str = "local"
20
+ app_debug: bool = True
21
+ app_key: str = ""
22
+
23
+ # Database
24
+ database_url: str = "sqlite:///database/app.db"
25
+ db_echo: bool = False
26
+ auto_create_tables: bool = True
27
+
28
+ # Sessions & Security
29
+ session_cookie_name: str = "astris_session"
30
+ session_max_age: int | None = 14 * 24 * 60 * 60 # 14 days
31
+ session_https_only: bool = False
32
+ session_same_site: Literal["lax", "strict", "none"] = "lax"
33
+
34
+ # CORS & CSRF
35
+ cors_origins: list[str] = [
36
+ "http://localhost:5173",
37
+ "http://127.0.0.1:5173",
38
+ ]
39
+ enable_csrf: bool = True
40
+ csrf_exempt_paths: list[str] = []
41
+
42
+
43
+ @lru_cache
44
+ def get_settings() -> Settings:
45
+ """Return the cached application settings instance."""
46
+ return Settings()
47
+
48
+
49
+ # Global settings singleton
50
+ settings = get_settings()
@@ -0,0 +1,28 @@
1
+ from sqlmodel import (
2
+ Field,
3
+ Relationship,
4
+ Session,
5
+ SQLModel,
6
+ col,
7
+ select,
8
+ )
9
+
10
+ from astris.database.session import (
11
+ Database,
12
+ DatabaseSession,
13
+ db,
14
+ get_session,
15
+ )
16
+
17
+ __all__ = [
18
+ "Database",
19
+ "DatabaseSession",
20
+ "Field",
21
+ "Relationship",
22
+ "SQLModel",
23
+ "Session",
24
+ "col",
25
+ "db",
26
+ "get_session",
27
+ "select",
28
+ ]
@@ -0,0 +1,259 @@
1
+ import importlib
2
+ import pkgutil
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from alembic import command
8
+ from alembic.config import Config
9
+ from alembic.runtime.migration import MigrationContext
10
+ from alembic.script import ScriptDirectory
11
+
12
+ from astris.database.session import db
13
+
14
+ ENV_PY_TEMPLATE = """from astris.database.migrations import run_env
15
+
16
+ run_env()
17
+ """
18
+
19
+ SCRIPT_MAKO_TEMPLATE = '''"""${message}
20
+
21
+ Revision ID: ${up_revision}
22
+ Revises: ${down_revision | comma,n}
23
+ Create Date: ${create_date}
24
+
25
+ """
26
+ from collections.abc import Sequence
27
+
28
+ from alembic import op
29
+ import sqlalchemy as sa
30
+ import sqlmodel
31
+ ${imports if imports else ""}
32
+
33
+ # revision identifiers, used by Alembic.
34
+ revision: str = ${repr(up_revision)}
35
+ down_revision: str | Sequence[str] | None = ${repr(down_revision)}
36
+ branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
37
+ depends_on: str | Sequence[str] | None = ${repr(depends_on)}
38
+
39
+
40
+ def upgrade() -> None:
41
+ """Upgrade schema."""
42
+ ${upgrades if upgrades else "pass"}
43
+
44
+
45
+ def downgrade() -> None:
46
+ """Downgrade schema."""
47
+ ${downgrades if downgrades else "pass"}
48
+ '''
49
+
50
+ ALEMBIC_INI_TEMPLATE = """[alembic]
51
+ script_location = database/migrations
52
+ prepend_sys_path = .
53
+ version_path_separator = os
54
+
55
+ [loggers]
56
+ keys = root,sqlalchemy,alembic
57
+
58
+ [handlers]
59
+ keys = console
60
+
61
+ [formatters]
62
+ keys = generic
63
+
64
+ [logger_root]
65
+ level = WARN
66
+ handlers = console
67
+ qualname =
68
+
69
+ [logger_sqlalchemy]
70
+ level = WARN
71
+ handlers =
72
+ qualname = sqlalchemy.engine
73
+
74
+ [logger_alembic]
75
+ level = INFO
76
+ handlers =
77
+ qualname = alembic
78
+
79
+ [handler_console]
80
+ class = StreamHandler
81
+ args = (sys.stderr,)
82
+ level = NOTSET
83
+ formatter = generic
84
+
85
+ [formatter_generic]
86
+ format = %(levelname)-5.5s [%(name)s] %(message)s
87
+ datefmt = %H:%M:%S
88
+ """
89
+
90
+
91
+ def discover_models(base_path: Path | None = None) -> None:
92
+ """Discover and import all domain model files to populate SQLModel.metadata."""
93
+ root = base_path or Path.cwd()
94
+ root_str = str(root)
95
+ if root_str not in sys.path:
96
+ sys.path.insert(0, root_str)
97
+
98
+ modules_dir = root / "app" / "modules"
99
+ if not modules_dir.exists():
100
+ return
101
+
102
+ for _, modname, ispkg in pkgutil.walk_packages(
103
+ [str(modules_dir)], prefix="app.modules."
104
+ ):
105
+ if ispkg:
106
+ continue
107
+ last_part = modname.split(".")[-1]
108
+ if last_part.endswith(("_model", "_models")) or last_part in (
109
+ "models",
110
+ "model",
111
+ ):
112
+ try:
113
+ importlib.import_module(modname)
114
+ except ImportError:
115
+ pass
116
+
117
+
118
+ def ensure_migration_setup(base_path: Path | None = None) -> tuple[Path, Path]:
119
+ """Ensure alembic.ini and database/migrations/ directory structure exist."""
120
+ root = base_path or Path.cwd()
121
+ ini_path = root / "alembic.ini"
122
+ migrations_dir = root / "database" / "migrations"
123
+ versions_dir = migrations_dir / "versions"
124
+
125
+ migrations_dir.mkdir(parents=True, exist_ok=True)
126
+ versions_dir.mkdir(parents=True, exist_ok=True)
127
+
128
+ if not ini_path.exists():
129
+ ini_path.write_text(ALEMBIC_INI_TEMPLATE, encoding="utf-8")
130
+
131
+ env_path = migrations_dir / "env.py"
132
+ if not env_path.exists():
133
+ env_path.write_text(ENV_PY_TEMPLATE, encoding="utf-8")
134
+
135
+ mako_path = migrations_dir / "script.py.mako"
136
+ if not mako_path.exists():
137
+ mako_path.write_text(SCRIPT_MAKO_TEMPLATE, encoding="utf-8")
138
+
139
+ return ini_path, migrations_dir
140
+
141
+
142
+ def get_alembic_config(base_path: Path | None = None) -> Config:
143
+ """Create and configure an Alembic Config object."""
144
+ root = base_path or Path.cwd()
145
+ ini_path, migrations_dir = ensure_migration_setup(root)
146
+
147
+ discover_models(root)
148
+
149
+ cfg = Config(str(ini_path))
150
+ cfg.set_main_option("script_location", str(migrations_dir))
151
+ cfg.set_main_option("sqlalchemy.url", db.url)
152
+ return cfg
153
+
154
+
155
+ def create_migration(
156
+ message: str,
157
+ autogenerate: bool = True,
158
+ base_path: Path | None = None,
159
+ ) -> None:
160
+ """Generate a new database migration file."""
161
+ cfg = get_alembic_config(base_path)
162
+ command.revision(cfg, message=message, autogenerate=autogenerate)
163
+
164
+
165
+ def run_migrations(
166
+ revision: str = "head",
167
+ base_path: Path | None = None,
168
+ ) -> None:
169
+ """Apply database migrations up to the target revision (default: 'head')."""
170
+ cfg = get_alembic_config(base_path)
171
+ command.upgrade(cfg, revision)
172
+
173
+
174
+ def rollback_migrations(
175
+ revision: str = "-1",
176
+ base_path: Path | None = None,
177
+ ) -> None:
178
+ """Roll back database migrations down to the target revision (default: '-1')."""
179
+ cfg = get_alembic_config(base_path)
180
+ command.downgrade(cfg, revision)
181
+
182
+
183
+ def get_migration_status(base_path: Path | None = None) -> dict[str, Any]:
184
+ """Retrieve the current migration revision and available heads."""
185
+ cfg = get_alembic_config(base_path)
186
+ script_dir = ScriptDirectory.from_config(cfg)
187
+ with db.engine.connect() as conn:
188
+ context = MigrationContext.configure(conn)
189
+ current_revs = context.get_current_heads()
190
+ heads = script_dir.get_heads()
191
+
192
+ return {
193
+ "current_revisions": list(current_revs),
194
+ "heads": list(heads),
195
+ "is_up_to_date": set(current_revs) == set(heads),
196
+ }
197
+
198
+
199
+ def run_env(
200
+ target_metadata: Any = None,
201
+ base_path: Path | None = None,
202
+ ) -> None:
203
+ """Run migrations in offline or online mode.
204
+
205
+ Executed by database/migrations/env.py during Alembic migration runs.
206
+ """
207
+ from logging.config import fileConfig
208
+
209
+ from alembic import context
210
+ from sqlalchemy import engine_from_config, pool
211
+
212
+ from astris.database import SQLModel
213
+
214
+ root = base_path or Path.cwd()
215
+ root_str = str(root)
216
+ if root_str not in sys.path:
217
+ sys.path.insert(0, root_str)
218
+
219
+ discover_models(root)
220
+
221
+ config = context.config
222
+
223
+ if config.config_file_name is not None:
224
+ fileConfig(config.config_file_name)
225
+
226
+ metadata = target_metadata if target_metadata is not None else SQLModel.metadata
227
+
228
+ if context.is_offline_mode():
229
+ url = config.get_main_option("sqlalchemy.url")
230
+ is_sqlite = url and url.startswith("sqlite")
231
+ context.configure(
232
+ url=url,
233
+ target_metadata=metadata,
234
+ literal_binds=True,
235
+ dialect_opts={"paramstyle": "named"},
236
+ render_as_batch=bool(is_sqlite),
237
+ )
238
+
239
+ with context.begin_transaction():
240
+ context.run_migrations()
241
+ else:
242
+ connectable = config.attributes.get("connection", None)
243
+ if connectable is None:
244
+ connectable = engine_from_config(
245
+ config.get_section(config.config_ini_section, {}),
246
+ prefix="sqlalchemy.",
247
+ poolclass=pool.NullPool,
248
+ )
249
+
250
+ with connectable.connect() as connection:
251
+ is_sqlite = connection.dialect.name == "sqlite"
252
+ context.configure(
253
+ connection=connection,
254
+ target_metadata=metadata,
255
+ render_as_batch=is_sqlite,
256
+ )
257
+
258
+ with context.begin_transaction():
259
+ context.run_migrations()
@@ -0,0 +1,108 @@
1
+ from collections.abc import Generator
2
+ from contextlib import contextmanager
3
+ from pathlib import Path
4
+ from typing import Annotated, Any
5
+
6
+ from fastapi import Depends
7
+ from sqlalchemy import Engine
8
+ from sqlmodel import Session, SQLModel, create_engine
9
+
10
+
11
+ class Database:
12
+ """Manages the SQLModel engine and session lifecycle."""
13
+
14
+ def __init__(
15
+ self,
16
+ url: str | None = None,
17
+ echo: bool = False,
18
+ base_path: Path | None = None,
19
+ **engine_kwargs: Any,
20
+ ) -> None:
21
+ self._url = url
22
+ self._echo = echo
23
+ self._base_path = base_path or Path.cwd()
24
+ self._engine_kwargs = engine_kwargs
25
+ self._engine: Engine | None = None
26
+
27
+ @property
28
+ def url(self) -> str:
29
+ if self._url:
30
+ return self._url
31
+ from astris.config import get_settings
32
+
33
+ config_url = get_settings().database_url
34
+ if config_url:
35
+ return config_url
36
+
37
+ # Default to SQLite at database/app.db
38
+ db_dir = self._base_path / "database"
39
+ db_dir.mkdir(parents=True, exist_ok=True)
40
+ db_path = db_dir / "app.db"
41
+ return f"sqlite:///{db_path}"
42
+
43
+ @property
44
+ def engine(self) -> Engine:
45
+ if self._engine is not None:
46
+ return self._engine
47
+
48
+ connect_args: dict[str, Any] = {}
49
+ if self.url.startswith("sqlite"):
50
+ connect_args["check_same_thread"] = False
51
+
52
+ kwargs = {**self._engine_kwargs}
53
+ if connect_args:
54
+ kwargs["connect_args"] = {
55
+ **connect_args,
56
+ **kwargs.get("connect_args", {}),
57
+ }
58
+
59
+ engine = create_engine(self.url, echo=self._echo, **kwargs)
60
+ self._engine = engine
61
+ return engine
62
+
63
+ def configure(
64
+ self,
65
+ url: str | None = None,
66
+ echo: bool = False,
67
+ base_path: Path | None = None,
68
+ **engine_kwargs: Any,
69
+ ) -> None:
70
+ """Reconfigure database settings and reset the engine."""
71
+ self._url = url
72
+ self._echo = echo
73
+ if base_path:
74
+ self._base_path = base_path
75
+ self._engine_kwargs = engine_kwargs
76
+ self._engine = None
77
+
78
+ def create_all(self) -> None:
79
+ """Create all registered SQLModel tables."""
80
+ SQLModel.metadata.create_all(self.engine)
81
+
82
+ def drop_all(self) -> None:
83
+ """Drop all registered SQLModel tables."""
84
+ SQLModel.metadata.drop_all(self.engine)
85
+
86
+ def get_session(self) -> Generator[Session]:
87
+ """FastAPI dependency yielding a Session."""
88
+ with Session(self.engine) as session:
89
+ yield session
90
+
91
+ @contextmanager
92
+ def session(self) -> Generator[Session]:
93
+ """Context manager yielding a Session for background tasks, CLI, and scripts."""
94
+ with Session(self.engine) as session:
95
+ yield session
96
+
97
+
98
+ # Global default database instance
99
+ db = Database()
100
+
101
+
102
+ def get_session() -> Generator[Session]:
103
+ """Astris dependency that yields a database session."""
104
+ yield from db.get_session()
105
+
106
+
107
+ # First-class dependency injection alias for route & controller handlers
108
+ DatabaseSession = Annotated[Session, Depends(get_session)]
@@ -0,0 +1,25 @@
1
+ from fastapi import BackgroundTasks, HTTPException, status
2
+ from fastapi.requests import Request
3
+ from fastapi.responses import (
4
+ FileResponse,
5
+ HTMLResponse,
6
+ JSONResponse,
7
+ PlainTextResponse,
8
+ RedirectResponse,
9
+ Response,
10
+ StreamingResponse,
11
+ )
12
+
13
+ __all__ = [
14
+ "BackgroundTasks",
15
+ "FileResponse",
16
+ "HTMLResponse",
17
+ "HTTPException",
18
+ "JSONResponse",
19
+ "PlainTextResponse",
20
+ "RedirectResponse",
21
+ "Request",
22
+ "Response",
23
+ "StreamingResponse",
24
+ "status",
25
+ ]
astris/http/static.py ADDED
@@ -0,0 +1,31 @@
1
+ from pathlib import Path
2
+
3
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
4
+ from starlette.requests import Request
5
+ from starlette.responses import FileResponse, Response
6
+ from starlette.types import ASGIApp
7
+
8
+
9
+ class PublicStaticMiddleware(BaseHTTPMiddleware):
10
+ """Serve physical static files directly from the public/ directory."""
11
+
12
+ def __init__(self, app: ASGIApp, public_dir: Path) -> None:
13
+ super().__init__(app)
14
+ self.public_dir = public_dir
15
+
16
+ async def dispatch(
17
+ self, request: Request, call_next: RequestResponseEndpoint
18
+ ) -> Response:
19
+ if request.method in ("GET", "HEAD"):
20
+ raw_path = request.url.path.lstrip("/")
21
+ if raw_path and not raw_path.startswith("build/"):
22
+ file_path = (self.public_dir / raw_path).resolve()
23
+ try:
24
+ if file_path.is_file() and file_path.is_relative_to(
25
+ self.public_dir
26
+ ):
27
+ return FileResponse(file_path)
28
+ except (ValueError, OSError):
29
+ pass
30
+
31
+ return await call_next(request)
@@ -0,0 +1,8 @@
1
+ from astris.inertia.response import InertiaResponse
2
+ from astris.inertia.shared import flash, share
3
+
4
+ __all__ = [
5
+ "InertiaResponse",
6
+ "flash",
7
+ "share",
8
+ ]
@@ -0,0 +1,133 @@
1
+ import json
2
+ from typing import Any
3
+ from urllib.parse import quote
4
+
5
+ from fastapi import Request
6
+ from fastapi.exceptions import RequestValidationError
7
+ from pydantic import ValidationError
8
+ from starlette.responses import JSONResponse, RedirectResponse, Response
9
+
10
+
11
+ def format_validation_errors(
12
+ exc: RequestValidationError | ValidationError,
13
+ ) -> dict[str, str]:
14
+ """Extract a clean, human-readable dictionary of field -> message from Pydantic validation errors."""
15
+ errors: dict[str, str] = {}
16
+ for error in exc.errors():
17
+ # Strip internal FastAPI/Pydantic location qualifiers like 'body', 'query', '__root__'
18
+ raw_loc = error.get("loc")
19
+ loc_parts: list[str] = []
20
+ if isinstance(raw_loc, (tuple, list)):
21
+ for part in raw_loc:
22
+ part_str = f"{part}"
23
+ if part_str not in (
24
+ "body",
25
+ "query",
26
+ "header",
27
+ "cookie",
28
+ "path",
29
+ "__root__",
30
+ ):
31
+ loc_parts.append(part_str)
32
+
33
+ field = ".".join(loc_parts) if loc_parts else "non_field_errors"
34
+ if field not in errors:
35
+ msg = error.get("msg")
36
+ errors[field] = msg if isinstance(msg, str) else "Invalid value"
37
+ return errors
38
+
39
+
40
+ def create_inertia_validation_response(
41
+ request: Request,
42
+ errors: dict[str, Any],
43
+ ) -> Response:
44
+ """Create an Inertia-compliant validation error response.
45
+
46
+ Returns a 303 redirect with flashed errors for state-changing form requests,
47
+ or a 422 JSON response with the X-Inertia header.
48
+ """
49
+ referer = request.headers.get("Referer")
50
+ if referer and request.method.upper() in ("POST", "PUT", "PATCH", "DELETE"):
51
+ if hasattr(request, "session"):
52
+ request.session["_errors"] = errors
53
+ response = RedirectResponse(url=referer, status_code=303)
54
+ response.set_cookie(
55
+ key="_inertia_errors",
56
+ value=quote(json.dumps(errors)),
57
+ path="/",
58
+ httponly=True,
59
+ samesite="lax",
60
+ max_age=10,
61
+ )
62
+ return response
63
+
64
+ return JSONResponse(
65
+ status_code=422,
66
+ content={"errors": errors},
67
+ headers={"X-Inertia": "true"},
68
+ )
69
+
70
+
71
+ async def inertia_validation_exception_handler(
72
+ request: Request,
73
+ exc: Exception,
74
+ ) -> Response:
75
+ """Handle validation errors for Inertia requests by returning an Inertia-compliant
76
+
77
+ 303 redirect with session errors, or a standard 422 JSON response for API consumers.
78
+ """
79
+ if isinstance(exc, (RequestValidationError, ValidationError)):
80
+ errors = format_validation_errors(exc)
81
+ detail = exc.errors()
82
+ else:
83
+ errors = {"non_field_errors": "Validation error"}
84
+ detail = [{"msg": "Validation error", "type": "value_error"}]
85
+
86
+ is_inertia = request.headers.get("X-Inertia") == "true"
87
+
88
+ if is_inertia:
89
+ return create_inertia_validation_response(request, errors)
90
+
91
+ return JSONResponse(
92
+ status_code=422,
93
+ content={"detail": detail, "errors": errors},
94
+ )
95
+
96
+
97
+ async def inertia_http_exception_handler(
98
+ request: Request,
99
+ exc: Exception,
100
+ ) -> Response:
101
+ """Handle HTTPExceptions for Inertia and standard API requests.
102
+
103
+ Formats 422 unprocessable entity errors as Inertia-compliant 303 redirects or {"errors": {...}}
104
+ responses with the X-Inertia header, so frontend form validation states update seamlessly.
105
+ """
106
+ is_inertia = request.headers.get("X-Inertia") == "true"
107
+ status_code = getattr(exc, "status_code", 500)
108
+ detail = getattr(exc, "detail", "An error occurred")
109
+
110
+ if is_inertia and status_code == 422:
111
+ if isinstance(detail, dict):
112
+ errors = {str(k): str(v) for k, v in detail.items()}
113
+ elif isinstance(detail, list):
114
+ errors = {}
115
+ for item in detail:
116
+ if isinstance(item, dict):
117
+ loc = item.get("loc", ["non_field_errors"])[-1]
118
+ errors[str(loc)] = str(item.get("msg", "Invalid value"))
119
+ else:
120
+ errors["non_field_errors"] = str(item)
121
+ elif isinstance(detail, str):
122
+ errors = {"non_field_errors": detail}
123
+ else:
124
+ errors = {"non_field_errors": "Validation error"}
125
+
126
+ return create_inertia_validation_response(request, errors)
127
+
128
+ headers = getattr(exc, "headers", None)
129
+ return JSONResponse(
130
+ status_code=status_code,
131
+ content={"detail": detail},
132
+ headers=headers,
133
+ )