apifetch 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.
- apifetch/__init__.py +55 -0
- apifetch/_utils.py +31 -0
- apifetch/api.py +168 -0
- apifetch/fetch.py +152 -0
- apifetch/tokens.py +67 -0
- apifetch-0.1.0.dist-info/METADATA +108 -0
- apifetch-0.1.0.dist-info/RECORD +9 -0
- apifetch-0.1.0.dist-info/WHEEL +4 -0
- apifetch-0.1.0.dist-info/licenses/LICENSE +21 -0
apifetch/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""apifetch — a generic toolkit for token-authenticated REST API retrieval.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import apifetch as af
|
|
6
|
+
|
|
7
|
+
api = af.Api(
|
|
8
|
+
endpoint="https://api.example.com/v1/search",
|
|
9
|
+
service="Example",
|
|
10
|
+
auth=af.AuthBearer(),
|
|
11
|
+
pagination=af.PaginateOffset(where="query"),
|
|
12
|
+
)
|
|
13
|
+
af.store_token("reports", "my-secret-token", service="Example")
|
|
14
|
+
rows = af.fetch_all(api, "reports", chunk_size=1000)
|
|
15
|
+
|
|
16
|
+
This is the Python sibling of the R package ``apifetch``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .api import (
|
|
22
|
+
Api,
|
|
23
|
+
Auth,
|
|
24
|
+
AuthBearer,
|
|
25
|
+
AuthHeader,
|
|
26
|
+
AuthQuery,
|
|
27
|
+
AuthRaw,
|
|
28
|
+
PaginateNone,
|
|
29
|
+
PaginateOffset,
|
|
30
|
+
Pagination,
|
|
31
|
+
)
|
|
32
|
+
from .fetch import ApiError, fetch, fetch_all
|
|
33
|
+
from .tokens import get_token, list_tokens, remove_token, store_token
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Api",
|
|
39
|
+
"Auth",
|
|
40
|
+
"AuthRaw",
|
|
41
|
+
"AuthBearer",
|
|
42
|
+
"AuthHeader",
|
|
43
|
+
"AuthQuery",
|
|
44
|
+
"Pagination",
|
|
45
|
+
"PaginateOffset",
|
|
46
|
+
"PaginateNone",
|
|
47
|
+
"fetch",
|
|
48
|
+
"fetch_all",
|
|
49
|
+
"ApiError",
|
|
50
|
+
"store_token",
|
|
51
|
+
"get_token",
|
|
52
|
+
"remove_token",
|
|
53
|
+
"list_tokens",
|
|
54
|
+
"__version__",
|
|
55
|
+
]
|
apifetch/_utils.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Internal helpers shared across the package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import unicodedata
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def sanitize_name(value: str) -> str:
|
|
10
|
+
"""Transliterate to ASCII (dropping accents) and turn spaces into underscores.
|
|
11
|
+
|
|
12
|
+
This matches the contract shared by every token function so that the same
|
|
13
|
+
environment-variable name is computed everywhere.
|
|
14
|
+
"""
|
|
15
|
+
nfkd = unicodedata.normalize("NFKD", value)
|
|
16
|
+
ascii_only = nfkd.encode("ascii", "ignore").decode("ascii")
|
|
17
|
+
return ascii_only.replace(" ", "_")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def token_var(name: str, service: str) -> str:
|
|
21
|
+
"""Build the environment-variable name for a token: ``<service>_<name>``."""
|
|
22
|
+
return f"{sanitize_name(service)}_{sanitize_name(name)}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_unset(value) -> bool:
|
|
26
|
+
"""True for values that mean "no pagination bound": ``None``, ``<= 0``, ``inf``."""
|
|
27
|
+
if value is None:
|
|
28
|
+
return True
|
|
29
|
+
if isinstance(value, float) and math.isinf(value):
|
|
30
|
+
return True
|
|
31
|
+
return value <= 0
|
apifetch/api.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""API profiles: authentication and pagination strategies.
|
|
2
|
+
|
|
3
|
+
An :class:`Api` describes *where* to call, *how* to authenticate, and *how* to
|
|
4
|
+
paginate. Auth and pagination are pluggable strategy objects, so the same fetch
|
|
5
|
+
functions work against APIs with different conventions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from ._utils import is_unset
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Auth",
|
|
16
|
+
"AuthRaw",
|
|
17
|
+
"AuthBearer",
|
|
18
|
+
"AuthHeader",
|
|
19
|
+
"AuthQuery",
|
|
20
|
+
"Pagination",
|
|
21
|
+
"PaginateOffset",
|
|
22
|
+
"PaginateNone",
|
|
23
|
+
"Api",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---- Authentication strategies -------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Auth:
|
|
31
|
+
"""Base authentication strategy.
|
|
32
|
+
|
|
33
|
+
Subclasses contribute to a request's headers and/or query parameters.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def headers(self, token: str) -> dict:
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
def params(self, token: str) -> dict:
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class AuthRaw(Auth):
|
|
45
|
+
"""Send the token verbatim in a header (default ``Authorization``).
|
|
46
|
+
|
|
47
|
+
This is what the Big Data PE API expects.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
header: str = "Authorization"
|
|
51
|
+
|
|
52
|
+
def headers(self, token: str) -> dict:
|
|
53
|
+
return {self.header: token}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class AuthBearer(Auth):
|
|
58
|
+
"""Send ``"<prefix><token>"`` in a header (default ``Authorization: Bearer``)."""
|
|
59
|
+
|
|
60
|
+
header: str = "Authorization"
|
|
61
|
+
prefix: str = "Bearer "
|
|
62
|
+
|
|
63
|
+
def headers(self, token: str) -> dict:
|
|
64
|
+
return {self.header: f"{self.prefix}{token}"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class AuthHeader(Auth):
|
|
69
|
+
"""Send the token in an arbitrary header (e.g. ``X-API-Key``)."""
|
|
70
|
+
|
|
71
|
+
header: str = "X-API-Key"
|
|
72
|
+
|
|
73
|
+
def headers(self, token: str) -> dict:
|
|
74
|
+
return {self.header: token}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class AuthQuery(Auth):
|
|
79
|
+
"""Send the token as a URL query parameter."""
|
|
80
|
+
|
|
81
|
+
param: str = "api_key"
|
|
82
|
+
|
|
83
|
+
def params(self, token: str) -> dict:
|
|
84
|
+
return {self.param: token}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---- Pagination strategies -----------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Pagination:
|
|
91
|
+
"""Base pagination strategy."""
|
|
92
|
+
|
|
93
|
+
def headers(self, limit, offset) -> dict:
|
|
94
|
+
return {}
|
|
95
|
+
|
|
96
|
+
def params(self, limit, offset) -> dict:
|
|
97
|
+
return {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class PaginateOffset(Pagination):
|
|
102
|
+
"""Send ``limit``/``offset`` as HTTP headers (default) or query parameters.
|
|
103
|
+
|
|
104
|
+
Non-positive, ``None`` and infinite values are omitted.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
where: str = "header" # "header" or "query"
|
|
108
|
+
limit_param: str = "limit"
|
|
109
|
+
offset_param: str = "offset"
|
|
110
|
+
|
|
111
|
+
def _values(self, limit, offset) -> dict:
|
|
112
|
+
vals = {}
|
|
113
|
+
if not is_unset(limit):
|
|
114
|
+
vals[self.limit_param] = int(limit)
|
|
115
|
+
if not is_unset(offset):
|
|
116
|
+
vals[self.offset_param] = int(offset)
|
|
117
|
+
return vals
|
|
118
|
+
|
|
119
|
+
def headers(self, limit, offset) -> dict:
|
|
120
|
+
# HTTP header values must be strings.
|
|
121
|
+
if self.where != "header":
|
|
122
|
+
return {}
|
|
123
|
+
return {k: str(v) for k, v in self._values(limit, offset).items()}
|
|
124
|
+
|
|
125
|
+
def params(self, limit, offset) -> dict:
|
|
126
|
+
return self._values(limit, offset) if self.where == "query" else {}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class PaginateNone(Pagination):
|
|
131
|
+
"""Send no pagination parameters."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---- API profile ----------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class Api:
|
|
139
|
+
"""Describe an API endpoint together with its auth and pagination strategies.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
endpoint: The base API URL.
|
|
143
|
+
service: Namespace used to look up the token (see :func:`get_token`).
|
|
144
|
+
auth: An :class:`Auth` strategy. Defaults to bearer-token auth.
|
|
145
|
+
pagination: A :class:`Pagination` strategy. Defaults to offset paging
|
|
146
|
+
sent as HTTP headers.
|
|
147
|
+
drop_cols: Keys to drop from each record after parsing (e.g. a status
|
|
148
|
+
column).
|
|
149
|
+
connect_hint: Optional extra line shown on a connection error (e.g. a
|
|
150
|
+
VPN requirement).
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
endpoint: str
|
|
154
|
+
service: str = "apifetch"
|
|
155
|
+
auth: Auth = field(default_factory=AuthBearer)
|
|
156
|
+
pagination: Pagination = field(default_factory=PaginateOffset)
|
|
157
|
+
drop_cols: tuple[str, ...] = ()
|
|
158
|
+
connect_hint: str | None = None
|
|
159
|
+
|
|
160
|
+
def __post_init__(self):
|
|
161
|
+
if not self.endpoint:
|
|
162
|
+
raise ValueError("endpoint must be a non-empty string.")
|
|
163
|
+
if not isinstance(self.auth, Auth):
|
|
164
|
+
raise TypeError("auth must be an Auth instance (e.g. AuthBearer()).")
|
|
165
|
+
if not isinstance(self.pagination, Pagination):
|
|
166
|
+
raise TypeError(
|
|
167
|
+
"pagination must be a Pagination instance (e.g. PaginateOffset())."
|
|
168
|
+
)
|
apifetch/fetch.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Data fetching: single page and chunked retrieval."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .api import Api
|
|
12
|
+
from .tokens import get_token
|
|
13
|
+
|
|
14
|
+
__all__ = ["fetch", "fetch_all", "ApiError"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApiError(RuntimeError):
|
|
18
|
+
"""Raised when the API is unreachable or returns an HTTP error."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _log(verbosity: int, message: str) -> None:
|
|
22
|
+
if verbosity > 0:
|
|
23
|
+
print(message, file=sys.stderr)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fetch(
|
|
27
|
+
api: Api,
|
|
28
|
+
name: str,
|
|
29
|
+
limit: Optional[float] = None,
|
|
30
|
+
offset: int = 0,
|
|
31
|
+
query: Optional[dict] = None,
|
|
32
|
+
verbosity: int = 0,
|
|
33
|
+
client: Optional[httpx.Client] = None,
|
|
34
|
+
) -> list[dict[str, Any]]:
|
|
35
|
+
"""Fetch a single page from ``api`` and return it as a list of records.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
api: An :class:`Api` profile.
|
|
39
|
+
name: Token name to authenticate with (looked up via ``api.service``).
|
|
40
|
+
limit: Maximum records to request. ``None``/``inf`` means no limit.
|
|
41
|
+
offset: Starting record (omitted when ``0``).
|
|
42
|
+
query: Extra query-string filters.
|
|
43
|
+
verbosity: ``0`` silent, ``>=1`` progress messages.
|
|
44
|
+
client: Optional pre-built ``httpx.Client`` (useful for testing or
|
|
45
|
+
connection reuse). One is created and closed per call otherwise.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The parsed JSON body as a list of dictionaries.
|
|
49
|
+
"""
|
|
50
|
+
if not isinstance(api, Api):
|
|
51
|
+
raise TypeError("api must be an Api instance (see apifetch.Api).")
|
|
52
|
+
query = query or {}
|
|
53
|
+
|
|
54
|
+
token = get_token(name, service=api.service)
|
|
55
|
+
if token is None:
|
|
56
|
+
raise ApiError(
|
|
57
|
+
f"No token available for {name!r}; store one with apifetch.store_token()."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
headers = {**api.auth.headers(token), **api.pagination.headers(limit, offset)}
|
|
61
|
+
params = {**query, **api.auth.params(token), **api.pagination.params(limit, offset)}
|
|
62
|
+
|
|
63
|
+
owns_client = client is None
|
|
64
|
+
client = client or httpx.Client()
|
|
65
|
+
try:
|
|
66
|
+
try:
|
|
67
|
+
resp = client.get(api.endpoint, headers=headers, params=params)
|
|
68
|
+
except httpx.RequestError as exc:
|
|
69
|
+
hint = f" {api.connect_hint}" if api.connect_hint else ""
|
|
70
|
+
raise ApiError(
|
|
71
|
+
f"Unable to connect to the API at {api.endpoint}. "
|
|
72
|
+
f"Check your network connection.{hint} ({exc})"
|
|
73
|
+
) from exc
|
|
74
|
+
finally:
|
|
75
|
+
if owns_client:
|
|
76
|
+
client.close()
|
|
77
|
+
|
|
78
|
+
if resp.status_code >= 400:
|
|
79
|
+
raise ApiError(
|
|
80
|
+
f"The API returned an error (HTTP {resp.status_code} - {resp.reason_phrase}). "
|
|
81
|
+
"Try again later, and check that the endpoint and token are valid."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
data = resp.json()
|
|
85
|
+
if isinstance(data, dict):
|
|
86
|
+
data = [data]
|
|
87
|
+
_log(verbosity, f"i Fetched {len(data)} records.")
|
|
88
|
+
return data
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def fetch_all(
|
|
92
|
+
api: Api,
|
|
93
|
+
name: str,
|
|
94
|
+
total_limit: Optional[float] = None,
|
|
95
|
+
chunk_size: int = 50_000,
|
|
96
|
+
query: Optional[dict] = None,
|
|
97
|
+
verbosity: int = 0,
|
|
98
|
+
client: Optional[httpx.Client] = None,
|
|
99
|
+
) -> list[dict[str, Any]]:
|
|
100
|
+
"""Fetch every record by paging through ``api`` in chunks.
|
|
101
|
+
|
|
102
|
+
Iteratively calls :func:`fetch` with an advancing ``offset`` until a chunk
|
|
103
|
+
comes back empty or ``total_limit`` is reached. Columns listed in
|
|
104
|
+
``api.drop_cols`` are removed from each record.
|
|
105
|
+
"""
|
|
106
|
+
if not isinstance(api, Api):
|
|
107
|
+
raise TypeError("api must be an Api instance (see apifetch.Api).")
|
|
108
|
+
if chunk_size <= 0:
|
|
109
|
+
raise ValueError("chunk_size must be a positive integer.")
|
|
110
|
+
|
|
111
|
+
total = math.inf if total_limit is None else total_limit
|
|
112
|
+
offset = 0
|
|
113
|
+
fetched = 0
|
|
114
|
+
records: list[dict[str, Any]] = []
|
|
115
|
+
|
|
116
|
+
owns_client = client is None
|
|
117
|
+
client = client or httpx.Client()
|
|
118
|
+
try:
|
|
119
|
+
while True:
|
|
120
|
+
current_limit = int(min(chunk_size, total - fetched))
|
|
121
|
+
if current_limit <= 0:
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
chunk = fetch(
|
|
125
|
+
api, name,
|
|
126
|
+
limit=current_limit,
|
|
127
|
+
offset=offset,
|
|
128
|
+
query=query,
|
|
129
|
+
verbosity=verbosity,
|
|
130
|
+
client=client,
|
|
131
|
+
)
|
|
132
|
+
if api.drop_cols:
|
|
133
|
+
chunk = [
|
|
134
|
+
{k: v for k, v in row.items() if k not in api.drop_cols}
|
|
135
|
+
for row in chunk
|
|
136
|
+
]
|
|
137
|
+
if not chunk:
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
records.extend(chunk)
|
|
141
|
+
fetched += len(chunk)
|
|
142
|
+
offset += len(chunk)
|
|
143
|
+
_log(verbosity, f"i Fetched {len(chunk)} records (total: {fetched}).")
|
|
144
|
+
|
|
145
|
+
if fetched >= total:
|
|
146
|
+
break
|
|
147
|
+
finally:
|
|
148
|
+
if owns_client:
|
|
149
|
+
client.close()
|
|
150
|
+
|
|
151
|
+
_log(verbosity, f"✓ Fetching complete: {len(records)} records retrieved.")
|
|
152
|
+
return records
|
apifetch/tokens.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Token management via environment variables.
|
|
2
|
+
|
|
3
|
+
Tokens are never written to disk; they live only in process environment
|
|
4
|
+
variables named ``<service>_<name>``. ``service`` acts as a namespace so a single
|
|
5
|
+
process can hold tokens for several different APIs without clashing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from ._utils import sanitize_name, token_var
|
|
13
|
+
|
|
14
|
+
__all__ = ["store_token", "get_token", "remove_token", "list_tokens"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def store_token(name: str, token: str, service: str = "apifetch") -> None:
|
|
18
|
+
"""Store ``token`` for ``name`` in an environment variable.
|
|
19
|
+
|
|
20
|
+
Refuses to overwrite an existing, non-empty variable.
|
|
21
|
+
"""
|
|
22
|
+
if not name:
|
|
23
|
+
raise ValueError("name must be a non-empty string.")
|
|
24
|
+
if not token:
|
|
25
|
+
raise ValueError("token must be a non-empty string.")
|
|
26
|
+
|
|
27
|
+
var = token_var(name, service)
|
|
28
|
+
if os.environ.get(var):
|
|
29
|
+
print(f"! {var} is already defined; not overwriting to avoid data loss.")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
os.environ[var] = token
|
|
33
|
+
print(f"✓ Token stored in environment variable: {var}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_token(name: str, service: str = "apifetch") -> str | None:
|
|
37
|
+
"""Return the token stored for ``name``/``service``, or ``None`` if missing."""
|
|
38
|
+
if not name:
|
|
39
|
+
raise ValueError("name must be a non-empty string.")
|
|
40
|
+
|
|
41
|
+
token = os.environ.get(token_var(name, service))
|
|
42
|
+
if not token:
|
|
43
|
+
print(f"! No token found for {name!r} (service {service!r}).")
|
|
44
|
+
return None
|
|
45
|
+
return token
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def remove_token(name: str, service: str = "apifetch") -> None:
|
|
49
|
+
"""Remove the token stored for ``name``/``service`` if present."""
|
|
50
|
+
if not name:
|
|
51
|
+
raise ValueError("name must be a non-empty string.")
|
|
52
|
+
|
|
53
|
+
var = token_var(name, service)
|
|
54
|
+
if os.environ.get(var):
|
|
55
|
+
del os.environ[var]
|
|
56
|
+
print(f"✓ Token removed for {name!r} (service {service!r}).")
|
|
57
|
+
else:
|
|
58
|
+
print(f"! No token found for {name!r} (service {service!r}).")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def list_tokens(service: str = "apifetch") -> list[str]:
|
|
62
|
+
"""Return the names (without the ``service`` prefix) of stored tokens."""
|
|
63
|
+
prefix = f"{sanitize_name(service)}_"
|
|
64
|
+
names = [key[len(prefix):] for key in os.environ if key.startswith(prefix)]
|
|
65
|
+
if not names:
|
|
66
|
+
print(f"i No tokens found for service {service!r}.")
|
|
67
|
+
return sorted(names)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apifetch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A generic toolkit for token-authenticated REST API retrieval.
|
|
5
|
+
Project-URL: Homepage, https://github.com/StrategicProjects/apifetch-py
|
|
6
|
+
Project-URL: Repository, https://github.com/StrategicProjects/apifetch-py
|
|
7
|
+
Project-URL: R sibling, https://github.com/StrategicProjects/apifetch
|
|
8
|
+
Author-email: André Leite <leite@de.ufpe.br>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 André Leite
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: api,client,http,pagination,rest,token
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
37
|
+
Requires-Python: >=3.9
|
|
38
|
+
Requires-Dist: httpx>=0.24
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: pandas>=1.3; extra == 'dev'
|
|
41
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
42
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
43
|
+
Provides-Extra: docs
|
|
44
|
+
Requires-Dist: mkdocs-material>=9; extra == 'docs'
|
|
45
|
+
Requires-Dist: mkdocstrings[python]>=0.24; extra == 'docs'
|
|
46
|
+
Provides-Extra: pandas
|
|
47
|
+
Requires-Dist: pandas>=1.3; extra == 'pandas'
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
<p align="center">
|
|
51
|
+
<img src="docs/assets/logo.png" width="200" alt="apifetch">
|
|
52
|
+
</p>
|
|
53
|
+
|
|
54
|
+
# apifetch (Python)
|
|
55
|
+
|
|
56
|
+
`apifetch` is a small, dependency-light toolkit for talking to
|
|
57
|
+
token-authenticated REST APIs. It handles three recurring chores:
|
|
58
|
+
|
|
59
|
+
1. **Token management** — store/get/remove/list tokens in process environment
|
|
60
|
+
variables (never written to disk), namespaced per service.
|
|
61
|
+
2. **Request building** — pluggable **authentication** and **pagination**
|
|
62
|
+
strategies, bundled into a reusable `Api` profile.
|
|
63
|
+
3. **Data retrieval** — fetch one page, or fetch everything in chunks.
|
|
64
|
+
|
|
65
|
+
This is the Python sibling of the R package
|
|
66
|
+
[apifetch](https://github.com/StrategicProjects/apifetch). Both were extracted
|
|
67
|
+
from the [BigDataPE](https://github.com/StrategicProjects/BigDataPE) package,
|
|
68
|
+
which is now one *use case* (see `examples/bigdatape.py`).
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install apifetch
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Usage
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import apifetch as af
|
|
80
|
+
|
|
81
|
+
# 1. Describe the API once: where, how to authenticate, how to paginate.
|
|
82
|
+
api = af.Api(
|
|
83
|
+
endpoint="https://api.example.com/v1/search",
|
|
84
|
+
service="Example",
|
|
85
|
+
auth=af.AuthBearer(), # "Authorization: Bearer <token>"
|
|
86
|
+
pagination=af.PaginateOffset(where="query"),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# 2. Store a token (kept only in this process's environment).
|
|
90
|
+
af.store_token("reports", "my-secret-token", service="Example")
|
|
91
|
+
|
|
92
|
+
# 3. Fetch.
|
|
93
|
+
one_page = af.fetch(api, "reports", limit=50)
|
|
94
|
+
everything = af.fetch_all(api, "reports", chunk_size=1000)
|
|
95
|
+
|
|
96
|
+
# Optional: turn it into a DataFrame.
|
|
97
|
+
# import pandas as pd; df = pd.DataFrame(everything)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Strategies
|
|
101
|
+
|
|
102
|
+
**Authentication:** `AuthBearer`, `AuthRaw`, `AuthHeader`, `AuthQuery`.
|
|
103
|
+
|
|
104
|
+
**Pagination:** `PaginateOffset(where="header" | "query")`, `PaginateNone`.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT © André Leite
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
apifetch/__init__.py,sha256=FdebCUxfc_A1nHJQct34lQcCVe0xxlS4bDF5sdL4fCs,1133
|
|
2
|
+
apifetch/_utils.py,sha256=IQtgRnd1hi4xCF1nz0JpJ1ECv_HMh5JjhBghpVPQvdY,983
|
|
3
|
+
apifetch/api.py,sha256=ZSf6mUooVmbeQ-pOnLhBBMGdjDvyoy5gEzjRaEYYY_8,4502
|
|
4
|
+
apifetch/fetch.py,sha256=BF7BW8JBbPJJZy-YcM-V7QRpRkuayz6weg_z_fOmHlw,4732
|
|
5
|
+
apifetch/tokens.py,sha256=Ox9VAy-MfOAkDq7PmEbV-qbPm4V5c9Eb-Rym2r5-Svs,2291
|
|
6
|
+
apifetch-0.1.0.dist-info/METADATA,sha256=8PIhDVkX1Djj9iTtNy6O8AGxtqcSkjNssYA3GvsoXpE,4126
|
|
7
|
+
apifetch-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
apifetch-0.1.0.dist-info/licenses/LICENSE,sha256=OGxCK_IX0xDkZ7FRQynkGICKfxuVKrvLVheW4A7dQsA,1069
|
|
9
|
+
apifetch-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 André Leite
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|