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.
- picsure/__init__.py +78 -0
- picsure/_data/__init__.py +0 -0
- picsure/_data/variant_consequences.json +35 -0
- picsure/_dev/__init__.py +1 -0
- picsure/_dev/buffer.py +38 -0
- picsure/_dev/config.py +101 -0
- picsure/_dev/events.py +21 -0
- picsure/_dev/redaction.py +102 -0
- picsure/_dev/reporting.py +72 -0
- picsure/_dev/timing.py +86 -0
- picsure/_models/__init__.py +0 -0
- picsure/_models/clause.py +98 -0
- picsure/_models/clause_group.py +51 -0
- picsure/_models/count_result.py +28 -0
- picsure/_models/dictionary.py +93 -0
- picsure/_models/facet.py +208 -0
- picsure/_models/genomic_filter.py +134 -0
- picsure/_models/query.py +28 -0
- picsure/_models/query_type.py +43 -0
- picsure/_models/resource.py +20 -0
- picsure/_models/session.py +592 -0
- picsure/_services/__init__.py +0 -0
- picsure/_services/_errors.py +65 -0
- picsure/_services/connect.py +332 -0
- picsure/_services/consents.py +58 -0
- picsure/_services/export.py +253 -0
- picsure/_services/genomic_data.py +28 -0
- picsure/_services/genomic_search.py +95 -0
- picsure/_services/query_build.py +314 -0
- picsure/_services/query_edit.py +143 -0
- picsure/_services/query_load.py +277 -0
- picsure/_services/query_run.py +413 -0
- picsure/_services/query_save.py +188 -0
- picsure/_services/search.py +298 -0
- picsure/_transport/__init__.py +0 -0
- picsure/_transport/client.py +445 -0
- picsure/_transport/errors.py +70 -0
- picsure/_transport/platforms.py +202 -0
- picsure/errors.py +21 -0
- picsure/py.typed +0 -0
- picsure-2.0.0.dist-info/METADATA +103 -0
- picsure-2.0.0.dist-info/RECORD +44 -0
- picsure-2.0.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|
picsure/_dev/__init__.py
ADDED
|
@@ -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
|
+
}
|