pryti-contract 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ankush Singh Gandhi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.4
2
+ Name: pryti-contract
3
+ Version: 0.1.0
4
+ Summary: Your backend keeps a list of what it does, and cannot lie about it.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: django
10
+ Requires-Dist: django>=4.2; extra == "django"
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7; extra == "dev"
13
+ Requires-Dist: django>=4.2; extra == "dev"
14
+ Dynamic: license-file
15
+
16
+ # pryti-contract
17
+
18
+ Your backend keeps a list of what it does. And it can't lie about it.
19
+
20
+ Companion library to [pryti-semantic-reviewer](https://github.com/AnkushSinghGandhi/pryti-semantic-reviewer).
21
+ Pryti reads code from outside and guesses. This runs inside your app and knows.
22
+
23
+ ## What it does
24
+
25
+ While your app starts, it records:
26
+
27
+ - every **route** (path, method, auth)
28
+ - every **model** (fields, types, constraints)
29
+ - every **outside call** (HTTP, email, payments)
30
+ - every **background job**
31
+ - how much of that it actually **knows** vs. couldn't figure out
32
+
33
+ Then you can ask your app one question and get an answer:
34
+
35
+ > "What does this app actually do?"
36
+
37
+ ## Why it matters
38
+
39
+ AI opens a PR with 400 changed lines. You don't read them. You read this:
40
+
41
+ ```
42
+ RISKY (4)
43
+ POST /orders auth: user -> public
44
+ POST /orders new effect: net:analytics.example.com
45
+ shop.Customer.email unique: True -> False
46
+ shop.Customer.name field removed (data loss)
47
+ ```
48
+
49
+ Four lines instead of four hundred.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install pryti-contract
55
+ ```
56
+
57
+ ## Use it — three levels
58
+
59
+ ### Level 1: nothing to write
60
+
61
+ Works on an existing Django project with zero code changes.
62
+
63
+ ```bash
64
+ pryti-contract export --settings myproject.settings -o contract.json
65
+ ```
66
+
67
+ It reads Django's real router and real model registry. Routes built in loops,
68
+ DRF routers, mixins — all found, because the app already resolved them at startup.
69
+
70
+ Coverage will be partial. That's reported, not hidden:
71
+
72
+ ```
73
+ wrote contract.json: 9 routes, 6 models, 0 jobs, auth known on 5/9
74
+ ```
75
+
76
+ ### Level 2: declare what matters
77
+
78
+ ```python
79
+ from pryti_contract import contract
80
+
81
+ @contract.route("POST /orders", auth="user")
82
+ @contract.effects("net:api.stripe.com")
83
+ def create_order(request):
84
+ ...
85
+ ```
86
+
87
+ Eight decorators total, max. AI writes these correctly first try, because
88
+ they look like every Django decorator it has ever seen.
89
+
90
+ ### Level 3: enforce it
91
+
92
+ ```python
93
+ # settings.py
94
+ MIDDLEWARE = ["pryti_contract.middleware.ContractMiddleware", ...]
95
+
96
+ # conftest.py or apps.py
97
+ from pryti_contract import guard
98
+ guard.install(mode="error") # off | record | warn | error
99
+ ```
100
+
101
+ Now an undeclared call fails loudly:
102
+
103
+ ```
104
+ shop.views.leaky_order performed undeclared effect 'net:api.stripe.com'.
105
+ Declared: none. Add @contract.effects('net:api.stripe.com') or remove the call.
106
+ ```
107
+
108
+ This is the part no linter can do. The bad code doesn't ship.
109
+
110
+ **Don't know what to declare?** Run your tests in `record` mode and let it tell you:
111
+
112
+ ```python
113
+ guard.install(mode="record")
114
+ # ... run test suite ...
115
+ json.dump(guard.suggestions(), open("observed.json", "w"))
116
+ ```
117
+
118
+ ```bash
119
+ pryti-contract suggest observed.json # prints the decorators to paste in
120
+ ```
121
+
122
+ ## In CI
123
+
124
+ ```yaml
125
+ - run: pryti-contract export --settings myproject.settings -o head.json
126
+ - run: git checkout ${{ github.base_ref }}
127
+ - run: pryti-contract export --settings myproject.settings -o base.json
128
+ - run: pryti-contract diff base.json head.json --markdown --fail-on risky
129
+ ```
130
+
131
+ `--fail-on risky` exits 1 on auth weakening, new outside calls, dropped fields,
132
+ relaxed uniqueness, or changed relations.
133
+
134
+ ## How the effect guard works
135
+
136
+ One hook at the socket layer, not per-library. `requests`, `httpx`, `urllib`,
137
+ `boto3`, `stripe` — all covered by the same code, including libraries that
138
+ don't exist yet.
139
+
140
+ - `socket.getaddrinfo` is checked **before** it runs, so a blocked call never leaves the process
141
+ - `socket.socket.connect` catches direct-IP connections
142
+ - `smtplib.SMTP.sendmail` catches email
143
+ - localhost and unix sockets are never effects, so your database doesn't trip it
144
+
145
+ Patterns support wildcards: `net:*.stripe.com`, `net:*`, `email`.
146
+
147
+ ## What it does not catch
148
+
149
+ Worth being blunt about.
150
+
151
+ - **Business logic.** If AI changes a discount from 10% to 90%, the contract is
152
+ identical. Routes same, models same, effects same. Tests catch that; this doesn't.
153
+ - **Anything the router never sees.** Dead code, unmounted views.
154
+ - **Dynamic hostnames** are caught at runtime, not at export time. The contract
155
+ records what you declared; the guard records what actually happened.
156
+
157
+ Structural mistakes: this. Logic mistakes: your tests. You need both.
158
+
159
+ ## Try it
160
+
161
+ ```bash
162
+ git clone ... && cd pryti-contract
163
+ pip install -e ".[dev]"
164
+ pytest
165
+ cd examples/demo && pryti-contract export --settings settings --root . -o /tmp/base.json
166
+ ```
167
+
168
+ MIT.
@@ -0,0 +1,153 @@
1
+ # pryti-contract
2
+
3
+ Your backend keeps a list of what it does. And it can't lie about it.
4
+
5
+ Companion library to [pryti-semantic-reviewer](https://github.com/AnkushSinghGandhi/pryti-semantic-reviewer).
6
+ Pryti reads code from outside and guesses. This runs inside your app and knows.
7
+
8
+ ## What it does
9
+
10
+ While your app starts, it records:
11
+
12
+ - every **route** (path, method, auth)
13
+ - every **model** (fields, types, constraints)
14
+ - every **outside call** (HTTP, email, payments)
15
+ - every **background job**
16
+ - how much of that it actually **knows** vs. couldn't figure out
17
+
18
+ Then you can ask your app one question and get an answer:
19
+
20
+ > "What does this app actually do?"
21
+
22
+ ## Why it matters
23
+
24
+ AI opens a PR with 400 changed lines. You don't read them. You read this:
25
+
26
+ ```
27
+ RISKY (4)
28
+ POST /orders auth: user -> public
29
+ POST /orders new effect: net:analytics.example.com
30
+ shop.Customer.email unique: True -> False
31
+ shop.Customer.name field removed (data loss)
32
+ ```
33
+
34
+ Four lines instead of four hundred.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install pryti-contract
40
+ ```
41
+
42
+ ## Use it — three levels
43
+
44
+ ### Level 1: nothing to write
45
+
46
+ Works on an existing Django project with zero code changes.
47
+
48
+ ```bash
49
+ pryti-contract export --settings myproject.settings -o contract.json
50
+ ```
51
+
52
+ It reads Django's real router and real model registry. Routes built in loops,
53
+ DRF routers, mixins — all found, because the app already resolved them at startup.
54
+
55
+ Coverage will be partial. That's reported, not hidden:
56
+
57
+ ```
58
+ wrote contract.json: 9 routes, 6 models, 0 jobs, auth known on 5/9
59
+ ```
60
+
61
+ ### Level 2: declare what matters
62
+
63
+ ```python
64
+ from pryti_contract import contract
65
+
66
+ @contract.route("POST /orders", auth="user")
67
+ @contract.effects("net:api.stripe.com")
68
+ def create_order(request):
69
+ ...
70
+ ```
71
+
72
+ Eight decorators total, max. AI writes these correctly first try, because
73
+ they look like every Django decorator it has ever seen.
74
+
75
+ ### Level 3: enforce it
76
+
77
+ ```python
78
+ # settings.py
79
+ MIDDLEWARE = ["pryti_contract.middleware.ContractMiddleware", ...]
80
+
81
+ # conftest.py or apps.py
82
+ from pryti_contract import guard
83
+ guard.install(mode="error") # off | record | warn | error
84
+ ```
85
+
86
+ Now an undeclared call fails loudly:
87
+
88
+ ```
89
+ shop.views.leaky_order performed undeclared effect 'net:api.stripe.com'.
90
+ Declared: none. Add @contract.effects('net:api.stripe.com') or remove the call.
91
+ ```
92
+
93
+ This is the part no linter can do. The bad code doesn't ship.
94
+
95
+ **Don't know what to declare?** Run your tests in `record` mode and let it tell you:
96
+
97
+ ```python
98
+ guard.install(mode="record")
99
+ # ... run test suite ...
100
+ json.dump(guard.suggestions(), open("observed.json", "w"))
101
+ ```
102
+
103
+ ```bash
104
+ pryti-contract suggest observed.json # prints the decorators to paste in
105
+ ```
106
+
107
+ ## In CI
108
+
109
+ ```yaml
110
+ - run: pryti-contract export --settings myproject.settings -o head.json
111
+ - run: git checkout ${{ github.base_ref }}
112
+ - run: pryti-contract export --settings myproject.settings -o base.json
113
+ - run: pryti-contract diff base.json head.json --markdown --fail-on risky
114
+ ```
115
+
116
+ `--fail-on risky` exits 1 on auth weakening, new outside calls, dropped fields,
117
+ relaxed uniqueness, or changed relations.
118
+
119
+ ## How the effect guard works
120
+
121
+ One hook at the socket layer, not per-library. `requests`, `httpx`, `urllib`,
122
+ `boto3`, `stripe` — all covered by the same code, including libraries that
123
+ don't exist yet.
124
+
125
+ - `socket.getaddrinfo` is checked **before** it runs, so a blocked call never leaves the process
126
+ - `socket.socket.connect` catches direct-IP connections
127
+ - `smtplib.SMTP.sendmail` catches email
128
+ - localhost and unix sockets are never effects, so your database doesn't trip it
129
+
130
+ Patterns support wildcards: `net:*.stripe.com`, `net:*`, `email`.
131
+
132
+ ## What it does not catch
133
+
134
+ Worth being blunt about.
135
+
136
+ - **Business logic.** If AI changes a discount from 10% to 90%, the contract is
137
+ identical. Routes same, models same, effects same. Tests catch that; this doesn't.
138
+ - **Anything the router never sees.** Dead code, unmounted views.
139
+ - **Dynamic hostnames** are caught at runtime, not at export time. The contract
140
+ records what you declared; the guard records what actually happened.
141
+
142
+ Structural mistakes: this. Logic mistakes: your tests. You need both.
143
+
144
+ ## Try it
145
+
146
+ ```bash
147
+ git clone ... && cd pryti-contract
148
+ pip install -e ".[dev]"
149
+ pytest
150
+ cd examples/demo && pryti-contract export --settings settings --root . -o /tmp/base.json
151
+ ```
152
+
153
+ MIT.
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pryti-contract"
7
+ version = "0.1.0"
8
+ description = "Your backend keeps a list of what it does, and cannot lie about it."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ dependencies = []
13
+
14
+ [project.optional-dependencies]
15
+ django = ["django>=4.2"]
16
+ dev = ["pytest>=7", "django>=4.2"]
17
+
18
+ [project.scripts]
19
+ pryti-contract = "pryti_contract.cli:main"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+
24
+ [tool.pytest.ini_options]
25
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,99 @@
1
+ """pryti-contract — your backend keeps a list of what it does.
2
+
3
+ from pryti_contract import contract
4
+
5
+ @contract.route("POST /orders", auth="user")
6
+ @contract.effects("net:api.stripe.com", "email")
7
+ def create_order(request): ...
8
+
9
+ Then::
10
+
11
+ pryti-contract export -o contract.json
12
+ pryti-contract diff base.json head.json
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .diff import Change, diff, render_markdown, render_text, worst
18
+ from .guard import UndeclaredEffect, guard
19
+ from .models import Contract, Coverage, Job, Model, Route
20
+ from .registry import handler_name, scope
21
+ from .registry import registry as contract
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ __all__ = [
26
+ "contract",
27
+ "guard",
28
+ "scope",
29
+ "handler_name",
30
+ "build",
31
+ "diff",
32
+ "render_text",
33
+ "render_markdown",
34
+ "worst",
35
+ "Change",
36
+ "Contract",
37
+ "Coverage",
38
+ "Route",
39
+ "Model",
40
+ "Job",
41
+ "UndeclaredEffect",
42
+ "__version__",
43
+ ]
44
+
45
+
46
+ def build(include_django: bool = True) -> Contract:
47
+ """The whole contract: what Django knows at runtime, plus what you declared.
48
+
49
+ Order matters. Probing loads the URLConf, which imports your views, which is
50
+ what makes the decorators run. Reading the registry first would find it empty.
51
+ """
52
+ result = Contract()
53
+ if include_django:
54
+ try:
55
+ from django.apps import apps # noqa: F401
56
+
57
+ from .django_probe import probe
58
+
59
+ probe(result)
60
+ except Exception: # noqa: BLE001 - Django is optional; declarations still work
61
+ pass
62
+
63
+ _merge_declarations(result, contract.build())
64
+ result.recompute_coverage()
65
+ return result
66
+
67
+
68
+ def _merge_declarations(runtime: Contract, declared: Contract) -> None:
69
+ """A declaration is a claim about a handler. The router says where it lives."""
70
+ by_handler: dict[str, list[str]] = {}
71
+ for key, route in runtime.routes.items():
72
+ by_handler.setdefault(route.handler, []).append(key)
73
+
74
+ for d in declared.routes.values():
75
+ keys = by_handler.get(d.handler, [])
76
+ exact = [k for k in keys if runtime.routes[k].path == d.path]
77
+ targets = exact or keys
78
+
79
+ if not targets:
80
+ # Declared but not mounted (yet). Keep it; a missing route is worth seeing.
81
+ runtime.routes[d.key] = d
82
+ continue
83
+
84
+ paths = sorted({runtime.routes[k].path for k in targets})
85
+ for k in targets:
86
+ runtime.routes.pop(k, None)
87
+ for path in paths:
88
+ merged = Route(
89
+ method=d.method,
90
+ path=path,
91
+ handler=d.handler,
92
+ auth=d.auth,
93
+ effects=sorted(d.effects),
94
+ source="declared",
95
+ )
96
+ runtime.routes[merged.key] = merged
97
+
98
+ for job in declared.jobs.values():
99
+ runtime.jobs[job.name] = job
@@ -0,0 +1,142 @@
1
+ """pryti-contract command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import json
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from . import build
13
+ from .diff import RISKY, REVIEW, diff, render_markdown, render_text, worst
14
+ from .models import Contract
15
+
16
+
17
+ def _setup_django(settings: str | None) -> None:
18
+ if settings:
19
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings)
20
+ if not os.environ.get("DJANGO_SETTINGS_MODULE"):
21
+ return
22
+ import django
23
+
24
+ django.setup()
25
+
26
+
27
+ def cmd_export(args: argparse.Namespace) -> int:
28
+ sys.path.insert(0, str(Path(args.root).resolve()))
29
+ _setup_django(args.settings)
30
+ for mod in args.import_module or []:
31
+ importlib.import_module(mod)
32
+
33
+ data = build(include_django=not args.no_django).to_dict()
34
+ text = json.dumps(data, indent=2, sort_keys=True)
35
+ if args.output:
36
+ Path(args.output).write_text(text + "\n", encoding="utf-8")
37
+ cov = data["coverage"]
38
+ print(
39
+ f"wrote {args.output}: {len(data['routes'])} routes, "
40
+ f"{len(data['models'])} models, {len(data['jobs'])} jobs, "
41
+ f"auth known on {cov['routes_with_auth']}/{cov['routes_total']}",
42
+ file=sys.stderr,
43
+ )
44
+ if cov["unresolved"]:
45
+ print(f"unresolved: {len(cov['unresolved'])}", file=sys.stderr)
46
+ else:
47
+ print(text)
48
+ return 0
49
+
50
+
51
+ def cmd_diff(args: argparse.Namespace) -> int:
52
+ base = Contract.from_dict(json.loads(Path(args.base).read_text(encoding="utf-8")))
53
+ head = Contract.from_dict(json.loads(Path(args.head).read_text(encoding="utf-8")))
54
+ changes = diff(base, head)
55
+
56
+ print(render_markdown(changes) if args.markdown else render_text(changes))
57
+
58
+ if not changes:
59
+ return 0
60
+ level = worst(changes)
61
+ if args.fail_on == "risky" and level == RISKY:
62
+ return 1
63
+ if args.fail_on == "review" and level in (RISKY, REVIEW):
64
+ return 1
65
+ if args.fail_on == "any":
66
+ return 1
67
+ return 0
68
+
69
+
70
+ def cmd_invariants(args: argparse.Namespace) -> int:
71
+ """Emit a confirmed-invariant corpus for pryti-semantic-reviewer --invariants."""
72
+ sys.path.insert(0, str(Path(args.root).resolve()))
73
+ _setup_django(args.settings)
74
+ for mod in args.import_module or []:
75
+ importlib.import_module(mod)
76
+
77
+ from .invariants import to_invariant_corpus
78
+
79
+ corpus = to_invariant_corpus(build(include_django=not args.no_django))
80
+ text = json.dumps(corpus, indent=2)
81
+ if args.output:
82
+ Path(args.output).write_text(text + "\n", encoding="utf-8")
83
+ dests = corpus[0]["observed"]["destinations"]
84
+ print(
85
+ f"wrote {args.output}: {len(corpus)} confirmed invariants, "
86
+ f"{len(dests)} egress destination(s)",
87
+ file=sys.stderr,
88
+ )
89
+ else:
90
+ print(text)
91
+ return 0
92
+
93
+
94
+ def cmd_suggest(args: argparse.Namespace) -> int:
95
+ """Print declarations discovered by a recorded run (e.g. your test suite)."""
96
+ data = json.loads(Path(args.observed).read_text(encoding="utf-8"))
97
+ for handler, effects in sorted(data.items()):
98
+ joined = ", ".join(repr(e) for e in effects)
99
+ print(f"# {handler}")
100
+ print(f"@contract.effects({joined})")
101
+ return 0
102
+
103
+
104
+ def main(argv: list[str] | None = None) -> int:
105
+ p = argparse.ArgumentParser(prog="pryti-contract", description=__doc__)
106
+ sub = p.add_subparsers(dest="cmd", required=True)
107
+
108
+ e = sub.add_parser("export", help="write the contract as JSON")
109
+ e.add_argument("-o", "--output")
110
+ e.add_argument("--settings", help="DJANGO_SETTINGS_MODULE")
111
+ e.add_argument("--root", default=".", help="project root to put on sys.path")
112
+ e.add_argument("--import-module", action="append", help="extra modules to import")
113
+ e.add_argument("--no-django", action="store_true")
114
+ e.set_defaults(func=cmd_export)
115
+
116
+ d = sub.add_parser("diff", help="compare two contract files")
117
+ d.add_argument("base")
118
+ d.add_argument("head")
119
+ d.add_argument("--markdown", action="store_true", help="format for a PR comment")
120
+ d.add_argument("--fail-on", choices=["never", "risky", "review", "any"], default="never")
121
+ d.set_defaults(func=cmd_diff)
122
+
123
+ inv = sub.add_parser(
124
+ "invariants", help="emit a confirmed-invariant corpus for the reviewer's --invariants"
125
+ )
126
+ inv.add_argument("-o", "--output")
127
+ inv.add_argument("--settings", help="DJANGO_SETTINGS_MODULE")
128
+ inv.add_argument("--root", default=".", help="project root to put on sys.path")
129
+ inv.add_argument("--import-module", action="append", help="extra modules to import")
130
+ inv.add_argument("--no-django", action="store_true")
131
+ inv.set_defaults(func=cmd_invariants)
132
+
133
+ s = sub.add_parser("suggest", help="turn a recorded run into declarations")
134
+ s.add_argument("observed", help="JSON from guard.suggestions()")
135
+ s.set_defaults(func=cmd_suggest)
136
+
137
+ args = p.parse_args(argv)
138
+ return int(args.func(args))
139
+
140
+
141
+ if __name__ == "__main__":
142
+ raise SystemExit(main())