test-data-service 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,229 @@
1
+ Metadata-Version: 2.4
2
+ Name: test-data-service
3
+ Version: 0.1.0
4
+ Summary: A disk-backed test data service with a REST API and web interface
5
+ Author-email: Roy de Kleijn <dekleijn.roy@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/testsmith-io/test-data-service
8
+ Project-URL: Documentation, https://github.com/testsmith-io/test-data-service#readme
9
+ Project-URL: Repository, https://github.com/testsmith-io/test-data-service
10
+ Project-URL: Issues, https://github.com/testsmith-io/test-data-service/issues
11
+ Keywords: test-data,rest,api,fastapi,key-value,testing,mock-data
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Classifier: Framework :: FastAPI
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: fastapi>=0.110
26
+ Requires-Dist: uvicorn[standard]>=0.27
27
+ Requires-Dist: jinja2>=3.1
28
+ Requires-Dist: pydantic>=2.0
29
+ Requires-Dist: pyyaml>=6.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=7.4; extra == "dev"
32
+ Requires-Dist: httpx>=0.27; extra == "dev"
33
+ Requires-Dist: black>=23.0; extra == "dev"
34
+ Requires-Dist: flake8>=6.0; extra == "dev"
35
+ Requires-Dist: mypy>=1.0; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+
39
+
40
+ ## Test Data Service
41
+
42
+ A working prototype of the above. Data is stored as JSON files on disk
43
+ (one file per namespace), exposed through a REST API and a web interface,
44
+ with optional role-based access control.
45
+
46
+ ### Install & run
47
+
48
+ ```bash
49
+ # from PyPI (once published)
50
+ pip install test-data-service
51
+
52
+ # start the server (API + web UI) on http://127.0.0.1:8000
53
+ tds serve # or: python -m testdataservice serve
54
+ # open http://127.0.0.1:8000 in a browser
55
+ ```
56
+
57
+ From source, for development:
58
+
59
+ ```bash
60
+ python3 -m venv .venv
61
+ .venv/bin/pip install -e ".[dev]"
62
+ .venv/bin/tds serve
63
+ ```
64
+
65
+ Packaging/release details (build, `twine`, GitHub Actions publish-on-release)
66
+ are in [CONTRIBUTING.md](CONTRIBUTING.md).
67
+
68
+ #### Configure with a YAML file
69
+
70
+ Drop a `tds.yml` (or `tds.yaml`) in your project root and just run `tds serve` —
71
+ no flags needed. Environment variables override the file.
72
+
73
+ ```yaml
74
+ # tds.yml
75
+ data_dir: ./data # where namespace files + .auth live
76
+ auth: true # turn on accounts / access control
77
+ host: 127.0.0.1
78
+ port: 8000
79
+ ```
80
+
81
+ ### Data model
82
+
83
+ Each entry is a `key` / `value` pair inside a **namespace**. A value is one
84
+ of four types: `string`, `boolean`, `number`, or `array`. The type is stored
85
+ alongside the value and validated on write.
86
+
87
+ ### REST API
88
+
89
+ `POST` targets the namespace (collection) with the key **in the body**; `PUT`
90
+ targets a specific entry URI. They are not interchangeable — `POST` only creates,
91
+ `PUT` only updates.
92
+
93
+ | Method | Path | Permission | Description |
94
+ |----------|-------------------------|------------|--------------------------------------|
95
+ | `GET` | `/api/health` | — | Health + auth status |
96
+ | `GET` | `/api/whoami` | — | Caller's access summary |
97
+ | `GET` | `/api/namespaces` | any key | List accessible namespaces |
98
+ | `GET` | `/api/{ns}` | read `ns` | List entries in a namespace |
99
+ | `POST` | `/api/{ns}` | write `ns` | Create an entry (`409` if key exists) |
100
+ | `GET` | `/api/{ns}/{key}` | read `ns` | Get one entry |
101
+ | `PUT` | `/api/{ns}/{key}` | write `ns` | Update an entry (`404` if missing) |
102
+ | `DELETE` | `/api/{ns}/{key}` | write `ns` | Delete an entry |
103
+ | `DELETE` | `/api/{ns}` | write `ns` | Delete a namespace |
104
+
105
+ ```bash
106
+ # create — POST to the namespace, key in the body
107
+ curl -X POST localhost:8000/api/demo \
108
+ -H 'Content-Type: application/json' \
109
+ -d '{"key":"greeting","value":"hello","type":"string"}'
110
+
111
+ # update — PUT to the entry's URI
112
+ curl -X PUT localhost:8000/api/demo/greeting \
113
+ -H 'Content-Type: application/json' \
114
+ -d '{"value":"hi there","type":"string"}'
115
+
116
+ curl localhost:8000/api/demo # list entries
117
+ ```
118
+
119
+ (The names `namespaces`, `health`, and `whoami` are reserved and can't be used
120
+ as namespaces, since they'd collide with the routes above.)
121
+
122
+ The API is self-documenting via OpenAPI. Interactive docs are available at:
123
+
124
+ - **`/docs`** — Swagger UI (also linked as **API docs** in the web interface header)
125
+ - **`/redoc`** — ReDoc
126
+ - **`/openapi.json`** — raw OpenAPI schema
127
+
128
+ When auth is enabled, each operation in Swagger exposes an `X-API-Key` header
129
+ field you can fill in to try requests.
130
+
131
+ ### CSV import / export (web UI only)
132
+
133
+ The web interface has **Download CSV** / **Import CSV** buttons on each
134
+ namespace. These are a convenience of the web UI only — they are built on the
135
+ regular entry API (export reads the entries; import creates/updates each row),
136
+ so there are no dedicated CSV endpoints. The CSV has three columns —
137
+ `key`, `type`, `value`:
138
+
139
+ ```csv
140
+ key,type,value
141
+ name,string,alice
142
+ count,number,7
143
+ tags,array,"[""x"", ""y""]"
144
+ ```
145
+
146
+ On import the `type` column is optional — if omitted, each value's type is
147
+ inferred from its text.
148
+
149
+ ### Access control (accounts + API keys)
150
+
151
+ Off by default. **Turn it on** with `auth: true` in `tds.yml`, `--auth`, or
152
+ `TDS_AUTH_ENABLED=1`. There are two credential types, and you can use both:
153
+
154
+ * **User accounts** — username + password. Humans log into the **web UI**
155
+ (session cookie). Passwords are hashed with scrypt (a slow, salted KDF); the
156
+ plaintext is never stored.
157
+ * **API keys** — long random tokens for scripts/CI. Only a SHA-256 hash is
158
+ stored; the token is shown **once**, at creation.
159
+
160
+ Access is **scoped to each namespace**. A role of `admin` means full CRUD on
161
+ that namespace; `reader` means read-only. A **global admin** manages everything.
162
+
163
+ #### First run
164
+
165
+ On first start with auth on, a default admin account is created:
166
+ **username `admin`, password `admin`** — and the web UI **requires you to set a
167
+ new password** before anything else.
168
+
169
+ #### What the admin does (from the web UI)
170
+
171
+ 1. **Create namespaces** (the admin-only form in the sidebar).
172
+ 2. **Create user accounts** scoped to namespaces — e.g. `alice` with
173
+ `projects=admin` (full CRUD on `projects`). Users then log in and
174
+ create/read/update/delete data in their namespaces, from the web UI **and**
175
+ the API.
176
+
177
+ #### Using the API as a user
178
+
179
+ Each signed-in user can mint a personal **API key** (web UI → *My API keys*, or
180
+ `POST /api/me/keys`) that inherits their namespace permissions:
181
+
182
+ ```bash
183
+ # the token is returned once; then use it as a header
184
+ curl -X POST localhost:8000/api/projects \
185
+ -H "X-API-Key: $TOKEN" -H "Content-Type: application/json" \
186
+ -d '{"key":"env","value":"staging","type":"string"}'
187
+ ```
188
+
189
+ #### Auth & admin endpoints (summary)
190
+
191
+ | Method | Path | Who | Purpose |
192
+ |----------|-------------------------|--------|--------------------------------------|
193
+ | `POST` | `/api/login` | anyone | Log in (sets session cookie) |
194
+ | `POST` | `/api/logout` | anyone | Clear session |
195
+ | `POST` | `/api/password` | user | Change your own password |
196
+ | `POST` | `/api/namespaces` | admin | Create a namespace |
197
+ | `GET`/`POST`/`DELETE` | `/api/accounts[/{user}]` | admin | Manage user accounts |
198
+ | `GET`/`POST`/`DELETE` | `/api/me/keys[/{id}]` | user | Manage your own API keys |
199
+ | `GET`/`POST`/`PUT`/`DELETE` | `/api/keys[/{id}]` | admin | Manage standalone (machine) keys |
200
+
201
+ Standalone machine-to-machine keys (not tied to an account) are still managed by
202
+ an admin at `/api/keys`. For bootstrapping you may seed keys via `TDS_API_KEYS`
203
+ or an admin token via `TDS_ADMIN_KEY` (both hashed in memory, never written to
204
+ disk).
205
+
206
+ ### Configuration
207
+
208
+ Settings come from `tds.yml`/`tds.yaml` in the project root, overridden by
209
+ environment variables:
210
+
211
+ | YAML key | Env var | Default | Purpose |
212
+ |------------|--------------------|--------------|------------------------------------------|
213
+ | `data_dir` | `TDS_DATA_DIR` | `./tds-data` | Namespace files + `.auth/` live here |
214
+ | `auth` | `TDS_AUTH_ENABLED` | `false` | Turn access control on/off |
215
+ | `host` | — | `127.0.0.1` | Bind host |
216
+ | `port` | — | `8000` | Bind port |
217
+ | `secret` | `TDS_SECRET` | *(generated)*| Signs web-UI session cookies |
218
+ | `admin_key`| `TDS_ADMIN_KEY` | *(empty)* | Bootstrap machine admin token |
219
+ | `api_keys` | `TDS_API_KEYS` | *(empty)* | Seed machine keys + grants |
220
+ | — | `TDS_KEY_PEPPER` | *(empty)* | Optional pepper for API-key hashing |
221
+
222
+ Credentials live under `<data_dir>/.auth/` (`accounts.json`, `keys.json`,
223
+ `secret`) — all hashed/secret material, never plaintext passwords or tokens.
224
+
225
+ ### Tests
226
+
227
+ ```bash
228
+ .venv/bin/python -m pytest
229
+ ```
@@ -0,0 +1,21 @@
1
+ test_data_service-0.1.0.dist-info/licenses/LICENSE,sha256=ze0jaKIkC6jEhhTGJVgr0n_Ids0q_1Twp4ROzWDXvzs,1070
2
+ testdataservice/__init__.py,sha256=cworwjgnLOi2wa5SXheaGiCEEkHZQ5SiTiOmOLGRy9o,518
3
+ testdataservice/__main__.py,sha256=_8zFIS696CBDsh-PA0SvttOtN85LzS7h6O18ainmWM0,299
4
+ testdataservice/accounts.py,sha256=hC8vugO42NkbIZczXh8XzlEwoSZe60Oz9Ba5dnSPa0E,5467
5
+ testdataservice/app.py,sha256=Vt11DZMUZH38oyNeoLIl5TCTBtld3VYq_cu9U1llcSI,20702
6
+ testdataservice/auth.py,sha256=-_u-UJz-EFXwrP8OIDo44MTvxieaqvnK4JDP5YwfeSM,4902
7
+ testdataservice/cli.py,sha256=hQcBjVtk_HRNMu_Fbs9NfBtcHTJesbR-BQMm9KhOCzg,2357
8
+ testdataservice/config.py,sha256=QaLJua6Tj1SWnZXPEfA48vX0P8Gfv5pu96_u97e3M50,6214
9
+ testdataservice/keystore.py,sha256=r4J1guP-jDGOIjUzTR_F0AutlXQ3ktgsZmZYqGaCI8E,5055
10
+ testdataservice/passwords.py,sha256=jlGZ8Qd3WA9g-2sj-vWsveQ0ojaSO3qpOPP4JGNkBec,1242
11
+ testdataservice/sessions.py,sha256=VnJwx8vdB6lwlPmjtpnoSwTaarb63VZfyij0ginqASY,1437
12
+ testdataservice/storage.py,sha256=irhOCUaG1nulmJkg6mSZh4XHOmh6NcnI7G_K_bLsZU0,6870
13
+ testdataservice/types.py,sha256=NDrC9_KO-AV5UgkG9HHsv37V12ayNwHveQtjaVR_8x8,1807
14
+ testdataservice/web/static/app.js,sha256=CPoH39Xkeba4yMOYNE2ZOoJoe2JV10QGZ-9Xm7sTm9w,17788
15
+ testdataservice/web/static/style.css,sha256=qMRq6P63lruVWkHUI02jguvRgsqRTUoz0zLWTCu8EaM,4858
16
+ testdataservice/web/templates/index.html,sha256=J90JnhOsr-1cO8cUgoGvjfHcgp1VqoQNJzK8ErnwWv0,6316
17
+ test_data_service-0.1.0.dist-info/METADATA,sha256=uFq2cNKKOWb4_hqj_3bB76rlKFV1SyswokMaFybo9pY,9465
18
+ test_data_service-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
19
+ test_data_service-0.1.0.dist-info/entry_points.txt,sha256=cCLyBTnqx7W16VnY6WxK9KMH4MQvV_bA9kwetZcaqQw,49
20
+ test_data_service-0.1.0.dist-info/top_level.txt,sha256=4OmyXKWIXnV8bjOiILXdsYTOrcLKLjd3o6wYa1ZUlqk,16
21
+ test_data_service-0.1.0.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,2 @@
1
+ [console_scripts]
2
+ tds = testdataservice.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roy de Kleijn
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.
@@ -0,0 +1 @@
1
+ testdataservice
@@ -0,0 +1,26 @@
1
+ """Test Data Service — a disk-backed key/value store with an API and web UI."""
2
+
3
+ from .app import create_app
4
+ from .config import Config
5
+ from .storage import (
6
+ Storage,
7
+ Entry,
8
+ KeyNotFoundError,
9
+ KeyExistsError,
10
+ InvalidNamespaceError,
11
+ InvalidKeyError,
12
+ )
13
+ from .types import ValueType
14
+
15
+ __version__ = "0.1.0"
16
+ __all__ = [
17
+ "create_app",
18
+ "Config",
19
+ "Storage",
20
+ "Entry",
21
+ "KeyNotFoundError",
22
+ "KeyExistsError",
23
+ "InvalidNamespaceError",
24
+ "InvalidKeyError",
25
+ "ValueType",
26
+ ]
@@ -0,0 +1,10 @@
1
+ """Module entry point: ``python -m testdataservice``.
2
+
3
+ Equivalent to the ``tds`` console script, but launched through the interpreter
4
+ directly — handy where the generated script shim isn't on PATH.
5
+ """
6
+
7
+ from testdataservice.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ raise SystemExit(main())
@@ -0,0 +1,168 @@
1
+ """Persistent user accounts (admin + users). Passwords are hashed, never stored
2
+ in plaintext (see :mod:`testdataservice.passwords`).
3
+
4
+ An account has per-namespace grants ({namespace: role}) exactly like an API key,
5
+ so a user's API keys can inherit the account's permissions. ``is_admin`` marks a
6
+ global admin who can manage namespaces, accounts, and keys.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import tempfile
14
+ import threading
15
+ from dataclasses import dataclass, asdict, field
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ from .passwords import hash_password, verify_password
20
+
21
+ DEFAULT_ADMIN_USER = "admin"
22
+ DEFAULT_ADMIN_PASSWORD = "admin"
23
+
24
+
25
+ def _now() -> str:
26
+ return datetime.now(timezone.utc).isoformat()
27
+
28
+
29
+ @dataclass
30
+ class Account:
31
+ username: str
32
+ password_hash: str
33
+ is_admin: bool = False
34
+ grants: dict[str, str] = field(default_factory=dict) # namespace -> role
35
+ must_change_password: bool = False
36
+ created_at: str = ""
37
+
38
+ def public(self) -> dict:
39
+ """Representation safe to return over the API (no password hash)."""
40
+ return {
41
+ "username": self.username,
42
+ "is_admin": self.is_admin,
43
+ "grants": self.grants,
44
+ "must_change_password": self.must_change_password,
45
+ "created_at": self.created_at,
46
+ }
47
+
48
+
49
+ class UsernameExistsError(ValueError):
50
+ pass
51
+
52
+
53
+ class AccountStore:
54
+ def __init__(self, path: str | os.PathLike[str]):
55
+ self.path = Path(path)
56
+ self.path.parent.mkdir(parents=True, exist_ok=True)
57
+ self._lock = threading.RLock()
58
+
59
+ # -- file helpers --------------------------------------------------
60
+ def _load(self) -> dict[str, dict]:
61
+ if not self.path.exists():
62
+ return {}
63
+ with self.path.open("r", encoding="utf-8") as fh:
64
+ return json.load(fh)
65
+
66
+ def _dump(self, data: dict[str, dict]) -> None:
67
+ fd, tmp = tempfile.mkstemp(dir=self.path.parent, suffix=".tmp")
68
+ try:
69
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
70
+ json.dump(data, fh, indent=2, sort_keys=True)
71
+ os.replace(tmp, self.path)
72
+ except BaseException:
73
+ Path(tmp).unlink(missing_ok=True)
74
+ raise
75
+
76
+ # -- public API ----------------------------------------------------
77
+ def list(self) -> list["Account"]:
78
+ with self._lock:
79
+ data = self._load()
80
+ return sorted((Account(**a) for a in data.values()), key=lambda a: a.username)
81
+
82
+ def get(self, username: str) -> "Account | None":
83
+ with self._lock:
84
+ data = self._load()
85
+ rec = data.get(username)
86
+ return Account(**rec) if rec else None
87
+
88
+ def exists(self) -> bool:
89
+ with self._lock:
90
+ return bool(self._load())
91
+
92
+ def create(
93
+ self,
94
+ username: str,
95
+ password: str,
96
+ *,
97
+ is_admin: bool = False,
98
+ grants: dict[str, str] | None = None,
99
+ must_change_password: bool = False,
100
+ ) -> "Account":
101
+ username = username.strip()
102
+ if not username:
103
+ raise ValueError("Username must be non-empty.")
104
+ if not password:
105
+ raise ValueError("Password must be non-empty.")
106
+ with self._lock:
107
+ data = self._load()
108
+ if username in data:
109
+ raise UsernameExistsError(f"Account {username!r} already exists.")
110
+ acct = Account(
111
+ username=username,
112
+ password_hash=hash_password(password),
113
+ is_admin=is_admin,
114
+ grants=dict(grants or {}),
115
+ must_change_password=must_change_password,
116
+ created_at=_now(),
117
+ )
118
+ data[username] = asdict(acct)
119
+ self._dump(data)
120
+ return acct
121
+
122
+ def set_password(self, username: str, new_password: str) -> bool:
123
+ if not new_password:
124
+ raise ValueError("Password must be non-empty.")
125
+ with self._lock:
126
+ data = self._load()
127
+ if username not in data:
128
+ return False
129
+ data[username]["password_hash"] = hash_password(new_password)
130
+ data[username]["must_change_password"] = False
131
+ self._dump(data)
132
+ return True
133
+
134
+ def update_grants(self, username: str, grants: dict[str, str]) -> "Account | None":
135
+ with self._lock:
136
+ data = self._load()
137
+ if username not in data:
138
+ return None
139
+ data[username]["grants"] = dict(grants)
140
+ self._dump(data)
141
+ return Account(**data[username])
142
+
143
+ def delete(self, username: str) -> bool:
144
+ with self._lock:
145
+ data = self._load()
146
+ if username not in data:
147
+ return False
148
+ del data[username]
149
+ self._dump(data)
150
+ return True
151
+
152
+ def verify(self, username: str, password: str) -> "Account | None":
153
+ acct = self.get(username)
154
+ if acct and verify_password(password, acct.password_hash):
155
+ return acct
156
+ return None
157
+
158
+ def ensure_default_admin(self) -> None:
159
+ """Seed the default admin/admin account (must change on first login)."""
160
+ with self._lock:
161
+ if self._load():
162
+ return
163
+ self.create(
164
+ DEFAULT_ADMIN_USER,
165
+ DEFAULT_ADMIN_PASSWORD,
166
+ is_admin=True,
167
+ must_change_password=True,
168
+ )