ipaapi 1.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.
ipaapi/dataset.py ADDED
@@ -0,0 +1,285 @@
1
+ """Loading tabular datasets and pairing them with a :class:`ColumnMapping`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import os
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, Optional, Union
9
+
10
+ from .errors import MappingError
11
+ from .mapping import ColumnMapping
12
+ from .models import MeasurementType
13
+
14
+ if TYPE_CHECKING: # pragma: no cover - typing only
15
+ import pandas as pd
16
+
17
+ __all__ = ["Dataset", "load_table"]
18
+
19
+ PathLike = Union[str, "os.PathLike[str]"]
20
+
21
+
22
+ def _sniff_separator(path: PathLike, default: str = "\t", skip_rows: int = 0) -> str:
23
+ """Guess the delimiter from the header line.
24
+
25
+ *skip_rows* lines of preamble are stepped over first, so a comment or title
26
+ line above the header does not get sniffed by mistake.
27
+ """
28
+ try:
29
+ with open(path, "r", newline="", encoding="utf-8-sig", errors="replace") as fh:
30
+ for _ in range(skip_rows):
31
+ if fh.readline() == "":
32
+ raise MappingError(
33
+ f"{str(path)!r} has fewer than {skip_rows + 1} lines, so there "
34
+ "is no header row left after --skip-rows."
35
+ )
36
+ sample = fh.readline()
37
+ except OSError as exc:
38
+ raise MappingError(f"Could not read {str(path)!r}: {exc}") from exc
39
+ if not sample:
40
+ raise MappingError(f"{str(path)!r} appears to be empty.")
41
+ try:
42
+ return csv.Sniffer().sniff(sample, delimiters="\t,;|").delimiter
43
+ except csv.Error:
44
+ return default
45
+
46
+
47
+ def load_table(
48
+ path: PathLike,
49
+ sep: Optional[str] = None,
50
+ skip_rows: int = 0,
51
+ **read_csv_kwargs,
52
+ ) -> "pd.DataFrame":
53
+ """Read a delimited text file into a DataFrame with a real header row.
54
+
55
+ Unlike the original demo, which read the file with ``header=None`` and then
56
+ treated row 0 as text, this keeps the header as column names so a
57
+ :class:`ColumnMapping` can address columns by name.
58
+
59
+ Args:
60
+ path: File to read.
61
+ sep: Field delimiter. Sniffed from the header line when omitted.
62
+ skip_rows: Number of lines to discard before the header row, for files
63
+ that carry a comment, title or provenance block above it.
64
+ **read_csv_kwargs: Passed through to :func:`pandas.read_csv`.
65
+ """
66
+ import pandas as pd
67
+
68
+ if skip_rows < 0:
69
+ raise MappingError("skip_rows cannot be negative.")
70
+ if sep is None:
71
+ sep = _sniff_separator(path, skip_rows=skip_rows)
72
+ read_csv_kwargs.setdefault("dtype", object)
73
+ read_csv_kwargs.setdefault("encoding", "utf-8-sig")
74
+ if skip_rows:
75
+ read_csv_kwargs.setdefault("skiprows", skip_rows)
76
+ try:
77
+ frame = pd.read_csv(path, sep=sep, **read_csv_kwargs)
78
+ except Exception as exc: # pandas raises a wide variety of parse errors
79
+ raise MappingError(f"Could not parse {str(path)!r} as a table: {exc}") from exc
80
+ frame.columns = [str(c).strip() for c in frame.columns]
81
+
82
+ if not skip_rows:
83
+ _warn_if_header_looks_wrong(path, frame)
84
+ return frame
85
+
86
+
87
+ #: Line prefixes that mark a comment in the formats these tables arrive in.
88
+ _COMMENT_PREFIXES = ("#", "//", ";", "!")
89
+
90
+
91
+ def _warn_if_header_looks_wrong(path: PathLike, frame: "pd.DataFrame") -> None:
92
+ """Raise if the row taken as the header is obviously not one.
93
+
94
+ A comment or title line above the real header is common, and the failure is
95
+ otherwise silent and confusing: the delimiter gets sniffed from the comment,
96
+ the comment becomes the column names, and the real header becomes data.
97
+ """
98
+ first = str(frame.columns[0]).strip()
99
+ looks_like_comment = first.startswith(_COMMENT_PREFIXES)
100
+ single_column = len(frame.columns) == 1
101
+
102
+ if not (looks_like_comment or single_column):
103
+ return
104
+
105
+ reason = (
106
+ f"the header row reads {first!r}, which looks like a comment"
107
+ if looks_like_comment
108
+ else f"the file parsed as a single column ({first!r})"
109
+ )
110
+ raise MappingError(
111
+ f"Could not find a header row in {str(path)!r}: {reason}.\n"
112
+ "If the file has comment or title lines above the header, skip them with "
113
+ "--skip-rows N (skip_rows=N from Python). If the delimiter is unusual, "
114
+ "set it with --sep."
115
+ )
116
+
117
+
118
+ @dataclass
119
+ class Dataset:
120
+ """A table plus the mapping that says how to submit it.
121
+
122
+ Construct with :meth:`from_file` or :meth:`from_frame`; both validate the
123
+ mapping against the data immediately, so a mistake surfaces before any
124
+ upload is attempted.
125
+ """
126
+
127
+ frame: "pd.DataFrame"
128
+ mapping: ColumnMapping
129
+ name: Optional[str] = None
130
+ #: Absolute path this dataset was read from, when it came from a file.
131
+ source_path: Optional[str] = None
132
+ #: Rows whose identifier came from the fallback column.
133
+ gene_ids_filled: int = 0
134
+ #: Rows left with no usable identifier at all.
135
+ gene_ids_missing: int = 0
136
+
137
+ @classmethod
138
+ def from_file(
139
+ cls,
140
+ path: PathLike,
141
+ mapping: ColumnMapping,
142
+ sep: Optional[str] = None,
143
+ name: Optional[str] = None,
144
+ check_ranges: bool = True,
145
+ skip_rows: int = 0,
146
+ **read_csv_kwargs,
147
+ ) -> "Dataset":
148
+ """Load *path* and validate *mapping* against it.
149
+
150
+ The dataset name defaults to the file stem, matching IPA's own habit of
151
+ naming a dataset after the file it came from.
152
+
153
+ Args:
154
+ skip_rows: Lines of preamble above the header row to discard.
155
+ """
156
+ frame = load_table(path, sep=sep, skip_rows=skip_rows, **read_csv_kwargs)
157
+ if name is None:
158
+ name = os.path.splitext(os.path.basename(str(path)))[0]
159
+ dataset = cls.from_frame(frame, mapping, name=name, check_ranges=check_ranges)
160
+ dataset.source_path = os.path.abspath(str(path))
161
+ return dataset
162
+
163
+ @classmethod
164
+ def from_frame(
165
+ cls,
166
+ frame: "pd.DataFrame",
167
+ mapping: ColumnMapping,
168
+ name: Optional[str] = None,
169
+ check_ranges: bool = True,
170
+ ) -> "Dataset":
171
+ """Pair an in-memory DataFrame with *mapping* and validate it."""
172
+ mapping.validate(frame, check_ranges=check_ranges)
173
+ if len(frame) == 0:
174
+ raise MappingError("Dataset contains no rows.")
175
+ resolved, filled = mapping.resolve_gene_ids(frame)
176
+ from .mapping import is_blank
177
+
178
+ missing = int(resolved.map(is_blank).sum())
179
+ if missing == len(frame):
180
+ raise MappingError(
181
+ f"Every row is missing an identifier in {mapping.gene_id_column!r}"
182
+ + (
183
+ f" and {mapping.gene_id_fallback_column!r}"
184
+ if mapping.gene_id_fallback_column
185
+ else ""
186
+ )
187
+ + ". Check the column number and that the file has a header row."
188
+ )
189
+ return cls(
190
+ frame=frame,
191
+ mapping=mapping,
192
+ name=name,
193
+ gene_ids_filled=filled,
194
+ gene_ids_missing=missing,
195
+ )
196
+
197
+ @property
198
+ def measurement_warnings(self) -> list:
199
+ """Warn where the data looks like a different measurement type than declared.
200
+
201
+ Specifically: a genuine log ratio is centred on zero, so a real
202
+ distribution always contains values between -1 and 1. Signed fold change
203
+ -- the ``ratio`` if >= 1, else ``-1/ratio`` convention -- cannot contain
204
+ any, by construction. A column declared ``logratio`` with nothing in that
205
+ interval is therefore almost certainly fold change mislabelled, which
206
+ inflates every magnitude exponentially while leaving directions intact.
207
+
208
+ Warnings only. IPA is the authority, and an unusual but legitimate
209
+ dataset should not be blocked.
210
+ """
211
+ import pandas as pd
212
+
213
+ notes = []
214
+ for obs in self.mapping.observations:
215
+ for m in obs.measurements:
216
+ if m.type is not MeasurementType.LOG_RATIO:
217
+ continue
218
+ values = pd.to_numeric(self.frame[m.column], errors="coerce").dropna()
219
+ # Too few points to say anything about the distribution.
220
+ if len(values) < 50:
221
+ continue
222
+ if ((values > -1) & (values < 1)).any():
223
+ continue
224
+ notes.append(
225
+ f"column {m.column!r} is declared {MeasurementType.LOG_RATIO.value!r}, "
226
+ f"but none of its {len(values):,} values fall between -1 and 1. "
227
+ "A real log ratio is centred on zero and would have many; signed "
228
+ "fold change (ratio if >=1, else -1/ratio) can have none at all. "
229
+ "If these are fold changes, declare them 'foldchange' -- read as "
230
+ "log ratios they are interpreted as 2^value, inflating every "
231
+ "magnitude."
232
+ )
233
+ return notes
234
+
235
+ @property
236
+ def id_warnings(self) -> list:
237
+ """Human-readable warnings about identifier coverage, empty if clean."""
238
+ notes = []
239
+ if self.gene_ids_filled:
240
+ notes.append(
241
+ f"{self.gene_ids_filled:,} of {len(self.frame):,} rows took their "
242
+ f"identifier from the fallback column "
243
+ f"{self.mapping.gene_id_fallback_column!r} "
244
+ f"({self.mapping.gene_id_fallback_type}). IPA is told a single "
245
+ f"gene ID type for the submission -- "
246
+ f"{self.mapping.gene_id_type!r} -- so those rows are uploaded "
247
+ "under that declaration and may not map."
248
+ )
249
+ if self.gene_ids_missing:
250
+ notes.append(
251
+ f"{self.gene_ids_missing:,} of {len(self.frame):,} rows have no "
252
+ "usable identifier and will almost certainly be dropped by IPA."
253
+ )
254
+ return notes
255
+
256
+ def __len__(self) -> int:
257
+ return len(self.frame)
258
+
259
+ @property
260
+ def n_genes(self) -> int:
261
+ """Number of identifier rows that will be uploaded."""
262
+ return len(self.frame)
263
+
264
+ def preview(self, rows: int = 5) -> "pd.DataFrame":
265
+ """Return just the mapped columns, for eyeballing before upload."""
266
+ return self.frame.loc[:, self.mapping.used_columns].head(rows)
267
+
268
+ def describe(self) -> str:
269
+ """Summarise the dataset and its mapping in one printable block."""
270
+ head = (
271
+ f"{self.name or 'dataset'}: {self.n_genes:,} "
272
+ f"{'row' if self.n_genes == 1 else 'rows'}"
273
+ )
274
+ body = head + "\n" + self.mapping.describe()
275
+ for note in self.warnings:
276
+ body += f"\nWarning: {note}"
277
+ return body
278
+
279
+ # NOTE: submit() deliberately does not repeat these; the CLI prints
280
+ # describe() for every dataset before uploading.
281
+
282
+ @property
283
+ def warnings(self) -> list:
284
+ """Everything worth saying about this dataset before it is uploaded."""
285
+ return self.measurement_warnings + self.id_warnings
ipaapi/errors.py ADDED
@@ -0,0 +1,101 @@
1
+ """Exception hierarchy for :mod:`ipaapi`.
2
+
3
+ Every error raised by this package derives from :class:`IPAError`, so callers
4
+ can catch that one type and be sure they have caught everything the library
5
+ raises deliberately.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __all__ = [
11
+ "IPAError",
12
+ "AuthenticationError",
13
+ "MappingError",
14
+ "SubmissionError",
15
+ "QuotaExceededError",
16
+ "MalformedRequestError",
17
+ "AnalysisRefusedError",
18
+ "AnalysisError",
19
+ "ResultsUnavailableError",
20
+ ]
21
+
22
+
23
+ class IPAError(Exception):
24
+ """Base class for all errors raised by :mod:`ipaapi`."""
25
+
26
+
27
+ class AuthenticationError(IPAError):
28
+ """OAuth login failed, timed out, or produced an unusable token."""
29
+
30
+
31
+ class MappingError(IPAError):
32
+ """A :class:`~ipaapi.mapping.ColumnMapping` is invalid or does not fit the data.
33
+
34
+ Raised before any network call is made, so a bad mapping never costs an
35
+ upload.
36
+ """
37
+
38
+
39
+ class SubmissionError(IPAError):
40
+ """The IPA server rejected an analysis submission."""
41
+
42
+ def __init__(self, message: str, status_code: int | None = None, body: str | None = None):
43
+ super().__init__(message)
44
+ self.status_code = status_code
45
+ self.body = body
46
+
47
+
48
+ class QuotaExceededError(SubmissionError):
49
+ """The account has no analyses left in its allowance.
50
+
51
+ Distinguished from other submission failures because the file is fine and
52
+ should be retried once the allowance resets -- unlike a malformed dataset,
53
+ which will fail identically forever.
54
+
55
+ .. warning::
56
+ The exact response IPA sends when an allowance is exhausted is not
57
+ documented, so detection is heuristic: see
58
+ :data:`ipaapi.client.QUOTA_PATTERNS`. The raw response body is always
59
+ reported so a misclassification is visible rather than silent.
60
+ """
61
+
62
+
63
+ class MalformedRequestError(SubmissionError):
64
+ """IPA rejected the request itself, not the data in it.
65
+
66
+ Recognised by IPA answering with an HTML error page where the API contract
67
+ is plain text: that means the request never reached the analysis logic, so
68
+ a bad parameter -- not a bad file -- is at fault, and every other file in
69
+ the batch would fail identically.
70
+
71
+ Kept distinct so batch processing stops and leaves the files alone, rather
72
+ than quarantining perfectly good data for a mistake in the command line.
73
+ """
74
+
75
+
76
+ class AnalysisRefusedError(SubmissionError):
77
+ """IPA accepted the request but would not start the analysis.
78
+
79
+ Distinguished from :class:`MalformedRequestError` by IPA saying "Unable to
80
+ run analysis", which means the request reached the analysis logic rather
81
+ than being rejected on a parameter. The dataset and the command line are
82
+ therefore probably fine, and the cause is on IPA's side -- an exhausted
83
+ allowance, a capacity limit, or a transient fault.
84
+
85
+ Treated like a quota response for batch purposes: the run stops and the
86
+ remaining files are left in place, since whatever stopped this submission
87
+ will very likely stop the next one too.
88
+ """
89
+
90
+
91
+ class AnalysisError(IPAError):
92
+ """An analysis finished in a non-successful terminal state, or never finished."""
93
+
94
+
95
+ class ResultsUnavailableError(IPAError):
96
+ """Analysis results could not be retrieved.
97
+
98
+ Programmatic result retrieval is a commercial IPA add-on. If the account in
99
+ use is not licensed for it, the results endpoints return an error and this
100
+ exception is raised. Submission and status checking are unaffected.
101
+ """
ipaapi/history.py ADDED
@@ -0,0 +1,145 @@
1
+ """A local record of everything submitted through this package.
2
+
3
+ IPA's API offers no way to list the analyses on an account: every endpoint
4
+ takes an analysis ID you must already hold. Lose the terminal output of a
5
+ submission and the ID is gone, recoverable only by hunting through the IPA
6
+ client by eye.
7
+
8
+ So the package keeps its own log. Each submitted analysis appends one
9
+ timestamped row to a tab-separated file, which ``ipaapi history`` reads back.
10
+ This covers work done through this tool only -- it cannot recover analyses
11
+ submitted from the IPA desktop client.
12
+
13
+ The format is deliberately boring: a TSV with a header, appended a line at a
14
+ time, so it survives interruption, is readable in any spreadsheet, and can be
15
+ grepped when all else fails.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import csv
21
+ import os
22
+ from dataclasses import asdict, dataclass, field
23
+ from datetime import datetime
24
+ from typing import List, Optional, Sequence
25
+
26
+ __all__ = [
27
+ "SubmissionRecord",
28
+ "default_log_path",
29
+ "append",
30
+ "read",
31
+ "FIELDS",
32
+ "LOG_FILE_ENV",
33
+ ]
34
+
35
+ FIELDS = (
36
+ "timestamp",
37
+ "analysis_id",
38
+ "project",
39
+ "dataset_name",
40
+ "observation",
41
+ "source_file",
42
+ "application_name",
43
+ "host",
44
+ )
45
+
46
+
47
+ #: Overrides the log location, for hosts where the home directory is not
48
+ #: writable. Mirrors ``IPAAPI_TOKEN_FILE``.
49
+ LOG_FILE_ENV = "IPAAPI_LOG_FILE"
50
+
51
+
52
+ def default_log_path() -> str:
53
+ """Where the log lives unless told otherwise.
54
+
55
+ ``IPAAPI_LOG_FILE`` wins, then ``XDG_STATE_HOME``, then
56
+ ``~/.local/state/ipaapi/submissions.tsv``.
57
+ """
58
+ override = os.environ.get(LOG_FILE_ENV)
59
+ if override:
60
+ return os.path.expanduser(override)
61
+ base = os.environ.get("XDG_STATE_HOME") or os.path.join(
62
+ os.path.expanduser("~"), ".local", "state"
63
+ )
64
+ return os.path.join(base, "ipaapi", "submissions.tsv")
65
+
66
+
67
+ @dataclass
68
+ class SubmissionRecord:
69
+ """One submitted analysis.
70
+
71
+ Attributes:
72
+ timestamp: Local time with UTC offset, ISO 8601, to the second.
73
+ analysis_id: The ID IPA returned.
74
+ project: Project the dataset was uploaded into.
75
+ dataset_name: Dataset name as IPA sees it.
76
+ observation: Observation the analysis covers.
77
+ source_file: Absolute path of the file submitted.
78
+ application_name: ``applicationname`` used, which scopes the analysis.
79
+ host: IPA host it was submitted to.
80
+ """
81
+
82
+ analysis_id: str
83
+ project: str
84
+ dataset_name: str = ""
85
+ observation: str = ""
86
+ source_file: str = ""
87
+ application_name: str = ""
88
+ host: str = ""
89
+ timestamp: str = field(default_factory=lambda: _now())
90
+
91
+ def as_row(self) -> List[str]:
92
+ data = asdict(self)
93
+ return [str(data.get(name, "")) for name in FIELDS]
94
+
95
+
96
+ def _now() -> str:
97
+ return datetime.now().astimezone().isoformat(timespec="seconds")
98
+
99
+
100
+ def append(
101
+ records: Sequence[SubmissionRecord], path: Optional[str] = None
102
+ ) -> Optional[str]:
103
+ """Append *records* to the log, creating it with a header if needed.
104
+
105
+ Returns the path written to, or ``None`` if the write failed. Logging is
106
+ best-effort: a full disk should not lose an analysis that IPA has already
107
+ accepted, so failures are reported and swallowed.
108
+ """
109
+ if not records:
110
+ return None
111
+ path = path or default_log_path()
112
+ try:
113
+ directory = os.path.dirname(path)
114
+ if directory:
115
+ os.makedirs(directory, exist_ok=True)
116
+ exists = os.path.exists(path) and os.path.getsize(path) > 0
117
+ with open(path, "a", newline="", encoding="utf-8") as fh:
118
+ writer = csv.writer(fh, delimiter="\t", lineterminator="\n")
119
+ if not exists:
120
+ writer.writerow(FIELDS)
121
+ for record in records:
122
+ writer.writerow(record.as_row())
123
+ return path
124
+ except OSError as exc:
125
+ print(
126
+ f"Warning: could not write the submission log at {path!r} ({exc}).\n"
127
+ " The analyses were submitted, but their IDs are only in this "
128
+ "terminal -- save them.\n"
129
+ f" Set a writable location with --log-file PATH or "
130
+ f"export {LOG_FILE_ENV}=$HOME/ipaapi-submissions.tsv"
131
+ )
132
+ return None
133
+
134
+
135
+ def read(path: Optional[str] = None) -> List[dict]:
136
+ """Read the log back, oldest first. A missing log reads as empty."""
137
+ path = path or default_log_path()
138
+ if not os.path.exists(path):
139
+ return []
140
+ try:
141
+ with open(path, "r", newline="", encoding="utf-8") as fh:
142
+ return [dict(row) for row in csv.DictReader(fh, delimiter="\t")]
143
+ except OSError as exc:
144
+ print(f"Warning: could not read the submission log at {path!r} ({exc}).")
145
+ return []