svc-infra 0.1.615__py3-none-any.whl → 0.1.617__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.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

@@ -27,6 +27,10 @@ class SimpleRateLimitMiddleware(BaseHTTPMiddleware):
27
27
  limit_resolver=None,
28
28
  # If True, automatically scopes the bucket key by tenant id when available
29
29
  scope_by_tenant: bool = False,
30
+ # When True, allows unresolved tenant IDs to fall back to an "X-Tenant-Id" header value.
31
+ # Disabled by default to avoid trusting arbitrary client-provided headers which could
32
+ # otherwise be used to evade per-tenant limits when authentication fails.
33
+ allow_untrusted_tenant_header: bool = False,
30
34
  store: RateLimitStore | None = None,
31
35
  ):
32
36
  super().__init__(app)
@@ -34,6 +38,7 @@ class SimpleRateLimitMiddleware(BaseHTTPMiddleware):
34
38
  self.key_fn = key_fn or (lambda r: r.headers.get("X-API-Key") or r.client.host)
35
39
  self._limit_resolver = limit_resolver
36
40
  self.scope_by_tenant = scope_by_tenant
41
+ self._allow_untrusted_tenant_header = allow_untrusted_tenant_header
37
42
  self.store = store or InMemoryRateLimitStore(limit=limit)
38
43
 
39
44
  async def dispatch(self, request, call_next):
@@ -45,6 +50,9 @@ class SimpleRateLimitMiddleware(BaseHTTPMiddleware):
45
50
  tenant_id = await _resolve_tenant_id(request)
46
51
  except Exception:
47
52
  tenant_id = None
53
+ # Fallback: read from header only if explicitly trusted
54
+ if not tenant_id and self._allow_untrusted_tenant_header:
55
+ tenant_id = request.headers.get("X-Tenant-Id") or request.headers.get("X-Tenant-ID")
48
56
 
49
57
  key = self.key_fn(request)
50
58
  if self.scope_by_tenant and tenant_id:
@@ -0,0 +1,5 @@
1
+ # Bundled Docs
2
+
3
+ This directory contains a minimal set of Markdown files that the `svc-infra docs` CLI can fall back to when the project running the CLI doesn't have a local `docs/` directory.
4
+
5
+ You can add more topics here as needed; each `*.md` file becomes a topic named after its stem (e.g., `getting-started.md` -> `getting-started`).
@@ -0,0 +1 @@
1
+ # Bundled docs package for zip-safe importlib.resources access
@@ -0,0 +1,6 @@
1
+ # Getting Started
2
+
3
+ Welcome to svc-infra docs. Use `svc-infra docs list` to see topics.
4
+
5
+ - This content is bundled with the package.
6
+ - If your project doesn't have a local `docs/` folder, you'll still see this.
@@ -1,69 +1,135 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  from pathlib import Path
4
- from typing import Dict, List, Tuple
5
+ from typing import Dict, List
5
6
 
7
+ import click
6
8
  import typer
9
+ from typer.core import TyperGroup
7
10
 
8
11
  from svc_infra.app.root import resolve_project_root
9
12
 
10
13
 
11
- def _discover_docs(root: Path) -> List[Tuple[str, Path]]:
12
- """Return a list of (topic, file_path) for top-level Markdown files under docs/.
13
-
14
- Topic is the filename stem (e.g. security.md -> "security").
15
- """
16
- docs_dir = root / "docs"
17
- topics: List[Tuple[str, Path]] = []
18
- if not docs_dir.exists() or not docs_dir.is_dir():
19
- return topics
20
- for p in sorted(docs_dir.glob("*.md")):
21
- if p.is_file():
22
- topics.append((p.stem.replace(" ", "-"), p))
14
+ def _discover_fs_topics(docs_dir: Path) -> Dict[str, Path]:
15
+ topics: Dict[str, Path] = {}
16
+ if docs_dir.exists() and docs_dir.is_dir():
17
+ for p in sorted(docs_dir.glob("*.md")):
18
+ if p.is_file():
19
+ topics[p.stem.replace(" ", "-")] = p
23
20
  return topics
24
21
 
25
22
 
26
- def register(app: typer.Typer) -> None:
27
- """Register the `docs` command group and dynamic topic subcommands."""
28
-
23
+ def _discover_pkg_topics() -> Dict[str, object]:
24
+ # No bundled fallback; docs are packaged from repo root 'docs/'
25
+ return {}
26
+
27
+
28
+ def _resolve_docs_dir(ctx: click.Context) -> Path | None:
29
+ # CLI option takes precedence; walk up parent contexts because Typer
30
+ # executes subcommands in child contexts that do not inherit params.
31
+ current: click.Context | None = ctx
32
+ while current is not None:
33
+ docs_dir = (current.params or {}).get("docs_dir")
34
+ if docs_dir:
35
+ path = docs_dir if isinstance(docs_dir, Path) else Path(docs_dir)
36
+ path = path.expanduser()
37
+ if path.exists():
38
+ return path
39
+ current = current.parent
40
+ # Env var next
41
+ env_dir = os.getenv("SVC_INFRA_DOCS_DIR")
42
+ if env_dir:
43
+ p = Path(env_dir).expanduser()
44
+ if p.exists():
45
+ return p
46
+ # Project docs
29
47
  root = resolve_project_root()
30
- discovered = _discover_docs(root)
48
+ proj_docs = root / "docs"
49
+ if proj_docs.exists():
50
+ return proj_docs
51
+ return None
31
52
 
32
- # Build help text listing available topics
33
- if discovered:
34
- topic_names = ", ".join(name for name, _ in discovered)
35
- docs_help = (
36
- f"Show docs from the repository's docs/ directory.\n\nAvailable topics: {topic_names}"
37
- )
38
- else:
39
- docs_help = "Show docs from the repository's docs/ directory.\n\nNo topics discovered."
40
53
 
41
- docs_app = typer.Typer(no_args_is_help=True, help=docs_help, add_completion=False)
54
+ class DocsGroup(TyperGroup):
55
+ def list_commands(self, ctx: click.Context) -> List[str]:
56
+ names: List[str] = list(super().list_commands(ctx) or [])
57
+ dir_to_use = _resolve_docs_dir(ctx)
58
+ fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
59
+ names.extend(fs.keys())
60
+ # Deduplicate and sort
61
+ uniq = sorted({*names})
62
+ return uniq
42
63
 
43
- @docs_app.command("list", help="List available documentation topics")
44
- def list_topics() -> None:
45
- for name, path in discovered:
46
- typer.echo(f"{name}\t{path.relative_to(root)}")
64
+ def get_command(self, ctx: click.Context, name: str) -> click.Command | None:
65
+ # Built-ins first (e.g., list)
66
+ cmd = super().get_command(ctx, name)
67
+ if cmd is not None:
68
+ return cmd
69
+
70
+ # Dynamic topic resolution
71
+ dir_to_use = _resolve_docs_dir(ctx)
72
+ fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
73
+ if name in fs:
74
+ file_path = fs[name]
75
+
76
+ @click.command(name=name)
77
+ def _show_fs() -> None:
78
+ click.echo(file_path.read_text(encoding="utf-8", errors="replace"))
47
79
 
48
- # Freeze mapping for use in dynamic commands and generic fallback
49
- topic_map: Dict[str, Path] = {name: path for name, path in discovered}
80
+ return _show_fs
50
81
 
51
- def _make_topic_cmd(topic: str, file_path: Path):
52
- @docs_app.command(name=topic, help=f"Show docs for topic: {topic}")
53
- def _show_topic() -> None: # noqa: WPS430 (nested function OK for closure)
54
- content = file_path.read_text(encoding="utf-8", errors="replace")
55
- typer.echo(content)
82
+ # No packaged fallback
56
83
 
57
- for name, path in discovered:
58
- _make_topic_cmd(name, path)
84
+ return None
85
+
86
+
87
+ def register(app: typer.Typer) -> None:
88
+ """Register the `docs` command group with dynamic topic subcommands."""
89
+
90
+ docs_app = typer.Typer(no_args_is_help=True, add_completion=False, cls=DocsGroup)
59
91
 
60
- # Optional generic fallback: allow `svc-infra docs --topic <name>`
61
92
  @docs_app.callback(invoke_without_command=True)
62
- def _maybe_show_topic(topic: str = typer.Option(None, "--topic", help="Topic to show")) -> None: # type: ignore[no-redef]
93
+ def _docs_options(
94
+ docs_dir: Path | None = typer.Option(
95
+ None,
96
+ "--docs-dir",
97
+ help="Path to a docs directory to read from (overrides env/project root)",
98
+ ),
99
+ topic: str | None = typer.Option(None, "--topic", help="Topic to show directly"),
100
+ ) -> None:
101
+ """Support --docs-dir and --topic at group level."""
63
102
  if topic:
64
- p = topic_map.get(topic)
65
- if not p:
66
- raise typer.BadParameter(f"Unknown topic: {topic}")
67
- typer.echo(p.read_text(encoding="utf-8", errors="replace"))
103
+ ctx = click.get_current_context()
104
+ dir_to_use = _resolve_docs_dir(ctx)
105
+ fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
106
+ if topic in fs:
107
+ typer.echo(fs[topic].read_text(encoding="utf-8", errors="replace"))
108
+ raise typer.Exit(code=0)
109
+ raise typer.BadParameter(f"Unknown topic: {topic}")
110
+
111
+ @docs_app.command("list", help="List available documentation topics")
112
+ def list_topics() -> None:
113
+ ctx = click.get_current_context()
114
+ root = resolve_project_root()
115
+ dir_to_use = _resolve_docs_dir(ctx)
116
+ fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
117
+ for name, path in fs.items():
118
+ try:
119
+ rel = path.relative_to(root)
120
+ typer.echo(f"{name}\t{rel}")
121
+ except Exception:
122
+ typer.echo(f"{name}\t{path}")
123
+
124
+ # Also support a generic "show" command
125
+ @docs_app.command("show", help="Show docs for a topic (alternative to dynamic subcommand)")
126
+ def show(topic: str) -> None:
127
+ ctx = click.get_current_context()
128
+ dir_to_use = _resolve_docs_dir(ctx)
129
+ fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
130
+ if topic in fs:
131
+ typer.echo(fs[topic].read_text(encoding="utf-8", errors="replace"))
132
+ return
133
+ raise typer.BadParameter(f"Unknown topic: {topic}")
68
134
 
69
135
  app.add_typer(docs_app, name="docs")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: svc-infra
3
- Version: 0.1.615
3
+ Version: 0.1.617
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
@@ -80,7 +80,7 @@ svc_infra/api/fastapi/middleware/errors/handlers.py,sha256=pQMVs5n627vcKkDFEaUzx
80
80
  svc_infra/api/fastapi/middleware/idempotency.py,sha256=vnBQgMWzJVaF8oWgfw2ATjEKCyQifDeGPUc9z1N7ebE,5051
81
81
  svc_infra/api/fastapi/middleware/idempotency_store.py,sha256=BQN_Cq_jf_cuZRhze4EF5v0lOMQXpUWoRo7CsSTprug,5528
82
82
  svc_infra/api/fastapi/middleware/optimistic_lock.py,sha256=9lOMBI4VNIVndXnrMmgSq4qeR7xPjNR1H9d1F71M5S8,1271
83
- svc_infra/api/fastapi/middleware/ratelimit.py,sha256=8A_J6JdU-cs9QYqP6Ufbp4vDkiH-H6CsZwege1nqf24,3855
83
+ svc_infra/api/fastapi/middleware/ratelimit.py,sha256=f-nvsh3wqLMpDEmwPsKvVnOrM3cfC9AqQyl8eUqrUQM,4496
84
84
  svc_infra/api/fastapi/middleware/ratelimit_store.py,sha256=LmJR8-kkW42rzOjls9lG1SBtCKjVY7L2Y_bNKHNY3-A,2553
85
85
  svc_infra/api/fastapi/middleware/request_id.py,sha256=Iru7ypTdK_n76lwziEGDWoVF4FKS0Ps1PMASYmzK8ek,768
86
86
  svc_infra/api/fastapi/middleware/request_size_limit.py,sha256=AcGqaB-F7Tbhg-at7ViT4Bpifst34jFneDBlUBjgo5I,1248
@@ -115,6 +115,9 @@ svc_infra/app/root.py,sha256=344EWBMJCduwzJ1BBo0yGAu15TkryuvOW4qBZ6Gk-8w,1635
115
115
  svc_infra/billing/__init__.py,sha256=AdVxgBWibsz0xWk-Z91B7HecA-EhPMSRrXWIYPBgtMA,365
116
116
  svc_infra/billing/models.py,sha256=bnCGPKfnK__6x0f0bwKYQsG2GwXjJFi3YRXnq5JYs7c,6083
117
117
  svc_infra/billing/service.py,sha256=3SDpPA3NF2lMYiOP4U99sgXpZAXaauexBfZQmYE2kvU,3727
118
+ svc_infra/bundled_docs/README.md,sha256=FqTieL4ADODxTnig8yehV2KdHX9bASDega52bjp5n70,338
119
+ svc_infra/bundled_docs/__init__.py,sha256=8_jF4fM-3Wf6j_mE4000_9AHcJ3tYZXO9hJY-pBEepM,63
120
+ svc_infra/bundled_docs/getting-started.md,sha256=JaMOgRUK_ajaX4SCtiE3GrhQ81wMwng6y46t0032ftU,210
118
121
  svc_infra/cache/README.md,sha256=ZgIpmE0UVlGktp2nXUYv6FKJATCdkR_01v-GGxHN6Ao,10795
119
122
  svc_infra/cache/__init__.py,sha256=Fz3NS81jrY5sLikRhITCeHDT4MlOLcbMed5EjVecSAg,956
120
123
  svc_infra/cache/backend.py,sha256=-dbZ2qkhebzbKosQqgvBNb01A-2_jGt6_0WmJhPoHy8,4418
@@ -139,7 +142,7 @@ svc_infra/cli/cmds/db/sql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJW
139
142
  svc_infra/cli/cmds/db/sql/alembic_cmds.py,sha256=kkAu8sfBLWbb9ApMS95b7b_c6GifqvPaRsO7K8icMVI,9649
140
143
  svc_infra/cli/cmds/db/sql/sql_export_cmds.py,sha256=6MxoQO-9upoXg0cl1RHIqz96yXFVGidiBYp_ewhB0E0,2700
141
144
  svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py,sha256=eNTCqHXOxgl9H3WTbGVn9BHXYwCpjIEJsDqhEFdrYMM,4613
142
- svc_infra/cli/cmds/docs/docs_cmds.py,sha256=3GCoRKjnRr_re__7TuelDbcaouJawHlz0AphYyI0DIE,2575
145
+ svc_infra/cli/cmds/docs/docs_cmds.py,sha256=A3LT__UffM8elPGTnaXmjNqsq8h4y2VVMMSzoqAeZeM,4668
143
146
  svc_infra/cli/cmds/dx/__init__.py,sha256=wQtl3-kOgoESlpVkjl3YFtqkOnQSIvVsOdutiaZFejM,197
144
147
  svc_infra/cli/cmds/dx/dx_cmds.py,sha256=XTKUJzS3UIYn6h3CHzDEWKYJaWn0TzGiUCq3OeW27E0,3326
145
148
  svc_infra/cli/cmds/help.py,sha256=wGfZFMYaR2ZPwW2JwKDU7M3m4AtdCd8GRQ412AmEBUM,758
@@ -293,7 +296,7 @@ svc_infra/webhooks/fastapi.py,sha256=BCNvGNxukf6dC2a4i-6en-PrjBGV19YvCWOot5lXWsA
293
296
  svc_infra/webhooks/router.py,sha256=6JvAVPMEth_xxHX-IsIOcyMgHX7g1H0OVxVXKLuMp9w,1596
294
297
  svc_infra/webhooks/service.py,sha256=hWgiJRXKBwKunJOx91C7EcLUkotDtD3Xp0RT6vj2IC0,1797
295
298
  svc_infra/webhooks/signing.py,sha256=NCwdZzmravUe7HVIK_uXK0qqf12FG-_MVsgPvOw6lsM,784
296
- svc_infra-0.1.615.dist-info/METADATA,sha256=VFmBWPFEQHoLsz2vTyHO2DMDkN9C70YlI3zAJolFyJg,8106
297
- svc_infra-0.1.615.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
298
- svc_infra-0.1.615.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
299
- svc_infra-0.1.615.dist-info/RECORD,,
299
+ svc_infra-0.1.617.dist-info/METADATA,sha256=gFtkUXgTuAGcECWRsaWvXwoxnTEqxyl2yTYw9zzYhSk,8106
300
+ svc_infra-0.1.617.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
301
+ svc_infra-0.1.617.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
302
+ svc_infra-0.1.617.dist-info/RECORD,,