Skip to content

Commit 72eab0c

Browse files
committed
feat: Add typeid explain command for human/machine-readable TypeID introspection and optional schema support
Changes: - Introduced a new CLI command: `typeid explain`, enabling human-readable and machine-readable (JSON) explanations for TypeID values. - Enhanced documentation in README.md with detailed usage, configuration, and design principles for the new explain feature. - Added support for schema-based explanations via JSON (default) and YAML (optional extra dependency). - Described schema discovery rules, including support for both local and user config directory schemas. - Updated `pyproject.toml` and `poetry.lock` to define a new optional extra dependency group: `yaml` (using PyYAML). - Registered the `yaml` extra in [tool.poetry.extras], enabling users to install YAML support as needed. - Added explanation formatting utilities and proper schema loading logic in CLI, robustly handling both errors and absence of schema. - Provided new examples in documentation for using explain with and without schema, and illustrating output formats. - Ensured backward compatibility by preserving all existing APIs and CLI commands.
1 parent d9cd00b commit 72eab0c

18 files changed

Lines changed: 2027 additions & 3 deletions

README.md

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ This particular implementation provides an pip package that can be used by any P
3636
poetry add typeid-python
3737
```
3838

39+
### Optional dependencies
40+
41+
TypeID supports schema-based ID explanations using JSON (always available) and
42+
YAML (optional).
43+
44+
To enable YAML support:
45+
46+
```console
47+
pip install typeid-python[yaml]
48+
```
49+
50+
If the extra is not installed, JSON schemas will still work.
51+
3952
## Usage
4053

4154
### Basic
@@ -109,3 +122,158 @@ This particular implementation provides an pip package that can be used by any P
109122
$ typeid encode 0188bac7-4afa-78aa-bc3b-bd1eef28d881 --prefix prefix
110123
prefix_01h2xcejqtf2nbrexx3vqjhp41
111124
```
125+
126+
## ✨ NEW: `typeid explain` — “What is this ID?”
127+
128+
TypeID can now **explain a TypeID** in a human-readable way.
129+
130+
This is useful when:
131+
132+
* debugging logs
133+
* inspecting database records
134+
* reviewing production incidents
135+
* understanding IDs shared via Slack, tickets, or dashboards
136+
137+
### Basic usage (no schema required)
138+
139+
```console
140+
$ typeid explain user_01h45ytscbebyvny4gc8cr8ma2
141+
```
142+
143+
Example output:
144+
145+
```yaml
146+
id: user_01h45ytscbebyvny4gc8cr8ma2
147+
valid: true
148+
149+
parsed:
150+
prefix: user
151+
suffix: 01h45ytscbebyvny4gc8cr8ma2
152+
uuid: 01890bf0-846f-7762-8605-5a3abb40e0e5
153+
created_at: 2025-03-12T10:41:23Z
154+
sortable: true
155+
156+
schema:
157+
found: false
158+
```
159+
160+
Even without configuration, `typeid explain` can:
161+
162+
* validate the ID
163+
* extract the UUID
164+
* derive creation time (UUIDv7)
165+
* determine sortability
166+
167+
## Schema-based explanations
168+
169+
To make explanations richer, you can define a **TypeID schema** describing what each
170+
prefix represents.
171+
172+
### Example schema (`typeid.schema.json`)
173+
174+
```json
175+
{
176+
"schema_version": 1,
177+
"types": {
178+
"user": {
179+
"name": "User",
180+
"description": "End-user account",
181+
"owner_team": "identity-platform",
182+
"pii": true,
183+
"retention": "7y",
184+
"links": {
185+
"logs": "https://logs.company/search?q={id}",
186+
"trace": "https://traces.company/?id={id}"
187+
}
188+
}
189+
}
190+
}
191+
```
192+
193+
### Explain using schema
194+
195+
```console
196+
$ typeid explain user_01h45ytscbebyvny4gc8cr8ma2
197+
```
198+
199+
Output (excerpt):
200+
201+
```yaml
202+
schema:
203+
found: true
204+
name: User
205+
owner_team: identity-platform
206+
pii: true
207+
retention: 7y
208+
209+
links:
210+
logs: https://logs.company/search?q=user_01h45ytscbebyvny4gc8cr8ma2
211+
```
212+
213+
## Schema discovery rules
214+
215+
If `--schema` is not provided, TypeID looks for a schema in the following order:
216+
217+
1. Environment variable:
218+
219+
```console
220+
TYPEID_SCHEMA=/path/to/schema.json
221+
```
222+
2. Current directory:
223+
224+
* `typeid.schema.json`
225+
* `typeid.schema.yaml`
226+
3. User config directory:
227+
228+
* `~/.config/typeid/schema.json`
229+
* `~/.config/typeid/schema.yaml`
230+
231+
If no schema is found, the command still works with derived information only.
232+
233+
## YAML schemas (optional)
234+
235+
YAML schemas are supported if the optional dependency is installed:
236+
237+
```console
238+
pip install typeid-python[yaml]
239+
```
240+
241+
Example (`typeid.schema.yaml`):
242+
243+
```yaml
244+
schema_version: 1
245+
types:
246+
user:
247+
name: User
248+
owner_team: identity-platform
249+
links:
250+
logs: "https://logs.company/search?q={id}"
251+
```
252+
253+
## JSON output (machine-readable)
254+
255+
```console
256+
$ typeid explain user_01h45ytscbebyvny4gc8cr8ma2 --json
257+
```
258+
259+
Useful for:
260+
261+
* scripts
262+
* CI pipelines
263+
* IDE integrations
264+
265+
## Design principles
266+
267+
* **Non-breaking**: existing APIs and CLI commands remain unchanged
268+
* **Schema-optional**: works fully offline
269+
* **Read-only**: no side effects or external mutations
270+
* **Declarative**: meaning is defined by users, not inferred by the tool
271+
272+
You can think of `typeid explain` as:
273+
274+
> **OpenAPI — but for identifiers instead of HTTP endpoints**
275+
276+
## License
277+
278+
MIT
279+

poetry.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ twine = "^6.2.0"
6060

6161
[tool.poetry.extras]
6262
cli = ["click"]
63+
yaml = ["PyYAML"]
6364

6465
[tool.poetry.scripts]
6566
typeid = "typeid.cli:cli"

tests/explain/__init__.py

Whitespace-only changes.

tests/explain/test_cli.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import json
2+
from pathlib import Path
3+
4+
from click.testing import CliRunner
5+
6+
from typeid import TypeID
7+
from typeid.cli import cli
8+
9+
10+
def _make_valid_id(prefix: str = "usr") -> str:
11+
return str(TypeID(prefix=prefix))
12+
13+
14+
def test_cli_explain_pretty_offline_no_schema():
15+
runner = CliRunner()
16+
tid = _make_valid_id("usr")
17+
18+
result = runner.invoke(cli, ["explain", tid, "--no-schema"])
19+
assert result.exit_code == 0
20+
out = result.output
21+
22+
assert f"id: {tid}" in out
23+
assert "valid: true" in out
24+
assert "schema:" in out
25+
assert "found: false" in out or "found: false" in out.lower()
26+
27+
28+
def test_cli_explain_json_offline():
29+
runner = CliRunner()
30+
tid = _make_valid_id("usr")
31+
32+
result = runner.invoke(cli, ["explain", tid, "--no-schema", "--json"])
33+
assert result.exit_code == 0
34+
35+
payload = json.loads(result.output)
36+
assert payload["id"] == tid
37+
assert payload["valid"] is True
38+
assert payload["schema"] is None
39+
assert payload["parsed"]["prefix"] == "usr"
40+
assert payload["parsed"]["uuid"] is not None
41+
42+
43+
def test_cli_explain_with_schema_file(tmp_path: Path):
44+
runner = CliRunner()
45+
tid = _make_valid_id("usr")
46+
47+
schema = {
48+
"schema_version": 1,
49+
"types": {
50+
"usr": {"name": "User", "owner_team": "identity-platform", "links": {"logs": "https://logs?q={id}"}}
51+
},
52+
}
53+
p = tmp_path / "typeid.schema.json"
54+
p.write_text(json.dumps(schema), encoding="utf-8")
55+
56+
result = runner.invoke(cli, ["explain", tid, "--schema", str(p)])
57+
assert result.exit_code == 0
58+
out = result.output
59+
60+
assert "schema:" in out
61+
assert "found: true" in out
62+
assert "name: User" in out
63+
assert "owner_team: identity-platform" in out
64+
assert "links:" in out
65+
assert "logs:" in out
66+
67+
68+
def test_cli_explain_schema_load_failure_still_works(tmp_path: Path):
69+
runner = CliRunner()
70+
tid = _make_valid_id("usr")
71+
72+
p = tmp_path / "typeid.schema.json"
73+
p.write_text("{not json", encoding="utf-8")
74+
75+
result = runner.invoke(cli, ["explain", tid, "--schema", str(p)])
76+
assert result.exit_code == 0
77+
out = result.output
78+
79+
# Should still explain derived facts and surface warning
80+
assert f"id: {tid}" in out
81+
assert "valid: true" in out
82+
assert "warnings:" in out.lower()
83+
84+
85+
def test_cli_explain_invalid_id_exit_code_zero_but_valid_false():
86+
# We keep exit_code 0 for "explain" so it can be used in scripts without
87+
# failing pipelines; the content will indicate validity.
88+
runner = CliRunner()
89+
90+
result = runner.invoke(cli, ["explain", "not_a_typeid", "--no-schema"])
91+
assert result.exit_code == 0
92+
assert "valid: false" in result.output.lower()
93+
assert "errors:" in result.output.lower()

tests/explain/test_cli_yaml.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import json
2+
from pathlib import Path
3+
4+
import pytest
5+
from click.testing import CliRunner
6+
7+
from typeid import TypeID
8+
from typeid.cli import cli
9+
10+
11+
yaml = pytest.importorskip("yaml") # skip if PyYAML not installed
12+
13+
14+
def test_cli_explain_with_yaml_schema(tmp_path: Path):
15+
runner = CliRunner()
16+
tid = str(TypeID(prefix="usr"))
17+
18+
p = tmp_path / "typeid.schema.yaml"
19+
p.write_text(
20+
"""
21+
schema_version: 1
22+
types:
23+
usr:
24+
name: User
25+
owner_team: identity-platform
26+
links:
27+
logs: "https://logs?q={id}"
28+
""",
29+
encoding="utf-8",
30+
)
31+
32+
result = runner.invoke(cli, ["explain", tid, "--schema", str(p)])
33+
assert result.exit_code == 0
34+
out = result.output
35+
36+
assert "schema:" in out
37+
assert "found: true" in out
38+
assert "name: User" in out
39+
assert "owner_team: identity-platform" in out
40+
assert "logs:" in out
41+
42+
43+
def test_cli_explain_with_yaml_schema_json_output(tmp_path: Path):
44+
runner = CliRunner()
45+
tid = str(TypeID(prefix="usr"))
46+
47+
p = tmp_path / "typeid.schema.yaml"
48+
p.write_text(
49+
"""
50+
schema_version: 1
51+
types:
52+
usr:
53+
name: User
54+
""",
55+
encoding="utf-8",
56+
)
57+
58+
result = runner.invoke(cli, ["explain", tid, "--schema", str(p), "--json"])
59+
assert result.exit_code == 0
60+
61+
payload = json.loads(result.output)
62+
assert payload["valid"] is True
63+
assert payload["schema"] is not None
64+
assert payload["schema"]["name"] == "User"

0 commit comments

Comments
 (0)