Metadata-Version: 2.4
Name: promptflip
Version: 0.2.0
Summary: PromptFlip — the SDK that fetches prompts without your code throwing, plus the `pf` local-first authoring CLI.
Author: Tursdev
License-Expression: MIT
Project-URL: Homepage, https://promptflip.dev
Project-URL: Repository, https://github.com/tursdev-org/promptflip
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27
Provides-Extra: test
Requires-Dist: pytest>=8; extra == "test"
Dynamic: license-file

# PromptFlip — Python SDK + CLI

The `promptflip` package is two things in one install:

- the **SDK** — fetch managed prompts and report run telemetry, without your
  code ever crashing if the service is unreachable;
- the **`pf` CLI** — a local-first, git-shaped tool for *authoring* prompts as
  files, that works fully offline and syncs to the cloud when you log in.

## Install

```bash
pip install promptflip      # `from promptflip import PromptFlip` + the `pf` command
```

Requires Python ≥3.10. One dependency: `httpx`.

## Quickstart

```python
from promptflip import PromptFlip

pm = PromptFlip(api_key="sk_...", environment="prod")

# Fetch a prompt. Always returns a usable Prompt — never throws.
prompt = pm.get(
    "support-agent-system",
    fallback="You are a helpful support agent for [[plan]]-plan users.",
    inputs={"plan": user.plan},
    assignment_key=user.id,           # sticky A/B variant
)

# Use it with your LLM, report telemetry in one block:
with prompt.run() as r:
    response = openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "system", "content": prompt.text},
                  {"role": "user", "content": user_msg}],
    )
    r.tokens = response.usage.total_tokens
    r.finish_reason = response.choices[0].finish_reason
    r.converted = True                # any attribute name → custom metric
# auto-sends tokens, finish_reason, latency_ms, converted

# Or report metrics directly (inline non-LLM, or from a worker later):
pm.send(prompt, csat_score=5)
pm.send(saved_prompt_id, converted=True, revenue_usd=49)
```

## The three primitives

That's the entire surface a caller writes against:

```
pm.get(key, fallback=None, *, inputs=None, assignment_key=None, version=None) -> Prompt
pm.send(prompt_or_id, **metrics)
with prompt.run() as r: ...
```

## Hybrid resolution (cloud + local-first)

`get` resolves in order — **cloud** (when an `api_key` is set and reachable) →
**local repo** (when `local_dir` is set) → **fallback** string → empty. Every
`Prompt` reports where it came from via `prompt.source`
(`cloud` / `local` / `fallback` / `none`).

When both `local_dir` and `cache_dir` are set, a cached cloud value and the
local file are reconciled by **recency**: if you've edited the local file more
recently than the cached cloud content last changed, the local copy wins (a
background revalidation that finds no change does *not* count as "newer"). A
live cloud fetch always wins when the service is reachable.

```python
# Cloud-first with the local repo as a free/offline fallback:
pm = PromptFlip(api_key="sk_...", local_dir=".promptflip")

# Local-only — no key, no network. Resolves from the same files `pf` manages,
# rendering {{vars}} from the process environment and [[inputs]] from `inputs`:
pm = PromptFlip(local_dir=".promptflip")

# Optional cross-process cache + version pin:
pm = PromptFlip(api_key="sk_...", cache_dir="~/.cache/promptflip", cache_ttl=300)
prompt = pm.get("support-agent-system", version=42)   # pin a specific version
```

## Guarantees

- **Never throws — including construction.** `get` always returns a `Prompt`;
  `send` always returns. A missing `api_key` is not an error — it selects
  local-only mode.
- **Offline fallback.** When the service is unreachable, `get` serves the
  last-good cached entry (in-memory or, with `cache_dir`, on disk), then the
  local repo, then your `fallback` template (with `[[inputs]]` filled
  client-side so the end-user sees the same string online or offline).
- **Optional disk cache.** Set `cache_dir` to persist cloud fetches across
  processes with a per-entry TTL, stale-while-revalidate, and ETag revalidation.
- **No private code.** The SDK only talks to the public `/v1` HTTP contract.
  An import-ban test enforces zero references to private product code, and the
  CLI never loads on `import promptflip`.
- **One runtime dependency:** `httpx`.

## Error handling

The SDK never throws on the serving path. Failures emit structured WARNING
logs to the `promptflip` logger; callers attach a `logging.Handler` to
react:

```python
import logging

class CreditAlert(logging.Handler):
    def emit(self, record):
        if getattr(record, "kind", None) == "out_of_credits":
            pagerduty.trigger("PM out of credits")

logging.getLogger("promptflip").addHandler(CreditAlert())
```

Log records carry structured `extra={"kind", "prompt_key", "status_code",
"missing_names"}`. `kind` is one of `transport`, `out_of_credits`,
`quota_exceeded`, `missing_inputs`, `missing_vars`, `missing_variables`,
`missing_inputs_in_fallback`, `invalid_metric`, `not_found`, `unresolved`,
`unknown`.

## Fallback is a template

`fallback` uses the same `[[placeholders]]` syntax as the stored prompt;
the SDK interpolates client-side from `inputs` so online and offline render
identically. `{{vars}}` are server-side (potentially secrets) — they are
treated as plain text in the fallback. Don't put `{{vars}}` in your fallback.

Pre-flight check for tests:

```python
from promptflip import check_fallback

missing = check_fallback("Hello [[name]] on [[plan]]", inputs={"name": "Alice"})
# missing == ["plan"]
```

## Command-line: local-first authoring (`pf`)

`pf` (aliased `promptflip`) versions prompts the way git versions code. Everything
lives in one portable `.promptflip/` directory:

```
.promptflip/
├── prompts/<slug>.prompt.md   # the working files you edit (Markdown + frontmatter)
├── versions/                  # full local history — plain JSON, yours to keep
├── config                     # cloud-workspace binding
├── lock.json                  # (when synced) last-synced remote pointer
└── cache/                     # (when synced) remote mirror — gitignored
```

Your history is plain files in `versions/`, so there's no lock-in: only `lock.json`
+ `cache/` are provider-specific, and dropping them never costs you a prompt or a
version.

```bash
pf config user.name "You"       # set your author identity once (required before init)
pf config user.email "you@x.io"
pf init my-workspace            # start a repo (no account needed — fully offline)
pf new support-agent            # create prompts/support-agent.prompt.md
pf commit -m "first draft"      # snapshot a version into the local store
pf status                       # drafts + (once synced) ahead/behind
pf log support-agent            # version history
pf diff support-agent           # working file vs latest committed

pf login                        # device-code login in your browser
pf remote --create my-workspace # create the cloud workspace and link this repo
pf push                         # first push imports your whole local history
```

To work against a workspace that already exists in the cloud, either clone it
fresh or link + pull (git-style — you must hold the latest remote state before
pushing):

```bash
pf clone my-workspace           # fresh local repo, already in sync

# …or, to attach an existing local repo to an existing workspace:
pf remote my-workspace          # link the cloud workspace (does not pull)
pf pull                         # bring down the remote's latest state
pf push                         # refused until you're in sync ("run pf pull first")
```

`{{var}}` placeholders resolve from your environment, `[[input]]` are runtime
inputs; `pf render <slug>` prints the resolved text with no model call. Rolling
back is `pf checkout <slug> <version>` → `pf commit` → `pf push`. Free repos stay
entirely local; logging in plus `pf remote` upgrades the same repo to cloud sync.

### Tab completion

```bash
pf completion install          # wires it into ~/.bashrc or ~/.zshrc (auto-detects)
# then restart your shell, or: source ~/.bashrc
```

Completes subcommands, and prompt slugs for `rm`/`checkout`/`log`/`diff`/`render`.
(`pf completion bash|zsh` just prints the raw script if you'd rather wire it up
yourself.) Output is colorized on a terminal; set `NO_COLOR` to disable.

## Extra headers (gateways in front of the API)

If your requests must pass a gateway that expects its own credential headers
(a corporate proxy, or an access-gated non-production environment), attach
extra headers to every request — either per client:

```python
pm = PromptFlip(api_key="…", extra_headers={"X-Gateway-Token": "…"})
```

or ambiently for the SDK **and** the `pf` CLI via an env var holding a JSON
object:

```bash
export PF_EXTRA_HEADERS='{"X-Gateway-Token": "…"}'
```

Unset means no extra headers. Reserved headers the client sets itself
(`Authorization`) always win. A malformed value fails loudly in the CLI and
warns (without throwing) in the SDK.

## License

MIT.
