labschema 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
labschema/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """LabSchema: Typed schemas with physical units and validation for lab-style tables."""
2
+
3
+ from labschema.errors import ErrorCode, LabSchemaError, SchemaError, ValidationIssue
4
+ from labschema.fields import Field
5
+ from labschema.infer import infer_schema
6
+ from labschema.schema import TableSchema
7
+ from labschema.units import u
8
+ from labschema.validate import ValidationReport, ValidationResult, validate_rows
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = [
13
+ "Field",
14
+ "TableSchema",
15
+ "u",
16
+ "ValidationResult",
17
+ "ValidationReport",
18
+ "ValidationIssue",
19
+ "ErrorCode",
20
+ "SchemaError",
21
+ "LabSchemaError",
22
+ "infer_schema",
23
+ "validate_rows",
24
+ "__version__",
25
+ ]
labschema/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m labschema."""
2
+
3
+ from labschema.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
labschema/cli.py ADDED
@@ -0,0 +1,309 @@
1
+ """Command-line interface for LabSchema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import NoReturn
11
+
12
+ from labschema import __version__
13
+ from labschema.errors import LabSchemaError, ReadError, SchemaError
14
+ from labschema.infer import infer_schema, schema_to_python
15
+ from labschema.io import read_csv
16
+ from labschema.schema import TableSchema
17
+
18
+
19
+ def _load_schema(schema_path: str) -> TableSchema:
20
+ """Load a schema from a file path or Python import path.
21
+
22
+ Args:
23
+ schema_path: Either a file path (ending in .json) or a Python import
24
+ path like 'mymodule:schema'.
25
+
26
+ Returns:
27
+ The loaded TableSchema.
28
+
29
+ Raises:
30
+ SchemaError: If the schema cannot be loaded.
31
+ """
32
+ if ":" in schema_path and not Path(schema_path).exists():
33
+ module_path, attr_name = schema_path.rsplit(":", 1)
34
+ try:
35
+ module = importlib.import_module(module_path)
36
+ except ImportError as e:
37
+ raise SchemaError(f"Cannot import module {module_path!r}: {e}") from e
38
+
39
+ try:
40
+ schema = getattr(module, attr_name)
41
+ except AttributeError as e:
42
+ raise SchemaError(f"Module {module_path!r} has no attribute {attr_name!r}") from e
43
+
44
+ if not isinstance(schema, TableSchema):
45
+ raise SchemaError(
46
+ f"{module_path}:{attr_name} is not a TableSchema (got {type(schema).__name__})"
47
+ )
48
+ return schema
49
+
50
+ return TableSchema.load(schema_path)
51
+
52
+
53
+ def cmd_validate(args: argparse.Namespace) -> int:
54
+ """Run the validate command."""
55
+ try:
56
+ schema = _load_schema(args.schema)
57
+ except (SchemaError, ReadError) as e:
58
+ print(f"Error loading schema: {e}", file=sys.stderr)
59
+ return 2
60
+
61
+ na_values = list(args.na) if args.na else []
62
+
63
+ try:
64
+ result = read_csv(
65
+ args.data,
66
+ schema,
67
+ delimiter=args.delimiter,
68
+ decimal=args.decimal,
69
+ thousands=args.thousands,
70
+ encoding=args.encoding,
71
+ extra_na_values=na_values,
72
+ skip_rows=args.skip_rows,
73
+ comment=args.comment,
74
+ max_errors=args.max_errors if args.max_errors > 0 else None,
75
+ )
76
+ except ReadError as e:
77
+ print(f"Error reading file: {e}", file=sys.stderr)
78
+ return 2
79
+
80
+ if args.strict:
81
+ has_problems = len(result.report.errors) > 0 or len(result.report.warnings) > 0
82
+ else:
83
+ has_problems = len(result.report.errors) > 0
84
+
85
+ if args.quiet:
86
+ return 1 if has_problems else 0
87
+
88
+ if args.format == "json":
89
+ output = {
90
+ "file": str(args.data),
91
+ "total_rows": result.total_rows,
92
+ "valid_rows": result.valid_rows,
93
+ **result.report.to_dict(),
94
+ }
95
+ print(json.dumps(output, indent=2))
96
+ else:
97
+ error_count = len(result.report.errors)
98
+ warning_count = len(result.report.warnings)
99
+ print(
100
+ f"{args.data}: {result.total_rows} rows, "
101
+ f"{result.valid_rows} valid, "
102
+ f"{error_count} errors, "
103
+ f"{warning_count} warnings"
104
+ )
105
+
106
+ if result.report.issues:
107
+ result.report.print_summary()
108
+
109
+ return 1 if has_problems else 0
110
+
111
+
112
+ def cmd_infer(args: argparse.Namespace) -> int:
113
+ """Run the infer command."""
114
+ try:
115
+ schema = infer_schema(
116
+ args.data,
117
+ sample_rows=args.sample_rows,
118
+ name=args.name,
119
+ )
120
+ except ReadError as e:
121
+ print(f"Error reading file: {e}", file=sys.stderr)
122
+ return 2
123
+
124
+ output = schema_to_python(schema) if args.format == "python" else schema.to_json()
125
+
126
+ if args.output:
127
+ Path(args.output).write_text(output, encoding="utf-8")
128
+ else:
129
+ print(output)
130
+
131
+ return 0
132
+
133
+
134
+ def cmd_export_schema(args: argparse.Namespace) -> int:
135
+ """Run the export-schema command."""
136
+ try:
137
+ schema = _load_schema(args.schema)
138
+ except (SchemaError, ReadError) as e:
139
+ print(f"Error loading schema: {e}", file=sys.stderr)
140
+ return 2
141
+
142
+ if args.format == "jsonschema":
143
+ output = json.dumps(schema.to_json_schema(), indent=2)
144
+ else:
145
+ output = schema.to_json()
146
+
147
+ if args.output:
148
+ Path(args.output).write_text(output, encoding="utf-8")
149
+ else:
150
+ print(output)
151
+
152
+ return 0
153
+
154
+
155
+ def main(argv: list[str] | None = None) -> NoReturn:
156
+ """Main entry point for the labschema CLI."""
157
+ parser = argparse.ArgumentParser(
158
+ prog="labschema",
159
+ description="Validate lab-style CSV/TSV files against typed schemas with units.",
160
+ )
161
+ parser.add_argument(
162
+ "--version",
163
+ action="version",
164
+ version=f"labschema {__version__}",
165
+ )
166
+
167
+ subparsers = parser.add_subparsers(dest="command", required=True)
168
+
169
+ validate_parser = subparsers.add_parser(
170
+ "validate",
171
+ help="Validate a data file against a schema.",
172
+ )
173
+ validate_parser.add_argument(
174
+ "data",
175
+ help="Path to the CSV/TSV file to validate.",
176
+ )
177
+ validate_parser.add_argument(
178
+ "--schema",
179
+ required=True,
180
+ help="Path to a LabSchema JSON file, or a Python import path (module:attr).",
181
+ )
182
+ validate_parser.add_argument(
183
+ "--delimiter",
184
+ help="Column delimiter (default: auto-detect).",
185
+ )
186
+ validate_parser.add_argument(
187
+ "--decimal",
188
+ default=".",
189
+ choices=[".", ","],
190
+ help="Decimal separator (default: '.').",
191
+ )
192
+ validate_parser.add_argument(
193
+ "--thousands",
194
+ help="Thousands separator to strip.",
195
+ )
196
+ validate_parser.add_argument(
197
+ "--encoding",
198
+ default="utf-8-sig",
199
+ help="File encoding (default: utf-8-sig).",
200
+ )
201
+ validate_parser.add_argument(
202
+ "--skip-rows",
203
+ type=int,
204
+ default=0,
205
+ dest="skip_rows",
206
+ help="Lines to skip before the header.",
207
+ )
208
+ validate_parser.add_argument(
209
+ "--comment",
210
+ help="Lines starting with this prefix are ignored.",
211
+ )
212
+ validate_parser.add_argument(
213
+ "--na",
214
+ action="append",
215
+ help="Extra missing-value sentinel (repeatable).",
216
+ )
217
+ validate_parser.add_argument(
218
+ "--max-errors",
219
+ type=int,
220
+ default=1000,
221
+ dest="max_errors",
222
+ help="Maximum errors to collect (0 = unlimited, default: 1000).",
223
+ )
224
+ validate_parser.add_argument(
225
+ "--format",
226
+ choices=["text", "json"],
227
+ default="text",
228
+ help="Output format (default: text).",
229
+ )
230
+ validate_parser.add_argument(
231
+ "--strict",
232
+ action="store_true",
233
+ help="Treat warnings as errors.",
234
+ )
235
+ validate_parser.add_argument(
236
+ "--quiet",
237
+ action="store_true",
238
+ help="Print nothing; rely on exit code.",
239
+ )
240
+ validate_parser.set_defaults(func=cmd_validate)
241
+
242
+ infer_parser = subparsers.add_parser(
243
+ "infer",
244
+ help="Infer a draft schema from a data file.",
245
+ )
246
+ infer_parser.add_argument(
247
+ "data",
248
+ help="Path to the CSV/TSV file to analyze.",
249
+ )
250
+ infer_parser.add_argument(
251
+ "--sample-rows",
252
+ type=int,
253
+ default=100,
254
+ dest="sample_rows",
255
+ help="Number of rows to sample (default: 100).",
256
+ )
257
+ infer_parser.add_argument(
258
+ "--name",
259
+ help="Schema name (default: derived from filename).",
260
+ )
261
+ infer_parser.add_argument(
262
+ "--format",
263
+ choices=["json", "python"],
264
+ default="json",
265
+ help="Output format (default: json).",
266
+ )
267
+ infer_parser.add_argument(
268
+ "-o",
269
+ "--output",
270
+ help="Output file path (default: stdout).",
271
+ )
272
+ infer_parser.set_defaults(func=cmd_infer)
273
+
274
+ export_parser = subparsers.add_parser(
275
+ "export-schema",
276
+ help="Export a schema to JSON or JSON Schema format.",
277
+ )
278
+ export_parser.add_argument(
279
+ "schema",
280
+ help="Path to a LabSchema JSON file, or a Python import path (module:attr).",
281
+ )
282
+ export_parser.add_argument(
283
+ "--format",
284
+ choices=["labschema", "jsonschema"],
285
+ default="labschema",
286
+ help="Output format (default: labschema).",
287
+ )
288
+ export_parser.add_argument(
289
+ "-o",
290
+ "--output",
291
+ help="Output file path (default: stdout).",
292
+ )
293
+ export_parser.set_defaults(func=cmd_export_schema)
294
+
295
+ args = parser.parse_args(argv)
296
+
297
+ try:
298
+ exit_code = args.func(args)
299
+ except LabSchemaError as e:
300
+ print(f"Error: {e}", file=sys.stderr)
301
+ sys.exit(2)
302
+ except KeyboardInterrupt:
303
+ sys.exit(130)
304
+
305
+ sys.exit(exit_code)
306
+
307
+
308
+ if __name__ == "__main__":
309
+ main()
labschema/errors.py ADDED
@@ -0,0 +1,101 @@
1
+ """Error codes, validation issues, and exceptions for LabSchema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+
10
+ class ErrorCode(str, Enum):
11
+ """Stable machine-readable error codes for validation issues.
12
+
13
+ These codes are part of the public API and will not change meaning
14
+ within a major version.
15
+ """
16
+
17
+ MISSING_COLUMN = "missing_column"
18
+ EXTRA_COLUMN = "extra_column"
19
+ DUPLICATE_COLUMN = "duplicate_column"
20
+ UNIT_UNKNOWN = "unit_unknown"
21
+ UNIT_INCOMPATIBLE = "unit_incompatible"
22
+ UNIT_UNEXPECTED = "unit_unexpected"
23
+ UNIT_AMBIGUOUS = "unit_ambiguous"
24
+ ROW_LENGTH_MISMATCH = "row_length_mismatch"
25
+ NULL_NOT_ALLOWED = "null_not_allowed"
26
+ TYPE_ERROR = "type_error"
27
+ LESS_THAN_MINIMUM = "less_than_minimum"
28
+ GREATER_THAN_MAXIMUM = "greater_than_maximum"
29
+ NOT_IN_ENUM = "not_in_enum"
30
+ PATTERN_MISMATCH = "pattern_mismatch"
31
+ CUSTOM_VALIDATOR = "custom_validator"
32
+ DUPLICATE_PRIMARY_KEY = "duplicate_primary_key"
33
+ PARSE_ERROR = "parse_error"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ValidationIssue:
38
+ """A single validation issue found during validation.
39
+
40
+ Attributes:
41
+ code: Stable machine-readable error code.
42
+ severity: 'error' or 'warning'. Only errors make a result invalid.
43
+ message: Human-readable description of the issue.
44
+ row: 1-based file line number (header is line 1). None for file-level issues.
45
+ column: Raw header text as it appears in the file. None for row-level issues.
46
+ field_name: Schema field name. None if the issue isn't field-specific.
47
+ value: Raw cell text (truncated to 80 chars). None if not applicable.
48
+ context: Code-specific details (e.g., {'ge': 0, 'unit': 'mg/L'}).
49
+ """
50
+
51
+ code: ErrorCode
52
+ severity: str
53
+ message: str
54
+ row: int | None = None
55
+ column: str | None = None
56
+ field_name: str | None = None
57
+ value: str | None = None
58
+ context: dict[str, Any] = field(default_factory=dict)
59
+
60
+ def to_dict(self) -> dict[str, Any]:
61
+ """Convert the issue to a dictionary for JSON serialization."""
62
+ result: dict[str, Any] = {
63
+ "code": self.code.value,
64
+ "severity": self.severity,
65
+ "message": self.message,
66
+ }
67
+ if self.row is not None:
68
+ result["row"] = self.row
69
+ if self.column is not None:
70
+ result["column"] = self.column
71
+ if self.field_name is not None:
72
+ result["field"] = self.field_name
73
+ if self.value is not None:
74
+ result["value"] = self.value
75
+ if self.context:
76
+ result["context"] = self.context
77
+ return result
78
+
79
+
80
+ class LabSchemaError(Exception):
81
+ """Base exception for all LabSchema errors."""
82
+
83
+ pass
84
+
85
+
86
+ class SchemaError(LabSchemaError):
87
+ """Exception raised for invalid schema definitions.
88
+
89
+ This is raised at construction or load time, not during validation.
90
+ """
91
+
92
+ pass
93
+
94
+
95
+ class ReadError(LabSchemaError):
96
+ """Exception raised when a file cannot be read.
97
+
98
+ This includes encoding failures, empty files, and other I/O errors.
99
+ """
100
+
101
+ pass