eosframes 1.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.
@@ -0,0 +1,20 @@
1
+ """Custom exceptions for the ``eosframes`` library."""
2
+
3
+
4
+ class EosframesError(Exception):
5
+ """Sole exception type raised by ``eosframes``.
6
+
7
+ Every validation failure inside the library — naming-convention
8
+ violations, model-ID / version mismatches, attempts to overwrite an
9
+ existing output, malformed scaler JSONs, HTTP failures from the model
10
+ hub — surfaces as ``EosframesError``. Users only need to catch this one
11
+ exception class to handle any library-level problem.
12
+
13
+ Examples
14
+ --------
15
+ >>> from eosframes import EosframesError, write_csv
16
+ >>> try:
17
+ ... write_csv(df, "results.csv") # missing model ID in path
18
+ ... except EosframesError as e:
19
+ ... print(f"eosframes refused the write: {e}")
20
+ """
eosframes/hub.py ADDED
@@ -0,0 +1,152 @@
1
+ """Fetch Ersilia model information from GitHub.
2
+
3
+ This module reaches out to ``raw.githubusercontent.com/ersilia-os/<model_id>``
4
+ to retrieve metadata (``fetch_metadata``) and per-version column
5
+ definitions (``fetch_columns``). ``fetch_columns`` first tries the
6
+ semver tag for *version* (``v<N>`` → ``v<N>.0.0``), then falls back to
7
+ ``main``; ``fetch_metadata`` fetches from ``main`` directly.
8
+
9
+ All HTTP calls have a 15 s timeout. Failures are surfaced as
10
+ :class:`~eosframes.EosframesError` with a hint pointing at the
11
+ ``ersilia-os`` repo URL.
12
+ """
13
+
14
+ import io
15
+ import json
16
+ import re
17
+
18
+ import pandas as pd
19
+ import requests
20
+
21
+ from .exceptions import EosframesError
22
+ from .logger import get_logger
23
+
24
+ _GITHUB_RAW = "https://raw.githubusercontent.com/ersilia-os/{model_id}/{ref}/{filename}"
25
+ _METADATA_CANDIDATES = ["metadata.json", "metadata.yml", "metadata.yaml"]
26
+ _RUN_COLUMNS_PATH = "model/framework/columns/run_columns.csv"
27
+
28
+
29
+ def _version_to_ref(version: str) -> str:
30
+ """Map a short version string ``v<N>`` to its semver git tag ``v<N>.0.0``.
31
+
32
+ Anything that doesn't match the short form is returned unchanged,
33
+ so callers can pass already-resolved refs (full SHAs, branch
34
+ names) without surprise rewriting.
35
+ """
36
+ m = re.match(r"^v(\d+)$", version)
37
+ if m:
38
+ return f"v{m.group(1)}.0.0"
39
+ return version
40
+
41
+
42
+ def _raw_url(model_id: str, ref: str, filename: str) -> str:
43
+ """Build a ``raw.githubusercontent.com`` URL for *filename* at *ref*."""
44
+ return _GITHUB_RAW.format(model_id=model_id, ref=ref, filename=filename)
45
+
46
+
47
+ def fetch_metadata(model_id: str) -> dict:
48
+ """Fetch metadata for an Ersilia model from GitHub.
49
+
50
+ Probes ``main`` for ``metadata.json``, ``metadata.yml`` and
51
+ ``metadata.yaml`` in that order, returning the first one that
52
+ responds with HTTP 200. YAML files require ``pyyaml`` to be
53
+ installed.
54
+
55
+ Parameters
56
+ ----------
57
+ model_id : str
58
+ Ersilia model identifier.
59
+
60
+ Returns
61
+ -------
62
+ dict
63
+ Raw metadata as a dictionary.
64
+
65
+ Raises
66
+ ------
67
+ EosframesError
68
+ If none of the candidate metadata files exist on the model's
69
+ ``main`` branch, or if a YAML metadata file is found but
70
+ ``pyyaml`` is not installed.
71
+ """
72
+ logger = get_logger()
73
+ for filename in _METADATA_CANDIDATES:
74
+ url = _raw_url(model_id, "main", filename)
75
+ logger.debug("GET %s", url)
76
+ resp = requests.get(url, timeout=15)
77
+ if resp.status_code == 200:
78
+ logger.info("Fetched metadata for %s from %s", model_id, filename)
79
+ if filename.endswith(".json"):
80
+ return json.loads(resp.text)
81
+ try:
82
+ import yaml
83
+
84
+ return yaml.safe_load(resp.text)
85
+ except ImportError as exc:
86
+ raise EosframesError(
87
+ f"Model '{model_id}' has a YAML metadata file but 'pyyaml' is not "
88
+ "installed. Install it with: pip install pyyaml"
89
+ ) from exc
90
+ logger.debug(
91
+ "Metadata candidate %s returned HTTP %d for %s",
92
+ filename,
93
+ resp.status_code,
94
+ model_id,
95
+ )
96
+ raise EosframesError(
97
+ f"Could not fetch metadata for model '{model_id}'. "
98
+ f"Make sure the repo exists at https://github.com/ersilia-os/{model_id}"
99
+ )
100
+
101
+
102
+ def fetch_columns(model_id: str, version: str) -> pd.DataFrame:
103
+ """Fetch the ``run_columns.csv`` for an Ersilia model version from GitHub.
104
+
105
+ Tries the semver tag for *version* first (``v<N>`` → ``v<N>.0.0``),
106
+ then falls back to ``main``. The first ref returning HTTP 200 wins.
107
+
108
+ Parameters
109
+ ----------
110
+ model_id : str
111
+ Ersilia model identifier.
112
+ version : str
113
+ Version string matching ``v\\d+``. Resolved by :func:`_version_to_ref`.
114
+
115
+ Returns
116
+ -------
117
+ pandas.DataFrame
118
+ Parsed ``run_columns.csv``, typically with columns ``name``,
119
+ ``type``, ``direction``, ``description``.
120
+
121
+ Raises
122
+ ------
123
+ EosframesError
124
+ If neither the tagged ref nor ``main`` returns the file.
125
+ """
126
+ logger = get_logger()
127
+ refs_to_try = list(
128
+ dict.fromkeys([_version_to_ref(version), "main"])
129
+ ) # ordered, unique
130
+ for ref in refs_to_try:
131
+ url = _raw_url(model_id, ref, _RUN_COLUMNS_PATH)
132
+ logger.debug("GET %s", url)
133
+ resp = requests.get(url, timeout=15)
134
+ if resp.status_code == 200:
135
+ logger.info(
136
+ "Fetched run_columns.csv for %s @ %s (resolved from version=%s)",
137
+ model_id,
138
+ ref,
139
+ version,
140
+ )
141
+ return pd.read_csv(io.StringIO(resp.text))
142
+ logger.debug(
143
+ "run_columns.csv at %s returned HTTP %d for %s",
144
+ ref,
145
+ resp.status_code,
146
+ model_id,
147
+ )
148
+ raise EosframesError(
149
+ f"Could not fetch run_columns.csv for model '{model_id}' at any of "
150
+ f"the candidate refs {refs_to_try}. "
151
+ f"Make sure the repo exists at https://github.com/ersilia-os/{model_id}"
152
+ )
eosframes/logger.py ADDED
@@ -0,0 +1,127 @@
1
+ """Singleton logger for the ``eosframes`` library.
2
+
3
+ The logger is named ``eosframes`` and configured once on first access. It
4
+ prefers ``rich.logging.RichHandler`` for human-friendly CLI output, falling
5
+ back to a plain stderr ``StreamHandler`` with a compact ``HH:MM:SS LEVEL
6
+ message`` format. Output goes to ``stderr`` so it stays out of piped stdout.
7
+
8
+ The default level is ``INFO`` — operational progress messages from
9
+ :mod:`eosframes.ops`, :mod:`eosframes.read`, and :mod:`eosframes.write` are
10
+ visible immediately. Two ways to change it:
11
+
12
+ * **Environment** — ``EOSFRAMES_LOG_LEVEL=DEBUG`` (or ``WARNING`` /
13
+ ``ERROR`` / ``CRITICAL``) before the process starts.
14
+ * **Programmatic** — :func:`set_verbosity` toggles between ``DEBUG`` and
15
+ ``INFO``. Useful in notebooks and library callers.
16
+
17
+ Level colour styling matches ``ersilia-os/lazy-qsar`` (debug=cyan,
18
+ info=blue, warning=yellow, error=red, critical=white-on-red).
19
+ """
20
+
21
+ import logging
22
+ import os
23
+ import sys
24
+
25
+ _logger = None # singleton
26
+
27
+ _LEVEL_THEME = {
28
+ "logging.level.debug": "bold cyan",
29
+ "logging.level.info": "bold blue",
30
+ "logging.level.warning": "bold yellow",
31
+ "logging.level.error": "bold red",
32
+ "logging.level.critical": "bold white on red",
33
+ }
34
+
35
+
36
+ def get_logger() -> logging.Logger:
37
+ """Return the singleton ``eosframes`` logger.
38
+
39
+ The logger is configured on first call and reused thereafter. Subsequent
40
+ calls return the same instance without re-installing handlers.
41
+
42
+ Behaviour
43
+ ---------
44
+ * Uses ``rich.logging.RichHandler`` (on a stderr ``Console``) when
45
+ ``rich`` is importable, falling back to ``logging.StreamHandler`` on
46
+ stderr.
47
+ * Default level is ``INFO``. Override with the ``EOSFRAMES_LOG_LEVEL``
48
+ environment variable (case-insensitive: ``DEBUG`` / ``INFO`` /
49
+ ``WARNING`` / ``ERROR`` / ``CRITICAL``) or with :func:`set_verbosity`
50
+ at runtime. Invalid env values fall back to ``INFO``.
51
+ * ``propagate = False`` so library records don't bubble up to the root
52
+ logger and produce duplicate output in host applications.
53
+
54
+ Returns
55
+ -------
56
+ logging.Logger
57
+ The shared ``eosframes`` logger instance.
58
+ """
59
+ global _logger
60
+ if _logger is not None:
61
+ return _logger
62
+
63
+ logger = logging.getLogger("eosframes")
64
+ logger.setLevel(_resolve_level())
65
+ # Don't double-log via the root logger when callers configure their own
66
+ # handlers (e.g. a Flask/Django app importing eosframes).
67
+ logger.propagate = False
68
+
69
+ if not logger.handlers:
70
+ logger.addHandler(_build_handler())
71
+
72
+ _logger = logger
73
+ return _logger
74
+
75
+
76
+ def set_verbosity(verbose: bool) -> None:
77
+ """Toggle verbose (``DEBUG``) logging on or off.
78
+
79
+ ``verbose=True`` lowers the level to ``DEBUG`` (everything visible);
80
+ ``verbose=False`` restores it to ``INFO`` — the default operational
81
+ level, where progress messages from readers, writers, and ops are still
82
+ shown but per-HTTP-probe / per-chunk traces are hidden. Warnings and
83
+ errors are emitted at both settings.
84
+
85
+ Parameters
86
+ ----------
87
+ verbose : bool
88
+ """
89
+ logger = get_logger()
90
+ logger.setLevel(logging.DEBUG if verbose else logging.INFO)
91
+
92
+
93
+ def _build_handler() -> logging.Handler:
94
+ """Build the singleton stderr handler, preferring rich's ``RichHandler``."""
95
+ try:
96
+ from rich.console import Console
97
+ from rich.logging import RichHandler
98
+ from rich.theme import Theme
99
+
100
+ console = Console(stderr=True, theme=Theme(_LEVEL_THEME))
101
+ handler: logging.Handler = RichHandler(
102
+ console=console,
103
+ rich_tracebacks=True,
104
+ show_path=False,
105
+ show_time=True,
106
+ markup=False,
107
+ log_time_format="%H:%M:%S",
108
+ )
109
+ handler.setFormatter(logging.Formatter("%(message)s"))
110
+ return handler
111
+ except ImportError:
112
+ handler = logging.StreamHandler(sys.stderr)
113
+ handler.setFormatter(
114
+ logging.Formatter(
115
+ "%(asctime)s %(levelname)-8s %(message)s", datefmt="%H:%M:%S"
116
+ )
117
+ )
118
+ return handler
119
+
120
+
121
+ def _resolve_level() -> int:
122
+ """Resolve the logging level from ``EOSFRAMES_LOG_LEVEL``, defaulting to INFO."""
123
+ raw = os.environ.get("EOSFRAMES_LOG_LEVEL", "").strip().upper()
124
+ if not raw:
125
+ return logging.INFO
126
+ level = logging.getLevelName(raw)
127
+ return level if isinstance(level, int) else logging.INFO