Metadata-Version: 2.4
Name: karve
Version: 0.1.0
Summary: Config-driven AI pipelines: logic in Python components, wiring in YAML specs.
Project-URL: Homepage, https://github.com/compiledwithproblems/karve
Project-URL: Repository, https://github.com/compiledwithproblems/karve
Project-URL: Issues, https://github.com/compiledwithproblems/karve/issues
Author: Brandon Tate
License: MIT
License-File: LICENSE
Keywords: ai,config,llm,pipeline,yaml
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: pydantic>=2
Requires-Dist: pyyaml>=6
Provides-Extra: all
Requires-Dist: fastapi; extra == 'all'
Requires-Dist: httpx; extra == 'all'
Requires-Dist: jmespath; extra == 'all'
Provides-Extra: dev
Requires-Dist: httpx; extra == 'dev'
Requires-Dist: mypy; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: pytest-asyncio; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Requires-Dist: types-pyyaml; extra == 'dev'
Provides-Extra: fastapi
Requires-Dist: fastapi; extra == 'fastapi'
Provides-Extra: http
Requires-Dist: httpx; extra == 'http'
Provides-Extra: transform
Requires-Dist: jmespath; extra == 'transform'
Description-Content-Type: text/markdown

# karve

> From the Old Norse *karvi* — a small, light, versatile longship built for
> quick coastal trips rather than grand voyages.

A lightweight, importable Python library for building AI-powered apps where
**logic lives in Python components** and **wiring lives in YAML config** —
swap prompts, models, personalities, or entire pipelines by editing a spec
file, never code.

```
pip install karve            # core: pyyaml + pydantic only
pip install 'karve[all]'     # + httpx, jmespath, fastapi extras
```

## Quickstart

`spec.yaml`:

```yaml
pipeline:
  - type: template
    template: "{body}, adventurer"
  - type: shout
    excitement: 3
```

`app.py`:

```python
import karve

@karve.step("shout")
class Shout(karve.BaseStep):
    class Config(karve.BaseStep.Config):
        excitement: int = 1

    async def run(self, msg):
        return msg.with_body(str(msg.body).upper() + "!" * self.config.excitement)

pipe = karve.build_pipeline(karve.load_spec("spec.yaml"))
print(pipe.run_sync("hello there").body)
# HELLO THERE, ADVENTURER!!!
```

That's the whole model: a `Message` (`body` + `meta`) flows through an
ordered list of steps. Steps are Python classes registered by name; the YAML
spec says which steps run, in what order, with what config. Config typos
fail at build time with the step index and field name, not at request time.

## Spec format

```yaml
resources:                      # optional; arbitrary named config data
  personality: !include personalities/grumpy_dwarf.yaml
  prompts: !include prompts/dark_fantasy.yaml

endpoints:                      # optional; conventionally where model endpoints live
  narrator:
    url: https://api.example.com/v1/generate
    headers: { Authorization: "Bearer ${env:NARRATOR_KEY}" }

pipeline:                       # required; ordered list of steps
  - type: template
    template: ${resources.prompts.encounter}
  - type: http-call
    endpoint: ${endpoints.narrator}
    body_map: { prompt: "$.body" }
    response_map: "$.choices[0].text"
```

- **`!include path.yaml`** splices another file in place (relative to the
  including file). Include cycles are detected and reported with the chain.
- **`${dotted.path}`** references any value in the merged spec. A reference
  that is the whole string splices structured data; embedded references
  stringify scalars. `${env:NAME}` reads environment variables — secrets
  never live in YAML. References only: no expressions, no conditionals.
- **`load_spec(path, overrides={...})`** deep-merges overrides over the spec
  (dicts merge recursively, lists and scalars replace). Overrides always win.
- **Multiple pipelines:** use a top-level `pipelines:` dict of name → step
  list and select with `build_pipeline(spec, name="...")`. A singular
  `pipeline:` key is the default.

## Built-in steps

Exactly six ship with karve. Everything else is userland via `@karve.step`.

| type | Purpose | Config fields |
|---|---|---|
| `template` | Render a prompt with `str.format`-style vars from the message | `template` (vars from `msg.body` if dict, plus `{body}` / `{meta[...]}`) |
| `http-call` | Generic model/API invocation — LLMs, image gen, plain APIs | `endpoint: {url, method?, headers?, timeout?}`, `body_map`, `response_map`, `raw` |
| `transform` | Data wrangling between steps | `expr` (JMESPath) **or** `fn` (a `@transform_fn("name")` function) — exactly one |
| `router` | Choose a sub-pipeline by key | `key` (dotted path, e.g. `body.category`), `routes`, `default` (sub-pipeline \| `pass` \| `error`) |
| `map` | Fan a sub-pipeline out over each item of a list body | `steps`, `concurrency` (default 5) |
| `retry` | Wrap a sub-pipeline with retries | `steps`, `attempts` (3), `backoff` (1.0, exponential), `retry_on` (exception names, `["*"]`) |

Notes:

- `http-call` mapping: `body_map` values and `response_map` are JMESPath
  expressions with a `$.` prefix, evaluated against the message
  (`{"body": ..., "meta": ...}`) or response JSON. `$.body` / `$.meta` work
  without jmespath installed; deeper expressions need `karve[transform]`.
  Expressions are evaluated recursively inside nested dicts/lists, so
  chat-style payloads like `messages: [{role: user, content: "$.body"}]`
  work directly. Values without the prefix are literals.
- Sub-pipelines are just nested step lists — the builder recursion applies
  everywhere, so `router` routes and `map`/`retry` bodies are real pipelines.
- All step execution is async; `pipeline.run_sync(...)` wraps `asyncio.run`
  for scripts and tests.

## FastAPI recipe

```python
from contextlib import asynccontextmanager
from fastapi import FastAPI
from karve import load_spec, build_pipeline
from karve.integrations.fastapi import pipeline_router

@asynccontextmanager
async def lifespan(app):
    spec = load_spec("specs/app.yaml")
    app.state.narrate = build_pipeline(spec, name="narrate")
    app.include_router(pipeline_router(app.state.narrate, "/narrate"))
    yield

app = FastAPI(lifespan=lifespan)
```

`pipeline_router` maps request JSON → `Message(body=json)` → `pipeline.run`
→ `{"body": ..., "meta": ...}`. Building in `lifespan` means every spec
error surfaces at startup, before a single request is served.

See [examples/digest](https://github.com/compiledwithproblems/karve/tree/main/examples/digest)
for a complete app: it fetches open GitHub issues,
classifies each with a cheap model (`map` + `retry`), and has a stronger
model write an executive summary — model tiers, prompts, and the data
source are all swappable YAML fragments.

## Philosophy

1. **Logic in Python, wiring in YAML.** No conditionals, loops, or
   variables invented in YAML syntax. If behavior needs branching, it is a
   Python component that takes routes/options as data. We are not building
   a programming language inside YAML.
2. **Provider-agnostic.** No Anthropic/OpenAI/Stability-specific code in
   core. The generic `http-call` step covers LLMs, image gen, video gen,
   and self-hosted models identically; provider sugar belongs in userland
   steps.
3. **Stateless pipelines.** The library runs a message through steps and
   returns. Sessions, history, persistence, resume — all owned by the host
   app, never the library.

Out of scope, permanently: **no YAML logic syntax, no scheduling, no
persistence/resume, no agent loops, no UI, no provider SDKs in core.**

## Security

Specs are code-adjacent: they choose which registered steps run and where
HTTP calls go. **Only load specs you trust**, exactly as you would only
import modules you trust. karve parses YAML exclusively through
`yaml.SafeLoader` (never `yaml.load`), so specs cannot construct arbitrary
Python objects — and the test suite greps the codebase to keep it that way.
Keep secrets out of specs; use `${env:...}`.
