Metadata-Version: 2.4
Name: runstamp
Version: 1.0.0
Summary: Build a process/service runtime stamp from a format template and pluggable value sources
Project-URL: Homepage, https://github.com/miriada-io/runstamp
Project-URL: Repository, https://github.com/miriada-io/runstamp
Project-URL: Issues, https://github.com/miriada-io/runstamp/issues
Project-URL: Changelog, https://github.com/miriada-io/runstamp/blob/master/CHANGELOG.md
Author-email: Miriada <info@miriada.io>
License-Expression: MIT
License-File: LICENSE
Keywords: build-info,id,instance,runstamp,runtime,service,stamp,template
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Provides-Extra: dev
Requires-Dist: coverage>=7.0; extra == 'dev'
Requires-Dist: pre-commit>=3.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# runstamp

[![PyPI](https://img.shields.io/pypi/v/runstamp.svg?label=PyPI)](https://pypi.org/project/runstamp/)
[![Python](https://img.shields.io/pypi/pyversions/runstamp.svg?label=Python)](https://pypi.org/project/runstamp/)
[![Tests](https://github.com/miriada-io/runstamp/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/miriada-io/runstamp/actions/workflows/tests.yml)
[![License](https://img.shields.io/pypi/l/runstamp.svg?label=License)](https://github.com/miriada-io/runstamp/blob/master/LICENSE)

Build a process/service runtime stamp from a `str.format` template and a handful of pluggable value sources.

## Installation

```bash
pip install runstamp
```

Requires Python 3.11+.

## Why runstamp?

Almost every long-running service wants a single string identifying the running instance — something to stamp into logs, metrics labels, and tracing spans:

```
service:acme/billing/api, build:1.42.7, host:node-07, user:svc-billing, run:2026-04-22T09:14:01+00:00
```

The pieces come from different places: some from the surrounding application, some from environment variables, some from a build-info module, some from `socket.gethostname()`. There is no stdlib way to assemble them — every project ends up hand-rolling the string with hidden indirections to each value.

`runstamp` describes that string as a format template plus one `Source` per placeholder. Sources are plain Python objects, the resolver is `async` — just a function, a dataclass, and a `Protocol`.

## Quick Start

```python
# my_app/build_info.py — committed as a stub, overwritten by CI before packaging.
# Recommended CI step (e.g. in your build job):
#     echo "build_id = \"$CI_BUILD_VERSION\"" > my_app/build_info.py
# Locally the stub is used as-is, so `build` resolves to "dev" via the source's default.
build_id = "dev"
```

```python
import asyncio
import socket
from datetime import datetime, timezone

from runstamp import (
    build_runstamp,
    RunstampConfig,
    KwargsSource,
    ImportSource,
    CallableSource,
)

config = RunstampConfig(
    template="service:{project}, build:{build}, host:{host}, run:{started_at}",
    sources={
        "project":    KwargsSource("ctx", attr="project_name"),
        "build":      ImportSource("my_app.build_info", attr="build_id", default="dev"),
        "host":       CallableSource(socket.gethostname),
        "started_at": CallableSource(
            lambda: datetime.now(timezone.utc).isoformat()
        ),
    },
)

app_ctx = type("Ctx", (), {"project_name": "billing"})()

stamp = asyncio.run(build_runstamp(config, ctx=app_ctx))
# In CI (after build_info.py was rewritten with the release version):
# 'service:billing, build:1.42.7, host:node-07, run:2026-04-22T09:14:01+00:00'
# Locally (build_info.py contains build_id = "dev"):
# 'service:billing, build:dev, host:my-laptop, run:2026-04-22T09:14:01+00:00'
```

## Overview

**Core:** [`build_runstamp`](#build_runstamp) | [`RunstampConfig`](#runstampconfig) | [`Source`](#source)

**Built-in sources:** [`ConstantSource`](#constantsource) | [`EnvVarSource`](#envvarsource) | [`KwargsSource`](#kwargssource) | [`ImportSource`](#importsource) | [`CallableSource`](#callablesource)

**Defaults:** [`default_sources`](#default_sources) | [`DEFAULT_TEMPLATE`](#default_template)

---

## Core

### `build_runstamp`

```python
async def build_runstamp(config: RunstampConfig, /, **context: Any) -> str
```

Renders `config.template` by calling `resolve(**context)` on each source. Every `{name}` in the template must have a matching entry in `config.sources`; otherwise `KeyError` is raised *before* any source runs. Extra unused sources are ignored — convenient when one source mapping is shared across several templates.

### `RunstampConfig`

Frozen dataclass pairing a template with its source mapping.

```python
from runstamp import RunstampConfig, EnvVarSource

config = RunstampConfig(
    template="{region}-{stage}",
    sources={
        "region": EnvVarSource("AWS_REGION"),
        "stage":  EnvVarSource("STAGE", default="dev"),
    },
)
```

### `Source`

`Protocol` describing the single method every source implements:

```python
class Source(Protocol):
    async def resolve(self, **context: Any) -> Any: ...
```

Write your own source whenever a built-in doesn't fit — for example, a source that fetches from a config server:

```python
from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class ConfigServerSource:
    key: str

    async def resolve(self, **context):
        return await my_config_client.get(self.key)
```

`resolve` is async so I/O-bound sources are natural; synchronous work is fine too — just `async def` and return.

## Built-in sources

Every built-in source accepts an optional `default=...` keyword. When set, any expected failure (missing env var, missing attribute, failed import, callable raising) returns the default instead of propagating. When not set, the failure raises.

### `ConstantSource`

```python
ConstantSource("prod")
```

Always returns the given value.

### `EnvVarSource`

```python
EnvVarSource("SHARD_ID")
EnvVarSource("REGION", default="us-east-1")
```

Reads an environment variable. Raises `KeyError` if missing and `default` was not supplied.

### `KwargsSource`

```python
KwargsSource("ctx")                           # -> context["ctx"]
KwargsSource("ctx", attr="project")           # -> context["ctx"].project
KwargsSource("ctx", attr="project.name")      # -> context["ctx"].project.name
KwargsSource("ctx", attr="missing", default=None)
```

Pulls a key from the `context` kwargs passed to `build_runstamp`, with optional dotted attribute access.

### `ImportSource`

```python
ImportSource("socket")                                  # -> the module
ImportSource("my_app.build_info", attr="build_id")      # -> attribute value
ImportSource("socket", attr="gethostname", call=True)   # -> gethostname()
ImportSource("missing.mod", attr="x", default=None)     # -> None on ImportError/AttributeError
```

Imports a module and optionally drills into a dotted attribute path. Set `call=True` to call the final attribute; if it returns a coroutine it is awaited.

### `CallableSource`

```python
CallableSource(socket.gethostname)
CallableSource(lambda: datetime.now(timezone.utc).isoformat())
CallableSource(fetch_current_tenant)  # async function also fine
```

Calls any zero-argument callable. Coroutines are awaited. Pass `default=...` to swallow a specific failure.

## Defaults

### `default_sources`

```python
from runstamp import default_sources, RunstampConfig, DEFAULT_TEMPLATE

config = RunstampConfig(DEFAULT_TEMPLATE, default_sources())
```

Returns a ready-made mapping:

| Placeholder   | Source                                                                        |
|---------------|-------------------------------------------------------------------------------|
| `company`     | `KwargsSource("context", attr="company")`                                     |
| `group`       | `KwargsSource("context", attr="project_group")`                               |
| `project`     | `KwargsSource("context", attr="project_name")`                                |
| `build`       | `ImportSource("build_info", attr="build_id", default=None)`                   |
| `environment` | `EnvVarSource("ENVIRONMENT", default=None)`                                   |
| `shard_id`    | `EnvVarSource("SHARD_ID", default=None)`                                      |
| `started_at`  | `CallableSource(lambda: datetime.now(UTC).astimezone().isoformat())`          |
| `host`        | `CallableSource(socket.gethostname)`                                          |
| `user`        | `CallableSource(getpass.getuser)`                                             |

Mix and match — override just the keys you need:

```python
sources = {**default_sources(), "project": KwargsSource("app", attr="name")}
```

### `DEFAULT_TEMPLATE`

```
service:{company}/{group}/{project}, built:{build}, host:{host}, user:{user}, run:{started_at}
```

## License

[MIT](https://github.com/miriada-io/runstamp/blob/master/LICENSE)
