svc-infra 0.1.182__tar.gz → 0.1.184__tar.gz

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.
Files changed (83) hide show
  1. {svc_infra-0.1.182 → svc_infra-0.1.184}/PKG-INFO +1 -1
  2. {svc_infra-0.1.182 → svc_infra-0.1.184}/pyproject.toml +1 -1
  3. svc_infra-0.1.184/src/svc_infra/api/fastapi/db/uniq.py +82 -0
  4. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/auth/user_default.py +1 -1
  5. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/templates/models_schemas/auth/models.py.tmpl +10 -20
  6. svc_infra-0.1.184/src/svc_infra/db/templates/models_schemas/entity/models.py.tmpl +52 -0
  7. svc_infra-0.1.184/src/svc_infra/db/uniq.py +77 -0
  8. svc_infra-0.1.182/src/svc_infra/db/templates/models_schemas/entity/models.py.tmpl +0 -29
  9. {svc_infra-0.1.182 → svc_infra-0.1.184}/README.md +0 -0
  10. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/__init__.py +0 -0
  11. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/__init__.py +0 -0
  12. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/__init__.py +0 -0
  13. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/db/README.md +0 -0
  14. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/db/__init__.py +0 -0
  15. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/db/add.py +0 -0
  16. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/db/crud_router.py +0 -0
  17. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/db/health.py +0 -0
  18. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/db/http.py +0 -0
  19. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/db/management.py +0 -0
  20. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/db/repository.py +0 -0
  21. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/db/resource.py +0 -0
  22. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/db/service.py +0 -0
  23. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/db/service_hooks.py +0 -0
  24. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/db/session.py +0 -0
  25. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/middleware/__init__.py +0 -0
  26. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/middleware/errors/__init__.py +0 -0
  27. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/middleware/errors/catchall.py +0 -0
  28. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/middleware/errors/error_handlers.py +0 -0
  29. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/middleware/errors/exceptions.py +0 -0
  30. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/routers/__init__.py +0 -0
  31. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/routers/ping.py +0 -0
  32. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/api/fastapi/settings.py +0 -0
  33. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/app/__init__.py +0 -0
  34. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/app/env.py +0 -0
  35. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/app/logging.py +0 -0
  36. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/app/root.py +0 -0
  37. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/app/settings.py +0 -0
  38. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/auth/__init__.py +0 -0
  39. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/auth/integration.py +0 -0
  40. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/auth/oauth_router.py +0 -0
  41. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/auth/providers.py +0 -0
  42. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/auth/settings.py +0 -0
  43. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/auth/users.py +0 -0
  44. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/cli/__init__.py +0 -0
  45. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/cli/cmds/__init__.py +0 -0
  46. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/cli/cmds/alembic_cmds.py +0 -0
  47. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/cli/cmds/help.py +0 -0
  48. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/cli/cmds/scaffold_cmds.py +0 -0
  49. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/cli/foundation/__init__.py +0 -0
  50. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/cli/foundation/runner.py +0 -0
  51. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/cli/foundation/typer_bootstrap.py +0 -0
  52. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/README.md +0 -0
  53. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/__init__.py +0 -0
  54. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/base.py +0 -0
  55. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/constants.py +0 -0
  56. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/core.py +0 -0
  57. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/scaffold.py +0 -0
  58. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/templates/__init__.py +0 -0
  59. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/templates/models_schemas/__init__.py +0 -0
  60. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/templates/models_schemas/auth/schemas.py.tmpl +0 -0
  61. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/templates/models_schemas/entity/schemas.py.tmpl +0 -0
  62. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/templates/setup/__init__.py +0 -0
  63. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/templates/setup/alembic.ini.tmpl +0 -0
  64. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/templates/setup/env_async.py.tmpl +0 -0
  65. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/templates/setup/env_sync.py.tmpl +0 -0
  66. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/templates/setup/script.py.mako.tmpl +0 -0
  67. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/db/utils.py +0 -0
  68. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/mcp/__init__.py +0 -0
  69. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/mcp/svc_infra_mcp.py +0 -0
  70. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/observability/README.md +0 -0
  71. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/observability/__init__.py +0 -0
  72. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/observability/metrics/__init__.py +0 -0
  73. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/observability/metrics/asgi.py +0 -0
  74. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/observability/metrics/base.py +0 -0
  75. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/observability/metrics/http.py +0 -0
  76. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/observability/metrics/sqlalchemy.py +0 -0
  77. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/observability/settings.py +0 -0
  78. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/observability/templates/grafana_dashboard.json +0 -0
  79. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/observability/templates/otel-collector.yaml +0 -0
  80. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/observability/templates/prometheus_rules.yml +0 -0
  81. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/observability/tracing/__init__.py +0 -0
  82. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/observability/tracing/setup.py +0 -0
  83. {svc_infra-0.1.182 → svc_infra-0.1.184}/src/svc_infra/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: svc-infra
3
- Version: 0.1.182
3
+ Version: 0.1.184
4
4
  Summary: Infrastructure for building and deploying prod-ready services
5
5
  License: MIT
6
6
  Keywords: fastapi,sqlalchemy,alembic,auth,infra,async,pydantic
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "svc-infra"
3
- version = "0.1.182"
3
+ version = "0.1.184"
4
4
  description = "Infrastructure for building and deploying prod-ready services"
5
5
  authors = ["Ali Khatami <aliikhatami94@gmail.com>"]
6
6
  license = "MIT"
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterable, Optional, Dict, Any, Callable
4
+ from sqlalchemy import func, and_
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from .repository import Repository
8
+ from svc_infra.db.uniq import ColumnSpec, _as_tuple
9
+
10
+ def make_uniqueness_hooks(
11
+ *,
12
+ model: type,
13
+ repo: Repository,
14
+ unique_cs: Iterable[ColumnSpec] = (),
15
+ unique_ci: Iterable[ColumnSpec] = (),
16
+ tenant_field: Optional[str] = None,
17
+ duplicate_message: str = "Duplicate resource violates uniqueness policy.",
18
+ ) -> tuple[
19
+ Callable[[Dict[str, Any]], Any], # async pre_create(data) -> data
20
+ Callable[[Dict[str, Any]], Any], # async pre_update(data) -> data
21
+ ]:
22
+ """Return (pre_create, pre_update) hooks that check duplicates *before* insert/update.
23
+
24
+ Usage with ServiceWithHooks:
25
+ pre_c, pre_u = make_uniqueness_hooks(model=User, repo=repo, unique_ci=["email"], tenant_field="tenant_id")
26
+ svc = ServiceWithHooks(repo, pre_create=pre_c, pre_update=pre_u)
27
+ """
28
+ cs_specs = [_as_tuple(s) for s in unique_cs]
29
+ ci_specs = [_as_tuple(s) for s in unique_ci]
30
+
31
+ def _build_wheres(data: Dict[str, Any]):
32
+ wheres = []
33
+
34
+ def col(name: str):
35
+ return getattr(model, name)
36
+
37
+ tenant_val = data.get(tenant_field) if tenant_field else None
38
+
39
+ # case-sensitive groups
40
+ for spec in cs_specs:
41
+ if not all(k in data for k in spec):
42
+ continue
43
+ parts = []
44
+ if tenant_field:
45
+ parts.append(col(tenant_field).is_(None) if tenant_val is None else col(tenant_field) == tenant_val)
46
+ for k in spec:
47
+ parts.append(col(k) == data[k])
48
+ wheres.append(and_(*parts))
49
+
50
+ # case-insensitive groups
51
+ for spec in ci_specs:
52
+ if not all(k in data for k in spec):
53
+ continue
54
+ parts = []
55
+ if tenant_field:
56
+ parts.append(col(tenant_field).is_(None) if tenant_val is None else col(tenant_field) == tenant_val)
57
+ for k in spec:
58
+ # compare lower(db_col) == lower(<value>) safely
59
+ parts.append(func.lower(col(k)) == func.lower(func.cast(data[k], col(k).type)))
60
+ wheres.append(and_(*parts))
61
+
62
+ return wheres
63
+
64
+ async def _deferred_check(session: AsyncSession, data: Dict[str, Any]):
65
+ wheres = _build_wheres(data)
66
+ for where in wheres:
67
+ if await repo.exists(session, where=[where]):
68
+ from fastapi import HTTPException
69
+ raise HTTPException(status_code=409, detail=duplicate_message)
70
+
71
+ async def _pre_create(data: Dict[str, Any]) -> Dict[str, Any]:
72
+ # store a coroutine to be awaited when a session is available in Service.create
73
+ data = dict(data)
74
+ data.setdefault("__uniq_check__", _deferred_check)
75
+ return data
76
+
77
+ async def _pre_update(data: Dict[str, Any]) -> Dict[str, Any]:
78
+ data = dict(data)
79
+ data.setdefault("__uniq_check__", _deferred_check)
80
+ return data
81
+
82
+ return _pre_create, _pre_update
@@ -1,7 +1,7 @@
1
1
  from typing import Any, Dict
2
2
  from fastapi import HTTPException
3
3
  from fastapi_users.password import PasswordHelper
4
- from sqlalchemy import func, and_, or_
4
+ from sqlalchemy import func
5
5
 
6
6
  from svc_infra.api.fastapi.db.service_hooks import ServiceWithHooks
7
7
  from svc_infra.api.fastapi.db.repository import Repository
@@ -12,6 +12,7 @@ from sqlalchemy.ext.mutable import MutableDict, MutableList
12
12
 
13
13
  from svc_infra.db.base import ModelBase
14
14
  from svc_infra.auth.user_default import _pwd
15
+ from svc_infra.db.uniq import make_unique_indexes
15
16
 
16
17
 
17
18
  class User(ModelBase):
@@ -60,23 +61,12 @@ class User(ModelBase):
60
61
  return f"<User id={self.id} email={self.email!r}>"
61
62
 
62
63
 
63
- # ---------- Uniqueness: case-insensitive email per-tenant ----------
64
- # Because tenant_id is Optional (nullable), use two partial unique indexes:
65
- # - When tenant_id IS NULL: email (lowercased) must be globally unique
66
- # - When tenant_id IS NOT NULL: (tenant_id, lower(email)) must be unique
67
- Index(
68
- "uq_users_email_lower_global",
69
- func.lower(User.email),
70
- unique=True,
71
- postgresql_where=User.tenant_id.is_(None),
72
- )
73
- Index(
74
- "uq_users_email_lower_per_tenant",
75
- func.lower(User.email),
76
- User.tenant_id,
77
- unique=True,
78
- postgresql_where=User.tenant_id.isnot(None),
79
- )
80
-
81
- # Optional helper index for case-insensitive lookups (non-unique)
82
- Index("ix_users_email_lower", func.lower(User.email))
64
+ # Unique indexes, including case-insensitive and/or tenant-scoped.
65
+ for _ix in make_unique_indexes(
66
+ User,
67
+ unique_ci=["email"], # case-insensitive unique email
68
+ tenant_field="tenant_id", # scoped by tenant if provided
69
+ ):
70
+ # Simply iterating keeps them registered with the Table metadata.
71
+ # (No further action needed if you use Alembic autogenerate or metadata.create_all.)
72
+ pass
@@ -0,0 +1,52 @@
1
+ # models/${table_name}.py
2
+ from __future__ import annotations
3
+ from datetime import datetime
4
+ from typing import Optional
5
+ import uuid
6
+
7
+ from sqlalchemy import String, Boolean, DateTime, JSON, Text, func
8
+ from sqlalchemy.dialects.postgresql import UUID
9
+ from sqlalchemy.orm import Mapped, mapped_column
10
+ from sqlalchemy.ext.mutable import MutableDict
11
+
12
+ from svc_infra.db.base import ModelBase
13
+ from svc_infra.db.uniq import make_unique_indexes
14
+
15
+
16
+ class ${Entity}(ModelBase):
17
+ __tablename__ = "${table_name}"
18
+
19
+ # identity
20
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
21
+
22
+ # core fields
23
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
24
+ description: Mapped[Optional[str]] = mapped_column(Text)
25
+
26
+ ${tenant_field}\
27
+ ${soft_delete_field}\
28
+ # misc (avoid attr name "metadata" clash)
29
+ extra: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSON), default=dict)
30
+
31
+ # auditing (DB-side timestamps)
32
+ created_at: Mapped[datetime] = mapped_column(
33
+ DateTime(timezone=True), server_default=func.now(), nullable=False
34
+ )
35
+ updated_at: Mapped[datetime] = mapped_column(
36
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
37
+ )
38
+
39
+ def __repr__(self) -> str:
40
+ return f"<${Entity} id={self.id} name={self.name!r}>"
41
+
42
+
43
+ # --- Uniqueness policy --------------------------------------------------------
44
+ # Register functional unique indexes (case-insensitive on "name"),
45
+ # optionally scoped by tenant if present.
46
+ for _ix in make_unique_indexes(
47
+ ${Entity},
48
+ unique_ci=["name"]${tenant_arg}
49
+ ):
50
+ # Iteration is enough to attach them to the Table metadata
51
+ # (Alembic autogenerate / metadata.create_all will pick them up)
52
+ pass
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+ from typing import Iterable, Sequence, Tuple, Union, List, Optional
3
+ from sqlalchemy import Index, func
4
+
5
+ ColumnSpec = Union[str, Sequence[str]]
6
+
7
+ def _as_tuple(spec: ColumnSpec) -> Tuple[str, ...]:
8
+ return (spec,) if isinstance(spec, str) else tuple(spec)
9
+
10
+ def make_unique_indexes(
11
+ model: type,
12
+ *,
13
+ unique_cs: Iterable[ColumnSpec] = (),
14
+ unique_ci: Iterable[ColumnSpec] = (),
15
+ tenant_field: Optional[str] = None,
16
+ name_prefix: str = "uq",
17
+ ) -> List[Index]:
18
+ """Return SQLAlchemy Index objects that enforce uniqueness.
19
+
20
+ - unique_cs: case-sensitive unique specs
21
+ - unique_ci: case-insensitive unique specs (lower(column))
22
+ - tenant_field: if provided, create two partial unique indexes:
23
+ * tenant IS NULL (global bucket)
24
+ * tenant IS NOT NULL (scoped per-tenant)
25
+
26
+ Declare right after your model class; Alembic or metadata.create_all will pick them up.
27
+ """
28
+ idxs: List[Index] = []
29
+
30
+ def _col(name: str):
31
+ return getattr(model, name)
32
+
33
+ def _to_sa_cols(spec: Tuple[str, ...], *, ci: bool):
34
+ cols = []
35
+ for cname in spec:
36
+ c = _col(cname)
37
+ cols.append(func.lower(c) if ci else c)
38
+ return tuple(cols)
39
+
40
+ tenant_col = _col(tenant_field) if tenant_field else None
41
+
42
+ def _name(ci: bool, spec: Tuple[str, ...], null_bucket: Optional[str] = None):
43
+ parts = [name_prefix, model.__tablename__]
44
+ if tenant_field:
45
+ parts.append(tenant_field)
46
+ if null_bucket:
47
+ parts.append(null_bucket)
48
+ parts.append("ci" if ci else "cs")
49
+ parts.extend(spec)
50
+ return "_".join(parts)
51
+
52
+ for ci, spec_list in ((False, unique_cs), (True, unique_ci)):
53
+ for spec in spec_list:
54
+ spec_t = _as_tuple(spec)
55
+ cols = _to_sa_cols(spec_t, ci=ci)
56
+
57
+ if tenant_col is None:
58
+ idxs.append(Index(_name(ci, spec_t), *cols, unique=True))
59
+ else:
60
+ idxs.append(
61
+ Index(
62
+ _name(ci, spec_t, "null"),
63
+ *cols,
64
+ unique=True,
65
+ postgresql_where=tenant_col.is_(None),
66
+ )
67
+ )
68
+ idxs.append(
69
+ Index(
70
+ _name(ci, spec_t, "notnull"),
71
+ tenant_col,
72
+ *cols,
73
+ unique=True,
74
+ postgresql_where=tenant_col.isnot(None),
75
+ )
76
+ )
77
+ return idxs
@@ -1,29 +0,0 @@
1
- from __future__ import annotations
2
- from datetime import datetime
3
- from typing import Optional
4
- import uuid
5
-
6
- from sqlalchemy import String, Boolean, DateTime, JSON, Text, func, UniqueConstraint, Index
7
- from sqlalchemy.dialects.postgresql import UUID
8
- from sqlalchemy.orm import Mapped, mapped_column
9
- from sqlalchemy.ext.mutable import MutableDict
10
-
11
- from svc_infra.db.base import ModelBase
12
-
13
-
14
- class ${Entity}(ModelBase):
15
- __tablename__ = "${table_name}"
16
-
17
- id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
18
- name: Mapped[str] = mapped_column(String(255), nullable=False)
19
- description: Mapped[Optional[str]] = mapped_column(Text)
20
-
21
- ${tenant_field}${soft_delete_field} extra: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSON), default=dict)
22
-
23
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
24
- updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
25
-
26
- ${constraints} def __repr__(self) -> str:
27
- return f"<${Entity} id={self.id} name={self.name!r}>"
28
-
29
- ${indexes}
File without changes