sql-write-gate 0.16.0__tar.gz → 0.16.1__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 (68) hide show
  1. {sql_write_gate-0.16.0/src/sql_write_gate.egg-info → sql_write_gate-0.16.1}/PKG-INFO +5 -1
  2. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/README.md +4 -0
  3. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/pyproject.toml +1 -1
  4. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1/src/sql_write_gate.egg-info}/PKG-INFO +5 -1
  5. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/sql_write_gate.egg-info/SOURCES.txt +2 -0
  6. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/__init__.py +1 -1
  7. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/adapters/base.py +2 -2
  8. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/adapters/duckdb.py +2 -2
  9. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/approvals.py +3 -3
  10. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/audit.py +6 -5
  11. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/catalog.py +2 -2
  12. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/cli.py +3 -3
  13. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/config.py +2 -2
  14. sql_write_gate-0.16.1/src/write_gate/paths.py +99 -0
  15. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/wrapper.py +14 -6
  16. sql_write_gate-0.16.1/tests/test_installed_init_check.py +46 -0
  17. sql_write_gate-0.16.1/tests/test_paths_defaults.py +64 -0
  18. sql_write_gate-0.16.0/src/write_gate/paths.py +0 -20
  19. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/LICENSE +0 -0
  20. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/setup.cfg +0 -0
  21. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/sql_write_gate.egg-info/dependency_links.txt +0 -0
  22. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/sql_write_gate.egg-info/entry_points.txt +0 -0
  23. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/sql_write_gate.egg-info/requires.txt +0 -0
  24. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/sql_write_gate.egg-info/top_level.txt +0 -0
  25. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/__main__.py +0 -0
  26. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/adapters/__init__.py +0 -0
  27. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/adapters/mysql.py +0 -0
  28. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/adapters/postgres.py +0 -0
  29. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/adapters/sqlite.py +0 -0
  30. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/cases.py +0 -0
  31. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/db.py +0 -0
  32. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/decision.py +0 -0
  33. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/engine.py +0 -0
  34. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/guards/__init__.py +0 -0
  35. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/guards/blast_radius.py +0 -0
  36. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/guards/destructive.py +0 -0
  37. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/guards/environment.py +0 -0
  38. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/guards/freshness.py +0 -0
  39. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/guards/pii.py +0 -0
  40. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/guards/schema.py +0 -0
  41. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/hooks.py +0 -0
  42. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/init.py +0 -0
  43. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/mcp_server.py +0 -0
  44. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/mcp_tools.py +0 -0
  45. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/parser.py +0 -0
  46. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/policy.py +0 -0
  47. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/proxy.py +0 -0
  48. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/templates/GETTING_STARTED.md +0 -0
  49. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/templates/catalog.json +0 -0
  50. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/src/write_gate/templates/policy.yaml +0 -0
  51. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_adapters.py +0 -0
  52. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_approvals.py +0 -0
  53. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_audit.py +0 -0
  54. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_blast_radius.py +0 -0
  55. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_demo_cases.py +0 -0
  56. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_destructive.py +0 -0
  57. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_environment.py +0 -0
  58. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_hooks.py +0 -0
  59. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_init.py +0 -0
  60. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_mcp.py +0 -0
  61. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_mcp_execute.py +0 -0
  62. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_mysql.py +0 -0
  63. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_mysql_live.py +0 -0
  64. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_policy.py +0 -0
  65. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_postgres_live.py +0 -0
  66. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_proxy.py +0 -0
  67. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_readme.py +0 -0
  68. {sql_write_gate-0.16.0 → sql_write_gate-0.16.1}/tests/test_sqlite.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-write-gate
3
- Version: 0.16.0
3
+ Version: 0.16.1
4
4
  Summary: Policy firewall for AI agents writing to databases (sql-write-gate)
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -322,6 +322,10 @@ Tagged **v0.11.0** with [CHANGELOG.md](CHANGELOG.md) and a GitHub Release. No Py
322
322
 
323
323
  README badges (CI / Release / Python / License). Tagged **v0.13.0** with a GitHub Release. No PyPI. No product behavior change.
324
324
 
325
+ ## v0.16.1 — installed-path defaults
326
+
327
+ After `pip install sql-write-gate` and `sql-write-gate init`, bare `sql-write-gate check "DELETE FROM orders"` uses `./policy.yaml` + `./catalog.json` (no `FileNotFoundError` on a fake `seed/catalog.json`).
328
+
325
329
  ## v0.16 — PyPI package name `sql-write-gate`
326
330
 
327
331
  Version **0.16.0**. Distribution name on PyPI is **`sql-write-gate`** (was `write-gate` in earlier pyproject drafts). Import package stays `write_gate`; CLI entry stays `sql-write-gate`.
@@ -301,6 +301,10 @@ Tagged **v0.11.0** with [CHANGELOG.md](CHANGELOG.md) and a GitHub Release. No Py
301
301
 
302
302
  README badges (CI / Release / Python / License). Tagged **v0.13.0** with a GitHub Release. No PyPI. No product behavior change.
303
303
 
304
+ ## v0.16.1 — installed-path defaults
305
+
306
+ After `pip install sql-write-gate` and `sql-write-gate init`, bare `sql-write-gate check "DELETE FROM orders"` uses `./policy.yaml` + `./catalog.json` (no `FileNotFoundError` on a fake `seed/catalog.json`).
307
+
304
308
  ## v0.16 — PyPI package name `sql-write-gate`
305
309
 
306
310
  Version **0.16.0**. Distribution name on PyPI is **`sql-write-gate`** (was `write-gate` in earlier pyproject drafts). Import package stays `write_gate`; CLI entry stays `sql-write-gate`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sql-write-gate"
3
- version = "0.16.0"
3
+ version = "0.16.1"
4
4
  description = "Policy firewall for AI agents writing to databases (sql-write-gate)"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-write-gate
3
- Version: 0.16.0
3
+ Version: 0.16.1
4
4
  Summary: Policy firewall for AI agents writing to databases (sql-write-gate)
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -322,6 +322,10 @@ Tagged **v0.11.0** with [CHANGELOG.md](CHANGELOG.md) and a GitHub Release. No Py
322
322
 
323
323
  README badges (CI / Release / Python / License). Tagged **v0.13.0** with a GitHub Release. No PyPI. No product behavior change.
324
324
 
325
+ ## v0.16.1 — installed-path defaults
326
+
327
+ After `pip install sql-write-gate` and `sql-write-gate init`, bare `sql-write-gate check "DELETE FROM orders"` uses `./policy.yaml` + `./catalog.json` (no `FileNotFoundError` on a fake `seed/catalog.json`).
328
+
325
329
  ## v0.16 — PyPI package name `sql-write-gate`
326
330
 
327
331
  Version **0.16.0**. Distribution name on PyPI is **`sql-write-gate`** (was `write-gate` in earlier pyproject drafts). Import package stays `write_gate`; CLI entry stays `sql-write-gate`.
@@ -52,10 +52,12 @@ tests/test_destructive.py
52
52
  tests/test_environment.py
53
53
  tests/test_hooks.py
54
54
  tests/test_init.py
55
+ tests/test_installed_init_check.py
55
56
  tests/test_mcp.py
56
57
  tests/test_mcp_execute.py
57
58
  tests/test_mysql.py
58
59
  tests/test_mysql_live.py
60
+ tests/test_paths_defaults.py
59
61
  tests/test_policy.py
60
62
  tests/test_postgres_live.py
61
63
  tests/test_proxy.py
@@ -4,4 +4,4 @@ from write_gate.decision import Decision, Evidence
4
4
  from write_gate.wrapper import WriteGate
5
5
 
6
6
  __all__ = ["WriteGate", "Evidence", "Decision"]
7
- __version__ = "0.16.0"
7
+ __version__ = "0.16.1"
@@ -6,7 +6,7 @@ import os
6
6
  from pathlib import Path
7
7
  from typing import Mapping
8
8
 
9
- from write_gate.paths import DB_PATH
9
+ from write_gate.paths import default_db_path
10
10
 
11
11
  BACKEND_DUCKDB = "duckdb"
12
12
  BACKEND_POSTGRES = "postgres"
@@ -98,4 +98,4 @@ def resolve_target(
98
98
  env_url = env.get("DATABASE_URL")
99
99
  if env_url:
100
100
  return detect_backend(env_url), env_url
101
- return BACKEND_DUCKDB, str(DB_PATH)
101
+ return BACKEND_DUCKDB, str(default_db_path())
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
  import duckdb
8
8
 
9
9
  from write_gate.adapters.base import BACKEND_DUCKDB, count_sql as _count_sql
10
- from write_gate.paths import DB_PATH
10
+ from write_gate.paths import default_db_path
11
11
 
12
12
  DIALECT = "duckdb"
13
13
  BACKEND = BACKEND_DUCKDB
@@ -31,7 +31,7 @@ def count_sql(table: str, predicate: str | None) -> str:
31
31
 
32
32
 
33
33
  def connect(db_path: Path | None = None, *, read_only: bool = False) -> duckdb.DuckDBPyConnection:
34
- path = Path(db_path) if db_path else DB_PATH
34
+ path = Path(db_path) if db_path else default_db_path()
35
35
  path.parent.mkdir(parents=True, exist_ok=True)
36
36
  return duckdb.connect(str(path), read_only=read_only)
37
37
 
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
  from typing import Any
11
11
 
12
12
  from write_gate.decision import Decision
13
- from write_gate.paths import APPROVALS_PATH, LOG_DIR
13
+ from write_gate.paths import default_approvals_path, default_log_dir
14
14
 
15
15
  STATUS_PENDING = "pending"
16
16
  STATUS_APPROVED = "approved"
@@ -81,7 +81,7 @@ def _new_id() -> str:
81
81
 
82
82
 
83
83
  def _path(path: Path | str | None = None) -> Path:
84
- return Path(path) if path else APPROVALS_PATH
84
+ return Path(path) if path else default_approvals_path()
85
85
 
86
86
 
87
87
  def _load(path: Path) -> dict[str, dict[str, Any]]:
@@ -202,4 +202,4 @@ def mark_rejected(approval_id: str, path: Path | str | None = None) -> ApprovalR
202
202
 
203
203
 
204
204
  def ensure_log_dir() -> None:
205
- LOG_DIR.mkdir(parents=True, exist_ok=True)
205
+ default_log_dir().mkdir(parents=True, exist_ok=True)
@@ -9,7 +9,7 @@ from typing import Any, Iterable
9
9
  from zoneinfo import ZoneInfo
10
10
 
11
11
  from write_gate.decision import ACTION_APPROVAL, Decision
12
- from write_gate.paths import AUDIT_PATH, LOG_DIR
12
+ from write_gate.paths import default_audit_path, default_log_dir
13
13
 
14
14
  # Glanceable table times: UTC ISO on disk, Asia/Shanghai in `sql-write-gate audit`.
15
15
  AUDIT_DISPLAY_TZ = ZoneInfo("Asia/Shanghai")
@@ -38,14 +38,14 @@ def append_audit(
38
38
  "decision": decision.action,
39
39
  "rule_id": decision.rule_id,
40
40
  }
41
- dest = Path(path) if path else AUDIT_PATH
41
+ dest = Path(path) if path else default_audit_path()
42
42
  dest.parent.mkdir(parents=True, exist_ok=True)
43
43
  with dest.open("a", encoding="utf-8") as fh:
44
44
  fh.write(json.dumps(record, ensure_ascii=False) + "\n")
45
45
 
46
46
 
47
47
  def read_audit(path: Path | None = None, limit: int = 20) -> list[dict[str, Any]]:
48
- dest = Path(path) if path else AUDIT_PATH
48
+ dest = Path(path) if path else default_audit_path()
49
49
  if not dest.exists():
50
50
  return []
51
51
  lines = dest.read_text(encoding="utf-8").splitlines()
@@ -114,7 +114,8 @@ def format_audit_table(rows: Iterable[dict[str, Any]]) -> str:
114
114
 
115
115
 
116
116
  def ensure_log_dir() -> None:
117
- LOG_DIR.mkdir(parents=True, exist_ok=True)
118
- gitkeep = LOG_DIR / ".gitkeep"
117
+ log_dir = default_log_dir()
118
+ log_dir.mkdir(parents=True, exist_ok=True)
119
+ gitkeep = log_dir / ".gitkeep"
119
120
  if not gitkeep.exists():
120
121
  gitkeep.write_text("", encoding="utf-8")
@@ -8,7 +8,7 @@ from datetime import date, timedelta
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
11
- from write_gate.paths import CATALOG_PATH
11
+ from write_gate.paths import default_catalog_path
12
12
 
13
13
 
14
14
  @dataclass(frozen=True)
@@ -65,7 +65,7 @@ def _table_spec(name: str, raw: dict[str, Any]) -> TableSpec:
65
65
 
66
66
 
67
67
  def load_catalog(path: Path | None = None) -> Catalog:
68
- catalog_path = Path(path) if path else CATALOG_PATH
68
+ catalog_path = Path(path) if path else default_catalog_path()
69
69
  with catalog_path.open(encoding="utf-8") as fh:
70
70
  raw = json.load(fh)
71
71
  tables = {
@@ -15,7 +15,7 @@ from write_gate.approvals import (
15
15
  )
16
16
  from write_gate.audit import format_audit_table, read_audit
17
17
  from write_gate.decision import ACTION_ALLOW, ACTION_APPROVAL, ACTION_BLOCK, Decision
18
- from write_gate.paths import APPROVALS_PATH, AUDIT_PATH
18
+ from write_gate.paths import default_approvals_path, default_audit_path
19
19
  from write_gate.wrapper import WriteGate
20
20
 
21
21
 
@@ -51,7 +51,7 @@ def _safe(value: object) -> str:
51
51
 
52
52
  def _approvals_path(args: argparse.Namespace) -> Path:
53
53
  raw = getattr(args, "approvals", None)
54
- return Path(raw) if raw else APPROVALS_PATH
54
+ return Path(raw) if raw else default_approvals_path()
55
55
 
56
56
 
57
57
  def _gate_from_args(args: argparse.Namespace) -> WriteGate:
@@ -358,7 +358,7 @@ def main(argv: list[str] | None = None) -> int:
358
358
  args = parser.parse_args(argv)
359
359
 
360
360
  if args.command == "audit":
361
- path = Path(args.audit_path) if args.audit_path else AUDIT_PATH
361
+ path = Path(args.audit_path) if args.audit_path else default_audit_path()
362
362
  rows = read_audit(path, limit=args.limit)
363
363
  sys.stdout.write(format_audit_table(rows) + "\n")
364
364
  return 0
@@ -8,7 +8,7 @@ from typing import Any
8
8
 
9
9
  import yaml
10
10
 
11
- from write_gate.paths import POLICY_PATH
11
+ from write_gate.paths import default_policy_path
12
12
 
13
13
  VALID_RULES = {"allow", "block", "approval"}
14
14
  VALID_OPS = ("select", "insert", "update", "delete", "ddl")
@@ -99,7 +99,7 @@ def policy_from_dict(raw: dict[str, Any] | None = None) -> Policy:
99
99
 
100
100
 
101
101
  def load_policy(path: Path | str | None = None) -> Policy:
102
- policy_path = Path(path) if path else POLICY_PATH
102
+ policy_path = Path(path) if path else default_policy_path()
103
103
  if not policy_path.exists():
104
104
  return policy_from_dict(PRODUCTION_DEFAULTS)
105
105
  with policy_path.open(encoding="utf-8") as fh:
@@ -0,0 +1,99 @@
1
+ """Project paths.
2
+
3
+ Prefer cwd files written by ``sql-write-gate init`` (``./policy.yaml``,
4
+ ``./catalog.json``). Only treat ``PACKAGE_DIR.parents[1]`` as a checkout root
5
+ when it looks like the repo (``pyproject.toml`` + ``seed/``). An installed
6
+ wheel must never invent a fake ``seed/`` under ``site-packages``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ PACKAGE_DIR = Path(__file__).resolve().parent
14
+
15
+
16
+ def _find_checkout_root() -> Path | None:
17
+ # src layout: <root>/src/write_gate/paths.py → parents[1] == <root>
18
+ candidate = PACKAGE_DIR.parents[1]
19
+ if (candidate / "pyproject.toml").is_file() and (candidate / "seed").is_dir():
20
+ return candidate
21
+ return None
22
+
23
+
24
+ CHECKOUT_ROOT = _find_checkout_root()
25
+ # Back-compat: many call sites expect PROJECT_ROOT. When installed, use cwd.
26
+ PROJECT_ROOT = CHECKOUT_ROOT if CHECKOUT_ROOT is not None else Path.cwd()
27
+
28
+
29
+ def default_policy_path() -> Path:
30
+ cwd_path = Path.cwd() / "policy.yaml"
31
+ if cwd_path.is_file():
32
+ return cwd_path
33
+ if CHECKOUT_ROOT is not None:
34
+ root_path = CHECKOUT_ROOT / "policy.yaml"
35
+ if root_path.is_file():
36
+ return root_path
37
+ return cwd_path
38
+
39
+
40
+ def default_catalog_path() -> Path:
41
+ cwd_path = Path.cwd() / "catalog.json"
42
+ if cwd_path.is_file():
43
+ return cwd_path
44
+ if CHECKOUT_ROOT is not None:
45
+ seed_cat = CHECKOUT_ROOT / "seed" / "catalog.json"
46
+ if seed_cat.is_file():
47
+ return seed_cat
48
+ return cwd_path
49
+
50
+
51
+ def default_db_path() -> Path:
52
+ if CHECKOUT_ROOT is not None:
53
+ seed_db = CHECKOUT_ROOT / "seed" / "warehouse.duckdb"
54
+ if seed_db.is_file():
55
+ return seed_db
56
+ return Path.cwd() / "warehouse.duckdb"
57
+
58
+
59
+ def default_log_dir() -> Path:
60
+ if CHECKOUT_ROOT is not None:
61
+ return CHECKOUT_ROOT / ".logs"
62
+ return Path.cwd() / ".logs"
63
+
64
+
65
+ def default_audit_path() -> Path:
66
+ return default_log_dir() / "audit.jsonl"
67
+
68
+
69
+ def default_approvals_path() -> Path:
70
+ return default_log_dir() / "approvals.jsonl"
71
+
72
+
73
+ # Checkout / demo helpers (tests and make demo run from a clone).
74
+ SEED_DIR = (CHECKOUT_ROOT / "seed") if CHECKOUT_ROOT is not None else (Path.cwd() / "seed")
75
+ EXAMPLES_DIR = (
76
+ (CHECKOUT_ROOT / "examples") if CHECKOUT_ROOT is not None else (Path.cwd() / "examples")
77
+ )
78
+ EXAMPLES_POLICY_PATH = EXAMPLES_DIR / "policy.yaml"
79
+ DEMO_POLICY_PATH = EXAMPLES_DIR / "policy.demo.yaml"
80
+ EXAMPLES_CATALOG_PATH = EXAMPLES_DIR / "catalog.json"
81
+ ORDERS_CSV = SEED_DIR / "orders.csv"
82
+
83
+
84
+ def __getattr__(name: str):
85
+ """Lazy aliases so ``from write_gate.paths import CATALOG_PATH`` still works.
86
+
87
+ Prefer calling ``default_*_path()`` at use time when cwd may change.
88
+ """
89
+ mapping = {
90
+ "POLICY_PATH": default_policy_path,
91
+ "CATALOG_PATH": default_catalog_path,
92
+ "DB_PATH": default_db_path,
93
+ "LOG_DIR": default_log_dir,
94
+ "AUDIT_PATH": default_audit_path,
95
+ "APPROVALS_PATH": default_approvals_path,
96
+ }
97
+ if name in mapping:
98
+ return mapping[name]()
99
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -24,7 +24,13 @@ from write_gate.catalog import Catalog, load_catalog
24
24
  from write_gate.config import Policy, load_policy
25
25
  from write_gate.decision import ACTION_ALLOW, ACTION_APPROVAL, Decision, Evidence
26
26
  from write_gate.engine import evaluate
27
- from write_gate.paths import APPROVALS_PATH, AUDIT_PATH, CATALOG_PATH, DB_PATH, POLICY_PATH
27
+ from write_gate.paths import (
28
+ default_approvals_path,
29
+ default_audit_path,
30
+ default_catalog_path,
31
+ default_db_path,
32
+ default_policy_path,
33
+ )
28
34
 
29
35
  __all__ = ["WriteGate", "Evidence", "Decision"]
30
36
 
@@ -53,13 +59,15 @@ class WriteGate:
53
59
  )
54
60
  self.backend = backend
55
61
  self.database = target
56
- self.db_path = Path(target) if backend == BACKEND_DUCKDB else DB_PATH
57
- self.catalog_path = Path(catalog_path) if catalog_path else CATALOG_PATH
62
+ self.db_path = Path(target) if backend == BACKEND_DUCKDB else default_db_path()
63
+ self.catalog_path = Path(catalog_path) if catalog_path else default_catalog_path()
58
64
  self.catalog = catalog or load_catalog(self.catalog_path)
59
- self.policy_path = Path(policy_path) if policy_path else POLICY_PATH
65
+ self.policy_path = Path(policy_path) if policy_path else default_policy_path()
60
66
  self.policy = policy or load_policy(self.policy_path)
61
- self.audit_path = Path(audit_path) if audit_path else AUDIT_PATH
62
- self.approvals_path = Path(approvals_path) if approvals_path else APPROVALS_PATH
67
+ self.audit_path = Path(audit_path) if audit_path else default_audit_path()
68
+ self.approvals_path = (
69
+ Path(approvals_path) if approvals_path else default_approvals_path()
70
+ )
63
71
  self.agent = agent
64
72
  self._conn = conn
65
73
  self._owns_conn = conn is None
@@ -0,0 +1,46 @@
1
+ """Regression: pip-installed wheel + init + bare check must BLOCK, not FileNotFoundError."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+
11
+
12
+ def test_wheel_install_init_bare_check(tmp_path):
13
+ """Build current tree into a wheel, install into a temp venv, init + bare check."""
14
+ outdir = tmp_path / "dist"
15
+ outdir.mkdir()
16
+ subprocess.check_call(
17
+ [sys.executable, "-m", "pip", "install", "-q", "build"],
18
+ cwd=str(ROOT),
19
+ )
20
+ subprocess.check_call(
21
+ [sys.executable, "-m", "build", "--wheel", "--outdir", str(outdir)],
22
+ cwd=str(ROOT),
23
+ )
24
+ wheels = sorted(outdir.glob("sql_write_gate-*.whl"))
25
+ assert wheels, "expected a wheel under temp dist/"
26
+ wheel = wheels[-1]
27
+
28
+ venv = tmp_path / "venv"
29
+ subprocess.check_call([sys.executable, "-m", "venv", str(venv)])
30
+ pip = venv / "bin" / "pip"
31
+ swg = venv / "bin" / "sql-write-gate"
32
+ subprocess.check_call([str(pip), "install", "-q", str(wheel)])
33
+
34
+ proj = tmp_path / "proj"
35
+ proj.mkdir()
36
+ subprocess.check_call([str(swg), "init", "--dir", str(proj)], cwd=str(proj))
37
+ proc = subprocess.run(
38
+ [str(swg), "check", "DELETE FROM orders"],
39
+ cwd=str(proj),
40
+ capture_output=True,
41
+ text=True,
42
+ )
43
+ out = proc.stdout + proc.stderr
44
+ assert proc.returncode == 2, out
45
+ assert "delete_without_where" in out
46
+ assert "FileNotFoundError" not in out
@@ -0,0 +1,64 @@
1
+ """Default policy/catalog prefer cwd init files; installed layout has no fake seed/."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import write_gate.paths as paths
8
+ from write_gate.paths import (
9
+ CHECKOUT_ROOT,
10
+ default_approvals_path,
11
+ default_audit_path,
12
+ default_catalog_path,
13
+ default_db_path,
14
+ default_log_dir,
15
+ default_policy_path,
16
+ )
17
+
18
+
19
+ def test_checkout_root_detected_in_dev_tree():
20
+ assert CHECKOUT_ROOT is not None
21
+ assert (CHECKOUT_ROOT / "seed" / "catalog.json").is_file()
22
+ assert (CHECKOUT_ROOT / "pyproject.toml").is_file()
23
+
24
+
25
+ def test_find_checkout_root_rejects_site_packages_layout(tmp_path, monkeypatch):
26
+ fake_pkg = tmp_path / "lib" / "python3.13" / "site-packages" / "write_gate"
27
+ fake_pkg.mkdir(parents=True)
28
+ monkeypatch.setattr(paths, "PACKAGE_DIR", fake_pkg)
29
+ assert paths._find_checkout_root() is None
30
+
31
+
32
+ def test_cwd_init_files_win_over_seed(tmp_path, monkeypatch):
33
+ monkeypatch.chdir(tmp_path)
34
+ (tmp_path / "policy.yaml").write_text("environment: production\nrules: {}\nlimits: {}\n")
35
+ (tmp_path / "catalog.json").write_text(
36
+ '{"as_of_date":"2026-09-02","freshness_days":7,"tables":{}}'
37
+ )
38
+ assert default_policy_path() == tmp_path / "policy.yaml"
39
+ assert default_catalog_path() == tmp_path / "catalog.json"
40
+
41
+
42
+ def test_installed_layout_defaults_to_cwd(tmp_path, monkeypatch):
43
+ """Simulate wheel install: package dir without repo seed/ above it."""
44
+ fake_pkg = tmp_path / "site-packages" / "write_gate"
45
+ fake_pkg.mkdir(parents=True)
46
+ monkeypatch.setattr(paths, "PACKAGE_DIR", fake_pkg)
47
+ monkeypatch.setattr(paths, "CHECKOUT_ROOT", None)
48
+ work = tmp_path / "proj"
49
+ work.mkdir()
50
+ monkeypatch.chdir(work)
51
+ (work / "policy.yaml").write_text("environment: production\nrules: {}\nlimits: {}\n")
52
+ (work / "catalog.json").write_text(
53
+ '{"as_of_date":"2026-09-02","freshness_days":7,"tables":{"orders":{"columns":{},'
54
+ '"allowed_write_columns":[],"pii_columns":[],"restricted_columns":[]}}}'
55
+ )
56
+ assert default_policy_path() == work / "policy.yaml"
57
+ assert default_catalog_path() == work / "catalog.json"
58
+ assert default_db_path() == work / "warehouse.duckdb"
59
+ assert default_log_dir() == work / ".logs"
60
+ assert default_audit_path() == work / ".logs" / "audit.jsonl"
61
+ assert default_approvals_path() == work / ".logs" / "approvals.jsonl"
62
+ # Must not point into site-packages parents
63
+ assert "site-packages" not in str(default_catalog_path())
64
+ assert "site-packages" not in str(default_policy_path())
@@ -1,20 +0,0 @@
1
- """Project paths. Warehouse and catalog live under seed/."""
2
-
3
- from pathlib import Path
4
-
5
- PACKAGE_DIR = Path(__file__).resolve().parent
6
- PROJECT_ROOT = PACKAGE_DIR.parents[1]
7
- SEED_DIR = PROJECT_ROOT / "seed"
8
- EXAMPLES_DIR = PROJECT_ROOT / "examples"
9
- LOG_DIR = PROJECT_ROOT / ".logs"
10
-
11
- DB_PATH = SEED_DIR / "warehouse.duckdb"
12
- CATALOG_PATH = SEED_DIR / "catalog.json"
13
- ORDERS_CSV = SEED_DIR / "orders.csv"
14
-
15
- POLICY_PATH = PROJECT_ROOT / "policy.yaml"
16
- EXAMPLES_POLICY_PATH = EXAMPLES_DIR / "policy.yaml"
17
- DEMO_POLICY_PATH = EXAMPLES_DIR / "policy.demo.yaml"
18
- EXAMPLES_CATALOG_PATH = EXAMPLES_DIR / "catalog.json"
19
- AUDIT_PATH = LOG_DIR / "audit.jsonl"
20
- APPROVALS_PATH = LOG_DIR / "approvals.jsonl"
File without changes