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.
- {apoc_data-0.1.3 → apoc_data-0.2.0}/PKG-INFO +10 -5
- {apoc_data-0.1.3 → apoc_data-0.2.0}/README.md +9 -4
- {apoc_data-0.1.3 → apoc_data-0.2.0}/pyproject.toml +1 -1
- apoc_data-0.2.0/src/apoc_data/cli.py +261 -0
- apoc_data-0.2.0/src/apoc_data/releases/__init__.py +17 -0
- apoc_data-0.2.0/src/apoc_data/releases/_gh.py +213 -0
- apoc_data-0.1.3/src/apoc_data/cli.py +0 -116
- apoc_data-0.1.3/src/apoc_data/releases/__init__.py +0 -1
- apoc_data-0.1.3/src/apoc_data/releases/_gh.py +0 -121
- {apoc_data-0.1.3 → apoc_data-0.2.0}/src/apoc_data/__init__.py +0 -0
- {apoc_data-0.1.3 → apoc_data-0.2.0}/src/apoc_data/__main__.py +0 -0
- {apoc_data-0.1.3 → apoc_data-0.2.0}/src/apoc_data/scrape/__init__.py +0 -0
- {apoc_data-0.1.3 → apoc_data-0.2.0}/src/apoc_data/scrape/_filters.py +0 -0
- {apoc_data-0.1.3 → apoc_data-0.2.0}/src/apoc_data/scrape/_scraper.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: apoc-data
|
|
3
|
-
Version: 0.
|
|
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
|
|
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
|
|
59
|
+
from apoc_data.releases import asset_download, release_download, release_list
|
|
57
60
|
|
|
58
|
-
|
|
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
|
|
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
|
|
47
|
+
from apoc_data.releases import asset_download, release_download, release_list
|
|
45
48
|
|
|
46
|
-
|
|
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
|
---
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|