pfmsoft-api-request 0.1.3__tar.gz → 0.1.7__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.
Files changed (42) hide show
  1. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/PKG-INFO +2 -2
  2. pfmsoft_api_request-0.1.7/pyproject.toml +82 -0
  3. pfmsoft_api_request-0.1.3/pyproject.toml → pfmsoft_api_request-0.1.7/pyproject.toml.orig +2 -2
  4. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cache/sqlite_cache/query_helpers.py +14 -0
  5. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cache/sqlite_cache/sqlite_cache.py +7 -2
  6. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cli/request/request.py +3 -3
  7. pfmsoft_api_request-0.1.7/src/pfmsoft/api_request/helpers/package_resource.py +16 -0
  8. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/request/api_requester.py +2 -2
  9. pfmsoft_api_request-0.1.7/src/pfmsoft/api_request/settings.py +94 -0
  10. pfmsoft_api_request-0.1.3/src/pfmsoft/api_request/cache/sqlite_cache/connection_helpers.py +0 -126
  11. pfmsoft_api_request-0.1.3/src/pfmsoft/api_request/helpers/http_session_factory.py +0 -80
  12. pfmsoft_api_request-0.1.3/src/pfmsoft/api_request/helpers/json_io.py +0 -256
  13. pfmsoft_api_request-0.1.3/src/pfmsoft/api_request/helpers/save_text_file.py +0 -43
  14. pfmsoft_api_request-0.1.3/src/pfmsoft/api_request/settings.py +0 -65
  15. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/LICENSE +0 -0
  16. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/README.md +0 -0
  17. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/__init__.py +0 -0
  18. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cache/__init__.py +0 -0
  19. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cache/memory_cache.py +0 -0
  20. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cache/metadata_helpers.py +0 -0
  21. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cache/models.py +0 -0
  22. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cache/protocols.py +0 -0
  23. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cache/sqlite_cache/__init__.py +0 -0
  24. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cache/sqlite_cache/table_definitions.sql +0 -0
  25. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cli/__init__.py +0 -0
  26. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cli/cache/__init__.py +0 -0
  27. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cli/cache/info.py +0 -0
  28. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cli/helpers.py +0 -0
  29. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cli/main_typer.py +0 -0
  30. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cli/request/__init__.py +0 -0
  31. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/cli/request/validate.py +0 -0
  32. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/helpers/whenever/__init__.py +0 -0
  33. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/helpers/whenever/instant_helpers.py +0 -0
  34. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/logging_config.py +0 -0
  35. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/py.typed +0 -0
  36. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/rate_limit/__init__.py +0 -0
  37. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/rate_limit/aio_limiter.py +0 -0
  38. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/rate_limit/protocols.py +0 -0
  39. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/request/__init__.py +0 -0
  40. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/request/intermediate_models.py +0 -0
  41. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/request/models.py +0 -0
  42. {pfmsoft_api_request-0.1.3 → pfmsoft_api_request-0.1.7}/src/pfmsoft/api_request/request/protocols.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pfmsoft-api-request
3
- Version: 0.1.3
3
+ Version: 0.1.7
4
4
  Summary: A command line api request tool with caching and rate limiting.
5
5
  Author: Chad Lowe
6
6
  Author-email: Chad Lowe <pfmsoft.dev@gmail.com>
@@ -8,7 +8,7 @@ License-Expression: MIT
8
8
  License-File: LICENSE
9
9
  Requires-Dist: aiolimiter>=1.2.1
10
10
  Requires-Dist: httpx2>=2.5.0
11
- Requires-Dist: pfmsoft-eve-snippets>=0.1.4
11
+ Requires-Dist: pfmsoft-eve-snippets>=0.1.7
12
12
  Requires-Dist: pydantic>=2.13.4
13
13
  Requires-Dist: pydantic-settings>=2.14.2
14
14
  Requires-Dist: typer>=0.26.8
@@ -0,0 +1,82 @@
1
+ [project]
2
+ name = "pfmsoft-api-request"
3
+ version = "0.1.7"
4
+ description = "A command line api request tool with caching and rate limiting."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "aiolimiter>=1.2.1",
9
+ "httpx2>=2.5.0",
10
+ "pfmsoft-eve-snippets>=0.1.7",
11
+ "pydantic>=2.13.4",
12
+ "pydantic-settings>=2.14.2",
13
+ "typer>=0.26.8",
14
+ "whenever>=0.10.2",
15
+ ]
16
+ license = "MIT"
17
+ license-files = ["LICENSE"]
18
+
19
+ [[project.authors]]
20
+ name = "Chad Lowe"
21
+ email = "pfmsoft.dev@gmail.com"
22
+
23
+ [project.scripts]
24
+ api-request = "pfmsoft.api_request.cli.main_typer:app"
25
+
26
+ [project.urls]
27
+ Documentation = "https://github.com/DonalChilde/pfmsoft-api-request#readme"
28
+ Issues = "https://github.com/DonalChilde/pfmsoft-api-request/issues"
29
+ Source = "https://github.com/DonalChilde/pfmsoft-api-request"
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.11.21,<0.12.0"]
33
+ build-backend = "uv_build"
34
+
35
+ [tool.uv]
36
+ exclude-newer = "1 week"
37
+
38
+ [tool.uv.build-backend]
39
+ module-name = "pfmsoft.api_request"
40
+
41
+ [tool.uv.exclude-newer-package]
42
+ pfmsoft-eve-snippets = false
43
+
44
+ [tool.pytest.ini_options]
45
+ pythonpath = "src"
46
+ log_file = "dev/pytest-logs/tests.log"
47
+ log_file_date_format = "%Y-%m-%dT%H:%M:%S.%f%z"
48
+ log_file_format = "%(asctime)s %(levelname)s:%(funcName)s: %(message)s [in %(pathname)s:%(lineno)d]"
49
+ log_file_level = "INFO"
50
+
51
+ [tool.coverage.run]
52
+ branch = true
53
+ parallel = true
54
+
55
+ [tool.ruff.lint]
56
+ select = [
57
+ "B",
58
+ "UP",
59
+ "D",
60
+ "FIX",
61
+ "I",
62
+ "F401",
63
+ ]
64
+ ignore = [
65
+ "D401",
66
+ "D101",
67
+ "D421",
68
+ ]
69
+
70
+ [tool.ruff.lint.pydocstyle]
71
+ convention = "google"
72
+
73
+ [tool.ruff.format]
74
+ docstring-code-format = true
75
+ docstring-code-line-length = 88
76
+
77
+ [dependency-groups]
78
+ dev = [
79
+ "pytest>=9.1.1",
80
+ "pytest-cov>=7.1.0",
81
+ "ruff>=0.15.21",
82
+ ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pfmsoft-api-request"
3
- version = "0.1.3"
3
+ version = "0.1.7"
4
4
  description = "A command line api request tool with caching and rate limiting."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Chad Lowe", email = "pfmsoft.dev@gmail.com" }]
@@ -8,7 +8,7 @@ requires-python = ">=3.14"
8
8
  dependencies = [
9
9
  "aiolimiter>=1.2.1",
10
10
  "httpx2>=2.5.0",
11
- "pfmsoft-eve-snippets>=0.1.4",
11
+ "pfmsoft-eve-snippets>=0.1.7",
12
12
  "pydantic>=2.13.4",
13
13
  "pydantic-settings>=2.14.2",
14
14
  "typer>=0.26.8",
@@ -8,10 +8,24 @@ import logging
8
8
  import sqlite3
9
9
  from uuid import UUID
10
10
 
11
+ from pfmsoft.api_request.helpers.package_resource import load_package_resouce_text
12
+
11
13
  from ..models import CachedResponse
12
14
 
13
15
  logger = logging.getLogger(__name__)
14
16
 
17
+ _table_def_parent = "pfmsoft.api_request.cache.sqlite_cache"
18
+ _table_def_sql = "table_definitions.sql"
19
+
20
+
21
+ def load_table_definitions() -> str:
22
+ """Load the packaged SQL script for creating the database schema.
23
+
24
+ Returns:
25
+ The contents of the SQL script as a string.
26
+ """
27
+ return load_package_resouce_text(_table_def_parent, _table_def_sql)
28
+
15
29
 
16
30
  def write_cached_response(
17
31
  connection: sqlite3.Connection, cache_key: str, cached_response: CachedResponse
@@ -10,14 +10,16 @@ from types import TracebackType
10
10
  from typing import Self
11
11
  from uuid import UUID
12
12
 
13
+ from pfmsoft.eve_snippets.sqlite3.connection_helpers import create_read_write_connection
13
14
  from whenever import Instant
14
15
 
16
+ from pfmsoft.api_request.cache.sqlite_cache.query_helpers import load_table_definitions
17
+
15
18
  from ...request.models import ResponseMetadata
16
19
  from ..metadata_helpers import merge_cached_revalidation_metadata
17
20
  from ..models import CachedResponse, CacheInfo
18
21
  from ..protocols import CacheFactoryProtocol, CacheProtocol
19
22
  from . import query_helpers
20
- from .connection_helpers import create_read_write_connection
21
23
 
22
24
 
23
25
  class SqliteCache(CacheProtocol):
@@ -56,7 +58,10 @@ class SqliteCache(CacheProtocol):
56
58
  async def __aenter__(self) -> Self:
57
59
  """Enter context and open path-owned connections when needed."""
58
60
  if self._connection_path is not None:
59
- self._connection = create_read_write_connection(self._connection_path)
61
+ table_definitions = load_table_definitions()
62
+ self._connection = create_read_write_connection(
63
+ self._connection_path, init_sql=table_definitions
64
+ )
60
65
  self._close_connection_on_exit = True
61
66
  return self
62
67
 
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  from typing import Annotated
8
8
 
9
9
  import typer
10
+ from pfmsoft.eve_snippets import save_text_file
10
11
  from rich.console import Console
11
12
  from rich.json import JSON
12
13
 
@@ -16,7 +17,6 @@ from pfmsoft.api_request.cli.helpers import (
16
17
  get_api_request_settings_from_context,
17
18
  get_stdin,
18
19
  )
19
- from pfmsoft.api_request.helpers.save_text_file import save_text_file
20
20
  from pfmsoft.api_request.logging_config import setup_logging
21
21
  from pfmsoft.api_request.rate_limit.aio_limiter import AiolimiterRateLimiterFactory
22
22
  from pfmsoft.api_request.request.models import (
@@ -206,8 +206,8 @@ def request(
206
206
  else:
207
207
  output_path = save_text_file(
208
208
  text=ResponsesRoot(root=responses).model_dump_json(indent=indent),
209
- output_directory=file_out.parent,
210
- file_name=file_out.name,
209
+ directory=file_out.parent,
210
+ filename=file_out.name,
211
211
  overwrite=overwrite,
212
212
  )
213
213
  messenger.print(f"[bold green]Responses saved to {output_path}[/bold green]")
@@ -0,0 +1,16 @@
1
+ """Helpers for working with package resources."""
2
+
3
+ from importlib.resources import files as resource_files
4
+
5
+
6
+ def load_package_resouce_text(package: str, resource_name: str) -> str:
7
+ """Load a resource from the package as text.
8
+
9
+ Args:
10
+ package: Package name where the resource is located.
11
+ resource_name: Name of the resource file to load.
12
+
13
+ Returns:
14
+ The contents of the resource file as a string.
15
+ """
16
+ return resource_files(package).joinpath(resource_name).read_text()
@@ -76,12 +76,12 @@ from uuid import UUID
76
76
 
77
77
  from httpx2 import AsyncClient
78
78
  from httpx2 import Response as HttpResponse
79
+ from pfmsoft.eve_snippets import json_io
80
+ from pfmsoft.eve_snippets.httpx2.http_session_factory import config_async_http_client
79
81
  from whenever import Instant
80
82
 
81
83
  from pfmsoft.api_request.cache.models import CachedResponse
82
84
  from pfmsoft.api_request.cache.protocols import CacheFactory, CacheProtocol
83
- from pfmsoft.api_request.helpers import json_io
84
- from pfmsoft.api_request.helpers.http_session_factory import config_async_http_client
85
85
  from pfmsoft.api_request.rate_limit.protocols import (
86
86
  RateLimiterFactoryProtocol,
87
87
  RateLimiterProtocol,
@@ -0,0 +1,94 @@
1
+ """Settings for the api-request module."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from uuid import NAMESPACE_DNS, uuid5
7
+
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+ from typer import get_app_dir
10
+
11
+ from pfmsoft.api_request import (
12
+ __app_name__,
13
+ __url__,
14
+ __version__,
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Typical application settings
20
+ USER_AGENT = f"{__app_name__}/{__version__} ({__url__})"
21
+ """User-Agent header value sent to remote OAuth and ESI services."""
22
+ APP_DOMAIN = f"{__app_name__}"
23
+ APP_NAMESPACE = uuid5(NAMESPACE_DNS, __app_name__)
24
+ ENV_PREFIX = __app_name__.replace(".", "_").replace("-", "_").upper() + "_"
25
+ SETTINGS_KEY = ENV_PREFIX + "SETTINGS"
26
+
27
+
28
+ @dataclass(slots=True, kw_only=True)
29
+ class ApiRequestSettings:
30
+ """Settings for the api-request module."""
31
+
32
+ application_directory: Path
33
+ """Directory where the application stores its data."""
34
+ logging_directory: Path
35
+ """Directory where the application stores its log files."""
36
+ web_cache_path: Path
37
+ """Path to the web cache SQLite database."""
38
+
39
+
40
+ class ApiRequestSettingsPydantic(BaseSettings):
41
+ """Pydantic settings for the api-request module."""
42
+
43
+ application_directory: Path = Path(get_app_dir(__app_name__)).resolve()
44
+ """Directory where the application stores its data."""
45
+
46
+ model_config = SettingsConfigDict(
47
+ env_prefix=ENV_PREFIX,
48
+ env_file=(".env", ".env.dev"),
49
+ env_file_encoding="utf-8",
50
+ )
51
+
52
+
53
+ def get_settings(
54
+ application_directory: Path | None = None,
55
+ ) -> ApiRequestSettings:
56
+ """Build runtime settings from a Pydantic settings model or application directory.
57
+
58
+ Args:
59
+ application_directory (Path | None): Optional application directory path.
60
+ If not provided, the default application directory is used.
61
+
62
+ Returns:
63
+ Runtime settings dataclass used by the application.
64
+
65
+ Raises:
66
+ ValueError: If the provided application directory exists but is not a directory.
67
+ """
68
+ if application_directory is None:
69
+ # If the application directory is not provided, use the value from the Pydantic
70
+ # settings model. This allows for environment variable overrides and .env file loading.
71
+ application_directory = ApiRequestSettingsPydantic().application_directory
72
+ application_directory = application_directory.expanduser().resolve()
73
+ if application_directory.exists() and not application_directory.is_dir():
74
+ raise ValueError(
75
+ f"Application directory '{application_directory}' exists but is not a directory."
76
+ )
77
+ settings = _initialize_settings(application_directory)
78
+ return settings
79
+
80
+
81
+ def _initialize_settings(application_directory: Path) -> ApiRequestSettings:
82
+ """Build default runtime settings.
83
+
84
+ Also ensures that the application directories exist.
85
+ """
86
+ settings = ApiRequestSettings(
87
+ application_directory=application_directory,
88
+ logging_directory=application_directory / "logs",
89
+ web_cache_path=application_directory / "api_requests_web_cache.sqlite",
90
+ )
91
+ # Ensure that the application directories exist.
92
+ settings.application_directory.mkdir(parents=True, exist_ok=True)
93
+ settings.logging_directory.mkdir(parents=True, exist_ok=True)
94
+ return settings
@@ -1,126 +0,0 @@
1
- """SQLite connection and schema bootstrap helpers.
2
-
3
- These helpers centralize URI construction and consistent connection setup for
4
- cache backends and tests.
5
- """
6
-
7
- import logging
8
- import sqlite3
9
- from collections.abc import Iterator
10
- from contextlib import contextmanager
11
- from importlib.resources import files as resource_files
12
- from pathlib import Path
13
-
14
- logger = logging.getLogger(__name__)
15
-
16
- _table_def_parent = "pfmsoft.api_request.cache.sqlite_cache"
17
- _table_def_sql = "table_definitions.sql"
18
-
19
-
20
- def read_only_uri(db_path: str) -> str:
21
- """Build a read-only SQLite URI for use with sqlite3.connect(uri=True).
22
-
23
- Args:
24
- db_path: Filesystem path to the SQLite database.
25
-
26
- Returns:
27
- URI string with mode=ro.
28
- """
29
- return f"file:{db_path}?mode=ro"
30
-
31
-
32
- def read_write_uri(db_path: str) -> str:
33
- """Build a read-write/create SQLite URI for sqlite3.connect(uri=True).
34
-
35
- Args:
36
- db_path: Filesystem path to the SQLite database.
37
-
38
- Returns:
39
- URI string with mode=rwc.
40
- """
41
- return f"file:{db_path}?mode=rwc"
42
-
43
-
44
- def create_read_only_connection(db_path: str | Path) -> sqlite3.Connection:
45
- """Create a read-only SQLite connection for an existing database.
46
-
47
- The caller is responsible for closing the connection when done.
48
-
49
- Args:
50
- db_path: Path to an existing SQLite database file.
51
-
52
- Returns:
53
- Open SQLite connection configured with sqlite3.Row row factory.
54
-
55
- Notes:
56
- The target database is expected to already contain the required schema.
57
- Read-only connections cannot modify schema or persisted rows.
58
- """
59
- if isinstance(db_path, Path):
60
- db_path = str(db_path.resolve())
61
- uri = read_only_uri(db_path)
62
- connection = sqlite3.connect(uri, uri=True)
63
- connection.row_factory = sqlite3.Row
64
- return connection
65
-
66
-
67
- def create_read_write_connection(db_path: str | Path) -> sqlite3.Connection:
68
- """Create a read-write SQLite connection and ensure the packaged schema exists.
69
-
70
- The caller is responsible for closing the connection when done.
71
-
72
- Args:
73
- db_path: Path to the SQLite database file.
74
-
75
- Returns:
76
- Open SQLite connection configured with sqlite3.Row row factory.
77
-
78
- Notes:
79
- Missing tables and other schema objects defined in the packaged SQL
80
- script are created before the connection is returned.
81
- """
82
- if isinstance(db_path, Path):
83
- db_path.parent.mkdir(parents=True, exist_ok=True)
84
- db_path = str(db_path.resolve())
85
- else:
86
- Path(db_path).parent.mkdir(parents=True, exist_ok=True)
87
- db_path = str(Path(db_path).resolve())
88
- uri = read_write_uri(db_path)
89
- connection = sqlite3.connect(uri, uri=True)
90
- logger.info(f"Created read-write connection to database at {db_path}")
91
- connection.row_factory = sqlite3.Row
92
- table_defs = resource_files(_table_def_parent).joinpath(_table_def_sql).read_text()
93
- with connection:
94
- connection.executescript(table_defs)
95
- logger.info("Ensured database schema is created.")
96
- return connection
97
-
98
-
99
- @contextmanager
100
- def db_connection_manager(
101
- db_path: str | Path, read_only: bool = True
102
- ) -> Iterator[sqlite3.Connection]:
103
- """Yield a SQLite connection and close it automatically on exit.
104
-
105
- Delegates to the read-only or read-write connection factory based on the
106
- `read_only` flag.
107
-
108
- Args:
109
- db_path: Path to the SQLite database file.
110
- read_only: Whether to open the connection in read-only mode.
111
-
112
- Yields:
113
- Open SQLite connection configured with sqlite3.Row row factory.
114
- """
115
- connection: sqlite3.Connection | None = None
116
- try:
117
- if read_only:
118
- logger.info(f"Opening read-only connection to database at {db_path}")
119
- connection = create_read_only_connection(db_path)
120
- else:
121
- logger.info(f"Opening read-write connection to database at {db_path}")
122
- connection = create_read_write_connection(db_path)
123
- yield connection
124
- finally:
125
- if connection is not None:
126
- connection.close()
@@ -1,80 +0,0 @@
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()
@@ -1,256 +0,0 @@
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
@@ -1,43 +0,0 @@
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
@@ -1,65 +0,0 @@
1
- """Settings for the api-request module."""
2
-
3
- import logging
4
- from dataclasses import asdict, dataclass
5
- from pathlib import Path
6
- from uuid import NAMESPACE_DNS, uuid5
7
-
8
- from pydantic_settings import BaseSettings, SettingsConfigDict
9
- from typer import get_app_dir
10
-
11
- from pfmsoft.api_request import (
12
- __app_name__,
13
- __project_namespace__,
14
- __url__,
15
- __version__,
16
- )
17
-
18
- logger = logging.getLogger(__name__)
19
-
20
- USER_AGENT = f"{__app_name__} ({__version__}) (+{__url__})"
21
- APP_DOMAIN = f"{__project_namespace__}.{__app_name__}"
22
- APP_NAMESPACE = uuid5(NAMESPACE_DNS, APP_DOMAIN)
23
- ENV_PREFIX = APP_DOMAIN.replace(".", "_").replace("-", "_").upper() + "_"
24
- SETTINGS_KEY = ENV_PREFIX + "SETTINGS"
25
-
26
-
27
- @dataclass(slots=True, kw_only=True)
28
- class ApiRequestSettings:
29
- """Settings for the api-request module."""
30
-
31
- application_directory: Path
32
- logging_directory: Path
33
- web_cache_path: Path
34
-
35
-
36
- class ApiRequestSettingsPydantic(BaseSettings):
37
- """Pydantic settings for the api-request module."""
38
-
39
- application_directory: Path = Path(get_app_dir(__app_name__))
40
-
41
- model_config = SettingsConfigDict(
42
- env_prefix=ENV_PREFIX,
43
- env_file=(".env", ".env.dev"),
44
- env_file_encoding="utf-8",
45
- )
46
-
47
-
48
- def get_settings() -> ApiRequestSettings:
49
- """Get the settings for the api-request module."""
50
- logger.info("Loading settings from environment variables...")
51
- logger.info(f"Environment variable prefix: {ENV_PREFIX}")
52
- pydantic_settings = ApiRequestSettingsPydantic()
53
- logger.info(
54
- f"Loaded settings from environment variables: {pydantic_settings.model_dump()}"
55
- )
56
- application_directory = pydantic_settings.application_directory.resolve()
57
- if not application_directory.exists():
58
- application_directory.mkdir(parents=True, exist_ok=True)
59
- settings = ApiRequestSettings(
60
- application_directory=application_directory,
61
- logging_directory=application_directory / "logs",
62
- web_cache_path=application_directory / "api_requests_web_cache.sqlite",
63
- )
64
- logger.info(f"Resolved application settings: {asdict(settings)}")
65
- return settings