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 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
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()