Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## v26.06.92 (2026-06-10)

### Added / Fixed (config server backends — parity initiative SP-12)

- **Git-backed config backend**: `GitConfigBackend` (new `pyfly[config-server-git]` extra, GitPython) clones a
config repo (works for local paths too), checks out a label/branch, and serves config from the working tree;
`save()` commits locally; all blocking git work runs off the event loop. Selectable via
`pyfly.config-server.backend.type=git` (+ `.git.uri`/`.git.label`), with a graceful filesystem fallback when
GitPython is absent.
- **Tiered search-locations overlay**: `FilesystemConfigBackend(root, search_locations=[...])` merges matching
config across multiple base directories (a common/core/domain convention — highest precedence first), wired
via `pyfly.config-server.backend.search-locations`. Single-root behavior is unchanged.
- **ConfigClient real e2e test**: `ConfigClient` now accepts an injectable `http_client`, enabling a true
round-trip test through `httpx.ASGITransport` → the Starlette config-server routes → backend, asserting the
reverse-merge precedence (app+profile overrides application+default; inherited keys survive).
- **Fixes**: wired the backend-selection logic into the auto-config bean (it was previously dead code, so
`backend.type`/`search-locations` were silently ignored); cleaned up the Git clone tempdir (atexit) and
guarded concurrent clone with a lock; corrected an over-promising docstring/doc that implied shipped
Consul/Vault backends. Docs updated.

## v26.06.91 (2026-06-10)

### Added (plugins parity — parity initiative SP-11)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.91-brightgreen" alt="Version: 26.06.91"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.92-brightgreen" alt="Version: 26.06.92"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
114 changes: 113 additions & 1 deletion docs/modules/config-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ client services over HTTP.
1. [Introduction](#introduction)
2. [ConfigSource](#configsource)
3. [Backends](#backends)
- [InMemoryConfigBackend](#inmemoryconfigbackend)
- [FilesystemConfigBackend](#filesystemconfigbackend)
- [Tiered search locations](#tiered-search-locations)
- [GitConfigBackend](#gitconfigbackend)
- [Custom backends](#custom-backends)
4. [ConfigServer](#configserver)
5. [ConfigClient](#configclient)

Expand Down Expand Up @@ -74,6 +79,12 @@ backend = InMemoryConfigBackend() # great for tests
backend = FilesystemConfigBackend("/etc/pyfly") # reads/writes files
```

### InMemoryConfigBackend

Dict-backed, thread-safe via `asyncio.Lock`. Ideal for unit tests.

### FilesystemConfigBackend

**`FilesystemConfigBackend`** stores each bundle as
`<root>/<label>/<application>-<profile>.{yaml,yml,json}` (falling back to
`<root>/<application>-<profile>.*` when the labeled file is absent). `fetch()`
Expand All @@ -86,9 +97,80 @@ When auto-configured, the backend `root` is **configurable and persistent** via
so saved config survives restarts. Only when neither is set does it fall back to a
throwaway tempdir (`config_server/auto_configuration.py`).

Implement `ConfigBackend` to back the server with a database, S3, Git, etc.:
### Tiered search locations

Pass `search_locations` (a list of directories, **highest-precedence first**) to
merge config from multiple layers. The convention is `[domain, core, common]` so
domain settings override core, which override common; keys present only in a
lower-precedence location are **inherited** (fill-in semantics).

```python
backend = FilesystemConfigBackend(
domain_dir,
search_locations=[domain_dir, core_dir, common_dir],
)
```

Configure via YAML (comma-separated or YAML list):

```yaml
pyfly:
config-server:
backend:
search-locations:
- /etc/pyfly/domain
- /etc/pyfly/core
- /etc/pyfly/common
```

`save()` and `list()` always operate on the **first** (primary / highest-
precedence) location.

### GitConfigBackend

A Git-backed backend that clones a repository and delegates file reads to
`FilesystemConfigBackend` over the working tree.

```python
from pyfly.config_server.adapters.git import GitConfigBackend

backend = GitConfigBackend(
"https://github.com/my-org/config-repo.git",
label="main", # branch / tag / SHA
clone_dir="/tmp/cfg", # optional; defaults to a tempdir
)
```

Requires GitPython: `pip install pyfly[config-server-git]`.

**Saving** writes the file into the working tree and commits it locally.
Pushing to the remote is **out of scope** — call `await backend.refresh()` to
pull the latest commits from `origin`.

Configure via YAML:

```yaml
pyfly:
config-server:
backend:
type: git
git:
uri: "https://github.com/my-org/config-repo.git"
label: main
clone-dir: /var/lib/pyfly/git-config # optional
```

When `backend.type=git` is set but GitPython is not installed, the server logs
a warning and falls back to `FilesystemConfigBackend`.

### Custom backends

Implement `ConfigBackend` to back the server with Consul, Vault, etcd,
a database, S3, or any other store:

```python
from pyfly.config_server.backend import ConfigBackend, ConfigSource

@runtime_checkable
class ConfigBackend(Protocol):
async def fetch(self, application: str, profile: str, label: str = "main") -> ConfigSource | None: ...
Expand Down Expand Up @@ -155,6 +237,7 @@ client = ConfigClient(
label="main", # optional, defaults to "main"
username=None, # optional HTTP basic auth
password=None,
http_client=None, # optional: inject an httpx.AsyncClient
)
properties = await client.fetch() # flattened {dotted_key: value} dict
```
Expand All @@ -164,6 +247,35 @@ properties = await client.fetch() # flattened {dotted_key: value} dict
in **reverse** order (Spring lists highest priority first) so the highest-priority
source wins. A non-200 response logs a warning and returns `{}`.

### Transport injection

Pass `http_client` (an `httpx.AsyncClient`) to reuse a connection pool or to
drive the client against an ASGI app in tests:

```python
import httpx
from starlette.applications import Starlette

from pyfly.config_server.adapters.starlette import make_starlette_config_server_routes

app = Starlette(routes=make_starlette_config_server_routes(server))

async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://config",
) as http_client:
client = ConfigClient(
url="http://config",
application="orders",
profile="prod",
http_client=http_client,
)
props = await client.fetch()
```

When `http_client` is injected the caller owns its lifecycle — `fetch()` does
**not** close it.

**Invoked automatically at startup.** You normally do not call `ConfigClient` directly:
`PyFlyApplication` invokes it during bootstrap when `pyfly.cloud.config.uri` (or
`pyfly.config.import`) is set, merging the result into the application `Config` as a
Expand Down
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
version = "26.6.91"
version = "26.6.92"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
Expand Down Expand Up @@ -88,6 +88,9 @@ cache = [
client = [
"httpx>=0.28.1",
]
config-server-git = [
"GitPython>=3.1",
]
grpc = [
"grpcio>=1.60.0",
]
Expand Down Expand Up @@ -150,7 +153,7 @@ web-fastapi = [
"uvloop>=0.22.1; sys_platform != 'win32'",
]
full = [
"pyfly[web,data-relational,data-document,postgresql,eda,cache,client,grpc,websocket,ecm-aws,ecm-azure,observability,security,scheduling,cli,shell,kafka,rabbitmq,redis,granian,fastapi,hypercorn,idp-azure,idp-keycloak,idp-cognito,notifications]",
"pyfly[web,data-relational,data-document,postgresql,eda,cache,client,grpc,websocket,ecm-aws,ecm-azure,observability,security,scheduling,cli,shell,kafka,rabbitmq,redis,granian,fastapi,hypercorn,idp-azure,idp-keycloak,idp-cognito,notifications,config-server-git]",
]

[dependency-groups]
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.91"
__version__ = "26.06.92"
25 changes: 23 additions & 2 deletions src/pyfly/config_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,19 @@
* **Client side** (``ConfigClient``): fetches config at startup, merges into
the application's :class:`pyfly.core.Config`.

The default backend is filesystem-based; subclass :class:`ConfigBackend` to
plug in Git, Consul, etcd, Vault, etc.
Two backends ship out of the box:

* :class:`~pyfly.config_server.backend.FilesystemConfigBackend` — reads/writes
YAML/JSON files under a configurable directory tree; supports tiered
search locations (e.g. ``[domain, core, common]``) with higher-precedence
locations overriding lower ones.
* :class:`~pyfly.config_server.adapters.git.GitConfigBackend` — clones a Git
repository and delegates file reads to ``FilesystemConfigBackend`` over the
working tree. Requires ``pip install pyfly[config-server-git]``.

To add Consul, Vault, etcd, or any other store, implement
:class:`~pyfly.config_server.backend.ConfigBackend` (a ``Protocol`` with
``fetch``, ``save``, and ``list`` methods).
"""

from __future__ import annotations
Expand All @@ -30,5 +41,15 @@
"ConfigServer",
"ConfigSource",
"FilesystemConfigBackend",
"GitConfigBackend",
"InMemoryConfigBackend",
]


def __getattr__(name: str) -> object:
if name == "GitConfigBackend":
from pyfly.config_server.adapters.git import GitConfigBackend # noqa: PLC0415

return GitConfigBackend
msg = f"module {__name__!r} has no attribute {name!r}"
raise AttributeError(msg)
Loading
Loading