dirigent-common 0.25.0__tar.gz → 0.25.2__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.
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/PKG-INFO +1 -1
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/pyproject.toml +1 -1
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/pyproject.toml.orig +1 -1
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/__init__.py +2 -1
- dirigent_common-0.25.2/src/dirigent_common/clients.py +86 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/schemas.py +3 -0
- dirigent_common-0.25.0/src/dirigent_common/clients.py +0 -27
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/LICENSE +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/README.md +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/docstrings.py +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/durations.py +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/formats.py +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/formatters.py +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/messages.py +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/names.py +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/programs.py +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/py.typed +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/requests.py +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/sizes.py +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/templating.py +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/types.py +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/uris.py +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/values.py +0 -0
- {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/versions.py +0 -0
|
@@ -5,7 +5,7 @@ more than one package has to agree on: how a document spells a duration or a siz
|
|
|
5
5
|
name may be, and the small schemas a plugin and the API both name.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from dirigent_common.clients import build_client
|
|
8
|
+
from dirigent_common.clients import BaseRedirect, build_client
|
|
9
9
|
from dirigent_common.docstrings import as_markdown
|
|
10
10
|
from dirigent_common.durations import (
|
|
11
11
|
DURATION_PATTERN,
|
|
@@ -79,6 +79,7 @@ __all__ = [
|
|
|
79
79
|
"STORAGE_URI_FORMAT",
|
|
80
80
|
"STEP_NAME_MAX_LENGTH",
|
|
81
81
|
"STEP_NAME_PATTERN",
|
|
82
|
+
"BaseRedirect",
|
|
82
83
|
"BlockModel",
|
|
83
84
|
"Catalogue",
|
|
84
85
|
"Duration",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Turning a connection's configuration into the client that talks to it.
|
|
2
|
+
|
|
3
|
+
This lives beside the configuration rather than in the standard library of blocks, so an
|
|
4
|
+
adapter pack can use the shared HTTP connection without depending on that library.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx2
|
|
11
|
+
|
|
12
|
+
from dirigent_common.schemas import HttpConnectionConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_client(config: HttpConnectionConfig) -> httpx2.AsyncClient:
|
|
16
|
+
"""Build a client carrying a connection's base URL, auth, TLS setting, and timeout."""
|
|
17
|
+
headers: dict[str, str] = {}
|
|
18
|
+
if config.bearer_token is not None:
|
|
19
|
+
headers["Authorization"] = f"Bearer {config.bearer_token.get_secret_value()}"
|
|
20
|
+
auth: httpx2.Auth | None = None
|
|
21
|
+
if config.basic_username:
|
|
22
|
+
password = config.basic_password.get_secret_value() if config.basic_password else ""
|
|
23
|
+
auth = httpx2.BasicAuth(config.basic_username, password)
|
|
24
|
+
hooks: dict[str, list[Callable[..., Any]]] | None = (
|
|
25
|
+
{"request": [BaseRedirect(config.base_url, verify=config.verify_tls)]}
|
|
26
|
+
if config.follow_redirects_with_auth
|
|
27
|
+
else None
|
|
28
|
+
)
|
|
29
|
+
return httpx2.AsyncClient(
|
|
30
|
+
base_url=config.base_url,
|
|
31
|
+
headers=headers,
|
|
32
|
+
auth=auth,
|
|
33
|
+
verify=config.verify_tls,
|
|
34
|
+
timeout=config.timeout.total_seconds(),
|
|
35
|
+
event_hooks=hooks,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BaseRedirect:
|
|
40
|
+
"""A request hook that sends every request under a base URL to where that base redirects.
|
|
41
|
+
|
|
42
|
+
The base is resolved once, on the first request, by following its redirects without a
|
|
43
|
+
credential. Each request is then rewritten onto the resolved base before it is sent, so its
|
|
44
|
+
credential and its method reach the target rather than being dropped or turned into a GET
|
|
45
|
+
by a redirect on the way.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, base_url: str, *, verify: bool) -> None:
|
|
49
|
+
"""Hold the configured base until the first request resolves it."""
|
|
50
|
+
self._base = base_url.rstrip("/")
|
|
51
|
+
self._verify = verify
|
|
52
|
+
self._resolved: str | None = None
|
|
53
|
+
|
|
54
|
+
async def __call__(self, request: httpx2.Request) -> None:
|
|
55
|
+
"""Rewrite a request under the configured base onto the resolved one."""
|
|
56
|
+
url = str(request.url)
|
|
57
|
+
if url != self._base and not url.startswith(f"{self._base}/"):
|
|
58
|
+
return
|
|
59
|
+
if self._resolved is None:
|
|
60
|
+
self._resolved = await resolve_base(self._base, verify=self._verify)
|
|
61
|
+
if self._resolved == self._base:
|
|
62
|
+
return
|
|
63
|
+
request.url = httpx2.URL(self._resolved + url[len(self._base) :])
|
|
64
|
+
request.headers["Host"] = request.url.netloc.decode()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def resolve_base(base_url: str, *, verify: bool) -> str:
|
|
68
|
+
"""Follow a base URL's redirects without a credential and answer where the base itself lands.
|
|
69
|
+
|
|
70
|
+
The walk stops before a redirect that goes deeper under the base it has reached, on the same
|
|
71
|
+
host: that one is the service sending an anonymous caller somewhere inside itself, such as
|
|
72
|
+
its sign-in page, not the base moving. A base that does not answer is kept as it is.
|
|
73
|
+
"""
|
|
74
|
+
base = base_url.rstrip("/")
|
|
75
|
+
try:
|
|
76
|
+
async with httpx2.AsyncClient(verify=verify, timeout=10.0, follow_redirects=True) as probe:
|
|
77
|
+
response = await probe.get(f"{base}/")
|
|
78
|
+
except httpx2.HTTPError:
|
|
79
|
+
return base
|
|
80
|
+
current = httpx2.URL(base)
|
|
81
|
+
for hop in [*(earlier.url for earlier in response.history[1:]), response.url]:
|
|
82
|
+
landed = httpx2.URL(str(hop).rstrip("/"))
|
|
83
|
+
if landed.host == current.host and landed.path.startswith(f"{current.path.rstrip('/')}/"):
|
|
84
|
+
break
|
|
85
|
+
current = landed
|
|
86
|
+
return str(current).rstrip("/")
|
|
@@ -60,6 +60,9 @@ class HttpConnectionConfig(BlockModel):
|
|
|
60
60
|
verify_tls: bool = True
|
|
61
61
|
"""Whether certificates are verified; turning this off is a per-connection decision."""
|
|
62
62
|
|
|
63
|
+
follow_redirects_with_auth: bool = False
|
|
64
|
+
"""Whether every request goes, credential and all, to where ``base_url`` redirects."""
|
|
65
|
+
|
|
63
66
|
timeout: Duration = Field(default=timedelta(seconds=30), gt=timedelta(0))
|
|
64
67
|
"""The timeout applied to every request through this connection."""
|
|
65
68
|
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
"""Turning a connection's configuration into the client that talks to it.
|
|
2
|
-
|
|
3
|
-
This lives beside the configuration rather than in the standard library of blocks, so an
|
|
4
|
-
adapter pack can use the shared HTTP connection without depending on that library.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import httpx2
|
|
8
|
-
|
|
9
|
-
from dirigent_common.schemas import HttpConnectionConfig
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def build_client(config: HttpConnectionConfig) -> httpx2.AsyncClient:
|
|
13
|
-
"""Build a client carrying a connection's base URL, auth, TLS setting, and timeout."""
|
|
14
|
-
headers: dict[str, str] = {}
|
|
15
|
-
if config.bearer_token is not None:
|
|
16
|
-
headers["Authorization"] = f"Bearer {config.bearer_token.get_secret_value()}"
|
|
17
|
-
auth: httpx2.Auth | None = None
|
|
18
|
-
if config.basic_username:
|
|
19
|
-
password = config.basic_password.get_secret_value() if config.basic_password else ""
|
|
20
|
-
auth = httpx2.BasicAuth(config.basic_username, password)
|
|
21
|
-
return httpx2.AsyncClient(
|
|
22
|
-
base_url=config.base_url,
|
|
23
|
-
headers=headers,
|
|
24
|
-
auth=auth,
|
|
25
|
-
verify=config.verify_tls,
|
|
26
|
-
timeout=config.timeout.total_seconds(),
|
|
27
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|