apoc-data 0.1.3__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: apoc-data
3
- Version: 0.1.3
3
+ Version: 0.2.0
4
4
  Summary: Data from the Alaska Public Offices Commission.
5
5
  Author: Nick Crews
6
6
  Author-email: Nick Crews <nicholas.b.crews@gmail.com>
@@ -32,8 +32,11 @@ Browse from [this repo's releases](https://github.com/NickCrews/apoc-data/releas
32
32
  Using [uv](https://docs.astral.sh/uv/)'s `uvx`:
33
33
 
34
34
  ```shell
35
- uvx apoc-data download # downloads all files from the latest release to ./downloads/ folder
36
- uvx apoc-data download --release "20260702-125614" --destination mydownloads/ # specify explicitly
35
+ uvx apoc-data release download # downloads all files from the latest release to ./downloads/ folder
36
+ uvx apoc-data release download "20260702-125614" --destination mydownloads/ # specify explicitly
37
+ uvx apoc-data asset download debt.csv --destination apoc_debt.csv # download a single file
38
+ uvx apoc-data release list # see what releases are available
39
+ uvx apoc-data asset list --json # see what files are in the latest release, as JSON
37
40
  ```
38
41
 
39
42
  Or, you can download these CSVs directly using the direct URLs from the releases page
@@ -53,9 +56,11 @@ duckdb -c "SELECT count(*) FROM 'https://github.com/NickCrews/apoc-data/releases
53
56
  We provide a python API too. `uv add apoc-data` and then
54
57
 
55
58
  ```python
56
- from apoc_data.releases import download
59
+ from apoc_data.releases import asset_download, release_download, release_list
57
60
 
58
- download(release="latest", filename="debt.csv", destination="apoc_debt.csv")
61
+ release_list() # all releases, newest first, as `Release` objects
62
+ release_download(destination="downloads/") # all files from the latest release
63
+ asset_download("debt.csv", destination="apoc_debt.csv") # a single file
59
64
  ```
60
65
 
61
66
  ---
@@ -20,8 +20,11 @@ Browse from [this repo's releases](https://github.com/NickCrews/apoc-data/releas
20
20
  Using [uv](https://docs.astral.sh/uv/)'s `uvx`:
21
21
 
22
22
  ```shell
23
- uvx apoc-data download # downloads all files from the latest release to ./downloads/ folder
24
- uvx apoc-data download --release "20260702-125614" --destination mydownloads/ # specify explicitly
23
+ uvx apoc-data release download # downloads all files from the latest release to ./downloads/ folder
24
+ uvx apoc-data release download "20260702-125614" --destination mydownloads/ # specify explicitly
25
+ uvx apoc-data asset download debt.csv --destination apoc_debt.csv # download a single file
26
+ uvx apoc-data release list # see what releases are available
27
+ uvx apoc-data asset list --json # see what files are in the latest release, as JSON
25
28
  ```
26
29
 
27
30
  Or, you can download these CSVs directly using the direct URLs from the releases page
@@ -41,9 +44,11 @@ duckdb -c "SELECT count(*) FROM 'https://github.com/NickCrews/apoc-data/releases
41
44
  We provide a python API too. `uv add apoc-data` and then
42
45
 
43
46
  ```python
44
- from apoc_data.releases import download
47
+ from apoc_data.releases import asset_download, release_download, release_list
45
48
 
46
- download(release="latest", filename="debt.csv", destination="apoc_debt.csv")
49
+ release_list() # all releases, newest first, as `Release` objects
50
+ release_download(destination="downloads/") # all files from the latest release
51
+ asset_download("debt.csv", destination="apoc_debt.csv") # a single file
47
52
  ```
48
53
 
49
54
  ---
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "apoc-data"
3
- version = "0.1.3"
3
+ version = "0.2.0"
4
4
  description = "Data from the Alaska Public Offices Commission."
5
5
  authors = [
6
6
  {name = "Nick Crews", email = "nicholas.b.crews@gmail.com"},
@@ -0,0 +1,261 @@
1
+ """Single CLI entry point for apoc-data.
2
+
3
+ Subcommands:
4
+
5
+ - ``release list|get|download``: inspect the available releases and download
6
+ all files in one (what most end users want; no extra dependencies).
7
+ - ``asset list|download``: inspect and fetch individual files within a release.
8
+ - ``scrape``: scrape the data from the APOC website using playwright
9
+ (requires installing the ``scrape`` extra, e.g. ``apoc-data[scrape]``).
10
+
11
+ Usage:
12
+
13
+ ```shell
14
+ uvx apoc-data release download
15
+ uvx apoc-data release list --json
16
+ uvx apoc-data asset list --release 20240716-025636
17
+ uvx apoc-data asset download debt.csv --destination apoc_debt.csv
18
+ uvx "apoc-data[scrape]" scrape --directory scraped/
19
+ ```
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import logging
27
+ from pathlib import Path
28
+
29
+ from apoc_data.releases import (
30
+ Asset,
31
+ Release,
32
+ asset_download,
33
+ asset_list,
34
+ release_download,
35
+ release_get,
36
+ release_list,
37
+ )
38
+
39
+
40
+ def _add_json_flag(parser: argparse.ArgumentParser) -> None:
41
+ parser.add_argument(
42
+ "--json",
43
+ action="store_true",
44
+ help="Output JSON instead of human-readable text",
45
+ )
46
+
47
+
48
+ def _add_release_flag(parser: argparse.ArgumentParser) -> None:
49
+ parser.add_argument(
50
+ "--release",
51
+ type=str,
52
+ default="latest",
53
+ help='A release tag, or "latest" (the default)',
54
+ )
55
+
56
+
57
+ def _human_size(size: int) -> str:
58
+ n = float(size)
59
+ for unit in ("B", "KB", "MB", "GB"):
60
+ if n < 1024 or unit == "GB":
61
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
62
+ n /= 1024
63
+ raise AssertionError("unreachable")
64
+
65
+
66
+ def _print_release(release: Release) -> None:
67
+ print(f"tag: {release.tag}")
68
+ print(f"name: {release.name}")
69
+ print(f"url: {release.url}")
70
+ print(f"published_at: {release.published_at.isoformat()}")
71
+ print("assets:")
72
+ for asset in release.assets:
73
+ print(f" {asset.name} ({_human_size(asset.size)})")
74
+
75
+
76
+ def _print_assets(assets: list[Asset]) -> None:
77
+ if not assets:
78
+ print("<no files>")
79
+ return
80
+ width = max(len(a.name) for a in assets)
81
+ for asset in assets:
82
+ print(f"{asset.name:<{width}} {_human_size(asset.size):>9} {asset.url}")
83
+
84
+
85
+ def _run_release_list(args: argparse.Namespace) -> None:
86
+ releases = release_list()
87
+ if args.json:
88
+ print(json.dumps([r.to_dict() for r in releases], indent=2))
89
+ return
90
+ for release in releases:
91
+ n_assets = len(release.assets)
92
+ print(f"{release.tag} {release.published_at.isoformat()} {n_assets} assets")
93
+
94
+
95
+ def _run_release_get(args: argparse.Namespace) -> None:
96
+ release = release_get(args.release)
97
+ if args.json:
98
+ print(json.dumps(release.to_dict(), indent=2))
99
+ else:
100
+ _print_release(release)
101
+
102
+
103
+ def _run_asset_list(args: argparse.Namespace) -> None:
104
+ assets = asset_list(args.release)
105
+ if args.json:
106
+ print(json.dumps([a.to_dict() for a in assets], indent=2))
107
+ else:
108
+ _print_assets(assets)
109
+
110
+
111
+ def _run_release_download(args: argparse.Namespace) -> None:
112
+ paths = release_download(args.release, destination=args.destination)
113
+ if args.json:
114
+ print(json.dumps([str(p) for p in paths], indent=2))
115
+ else:
116
+ for path in paths:
117
+ print(path)
118
+
119
+
120
+ def _run_asset_download(args: argparse.Namespace) -> None:
121
+ path = asset_download(
122
+ args.filename, release=args.release, destination=args.destination
123
+ )
124
+ if args.json:
125
+ print(json.dumps(str(path)))
126
+ else:
127
+ print(path)
128
+
129
+
130
+ def _run_scrape(args: argparse.Namespace) -> None:
131
+ try:
132
+ from apoc_data.scrape import scrape_all
133
+ from apoc_data.scrape._scraper import DEFAULT_DIRECTORY
134
+ except ImportError as e:
135
+ raise SystemExit(
136
+ "The scrape command requires extra dependencies. "
137
+ 'Install them with the "scrape" extra, e.g. '
138
+ '`pip install "apoc-data[scrape]"` or `uvx "apoc-data[scrape]" scrape`.'
139
+ f"\n(import failed: {e})"
140
+ ) from e
141
+
142
+ directory = Path(args.directory or DEFAULT_DIRECTORY).absolute()
143
+ if directory.is_file():
144
+ raise ValueError("The directory can't be a file")
145
+ logging.basicConfig(level=logging.INFO)
146
+ scrape_all(directory, headless=args.headless)
147
+
148
+
149
+ def main(argv: list[str] | None = None) -> None:
150
+ parser = argparse.ArgumentParser(
151
+ prog="apoc-data",
152
+ description="Data from the Alaska Public Offices Commission",
153
+ )
154
+ subparsers = parser.add_subparsers(dest="command", required=True)
155
+
156
+ release_parser = subparsers.add_parser(
157
+ "release",
158
+ help="Inspect the available releases",
159
+ )
160
+ release_subparsers = release_parser.add_subparsers(
161
+ dest="release_command", required=True
162
+ )
163
+
164
+ release_list_parser = release_subparsers.add_parser(
165
+ "list",
166
+ help="List all releases, newest first",
167
+ )
168
+ _add_json_flag(release_list_parser)
169
+ release_list_parser.set_defaults(func=_run_release_list)
170
+
171
+ release_get_parser = release_subparsers.add_parser(
172
+ "get",
173
+ help="Show a single release",
174
+ )
175
+ release_get_parser.add_argument(
176
+ "release",
177
+ nargs="?",
178
+ default="latest",
179
+ help='A release tag, or "latest" (the default)',
180
+ )
181
+ _add_json_flag(release_get_parser)
182
+ release_get_parser.set_defaults(func=_run_release_get)
183
+
184
+ release_download_parser = release_subparsers.add_parser(
185
+ "download",
186
+ help="Download all files in a release to a folder",
187
+ description="Download all CSVs of APOC data from "
188
+ "https://github.com/NickCrews/apoc-data/releases",
189
+ )
190
+ release_download_parser.add_argument(
191
+ "release",
192
+ nargs="?",
193
+ default="latest",
194
+ help='A release tag, or "latest" (the default)',
195
+ )
196
+ release_download_parser.add_argument(
197
+ "--destination",
198
+ type=str,
199
+ default="downloads/",
200
+ help="The folder to save the files under (default: downloads/)",
201
+ )
202
+ _add_json_flag(release_download_parser)
203
+ release_download_parser.set_defaults(func=_run_release_download)
204
+
205
+ asset_parser = subparsers.add_parser(
206
+ "asset",
207
+ help="Inspect and download the files within a release",
208
+ )
209
+ asset_subparsers = asset_parser.add_subparsers(dest="asset_command", required=True)
210
+
211
+ asset_list_parser = asset_subparsers.add_parser(
212
+ "list",
213
+ help="List the files in a release",
214
+ )
215
+ _add_release_flag(asset_list_parser)
216
+ _add_json_flag(asset_list_parser)
217
+ asset_list_parser.set_defaults(func=_run_asset_list)
218
+
219
+ asset_download_parser = asset_subparsers.add_parser(
220
+ "download",
221
+ help="Download file(s) from a release",
222
+ )
223
+ asset_download_parser.add_argument(
224
+ "filename",
225
+ help="The name of the file to download, e.g. debt.csv",
226
+ )
227
+ _add_release_flag(asset_download_parser)
228
+ asset_download_parser.add_argument(
229
+ "--destination",
230
+ type=str,
231
+ default=None,
232
+ help="The file path to save to (default: the filename in the current directory)",
233
+ )
234
+ _add_json_flag(asset_download_parser)
235
+ asset_download_parser.set_defaults(func=_run_asset_download)
236
+
237
+ scrape_parser = subparsers.add_parser(
238
+ "scrape",
239
+ help='Scrape the data from the APOC website (requires the "scrape" extra)',
240
+ description="Scrape .CSVs from https://aws.state.ak.us/ApocReports/Campaign/",
241
+ )
242
+ scrape_parser.add_argument(
243
+ "--directory",
244
+ type=str,
245
+ default=None,
246
+ help="The directory to save the data to (default: scraped/)",
247
+ )
248
+ scrape_parser.add_argument(
249
+ "--headless",
250
+ default=True,
251
+ action=argparse.BooleanOptionalAction,
252
+ help="Run the browser in headless mode",
253
+ )
254
+ scrape_parser.set_defaults(func=_run_scrape)
255
+
256
+ args = parser.parse_args(argv)
257
+ args.func(args)
258
+
259
+
260
+ if __name__ == "__main__":
261
+ main()
@@ -0,0 +1,17 @@
1
+ from apoc_data.releases._gh import Asset as Asset
2
+ from apoc_data.releases._gh import Release as Release
3
+ from apoc_data.releases._gh import asset_download as asset_download
4
+ from apoc_data.releases._gh import asset_list as asset_list
5
+ from apoc_data.releases._gh import release_download as release_download
6
+ from apoc_data.releases._gh import release_get as release_get
7
+ from apoc_data.releases._gh import release_list as release_list
8
+
9
+ __all__ = [
10
+ "Asset",
11
+ "Release",
12
+ "asset_download",
13
+ "asset_list",
14
+ "release_download",
15
+ "release_get",
16
+ "release_list",
17
+ ]
@@ -0,0 +1,213 @@
1
+ """Access the APOC data published at https://github.com/NickCrews/apoc-data/releases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import json
7
+ import os
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.error import HTTPError
12
+ from urllib.request import Request, urlopen
13
+
14
+ _REPO = "NickCrews/apoc-data"
15
+ _API_ROOT = f"https://api.github.com/repos/{_REPO}"
16
+
17
+
18
+ @dataclasses.dataclass(frozen=True)
19
+ class Asset:
20
+ """A single downloadable file in a release."""
21
+
22
+ name: str
23
+ """The filename, e.g. ``candidate_registration.csv``."""
24
+ url: str
25
+ """The direct download URL."""
26
+ size: int
27
+ """The size in bytes."""
28
+ updated_at: datetime
29
+ """When the asset was last updated."""
30
+
31
+ @classmethod
32
+ def _from_api(cls, raw: dict[str, Any]) -> Asset:
33
+ return cls(
34
+ name=raw["name"],
35
+ url=raw["browser_download_url"],
36
+ size=raw["size"],
37
+ updated_at=_parse_timestamp(raw["updated_at"]),
38
+ )
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ """Convert to a JSON-serializable dict."""
42
+ return {
43
+ "name": self.name,
44
+ "url": self.url,
45
+ "size": self.size,
46
+ "updated_at": self.updated_at.isoformat(),
47
+ }
48
+
49
+ def download(self, destination: str | Path) -> Path:
50
+ """Download this asset to the given file path and return it."""
51
+ destination = Path(destination)
52
+ destination.parent.mkdir(parents=True, exist_ok=True)
53
+ destination.write_bytes(_get(self.url))
54
+ return destination
55
+
56
+
57
+ @dataclasses.dataclass(frozen=True)
58
+ class Release:
59
+ """A release of the APOC data on GitHub."""
60
+
61
+ tag: str
62
+ """The git tag, e.g. ``20240716-025636``."""
63
+ name: str
64
+ """The human-readable release title."""
65
+ url: str
66
+ """The web page for the release."""
67
+ published_at: datetime
68
+ """When the release was published."""
69
+ assets: tuple[Asset, ...]
70
+ """The downloadable files in this release."""
71
+
72
+ @classmethod
73
+ def _from_api(cls, raw: dict[str, Any]) -> Release:
74
+ return cls(
75
+ tag=raw["tag_name"],
76
+ name=raw["name"] or raw["tag_name"],
77
+ url=raw["html_url"],
78
+ published_at=_parse_timestamp(raw["published_at"]),
79
+ assets=tuple(Asset._from_api(a) for a in raw["assets"]),
80
+ )
81
+
82
+ def to_dict(self) -> dict[str, Any]:
83
+ """Convert to a JSON-serializable dict."""
84
+ return {
85
+ "tag": self.tag,
86
+ "name": self.name,
87
+ "url": self.url,
88
+ "published_at": self.published_at.isoformat(),
89
+ "assets": [a.to_dict() for a in self.assets],
90
+ }
91
+
92
+ def asset(self, filename: str) -> Asset:
93
+ """Get the asset with the given filename, or raise ValueError."""
94
+ for asset in self.assets:
95
+ if asset.name == filename:
96
+ return asset
97
+ available = ", ".join(sorted(a.name for a in self.assets)) or "<no files>"
98
+ raise ValueError(
99
+ f"Release {self.tag} does not have a file named {filename}. "
100
+ f"Available files: {available}"
101
+ )
102
+
103
+
104
+ def release_list() -> list[Release]:
105
+ """List all releases of the APOC data, newest first."""
106
+ raw = json.loads(_get(f"{_API_ROOT}/releases"))
107
+ return [Release._from_api(r) for r in raw]
108
+
109
+
110
+ def release_get(release: str = "latest") -> Release:
111
+ """Get a single release by tag, or the latest release.
112
+
113
+ Parameters
114
+ ----------
115
+ release :
116
+ A tag such as ``20240716-025636``, or ``latest`` for the most recent release.
117
+ """
118
+ if release == "latest":
119
+ url = f"{_API_ROOT}/releases/latest"
120
+ else:
121
+ url = f"{_API_ROOT}/releases/tags/{release}"
122
+ try:
123
+ raw = json.loads(_get(url))
124
+ except HTTPError as e:
125
+ if e.code == 404:
126
+ raise ValueError(
127
+ f"No release found for {release!r}. "
128
+ f"Available releases: {_available_releases_hint()}"
129
+ ) from e
130
+ raise
131
+ return Release._from_api(raw)
132
+
133
+
134
+ def asset_list(release: str = "latest") -> list[Asset]:
135
+ """List the downloadable files in a release.
136
+
137
+ Parameters
138
+ ----------
139
+ release :
140
+ A tag such as ``20240716-025636``, or ``latest`` for the most recent release.
141
+ """
142
+ return list(release_get(release).assets)
143
+
144
+
145
+ def release_download(
146
+ release: str = "latest",
147
+ *,
148
+ destination: str | Path = "downloads/",
149
+ ) -> list[Path]:
150
+ """Download all files in a release to a folder and return the downloaded paths.
151
+
152
+ Parameters
153
+ ----------
154
+ release :
155
+ A tag such as ``20240716-025636``, or ``latest`` for the most recent release.
156
+ destination :
157
+ The folder to save the files under.
158
+ """
159
+ destination = Path(destination)
160
+ rel = release_get(release)
161
+ return [asset.download(destination / asset.name) for asset in rel.assets]
162
+
163
+
164
+ def asset_download(
165
+ filename: str,
166
+ *,
167
+ release: str = "latest",
168
+ destination: str | Path | None = None,
169
+ ) -> Path:
170
+ """Download a single file from a release and return the downloaded path.
171
+
172
+ Parameters
173
+ ----------
174
+ filename :
175
+ The name of the file to download, e.g. ``debt.csv``.
176
+ release :
177
+ A tag such as ``20240716-025636``, or ``latest`` for the most recent release.
178
+ destination :
179
+ The file path to save to.
180
+ Default is None, which saves to ``filename`` in the current directory.
181
+ """
182
+ asset = release_get(release).asset(filename)
183
+ if destination is None:
184
+ destination = Path(asset.name)
185
+ return asset.download(destination)
186
+
187
+
188
+ def _available_releases_hint() -> str:
189
+ try:
190
+ tags = [r.tag for r in release_list()]
191
+ except Exception:
192
+ return "<unable to fetch releases>"
193
+ return ", ".join(["latest", *tags]) or "<no releases>"
194
+
195
+
196
+ def _parse_timestamp(raw: str) -> datetime:
197
+ # GitHub timestamps look like "2024-07-16T02:56:36Z".
198
+ # datetime.fromisoformat can't parse the trailing "Z" until python 3.11.
199
+ return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
200
+
201
+
202
+ def _get(url: str) -> bytes:
203
+ # I'm getting hit by rate limits when using streamlit cloud, I assume because
204
+ # the IP address is shared. So I'm trying to use a personal access token to
205
+ # authenticate.
206
+ headers = {"Accept": "application/vnd.github.v3+json"}
207
+ try:
208
+ pat = os.environ["GITHUB_PAT"]
209
+ headers["Authorization"] = f"token {pat}"
210
+ except KeyError:
211
+ pass
212
+ with urlopen(Request(url, headers=headers)) as response:
213
+ return response.read()
@@ -1,116 +0,0 @@
1
- """Single CLI entry point for apoc-data.
2
-
3
- Exposes two subcommands:
4
-
5
- - ``download``: fetch prebuilt CSVs from the GitHub releases
6
- (what most end users want; no extra dependencies).
7
- - ``scrape``: scrape the data from the APOC website using playwright
8
- (requires installing the ``scrape`` extra, e.g. ``apoc-data[scrape]``).
9
-
10
- Usage:
11
-
12
- ```shell
13
- uvx apoc-data download --release latest
14
- uvx "apoc-data[scrape]" scrape --directory scraped/
15
- ```
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import argparse
21
- import logging
22
- from pathlib import Path
23
-
24
- from apoc_data.releases import download
25
-
26
-
27
- def _add_download_args(parser: argparse.ArgumentParser) -> None:
28
- parser.add_argument(
29
- "--release",
30
- type=str,
31
- default="latest",
32
- help="The name of the release to download",
33
- )
34
- parser.add_argument(
35
- "--filename",
36
- type=str,
37
- help="The name of the file to download",
38
- )
39
- parser.add_argument(
40
- "--destination",
41
- type=str,
42
- default="downloads/",
43
- help="Where to save the file(s)",
44
- )
45
-
46
-
47
- def _add_scrape_args(parser: argparse.ArgumentParser) -> None:
48
- parser.add_argument(
49
- "--directory",
50
- type=str,
51
- default=None,
52
- help="The directory to save the data to (default: scraped/)",
53
- )
54
- parser.add_argument(
55
- "--headless",
56
- default=True,
57
- action=argparse.BooleanOptionalAction,
58
- help="Run the browser in headless mode",
59
- )
60
-
61
-
62
- def _run_scrape(args: argparse.Namespace) -> None:
63
- try:
64
- from apoc_data.scrape import scrape_all
65
- from apoc_data.scrape._scraper import DEFAULT_DIRECTORY
66
- except ImportError as e:
67
- raise SystemExit(
68
- "The scrape command requires extra dependencies. "
69
- 'Install them with the "scrape" extra, e.g. '
70
- '`pip install "apoc-data[scrape]"` or `uvx "apoc-data[scrape]" scrape`.'
71
- f"\n(import failed: {e})"
72
- ) from e
73
-
74
- directory = Path(args.directory or DEFAULT_DIRECTORY).absolute()
75
- if directory.is_file():
76
- raise ValueError("The directory can't be a file")
77
- logging.basicConfig(level=logging.INFO)
78
- scrape_all(directory, headless=args.headless)
79
-
80
-
81
- def main(argv: list[str] | None = None) -> None:
82
- parser = argparse.ArgumentParser(
83
- prog="apoc-data",
84
- description="Data from the Alaska Public Offices Commission",
85
- )
86
- subparsers = parser.add_subparsers(dest="command", required=True)
87
-
88
- download_parser = subparsers.add_parser(
89
- "download",
90
- help="Download prebuilt CSVs from the GitHub releases",
91
- description="Download CSV(s) of APOC data from "
92
- "https://github.com/NickCrews/apoc-data/releases",
93
- )
94
- _add_download_args(download_parser)
95
-
96
- def _run_download(args: argparse.Namespace) -> None:
97
- download(
98
- release=args.release, filename=args.filename, destination=args.destination
99
- )
100
-
101
- download_parser.set_defaults(func=_run_download)
102
-
103
- scrape_parser = subparsers.add_parser(
104
- "scrape",
105
- help='Scrape the data from the APOC website (requires the "scrape" extra)',
106
- description="Scrape .CSVs from https://aws.state.ak.us/ApocReports/Campaign/",
107
- )
108
- _add_scrape_args(scrape_parser)
109
- scrape_parser.set_defaults(func=_run_scrape)
110
-
111
- args = parser.parse_args(argv)
112
- args.func(args)
113
-
114
-
115
- if __name__ == "__main__":
116
- main()
@@ -1 +0,0 @@
1
- from apoc_data.releases._gh import download as download
@@ -1,121 +0,0 @@
1
- """Download CSV(s) of APOC data from https://github.com/NickCrews/apoc-data/releases."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- from pathlib import Path
8
- from typing import Any
9
- from urllib.error import HTTPError
10
- from urllib.request import Request, urlopen
11
-
12
-
13
- def download(
14
- *,
15
- release: str | None = None,
16
- tag: str | None = None,
17
- filename: str | None = None,
18
- destination: str | Path = "downloads/",
19
- ) -> None:
20
- """Download CSV(s) of APOC data from https://github.com/NickCrews/apoc-data/releases.
21
-
22
- Parameters
23
- ----------
24
- release :
25
- The name of the release to download.
26
- Default is None, which means latest release.
27
- You can also provide a tag instead of a release.
28
- tag :
29
- The name of the release to download.
30
- Default is None, which means latest release.
31
- You can also provide a release instead of a tag.
32
- filename :
33
- The name of the file to download.
34
- Default is None, which downloads all files.
35
- destination :
36
- Where to save the file(s).
37
- If this looks like a file (the final path segment contains a `.`),
38
- then we can only download a single file, and it will be saved to that location.
39
- Otherwise, the file(s) will be saved underneath there.
40
- """
41
- if release is not None and tag is not None:
42
- raise ValueError("Can't provide both release and tag")
43
- if release is None and tag is None:
44
- release = "latest"
45
-
46
- destination = Path(destination)
47
- release, assets = _get_release_info(release=release, tag=tag)
48
- if filename is not None:
49
- if filename not in assets:
50
- available = ", ".join(sorted(assets)) or "<no files>"
51
- raise ValueError(
52
- f"Release {release} does not have a file named {filename}. "
53
- f"Available files: {available}"
54
- )
55
- if not _is_file(destination):
56
- destination = destination / filename
57
- _download_asset(assets[filename], destination)
58
- else:
59
- if _is_file(destination):
60
- raise ValueError("Can't download all files to a single file")
61
- for name, url in assets.items():
62
- _download_asset(url, destination / name)
63
-
64
-
65
- def get_releases() -> list[dict[str, Any]]:
66
- """Get information about all releases of the APOC data."""
67
- url = "https://api.github.com/repos/NickCrews/apoc-data/releases"
68
- return json.loads(_get(url))
69
-
70
-
71
- def _is_file(destination: Path) -> bool:
72
- return "." in destination.name
73
-
74
-
75
- def _get_release_info(
76
- *, release: str | None = None, tag: str | None = None
77
- ) -> tuple[str, dict[str, str]]:
78
- if release is not None:
79
- url = f"https://api.github.com/repos/NickCrews/apoc-data/releases/{release}"
80
- else:
81
- url = f"https://api.github.com/repos/NickCrews/apoc-data/releases/tags/{tag}"
82
- try:
83
- info = json.loads(_get(url))
84
- except HTTPError as e:
85
- if e.code == 404:
86
- requested = release if release is not None else tag
87
- raise ValueError(
88
- f"No release found for {requested!r}. "
89
- f"Available releases: {_available_releases_hint()}"
90
- ) from e
91
- raise
92
- assets = {asset["name"]: asset["browser_download_url"] for asset in info["assets"]}
93
- return info["tag_name"], assets
94
-
95
-
96
- def _available_releases_hint() -> str:
97
- try:
98
- tags = [r["tag_name"] for r in get_releases()]
99
- except Exception:
100
- return "<unable to fetch releases>"
101
- return ", ".join(["latest", *tags]) or "<no releases>"
102
-
103
-
104
- def _download_asset(url: str, destination: Path) -> None:
105
- destination.parent.mkdir(parents=True, exist_ok=True)
106
- with open(destination, "wb") as file:
107
- file.write(_get(url))
108
-
109
-
110
- def _get(url: str) -> bytes:
111
- # I'm getting hit by rate limits when using streamlit cloud, I assume because
112
- # the IP address is shared. So I'm trying to use a personal access token to
113
- # authenticate.
114
- headers = {"Accept": "application/vnd.github.v3+json"}
115
- try:
116
- pat = os.environ["GITHUB_PAT"]
117
- headers["Authorization"] = f"token {pat}"
118
- except KeyError:
119
- pass
120
- with urlopen(Request(url, headers=headers)) as response:
121
- return response.read()