apographe 0.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.
- apographe/__init__.py +37 -0
- apographe/__main__.py +5 -0
- apographe/_version.py +8 -0
- apographe/cli.py +208 -0
- apographe/core/__init__.py +1 -0
- apographe/core/http.py +94 -0
- apographe/core/oauth.py +83 -0
- apographe/core/registry.py +33 -0
- apographe/core/source.py +82 -0
- apographe/core/storage.py +121 -0
- apographe/py.typed +0 -0
- apographe/runner.py +131 -0
- apographe/sources/__init__.py +0 -0
- apographe/sources/cy/__init__.py +0 -0
- apographe/sources/cy/ekalathi/__init__.py +197 -0
- apographe/sources/cy/ekalathi/client.py +98 -0
- apographe/sources/cy/ekalathi/parse.py +250 -0
- apographe/sources/cy/fuel/__init__.py +1 -0
- apographe/sources/cy/fuel/api.py +258 -0
- apographe/sources/cy/fuel/pdf.py +346 -0
- apographe/sources/cy/fuel/scraper.py +184 -0
- apographe/sources/cy/fuel/source.py +113 -0
- apographe-0.1.0.dist-info/METADATA +117 -0
- apographe-0.1.0.dist-info/RECORD +27 -0
- apographe-0.1.0.dist-info/WHEEL +4 -0
- apographe-0.1.0.dist-info/entry_points.txt +2 -0
- apographe-0.1.0.dist-info/licenses/LICENSE +21 -0
apographe/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Apographe: collect public data, source by source and day by day, into plain files.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from apographe import run
|
|
6
|
+
|
|
7
|
+
report = run("cy.ekalathi", out="data", limit=5)
|
|
8
|
+
print(report.rows) # rows written per table
|
|
9
|
+
|
|
10
|
+
``help(run)`` shows every option and how to write your own source.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from apographe._version import __version__
|
|
14
|
+
from apographe.core.http import PoliteClient
|
|
15
|
+
from apographe.core.oauth import ClientCredentials
|
|
16
|
+
from apographe.core.source import (
|
|
17
|
+
LoginWall,
|
|
18
|
+
Options,
|
|
19
|
+
Report,
|
|
20
|
+
Source,
|
|
21
|
+
SourceError,
|
|
22
|
+
)
|
|
23
|
+
from apographe.core.storage import Store
|
|
24
|
+
from apographe.runner import run
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ClientCredentials",
|
|
28
|
+
"LoginWall",
|
|
29
|
+
"Options",
|
|
30
|
+
"PoliteClient",
|
|
31
|
+
"Report",
|
|
32
|
+
"Source",
|
|
33
|
+
"SourceError",
|
|
34
|
+
"Store",
|
|
35
|
+
"__version__",
|
|
36
|
+
"run",
|
|
37
|
+
]
|
apographe/__main__.py
ADDED
apographe/_version.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The installed version, in its own module so core code can import it early."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("apographe")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree that is not installed
|
|
8
|
+
__version__ = "0.0.0"
|
apographe/cli.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Command line: ``apographe list`` and ``apographe run <source>``.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 done, 2 the source now needs a login (nothing collected),
|
|
4
|
+
1 anything else.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import date
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from apographe._version import __version__
|
|
16
|
+
from apographe.core import registry
|
|
17
|
+
from apographe.core.source import LoginWall, Report, SourceError
|
|
18
|
+
from apographe.runner import run
|
|
19
|
+
|
|
20
|
+
DOCS = "https://github.com/aristidesz/apographe/blob/main/docs"
|
|
21
|
+
EPILOG = f"""examples:
|
|
22
|
+
apographe list what you can collect
|
|
23
|
+
apographe info cy.fuel a source's jobs and what they need
|
|
24
|
+
apographe run cy.ekalathi --limit 5 --out data try a source
|
|
25
|
+
|
|
26
|
+
docs: {DOCS}/usage.md"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: list[str] | None = None) -> int:
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="apographe",
|
|
32
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
33
|
+
epilog=EPILOG,
|
|
34
|
+
description=(
|
|
35
|
+
"Collect public data, source by source and day by day, into plain files."
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
39
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
40
|
+
|
|
41
|
+
sub.add_parser("list", help="show the sources and their jobs")
|
|
42
|
+
|
|
43
|
+
info = sub.add_parser("info", help="show a source's jobs and what they need")
|
|
44
|
+
info.add_argument("source", help="source id, e.g. cy.fuel")
|
|
45
|
+
|
|
46
|
+
run = sub.add_parser("run", help="run a source's jobs")
|
|
47
|
+
run.add_argument("source", help="source id, e.g. cy.ekalathi")
|
|
48
|
+
run.add_argument(
|
|
49
|
+
"--job",
|
|
50
|
+
action="append",
|
|
51
|
+
dest="jobs",
|
|
52
|
+
help="job to run (repeatable); default: the source's usual daily jobs",
|
|
53
|
+
)
|
|
54
|
+
run.add_argument(
|
|
55
|
+
"--out", type=Path, default=Path("data"), help="folder to write into"
|
|
56
|
+
)
|
|
57
|
+
run.add_argument(
|
|
58
|
+
"--date",
|
|
59
|
+
type=date.fromisoformat,
|
|
60
|
+
help="the day to file the data under (default: today where the source is)",
|
|
61
|
+
)
|
|
62
|
+
run.add_argument("--limit", type=int, help="only the first N items")
|
|
63
|
+
run.add_argument(
|
|
64
|
+
"--only", help="only these item ids, comma separated (e.g. 2074,1906)"
|
|
65
|
+
)
|
|
66
|
+
run.add_argument("--delay", type=float, help="seconds between requests")
|
|
67
|
+
run.add_argument(
|
|
68
|
+
"--input",
|
|
69
|
+
type=Path,
|
|
70
|
+
action="append",
|
|
71
|
+
dest="inputs",
|
|
72
|
+
default=[],
|
|
73
|
+
help="file or folder for a job that imports (repeatable)",
|
|
74
|
+
)
|
|
75
|
+
run.add_argument("--force", action="store_true", help="redo existing work")
|
|
76
|
+
run.add_argument(
|
|
77
|
+
"--env-file",
|
|
78
|
+
type=Path,
|
|
79
|
+
help="file of KEY=VALUE lines to use as environment variables for this run",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
args = parser.parse_args(argv)
|
|
83
|
+
if args.command == "list":
|
|
84
|
+
return _list()
|
|
85
|
+
if args.command == "info":
|
|
86
|
+
return _info(args.source)
|
|
87
|
+
return _run(args)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _list() -> int:
|
|
91
|
+
for source_id in sorted(registry.available()):
|
|
92
|
+
source = registry.load(source_id)
|
|
93
|
+
print(f"{source.id} {source.title} ({source.country})")
|
|
94
|
+
for job, about in source.jobs.items():
|
|
95
|
+
mark = "*" if job in source.default_jobs else " "
|
|
96
|
+
needs = []
|
|
97
|
+
if source.job_env.get(job):
|
|
98
|
+
needs.append("[needs env]")
|
|
99
|
+
if job in source.extras:
|
|
100
|
+
needs.append(f"[needs extra {source.extras[job]}]")
|
|
101
|
+
print(f" {mark} {job:<14} {about} {' '.join(needs)}".rstrip())
|
|
102
|
+
print("\n* runs by default")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _info(source_id: str) -> int:
|
|
107
|
+
try:
|
|
108
|
+
source = registry.load(source_id)
|
|
109
|
+
except KeyError as exc:
|
|
110
|
+
print(exc.args[0], file=sys.stderr)
|
|
111
|
+
return 1
|
|
112
|
+
print(f"{source.id}: {source.title}")
|
|
113
|
+
print(f"homepage: {source.homepage}")
|
|
114
|
+
print(f"time zone: {source.timezone}")
|
|
115
|
+
print(f"output: <out>/{source.folder}/")
|
|
116
|
+
print("jobs:")
|
|
117
|
+
for job, about in source.jobs.items():
|
|
118
|
+
default = " (default)" if job in source.default_jobs else ""
|
|
119
|
+
print(f" {job}{default}: {about}")
|
|
120
|
+
if job in source.extras:
|
|
121
|
+
print(f" install: pip install 'apographe[{source.extras[job]}]'")
|
|
122
|
+
for name in source.job_env.get(job, ()):
|
|
123
|
+
print(f" needs {name}: {source.env.get(name, '')}")
|
|
124
|
+
optional = [
|
|
125
|
+
n for n in source.env if not any(n in v for v in source.job_env.values())
|
|
126
|
+
]
|
|
127
|
+
for name in optional:
|
|
128
|
+
print(f"optional {name}: {source.env[name]}")
|
|
129
|
+
print(f"docs: {DOCS}/sources/{source.id.replace('.', '-')}.md")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _read_env_file(path: Path) -> dict[str, str]:
|
|
134
|
+
"""KEY=VALUE lines; blank lines and # comments skipped, quotes stripped."""
|
|
135
|
+
values = {}
|
|
136
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
137
|
+
line = line.strip()
|
|
138
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
139
|
+
continue
|
|
140
|
+
key, _, value = line.partition("=")
|
|
141
|
+
value = value.strip()
|
|
142
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
143
|
+
value = value[1:-1]
|
|
144
|
+
values[key.strip()] = value
|
|
145
|
+
return values
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _run(args: argparse.Namespace) -> int:
|
|
149
|
+
added: list[str] = []
|
|
150
|
+
if args.env_file:
|
|
151
|
+
try:
|
|
152
|
+
values = _read_env_file(args.env_file)
|
|
153
|
+
except OSError as exc:
|
|
154
|
+
print(f"cannot read {args.env_file}: {exc.strerror}", file=sys.stderr)
|
|
155
|
+
return 1
|
|
156
|
+
for key, value in values.items():
|
|
157
|
+
if key not in os.environ: # a variable already set wins
|
|
158
|
+
os.environ[key] = value
|
|
159
|
+
added.append(key)
|
|
160
|
+
try:
|
|
161
|
+
return _run_source(args)
|
|
162
|
+
finally:
|
|
163
|
+
for key in added: # the file only applies to this run
|
|
164
|
+
del os.environ[key]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _run_source(args: argparse.Namespace) -> int:
|
|
168
|
+
current = args.source
|
|
169
|
+
|
|
170
|
+
def progress(job: str, day: date, report: Report | None) -> None:
|
|
171
|
+
nonlocal current
|
|
172
|
+
current = f"{args.source} {job}"
|
|
173
|
+
if report is None:
|
|
174
|
+
print(f"{current}: started for {day}", flush=True)
|
|
175
|
+
return
|
|
176
|
+
for table, count in report.rows.items():
|
|
177
|
+
print(f"{current}: {count} rows -> {table}", flush=True)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
run(
|
|
181
|
+
args.source,
|
|
182
|
+
out=args.out,
|
|
183
|
+
jobs=args.jobs,
|
|
184
|
+
day=args.date,
|
|
185
|
+
limit=args.limit,
|
|
186
|
+
only=[x.strip() for x in args.only.split(",")] if args.only else None,
|
|
187
|
+
delay=args.delay,
|
|
188
|
+
inputs=args.inputs,
|
|
189
|
+
force=args.force,
|
|
190
|
+
progress=progress,
|
|
191
|
+
)
|
|
192
|
+
except LoginWall as exc:
|
|
193
|
+
print(f"::warning::{args.source} needs a login now, skipping: {exc}")
|
|
194
|
+
return 2
|
|
195
|
+
except SourceError as exc:
|
|
196
|
+
print(f"{current}: stopped: {exc}", file=sys.stderr)
|
|
197
|
+
return 1
|
|
198
|
+
except KeyError as exc:
|
|
199
|
+
print(exc.args[0], file=sys.stderr)
|
|
200
|
+
return 1
|
|
201
|
+
except ValueError as exc:
|
|
202
|
+
print(exc, file=sys.stderr)
|
|
203
|
+
return 1
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The framework every source plugs into."""
|
apographe/core/http.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""A polite JSON client: one request at a time, spaced out, retried when the
|
|
2
|
+
server stumbles, and clear about it when a site starts asking for a login."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from apographe._version import __version__
|
|
14
|
+
from apographe.core.source import LoginWall, SourceError
|
|
15
|
+
|
|
16
|
+
USER_AGENT = f"apographe/{__version__} (+https://github.com/aristidesz/apographe)"
|
|
17
|
+
RETRY_STATUS = {429, 500, 502, 503, 504}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PoliteClient:
|
|
21
|
+
"""Fetch JSON from one site, politely.
|
|
22
|
+
|
|
23
|
+
Waits ``min_interval`` seconds between requests, retries 429 and 5xx answers
|
|
24
|
+
with a growing pause, and raises ``LoginWall`` if the site asks for a login.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
base_url: str,
|
|
30
|
+
*,
|
|
31
|
+
min_interval: float = 3.0,
|
|
32
|
+
timeout: float = 60.0,
|
|
33
|
+
retries: int = 3,
|
|
34
|
+
backoff: float = 10.0,
|
|
35
|
+
session: requests.Session | None = None,
|
|
36
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
37
|
+
clock: Callable[[], float] = time.monotonic,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.base_url = base_url.rstrip("/")
|
|
40
|
+
self.min_interval = min_interval
|
|
41
|
+
self.timeout = timeout
|
|
42
|
+
self.retries = retries
|
|
43
|
+
self.backoff = backoff
|
|
44
|
+
self.session = session or requests.Session()
|
|
45
|
+
self.session.headers.setdefault("User-Agent", USER_AGENT)
|
|
46
|
+
self.session.headers["Accept"] = "application/json"
|
|
47
|
+
self._sleep = sleep
|
|
48
|
+
self._clock = clock
|
|
49
|
+
self._last: float | None = None
|
|
50
|
+
|
|
51
|
+
def get_json(self, path: str, params: Mapping[str, Any] | None = None) -> Any:
|
|
52
|
+
"""GET ``base_url + path`` and decode the JSON, retrying server errors.
|
|
53
|
+
|
|
54
|
+
Raises LoginWall when the site answers 401/403 or redirects elsewhere
|
|
55
|
+
(typically to a login page), and SourceError when retries run out.
|
|
56
|
+
"""
|
|
57
|
+
url = f"{self.base_url}{path}"
|
|
58
|
+
last_error = ""
|
|
59
|
+
for attempt in range(self.retries + 1):
|
|
60
|
+
if attempt:
|
|
61
|
+
self._sleep(self.backoff * attempt)
|
|
62
|
+
self._wait()
|
|
63
|
+
try:
|
|
64
|
+
resp = self.session.get(url, params=params, timeout=self.timeout)
|
|
65
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
66
|
+
last_error = type(exc).__name__
|
|
67
|
+
continue
|
|
68
|
+
if resp.status_code in (401, 403) or _left_site(url, resp.url):
|
|
69
|
+
raise LoginWall(f"{path} answered {resp.status_code} at {resp.url}")
|
|
70
|
+
if resp.status_code in RETRY_STATUS:
|
|
71
|
+
last_error = f"HTTP {resp.status_code}"
|
|
72
|
+
continue
|
|
73
|
+
if resp.status_code != 200:
|
|
74
|
+
raise SourceError(f"{path} answered HTTP {resp.status_code}")
|
|
75
|
+
try:
|
|
76
|
+
return resp.json()
|
|
77
|
+
except ValueError as exc:
|
|
78
|
+
kind = resp.headers.get("Content-Type", "unknown content")
|
|
79
|
+
raise SourceError(f"{path} did not return JSON ({kind})") from exc
|
|
80
|
+
raise SourceError(f"{path} failed {self.retries + 1} times: {last_error}")
|
|
81
|
+
|
|
82
|
+
def _wait(self) -> None:
|
|
83
|
+
now = self._clock()
|
|
84
|
+
if self._last is not None:
|
|
85
|
+
gap = self.min_interval - (now - self._last)
|
|
86
|
+
if gap > 0:
|
|
87
|
+
self._sleep(gap)
|
|
88
|
+
self._last = self._clock()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _left_site(requested: str, final: str | None) -> bool:
|
|
92
|
+
if not final:
|
|
93
|
+
return False
|
|
94
|
+
return urlsplit(requested).netloc != urlsplit(final).netloc
|
apographe/core/oauth.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""OAuth 2.0 client-credentials sign-in, for sources that sit behind a gateway.
|
|
2
|
+
|
|
3
|
+
The client id and secret identify a program, not a person. They are exchanged
|
|
4
|
+
for a short-lived access token, which is kept and reused until it is about to
|
|
5
|
+
expire (gateways ask clients not to fetch a new token for every call).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
from apographe._version import __version__
|
|
16
|
+
from apographe.core.source import SourceError
|
|
17
|
+
|
|
18
|
+
USER_AGENT = f"apographe/{__version__} (+https://github.com/aristidesz/apographe)"
|
|
19
|
+
EARLY = 60.0 # seconds before expiry at which a token is replaced
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClientCredentials:
|
|
23
|
+
"""Sign in with a client id and secret; ``token()`` gives the access token."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
token_url: str,
|
|
28
|
+
client_id: str,
|
|
29
|
+
client_secret: str,
|
|
30
|
+
*,
|
|
31
|
+
session: requests.Session | None = None,
|
|
32
|
+
clock: Callable[[], float] = time.monotonic,
|
|
33
|
+
timeout: float = 30.0,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.token_url = token_url
|
|
36
|
+
self._id = client_id
|
|
37
|
+
self._secret = client_secret
|
|
38
|
+
self.session = session or requests.Session()
|
|
39
|
+
self._clock = clock
|
|
40
|
+
self.timeout = timeout
|
|
41
|
+
self._token: str | None = None
|
|
42
|
+
self._expires = 0.0
|
|
43
|
+
|
|
44
|
+
def __repr__(self) -> str:
|
|
45
|
+
# Never show the secret, even in a traceback or a log line.
|
|
46
|
+
return f"ClientCredentials(token_url={self.token_url!r}, client_id=...)"
|
|
47
|
+
|
|
48
|
+
def token(self) -> str:
|
|
49
|
+
"""The current access token, fetched only when there is none or it expired."""
|
|
50
|
+
if self._token is None or self._clock() >= self._expires:
|
|
51
|
+
self._token = self._fetch()
|
|
52
|
+
return self._token
|
|
53
|
+
|
|
54
|
+
def forget(self) -> None:
|
|
55
|
+
"""Drop the token, e.g. after the API refused it."""
|
|
56
|
+
self._token = None
|
|
57
|
+
|
|
58
|
+
def _fetch(self) -> str:
|
|
59
|
+
try:
|
|
60
|
+
resp = self.session.post(
|
|
61
|
+
self.token_url,
|
|
62
|
+
data={"grant_type": "client_credentials"},
|
|
63
|
+
auth=(self._id, self._secret),
|
|
64
|
+
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
|
|
65
|
+
timeout=self.timeout,
|
|
66
|
+
)
|
|
67
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
68
|
+
raise SourceError(f"the token server was unreachable: {exc!r}") from exc
|
|
69
|
+
if resp.status_code in (400, 401):
|
|
70
|
+
raise SourceError(
|
|
71
|
+
f"the token server refused the client id and secret "
|
|
72
|
+
f"(HTTP {resp.status_code}); check them and the environment"
|
|
73
|
+
)
|
|
74
|
+
if resp.status_code != 200:
|
|
75
|
+
raise SourceError(f"the token server answered HTTP {resp.status_code}")
|
|
76
|
+
try:
|
|
77
|
+
body = resp.json()
|
|
78
|
+
token = str(body["access_token"])
|
|
79
|
+
lifetime = float(body.get("expires_in", 3600))
|
|
80
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
81
|
+
raise SourceError("the token server's answer had no access_token") from exc
|
|
82
|
+
self._expires = self._clock() + max(lifetime - EARLY, 0.0)
|
|
83
|
+
return token
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Find a source by its id.
|
|
2
|
+
|
|
3
|
+
Sources are listed by "module:Class" text rather than imported up front, so
|
|
4
|
+
running one source never imports another's dependencies (e-kalathi works
|
|
5
|
+
without the browser that the fuel source needs).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from importlib import import_module
|
|
11
|
+
|
|
12
|
+
from apographe.core.source import Source
|
|
13
|
+
|
|
14
|
+
BUILT_IN = {
|
|
15
|
+
"cy.ekalathi": "apographe.sources.cy.ekalathi:EkalathiSource",
|
|
16
|
+
"cy.fuel": "apographe.sources.cy.fuel.source:FuelSource",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def available() -> dict[str, str]:
|
|
21
|
+
"""Source id -> "module:Class"."""
|
|
22
|
+
return dict(BUILT_IN)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load(source_id: str) -> Source:
|
|
26
|
+
"""Make the built-in source with this id; KeyError if there is none."""
|
|
27
|
+
target = available().get(source_id)
|
|
28
|
+
if target is None:
|
|
29
|
+
known = ", ".join(sorted(available()))
|
|
30
|
+
raise KeyError(f"unknown source {source_id!r}; known sources: {known}")
|
|
31
|
+
module, _, name = target.partition(":")
|
|
32
|
+
cls = getattr(import_module(module), name)
|
|
33
|
+
return cls()
|
apographe/core/source.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""What every source provides, and what a run hands back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import ClassVar
|
|
10
|
+
|
|
11
|
+
from apographe.core.storage import Store
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Options:
|
|
16
|
+
"""Settings for one run, shared by every source.
|
|
17
|
+
|
|
18
|
+
A source reads the ones that make sense for it and ignores the rest.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
day: date
|
|
22
|
+
limit: int | None = None # only the first N items, for a quick trial run
|
|
23
|
+
only: list[str] = field(default_factory=list) # only these item ids
|
|
24
|
+
delay: float | None = None # seconds between requests; None = source default
|
|
25
|
+
inputs: list[Path] = field(default_factory=list) # files a job imports
|
|
26
|
+
force: bool = False # redo work that already exists
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Report:
|
|
31
|
+
"""Rows written per table. Counts only, so it is safe to print in public logs."""
|
|
32
|
+
|
|
33
|
+
rows: dict[str, int] = field(default_factory=dict)
|
|
34
|
+
files: list[Path] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
def add(self, table: str, count: int, path: Path | None = None) -> None:
|
|
37
|
+
"""Record ``count`` rows written to ``table`` (and the file, if given)."""
|
|
38
|
+
self.rows[table] = self.rows.get(table, 0) + count
|
|
39
|
+
if path is not None:
|
|
40
|
+
self.files.append(path)
|
|
41
|
+
|
|
42
|
+
def merge(self, other: Report) -> None:
|
|
43
|
+
"""Add another report's rows and files to this one."""
|
|
44
|
+
for table, count in other.rows.items():
|
|
45
|
+
self.add(table, count)
|
|
46
|
+
self.files.extend(other.files)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SourceError(RuntimeError):
|
|
50
|
+
"""The source answered, but not with what we expected."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LoginWall(SourceError):
|
|
54
|
+
"""The source now asks for a login, so there is nothing to collect."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Source(ABC):
|
|
58
|
+
"""One place data comes from, such as a country's fuel price observatory.
|
|
59
|
+
|
|
60
|
+
Subclasses set the class attributes and implement :meth:`run`. Everything a
|
|
61
|
+
source writes goes through ``out``, under the source's own folder.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
id: ClassVar[str] # "cy.ekalathi": country code, then a short name
|
|
65
|
+
title: ClassVar[str]
|
|
66
|
+
country: ClassVar[str] # ISO 3166-1 alpha-2, e.g. "CY"
|
|
67
|
+
homepage: ClassVar[str]
|
|
68
|
+
timezone: ClassVar[str] = "UTC" # the day a run belongs to is taken here
|
|
69
|
+
jobs: ClassVar[dict[str, str]] # job name -> one-line description
|
|
70
|
+
default_jobs: ClassVar[tuple[str, ...]] # what `apographe run <id>` does
|
|
71
|
+
env: ClassVar[dict[str, str]] = {} # variable -> what it is
|
|
72
|
+
job_env: ClassVar[dict[str, tuple[str, ...]]] = {} # job -> variables it needs
|
|
73
|
+
extras: ClassVar[dict[str, str]] = {} # job -> pip extra it needs
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def folder(self) -> str:
|
|
77
|
+
"""Where the source writes inside a store: "cy.ekalathi" -> "cy/ekalathi"."""
|
|
78
|
+
return self.id.replace(".", "/")
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def run(self, job: str, out: Store, options: Options) -> Report:
|
|
82
|
+
"""Run one job, write its tables to ``out`` and report what was written."""
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Write tables to a folder: CSV (optionally gzipped) and raw JSON responses.
|
|
2
|
+
|
|
3
|
+
Every write goes to a temporary file first and is renamed into place, so a run
|
|
4
|
+
that dies half way never leaves a truncated file behind.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
import gzip
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import Iterable, Mapping
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Store:
|
|
20
|
+
"""A folder that tables are written into, scoped to one source."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, root: Path | str) -> None:
|
|
23
|
+
self.root = Path(root)
|
|
24
|
+
|
|
25
|
+
def scope(self, folder: str) -> Store:
|
|
26
|
+
"""A store for the subfolder ``folder``, e.g. ``"cy/ekalathi"``."""
|
|
27
|
+
return Store(self.root / folder)
|
|
28
|
+
|
|
29
|
+
def path(self, rel: str) -> Path:
|
|
30
|
+
"""The full path of ``rel`` inside this store."""
|
|
31
|
+
return self.root / rel
|
|
32
|
+
|
|
33
|
+
def exists(self, rel: str) -> bool:
|
|
34
|
+
"""Whether ``rel`` has been written already."""
|
|
35
|
+
return self.path(rel).exists()
|
|
36
|
+
|
|
37
|
+
def write_csv(
|
|
38
|
+
self, rel: str, rows: Iterable[Mapping[str, Any]], fields: list[str]
|
|
39
|
+
) -> tuple[Path, int]:
|
|
40
|
+
"""Write rows as CSV (gzipped when ``rel`` ends in .gz). Returns path, count."""
|
|
41
|
+
path = self.path(rel)
|
|
42
|
+
count = 0
|
|
43
|
+
with _atomic(path) as fh:
|
|
44
|
+
writer = csv.DictWriter(
|
|
45
|
+
fh, fieldnames=fields, extrasaction="ignore", lineterminator="\n"
|
|
46
|
+
)
|
|
47
|
+
writer.writeheader()
|
|
48
|
+
for row in rows:
|
|
49
|
+
writer.writerow(row)
|
|
50
|
+
count += 1
|
|
51
|
+
return path, count
|
|
52
|
+
|
|
53
|
+
def read_csv(self, rel: str) -> list[dict[str, str]]:
|
|
54
|
+
"""Read a CSV (gzipped or not) back as dicts; ``[]`` if it does not exist."""
|
|
55
|
+
path = self.path(rel)
|
|
56
|
+
if not path.exists():
|
|
57
|
+
return []
|
|
58
|
+
opener = gzip.open if path.suffix == ".gz" else open
|
|
59
|
+
with opener(path, "rt", encoding="utf-8", newline="") as fh:
|
|
60
|
+
return list(csv.DictReader(fh))
|
|
61
|
+
|
|
62
|
+
def append_csv(
|
|
63
|
+
self, rel: str, rows: Iterable[Mapping[str, Any]], fields: list[str]
|
|
64
|
+
) -> int:
|
|
65
|
+
"""Append rows to a plain CSV, writing the header if the file is new.
|
|
66
|
+
|
|
67
|
+
Used for progress files that a later run picks up from, so it is not
|
|
68
|
+
atomic: each call adds whole rows and flushes.
|
|
69
|
+
"""
|
|
70
|
+
path = self.path(rel)
|
|
71
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
new = not path.exists()
|
|
73
|
+
count = 0
|
|
74
|
+
with open(path, "a", encoding="utf-8", newline="") as fh:
|
|
75
|
+
writer = csv.DictWriter(
|
|
76
|
+
fh, fieldnames=fields, extrasaction="ignore", lineterminator="\n"
|
|
77
|
+
)
|
|
78
|
+
if new:
|
|
79
|
+
writer.writeheader()
|
|
80
|
+
for row in rows:
|
|
81
|
+
writer.writerow(row)
|
|
82
|
+
count += 1
|
|
83
|
+
return count
|
|
84
|
+
|
|
85
|
+
def write_json(self, rel: str, payload: Any) -> Path:
|
|
86
|
+
"""Save a response untouched (gzipped when ``rel`` ends in .gz)."""
|
|
87
|
+
path = self.path(rel)
|
|
88
|
+
with _atomic(path) as fh:
|
|
89
|
+
json.dump(payload, fh, ensure_ascii=False)
|
|
90
|
+
return path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _atomic:
|
|
94
|
+
"""Open a text file for writing that only appears once it is complete."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, path: Path) -> None:
|
|
97
|
+
self.path = path
|
|
98
|
+
self.tmp = path.with_name(f".{path.name}.tmp")
|
|
99
|
+
self.file: io.BufferedWriter | None = None
|
|
100
|
+
self.fh: io.TextIOWrapper | None = None
|
|
101
|
+
|
|
102
|
+
def __enter__(self) -> io.TextIOWrapper:
|
|
103
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
self.file = open(self.tmp, "wb")
|
|
105
|
+
stream: Any = self.file # a plain or gzip binary stream
|
|
106
|
+
if self.path.suffix == ".gz":
|
|
107
|
+
# mtime=0 keeps the bytes identical for identical content, so
|
|
108
|
+
# re-running a day does not create a spurious git change.
|
|
109
|
+
stream = gzip.GzipFile(filename="", mode="wb", fileobj=self.file, mtime=0)
|
|
110
|
+
self.fh = io.TextIOWrapper(stream, encoding="utf-8", newline="")
|
|
111
|
+
return self.fh
|
|
112
|
+
|
|
113
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
114
|
+
if self.fh is None or self.file is None:
|
|
115
|
+
return
|
|
116
|
+
self.fh.close() # also finishes the gzip stream, if any
|
|
117
|
+
self.file.close()
|
|
118
|
+
if exc_type is None:
|
|
119
|
+
os.replace(self.tmp, self.path)
|
|
120
|
+
else:
|
|
121
|
+
self.tmp.unlink(missing_ok=True)
|
apographe/py.typed
ADDED
|
File without changes
|