pysnapapi 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pysnapapi-0.3.0.dist-info/METADATA +353 -0
- pysnapapi-0.3.0.dist-info/RECORD +32 -0
- pysnapapi-0.3.0.dist-info/WHEEL +5 -0
- pysnapapi-0.3.0.dist-info/entry_points.txt +5 -0
- pysnapapi-0.3.0.dist-info/licenses/LICENSE +21 -0
- pysnapapi-0.3.0.dist-info/top_level.txt +1 -0
- snapapi/__init__.py +8 -0
- snapapi/__main__.py +4 -0
- snapapi/api_client.py +125 -0
- snapapi/cassette.py +81 -0
- snapapi/cli.py +603 -0
- snapapi/engine.py +1837 -0
- snapapi/exceptions.py +22 -0
- snapapi/fmt.py +102 -0
- snapapi/helpers.py +63 -0
- snapapi/history.py +155 -0
- snapapi/jsonpath.py +173 -0
- snapapi/lint.py +119 -0
- snapapi/listeners.py +87 -0
- snapapi/mock.py +207 -0
- snapapi/openapi.py +236 -0
- snapapi/parser.py +1214 -0
- snapapi/profiles.py +27 -0
- snapapi/pytest_plugin.py +52 -0
- snapapi/redact.py +79 -0
- snapapi/reports.py +541 -0
- snapapi/safety.py +34 -0
- snapapi/select.py +133 -0
- snapapi/suites.py +29 -0
- snapapi/variables.py +111 -0
- snapapi/watch.py +68 -0
- snapapi/xpath.py +66 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pysnapapi
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Lightweight DSL for HTTP API testing
|
|
5
|
+
Author-email: Deekshith Poojary <deekshithpoojary355@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Deekshith Poojary
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://deekshith-poojary98.github.io/snapapi/
|
|
29
|
+
Project-URL: Documentation, https://github.com/deekshith-poojary98/snapapi#readme
|
|
30
|
+
Project-URL: Repository, https://github.com/deekshith-poojary98/snapapi
|
|
31
|
+
Project-URL: Issues, https://github.com/deekshith-poojary98/snapapi/issues
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Environment :: Console
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Topic :: Software Development :: Testing
|
|
38
|
+
Requires-Python: >=3.9
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
License-File: LICENSE
|
|
41
|
+
Requires-Dist: requests>=2.28.0
|
|
42
|
+
Requires-Dist: colorama>=0.4.6
|
|
43
|
+
Requires-Dist: jsonschema>=4.18.0
|
|
44
|
+
Requires-Dist: PyYAML>=6.0
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
47
|
+
Provides-Extra: watch
|
|
48
|
+
Requires-Dist: watchdog>=2.1; extra == "watch"
|
|
49
|
+
Dynamic: license-file
|
|
50
|
+
|
|
51
|
+
# SnapAPI
|
|
52
|
+
|
|
53
|
+
[](https://badge.fury.io/py/snapapi)
|
|
54
|
+
[](https://www.python.org/downloads/)
|
|
55
|
+
[](https://github.com/deekshith-poojary98/snapapi/actions/workflows/snapapi.yml)
|
|
56
|
+
[](https://deepwiki.com/deekshith-poojary98/snapapi)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
SnapAPI is a lightweight HTTP API testing framework with a small custom DSL.
|
|
60
|
+
Write `.sapi` files, then run them from the CLI. The older `.snaptest` extension still works.
|
|
61
|
+
|
|
62
|
+
**[User guide](https://deekshith-poojary98.github.io/snapapi/)** — install, DSL reference, CLI, CI, VS Code, and an in-browser **[playground](https://deekshith-poojary98.github.io/snapapi/playground.html)**
|
|
63
|
+
|
|
64
|
+
## Features
|
|
65
|
+
|
|
66
|
+
- Human-readable DSL for GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS
|
|
67
|
+
- Attach `BODY`/`DATA`, `HEADER`s, `QUERY`/`PARAM`, and `AUTH` to the current request
|
|
68
|
+
- `EXPECT` checks: status, body contains, JSONPath (filters + collection asserts), XPath, response headers
|
|
69
|
+
- `SAVE` values from JSON responses and reuse them as `${var}`
|
|
70
|
+
- Setup / teardown with cycle detection
|
|
71
|
+
- Env files, tag filters, timeouts, retries, JSON and JUnit reports
|
|
72
|
+
- OpenAPI response/request contract checks, VCR cassettes, JSON mock server
|
|
73
|
+
- pytest plugin (`snapapi_run` / `@pytest.mark.snapapi`)
|
|
74
|
+
- VS Code syntax highlighting and diagnostics for `.sapi` files
|
|
75
|
+
|
|
76
|
+
## Requirements
|
|
77
|
+
|
|
78
|
+
- Python 3.9 or newer
|
|
79
|
+
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
git clone https://github.com/Deekshith-07/snapapi.git
|
|
84
|
+
cd snapapi
|
|
85
|
+
python3 -m venv .venv
|
|
86
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
87
|
+
pip install -e ".[dev]"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Or with the pinned runtime dependencies:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pip install -r requirements.txt
|
|
94
|
+
pip install -e .
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## CLI
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
snapapi path/to/file.sapi
|
|
101
|
+
python -m snapapi path/to/file.sapi
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Pass multiple files or a directory of `.sapi` files:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
snapapi tests/test_suite.snaptest
|
|
108
|
+
snapapi tests/ suites/auth.sapi
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Options:
|
|
112
|
+
|
|
113
|
+
| Flag | Meaning |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `-k "Login or not Health"` | Pytest-style keyword expression on name/description/tags |
|
|
116
|
+
| `-m "smoke and not slow"` | Pytest-style tag expression |
|
|
117
|
+
| `--tag user` | Run tests that have this tag (repeatable; all given tags must match) |
|
|
118
|
+
| `--exclude slow` | Skip tests with this tag (repeatable) |
|
|
119
|
+
| `--name "Create User"` | Run tests with this name (repeatable) |
|
|
120
|
+
| `--env .env` | Load `KEY=VALUE` pairs for `${VAR}`. If omitted, SnapAPI loads `<suite>.env`, then `.env` next to the file, then a single sibling `*.env` |
|
|
121
|
+
| `-D TOKEN=secret` | Set `${VAR}` from the CLI (repeatable) |
|
|
122
|
+
| `--timeout 10` | HTTP timeout in seconds (overrides `TIMEOUT`) |
|
|
123
|
+
| `--report json:report.json` | Write a JSON report |
|
|
124
|
+
| `--report junit:report.xml` | Write a JUnit XML report |
|
|
125
|
+
| `--report html:report.html` | Write a self-contained HTML report |
|
|
126
|
+
| `-x` / `--exitfirst` / `--stop-on-failure` | Stop each suite on the first failed test (off by default) |
|
|
127
|
+
| `--maxfail N` | Stop after N failures |
|
|
128
|
+
| `--collect-only` | List selected tests without HTTP |
|
|
129
|
+
| `-q` / `-v` | Quiet or verbose console |
|
|
130
|
+
| `--durations N` | Show the N slowest tests |
|
|
131
|
+
| `--profile stage` | Load `environments/stage.env`, `.snapapi/stage.env`, or `stage.env` |
|
|
132
|
+
| `--workers N` | Run independent tests in parallel (SAVE is isolated per test; sibling SAVE falls back to sequential) |
|
|
133
|
+
| `--grep regex` | Filter tests by name/description |
|
|
134
|
+
| `--lf` / `--last-failed` | Re-run failures from `.snapapi/last-run.json` (matches file + suite + name, including `Test [row]`) |
|
|
135
|
+
| `--ff` / `--failed-first` | Run last-failed tests first, then the rest |
|
|
136
|
+
| `--mode record\|replay\|record-on-miss` | VCR cassettes under `.snapapi/cassettes/` |
|
|
137
|
+
| `--record-on-miss` | With `--mode replay`, hit the network and save when a cassette is missing |
|
|
138
|
+
| `--vcr-match query,body,accept,authorization` | Cassette identity fields (default: query, content-type, accept, body) |
|
|
139
|
+
| `--contract-strict` | Fail when an OpenAPI path/method/schema is missing (default: skip/warn) |
|
|
140
|
+
| `--reruns N` | Re-run failed *tests* up to N times (distinct from `EXPECT RETRY`) |
|
|
141
|
+
| `--listener PATH[:Class]` | Python listener called after each test / suite (repeatable) |
|
|
142
|
+
| `--on-fail curl` / `--on-fail har:dir` | Emit a redacted curl or HAR on failure |
|
|
143
|
+
| `--safe-url` | Block private/metadata hosts |
|
|
144
|
+
| `--proxy URL` | HTTP/HTTPS proxy |
|
|
145
|
+
| `--insecure` | Skip TLS certificate verification |
|
|
146
|
+
| `--cert PATH` | Client certificate |
|
|
147
|
+
| `--cacert PATH` | CA bundle used to verify TLS |
|
|
148
|
+
| `snapapi lint PATH` | Parse/validate without HTTP |
|
|
149
|
+
| `snapapi fmt PATH` | Format `.sapi` files |
|
|
150
|
+
| `snapapi openapi spec.yaml` | Generate GET/POST/PUT/PATCH/DELETE smoke tests |
|
|
151
|
+
| `snapapi history [--failed] [--since 7d]` | Print `.snapapi/history.jsonl` |
|
|
152
|
+
| `snapapi mock mock.json [--port 0]` | Serve routes from a JSON mock file (prints the URL) |
|
|
153
|
+
| `snapapi watch PATH [--interval 0.5]` | Re-run when `.sapi` files change (poll; optional `watchdog` extra) |
|
|
154
|
+
|
|
155
|
+
The process exits `0` when every test passed, `1` when a test failed, and `2` on parse or usage errors.
|
|
156
|
+
|
|
157
|
+
## DSL
|
|
158
|
+
|
|
159
|
+
Recommended form:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
SUITE: Book Store
|
|
163
|
+
DESC: Validates the user API
|
|
164
|
+
TIMEOUT: 10
|
|
165
|
+
URL: https://api.example.com
|
|
166
|
+
HEADER Content-Type: application/json
|
|
167
|
+
|
|
168
|
+
TEST: Create User
|
|
169
|
+
DESC: Create a user and keep the id
|
|
170
|
+
TAG: users write
|
|
171
|
+
POST: /users
|
|
172
|
+
AUTH: bearer ${TOKEN}
|
|
173
|
+
BODY: {"name": "Jane", "email": "jane@example.com"}
|
|
174
|
+
EXPECT: status == 201
|
|
175
|
+
EXPECT: body contains id
|
|
176
|
+
SAVE: userId FROM $.id
|
|
177
|
+
|
|
178
|
+
TEST: Get User
|
|
179
|
+
TAG: users
|
|
180
|
+
SETUP: Create User
|
|
181
|
+
GET: /users/${userId}
|
|
182
|
+
EXPECT: status == 200
|
|
183
|
+
EXPECT: json $.email == "jane@example.com"
|
|
184
|
+
EXPECT: header Content-Type contains json
|
|
185
|
+
|
|
186
|
+
TEST: List Users
|
|
187
|
+
GET: /users
|
|
188
|
+
QUERY: page=2&limit=10
|
|
189
|
+
PARAM: sort name
|
|
190
|
+
EXPECT: status == 200
|
|
191
|
+
|
|
192
|
+
TEST: Wait for ready
|
|
193
|
+
GET: /jobs/${id}
|
|
194
|
+
WAIT: json $.status == "ready" TIMEOUT 10s BACKOFF 0.5s
|
|
195
|
+
EXPECT: status == 200
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Comments are `//` lines. Indentation is cosmetic. JSON bodies may be one line or span multiple lines.
|
|
199
|
+
|
|
200
|
+
The older forms still parse:
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
OPTIONS: {"TIMEOUT": 10}
|
|
204
|
+
HEADERS: {"Content-Type": "application/json"}
|
|
205
|
+
TAG: users, write
|
|
206
|
+
REQUEST: POST /users
|
|
207
|
+
HEADERS: {"Authorization": "Bearer ${TOKEN}"}
|
|
208
|
+
DATA: {"name": "Jane"}
|
|
209
|
+
EXPECT: STATUS 201
|
|
210
|
+
EXPECT: CONTAINS id
|
|
211
|
+
EXPECT: JSON $.email == "jane@example.com"
|
|
212
|
+
EXPECT: HEADER Content-Type CONTAINS json
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Keywords
|
|
216
|
+
|
|
217
|
+
- Suite: `SUITE`, `DESC`, `URL`, `TIMEOUT`, `FOLLOW-REDIRECTS`, `OPTIONS`, `IMPORT`, `SUITE-SETUP`, `SET`
|
|
218
|
+
- Test: `TEST`, `TAG`, `SETUP`, `TEARDOWN`, `DEPENDS`, `SKIP`, `ONLY`, `QUARANTINE`, `EXAMPLES`, `SET`
|
|
219
|
+
- Helper: `HELPER` (named procedure for `SUITE-SETUP` / `SETUP` / `TEARDOWN`; not a test case)
|
|
220
|
+
- Request: `REQUEST`, `GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`HEAD`, `BODY`/`DATA`, `FILE`, `GRAPHQL`, `HEADER`/`HEADERS`, `QUERY`, `PARAM`, `AUTH`, `EXPECT`, `SAVE`, `WAIT`, `SET`
|
|
221
|
+
|
|
222
|
+
HTTP `OPTIONS` is written as `REQUEST: OPTIONS /path` so it does not collide with suite-level `OPTIONS: {...}` JSON. `HEAD: /x` is a request alias like `GET:`.
|
|
223
|
+
|
|
224
|
+
`SETUP` / `TEARDOWN` name a `HELPER` or `TEST`. Prefer `HELPER:` for procedures that should not appear as cases. A `TEST` named only as setup is still treated as a helper (legacy).
|
|
225
|
+
|
|
226
|
+
`DEPENDS: Create User` (comma-separated for several names) keeps both tests as primaries. SnapAPI reorders so named tests run first; unrelated tests keep file order. If a named test failed, skipped, or was not selected (for example `-k`), the dependent test is skipped with that reason. Cycles and unknown names are parse errors. `DEPENDS` cannot target a `HELPER`. This is not `SETUP:` — setup helpers always run first and fail the dependent test when they fail.
|
|
227
|
+
|
|
228
|
+
`IMPORT: other.sapi` pulls tests from another file (paths are relative to the current file).
|
|
229
|
+
|
|
230
|
+
`SET: orderId ${uuid()}` assigns an interpolated value (including helpers) without an HTTP call. It may appear at suite, test, or step level.
|
|
231
|
+
|
|
232
|
+
`WAIT: json $.status == "ready" TIMEOUT 10s BACKOFF 0.5s` reissues the current request until the check passes or the timeout expires. `EXPECT: json $.status == "ready" RETRY 20 BACKOFF 0.5s` also retries when the HTTP status is already 200.
|
|
233
|
+
|
|
234
|
+
`AUTH: bearer ${TOKEN}` sets `Authorization: Bearer ${TOKEN}`. Explicit `HEADER` lines still work.
|
|
235
|
+
|
|
236
|
+
`AUTH: oauth2 grant=client_credentials token_url=... client_id=...` and `grant=password username=... password=...` fetch a token (cached). If the token response includes `refresh_token`, a 401 retries once after refresh.
|
|
237
|
+
|
|
238
|
+
`AUTH: oauth2 grant=authorization_code token_url=... auth_url=... client_id=... redirect_uri=... code=${AUTH_CODE} pkce=true` exchanges an authorization code. SnapAPI does not open a browser; supply `${AUTH_CODE}` from the environment. With `pkce=true` the token request includes S256 `code_verifier` / `code_challenge` fields.
|
|
239
|
+
|
|
240
|
+
`AUTH: digest user:pass` uses `requests` HTTP Digest Auth.
|
|
241
|
+
|
|
242
|
+
`QUERY: page=2&limit=10` and `PARAM: page 2` attach query parameters to the current request (they merge with any query string already in the path).
|
|
243
|
+
|
|
244
|
+
`OPTIONS: {"OPENAPI": "spec.yaml"}` validates JSON responses (and request bodies/required params) against the matching path+method schema when present. Missing path/schema is skipped by default. Strict mode fails instead:
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
OPTIONS: {"OPENAPI": "spec.yaml", "OPENAPI-STRICT": true}
|
|
248
|
+
EXPECT: openapi ./spec.yaml strict
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
CLI `--contract-strict` is the same switch. Partial path match (`/users/{id}` vs `/users/1`) is allowed.
|
|
252
|
+
|
|
253
|
+
### Checks
|
|
254
|
+
|
|
255
|
+
Check types are case-insensitive. Preferred:
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
EXPECT: status == 200
|
|
259
|
+
EXPECT: status != 500
|
|
260
|
+
EXPECT: status == 200 RETRY 5 ON 5xx BACKOFF 1s
|
|
261
|
+
EXPECT: body contains userId
|
|
262
|
+
EXPECT: body not contains stack
|
|
263
|
+
EXPECT: json $.email matches ^.+@example\\.com$
|
|
264
|
+
EXPECT: json $.items length == 3
|
|
265
|
+
EXPECT: json $.items[*].id contains 3
|
|
266
|
+
EXPECT: json $.items[?(@.status=="open")].id contains 3
|
|
267
|
+
EXPECT: json $.tags contains-all ["a","b"]
|
|
268
|
+
EXPECT: json $.items each $.status == "active"
|
|
269
|
+
EXPECT: status == 400 OR status == 401
|
|
270
|
+
EXPECT: json $.success == false AND body contains error
|
|
271
|
+
EXPECT: (status == 400 OR status == 401) AND json $.success == false
|
|
272
|
+
EXPECT: schema ./schemas/user.json
|
|
273
|
+
EXPECT: duration < 200ms
|
|
274
|
+
EXPECT: header Content-Type contains json
|
|
275
|
+
EXPECT: openapi ./openapi.yaml
|
|
276
|
+
EXPECT: openapi ./openapi.yaml strict
|
|
277
|
+
EXPECT: xpath //Order/@id == "1"
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Also accepted:
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
EXPECT: STATUS 200
|
|
284
|
+
EXPECT: CONTAINS userId
|
|
285
|
+
EXPECT: JSON $.data.email == "jane@example.com"
|
|
286
|
+
EXPECT: HEADER Content-Type CONTAINS json
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
JSONPath is a small subset: `$.a.b`, `$.items.0.id`, `$.items[0].id`, `$.items[*].id`, and equality filters `$.items[?(@.status=="open")]` / `$.items[?(@.id==1)]`.
|
|
290
|
+
|
|
291
|
+
`AND` / `OR` combine checks on one line (`AND` binds tighter than `OR`; parentheses group). Quote a value if it contains those words. Multiple `EXPECT` lines on the same request still all have to pass.
|
|
292
|
+
|
|
293
|
+
XPath uses stdlib `xml.etree` (descendant tags and `/@attr`). Axes, namespaces, and functions are not implemented.
|
|
294
|
+
|
|
295
|
+
### Variables
|
|
296
|
+
|
|
297
|
+
`${NAME}` is expanded in URLs, paths, headers, data, and expect values.
|
|
298
|
+
|
|
299
|
+
Lookup order: process environment, then `--env` / auto-discovered suite env file, then `SET` / `SAVE` values.
|
|
300
|
+
|
|
301
|
+
## Sample suite
|
|
302
|
+
|
|
303
|
+
`tests/recommended.snaptest` shows the current DSL against a local mock server (pytest injects `BASE_URL`). `tests/test_suite.snaptest` is a classic-syntax example against [reqres.in](https://reqres.in) and needs network access. Automated tests in `tests/test_*.py` use a local mock HTTP server and do not call reqres. CI replays `tests/fixtures/offline.snaptest` from a checked-in cassette.
|
|
304
|
+
|
|
305
|
+
HTML reports include redacted request/response bodies. VCR cassette keys include method, path, and (by default) sorted query string, `Content-Type`/`Accept`, and body. `OPTIONS: {"VCR-MATCH": ["query","body","accept","authorization"]}` or `--vcr-match authorization,query` replaces that default. Replay restores `Set-Cookie` onto the session.
|
|
306
|
+
|
|
307
|
+
`snapapi mock tests/fixtures/mock.json --port 0` serves JSON routes. Routes may use path templates (`/users/{id}`), optional `match.query` / `match.body` subsets, and `delay_ms`. Exact paths win over templates. There is no language server, gRPC, or WebSocket support.
|
|
308
|
+
|
|
309
|
+
### pytest plugin
|
|
310
|
+
|
|
311
|
+
Install with `pip install -e ".[dev]"`. Then:
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
def test_suite(snapapi_run):
|
|
315
|
+
result = snapapi_run("tests/foo.sapi")
|
|
316
|
+
assert result.ok
|
|
317
|
+
|
|
318
|
+
@pytest.mark.snapapi("tests/foo.sapi")
|
|
319
|
+
def test_marked(snapapi_run, request):
|
|
320
|
+
snapapi_run(request.node.get_closest_marker("snapapi").args[0])
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`snapapi_run(path, **engine_kwargs)` returns `SuiteResult` and fails the pytest case when the suite is not ok.
|
|
324
|
+
|
|
325
|
+
## Project layout
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
snapapi/
|
|
329
|
+
├── snapapi/ # Python package
|
|
330
|
+
│ ├── parser.py # .sapi DSL parser
|
|
331
|
+
│ ├── engine.py # runner, checks, setup/teardown
|
|
332
|
+
│ ├── api_client.py # requests wrapper
|
|
333
|
+
│ └── cli.py # snapapi command
|
|
334
|
+
├── tests/ # pytest + sample .sapi / .snaptest suites
|
|
335
|
+
├── docs/ # User guide + in-browser playground
|
|
336
|
+
├── snapapi-language/ # VS Code grammar / run command
|
|
337
|
+
├── pyproject.toml
|
|
338
|
+
└── README.md
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
## Running the tests
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
pytest
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
## VS Code
|
|
348
|
+
|
|
349
|
+
The `snapapi-language` extension is a language pack for `.sapi` files: syntax highlighting, snippets, completions, lightweight diagnostics (unknown keywords, unknown SETUP names, `HEAD:` / `REQUEST: OPTIONS`), and **SnapAPI: Run current file** / **Run test at cursor**. See [snapapi-language/README.md](snapapi-language/README.md). There is no separate language-server process.
|
|
350
|
+
|
|
351
|
+
## License
|
|
352
|
+
|
|
353
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
pysnapapi-0.3.0.dist-info/licenses/LICENSE,sha256=ChRR1xMo7b0WSSORcp1Ue9sJ9rDVG9dqNnIMdefSHLs,1074
|
|
2
|
+
snapapi/__init__.py,sha256=fhKzpKQWD0rF8IPKxIE6sQSWEa1sm_mrtMaGcJzJetM,290
|
|
3
|
+
snapapi/__main__.py,sha256=K0UJytWE0UIzqxvB7XzDpri8gJ8PeH-hjoX4GaqcHR0,70
|
|
4
|
+
snapapi/api_client.py,sha256=5-apEcbAdVfBFiETfhfdi668yVpj7of0RajWk99nT5I,4571
|
|
5
|
+
snapapi/cassette.py,sha256=wNYrjT_sX8tTiFnW5YPMwtGIqUc0RyVoBG4uthIQ88I,2827
|
|
6
|
+
snapapi/cli.py,sha256=RdEWB1cALSElgVMY-k78OJU-0JHEyDP22h0T2ZZAbws,22331
|
|
7
|
+
snapapi/engine.py,sha256=3veWRyle1xa_V1TNRIGtsQlKYz88_bW6ZCzaZgEDfRc,74530
|
|
8
|
+
snapapi/exceptions.py,sha256=ZcU4o1XACzwq2GvvD4_I2EU-uYi96clpM0GdHj-5q9Q,681
|
|
9
|
+
snapapi/fmt.py,sha256=FW3O5UJrJWf4CEm9L7Jrc4v2MbbeR45ekDK6CjrRYyk,2665
|
|
10
|
+
snapapi/helpers.py,sha256=sVTBDUbMLSa6zK441BwqDRmoM0AHCi7kb_HhzJ-mFMM,1957
|
|
11
|
+
snapapi/history.py,sha256=0LJJ3HAJMaNrLqlt3VKPG3PZl82XfcNq1A8ZfPaqTPw,4835
|
|
12
|
+
snapapi/jsonpath.py,sha256=2C2ovYOoCN0PoIHmSUioPto0NZcz03XHllUlsHWqE1Y,5262
|
|
13
|
+
snapapi/lint.py,sha256=Y6FqNu46epL4LM0CFYsaCXqYtjKabV70dxCs82_lZlQ,3942
|
|
14
|
+
snapapi/listeners.py,sha256=AAmdovNGbC6HENDUpoW1bo74SQBhTQRIuZkBUltUrTc,2889
|
|
15
|
+
snapapi/mock.py,sha256=xOQ-ULRGALjw99Lg1HQi690-OxcRLS3y_pXqdcVqIXM,6919
|
|
16
|
+
snapapi/openapi.py,sha256=lObMi5y9enhGTMsf_qqVaZauys8uW9vMWcG7_yX5QVw,8051
|
|
17
|
+
snapapi/parser.py,sha256=7sbLVyFmxwy_HJwwHAod-Jqp5oziP0AKBeO9ZDku4Rw,49913
|
|
18
|
+
snapapi/profiles.py,sha256=nBUMhxAV0n-RO5mmF6R9eDb_lds7kg6-kxhOEC69gFc,747
|
|
19
|
+
snapapi/pytest_plugin.py,sha256=LPIOwZgfhfEoO6BBvjbUV2WWyK3JFQtEt7aWPxSwXG8,1671
|
|
20
|
+
snapapi/redact.py,sha256=xLYRMkvoFxq_ZxTXYJKdx-7NxaAUppwd1GIgbdfaZPs,2005
|
|
21
|
+
snapapi/reports.py,sha256=n52CrVYGqqArmQFZj6Vglilo5hOl_LMStE19yxXHCRI,18011
|
|
22
|
+
snapapi/safety.py,sha256=Ls0aR7u0itWPse45hmiJ4KPqizZv3PghW3LZFwJigTw,1098
|
|
23
|
+
snapapi/select.py,sha256=8bxjowgnYi2H_0uZvkcvj0T1XdQ148-jAfejPg6Jj70,3627
|
|
24
|
+
snapapi/suites.py,sha256=IuMuaZSg85YEsFpQQhs5LxT74flz68umSIuuMJbYrs0,831
|
|
25
|
+
snapapi/variables.py,sha256=sUEsZjY76m7UcvBe_yPzG2PcxcfcnZHAM5QACyFj89Y,3493
|
|
26
|
+
snapapi/watch.py,sha256=F_dBiqInLrelvzVKUaPvuH46X4pAd54FrSAo85h4r74,2141
|
|
27
|
+
snapapi/xpath.py,sha256=Xdo_6GfeplHtr5_4707OydLw1kTScfNZYW_IotyrNqw,2134
|
|
28
|
+
pysnapapi-0.3.0.dist-info/METADATA,sha256=2pfZ387vFdw31SQKpIYoiTcADkQqeKMBzmt6UaWpAm4,16114
|
|
29
|
+
pysnapapi-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
30
|
+
pysnapapi-0.3.0.dist-info/entry_points.txt,sha256=5vCGUbjt-MrEO6YwbYJJQLoNnU6w6EApymINp5XuR0U,90
|
|
31
|
+
pysnapapi-0.3.0.dist-info/top_level.txt,sha256=ixqJtWjxOFIoE_Mirko8xyRTH-3pKC_AYNRLLUuESoA,8
|
|
32
|
+
pysnapapi-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Deekshith Poojary
|
|
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 @@
|
|
|
1
|
+
snapapi
|
snapapi/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""SnapAPI — a lightweight DSL for HTTP API testing."""
|
|
2
|
+
|
|
3
|
+
from snapapi.engine import Engine
|
|
4
|
+
from snapapi.exceptions import ParseError, SnapAPIError
|
|
5
|
+
from snapapi.parser import TestParser
|
|
6
|
+
|
|
7
|
+
__version__ = "0.3.0"
|
|
8
|
+
__all__ = ["Engine", "TestParser", "ParseError", "SnapAPIError", "__version__"]
|
snapapi/__main__.py
ADDED
snapapi/api_client.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APIClient:
|
|
9
|
+
"""Session-backed HTTP client with JSON, form, raw, multipart, and GraphQL bodies."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
base_url=None,
|
|
14
|
+
timeout=30,
|
|
15
|
+
follow_redirects=True,
|
|
16
|
+
verify=True,
|
|
17
|
+
cert=None,
|
|
18
|
+
proxies=None,
|
|
19
|
+
):
|
|
20
|
+
self.base_url = (base_url or "").rstrip("/")
|
|
21
|
+
self.timeout = timeout
|
|
22
|
+
self.follow_redirects = follow_redirects
|
|
23
|
+
self.verify = verify
|
|
24
|
+
self.cert = cert
|
|
25
|
+
self.proxies = dict(proxies or {})
|
|
26
|
+
self.session = requests.Session()
|
|
27
|
+
self.session.verify = verify
|
|
28
|
+
if cert:
|
|
29
|
+
self.session.cert = cert
|
|
30
|
+
if self.proxies:
|
|
31
|
+
self.session.proxies.update(self.proxies)
|
|
32
|
+
|
|
33
|
+
def request(
|
|
34
|
+
self,
|
|
35
|
+
method,
|
|
36
|
+
endpoint,
|
|
37
|
+
json=None,
|
|
38
|
+
data=None,
|
|
39
|
+
headers=None,
|
|
40
|
+
timeout=None,
|
|
41
|
+
files=None,
|
|
42
|
+
body_type=None,
|
|
43
|
+
raw=None,
|
|
44
|
+
content_type=None,
|
|
45
|
+
follow_redirects=None,
|
|
46
|
+
auth=None,
|
|
47
|
+
):
|
|
48
|
+
url = self._build_url(endpoint)
|
|
49
|
+
kwargs = {
|
|
50
|
+
"headers": dict(headers or {}),
|
|
51
|
+
"timeout": self.timeout if timeout is None else timeout,
|
|
52
|
+
"allow_redirects": self.follow_redirects if follow_redirects is None else follow_redirects,
|
|
53
|
+
}
|
|
54
|
+
if auth is not None:
|
|
55
|
+
kwargs["auth"] = auth
|
|
56
|
+
kind = (body_type or "json").lower()
|
|
57
|
+
if files:
|
|
58
|
+
kwargs["files"] = files
|
|
59
|
+
if data is not None:
|
|
60
|
+
kwargs["data"] = data
|
|
61
|
+
elif kind == "form":
|
|
62
|
+
kwargs["data"] = data if data is not None else json
|
|
63
|
+
elif kind == "raw":
|
|
64
|
+
kwargs["data"] = raw if raw is not None else data
|
|
65
|
+
if content_type:
|
|
66
|
+
kwargs["headers"]["Content-Type"] = content_type
|
|
67
|
+
elif kind == "graphql":
|
|
68
|
+
kwargs["json"] = json if json is not None else data
|
|
69
|
+
elif json is not None:
|
|
70
|
+
kwargs["json"] = json
|
|
71
|
+
elif data is not None:
|
|
72
|
+
kwargs["json"] = data
|
|
73
|
+
return self.session.request(method.upper(), url, **kwargs)
|
|
74
|
+
|
|
75
|
+
def get(self, endpoint, headers=None, timeout=None, **kwargs):
|
|
76
|
+
if "json" in kwargs:
|
|
77
|
+
return self.request("GET", endpoint, json=kwargs["json"], headers=headers, timeout=timeout)
|
|
78
|
+
return self.request("GET", endpoint, headers=headers, timeout=timeout)
|
|
79
|
+
|
|
80
|
+
def post(self, endpoint, data=None, headers=None, timeout=None, **kwargs):
|
|
81
|
+
body = kwargs["json"] if "json" in kwargs else data
|
|
82
|
+
return self.request("POST", endpoint, json=body, headers=headers, timeout=timeout)
|
|
83
|
+
|
|
84
|
+
def put(self, endpoint, data=None, headers=None, timeout=None, **kwargs):
|
|
85
|
+
body = kwargs["json"] if "json" in kwargs else data
|
|
86
|
+
return self.request("PUT", endpoint, json=body, headers=headers, timeout=timeout)
|
|
87
|
+
|
|
88
|
+
def patch(self, endpoint, data=None, headers=None, timeout=None, **kwargs):
|
|
89
|
+
body = kwargs["json"] if "json" in kwargs else data
|
|
90
|
+
return self.request("PATCH", endpoint, json=body, headers=headers, timeout=timeout)
|
|
91
|
+
|
|
92
|
+
def delete(self, endpoint, data=None, headers=None, timeout=None, **kwargs):
|
|
93
|
+
body = kwargs["json"] if "json" in kwargs else data
|
|
94
|
+
return self.request("DELETE", endpoint, json=body, headers=headers, timeout=timeout)
|
|
95
|
+
|
|
96
|
+
def head(self, endpoint, headers=None, timeout=None, **kwargs):
|
|
97
|
+
return self.request("HEAD", endpoint, headers=headers, timeout=timeout, **kwargs)
|
|
98
|
+
|
|
99
|
+
def options(self, endpoint, headers=None, timeout=None, **kwargs):
|
|
100
|
+
return self.request("OPTIONS", endpoint, headers=headers, timeout=timeout, **kwargs)
|
|
101
|
+
|
|
102
|
+
def _build_url(self, endpoint):
|
|
103
|
+
endpoint = (endpoint or "").strip()
|
|
104
|
+
if endpoint.startswith("http://") or endpoint.startswith("https://"):
|
|
105
|
+
return endpoint
|
|
106
|
+
if not self.base_url:
|
|
107
|
+
raise ValueError(f"No base URL configured for endpoint {endpoint!r}")
|
|
108
|
+
if not endpoint:
|
|
109
|
+
return self.base_url
|
|
110
|
+
if not endpoint.startswith("/"):
|
|
111
|
+
return f"{self.base_url}/{endpoint}"
|
|
112
|
+
return f"{self.base_url}{endpoint}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def open_files(file_specs, base_dir):
|
|
116
|
+
opened = {}
|
|
117
|
+
handles = []
|
|
118
|
+
for spec in file_specs or []:
|
|
119
|
+
path = Path(spec["path"])
|
|
120
|
+
if not path.is_absolute():
|
|
121
|
+
path = Path(base_dir) / path
|
|
122
|
+
handle = path.open("rb")
|
|
123
|
+
handles.append(handle)
|
|
124
|
+
opened[spec["field"]] = (path.name, handle)
|
|
125
|
+
return opened, handles
|
snapapi/cassette.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
7
|
+
|
|
8
|
+
DEFAULT_VCR_MATCH = ("query", "body", "content-type", "accept")
|
|
9
|
+
VCR_HEADER_ALIASES = {
|
|
10
|
+
"content_type": "content-type",
|
|
11
|
+
"content-type": "content-type",
|
|
12
|
+
"accept": "accept",
|
|
13
|
+
"authorization": "authorization",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_vcr_match(value):
|
|
18
|
+
if value is None:
|
|
19
|
+
return list(DEFAULT_VCR_MATCH)
|
|
20
|
+
if isinstance(value, str):
|
|
21
|
+
items = [item.strip().lower() for item in value.replace(";", ",").split(",") if item.strip()]
|
|
22
|
+
elif isinstance(value, (list, tuple)):
|
|
23
|
+
items = [str(item).strip().lower() for item in value if str(item).strip()]
|
|
24
|
+
else:
|
|
25
|
+
return list(DEFAULT_VCR_MATCH)
|
|
26
|
+
return [VCR_HEADER_ALIASES.get(item, item) for item in items] or list(DEFAULT_VCR_MATCH)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cassette_key(method, url, body=None, headers=None, match=None):
|
|
30
|
+
match_keys = parse_vcr_match(match)
|
|
31
|
+
parts = urlsplit(url or "")
|
|
32
|
+
if "query" in match_keys:
|
|
33
|
+
query = sorted(parse_qsl(parts.query, keep_blank_values=True))
|
|
34
|
+
query_blob = urlencode(query)
|
|
35
|
+
else:
|
|
36
|
+
query_blob = ""
|
|
37
|
+
canonical = urlunsplit((parts.scheme, parts.netloc, parts.path, query_blob, parts.fragment))
|
|
38
|
+
wanted_headers = {item for item in match_keys if item in ("accept", "content-type", "authorization")}
|
|
39
|
+
selected = []
|
|
40
|
+
for key, value in (headers or {}).items():
|
|
41
|
+
lowered = str(key).lower()
|
|
42
|
+
if lowered in wanted_headers:
|
|
43
|
+
selected.append((lowered, "" if value is None else str(value)))
|
|
44
|
+
selected.sort()
|
|
45
|
+
header_blob = "\n".join(f"{key}:{value}" for key, value in selected)
|
|
46
|
+
body_blob = _body_text(body) if "body" in match_keys else ""
|
|
47
|
+
raw = f"{method.upper()}\n{canonical}\n{header_blob}\n{body_blob}"
|
|
48
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_cassettes(directory):
|
|
52
|
+
path = Path(directory)
|
|
53
|
+
if not path.is_dir():
|
|
54
|
+
return {}
|
|
55
|
+
store = {}
|
|
56
|
+
for item in path.glob("*.json"):
|
|
57
|
+
try:
|
|
58
|
+
payload = json.loads(item.read_text(encoding="utf-8"))
|
|
59
|
+
except (OSError, json.JSONDecodeError):
|
|
60
|
+
continue
|
|
61
|
+
key = payload.get("key") or item.stem
|
|
62
|
+
store[key] = payload
|
|
63
|
+
return store
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save_cassette(directory, key, payload):
|
|
67
|
+
path = Path(directory)
|
|
68
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
record = dict(payload)
|
|
70
|
+
record["key"] = key
|
|
71
|
+
(path / f"{key}.json").write_text(json.dumps(record, indent=2, default=str) + "\n", encoding="utf-8")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _body_text(body):
|
|
75
|
+
if body is None:
|
|
76
|
+
return ""
|
|
77
|
+
if isinstance(body, (dict, list)):
|
|
78
|
+
return json.dumps(body, sort_keys=True, default=str)
|
|
79
|
+
if isinstance(body, bytes):
|
|
80
|
+
return hashlib.sha256(body).hexdigest()
|
|
81
|
+
return str(body)
|