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.
Files changed (40) hide show
  1. pfmsoft/api_request/__init__.py +37 -0
  2. pfmsoft/api_request/cache/__init__.py +36 -0
  3. pfmsoft/api_request/cache/memory_cache.py +177 -0
  4. pfmsoft/api_request/cache/metadata_helpers.py +55 -0
  5. pfmsoft/api_request/cache/models.py +83 -0
  6. pfmsoft/api_request/cache/protocols.py +166 -0
  7. pfmsoft/api_request/cache/sqlite_cache/__init__.py +7 -0
  8. pfmsoft/api_request/cache/sqlite_cache/connection_helpers.py +126 -0
  9. pfmsoft/api_request/cache/sqlite_cache/query_helpers.py +107 -0
  10. pfmsoft/api_request/cache/sqlite_cache/sqlite_cache.py +233 -0
  11. pfmsoft/api_request/cache/sqlite_cache/table_definitions.sql +23 -0
  12. pfmsoft/api_request/cli/__init__.py +17 -0
  13. pfmsoft/api_request/cli/cache/__init__.py +12 -0
  14. pfmsoft/api_request/cli/cache/info.py +11 -0
  15. pfmsoft/api_request/cli/helpers.py +41 -0
  16. pfmsoft/api_request/cli/main_typer.py +52 -0
  17. pfmsoft/api_request/cli/request/__init__.py +11 -0
  18. pfmsoft/api_request/cli/request/request.py +222 -0
  19. pfmsoft/api_request/cli/request/validate.py +11 -0
  20. pfmsoft/api_request/helpers/http_session_factory.py +80 -0
  21. pfmsoft/api_request/helpers/json_io.py +256 -0
  22. pfmsoft/api_request/helpers/save_text_file.py +43 -0
  23. pfmsoft/api_request/helpers/whenever/__init__.py +4 -0
  24. pfmsoft/api_request/helpers/whenever/instant_helpers.py +41 -0
  25. pfmsoft/api_request/logging_config.py +202 -0
  26. pfmsoft/api_request/py.typed +0 -0
  27. pfmsoft/api_request/rate_limit/__init__.py +24 -0
  28. pfmsoft/api_request/rate_limit/aio_limiter.py +89 -0
  29. pfmsoft/api_request/rate_limit/protocols.py +101 -0
  30. pfmsoft/api_request/request/__init__.py +11 -0
  31. pfmsoft/api_request/request/api_requester.py +681 -0
  32. pfmsoft/api_request/request/intermediate_models.py +143 -0
  33. pfmsoft/api_request/request/models.py +355 -0
  34. pfmsoft/api_request/request/protocols.py +54 -0
  35. pfmsoft/api_request/settings.py +65 -0
  36. pfmsoft_api_request-0.1.3.dist-info/METADATA +171 -0
  37. pfmsoft_api_request-0.1.3.dist-info/RECORD +40 -0
  38. pfmsoft_api_request-0.1.3.dist-info/WHEEL +4 -0
  39. pfmsoft_api_request-0.1.3.dist-info/entry_points.txt +3 -0
  40. 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,11 @@
1
+ """Validate an api request."""
2
+
3
+ import typer
4
+
5
+ app = typer.Typer(no_args_is_help=True)
6
+
7
+
8
+ @app.command()
9
+ def validate(ctx: typer.Context) -> None:
10
+ """Validate an api request."""
11
+ typer.echo("Validation Placeholder...")
@@ -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,4 @@
1
+ """Helper functions for working with the whenever library.
2
+
3
+ https://github.com/ariebovenberg/whenever
4
+ """
@@ -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