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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .card import Access, Card, CardError, Field
2
+ from .lint import Issue, lint
3
+
4
+ __all__ = ["Access", "Card", "CardError", "Field", "Issue", "lint"]
@@ -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,2 @@
1
+ [console_scripts]
2
+ scigantic-cards = scigantic_cards.cli:main
@@ -0,0 +1,6 @@
1
+
2
+ [dev]
3
+ pytest
4
+ mypy
5
+ jsonschema
6
+ hypothesis
@@ -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)