papollo 0.1.0__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.
papollo/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .async_client import AsyncApollo
2
+ from .client import Apollo
3
+ from .exceptions import ApolloError
4
+
5
+ __all__ = ["Apollo", "ApolloError", "AsyncApollo"]
papollo/_core.py ADDED
@@ -0,0 +1,94 @@
1
+ """Sans-IO protocol logic shared by the sync and async clients."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import time
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from types import MappingProxyType
10
+ from urllib.parse import quote
11
+
12
+ import httpx
13
+
14
+ from .exceptions import ApolloError
15
+
16
+ DEFAULT_NAMESPACE = "application"
17
+
18
+ _PROPERTIES_SUFFIX = ".properties"
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Snapshot:
23
+ release_key: str
24
+ configurations: Mapping[str, str]
25
+
26
+
27
+ def normalize_namespace(name: str) -> str:
28
+ # Apollo treats "foo" and "foo.properties" as the same namespace.
29
+ if name.lower().endswith(_PROPERTIES_SUFFIX):
30
+ return name[: -len(_PROPERTIES_SUFFIX)]
31
+ return name
32
+
33
+
34
+ def sign(timestamp: str, path_with_query: str, secret: str) -> str:
35
+ message = f"{timestamp}\n{path_with_query}".encode()
36
+ digest = hmac.new(secret.encode(), message, hashlib.sha1).digest()
37
+ return base64.b64encode(digest).decode()
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class Settings:
42
+ server_url: str
43
+ app_id: str
44
+ cluster: str
45
+ secret: str | None
46
+ ip: str | None
47
+ label: str | None
48
+ timeout: float | None
49
+
50
+ def build_config_request(
51
+ self,
52
+ http: httpx.Client | httpx.AsyncClient,
53
+ namespace: str,
54
+ release_key: str | None,
55
+ ) -> httpx.Request:
56
+ path = "/".join(quote(part, safe="") for part in (self.app_id, self.cluster, namespace))
57
+ params = {
58
+ key: value
59
+ for key, value in (("releaseKey", release_key), ("ip", self.ip), ("label", self.label))
60
+ if value is not None
61
+ }
62
+ request = http.build_request(
63
+ "GET",
64
+ f"{self.server_url}/configs/{path}",
65
+ params=params,
66
+ timeout=self.timeout if self.timeout is not None else httpx.USE_CLIENT_DEFAULT,
67
+ )
68
+ if self.secret is not None:
69
+ # Sign exactly what goes on the wire so the server side check matches.
70
+ timestamp = str(int(time.time() * 1000))
71
+ signature = sign(timestamp, request.url.raw_path.decode("ascii"), self.secret)
72
+ request.headers["Authorization"] = f"Apollo {self.app_id}:{signature}"
73
+ request.headers["Timestamp"] = timestamp
74
+ return request
75
+
76
+
77
+ def parse_config_response(response: httpx.Response, namespace: str) -> Snapshot | None:
78
+ """Return the new snapshot, or None when the server answers 304 Not Modified."""
79
+ if response.status_code == 304:
80
+ return None
81
+ if response.status_code != 200:
82
+ raise ApolloError(
83
+ f"failed to fetch namespace {namespace!r}: HTTP {response.status_code}",
84
+ status_code=response.status_code,
85
+ )
86
+ try:
87
+ body = response.json()
88
+ release_key = body["releaseKey"]
89
+ configurations = dict(body["configurations"])
90
+ except (ValueError, KeyError, TypeError) as exc:
91
+ raise ApolloError(
92
+ f"invalid response for namespace {namespace!r}", status_code=response.status_code
93
+ ) from exc
94
+ return Snapshot(release_key, MappingProxyType(configurations))
@@ -0,0 +1,115 @@
1
+ import asyncio
2
+ import sys
3
+ from collections.abc import Mapping
4
+ from types import TracebackType
5
+ from typing import TypeVar, overload
6
+
7
+ import httpx
8
+
9
+ if sys.version_info >= (3, 11):
10
+ from typing import Self
11
+ else:
12
+ from typing_extensions import Self
13
+
14
+ from ._core import (
15
+ DEFAULT_NAMESPACE,
16
+ Settings,
17
+ Snapshot,
18
+ normalize_namespace,
19
+ parse_config_response,
20
+ )
21
+ from .exceptions import ApolloError
22
+
23
+ T = TypeVar("T")
24
+
25
+
26
+ class AsyncApollo:
27
+ """Asyncio Apollo config client, same API as ``Apollo``.
28
+
29
+ A client instance is bound to the event loop it is first used in.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ server_url: str,
35
+ app_id: str,
36
+ *,
37
+ cluster: str = "default",
38
+ secret: str | None = None,
39
+ ip: str | None = None,
40
+ label: str | None = None,
41
+ timeout: float | None = None,
42
+ http_client: httpx.AsyncClient | None = None,
43
+ ) -> None:
44
+ self._settings = Settings(
45
+ server_url.rstrip("/"), app_id, cluster, secret, ip, label, timeout
46
+ )
47
+ self._http = http_client if http_client is not None else httpx.AsyncClient()
48
+ self._owns_http = http_client is None
49
+ self._snapshots: dict[str, Snapshot] = {}
50
+ self._locks: dict[str, asyncio.Lock] = {}
51
+
52
+ @overload
53
+ async def get(self, key: str, *, namespace: str = DEFAULT_NAMESPACE) -> str | None: ...
54
+ @overload
55
+ async def get(self, key: str, default: T, *, namespace: str = DEFAULT_NAMESPACE) -> str | T: ...
56
+ async def get(
57
+ self, key: str, default: object = None, *, namespace: str = DEFAULT_NAMESPACE
58
+ ) -> object:
59
+ return (await self.namespace(namespace)).get(key, default)
60
+
61
+ async def namespace(self, name: str = DEFAULT_NAMESPACE) -> Mapping[str, str]:
62
+ name = normalize_namespace(name)
63
+ snapshot = self._snapshots.get(name)
64
+ if snapshot is None:
65
+ snapshot = await self._load(name, only_if_missing=True)
66
+ return snapshot.configurations
67
+
68
+ async def refresh(self, name: str | None = None) -> None:
69
+ """Refetch one namespace, or every loaded namespace when ``name`` is None.
70
+
71
+ A namespace that has not been loaded yet is loaded, so this can also be
72
+ used to preload namespaces at startup. On failure the cached config is
73
+ kept and the error is raised after all namespaces were tried.
74
+ """
75
+ names = [normalize_namespace(name)] if name is not None else list(self._snapshots)
76
+ results = await asyncio.gather(*(self._load(ns) for ns in names), return_exceptions=True)
77
+ for result in results:
78
+ if isinstance(result, BaseException):
79
+ raise result
80
+
81
+ async def aclose(self) -> None:
82
+ if self._owns_http:
83
+ await self._http.aclose()
84
+
85
+ async def __aenter__(self) -> Self:
86
+ return self
87
+
88
+ async def __aexit__(
89
+ self,
90
+ exc_type: type[BaseException] | None,
91
+ exc: BaseException | None,
92
+ tb: TracebackType | None,
93
+ ) -> None:
94
+ await self.aclose()
95
+
96
+ async def _load(self, name: str, *, only_if_missing: bool = False) -> Snapshot:
97
+ async with self._locks.setdefault(name, asyncio.Lock()):
98
+ current = self._snapshots.get(name)
99
+ if only_if_missing and current is not None:
100
+ return current
101
+ release_key = current.release_key if current is not None else None
102
+ try:
103
+ request = self._settings.build_config_request(self._http, name, release_key)
104
+ response = await self._http.send(request)
105
+ except (httpx.HTTPError, httpx.InvalidURL) as exc:
106
+ raise ApolloError(f"failed to fetch namespace {name!r}: {exc}") from exc
107
+ snapshot = parse_config_response(response, name)
108
+ if snapshot is None:
109
+ if current is None:
110
+ raise ApolloError(
111
+ f"unexpected 304 for unloaded namespace {name!r}", status_code=304
112
+ )
113
+ return current
114
+ self._snapshots[name] = snapshot
115
+ return snapshot
papollo/client.py ADDED
@@ -0,0 +1,120 @@
1
+ import sys
2
+ import threading
3
+ from collections.abc import Mapping
4
+ from types import TracebackType
5
+ from typing import TypeVar, overload
6
+
7
+ import httpx
8
+
9
+ if sys.version_info >= (3, 11):
10
+ from typing import Self
11
+ else:
12
+ from typing_extensions import Self
13
+
14
+ from ._core import (
15
+ DEFAULT_NAMESPACE,
16
+ Settings,
17
+ Snapshot,
18
+ normalize_namespace,
19
+ parse_config_response,
20
+ )
21
+ from .exceptions import ApolloError
22
+
23
+ T = TypeVar("T")
24
+
25
+
26
+ class Apollo:
27
+ """Blocking Apollo config client.
28
+
29
+ Namespaces are fetched on first access and then served from memory.
30
+ Call ``refresh()`` to pull the latest release.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ server_url: str,
36
+ app_id: str,
37
+ *,
38
+ cluster: str = "default",
39
+ secret: str | None = None,
40
+ ip: str | None = None,
41
+ label: str | None = None,
42
+ timeout: float | None = None,
43
+ http_client: httpx.Client | None = None,
44
+ ) -> None:
45
+ self._settings = Settings(
46
+ server_url.rstrip("/"), app_id, cluster, secret, ip, label, timeout
47
+ )
48
+ self._http = http_client if http_client is not None else httpx.Client()
49
+ self._owns_http = http_client is None
50
+ self._snapshots: dict[str, Snapshot] = {}
51
+ self._locks: dict[str, threading.Lock] = {}
52
+
53
+ @overload
54
+ def get(self, key: str, *, namespace: str = DEFAULT_NAMESPACE) -> str | None: ...
55
+ @overload
56
+ def get(self, key: str, default: T, *, namespace: str = DEFAULT_NAMESPACE) -> str | T: ...
57
+ def get(
58
+ self, key: str, default: object = None, *, namespace: str = DEFAULT_NAMESPACE
59
+ ) -> object:
60
+ return self.namespace(namespace).get(key, default)
61
+
62
+ def namespace(self, name: str = DEFAULT_NAMESPACE) -> Mapping[str, str]:
63
+ name = normalize_namespace(name)
64
+ snapshot = self._snapshots.get(name)
65
+ if snapshot is None:
66
+ snapshot = self._load(name, only_if_missing=True)
67
+ return snapshot.configurations
68
+
69
+ def refresh(self, name: str | None = None) -> None:
70
+ """Refetch one namespace, or every loaded namespace when ``name`` is None.
71
+
72
+ A namespace that has not been loaded yet is loaded, so this can also be
73
+ used to preload namespaces at startup. On failure the cached config is
74
+ kept and the error is raised after all namespaces were tried.
75
+ """
76
+ names = [normalize_namespace(name)] if name is not None else list(self._snapshots)
77
+ error: Exception | None = None
78
+ for ns in names:
79
+ try:
80
+ self._load(ns)
81
+ except Exception as exc: # noqa: BLE001 raised below after all were tried
82
+ error = error or exc
83
+ if error is not None:
84
+ raise error
85
+
86
+ def close(self) -> None:
87
+ if self._owns_http:
88
+ self._http.close()
89
+
90
+ def __enter__(self) -> Self:
91
+ return self
92
+
93
+ def __exit__(
94
+ self,
95
+ exc_type: type[BaseException] | None,
96
+ exc: BaseException | None,
97
+ tb: TracebackType | None,
98
+ ) -> None:
99
+ self.close()
100
+
101
+ def _load(self, name: str, *, only_if_missing: bool = False) -> Snapshot:
102
+ with self._locks.setdefault(name, threading.Lock()):
103
+ current = self._snapshots.get(name)
104
+ if only_if_missing and current is not None:
105
+ return current
106
+ release_key = current.release_key if current is not None else None
107
+ try:
108
+ request = self._settings.build_config_request(self._http, name, release_key)
109
+ response = self._http.send(request)
110
+ except (httpx.HTTPError, httpx.InvalidURL) as exc:
111
+ raise ApolloError(f"failed to fetch namespace {name!r}: {exc}") from exc
112
+ snapshot = parse_config_response(response, name)
113
+ if snapshot is None:
114
+ if current is None:
115
+ raise ApolloError(
116
+ f"unexpected 304 for unloaded namespace {name!r}", status_code=304
117
+ )
118
+ return current
119
+ self._snapshots[name] = snapshot
120
+ return snapshot
papollo/exceptions.py ADDED
@@ -0,0 +1,6 @@
1
+ class ApolloError(Exception):
2
+ """Raised when config can not be fetched from Apollo."""
3
+
4
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
5
+ super().__init__(message)
6
+ self.status_code = status_code
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: papollo
3
+ Version: 0.1.0
4
+ Summary: Python client for Apollo config center, with sync and async support
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Dist: httpx>=0.28
8
+ Requires-Dist: typing-extensions>=4.0 ; python_full_version < '3.11'
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # papollo
13
+
14
+ Python client for [Apollo](https://github.com/apolloconfig/apollo) config center, with sync and async support.
15
+
16
+ ```python
17
+ from papollo import Apollo
18
+
19
+ client = Apollo("http://apollo-config:8080", "demo-app")
20
+
21
+ client.get("timeout") # "30", or None if missing
22
+ client.get("timeout", "10") # with default
23
+ client.get("db.url", namespace="database")
24
+ client.namespace("app.json")["content"] # non-properties namespace
25
+ client.refresh() # pull latest releases
26
+ ```
27
+
28
+ ```python
29
+ from papollo import AsyncApollo
30
+
31
+ client = AsyncApollo("http://apollo-config:8080", "demo-app")
32
+
33
+ await client.get("timeout")
34
+ await client.refresh()
35
+ ```
36
+
37
+ A client is meant to live as long as the process, usually as a module level object. It holds an
38
+ httpx connection pool, so call `close()` (or `await client.aclose()`) if you create short lived
39
+ clients. Both also work as context managers. An `AsyncApollo` is bound to the event loop it
40
+ is first used in.
41
+
42
+ Namespaces are fetched on first access and then served from memory. `refresh()` refetches every
43
+ loaded namespace, and `refresh(name)` also loads a namespace that was not loaded yet, which is handy
44
+ for failing fast at startup. Failures raise `ApolloError` and keep the previously cached config.
45
+
46
+ Other options: `cluster`, `secret` (access key), `ip` and `label` (gray release), `timeout`, and
47
+ `http_client` to bring your own `httpx.Client` / `httpx.AsyncClient`. When `timeout` is not set, the
48
+ httpx client's own timeout is used. `ip` is not detected automatically, so IP based gray release
49
+ only works when it is set.
50
+
51
+ ## Testing
52
+
53
+ Most tests run against a real Apollo at `http://localhost:8080`, the rest cover pure functions and
54
+ failures a real server can not produce on demand. The easiest server is the
55
+ [quick start](https://github.com/apolloconfig/apollo-quick-start) all in one jar with an in memory
56
+ H2 database:
57
+
58
+ ```sh
59
+ SPRING_PROFILES_ACTIVE=github,database-discovery,auth SPRING_PROFILES_GROUP_GITHUB=h2 \
60
+ LOGGING_FILE_NAME=/tmp/apollo.log java -jar apollo-all-in-one.jar
61
+
62
+ uv run pytest
63
+ ```
64
+
65
+ Tests that need Apollo are skipped when it is not reachable. Set `PAPOLLO_REQUIRE_APOLLO=1` to make
66
+ them fail instead, which is what CI should do. The portal at `http://localhost:8070` is used to
67
+ create apps and publish releases. Use `APOLLO_CONFIG_URL`, `APOLLO_PORTAL_URL`,
68
+ `APOLLO_PORTAL_USER`, `APOLLO_PORTAL_PASSWORD` and `APOLLO_ENV` to point elsewhere.
@@ -0,0 +1,9 @@
1
+ papollo/__init__.py,sha256=7jVHK-L7pmmKjAcEX1rm_rrI175RcPHp0-BAvrELxBg,153
2
+ papollo/_core.py,sha256=34wJuOitjSTiBQdssMl7Wq9hn5ufBzrnSNJFrjmn3gY,3104
3
+ papollo/async_client.py,sha256=BRC6tkvr2Xxni4DQ-zfpG5DNWxODgeIdNseK5vELLCI,4116
4
+ papollo/client.py,sha256=j5jAc1Ix_RN4qyNRcQ06CRM1O9MWAdILoOuEnB6Sw5g,4122
5
+ papollo/exceptions.py,sha256=ODLw1ABRW0RefbGHxHLWnutO-EAT51vfUgge1umSnJg,246
6
+ papollo-0.1.0.dist-info/licenses/LICENSE,sha256=5LtonWQJ4JYxkh2u5scD3oN1kfZ1yGrPG6bTZMvwA_s,1074
7
+ papollo-0.1.0.dist-info/WHEEL,sha256=V5-3dKee3Zs8C4JP6swr6zdqriLsOpItBEQxe6_oWpY,81
8
+ papollo-0.1.0.dist-info/METADATA,sha256=YL0DfhvyGkcymZgKF2Rvujg6UqwTivCZUHKevunxyng,2805
9
+ papollo-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.18
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 AN Long
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.