ojs 0.7.2__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.
ojs/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.7.2"
ojs/api/__init__.py ADDED
File without changes
ojs/api/client.py ADDED
@@ -0,0 +1,577 @@
1
+ """OJS REST API client with pagination and publication detail fetching."""
2
+
3
+ import re
4
+ import time
5
+ from typing import Any, Protocol
6
+
7
+ import httpx
8
+
9
+ PAGE_SIZE = 100
10
+ MAX_RETRIES = 3
11
+ RETRY_DELAY = 2
12
+
13
+ # HTTP statuses that mean "this resource isn't available to us" rather than a
14
+ # real failure: the key lacks permission (403) or the resource is gone (404).
15
+ # Callers skip the affected item and keep going instead of aborting the run.
16
+ SKIP_STATUSES = (403, 404)
17
+
18
+
19
+ def _is_unchanged(sub: dict[str, Any], known: dict[int, str]) -> bool:
20
+ """True when `sub`'s `dateLastActivity` matches the last-seen value in `known`.
21
+
22
+ The shared early-stop test for the per-submission detail endpoints: a match
23
+ means nothing changed since the previous sync, so the round-trip is skipped.
24
+ A submission absent from `known` (brand new) is never treated as unchanged.
25
+ """
26
+ prior = known.get(sub["id"])
27
+ return prior is not None and prior == sub.get("dateLastActivity")
28
+
29
+
30
+ class _Response(Protocol):
31
+ """The subset of `httpx.Response` the client helpers rely on."""
32
+
33
+ def raise_for_status(self) -> object: ...
34
+ def json(self) -> Any: ...
35
+
36
+ # Raw bytes, used by the file downloader (`files.py`) to write artifacts.
37
+ @property
38
+ def content(self) -> bytes: ...
39
+
40
+
41
+ class _HttpClient(Protocol):
42
+ """A GET-capable client, satisfied by `httpx.Client` and test doubles alike.
43
+
44
+ The helpers below only need `.get(url, params=...)`, so typing against this
45
+ structural contract (rather than the concrete `httpx.Client`) lets tests pass
46
+ lightweight fakes without subclassing the real client.
47
+ """
48
+
49
+ def get(self, url: str, *, params: dict[str, Any] | None = ...) -> _Response: ...
50
+
51
+
52
+ def _http_client() -> httpx.Client:
53
+ """An httpx client configured the way every fetch in this module wants it.
54
+
55
+ Centralizes the redirect-following and timeout settings so the JSON helpers
56
+ and the binary file downloader (``files.py``) share one definition.
57
+ """
58
+ return httpx.Client(follow_redirects=True, timeout=30)
59
+
60
+
61
+ def _redact_token(text: str) -> str:
62
+ """Redact the ``apiToken`` query value so the API key never reaches logs.
63
+
64
+ The key is sent as a query parameter, so httpx echoes the full URL --
65
+ ``?apiToken=<secret>`` and all -- in ``HTTPStatusError`` messages. Scrubbing
66
+ the value keeps an unexpected HTTP error from printing the key to the console
67
+ or a CI log.
68
+ """
69
+ return re.sub(r"(apiToken=)[^&\s'\")]+", r"\1[REDACTED]", text)
70
+
71
+
72
+ def _request_with_retry(
73
+ client: _HttpClient, url: str, params: dict[str, Any]
74
+ ) -> _Response:
75
+ """Make a GET request with retry on transient failures."""
76
+ for attempt in range(MAX_RETRIES):
77
+ try:
78
+ response = client.get(url, params=params)
79
+ response.raise_for_status()
80
+ return response
81
+ except httpx.HTTPStatusError as e:
82
+ # An HTTP status error carries the request URL -- with the apiToken
83
+ # query value -- in its message. Re-raise a status-equivalent error
84
+ # with the token redacted so it can't leak to the console/logs;
85
+ # callers still branch on ``e.response.status_code`` for 403/404 skips.
86
+ raise httpx.HTTPStatusError(
87
+ _redact_token(str(e)), request=e.request, response=e.response
88
+ ) from None
89
+ except httpx.TransportError as e:
90
+ # TransportError covers connect/read/pool timeouts and protocol
91
+ # errors -- the transient failures worth retrying. HTTP status
92
+ # errors (4xx/5xx) are not transport errors and propagate.
93
+ if attempt == MAX_RETRIES - 1:
94
+ raise
95
+ delay = RETRY_DELAY * (attempt + 1)
96
+ cls = e.__class__.__name__
97
+ print(
98
+ f" Retry {attempt + 1}/{MAX_RETRIES} after {cls}, "
99
+ f"waiting {delay}s..."
100
+ )
101
+ time.sleep(delay)
102
+ raise RuntimeError("unreachable")
103
+
104
+
105
+ def _unwrap_items(data: Any, url: str) -> tuple[list[dict[str, Any]], int | None]:
106
+ """Split an OJS list response into ``(page, total)``.
107
+
108
+ Accepts the two documented shapes -- a bare array (``total`` ``None``) and the
109
+ ``{items, itemsMax}`` envelope (``itemsMax`` read with ``.get`` so an envelope
110
+ missing only the count still paginates by short-page detection). Any other 200
111
+ JSON (e.g. an OJS error object that slipped past ``raise_for_status``) raises a
112
+ ``RuntimeError`` surfacing the URL and payload instead of a bare ``KeyError``.
113
+ """
114
+ if isinstance(data, list):
115
+ return data, None
116
+ if isinstance(data, dict) and "items" in data:
117
+ return data["items"], data.get("itemsMax")
118
+ raise RuntimeError(f"Unexpected response shape from {url}: {repr(data)[:300]}")
119
+
120
+
121
+ def _paginate(
122
+ client: _HttpClient,
123
+ url: str,
124
+ params: dict[str, Any],
125
+ *,
126
+ since: str | None = None,
127
+ since_key: str = "dateLastActivity",
128
+ ) -> list[dict[str, Any]]:
129
+ """Fetch all pages from a paginated OJS API endpoint.
130
+
131
+ Most list endpoints return an `{items, itemsMax}` envelope. Some (the swagger
132
+ snapshot documents `/stats/publications` this way) return a bare array; that
133
+ is handled too -- a short page ends pagination, a full page advances offset.
134
+
135
+ When `since` is set the caller must also request newest-first ordering on
136
+ `since_key` (`orderBy=...&orderDirection=DESC`). Pagination then early-stops:
137
+ the first item at or below `since` ends the walk, since DESC order guarantees
138
+ every later record is older. Items missing `since_key` are kept (err toward
139
+ refetch). With `since` None the behavior is a plain full pull.
140
+ """
141
+ all_items: list[dict[str, Any]] = []
142
+ offset = 0
143
+
144
+ while True:
145
+ params["count"] = PAGE_SIZE
146
+ params["offset"] = offset
147
+ response = _request_with_retry(client, url, params)
148
+ data = response.json()
149
+
150
+ page, total = _unwrap_items(data, url)
151
+
152
+ reached_since = False
153
+ if since is not None:
154
+ kept = []
155
+ for item in page:
156
+ ts = item.get(since_key)
157
+ if ts is not None and ts <= since:
158
+ reached_since = True
159
+ break
160
+ kept.append(item)
161
+ all_items.extend(kept)
162
+ else:
163
+ all_items.extend(page)
164
+
165
+ if total is None:
166
+ print(f" Fetched {len(all_items)}")
167
+ else:
168
+ print(f" Fetched {len(all_items)}/{total}")
169
+
170
+ if reached_since:
171
+ break
172
+ if total is None:
173
+ if len(page) < PAGE_SIZE:
174
+ break
175
+ elif len(all_items) >= total or not page:
176
+ break
177
+ offset += PAGE_SIZE
178
+
179
+ return all_items
180
+
181
+
182
+ def _submission_list_params(api_key: str, since: str | None) -> dict[str, Any]:
183
+ """Query params for a submission list call, ordered for early-stop when incremental.
184
+
185
+ With `since` set, page newest-first by `dateLastActivity` so `_paginate` can
186
+ stop at the watermark. Both `/submissions` and `/_submissions` accept this
187
+ ordering.
188
+ """
189
+ params = {"apiToken": api_key}
190
+ if since is not None:
191
+ params["orderBy"] = "dateLastActivity"
192
+ params["orderDirection"] = "DESC"
193
+ return params
194
+
195
+
196
+ def fetch_submissions(
197
+ base_url: str, api_key: str, *, since: str | None = None
198
+ ) -> list[dict[str, Any]]:
199
+ """Fetch submissions from /submissions.
200
+
201
+ With `since` set, returns only submissions whose `dateLastActivity` is newer
202
+ than the watermark (incremental delta); with `since` None, a full pull.
203
+ """
204
+ print("Fetching submissions...")
205
+ with _http_client() as client:
206
+ url = f"{base_url}/api/v1/submissions"
207
+ return _paginate(
208
+ client, url, _submission_list_params(api_key, since), since=since
209
+ )
210
+
211
+
212
+ def fetch_submissions_extended(
213
+ base_url: str, api_key: str, *, since: str | None = None
214
+ ) -> list[dict[str, Any]]:
215
+ """Fetch submissions from /_submissions (includes review data).
216
+
217
+ Honors `since` for an incremental delta, the same way as `fetch_submissions`.
218
+ """
219
+ print("Fetching extended submissions (with review data)...")
220
+ with _http_client() as client:
221
+ url = f"{base_url}/api/v1/_submissions"
222
+ return _paginate(
223
+ client, url, _submission_list_params(api_key, since), since=since
224
+ )
225
+
226
+
227
+ def _fetch_publication(
228
+ client: httpx.Client,
229
+ base_url: str,
230
+ api_key: str,
231
+ submission_id: int,
232
+ publication_id: int,
233
+ ) -> dict[str, Any]:
234
+ """Fetch a publication's detail on an open client, tagged with submission id."""
235
+ url = f"{base_url}/api/v1/submissions/{submission_id}/publications/{publication_id}"
236
+ response = _request_with_retry(client, url, {"apiToken": api_key})
237
+ pub = response.json()
238
+ pub["_submission_id"] = submission_id
239
+ return pub
240
+
241
+
242
+ def fetch_publication(
243
+ base_url: str, api_key: str, submission_id: int, publication_id: int
244
+ ) -> dict[str, Any]:
245
+ """Fetch a single submission's full publication detail (one-off request)."""
246
+ with _http_client() as client:
247
+ return _fetch_publication(
248
+ client, base_url, api_key, submission_id, publication_id
249
+ )
250
+
251
+
252
+ def fetch_all_publications(
253
+ base_url: str,
254
+ api_key: str,
255
+ submissions: list[dict[str, Any]],
256
+ *,
257
+ known: dict[int, str] | None = None,
258
+ ) -> list[dict[str, Any]]:
259
+ """Fetch full publication details for the given submissions.
260
+
261
+ `known` maps submission_id -> last-seen `dateLastActivity`. When a
262
+ submission's current `dateLastActivity` matches its `known` value, the detail
263
+ GET is skipped as unchanged -- the biggest incremental saving, since this
264
+ endpoint costs one HTTP round-trip per submission. Brand-new submissions
265
+ (absent from `known`) are always fetched.
266
+ """
267
+ known = known or {}
268
+ print(f"Fetching full publication details for {len(submissions)} submissions...")
269
+ publications = []
270
+ skipped = 0
271
+
272
+ with _http_client() as client:
273
+ for i, sub in enumerate(submissions):
274
+ pubs = sub.get("publications", [])
275
+ if not pubs:
276
+ continue
277
+
278
+ if _is_unchanged(sub, known):
279
+ skipped += 1
280
+ continue
281
+
282
+ pub = _fetch_publication(
283
+ client, base_url, api_key, sub["id"], pubs[0]["id"]
284
+ )
285
+ publications.append(pub)
286
+
287
+ if (i + 1) % 50 == 0:
288
+ print(f" Fetched {i + 1}/{len(submissions)} publications")
289
+
290
+ if skipped:
291
+ print(f" Skipped {skipped} unchanged publications")
292
+ print(f" Fetched {len(publications)}/{len(submissions)} publications")
293
+ return publications
294
+
295
+
296
+ def _submission_files_url(base_url: str, submission_id: int) -> str:
297
+ return f"{base_url}/api/v1/submissions/{submission_id}/files"
298
+
299
+
300
+ def _file_query_params(
301
+ api_key: str,
302
+ *,
303
+ file_stages: list[int] | None,
304
+ review_round_ids: list[int] | None,
305
+ ) -> dict[str, Any]:
306
+ """Query params for the files list endpoint, dropping unset filters.
307
+
308
+ `fileStages` and `reviewRoundIds` are `style=form, explode=false` array
309
+ params in the OJS spec, i.e. comma-separated single values (`fileStages=4,15`)
310
+ -- the same encoding `_stats_params` uses for `submissionIds`.
311
+ """
312
+ params: dict[str, Any] = {"apiToken": api_key}
313
+ if file_stages:
314
+ params["fileStages"] = ",".join(str(s) for s in file_stages)
315
+ if review_round_ids:
316
+ params["reviewRoundIds"] = ",".join(str(r) for r in review_round_ids)
317
+ return params
318
+
319
+
320
+ def _fetch_submission_files(
321
+ client: _HttpClient,
322
+ base_url: str,
323
+ api_key: str,
324
+ submission_id: int,
325
+ *,
326
+ file_stages: list[int] | None,
327
+ review_round_ids: list[int] | None,
328
+ ) -> list[dict[str, Any]]:
329
+ """Fetch one submission's files on an open client, tagging each with its id.
330
+
331
+ The endpoint returns the standard `{items, itemsMax}` envelope (older swagger
332
+ snapshots document a bare array; both are accepted). It is scoped to a single
333
+ submission, which is never expected to carry a full page of files, so this
334
+ does not paginate.
335
+ """
336
+ url = _submission_files_url(base_url, submission_id)
337
+ params = _file_query_params(
338
+ api_key, file_stages=file_stages, review_round_ids=review_round_ids
339
+ )
340
+ payload = _request_with_retry(client, url, params).json()
341
+ files, _ = _unwrap_items(payload, url)
342
+ for f in files:
343
+ f["_submission_id"] = submission_id
344
+ return files
345
+
346
+
347
+ def fetch_submission_files(
348
+ base_url: str,
349
+ api_key: str,
350
+ submission_id: int,
351
+ *,
352
+ file_stages: list[int] | None = None,
353
+ review_round_ids: list[int] | None = None,
354
+ ) -> list[dict[str, Any]]:
355
+ """Fetch the file metadata for a single submission (one-off request)."""
356
+ with _http_client() as client:
357
+ return _fetch_submission_files(
358
+ client,
359
+ base_url,
360
+ api_key,
361
+ submission_id,
362
+ file_stages=file_stages,
363
+ review_round_ids=review_round_ids,
364
+ )
365
+
366
+
367
+ def fetch_all_submission_files(
368
+ base_url: str,
369
+ api_key: str,
370
+ submissions: list[dict[str, Any]],
371
+ *,
372
+ known: dict[int, str] | None = None,
373
+ file_stages: list[int] | None = None,
374
+ review_round_ids: list[int] | None = None,
375
+ ) -> list[dict[str, Any]]:
376
+ """Fetch file metadata for the given submissions, one request each.
377
+
378
+ `known` maps submission_id -> last-seen `dateLastActivity`. A submission
379
+ whose current `dateLastActivity` matches its `known` value is skipped as
380
+ unchanged -- the same incremental saving used by `fetch_all_publications`,
381
+ since this endpoint costs one HTTP round-trip per submission. New
382
+ submissions (absent from `known`) are always fetched.
383
+ """
384
+ known = known or {}
385
+ print(f"Fetching submission files for {len(submissions)} submissions...")
386
+ files: list[dict[str, Any]] = []
387
+ skipped = 0
388
+
389
+ with _http_client() as client:
390
+ for i, sub in enumerate(submissions):
391
+ if _is_unchanged(sub, known):
392
+ skipped += 1
393
+ continue
394
+
395
+ files.extend(
396
+ _fetch_submission_files(
397
+ client,
398
+ base_url,
399
+ api_key,
400
+ sub["id"],
401
+ file_stages=file_stages,
402
+ review_round_ids=review_round_ids,
403
+ )
404
+ )
405
+
406
+ if (i + 1) % 50 == 0:
407
+ print(f" Fetched files for {i + 1}/{len(submissions)} submissions")
408
+
409
+ if skipped:
410
+ print(f" Skipped {skipped} unchanged submissions")
411
+ print(f" Fetched {len(files)} file records")
412
+ return files
413
+
414
+
415
+ def fetch_users(base_url: str, api_key: str) -> list[dict[str, Any]]:
416
+ """Fetch all users from /users endpoint."""
417
+ print("Fetching users...")
418
+ with _http_client() as client:
419
+ url = f"{base_url}/api/v1/users"
420
+ return _paginate(client, url, {"apiToken": api_key})
421
+
422
+
423
+ def _stats_params(
424
+ api_key: str,
425
+ *,
426
+ date_start: str | None = None,
427
+ date_end: str | None = None,
428
+ submission_ids: list[int] | None = None,
429
+ ) -> dict[str, Any]:
430
+ """Build a query-param dict for the /stats endpoints, dropping unset filters."""
431
+ params = {"apiToken": api_key}
432
+ if date_start:
433
+ params["dateStart"] = date_start
434
+ if date_end:
435
+ params["dateEnd"] = date_end
436
+ if submission_ids:
437
+ # OJS expects a comma-separated list (style=form, explode=false).
438
+ params["submissionIds"] = ",".join(str(s) for s in submission_ids)
439
+ return params
440
+
441
+
442
+ def fetch_publication_stats(
443
+ base_url: str,
444
+ api_key: str,
445
+ *,
446
+ date_start: str | None = None,
447
+ date_end: str | None = None,
448
+ submission_ids: list[int] | None = None,
449
+ ) -> list[dict[str, Any]]:
450
+ """Fetch per-publication view totals from /stats/publications.
451
+
452
+ Returns one record per publication with abstract, all-galley, PDF, HTML, and
453
+ other view counts. `_paginate` tolerates both the `{items, itemsMax}`
454
+ envelope and a bare-array response from this endpoint.
455
+ """
456
+ print("Fetching publication view stats...")
457
+ params = _stats_params(
458
+ api_key,
459
+ date_start=date_start,
460
+ date_end=date_end,
461
+ submission_ids=submission_ids,
462
+ )
463
+ with _http_client() as client:
464
+ url = f"{base_url}/api/v1/stats/publications"
465
+ return _paginate(client, url, params)
466
+
467
+
468
+ def _request_timeline(
469
+ client: httpx.Client,
470
+ base_url: str,
471
+ api_key: str,
472
+ submission_id: int,
473
+ kind: str,
474
+ *,
475
+ interval: str,
476
+ date_start: str | None,
477
+ date_end: str | None,
478
+ ) -> list[dict[str, Any]]:
479
+ """Fetch one publication's abstract- or galley-view timeline.
480
+
481
+ Returns a flat list of `{date, label, value}` points for the requested
482
+ submission. These per-publication endpoints are not paginated.
483
+ """
484
+ params = _stats_params(api_key, date_start=date_start, date_end=date_end)
485
+ params["timelineInterval"] = interval
486
+ url = f"{base_url}/api/v1/stats/publications/{submission_id}/{kind}"
487
+ response = _request_with_retry(client, url, params)
488
+ return response.json()
489
+
490
+
491
+ def fetch_view_timelines(
492
+ base_url: str,
493
+ api_key: str,
494
+ submission_ids: list[int],
495
+ *,
496
+ interval: str = "day",
497
+ date_start: str | None = None,
498
+ date_end: str | None = None,
499
+ ) -> list[dict[str, Any]]:
500
+ """Fetch per-submission abstract and galley view timelines.
501
+
502
+ Makes one request per submission per view type and returns a flat list of
503
+ timeline points, each tagged with `_submission_id`, `kind`, and `interval`
504
+ (the granularity it was fetched at), ready for normalization into a long
505
+ table. Defaults to daily resolution, the finest granularity the OJS stats API
506
+ exposes.
507
+ """
508
+ total = len(submission_ids)
509
+ print(f"Fetching view timelines for {total} submissions ({interval})...")
510
+ points = []
511
+
512
+ with _http_client() as client:
513
+ for i, submission_id in enumerate(submission_ids):
514
+ for kind in ("abstract", "galley"):
515
+ series = _request_timeline(
516
+ client,
517
+ base_url,
518
+ api_key,
519
+ submission_id,
520
+ kind,
521
+ interval=interval,
522
+ date_start=date_start,
523
+ date_end=date_end,
524
+ )
525
+ for point in series:
526
+ points.append(
527
+ {
528
+ **point,
529
+ "_submission_id": submission_id,
530
+ "kind": kind,
531
+ "interval": interval,
532
+ }
533
+ )
534
+
535
+ if (i + 1) % 50 == 0:
536
+ print(f" Fetched {i + 1}/{total} submission timelines")
537
+
538
+ # Final tally, unless the in-loop print just emitted it.
539
+ if total and total % 50 != 0:
540
+ print(f" Fetched {total}/{total} submission timelines")
541
+ return points
542
+
543
+
544
+ def fetch_view_timeline_totals(
545
+ base_url: str,
546
+ api_key: str,
547
+ *,
548
+ interval: str = "day",
549
+ date_start: str | None = None,
550
+ date_end: str | None = None,
551
+ submission_ids: list[int] | None = None,
552
+ ) -> list[dict[str, Any]]:
553
+ """Fetch journal-wide abstract and galley view timelines.
554
+
555
+ Hits the aggregate `/stats/publications/abstract` and
556
+ `/stats/publications/galley` endpoints (no submission id) -- the same data
557
+ the OJS statistics page graphs by month over the full span. Returns a flat
558
+ list of timeline points, each tagged with `kind` (`abstract` or `galley`)
559
+ and `interval` (the granularity), ready to normalize into a journal-wide
560
+ totals table.
561
+ """
562
+ print(f"Fetching journal-wide view timeline totals ({interval})...")
563
+ params = _stats_params(
564
+ api_key,
565
+ date_start=date_start,
566
+ date_end=date_end,
567
+ submission_ids=submission_ids,
568
+ )
569
+ params["timelineInterval"] = interval
570
+ points = []
571
+ with _http_client() as client:
572
+ for kind in ("abstract", "galley"):
573
+ url = f"{base_url}/api/v1/stats/publications/{kind}"
574
+ series = _request_with_retry(client, url, params).json()
575
+ for point in series:
576
+ points.append({**point, "kind": kind, "interval": interval})
577
+ return points