svc-infra 0.1.180__tar.gz → 0.1.182__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 (81) hide show
  1. {svc_infra-0.1.180 → svc_infra-0.1.182}/PKG-INFO +1 -1
  2. {svc_infra-0.1.180 → svc_infra-0.1.182}/pyproject.toml +1 -1
  3. svc_infra-0.1.182/src/svc_infra/auth/user_default.py +53 -0
  4. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/templates/models_schemas/auth/models.py.tmpl +26 -8
  5. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/templates/models_schemas/auth/schemas.py.tmpl +3 -1
  6. svc_infra-0.1.180/src/svc_infra/auth/user_default.py +0 -27
  7. {svc_infra-0.1.180 → svc_infra-0.1.182}/README.md +0 -0
  8. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/__init__.py +0 -0
  9. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/__init__.py +0 -0
  10. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/__init__.py +0 -0
  11. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/db/README.md +0 -0
  12. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/db/__init__.py +0 -0
  13. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/db/add.py +0 -0
  14. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/db/crud_router.py +0 -0
  15. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/db/health.py +0 -0
  16. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/db/http.py +0 -0
  17. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/db/management.py +0 -0
  18. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/db/repository.py +0 -0
  19. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/db/resource.py +0 -0
  20. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/db/service.py +0 -0
  21. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/db/service_hooks.py +0 -0
  22. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/db/session.py +0 -0
  23. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/middleware/__init__.py +0 -0
  24. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/middleware/errors/__init__.py +0 -0
  25. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/middleware/errors/catchall.py +0 -0
  26. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/middleware/errors/error_handlers.py +0 -0
  27. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/middleware/errors/exceptions.py +0 -0
  28. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/routers/__init__.py +0 -0
  29. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/routers/ping.py +0 -0
  30. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/api/fastapi/settings.py +0 -0
  31. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/app/__init__.py +0 -0
  32. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/app/env.py +0 -0
  33. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/app/logging.py +0 -0
  34. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/app/root.py +0 -0
  35. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/app/settings.py +0 -0
  36. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/auth/__init__.py +0 -0
  37. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/auth/integration.py +0 -0
  38. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/auth/oauth_router.py +0 -0
  39. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/auth/providers.py +0 -0
  40. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/auth/settings.py +0 -0
  41. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/auth/users.py +0 -0
  42. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/cli/__init__.py +0 -0
  43. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/cli/cmds/__init__.py +0 -0
  44. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/cli/cmds/alembic_cmds.py +0 -0
  45. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/cli/cmds/help.py +0 -0
  46. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/cli/cmds/scaffold_cmds.py +0 -0
  47. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/cli/foundation/__init__.py +0 -0
  48. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/cli/foundation/runner.py +0 -0
  49. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/cli/foundation/typer_bootstrap.py +0 -0
  50. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/README.md +0 -0
  51. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/__init__.py +0 -0
  52. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/base.py +0 -0
  53. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/constants.py +0 -0
  54. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/core.py +0 -0
  55. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/scaffold.py +0 -0
  56. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/templates/__init__.py +0 -0
  57. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/templates/models_schemas/__init__.py +0 -0
  58. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/templates/models_schemas/entity/models.py.tmpl +0 -0
  59. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/templates/models_schemas/entity/schemas.py.tmpl +0 -0
  60. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/templates/setup/__init__.py +0 -0
  61. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/templates/setup/alembic.ini.tmpl +0 -0
  62. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/templates/setup/env_async.py.tmpl +0 -0
  63. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/templates/setup/env_sync.py.tmpl +0 -0
  64. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/templates/setup/script.py.mako.tmpl +0 -0
  65. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/db/utils.py +0 -0
  66. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/mcp/__init__.py +0 -0
  67. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/mcp/svc_infra_mcp.py +0 -0
  68. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/observability/README.md +0 -0
  69. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/observability/__init__.py +0 -0
  70. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/observability/metrics/__init__.py +0 -0
  71. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/observability/metrics/asgi.py +0 -0
  72. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/observability/metrics/base.py +0 -0
  73. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/observability/metrics/http.py +0 -0
  74. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/observability/metrics/sqlalchemy.py +0 -0
  75. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/observability/settings.py +0 -0
  76. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/observability/templates/grafana_dashboard.json +0 -0
  77. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/observability/templates/otel-collector.yaml +0 -0
  78. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/observability/templates/prometheus_rules.yml +0 -0
  79. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/observability/tracing/__init__.py +0 -0
  80. {svc_infra-0.1.180 → svc_infra-0.1.182}/src/svc_infra/observability/tracing/setup.py +0 -0
  81. {svc_infra-0.1.180 → svc_infra-0.1.182}/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.180
3
+ Version: 0.1.182
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.180"
3
+ version = "0.1.182"
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,53 @@
1
+ from typing import Any, Dict
2
+ from fastapi import HTTPException
3
+ from fastapi_users.password import PasswordHelper
4
+ from sqlalchemy import func, and_, or_
5
+
6
+ from svc_infra.api.fastapi.db.service_hooks import ServiceWithHooks
7
+ from svc_infra.api.fastapi.db.repository import Repository
8
+
9
+ _pwd = PasswordHelper()
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
+ def make_default_user_service(repo: Repository):
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,15 +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
15
 
16
+
16
17
  class User(ModelBase):
17
18
  __tablename__ = "users"
18
19
 
@@ -24,10 +25,12 @@ class User(ModelBase):
24
25
  is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
25
26
  is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
26
27
 
28
+ # auth state
27
29
  password_hash: Mapped[str] = mapped_column(String(512), nullable=False)
28
30
 
31
+ # Write-only facade over password
29
32
  @property
30
- def password(self) -> str: # never readable
33
+ def password(self) -> str:
31
34
  raise AttributeError("password is write-only")
32
35
 
33
36
  @password.setter
@@ -53,12 +56,27 @@ class User(ModelBase):
53
56
  DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
54
57
  )
55
58
 
56
- __table_args__ = (
57
- UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
58
- )
59
-
60
59
  def __repr__(self) -> str:
61
60
  return f"<User id={self.id} email={self.email!r}>"
62
61
 
63
- # define functional index *after* class definition
62
+
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)
64
82
  Index("ix_users_email_lower", func.lower(User.email))
@@ -1,4 +1,6 @@
1
1
  from __future__ import annotations
2
+
3
+ from uuid import UUID
2
4
  from typing import Optional, Any, Dict, List
3
5
  from datetime import datetime
4
6
  from pydantic import BaseModel, EmailStr, Field, ConfigDict
@@ -21,7 +23,7 @@ class UserBase(BaseModel):
21
23
  metadata: Optional[Dict[str, Any]] = Field(default=None, alias="extra") # <-- matches model.extra
22
24
 
23
25
  class UserRead(UserBase, Timestamped):
24
- id: str
26
+ id: UUID
25
27
  last_login: Optional[datetime] = None
26
28
  disabled_reason: Optional[str] = None
27
29
 
@@ -1,27 +0,0 @@
1
- from typing import Any, Dict
2
- from fastapi_users.password import PasswordHelper
3
-
4
- from svc_infra.api.fastapi.db.service_hooks import ServiceWithHooks
5
- from svc_infra.api.fastapi.db.repository import Repository
6
-
7
- _pwd = PasswordHelper()
8
-
9
- def _pre_create(data: Dict[str, Any]) -> Dict[str, Any]:
10
- data = dict(data)
11
- if "password" in data:
12
- data["password_hash"] = _pwd.hash(data.pop("password"))
13
- if "metadata" in data: # pydantic alias -> model column "metadata" (extra)
14
- data["extra"] = data.pop("metadata")
15
- data.setdefault("roles", [])
16
- return data
17
-
18
- def _pre_update(data: Dict[str, Any]) -> Dict[str, Any]:
19
- data = dict(data)
20
- if "password" in data:
21
- data["password_hash"] = _pwd.hash(data.pop("password"))
22
- if "metadata" in data:
23
- data["extra"] = data.pop("metadata")
24
- return data
25
-
26
- def make_default_user_service(repo: Repository):
27
- return ServiceWithHooks(repo, pre_create=_pre_create, pre_update=_pre_update)
File without changes