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 +25 -0
- labschema/__main__.py +6 -0
- labschema/cli.py +309 -0
- labschema/errors.py +101 -0
- labschema/fields.py +270 -0
- labschema/infer.py +383 -0
- labschema/io/__init__.py +5 -0
- labschema/io/csv.py +246 -0
- labschema/io/jsonschema.py +76 -0
- labschema/py.typed +0 -0
- labschema/schema.py +508 -0
- labschema/units.py +238 -0
- labschema/validate.py +668 -0
- labschema-0.1.0.dist-info/METADATA +355 -0
- labschema-0.1.0.dist-info/RECORD +18 -0
- labschema-0.1.0.dist-info/WHEEL +4 -0
- labschema-0.1.0.dist-info/entry_points.txt +2 -0
- labschema-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|