svc-infra 0.1.183__py3-none-any.whl → 0.1.185__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.
@@ -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
@@ -8,46 +8,74 @@ from svc_infra.api.fastapi.db.repository import Repository
8
8
 
9
9
  _pwd = PasswordHelper()
10
10
 
11
- def _user_pre_create(data: Dict[str, Any]) -> Dict[str, Any]:
12
- data = dict(data)
13
- # normalize + map fields
14
- if "password" in data:
15
- data["password_hash"] = _pwd.hash(data.pop("password"))
16
- if "metadata" in data:
17
- data["extra"] = data.pop("metadata")
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
-
36
- return data
37
-
38
- def _user_pre_update(data: Dict[str, Any]) -> Dict[str, Any]:
39
- data = dict(data)
40
- if "password" in data:
41
- data["password_hash"] = _pwd.hash(data.pop("password"))
42
- if "metadata" in data:
43
- data["extra"] = data.pop("metadata")
44
- return data
45
-
46
11
  def make_default_user_service(repo: Repository):
12
+ Model = repo.model # <-- capture the actual mapped model from the repo
13
+
14
+ def _user_pre_create(data: Dict[str, Any]) -> Dict[str, Any]:
15
+ data = dict(data)
16
+ # normalize + map fields
17
+ if "password" in data:
18
+ data["password_hash"] = _pwd.hash(data.pop("password"))
19
+ if "metadata" in data:
20
+ data["extra"] = data.pop("metadata")
21
+ data.setdefault("roles", [])
22
+
23
+ # BEFORE insert: app-level guard (nice 409) aligned with DB uniqueness
24
+ email = data.get("email")
25
+ tenant_id = data.get("tenant_id")
26
+ if email is not None:
27
+ # case-insensitive email; scope by tenant (NULL => global)
28
+ where = [func.lower(Model.email) == func.lower(email)]
29
+ if hasattr(Model, "tenant_id"):
30
+ if tenant_id is None:
31
+ where.append(Model.tenant_id.is_(None))
32
+ else:
33
+ where.append(Model.tenant_id == tenant_id)
34
+
35
+ async def _exists(session):
36
+ return await repo.exists(session, where=where)
37
+
38
+ # stash the callback so Service.create can await it (has session)
39
+ data["_precreate_exists_check"] = _exists
40
+
41
+ return data
42
+
43
+ def _user_pre_update(data: Dict[str, Any]) -> Dict[str, Any]:
44
+ data = dict(data)
45
+ if "password" in data:
46
+ data["password_hash"] = _pwd.hash(data.pop("password"))
47
+ if "metadata" in data:
48
+ data["extra"] = data.pop("metadata")
49
+
50
+ # Optional: also protect email change from creating dupes
51
+ email = data.get("email")
52
+ tenant_id = data.get("tenant_id")
53
+ if email is not None:
54
+ where = [func.lower(Model.email) == func.lower(email)]
55
+ if hasattr(Model, "tenant_id"):
56
+ if tenant_id is None:
57
+ where.append(Model.tenant_id.is_(None))
58
+ else:
59
+ where.append(Model.tenant_id == tenant_id)
60
+
61
+ async def _exists(session):
62
+ return await repo.exists(session, where=where)
63
+
64
+ data["_precreate_exists_check"] = _exists # reuse same key
65
+
66
+ return data
67
+
47
68
  class _Svc(ServiceWithHooks):
48
69
  async def create(self, session, data):
49
70
  exists_cb = data.pop("_precreate_exists_check", None)
50
71
  if exists_cb and await exists_cb(session):
51
72
  raise HTTPException(status_code=409, detail="User with this email already exists.")
52
73
  return await super().create(session, data)
74
+
75
+ async def update(self, session, id_value, data):
76
+ exists_cb = data.pop("_precreate_exists_check", None)
77
+ if exists_cb and await exists_cb(session):
78
+ raise HTTPException(status_code=409, detail="User with this email already exists.")
79
+ return await super().update(session, id_value, data)
80
+
53
81
  return _Svc(repo, pre_create=_user_pre_create, pre_update=_user_pre_update)
svc_infra/db/uniq.py CHANGED
@@ -2,42 +2,28 @@ from __future__ import annotations
2
2
  from typing import Iterable, Sequence, Tuple, Union, List, Optional
3
3
  from sqlalchemy import Index, func
4
4
 
5
-
6
- ColumnSpec = Union[str, Sequence[str]] # "email" or ("first_name","last_name")
7
-
5
+ ColumnSpec = Union[str, Sequence[str]]
8
6
 
9
7
  def _as_tuple(spec: ColumnSpec) -> Tuple[str, ...]:
10
8
  return (spec,) if isinstance(spec, str) else tuple(spec)
11
9
 
12
-
13
10
  def make_unique_indexes(
14
11
  model: type,
15
12
  *,
16
- # Case-sensitive uniqueness specs (exact match)
17
13
  unique_cs: Iterable[ColumnSpec] = (),
18
- # Case-insensitive uniqueness specs: lower() applied to string columns
19
14
  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
15
  tenant_field: Optional[str] = None,
24
- # Prefix for index names
25
16
  name_prefix: str = "uq",
26
17
  ) -> List[Index]:
27
- """
28
- Returns a list of SQLAlchemy Index objects enforcing uniqueness.
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)
29
25
 
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
26
+ Declare right after your model class; Alembic or metadata.create_all will pick them up.
41
27
  """
42
28
  idxs: List[Index] = []
43
29
 
@@ -53,7 +39,6 @@ def make_unique_indexes(
53
39
 
54
40
  tenant_col = _col(tenant_field) if tenant_field else None
55
41
 
56
- # Helper: name like uq_<table>_<tenant?>_<ci/cs>_<joined-columns>
57
42
  def _name(ci: bool, spec: Tuple[str, ...], null_bucket: Optional[str] = None):
58
43
  parts = [name_prefix, model.__tablename__]
59
44
  if tenant_field:
@@ -64,18 +49,14 @@ def make_unique_indexes(
64
49
  parts.extend(spec)
65
50
  return "_".join(parts)
66
51
 
67
- # Build indexes for both CS and CI specs
68
52
  for ci, spec_list in ((False, unique_cs), (True, unique_ci)):
69
53
  for spec in spec_list:
70
54
  spec_t = _as_tuple(spec)
71
55
  cols = _to_sa_cols(spec_t, ci=ci)
72
56
 
73
57
  if tenant_col is None:
74
- # simple global unique (with or without lower())
75
58
  idxs.append(Index(_name(ci, spec_t), *cols, unique=True))
76
59
  else:
77
- # two partial unique indexes to treat NULL tenant as its own bucket
78
- # 1) global bucket (tenant IS NULL)
79
60
  idxs.append(
80
61
  Index(
81
62
  _name(ci, spec_t, "null"),
@@ -84,7 +65,6 @@ def make_unique_indexes(
84
65
  postgresql_where=tenant_col.is_(None),
85
66
  )
86
67
  )
87
- # 2) per-tenant bucket (tenant IS NOT NULL)
88
68
  idxs.append(
89
69
  Index(
90
70
  _name(ci, spec_t, "notnull"),
@@ -94,5 +74,4 @@ def make_unique_indexes(
94
74
  postgresql_where=tenant_col.isnot(None),
95
75
  )
96
76
  )
97
-
98
77
  return idxs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: svc-infra
3
- Version: 0.1.183
3
+ Version: 0.1.185
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
@@ -13,6 +13,7 @@ svc_infra/api/fastapi/db/resource.py,sha256=h-kCjp0_Sw_lGthizkEaneKyIZZXX3SCs6LF
13
13
  svc_infra/api/fastapi/db/service.py,sha256=w7WFzETndKuk-2wkOdjmGbznY8lJTzUP3781Dg42alE,2033
14
14
  svc_infra/api/fastapi/db/service_hooks.py,sha256=KZzkKL-2y7hrPx4bfcDdwItAqPBksyVFUjnx1hGJwxw,655
15
15
  svc_infra/api/fastapi/db/session.py,sha256=1ixBxRZLbdE8LXLP6R87u7pDzITZkZRYZ3Un8ugvJNY,1775
16
+ svc_infra/api/fastapi/db/uniq.py,sha256=f96o8-Sz1nZIPa9lKX5bniot8Kv0xP1_zRns8R3p2KY,3132
16
17
  svc_infra/api/fastapi/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
18
  svc_infra/api/fastapi/middleware/errors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
19
  svc_infra/api/fastapi/middleware/errors/catchall.py,sha256=Kvu7yrHu6r8wlyEblB8sH4Mh-l8FYPmwAGJeSXugYOM,1713
@@ -31,7 +32,7 @@ svc_infra/auth/integration.py,sha256=L230xUw7vRM0AJIZwNzufRQrfNTPRdczp4BPd_NILeo
31
32
  svc_infra/auth/oauth_router.py,sha256=JY3VQ9_0oS_a2gGg418IDG2TbK79sQWcpA9KPiOAfjY,4918
32
33
  svc_infra/auth/providers.py,sha256=fiw0ouuGKtwcwMY0Zw7cEv-CXNaUYDnqo_NmiWiB8Lc,3185
33
34
  svc_infra/auth/settings.py,sha256=wVCLQnpA9M6zfiWyUpSdn9fo4q-Z4WpVyC3KvkG3HYg,1742
34
- svc_infra/auth/user_default.py,sha256=1GKxiJNUFKKghtGiIQKdC_rD1cs4vVye0zUTSPMrrDg,2081
35
+ svc_infra/auth/user_default.py,sha256=D6oMni4YyFeb1muws0CokC20t-R0FwMH28HMmSEJnMw,3290
35
36
  svc_infra/auth/users.py,sha256=4rlTCELU-po7bF7u6z02Bd8pXLGcLI2Adj0VjA-gg5E,2098
36
37
  svc_infra/cli/__init__.py,sha256=g47RSROuFW8LSsB6WFNSus5BnUl9z_DrPK9idYo3O6M,401
37
38
  svc_infra/cli/cmds/__init__.py,sha256=263YKSg73Ik-SQeilyGePdc663BYi4RfBrc0wMFxeoU,212
@@ -58,7 +59,7 @@ svc_infra/db/templates/setup/alembic.ini.tmpl,sha256=QAQ_3-p8GnrpLoX2g-Fpi6aMfrS
58
59
  svc_infra/db/templates/setup/env_async.py.tmpl,sha256=05uDe_jiEkJOdXcSIgJovN3TxPG8wm8BjUoSdktsH_0,6224
59
60
  svc_infra/db/templates/setup/env_sync.py.tmpl,sha256=vlUKrhnQiBqAGDfbtx0D_CahF1Hjj8uTIQ5lNnx_l0Q,6273
60
61
  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
62
+ svc_infra/db/uniq.py,sha256=Pre3tdgXHw6I7HU13xlJPcCe8ymSs8kAdEyAYRfvsto,2614
62
63
  svc_infra/db/utils.py,sha256=PxaZ6AesRlDyQk7tOzi9YaeF7Ge4zsZP83wqkKHu5-E,30838
63
64
  svc_infra/mcp/__init__.py,sha256=SiJ50LO7J6AneYswZe1kUX3dKXiECPpsjGPjT0SGtuc,1272
64
65
  svc_infra/mcp/svc_infra_mcp.py,sha256=VTU8esJ4FH9WNgxplufUWCVqGkxwscaLnBBtj-B5iCQ,1343
@@ -76,7 +77,7 @@ svc_infra/observability/templates/prometheus_rules.yml,sha256=sbVLm1h40FMkGSeWO4
76
77
  svc_infra/observability/tracing/__init__.py,sha256=TOs2yCicqBdo4OfOHTMmqeHsn7DBRu5EdvF2L5f31Y0,237
77
78
  svc_infra/observability/tracing/setup.py,sha256=21Ob276U4KZOs6M2o1O79wQXFHV0gY6YdMyjeqMrzMU,5042
78
79
  svc_infra/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
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,,
80
+ svc_infra-0.1.185.dist-info/METADATA,sha256=PubOT8LUdQnRSSJXHLtHa4V1EgFEGLyDSIyAFVwe_pw,4981
81
+ svc_infra-0.1.185.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
82
+ svc_infra-0.1.185.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
83
+ svc_infra-0.1.185.dist-info/RECORD,,