pywrit 0.2.5__tar.gz → 0.2.7__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.
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywrit
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: Python client + CLI for Writ — the gate before the write
5
- License: MIT
5
+ License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/AvenueDAdmin/pywrit
7
7
  Project-URL: Documentation, https://withwrit.com/docs
8
8
  Requires-Python: >=3.9
@@ -40,6 +40,18 @@ pip install pywrit
40
40
 
41
41
  Requires Python 3.9+. Installs the `writ` command and the `pywrit` Python client.
42
42
 
43
+ **JavaScript/TypeScript devs:** run the scanner with no Python setup via npm:
44
+
45
+ ```bash
46
+ npx writ-scan .
47
+ ```
48
+
49
+ The `writ-scan` package wraps the same scanner (including TS/JS support). On
50
+ first run it installs `pywrit[polyglot]` from PyPI using your Python 3.9+
51
+ (one-time); afterwards it starts instantly. See [`npm/`](npm/) for details,
52
+ environment overrides (`WRIT_PYTHON`, `WRIT_SCAN_NO_INSTALL`), and the
53
+ install test.
54
+
43
55
  ## 60-second quickstart: scan → apply → gate
44
56
 
45
57
  **1. Scan.** See which functions write, and how many of them are gated.
@@ -142,6 +154,23 @@ clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
142
154
 
143
155
  ![writ scan on a TypeScript agent finds 5 write sites, 1 of 5 gated](https://raw.githubusercontent.com/AvenueDAdmin/pywrit/main/docs/assets/scan-tsjs.gif)
144
156
 
157
+ ## GitHub Action
158
+
159
+ Scan every pull request for ungated write sites. Findings land as check
160
+ annotations on the exact file and line, plus a Markdown risk report in the
161
+ job summary. The check fails when an ungated finding meets your
162
+ `fail-on-risk` threshold (default: `high`).
163
+
164
+ ```yaml
165
+ - uses: actions/checkout@v4
166
+ - uses: AvenueDAdmin/pywrit@v0
167
+ with:
168
+ fail-on-risk: high # high | medium | low | never
169
+ ```
170
+
171
+ No API key needed. Full reference: [docs/github-action.md](docs/github-action.md) ·
172
+ example workflow: [examples/github-action/writ-scan.yml](examples/github-action/writ-scan.yml)
173
+
145
174
  ## Python client
146
175
 
147
176
  ```python
@@ -205,6 +234,32 @@ writ grant --sponsor acme --agent agent-7 --verb payments.refund \
205
234
  Sponsor-token commands (`revoke`, `reinstate`, `revoked`, `grant`) read
206
235
  `--sponsor-token` or `WRIT_SPONSOR_TOKEN`. Run `writ --help` for the full command list.
207
236
 
237
+ ## README badge
238
+
239
+ Show that your agent's writes are gated by Writ. Two flavors:
240
+
241
+ **Static** (works today, no API key) — same design, no live count:
242
+
243
+ ```md
244
+ [![agent writes gated by Writ](https://cdn.jsdelivr.net/gh/AvenueDAdmin/pywrit@main/badge/writ-gated.svg)](https://withwrit.com)
245
+ ```
246
+
247
+ **Dynamic** (live 30-day gated-write count from your audit log) — mint a badge
248
+ token, then embed the snippet the API returns:
249
+
250
+ ```bash
251
+ curl -s https://api.withwrit.com/v1/badge/tokens \
252
+ -H "Authorization: Bearer writ_..." \
253
+ -H "Content-Type: application/json" \
254
+ -d '{}'
255
+ ```
256
+
257
+ The dynamic endpoint ships with the gate — until it's live, `POST /v1/badge/tokens`
258
+ returns 404 and the static badge above is the one to use.
259
+
260
+ See [`badge/`](badge/) for embed docs and the verification model (what the
261
+ badge proves — and what it doesn't).
262
+
208
263
  ## Docs
209
264
 
210
265
  Full docs: [docs.withwrit.com](https://docs.withwrit.com) ·
@@ -18,6 +18,18 @@ pip install pywrit
18
18
 
19
19
  Requires Python 3.9+. Installs the `writ` command and the `pywrit` Python client.
20
20
 
21
+ **JavaScript/TypeScript devs:** run the scanner with no Python setup via npm:
22
+
23
+ ```bash
24
+ npx writ-scan .
25
+ ```
26
+
27
+ The `writ-scan` package wraps the same scanner (including TS/JS support). On
28
+ first run it installs `pywrit[polyglot]` from PyPI using your Python 3.9+
29
+ (one-time); afterwards it starts instantly. See [`npm/`](npm/) for details,
30
+ environment overrides (`WRIT_PYTHON`, `WRIT_SCAN_NO_INSTALL`), and the
31
+ install test.
32
+
21
33
  ## 60-second quickstart: scan → apply → gate
22
34
 
23
35
  **1. Scan.** See which functions write, and how many of them are gated.
@@ -120,6 +132,23 @@ clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
120
132
 
121
133
  ![writ scan on a TypeScript agent finds 5 write sites, 1 of 5 gated](https://raw.githubusercontent.com/AvenueDAdmin/pywrit/main/docs/assets/scan-tsjs.gif)
122
134
 
135
+ ## GitHub Action
136
+
137
+ Scan every pull request for ungated write sites. Findings land as check
138
+ annotations on the exact file and line, plus a Markdown risk report in the
139
+ job summary. The check fails when an ungated finding meets your
140
+ `fail-on-risk` threshold (default: `high`).
141
+
142
+ ```yaml
143
+ - uses: actions/checkout@v4
144
+ - uses: AvenueDAdmin/pywrit@v0
145
+ with:
146
+ fail-on-risk: high # high | medium | low | never
147
+ ```
148
+
149
+ No API key needed. Full reference: [docs/github-action.md](docs/github-action.md) ·
150
+ example workflow: [examples/github-action/writ-scan.yml](examples/github-action/writ-scan.yml)
151
+
123
152
  ## Python client
124
153
 
125
154
  ```python
@@ -183,6 +212,32 @@ writ grant --sponsor acme --agent agent-7 --verb payments.refund \
183
212
  Sponsor-token commands (`revoke`, `reinstate`, `revoked`, `grant`) read
184
213
  `--sponsor-token` or `WRIT_SPONSOR_TOKEN`. Run `writ --help` for the full command list.
185
214
 
215
+ ## README badge
216
+
217
+ Show that your agent's writes are gated by Writ. Two flavors:
218
+
219
+ **Static** (works today, no API key) — same design, no live count:
220
+
221
+ ```md
222
+ [![agent writes gated by Writ](https://cdn.jsdelivr.net/gh/AvenueDAdmin/pywrit@main/badge/writ-gated.svg)](https://withwrit.com)
223
+ ```
224
+
225
+ **Dynamic** (live 30-day gated-write count from your audit log) — mint a badge
226
+ token, then embed the snippet the API returns:
227
+
228
+ ```bash
229
+ curl -s https://api.withwrit.com/v1/badge/tokens \
230
+ -H "Authorization: Bearer writ_..." \
231
+ -H "Content-Type: application/json" \
232
+ -d '{}'
233
+ ```
234
+
235
+ The dynamic endpoint ships with the gate — until it's live, `POST /v1/badge/tokens`
236
+ returns 404 and the static badge above is the one to use.
237
+
238
+ See [`badge/`](badge/) for embed docs and the verification model (what the
239
+ badge proves — and what it doesn't).
240
+
186
241
  ## Docs
187
242
 
188
243
  Full docs: [docs.withwrit.com](https://docs.withwrit.com) ·
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pywrit"
7
- version = "0.2.5"
7
+ version = "0.2.7"
8
8
  description = "Python client + CLI for Writ — the gate before the write"
9
9
  readme = "README.md"
10
- license = { text = "MIT" }
10
+ license = "MIT"
11
11
  requires-python = ">=3.9"
12
12
  dependencies = ["httpx>=0.27"]
13
13
 
@@ -27,7 +27,7 @@ from .errors import (
27
27
  WritError,
28
28
  )
29
29
 
30
- __version__ = "0.2.5"
30
+ __version__ = "0.2.7"
31
31
 
32
32
  __all__ = [
33
33
  "WritClient",
@@ -1072,6 +1072,68 @@ def _tsjs_scan(root, excludes):
1072
1072
  res["gated"].update((str(path),) + g for g in gated)
1073
1073
  return res
1074
1074
 
1075
+ # ---------------------------------------------------------------------------
1076
+ # writ scan --format json: machine-readable risk report for CI (GitHub Action,
1077
+ # editor integrations). Pure read-only: prints one JSON document to stdout.
1078
+ # ---------------------------------------------------------------------------
1079
+ _RISK_ORDER = {"high": 0, "medium": 1, "low": 2}
1080
+
1081
+
1082
+ def _scan_findings_json(root, sites, gated_funcs, tsjs, ts_sites):
1083
+ """Return the findings list for `writ scan --format json`.
1084
+
1085
+ Each finding is a dict with file (repo-relative, posix), line, function,
1086
+ verb, risk tier, gated flag, kind, language, and a short snippet.
1087
+ """
1088
+ findings = []
1089
+ for s in sites:
1090
+ rel = _pathlib.Path(s.file).relative_to(root)
1091
+ verb = infer_verb(rel, s.func, s.cls, s.kind)
1092
+ findings.append({
1093
+ "file": rel.as_posix(),
1094
+ "line": s.lineno,
1095
+ "function": s.func or "<module>",
1096
+ "verb": verb,
1097
+ "risk": _risk_tier(verb),
1098
+ "gated": (s.file, s.func) in gated_funcs,
1099
+ "kind": s.kind,
1100
+ "language": "python",
1101
+ "snippet": (s.snippet or "")[:200],
1102
+ })
1103
+ for s, _rid, fkey in ts_sites:
1104
+ rel = _pathlib.Path(s.file).relative_to(root)
1105
+ verb = _tsjs_verb(rel, fkey[0], s.cls, s.kind)
1106
+ findings.append({
1107
+ "file": rel.as_posix(),
1108
+ "line": s.lineno,
1109
+ "function": fkey[0] or "<module>",
1110
+ "verb": verb,
1111
+ "risk": _risk_tier(verb),
1112
+ "gated": (s.file,) + fkey in tsjs["gated"],
1113
+ "kind": s.kind,
1114
+ "language": "tsjs",
1115
+ "snippet": (s.snippet or "")[:200],
1116
+ })
1117
+ findings.sort(key=lambda f: (_RISK_ORDER[f["risk"]], f["file"], f["line"]))
1118
+ return findings
1119
+
1120
+
1121
+ def print_scan_json(root, files, skipped, sites, gated_funcs, tsjs, ts_sites):
1122
+ """Print the machine-readable scan report. Read-only: no files written."""
1123
+ from . import __version__
1124
+ doc = {
1125
+ "scanner": "pywrit",
1126
+ "version": __version__,
1127
+ "root": str(root),
1128
+ "files_scanned": len(files) + len(tsjs.get("files", [])),
1129
+ "files_skipped": len(skipped) + len(tsjs.get("skipped", [])),
1130
+ "tsjs_hint": bool(tsjs.get("missing_extra")),
1131
+ "findings": _scan_findings_json(root, sites, gated_funcs, tsjs,
1132
+ ts_sites),
1133
+ }
1134
+ print(json.dumps(doc, indent=2))
1135
+
1136
+
1075
1137
  def scan_cmd(args):
1076
1138
  import json as _json
1077
1139
  root = _pathlib.Path(args.path).resolve()
@@ -1114,6 +1176,13 @@ def scan_cmd(args):
1114
1176
  total = len(sites)
1115
1177
  pct = (100 * gated_site_count // total) if total else 100
1116
1178
 
1179
+ if getattr(args, "format", "text") == "json":
1180
+ # Machine-readable risk report for CI. Read-only: no policy file,
1181
+ # no diffs, no prompts.
1182
+ print_scan_json(root, files, skipped, sites, gated_funcs, tsjs,
1183
+ ts_sites)
1184
+ return 0
1185
+
1117
1186
  verbs = {}
1118
1187
  for (f, func_name, cls_name), ss in sorted(by_func.items()):
1119
1188
  rel = _pathlib.Path(f).relative_to(root)
@@ -1601,6 +1670,10 @@ def main():
1601
1670
  help="Apply without prompting")
1602
1671
  scan.add_argument("--score", action="store_true",
1603
1672
  help="Print only the Writ Score risk report (no diff, no policy file)")
1673
+ scan.add_argument("--format", choices=("text", "json"), default="text",
1674
+ help="Output format: human-readable text (default) or "
1675
+ "machine-readable JSON risk report (read-only; no "
1676
+ "policy file, no diffs)")
1604
1677
  scan.add_argument("--push-policy", action="store_true",
1605
1678
  help="PUT the discovered policy to the gate (needs --key)")
1606
1679
  scan.add_argument("--key", default="",
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywrit
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: Python client + CLI for Writ — the gate before the write
5
- License: MIT
5
+ License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/AvenueDAdmin/pywrit
7
7
  Project-URL: Documentation, https://withwrit.com/docs
8
8
  Requires-Python: >=3.9
@@ -40,6 +40,18 @@ pip install pywrit
40
40
 
41
41
  Requires Python 3.9+. Installs the `writ` command and the `pywrit` Python client.
42
42
 
43
+ **JavaScript/TypeScript devs:** run the scanner with no Python setup via npm:
44
+
45
+ ```bash
46
+ npx writ-scan .
47
+ ```
48
+
49
+ The `writ-scan` package wraps the same scanner (including TS/JS support). On
50
+ first run it installs `pywrit[polyglot]` from PyPI using your Python 3.9+
51
+ (one-time); afterwards it starts instantly. See [`npm/`](npm/) for details,
52
+ environment overrides (`WRIT_PYTHON`, `WRIT_SCAN_NO_INSTALL`), and the
53
+ install test.
54
+
43
55
  ## 60-second quickstart: scan → apply → gate
44
56
 
45
57
  **1. Scan.** See which functions write, and how many of them are gated.
@@ -142,6 +154,23 @@ clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
142
154
 
143
155
  ![writ scan on a TypeScript agent finds 5 write sites, 1 of 5 gated](https://raw.githubusercontent.com/AvenueDAdmin/pywrit/main/docs/assets/scan-tsjs.gif)
144
156
 
157
+ ## GitHub Action
158
+
159
+ Scan every pull request for ungated write sites. Findings land as check
160
+ annotations on the exact file and line, plus a Markdown risk report in the
161
+ job summary. The check fails when an ungated finding meets your
162
+ `fail-on-risk` threshold (default: `high`).
163
+
164
+ ```yaml
165
+ - uses: actions/checkout@v4
166
+ - uses: AvenueDAdmin/pywrit@v0
167
+ with:
168
+ fail-on-risk: high # high | medium | low | never
169
+ ```
170
+
171
+ No API key needed. Full reference: [docs/github-action.md](docs/github-action.md) ·
172
+ example workflow: [examples/github-action/writ-scan.yml](examples/github-action/writ-scan.yml)
173
+
145
174
  ## Python client
146
175
 
147
176
  ```python
@@ -205,6 +234,32 @@ writ grant --sponsor acme --agent agent-7 --verb payments.refund \
205
234
  Sponsor-token commands (`revoke`, `reinstate`, `revoked`, `grant`) read
206
235
  `--sponsor-token` or `WRIT_SPONSOR_TOKEN`. Run `writ --help` for the full command list.
207
236
 
237
+ ## README badge
238
+
239
+ Show that your agent's writes are gated by Writ. Two flavors:
240
+
241
+ **Static** (works today, no API key) — same design, no live count:
242
+
243
+ ```md
244
+ [![agent writes gated by Writ](https://cdn.jsdelivr.net/gh/AvenueDAdmin/pywrit@main/badge/writ-gated.svg)](https://withwrit.com)
245
+ ```
246
+
247
+ **Dynamic** (live 30-day gated-write count from your audit log) — mint a badge
248
+ token, then embed the snippet the API returns:
249
+
250
+ ```bash
251
+ curl -s https://api.withwrit.com/v1/badge/tokens \
252
+ -H "Authorization: Bearer writ_..." \
253
+ -H "Content-Type: application/json" \
254
+ -d '{}'
255
+ ```
256
+
257
+ The dynamic endpoint ships with the gate — until it's live, `POST /v1/badge/tokens`
258
+ returns 404 and the static badge above is the one to use.
259
+
260
+ See [`badge/`](badge/) for embed docs and the verification model (what the
261
+ badge proves — and what it doesn't).
262
+
208
263
  ## Docs
209
264
 
210
265
  Full docs: [docs.withwrit.com](https://docs.withwrit.com) ·
@@ -12,4 +12,5 @@ src/pywrit.egg-info/entry_points.txt
12
12
  src/pywrit.egg-info/requires.txt
13
13
  src/pywrit.egg-info/top_level.txt
14
14
  tests/test_client.py
15
+ tests/test_scan_json.py
15
16
  tests/test_scan_tsjs.py
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for `writ scan --format json` (the GitHub Action's input).
3
+
4
+ Run with: python -m pytest tests/test_scan_json.py
5
+ """
6
+
7
+ import argparse
8
+ import contextlib
9
+ import io
10
+ import json
11
+ import pathlib
12
+ import tempfile
13
+ import unittest
14
+
15
+ from pywrit import cli
16
+
17
+
18
+ def _args(path, **kw):
19
+ ns = argparse.Namespace(path=str(path), exclude=[], policy_out="writ-policy.json",
20
+ apply=False, yes=False, score=False, push_policy=False,
21
+ key="", format="json")
22
+ for k, v in kw.items():
23
+ setattr(ns, k, v)
24
+ return ns
25
+
26
+
27
+ def _run_scan_json(root, **kw):
28
+ buf = io.StringIO()
29
+ with contextlib.redirect_stdout(buf):
30
+ code = cli.scan_cmd(_args(root, **kw))
31
+ return code, json.loads(buf.getvalue())
32
+
33
+
34
+ FIXTURE = '''\
35
+ import requests
36
+
37
+
38
+ def issue_refund(payment_id, amount_cents):
39
+ """High risk: money movement, ungated."""
40
+ resp = requests.post(
41
+ "https://api.example.com/refunds",
42
+ json={"payment": payment_id, "amount": amount_cents},
43
+ )
44
+ return resp.json()
45
+
46
+
47
+ def send_receipt(order_id, email):
48
+ """Medium risk: outbound email, ungated."""
49
+ import smtplib
50
+
51
+ server = smtplib.SMTP("smtp.example.com")
52
+ server.sendmail("noreply@example.com", [email], "receipt %s" % order_id)
53
+
54
+
55
+ def cancel_subscription(subscription_id):
56
+ """Gated: already behind a writ gate, must not be reported."""
57
+ if _writ_check("subscriptions.cancel", subscription_id,
58
+ "user requested cancellation") != "ALLOW":
59
+ raise RuntimeError("denied")
60
+ requests.post("https://api.example.com/subs/cancel",
61
+ json={"id": subscription_id})
62
+ '''
63
+
64
+ NOTIFY_FIXTURE = '''\
65
+ import smtplib
66
+
67
+
68
+ def send_receipt(order_id, email):
69
+ """Medium risk: outbound email, ungated."""
70
+ server = smtplib.SMTP("smtp.example.com")
71
+ server.sendmail("noreply@example.com", [email], "receipt %s" % order_id)
72
+
73
+
74
+ def log_event(name):
75
+ """Low risk: local append-only log, ungated."""
76
+ with open("events.log", "a") as fh:
77
+ fh.write(name + "\\n")
78
+ '''
79
+
80
+
81
+ class ScanJsonTest(unittest.TestCase):
82
+ def _repo(self, td):
83
+ root = pathlib.Path(td) / "repo"
84
+ root.mkdir()
85
+ (root / "billing.py").write_text(FIXTURE, encoding="utf-8")
86
+ (root / "notify.py").write_text(NOTIFY_FIXTURE, encoding="utf-8")
87
+ return root
88
+
89
+ def test_json_document_shape(self):
90
+ with tempfile.TemporaryDirectory() as td:
91
+ code, doc = _run_scan_json(self._repo(td))
92
+ self.assertEqual(code, 0)
93
+ for key in ("scanner", "version", "root", "files_scanned",
94
+ "files_skipped", "findings"):
95
+ self.assertIn(key, doc)
96
+ self.assertEqual(doc["scanner"], "pywrit")
97
+ self.assertGreaterEqual(len(doc["findings"]), 3)
98
+
99
+ def test_finding_fields_and_risk_tiers(self):
100
+ with tempfile.TemporaryDirectory() as td:
101
+ _code, doc = _run_scan_json(self._repo(td))
102
+ by_func = {f["function"]: f for f in doc["findings"]}
103
+ for name in ("issue_refund", "send_receipt", "log_event",
104
+ "cancel_subscription"):
105
+ self.assertIn(name, by_func, doc["findings"])
106
+ for key in ("file", "line", "function", "verb", "risk", "gated",
107
+ "kind", "language", "snippet"):
108
+ self.assertIn(key, by_func[name])
109
+ # refunds are money movement -> high; email send -> medium;
110
+ # local log append -> low
111
+ self.assertEqual(by_func["issue_refund"]["risk"], "high")
112
+ self.assertEqual(by_func["send_receipt"]["risk"], "medium")
113
+ self.assertEqual(by_func["log_event"]["risk"], "low")
114
+ # gated writes are flagged so the action can skip them
115
+ self.assertTrue(by_func["cancel_subscription"]["gated"])
116
+ self.assertFalse(by_func["issue_refund"]["gated"])
117
+ self.assertEqual(by_func["issue_refund"]["file"], "billing.py")
118
+ self.assertEqual(by_func["send_receipt"]["file"], "notify.py")
119
+ for f in doc["findings"]:
120
+ self.assertIsInstance(f["line"], int)
121
+ self.assertGreater(f["line"], 0)
122
+
123
+ def test_findings_sorted_high_first(self):
124
+ with tempfile.TemporaryDirectory() as td:
125
+ _code, doc = _run_scan_json(self._repo(td))
126
+ order = {"high": 0, "medium": 1, "low": 2}
127
+ tiers = [order[f["risk"]] for f in doc["findings"]]
128
+ self.assertEqual(tiers, sorted(tiers))
129
+
130
+ def test_json_is_read_only(self):
131
+ # --format json must not write the policy file or anything else.
132
+ with tempfile.TemporaryDirectory() as td:
133
+ root = self._repo(td)
134
+ code, _doc = _run_scan_json(root)
135
+ self.assertEqual(code, 0)
136
+ self.assertEqual(
137
+ sorted(p.name for p in root.iterdir()),
138
+ ["billing.py", "notify.py"])
139
+
140
+ def test_empty_repo_gives_empty_findings(self):
141
+ with tempfile.TemporaryDirectory() as td:
142
+ root = pathlib.Path(td) / "repo"
143
+ root.mkdir()
144
+ (root / "clean.py").write_text("X = 1\n", encoding="utf-8")
145
+ code, doc = _run_scan_json(root)
146
+ self.assertEqual(code, 0)
147
+ self.assertEqual(doc["findings"], [])
148
+
149
+
150
+ if __name__ == "__main__":
151
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes