pfmsoft-api-request 0.1.3__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.
- pfmsoft/api_request/__init__.py +37 -0
- pfmsoft/api_request/cache/__init__.py +36 -0
- pfmsoft/api_request/cache/memory_cache.py +177 -0
- pfmsoft/api_request/cache/metadata_helpers.py +55 -0
- pfmsoft/api_request/cache/models.py +83 -0
- pfmsoft/api_request/cache/protocols.py +166 -0
- pfmsoft/api_request/cache/sqlite_cache/__init__.py +7 -0
- pfmsoft/api_request/cache/sqlite_cache/connection_helpers.py +126 -0
- pfmsoft/api_request/cache/sqlite_cache/query_helpers.py +107 -0
- pfmsoft/api_request/cache/sqlite_cache/sqlite_cache.py +233 -0
- pfmsoft/api_request/cache/sqlite_cache/table_definitions.sql +23 -0
- pfmsoft/api_request/cli/__init__.py +17 -0
- pfmsoft/api_request/cli/cache/__init__.py +12 -0
- pfmsoft/api_request/cli/cache/info.py +11 -0
- pfmsoft/api_request/cli/helpers.py +41 -0
- pfmsoft/api_request/cli/main_typer.py +52 -0
- pfmsoft/api_request/cli/request/__init__.py +11 -0
- pfmsoft/api_request/cli/request/request.py +222 -0
- pfmsoft/api_request/cli/request/validate.py +11 -0
- pfmsoft/api_request/helpers/http_session_factory.py +80 -0
- pfmsoft/api_request/helpers/json_io.py +256 -0
- pfmsoft/api_request/helpers/save_text_file.py +43 -0
- pfmsoft/api_request/helpers/whenever/__init__.py +4 -0
- pfmsoft/api_request/helpers/whenever/instant_helpers.py +41 -0
- pfmsoft/api_request/logging_config.py +202 -0
- pfmsoft/api_request/py.typed +0 -0
- pfmsoft/api_request/rate_limit/__init__.py +24 -0
- pfmsoft/api_request/rate_limit/aio_limiter.py +89 -0
- pfmsoft/api_request/rate_limit/protocols.py +101 -0
- pfmsoft/api_request/request/__init__.py +11 -0
- pfmsoft/api_request/request/api_requester.py +681 -0
- pfmsoft/api_request/request/intermediate_models.py +143 -0
- pfmsoft/api_request/request/models.py +355 -0
- pfmsoft/api_request/request/protocols.py +54 -0
- pfmsoft/api_request/settings.py +65 -0
- pfmsoft_api_request-0.1.3.dist-info/METADATA +171 -0
- pfmsoft_api_request-0.1.3.dist-info/RECORD +40 -0
- pfmsoft_api_request-0.1.3.dist-info/WHEEL +4 -0
- pfmsoft_api_request-0.1.3.dist-info/entry_points.txt +3 -0
- pfmsoft_api_request-0.1.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Command-line interface for making API requests."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.json import JSON
|
|
12
|
+
|
|
13
|
+
from pfmsoft.api_request import ApiRequester, __app_name__, __version__
|
|
14
|
+
from pfmsoft.api_request.cache import SqliteCacheFactory
|
|
15
|
+
from pfmsoft.api_request.cli.helpers import (
|
|
16
|
+
get_api_request_settings_from_context,
|
|
17
|
+
get_stdin,
|
|
18
|
+
)
|
|
19
|
+
from pfmsoft.api_request.helpers.save_text_file import save_text_file
|
|
20
|
+
from pfmsoft.api_request.logging_config import setup_logging
|
|
21
|
+
from pfmsoft.api_request.rate_limit.aio_limiter import AiolimiterRateLimiterFactory
|
|
22
|
+
from pfmsoft.api_request.request.models import (
|
|
23
|
+
Requests,
|
|
24
|
+
RequestsRoot,
|
|
25
|
+
Responses,
|
|
26
|
+
ResponsesRoot,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(
|
|
32
|
+
no_args_is_help=True,
|
|
33
|
+
help="Commands for executing JSON-defined API request batches.",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command(help="Make an API request.")
|
|
38
|
+
def request(
|
|
39
|
+
ctx: Annotated[typer.Context, typer.Option(hidden=True)],
|
|
40
|
+
file_in: Annotated[
|
|
41
|
+
Path,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--from",
|
|
44
|
+
help="Input JSON file path. Use '-' (default) to read stdin.",
|
|
45
|
+
allow_dash=True,
|
|
46
|
+
file_okay=True,
|
|
47
|
+
dir_okay=False,
|
|
48
|
+
readable=True,
|
|
49
|
+
),
|
|
50
|
+
] = Path("-"),
|
|
51
|
+
file_out: Annotated[
|
|
52
|
+
Path,
|
|
53
|
+
typer.Option(
|
|
54
|
+
"--to",
|
|
55
|
+
help="Output JSON file path. Use '-' (default) to write stdout.",
|
|
56
|
+
allow_dash=True,
|
|
57
|
+
file_okay=True,
|
|
58
|
+
dir_okay=False,
|
|
59
|
+
writable=True,
|
|
60
|
+
),
|
|
61
|
+
] = Path("-"),
|
|
62
|
+
app_dir: Annotated[
|
|
63
|
+
Path | None,
|
|
64
|
+
typer.Option(
|
|
65
|
+
"--app-dir",
|
|
66
|
+
help="Application directory override for cache/log/settings paths.",
|
|
67
|
+
file_okay=False,
|
|
68
|
+
dir_okay=True,
|
|
69
|
+
writable=True,
|
|
70
|
+
),
|
|
71
|
+
]
|
|
72
|
+
| None = None,
|
|
73
|
+
max_rate: Annotated[
|
|
74
|
+
float,
|
|
75
|
+
typer.Option(
|
|
76
|
+
"--max-rate",
|
|
77
|
+
help="Rate limiter max acquisitions per time window.",
|
|
78
|
+
),
|
|
79
|
+
] = 50.0,
|
|
80
|
+
time_period: Annotated[
|
|
81
|
+
float,
|
|
82
|
+
typer.Option(
|
|
83
|
+
"--time-period",
|
|
84
|
+
help="Rate limiter window length in seconds.",
|
|
85
|
+
),
|
|
86
|
+
] = 60.0,
|
|
87
|
+
forced_fail_status_codes: Annotated[
|
|
88
|
+
list[int] | None,
|
|
89
|
+
typer.Option(
|
|
90
|
+
"--code",
|
|
91
|
+
help="HTTP status codes that should be treated as batch request failures.",
|
|
92
|
+
),
|
|
93
|
+
] = None,
|
|
94
|
+
plain: Annotated[
|
|
95
|
+
bool,
|
|
96
|
+
typer.Option(
|
|
97
|
+
"--plain",
|
|
98
|
+
help="Write plain JSON to stdout instead of Rich formatted output.",
|
|
99
|
+
),
|
|
100
|
+
] = False,
|
|
101
|
+
overwrite: Annotated[
|
|
102
|
+
bool,
|
|
103
|
+
typer.Option(
|
|
104
|
+
"--overwrite",
|
|
105
|
+
help="Overwrite existing output file when --to is a file path.",
|
|
106
|
+
),
|
|
107
|
+
] = False,
|
|
108
|
+
indent: Annotated[
|
|
109
|
+
int | None,
|
|
110
|
+
typer.Option(
|
|
111
|
+
"--indent",
|
|
112
|
+
help="Indent width for output JSON (compact when omitted).",
|
|
113
|
+
),
|
|
114
|
+
] = None,
|
|
115
|
+
quiet: Annotated[
|
|
116
|
+
bool,
|
|
117
|
+
typer.Option(
|
|
118
|
+
"--quiet",
|
|
119
|
+
help="Suppress non-essential CLI messages.",
|
|
120
|
+
),
|
|
121
|
+
] = False,
|
|
122
|
+
):
|
|
123
|
+
"""Execute a batch of API requests from JSON input.
|
|
124
|
+
|
|
125
|
+
Input format:
|
|
126
|
+
A JSON object keyed by request UUID. Each value must match the
|
|
127
|
+
`Request` model shape.
|
|
128
|
+
|
|
129
|
+
Output format:
|
|
130
|
+
A JSON object matching the `Responses` model with `successful` and
|
|
131
|
+
`failed` maps keyed by the same request UUIDs.
|
|
132
|
+
|
|
133
|
+
Notes:
|
|
134
|
+
- `--from -` reads input JSON from stdin.
|
|
135
|
+
- `--to -` writes output to stdout.
|
|
136
|
+
- Use `--plain` for plain stdout JSON when writing to stdout.
|
|
137
|
+
- Use `--code` to treat specific HTTP status codes as batch request failures.
|
|
138
|
+
Any requests that have not made it to the network yet will fail if any of the
|
|
139
|
+
specified codes are present in a response.
|
|
140
|
+
|
|
141
|
+
UUID generation quick reference:
|
|
142
|
+
Request maps are keyed by UUID strings. Generate UUID values with one
|
|
143
|
+
of the commands below.
|
|
144
|
+
|
|
145
|
+
Shell:
|
|
146
|
+
```bash
|
|
147
|
+
uuidgen
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Python:
|
|
151
|
+
```bash
|
|
152
|
+
python -c "import uuid; print(uuid.uuid4())"
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
python3 -c "import uuid; print(uuid.uuid4())"
|
|
157
|
+
```
|
|
158
|
+
"""
|
|
159
|
+
if quiet:
|
|
160
|
+
messenger = Console(stderr=True, quiet=True)
|
|
161
|
+
else:
|
|
162
|
+
messenger = Console(stderr=True)
|
|
163
|
+
settings = get_api_request_settings_from_context(ctx)
|
|
164
|
+
if app_dir:
|
|
165
|
+
settings.application_directory = app_dir
|
|
166
|
+
setup_logging(log_dir=settings.logging_directory)
|
|
167
|
+
logger.info(
|
|
168
|
+
f"Starting {__app_name__} v{__version__} with settings: {asdict(settings)!r}"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
input_data = get_stdin() if file_in == Path("-") else file_in.read_text()
|
|
172
|
+
requests = RequestsRoot.model_validate_json(input_data).root
|
|
173
|
+
if not requests:
|
|
174
|
+
messenger.print(
|
|
175
|
+
"[bold red]Error: No requests found in the input file.[/bold red]"
|
|
176
|
+
)
|
|
177
|
+
raise typer.Exit(code=1)
|
|
178
|
+
force_codes: set[int] = (
|
|
179
|
+
set(forced_fail_status_codes) if forced_fail_status_codes else set()
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
async def run_requests(
|
|
183
|
+
requests_to_run: Requests, force_fail_codes: set[int]
|
|
184
|
+
) -> Responses:
|
|
185
|
+
cache_factory = SqliteCacheFactory(settings.web_cache_path)
|
|
186
|
+
rate_limiter_factory = AiolimiterRateLimiterFactory(
|
|
187
|
+
max_rate=max_rate, time_period=time_period
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async with ApiRequester(
|
|
191
|
+
cache_factory=cache_factory,
|
|
192
|
+
rate_limiter_factory=rate_limiter_factory,
|
|
193
|
+
force_fail_on=force_fail_codes,
|
|
194
|
+
) as requester:
|
|
195
|
+
responses = await requester.process_requests(requests_to_run)
|
|
196
|
+
return responses
|
|
197
|
+
|
|
198
|
+
responses = asyncio.run(run_requests(requests, force_codes))
|
|
199
|
+
if file_out == Path("-"):
|
|
200
|
+
if plain:
|
|
201
|
+
print(ResponsesRoot(root=responses).model_dump_json(indent=indent))
|
|
202
|
+
else:
|
|
203
|
+
messenger.print(
|
|
204
|
+
JSON(ResponsesRoot(root=responses).model_dump_json(indent=indent))
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
output_path = save_text_file(
|
|
208
|
+
text=ResponsesRoot(root=responses).model_dump_json(indent=indent),
|
|
209
|
+
output_directory=file_out.parent,
|
|
210
|
+
file_name=file_out.name,
|
|
211
|
+
overwrite=overwrite,
|
|
212
|
+
)
|
|
213
|
+
messenger.print(f"[bold green]Responses saved to {output_path}[/bold green]")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
_example_request_status = {
|
|
217
|
+
"request_key": "293c20f6-7356-4787-9fb9-3833ed9a4956",
|
|
218
|
+
"method": "get",
|
|
219
|
+
"url": "https://esi.evetech.net/status/",
|
|
220
|
+
"cache_key": "af7c3da6-355b-4ece-abf3-b79ce625ea37",
|
|
221
|
+
"rate_key": "status",
|
|
222
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Create preconfigured sync and async HTTP clients for Eve Auth Manager."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Iterator
|
|
4
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
5
|
+
|
|
6
|
+
from httpx2 import AsyncClient, Client
|
|
7
|
+
|
|
8
|
+
from pfmsoft.api_request.settings import USER_AGENT
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def config_http_client(user_agent: str = USER_AGENT) -> Client:
|
|
12
|
+
"""Create an HTTP client configured with the project User-Agent header.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
user_agent: User-Agent header value to send with requests. Defaults to
|
|
16
|
+
the project setting.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Configured synchronous HTTP client.
|
|
20
|
+
|
|
21
|
+
Note:
|
|
22
|
+
The caller owns the returned client and must close it when finished.
|
|
23
|
+
"""
|
|
24
|
+
return Client(headers={"User-Agent": user_agent})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def config_async_http_client(user_agent: str = USER_AGENT) -> AsyncClient:
|
|
28
|
+
"""Create an async HTTP client configured with the project User-Agent header.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
user_agent: User-Agent header value to send with requests. Defaults to
|
|
32
|
+
the project setting.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Configured asynchronous HTTP client.
|
|
36
|
+
|
|
37
|
+
Note:
|
|
38
|
+
The caller owns the returned client and must close it when finished.
|
|
39
|
+
"""
|
|
40
|
+
return AsyncClient(headers={"User-Agent": user_agent})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@contextmanager
|
|
44
|
+
def client_manager(user_agent: str = USER_AGENT) -> Iterator[Client]:
|
|
45
|
+
"""Yield a configured HTTP client and close it automatically on exit.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
user_agent: User-Agent header value to send with requests.
|
|
49
|
+
|
|
50
|
+
Yields:
|
|
51
|
+
Configured synchronous HTTP client.
|
|
52
|
+
"""
|
|
53
|
+
client: Client | None = None
|
|
54
|
+
try:
|
|
55
|
+
client = config_http_client(user_agent)
|
|
56
|
+
yield client
|
|
57
|
+
finally:
|
|
58
|
+
if client is not None:
|
|
59
|
+
client.close()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@asynccontextmanager
|
|
63
|
+
async def async_client_manager(
|
|
64
|
+
user_agent: str = USER_AGENT,
|
|
65
|
+
) -> AsyncIterator[AsyncClient]:
|
|
66
|
+
"""Yield a configured async HTTP client and close it automatically on exit.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
user_agent: User-Agent header value to send with requests.
|
|
70
|
+
|
|
71
|
+
Yields:
|
|
72
|
+
Configured asynchronous HTTP client.
|
|
73
|
+
"""
|
|
74
|
+
client: AsyncClient | None = None
|
|
75
|
+
try:
|
|
76
|
+
client = await config_async_http_client(user_agent)
|
|
77
|
+
yield client
|
|
78
|
+
finally:
|
|
79
|
+
if client is not None:
|
|
80
|
+
await client.aclose()
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Helpers for loading, dumping, and streaming JSON and JSONL data with pydantic_core."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, TextIO
|
|
6
|
+
|
|
7
|
+
from pydantic_core import from_json, to_json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def json_load_path(filepath: Path) -> Any:
|
|
11
|
+
"""Load a JSON file into a Python object.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
filepath: Path to a UTF-8 encoded JSON file.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Parsed Python object produced by pydantic_core.from_json().
|
|
18
|
+
"""
|
|
19
|
+
return from_json(filepath.read_text(encoding="utf-8"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def json_loads(json_string: str | bytes) -> Any:
|
|
23
|
+
"""Parse a JSON text or byte payload into a Python object.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
json_string: JSON document encoded as text or bytes.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Parsed Python object produced by pydantic_core.from_json().
|
|
30
|
+
"""
|
|
31
|
+
return from_json(json_string)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def json_dumps(
|
|
35
|
+
obj: Any, indent: int | None = None, encoding: str = "utf-8", **kwargs: Any
|
|
36
|
+
) -> str:
|
|
37
|
+
"""Serialize a Python object to JSON text.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
obj: Python object to serialize.
|
|
41
|
+
indent: Optional indentation level passed to pydantic_core.to_json().
|
|
42
|
+
encoding: Text encoding used to decode the serialized JSON bytes.
|
|
43
|
+
**kwargs: Additional keyword arguments forwarded to
|
|
44
|
+
pydantic_core.to_json().
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
JSON document as a string.
|
|
48
|
+
"""
|
|
49
|
+
return to_json(obj, indent=indent, **kwargs).decode(encoding)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def json_dump_bytes(obj: Any, indent: int | None = None, **kwargs: Any) -> bytes:
|
|
53
|
+
"""Serialize a Python object to JSON bytes.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
obj: Python object to serialize.
|
|
57
|
+
indent: Optional indentation level passed to pydantic_core.to_json().
|
|
58
|
+
**kwargs: Additional keyword arguments forwarded to
|
|
59
|
+
pydantic_core.to_json().
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
JSON document as bytes.
|
|
63
|
+
"""
|
|
64
|
+
return to_json(obj, indent=indent, **kwargs)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def json_dumps_path(
|
|
68
|
+
obj: Any,
|
|
69
|
+
*,
|
|
70
|
+
filepath: Path,
|
|
71
|
+
overwrite: bool = False,
|
|
72
|
+
encoding: str = "utf-8",
|
|
73
|
+
indent: int | None = None,
|
|
74
|
+
**kwargs: Any,
|
|
75
|
+
) -> int:
|
|
76
|
+
"""Dump a Python object to a JSON file.
|
|
77
|
+
|
|
78
|
+
Uses pydantic_core.to_json() to serialize the object and writes a trailing
|
|
79
|
+
newline after the JSON document.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
obj: The Python object to dump.
|
|
83
|
+
filepath: The path to the JSON file.
|
|
84
|
+
overwrite: Whether to overwrite the file if it exists.
|
|
85
|
+
encoding: The encoding to use when writing the file.
|
|
86
|
+
indent: The indentation level for the JSON file.
|
|
87
|
+
**kwargs: Additional keyword arguments passed to `json_dumps`.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The number of characters written to the file, including the trailing
|
|
91
|
+
newline.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
FileExistsError: If the file already exists and `overwrite` is False.
|
|
95
|
+
"""
|
|
96
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
if overwrite:
|
|
98
|
+
mode = "w"
|
|
99
|
+
else:
|
|
100
|
+
mode = "x"
|
|
101
|
+
with filepath.open(mode, encoding=encoding) as f:
|
|
102
|
+
counter = 0
|
|
103
|
+
counter += f.write(json_dumps(obj, indent=indent, **kwargs))
|
|
104
|
+
counter += f.write("\n")
|
|
105
|
+
return counter
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def jsonl_loads(jsonl_string: str) -> Iterator[Any]:
|
|
109
|
+
"""Yield Python objects parsed lazily from non-empty JSONL text lines."""
|
|
110
|
+
for line in jsonl_string.splitlines():
|
|
111
|
+
if line.strip():
|
|
112
|
+
yield from_json(line)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def jsonl_loads_indexed(jsonl_string: str) -> Iterator[tuple[int, Any]]:
|
|
116
|
+
"""Yield line-number and object pairs from non-empty JSONL text lines.
|
|
117
|
+
|
|
118
|
+
Line numbers correspond to the original input line positions, including
|
|
119
|
+
blank lines.
|
|
120
|
+
"""
|
|
121
|
+
for line_number, line in enumerate(jsonl_string.splitlines(), start=1):
|
|
122
|
+
if line.strip():
|
|
123
|
+
yield line_number, from_json(line)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def jsonl_load_bytes(jsonl_bytes: bytes) -> Iterator[Any]:
|
|
127
|
+
"""Yield Python objects parsed lazily from non-empty JSONL byte lines."""
|
|
128
|
+
for line in jsonl_bytes.splitlines():
|
|
129
|
+
if line.strip():
|
|
130
|
+
yield from_json(line)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def jsonl_load_bytes_indexed(jsonl_bytes: bytes) -> Iterator[tuple[int, Any]]:
|
|
134
|
+
"""Yield line-number and object pairs from non-empty JSONL byte lines.
|
|
135
|
+
|
|
136
|
+
Line numbers correspond to the original input line positions, including
|
|
137
|
+
blank lines.
|
|
138
|
+
"""
|
|
139
|
+
for line_number, line in enumerate(jsonl_bytes.splitlines(), start=1):
|
|
140
|
+
if line.strip():
|
|
141
|
+
yield line_number, from_json(line)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def jsonl_load_path(filepath: Path) -> Iterator[Any]:
|
|
145
|
+
"""Yield Python objects parsed lazily from non-empty lines in a JSONL file."""
|
|
146
|
+
with filepath.open("r", encoding="utf-8") as f:
|
|
147
|
+
for line in f:
|
|
148
|
+
if line.strip():
|
|
149
|
+
yield from_json(line)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def jsonl_load_path_indexed(filepath: Path) -> Iterator[tuple[int, Any]]:
|
|
153
|
+
"""Yield line-number and object pairs parsed from a JSONL file.
|
|
154
|
+
|
|
155
|
+
Line numbers correspond to the original file line positions, including
|
|
156
|
+
blank lines.
|
|
157
|
+
"""
|
|
158
|
+
with filepath.open("r", encoding="utf-8") as f:
|
|
159
|
+
for line_number, line in enumerate(f, start=1):
|
|
160
|
+
if line.strip():
|
|
161
|
+
yield line_number, from_json(line)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def jsonl_dump_path(
|
|
165
|
+
objs: Iterator[Any],
|
|
166
|
+
*,
|
|
167
|
+
filepath: Path,
|
|
168
|
+
encoding: str = "utf-8",
|
|
169
|
+
overwrite: bool = False,
|
|
170
|
+
append: bool = False,
|
|
171
|
+
**kwargs: Any,
|
|
172
|
+
) -> int:
|
|
173
|
+
"""Write JSONL output from an iterator of Python objects.
|
|
174
|
+
|
|
175
|
+
Serializes one object per line without materializing the full output in
|
|
176
|
+
memory.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
objs: Iterator of Python objects to serialize as JSONL.
|
|
180
|
+
filepath: The path to the JSONL file.
|
|
181
|
+
encoding: The encoding to use when writing the file.
|
|
182
|
+
overwrite: Whether to overwrite the file if it exists.
|
|
183
|
+
append: Whether to append to the file if it exists.
|
|
184
|
+
**kwargs: Additional keyword arguments passed to `json_dumps`.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Number of characters written to the file, including newline
|
|
188
|
+
separators.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
ValueError: If `indent` is provided or if `overwrite` and `append` are
|
|
192
|
+
both True.
|
|
193
|
+
FileExistsError: If the file already exists and neither overwrite nor
|
|
194
|
+
append permits writing.
|
|
195
|
+
"""
|
|
196
|
+
if "indent" in kwargs:
|
|
197
|
+
raise ValueError("indent is not supported for JSONL files.")
|
|
198
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
if overwrite and append:
|
|
200
|
+
raise ValueError("overwrite and append are mutually exclusive.")
|
|
201
|
+
if append:
|
|
202
|
+
mode = "a"
|
|
203
|
+
elif overwrite:
|
|
204
|
+
mode = "w"
|
|
205
|
+
else:
|
|
206
|
+
mode = "x"
|
|
207
|
+
|
|
208
|
+
def write_objs(f: TextIO) -> int:
|
|
209
|
+
counter = 0
|
|
210
|
+
for obj in objs:
|
|
211
|
+
counter += f.write(json_dumps(obj, **kwargs))
|
|
212
|
+
counter += f.write("\n")
|
|
213
|
+
return counter
|
|
214
|
+
|
|
215
|
+
with filepath.open(mode, encoding=encoding) as f:
|
|
216
|
+
return write_objs(f)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def jsonl_dumps(objs: Iterator[Any], encoding: str = "utf-8", **kwargs: Any) -> str:
|
|
220
|
+
"""Serialize an iterator of objects to a JSONL string.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
objs: Iterator of Python objects to serialize as JSONL.
|
|
224
|
+
encoding: Text encoding used when converting each JSON document.
|
|
225
|
+
**kwargs: Additional keyword arguments forwarded to json_dumps().
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
JSONL document with one serialized object per line.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
ValueError: If `indent` is provided, because JSONL output must remain
|
|
232
|
+
one JSON value per line.
|
|
233
|
+
"""
|
|
234
|
+
if "indent" in kwargs:
|
|
235
|
+
raise ValueError("indent is not supported for JSONL files.")
|
|
236
|
+
return "\n".join(json_dumps(obj, encoding=encoding, **kwargs) for obj in objs)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def jsonl_dump_bytes(objs: Iterator[Any], **kwargs: Any) -> bytes:
|
|
240
|
+
"""Serialize an iterator of objects to JSONL bytes.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
objs: Iterator of Python objects to serialize as JSONL.
|
|
244
|
+
**kwargs: Additional keyword arguments forwarded to json_dump_bytes().
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
JSONL document as bytes with one serialized object per line.
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
ValueError: If `indent` is provided, because JSONL output must remain
|
|
251
|
+
one JSON value per line.
|
|
252
|
+
"""
|
|
253
|
+
if "indent" in kwargs:
|
|
254
|
+
raise ValueError("indent is not supported for JSONL files.")
|
|
255
|
+
jsonl_bytes = b"\n".join(json_dump_bytes(obj, **kwargs) for obj in objs)
|
|
256
|
+
return jsonl_bytes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Write text files while creating parent directories and enforcing overwrite policy."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def save_text_file(
|
|
7
|
+
*,
|
|
8
|
+
text: str,
|
|
9
|
+
output_directory: Path,
|
|
10
|
+
file_name: str,
|
|
11
|
+
overwrite: bool = False,
|
|
12
|
+
encoding: str = "utf-8",
|
|
13
|
+
) -> Path:
|
|
14
|
+
"""Write text to a file in the target output directory.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
text: Text content to write.
|
|
18
|
+
output_directory: Directory that should contain the output file.
|
|
19
|
+
file_name: Name of the output file to create.
|
|
20
|
+
overwrite: If true, replace an existing file. If false, raise an error
|
|
21
|
+
when the target file already exists.
|
|
22
|
+
encoding: Text encoding to use when writing the file.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Path object for the written file.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
FileExistsError: If the target file exists and overwrite is false.
|
|
29
|
+
OSError: If the parent directory cannot be created or the file cannot
|
|
30
|
+
be written.
|
|
31
|
+
|
|
32
|
+
Notes:
|
|
33
|
+
Parent directories are created automatically when missing.
|
|
34
|
+
"""
|
|
35
|
+
output_file = output_directory / file_name
|
|
36
|
+
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
if overwrite:
|
|
38
|
+
mode = "w"
|
|
39
|
+
else:
|
|
40
|
+
mode = "x"
|
|
41
|
+
with output_file.open(mode, encoding=encoding) as f:
|
|
42
|
+
f.write(text)
|
|
43
|
+
return output_file
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Convenience helpers for current-time and relative-time calculations with whenever.Instant."""
|
|
2
|
+
|
|
3
|
+
from pfmsoft.api_request.helpers.whenever import Instant
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def timestamp() -> int:
|
|
7
|
+
"""Return the current Unix timestamp in whole seconds."""
|
|
8
|
+
return Instant.now().timestamp()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def timestamp_nanos() -> int:
|
|
12
|
+
"""Return the current Unix timestamp in nanoseconds."""
|
|
13
|
+
return Instant.now().timestamp_nanos()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def from_now(timestamp: int) -> int:
|
|
17
|
+
"""Compute the signed number of seconds until a target Unix timestamp.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
timestamp: Target Unix timestamp in whole seconds.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Signed number of seconds between now and the target timestamp.
|
|
24
|
+
Negative values indicate the target time is already in the past.
|
|
25
|
+
"""
|
|
26
|
+
now = Instant.now().timestamp()
|
|
27
|
+
return timestamp - now
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def from_now_nanos(timestamp_nanos: int) -> int:
|
|
31
|
+
"""Compute the signed number of nanoseconds until a target Unix timestamp.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
timestamp_nanos: Target Unix timestamp in nanoseconds.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Signed number of nanoseconds between now and the target timestamp.
|
|
38
|
+
Negative values indicate the target time is already in the past.
|
|
39
|
+
"""
|
|
40
|
+
now_nanos = Instant.now().timestamp_nanos()
|
|
41
|
+
return timestamp_nanos - now_nanos
|