Metadata-Version: 2.4
Name: promcraft
Version: 0.5.0
Summary: Build Prometheus PromQL queries programmatically with composable, typed Python objects.
License-Expression: MIT
License-File: LICENSE
Keywords: prometheus,promql,query-builder,monitoring,grafana,observability
Author: Peter Babka
Author-email: 159peter951@gmail.com
Requires-Python: >=3.11,<4.0
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
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: Programming Language :: Python :: 3.15
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Monitoring
Classifier: Typing :: Typed
Project-URL: Changelog, https://github.com/xbabka01/promcraft/releases
Project-URL: Homepage, https://github.com/xbabka01/promcraft
Project-URL: Issues, https://github.com/xbabka01/promcraft/issues
Project-URL: Repository, https://github.com/xbabka01/promcraft
Description-Content-Type: text/markdown

# promcraft

[![pypi](https://img.shields.io/pypi/v/promcraft)](https://pypi.org/project/promcraft/)
[![python](https://img.shields.io/pypi/pyversions/promcraft.svg)](https://pypi.org/project/promcraft/)
[![Tests](https://github.com/xbabka01/promcraft/actions/workflows/python.yml/badge.svg)](https://github.com/xbabka01/promcraft/actions/workflows/python.yml)

A Python library for building [Prometheus QL](https://prometheus.io/docs/prometheus/latest/querying/basics/) queries programmatically. Instead of constructing raw query strings, use composable Python objects that serialize to valid PromQL syntax.

## Installation

```bash
pip install promcraft
# or with Poetry
poetry add promcraft
```

Requires Python 3.11+.

## Quick Start

```python
from promcraft import InstantVector, RangeVector, Label, Duration

# Simple metric selector
query = InstantVector("up", [])
str(query)  # "up{}"

# With label filters
query = InstantVector(
    "http_requests_total",
    [
        Label.eq("job", "api-server"),
        Label.eq("env", "production"),
    ],
)
str(query)  # 'http_requests_total{ job = "api-server", env = "production" }'

# Range vector with rate()
from promcraft import rate

query = rate(
    RangeVector(
        "http_requests_total",
        [
            Label.eq("job", "api-server"),
        ],
        Duration(m=5),
    )
)
str(query)  # 'rate(http_requests_total{ job = "api-server" }[5m])'

# Binary operation
left = InstantVector("http_requests_total", [Label.eq("status", "500")])
right = InstantVector("http_requests_total", [])
ratio = left / right
str(ratio)  # 'http_requests_total{ status = "500" } / http_requests_total{}'
```

### Raw values instead of wrapper types

Anywhere a `String` is expected (label values, `count_values`, label-manipulation function
arguments), you can pass a plain `str` instead — it's converted with `String.from_value()`
under the hood. Anywhere a `Scalar` is expected (`Float`-typed function/aggregation
parameters, `at`, binary-operator operands), you can pass a plain `float`/`int` instead,
converted with `Float.from_value()`. Range, resolution and `offset` values are converted with
`Duration.from_value()` instead, so `offset=300` renders as `offset 5m`. Passing an existing `String`/`Scalar` instance through is a no-op, so
both styles compose freely:

```python
Label.eq("job", "api-server")  # same as Label.eq("job", String("api-server"))
topk(5, v)  # same as topk(Float(5), v)
```

## API Reference

### Scalars

#### `Float`

Wraps a Python float for use in queries.

```python
from promcraft import Float

Float(3.14)  # "3.14"
Float(0.0)  # "0.0"
Float(-2.5)  # "-2.5"
Float(5)  # "5" -- an int stays an int

Float.from_value(3)  # Float(3) -- "3"
Float.from_value(3.0)  # Float(3.0) -- "3.0"
Float.from_value(Float(3.14))  # passthrough, still "3.14"
```

#### `Hex`

Represents a hexadecimal integer literal.

```python
from promcraft import Hex

Hex(255)  # "0xff"
Hex(0)  # "0x0"
Hex(16)  # "0x10"
```

`Hex.from_value()` accepts whole floats (`3.0` → `0x3`) but raises `ValueError` for a
fractional value such as `3.7` instead of truncating it.

#### `Duration`

Represents a Prometheus duration literal. All parameters are keyword-only.

```python
from promcraft import Duration

Duration()  # "0s"  (default when no units given)
Duration(s=5)  # "5s"
Duration(m=1, s=30)  # "1m30s"
Duration(h=1, m=30)  # "1h30m"
Duration(ms=500)  # "500ms"
Duration(y=1, w=2, d=3, h=4, m=5, s=6, ms=7)  # "1y2w3d4h5m6s7ms"
Duration(m=5, neg=True)  # "-5m"
```

Fields must be non-negative; use `neg=True` for a negative duration (`Duration(m=-5)` raises
`ValueError`).

| Parameter | Unit        |
|-----------|-------------|
| `y`       | years       |
| `w`       | weeks       |
| `d`       | days        |
| `h`       | hours       |
| `m`       | minutes     |
| `s`       | seconds     |
| `ms`      | milliseconds|
| `neg`     | negate the duration |

Every `Scalar` subclass (`Float`, `Hex`, `Duration`) also implements a `from_value()`
classmethod used throughout the library to accept raw numbers wherever a scalar is expected.
Passing an existing `Scalar` through `from_value()` returns it unchanged.

---

### `String`

Represents a PromQL string literal with configurable quoting.

```python
from promcraft import String

String("hello")  # '"hello"'   (double-quote default)
String("hello", '"')  # '"hello"'
String("hello", "'")  # "'hello'"
String("hello", "`")  # "`hello`"

String.from_value("hello")  # same as String("hello")
String.from_value(String("hello", "'"))  # passthrough, still "'hello'"
```

Special characters are escaped automatically for `"` and `'` quotes. Backtick strings are not escaped.

---

### `Variable`

Represents a [Grafana dashboard template variable](https://grafana.com/docs/grafana/latest/dashboards/variables/) (`$name`, or `${name}` when braces are needed). Grafana interpolates the variable's selected value before the query text reaches Prometheus, so a `Variable` is accepted anywhere a query fragment, scalar, or string is expected — metric names, label names/values, `offset`/`at`/range/resolution, binary-operator operands, `on`/`ignoring`/`group_left`/`group_right`/`by`/`without` label lists, and aggregation/function arguments.

```python
from promcraft import Variable

Variable("job")  # "$job"
Variable("job", braces=True)  # "${job}"
```

```python
from promcraft import InstantVector, Label, sum_, topk

# dynamic metric name and label value
InstantVector(Variable("metric_name"), [Label.eq("env", Variable("environment"))])
# '$metric_name{ env = $environment }'

# dynamic offset and range
InstantVector("up", [], offset=Variable("offset_var"))[Variable("range_var")]
# 'up{}[$range_var] offset $offset_var'

# dynamic grouping labels
sum_(InstantVector("requests", [])).by([Variable("groupby")])
# 'sum(requests{}) by ($groupby)'

# dynamic aggregation parameter
topk(Variable("k"), InstantVector("requests", []))
# 'topk($k, requests{})'
```

A `Variable` is never coerced into a literal — `Float.from_value()`, `Hex.from_value()`, `Duration.from_value()`, and `String.from_value()` all pass an existing `Variable` through unchanged, the same way they already do for `Scalar`/`String` instances.

---

### `Label`

Represents a label matcher used inside vector selectors. Use the factory methods for convenience. The value accepts either a raw `str` or a `String` instance.

```python
from promcraft import Label, String

Label.eq("job", "prometheus")  # 'job = "prometheus"'
Label.neq("env", "prod")  # 'env != "prod"'
Label.re("name", "prom.*")  # 'name =~ "prom.*"'
Label.nre("name", String("test.*"))  # 'name !~ "test.*"'
```

| Method        | Operator | Meaning              |
|---------------|----------|----------------------|
| `Label.eq`    | `=`      | Exact match          |
| `Label.neq`   | `!=`     | Not equal            |
| `Label.re`    | `=~`     | Regex match          |
| `Label.nre`   | `!~`     | Regex does not match |

---

### `InstantVector`

Selects a set of time series at a single point in time.

```python
InstantVector(metric, labels, *, offset=None, at=None)
```

```python
from promcraft import InstantVector, Label, String, Duration, Float

# Basic
InstantVector("up", [])
# "up{}"

# With labels
InstantVector(
    "http_requests_total",
    [
        Label.eq("job", "api"),
        Label.re("status", "5.."),
    ],
)
# 'http_requests_total{ job = "api", status =~ "5.." }'

# With offset
InstantVector("up", [], offset=Duration(m=5))
# "up{} offset 5m"

# With @ modifier (Unix timestamp)
InstantVector("up", [], at=1609746000.0)
# "up{} @ 1609746000.0"

# With @ modifier (range function references)
InstantVector("up", [], at="start()")
# "up{} @ start()"

InstantVector("up", [], at="end()")
# "up{} @ end()"
```

---

### `RangeVector`

Selects a range of samples over a time window. Required by functions like `rate()`, `increase()`, `avg_over_time()`.

```python
RangeVector(metric, labels, range, resolution=None, *, offset=None, at=None)
```

```python
from promcraft import RangeVector, Label, String, Duration

# Basic range
RangeVector("http_requests_total", [], Duration(m=5))
# "http_requests_total{}[5m]"

# With resolution (subquery step)
RangeVector("up", [], Duration(m=5), resolution=Duration(s=30))
# "up{}[5m:30s]"

# With offset
RangeVector("up", [], Duration(m=5), offset=Duration(m=1))
# "up{}[5m] offset 1m"

# Combined
RangeVector(
    "http_requests_total",
    [Label.eq("job", "api")],
    Duration(m=5),
    resolution=Duration(s=30),
    offset=Duration(m=1),
    at="end()",
)
# 'http_requests_total{ job = "api" }[5m:30s] offset 1m @ end()'
```

`InstantVector` also supports subscript syntax as a shortcut for building a `RangeVector` that reuses its metric, labels, offset and `@` modifier:

```python
InstantVector("up", [])[Duration(m=5)]  # equivalent to RangeVector("up", [], Duration(m=5))
InstantVector("up", [])[Duration(m=5), Duration(s=30)]  # range + resolution
```

### `Subquery`

A subquery evaluates any instant-vector expression over a range at a fixed resolution,
producing a range vector for functions like `max_over_time`. Build one with `.subquery()`
on any query, or construct `Subquery` directly. Leaving out `resolution` uses the global
evaluation interval (`[1h:]`). Binary operations and negations are
parenthesized automatically.

```python
from promcraft import Duration, InstantVector, Subquery, max_over_time, rate, sum_

requests = InstantVector("http_requests_total", [])
per_second = rate(requests[Duration(m=5)])

per_second.subquery(Duration(h=1), Duration(m=1))
# "rate(http_requests_total{}[5m])[1h:1m]"

max_over_time(per_second.subquery(Duration(h=1)))
# "max_over_time(rate(http_requests_total{}[5m])[1h:])"

sum_(per_second).by(["job"]).subquery(Duration(h=1), Duration(m=1), offset=Duration(d=1))
# "sum(rate(http_requests_total{}[5m])) by (job)[1h:1m] offset 1d"

Subquery(requests + requests, Duration(h=1), at="end()")
# "(http_requests_total{} + http_requests_total{})[1h:] @ end()"
```

Slice syntax is a shorthand for `.subquery(range, resolution)`:

```python
per_second[Duration(h=1) : Duration(m=1)]  # "rate(http_requests_total{}[5m])[1h:1m]"
per_second[3600:]  # "rate(http_requests_total{}[5m])[1h:]"
requests[Duration(h=1) :]  # "http_requests_total{}[1h:]"
```

On an `InstantVector`, a slice builds a `Subquery`, while plain and tuple indexing still
build a `RangeVector`. Slices take no `offset`/`@`; use `.subquery()` for those.

> **mypy users:** mypy rejects non-integer slice bounds (`Slice index must be an integer,
> SupportsIndex or None`), so slices with `Duration` or `Variable` bounds need
> `# type: ignore[misc]`. Integer bounds (`[3600:60]`) and pyright work as-is. Prefer
> `.subquery()` in mypy-checked code.

PromQL only allows `offset` and `@` on selectors and subqueries. To shift a function or
aggregation, wrap it in a subquery or put the modifier on the selector inside it.

---

### Binary operators

Combine two query expressions with an arithmetic, comparison, logical/set, or trigonometric operator. Each operator has a module-level helper function, and this is the primary way to build binary expressions:

```python
from promcraft import (
    add,
    sub,
    mul,
    div,
    mod,
    pow,
    eq,
    neq,
    lt,
    lte,
    gt,
    gte,
    and_,
    or_,
    unless,
    atan2,
    Float,
    InstantVector,
)

v1 = InstantVector("requests", [])
v2 = InstantVector("errors", [])

add(Float(1.0), Float(2.0))  # "1.0 + 2.0"
div(v2, v1)  # "errors{} / requests{}"
gt(v1, Float(0.0))  # "requests{} > 0.0"
and_(v1, v2)  # "requests{} and errors{}"
```

| Helper   | Symbol   | Category       |
|----------|----------|----------------|
| `add`    | `+`      | Arithmetic     |
| `sub`    | `-`      | Arithmetic     |
| `mul`    | `*`      | Arithmetic     |
| `div`    | `/`      | Arithmetic     |
| `mod`    | `%`      | Arithmetic     |
| `pow`    | `^`      | Arithmetic     |
| `eq`     | `==`     | Comparison     |
| `neq`    | `!=`     | Comparison     |
| `lt`     | `<`      | Comparison     |
| `lte`    | `<=`     | Comparison     |
| `gt`     | `>`      | Comparison     |
| `gte`    | `>=`     | Comparison     |
| `and_`   | `and`    | Logical/Set    |
| `or_`    | `or`     | Logical/Set    |
| `unless` | `unless` | Logical/Set    |
| `atan2`  | `atan2`  | Trigonometric  |

Python's own operators also work directly on any `Query` (`+ - * / % **`,
`== != < <= > >=`, `&` for `and`, `|` for `or`). Plain numbers are accepted on either side:

```python
requests + errors  # same as add(requests, errors)
errors / requests * 100  # "(errors{} / requests{}) * 100"
1 - errors / requests  # "1 - (errors{} / requests{})"
```

Because comparisons build PromQL expressions, `Query` objects have no truth value:
`bool(q)`, `if q:` and `q in some_list` raise `TypeError`. Comparing with anything that
isn't a query or a number (`q == "foo"`, `q != None`) raises `TypeError` too, rather than
silently returning `False`/`True`. Use `is None` / `is` instead.

#### Nested expressions

Nested binary expressions are automatically parenthesized to preserve evaluation order
(function calls and aggregations are self-delimiting and are left unwrapped):

```python
inner = add(Float(1.0), Float(2.0))
outer = mul(inner, Float(3.0))
str(outer)  # "(1.0 + 2.0) * 3.0"
```

#### `bool` modifier

Comparisons filter by default. `.bool()` makes them return `0`/`1` per element instead,
which PromQL requires when both sides are scalars. It is only valid on comparison operators:

```python
(v1 > 0).bool()  # "requests{} > bool 0"
(Float(1) > Float(2)).bool()  # "1 > bool 2"
gt(v1, v2).bool().on(["job"])  # "requests{} > bool on (job) errors{}"
```

#### Unary minus

```python
-rate(v1[Duration(m=5)])  # "-rate(requests{}[5m])"
-(v1 + v2)  # "-(requests{} + errors{})"
```

In PromQL `-2 ^ 2` means `-(2 ^ 2)`, because `^` binds tighter than unary minus. promcraft therefore parenthesises a negative base of `^`, whether it is a literal or a negation:

```python
Float(-2) ** 2  # "(-2) ^ 2"
(-v1) ** 2  # "(-requests{}) ^ 2"
```

#### Label matching

Use `.on()` or `.ignoring()` to control which labels are used for matching when combining two vector selectors:

```python
requests = InstantVector("http_requests_total", [])
errors = InstantVector("http_errors_total", [])

# Match on specific labels
expr = div(errors, requests).on(["job", "env"])
# 'http_errors_total{} / on (job, env) http_requests_total{}'

# Ignore specific labels
expr = div(errors, requests).ignoring(["instance"])
# 'http_errors_total{} / ignoring (instance) http_requests_total{}'
```

#### Grouping

Use `.group_left()` or `.group_right()` for many-to-one or one-to-many matching:

```python
# group_left: result has labels from the left side
expr = mul(requests, errors).on(["job"]).group_left(["env"])
# '... on (job) group_left (env) ...'

# group_right: result has labels from the right side
expr = mul(requests, errors).group_right([])
```

`.on()`, `.ignoring()`, `.group_left()`, and `.group_right()` are all chainable and each returns a new immutable instance, so they can be combined freely:

```python
mul(requests, errors).ignoring(["env"]).group_right(["env"])
```

---

### Aggregation operators

Apply a PromQL [aggregation operator](https://prometheus.io/docs/prometheus/latest/querying/operators/#aggregation-operators) to a vector via a module-level helper function. Helpers for operators that require a parameter (`topk`, `bottomk`, `quantile`, `limitk`, `limit_ratio`, `count_values`) take it as their first argument.

**No-parameter aggregations** — `sum_`, `avg`, `min_`, `max_`, `count`, `group`, `stddev`, `stdvar`:

> `sum`, `min`, and `max` are named `sum_`, `min_`, `max_` in this library so they don't shadow the Python builtins.

```python
from promcraft import sum_, avg, min_, max_, count, group, stddev, stdvar, InstantVector

v = InstantVector("http_requests_total", [])

sum_(v)  # "sum(http_requests_total{})"
avg(v).by(["job"])  # "avg(http_requests_total{}) by (job)"
stddev(v).without(["env"])
# "stddev(http_requests_total{}) without (env)"
```

Use `.by(labels)` to keep only the listed labels in the grouped result, or `.without(labels)` to drop the listed labels and keep everything else. Both are chainable and return a new instance.

`labels` must be non-empty — `.by([])`/`.without([])` raise `ValueError`. To aggregate away
all labels, omit `.by()`/`.without()` entirely rather than passing an empty list.

**Scalar-parameter aggregations** — `topk`, `bottomk`, `quantile`, `limitk`, `limit_ratio`. The parameter accepts a raw `float`/`int` or a `Float`:

```python
from promcraft import topk, bottomk, quantile, limitk, limit_ratio

topk(5, v)  # "topk(5, http_requests_total{})"
bottomk(3, v)  # "bottomk(3, http_requests_total{})"
quantile(0.95, v)  # "quantile(0.95, http_requests_total{})"
limitk(10, v)  # "limitk(10, http_requests_total{})"
limit_ratio(0.1, v)  # "limit_ratio(0.1, http_requests_total{})"
topk(5, v).by(["job"])
# "topk(5, http_requests_total{}) by (job)"
```

**String-parameter aggregation** — `count_values` (label name, accepts a raw `str` or a `String`):

```python
from promcraft import count_values

count_values("version", v)
# 'count_values("version", http_requests_total{})'
```

---

### Functions

Every [Prometheus query function](https://prometheus.io/docs/prometheus/latest/querying/functions/) has a typed module-level helper. Scalar and string arguments accept raw `float`/`int`/`str` values interchangeably with `Float`/`String`.

#### Rate / counter functions

```python
from promcraft import RangeVector, Duration, rate, irate, increase, delta, deriv, resets

rv = RangeVector("http_requests_total", [], Duration(m=5))

rate(rv)  # "rate(http_requests_total{}[5m])"
irate(rv)  # "irate(http_requests_total{}[5m])"
increase(rv)  # "increase(http_requests_total{}[5m])"
delta(rv)  # "delta(http_requests_total{}[5m])"
deriv(rv)  # "deriv(http_requests_total{}[5m])"
resets(rv)  # "resets(http_requests_total{}[5m])"
```

#### `*_over_time` aggregations

```python
from promcraft import (
    avg_over_time,
    min_over_time,
    max_over_time,
    sum_over_time,
    count_over_time,
    stddev_over_time,
    first_over_time,
    last_over_time,
    present_over_time,
    absent_over_time,
    quantile_over_time,
)

avg_over_time(rv)  # "avg_over_time(http_requests_total{}[5m])"
quantile_over_time(0.95, rv)  # "quantile_over_time(0.95, http_requests_total{}[5m])"
first_over_time(rv)  # "first_over_time(http_requests_total{}[5m])"
```

#### Math & rounding

```python
from promcraft import InstantVector, abs, ceil, floor, sqrt, round, clamp, clamp_min, clamp_max

v = InstantVector("cpu_usage", [])

abs(v)  # "abs(cpu_usage{})"
ceil(v)  # "ceil(cpu_usage{})"
sqrt(v)  # "sqrt(cpu_usage{})"
round(v)  # "round(cpu_usage{})"
round(v, 0.5)  # "round(cpu_usage{}, 0.5)"
clamp(v, 0.0, 1.0)  # "clamp(cpu_usage{}, 0.0, 1.0)"
clamp_min(v, 0.0)  # "clamp_min(cpu_usage{}, 0.0)"
clamp_max(v, 1.0)  # "clamp_max(cpu_usage{}, 1.0)"
```

#### Exponential & logarithm

```python
from promcraft import exp, ln, log2, log10

exp(v)  # "exp(cpu_usage{})"
ln(v)  # "ln(cpu_usage{})"
log2(v)  # "log2(cpu_usage{})"
log10(v)  # "log10(cpu_usage{})"
```

#### Trigonometry

```python
from promcraft import sin, cos, tan, asin, acos, atan, sinh, cosh, tanh, deg, rad, pi, sgn

sin(v)  # "sin(cpu_usage{})"
deg(v)  # "deg(cpu_usage{})"
rad(v)  # "rad(cpu_usage{})"
pi()  # "pi()"
sgn(v)  # "sgn(cpu_usage{})"
```

#### Date / time

All accept an optional instant vector; when omitted they default to `vector(time())` in PromQL.

```python
from promcraft import hour, minute, day_of_week, day_of_month, month, year, time

hour()  # "hour()"
hour(v)  # "hour(cpu_usage{})"
time()  # "time()"
```

#### Sorting

```python
from promcraft import sort, sort_desc, sort_by_label, sort_by_label_desc

sort(v)  # "sort(cpu_usage{})"
sort_desc(v)  # "sort_desc(cpu_usage{})"
sort_by_label(v, "job")  # 'sort_by_label(cpu_usage{}, "job")'
sort_by_label_desc(v, "job", "env")
# 'sort_by_label_desc(cpu_usage{}, "job", "env")'
```

#### Label manipulation

```python
from promcraft import label_join, label_replace

label_join(v, "addr", ":", "host", "port")
# 'label_join(cpu_usage{}, "addr", ":", "host", "port")'

label_replace(v, "job", "${1}", "job", "(.+)")
# 'label_replace(cpu_usage{}, "job", "${1}", "job", "(.+)")'
```

#### Type conversion

```python
from promcraft import scalar, vector

scalar(v)  # "scalar(cpu_usage{})"
vector(1.0)  # "vector(1.0)"
```

#### Histogram functions

```python
from promcraft import (
    histogram_quantile,
    histogram_fraction,
    histogram_count,
    histogram_sum,
    histogram_avg,
    histogram_stddev,
    histogram_stdvar,
)

histogram_quantile(0.95, v)  # "histogram_quantile(0.95, cpu_usage{})"
histogram_fraction(0.0, 1.0, v)  # "histogram_fraction(0.0, 1.0, cpu_usage{})"
histogram_count(v)  # "histogram_count(cpu_usage{})"
```

#### Prediction & smoothing

```python
from promcraft import predict_linear, double_exponential_smoothing

predict_linear(rv, 3600.0)
# "predict_linear(http_requests_total{}[5m], 3600.0)"

double_exponential_smoothing(rv, 0.1, 0.5)
# "double_exponential_smoothing(http_requests_total{}[5m], 0.1, 0.5)"
```

#### Absence detection

```python
from promcraft import absent, absent_over_time

absent(v)  # "absent(cpu_usage{})"
absent_over_time(rv)  # "absent_over_time(http_requests_total{}[5m])"
```

#### Low-level: `Function`

All of the helpers above build a `Function` instance internally. It isn't part of the top-level `promcraft` public API — import it from its submodule if you need to call a custom or newly-added Prometheus function that doesn't have a helper yet:

```python
from promcraft import Function
from promcraft import InstantVector, Float

Function("my_custom_func", [InstantVector("up", []), Float(42.0)])
# "my_custom_func(up{}, 42.0)"
```

---

## Composing Complex Queries

Every `Query` implements `to_string()` (which `__str__` delegates to), so you can compose arbitrarily deep expressions and print them with `str()`:

```python
from promcraft import (
    InstantVector,
    RangeVector,
    Label,
    Duration,
    rate,
    div,
)

job_label = Label.eq("job", "api")

# rate of 5xx errors divided by rate of total requests
error_rate = rate(
    RangeVector(
        "http_requests_total",
        [
            job_label,
            Label.re("status", "5.."),
        ],
        Duration(m=5),
    )
)

total_rate = rate(RangeVector("http_requests_total", [job_label], Duration(m=5)))

ratio = div(error_rate, total_rate).on(["job"])
str(ratio)
# 'rate(http_requests_total{ job = "api", status =~ "5.." }[5m])
#   / on (job) rate(http_requests_total{ job = "api" }[5m])'
```

### Pretty printing

Every `to_string()` accepts an optional `indent` argument to render multi-line, indented
output instead of the single-line compact form. Pass an `int` (spaces per level) or a
whitespace string (e.g. `"\t"`). A `bool` is rejected rather than treated as `1`/`0`:

```python
from promcraft import InstantVector, Duration, sum_

vec = InstantVector("metric", [])[Duration(m=1)]
expr = sum_(vec).by(["job", "env"])

print(expr.to_string(indent=4))
# sum(
#     metric{}[1m]
# ) by (job, env)
```

Omitting `indent` (or passing `None`) keeps the existing single-line output — `str(query)`
is unaffected either way.

## Development

```bash
# Install dependencies
poetry install

# Run tests (includes type checking and linting)
poetry run pytest

# Run only unit tests
poetry run pytest -o addopts=""
```

The test suite uses `pytest` with `pytest-mypy` for static type checking and `pytest-ruff` for linting. All checks run together by default.

