onemax 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.
- onemax-0.1.0/.github/workflows/ci.yml +29 -0
- onemax-0.1.0/.github/workflows/publish.yml +58 -0
- onemax-0.1.0/.gitignore +11 -0
- onemax-0.1.0/CHANGELOG.md +13 -0
- onemax-0.1.0/LICENSE +21 -0
- onemax-0.1.0/PKG-INFO +225 -0
- onemax-0.1.0/README.md +202 -0
- onemax-0.1.0/pyproject.toml +61 -0
- onemax-0.1.0/src/onemax/__init__.py +44 -0
- onemax-0.1.0/src/onemax/_endpoints.py +87 -0
- onemax-0.1.0/src/onemax/_transport.py +60 -0
- onemax-0.1.0/src/onemax/_version.py +1 -0
- onemax-0.1.0/src/onemax/aio.py +98 -0
- onemax-0.1.0/src/onemax/client.py +96 -0
- onemax-0.1.0/src/onemax/errors.py +80 -0
- onemax-0.1.0/src/onemax/models.py +147 -0
- onemax-0.1.0/src/onemax/pkce.py +27 -0
- onemax-0.1.0/src/onemax/py.typed +0 -0
- onemax-0.1.0/tests/__init__.py +0 -0
- onemax-0.1.0/tests/conftest.py +60 -0
- onemax-0.1.0/tests/test_async.py +98 -0
- onemax-0.1.0/tests/test_client.py +339 -0
- onemax-0.1.0/tests/test_linking.py +78 -0
- onemax-0.1.0/uv.lock +734 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
python: ["3.10", "3.11", "3.12", "3.13"]
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v5
|
|
21
|
+
- uses: astral-sh/setup-uv@v7
|
|
22
|
+
with:
|
|
23
|
+
enable-cache: true
|
|
24
|
+
python-version: ${{ matrix.python }}
|
|
25
|
+
- run: uv sync
|
|
26
|
+
- run: uv run ruff format --check .
|
|
27
|
+
- run: uv run ruff check .
|
|
28
|
+
- run: uv run mypy src
|
|
29
|
+
- run: uv run pytest --cov --cov-report=term-missing
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
verify:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v5
|
|
12
|
+
- uses: astral-sh/setup-uv@v7
|
|
13
|
+
with:
|
|
14
|
+
python-version: "3.12"
|
|
15
|
+
- name: Check the tag matches the package version
|
|
16
|
+
run: |
|
|
17
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
18
|
+
declared="$(uv run python -c 'from importlib.metadata import version; print(version("onemax"))')"
|
|
19
|
+
imported="$(uv run python -c 'import onemax; print(onemax.__version__)')"
|
|
20
|
+
echo "tag $tag, pyproject $declared, module $imported"
|
|
21
|
+
test "$tag" = "$declared"
|
|
22
|
+
test "$tag" = "$imported"
|
|
23
|
+
- run: uv run ruff check .
|
|
24
|
+
- run: uv run mypy src
|
|
25
|
+
- run: uv run pytest
|
|
26
|
+
|
|
27
|
+
publish:
|
|
28
|
+
needs: verify
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
environment: pypi
|
|
31
|
+
permissions:
|
|
32
|
+
id-token: write
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v5
|
|
35
|
+
- uses: astral-sh/setup-uv@v7
|
|
36
|
+
- run: uv build
|
|
37
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
38
|
+
with:
|
|
39
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
40
|
+
|
|
41
|
+
release:
|
|
42
|
+
needs: publish
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
permissions:
|
|
45
|
+
contents: write
|
|
46
|
+
steps:
|
|
47
|
+
- uses: actions/checkout@v5
|
|
48
|
+
- name: Take the notes from the changelog
|
|
49
|
+
run: |
|
|
50
|
+
awk -v version="${GITHUB_REF_NAME#v}" '
|
|
51
|
+
/^## / { found = ($2 == version); next }
|
|
52
|
+
found { print }
|
|
53
|
+
' CHANGELOG.md > notes.md
|
|
54
|
+
test -s notes.md
|
|
55
|
+
- name: Publish the GitHub release
|
|
56
|
+
env:
|
|
57
|
+
GH_TOKEN: ${{ github.token }}
|
|
58
|
+
run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file notes.md
|
onemax-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- Check a member by the code on their card and record the usage: `verify_code`, `confirm_usage`,
|
|
8
|
+
`void_usage`, `usage`.
|
|
9
|
+
- Account linking with PKCE: `generate_pkce`, `authorize_url`, `exchange_code`, `link_status`,
|
|
10
|
+
`verify_link`, `revoke_link`.
|
|
11
|
+
- `context` to check a key and list the venue's offers.
|
|
12
|
+
- Sync `OneMaxClient` and async `AsyncOneMaxClient` with the same methods.
|
|
13
|
+
- Typed results and one error class per kind of failure.
|
onemax-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OneMax
|
|
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.
|
onemax-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: onemax
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the OneMax partner API
|
|
5
|
+
Project-URL: Homepage, https://onemax.az
|
|
6
|
+
Project-URL: Repository, https://github.com/onemax-az/onemax-python
|
|
7
|
+
Project-URL: Changelog, https://github.com/onemax-az/onemax-python/blob/main/CHANGELOG.md
|
|
8
|
+
Author: OneMax
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,azerbaijan,membership,onemax,partner
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# onemax
|
|
25
|
+
|
|
26
|
+
Python client for the [OneMax](https://onemax.az) partner API.
|
|
27
|
+
|
|
28
|
+
OneMax is a 1+1 membership club in Baku. Members pay for a plan and show a code at the counter to
|
|
29
|
+
use a partner's offer. This library lets a partner's own system do what the counter does: check
|
|
30
|
+
that someone is a member and record that they used the offer.
|
|
31
|
+
|
|
32
|
+
Sync and async, fully typed, one dependency.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install onemax
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Getting a key
|
|
39
|
+
|
|
40
|
+
1. OneMax switches API access on for your business.
|
|
41
|
+
2. The owner opens the partner panel, goes to API and creates a key.
|
|
42
|
+
3. The key is shown once. It belongs to one venue and acts there like a cashier.
|
|
43
|
+
|
|
44
|
+
Keep the key on your server. It must never reach a browser or a mobile app.
|
|
45
|
+
|
|
46
|
+
## Quick start: a member's code
|
|
47
|
+
|
|
48
|
+
A member opens their OneMax card and reads you the six digit code.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from onemax import OneMaxClient
|
|
52
|
+
|
|
53
|
+
client = OneMaxClient("omx_live_...")
|
|
54
|
+
|
|
55
|
+
check = client.verify_code("482915")
|
|
56
|
+
if not check.usable:
|
|
57
|
+
print("No discount:", check.reason)
|
|
58
|
+
else:
|
|
59
|
+
order = place_order(discounted=True)
|
|
60
|
+
client.confirm_usage(check.usage_id, reference=order.id)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`verify_code` only answers the question. Nothing counts against the member until you call
|
|
64
|
+
`confirm_usage`. If the order falls through, call `void_usage(check.usage_id)` instead.
|
|
65
|
+
|
|
66
|
+
Pass your own order id as `reference`. Confirming again with the same reference returns the same
|
|
67
|
+
usage, so a retried request is safe.
|
|
68
|
+
|
|
69
|
+
## Account linking
|
|
70
|
+
|
|
71
|
+
A member links their OneMax account to your app once. After that you check them without a code.
|
|
72
|
+
Linking is the OAuth 2.0 authorization code flow with PKCE, so the same steps work for a website
|
|
73
|
+
and a mobile app.
|
|
74
|
+
|
|
75
|
+
Register the addresses your app may return to in the partner panel, under API, Account linking.
|
|
76
|
+
Your client id is shown there.
|
|
77
|
+
|
|
78
|
+
**1. Send the member to the permission page.**
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from onemax import generate_pkce
|
|
82
|
+
|
|
83
|
+
pkce = generate_pkce()
|
|
84
|
+
session["onemax_verifier"] = pkce.verifier
|
|
85
|
+
|
|
86
|
+
url = client.authorize_url(
|
|
87
|
+
client_id="omx_client_...",
|
|
88
|
+
redirect_uri="https://example.az/onemax/return",
|
|
89
|
+
code_challenge=pkce.challenge,
|
|
90
|
+
state=session_id,
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
In a mobile app, open `url` in the system browser and use your app link as the `redirect_uri`,
|
|
95
|
+
for example `taksi://onemax/return`.
|
|
96
|
+
|
|
97
|
+
**2. The member returns to your address** with `code` and `state`, or with `error=access_denied`
|
|
98
|
+
if they declined. Check that `state` is the one you sent.
|
|
99
|
+
|
|
100
|
+
**3. Exchange the code on your server and store the token.**
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
link = client.exchange_code(
|
|
104
|
+
code,
|
|
105
|
+
code_verifier=session["onemax_verifier"],
|
|
106
|
+
redirect_uri="https://example.az/onemax/return",
|
|
107
|
+
)
|
|
108
|
+
save(user_id, link.link_token)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The code works once and for five minutes.
|
|
112
|
+
|
|
113
|
+
**4. Check the member whenever you need to.**
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
status = client.link_status(link_token)
|
|
117
|
+
if not status.linked:
|
|
118
|
+
forget(user_id)
|
|
119
|
+
|
|
120
|
+
check = client.verify_link(link_token)
|
|
121
|
+
if check.usable:
|
|
122
|
+
client.confirm_usage(check.usage_id, reference=order.id)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A member can unlink at any time in their OneMax profile. From then on `link_status` answers
|
|
126
|
+
`linked=False` and `verify_link` answers `reason="not_linked"`. Neither raises.
|
|
127
|
+
|
|
128
|
+
You learn only whether the subscription is active. The member's name, email, phone and photo are
|
|
129
|
+
never shared.
|
|
130
|
+
|
|
131
|
+
## Async
|
|
132
|
+
|
|
133
|
+
`AsyncOneMaxClient` has the same methods, awaited:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from onemax import AsyncOneMaxClient
|
|
137
|
+
|
|
138
|
+
async with AsyncOneMaxClient("omx_live_...") as client:
|
|
139
|
+
check = await client.verify_code("482915")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Results
|
|
143
|
+
|
|
144
|
+
`verify_code` and `verify_link` return a `Verification`:
|
|
145
|
+
|
|
146
|
+
| Field | Meaning |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| `active` | The member has a subscription that works today |
|
|
149
|
+
| `usable` | They may use the offer right now |
|
|
150
|
+
| `reason` | Why, as a `Reason` |
|
|
151
|
+
| `usage_id` | Set when `usable` is true. Confirm it or void it |
|
|
152
|
+
| `daily_limit` | How many times a day a member may use this venue |
|
|
153
|
+
|
|
154
|
+
| `Reason` | Meaning |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| `OK` | Go ahead |
|
|
157
|
+
| `NOT_SUBSCRIBED` | No subscription, or it has ended |
|
|
158
|
+
| `LIMIT_REACHED` | The member already used today's allowance at this venue |
|
|
159
|
+
| `OFFER_UNAVAILABLE` | The venue or the offer is not open right now |
|
|
160
|
+
| `INVALID_CODE` | The code is wrong, expired or already used |
|
|
161
|
+
| `NOT_LINKED` | The link token no longer works |
|
|
162
|
+
|
|
163
|
+
`Reason` and `UsageStatus` are string enums, so `check.reason == "ok"` works as well. A value this
|
|
164
|
+
version does not know is returned as a plain string.
|
|
165
|
+
|
|
166
|
+
If a venue has several offers open, pass `offer_id`. `client.context()` lists them.
|
|
167
|
+
|
|
168
|
+
## Errors
|
|
169
|
+
|
|
170
|
+
Everything the library raises is a `OneMaxError`.
|
|
171
|
+
|
|
172
|
+
| Class | When |
|
|
173
|
+
| --- | --- |
|
|
174
|
+
| `TransportError` | No usable answer: a connection failure, a timeout, or a body that is not JSON |
|
|
175
|
+
| `ApiError` | The API answered with an error. Has `status`, `code`, `message`, `details`, `request_id` |
|
|
176
|
+
| `AuthenticationError` | 401. The key is missing, wrong or switched off |
|
|
177
|
+
| `AccessDisabledError` | 403. API access is switched off for the partner |
|
|
178
|
+
| `NotFoundError` | 404. No such usage for this key |
|
|
179
|
+
| `ConflictError` | 409. For example `usage_limit_reached`, `already_applied`, `reference_used` |
|
|
180
|
+
| `InvalidRequestError` | 400 or 422. For example `offer_required`, `link_code_invalid` |
|
|
181
|
+
| `RateLimitError` | 429. Has `retry_after` in seconds |
|
|
182
|
+
| `ServerError` | 500 and above |
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from onemax import ConflictError, OneMaxError
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
client.confirm_usage(usage_id, reference=order.id)
|
|
189
|
+
except ConflictError as error:
|
|
190
|
+
if error.code == "usage_limit_reached":
|
|
191
|
+
charge_full_price(order)
|
|
192
|
+
else:
|
|
193
|
+
raise
|
|
194
|
+
except OneMaxError:
|
|
195
|
+
retry_later(order)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`message` is in Azerbaijani and meant for your logs. Decide on `code`.
|
|
199
|
+
|
|
200
|
+
## Limits
|
|
201
|
+
|
|
202
|
+
- The member's daily limit applies exactly as it does at a counter.
|
|
203
|
+
- 120 requests a minute per key.
|
|
204
|
+
- The library never retries by itself. `confirm_usage` with a `reference` is safe to retry.
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
uv sync
|
|
210
|
+
uv run ruff format --check .
|
|
211
|
+
uv run ruff check .
|
|
212
|
+
uv run mypy src
|
|
213
|
+
uv run pytest
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Releasing
|
|
217
|
+
|
|
218
|
+
Set the version in `src/onemax/_version.py`, add a `## x.y.z` section to `CHANGELOG.md`, commit, then push a tag
|
|
219
|
+
`vx.y.z`. The `Publish` workflow checks that the tag equals the package version, runs the checks,
|
|
220
|
+
publishes to PyPI and creates the GitHub release from the changelog section. A tag that does not match the
|
|
221
|
+
version publishes nothing.
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
MIT
|
onemax-0.1.0/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# onemax
|
|
2
|
+
|
|
3
|
+
Python client for the [OneMax](https://onemax.az) partner API.
|
|
4
|
+
|
|
5
|
+
OneMax is a 1+1 membership club in Baku. Members pay for a plan and show a code at the counter to
|
|
6
|
+
use a partner's offer. This library lets a partner's own system do what the counter does: check
|
|
7
|
+
that someone is a member and record that they used the offer.
|
|
8
|
+
|
|
9
|
+
Sync and async, fully typed, one dependency.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install onemax
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Getting a key
|
|
16
|
+
|
|
17
|
+
1. OneMax switches API access on for your business.
|
|
18
|
+
2. The owner opens the partner panel, goes to API and creates a key.
|
|
19
|
+
3. The key is shown once. It belongs to one venue and acts there like a cashier.
|
|
20
|
+
|
|
21
|
+
Keep the key on your server. It must never reach a browser or a mobile app.
|
|
22
|
+
|
|
23
|
+
## Quick start: a member's code
|
|
24
|
+
|
|
25
|
+
A member opens their OneMax card and reads you the six digit code.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from onemax import OneMaxClient
|
|
29
|
+
|
|
30
|
+
client = OneMaxClient("omx_live_...")
|
|
31
|
+
|
|
32
|
+
check = client.verify_code("482915")
|
|
33
|
+
if not check.usable:
|
|
34
|
+
print("No discount:", check.reason)
|
|
35
|
+
else:
|
|
36
|
+
order = place_order(discounted=True)
|
|
37
|
+
client.confirm_usage(check.usage_id, reference=order.id)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`verify_code` only answers the question. Nothing counts against the member until you call
|
|
41
|
+
`confirm_usage`. If the order falls through, call `void_usage(check.usage_id)` instead.
|
|
42
|
+
|
|
43
|
+
Pass your own order id as `reference`. Confirming again with the same reference returns the same
|
|
44
|
+
usage, so a retried request is safe.
|
|
45
|
+
|
|
46
|
+
## Account linking
|
|
47
|
+
|
|
48
|
+
A member links their OneMax account to your app once. After that you check them without a code.
|
|
49
|
+
Linking is the OAuth 2.0 authorization code flow with PKCE, so the same steps work for a website
|
|
50
|
+
and a mobile app.
|
|
51
|
+
|
|
52
|
+
Register the addresses your app may return to in the partner panel, under API, Account linking.
|
|
53
|
+
Your client id is shown there.
|
|
54
|
+
|
|
55
|
+
**1. Send the member to the permission page.**
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from onemax import generate_pkce
|
|
59
|
+
|
|
60
|
+
pkce = generate_pkce()
|
|
61
|
+
session["onemax_verifier"] = pkce.verifier
|
|
62
|
+
|
|
63
|
+
url = client.authorize_url(
|
|
64
|
+
client_id="omx_client_...",
|
|
65
|
+
redirect_uri="https://example.az/onemax/return",
|
|
66
|
+
code_challenge=pkce.challenge,
|
|
67
|
+
state=session_id,
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
In a mobile app, open `url` in the system browser and use your app link as the `redirect_uri`,
|
|
72
|
+
for example `taksi://onemax/return`.
|
|
73
|
+
|
|
74
|
+
**2. The member returns to your address** with `code` and `state`, or with `error=access_denied`
|
|
75
|
+
if they declined. Check that `state` is the one you sent.
|
|
76
|
+
|
|
77
|
+
**3. Exchange the code on your server and store the token.**
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
link = client.exchange_code(
|
|
81
|
+
code,
|
|
82
|
+
code_verifier=session["onemax_verifier"],
|
|
83
|
+
redirect_uri="https://example.az/onemax/return",
|
|
84
|
+
)
|
|
85
|
+
save(user_id, link.link_token)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The code works once and for five minutes.
|
|
89
|
+
|
|
90
|
+
**4. Check the member whenever you need to.**
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
status = client.link_status(link_token)
|
|
94
|
+
if not status.linked:
|
|
95
|
+
forget(user_id)
|
|
96
|
+
|
|
97
|
+
check = client.verify_link(link_token)
|
|
98
|
+
if check.usable:
|
|
99
|
+
client.confirm_usage(check.usage_id, reference=order.id)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A member can unlink at any time in their OneMax profile. From then on `link_status` answers
|
|
103
|
+
`linked=False` and `verify_link` answers `reason="not_linked"`. Neither raises.
|
|
104
|
+
|
|
105
|
+
You learn only whether the subscription is active. The member's name, email, phone and photo are
|
|
106
|
+
never shared.
|
|
107
|
+
|
|
108
|
+
## Async
|
|
109
|
+
|
|
110
|
+
`AsyncOneMaxClient` has the same methods, awaited:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from onemax import AsyncOneMaxClient
|
|
114
|
+
|
|
115
|
+
async with AsyncOneMaxClient("omx_live_...") as client:
|
|
116
|
+
check = await client.verify_code("482915")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Results
|
|
120
|
+
|
|
121
|
+
`verify_code` and `verify_link` return a `Verification`:
|
|
122
|
+
|
|
123
|
+
| Field | Meaning |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `active` | The member has a subscription that works today |
|
|
126
|
+
| `usable` | They may use the offer right now |
|
|
127
|
+
| `reason` | Why, as a `Reason` |
|
|
128
|
+
| `usage_id` | Set when `usable` is true. Confirm it or void it |
|
|
129
|
+
| `daily_limit` | How many times a day a member may use this venue |
|
|
130
|
+
|
|
131
|
+
| `Reason` | Meaning |
|
|
132
|
+
| --- | --- |
|
|
133
|
+
| `OK` | Go ahead |
|
|
134
|
+
| `NOT_SUBSCRIBED` | No subscription, or it has ended |
|
|
135
|
+
| `LIMIT_REACHED` | The member already used today's allowance at this venue |
|
|
136
|
+
| `OFFER_UNAVAILABLE` | The venue or the offer is not open right now |
|
|
137
|
+
| `INVALID_CODE` | The code is wrong, expired or already used |
|
|
138
|
+
| `NOT_LINKED` | The link token no longer works |
|
|
139
|
+
|
|
140
|
+
`Reason` and `UsageStatus` are string enums, so `check.reason == "ok"` works as well. A value this
|
|
141
|
+
version does not know is returned as a plain string.
|
|
142
|
+
|
|
143
|
+
If a venue has several offers open, pass `offer_id`. `client.context()` lists them.
|
|
144
|
+
|
|
145
|
+
## Errors
|
|
146
|
+
|
|
147
|
+
Everything the library raises is a `OneMaxError`.
|
|
148
|
+
|
|
149
|
+
| Class | When |
|
|
150
|
+
| --- | --- |
|
|
151
|
+
| `TransportError` | No usable answer: a connection failure, a timeout, or a body that is not JSON |
|
|
152
|
+
| `ApiError` | The API answered with an error. Has `status`, `code`, `message`, `details`, `request_id` |
|
|
153
|
+
| `AuthenticationError` | 401. The key is missing, wrong or switched off |
|
|
154
|
+
| `AccessDisabledError` | 403. API access is switched off for the partner |
|
|
155
|
+
| `NotFoundError` | 404. No such usage for this key |
|
|
156
|
+
| `ConflictError` | 409. For example `usage_limit_reached`, `already_applied`, `reference_used` |
|
|
157
|
+
| `InvalidRequestError` | 400 or 422. For example `offer_required`, `link_code_invalid` |
|
|
158
|
+
| `RateLimitError` | 429. Has `retry_after` in seconds |
|
|
159
|
+
| `ServerError` | 500 and above |
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from onemax import ConflictError, OneMaxError
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
client.confirm_usage(usage_id, reference=order.id)
|
|
166
|
+
except ConflictError as error:
|
|
167
|
+
if error.code == "usage_limit_reached":
|
|
168
|
+
charge_full_price(order)
|
|
169
|
+
else:
|
|
170
|
+
raise
|
|
171
|
+
except OneMaxError:
|
|
172
|
+
retry_later(order)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`message` is in Azerbaijani and meant for your logs. Decide on `code`.
|
|
176
|
+
|
|
177
|
+
## Limits
|
|
178
|
+
|
|
179
|
+
- The member's daily limit applies exactly as it does at a counter.
|
|
180
|
+
- 120 requests a minute per key.
|
|
181
|
+
- The library never retries by itself. `confirm_usage` with a `reference` is safe to retry.
|
|
182
|
+
|
|
183
|
+
## Development
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
uv sync
|
|
187
|
+
uv run ruff format --check .
|
|
188
|
+
uv run ruff check .
|
|
189
|
+
uv run mypy src
|
|
190
|
+
uv run pytest
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Releasing
|
|
194
|
+
|
|
195
|
+
Set the version in `src/onemax/_version.py`, add a `## x.y.z` section to `CHANGELOG.md`, commit, then push a tag
|
|
196
|
+
`vx.y.z`. The `Publish` workflow checks that the tag equals the package version, runs the checks,
|
|
197
|
+
publishes to PyPI and creates the GitHub release from the changelog section. A tag that does not match the
|
|
198
|
+
version publishes nothing.
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "onemax"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Python client for the OneMax partner API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "OneMax" }]
|
|
9
|
+
keywords = ["onemax", "membership", "partner", "api", "azerbaijan"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["httpx>=0.27"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://onemax.az"
|
|
24
|
+
Repository = "https://github.com/onemax-az/onemax-python"
|
|
25
|
+
Changelog = "https://github.com/onemax-az/onemax-python/blob/main/CHANGELOG.md"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8.4.2",
|
|
30
|
+
"pytest-asyncio>=1.0",
|
|
31
|
+
"pytest-cov>=7.0.0",
|
|
32
|
+
"ruff>=0.15.0",
|
|
33
|
+
"mypy>=1.19.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["hatchling"]
|
|
38
|
+
build-backend = "hatchling.build"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.version]
|
|
41
|
+
path = "src/onemax/_version.py"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/onemax"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 100
|
|
48
|
+
target-version = "py310"
|
|
49
|
+
src = ["src", "tests"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = ["E", "W", "F", "I", "N", "UP", "B", "A", "C4", "SIM", "RUF", "PT", "TID"]
|
|
53
|
+
|
|
54
|
+
[tool.mypy]
|
|
55
|
+
python_version = "3.10"
|
|
56
|
+
strict = true
|
|
57
|
+
files = ["src"]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
|
61
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from ._version import __version__
|
|
2
|
+
from .aio import AsyncOneMaxClient
|
|
3
|
+
from .client import OneMaxClient
|
|
4
|
+
from .errors import (
|
|
5
|
+
AccessDisabledError,
|
|
6
|
+
ApiError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
InvalidRequestError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
OneMaxError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
ServerError,
|
|
14
|
+
TransportError,
|
|
15
|
+
)
|
|
16
|
+
from .models import Context, LinkStatus, LinkToken, Offer, Reason, Usage, UsageStatus, Verification
|
|
17
|
+
from .pkce import Pkce, challenge_for, generate_pkce
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AccessDisabledError",
|
|
21
|
+
"ApiError",
|
|
22
|
+
"AsyncOneMaxClient",
|
|
23
|
+
"AuthenticationError",
|
|
24
|
+
"ConflictError",
|
|
25
|
+
"Context",
|
|
26
|
+
"InvalidRequestError",
|
|
27
|
+
"LinkStatus",
|
|
28
|
+
"LinkToken",
|
|
29
|
+
"NotFoundError",
|
|
30
|
+
"Offer",
|
|
31
|
+
"OneMaxClient",
|
|
32
|
+
"OneMaxError",
|
|
33
|
+
"Pkce",
|
|
34
|
+
"RateLimitError",
|
|
35
|
+
"Reason",
|
|
36
|
+
"ServerError",
|
|
37
|
+
"TransportError",
|
|
38
|
+
"Usage",
|
|
39
|
+
"UsageStatus",
|
|
40
|
+
"Verification",
|
|
41
|
+
"__version__",
|
|
42
|
+
"challenge_for",
|
|
43
|
+
"generate_pkce",
|
|
44
|
+
]
|