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/fields.py ADDED
@@ -0,0 +1,270 @@
1
+ """Field definition for LabSchema table columns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable, Collection, Sequence
7
+ from dataclasses import dataclass
8
+ from dataclasses import field as dataclass_field
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from labschema.errors import SchemaError
12
+ from labschema.units import get_unit_string, parse_unit
13
+
14
+ if TYPE_CHECKING:
15
+ from pint import Unit
16
+
17
+ _VALID_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
18
+
19
+ _ALLOWED_DTYPES = (int, float, str, bool)
20
+
21
+
22
+ class _UnsetType:
23
+ """Sentinel for unset default values."""
24
+
25
+ _instance: _UnsetType | None = None
26
+
27
+ def __new__(cls) -> _UnsetType:
28
+ if cls._instance is None:
29
+ cls._instance = super().__new__(cls)
30
+ return cls._instance
31
+
32
+ def __repr__(self) -> str:
33
+ return "<UNSET>"
34
+
35
+ def __bool__(self) -> bool:
36
+ return False
37
+
38
+
39
+ _UNSET: _UnsetType = _UnsetType()
40
+
41
+
42
+ def _validate_name(name: str) -> None:
43
+ """Validate that a field name is a valid Python identifier-like string."""
44
+ if not _VALID_NAME_PATTERN.match(name):
45
+ raise SchemaError(f"Invalid field name: {name!r}. Names must match [A-Za-z_][A-Za-z0-9_]*")
46
+
47
+
48
+ @dataclass(frozen=True, eq=True)
49
+ class Field:
50
+ """Describes a single column in a table schema.
51
+
52
+ Attributes:
53
+ name: Canonical field name. Must be a valid Python identifier-like string.
54
+ dtype: One of int, float, str, bool.
55
+ unit: Declared unit. Only allowed when dtype is int or float.
56
+ nullable: Whether missing values are allowed.
57
+ description: Human-readable description; exported to JSON Schema.
58
+ aliases: Extra header names that map to this field.
59
+ ge: Greater than or equal constraint (numeric fields only).
60
+ gt: Greater than constraint (numeric fields only).
61
+ le: Less than or equal constraint (numeric fields only).
62
+ lt: Less than constraint (numeric fields only).
63
+ enum: Allowed values.
64
+ pattern: Regex pattern for str fields. Matched with re.fullmatch.
65
+ validators: Custom callables (value) -> value; raise ValueError to reject.
66
+ default: Value substituted for missing cells.
67
+ """
68
+
69
+ name: str
70
+ dtype: type
71
+ unit: Unit | str | None = None
72
+ nullable: bool = False
73
+ description: str | None = None
74
+ aliases: tuple[str, ...] = ()
75
+ ge: float | None = None
76
+ gt: float | None = None
77
+ le: float | None = None
78
+ lt: float | None = None
79
+ enum: Collection[Any] | None = None
80
+ pattern: str | None = None
81
+ validators: Sequence[Callable[[Any], Any]] = ()
82
+ default: Any = dataclass_field(default_factory=lambda: _UNSET)
83
+
84
+ def __post_init__(self) -> None:
85
+ """Validate field configuration at construction time."""
86
+ _validate_name(self.name)
87
+
88
+ if self.dtype not in _ALLOWED_DTYPES:
89
+ raise SchemaError(
90
+ f"Invalid dtype for field {self.name!r}: {self.dtype}. "
91
+ f"Allowed types: int, float, str, bool"
92
+ )
93
+
94
+ if self.unit is not None and self.dtype not in (int, float):
95
+ raise SchemaError(
96
+ f"Field {self.name!r}: unit is only allowed for numeric types (int, float)"
97
+ )
98
+
99
+ if self.unit is not None:
100
+ try:
101
+ parse_unit(self.unit if isinstance(self.unit, str) else str(self.unit))
102
+ except SchemaError as e:
103
+ raise SchemaError(f"Field {self.name!r}: {e}") from e
104
+
105
+ if self.pattern is not None and self.dtype is not str:
106
+ raise SchemaError(f"Field {self.name!r}: pattern is only allowed for str type")
107
+
108
+ if self.pattern is not None:
109
+ try:
110
+ re.compile(self.pattern)
111
+ except re.error as e:
112
+ raise SchemaError(f"Field {self.name!r}: invalid regex pattern: {e}") from e
113
+
114
+ if self.dtype not in (int, float) and any(
115
+ b is not None for b in (self.ge, self.gt, self.le, self.lt)
116
+ ):
117
+ raise SchemaError(
118
+ f"Field {self.name!r}: bounds (ge/gt/le/lt) are only allowed "
119
+ f"for numeric types (int, float)"
120
+ )
121
+
122
+ if self.ge is not None and self.gt is not None:
123
+ raise SchemaError(f"Field {self.name!r}: cannot set both 'ge' and 'gt'")
124
+
125
+ if self.le is not None and self.lt is not None:
126
+ raise SchemaError(f"Field {self.name!r}: cannot set both 'le' and 'lt'")
127
+
128
+ def __hash__(self) -> int:
129
+ """Make Field hashable by using only hashable attributes."""
130
+ enum_hash = tuple(self.enum) if self.enum is not None else None
131
+ validators_hash = tuple(id(v) for v in self.validators)
132
+ default_hash = id(self.default) if self.default is _UNSET else hash(str(self.default))
133
+ return hash(
134
+ (
135
+ self.name,
136
+ self.dtype,
137
+ get_unit_string(self.unit),
138
+ self.nullable,
139
+ self.description,
140
+ self.aliases,
141
+ self.ge,
142
+ self.gt,
143
+ self.le,
144
+ self.lt,
145
+ enum_hash,
146
+ self.pattern,
147
+ validators_hash,
148
+ default_hash,
149
+ )
150
+ )
151
+
152
+ @property
153
+ def has_default(self) -> bool:
154
+ """Check if this field has a default value set."""
155
+ return self.default is not _UNSET
156
+
157
+ @property
158
+ def unit_string(self) -> str | None:
159
+ """Get the canonical string representation of the unit."""
160
+ return get_unit_string(self.unit)
161
+
162
+ def to_dict(self) -> dict[str, Any]:
163
+ """Convert the field to a dictionary for JSON serialization."""
164
+ result: dict[str, Any] = {
165
+ "name": self.name,
166
+ "dtype": self.dtype.__name__,
167
+ }
168
+
169
+ if self.unit is not None:
170
+ result["unit"] = self.unit_string
171
+
172
+ if self.nullable:
173
+ result["nullable"] = True
174
+
175
+ if self.description is not None:
176
+ result["description"] = self.description
177
+
178
+ if self.aliases:
179
+ result["aliases"] = list(self.aliases)
180
+
181
+ if self.ge is not None:
182
+ result["ge"] = self.ge
183
+ if self.gt is not None:
184
+ result["gt"] = self.gt
185
+ if self.le is not None:
186
+ result["le"] = self.le
187
+ if self.lt is not None:
188
+ result["lt"] = self.lt
189
+
190
+ if self.enum is not None:
191
+ result["enum"] = list(self.enum)
192
+
193
+ if self.pattern is not None:
194
+ result["pattern"] = self.pattern
195
+
196
+ if self.validators:
197
+ result["validators"] = [getattr(v, "__name__", repr(v)) for v in self.validators]
198
+
199
+ if self.has_default:
200
+ result["default"] = self.default
201
+
202
+ return result
203
+
204
+ @classmethod
205
+ def from_dict(cls, data: dict[str, Any]) -> Field:
206
+ """Create a Field from a dictionary.
207
+
208
+ Args:
209
+ data: Dictionary with field attributes.
210
+
211
+ Returns:
212
+ A new Field instance.
213
+
214
+ Raises:
215
+ SchemaError: If the dictionary contains invalid data.
216
+ """
217
+ dtype_map = {"int": int, "float": float, "str": str, "bool": bool}
218
+
219
+ name = data.get("name")
220
+ if not isinstance(name, str):
221
+ raise SchemaError("Field 'name' is required and must be a string")
222
+
223
+ dtype_str = data.get("dtype")
224
+ if dtype_str not in dtype_map:
225
+ raise SchemaError(
226
+ f"Field {name!r}: invalid dtype {dtype_str!r}. Allowed: int, float, str, bool"
227
+ )
228
+ dtype = dtype_map[dtype_str]
229
+
230
+ kwargs: dict[str, Any] = {
231
+ "name": name,
232
+ "dtype": dtype,
233
+ }
234
+
235
+ if "unit" in data:
236
+ kwargs["unit"] = data["unit"]
237
+
238
+ if data.get("nullable"):
239
+ kwargs["nullable"] = True
240
+
241
+ if "description" in data:
242
+ kwargs["description"] = data["description"]
243
+
244
+ if "aliases" in data:
245
+ kwargs["aliases"] = tuple(data["aliases"])
246
+
247
+ for bound in ("ge", "gt", "le", "lt"):
248
+ if bound in data:
249
+ kwargs[bound] = data[bound]
250
+
251
+ if "enum" in data:
252
+ kwargs["enum"] = data["enum"]
253
+
254
+ if "pattern" in data:
255
+ kwargs["pattern"] = data["pattern"]
256
+
257
+ if "default" in data:
258
+ kwargs["default"] = data["default"]
259
+
260
+ if "validators" in data and data["validators"]:
261
+ import warnings
262
+
263
+ warnings.warn(
264
+ f"Field {name!r}: validators cannot be deserialized from JSON. "
265
+ f"Original validators: {data['validators']}",
266
+ UserWarning,
267
+ stacklevel=2,
268
+ )
269
+
270
+ return cls(**kwargs)
labschema/infer.py ADDED
@@ -0,0 +1,383 @@
1
+ """Schema inference from CSV/TSV files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import os
8
+ from collections.abc import Collection
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any, Literal, TextIO
11
+
12
+ from labschema.errors import ReadError
13
+ from labschema.fields import Field
14
+ from labschema.schema import TableSchema, normalize_name
15
+ from labschema.units import parse_header_unit
16
+ from labschema.validate import DEFAULT_NA_VALUES
17
+
18
+ if TYPE_CHECKING:
19
+ pass
20
+
21
+
22
+ def _get_version() -> str:
23
+ """Get version without circular import."""
24
+ from labschema import __version__
25
+
26
+ return __version__
27
+
28
+
29
+ def _detect_delimiter(sample: str) -> str:
30
+ """Detect the delimiter from a sample of the file."""
31
+ try:
32
+ dialect = csv.Sniffer().sniff(sample, delimiters=",;\t|")
33
+ return dialect.delimiter
34
+ except csv.Error:
35
+ return ","
36
+
37
+
38
+ def _try_parse_bool(value: str) -> bool | None:
39
+ """Try to parse a value as boolean."""
40
+ lower = value.lower()
41
+ if lower in ("true", "yes", "y", "1", "t"):
42
+ return True
43
+ if lower in ("false", "no", "n", "0", "f"):
44
+ return False
45
+ return None
46
+
47
+
48
+ def _try_parse_int(value: str, decimal: str = ".") -> int | None:
49
+ """Try to parse a value as integer."""
50
+ try:
51
+ if decimal != ".":
52
+ value = value.replace(decimal, ".")
53
+
54
+ if "." in value:
55
+ float_val = float(value)
56
+ if float_val == int(float_val):
57
+ return int(float_val)
58
+ return None
59
+ return int(value)
60
+ except (ValueError, TypeError):
61
+ return None
62
+
63
+
64
+ def _try_parse_float(value: str, decimal: str = ".") -> float | None:
65
+ """Try to parse a value as float."""
66
+ try:
67
+ if decimal != ".":
68
+ value = value.replace(decimal, ".")
69
+ return float(value)
70
+ except (ValueError, TypeError):
71
+ return None
72
+
73
+
74
+ def _infer_dtype(values: list[str], decimal: str = ".") -> tuple[type, bool]:
75
+ """Infer the dtype from a list of non-missing values.
76
+
77
+ Returns:
78
+ (dtype, nullable) where nullable is True if there were any missing values.
79
+ """
80
+ if not values:
81
+ return str, True
82
+
83
+ all_bool = True
84
+ all_int = True
85
+ all_float = True
86
+
87
+ for v in values:
88
+ if all_bool and _try_parse_bool(v) is None:
89
+ all_bool = False
90
+
91
+ if all_int and _try_parse_int(v, decimal) is None:
92
+ all_int = False
93
+
94
+ if all_float and _try_parse_float(v, decimal) is None:
95
+ all_float = False
96
+
97
+ if not (all_bool or all_int or all_float):
98
+ break
99
+
100
+ if all_bool:
101
+ return bool, False
102
+ if all_int:
103
+ return int, False
104
+ if all_float:
105
+ return float, False
106
+ return str, False
107
+
108
+
109
+ def _format_observed_range(values: list[Any], dtype: type) -> str | None:
110
+ """Format the observed range for a description."""
111
+ if not values:
112
+ return None
113
+
114
+ if dtype in (int, float):
115
+ numeric_values = []
116
+ for v in values:
117
+ parsed = _try_parse_int(str(v)) if dtype is int else _try_parse_float(str(v))
118
+ if parsed is not None:
119
+ numeric_values.append(parsed)
120
+
121
+ if numeric_values:
122
+ min_val = min(numeric_values)
123
+ max_val = max(numeric_values)
124
+ return f"observed range {min_val}–{max_val} in {len(values)} sampled rows"
125
+
126
+ return None
127
+
128
+
129
+ def infer_schema(
130
+ source: str | os.PathLike[str] | TextIO,
131
+ *,
132
+ sample_rows: int = 100,
133
+ name: str | None = None,
134
+ delimiter: str | None = None,
135
+ decimal: Literal[".", ","] = ".",
136
+ thousands: str | None = None,
137
+ encoding: str = "utf-8-sig",
138
+ na_values: Collection[str] | None = None,
139
+ extra_na_values: Collection[str] = (),
140
+ skip_rows: int = 0,
141
+ comment: str | None = None,
142
+ strip: bool = True,
143
+ ) -> TableSchema:
144
+ """Infer a draft schema from a CSV/TSV file.
145
+
146
+ The inferred schema is a draft meant for human review before use.
147
+ Units are only inferred from headers, never from values.
148
+
149
+ Args:
150
+ source: File path, Path object, or text stream.
151
+ sample_rows: Number of rows to sample for type inference.
152
+ name: Schema name (default: derived from filename or 'inferred_schema').
153
+ delimiter: Column delimiter. None = auto-detect.
154
+ decimal: Decimal separator for numeric fields.
155
+ thousands: Thousands separator to strip.
156
+ encoding: File encoding (default: utf-8-sig to handle BOM).
157
+ na_values: Replace the default missing-value sentinel set.
158
+ extra_na_values: Add to the default missing-value sentinel set.
159
+ skip_rows: Lines to skip before the header.
160
+ comment: Lines starting with this prefix are ignored.
161
+ strip: Trim whitespace around cells.
162
+
163
+ Returns:
164
+ A TableSchema with inferred field types and units.
165
+
166
+ Raises:
167
+ ReadError: If the file cannot be read.
168
+ """
169
+ if na_values is not None:
170
+ all_na_values = frozenset(na_values)
171
+ else:
172
+ all_na_values = DEFAULT_NA_VALUES | frozenset(extra_na_values)
173
+
174
+ source_name: str | None = None
175
+
176
+ if isinstance(source, (str, os.PathLike)):
177
+ path = Path(source)
178
+ source_name = str(source)
179
+ try:
180
+ text_content = path.read_text(encoding=encoding)
181
+ except OSError as e:
182
+ raise ReadError(f"Cannot read file: {e}") from e
183
+ if name is None:
184
+ name = normalize_name(path.stem) or "inferred_schema"
185
+ else:
186
+ text_content = source.read()
187
+ if name is None:
188
+ name = "inferred_schema"
189
+
190
+ lines = text_content.splitlines(keepends=False)
191
+
192
+ for _ in range(skip_rows):
193
+ if lines:
194
+ lines.pop(0)
195
+
196
+ if comment:
197
+ lines = [line for line in lines if not line.startswith(comment)]
198
+
199
+ if not lines:
200
+ raise ReadError("File is empty or contains no data rows")
201
+
202
+ if delimiter is None:
203
+ sample = "\n".join(lines[: min(10, len(lines))])
204
+ delimiter = _detect_delimiter(sample)
205
+
206
+ content = "\n".join(lines)
207
+
208
+ try:
209
+ reader = csv.reader(io.StringIO(content), delimiter=delimiter)
210
+ rows = list(reader)
211
+ except csv.Error as e:
212
+ raise ReadError(f"Malformed CSV: {e}") from e
213
+
214
+ if not rows:
215
+ raise ReadError("File is empty or contains no data rows")
216
+
217
+ headers = rows[0]
218
+ if strip:
219
+ headers = [h.strip() for h in headers]
220
+
221
+ data_rows = rows[1 : 1 + sample_rows]
222
+
223
+ column_values: dict[int, list[str]] = {i: [] for i in range(len(headers))}
224
+ column_missing: dict[int, int] = dict.fromkeys(range(len(headers)), 0)
225
+
226
+ for row in data_rows:
227
+ if strip:
228
+ row = [cell.strip() for cell in row]
229
+
230
+ for i, cell in enumerate(row):
231
+ if i >= len(headers):
232
+ continue
233
+
234
+ if thousands and i < len(headers):
235
+ cell = cell.replace(thousands, "")
236
+
237
+ if cell in all_na_values:
238
+ column_missing[i] += 1
239
+ else:
240
+ column_values[i].append(cell)
241
+
242
+ fields: list[Field] = []
243
+
244
+ for i, header in enumerate(headers):
245
+ col_name, unit_str, _ = parse_header_unit(header)
246
+ field_name = normalize_name(col_name)
247
+
248
+ if not field_name:
249
+ field_name = f"column_{i + 1}"
250
+
251
+ values = column_values.get(i, [])
252
+ has_missing = column_missing.get(i, 0) > 0
253
+
254
+ dtype: type
255
+ if not values:
256
+ dtype = str
257
+ nullable = True
258
+ else:
259
+ dtype, _ = _infer_dtype(values, decimal)
260
+ nullable = has_missing
261
+
262
+ alias = header if header != col_name and header != field_name else None
263
+
264
+ description_parts: list[str] = []
265
+ observed_range = _format_observed_range(values, dtype)
266
+ if observed_range:
267
+ description_parts.append(observed_range)
268
+
269
+ if dtype is str and values:
270
+ unique_values = set(values)
271
+ if len(unique_values) <= 10 and len(values) >= 20:
272
+ sorted_values = sorted(unique_values)
273
+ description_parts.append(f"distinct values: {sorted_values}")
274
+
275
+ description = "; ".join(description_parts) if description_parts else None
276
+
277
+ field_kwargs: dict[str, Any] = {
278
+ "name": field_name,
279
+ "dtype": dtype,
280
+ "nullable": nullable,
281
+ }
282
+
283
+ if unit_str and dtype in (int, float):
284
+ field_kwargs["unit"] = unit_str
285
+
286
+ if description:
287
+ field_kwargs["description"] = description
288
+
289
+ if alias:
290
+ field_kwargs["aliases"] = (alias,)
291
+
292
+ fields.append(Field(**field_kwargs))
293
+
294
+ metadata: dict[str, Any] = {
295
+ "inferred": {
296
+ "sample_rows": len(data_rows),
297
+ "labschema_version": _get_version(),
298
+ }
299
+ }
300
+ if source_name:
301
+ metadata["inferred"]["source"] = source_name
302
+
303
+ return TableSchema(
304
+ name=name,
305
+ fields=fields,
306
+ metadata=metadata,
307
+ )
308
+
309
+
310
+ def schema_to_python(schema: TableSchema) -> str:
311
+ """Generate Python source code for a TableSchema.
312
+
313
+ This creates editable source code that can be used in a Python script.
314
+
315
+ Args:
316
+ schema: The TableSchema to convert.
317
+
318
+ Returns:
319
+ Python source code as a string.
320
+ """
321
+ lines = [
322
+ "from labschema import Field, TableSchema, u",
323
+ "",
324
+ "schema = TableSchema(",
325
+ f" name={schema.name!r},",
326
+ ]
327
+
328
+ if schema.primary_key:
329
+ lines.append(f" primary_key={schema.primary_key!r},")
330
+
331
+ if schema.extra_columns != "warn":
332
+ lines.append(f" extra_columns={schema.extra_columns!r},")
333
+
334
+ if schema.version:
335
+ lines.append(f" version={schema.version!r},")
336
+
337
+ if schema.description:
338
+ lines.append(f" description={schema.description!r},")
339
+
340
+ lines.append(" fields=[")
341
+
342
+ for f in schema.fields:
343
+ parts = [f" Field({f.name!r}, {f.dtype.__name__}"]
344
+
345
+ if f.unit:
346
+ unit_str = f.unit_string or str(f.unit)
347
+ parts.append(f", unit={unit_str!r}")
348
+
349
+ if f.nullable:
350
+ parts.append(", nullable=True")
351
+
352
+ if f.ge is not None:
353
+ parts.append(f", ge={f.ge!r}")
354
+ if f.gt is not None:
355
+ parts.append(f", gt={f.gt!r}")
356
+ if f.le is not None:
357
+ parts.append(f", le={f.le!r}")
358
+ if f.lt is not None:
359
+ parts.append(f", lt={f.lt!r}")
360
+
361
+ if f.enum:
362
+ parts.append(f", enum={list(f.enum)!r}")
363
+
364
+ if f.pattern:
365
+ parts.append(f", pattern={f.pattern!r}")
366
+
367
+ if f.description:
368
+ desc = f.description
369
+ if len(desc) > 50:
370
+ desc = desc[:47] + "..."
371
+ parts.append(f", description={desc!r}")
372
+
373
+ if f.aliases:
374
+ parts.append(f", aliases={f.aliases!r}")
375
+
376
+ parts.append("),")
377
+ lines.append("".join(parts))
378
+
379
+ lines.append(" ],")
380
+ lines.append(")")
381
+ lines.append("")
382
+
383
+ return "\n".join(lines)
@@ -0,0 +1,5 @@
1
+ """I/O modules for LabSchema."""
2
+
3
+ from labschema.io.csv import read_csv, read_text, read_tsv
4
+
5
+ __all__ = ["read_csv", "read_tsv", "read_text"]