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.
- pryti_contract-0.1.0/LICENSE +21 -0
- pryti_contract-0.1.0/PKG-INFO +168 -0
- pryti_contract-0.1.0/README.md +153 -0
- pryti_contract-0.1.0/pyproject.toml +25 -0
- pryti_contract-0.1.0/setup.cfg +4 -0
- pryti_contract-0.1.0/src/pryti_contract/__init__.py +99 -0
- pryti_contract-0.1.0/src/pryti_contract/cli.py +142 -0
- pryti_contract-0.1.0/src/pryti_contract/diff.py +182 -0
- pryti_contract-0.1.0/src/pryti_contract/django_probe.py +143 -0
- pryti_contract-0.1.0/src/pryti_contract/guard.py +159 -0
- pryti_contract-0.1.0/src/pryti_contract/invariants.py +75 -0
- pryti_contract-0.1.0/src/pryti_contract/middleware.py +34 -0
- pryti_contract-0.1.0/src/pryti_contract/models.py +111 -0
- pryti_contract-0.1.0/src/pryti_contract/registry.py +166 -0
- pryti_contract-0.1.0/src/pryti_contract.egg-info/PKG-INFO +168 -0
- pryti_contract-0.1.0/src/pryti_contract.egg-info/SOURCES.txt +22 -0
- pryti_contract-0.1.0/src/pryti_contract.egg-info/dependency_links.txt +1 -0
- pryti_contract-0.1.0/src/pryti_contract.egg-info/entry_points.txt +2 -0
- pryti_contract-0.1.0/src/pryti_contract.egg-info/requires.txt +7 -0
- pryti_contract-0.1.0/src/pryti_contract.egg-info/top_level.txt +1 -0
- pryti_contract-0.1.0/tests/test_core.py +183 -0
- pryti_contract-0.1.0/tests/test_django.py +77 -0
- pryti_contract-0.1.0/tests/test_invariants.py +39 -0
- pryti_contract-0.1.0/tests/test_middleware.py +85 -0
|
@@ -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,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())
|