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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from apographe.cli import main
4
+
5
+ sys.exit(main())
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
@@ -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()
@@ -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