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.
Files changed (24) hide show
  1. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/PKG-INFO +1 -1
  2. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/pyproject.toml +1 -1
  3. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/pyproject.toml.orig +1 -1
  4. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/__init__.py +2 -1
  5. dirigent_common-0.25.2/src/dirigent_common/clients.py +86 -0
  6. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/schemas.py +3 -0
  7. dirigent_common-0.25.0/src/dirigent_common/clients.py +0 -27
  8. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/LICENSE +0 -0
  9. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/README.md +0 -0
  10. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/docstrings.py +0 -0
  11. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/durations.py +0 -0
  12. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/formats.py +0 -0
  13. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/formatters.py +0 -0
  14. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/messages.py +0 -0
  15. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/names.py +0 -0
  16. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/programs.py +0 -0
  17. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/py.typed +0 -0
  18. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/requests.py +0 -0
  19. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/sizes.py +0 -0
  20. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/templating.py +0 -0
  21. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/types.py +0 -0
  22. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/uris.py +0 -0
  23. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/values.py +0 -0
  24. {dirigent_common-0.25.0 → dirigent_common-0.25.2}/src/dirigent_common/versions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-common
3
- Version: 0.25.0
3
+ Version: 0.25.2
4
4
  Summary: Value types and shared schemas every dirigent package may depend on.
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-common"
3
- version = "0.25.0"
3
+ version = "0.25.2"
4
4
  description = "Value types and shared schemas every dirigent package may depend on."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-common"
3
- version = "0.25.0"
3
+ version = "0.25.2"
4
4
  description = "Value types and shared schemas every dirigent package may depend on."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -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
- )