flex-api 1.0.0b35__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flex_api/__init__.py ADDED
@@ -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,6 @@
1
+ flex_api/__init__.py,sha256=fVSASvVtpwRvEKNizR-n3fnAoGjpHBFCsckWce-zndg,8927
2
+ flex_api-1.0.0b35.dist-info/licenses/LICENSE,sha256=EKxK-PX7qM2cv2bJ74p436Zew25apeQ7ayNPAlUVzCs,2823
3
+ flex_api-1.0.0b35.dist-info/METADATA,sha256=BJPOK4qIN_r_DmsVlbw-jYOzrYUMzb_bfh6MB8buqd8,3498
4
+ flex_api-1.0.0b35.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ flex_api-1.0.0b35.dist-info/top_level.txt,sha256=65rmWjm-LH8azmbaFLhVicMryd9HolAF_K-XWtE-GbU,9
6
+ flex_api-1.0.0b35.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ flex_api