Skip to content

Commit c8730f9

Browse files
authored
Merge pull request #30 from getmarkus/cm-branch-28
cm-branch-28
2 parents 5f61f34 + 575d309 commit c8730f9

File tree

14 files changed

+139
-78
lines changed

14 files changed

+139
-78
lines changed

.env.example

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
# Application Settings
22
APP_PROJECT_NAME=python-template
3-
# sqlite:// sqlite:///./test.db
4-
APP_DATABASE_URL=postgresql+psycopg://app_user:change_this_password@localhost:5432/app_database
3+
# sqlite:// will silently swallow exceptions, better not to use for in-memory database
4+
# APP_DATABASE_URL=sqlite://
5+
# sqlite:///:memory: follows normal exception handling rules
6+
APP_DATABASE_URL=sqlite:///:memory:
7+
# APP_DATABASE_URL=sqlite:///./issues.db
8+
# APP_DATABASE_URL=postgresql+psycopg://app_user:change_this_password@localhost:5432/app_database
59
APP_DATABASE_SCHEMA=issue_analysis
610
APP_MIGRATE_DATABASE=false
711
APP_SQLITE_WAL_MODE=false

.trunk/trunk.yaml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml
33
version: 0.1
44
cli:
5-
version: 1.22.10
5+
version: 1.22.12
66
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
77
plugins:
88
sources:
@@ -20,16 +20,16 @@ lint:
2020
- bandit
2121
- git-diff-check
2222
enabled:
23-
- osv-scanner@1.9.2
24-
- yamllint@1.35.1
23+
- osv-scanner@2.0.0
24+
- yamllint@1.37.0
2525
26-
27-
26+
27+
2828
29-
30-
- ruff@0.9.7
29+
30+
- ruff@0.11.2
3131
32-
32+
3333
actions:
3434
enabled:
3535
- trunk-upgrade-available

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ Simple python template I am experimenting with around a set of overlapping conce
1111
uv run fastapi dev main.py
1212
uv run pytest
1313

14+
atlas schema inspect -u "sqlite://file?cache=shared&mode=memory" --format "{{ sql . }}"
1415
atlas schema inspect -u "sqlite://issues.db" --format "{{ sql . }}" > migrate.sql
1516

1617
atlas schema apply --url "sqlite://issues.db" --to "file://migrate.sql" --dev-url "sqlite://file?mode=memory" --dry-run
1718
atlas schema apply --url "sqlite://issues.db" --to "file://migrate.sql" --dev-url "sqlite://file?mode=memory"
1819

19-
atlas schema apply --url "postgres://postgres:pass@localhost:5432/database?search_path=public&sslmode=disable" --to "file://schema.sql" --dev-url "docker://postgres/17"
20+
atlas schema apply --url "postgres://app_user:change_this_password@localhost:5432/app_database?sslmode=disable" --to "file://migrate.sql" --dev-url "docker://postgres/17" --schema "issue_analysis" --dry-run
2021
```
2122

2223
```mermaid

app/core/factory.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,16 @@
66
from app.interface_adapters import api_router
77
from app.interface_adapters.exceptions import AppException
88
from app.interface_adapters.middleware.error_handler import app_exception_handler
9-
from app.resource_adapters.persistence.sqlmodel.database import get_engine
109
from config import Settings
10+
from app.resource_adapters.persistence.sqlmodel.database import get_engine
1111

1212

1313
def create_app(settings: Settings, lifespan_handler=None) -> FastAPI:
1414
if lifespan_handler is None:
1515

1616
@asynccontextmanager
1717
async def default_lifespan(app: FastAPI):
18-
19-
# Initialize database if using SQLModel
20-
get_engine()
21-
18+
get_engine(settings)
2219
app.state.running = True
2320

2421
yield

app/domain/aggregate_root.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import abc
22
from datetime import datetime
33
from typing import Protocol
4+
from config import get_settings
45

56
from sqlmodel import SQLModel
67

@@ -10,15 +11,24 @@ class BaseCommand(Protocol):
1011
timestamp: datetime
1112

1213
# might need specification pattern here
13-
def validate(self) -> bool: ...
14+
def validate(self) -> bool:
15+
...
1416

1517

1618
class BaseEvent(Protocol):
1719
event_id: str
1820
timestamp: datetime
1921

2022

21-
class AggregateRoot(SQLModel, abc.ABC):
23+
class AggregateRoot(SQLModel, abc.ABC, table=False):
24+
"""
25+
Base class for all aggregate roots in the domain.
26+
27+
The metadata for this class is injected at runtime using FastAPI's dependency injection.
28+
Before using this class for database operations, ensure that set_metadata has been called.
29+
"""
30+
31+
__table_args__ = {"schema": get_settings().get_table_schema}
2232
version: int = 0
2333

2434
def __init__(self, **data):

app/interface_adapters/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
from app.interface_adapters.endpoints import issues
44

55
api_router = APIRouter()
6-
api_router.include_router(issues.router, tags=["issues"])
6+
api_router.include_router(issues.router, tags=["issues"])

app/resource_adapters/persistence/sqlmodel/database.py

Lines changed: 53 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,50 @@
22

33
from fastapi import Depends
44
from loguru import logger
5-
from sqlalchemy import inspect, schema, text
65
from sqlalchemy.engine import Engine
76
from sqlalchemy.pool import StaticPool
8-
from sqlmodel import Session, SQLModel, create_engine
7+
from sqlmodel import Session, SQLModel, create_engine, MetaData
8+
from sqlalchemy import text, schema
99

1010
from config import Settings, get_settings
1111

1212
_engine: Engine | None = None
13+
_metadata: MetaData | None = None
14+
15+
16+
def get_metadata(
17+
settings: Annotated[Settings, Depends(get_settings)],
18+
) -> MetaData:
19+
"""Dependency that provides SQLModel metadata with the correct schema configuration."""
20+
global _metadata
21+
22+
if _metadata is None:
23+
_metadata = MetaData(schema=settings.get_table_schema)
24+
25+
# Optional: Add naming convention for constraints
26+
# _metadata.naming_convention = {
27+
# "ix": "ix_%(column_0_label)s",
28+
# "uq": "uq_%(table_name)s_%(column_0_name)s",
29+
# "ck": "ck_%(table_name)s_%(constraint_name)s",
30+
# "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
31+
# "pk": "pk_%(table_name)s"
32+
# }
33+
34+
return _metadata
35+
36+
37+
MetadataDep = Annotated[MetaData, Depends(get_metadata)]
1338

1439

1540
def get_session(
1641
settings: Annotated[Settings, Depends(get_settings)],
1742
) -> Generator[Session, Settings, None]:
1843
with Session(_engine) as session:
19-
session.connection(
20-
execution_options={"schema_translate_map": {None: settings.database_schema}}
21-
)
44+
""" session.connection(
45+
execution_options={
46+
"schema_translate_map": {None: settings.get_table_schema}
47+
}
48+
) """
2249
yield session
2350

2451

@@ -35,18 +62,16 @@ def get_engine(_settings: Settings | None = None) -> Engine:
3562
if _settings is None:
3663
_settings = get_settings()
3764

38-
database_url = _settings.database_url
39-
4065
# Configure engine based on database type
4166
engine_args = {"echo": True}
4267

4368
# Add SQLite-specific settings for in-memory database
44-
if database_url == "sqlite://":
69+
if _settings.database_url.startswith("sqlite"):
4570
engine_args.update(
4671
{"connect_args": {"check_same_thread": False}, "poolclass": StaticPool}
4772
)
4873

49-
_engine = create_engine(database_url, **engine_args)
74+
_engine = create_engine(_settings.database_url, **engine_args)
5075

5176
# Enable WAL mode if configured
5277
if _settings.sqlite_wal_mode:
@@ -56,42 +81,29 @@ def get_engine(_settings: Settings | None = None) -> Engine:
5681
# conn.execute(text("PRAGMA synchronous=OFF"))
5782
logger.info("SQLite WAL mode enabled")
5883

59-
# Initialize database if using SQLModel
60-
if (
61-
_settings.database_type == "sqlmodel"
62-
and _settings.database_schema
63-
and not _settings.database_url.startswith("sqlite")
64-
):
65-
SQLModel.metadata.schema = _settings.database_schema
84+
# Initialize schema if using SQLModel, with a schema if not sqlite
85+
if _settings.database_type == "sqlmodel":
86+
SQLModel.metadata.schema = _settings.get_table_schema
6687
with _engine.connect() as conn:
67-
conn.execution_options = {
68-
"schema_translate_map": {None: _settings.database_schema}
69-
}
70-
if not conn.dialect.has_schema(conn, _settings.database_schema):
88+
""" conn.execution_options = {
89+
"schema_translate_map": {None: _settings.get_table_schema}
90+
} """
91+
if not _settings.database_url.startswith(
92+
"sqlite"
93+
) and not conn.dialect.has_schema(conn, _settings.get_table_schema):
7194
logger.warning(
72-
f"Schema '{_settings.database_schema}' not found in database. Creating..."
95+
f"Schema '{_settings.get_table_schema}' not found in database. Creating..."
7396
)
74-
conn.execute(schema.CreateSchema(_settings.database_schema))
97+
conn.execute(schema.CreateSchema(_settings.get_table_schema))
7598
conn.commit()
7699

77-
if _settings.database_type == "sqlmodel" and not _settings.migrate_database:
78-
logger.info("Creating database tables...")
79-
80-
# Check if tables exist before creating them
81-
inspector = inspect(_engine)
82-
existing_tables = inspector.get_table_names(
83-
schema=_settings.database_schema if _settings.database_schema else None
84-
)
85-
if not existing_tables:
86-
# if settings.database_schema:
87-
# # Create schema if it doesn't exist
88-
# if not inspector.has_schema(settings.database_schema):
89-
# logger.info(f"Creating schema '{settings.database_schema}'")
90-
# with _engine.begin() as conn:
91-
# conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS {settings.database_schema}'))
92-
SQLModel.metadata.create_all(_engine)
93-
logger.info("Database tables created successfully")
94-
else:
95-
logger.info("Database tables already exist, skipping creation")
100+
if not _settings.migrate_database:
101+
SQLModel.metadata.create_all(conn)
102+
conn.commit()
103+
logger.info("Database tables created successfully")
104+
else:
105+
logger.info(
106+
"Database tables already exist or migration is configured, skipping creation"
107+
)
96108

97109
return _engine

app/tests/conftest.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,36 @@
1010
from loguru import logger
1111
from sqlmodel import Session, delete
1212

13-
from app.core.factory import create_app
14-
from app.resource_adapters.persistence.sqlmodel.database import get_engine
15-
from app.resource_adapters.persistence.sqlmodel.issues import Issue
16-
from config import get_settings
1713

1814
# Specify the custom .env file
15+
# don't change ordering here, settings must be called prior to initialization of app.core.factory
1916
dotenv_path = Path(".env.testing")
2017
load_dotenv(dotenv_path=dotenv_path, override=True)
2118

19+
from app.core.factory import create_app # noqa: E402
20+
from app.resource_adapters.persistence.sqlmodel.database import get_engine # noqa: E402
21+
from app.resource_adapters.persistence.sqlmodel.issues import Issue # noqa: E402
22+
from config import get_settings # noqa: E402
23+
2224

2325
settings = get_settings()
2426

2527

2628
def pytest_unconfigure(config: Config) -> None:
2729
"""Clean up after each test."""
28-
# Remove test database if it exists
29-
if os.path.exists("test.db"):
30-
logger.info("Removing test database")
31-
os.remove("test.db")
30+
# Extract database path from the database URL
31+
db_url = settings.database_url
32+
if db_url.startswith("sqlite:///") and not db_url.endswith(":memory:"):
33+
# Remove the sqlite:/// prefix to get the file path
34+
db_path = db_url.replace("sqlite:///", "")
35+
# Remove ./ prefix if present
36+
if db_path.startswith("./"):
37+
db_path = db_path[2:]
38+
39+
# Remove test database if it exists
40+
if os.path.exists(db_path):
41+
logger.info(f"Removing test database: {db_path}")
42+
os.remove(db_path)
3243

3344

3445
@asynccontextmanager
@@ -42,7 +53,7 @@ async def test_lifespan(app: FastAPI):
4253
@pytest.fixture(name="app")
4354
def test_app():
4455
"""Create test app instance only during test execution."""
45-
return create_app(settings, lifespan_handler=test_lifespan)
56+
return create_app(settings)
4657

4758

4859
@pytest.fixture(name="session")

app/tests/test_config.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,4 @@
22

33

44
def test_testing_database_url():
5-
"""Verify that the database URL is set to sqlite for testing"""
6-
assert settings.database_url == "sqlite:///./test.db"
75
assert settings.current_env == "testing" # Verify we're in testing environment

config.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from functools import lru_cache
21
from typing import List, Literal
32

4-
from pydantic import AnyHttpUrl, field_validator
3+
from pydantic import AnyHttpUrl
54
from pydantic_settings import BaseSettings, SettingsConfigDict
5+
from functools import lru_cache
66

77

88
class Settings(BaseSettings):
@@ -15,6 +15,13 @@ class Settings(BaseSettings):
1515
database_type: str = "sqlmodel"
1616
current_env: Literal["testing", "default"]
1717

18+
@property
19+
def get_table_schema(self) -> str | None:
20+
"""Return table_schema if database_url does not start with sqlite, otherwise return None."""
21+
if self.database_url.startswith("sqlite"):
22+
return None
23+
return self.database_schema
24+
1825
# CORS settings
1926
backend_cors_origins: List[str] = ["http://localhost:8000", "http://localhost:3000"]
2027
cors_allow_credentials: bool = False
@@ -36,12 +43,12 @@ class Settings(BaseSettings):
3643
extra="ignore",
3744
)
3845

39-
@field_validator("database_schema")
46+
""" @field_validator("database_schema")
4047
def validate_schema(cls, v: str | None, values) -> str | None:
4148
# Only set database_schema if database_url doesn't start with sqlite
4249
if values.data.get("database_url", "").startswith("sqlite"):
4350
return None
44-
return v
51+
return v """
4552

4653
@property
4754
def backend_cors_origins_list(self) -> List[AnyHttpUrl]:

0 commit comments

Comments
 (0)