svc-infra 0.1.181__py3-none-any.whl → 0.1.183__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.
@@ -1,21 +1,41 @@
1
1
  from typing import Any, Dict
2
+ from fastapi import HTTPException
2
3
  from fastapi_users.password import PasswordHelper
4
+ from sqlalchemy import func
3
5
 
4
6
  from svc_infra.api.fastapi.db.service_hooks import ServiceWithHooks
5
7
  from svc_infra.api.fastapi.db.repository import Repository
6
8
 
7
9
  _pwd = PasswordHelper()
8
10
 
9
- def _pre_create(data: Dict[str, Any]) -> Dict[str, Any]:
11
+ def _user_pre_create(data: Dict[str, Any]) -> Dict[str, Any]:
10
12
  data = dict(data)
13
+ # normalize + map fields
11
14
  if "password" in data:
12
15
  data["password_hash"] = _pwd.hash(data.pop("password"))
13
- if "metadata" in data: # pydantic alias -> model column "metadata" (extra)
16
+ if "metadata" in data:
14
17
  data["extra"] = data.pop("metadata")
15
- data.setdefault("roles", [])
18
+
19
+ # BEFORE insert: application-level guard (nice error)
20
+ # case-insensitive email; handle tenant/null logic the same way as your unique index
21
+ email = data.get("email")
22
+ tenant_id = data.get("tenant_id")
23
+ if email:
24
+ where = [func.lower(Repository.model.email) == func.lower(email)]
25
+ if tenant_id is None:
26
+ where.append(Repository.model.tenant_id.is_(None))
27
+ else:
28
+ where.append(Repository.model.tenant_id == tenant_id)
29
+
30
+ # use a small “exists” helper on the repo if you have it
31
+ async def _exists(session):
32
+ return await Repository.exists(session, where=where)
33
+
34
+ data["_precreate_exists_check"] = _exists # stash callable for service.create to run
35
+
16
36
  return data
17
37
 
18
- def _pre_update(data: Dict[str, Any]) -> Dict[str, Any]:
38
+ def _user_pre_update(data: Dict[str, Any]) -> Dict[str, Any]:
19
39
  data = dict(data)
20
40
  if "password" in data:
21
41
  data["password_hash"] = _pwd.hash(data.pop("password"))
@@ -24,4 +44,10 @@ def _pre_update(data: Dict[str, Any]) -> Dict[str, Any]:
24
44
  return data
25
45
 
26
46
  def make_default_user_service(repo: Repository):
27
- return ServiceWithHooks(repo, pre_create=_pre_create, pre_update=_pre_update)
47
+ class _Svc(ServiceWithHooks):
48
+ async def create(self, session, data):
49
+ exists_cb = data.pop("_precreate_exists_check", None)
50
+ if exists_cb and await exists_cb(session):
51
+ raise HTTPException(status_code=409, detail="User with this email already exists.")
52
+ return await super().create(session, data)
53
+ return _Svc(repo, pre_create=_user_pre_create, pre_update=_user_pre_update)
@@ -4,14 +4,16 @@ from typing import Optional
4
4
  import uuid
5
5
 
6
6
  from sqlalchemy import (
7
- String, Boolean, DateTime, JSON, Text, func, UniqueConstraint, Index
7
+ String, Boolean, DateTime, JSON, Text, func, Index
8
8
  )
9
9
  from sqlalchemy.dialects.postgresql import UUID
10
10
  from sqlalchemy.orm import Mapped, mapped_column
11
- from sqlalchemy.ext.mutable import MutableDict, MutableList # <-- add
11
+ 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
16
+
15
17
 
16
18
  class User(ModelBase):
17
19
  __tablename__ = "users"
@@ -24,10 +26,12 @@ class User(ModelBase):
24
26
  is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
25
27
  is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
26
28
 
29
+ # auth state
27
30
  password_hash: Mapped[str] = mapped_column(String(512), nullable=False)
28
31
 
32
+ # Write-only facade over password
29
33
  @property
30
- def password(self) -> str: # never readable
34
+ def password(self) -> str:
31
35
  raise AttributeError("password is write-only")
32
36
 
33
37
  @password.setter
@@ -53,12 +57,16 @@ class User(ModelBase):
53
57
  DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
54
58
  )
55
59
 
56
- __table_args__ = (
57
- UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
58
- )
59
-
60
60
  def __repr__(self) -> str:
61
61
  return f"<User id={self.id} email={self.email!r}>"
62
62
 
63
- # define functional index *after* class definition
64
- Index("ix_users_email_lower", func.lower(User.email))
63
+
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
@@ -1,29 +1,52 @@
1
+ # models/${table_name}.py
1
2
  from __future__ import annotations
2
3
  from datetime import datetime
3
4
  from typing import Optional
4
5
  import uuid
5
6
 
6
- from sqlalchemy import String, Boolean, DateTime, JSON, Text, func, UniqueConstraint, Index
7
+ from sqlalchemy import String, Boolean, DateTime, JSON, Text, func
7
8
  from sqlalchemy.dialects.postgresql import UUID
8
9
  from sqlalchemy.orm import Mapped, mapped_column
9
10
  from sqlalchemy.ext.mutable import MutableDict
10
11
 
11
12
  from svc_infra.db.base import ModelBase
13
+ from svc_infra.db.uniq import make_unique_indexes
12
14
 
13
15
 
14
16
  class ${Entity}(ModelBase):
15
17
  __tablename__ = "${table_name}"
16
18
 
19
+ # identity
17
20
  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)
21
+
22
+ # core fields
23
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
19
24
  description: Mapped[Optional[str]] = mapped_column(Text)
20
25
 
21
- ${tenant_field}${soft_delete_field} extra: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSON), default=dict)
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)
22
30
 
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)
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
+ )
25
38
 
26
- ${constraints} def __repr__(self) -> str:
39
+ def __repr__(self) -> str:
27
40
  return f"<${Entity} id={self.id} name={self.name!r}>"
28
41
 
29
- ${indexes}
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
svc_infra/db/uniq.py ADDED
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+ from typing import Iterable, Sequence, Tuple, Union, List, Optional
3
+ from sqlalchemy import Index, func
4
+
5
+
6
+ ColumnSpec = Union[str, Sequence[str]] # "email" or ("first_name","last_name")
7
+
8
+
9
+ def _as_tuple(spec: ColumnSpec) -> Tuple[str, ...]:
10
+ return (spec,) if isinstance(spec, str) else tuple(spec)
11
+
12
+
13
+ def make_unique_indexes(
14
+ model: type,
15
+ *,
16
+ # Case-sensitive uniqueness specs (exact match)
17
+ unique_cs: Iterable[ColumnSpec] = (),
18
+ # Case-insensitive uniqueness specs: lower() applied to string columns
19
+ unique_ci: Iterable[ColumnSpec] = (),
20
+ # Optional tenant scoping (e.g. "tenant_id"). If provided:
21
+ # - when tenant is NULL -> global uniqueness
22
+ # - when tenant is NOT NULL -> uniqueness within tenant
23
+ tenant_field: Optional[str] = None,
24
+ # Prefix for index names
25
+ name_prefix: str = "uq",
26
+ ) -> List[Index]:
27
+ """
28
+ Returns a list of SQLAlchemy Index objects enforcing uniqueness.
29
+
30
+ Notes:
31
+ - For case-insensitive specs, we use functional unique indexes with lower(col).
32
+ - If tenant_field is given and the tenant column is nullable, we generate two partial
33
+ unique indexes: one for tenant IS NULL (global), one for tenant IS NOT NULL (scoped).
34
+ - Attach these indexes after your model class definition, before migrations/DDL run:
35
+ Indexes = make_unique_indexes(User, unique_ci=["email"], tenant_field="tenant_id")
36
+ for ix in Indexes:
37
+ ix.create(model.metadata.bind) # OR just rely on Alembic autogenerate / metadata.create_all
38
+ Most apps just *define* them at module import time:
39
+ for ix in Indexes: # they auto-register with the Table; no manual create() needed
40
+ pass
41
+ """
42
+ idxs: List[Index] = []
43
+
44
+ def _col(name: str):
45
+ return getattr(model, name)
46
+
47
+ def _to_sa_cols(spec: Tuple[str, ...], *, ci: bool):
48
+ cols = []
49
+ for cname in spec:
50
+ c = _col(cname)
51
+ cols.append(func.lower(c) if ci else c)
52
+ return tuple(cols)
53
+
54
+ tenant_col = _col(tenant_field) if tenant_field else None
55
+
56
+ # Helper: name like uq_<table>_<tenant?>_<ci/cs>_<joined-columns>
57
+ def _name(ci: bool, spec: Tuple[str, ...], null_bucket: Optional[str] = None):
58
+ parts = [name_prefix, model.__tablename__]
59
+ if tenant_field:
60
+ parts.append(tenant_field)
61
+ if null_bucket:
62
+ parts.append(null_bucket)
63
+ parts.append("ci" if ci else "cs")
64
+ parts.extend(spec)
65
+ return "_".join(parts)
66
+
67
+ # Build indexes for both CS and CI specs
68
+ for ci, spec_list in ((False, unique_cs), (True, unique_ci)):
69
+ for spec in spec_list:
70
+ spec_t = _as_tuple(spec)
71
+ cols = _to_sa_cols(spec_t, ci=ci)
72
+
73
+ if tenant_col is None:
74
+ # simple global unique (with or without lower())
75
+ idxs.append(Index(_name(ci, spec_t), *cols, unique=True))
76
+ else:
77
+ # two partial unique indexes to treat NULL tenant as its own bucket
78
+ # 1) global bucket (tenant IS NULL)
79
+ idxs.append(
80
+ Index(
81
+ _name(ci, spec_t, "null"),
82
+ *cols,
83
+ unique=True,
84
+ postgresql_where=tenant_col.is_(None),
85
+ )
86
+ )
87
+ # 2) per-tenant bucket (tenant IS NOT NULL)
88
+ idxs.append(
89
+ Index(
90
+ _name(ci, spec_t, "notnull"),
91
+ tenant_col,
92
+ *cols,
93
+ unique=True,
94
+ postgresql_where=tenant_col.isnot(None),
95
+ )
96
+ )
97
+
98
+ return idxs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: svc-infra
3
- Version: 0.1.181
3
+ Version: 0.1.183
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
@@ -31,7 +31,7 @@ svc_infra/auth/integration.py,sha256=L230xUw7vRM0AJIZwNzufRQrfNTPRdczp4BPd_NILeo
31
31
  svc_infra/auth/oauth_router.py,sha256=JY3VQ9_0oS_a2gGg418IDG2TbK79sQWcpA9KPiOAfjY,4918
32
32
  svc_infra/auth/providers.py,sha256=fiw0ouuGKtwcwMY0Zw7cEv-CXNaUYDnqo_NmiWiB8Lc,3185
33
33
  svc_infra/auth/settings.py,sha256=wVCLQnpA9M6zfiWyUpSdn9fo4q-Z4WpVyC3KvkG3HYg,1742
34
- svc_infra/auth/user_default.py,sha256=Jn2XeeW8QgijZfxXUsV4T4CvaewsWA6VsDVqotYIgxQ,968
34
+ svc_infra/auth/user_default.py,sha256=1GKxiJNUFKKghtGiIQKdC_rD1cs4vVye0zUTSPMrrDg,2081
35
35
  svc_infra/auth/users.py,sha256=4rlTCELU-po7bF7u6z02Bd8pXLGcLI2Adj0VjA-gg5E,2098
36
36
  svc_infra/cli/__init__.py,sha256=g47RSROuFW8LSsB6WFNSus5BnUl9z_DrPK9idYo3O6M,401
37
37
  svc_infra/cli/cmds/__init__.py,sha256=263YKSg73Ik-SQeilyGePdc663BYi4RfBrc0wMFxeoU,212
@@ -49,15 +49,16 @@ svc_infra/db/core.py,sha256=3Efm88fajfKJa8gDiiTVkhx2ci_UbrLiR4B8gMQYuwU,10745
49
49
  svc_infra/db/scaffold.py,sha256=NBz8BJR8MPvntCGv5HKoqNNR41SMnCkb-DlCCIsu0fo,9878
50
50
  svc_infra/db/templates/__init__.py,sha256=3PRa4v05RGyjX6LZXSOf-eMRir8vij7tET95limMips,45
51
51
  svc_infra/db/templates/models_schemas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
- svc_infra/db/templates/models_schemas/auth/models.py.tmpl,sha256=oBr-xVlR5OgeMTztd1qPmPc4TLjdHLJyieATkheo710,2514
52
+ svc_infra/db/templates/models_schemas/auth/models.py.tmpl,sha256=CR5TqE_S6xaVgotLF3jSFuigxL8V_aoMwubttnEx2js,2776
53
53
  svc_infra/db/templates/models_schemas/auth/schemas.py.tmpl,sha256=w79j8GeRPpywg9OVBmEuoksPVZLf9hUfEcRS4hBbiJQ,1717
54
- svc_infra/db/templates/models_schemas/entity/models.py.tmpl,sha256=dZk7a3WwpddSylPW81ARTyjbqJ-4rduX8-KAZ3Tlomo,1173
54
+ svc_infra/db/templates/models_schemas/entity/models.py.tmpl,sha256=PXy8S9Zbd8JQWkSDMOdr-ubn_O1wNdZvFJdeOinoPV4,1777
55
55
  svc_infra/db/templates/models_schemas/entity/schemas.py.tmpl,sha256=zBhid1jFPwVN86OvkiIJkTFphN7LDcs_Hs9kxxeoNjE,1009
56
56
  svc_infra/db/templates/setup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
57
  svc_infra/db/templates/setup/alembic.ini.tmpl,sha256=QAQ_3-p8GnrpLoX2g-Fpi6aMfrS3m4ZMJ34OTfujnrI,780
58
58
  svc_infra/db/templates/setup/env_async.py.tmpl,sha256=05uDe_jiEkJOdXcSIgJovN3TxPG8wm8BjUoSdktsH_0,6224
59
59
  svc_infra/db/templates/setup/env_sync.py.tmpl,sha256=vlUKrhnQiBqAGDfbtx0D_CahF1Hjj8uTIQ5lNnx_l0Q,6273
60
60
  svc_infra/db/templates/setup/script.py.mako.tmpl,sha256=EgltN1ghYU7rH1zAujsLWLe1NThI-41x9fabByne5II,509
61
+ svc_infra/db/uniq.py,sha256=9YCqTPp-Aexk7XJB-df_avs2-UvFUKa8YfQ94nQJKC0,3800
61
62
  svc_infra/db/utils.py,sha256=PxaZ6AesRlDyQk7tOzi9YaeF7Ge4zsZP83wqkKHu5-E,30838
62
63
  svc_infra/mcp/__init__.py,sha256=SiJ50LO7J6AneYswZe1kUX3dKXiECPpsjGPjT0SGtuc,1272
63
64
  svc_infra/mcp/svc_infra_mcp.py,sha256=VTU8esJ4FH9WNgxplufUWCVqGkxwscaLnBBtj-B5iCQ,1343
@@ -75,7 +76,7 @@ svc_infra/observability/templates/prometheus_rules.yml,sha256=sbVLm1h40FMkGSeWO4
75
76
  svc_infra/observability/tracing/__init__.py,sha256=TOs2yCicqBdo4OfOHTMmqeHsn7DBRu5EdvF2L5f31Y0,237
76
77
  svc_infra/observability/tracing/setup.py,sha256=21Ob276U4KZOs6M2o1O79wQXFHV0gY6YdMyjeqMrzMU,5042
77
78
  svc_infra/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
78
- svc_infra-0.1.181.dist-info/METADATA,sha256=ROhEqWKfcG8dG1UciKFoMaldw79uud74mfs0ic6pdTU,4981
79
- svc_infra-0.1.181.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
80
- svc_infra-0.1.181.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
81
- svc_infra-0.1.181.dist-info/RECORD,,
79
+ svc_infra-0.1.183.dist-info/METADATA,sha256=DYUWWxezbPlw5QrZXXjPNjS_fjWD0zTBW9hxcv86bSw,4981
80
+ svc_infra-0.1.183.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
81
+ svc_infra-0.1.183.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
82
+ svc_infra-0.1.183.dist-info/RECORD,,