Skip to content

Commit e995923

Browse files
Add parse package migrated from bonsai-prp
1 parent 7764126 commit e995923

71 files changed

Lines changed: 2845 additions & 768 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/bonsai_libs/parse/__init__.py

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,7 @@
22

33
from importlib import import_module
44
from pathlib import Path
5-
6-
from .core.registry import (
7-
get_parser,
8-
hydrate_result,
9-
registered_softwares,
10-
registered_version_ranges,
11-
run_parser,
12-
)
5+
from .core.registry import get_parser, registered_softwares, registered_version_ranges, run_parser, hydrate_result
136

147
# auto-import all modules under parse/parsers to ensure that all parsers are registered
158
PARSER_DIR = "parsers"
@@ -18,10 +11,4 @@
1811
if file.name not in ("__init__.py", "utils.py"):
1912
import_module(f"{__name__}.{PARSER_DIR}.{file.stem}")
2013

21-
__all__ = [
22-
"get_parser",
23-
"registered_softwares",
24-
"registered_version_ranges",
25-
"run_parser",
26-
"hydrate_result",
27-
]
14+
__all__ = ["get_parser", "registered_softwares", "registered_version_ranges", "run_parser", "hydrate_result"]

src/bonsai_libs/parse/core/base.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@
55
from logging import Logger, getLogger
66
from typing import Any, Mapping, Type, TypeVar
77

8+
from bonsai_libs.parse.io.delimited import read_delimited, validate_fields
9+
from bonsai_libs.parse.io.types import DelimiterRow, StreamOrPath
810
from bonsai_libs.parse.core.envelope import (
911
default_empty_predicate,
1012
envelope_absent,
1113
envelope_skipped,
1214
run_as_envelope,
1315
)
1416
from bonsai_libs.parse.exceptions import UnsupportedAnalysisTypeError
15-
from bonsai_libs.parse.io.delimited import read_delimited, validate_fields
16-
from bonsai_libs.parse.io.types import DelimiterRow, StreamOrPath
1717
from bonsai_libs.parse.models.enums import AnalysisType, ResultStatus
1818

1919
T = TypeVar("T")
@@ -91,7 +91,9 @@ def parse(
9191
out.results.update(results)
9292
return out
9393

94-
def _normalize_want(self, want: set[AnalysisType] | AnalysisType | None) -> set[AnalysisType]:
94+
def _normalize_want(
95+
self, want: set[AnalysisType] | AnalysisType | None
96+
) -> set[AnalysisType]:
9597
"""Normalize the want parameter to a set of AnalysisType."""
9698

9799
want = want or set(self.produces)
@@ -174,11 +176,13 @@ def _get_first_normalized_row(
174176
) -> Mapping[str, Any] | None:
175177
"""Convenience: read, validate and normalize a single delimited row.
176178
177-
``column_map`` is passed through to :func:`bonsai_libs.io.delimited.normalize_row`.
179+
``column_map`` is passed through to :func:`prp.io.delimited.normalize_row`.
178180
If the source is empty this returns ``None``. Extra rows are consumed up
179181
to ``max_consume`` and a warning emitted via :meth:`log_warning`.
180182
"""
181-
first, rows = self._read_rows(source, required=required, strict_columns=strict_columns)
183+
first, rows = self._read_rows(
184+
source, required=required, strict_columns=strict_columns
185+
)
182186
if first is None:
183187
return None
184188
# normalization is a very common pattern; import lazily to avoid a
@@ -213,10 +217,18 @@ def _parse_impl(
213217
class SingleAnalysisParser(BaseParser):
214218
"""Abtracted parser class for softwares that produces exactly one AnalysisType"""
215219

220+
subcommand: str | None = None
221+
"""Optional subcommand identifier used when one software binary produces
222+
multiple distinct output formats (e.g. 'coverage' and 'stats' for samtools).
223+
The registry uses (software, subcommand) as a composite key so each output
224+
type can have its own parser class."""
225+
216226
def __init_subclass__(cls, **kwargs):
217227
super().__init_subclass__(**kwargs)
218228
if not hasattr(cls, "produces") or len(cls.produces) != 1:
219-
raise TypeError(f"{cls.__name__}.produces must contain exactly one AnalysisType")
229+
raise TypeError(
230+
f"{cls.__name__}.produces must contain exactly one AnalysisType"
231+
)
220232

221233
@property
222234
def analysis_type(self) -> AnalysisType:

src/bonsai_libs/parse/core/envelope.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,21 +31,22 @@ def envelope_from_value(
3131

3232
status = ResultStatus.EMPTY if empty_predicate(value) else ResultStatus.PARSED
3333
from bonsai_libs.parse.models.base import ResultEnvelope
34-
3534
return ResultEnvelope(status=status, value=value, reason=reason, meta=meta or {})
3635

3736

38-
def envelope_error(reason: str, *, meta: dict[str, Any] | None = None) -> "ResultEnvelope":
37+
def envelope_error(
38+
reason: str, *, meta: dict[str, Any] | None = None
39+
) -> "ResultEnvelope":
3940
"""Create an envelope that signifies that an error occured."""
4041
from bonsai_libs.parse.models.base import ResultEnvelope
41-
4242
return ResultEnvelope(status=ResultStatus.ERROR, reason=reason, meta=meta or {})
4343

4444

45-
def envelope_absent(reason: str, *, meta: dict[str, Any] | None = None) -> "ResultEnvelope":
45+
def envelope_absent(
46+
reason: str, *, meta: dict[str, Any] | None = None
47+
) -> "ResultEnvelope":
4648
"""Create an envelope that for result being absent in the input file."""
4749
from bonsai_libs.parse.models.base import ResultEnvelope
48-
4950
return ResultEnvelope(status=ResultStatus.ABSENT, reason=reason, meta=meta or {})
5051

5152

@@ -54,7 +55,6 @@ def envelope_skipped(
5455
) -> "ResultEnvelope":
5556
"""Create an envelope that signifies that the result was skipped by the user."""
5657
from bonsai_libs.parse.models.base import ResultEnvelope
57-
5858
return ResultEnvelope(status=ResultStatus.SKIPPED, reason=reason, meta=meta or {})
5959

6060

@@ -75,7 +75,9 @@ def run_as_envelope(
7575
value = fn()
7676
except AbsentResultError as exc:
7777
if logger:
78-
logger.info("result absent", extra={"context": {**base_meta, "error": str(exc)}})
78+
logger.info(
79+
"result absent", extra={"context": {**base_meta, "error": str(exc)}}
80+
)
7981
return envelope_absent(reason=reason_if_absent or str(exc), meta=base_meta)
8082

8183
except ParserError as exc:
@@ -93,7 +95,9 @@ def run_as_envelope(
9395

9496
except Exception as exc:
9597
if logger:
96-
logger.exception("Step failed", extra={"context": {**base_meta, "error": str(exc)}})
98+
logger.exception(
99+
"Step failed", extra={"context": {**base_meta, "error": str(exc)}}
100+
)
97101
err_meta = {**base_meta, "exception": type(exc).__name__}
98102
return envelope_error(reason=str(exc), meta=err_meta)
99103

@@ -112,5 +116,4 @@ def run_as_envelope(
112116
reason = reason_if_empty or None
113117

114118
from bonsai_libs.parse.models.base import ResultEnvelope
115-
116119
return ResultEnvelope(status=status, value=value, reason=reason, meta=base_meta)

src/bonsai_libs/parse/core/registry.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,24 @@ def _normalize_version(version: str | Version) -> Version:
4747
raise TypeError(f"Version must be str or Version, got {type(version).__name__}")
4848

4949

50-
def register_parser(software: str, min_version: str | None = None, max_version: str | None = None):
50+
def _registry_key(software: str, subcommand: str | None) -> str:
51+
"""Build the registry lookup key from software and optional subcommand.
52+
53+
When a subcommand is given the key becomes 'software.subcommand'
54+
(e.g. 'samtools.coverage', 'samtools.stats'), forming a single composite
55+
key — not two separate registrations — so multiple parsers can share the
56+
same software name without colliding.
57+
"""
58+
return f"{software}.{subcommand}" if subcommand else software
59+
60+
61+
def register_parser(
62+
software: str,
63+
min_version: str | None = None,
64+
max_version: str | None = None,
65+
*,
66+
subcommand: str | None = None,
67+
):
5168
"""Decorator to register a parser for a range of versions.
5269
5370
Null values means either undefined or no upper range.
@@ -61,15 +78,17 @@ def wrapper(cls: ParserRegistryEntry):
6178
new_min = _normalize_version(min_version)
6279
new_max = _normalize_version(max_version)
6380

81+
key = _registry_key(software, subcommand)
82+
6483
# Fetch existing ranges for this software
65-
existing_ranges = _PARSER_REGISTRY.get(software, [])
84+
existing_ranges = _PARSER_REGISTRY.get(key, [])
6685

6786
# Check for overlapping version ranges
6887
for span in existing_ranges:
6988
if not (new_max < span.min_version or new_min > span.max_version):
7089
# Ranges overlap → safety error
7190
raise ValueError(
72-
f"Cannot register parser {cls.__name__} for software '{software}' "
91+
f"Cannot register parser {cls.__name__} for software '{key}' "
7392
f"with version range [{new_min}, {new_max}] because it overlaps "
7493
f"with existing parser {span.entry.__name__} range "
7594
f"[{span.min_version}, {span.max_version}]."
@@ -81,30 +100,36 @@ def wrapper(cls: ParserRegistryEntry):
81100
max_version=new_max,
82101
entry=cls,
83102
)
84-
_PARSER_REGISTRY.setdefault(software, []).append(v_range)
103+
_PARSER_REGISTRY.setdefault(key, []).append(v_range)
85104

86105
return cls
87106

88107
return wrapper
89108

90109

91-
def get_parser(software: str, *, version: str) -> ParserRegistryEntry:
110+
def get_parser(
111+
software: str, *, version: str, subcommand: str | None = None
112+
) -> ParserRegistryEntry:
92113
"""Get parser from registry."""
93114
if not isinstance(software, str):
94115
raise TypeError(f"`software` must be str, got {type(software).__name__}")
95116

96-
if software not in registered_softwares():
97-
raise UnsupportedSoftwareError(f"No parser registered for software: {software}")
117+
key = _registry_key(software, subcommand)
118+
119+
if key not in registered_softwares():
120+
raise UnsupportedSoftwareError(f"No parser registered for software: {key}")
98121

99122
# Normalize version to PkgVersion
100123
v = _normalize_version(version)
101124

102-
for span in sorted(_PARSER_REGISTRY[software], key=lambda r: (r.min_version, r.max_version)):
125+
for span in sorted(_PARSER_REGISTRY[key], key=lambda r: (r.min_version, r.max_version)):
103126
if span.min_version <= v <= span.max_version:
104127
return span.entry
105128

106129
# Return the correct error.
107-
raise UnsupportedVersionError(f"No parser available for software '{software}' version {v}")
130+
raise UnsupportedVersionError(
131+
f"No parser available for software '{key}' version {v}"
132+
)
108133

109134

110135
def registered_softwares() -> list[str]:
@@ -137,6 +162,7 @@ def run_parser(
137162
*,
138163
version: str,
139164
data: StreamOrPath,
165+
subcommand: str | None = None,
140166
want: set[AnalysisType] | None = None,
141167
parser_init: dict[str, Any] | None = None,
142168
**parse_kwargs: Any,
@@ -146,7 +172,7 @@ def run_parser(
146172
if not isinstance(software, (AnalysisSoftware, str)):
147173
raise ValueError(f"Invalid input for 'run_parser', got {type(software)}")
148174

149-
entry = get_parser(software, version=version)
175+
entry = get_parser(software, version=version, subcommand=subcommand)
150176
parse_fn = resolve_parser(entry, **(parser_init or {}))
151177
ev = parse_fn(data, want=want, **parse_kwargs)
152178
# add version to results
@@ -233,9 +259,7 @@ def _hydrate_raw_value(model_cls: ModelClass | TypeAdapter, raw_value: Any) -> A
233259
raise TypeError(f"Unsupported nested element model type: {type(model_cls).__name__}")
234260

235261

236-
def _hydrate_nested_fields(
237-
result_obj: BaseModel, field_models: dict[str, ModelClass | TypeAdapter]
238-
) -> BaseModel:
262+
def _hydrate_nested_fields(result_obj: BaseModel, field_models: dict[str, ModelClass | TypeAdapter]) -> BaseModel:
239263
updates: dict[str, Any] = {}
240264

241265
for field_name, model_cls in field_models.items():

src/bonsai_libs/parse/exceptions.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Common errors for bonsai_libs.parse."""
1+
"""Common errors"""
22

33
from typing import Any
44

@@ -11,6 +11,10 @@ def __init__(self, message: str, *, context: dict[str, Any] | None = None):
1111
self.context = context or {}
1212

1313

14+
class DataFormatError(ParserError):
15+
"""Generic data/serialization errors (IO-level)."""
16+
17+
1418
class InvalidDataFormat(ParserError):
1519
"""Fatal: content present but corrupted/ill-formed -> ERROR."""
1620

src/bonsai_libs/parse/io/delimited.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@ def validate_fields(
127127
extra = (cols - allowed) if strict else set()
128128

129129
if missing:
130-
raise ValueError(f"Missing required columns: {sorted(missing)}; got: {sorted(cols)}")
130+
raise ValueError(
131+
f"Missing required columns: {sorted(missing)}; got: {sorted(cols)}"
132+
)
131133
if strict and extra:
132134
raise ValueError(f"Unexpected extra columns: {sorted(extra)}")
133135

src/bonsai_libs/parse/io/json.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44
from typing import Any, Mapping
55

6-
from bonsai_libs.parse.exceptions import InvalidDataFormat
6+
from bonsai_libs.parse.exceptions import DataFormatError
77

88
from .types import StreamOrPath
99
from .utils import ensure_text_stream
@@ -19,7 +19,9 @@ def read_json(source: StreamOrPath, *, encoding: str = "utf-8") -> Any:
1919
stream = ensure_text_stream(source, encoding=encoding)
2020
return json.loads(stream.read())
2121
except TypeError as exc:
22-
raise DataFormatError(f"Failed to read JSON from source of type {type(source)!r}") from exc
22+
raise DataFormatError(
23+
f"Failed to read JSON from source of type {type(source)!r}"
24+
) from exc
2325

2426

2527
def require_mapping(obj: Any, *, what: str) -> Mapping[str, Any]:

src/bonsai_libs/parse/io/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def convert_rel_to_abs_path(path: str, validation_info: ValidationInfo) -> Path:
6666
"""Validate that file exist and resolve realtive directories.
6767
6868
if a path is relative, convert to absolute from the configs parent directory
69-
i.e. path = ./results/sample_name.json --> /path/to/sample_name.json
69+
i.e. prp_path = ./results/sample_name.json --> /path/to/sample_name.json
7070
given, cnf_path = /data/samples/cnf.yml
7171
relative paths are used when bootstraping a test database
7272
"""

0 commit comments

Comments
 (0)