Skip to main content

Greyhorse ElasticSearch library

Greyhorse framework library for Elasticsearch support (async only -- the underlying elasticsearch client is used through its [async] extra).

The primary API is the pieces, not a ready-made module:

Piece Role
ESAsyncFragment material -- builds the engine
ESAsyncBorder lifecycle -- start/stop plus a real liveness probe
ESAsyncClients access -- hands out a shared AsyncElasticsearch client
AsyncESEngine the resource itself
ESAsyncModule ready-made single-storage floor, sugar over the three pieces above

An application lists the ones it needs on its own Module, alongside pieces from any other storage library -- no subclassing, no multiple inheritance. See examples/.

How to build

  • Install the project

    uv python pin 3.14

    uv sync

    source .venv/bin/activate

  • Format and check code

    uv run ruff check --unsafe-fixes --fix

    uv run ruff format

    uv run mypy greyhorse_elasticsearch examples tests

  • Run tests

    uv run pytest

Usage

Every snippet below is a runnable program, checked against a live cluster. The longer, commented versions live in examples/ and are executed by the test suite, so they cannot rot silently.

One engine, one client

ESAsyncModule is the ready-made bundle for the single-storage case. The config reaches the engine's constructor through the same args={Type: value} door every greyhorse.strand resource uses -- there is no elasticsearch-specific wiring.

from greyhorse.run import run
from greyhorse.strand import running

from greyhorse_elasticsearch import EngineConf, ESAsyncModule, ESClientCtx


async def main() -> None:
    conf = EngineConf(dsn='http://elastic:elastic@localhost:9200/')

    with running(ESAsyncModule, args={EngineConf: conf}) as module:
        client_ctx = module.get(ESClientCtx).unwrap()
        async with client_ctx as client:
            info = await client.info()
            print(info['cluster_name'])


run(main)

The engine starts when the module starts and stops when it stops; client is an ordinary AsyncElasticsearch borrowed for the length of the async with block. running() stays a plain sync context manager even here -- it starts a module, it is not an I/O operation.

Why there is only one product, and why it is Shared

Redis and SQL siblings publish a second, Mut product (a pipeline, a transaction) whose apply() commits. Elasticsearch has no transaction: every request takes effect the moment the cluster sees it, so there is nothing for an apply()/cancel() pair to mean. The client is therefore Shared -- N consumers may hold it at once -- and the package deliberately does not invent a write window that would only pretend to be one.

Closing is still coordinated: the client is closed once, after the LAST borrow exits. A borrow opened while the engine is closing is refused rather than handed a client about to disappear.

A consumer that knows nothing about greyhorse

The point of the split: the class that talks to Elasticsearch takes a context by TYPE and imports nothing from this package. Only the component says how it is wired.

from typing import ClassVar

from greyhorse.strand import AsyncShared, Component, Use

from greyhorse_elasticsearch import AsyncESEngine, ESAsyncClients, ESAsyncModule, ESClientCtx


class PingApi:
    def __init__(self, client: ESClientCtx) -> None:
        self._client = client

    async def ping(self) -> bool:
        async with self._client as client:
            return bool(await client.ping())


class PingComponent(Component):
    imports: ClassVar = AsyncShared[AsyncESEngine]
    providers: ClassVar = ESAsyncClients
    exports: ClassVar = PingApi


class App(ESAsyncModule):
    name = 'ping-app'
    components: ClassVar = {'ping': Use(PingComponent)}

Subclassing ESAsyncModule fits exactly this shape: one cluster, one consumer, same floor. For two independent clusters -- or Elasticsearch next to a completely different storage -- list the pieces on your own Module instead:

from typing import ClassVar

from greyhorse.strand import Module, Produce, Resource

from greyhorse_elasticsearch import (
    AsyncESEngine,
    ESAsyncBorder,
    ESAsyncClients,
    ESAsyncFragment,
)


class App(Module):
    name = 'search-app'
    fragments: ClassVar = (ESAsyncFragment,)
    resources: ClassVar = (Resource(AsyncESEngine, operators=ESAsyncBorder),)
    produces: ClassVar = (Produce(AsyncESEngine, provider=ESAsyncClients, name='es'),)

examples/03_pieces.py runs that version in full, and explains why a real application composes this way rather than inheriting from several ready-made modules.

Health

.active and check_liveness() answer different questions, and confusing them is how a dead cluster reports itself healthy. .active is a start/stop reference count -- it says setup() was called. check_liveness() is the engine's own SAFE public health probe -- call this, from application code or anywhere else, not is_alive() (below).

engine = ...  # from the module's slot, or ESAsyncEngineFactory().create_engine(...)

engine.active  # True after start(), regardless of reachability
await engine.check_liveness()  # None before anything has borrowed the client,
                                # then True/False once it has
ESAsyncBorder().check_status(engine)  # same answer -- the border's opt-in
                                # tri-state surface is a thin synchronous
                                # bridge over this same method. The plain
                                # ESAsyncBorder().check(engine) stays a
                                # two-valued bool (True unless confirmed
                                # dead), the released Operator contract.

O-lazy-loop-ownership (core/.work/theory/loop-ownership.md, O32): the underlying elasticsearch client's async HTTP node binds its connection pool to whichever event loop makes the FIRST real request, and caches it there for the client's whole life. If your application has a threaded or streaming gateway -- a lifecycle loop separate from the loop that actually serves requests -- a health probe that ran on the wrong loop, before any real request had bound the client, would silently claim it for a loop the request path can never reach. check_liveness() closes that door: it only sends a real ping() once it can prove the calling loop already owns the client; before that (or from a genuinely different loop), it answers None ("no evidence yet") instead of guessing, or a signal fed by real request-path traffic once there is one.

is_alive() is the RAW probe underneath -- it always sends a real ping(), unconditionally, on whatever loop it happens to be awaited from, with NO ownership check of its own. Reach for it directly only when you already know you are on the loop the client is bound to (inside a session() borrow's own task, say); every general-purpose caller wants check_liveness() instead. Calling is_alive() itself never raises a FAILURE: a refused connection, a TLS failure, a timeout and any driver exception all come back as False. Cancellation is the one deliberate exception -- an external CancelledError propagates rather than being answered as an unhealthy cluster, because a caller who cancelled asked for nothing and must not be handed a verdict. examples/04_liveness.py shows the contrast against an endpoint that was never reachable, including the None verdict before anything has bound the client.

That whole half needs no Module and no wiring at all: ESAsyncEngineFactory().create_engine(...) plus ESAsyncBorder() is enough. examples/04_liveness.py runs exactly that shape, which is the one to copy into your own tests.

Configuration

EngineConf is what the engine is built from:

EngineConf(
    dsn='https://user:password@es.internal:9243/',
    api_key=None,
    request_timeout_seconds=15,
    max_retries=3,
    retry_on_timeout=True,
    verify_certs=True,
    ca_certs=None,
)

verify_certs and ca_certs are passed to the client only for an https DSN -- elastic-transport rejects TLS options on a plain-http node.

ElasticSearchSettings is the environment side. It reads ES_* (and .env), and assembles a DSN from the parts when ES_DSN is not given:

from greyhorse_elasticsearch import ElasticSearchSettings, EngineConf

settings = ElasticSearchSettings()  # ES_HOST, ES_PORT, ES_USER, ...
conf = EngineConf(dsn=settings.dsn)
variable default notes
ES_DSN assembled from the fields below set directly to skip assembly entirely
ES_SCHEME / ES_HOST / ES_PORT http / localhost / 9200 used only when ES_DSN is unset
ES_USER / ES_PASSWORD elastic / elastic percent-encoded into the assembled DSN
ES_PASSWORD_FILE unset read from disk, wins over ES_PASSWORD

ES_PASSWORD_FILE points at a Docker/Kubernetes secret file. A missing, unreadable or blank file is an error at config time, not a silent fallback to the inline password. A blank ES_PASSWORD_FILE means "not configured" rather than "read the current directory".

ElasticSearchSettings only assembles a DSN -- pass it into EngineConf yourself for the rest of the engine's tuning knobs.

Credentials. repr() and str() come out redacted -- host and user stay visible, the secret does not -- so a config that reaches a log, an f-string or a traceback does not leak. The same holds for a config that fails validation: ValidationError.errors() and .json(), which is what a JSON logger or an error reporter serializes, carry no credential either. model_dump() deliberately keeps it: that is what builds the client. Dumps are for machines; do not log one.

Tests

The live tests need a running Elasticsearch instance:

docker compose -f tests/docker-compose.yml up -d --wait
export ES_TEST_URI='http://localhost:9200/'
uv run pytest tests -q
docker compose -f tests/docker-compose.yml down -v

They are gated behind the ES_TEST_URI environment variable; without it they skip.

Metadata

Release files for greyhorse-elasticsearch 0.5.8

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for greyhorse-elasticsearch 0.5.8
File Size Uploaded
greyhorse_elasticsearch-0.5.8.tar.gz 141.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for greyhorse-elasticsearch 0.5.8
File Interpreter ABI Platform
greyhorse_elasticsearch-0.5.8-py3-none-any.whl Python 3 none any Details

Total release size: 180.7 kB

Release files / greyhorse_elasticsearch-0.5.8.tar.gz

Download URL greyhorse_elasticsearch-0.5.8.tar.gz
Size 141.3 kB
Tags Source
SHA-256 checksum
How to use checksums
86ee3ac17637490b384ee556a1dedf146af25add3905abe46e8a736fc8634a2a
BLAKE2b-256 checksum
How to use checksums
0b8b1bd8c784c81f593d1d7763cbc36a2bfdcb3153934707c1a36630fd792d42
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.0

Release files / greyhorse_elasticsearch-0.5.8-py3-none-any.whl

Download URL greyhorse_elasticsearch-0.5.8-py3-none-any.whl
Size 39.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ed7130b361d4a81eb49617167b77806ad5c9c67556e8570476a6534a8a0a2d28
BLAKE2b-256 checksum
How to use checksums
1659d6b3357bac70d503770dda83a8fb702c64f5ae208643a1d4e8ac0c3c8945
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.0

Release history Release notifications | RSS feed

This release

0.5.8 This release

2 release files

0.5.7

2 release files

0.5.6

2 release files

0.5.5

2 release files

0.2.1

2 release files

0.2

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page