flatland-client 0.1.0__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.
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""flatland-client — the durable, dependency-free Python client for hosted Flatland.
|
|
2
|
+
|
|
3
|
+
Option C: your model is a file YOU own (``~/.flatland/models/<name>.flatland.json``),
|
|
4
|
+
versioned in your own git, diffable, re-runnable anywhere. The hosted server is a
|
|
5
|
+
pure function (``/api/v2/*``): the client ships the IR in, the server
|
|
6
|
+
computes/transforms and returns, and retains NOTHING at rest.
|
|
7
|
+
|
|
8
|
+
This is the SAME durability mechanism as the in-engine reference
|
|
9
|
+
``flatland.client`` (kept byte-for-byte in semantics), repackaged as a thin,
|
|
10
|
+
stdlib-only install so a Python user gets the durable metered path WITHOUT
|
|
11
|
+
pulling in the full engine (fastapi/uvicorn/stripe/...). It talks to the public
|
|
12
|
+
hosted ``/api/v2`` surface over HTTPS with the user's ``X-API-Key``; auth +
|
|
13
|
+
metering + the server-derived tenant key all apply.
|
|
14
|
+
|
|
15
|
+
Quickstart::
|
|
16
|
+
|
|
17
|
+
from flatland_client import create, load, http_transport
|
|
18
|
+
t = http_transport(api_key="fl_live_...")
|
|
19
|
+
h = create("acme_plan", t) # writes ~/.flatland/models/acme_plan.flatland.json
|
|
20
|
+
h.bulk_add([{"name": "revenue", "type": "Currency", "value": 1000}])
|
|
21
|
+
print(h.compile()["values"]) # metered compute, nothing stored server-side
|
|
22
|
+
# --- new session / new machine that has the file ---
|
|
23
|
+
h = load("acme_plan", t) # durable across restart
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from .client import (
|
|
27
|
+
DEFAULT_CLIENT_STORE_DIR,
|
|
28
|
+
PORTABLE_IR_VERSION,
|
|
29
|
+
FlatlandModelFile,
|
|
30
|
+
FlatlandMutationError,
|
|
31
|
+
Transport,
|
|
32
|
+
create,
|
|
33
|
+
http_transport,
|
|
34
|
+
load,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"Transport",
|
|
41
|
+
"http_transport",
|
|
42
|
+
"FlatlandModelFile",
|
|
43
|
+
"FlatlandMutationError",
|
|
44
|
+
"create",
|
|
45
|
+
"load",
|
|
46
|
+
"DEFAULT_CLIENT_STORE_DIR",
|
|
47
|
+
"PORTABLE_IR_VERSION",
|
|
48
|
+
"__version__",
|
|
49
|
+
]
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Flatland thin client — the CLIENT-SIDE half of Option C (durability mechanism).
|
|
2
|
+
|
|
3
|
+
This is the load-bearing durability contract: **the customer's model is a file
|
|
4
|
+
THE CLIENT owns and persists locally, and it survives across sessions and process
|
|
5
|
+
restarts because it lives on the client's disk — never the server's.** The hosted
|
|
6
|
+
server is a pure function (``/api/v2/*``): the client ships the IR in, the server
|
|
7
|
+
computes/transforms and returns, and retains nothing.
|
|
8
|
+
|
|
9
|
+
This is the dependency-free, pip-packaged twin of the in-engine reference
|
|
10
|
+
``flatland.client``. The semantics are identical; the only thing dropped is the
|
|
11
|
+
engine-coupled ``in_process_transport`` (a test/dev convenience that needs the
|
|
12
|
+
full engine) — a thin client install has nothing to call it against. Use
|
|
13
|
+
``http_transport`` against the hosted ``/api/v2`` surface.
|
|
14
|
+
|
|
15
|
+
Where the file lives (the durability mechanism, spelled out):
|
|
16
|
+
* Default: ``~/.flatland/models/<name>.flatland.json`` ON THE CLIENT'S machine.
|
|
17
|
+
* Override: any path the caller chooses (a project dir, a git working tree).
|
|
18
|
+
The file is a plain JSON envelope (``PORTABLE_IR_VERSION`` + the IR). It is
|
|
19
|
+
exactly what a customer commits to their own git — versioned, diffable,
|
|
20
|
+
re-runnable anywhere. The server is never the system of record.
|
|
21
|
+
|
|
22
|
+
How it survives a restart: a mutation call (``add_driver`` / ``bulk_add`` / ...)
|
|
23
|
+
returns the NEW IR; the client writes it to the local file IMMEDIATELY (atomic
|
|
24
|
+
tmp+replace). A fresh process (new session, after a reboot, on another laptop
|
|
25
|
+
that has the file) calls :meth:`load` and continues exactly where it left off.
|
|
26
|
+
|
|
27
|
+
F2 (data-safety): a failed or 5xx mutation MUST raise and leave the local file
|
|
28
|
+
UNTOUCHED — never a silent stale-file "success".
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Protocol
|
|
37
|
+
|
|
38
|
+
#: Wire-format version for the portable IR envelope. Kept in lockstep with the
|
|
39
|
+
#: server's ``portable.PORTABLE_IR_VERSION``. A client file tagged with an older
|
|
40
|
+
#: version must keep loading — the client-durability contract.
|
|
41
|
+
PORTABLE_IR_VERSION = 1
|
|
42
|
+
|
|
43
|
+
DEFAULT_CLIENT_STORE_DIR = Path.home() / ".flatland" / "models"
|
|
44
|
+
_FILE_SUFFIX = ".flatland.json"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FlatlandMutationError(RuntimeError):
|
|
48
|
+
"""A mutation call did not produce an updated model IR (F2).
|
|
49
|
+
|
|
50
|
+
Raised when the server rejected the mutation (an error response) or returned
|
|
51
|
+
no new IR. The client is the system-of-record, so a failed mutation must be
|
|
52
|
+
loud — the local file is left untouched and the caller cannot mistake the
|
|
53
|
+
failure for success. ``response`` carries the structured error body (if any)
|
|
54
|
+
for inspection."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, message: str, *, response: dict[str, Any] | None = None):
|
|
57
|
+
super().__init__(message)
|
|
58
|
+
self.response = response
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Transport(Protocol):
|
|
62
|
+
"""A function that POSTs a JSON body to a ``/api/v2/*`` path and returns the
|
|
63
|
+
decoded JSON response. The concrete HTTP implementation lives in
|
|
64
|
+
:func:`http_transport`."""
|
|
65
|
+
|
|
66
|
+
def __call__(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
67
|
+
"""POST ``body`` to ``path`` and return the decoded JSON response."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def http_transport(
|
|
71
|
+
api_key: str,
|
|
72
|
+
base_url: str = "https://api.flatlandfi.com",
|
|
73
|
+
timeout: float = 60.0,
|
|
74
|
+
) -> Transport:
|
|
75
|
+
"""Build an HTTPS transport for the hosted per-call surface.
|
|
76
|
+
|
|
77
|
+
Uses urllib (stdlib) so the client carries no third-party dependency. The
|
|
78
|
+
API key goes in the server-validated ``X-API-Key`` header — the tenant key
|
|
79
|
+
is derived server-side (sha256), never sent by the client.
|
|
80
|
+
|
|
81
|
+
``timeout`` (seconds) bounds every request: without it a stalled connection
|
|
82
|
+
or a server that stops sending mid-response would block create/mutations/
|
|
83
|
+
compute FOREVER, and the caller would never receive the fail-loud error the
|
|
84
|
+
durable contract promises (Greptile #78). On timeout urllib raises (a
|
|
85
|
+
``URLError`` wrapping ``socket.timeout``), which propagates exactly like a
|
|
86
|
+
5xx/network failure — the local file is left untouched."""
|
|
87
|
+
import urllib.error
|
|
88
|
+
import urllib.request
|
|
89
|
+
|
|
90
|
+
def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
91
|
+
url = base_url.rstrip("/") + path
|
|
92
|
+
data = json.dumps(body).encode("utf-8")
|
|
93
|
+
req = urllib.request.Request(
|
|
94
|
+
url,
|
|
95
|
+
data=data,
|
|
96
|
+
headers={"Content-Type": "application/json", "X-API-Key": api_key},
|
|
97
|
+
method="POST",
|
|
98
|
+
)
|
|
99
|
+
try:
|
|
100
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 — https hosted API
|
|
101
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
102
|
+
except urllib.error.HTTPError as exc:
|
|
103
|
+
# EXPECTED 4xx (400 bad IR, 402 billing-gate, 413 too-large) carry a
|
|
104
|
+
# STRUCTURED JSON body — decode it and tag the status so the caller
|
|
105
|
+
# can branch (e.g. 402 = quota). A rejected mutation must be
|
|
106
|
+
# distinguishable from a network failure.
|
|
107
|
+
#
|
|
108
|
+
# A 5xx is DIFFERENT and MUST RAISE (F2). The client is the
|
|
109
|
+
# system-of-record: a 5xx means the server failed to process the
|
|
110
|
+
# mutation, so there is NO trustworthy new IR. Returning a decoded
|
|
111
|
+
# 5xx body would let ``_apply`` treat a failed mutation as success
|
|
112
|
+
# (no ``ir`` key → keeps the stale local file) — silent data loss.
|
|
113
|
+
if exc.code >= 500:
|
|
114
|
+
raise
|
|
115
|
+
raw = exc.read()
|
|
116
|
+
try:
|
|
117
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
118
|
+
except (ValueError, UnicodeDecodeError):
|
|
119
|
+
raise
|
|
120
|
+
if isinstance(payload, dict):
|
|
121
|
+
payload.setdefault("http_status", exc.code)
|
|
122
|
+
return payload
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
return _post
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _check_envelope_version(envelope: dict[str, Any], where: str) -> None:
|
|
129
|
+
"""Reject an envelope this client is too old to handle, BEFORE persisting it.
|
|
130
|
+
|
|
131
|
+
Mixed-version safety: if the server returns a newer envelope than this client
|
|
132
|
+
supports, persisting it would write a file the same client cannot ``load()``
|
|
133
|
+
next session. Fail at receipt instead, with a clear upgrade message."""
|
|
134
|
+
version = envelope.get("flatland_ir_version", PORTABLE_IR_VERSION)
|
|
135
|
+
if not isinstance(version, int) or version > PORTABLE_IR_VERSION:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"{where}: server returned flatland_ir_version {version}; this "
|
|
138
|
+
f"client supports up to {PORTABLE_IR_VERSION}. Upgrade flatland-client "
|
|
139
|
+
f"before persisting this model file."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class FlatlandModelFile:
|
|
144
|
+
"""A client-owned model file + the pure-function calls that operate on it.
|
|
145
|
+
|
|
146
|
+
Hold one of these per model. Compute calls ship the local IR and return
|
|
147
|
+
results without changing the file. Mutation calls ship the local IR, receive
|
|
148
|
+
the new IR, and PERSIST it locally so the change survives the next restart.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self, path: Path | str, transport: Transport, ir: dict[str, Any]):
|
|
152
|
+
self.path = Path(path)
|
|
153
|
+
self._transport = transport
|
|
154
|
+
self._ir = ir # the portable envelope {flatland_ir_version, ir:{...}}
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def ir(self) -> dict[str, Any]:
|
|
158
|
+
"""The current in-memory IR envelope (mirror of the on-disk file)."""
|
|
159
|
+
return self._ir
|
|
160
|
+
|
|
161
|
+
def save(self) -> Path:
|
|
162
|
+
"""Atomically write the current IR to the local file (tmp + replace), so
|
|
163
|
+
a restart loses nothing. Returns the file path."""
|
|
164
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
tmp = self.path.with_suffix(self.path.suffix + f".tmp.{os.getpid()}")
|
|
166
|
+
tmp.write_text(json.dumps(self._ir, indent=2))
|
|
167
|
+
# 0600: the model is the user's sensitive IP.
|
|
168
|
+
try:
|
|
169
|
+
os.chmod(tmp, 0o600)
|
|
170
|
+
except OSError:
|
|
171
|
+
# Best-effort: some filesystems (e.g. Windows/FAT) don't support
|
|
172
|
+
# POSIX modes. Persistence must still proceed for durability, so we
|
|
173
|
+
# swallow the chmod failure rather than abort the save.
|
|
174
|
+
pass
|
|
175
|
+
os.replace(tmp, self.path)
|
|
176
|
+
return self.path
|
|
177
|
+
|
|
178
|
+
def _apply(self, response: dict[str, Any]) -> dict[str, Any]:
|
|
179
|
+
"""Adopt + persist a successful mutation's new IR locally; raise on a
|
|
180
|
+
failed mutation so it is never silently mistaken for success (F2)."""
|
|
181
|
+
if isinstance(response, dict) and isinstance(response.get("ir"), dict):
|
|
182
|
+
_check_envelope_version(response["ir"], "mutation")
|
|
183
|
+
self._ir = response["ir"]
|
|
184
|
+
self.save()
|
|
185
|
+
return {k: v for k, v in response.items() if k != "ir"}
|
|
186
|
+
if isinstance(response, dict) and response.get("error"):
|
|
187
|
+
raise FlatlandMutationError(
|
|
188
|
+
response.get("message") or str(response.get("error")),
|
|
189
|
+
response=response,
|
|
190
|
+
)
|
|
191
|
+
raise FlatlandMutationError(
|
|
192
|
+
"Mutation returned no updated model IR; the local file was not "
|
|
193
|
+
"changed.",
|
|
194
|
+
response=response if isinstance(response, dict) else None,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# -- compute (pure; file unchanged) -------------------------------------
|
|
198
|
+
|
|
199
|
+
def compile(self, scenario: str = "base") -> dict[str, Any]:
|
|
200
|
+
return self._transport("/api/v2/compile", {"ir": self._ir, "scenario": scenario})
|
|
201
|
+
|
|
202
|
+
def sensitivity(self, target: str, perturbation: float = 0.10, scenario: str = "base") -> dict[str, Any]:
|
|
203
|
+
return self._transport("/api/v2/sensitivity", {
|
|
204
|
+
"ir": self._ir, "target": target, "perturbation": perturbation, "scenario": scenario,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
def diff(self, from_scenario: str, to_scenario: str, target: str | None = None) -> dict[str, Any]:
|
|
208
|
+
return self._transport("/api/v2/diff", {
|
|
209
|
+
"ir": self._ir, "from_scenario": from_scenario, "to_scenario": to_scenario, "target": target,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
def explain(self, driver: str, max_depth: int = 5) -> dict[str, Any]:
|
|
213
|
+
return self._transport("/api/v2/explain", {
|
|
214
|
+
"ir": self._ir, "driver": driver, "max_depth": max_depth,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
def impact_preview(self, proposed_changes: list[dict[str, Any]], target_drivers: list[str] | None = None) -> dict[str, Any]:
|
|
218
|
+
return self._transport("/api/v2/impact_preview", {
|
|
219
|
+
"ir": self._ir, "proposed_changes": proposed_changes, "target_drivers": target_drivers,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
# -- mutations (ship IR, receive new IR, persist locally) ---------------
|
|
223
|
+
|
|
224
|
+
def bulk_add(self, drivers: list[dict[str, Any]]) -> dict[str, Any]:
|
|
225
|
+
return self._apply(self._transport("/api/v2/bulk_add", {"ir": self._ir, "drivers": drivers}))
|
|
226
|
+
|
|
227
|
+
def add_driver(self, **kwargs: Any) -> dict[str, Any]:
|
|
228
|
+
return self._apply(self._transport("/api/v2/add_driver", {"ir": self._ir, **kwargs}))
|
|
229
|
+
|
|
230
|
+
def add_computed(self, **kwargs: Any) -> dict[str, Any]:
|
|
231
|
+
return self._apply(self._transport("/api/v2/add_computed", {"ir": self._ir, **kwargs}))
|
|
232
|
+
|
|
233
|
+
def update_driver(self, **kwargs: Any) -> dict[str, Any]:
|
|
234
|
+
return self._apply(self._transport("/api/v2/update_driver", {"ir": self._ir, **kwargs}))
|
|
235
|
+
|
|
236
|
+
def remove_driver(self, name: str, cascade: bool = False) -> dict[str, Any]:
|
|
237
|
+
return self._apply(self._transport("/api/v2/remove_driver", {
|
|
238
|
+
"ir": self._ir, "name": name, "cascade": cascade,
|
|
239
|
+
}))
|
|
240
|
+
|
|
241
|
+
def create_scenario(self, name: str, overrides: dict[str, Any], description: str = "") -> dict[str, Any]:
|
|
242
|
+
return self._apply(self._transport("/api/v2/create_scenario", {
|
|
243
|
+
"ir": self._ir, "name": name, "overrides": overrides, "description": description,
|
|
244
|
+
}))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _assert_safe_name(name: str) -> str:
|
|
248
|
+
"""Validate a bare model name as a safe single path segment (path-traversal
|
|
249
|
+
defense — the name is untrusted). A dotted name ("acme.v2") is fine; a
|
|
250
|
+
separator, ``..``, leading dot, or NUL is not."""
|
|
251
|
+
if not isinstance(name, str) or not name.strip():
|
|
252
|
+
raise ValueError("Model name must be a non-empty string.")
|
|
253
|
+
n = name.strip()
|
|
254
|
+
if n in (".", ".."):
|
|
255
|
+
raise ValueError(f"Model name {n!r} is not allowed.")
|
|
256
|
+
if "/" in n or "\\" in n or "\0" in n:
|
|
257
|
+
raise ValueError(f"Model name {n!r} may not contain path separators.")
|
|
258
|
+
# Reject a name already ending in the reserved suffix: create() would save
|
|
259
|
+
# ``report.flatland.json`` as ``report.flatland.json.flatland.json`` while
|
|
260
|
+
# load() (which treats a suffixed arg as a path) would resolve a different
|
|
261
|
+
# file — the durable model would appear missing (Greptile #78).
|
|
262
|
+
if n.endswith(_FILE_SUFFIX):
|
|
263
|
+
raise ValueError(
|
|
264
|
+
f"Model name {n!r} may not end with the reserved {_FILE_SUFFIX!r} "
|
|
265
|
+
f"suffix; pass a bare name (the suffix is added for you)."
|
|
266
|
+
)
|
|
267
|
+
if n.startswith("."):
|
|
268
|
+
raise ValueError(f"Model name {n!r} may not start with a dot.")
|
|
269
|
+
return n
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _path_for(name: str, store_dir: Path | str | None) -> Path:
|
|
273
|
+
base = Path(store_dir) if store_dir is not None else DEFAULT_CLIENT_STORE_DIR
|
|
274
|
+
return base / f"{_assert_safe_name(name)}{_FILE_SUFFIX}"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def create(
|
|
278
|
+
name: str,
|
|
279
|
+
transport: Transport,
|
|
280
|
+
*,
|
|
281
|
+
store_dir: Path | str | None = None,
|
|
282
|
+
currency: str = "USD",
|
|
283
|
+
period_start: str = "2026-01",
|
|
284
|
+
period_end: str = "2026-12",
|
|
285
|
+
grain: str = "monthly",
|
|
286
|
+
description: str = "",
|
|
287
|
+
path: Path | str | None = None,
|
|
288
|
+
) -> FlatlandModelFile:
|
|
289
|
+
"""Create a new model on the server (pure call), persist its IR to a LOCAL
|
|
290
|
+
file the client owns, and return a handle. The file is the system of record
|
|
291
|
+
from here on; the server holds nothing."""
|
|
292
|
+
resp = transport("/api/v2/model/create", {
|
|
293
|
+
"name": name, "currency": currency, "period_start": period_start,
|
|
294
|
+
"period_end": period_end, "grain": grain, "description": description,
|
|
295
|
+
})
|
|
296
|
+
ir = resp.get("ir")
|
|
297
|
+
if not isinstance(ir, dict):
|
|
298
|
+
raise FlatlandMutationError(f"create did not return an IR envelope: {resp}", response=resp if isinstance(resp, dict) else None)
|
|
299
|
+
_check_envelope_version(ir, "create")
|
|
300
|
+
file_path = Path(path) if path is not None else _path_for(name, store_dir)
|
|
301
|
+
handle = FlatlandModelFile(file_path, transport, ir)
|
|
302
|
+
handle.save()
|
|
303
|
+
return handle
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def load(
|
|
307
|
+
name_or_path: str | Path,
|
|
308
|
+
transport: Transport,
|
|
309
|
+
*,
|
|
310
|
+
store_dir: Path | str | None = None,
|
|
311
|
+
) -> FlatlandModelFile:
|
|
312
|
+
"""Load a model from the LOCAL file the client owns (durability across
|
|
313
|
+
sessions / restarts). Accepts a model name (resolved under ``store_dir``) or
|
|
314
|
+
a direct path. Raises ``FileNotFoundError`` if the client has no such file —
|
|
315
|
+
there is NO server fallback by design (the server never held it)."""
|
|
316
|
+
p = Path(name_or_path)
|
|
317
|
+
# Decide name-vs-path WITHOUT keying on ``.suffix`` (a valid model NAME may
|
|
318
|
+
# contain a dot). Treat the argument as a path only if it is unambiguously
|
|
319
|
+
# one: absolute, has a directory component, or carries the model-file suffix.
|
|
320
|
+
looks_like_path = (
|
|
321
|
+
p.is_absolute()
|
|
322
|
+
or p.parent != Path(".")
|
|
323
|
+
or str(name_or_path).endswith(_FILE_SUFFIX)
|
|
324
|
+
)
|
|
325
|
+
if not looks_like_path:
|
|
326
|
+
p = _path_for(str(name_or_path), store_dir)
|
|
327
|
+
if not p.exists():
|
|
328
|
+
raise FileNotFoundError(
|
|
329
|
+
f"No local model file at {p}. The client owns the model — there is "
|
|
330
|
+
f"no server copy to fall back to (Option C). Create it with "
|
|
331
|
+
f"flatland_client.create()."
|
|
332
|
+
)
|
|
333
|
+
envelope = json.loads(p.read_text())
|
|
334
|
+
_check_envelope_version(envelope, f"load {p}")
|
|
335
|
+
return FlatlandModelFile(p, transport, envelope)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
__all__ = [
|
|
339
|
+
"Transport",
|
|
340
|
+
"http_transport",
|
|
341
|
+
"FlatlandModelFile",
|
|
342
|
+
"FlatlandMutationError",
|
|
343
|
+
"create",
|
|
344
|
+
"load",
|
|
345
|
+
"DEFAULT_CLIENT_STORE_DIR",
|
|
346
|
+
"PORTABLE_IR_VERSION",
|
|
347
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flatland-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Durable, dependency-free Python client for the hosted Flatland engine. Your model is a file you own; the server is a pure function that holds nothing at rest.
|
|
5
|
+
Project-URL: Homepage, https://flatlandfi.com
|
|
6
|
+
Project-URL: Repository, https://github.com/Flatlandfi/flatland
|
|
7
|
+
Author-email: Flatland <info@flatlandfi.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: decision-modeling,financial-modeling,flatland,fpa,local-first
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# flatland-client
|
|
19
|
+
|
|
20
|
+
The durable, dependency-free Python client for the hosted [Flatland](https://flatlandfi.com) engine.
|
|
21
|
+
|
|
22
|
+
**Your model is a file you own.** It lives at `~/.flatland/models/<name>.flatland.json`
|
|
23
|
+
on *your* machine — versioned in your own git, diffable, re-runnable anywhere.
|
|
24
|
+
The hosted server is a pure function (`/api/v2/*`): the client ships the model IR
|
|
25
|
+
in, the server computes/transforms and returns, and **retains nothing at rest**.
|
|
26
|
+
|
|
27
|
+
This package is `pip install`-light: stdlib `urllib` only, no engine, no
|
|
28
|
+
fastapi/uvicorn/stripe. It is the supported durable, metered hosted path for
|
|
29
|
+
Python users.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install flatland-client
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from flatland_client import create, load, http_transport
|
|
41
|
+
|
|
42
|
+
t = http_transport(api_key="fl_live_...") # HTTPS to api.flatlandfi.com
|
|
43
|
+
|
|
44
|
+
h = create("acme_plan", t) # writes ~/.flatland/models/acme_plan.flatland.json
|
|
45
|
+
h.bulk_add([
|
|
46
|
+
{"name": "revenue", "type": "Currency", "value": 1000},
|
|
47
|
+
{"name": "cost", "type": "Currency", "value": 400},
|
|
48
|
+
{"name": "profit", "type": "Currency", "formula": "revenue - cost"},
|
|
49
|
+
])
|
|
50
|
+
print(h.compile()["values"]["profit"]["value"]) # metered compute, nothing stored server-side
|
|
51
|
+
|
|
52
|
+
# --- new session / a new machine that has the file ---
|
|
53
|
+
h = load("acme_plan", t) # durable across restart
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Durability + safety contract
|
|
57
|
+
|
|
58
|
+
- **Atomic writes.** Every mutation persists the new IR with a tmp + `os.replace`
|
|
59
|
+
rename, so a crash never leaves a half-written model file.
|
|
60
|
+
- **Fail-loud (F2).** A failed mutation (a `5xx`, or a rejected `4xx`) raises and
|
|
61
|
+
**leaves the local file untouched** — a failure is never silently mistaken for
|
|
62
|
+
success.
|
|
63
|
+
- **No server fallback.** `load()` of a missing file raises — the server never
|
|
64
|
+
held your model, so it cannot supply one.
|
|
65
|
+
- **Envelope versioning.** The file is `{ "flatland_ir_version": 1, "ir": {...} }`.
|
|
66
|
+
A file written by an older client keeps loading; a too-new envelope is refused
|
|
67
|
+
before it is persisted.
|
|
68
|
+
- **Path-traversal-safe** model names.
|
|
69
|
+
|
|
70
|
+
Get a key at https://flatlandfi.com/install.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
flatland_client/__init__.py,sha256=IAGobINxVsL8XsflN3g6PTLc0ktaXpLJwkolf8228O0,1748
|
|
2
|
+
flatland_client/client.py,sha256=cOLgy5LOhy61pTOA6Yn1xORadu-wN1os5jq7HW-zOVw,15680
|
|
3
|
+
flatland_client-0.1.0.dist-info/METADATA,sha256=GAVK6o_FrkiztUreD4a6atJ1rt7qS9K4nBeFPCCYsKg,2889
|
|
4
|
+
flatland_client-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
flatland_client-0.1.0.dist-info/licenses/LICENSE,sha256=d9kPorFrFdeE8m9-ulSAVKTHXDkO0c2MZMVtThRaV54,1528
|
|
6
|
+
flatland_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Isaac Lestienne
|
|
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.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Note: This MIT license covers the flatland-agent terminal client only. The
|
|
26
|
+
Flatland engine and API (accessed at https://api.flatlandfi.com) are a
|
|
27
|
+
separate, proprietary service. "Flatland", "Flatland Agent", and the Flatland
|
|
28
|
+
marks are used as product identifiers by Isaac Lestienne; this MIT license does
|
|
29
|
+
not grant rights to the names, branding, or associated service. You bring your
|
|
30
|
+
own LLM provider key; inference is billed to you by your provider.
|