flex-api 1.0.0b35__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.
- flex_api-1.0.0b35/LICENSE +58 -0
- flex_api-1.0.0b35/PKG-INFO +64 -0
- flex_api-1.0.0b35/README.md +48 -0
- flex_api-1.0.0b35/flex_api/__init__.py +198 -0
- flex_api-1.0.0b35/flex_api.egg-info/PKG-INFO +64 -0
- flex_api-1.0.0b35/flex_api.egg-info/SOURCES.txt +9 -0
- flex_api-1.0.0b35/flex_api.egg-info/dependency_links.txt +1 -0
- flex_api-1.0.0b35/flex_api.egg-info/top_level.txt +1 -0
- flex_api-1.0.0b35/pyproject.toml +26 -0
- flex_api-1.0.0b35/setup.cfg +4 -0
- flex_api-1.0.0b35/tests/test_client.py +105 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Flex API Client SDK — Proprietary License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Flex Services, LLC. All rights reserved.
|
|
4
|
+
|
|
5
|
+
1. Definitions.
|
|
6
|
+
"Software" means this package (the Flex API client SDK), in source and
|
|
7
|
+
compiled form, together with its documentation.
|
|
8
|
+
"Flex Service" means the Flex on the Job application and its API
|
|
9
|
+
(https://flexonthejob.com).
|
|
10
|
+
"You" means the individual or entity exercising the rights granted below.
|
|
11
|
+
|
|
12
|
+
2. Grant of license.
|
|
13
|
+
Subject to Your compliance with this License, Flex Services, LLC grants You
|
|
14
|
+
a personal, non-exclusive, non-transferable, non-sublicensable, revocable
|
|
15
|
+
license to install and use the Software, in unmodified form, solely to
|
|
16
|
+
develop and operate Your own applications that access the Flex Service
|
|
17
|
+
through its API using credentials issued to You.
|
|
18
|
+
|
|
19
|
+
3. Restrictions.
|
|
20
|
+
Except as expressly permitted in Section 2, You may not:
|
|
21
|
+
(a) copy, reproduce or distribute the Software, in whole or in part, or
|
|
22
|
+
publish or re-publish it to any package registry or other repository;
|
|
23
|
+
(b) modify, adapt, translate or create derivative works of the Software;
|
|
24
|
+
(c) sell, rent, lease, lend, sublicense or otherwise transfer the Software
|
|
25
|
+
or Your rights under this License;
|
|
26
|
+
(d) reverse engineer, decompile or disassemble the Software, or attempt to
|
|
27
|
+
derive its source code, except to the extent this restriction is
|
|
28
|
+
prohibited by applicable law;
|
|
29
|
+
(e) remove, alter or obscure any copyright, trademark or other proprietary
|
|
30
|
+
notice; or
|
|
31
|
+
(f) use the Software to build, train or improve a product or service that
|
|
32
|
+
competes with the Software or the Flex Service.
|
|
33
|
+
|
|
34
|
+
4. Ownership.
|
|
35
|
+
The Software is licensed, not sold. Flex Services, LLC and its licensors
|
|
36
|
+
retain all right, title and interest in and to the Software, including all
|
|
37
|
+
intellectual property rights. No rights are granted except as expressly set
|
|
38
|
+
out in this License.
|
|
39
|
+
|
|
40
|
+
5. Termination.
|
|
41
|
+
This License terminates automatically if You breach any of its terms, and
|
|
42
|
+
may be revoked by Flex Services, LLC at any time. On termination You must
|
|
43
|
+
stop using and destroy all copies of the Software. Sections 3, 4, 6 and 7
|
|
44
|
+
survive termination.
|
|
45
|
+
|
|
46
|
+
6. No warranty.
|
|
47
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
48
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
49
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT.
|
|
50
|
+
|
|
51
|
+
7. Limitation of liability.
|
|
52
|
+
TO THE MAXIMUM EXTENT PERMITTED BY LAW, IN NO EVENT SHALL FLEX SERVICES, LLC
|
|
53
|
+
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
|
54
|
+
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
55
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
56
|
+
|
|
57
|
+
For other uses, or to request additional rights, contact
|
|
58
|
+
legal@flexonthejob.com.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flex-api
|
|
3
|
+
Version: 1.0.0b35
|
|
4
|
+
Summary: Client for the Flex on the Job tenant API (/api/v1): inventory, jobs, invoices, purchasing.
|
|
5
|
+
License: Proprietary. See LICENSE file.
|
|
6
|
+
Project-URL: Homepage, https://flexonthejob.com/developers
|
|
7
|
+
Project-URL: API reference, https://app.flexonthejob.com/docs
|
|
8
|
+
Keywords: flex,inventory,field-service,openapi,api-client
|
|
9
|
+
Classifier: License :: Other/Proprietary License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# flex-api (Python)
|
|
18
|
+
|
|
19
|
+
Client for the [Flex on the Job](https://flexonthejob.com) tenant API. Standard library only, Python 3.9+.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from flex_api import FlexClient, FlexApiError, SANDBOX_URL
|
|
23
|
+
|
|
24
|
+
flex = FlexClient(api_key="flx_live_...") # Settings -> Integrations -> API Keys
|
|
25
|
+
# flex = FlexClient("flx_live_YLJG6oxA6hOiJTT7ziyAOFs8nJAJfbRE", base_url=SANDBOX_URL) # public read-only demo
|
|
26
|
+
|
|
27
|
+
for item in flex.paginate("/api/v1/items", search="filter"):
|
|
28
|
+
print(item["name"], item["onHand"])
|
|
29
|
+
|
|
30
|
+
# Writes get an Idempotency-Key automatically (kept across retries).
|
|
31
|
+
flex.post("/api/v1/items/stock/batch", {"operations": [
|
|
32
|
+
{"action": "Adjust", "itemId": 12, "locationId": 3, "delta": -2, "reason": "Cycle count"},
|
|
33
|
+
{"action": "Move", "itemId": 12, "fromLocationId": 3, "toLocationId": 4, "quantity": 1},
|
|
34
|
+
]})
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
flex.post("/api/v1/purchase-orders", {"receivingLocationId": 1, "lines": [{"itemId": 999, "quantityOrdered": 1}]})
|
|
38
|
+
except FlexApiError as e:
|
|
39
|
+
print(e.status, e.detail, e.errors) # 422 ... {'lines[0].itemId': [...]}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
What the client does for you:
|
|
43
|
+
|
|
44
|
+
- `Authorization: Bearer` on every request.
|
|
45
|
+
- An `Idempotency-Key` on every POST/PUT/PATCH/DELETE (pass `idempotency_key=` to choose your own), reused if the
|
|
46
|
+
request is retried, so a retry is never applied twice.
|
|
47
|
+
- Retries 429 and 503 (default 3 times), waiting `Retry-After` (seconds or an HTTP date), and a 409 "still being
|
|
48
|
+
processed" while an earlier attempt with the same key is running.
|
|
49
|
+
- `flex.last_response_headers` holds the last response's headers: send its `etag` back as `if_match=` on
|
|
50
|
+
`put()` / `patch()` to refuse an update if the record changed in between (412).
|
|
51
|
+
- `paginate(path, **query)` follows `nextCursor` across pages.
|
|
52
|
+
- Errors raise `FlexApiError` with the RFC 7807 problem: `status`, `detail`, and `errors` per field.
|
|
53
|
+
|
|
54
|
+
Paths, parameters and bodies are exactly those of the OpenAPI document at
|
|
55
|
+
<https://app.flexonthejob.com/openapi/v1.json> (browsable at `/docs`). Tests: `python -m unittest discover -s tests`.
|
|
56
|
+
|
|
57
|
+
Examples in `examples/`: `cycle_count.py`, `invoice_completed_jobs.py` (`--finalize` also marks the drafts sent), `receive_delivery.py` and `reorder_low_stock.py`. They talk to the sandbox unless you set `FLEX_BASE_URL=https://app.flexonthejob.com`. The last three preview
|
|
58
|
+
their change and write only after `--apply`, `--yes` or an interactive yes. The Flex MCP server (`/mcp`) offers the same tasks as prompts (`cycle_count`, `invoice_completed_jobs`, `receive_delivery`, `reorder_below_minimum`).
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
Proprietary — Copyright (c) 2026 Flex Services, LLC. All rights reserved. Licensed for use in your
|
|
63
|
+
applications to access the Flex on the Job API; you may not copy, modify, redistribute, or reverse-engineer
|
|
64
|
+
it. See the [LICENSE](./LICENSE) file for the full terms.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# flex-api (Python)
|
|
2
|
+
|
|
3
|
+
Client for the [Flex on the Job](https://flexonthejob.com) tenant API. Standard library only, Python 3.9+.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from flex_api import FlexClient, FlexApiError, SANDBOX_URL
|
|
7
|
+
|
|
8
|
+
flex = FlexClient(api_key="flx_live_...") # Settings -> Integrations -> API Keys
|
|
9
|
+
# flex = FlexClient("flx_live_YLJG6oxA6hOiJTT7ziyAOFs8nJAJfbRE", base_url=SANDBOX_URL) # public read-only demo
|
|
10
|
+
|
|
11
|
+
for item in flex.paginate("/api/v1/items", search="filter"):
|
|
12
|
+
print(item["name"], item["onHand"])
|
|
13
|
+
|
|
14
|
+
# Writes get an Idempotency-Key automatically (kept across retries).
|
|
15
|
+
flex.post("/api/v1/items/stock/batch", {"operations": [
|
|
16
|
+
{"action": "Adjust", "itemId": 12, "locationId": 3, "delta": -2, "reason": "Cycle count"},
|
|
17
|
+
{"action": "Move", "itemId": 12, "fromLocationId": 3, "toLocationId": 4, "quantity": 1},
|
|
18
|
+
]})
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
flex.post("/api/v1/purchase-orders", {"receivingLocationId": 1, "lines": [{"itemId": 999, "quantityOrdered": 1}]})
|
|
22
|
+
except FlexApiError as e:
|
|
23
|
+
print(e.status, e.detail, e.errors) # 422 ... {'lines[0].itemId': [...]}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
What the client does for you:
|
|
27
|
+
|
|
28
|
+
- `Authorization: Bearer` on every request.
|
|
29
|
+
- An `Idempotency-Key` on every POST/PUT/PATCH/DELETE (pass `idempotency_key=` to choose your own), reused if the
|
|
30
|
+
request is retried, so a retry is never applied twice.
|
|
31
|
+
- Retries 429 and 503 (default 3 times), waiting `Retry-After` (seconds or an HTTP date), and a 409 "still being
|
|
32
|
+
processed" while an earlier attempt with the same key is running.
|
|
33
|
+
- `flex.last_response_headers` holds the last response's headers: send its `etag` back as `if_match=` on
|
|
34
|
+
`put()` / `patch()` to refuse an update if the record changed in between (412).
|
|
35
|
+
- `paginate(path, **query)` follows `nextCursor` across pages.
|
|
36
|
+
- Errors raise `FlexApiError` with the RFC 7807 problem: `status`, `detail`, and `errors` per field.
|
|
37
|
+
|
|
38
|
+
Paths, parameters and bodies are exactly those of the OpenAPI document at
|
|
39
|
+
<https://app.flexonthejob.com/openapi/v1.json> (browsable at `/docs`). Tests: `python -m unittest discover -s tests`.
|
|
40
|
+
|
|
41
|
+
Examples in `examples/`: `cycle_count.py`, `invoice_completed_jobs.py` (`--finalize` also marks the drafts sent), `receive_delivery.py` and `reorder_low_stock.py`. They talk to the sandbox unless you set `FLEX_BASE_URL=https://app.flexonthejob.com`. The last three preview
|
|
42
|
+
their change and write only after `--apply`, `--yes` or an interactive yes. The Flex MCP server (`/mcp`) offers the same tasks as prompts (`cycle_count`, `invoice_completed_jobs`, `receive_delivery`, `reorder_below_minimum`).
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
Proprietary — Copyright (c) 2026 Flex Services, LLC. All rights reserved. Licensed for use in your
|
|
47
|
+
applications to access the Flex on the Job API; you may not copy, modify, redistribute, or reverse-engineer
|
|
48
|
+
it. See the [LICENSE](./LICENSE) file for the full terms.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Flex on the Job API client (https://flexonthejob.com/developers).
|
|
2
|
+
|
|
3
|
+
Standard library only (Python 3.9+). Wraps /api/v1 with what every caller needs:
|
|
4
|
+
|
|
5
|
+
* ``Authorization: Bearer flx_live_...`` on every request;
|
|
6
|
+
* an ``Idempotency-Key`` on every POST/PUT/PATCH/DELETE (the API requires one), reused across retries so a
|
|
7
|
+
retried write is applied once;
|
|
8
|
+
* retry on 429 / 503 honouring ``Retry-After`` (seconds or an HTTP date), and on the 409 "still being processed"
|
|
9
|
+
answer while an earlier attempt with the same ``Idempotency-Key`` is in flight;
|
|
10
|
+
* :attr:`FlexClient.last_response_headers` (e.g. the ``ETag`` to send back as ``if_match=``);
|
|
11
|
+
* :meth:`FlexClient.paginate` over ``{ items, nextCursor }`` list endpoints;
|
|
12
|
+
* :class:`FlexApiError` carrying the RFC 7807 problem (``status``, ``detail``, field ``errors``).
|
|
13
|
+
|
|
14
|
+
Paths, parameters and bodies are exactly those in the OpenAPI document
|
|
15
|
+
(https://app.flexonthejob.com/openapi/v1.json, browsable at /docs)::
|
|
16
|
+
|
|
17
|
+
from flex_api import FlexClient
|
|
18
|
+
flex = FlexClient(api_key="flx_live_...")
|
|
19
|
+
for item in flex.paginate("/api/v1/items", search="filter"):
|
|
20
|
+
print(item["name"], item["onHand"])
|
|
21
|
+
flex.post("/api/v1/items/stock/batch", {"operations": [
|
|
22
|
+
{"action": "Adjust", "itemId": 12, "locationId": 3, "delta": -2, "reason": "Cycle count"},
|
|
23
|
+
]})
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import email.utils
|
|
28
|
+
import json
|
|
29
|
+
import time
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.parse
|
|
32
|
+
import urllib.request
|
|
33
|
+
import uuid
|
|
34
|
+
from typing import Any, Callable, Dict, Iterator, Optional
|
|
35
|
+
|
|
36
|
+
__all__ = ["FlexClient", "FlexApiError", "PRODUCTION_URL", "SANDBOX_URL"]
|
|
37
|
+
__version__ = "1.0.0b35"
|
|
38
|
+
|
|
39
|
+
PRODUCTION_URL = "https://app.flexonthejob.com"
|
|
40
|
+
SANDBOX_URL = "https://sandbox.flexonthejob.com"
|
|
41
|
+
_WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
42
|
+
# The API's 409 detail while a request with the same Idempotency-Key is still running (safe to retry with that key).
|
|
43
|
+
_IN_PROGRESS_MARKER = "still being processed"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FlexApiError(Exception):
|
|
47
|
+
"""An error response from the API. ``problem`` is the parsed problem+json body (may be empty)."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, status: int, problem: Optional[Dict[str, Any]] = None):
|
|
50
|
+
self.status = status
|
|
51
|
+
self.problem = problem or {}
|
|
52
|
+
self.detail = self.problem.get("detail") or self.problem.get("title")
|
|
53
|
+
self.errors: Dict[str, Any] = self.problem.get("errors") or {}
|
|
54
|
+
super().__init__(self.detail or f"Flex API error {status}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# (method, url, headers, body bytes, timeout) -> (status, headers dict, body bytes)
|
|
58
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], "tuple[int, Dict[str, str], bytes]"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float):
|
|
62
|
+
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
63
|
+
try:
|
|
64
|
+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - https API URL
|
|
65
|
+
return response.status, {k.lower(): v for k, v in response.headers.items()}, response.read()
|
|
66
|
+
except urllib.error.HTTPError as error:
|
|
67
|
+
return error.code, {k.lower(): v for k, v in error.headers.items()}, error.read()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FlexClient:
|
|
71
|
+
"""Client for one organization's API key.
|
|
72
|
+
|
|
73
|
+
:param api_key: key from Settings -> Integrations -> API Keys (``flx_live_...``).
|
|
74
|
+
:param base_url: defaults to production; use :data:`SANDBOX_URL` with the public demo keys.
|
|
75
|
+
:param max_retries: retries after 429/503 (default 3; 0 disables).
|
|
76
|
+
:param timeout: seconds per request (default 30).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
api_key: str,
|
|
82
|
+
base_url: str = PRODUCTION_URL,
|
|
83
|
+
max_retries: int = 3,
|
|
84
|
+
timeout: float = 30.0,
|
|
85
|
+
max_retry_after: float = 60.0,
|
|
86
|
+
transport: Optional[Transport] = None,
|
|
87
|
+
):
|
|
88
|
+
if not api_key:
|
|
89
|
+
raise ValueError("api_key is required (Settings -> Integrations -> API Keys).")
|
|
90
|
+
self.api_key = api_key
|
|
91
|
+
self.base_url = base_url.rstrip("/")
|
|
92
|
+
self.max_retries = max_retries
|
|
93
|
+
self.timeout = timeout
|
|
94
|
+
self.max_retry_after = max_retry_after
|
|
95
|
+
self._transport = transport or _urllib_transport
|
|
96
|
+
#: Lower-cased headers of the last response (``etag``, ``x-ratelimit-remaining``, ...).
|
|
97
|
+
self.last_response_headers: Dict[str, str] = {}
|
|
98
|
+
|
|
99
|
+
# ---- verbs -------------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def get(self, path: str, **query: Any) -> Any:
|
|
102
|
+
return self.request("GET", path, query=query)
|
|
103
|
+
|
|
104
|
+
def post(self, path: str, body: Any = None, idempotency_key: Optional[str] = None, **query: Any) -> Any:
|
|
105
|
+
return self.request("POST", path, query=query, body=body, idempotency_key=idempotency_key)
|
|
106
|
+
|
|
107
|
+
def put(self, path: str, body: Any = None, idempotency_key: Optional[str] = None, if_match: Optional[str] = None) -> Any:
|
|
108
|
+
headers = {"If-Match": if_match} if if_match else None
|
|
109
|
+
return self.request("PUT", path, body=body, idempotency_key=idempotency_key, headers=headers)
|
|
110
|
+
|
|
111
|
+
def patch(self, path: str, body: Any = None, idempotency_key: Optional[str] = None, if_match: Optional[str] = None) -> Any:
|
|
112
|
+
headers = {"If-Match": if_match} if if_match else None
|
|
113
|
+
return self.request("PATCH", path, body=body, idempotency_key=idempotency_key, headers=headers)
|
|
114
|
+
|
|
115
|
+
def delete(self, path: str, idempotency_key: Optional[str] = None, **query: Any) -> Any:
|
|
116
|
+
return self.request("DELETE", path, query=query, idempotency_key=idempotency_key)
|
|
117
|
+
|
|
118
|
+
def paginate(self, path: str, **query: Any) -> Iterator[Dict[str, Any]]:
|
|
119
|
+
"""Yields every item of a list endpoint, following ``nextCursor``."""
|
|
120
|
+
after = None
|
|
121
|
+
while True:
|
|
122
|
+
page = self.get(path, **dict(query, after=after) if after else query)
|
|
123
|
+
for item in page.get("items") or []:
|
|
124
|
+
yield item
|
|
125
|
+
after = page.get("nextCursor")
|
|
126
|
+
if not after:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# ---- core --------------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def request(
|
|
132
|
+
self,
|
|
133
|
+
method: str,
|
|
134
|
+
path: str,
|
|
135
|
+
query: Optional[Dict[str, Any]] = None,
|
|
136
|
+
body: Any = None,
|
|
137
|
+
idempotency_key: Optional[str] = None,
|
|
138
|
+
headers: Optional[Dict[str, str]] = None,
|
|
139
|
+
) -> Any:
|
|
140
|
+
"""Sends one request; returns the parsed JSON body (None for an empty body) or raises FlexApiError."""
|
|
141
|
+
method = method.upper()
|
|
142
|
+
url = self.base_url + (path if path.startswith("/") else "/" + path)
|
|
143
|
+
params = {k: _query_value(v) for k, v in (query or {}).items() if v is not None}
|
|
144
|
+
if params:
|
|
145
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
146
|
+
|
|
147
|
+
sent = {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json", "User-Agent": f"flex-api-python/{__version__}"}
|
|
148
|
+
data = None
|
|
149
|
+
if body is not None:
|
|
150
|
+
data = json.dumps(body).encode("utf-8")
|
|
151
|
+
sent["Content-Type"] = "application/json"
|
|
152
|
+
if method in _WRITE_METHODS:
|
|
153
|
+
sent["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
154
|
+
sent.update(headers or {})
|
|
155
|
+
|
|
156
|
+
attempt = 0
|
|
157
|
+
while True:
|
|
158
|
+
status, response_headers, raw = self._transport(method, url, sent, data, self.timeout)
|
|
159
|
+
in_progress = status == 409 and "Idempotency-Key" in sent and _IN_PROGRESS_MARKER.encode() in (raw or b"")
|
|
160
|
+
if (status in (429, 503) or in_progress) and attempt < self.max_retries:
|
|
161
|
+
time.sleep(self._retry_delay(response_headers.get("retry-after"), attempt))
|
|
162
|
+
attempt += 1
|
|
163
|
+
continue
|
|
164
|
+
self.last_response_headers = dict(response_headers)
|
|
165
|
+
payload = _parse(raw)
|
|
166
|
+
if status >= 400:
|
|
167
|
+
raise FlexApiError(status, payload if isinstance(payload, dict) else None)
|
|
168
|
+
return payload
|
|
169
|
+
|
|
170
|
+
def _retry_delay(self, retry_after: Optional[str], attempt: int, now: Optional[float] = None) -> float:
|
|
171
|
+
seconds = 2.0 ** attempt
|
|
172
|
+
value = (retry_after or "").strip()
|
|
173
|
+
if value:
|
|
174
|
+
try:
|
|
175
|
+
seconds = float(value)
|
|
176
|
+
except ValueError:
|
|
177
|
+
try: # HTTP-date form, e.g. "Thu, 24 Sep 2026 12:00:05 GMT"
|
|
178
|
+
seconds = email.utils.parsedate_to_datetime(value).timestamp() - (time.time() if now is None else now)
|
|
179
|
+
except (TypeError, ValueError, IndexError):
|
|
180
|
+
pass
|
|
181
|
+
return max(0.0, min(seconds, self.max_retry_after))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _query_value(value: Any) -> str:
|
|
185
|
+
if isinstance(value, bool):
|
|
186
|
+
return "true" if value else "false"
|
|
187
|
+
if hasattr(value, "isoformat"):
|
|
188
|
+
return value.isoformat()
|
|
189
|
+
return str(value)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse(raw: bytes) -> Any:
|
|
193
|
+
if not raw:
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
return json.loads(raw.decode("utf-8"))
|
|
197
|
+
except ValueError:
|
|
198
|
+
return raw.decode("utf-8", errors="replace")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flex-api
|
|
3
|
+
Version: 1.0.0b35
|
|
4
|
+
Summary: Client for the Flex on the Job tenant API (/api/v1): inventory, jobs, invoices, purchasing.
|
|
5
|
+
License: Proprietary. See LICENSE file.
|
|
6
|
+
Project-URL: Homepage, https://flexonthejob.com/developers
|
|
7
|
+
Project-URL: API reference, https://app.flexonthejob.com/docs
|
|
8
|
+
Keywords: flex,inventory,field-service,openapi,api-client
|
|
9
|
+
Classifier: License :: Other/Proprietary License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# flex-api (Python)
|
|
18
|
+
|
|
19
|
+
Client for the [Flex on the Job](https://flexonthejob.com) tenant API. Standard library only, Python 3.9+.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from flex_api import FlexClient, FlexApiError, SANDBOX_URL
|
|
23
|
+
|
|
24
|
+
flex = FlexClient(api_key="flx_live_...") # Settings -> Integrations -> API Keys
|
|
25
|
+
# flex = FlexClient("flx_live_YLJG6oxA6hOiJTT7ziyAOFs8nJAJfbRE", base_url=SANDBOX_URL) # public read-only demo
|
|
26
|
+
|
|
27
|
+
for item in flex.paginate("/api/v1/items", search="filter"):
|
|
28
|
+
print(item["name"], item["onHand"])
|
|
29
|
+
|
|
30
|
+
# Writes get an Idempotency-Key automatically (kept across retries).
|
|
31
|
+
flex.post("/api/v1/items/stock/batch", {"operations": [
|
|
32
|
+
{"action": "Adjust", "itemId": 12, "locationId": 3, "delta": -2, "reason": "Cycle count"},
|
|
33
|
+
{"action": "Move", "itemId": 12, "fromLocationId": 3, "toLocationId": 4, "quantity": 1},
|
|
34
|
+
]})
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
flex.post("/api/v1/purchase-orders", {"receivingLocationId": 1, "lines": [{"itemId": 999, "quantityOrdered": 1}]})
|
|
38
|
+
except FlexApiError as e:
|
|
39
|
+
print(e.status, e.detail, e.errors) # 422 ... {'lines[0].itemId': [...]}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
What the client does for you:
|
|
43
|
+
|
|
44
|
+
- `Authorization: Bearer` on every request.
|
|
45
|
+
- An `Idempotency-Key` on every POST/PUT/PATCH/DELETE (pass `idempotency_key=` to choose your own), reused if the
|
|
46
|
+
request is retried, so a retry is never applied twice.
|
|
47
|
+
- Retries 429 and 503 (default 3 times), waiting `Retry-After` (seconds or an HTTP date), and a 409 "still being
|
|
48
|
+
processed" while an earlier attempt with the same key is running.
|
|
49
|
+
- `flex.last_response_headers` holds the last response's headers: send its `etag` back as `if_match=` on
|
|
50
|
+
`put()` / `patch()` to refuse an update if the record changed in between (412).
|
|
51
|
+
- `paginate(path, **query)` follows `nextCursor` across pages.
|
|
52
|
+
- Errors raise `FlexApiError` with the RFC 7807 problem: `status`, `detail`, and `errors` per field.
|
|
53
|
+
|
|
54
|
+
Paths, parameters and bodies are exactly those of the OpenAPI document at
|
|
55
|
+
<https://app.flexonthejob.com/openapi/v1.json> (browsable at `/docs`). Tests: `python -m unittest discover -s tests`.
|
|
56
|
+
|
|
57
|
+
Examples in `examples/`: `cycle_count.py`, `invoice_completed_jobs.py` (`--finalize` also marks the drafts sent), `receive_delivery.py` and `reorder_low_stock.py`. They talk to the sandbox unless you set `FLEX_BASE_URL=https://app.flexonthejob.com`. The last three preview
|
|
58
|
+
their change and write only after `--apply`, `--yes` or an interactive yes. The Flex MCP server (`/mcp`) offers the same tasks as prompts (`cycle_count`, `invoice_completed_jobs`, `receive_delivery`, `reorder_below_minimum`).
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
Proprietary — Copyright (c) 2026 Flex Services, LLC. All rights reserved. Licensed for use in your
|
|
63
|
+
applications to access the Flex on the Job API; you may not copy, modify, redistribute, or reverse-engineer
|
|
64
|
+
it. See the [LICENSE](./LICENSE) file for the full terms.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flex_api
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flex-api"
|
|
7
|
+
version = "1.0.0b35"
|
|
8
|
+
description = "Client for the Flex on the Job tenant API (/api/v1): inventory, jobs, invoices, purchasing."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Proprietary. See LICENSE file." }
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["flex", "inventory", "field-service", "openapi", "api-client"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: Other/Proprietary License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://flexonthejob.com/developers"
|
|
22
|
+
"API reference" = "https://app.flexonthejob.com/docs"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
packages = ["flex_api"]
|
|
26
|
+
license-files = ["LICENSE"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Offline tests for flex_api (python -m unittest discover -s tests). No network: a fake transport."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
8
|
+
from flex_api import FlexApiError, FlexClient # noqa: E402
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeTransport:
|
|
12
|
+
def __init__(self, responses):
|
|
13
|
+
self.responses = list(responses)
|
|
14
|
+
self.calls = []
|
|
15
|
+
|
|
16
|
+
def __call__(self, method, url, headers, body, timeout):
|
|
17
|
+
self.calls.append({"method": method, "url": url, "headers": dict(headers), "body": body})
|
|
18
|
+
status, payload, extra = self.responses.pop(0)
|
|
19
|
+
return status, {"content-type": "application/json", **extra}, json.dumps(payload).encode() if payload is not None else b""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClientTests(unittest.TestCase):
|
|
23
|
+
def test_paginate_follows_next_cursor(self):
|
|
24
|
+
fake = FakeTransport([(200, {"items": [{"id": 1}], "nextCursor": "c1"}, {}), (200, {"items": [{"id": 2}], "nextCursor": None}, {})])
|
|
25
|
+
flex = FlexClient("flx_live_test", base_url="https://example.test/", transport=fake)
|
|
26
|
+
self.assertEqual([1, 2], [i["id"] for i in flex.paginate("/api/v1/items", search="x", includeDeleted=False)])
|
|
27
|
+
self.assertIn("search=x", fake.calls[0]["url"])
|
|
28
|
+
self.assertIn("includeDeleted=false", fake.calls[0]["url"])
|
|
29
|
+
self.assertIn("after=c1", fake.calls[1]["url"])
|
|
30
|
+
self.assertTrue(all(c["headers"]["Authorization"] == "Bearer flx_live_test" for c in fake.calls))
|
|
31
|
+
self.assertTrue(all("Idempotency-Key" not in c["headers"] for c in fake.calls))
|
|
32
|
+
|
|
33
|
+
def test_writes_get_one_idempotency_key_kept_across_429_retry(self):
|
|
34
|
+
fake = FakeTransport([(429, None, {"retry-after": "0"}), (200, {"message": "ok"}, {})])
|
|
35
|
+
flex = FlexClient("flx_live_test", transport=fake)
|
|
36
|
+
self.assertEqual({"message": "ok"}, flex.post("/api/v1/items/stock/batch", {"operations": []}))
|
|
37
|
+
keys = [c["headers"]["Idempotency-Key"] for c in fake.calls]
|
|
38
|
+
self.assertEqual(2, len(keys))
|
|
39
|
+
self.assertEqual(keys[0], keys[1])
|
|
40
|
+
self.assertEqual(b'{"operations": []}', fake.calls[0]["body"])
|
|
41
|
+
|
|
42
|
+
def test_delete_also_gets_an_idempotency_key(self):
|
|
43
|
+
# Every /api/v1 DELETE is [Idempotent] and refuses a request without the header (400).
|
|
44
|
+
fake = FakeTransport([(200, {"message": "ok"}, {})])
|
|
45
|
+
flex = FlexClient("flx_live_test", transport=fake)
|
|
46
|
+
flex.delete("/api/v1/categories/7")
|
|
47
|
+
self.assertTrue(fake.calls[0]["headers"].get("Idempotency-Key"))
|
|
48
|
+
|
|
49
|
+
def test_problem_json_becomes_flex_api_error(self):
|
|
50
|
+
fake = FakeTransport([(422, {"title": "One or more validation errors occurred.", "status": 422, "errors": {"lines[0].itemId": ["No active item"]}}, {})])
|
|
51
|
+
flex = FlexClient("flx_live_test", transport=fake)
|
|
52
|
+
with self.assertRaises(FlexApiError) as caught:
|
|
53
|
+
flex.post("/api/v1/purchase-orders", {"receivingLocationId": 1})
|
|
54
|
+
self.assertEqual(422, caught.exception.status)
|
|
55
|
+
self.assertIn("lines[0].itemId", caught.exception.errors)
|
|
56
|
+
|
|
57
|
+
def test_in_flight_409_is_retried_with_the_same_key_but_other_409s_are_not(self):
|
|
58
|
+
busy = {"status": 409, "detail": "A request with this Idempotency-Key is still being processed. Wait for it to finish, then retry with the same key to get its result."}
|
|
59
|
+
fake = FakeTransport([(409, busy, {"retry-after": "0"}), (200, {"message": "ok"}, {}), (409, {"status": 409, "detail": "Version conflict"}, {})])
|
|
60
|
+
flex = FlexClient("flx_live_test", transport=fake)
|
|
61
|
+
self.assertEqual({"message": "ok"}, flex.post("/api/v1/items/stock/batch", {"operations": []}))
|
|
62
|
+
self.assertEqual(fake.calls[0]["headers"]["Idempotency-Key"], fake.calls[1]["headers"]["Idempotency-Key"])
|
|
63
|
+
with self.assertRaises(FlexApiError) as caught:
|
|
64
|
+
flex.post("/api/v1/customers", {"name": "x"})
|
|
65
|
+
self.assertEqual(409, caught.exception.status)
|
|
66
|
+
self.assertEqual(3, len(fake.calls))
|
|
67
|
+
|
|
68
|
+
def test_retry_after_seconds_http_date_and_garbage(self):
|
|
69
|
+
flex = FlexClient("flx_live_test", transport=FakeTransport([]))
|
|
70
|
+
now = 1790251200.0 # 2026-09-24T12:00:00Z
|
|
71
|
+
self.assertEqual(2.0, flex._retry_delay("2", 0, now))
|
|
72
|
+
self.assertEqual(5.0, flex._retry_delay("Thu, 24 Sep 2026 12:00:05 GMT", 0, now))
|
|
73
|
+
self.assertEqual(0.0, flex._retry_delay("Thu, 24 Sep 2026 11:59:00 GMT", 0, now))
|
|
74
|
+
self.assertEqual(4.0, flex._retry_delay("soon", 2, now))
|
|
75
|
+
self.assertEqual(60.0, flex._retry_delay("600", 0, now))
|
|
76
|
+
|
|
77
|
+
def test_etag_round_trip_with_if_match_on_put_and_patch(self):
|
|
78
|
+
fake = FakeTransport([(200, {"id": 7}, {"etag": 'W/"abc"'}), (200, {"id": 7}, {}), (200, {"id": 7}, {})])
|
|
79
|
+
flex = FlexClient("flx_live_test", transport=fake)
|
|
80
|
+
flex.get("/api/v1/customers/7")
|
|
81
|
+
etag = flex.last_response_headers["etag"]
|
|
82
|
+
flex.put("/api/v1/customers/7", {"name": "x"}, if_match=etag)
|
|
83
|
+
flex.patch("/api/v1/items/7", {"name": "x"}, if_match=etag)
|
|
84
|
+
self.assertEqual('W/"abc"', fake.calls[1]["headers"]["If-Match"])
|
|
85
|
+
self.assertEqual('W/"abc"', fake.calls[2]["headers"]["If-Match"])
|
|
86
|
+
|
|
87
|
+
def test_version_matches_pyproject_and_user_agent(self):
|
|
88
|
+
from flex_api import __version__
|
|
89
|
+
with open(os.path.join(os.path.dirname(__file__), "..", "pyproject.toml"), encoding="utf-8") as f:
|
|
90
|
+
self.assertIn(f'version = "{__version__}"', f.read())
|
|
91
|
+
fake = FakeTransport([(200, {}, {})])
|
|
92
|
+
FlexClient("k", transport=fake).get("/api/v1/items")
|
|
93
|
+
self.assertEqual(f"flex-api-python/{__version__}", fake.calls[0]["headers"]["User-Agent"])
|
|
94
|
+
|
|
95
|
+
def test_retries_stop_after_max_retries(self):
|
|
96
|
+
fake = FakeTransport([(429, None, {"retry-after": "0"})] * 2)
|
|
97
|
+
flex = FlexClient("flx_live_test", max_retries=1, transport=fake)
|
|
98
|
+
with self.assertRaises(FlexApiError) as caught:
|
|
99
|
+
flex.get("/api/v1/items")
|
|
100
|
+
self.assertEqual(429, caught.exception.status)
|
|
101
|
+
self.assertEqual(2, len(fake.calls))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
unittest.main()
|