scigantic-cards 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.
- scigantic_cards-0.1.0/LICENSE +16 -0
- scigantic_cards-0.1.0/PKG-INFO +121 -0
- scigantic_cards-0.1.0/README.md +96 -0
- scigantic_cards-0.1.0/pyproject.toml +39 -0
- scigantic_cards-0.1.0/setup.cfg +4 -0
- scigantic_cards-0.1.0/src/scigantic_cards/__init__.py +4 -0
- scigantic_cards-0.1.0/src/scigantic_cards/card.py +185 -0
- scigantic_cards-0.1.0/src/scigantic_cards/card.schema.json +44 -0
- scigantic_cards-0.1.0/src/scigantic_cards/cli.py +50 -0
- scigantic_cards-0.1.0/src/scigantic_cards/lint.py +98 -0
- scigantic_cards-0.1.0/src/scigantic_cards/py.typed +0 -0
- scigantic_cards-0.1.0/src/scigantic_cards.egg-info/PKG-INFO +121 -0
- scigantic_cards-0.1.0/src/scigantic_cards.egg-info/SOURCES.txt +18 -0
- scigantic_cards-0.1.0/src/scigantic_cards.egg-info/dependency_links.txt +1 -0
- scigantic_cards-0.1.0/src/scigantic_cards.egg-info/entry_points.txt +2 -0
- scigantic_cards-0.1.0/src/scigantic_cards.egg-info/requires.txt +6 -0
- scigantic_cards-0.1.0/src/scigantic_cards.egg-info/top_level.txt +1 -0
- scigantic_cards-0.1.0/tests/test_lint.py +96 -0
- scigantic_cards-0.1.0/tests/test_load.py +77 -0
- scigantic_cards-0.1.0/tests/test_stress.py +200 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT No Attribution
|
|
2
|
+
|
|
3
|
+
Copyright 2026 Scigantic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
|
6
|
+
software and associated documentation files (the "Software"), to deal in the Software
|
|
7
|
+
without restriction, including without limitation the rights to use, copy, modify,
|
|
8
|
+
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
|
|
9
|
+
permit persons to whom the Software is furnished to do so.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
12
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
13
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
14
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
|
15
|
+
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
|
|
16
|
+
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scigantic-cards
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small data card format and linter for LLM agents: record what differs from the well-known dataset, how big it is, and how to open it.
|
|
5
|
+
Author: Scigantic
|
|
6
|
+
License: MIT-0
|
|
7
|
+
Keywords: llm,agents,datasets,data-card,schema,lint
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT No Attribution License (MIT-0)
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
21
|
+
Requires-Dist: mypy; extra == "dev"
|
|
22
|
+
Requires-Dist: jsonschema; extra == "dev"
|
|
23
|
+
Requires-Dist: hypothesis; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# scigantic-cards
|
|
27
|
+
|
|
28
|
+
A JSON format for describing a dataset to an LLM agent, and a linter for it.
|
|
29
|
+
|
|
30
|
+
A card holds what the agent is unlikely to know already: which published dataset this is, how this copy differs from it, how large it is, and a snippet that opens it. A card that only restates what the model already knows fails the lint.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install scigantic-cards
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Python 3.10 or newer. No dependencies.
|
|
39
|
+
|
|
40
|
+
## Format
|
|
41
|
+
|
|
42
|
+
A card is a JSON object. Only `name` is required for the file to load. The linter checks the rest.
|
|
43
|
+
|
|
44
|
+
| Field | Type | Meaning |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `name` | string | Short id for the card |
|
|
47
|
+
| `identity` | string | The published dataset, release or paper this copy comes from |
|
|
48
|
+
| `summary` | string | One or two sentences |
|
|
49
|
+
| `layout` | string | How files are organized |
|
|
50
|
+
| `approx_bytes`, `approx_items` | integer or null | Size, for choosing between streaming and loading |
|
|
51
|
+
| `fields` | list of `{name, dtype, note}` | Columns or variables |
|
|
52
|
+
| `differs_from_upstream` | list of strings | How this copy differs from the well-known version |
|
|
53
|
+
| `gotchas` | list of strings | Sentinel values, unit conventions, known bad files |
|
|
54
|
+
| `access` | list of `{goal, snippet, verified}` | Code that opens the data. `verified` is true if you ran it |
|
|
55
|
+
| `checked_at` | string | ISO date the card was last checked against the data |
|
|
56
|
+
|
|
57
|
+
Unknown keys are rejected. See [`examples/acme-stations.json`](examples/acme-stations.json), a fictional dataset.
|
|
58
|
+
|
|
59
|
+
The same rules are published as JSON Schema in `src/scigantic_cards/card.schema.json`, for validating cards from other languages.
|
|
60
|
+
|
|
61
|
+
## Lint
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
$ scigantic-cards lint examples/needs-work.json
|
|
65
|
+
examples/needs-work.json: error: identity: No identity. Name the canonical dataset, release, or paper so the agent can recall what it already knows.
|
|
66
|
+
examples/needs-work.json: error: delta: No differences or gotchas. A card with neither only restates what the agent already knows.
|
|
67
|
+
examples/needs-work.json: warn: size: No size. Agents choose between streaming and loading based on it.
|
|
68
|
+
examples/needs-work.json: warn: unverified-access: 'List files': snippet was not executed.
|
|
69
|
+
examples/needs-work.json: error: tilde-path: 'List files': a path starting with ~/ is not expanded by Python unless it goes through os.path.expanduser or Path.expanduser.
|
|
70
|
+
examples/needs-work.json: warn: checked-at: checked_at is missing or not an ISO date (YYYY-MM-DD).
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Options: `--strict` counts warnings as failures. `--budget N` sets the size limit in bytes (default 4000).
|
|
74
|
+
|
|
75
|
+
Exit codes: 0 no problems, 1 lint errors (or warnings with `--strict`), 2 a file could not be read or is not a valid card.
|
|
76
|
+
|
|
77
|
+
### Rules
|
|
78
|
+
|
|
79
|
+
| Rule | Severity | Fires when |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `name` | error | name is blank |
|
|
82
|
+
| `identity` | error | identity is blank |
|
|
83
|
+
| `delta` | error | `differs_from_upstream` and `gotchas` have no non-blank entry |
|
|
84
|
+
| `access` | error | there are no access snippets |
|
|
85
|
+
| `empty-snippet` | error | a snippet is blank |
|
|
86
|
+
| `tilde-path` | error | a snippet has a string starting with `~/` and no `expanduser` |
|
|
87
|
+
| `size` | warn | neither size field is set |
|
|
88
|
+
| `unverified-access` | warn | a snippet has `verified: false` |
|
|
89
|
+
| `field-dtype` | warn | a field has no dtype |
|
|
90
|
+
| `field-duplicate` | warn | a field name appears twice |
|
|
91
|
+
| `checked-at` | warn | `checked_at` is not an ISO date |
|
|
92
|
+
| `budget` | warn | the card is over the budget as compact UTF-8 JSON |
|
|
93
|
+
|
|
94
|
+
## Python
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from scigantic_cards import Card, CardError, lint
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
card = Card.from_json(open("card.json", encoding="utf-8").read())
|
|
101
|
+
except CardError as e:
|
|
102
|
+
print(e) # for example: card.fields[2].dtype: required
|
|
103
|
+
else:
|
|
104
|
+
for issue in lint(card):
|
|
105
|
+
print(issue.severity, issue.rule, issue.message)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`Card.from_dict` and `Card.from_json` accept the same documents as the JSON Schema and raise `CardError` with the path of the bad value otherwise.
|
|
109
|
+
|
|
110
|
+
## Tests
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
pip install -e ".[dev]"
|
|
114
|
+
pytest
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The suite includes property-based tests (Hypothesis) that generate valid and slightly broken cards and check that the Python loader and the JSON Schema agree on every one.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT-0.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# scigantic-cards
|
|
2
|
+
|
|
3
|
+
A JSON format for describing a dataset to an LLM agent, and a linter for it.
|
|
4
|
+
|
|
5
|
+
A card holds what the agent is unlikely to know already: which published dataset this is, how this copy differs from it, how large it is, and a snippet that opens it. A card that only restates what the model already knows fails the lint.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install scigantic-cards
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Python 3.10 or newer. No dependencies.
|
|
14
|
+
|
|
15
|
+
## Format
|
|
16
|
+
|
|
17
|
+
A card is a JSON object. Only `name` is required for the file to load. The linter checks the rest.
|
|
18
|
+
|
|
19
|
+
| Field | Type | Meaning |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `name` | string | Short id for the card |
|
|
22
|
+
| `identity` | string | The published dataset, release or paper this copy comes from |
|
|
23
|
+
| `summary` | string | One or two sentences |
|
|
24
|
+
| `layout` | string | How files are organized |
|
|
25
|
+
| `approx_bytes`, `approx_items` | integer or null | Size, for choosing between streaming and loading |
|
|
26
|
+
| `fields` | list of `{name, dtype, note}` | Columns or variables |
|
|
27
|
+
| `differs_from_upstream` | list of strings | How this copy differs from the well-known version |
|
|
28
|
+
| `gotchas` | list of strings | Sentinel values, unit conventions, known bad files |
|
|
29
|
+
| `access` | list of `{goal, snippet, verified}` | Code that opens the data. `verified` is true if you ran it |
|
|
30
|
+
| `checked_at` | string | ISO date the card was last checked against the data |
|
|
31
|
+
|
|
32
|
+
Unknown keys are rejected. See [`examples/acme-stations.json`](examples/acme-stations.json), a fictional dataset.
|
|
33
|
+
|
|
34
|
+
The same rules are published as JSON Schema in `src/scigantic_cards/card.schema.json`, for validating cards from other languages.
|
|
35
|
+
|
|
36
|
+
## Lint
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
$ scigantic-cards lint examples/needs-work.json
|
|
40
|
+
examples/needs-work.json: error: identity: No identity. Name the canonical dataset, release, or paper so the agent can recall what it already knows.
|
|
41
|
+
examples/needs-work.json: error: delta: No differences or gotchas. A card with neither only restates what the agent already knows.
|
|
42
|
+
examples/needs-work.json: warn: size: No size. Agents choose between streaming and loading based on it.
|
|
43
|
+
examples/needs-work.json: warn: unverified-access: 'List files': snippet was not executed.
|
|
44
|
+
examples/needs-work.json: error: tilde-path: 'List files': a path starting with ~/ is not expanded by Python unless it goes through os.path.expanduser or Path.expanduser.
|
|
45
|
+
examples/needs-work.json: warn: checked-at: checked_at is missing or not an ISO date (YYYY-MM-DD).
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Options: `--strict` counts warnings as failures. `--budget N` sets the size limit in bytes (default 4000).
|
|
49
|
+
|
|
50
|
+
Exit codes: 0 no problems, 1 lint errors (or warnings with `--strict`), 2 a file could not be read or is not a valid card.
|
|
51
|
+
|
|
52
|
+
### Rules
|
|
53
|
+
|
|
54
|
+
| Rule | Severity | Fires when |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `name` | error | name is blank |
|
|
57
|
+
| `identity` | error | identity is blank |
|
|
58
|
+
| `delta` | error | `differs_from_upstream` and `gotchas` have no non-blank entry |
|
|
59
|
+
| `access` | error | there are no access snippets |
|
|
60
|
+
| `empty-snippet` | error | a snippet is blank |
|
|
61
|
+
| `tilde-path` | error | a snippet has a string starting with `~/` and no `expanduser` |
|
|
62
|
+
| `size` | warn | neither size field is set |
|
|
63
|
+
| `unverified-access` | warn | a snippet has `verified: false` |
|
|
64
|
+
| `field-dtype` | warn | a field has no dtype |
|
|
65
|
+
| `field-duplicate` | warn | a field name appears twice |
|
|
66
|
+
| `checked-at` | warn | `checked_at` is not an ISO date |
|
|
67
|
+
| `budget` | warn | the card is over the budget as compact UTF-8 JSON |
|
|
68
|
+
|
|
69
|
+
## Python
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from scigantic_cards import Card, CardError, lint
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
card = Card.from_json(open("card.json", encoding="utf-8").read())
|
|
76
|
+
except CardError as e:
|
|
77
|
+
print(e) # for example: card.fields[2].dtype: required
|
|
78
|
+
else:
|
|
79
|
+
for issue in lint(card):
|
|
80
|
+
print(issue.severity, issue.rule, issue.message)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`Card.from_dict` and `Card.from_json` accept the same documents as the JSON Schema and raise `CardError` with the path of the bad value otherwise.
|
|
84
|
+
|
|
85
|
+
## Tests
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
pip install -e ".[dev]"
|
|
89
|
+
pytest
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The suite includes property-based tests (Hypothesis) that generate valid and slightly broken cards and check that the Python loader and the JSON Schema agree on every one.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT-0.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scigantic-cards"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A small data card format and linter for LLM agents: record what differs from the well-known dataset, how big it is, and how to open it."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT-0" }
|
|
12
|
+
authors = [{ name = "Scigantic" }]
|
|
13
|
+
keywords = ["llm", "agents", "datasets", "data-card", "schema", "lint"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: MIT No Attribution License (MIT-0)",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest", "mypy", "jsonschema", "hypothesis"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
scigantic-cards = "scigantic_cards.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.package-data]
|
|
36
|
+
scigantic_cards = ["py.typed", "card.schema.json"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Card data model and strict loading.
|
|
2
|
+
|
|
3
|
+
`Card.from_dict` accepts exactly the documents that `card.schema.json`
|
|
4
|
+
accepts, and raises `CardError` with a path to the offending value
|
|
5
|
+
otherwise.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CardError(ValueError):
|
|
15
|
+
"""The input is not a valid card."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Field:
|
|
20
|
+
name: str
|
|
21
|
+
dtype: str
|
|
22
|
+
note: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Access:
|
|
27
|
+
"""A way to open the data. `verified` means the snippet was executed."""
|
|
28
|
+
|
|
29
|
+
goal: str
|
|
30
|
+
snippet: str
|
|
31
|
+
verified: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Card:
|
|
36
|
+
name: str
|
|
37
|
+
identity: str = ""
|
|
38
|
+
summary: str = ""
|
|
39
|
+
layout: str = ""
|
|
40
|
+
approx_bytes: int | None = None
|
|
41
|
+
approx_items: int | None = None
|
|
42
|
+
fields: list[Field] = field(default_factory=list)
|
|
43
|
+
differs_from_upstream: list[str] = field(default_factory=list)
|
|
44
|
+
gotchas: list[str] = field(default_factory=list)
|
|
45
|
+
access: list[Access] = field(default_factory=list)
|
|
46
|
+
checked_at: str = ""
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, Any]:
|
|
49
|
+
return asdict(self)
|
|
50
|
+
|
|
51
|
+
def to_json(self, indent: int | None = 2) -> str:
|
|
52
|
+
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
|
|
53
|
+
|
|
54
|
+
def compact_size(self) -> int:
|
|
55
|
+
"""UTF-8 bytes of the compact JSON form. This is what the budget measures."""
|
|
56
|
+
text = json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))
|
|
57
|
+
return len(text.encode("utf-8"))
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_dict(cls, d: Any) -> "Card":
|
|
61
|
+
obj = _obj(d, "card")
|
|
62
|
+
_no_extra(obj, _TOP_KEYS, "card")
|
|
63
|
+
if "name" not in obj:
|
|
64
|
+
raise CardError("card.name: required")
|
|
65
|
+
name = _str(obj["name"], "card.name")
|
|
66
|
+
if not name:
|
|
67
|
+
raise CardError("card.name: must not be empty")
|
|
68
|
+
return cls(
|
|
69
|
+
name=name,
|
|
70
|
+
identity=_str(obj.get("identity", ""), "card.identity"),
|
|
71
|
+
summary=_str(obj.get("summary", ""), "card.summary"),
|
|
72
|
+
layout=_str(obj.get("layout", ""), "card.layout"),
|
|
73
|
+
approx_bytes=_count(obj.get("approx_bytes"), "card.approx_bytes"),
|
|
74
|
+
approx_items=_count(obj.get("approx_items"), "card.approx_items"),
|
|
75
|
+
fields=[
|
|
76
|
+
_field(x, f"card.fields[{i}]")
|
|
77
|
+
for i, x in enumerate(_list(obj.get("fields", []), "card.fields"))
|
|
78
|
+
],
|
|
79
|
+
differs_from_upstream=_strs(
|
|
80
|
+
obj.get("differs_from_upstream", []), "card.differs_from_upstream"
|
|
81
|
+
),
|
|
82
|
+
gotchas=_strs(obj.get("gotchas", []), "card.gotchas"),
|
|
83
|
+
access=[
|
|
84
|
+
_access(x, f"card.access[{i}]")
|
|
85
|
+
for i, x in enumerate(_list(obj.get("access", []), "card.access"))
|
|
86
|
+
],
|
|
87
|
+
checked_at=_str(obj.get("checked_at", ""), "card.checked_at"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_json(cls, text: str) -> "Card":
|
|
92
|
+
try:
|
|
93
|
+
data = json.loads(text)
|
|
94
|
+
except RecursionError:
|
|
95
|
+
raise CardError("JSON is nested too deeply") from None
|
|
96
|
+
except json.JSONDecodeError as e:
|
|
97
|
+
raise CardError(f"invalid JSON: {e}") from None
|
|
98
|
+
return cls.from_dict(data)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_TOP_KEYS = {
|
|
102
|
+
"name", "identity", "summary", "layout", "approx_bytes", "approx_items",
|
|
103
|
+
"fields", "differs_from_upstream", "gotchas", "access", "checked_at",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _obj(v: Any, where: str) -> dict[str, Any]:
|
|
108
|
+
if not isinstance(v, dict):
|
|
109
|
+
raise CardError(f"{where}: expected an object, got {_kind(v)}")
|
|
110
|
+
return v
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _list(v: Any, where: str) -> list[Any]:
|
|
114
|
+
if not isinstance(v, list):
|
|
115
|
+
raise CardError(f"{where}: expected an array, got {_kind(v)}")
|
|
116
|
+
return v
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _str(v: Any, where: str) -> str:
|
|
120
|
+
if not isinstance(v, str):
|
|
121
|
+
raise CardError(f"{where}: expected a string, got {_kind(v)}")
|
|
122
|
+
return v
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _strs(v: Any, where: str) -> list[str]:
|
|
126
|
+
return [_str(x, f"{where}[{i}]") for i, x in enumerate(_list(v, where))]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _count(v: Any, where: str) -> int | None:
|
|
130
|
+
if v is None:
|
|
131
|
+
return None
|
|
132
|
+
if isinstance(v, bool):
|
|
133
|
+
raise CardError(f"{where}: expected an integer, got boolean")
|
|
134
|
+
if isinstance(v, float) and v.is_integer():
|
|
135
|
+
v = int(v)
|
|
136
|
+
if not isinstance(v, int):
|
|
137
|
+
raise CardError(f"{where}: expected an integer, got {_kind(v)}")
|
|
138
|
+
if v < 0:
|
|
139
|
+
raise CardError(f"{where}: must not be negative")
|
|
140
|
+
return v
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _no_extra(obj: dict[str, Any], allowed: set[str], where: str) -> None:
|
|
144
|
+
extra = sorted(set(obj) - allowed)
|
|
145
|
+
if extra:
|
|
146
|
+
raise CardError(f"{where}: unknown key {extra[0]!r}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _field(v: Any, where: str) -> Field:
|
|
150
|
+
obj = _obj(v, where)
|
|
151
|
+
_no_extra(obj, {"name", "dtype", "note"}, where)
|
|
152
|
+
for k in ("name", "dtype"):
|
|
153
|
+
if k not in obj:
|
|
154
|
+
raise CardError(f"{where}.{k}: required")
|
|
155
|
+
return Field(
|
|
156
|
+
name=_str(obj["name"], f"{where}.name"),
|
|
157
|
+
dtype=_str(obj["dtype"], f"{where}.dtype"),
|
|
158
|
+
note=_str(obj.get("note", ""), f"{where}.note"),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _access(v: Any, where: str) -> Access:
|
|
163
|
+
obj = _obj(v, where)
|
|
164
|
+
_no_extra(obj, {"goal", "snippet", "verified"}, where)
|
|
165
|
+
for k in ("goal", "snippet"):
|
|
166
|
+
if k not in obj:
|
|
167
|
+
raise CardError(f"{where}.{k}: required")
|
|
168
|
+
verified = obj.get("verified", False)
|
|
169
|
+
if not isinstance(verified, bool):
|
|
170
|
+
raise CardError(f"{where}.verified: expected a boolean, got {_kind(verified)}")
|
|
171
|
+
return Access(
|
|
172
|
+
goal=_str(obj["goal"], f"{where}.goal"),
|
|
173
|
+
snippet=_str(obj["snippet"], f"{where}.snippet"),
|
|
174
|
+
verified=verified,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _kind(v: Any) -> str:
|
|
179
|
+
if v is None:
|
|
180
|
+
return "null"
|
|
181
|
+
if isinstance(v, bool):
|
|
182
|
+
return "boolean"
|
|
183
|
+
if isinstance(v, (int, float)):
|
|
184
|
+
return "number"
|
|
185
|
+
return type(v).__name__
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "scigantic-cards card",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["name"],
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"name": {"type": "string", "minLength": 1},
|
|
9
|
+
"identity": {"type": "string"},
|
|
10
|
+
"summary": {"type": "string"},
|
|
11
|
+
"layout": {"type": "string"},
|
|
12
|
+
"approx_bytes": {"type": ["integer", "null"], "minimum": 0},
|
|
13
|
+
"approx_items": {"type": ["integer", "null"], "minimum": 0},
|
|
14
|
+
"fields": {
|
|
15
|
+
"type": "array",
|
|
16
|
+
"items": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"required": ["name", "dtype"],
|
|
19
|
+
"additionalProperties": false,
|
|
20
|
+
"properties": {
|
|
21
|
+
"name": {"type": "string"},
|
|
22
|
+
"dtype": {"type": "string"},
|
|
23
|
+
"note": {"type": "string"}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"differs_from_upstream": {"type": "array", "items": {"type": "string"}},
|
|
28
|
+
"gotchas": {"type": "array", "items": {"type": "string"}},
|
|
29
|
+
"access": {
|
|
30
|
+
"type": "array",
|
|
31
|
+
"items": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"required": ["goal", "snippet"],
|
|
34
|
+
"additionalProperties": false,
|
|
35
|
+
"properties": {
|
|
36
|
+
"goal": {"type": "string"},
|
|
37
|
+
"snippet": {"type": "string"},
|
|
38
|
+
"verified": {"type": "boolean"}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"checked_at": {"type": "string"}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Command line entry point: scigantic-cards lint card.json [--budget N] [--strict].
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 no problems, 1 lint errors (or warnings with --strict),
|
|
4
|
+
2 a file could not be read or is not a valid card.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from .card import Card, CardError
|
|
12
|
+
from .lint import DEFAULT_BYTE_BUDGET, lint
|
|
13
|
+
|
|
14
|
+
MAX_BYTES = 5_000_000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv: list[str] | None = None) -> int:
|
|
18
|
+
p = argparse.ArgumentParser(prog="scigantic-cards")
|
|
19
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
20
|
+
lp = sub.add_parser("lint", help="lint one or more card files")
|
|
21
|
+
lp.add_argument("files", nargs="+")
|
|
22
|
+
lp.add_argument("--budget", type=int, default=DEFAULT_BYTE_BUDGET)
|
|
23
|
+
lp.add_argument("--strict", action="store_true", help="treat warnings as errors")
|
|
24
|
+
args = p.parse_args(argv)
|
|
25
|
+
|
|
26
|
+
code = 0
|
|
27
|
+
for path in args.files:
|
|
28
|
+
try:
|
|
29
|
+
card = _load(path)
|
|
30
|
+
except (OSError, CardError, UnicodeDecodeError) as e:
|
|
31
|
+
print(f"{path}: cannot load: {e}", file=sys.stderr)
|
|
32
|
+
code = 2
|
|
33
|
+
continue
|
|
34
|
+
for issue in lint(card, byte_budget=args.budget):
|
|
35
|
+
print(f"{path}: {issue.severity}: {issue.rule}: {issue.message}")
|
|
36
|
+
if code == 0 and (issue.severity == "error" or args.strict):
|
|
37
|
+
code = 1
|
|
38
|
+
return code
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load(path: str) -> Card:
|
|
42
|
+
with open(path, "rb") as fh:
|
|
43
|
+
raw = fh.read(MAX_BYTES + 1)
|
|
44
|
+
if len(raw) > MAX_BYTES:
|
|
45
|
+
raise CardError(f"file is larger than {MAX_BYTES} bytes")
|
|
46
|
+
return Card.from_json(raw.decode("utf-8-sig"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Lint a card. Each rule yields zero or more Issue objects."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import date
|
|
7
|
+
|
|
8
|
+
from .card import Card
|
|
9
|
+
|
|
10
|
+
DEFAULT_BYTE_BUDGET = 4000
|
|
11
|
+
|
|
12
|
+
# A string literal that starts with ~/ . Python does not expand it in glob(),
|
|
13
|
+
# open(), Path() or os.listdir().
|
|
14
|
+
_TILDE_PATH = re.compile(r"""['"]~/""")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Issue:
|
|
19
|
+
rule: str
|
|
20
|
+
severity: str # "error" or "warn"
|
|
21
|
+
message: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def lint(card: Card, byte_budget: int = DEFAULT_BYTE_BUDGET) -> list[Issue]:
|
|
25
|
+
out: list[Issue] = []
|
|
26
|
+
|
|
27
|
+
if not card.name.strip():
|
|
28
|
+
out.append(Issue("name", "error", "Name is blank."))
|
|
29
|
+
|
|
30
|
+
if not card.identity.strip():
|
|
31
|
+
out.append(Issue(
|
|
32
|
+
"identity", "error",
|
|
33
|
+
"No identity. Name the canonical dataset, release, or paper so the "
|
|
34
|
+
"agent can recall what it already knows.",
|
|
35
|
+
))
|
|
36
|
+
|
|
37
|
+
differences = [s for s in card.differs_from_upstream + card.gotchas if s.strip()]
|
|
38
|
+
if not differences:
|
|
39
|
+
out.append(Issue(
|
|
40
|
+
"delta", "error",
|
|
41
|
+
"No differences or gotchas. A card with neither only restates what "
|
|
42
|
+
"the agent already knows.",
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
if card.approx_bytes is None and card.approx_items is None:
|
|
46
|
+
out.append(Issue(
|
|
47
|
+
"size", "warn",
|
|
48
|
+
"No size. Agents choose between streaming and loading based on it.",
|
|
49
|
+
))
|
|
50
|
+
|
|
51
|
+
if not card.access:
|
|
52
|
+
out.append(Issue("access", "error", "No access snippet."))
|
|
53
|
+
for a in card.access:
|
|
54
|
+
goal = a.goal.strip() or "(no goal)"
|
|
55
|
+
if not a.snippet.strip():
|
|
56
|
+
out.append(Issue("empty-snippet", "error", f"'{goal}': snippet is empty."))
|
|
57
|
+
if not a.verified:
|
|
58
|
+
out.append(Issue(
|
|
59
|
+
"unverified-access", "warn", f"'{goal}': snippet was not executed.",
|
|
60
|
+
))
|
|
61
|
+
if _TILDE_PATH.search(a.snippet) and "expanduser" not in a.snippet:
|
|
62
|
+
out.append(Issue(
|
|
63
|
+
"tilde-path", "error",
|
|
64
|
+
f"'{goal}': a path starting with ~/ is not expanded by Python "
|
|
65
|
+
"unless it goes through os.path.expanduser or Path.expanduser.",
|
|
66
|
+
))
|
|
67
|
+
|
|
68
|
+
seen: set[str] = set()
|
|
69
|
+
for f in card.fields:
|
|
70
|
+
if not f.dtype.strip():
|
|
71
|
+
out.append(Issue("field-dtype", "warn", f"Field '{f.name}' has no dtype."))
|
|
72
|
+
if f.name in seen:
|
|
73
|
+
out.append(Issue("field-duplicate", "warn", f"Field '{f.name}' is listed twice."))
|
|
74
|
+
seen.add(f.name)
|
|
75
|
+
|
|
76
|
+
if not _is_iso_date(card.checked_at):
|
|
77
|
+
out.append(Issue(
|
|
78
|
+
"checked-at", "warn", "checked_at is missing or not an ISO date (YYYY-MM-DD).",
|
|
79
|
+
))
|
|
80
|
+
|
|
81
|
+
size = card.compact_size()
|
|
82
|
+
if size > byte_budget:
|
|
83
|
+
out.append(Issue(
|
|
84
|
+
"budget", "warn",
|
|
85
|
+
f"Card is {size} bytes as compact JSON, over the {byte_budget} byte "
|
|
86
|
+
"budget. Cut what the agent would already know.",
|
|
87
|
+
))
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_iso_date(s: str) -> bool:
|
|
92
|
+
if len(s) < 10 or (len(s) > 10 and s[10] not in "T "):
|
|
93
|
+
return False
|
|
94
|
+
try:
|
|
95
|
+
date.fromisoformat(s[:10])
|
|
96
|
+
except ValueError:
|
|
97
|
+
return False
|
|
98
|
+
return True
|
|
File without changes
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scigantic-cards
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small data card format and linter for LLM agents: record what differs from the well-known dataset, how big it is, and how to open it.
|
|
5
|
+
Author: Scigantic
|
|
6
|
+
License: MIT-0
|
|
7
|
+
Keywords: llm,agents,datasets,data-card,schema,lint
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT No Attribution License (MIT-0)
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
21
|
+
Requires-Dist: mypy; extra == "dev"
|
|
22
|
+
Requires-Dist: jsonschema; extra == "dev"
|
|
23
|
+
Requires-Dist: hypothesis; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# scigantic-cards
|
|
27
|
+
|
|
28
|
+
A JSON format for describing a dataset to an LLM agent, and a linter for it.
|
|
29
|
+
|
|
30
|
+
A card holds what the agent is unlikely to know already: which published dataset this is, how this copy differs from it, how large it is, and a snippet that opens it. A card that only restates what the model already knows fails the lint.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install scigantic-cards
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Python 3.10 or newer. No dependencies.
|
|
39
|
+
|
|
40
|
+
## Format
|
|
41
|
+
|
|
42
|
+
A card is a JSON object. Only `name` is required for the file to load. The linter checks the rest.
|
|
43
|
+
|
|
44
|
+
| Field | Type | Meaning |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `name` | string | Short id for the card |
|
|
47
|
+
| `identity` | string | The published dataset, release or paper this copy comes from |
|
|
48
|
+
| `summary` | string | One or two sentences |
|
|
49
|
+
| `layout` | string | How files are organized |
|
|
50
|
+
| `approx_bytes`, `approx_items` | integer or null | Size, for choosing between streaming and loading |
|
|
51
|
+
| `fields` | list of `{name, dtype, note}` | Columns or variables |
|
|
52
|
+
| `differs_from_upstream` | list of strings | How this copy differs from the well-known version |
|
|
53
|
+
| `gotchas` | list of strings | Sentinel values, unit conventions, known bad files |
|
|
54
|
+
| `access` | list of `{goal, snippet, verified}` | Code that opens the data. `verified` is true if you ran it |
|
|
55
|
+
| `checked_at` | string | ISO date the card was last checked against the data |
|
|
56
|
+
|
|
57
|
+
Unknown keys are rejected. See [`examples/acme-stations.json`](examples/acme-stations.json), a fictional dataset.
|
|
58
|
+
|
|
59
|
+
The same rules are published as JSON Schema in `src/scigantic_cards/card.schema.json`, for validating cards from other languages.
|
|
60
|
+
|
|
61
|
+
## Lint
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
$ scigantic-cards lint examples/needs-work.json
|
|
65
|
+
examples/needs-work.json: error: identity: No identity. Name the canonical dataset, release, or paper so the agent can recall what it already knows.
|
|
66
|
+
examples/needs-work.json: error: delta: No differences or gotchas. A card with neither only restates what the agent already knows.
|
|
67
|
+
examples/needs-work.json: warn: size: No size. Agents choose between streaming and loading based on it.
|
|
68
|
+
examples/needs-work.json: warn: unverified-access: 'List files': snippet was not executed.
|
|
69
|
+
examples/needs-work.json: error: tilde-path: 'List files': a path starting with ~/ is not expanded by Python unless it goes through os.path.expanduser or Path.expanduser.
|
|
70
|
+
examples/needs-work.json: warn: checked-at: checked_at is missing or not an ISO date (YYYY-MM-DD).
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Options: `--strict` counts warnings as failures. `--budget N` sets the size limit in bytes (default 4000).
|
|
74
|
+
|
|
75
|
+
Exit codes: 0 no problems, 1 lint errors (or warnings with `--strict`), 2 a file could not be read or is not a valid card.
|
|
76
|
+
|
|
77
|
+
### Rules
|
|
78
|
+
|
|
79
|
+
| Rule | Severity | Fires when |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `name` | error | name is blank |
|
|
82
|
+
| `identity` | error | identity is blank |
|
|
83
|
+
| `delta` | error | `differs_from_upstream` and `gotchas` have no non-blank entry |
|
|
84
|
+
| `access` | error | there are no access snippets |
|
|
85
|
+
| `empty-snippet` | error | a snippet is blank |
|
|
86
|
+
| `tilde-path` | error | a snippet has a string starting with `~/` and no `expanduser` |
|
|
87
|
+
| `size` | warn | neither size field is set |
|
|
88
|
+
| `unverified-access` | warn | a snippet has `verified: false` |
|
|
89
|
+
| `field-dtype` | warn | a field has no dtype |
|
|
90
|
+
| `field-duplicate` | warn | a field name appears twice |
|
|
91
|
+
| `checked-at` | warn | `checked_at` is not an ISO date |
|
|
92
|
+
| `budget` | warn | the card is over the budget as compact UTF-8 JSON |
|
|
93
|
+
|
|
94
|
+
## Python
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from scigantic_cards import Card, CardError, lint
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
card = Card.from_json(open("card.json", encoding="utf-8").read())
|
|
101
|
+
except CardError as e:
|
|
102
|
+
print(e) # for example: card.fields[2].dtype: required
|
|
103
|
+
else:
|
|
104
|
+
for issue in lint(card):
|
|
105
|
+
print(issue.severity, issue.rule, issue.message)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`Card.from_dict` and `Card.from_json` accept the same documents as the JSON Schema and raise `CardError` with the path of the bad value otherwise.
|
|
109
|
+
|
|
110
|
+
## Tests
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
pip install -e ".[dev]"
|
|
114
|
+
pytest
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The suite includes property-based tests (Hypothesis) that generate valid and slightly broken cards and check that the Python loader and the JSON Schema agree on every one.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT-0.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/scigantic_cards/__init__.py
|
|
5
|
+
src/scigantic_cards/card.py
|
|
6
|
+
src/scigantic_cards/card.schema.json
|
|
7
|
+
src/scigantic_cards/cli.py
|
|
8
|
+
src/scigantic_cards/lint.py
|
|
9
|
+
src/scigantic_cards/py.typed
|
|
10
|
+
src/scigantic_cards.egg-info/PKG-INFO
|
|
11
|
+
src/scigantic_cards.egg-info/SOURCES.txt
|
|
12
|
+
src/scigantic_cards.egg-info/dependency_links.txt
|
|
13
|
+
src/scigantic_cards.egg-info/entry_points.txt
|
|
14
|
+
src/scigantic_cards.egg-info/requires.txt
|
|
15
|
+
src/scigantic_cards.egg-info/top_level.txt
|
|
16
|
+
tests/test_lint.py
|
|
17
|
+
tests/test_load.py
|
|
18
|
+
tests/test_stress.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scigantic_cards
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""One positive and one negative case per rule."""
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from scigantic_cards import Access, Field, lint
|
|
5
|
+
from conftest import make_card
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def rules(card, **kw):
|
|
9
|
+
return {i.rule for i in lint(card, **kw)}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_clean_card_has_no_issues():
|
|
13
|
+
assert lint(make_card()) == []
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.mark.parametrize("overrides,rule", [
|
|
17
|
+
({"name": " "}, "name"),
|
|
18
|
+
({"identity": ""}, "identity"),
|
|
19
|
+
({"identity": " \n"}, "identity"),
|
|
20
|
+
({"differs_from_upstream": []}, "delta"),
|
|
21
|
+
({"differs_from_upstream": ["", " "]}, "delta"),
|
|
22
|
+
({"approx_bytes": None}, "size"),
|
|
23
|
+
({"access": []}, "access"),
|
|
24
|
+
({"access": [Access("g", " ", True)]}, "empty-snippet"),
|
|
25
|
+
({"access": [Access("g", "x = 1", False)]}, "unverified-access"),
|
|
26
|
+
({"fields": [Field("a", "")]}, "field-dtype"),
|
|
27
|
+
({"fields": [Field("a", "int"), Field("a", "str")]}, "field-duplicate"),
|
|
28
|
+
({"checked_at": ""}, "checked-at"),
|
|
29
|
+
({"checked_at": "yesterday"}, "checked-at"),
|
|
30
|
+
({"checked_at": "2026-13-40"}, "checked-at"),
|
|
31
|
+
({"checked_at": "2026-10-11x"}, "checked-at"),
|
|
32
|
+
({"gotchas": ["x" * 5000]}, "budget"),
|
|
33
|
+
])
|
|
34
|
+
def test_rule_fires(overrides, rule):
|
|
35
|
+
assert rule in rules(make_card(**overrides))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize("snippet", [
|
|
39
|
+
"glob.glob('~/data/*.nc')",
|
|
40
|
+
'open("~/x.csv")',
|
|
41
|
+
"Path('~/data').iterdir()",
|
|
42
|
+
"os.listdir('~/d')",
|
|
43
|
+
])
|
|
44
|
+
def test_tilde_path_fires(snippet):
|
|
45
|
+
assert "tilde-path" in rules(make_card(access=[Access("g", snippet, True)]))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize("snippet", [
|
|
49
|
+
"glob.glob(os.path.expanduser('~/data/*.nc'))",
|
|
50
|
+
"Path('~/data').expanduser()",
|
|
51
|
+
"url = 'https://example.org/~user/x'",
|
|
52
|
+
"s3://bucket/~/x",
|
|
53
|
+
])
|
|
54
|
+
def test_tilde_path_quiet(snippet):
|
|
55
|
+
assert "tilde-path" not in rules(make_card(access=[Access("g", snippet, True)]))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.mark.parametrize("value", ["2026-10-11", "2026-10-11T08:00:00Z", "2026-10-11 08:00"])
|
|
59
|
+
def test_checked_at_accepts(value):
|
|
60
|
+
assert "checked-at" not in rules(make_card(checked_at=value))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_either_differs_or_gotchas_satisfies_delta():
|
|
64
|
+
assert "delta" not in rules(make_card(differs_from_upstream=[], gotchas=["x"]))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_one_size_field_is_enough():
|
|
68
|
+
assert "size" not in rules(make_card(approx_bytes=None, approx_items=5))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_zero_size_counts_as_given():
|
|
72
|
+
assert "size" not in rules(make_card(approx_bytes=0))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_budget_is_configurable():
|
|
76
|
+
c = make_card()
|
|
77
|
+
assert "budget" in rules(c, byte_budget=10)
|
|
78
|
+
assert "budget" not in rules(c, byte_budget=10_000)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_budget_counts_utf8_bytes_not_escapes():
|
|
82
|
+
# 1000 CJK characters are 3000 UTF-8 bytes. With ensure_ascii escaping the
|
|
83
|
+
# same text would be counted as 6000 bytes and trip a 4000 budget.
|
|
84
|
+
c = make_card(gotchas=["字" * 1000])
|
|
85
|
+
assert 3000 < c.compact_size() < 3400
|
|
86
|
+
assert "budget" not in rules(c)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_one_issue_per_unverified_snippet():
|
|
90
|
+
c = make_card(access=[Access("a", "x", False), Access("b", "y", False)])
|
|
91
|
+
assert [i.rule for i in lint(c)].count("unverified-access") == 2
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_lint_is_deterministic():
|
|
95
|
+
c = make_card(identity="", access=[])
|
|
96
|
+
assert lint(c) == lint(c)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from scigantic_cards import Card, CardError
|
|
6
|
+
from conftest import make_card
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_round_trip():
|
|
10
|
+
c = make_card(gotchas=["a", "b"])
|
|
11
|
+
assert Card.from_json(c.to_json()) == c
|
|
12
|
+
assert Card.from_json(c.to_json(indent=None)) == c
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_round_trip_non_ascii():
|
|
16
|
+
c = make_card(identity="Données d'été, 日本語, 🙂")
|
|
17
|
+
assert Card.from_json(c.to_json()) == c
|
|
18
|
+
assert "日本語" in c.to_json()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_minimal_card():
|
|
22
|
+
c = Card.from_dict({"name": "x"})
|
|
23
|
+
assert c.fields == [] and c.approx_bytes is None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.parametrize("doc,where", [
|
|
27
|
+
([], "card"),
|
|
28
|
+
("x", "card"),
|
|
29
|
+
(None, "card"),
|
|
30
|
+
({}, "card.name"),
|
|
31
|
+
({"name": ""}, "card.name"),
|
|
32
|
+
({"name": 5}, "card.name"),
|
|
33
|
+
({"name": None}, "card.name"),
|
|
34
|
+
({"name": "x", "extra": 1}, "extra"),
|
|
35
|
+
({"name": "x", "approx_bytes": -1}, "card.approx_bytes"),
|
|
36
|
+
({"name": "x", "approx_bytes": True}, "card.approx_bytes"),
|
|
37
|
+
({"name": "x", "approx_bytes": 1.5}, "card.approx_bytes"),
|
|
38
|
+
({"name": "x", "approx_bytes": "10"}, "card.approx_bytes"),
|
|
39
|
+
({"name": "x", "approx_bytes": float("nan")}, "card.approx_bytes"),
|
|
40
|
+
({"name": "x", "approx_bytes": float("inf")}, "card.approx_bytes"),
|
|
41
|
+
({"name": "x", "fields": "a"}, "card.fields"),
|
|
42
|
+
({"name": "x", "fields": [1]}, "card.fields[0]"),
|
|
43
|
+
({"name": "x", "fields": [{"name": "a"}]}, "card.fields[0].dtype"),
|
|
44
|
+
({"name": "x", "fields": [{"name": "a", "dtype": "i", "z": 1}]}, "z"),
|
|
45
|
+
({"name": "x", "gotchas": "a"}, "card.gotchas"),
|
|
46
|
+
({"name": "x", "gotchas": ["a", 2]}, "card.gotchas[1]"),
|
|
47
|
+
({"name": "x", "access": [{"goal": "g"}]}, "card.access[0].snippet"),
|
|
48
|
+
({"name": "x", "access": [{"goal": "g", "snippet": "s", "verified": "yes"}]},
|
|
49
|
+
"card.access[0].verified"),
|
|
50
|
+
])
|
|
51
|
+
def test_rejects_with_path(doc, where):
|
|
52
|
+
with pytest.raises(CardError) as e:
|
|
53
|
+
Card.from_dict(doc)
|
|
54
|
+
assert where in str(e.value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_integral_float_is_accepted_as_int():
|
|
58
|
+
c = Card.from_dict({"name": "x", "approx_bytes": 1e9})
|
|
59
|
+
assert c.approx_bytes == 10**9 and isinstance(c.approx_bytes, int)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_huge_int_is_accepted():
|
|
63
|
+
assert Card.from_dict({"name": "x", "approx_bytes": 10**40}).approx_bytes == 10**40
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_invalid_json_is_a_card_error():
|
|
67
|
+
with pytest.raises(CardError):
|
|
68
|
+
Card.from_json("{not json")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_deep_nesting_is_a_card_error():
|
|
72
|
+
with pytest.raises(CardError):
|
|
73
|
+
Card.from_json("[" * 100_000 + "]" * 100_000)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_card_error_is_a_value_error():
|
|
77
|
+
assert issubclass(CardError, ValueError)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Property-based tests. The loader and the shipped JSON Schema must agree."""
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import jsonschema
|
|
6
|
+
from hypothesis import HealthCheck, given, settings, strategies as st
|
|
7
|
+
|
|
8
|
+
from scigantic_cards import Card, CardError, lint
|
|
9
|
+
from scigantic_cards.cli import main
|
|
10
|
+
|
|
11
|
+
SETTINGS = settings(max_examples=1500, suppress_health_check=[HealthCheck.too_slow], deadline=None)
|
|
12
|
+
|
|
13
|
+
json_leaf = st.none() | st.booleans() | st.integers() | st.floats() | st.text()
|
|
14
|
+
json_any = st.recursive(
|
|
15
|
+
json_leaf,
|
|
16
|
+
lambda c: st.lists(c, max_size=4) | st.dictionaries(st.text(max_size=12), c, max_size=4),
|
|
17
|
+
max_leaves=25,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
text = st.text(max_size=30)
|
|
21
|
+
good_count = st.none() | st.integers(min_value=0, max_value=10**15) | st.sampled_from([0, 1.0, 1e20, -0.0])
|
|
22
|
+
edge = st.sampled_from([-1, -3, 1.5, True, False, None, "", "x", [], {}, float("nan"), float("inf")])
|
|
23
|
+
bad_or_any = edge | json_any
|
|
24
|
+
|
|
25
|
+
valid_field = st.fixed_dictionaries({"name": text, "dtype": text}, optional={"note": text})
|
|
26
|
+
valid_access = st.fixed_dictionaries(
|
|
27
|
+
{"goal": text, "snippet": text}, optional={"verified": st.booleans()}
|
|
28
|
+
)
|
|
29
|
+
valid_doc = st.fixed_dictionaries(
|
|
30
|
+
{"name": st.text(min_size=1, max_size=30)},
|
|
31
|
+
optional={
|
|
32
|
+
"identity": text, "summary": text, "layout": text,
|
|
33
|
+
"approx_bytes": good_count, "approx_items": good_count,
|
|
34
|
+
"fields": st.lists(valid_field, max_size=4),
|
|
35
|
+
"differs_from_upstream": st.lists(text, max_size=4),
|
|
36
|
+
"gotchas": st.lists(text, max_size=4),
|
|
37
|
+
"access": st.lists(valid_access, max_size=4),
|
|
38
|
+
"checked_at": text,
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
COUNT_NEAR = [-1, -3, 1.5, True, False, "1", float("nan"), float("inf"), [], {}]
|
|
43
|
+
STR_NEAR = [5, None, [], {}, True, 1.5]
|
|
44
|
+
NEAR = {
|
|
45
|
+
"name": ["", 5, None, [], True],
|
|
46
|
+
"approx_bytes": COUNT_NEAR, "approx_items": COUNT_NEAR,
|
|
47
|
+
"fields": ["a", None, {}, [1], [None], [{}]],
|
|
48
|
+
"access": ["a", None, {}, [1], [None], [{}]],
|
|
49
|
+
"differs_from_upstream": ["a", None, {}, [1], [None]],
|
|
50
|
+
"gotchas": ["a", None, {}, [1], [None]],
|
|
51
|
+
"bogus": ["x", 1, None],
|
|
52
|
+
}
|
|
53
|
+
SUB_NEAR = {
|
|
54
|
+
"field": {"name": STR_NEAR, "dtype": STR_NEAR + [None], "note": STR_NEAR, "bogus": ["x", 1]},
|
|
55
|
+
"access": {"goal": STR_NEAR, "snippet": STR_NEAR, "verified": ["yes", 1, 0, None, "true"],
|
|
56
|
+
"bogus": ["x", 1]},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
TOP = ["name", "identity", "summary", "layout", "approx_bytes", "approx_items", "fields",
|
|
60
|
+
"differs_from_upstream", "gotchas", "access", "checked_at", "bogus"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _dict_items(v):
|
|
64
|
+
return isinstance(v, list) and v and all(isinstance(x, dict) for x in v)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@st.composite
|
|
68
|
+
def cardish(draw):
|
|
69
|
+
"""A valid card with up to two single-value mutations, at top level or one level down."""
|
|
70
|
+
doc = draw(valid_doc)
|
|
71
|
+
for _ in range(draw(st.integers(0, 2))):
|
|
72
|
+
where = draw(st.sampled_from(["top", "field", "access", "list_item", "drop"]))
|
|
73
|
+
value = draw(bad_or_any)
|
|
74
|
+
if where == "top":
|
|
75
|
+
key = draw(st.sampled_from(TOP))
|
|
76
|
+
near = NEAR.get(key, STR_NEAR)
|
|
77
|
+
doc[key] = draw(st.sampled_from(near)) if draw(st.booleans()) else value
|
|
78
|
+
elif where == "field" and _dict_items(doc.get("fields")):
|
|
79
|
+
i = draw(st.integers(0, len(doc["fields"]) - 1))
|
|
80
|
+
key = draw(st.sampled_from(sorted(SUB_NEAR["field"])))
|
|
81
|
+
doc["fields"][i][key] = draw(st.sampled_from(SUB_NEAR["field"][key])) if draw(st.booleans()) else value
|
|
82
|
+
elif where == "access" and _dict_items(doc.get("access")):
|
|
83
|
+
i = draw(st.integers(0, len(doc["access"]) - 1))
|
|
84
|
+
key = draw(st.sampled_from(sorted(SUB_NEAR["access"])))
|
|
85
|
+
doc["access"][i][key] = draw(st.sampled_from(SUB_NEAR["access"][key])) if draw(st.booleans()) else value
|
|
86
|
+
elif where == "drop":
|
|
87
|
+
target = draw(st.sampled_from(["top", "fields", "access"]))
|
|
88
|
+
if target == "top":
|
|
89
|
+
doc.pop(draw(st.sampled_from(TOP)), None)
|
|
90
|
+
elif _dict_items(doc.get(target)):
|
|
91
|
+
item = doc[target][0]
|
|
92
|
+
item.pop(draw(st.sampled_from(sorted(item))), None)
|
|
93
|
+
elif where == "list_item":
|
|
94
|
+
key = draw(st.sampled_from(["gotchas", "differs_from_upstream", "fields", "access"]))
|
|
95
|
+
if isinstance(doc.get(key), list):
|
|
96
|
+
doc[key].append(value)
|
|
97
|
+
return doc
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def schema_accepts(schema, doc) -> bool:
|
|
101
|
+
try:
|
|
102
|
+
jsonschema.validate(doc, schema)
|
|
103
|
+
return True
|
|
104
|
+
except jsonschema.ValidationError:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def loader_accepts(doc) -> bool:
|
|
109
|
+
try:
|
|
110
|
+
Card.from_dict(doc)
|
|
111
|
+
return True
|
|
112
|
+
except CardError:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@SETTINGS
|
|
117
|
+
@given(cardish())
|
|
118
|
+
def test_loader_matches_schema_on_card_shaped_input(schema, doc):
|
|
119
|
+
assert loader_accepts(doc) == schema_accepts(schema, doc), doc
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@SETTINGS
|
|
123
|
+
@given(json_any)
|
|
124
|
+
def test_loader_matches_schema_on_arbitrary_json(schema, doc):
|
|
125
|
+
assert loader_accepts(doc) == schema_accepts(schema, doc), doc
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@SETTINGS
|
|
129
|
+
@given(cardish())
|
|
130
|
+
def test_accepted_cards_lint_and_round_trip(doc):
|
|
131
|
+
try:
|
|
132
|
+
card = Card.from_dict(doc)
|
|
133
|
+
except CardError:
|
|
134
|
+
return
|
|
135
|
+
lint(card) # must not raise
|
|
136
|
+
assert Card.from_json(card.to_json()) == card
|
|
137
|
+
assert Card.from_json(card.to_json(indent=None)) == card
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@SETTINGS
|
|
141
|
+
@given(json_any)
|
|
142
|
+
def test_loader_only_ever_raises_card_error(doc):
|
|
143
|
+
try:
|
|
144
|
+
Card.from_dict(doc)
|
|
145
|
+
except CardError:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@settings(max_examples=150, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
150
|
+
@given(st.binary(max_size=2000))
|
|
151
|
+
def test_cli_survives_arbitrary_bytes(tmp_path_factory, data):
|
|
152
|
+
p = tmp_path_factory.mktemp("f") / "card.json"
|
|
153
|
+
p.write_bytes(data)
|
|
154
|
+
assert main(["lint", str(p)]) in (0, 1, 2)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_cli_survives_non_files(tmp_path):
|
|
158
|
+
assert main(["lint", str(tmp_path)]) == 2 # a directory
|
|
159
|
+
assert main(["lint", str(tmp_path / "nope.json")]) == 2
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_cli_handles_bom_and_crlf(tmp_path):
|
|
163
|
+
p = tmp_path / "c.json"
|
|
164
|
+
body = json.dumps({"name": "x"}, indent=2).replace("\n", "\r\n")
|
|
165
|
+
p.write_bytes(b"\xef\xbb\xbf" + body.encode())
|
|
166
|
+
assert main(["lint", str(p)]) == 1 # loads; lint errors on the empty card
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_cli_rejects_oversized_file(tmp_path, capsys):
|
|
170
|
+
p = tmp_path / "big.json"
|
|
171
|
+
p.write_text('{"name": "x", "summary": "' + "a" * 6_000_000 + '"}')
|
|
172
|
+
assert main(["lint", str(p)]) == 2
|
|
173
|
+
assert "larger than" in capsys.readouterr().err
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_cli_exit_codes_and_strict(tmp_path, capsys):
|
|
177
|
+
from conftest import make_card
|
|
178
|
+
ok = tmp_path / "ok.json"
|
|
179
|
+
ok.write_text(make_card().to_json())
|
|
180
|
+
warn = tmp_path / "warn.json"
|
|
181
|
+
warn.write_text(make_card(checked_at="").to_json())
|
|
182
|
+
bad = tmp_path / "bad.json"
|
|
183
|
+
bad.write_text("[]")
|
|
184
|
+
assert main(["lint", str(ok)]) == 0
|
|
185
|
+
assert main(["lint", str(warn)]) == 0
|
|
186
|
+
assert main(["lint", "--strict", str(warn)]) == 1
|
|
187
|
+
assert main(["lint", str(bad)]) == 2
|
|
188
|
+
# one bad file does not stop later files from being linted
|
|
189
|
+
assert main(["lint", str(bad), str(warn), "--strict"]) == 2
|
|
190
|
+
assert "warn.json" in capsys.readouterr().out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_large_card_lints_quickly():
|
|
194
|
+
from conftest import make_card
|
|
195
|
+
from scigantic_cards import Field
|
|
196
|
+
card = make_card(fields=[Field(f"f{i}", "int") for i in range(100_000)])
|
|
197
|
+
t = time.perf_counter()
|
|
198
|
+
issues = lint(card)
|
|
199
|
+
assert time.perf_counter() - t < 5
|
|
200
|
+
assert any(i.rule == "budget" for i in issues)
|