httpee 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.
- httpee-0.1.0/LICENSE +21 -0
- httpee-0.1.0/PKG-INFO +175 -0
- httpee-0.1.0/README.md +164 -0
- httpee-0.1.0/httpee/__init__.py +204 -0
- httpee-0.1.0/httpee.egg-info/PKG-INFO +175 -0
- httpee-0.1.0/httpee.egg-info/SOURCES.txt +14 -0
- httpee-0.1.0/httpee.egg-info/dependency_links.txt +1 -0
- httpee-0.1.0/httpee.egg-info/requires.txt +1 -0
- httpee-0.1.0/httpee.egg-info/top_level.txt +1 -0
- httpee-0.1.0/pyproject.toml +16 -0
- httpee-0.1.0/setup.cfg +4 -0
- httpee-0.1.0/tests/test_eelang.py +155 -0
- httpee-0.1.0/tests/test_gevent.py +50 -0
- httpee-0.1.0/tests/test_http.py +159 -0
- httpee-0.1.0/tests/test_response.py +32 -0
- httpee-0.1.0/tests/test_runner.py +64 -0
httpee-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pwtail
|
|
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.
|
httpee-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: httpee
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: httpee — thin httpx wrapper for eelang pipelines
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# httpee
|
|
13
|
+
|
|
14
|
+
`httpee` is a thin wrapper around [httpx](https://www.python-httpx.org/) for
|
|
15
|
+
[eelang](https://github.com/eel-lang/eelang-py): requests are written as pipelines,
|
|
16
|
+
responses are checked with patterns.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from httpee import POST, headers, httpee, Response
|
|
20
|
+
|
|
21
|
+
user =
|
|
22
|
+
{'name': 'kenneth', 'role': 'admin'} # dict → form-encoded, like requests data=
|
|
23
|
+
..headers: {'Authorization': f'Bearer {token}'}
|
|
24
|
+
..POST: 'https://api.example.com/users'
|
|
25
|
+
..assert:
|
|
26
|
+
Response(status=201)
|
|
27
|
+
|
|
28
|
+
page =
|
|
29
|
+
httpee() # empty request — no body
|
|
30
|
+
..query: {'page': 2}
|
|
31
|
+
..GET: 'https://api.example.com/users'
|
|
32
|
+
..assert:
|
|
33
|
+
Response(status=200)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Cheatsheet
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from httpee import (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, send,
|
|
40
|
+
httpee, headers, query, timeout, cookies, auth, json,
|
|
41
|
+
method, url, Response)
|
|
42
|
+
from eelang import MatchError
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### A request is a pipeline
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
r =
|
|
49
|
+
{'name': 'kenneth', 'role': 'admin'} # dict → form-encoded
|
|
50
|
+
..headers: {'X-Key': v} # headers (repeats add keys)
|
|
51
|
+
..query: {'page': 2} # query parameters (repeats add keys)
|
|
52
|
+
..POST: 'https://api.example.com/users' # verb performs the request
|
|
53
|
+
..assert:
|
|
54
|
+
Response(status=201) # failure → MatchError
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Bodies
|
|
58
|
+
|
|
59
|
+
| Value | Sent as |
|
|
60
|
+
|---|---|
|
|
61
|
+
| dict | form-encoded (`data=`, like requests) |
|
|
62
|
+
| str | text |
|
|
63
|
+
| bytes | raw bytes |
|
|
64
|
+
| `httpee()` | empty request — no body |
|
|
65
|
+
| `..json: {…}` | JSON — takes precedence over the pipeline body |
|
|
66
|
+
|
|
67
|
+
### Dynamic method — `..send()`
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
r =
|
|
71
|
+
{'role': 'moderator'}
|
|
72
|
+
..method: m # any method, even from a variable
|
|
73
|
+
..url: 'https://…'
|
|
74
|
+
..send() # without ..method: defaults to GET
|
|
75
|
+
..assert:
|
|
76
|
+
Response(status=200)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Pattern checks
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# ..assert: — shape check, binds variables, failure → MatchError
|
|
83
|
+
p =
|
|
84
|
+
httpee()
|
|
85
|
+
..GET: url
|
|
86
|
+
..assert:
|
|
87
|
+
Response(status=s) # s is available afterwards
|
|
88
|
+
|
|
89
|
+
# ..match: — branch on status; no match → the value is unchanged
|
|
90
|
+
verdict =
|
|
91
|
+
httpee()
|
|
92
|
+
..GET: url
|
|
93
|
+
..match:
|
|
94
|
+
case Response(status=200): 'ok'
|
|
95
|
+
case Response(status=n): f'HTTP {n}'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Errors
|
|
99
|
+
|
|
100
|
+
- 4xx/5xx do **not** raise: check the status with a pattern.
|
|
101
|
+
- Network errors and timeouts are httpx exceptions (`ConnectError`, `TimeoutException`).
|
|
102
|
+
- `Response` is `httpx.Response` plus `.status`: `.json()`, `.text`, `.headers` work as usual.
|
|
103
|
+
|
|
104
|
+
### Modifiers at a glance
|
|
105
|
+
|
|
106
|
+
`..headers:` · `..query:` · `..timeout: 5.0` · `..cookies:` · `..auth: (u, p)` · `..json:` · `..method:` · `..url:`
|
|
107
|
+
|
|
108
|
+
## How it works
|
|
109
|
+
|
|
110
|
+
- The pipeline's initial value is the request body: dict → form-encoded, str → text,
|
|
111
|
+
bytes → raw bytes. JSON goes through an explicit `..json: {…}`. A request without
|
|
112
|
+
a body starts with `httpee()`.
|
|
113
|
+
- Modifiers accumulate parameters (repeatable, in any order); the verb at the end
|
|
114
|
+
performs the request and returns a `Response`.
|
|
115
|
+
- 4xx/5xx do not raise — the status is checked with a pattern; a failed `..assert:`
|
|
116
|
+
raises `MatchError`. Network errors and timeouts are httpx exceptions.
|
|
117
|
+
|
|
118
|
+
## Verbs
|
|
119
|
+
|
|
120
|
+
| Verb | Example | What it does |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `GET` … `OPTIONS` | `..GET: url` | performs the request with this method |
|
|
123
|
+
| `send` | `..send()` | performs the request with a previously set `..method:`/`..url:` |
|
|
124
|
+
|
|
125
|
+
The method can be set up front — and can be anything:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
patch =
|
|
129
|
+
{'role': 'moderator'}
|
|
130
|
+
..method: 'PATCH' # or from a variable
|
|
131
|
+
..url: 'https://api.example.com/users/1'
|
|
132
|
+
..send()
|
|
133
|
+
..assert:
|
|
134
|
+
Response(status=200)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Modifiers
|
|
138
|
+
|
|
139
|
+
| Modifier | Example | What it does |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| `headers` | `..headers: {'X-Key': v}` | headers (repeats add keys) |
|
|
142
|
+
| `query` | `..query: {'page': 2}` | URL query parameters (repeats add keys) |
|
|
143
|
+
| `timeout` | `..timeout: 5.0` | request timeout, seconds |
|
|
144
|
+
| `cookies` | `..cookies: {'sid': v}` | cookies |
|
|
145
|
+
| `auth` | `..auth: ('user', 'pass')` | basic auth (any `httpx.Auth` works) |
|
|
146
|
+
| `json` | `..json: {…}` | JSON body (takes precedence over the pipeline body) |
|
|
147
|
+
| `method` | `..method: 'PATCH'` | method up front — for `..send()` |
|
|
148
|
+
| `url` | `..url: 'https://…'` | URL up front — for `..send()` |
|
|
149
|
+
|
|
150
|
+
## Installation
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
pip install httpee
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
From source:
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
git clone https://github.com/eel-lang/httpee
|
|
160
|
+
pip install ./httpee
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Requires CPython 3.15 + [eelang](https://github.com/eel-lang/eelang-py) to run `.eel`
|
|
164
|
+
files — the package itself is plain Python, and httpx is its only dependency.
|
|
165
|
+
Works under gevent (`gevent.monkey.patch_all()`).
|
|
166
|
+
|
|
167
|
+
## Tests
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
python -m unittest discover -s tests
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT (`LICENSE`).
|
httpee-0.1.0/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# httpee
|
|
2
|
+
|
|
3
|
+
`httpee` is a thin wrapper around [httpx](https://www.python-httpx.org/) for
|
|
4
|
+
[eelang](https://github.com/eel-lang/eelang-py): requests are written as pipelines,
|
|
5
|
+
responses are checked with patterns.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from httpee import POST, headers, httpee, Response
|
|
9
|
+
|
|
10
|
+
user =
|
|
11
|
+
{'name': 'kenneth', 'role': 'admin'} # dict → form-encoded, like requests data=
|
|
12
|
+
..headers: {'Authorization': f'Bearer {token}'}
|
|
13
|
+
..POST: 'https://api.example.com/users'
|
|
14
|
+
..assert:
|
|
15
|
+
Response(status=201)
|
|
16
|
+
|
|
17
|
+
page =
|
|
18
|
+
httpee() # empty request — no body
|
|
19
|
+
..query: {'page': 2}
|
|
20
|
+
..GET: 'https://api.example.com/users'
|
|
21
|
+
..assert:
|
|
22
|
+
Response(status=200)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Cheatsheet
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from httpee import (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, send,
|
|
29
|
+
httpee, headers, query, timeout, cookies, auth, json,
|
|
30
|
+
method, url, Response)
|
|
31
|
+
from eelang import MatchError
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### A request is a pipeline
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
r =
|
|
38
|
+
{'name': 'kenneth', 'role': 'admin'} # dict → form-encoded
|
|
39
|
+
..headers: {'X-Key': v} # headers (repeats add keys)
|
|
40
|
+
..query: {'page': 2} # query parameters (repeats add keys)
|
|
41
|
+
..POST: 'https://api.example.com/users' # verb performs the request
|
|
42
|
+
..assert:
|
|
43
|
+
Response(status=201) # failure → MatchError
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Bodies
|
|
47
|
+
|
|
48
|
+
| Value | Sent as |
|
|
49
|
+
|---|---|
|
|
50
|
+
| dict | form-encoded (`data=`, like requests) |
|
|
51
|
+
| str | text |
|
|
52
|
+
| bytes | raw bytes |
|
|
53
|
+
| `httpee()` | empty request — no body |
|
|
54
|
+
| `..json: {…}` | JSON — takes precedence over the pipeline body |
|
|
55
|
+
|
|
56
|
+
### Dynamic method — `..send()`
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
r =
|
|
60
|
+
{'role': 'moderator'}
|
|
61
|
+
..method: m # any method, even from a variable
|
|
62
|
+
..url: 'https://…'
|
|
63
|
+
..send() # without ..method: defaults to GET
|
|
64
|
+
..assert:
|
|
65
|
+
Response(status=200)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Pattern checks
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
# ..assert: — shape check, binds variables, failure → MatchError
|
|
72
|
+
p =
|
|
73
|
+
httpee()
|
|
74
|
+
..GET: url
|
|
75
|
+
..assert:
|
|
76
|
+
Response(status=s) # s is available afterwards
|
|
77
|
+
|
|
78
|
+
# ..match: — branch on status; no match → the value is unchanged
|
|
79
|
+
verdict =
|
|
80
|
+
httpee()
|
|
81
|
+
..GET: url
|
|
82
|
+
..match:
|
|
83
|
+
case Response(status=200): 'ok'
|
|
84
|
+
case Response(status=n): f'HTTP {n}'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Errors
|
|
88
|
+
|
|
89
|
+
- 4xx/5xx do **not** raise: check the status with a pattern.
|
|
90
|
+
- Network errors and timeouts are httpx exceptions (`ConnectError`, `TimeoutException`).
|
|
91
|
+
- `Response` is `httpx.Response` plus `.status`: `.json()`, `.text`, `.headers` work as usual.
|
|
92
|
+
|
|
93
|
+
### Modifiers at a glance
|
|
94
|
+
|
|
95
|
+
`..headers:` · `..query:` · `..timeout: 5.0` · `..cookies:` · `..auth: (u, p)` · `..json:` · `..method:` · `..url:`
|
|
96
|
+
|
|
97
|
+
## How it works
|
|
98
|
+
|
|
99
|
+
- The pipeline's initial value is the request body: dict → form-encoded, str → text,
|
|
100
|
+
bytes → raw bytes. JSON goes through an explicit `..json: {…}`. A request without
|
|
101
|
+
a body starts with `httpee()`.
|
|
102
|
+
- Modifiers accumulate parameters (repeatable, in any order); the verb at the end
|
|
103
|
+
performs the request and returns a `Response`.
|
|
104
|
+
- 4xx/5xx do not raise — the status is checked with a pattern; a failed `..assert:`
|
|
105
|
+
raises `MatchError`. Network errors and timeouts are httpx exceptions.
|
|
106
|
+
|
|
107
|
+
## Verbs
|
|
108
|
+
|
|
109
|
+
| Verb | Example | What it does |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `GET` … `OPTIONS` | `..GET: url` | performs the request with this method |
|
|
112
|
+
| `send` | `..send()` | performs the request with a previously set `..method:`/`..url:` |
|
|
113
|
+
|
|
114
|
+
The method can be set up front — and can be anything:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
patch =
|
|
118
|
+
{'role': 'moderator'}
|
|
119
|
+
..method: 'PATCH' # or from a variable
|
|
120
|
+
..url: 'https://api.example.com/users/1'
|
|
121
|
+
..send()
|
|
122
|
+
..assert:
|
|
123
|
+
Response(status=200)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Modifiers
|
|
127
|
+
|
|
128
|
+
| Modifier | Example | What it does |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `headers` | `..headers: {'X-Key': v}` | headers (repeats add keys) |
|
|
131
|
+
| `query` | `..query: {'page': 2}` | URL query parameters (repeats add keys) |
|
|
132
|
+
| `timeout` | `..timeout: 5.0` | request timeout, seconds |
|
|
133
|
+
| `cookies` | `..cookies: {'sid': v}` | cookies |
|
|
134
|
+
| `auth` | `..auth: ('user', 'pass')` | basic auth (any `httpx.Auth` works) |
|
|
135
|
+
| `json` | `..json: {…}` | JSON body (takes precedence over the pipeline body) |
|
|
136
|
+
| `method` | `..method: 'PATCH'` | method up front — for `..send()` |
|
|
137
|
+
| `url` | `..url: 'https://…'` | URL up front — for `..send()` |
|
|
138
|
+
|
|
139
|
+
## Installation
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
pip install httpee
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
From source:
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
git clone https://github.com/eel-lang/httpee
|
|
149
|
+
pip install ./httpee
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Requires CPython 3.15 + [eelang](https://github.com/eel-lang/eelang-py) to run `.eel`
|
|
153
|
+
files — the package itself is plain Python, and httpx is its only dependency.
|
|
154
|
+
Works under gevent (`gevent.monkey.patch_all()`).
|
|
155
|
+
|
|
156
|
+
## Tests
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
python -m unittest discover -s tests
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT (`LICENSE`).
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""httpee — HTTP для людей. Теперь пайплайнами.
|
|
2
|
+
|
|
3
|
+
Тонкая обёртка над httpx для eelang: запрос собирается пайплайном, результат
|
|
4
|
+
проверяется паттернами::
|
|
5
|
+
|
|
6
|
+
from httpee import POST, headers, Response
|
|
7
|
+
|
|
8
|
+
user =
|
|
9
|
+
{'name': 'kenneth', 'role': 'admin'}
|
|
10
|
+
..headers: {'Authorization': 'Bearer ...'}
|
|
11
|
+
..POST: 'https://api.example.com/users'
|
|
12
|
+
..assert:
|
|
13
|
+
Response(status=201)
|
|
14
|
+
|
|
15
|
+
page =
|
|
16
|
+
httpee()
|
|
17
|
+
..GET: 'https://api.example.com/users'
|
|
18
|
+
..assert:
|
|
19
|
+
Response(status=200)
|
|
20
|
+
|
|
21
|
+
4xx/5xx исключений не вызывают — статус проверяется ``..assert:``/``..match:``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Response(httpx.Response):
|
|
30
|
+
"""httpx.Response + свойство ``status`` для паттерна ``Response(status=200)``.
|
|
31
|
+
|
|
32
|
+
Остальное — родное: ``.json()``, ``.text``, ``.content``, ``.headers``,
|
|
33
|
+
``.raise_for_status()``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def status(self):
|
|
38
|
+
return self.status_code
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Request:
|
|
42
|
+
"""Накопленное состояние запроса. Пользователю не виден."""
|
|
43
|
+
|
|
44
|
+
__slots__ = ('method', 'url', 'body', 'json_body', 'headers', 'params',
|
|
45
|
+
'timeout', 'cookies', 'auth')
|
|
46
|
+
|
|
47
|
+
def __init__(self, body=None):
|
|
48
|
+
self.method = None
|
|
49
|
+
self.url = None
|
|
50
|
+
self.body = body
|
|
51
|
+
self.json_body = None
|
|
52
|
+
self.headers = {}
|
|
53
|
+
self.params = {}
|
|
54
|
+
self.timeout = None
|
|
55
|
+
self.cookies = None
|
|
56
|
+
self.auth = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def httpee():
|
|
60
|
+
"""Пустой запрос: httpee() ..GET: url — когда тела нет."""
|
|
61
|
+
return _Request()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _ensure(value):
|
|
65
|
+
"""Тело или _Request → _Request; на Response — внятный TypeError."""
|
|
66
|
+
if isinstance(value, Response):
|
|
67
|
+
raise TypeError(
|
|
68
|
+
'это уже Response — модификаторы и глаголы идут до выполнения запроса')
|
|
69
|
+
return value if isinstance(value, _Request) else _Request(body=value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _wrap(resp):
|
|
73
|
+
"""httpx.Response → httpee.Response (для class-паттернов)."""
|
|
74
|
+
return Response(
|
|
75
|
+
resp.status_code,
|
|
76
|
+
headers=resp.headers,
|
|
77
|
+
content=resp.content,
|
|
78
|
+
request=resp.request,
|
|
79
|
+
extensions=resp.extensions,
|
|
80
|
+
history=resp.history,
|
|
81
|
+
default_encoding=resp.default_encoding,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _execute(req):
|
|
86
|
+
"""Отправить _Request новым httpx.Client и вернуть Response."""
|
|
87
|
+
if req.url is None:
|
|
88
|
+
raise TypeError(
|
|
89
|
+
'запрос без адреса: задайте ..url: или вызовите ..GET:/..POST: с адресом')
|
|
90
|
+
method = (req.method or 'GET').upper()
|
|
91
|
+
kwargs = {}
|
|
92
|
+
if req.json_body is not None:
|
|
93
|
+
kwargs['json'] = req.json_body
|
|
94
|
+
else:
|
|
95
|
+
body = req.body
|
|
96
|
+
if body is None:
|
|
97
|
+
pass
|
|
98
|
+
elif isinstance(body, dict):
|
|
99
|
+
kwargs['data'] = body
|
|
100
|
+
elif isinstance(body, str):
|
|
101
|
+
kwargs['content'] = body.encode()
|
|
102
|
+
elif isinstance(body, (bytes, bytearray, memoryview)):
|
|
103
|
+
kwargs['content'] = bytes(body)
|
|
104
|
+
else:
|
|
105
|
+
raise TypeError(
|
|
106
|
+
f'неподдерживаемое тело запроса: {type(body).__name__} '
|
|
107
|
+
'(ожидаются dict/str/bytes)')
|
|
108
|
+
with httpx.Client(timeout=req.timeout) as client:
|
|
109
|
+
resp = client.request(method, req.url, headers=req.headers or None,
|
|
110
|
+
params=req.params or None, cookies=req.cookies,
|
|
111
|
+
auth=req.auth, **kwargs)
|
|
112
|
+
return _wrap(resp)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _verb(method):
|
|
116
|
+
"""Глагол: ..GET: url → GET(url, значение) — выполняет запрос сразу."""
|
|
117
|
+
|
|
118
|
+
def run(url, value):
|
|
119
|
+
req = _ensure(value)
|
|
120
|
+
req.method = method
|
|
121
|
+
req.url = url
|
|
122
|
+
return _execute(req)
|
|
123
|
+
|
|
124
|
+
run.__name__ = method
|
|
125
|
+
run.__qualname__ = method
|
|
126
|
+
run.__doc__ = (
|
|
127
|
+
f'Выполнить {method}-запрос: ..{method}: url. '
|
|
128
|
+
'Значение пайплайна — тело или накопленный запрос.')
|
|
129
|
+
return run
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
GET = _verb('GET')
|
|
133
|
+
POST = _verb('POST')
|
|
134
|
+
PUT = _verb('PUT')
|
|
135
|
+
PATCH = _verb('PATCH')
|
|
136
|
+
DELETE = _verb('DELETE')
|
|
137
|
+
HEAD = _verb('HEAD')
|
|
138
|
+
OPTIONS = _verb('OPTIONS')
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def send(value):
|
|
142
|
+
"""Выполнить запрос с заранее заданными ..method:/..url: (..send())."""
|
|
143
|
+
return _execute(_ensure(value))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def headers(headers_value, value):
|
|
147
|
+
"""..headers: {...} — заголовки запроса (повторные вызовы дополняют)."""
|
|
148
|
+
req = _ensure(value)
|
|
149
|
+
req.headers.update(dict(headers_value))
|
|
150
|
+
return req
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def query(params_value, value):
|
|
154
|
+
"""..query: {...} — query-параметры URL (повторные вызовы дополняют)."""
|
|
155
|
+
req = _ensure(value)
|
|
156
|
+
req.params.update(dict(params_value))
|
|
157
|
+
return req
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def timeout(timeout_value, value):
|
|
161
|
+
"""..timeout: 5.0 — таймаут запроса в секундах."""
|
|
162
|
+
req = _ensure(value)
|
|
163
|
+
req.timeout = timeout_value
|
|
164
|
+
return req
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def cookies(cookies_value, value):
|
|
168
|
+
"""..cookies: {...} — куки запроса."""
|
|
169
|
+
req = _ensure(value)
|
|
170
|
+
req.cookies = cookies_value
|
|
171
|
+
return req
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def auth(auth_value, value):
|
|
175
|
+
"""..auth: ('user', 'pass') — basic-auth; можно httpx.Auth."""
|
|
176
|
+
req = _ensure(value)
|
|
177
|
+
req.auth = auth_value
|
|
178
|
+
return req
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def json(json_value, value):
|
|
182
|
+
"""..json: {...} — тело JSON-ом (побеждает над телом пайплайна)."""
|
|
183
|
+
req = _ensure(value)
|
|
184
|
+
req.json_body = json_value
|
|
185
|
+
return req
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def method(method_value, value):
|
|
189
|
+
"""..method: 'PATCH' — метод заранее; выполняется ..send()."""
|
|
190
|
+
req = _ensure(value)
|
|
191
|
+
req.method = str(method_value).upper()
|
|
192
|
+
return req
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def url(url_value, value):
|
|
196
|
+
"""..url: 'https://...' — адрес заранее; выполняется ..send()."""
|
|
197
|
+
req = _ensure(value)
|
|
198
|
+
req.url = url_value
|
|
199
|
+
return req
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
__all__ = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS',
|
|
203
|
+
'send', 'headers', 'query', 'timeout', 'cookies', 'auth', 'json',
|
|
204
|
+
'method', 'url', 'Response', 'httpee']
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: httpee
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: httpee — thin httpx wrapper for eelang pipelines
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# httpee
|
|
13
|
+
|
|
14
|
+
`httpee` is a thin wrapper around [httpx](https://www.python-httpx.org/) for
|
|
15
|
+
[eelang](https://github.com/eel-lang/eelang-py): requests are written as pipelines,
|
|
16
|
+
responses are checked with patterns.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from httpee import POST, headers, httpee, Response
|
|
20
|
+
|
|
21
|
+
user =
|
|
22
|
+
{'name': 'kenneth', 'role': 'admin'} # dict → form-encoded, like requests data=
|
|
23
|
+
..headers: {'Authorization': f'Bearer {token}'}
|
|
24
|
+
..POST: 'https://api.example.com/users'
|
|
25
|
+
..assert:
|
|
26
|
+
Response(status=201)
|
|
27
|
+
|
|
28
|
+
page =
|
|
29
|
+
httpee() # empty request — no body
|
|
30
|
+
..query: {'page': 2}
|
|
31
|
+
..GET: 'https://api.example.com/users'
|
|
32
|
+
..assert:
|
|
33
|
+
Response(status=200)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Cheatsheet
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from httpee import (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, send,
|
|
40
|
+
httpee, headers, query, timeout, cookies, auth, json,
|
|
41
|
+
method, url, Response)
|
|
42
|
+
from eelang import MatchError
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### A request is a pipeline
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
r =
|
|
49
|
+
{'name': 'kenneth', 'role': 'admin'} # dict → form-encoded
|
|
50
|
+
..headers: {'X-Key': v} # headers (repeats add keys)
|
|
51
|
+
..query: {'page': 2} # query parameters (repeats add keys)
|
|
52
|
+
..POST: 'https://api.example.com/users' # verb performs the request
|
|
53
|
+
..assert:
|
|
54
|
+
Response(status=201) # failure → MatchError
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Bodies
|
|
58
|
+
|
|
59
|
+
| Value | Sent as |
|
|
60
|
+
|---|---|
|
|
61
|
+
| dict | form-encoded (`data=`, like requests) |
|
|
62
|
+
| str | text |
|
|
63
|
+
| bytes | raw bytes |
|
|
64
|
+
| `httpee()` | empty request — no body |
|
|
65
|
+
| `..json: {…}` | JSON — takes precedence over the pipeline body |
|
|
66
|
+
|
|
67
|
+
### Dynamic method — `..send()`
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
r =
|
|
71
|
+
{'role': 'moderator'}
|
|
72
|
+
..method: m # any method, even from a variable
|
|
73
|
+
..url: 'https://…'
|
|
74
|
+
..send() # without ..method: defaults to GET
|
|
75
|
+
..assert:
|
|
76
|
+
Response(status=200)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Pattern checks
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# ..assert: — shape check, binds variables, failure → MatchError
|
|
83
|
+
p =
|
|
84
|
+
httpee()
|
|
85
|
+
..GET: url
|
|
86
|
+
..assert:
|
|
87
|
+
Response(status=s) # s is available afterwards
|
|
88
|
+
|
|
89
|
+
# ..match: — branch on status; no match → the value is unchanged
|
|
90
|
+
verdict =
|
|
91
|
+
httpee()
|
|
92
|
+
..GET: url
|
|
93
|
+
..match:
|
|
94
|
+
case Response(status=200): 'ok'
|
|
95
|
+
case Response(status=n): f'HTTP {n}'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Errors
|
|
99
|
+
|
|
100
|
+
- 4xx/5xx do **not** raise: check the status with a pattern.
|
|
101
|
+
- Network errors and timeouts are httpx exceptions (`ConnectError`, `TimeoutException`).
|
|
102
|
+
- `Response` is `httpx.Response` plus `.status`: `.json()`, `.text`, `.headers` work as usual.
|
|
103
|
+
|
|
104
|
+
### Modifiers at a glance
|
|
105
|
+
|
|
106
|
+
`..headers:` · `..query:` · `..timeout: 5.0` · `..cookies:` · `..auth: (u, p)` · `..json:` · `..method:` · `..url:`
|
|
107
|
+
|
|
108
|
+
## How it works
|
|
109
|
+
|
|
110
|
+
- The pipeline's initial value is the request body: dict → form-encoded, str → text,
|
|
111
|
+
bytes → raw bytes. JSON goes through an explicit `..json: {…}`. A request without
|
|
112
|
+
a body starts with `httpee()`.
|
|
113
|
+
- Modifiers accumulate parameters (repeatable, in any order); the verb at the end
|
|
114
|
+
performs the request and returns a `Response`.
|
|
115
|
+
- 4xx/5xx do not raise — the status is checked with a pattern; a failed `..assert:`
|
|
116
|
+
raises `MatchError`. Network errors and timeouts are httpx exceptions.
|
|
117
|
+
|
|
118
|
+
## Verbs
|
|
119
|
+
|
|
120
|
+
| Verb | Example | What it does |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `GET` … `OPTIONS` | `..GET: url` | performs the request with this method |
|
|
123
|
+
| `send` | `..send()` | performs the request with a previously set `..method:`/`..url:` |
|
|
124
|
+
|
|
125
|
+
The method can be set up front — and can be anything:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
patch =
|
|
129
|
+
{'role': 'moderator'}
|
|
130
|
+
..method: 'PATCH' # or from a variable
|
|
131
|
+
..url: 'https://api.example.com/users/1'
|
|
132
|
+
..send()
|
|
133
|
+
..assert:
|
|
134
|
+
Response(status=200)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Modifiers
|
|
138
|
+
|
|
139
|
+
| Modifier | Example | What it does |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| `headers` | `..headers: {'X-Key': v}` | headers (repeats add keys) |
|
|
142
|
+
| `query` | `..query: {'page': 2}` | URL query parameters (repeats add keys) |
|
|
143
|
+
| `timeout` | `..timeout: 5.0` | request timeout, seconds |
|
|
144
|
+
| `cookies` | `..cookies: {'sid': v}` | cookies |
|
|
145
|
+
| `auth` | `..auth: ('user', 'pass')` | basic auth (any `httpx.Auth` works) |
|
|
146
|
+
| `json` | `..json: {…}` | JSON body (takes precedence over the pipeline body) |
|
|
147
|
+
| `method` | `..method: 'PATCH'` | method up front — for `..send()` |
|
|
148
|
+
| `url` | `..url: 'https://…'` | URL up front — for `..send()` |
|
|
149
|
+
|
|
150
|
+
## Installation
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
pip install httpee
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
From source:
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
git clone https://github.com/eel-lang/httpee
|
|
160
|
+
pip install ./httpee
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Requires CPython 3.15 + [eelang](https://github.com/eel-lang/eelang-py) to run `.eel`
|
|
164
|
+
files — the package itself is plain Python, and httpx is its only dependency.
|
|
165
|
+
Works under gevent (`gevent.monkey.patch_all()`).
|
|
166
|
+
|
|
167
|
+
## Tests
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
python -m unittest discover -s tests
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT (`LICENSE`).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
httpee/__init__.py
|
|
5
|
+
httpee.egg-info/PKG-INFO
|
|
6
|
+
httpee.egg-info/SOURCES.txt
|
|
7
|
+
httpee.egg-info/dependency_links.txt
|
|
8
|
+
httpee.egg-info/requires.txt
|
|
9
|
+
httpee.egg-info/top_level.txt
|
|
10
|
+
tests/test_eelang.py
|
|
11
|
+
tests/test_gevent.py
|
|
12
|
+
tests/test_http.py
|
|
13
|
+
tests/test_response.py
|
|
14
|
+
tests/test_runner.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.27
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpee
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "httpee"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "httpee — thin httpx wrapper for eelang pipelines"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = ["httpx>=0.27"]
|
|
14
|
+
|
|
15
|
+
[tool.setuptools]
|
|
16
|
+
packages = ["httpee"]
|
httpee-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Тесты eelang-слоя: пайплайны, ..assert:, ..match:, MatchError."""
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
import eelang
|
|
6
|
+
from eelang import MatchError
|
|
7
|
+
except ImportError:
|
|
8
|
+
eelang = MatchError = None
|
|
9
|
+
|
|
10
|
+
from tests._server import TestServer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(source, ns=None):
|
|
14
|
+
"""Парсим eelang-исходник C-парсером и исполняем."""
|
|
15
|
+
if ns is None:
|
|
16
|
+
ns = {}
|
|
17
|
+
mod = eelang.parse(source, '<httpee-test>')
|
|
18
|
+
exec(compile(mod, '<httpee-test>', 'exec'), ns)
|
|
19
|
+
return ns
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@unittest.skipIf(eelang is None, 'eelang не установлен')
|
|
23
|
+
class EelangPipelineTests(unittest.TestCase):
|
|
24
|
+
def test_post_with_assert_pattern(self):
|
|
25
|
+
with TestServer() as srv:
|
|
26
|
+
ns = run(f'''
|
|
27
|
+
from httpee import POST, headers, Response
|
|
28
|
+
user =
|
|
29
|
+
{{'name': 'kenneth', 'role': 'admin'}}
|
|
30
|
+
..headers: {{'X-Test': 'yes'}}
|
|
31
|
+
..POST: '{srv.url}/users'
|
|
32
|
+
..assert:
|
|
33
|
+
Response(status=200)
|
|
34
|
+
''')
|
|
35
|
+
req = srv.requests[0]
|
|
36
|
+
self.assertEqual(req['body'], 'name=kenneth&role=admin')
|
|
37
|
+
self.assertEqual(req['headers']['X-Test'], 'yes')
|
|
38
|
+
self.assertEqual(ns['user'].json()['echo']['method'], 'POST')
|
|
39
|
+
|
|
40
|
+
def test_get_without_body(self):
|
|
41
|
+
with TestServer() as srv:
|
|
42
|
+
ns = run(f'''
|
|
43
|
+
from httpee import GET, query, httpee, Response
|
|
44
|
+
page =
|
|
45
|
+
httpee()
|
|
46
|
+
..query: {{'page': 2}}
|
|
47
|
+
..GET: '{srv.url}/users'
|
|
48
|
+
..assert:
|
|
49
|
+
Response(status=200)
|
|
50
|
+
''')
|
|
51
|
+
self.assertEqual(srv.requests[0]['path'], '/users?page=2')
|
|
52
|
+
self.assertEqual(ns['page'].status, 200)
|
|
53
|
+
|
|
54
|
+
def test_assert_failure_raises_matcherror(self):
|
|
55
|
+
with TestServer(status=404) as srv:
|
|
56
|
+
with self.assertRaises(MatchError):
|
|
57
|
+
run(f'''
|
|
58
|
+
from httpee import GET, httpee, Response
|
|
59
|
+
r =
|
|
60
|
+
httpee()
|
|
61
|
+
..GET: '{srv.url}/missing'
|
|
62
|
+
..assert:
|
|
63
|
+
Response(status=200)
|
|
64
|
+
''')
|
|
65
|
+
|
|
66
|
+
def test_assert_binds_variables(self):
|
|
67
|
+
with TestServer() as srv:
|
|
68
|
+
ns = run(f'''
|
|
69
|
+
from httpee import GET, httpee, Response
|
|
70
|
+
r =
|
|
71
|
+
httpee()
|
|
72
|
+
..GET: '{srv.url}/ok'
|
|
73
|
+
..assert:
|
|
74
|
+
Response(status=s)
|
|
75
|
+
doubled = s * 2
|
|
76
|
+
''')
|
|
77
|
+
self.assertEqual(ns['doubled'], 400)
|
|
78
|
+
|
|
79
|
+
def test_match_branches_on_status(self):
|
|
80
|
+
with TestServer(status=418) as srv:
|
|
81
|
+
ns = run(f'''
|
|
82
|
+
from httpee import GET, httpee, Response
|
|
83
|
+
verdict =
|
|
84
|
+
httpee()
|
|
85
|
+
..GET: '{srv.url}/teapot'
|
|
86
|
+
..match:
|
|
87
|
+
case Response(status=200):
|
|
88
|
+
'ok'
|
|
89
|
+
case Response(status=418):
|
|
90
|
+
'teapot'
|
|
91
|
+
case Response(status=n):
|
|
92
|
+
f'HTTP {{n}}'
|
|
93
|
+
''')
|
|
94
|
+
self.assertEqual(ns['verdict'], 'teapot')
|
|
95
|
+
|
|
96
|
+
def test_match_fallback_captures_status(self):
|
|
97
|
+
with TestServer(status=503) as srv:
|
|
98
|
+
ns = run(f'''
|
|
99
|
+
from httpee import GET, httpee, Response
|
|
100
|
+
verdict =
|
|
101
|
+
httpee()
|
|
102
|
+
..GET: '{srv.url}/down'
|
|
103
|
+
..match:
|
|
104
|
+
case Response(status=200):
|
|
105
|
+
'ok'
|
|
106
|
+
case Response(status=n):
|
|
107
|
+
f'HTTP {{n}}'
|
|
108
|
+
''')
|
|
109
|
+
self.assertEqual(ns['verdict'], 'HTTP 503')
|
|
110
|
+
|
|
111
|
+
def test_send_with_dynamic_method(self):
|
|
112
|
+
with TestServer() as srv:
|
|
113
|
+
ns = run(f'''
|
|
114
|
+
from httpee import send, method, url, Response
|
|
115
|
+
m = 'PATCH'
|
|
116
|
+
r =
|
|
117
|
+
{{'v': 1}}
|
|
118
|
+
..method: m
|
|
119
|
+
..url: '{srv.url}/thing'
|
|
120
|
+
..send()
|
|
121
|
+
..assert:
|
|
122
|
+
Response(status=200)
|
|
123
|
+
''')
|
|
124
|
+
self.assertEqual(srv.requests[0]['method'], 'PATCH')
|
|
125
|
+
self.assertEqual(srv.requests[0]['body'], 'v=1')
|
|
126
|
+
|
|
127
|
+
def test_json_modifier_in_pipeline(self):
|
|
128
|
+
with TestServer() as srv:
|
|
129
|
+
ns = run(f'''
|
|
130
|
+
from httpee import POST, json, httpee, Response
|
|
131
|
+
r =
|
|
132
|
+
httpee()
|
|
133
|
+
..json: {{'nested': [1, 2]}}
|
|
134
|
+
..POST: '{srv.url}/j'
|
|
135
|
+
..assert:
|
|
136
|
+
Response(status=200)
|
|
137
|
+
''')
|
|
138
|
+
self.assertEqual(srv.requests[0]['body'], '{"nested":[1,2]}')
|
|
139
|
+
|
|
140
|
+
def test_body_as_initial_value_like_readme(self):
|
|
141
|
+
with TestServer() as srv:
|
|
142
|
+
ns = run(f'''
|
|
143
|
+
from httpee import GET, headers, Response
|
|
144
|
+
r =
|
|
145
|
+
{{'some': 'json'}}
|
|
146
|
+
..headers: {{'X-A': '1'}}
|
|
147
|
+
..GET: '{srv.url}/form'
|
|
148
|
+
..assert:
|
|
149
|
+
Response(status=200)
|
|
150
|
+
''')
|
|
151
|
+
self.assertEqual(srv.requests[0]['body'], 'some=json')
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == '__main__':
|
|
155
|
+
unittest.main()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Проверка под gevent: monkey-patch в изолированном подпроцессе.
|
|
2
|
+
|
|
3
|
+
patch_all() глобально меняет потоки/сокеты процесса, поэтому проверка идёт
|
|
4
|
+
в отдельном подпроцессе и не влияет на остальные тесты.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import gevent # noqa: F401
|
|
14
|
+
except ImportError:
|
|
15
|
+
gevent = None
|
|
16
|
+
|
|
17
|
+
from tests._server import TestServer
|
|
18
|
+
|
|
19
|
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@unittest.skipUnless(gevent is not None, 'gevent не установлен')
|
|
23
|
+
class GeventTests(unittest.TestCase):
|
|
24
|
+
def test_get_under_gevent_monkeypatch(self):
|
|
25
|
+
with TestServer() as srv:
|
|
26
|
+
script = '''\
|
|
27
|
+
import gevent.monkey
|
|
28
|
+
gevent.monkey.patch_all()
|
|
29
|
+
import sys
|
|
30
|
+
from httpee import GET, Response
|
|
31
|
+
r = GET(sys.argv[1], None)
|
|
32
|
+
assert isinstance(r, Response)
|
|
33
|
+
print('status', r.status)
|
|
34
|
+
'''
|
|
35
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
36
|
+
path = os.path.join(tmp, 'gevent_smoke.py')
|
|
37
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
38
|
+
f.write(script)
|
|
39
|
+
env = dict(os.environ)
|
|
40
|
+
env['PYTHONPATH'] = REPO_ROOT + os.pathsep + env.get('PYTHONPATH', '')
|
|
41
|
+
proc = subprocess.run(
|
|
42
|
+
[sys.executable, path, srv.url + '/g'],
|
|
43
|
+
capture_output=True, text=True, env=env)
|
|
44
|
+
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
45
|
+
self.assertIn('status 200', proc.stdout)
|
|
46
|
+
self.assertEqual(srv.requests[0]['path'], '/g')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == '__main__':
|
|
50
|
+
unittest.main()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Тесты глаголов, модификаторов и send на чистом Python (без eelang)."""
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from httpee import (GET, POST, PATCH, HEAD, send, httpee, headers, query,
|
|
7
|
+
timeout, cookies, auth, json, method, url, Response)
|
|
8
|
+
from tests._server import TestServer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class VerbTests(unittest.TestCase):
|
|
12
|
+
def test_get_executes_and_returns_response(self):
|
|
13
|
+
with TestServer() as srv:
|
|
14
|
+
r = GET(srv.url + '/x', None)
|
|
15
|
+
self.assertIsInstance(r, Response)
|
|
16
|
+
self.assertIsInstance(r, httpx.Response)
|
|
17
|
+
self.assertEqual(r.status, 200)
|
|
18
|
+
self.assertEqual(srv.requests[0]['method'], 'GET')
|
|
19
|
+
self.assertEqual(srv.requests[0]['path'], '/x')
|
|
20
|
+
|
|
21
|
+
def test_all_verbs(self):
|
|
22
|
+
with TestServer() as srv:
|
|
23
|
+
for verb, path in [(GET, '/a'), (POST, '/b'), (PATCH, '/c')]:
|
|
24
|
+
r = verb(srv.url + path, None)
|
|
25
|
+
self.assertEqual(r.status, 200)
|
|
26
|
+
self.assertEqual(srv.requests[-1]['method'], verb.__name__)
|
|
27
|
+
|
|
28
|
+
def test_head(self):
|
|
29
|
+
with TestServer() as srv:
|
|
30
|
+
r = HEAD(srv.url + '/h', None)
|
|
31
|
+
self.assertEqual(r.status, 200)
|
|
32
|
+
self.assertEqual(srv.requests[0]['method'], 'HEAD')
|
|
33
|
+
|
|
34
|
+
def test_httpee_empty_request(self):
|
|
35
|
+
with TestServer() as srv:
|
|
36
|
+
r = GET(srv.url + '/e', httpee())
|
|
37
|
+
self.assertIsInstance(r, Response)
|
|
38
|
+
self.assertEqual(r.status, 200)
|
|
39
|
+
self.assertEqual(srv.requests[0]['method'], 'GET')
|
|
40
|
+
|
|
41
|
+
def test_non_2xx_does_not_raise(self):
|
|
42
|
+
with TestServer(status=404) as srv:
|
|
43
|
+
r = GET(srv.url + '/missing', None)
|
|
44
|
+
self.assertEqual(r.status, 404)
|
|
45
|
+
|
|
46
|
+
def test_connect_error_propagates(self):
|
|
47
|
+
# Порт занят локальным сервером, но сервер закрыт — соединение разорвётся.
|
|
48
|
+
with TestServer() as srv:
|
|
49
|
+
port = srv.port
|
|
50
|
+
with self.assertRaises(httpx.ConnectError):
|
|
51
|
+
GET(f'http://127.0.0.1:{port}/x', None)
|
|
52
|
+
|
|
53
|
+
def test_timeout_propagates(self):
|
|
54
|
+
with TestServer(delay=1.0) as srv:
|
|
55
|
+
with self.assertRaises(httpx.TimeoutException):
|
|
56
|
+
GET(srv.url + '/slow', timeout(0.05, None))
|
|
57
|
+
|
|
58
|
+
def test_verb_after_verb_type_error(self):
|
|
59
|
+
with TestServer() as srv:
|
|
60
|
+
r = GET(srv.url + '/x', None)
|
|
61
|
+
with self.assertRaises(TypeError):
|
|
62
|
+
GET(srv.url + '/y', r)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class BodyTests(unittest.TestCase):
|
|
66
|
+
def test_dict_body_is_form_encoded(self):
|
|
67
|
+
with TestServer() as srv:
|
|
68
|
+
POST(srv.url + '/f', {'a': 1, 'b': 'два'})
|
|
69
|
+
body = srv.requests[0]['body']
|
|
70
|
+
self.assertIn('a=1', body)
|
|
71
|
+
self.assertIn('%D0%B4%D0%B2%D0%B0', body) # urlencoded utf-8
|
|
72
|
+
self.assertIn('application/x-www-form-urlencoded',
|
|
73
|
+
srv.requests[0]['headers']['Content-Type'])
|
|
74
|
+
|
|
75
|
+
def test_str_body_is_raw_text(self):
|
|
76
|
+
with TestServer() as srv:
|
|
77
|
+
POST(srv.url + '/t', 'привет')
|
|
78
|
+
self.assertEqual(srv.requests[0]['body'], 'привет')
|
|
79
|
+
|
|
80
|
+
def test_bytes_body_is_raw(self):
|
|
81
|
+
with TestServer() as srv:
|
|
82
|
+
POST(srv.url + '/b', b'\x00\x01raw')
|
|
83
|
+
self.assertEqual(srv.requests[0]['body'], '\x00\x01raw')
|
|
84
|
+
|
|
85
|
+
def test_json_modifier_wins_over_body(self):
|
|
86
|
+
with TestServer() as srv:
|
|
87
|
+
POST(srv.url + '/j', json({'x': [1, 2]}, {'ignored': 'body'}))
|
|
88
|
+
self.assertEqual(srv.requests[0]['body'], '{"x":[1,2]}')
|
|
89
|
+
self.assertIn('application/json',
|
|
90
|
+
srv.requests[0]['headers']['Content-Type'])
|
|
91
|
+
|
|
92
|
+
def test_unsupported_body_type_error(self):
|
|
93
|
+
with self.assertRaises(TypeError):
|
|
94
|
+
GET('http://127.0.0.1:1/x', 123)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ModifierTests(unittest.TestCase):
|
|
98
|
+
def test_headers_and_query_merge_and_repeat(self):
|
|
99
|
+
with TestServer() as srv:
|
|
100
|
+
v = headers({'X-A': '1'}, None)
|
|
101
|
+
v = headers({'X-B': '2'}, v)
|
|
102
|
+
v = query({'page': 2}, v)
|
|
103
|
+
v = query({'size': 10}, v)
|
|
104
|
+
r = GET(srv.url + '/users', v)
|
|
105
|
+
self.assertEqual(r.status, 200)
|
|
106
|
+
req = srv.requests[0]
|
|
107
|
+
self.assertEqual(req['path'], '/users?page=2&size=10')
|
|
108
|
+
self.assertEqual(req['headers']['X-A'], '1')
|
|
109
|
+
self.assertEqual(req['headers']['X-B'], '2')
|
|
110
|
+
|
|
111
|
+
def test_cookies(self):
|
|
112
|
+
with TestServer() as srv:
|
|
113
|
+
r = GET(srv.url + '/c', cookies({'sid': 'abc'}, None))
|
|
114
|
+
self.assertEqual(srv.requests[0]['headers']['Cookie'], 'sid=abc')
|
|
115
|
+
self.assertEqual(r.status, 200)
|
|
116
|
+
|
|
117
|
+
def test_auth_basic(self):
|
|
118
|
+
with TestServer() as srv:
|
|
119
|
+
GET(srv.url + '/auth', auth(('user', 'pass'), None))
|
|
120
|
+
auth_header = srv.requests[0]['headers'].get('Authorization')
|
|
121
|
+
self.assertIsNotNone(auth_header)
|
|
122
|
+
self.assertTrue(auth_header.startswith('Basic '))
|
|
123
|
+
|
|
124
|
+
def test_modifier_after_verb_type_error(self):
|
|
125
|
+
with TestServer() as srv:
|
|
126
|
+
r = GET(srv.url + '/x', None)
|
|
127
|
+
with self.assertRaises(TypeError):
|
|
128
|
+
headers({'X': '1'}, r)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class SendTests(unittest.TestCase):
|
|
132
|
+
def test_send_with_dynamic_method(self):
|
|
133
|
+
with TestServer() as srv:
|
|
134
|
+
m = 'PATCH'
|
|
135
|
+
v = method(m, {'v': 1})
|
|
136
|
+
v = url(srv.url + '/thing', v)
|
|
137
|
+
r = send(v)
|
|
138
|
+
self.assertEqual(r.status, 200)
|
|
139
|
+
self.assertEqual(srv.requests[0]['method'], 'PATCH')
|
|
140
|
+
self.assertEqual(srv.requests[0]['body'], 'v=1')
|
|
141
|
+
|
|
142
|
+
def test_send_defaults_to_get(self):
|
|
143
|
+
with TestServer() as srv:
|
|
144
|
+
r = send(url(srv.url + '/g', None))
|
|
145
|
+
self.assertEqual(r.status, 200)
|
|
146
|
+
self.assertEqual(srv.requests[0]['method'], 'GET')
|
|
147
|
+
|
|
148
|
+
def test_send_without_url_type_error(self):
|
|
149
|
+
with self.assertRaises(TypeError):
|
|
150
|
+
send(method('POST', None))
|
|
151
|
+
|
|
152
|
+
def test_method_is_uppercased(self):
|
|
153
|
+
with TestServer() as srv:
|
|
154
|
+
send(url(srv.url + '/u', method('post', None)))
|
|
155
|
+
self.assertEqual(srv.requests[0]['method'], 'POST')
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == '__main__':
|
|
159
|
+
unittest.main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Тесты Response — подкласс httpx.Response со свойством status."""
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from httpee import Response
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResponseTests(unittest.TestCase):
|
|
10
|
+
def test_subclass_of_httpx(self):
|
|
11
|
+
self.assertTrue(issubclass(Response, httpx.Response))
|
|
12
|
+
|
|
13
|
+
def test_status_property(self):
|
|
14
|
+
r = Response(201, content=b'{}', headers={'content-type': 'application/json'})
|
|
15
|
+
self.assertEqual(r.status, 201)
|
|
16
|
+
self.assertEqual(r.status_code, 201)
|
|
17
|
+
|
|
18
|
+
def test_httpx_api_intact(self):
|
|
19
|
+
r = Response(200, content=b'{"k": "v"}',
|
|
20
|
+
headers={'content-type': 'application/json'})
|
|
21
|
+
self.assertEqual(r.json(), {'k': 'v'})
|
|
22
|
+
self.assertIn('application/json', r.headers['content-type'])
|
|
23
|
+
self.assertEqual(r.status, 200)
|
|
24
|
+
|
|
25
|
+
def test_raise_for_status_still_available(self):
|
|
26
|
+
r = Response(500, content=b'', request=httpx.Request('GET', 'http://x/'))
|
|
27
|
+
with self.assertRaises(httpx.HTTPStatusError):
|
|
28
|
+
r.raise_for_status()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == '__main__':
|
|
32
|
+
unittest.main()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Smoke-тесты: настоящий .eel-скрипт через python -m eelang."""
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import eelang # noqa: F401 — наличие eelang обязательно для runner'а
|
|
10
|
+
except ImportError:
|
|
11
|
+
eelang = None
|
|
12
|
+
|
|
13
|
+
from tests._server import TestServer
|
|
14
|
+
|
|
15
|
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_script(source, env=None):
|
|
19
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
20
|
+
path = os.path.join(tmp, 'smoke.eel')
|
|
21
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
22
|
+
f.write(source)
|
|
23
|
+
full_env = dict(os.environ)
|
|
24
|
+
full_env['PYTHONPATH'] = REPO_ROOT + os.pathsep + full_env.get('PYTHONPATH', '')
|
|
25
|
+
if env:
|
|
26
|
+
full_env.update(env)
|
|
27
|
+
return subprocess.run(
|
|
28
|
+
[sys.executable, '-m', 'eelang', path],
|
|
29
|
+
capture_output=True, text=True, env=full_env)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@unittest.skipIf(eelang is None, 'eelang не установлен')
|
|
33
|
+
class RunnerTests(unittest.TestCase):
|
|
34
|
+
def test_eel_script_ok(self):
|
|
35
|
+
with TestServer() as srv:
|
|
36
|
+
proc = run_script(f'''
|
|
37
|
+
from httpee import GET, httpee, Response
|
|
38
|
+
r =
|
|
39
|
+
httpee()
|
|
40
|
+
..GET: '{srv.url}/runner'
|
|
41
|
+
..assert:
|
|
42
|
+
Response(status=200)
|
|
43
|
+
print('status', r.status)
|
|
44
|
+
''')
|
|
45
|
+
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
46
|
+
self.assertIn('status 200', proc.stdout)
|
|
47
|
+
self.assertEqual(srv.requests[0]['path'], '/runner')
|
|
48
|
+
|
|
49
|
+
def test_eel_script_match_error(self):
|
|
50
|
+
with TestServer(status=500) as srv:
|
|
51
|
+
proc = run_script(f'''
|
|
52
|
+
from httpee import GET, httpee, Response
|
|
53
|
+
r =
|
|
54
|
+
httpee()
|
|
55
|
+
..GET: '{srv.url}/broken'
|
|
56
|
+
..assert:
|
|
57
|
+
Response(status=200)
|
|
58
|
+
''')
|
|
59
|
+
self.assertNotEqual(proc.returncode, 0)
|
|
60
|
+
self.assertIn('MatchError', proc.stderr)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == '__main__':
|
|
64
|
+
unittest.main()
|