picsure 2.0.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.
Files changed (44) hide show
  1. picsure/__init__.py +78 -0
  2. picsure/_data/__init__.py +0 -0
  3. picsure/_data/variant_consequences.json +35 -0
  4. picsure/_dev/__init__.py +1 -0
  5. picsure/_dev/buffer.py +38 -0
  6. picsure/_dev/config.py +101 -0
  7. picsure/_dev/events.py +21 -0
  8. picsure/_dev/redaction.py +102 -0
  9. picsure/_dev/reporting.py +72 -0
  10. picsure/_dev/timing.py +86 -0
  11. picsure/_models/__init__.py +0 -0
  12. picsure/_models/clause.py +98 -0
  13. picsure/_models/clause_group.py +51 -0
  14. picsure/_models/count_result.py +28 -0
  15. picsure/_models/dictionary.py +93 -0
  16. picsure/_models/facet.py +208 -0
  17. picsure/_models/genomic_filter.py +134 -0
  18. picsure/_models/query.py +28 -0
  19. picsure/_models/query_type.py +43 -0
  20. picsure/_models/resource.py +20 -0
  21. picsure/_models/session.py +592 -0
  22. picsure/_services/__init__.py +0 -0
  23. picsure/_services/_errors.py +65 -0
  24. picsure/_services/connect.py +332 -0
  25. picsure/_services/consents.py +58 -0
  26. picsure/_services/export.py +253 -0
  27. picsure/_services/genomic_data.py +28 -0
  28. picsure/_services/genomic_search.py +95 -0
  29. picsure/_services/query_build.py +314 -0
  30. picsure/_services/query_edit.py +143 -0
  31. picsure/_services/query_load.py +277 -0
  32. picsure/_services/query_run.py +413 -0
  33. picsure/_services/query_save.py +188 -0
  34. picsure/_services/search.py +298 -0
  35. picsure/_transport/__init__.py +0 -0
  36. picsure/_transport/client.py +445 -0
  37. picsure/_transport/errors.py +70 -0
  38. picsure/_transport/platforms.py +202 -0
  39. picsure/errors.py +21 -0
  40. picsure/py.typed +0 -0
  41. picsure-2.0.0.dist-info/METADATA +103 -0
  42. picsure-2.0.0.dist-info/RECORD +44 -0
  43. picsure-2.0.0.dist-info/WHEEL +4 -0
  44. picsure-2.0.0.dist-info/licenses/LICENSE +201 -0
picsure/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ """PIC-SURE Python API adapter."""
2
+
3
+ import os
4
+
5
+ from picsure._models.clause import Clause, PhenotypicFilterType
6
+ from picsure._models.clause_group import ClauseGroup, GroupOperator
7
+ from picsure._models.count_result import CountResult
8
+ from picsure._models.facet import FacetSet
9
+ from picsure._models.genomic_filter import (
10
+ GenomicFilter,
11
+ GenomicFilterKey,
12
+ VariantFrequency,
13
+ VariantSeverity,
14
+ )
15
+ from picsure._models.query import Query
16
+ from picsure._models.query_type import QueryType
17
+ from picsure._models.session import Session
18
+ from picsure._services.connect import connect
19
+ from picsure._services.genomic_data import genomicConsequences
20
+ from picsure._services.query_build import (
21
+ buildClause,
22
+ buildClauseGroup,
23
+ buildGenomicFilter,
24
+ buildQuery,
25
+ )
26
+ from picsure._services.query_edit import removeSubQuery, replaceClause
27
+ from picsure._transport.platforms import Platform
28
+ from picsure.errors import (
29
+ PicSureAuthError,
30
+ PicSureConnectionError,
31
+ PicSureError,
32
+ PicSureQueryError,
33
+ PicSureValidationError,
34
+ )
35
+
36
+
37
+ def set_dev_mode(enabled: bool) -> None:
38
+ """Set the ``PICSURE_DEV_MODE`` environment variable.
39
+
40
+ Affects the **next** call to :func:`connect`. Existing ``Session``
41
+ objects are not mutated; reconnect to pick up the change.
42
+ """
43
+ if enabled:
44
+ os.environ["PICSURE_DEV_MODE"] = "1"
45
+ else:
46
+ os.environ.pop("PICSURE_DEV_MODE", None)
47
+
48
+
49
+ __all__ = [
50
+ "buildClause",
51
+ "buildClauseGroup",
52
+ "buildGenomicFilter",
53
+ "buildQuery",
54
+ "connect",
55
+ "genomicConsequences",
56
+ "removeSubQuery",
57
+ "replaceClause",
58
+ "set_dev_mode",
59
+ "Clause",
60
+ "ClauseGroup",
61
+ "CountResult",
62
+ "FacetSet",
63
+ "GenomicFilter",
64
+ "GenomicFilterKey",
65
+ "GroupOperator",
66
+ "PhenotypicFilterType",
67
+ "PicSureAuthError",
68
+ "PicSureConnectionError",
69
+ "PicSureError",
70
+ "PicSureQueryError",
71
+ "PicSureValidationError",
72
+ "Platform",
73
+ "Query",
74
+ "QueryType",
75
+ "Session",
76
+ "VariantFrequency",
77
+ "VariantSeverity",
78
+ ]
File without changes
@@ -0,0 +1,35 @@
1
+ [
2
+ {
3
+ "key": "High Severity",
4
+ "children": [
5
+ "splice_acceptor_variant",
6
+ "splice_donor_variant",
7
+ "stop_gained",
8
+ "frameshift_variant",
9
+ "stop_lost",
10
+ "start_lost"
11
+ ]
12
+ },
13
+ {
14
+ "key": "Medium Severity",
15
+ "children": [
16
+ "inframe_insertion",
17
+ "inframe_deletion",
18
+ "missense_variant",
19
+ "protein_altering_variant"
20
+ ]
21
+ },
22
+ {
23
+ "key": "Low Severity",
24
+ "children": [
25
+ "splice_region_variant",
26
+ "splice_donor_5th_base_variant",
27
+ "splice_donor_region_variant",
28
+ "splice_polypyrimidine_tract_variant",
29
+ "incomplete_terminal_codon_variant",
30
+ "start_retained_variant",
31
+ "stop_retained_variant",
32
+ "synonymous_variant"
33
+ ]
34
+ }
35
+ ]
@@ -0,0 +1 @@
1
+ """Developer-mode internals: config, events, buffer, redaction, reporting, timing."""
picsure/_dev/buffer.py ADDED
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from collections import deque
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from picsure._dev.events import Event
9
+
10
+
11
+ class EventBuffer:
12
+ """Thread-safe FIFO of dev-mode events with a fixed maximum size.
13
+
14
+ When full, the oldest entry is dropped on append.
15
+ """
16
+
17
+ def __init__(self, max_events: int) -> None:
18
+ if max_events <= 0:
19
+ raise ValueError("max_events must be positive")
20
+ self._deque: deque[Event] = deque(maxlen=max_events)
21
+ self._lock = threading.Lock()
22
+
23
+ def append(self, event: Event) -> None:
24
+ with self._lock:
25
+ self._deque.append(event)
26
+
27
+ def snapshot(self) -> list[Event]:
28
+ """Return a list copy of current events (snapshot at call time)."""
29
+ with self._lock:
30
+ return list(self._deque)
31
+
32
+ def clear(self) -> None:
33
+ with self._lock:
34
+ self._deque.clear()
35
+
36
+ def __len__(self) -> int:
37
+ with self._lock:
38
+ return len(self._deque)
picsure/_dev/config.py ADDED
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ from dataclasses import dataclass, field
6
+
7
+ from picsure._dev.buffer import EventBuffer
8
+ from picsure._dev.events import Event
9
+
10
+ _ENV_MODE = "PICSURE_DEV_MODE"
11
+ _ENV_MAX_EVENTS = "PICSURE_DEV_MAX_EVENTS"
12
+ _DEFAULT_MAX_EVENTS = 1000
13
+ _TRUTHY = {"1", "true", "yes"}
14
+
15
+ _HTTP_LOGGER = logging.getLogger("picsure.http")
16
+ _FN_LOGGER = logging.getLogger("picsure.fn")
17
+ _CONNECT_LOGGER = logging.getLogger("picsure.connect")
18
+ _ERROR_LOGGER = logging.getLogger("picsure.error")
19
+ _DEV_WARNING_LOGGER = logging.getLogger("picsure.dev")
20
+
21
+ _LOGGERS = {
22
+ "http": _HTTP_LOGGER,
23
+ "function": _FN_LOGGER,
24
+ "connect": _CONNECT_LOGGER,
25
+ }
26
+
27
+
28
+ def _env_truthy(raw: str | None) -> bool:
29
+ return raw is not None and raw.strip().lower() in _TRUTHY
30
+
31
+
32
+ def _env_int(raw: str | None, default: int) -> int:
33
+ if raw is None:
34
+ return default
35
+ try:
36
+ value = int(raw)
37
+ except ValueError:
38
+ return default
39
+ return value if value > 0 else default
40
+
41
+
42
+ def _logger_for(kind: str) -> logging.Logger:
43
+ return _LOGGERS.get(kind, _ERROR_LOGGER)
44
+
45
+
46
+ def _format_bytes(n: int | None) -> str:
47
+ if n is None:
48
+ return "-"
49
+ if n < 1024:
50
+ return f"{n}B"
51
+ if n < 1024 * 1024:
52
+ return f"{n / 1024:.1f}KB"
53
+ return f"{n / (1024 * 1024):.1f}MB"
54
+
55
+
56
+ def _format_event(event: Event) -> str:
57
+ parts: list[str] = []
58
+ if event.kind == "http":
59
+ status = event.status if event.status is not None else "-"
60
+ parts.append(f"{event.name} {status} {event.duration_ms:.0f}ms")
61
+ parts.append(f"in={_format_bytes(event.bytes_in)}")
62
+ parts.append(f"out={_format_bytes(event.bytes_out)}")
63
+ parts.append(f"retry={event.retry}")
64
+ if event.metadata.get("redacted"):
65
+ parts.append(f"[body redacted: {event.metadata['redacted']}]")
66
+ else:
67
+ parts.append(f"{event.name} {event.duration_ms:.0f}ms")
68
+ if event.error:
69
+ parts.append(f"error={event.error}")
70
+ return " ".join(parts)
71
+
72
+
73
+ @dataclass
74
+ class DevConfig:
75
+ """Resolved developer-mode config shared by transport and session layers."""
76
+
77
+ enabled: bool
78
+ max_events: int
79
+ buffer: EventBuffer = field(init=False)
80
+
81
+ def __post_init__(self) -> None:
82
+ self.buffer = EventBuffer(self.max_events)
83
+
84
+ @classmethod
85
+ def from_env(cls, override: bool | None) -> DevConfig:
86
+ if override is None:
87
+ enabled = _env_truthy(os.environ.get(_ENV_MODE))
88
+ else:
89
+ enabled = override
90
+ max_events = _env_int(os.environ.get(_ENV_MAX_EVENTS), _DEFAULT_MAX_EVENTS)
91
+ return cls(enabled=enabled, max_events=max_events)
92
+
93
+ def emit(self, event: Event) -> None:
94
+ """Record an event. No-op when disabled. Never raises."""
95
+ if not self.enabled:
96
+ return
97
+ try:
98
+ self.buffer.append(event)
99
+ _logger_for(event.kind).debug(_format_event(event))
100
+ except Exception as exc: # pragma: no cover — defensive
101
+ _DEV_WARNING_LOGGER.warning("dev-mode emit failed: %s", exc)
picsure/_dev/events.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class Event:
10
+ """A single dev-mode event: HTTP call, function, connect, or error."""
11
+
12
+ timestamp: datetime
13
+ kind: str
14
+ name: str
15
+ duration_ms: float
16
+ bytes_in: int | None
17
+ bytes_out: int | None
18
+ status: int | None
19
+ retry: int
20
+ error: str | None
21
+ metadata: dict[str, Any] = field(default_factory=dict)
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ _SENSITIVE_RESULT_TYPES = {
7
+ "DATAFRAME",
8
+ "DATAFRAME_TIMESERIES",
9
+ "DATAFRAME_PFB",
10
+ }
11
+
12
+ # PSAMA's /user/me returns the user's JWT in a `token` field alongside
13
+ # `email` and other identity fields. Treat any of these as secrets when
14
+ # they appear in a PSAMA-pathed body.
15
+ _PSAMA_SENSITIVE_KEYS = frozenset(
16
+ {
17
+ "email",
18
+ "token",
19
+ "access_token",
20
+ "refresh_token",
21
+ "password",
22
+ "secret",
23
+ "apikey",
24
+ "api_key",
25
+ }
26
+ )
27
+
28
+
29
+ def redact_headers(headers: dict[str, str]) -> dict[str, str]:
30
+ """Return a copy of headers with Authorization masked."""
31
+ out = dict(headers)
32
+ for key in list(out.keys()):
33
+ if key.lower() == "authorization":
34
+ out[key] = "Bearer ***"
35
+ return out
36
+
37
+
38
+ def redact_for_log(
39
+ path: str,
40
+ method: str,
41
+ body: dict[str, Any] | list[Any] | None,
42
+ ) -> str | None:
43
+ """Return a safe string repr of a body, or None if it must not be logged.
44
+
45
+ Returning None signals "body is sensitive — log size only."
46
+ """
47
+ if body is None:
48
+ return ""
49
+
50
+ if _is_psama_path(path):
51
+ return json.dumps(_redact_psama_secrets(body))
52
+
53
+ # Suppress based on body SHAPE, not path: the async PFB export posts the
54
+ # same participant-bearing query body to /picsure/v3/query (and
55
+ # /status, /result), none of which end in /query/sync.
56
+ if _body_is_participant_like(body):
57
+ return None
58
+
59
+ # Default: safe to log
60
+ return json.dumps(body, default=str)
61
+
62
+
63
+ def body_is_sensitive(
64
+ path: str,
65
+ method: str,
66
+ body: dict[str, Any] | list[Any] | None,
67
+ ) -> bool:
68
+ """Cheap predicate: would ``redact_for_log`` refuse to serialize this body?
69
+
70
+ Callers that only need the yes/no decision can use this to avoid the
71
+ full ``json.dumps`` round-trip in ``redact_for_log``.
72
+ """
73
+ if body is None:
74
+ return False
75
+ return _body_is_participant_like(body)
76
+
77
+
78
+ def _is_psama_path(path: str) -> bool:
79
+ return path.startswith("/psama/")
80
+
81
+
82
+ def _body_is_participant_like(body: Any) -> bool:
83
+ query = body.get("query") if isinstance(body, dict) else None
84
+ if not isinstance(query, dict):
85
+ return False
86
+ result_type = query.get("expectedResultType")
87
+ return result_type in _SENSITIVE_RESULT_TYPES
88
+
89
+
90
+ def _redact_psama_secrets(body: Any) -> Any:
91
+ if isinstance(body, dict):
92
+ return {
93
+ k: (
94
+ "***"
95
+ if k.lower() in _PSAMA_SENSITIVE_KEYS and isinstance(v, str)
96
+ else _redact_psama_secrets(v)
97
+ )
98
+ for k, v in body.items()
99
+ }
100
+ if isinstance(body, list):
101
+ return [_redact_psama_secrets(item) for item in body]
102
+ return body
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import asdict
5
+ from typing import TYPE_CHECKING
6
+
7
+ import pandas as pd
8
+
9
+ if TYPE_CHECKING:
10
+ from picsure._dev.events import Event
11
+
12
+ _EVENT_COLS = [
13
+ "timestamp",
14
+ "kind",
15
+ "name",
16
+ "duration_ms",
17
+ "bytes_in",
18
+ "bytes_out",
19
+ "status",
20
+ "retry",
21
+ "error",
22
+ "metadata",
23
+ ]
24
+
25
+ _STATS_COLS = [
26
+ "kind",
27
+ "name",
28
+ "calls",
29
+ "total_ms",
30
+ "avg_ms",
31
+ "min_ms",
32
+ "max_ms",
33
+ "bytes_in_total",
34
+ "bytes_out_total",
35
+ "retries",
36
+ "errors",
37
+ ]
38
+
39
+
40
+ def events_to_df(events: list[Event]) -> pd.DataFrame:
41
+ if not events:
42
+ return pd.DataFrame(columns=_EVENT_COLS)
43
+ return pd.DataFrame([asdict(e) for e in events], columns=_EVENT_COLS)
44
+
45
+
46
+ def stats_to_df(events: list[Event]) -> pd.DataFrame:
47
+ if not events:
48
+ return pd.DataFrame(columns=_STATS_COLS)
49
+
50
+ groups: dict[tuple[str, str], list[Event]] = defaultdict(list)
51
+ for event in events:
52
+ groups[(event.kind, event.name)].append(event)
53
+
54
+ rows = []
55
+ for (kind, name), batch in groups.items():
56
+ durations = [e.duration_ms for e in batch]
57
+ rows.append(
58
+ {
59
+ "kind": kind,
60
+ "name": name,
61
+ "calls": len(batch),
62
+ "total_ms": sum(durations),
63
+ "avg_ms": sum(durations) / len(batch),
64
+ "min_ms": min(durations),
65
+ "max_ms": max(durations),
66
+ "bytes_in_total": sum((e.bytes_in or 0) for e in batch),
67
+ "bytes_out_total": sum((e.bytes_out or 0) for e in batch),
68
+ "retries": sum(1 for e in batch if e.retry > 0),
69
+ "errors": sum(1 for e in batch if e.error is not None),
70
+ }
71
+ )
72
+ return pd.DataFrame(rows, columns=_STATS_COLS)
picsure/_dev/timing.py ADDED
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import time
5
+ from collections.abc import Callable
6
+ from datetime import datetime, timezone
7
+ from typing import TYPE_CHECKING, Any, TypeVar
8
+
9
+ from picsure._dev.events import Event
10
+
11
+ if TYPE_CHECKING:
12
+ from picsure._dev.config import DevConfig
13
+
14
+ F = TypeVar("F", bound=Callable[..., Any])
15
+
16
+
17
+ def timed(name: str) -> Callable[[F], F]:
18
+ """Method decorator: emits a 'function' event on success, 'error' on exception.
19
+
20
+ Wrapped object must expose ``self._dev_config: DevConfig | None``.
21
+ When the attribute is missing or the config is disabled, the decorator
22
+ is a no-op.
23
+ """
24
+
25
+ def decorator(func: F) -> F:
26
+ @functools.wraps(func)
27
+ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
28
+ cfg: DevConfig | None = getattr(self, "_dev_config", None)
29
+ if cfg is None or not cfg.enabled:
30
+ return func(self, *args, **kwargs)
31
+
32
+ start = time.monotonic()
33
+ try:
34
+ result = func(self, *args, **kwargs)
35
+ except Exception as exc:
36
+ if getattr(exc, "_picsure_dev_emitted", False):
37
+ raise
38
+ duration_ms = (time.monotonic() - start) * 1000.0
39
+ cfg.emit(
40
+ Event(
41
+ timestamp=datetime.now(timezone.utc),
42
+ kind="error",
43
+ name=name,
44
+ duration_ms=duration_ms,
45
+ bytes_in=None,
46
+ bytes_out=None,
47
+ status=None,
48
+ retry=0,
49
+ error=type(exc).__name__,
50
+ metadata={},
51
+ )
52
+ )
53
+ raise
54
+
55
+ duration_ms = (time.monotonic() - start) * 1000.0
56
+ cfg.emit(
57
+ Event(
58
+ timestamp=datetime.now(timezone.utc),
59
+ kind="function",
60
+ name=name,
61
+ duration_ms=duration_ms,
62
+ bytes_in=None,
63
+ bytes_out=None,
64
+ status=None,
65
+ retry=0,
66
+ error=None,
67
+ metadata=_metadata_for(result),
68
+ )
69
+ )
70
+ return result
71
+
72
+ return wrapper # type: ignore[return-value]
73
+
74
+ return decorator
75
+
76
+
77
+ def _metadata_for(result: Any) -> dict[str, Any]:
78
+ try:
79
+ import pandas as pd
80
+ except Exception: # pragma: no cover — pandas is a hard dep
81
+ return {}
82
+ if isinstance(result, pd.DataFrame):
83
+ return {"df_rows": int(result.shape[0]), "df_cols": int(result.shape[1])}
84
+ if isinstance(result, int):
85
+ return {"result_type": "int"}
86
+ return {}
File without changes
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+
7
+ class PhenotypicFilterType(Enum):
8
+ """Type of filter clause in a PIC-SURE query.
9
+
10
+ Use these constants with ``picsure.buildClause()``:
11
+
12
+ - ``FILTER`` — filter by categorical values or numeric range
13
+ - ``ANYRECORD`` — match records where the concept path *or any
14
+ descendant* has a value (wire: ``ANY_RECORD_OF``)
15
+ - ``REQUIRE`` — require the concept path to have a non-null value
16
+ (wire: ``REQUIRED``)
17
+
18
+ To include concept paths in query output without filtering, pass
19
+ them to ``picsure.buildQuery(includeConcepts=...)`` instead — output
20
+ columns are no longer a clause type.
21
+ """
22
+
23
+ FILTER = "filter"
24
+ ANYRECORD = "anyrecord"
25
+ REQUIRE = "require"
26
+
27
+
28
+ _WIRE_NAME_BY_TYPE: dict[PhenotypicFilterType, str] = {
29
+ PhenotypicFilterType.FILTER: "FILTER",
30
+ PhenotypicFilterType.REQUIRE: "REQUIRED",
31
+ PhenotypicFilterType.ANYRECORD: "ANY_RECORD_OF",
32
+ }
33
+
34
+ # Reverse of _WIRE_NAME_BY_TYPE, exposed so query_load can rebuild
35
+ # clauses from the wire payload without redefining the mapping.
36
+ PHENOTYPIC_FILTER_TYPE_BY_WIRE_NAME: dict[str, PhenotypicFilterType] = {
37
+ v: k for k, v in _WIRE_NAME_BY_TYPE.items()
38
+ }
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Clause:
43
+ """A single filter clause in a PIC-SURE query.
44
+
45
+ Created by ``picsure.buildClause()``. Can be passed directly to
46
+ ``Session.runQuery()`` or combined with other clauses via
47
+ ``picsure.buildClauseGroup()``.
48
+
49
+ **Wire format.** :meth:`to_query_json` emits a v3 ``PhenotypicFilter``
50
+ leaf (or an ``OR`` ``PhenotypicSubquery`` of leaves for multi-key
51
+ clauses) per the ``/picsure/v3/query/sync`` contract.
52
+ """
53
+
54
+ keys: list[str]
55
+ type: PhenotypicFilterType
56
+ categories: list[str] | None = None
57
+ min: float | None = None
58
+ max: float | None = None
59
+
60
+ def concept_paths(self) -> list[str]:
61
+ """Concept paths this clause references, in order.
62
+
63
+ Used to fold a query's filter variables into the output ``select``
64
+ array so they are returned without being repeated in
65
+ ``includeConcepts``.
66
+ """
67
+ return list(self.keys)
68
+
69
+ def to_query_json(self) -> dict[str, object]:
70
+ """Serialize this clause as a v3 ``PhenotypicClause``.
71
+
72
+ Emits a ``PhenotypicFilter`` leaf for single-key clauses, or
73
+ an OR ``PhenotypicSubquery`` of per-key leaves when the clause
74
+ spans multiple keys.
75
+ """
76
+ leaves = [self._make_leaf(k) for k in self.keys]
77
+ if len(leaves) == 1:
78
+ return leaves[0]
79
+ return {
80
+ "operator": "OR",
81
+ "phenotypicClauses": leaves,
82
+ "not": False,
83
+ }
84
+
85
+ def _make_leaf(self, concept_path: str) -> dict[str, object]:
86
+ leaf: dict[str, object] = {
87
+ "phenotypicFilterType": _WIRE_NAME_BY_TYPE[self.type],
88
+ "conceptPath": concept_path,
89
+ "not": False,
90
+ }
91
+ if self.type == PhenotypicFilterType.FILTER:
92
+ if self.categories is not None:
93
+ leaf["values"] = list(self.categories)
94
+ if self.min is not None:
95
+ leaf["min"] = self.min
96
+ if self.max is not None:
97
+ leaf["max"] = self.max
98
+ return leaf
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+ from picsure._models.clause import Clause
7
+
8
+
9
+ class GroupOperator(Enum):
10
+ """Logical operator for combining clauses in a group.
11
+
12
+ - ``AND`` — all clauses must match
13
+ - ``OR`` — at least one clause must match
14
+ """
15
+
16
+ AND = "AND"
17
+ OR = "OR"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ClauseGroup:
22
+ """A group of clauses combined with AND or OR.
23
+
24
+ Created by ``picsure.buildClauseGroup()``. Can contain both
25
+ ``Clause`` and nested ``ClauseGroup`` objects for arbitrarily
26
+ deep nesting.
27
+
28
+ **Wire format.** :meth:`to_query_json` emits a v3
29
+ ``PhenotypicSubquery`` (``operator`` / ``phenotypicClauses``) per
30
+ the ``/picsure/v3/query/sync`` contract. The previous wire format
31
+ is not supported.
32
+ """
33
+
34
+ clauses: list[Clause | ClauseGroup]
35
+ operator: GroupOperator
36
+
37
+ def concept_paths(self) -> list[str]:
38
+ """All concept paths referenced anywhere in this group, depth-first.
39
+
40
+ Recurses uniformly through nested groups because each child —
41
+ ``Clause`` or ``ClauseGroup`` — implements ``concept_paths()``.
42
+ """
43
+ return [path for child in self.clauses for path in child.concept_paths()]
44
+
45
+ def to_query_json(self) -> dict[str, object]:
46
+ """Serialize this group as a v3 ``PhenotypicSubquery``."""
47
+ return {
48
+ "operator": self.operator.value,
49
+ "phenotypicClauses": [child.to_query_json() for child in self.clauses],
50
+ "not": False,
51
+ }