flatland-client 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.
@@ -0,0 +1,126 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ # Exception: the Next.js web app's source lib dir (beat-state, funnel helpers)
20
+ # is real source, NOT a Python build artifact — the `lib/` rule above
21
+ # over-matches it and silently excluded it from the #21 import. Keep tracked.
22
+ !web/app/lib/
23
+ parts/
24
+ sdist/
25
+ var/
26
+ wheels/
27
+ share/python-wheels/
28
+ *.egg-info/
29
+ .installed.cfg
30
+ *.egg
31
+ MANIFEST
32
+
33
+ # PyInstaller
34
+ *.manifest
35
+ *.spec
36
+
37
+ # Installer logs
38
+ pip-log.txt
39
+ pip-delete-this-directory.txt
40
+
41
+ # Unit test / coverage reports
42
+ htmlcov/
43
+ .tox/
44
+ .nox/
45
+ .coverage
46
+ .coverage.*
47
+ .cache
48
+ nosetests.xml
49
+ coverage.xml
50
+ *.cover
51
+ *.py.cover
52
+ .hypothesis/
53
+ .pytest_cache/
54
+ cover/
55
+
56
+ # Translations
57
+ *.mo
58
+ *.pot
59
+
60
+ # Environments
61
+ .env
62
+ .envrc
63
+ .venv
64
+ env/
65
+ venv/
66
+ ENV/
67
+ env.bak/
68
+ venv.bak/
69
+
70
+ # PEP 582
71
+ __pypackages__/
72
+
73
+ # mypy
74
+ .mypy_cache/
75
+ .dmypy.json
76
+ dmypy.json
77
+
78
+ # Pyre type checker
79
+ .pyre/
80
+
81
+ # pytype static type analyzer
82
+ .pytype/
83
+
84
+ # Cython debug symbols
85
+ cython_debug/
86
+
87
+ # Ruff
88
+ .ruff_cache/
89
+
90
+ # PyPI configuration file
91
+ .pypirc
92
+
93
+ # IDE
94
+ .idea/
95
+ .vscode/
96
+ *.swp
97
+ *.swo
98
+ *~
99
+
100
+ # Cursor
101
+ .cursorignore
102
+ .cursorindexingignore
103
+
104
+ # Claude Code local state (agent memory must NOT live in repo — canonical location is
105
+ # flatland-business/agents/memory/<agent>/; see agents/AGENTS.md Memory & Persistence)
106
+ .claude/
107
+
108
+ # Stray agent-memory backstop. If an agent's cwd is inside this subrepo and it
109
+ # resolves a bare `memory/<role>/` path relative to cwd, the write lands here
110
+ # instead of the canonical `flatland-business/agents/memory/<role>/`. Drift
111
+ # incident 2026-05-16 (CAO log) is the precedent. Ignore so any accidental
112
+ # write stays local and never commits. Migrate to canonical and delete the dir.
113
+ /memory/
114
+
115
+ # OS
116
+ .DS_Store
117
+ Thumbs.db
118
+
119
+ # Flatland local data
120
+ *.xlsx
121
+ models/
122
+ exports/
123
+ results.tsv
124
+ run.log
125
+ __pycache__/
126
+ *.pyc
@@ -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.
@@ -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,53 @@
1
+ # flatland-client
2
+
3
+ The durable, dependency-free Python client for the hosted [Flatland](https://flatlandfi.com) engine.
4
+
5
+ **Your model is a file you own.** It lives at `~/.flatland/models/<name>.flatland.json`
6
+ on *your* machine — versioned in your own git, diffable, re-runnable anywhere.
7
+ The hosted server is a pure function (`/api/v2/*`): the client ships the model IR
8
+ in, the server computes/transforms and returns, and **retains nothing at rest**.
9
+
10
+ This package is `pip install`-light: stdlib `urllib` only, no engine, no
11
+ fastapi/uvicorn/stripe. It is the supported durable, metered hosted path for
12
+ Python users.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install flatland-client
18
+ ```
19
+
20
+ ## Quickstart
21
+
22
+ ```python
23
+ from flatland_client import create, load, http_transport
24
+
25
+ t = http_transport(api_key="fl_live_...") # HTTPS to api.flatlandfi.com
26
+
27
+ h = create("acme_plan", t) # writes ~/.flatland/models/acme_plan.flatland.json
28
+ h.bulk_add([
29
+ {"name": "revenue", "type": "Currency", "value": 1000},
30
+ {"name": "cost", "type": "Currency", "value": 400},
31
+ {"name": "profit", "type": "Currency", "formula": "revenue - cost"},
32
+ ])
33
+ print(h.compile()["values"]["profit"]["value"]) # metered compute, nothing stored server-side
34
+
35
+ # --- new session / a new machine that has the file ---
36
+ h = load("acme_plan", t) # durable across restart
37
+ ```
38
+
39
+ ## Durability + safety contract
40
+
41
+ - **Atomic writes.** Every mutation persists the new IR with a tmp + `os.replace`
42
+ rename, so a crash never leaves a half-written model file.
43
+ - **Fail-loud (F2).** A failed mutation (a `5xx`, or a rejected `4xx`) raises and
44
+ **leaves the local file untouched** — a failure is never silently mistaken for
45
+ success.
46
+ - **No server fallback.** `load()` of a missing file raises — the server never
47
+ held your model, so it cannot supply one.
48
+ - **Envelope versioning.** The file is `{ "flatland_ir_version": 1, "ir": {...} }`.
49
+ A file written by an older client keeps loading; a too-new envelope is refused
50
+ before it is persisted.
51
+ - **Path-traversal-safe** model names.
52
+
53
+ Get a key at https://flatlandfi.com/install.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "flatland-client"
7
+ version = "0.1.0"
8
+ description = "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."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "Flatland", email = "info@flatlandfi.com" }]
13
+ keywords = ["flatland", "financial-modeling", "fpa", "decision-modeling", "local-first"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Intended Audience :: Developers",
18
+ "Topic :: Office/Business :: Financial",
19
+ ]
20
+ # Zero third-party dependencies — stdlib urllib only. This is the point: a thin
21
+ # durable client a Python user can `pip install` without pulling the engine.
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://flatlandfi.com"
26
+ Repository = "https://github.com/Flatlandfi/flatland"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/flatland_client"]
@@ -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,115 @@
1
+ """Durability + F2 tests for the standalone pip-packaged flatland_client.
2
+
3
+ Exercises the SAME contract as the in-engine reference, with a fake transport
4
+ (no network, no engine) so the package stays dependency-free and self-testing."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ import urllib.error
10
+ from io import BytesIO
11
+ from pathlib import Path
12
+
13
+ # Make the package importable without an install (src layout).
14
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
15
+
16
+ import pytest # noqa: E402
17
+
18
+ from flatland_client import ( # noqa: E402
19
+ FlatlandMutationError,
20
+ create,
21
+ load,
22
+ )
23
+ from flatland_client.client import PORTABLE_IR_VERSION # noqa: E402
24
+
25
+
26
+ def _env(ir: dict) -> dict:
27
+ return {"flatland_ir_version": PORTABLE_IR_VERSION, "ir": ir}
28
+
29
+
30
+ def fake_transport(fail_path: str | None = None, mode: str = "5xx"):
31
+ def _post(path: str, body: dict) -> dict:
32
+ if path == fail_path:
33
+ if mode == "5xx":
34
+ raise urllib.error.HTTPError(path, 500, "server error", {}, BytesIO(b"oops"))
35
+ if mode == "4xx":
36
+ return {"error": "duplicate_driver", "message": "exists", "http_status": 400}
37
+ if path == "/api/v2/model/create":
38
+ return {"status": "created", "ir": _env({"name": body["name"], "drivers": {}})}
39
+ env = body.get("ir", {})
40
+ ir = dict(env.get("ir", {}))
41
+ drivers = dict(ir.get("drivers", {}))
42
+ if path == "/api/v2/add_driver":
43
+ drivers[body["name"]] = {"value": body["value"]}
44
+ ir["drivers"] = drivers
45
+ return {"status": "added", "ir": _env(ir)}
46
+ if path == "/api/v2/compile":
47
+ return {"values": {k: {"value": 1} for k in drivers}}
48
+ return {}
49
+
50
+ return _post
51
+
52
+
53
+ def test_create_persists_and_loads_across_sessions(tmp_path):
54
+ t = fake_transport()
55
+ h1 = create("m", t, store_dir=tmp_path)
56
+ h1.add_driver(name="revenue", type="Currency", value=1000)
57
+ # New session: fresh handle, load from the local file only.
58
+ h2 = load("m", t, store_dir=tmp_path)
59
+ assert "revenue" in h2.ir["ir"]["drivers"]
60
+
61
+
62
+ def test_5xx_mutation_raises_and_file_untouched(tmp_path):
63
+ t = fake_transport(fail_path="/api/v2/add_driver", mode="5xx")
64
+ h = create("m", t, store_dir=tmp_path)
65
+ before = h.path.read_text()
66
+ with pytest.raises(urllib.error.HTTPError):
67
+ h.add_driver(name="x", type="Count", value=1)
68
+ assert h.path.read_text() == before
69
+
70
+
71
+ def test_4xx_rejection_raises_mutation_error_file_untouched(tmp_path):
72
+ t = fake_transport(fail_path="/api/v2/add_driver", mode="4xx")
73
+ h = create("m", t, store_dir=tmp_path)
74
+ before = h.path.read_text()
75
+ with pytest.raises(FlatlandMutationError):
76
+ h.add_driver(name="x", type="Count", value=1)
77
+ assert h.path.read_text() == before
78
+
79
+
80
+ def test_load_missing_raises_no_server_fallback(tmp_path):
81
+ with pytest.raises(FileNotFoundError):
82
+ load("nope", fake_transport(), store_dir=tmp_path)
83
+
84
+
85
+ def test_path_traversal_name_rejected(tmp_path):
86
+ with pytest.raises(ValueError):
87
+ create("../escape", fake_transport(), store_dir=tmp_path)
88
+
89
+
90
+ def test_reserved_suffix_name_rejected(tmp_path):
91
+ # A name already ending in .flatland.json would double-suffix on create but
92
+ # be treated as a path on load — reject it (Greptile #78).
93
+ with pytest.raises(ValueError):
94
+ create("report.flatland.json", fake_transport(), store_dir=tmp_path)
95
+
96
+
97
+ def test_http_transport_passes_timeout():
98
+ # The timeout must reach urlopen — without it a stalled server hangs forever.
99
+ import urllib.request
100
+ captured = {}
101
+ real_urlopen = urllib.request.urlopen
102
+
103
+ def spy(req, timeout=None): # noqa: ARG001
104
+ captured["timeout"] = timeout
105
+ raise urllib.error.URLError("stop here")
106
+
107
+ urllib.request.urlopen = spy
108
+ try:
109
+ from flatland_client import http_transport
110
+ t = http_transport(api_key="fl_live_x", timeout=12.5)
111
+ with pytest.raises(Exception):
112
+ t("/api/v2/compile", {"ir": {}})
113
+ finally:
114
+ urllib.request.urlopen = real_urlopen
115
+ assert captured["timeout"] == 12.5