prompt-manager-client-dl 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ .env
2
+ .env-*
3
+ !.env.example
4
+ **/.venv/
5
+ **/__pycache__/
6
+ **/.ruff_cache/
7
+ **/*.pyc
8
+ ui/node_modules/
9
+ ui/dist/
10
+ ui/*.tsbuildinfo
11
+ .DS_Store
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.5
2
+ Name: prompt-manager-client-dl
3
+ Version: 0.1.0
4
+ Summary: Live-updating client for Prompt Manager
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: cachetools<7,>=5
7
+ Requires-Dist: httpx<1,>=0.27
8
+ Requires-Dist: requests<3,>=2.31
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Prompt Manager — Cliente Python
12
+
13
+ `prompt-manager-client` é a biblioteca que as aplicações de IA importam para buscar seus prompts na API do Prompt Manager em tempo de execução. Os prompts se atualizam ao vivo: quando alguém commita e publica uma nova versão na UI, a aplicação passa a usá-la em segundos, sem redeploy.
14
+
15
+ Ela é deliberadamente pequena — três coisas públicas:
16
+
17
+ - `PromptManager` — cliente síncrono (`requests`)
18
+ - `AsyncPromptManager` — cliente assíncrono (`httpx`), mesma interface com `await`
19
+ - `Prompt` — o prompt imutável e totalmente resolvido que uma busca retorna
20
+
21
+ ## Instalação
22
+
23
+ O pacote vive neste repositório, em `client/`. Adicione-o como dependência de path/git, por exemplo com uv:
24
+
25
+ ```bash
26
+ uv add "prompt-manager-client @ git+ssh://git@bitbucket.org/avisourgente/prompt-manager.git#subdirectory=client"
27
+ ```
28
+
29
+ Requer Python ≥ 3.10. Dependências: `requests`, `httpx`, `cachetools`.
30
+
31
+ ## Começo rápido
32
+
33
+ Crie **um cliente por processo** na inicialização (ele guarda o cache e a sessão HTTP) e reutilize-o:
34
+
35
+ ```python
36
+ from prompt_manager import PromptManager
37
+
38
+ pm = PromptManager(
39
+ application="superchat", # o nome da sua aplicação no manager
40
+ environment="prod", # qual deployment seguir
41
+ fallback_dir="prompts_fallback", # opcional: snapshots offline (veja abaixo)
42
+ )
43
+
44
+ # Uma chamada: busca (com cache) + preenche variáveis + relata o uso
45
+ text = pm.render(
46
+ "resposta_juridica",
47
+ variables={"pergunta": pergunta, "contexto": contexto},
48
+ request_id=request_id, # campos opcionais de observabilidade —
49
+ user=user_email, # eles vinculam o caso real ao prompt
50
+ metadata={"tenant": tenant}, # na tela de "Registros" do manager
51
+ )
52
+ ```
53
+
54
+ `render()` retorna a string final pronta para enviar ao modelo. Se o prompt define mensagens de chat, você provavelmente quer `get_prompt()` (abaixo).
55
+
56
+ ### Onde o cliente se conecta (`base_url`)
57
+
58
+ O endereço da API é resolvido nesta ordem:
59
+
60
+ 1. o argumento `base_url` do construtor, se informado;
61
+ 2. a variável de ambiente **`PROMPT_MANAGER_URL`**;
62
+ 3. o padrão de produção: **`http://promptmanager-prod.datalawyer.local`**.
63
+
64
+ Ou seja: em produção você não configura nada, e deployments de staging/dev redirecionam todos os clientes com uma única variável de ambiente:
65
+
66
+ ```bash
67
+ export PROMPT_MANAGER_URL=http://localhost:8000 # ex.: stack de dev local
68
+ ```
69
+
70
+ ### Async
71
+
72
+ ```python
73
+ from prompt_manager import AsyncPromptManager
74
+
75
+ pm = AsyncPromptManager(application="superchat", environment="prod")
76
+
77
+ async def handle(question: str) -> str:
78
+ return await pm.render("resposta_juridica", variables={"pergunta": question})
79
+ ```
80
+
81
+ Use-o como async context manager (`async with AsyncPromptManager(...) as pm:`) ou mantenha-o pela vida do processo; ao sair, ele descarrega os relatos de uso pendentes e fecha seu cliente `httpx` (a menos que você tenha passado o seu próprio).
82
+
83
+ ## Trabalhando com o objeto `Prompt`
84
+
85
+ `get_prompt()` retorna o prompt resolvido sem renderizá-lo:
86
+
87
+ ```python
88
+ prompt = pm.get_prompt("resposta_juridica")
89
+
90
+ prompt.template # texto resolvido completo (fragmentos, controles e exemplos já expandidos)
91
+ prompt.variables # as variáveis de runtime que o template ainda espera
92
+ prompt.version # número da versão commitada que esta resolução usou
93
+ prompt.hash # hash do snapshot — registre-o para rastrear qualquer chamada de LLM até o prompt exato
94
+ prompt.suggested_model # modelo + parâmetros sugeridos pelo autor do prompt
95
+ prompt.suggested_params
96
+ prompt.fragment_versions # quais versões de fragmentos foram embutidas
97
+ prompt.fewshots # quais bancos de exemplos foram injetados, e quantos exemplos cada um contribuiu
98
+
99
+ rendered = prompt.render(pergunta="...", contexto="...")
100
+ ```
101
+
102
+ `render` (tanto no cliente quanto no `Prompt`) valida os valores: nomes de variáveis desconhecidos, variáveis obrigatórias faltando e `{{placeholders}}` restantes levantam `PromptValidationError`. Valores dict/list são codificados em JSON automaticamente.
103
+
104
+ ### Mensagens de chat
105
+
106
+ Um template resolvido pode conter linhas com marcadores de papel `{% system %}` / `{% user %}` / `{% assistant %}`. O cliente mantém o template plano (os marcadores sobrevivem intactos ao `render()`); para prompts com múltiplas mensagens, divida você mesmo o texto renderizado nessas linhas de marcador: uma nova mensagem começa em cada marcador, e o conteúdo antes do primeiro marcador pertence a `system`. A resposta do `/fetch` da API também carrega um array `messages` já dividido, se você preferir consumi-lo diretamente.
107
+
108
+ ## Ambientes
109
+
110
+ O `environment` informado na construção é o padrão; qualquer chamada pode sobrescrevê-lo:
111
+
112
+ ```python
113
+ pm.get_prompt("resposta_juridica", environment="staging")
114
+ ```
115
+
116
+ `"default"` é especial: ele sempre segue a **versão commitada mais recente**, sem precisar de deployment explícito. Ambientes nomeados (`prod`, `staging`, …) servem a versão que foi fixada neles pela UI — e caem para a versão mais recente se o prompt nunca foi fixado ali.
117
+
118
+ ## Ablação de few-shots (A/B sem exemplos)
119
+
120
+ `fewshots=False` resolve o mesmo prompt com todos os bancos de exemplos desligados — é assim que uma aplicação mede o que os exemplos realmente valem:
121
+
122
+ ```python
123
+ com_exemplos = pm.render("classificador", variables=v) # normal
124
+ sem_exemplos = pm.render("classificador", variables=v, fewshots=False) # ablação
125
+ ```
126
+
127
+ As duas resoluções têm cache e ETag separados.
128
+
129
+ ## Cache, resiliência e fallback offline
130
+
131
+ O cliente é construído para que a API de prompts nunca seja um ponto único de falha:
132
+
133
+ 1. **Cache com TTL** (padrão 45 s, `ttl=` no construtor): buscas repetidas dentro da janela não custam nada.
134
+ 2. **Revalidação por ETag**: expirado o TTL, o cliente revalida com `If-None-Match`; um prompt inalterado custa um 304, não um novo download.
135
+ 3. **Último valor conhecido**: se a API estiver inacessível, o cliente registra um warning e continua servindo o último prompt buscado com sucesso (por chave nome/ambiente/fewshots), pela vida do processo.
136
+ 4. **Arquivos de fallback locais**: se a API estiver inacessível *e* nada foi buscado ainda (ex.: logo após um cold start), o cliente carrega `<fallback_dir>/<nome>.<ambiente>.json`, depois `<fallback_dir>/<nome>.json`.
137
+
138
+ Só quando os quatro falham ele levanta `PromptUnavailableError`.
139
+
140
+ Gere os snapshots de fallback com a CLI incluída (commite-os no seu repositório ou embuta-os na sua imagem, atualizando a cada deploy):
141
+
142
+ ```bash
143
+ uv run prompt-manager pull --app superchat --env prod --out prompts_fallback/
144
+ ```
145
+
146
+ (`--url` sobrescreve o endereço da API; caso contrário valem `PROMPT_MANAGER_URL` / o padrão de produção, igual à biblioteca.)
147
+
148
+ ## Relato de uso
149
+
150
+ Cada `render()` dispara (fire-and-forget) um relato para `POST /api/v1/usage` com a identidade do prompt (nome, versão, hash do snapshot) e os valores de variáveis com que foi preenchido. A UI do manager os lê de volta para que os prompts possam ser testados contra **casos reais de produção** em vez de casos inventados.
151
+
152
+ - Nunca bloqueia nem quebra um render — falhas são logadas em nível debug e descartadas.
153
+ - Passe `request_id`, `user` e `metadata` para tornar os casos gravados filtráveis.
154
+ - Desabilite completamente com `report_usage=False` (ex.: em testes), ou no servidor deixando `OPENSEARCH_HOST` vazio.
155
+
156
+ ## Referência do construtor
157
+
158
+ | Parâmetro | Padrão | Significado |
159
+ |-----------|--------|-------------|
160
+ | `application` | *(obrigatório)* | Nome da aplicação registrada no manager |
161
+ | `base_url` | `$PROMPT_MANAGER_URL`, senão `http://promptmanager-prod.datalawyer.local` | Raiz da API do Prompt Manager |
162
+ | `environment` | `"default"` | Ambiente padrão de todas as chamadas |
163
+ | `ttl` | `45` | Segundos que um prompt buscado é servido sem revalidação |
164
+ | `fallback_dir` | `None` | Diretório com snapshots offline do `prompt-manager pull` |
165
+ | `timeout` | `10` | Timeout HTTP em segundos |
166
+ | `session` / `client` | `None` | Traga seu próprio `requests.Session` / `httpx.AsyncClient` |
167
+ | `report_usage` | `True` | Relata as variáveis de cada render para a tela de Registros |
@@ -0,0 +1,157 @@
1
+ # Prompt Manager — Cliente Python
2
+
3
+ `prompt-manager-client` é a biblioteca que as aplicações de IA importam para buscar seus prompts na API do Prompt Manager em tempo de execução. Os prompts se atualizam ao vivo: quando alguém commita e publica uma nova versão na UI, a aplicação passa a usá-la em segundos, sem redeploy.
4
+
5
+ Ela é deliberadamente pequena — três coisas públicas:
6
+
7
+ - `PromptManager` — cliente síncrono (`requests`)
8
+ - `AsyncPromptManager` — cliente assíncrono (`httpx`), mesma interface com `await`
9
+ - `Prompt` — o prompt imutável e totalmente resolvido que uma busca retorna
10
+
11
+ ## Instalação
12
+
13
+ O pacote vive neste repositório, em `client/`. Adicione-o como dependência de path/git, por exemplo com uv:
14
+
15
+ ```bash
16
+ uv add "prompt-manager-client @ git+ssh://git@bitbucket.org/avisourgente/prompt-manager.git#subdirectory=client"
17
+ ```
18
+
19
+ Requer Python ≥ 3.10. Dependências: `requests`, `httpx`, `cachetools`.
20
+
21
+ ## Começo rápido
22
+
23
+ Crie **um cliente por processo** na inicialização (ele guarda o cache e a sessão HTTP) e reutilize-o:
24
+
25
+ ```python
26
+ from prompt_manager import PromptManager
27
+
28
+ pm = PromptManager(
29
+ application="superchat", # o nome da sua aplicação no manager
30
+ environment="prod", # qual deployment seguir
31
+ fallback_dir="prompts_fallback", # opcional: snapshots offline (veja abaixo)
32
+ )
33
+
34
+ # Uma chamada: busca (com cache) + preenche variáveis + relata o uso
35
+ text = pm.render(
36
+ "resposta_juridica",
37
+ variables={"pergunta": pergunta, "contexto": contexto},
38
+ request_id=request_id, # campos opcionais de observabilidade —
39
+ user=user_email, # eles vinculam o caso real ao prompt
40
+ metadata={"tenant": tenant}, # na tela de "Registros" do manager
41
+ )
42
+ ```
43
+
44
+ `render()` retorna a string final pronta para enviar ao modelo. Se o prompt define mensagens de chat, você provavelmente quer `get_prompt()` (abaixo).
45
+
46
+ ### Onde o cliente se conecta (`base_url`)
47
+
48
+ O endereço da API é resolvido nesta ordem:
49
+
50
+ 1. o argumento `base_url` do construtor, se informado;
51
+ 2. a variável de ambiente **`PROMPT_MANAGER_URL`**;
52
+ 3. o padrão de produção: **`http://promptmanager-prod.datalawyer.local`**.
53
+
54
+ Ou seja: em produção você não configura nada, e deployments de staging/dev redirecionam todos os clientes com uma única variável de ambiente:
55
+
56
+ ```bash
57
+ export PROMPT_MANAGER_URL=http://localhost:8000 # ex.: stack de dev local
58
+ ```
59
+
60
+ ### Async
61
+
62
+ ```python
63
+ from prompt_manager import AsyncPromptManager
64
+
65
+ pm = AsyncPromptManager(application="superchat", environment="prod")
66
+
67
+ async def handle(question: str) -> str:
68
+ return await pm.render("resposta_juridica", variables={"pergunta": question})
69
+ ```
70
+
71
+ Use-o como async context manager (`async with AsyncPromptManager(...) as pm:`) ou mantenha-o pela vida do processo; ao sair, ele descarrega os relatos de uso pendentes e fecha seu cliente `httpx` (a menos que você tenha passado o seu próprio).
72
+
73
+ ## Trabalhando com o objeto `Prompt`
74
+
75
+ `get_prompt()` retorna o prompt resolvido sem renderizá-lo:
76
+
77
+ ```python
78
+ prompt = pm.get_prompt("resposta_juridica")
79
+
80
+ prompt.template # texto resolvido completo (fragmentos, controles e exemplos já expandidos)
81
+ prompt.variables # as variáveis de runtime que o template ainda espera
82
+ prompt.version # número da versão commitada que esta resolução usou
83
+ prompt.hash # hash do snapshot — registre-o para rastrear qualquer chamada de LLM até o prompt exato
84
+ prompt.suggested_model # modelo + parâmetros sugeridos pelo autor do prompt
85
+ prompt.suggested_params
86
+ prompt.fragment_versions # quais versões de fragmentos foram embutidas
87
+ prompt.fewshots # quais bancos de exemplos foram injetados, e quantos exemplos cada um contribuiu
88
+
89
+ rendered = prompt.render(pergunta="...", contexto="...")
90
+ ```
91
+
92
+ `render` (tanto no cliente quanto no `Prompt`) valida os valores: nomes de variáveis desconhecidos, variáveis obrigatórias faltando e `{{placeholders}}` restantes levantam `PromptValidationError`. Valores dict/list são codificados em JSON automaticamente.
93
+
94
+ ### Mensagens de chat
95
+
96
+ Um template resolvido pode conter linhas com marcadores de papel `{% system %}` / `{% user %}` / `{% assistant %}`. O cliente mantém o template plano (os marcadores sobrevivem intactos ao `render()`); para prompts com múltiplas mensagens, divida você mesmo o texto renderizado nessas linhas de marcador: uma nova mensagem começa em cada marcador, e o conteúdo antes do primeiro marcador pertence a `system`. A resposta do `/fetch` da API também carrega um array `messages` já dividido, se você preferir consumi-lo diretamente.
97
+
98
+ ## Ambientes
99
+
100
+ O `environment` informado na construção é o padrão; qualquer chamada pode sobrescrevê-lo:
101
+
102
+ ```python
103
+ pm.get_prompt("resposta_juridica", environment="staging")
104
+ ```
105
+
106
+ `"default"` é especial: ele sempre segue a **versão commitada mais recente**, sem precisar de deployment explícito. Ambientes nomeados (`prod`, `staging`, …) servem a versão que foi fixada neles pela UI — e caem para a versão mais recente se o prompt nunca foi fixado ali.
107
+
108
+ ## Ablação de few-shots (A/B sem exemplos)
109
+
110
+ `fewshots=False` resolve o mesmo prompt com todos os bancos de exemplos desligados — é assim que uma aplicação mede o que os exemplos realmente valem:
111
+
112
+ ```python
113
+ com_exemplos = pm.render("classificador", variables=v) # normal
114
+ sem_exemplos = pm.render("classificador", variables=v, fewshots=False) # ablação
115
+ ```
116
+
117
+ As duas resoluções têm cache e ETag separados.
118
+
119
+ ## Cache, resiliência e fallback offline
120
+
121
+ O cliente é construído para que a API de prompts nunca seja um ponto único de falha:
122
+
123
+ 1. **Cache com TTL** (padrão 45 s, `ttl=` no construtor): buscas repetidas dentro da janela não custam nada.
124
+ 2. **Revalidação por ETag**: expirado o TTL, o cliente revalida com `If-None-Match`; um prompt inalterado custa um 304, não um novo download.
125
+ 3. **Último valor conhecido**: se a API estiver inacessível, o cliente registra um warning e continua servindo o último prompt buscado com sucesso (por chave nome/ambiente/fewshots), pela vida do processo.
126
+ 4. **Arquivos de fallback locais**: se a API estiver inacessível *e* nada foi buscado ainda (ex.: logo após um cold start), o cliente carrega `<fallback_dir>/<nome>.<ambiente>.json`, depois `<fallback_dir>/<nome>.json`.
127
+
128
+ Só quando os quatro falham ele levanta `PromptUnavailableError`.
129
+
130
+ Gere os snapshots de fallback com a CLI incluída (commite-os no seu repositório ou embuta-os na sua imagem, atualizando a cada deploy):
131
+
132
+ ```bash
133
+ uv run prompt-manager pull --app superchat --env prod --out prompts_fallback/
134
+ ```
135
+
136
+ (`--url` sobrescreve o endereço da API; caso contrário valem `PROMPT_MANAGER_URL` / o padrão de produção, igual à biblioteca.)
137
+
138
+ ## Relato de uso
139
+
140
+ Cada `render()` dispara (fire-and-forget) um relato para `POST /api/v1/usage` com a identidade do prompt (nome, versão, hash do snapshot) e os valores de variáveis com que foi preenchido. A UI do manager os lê de volta para que os prompts possam ser testados contra **casos reais de produção** em vez de casos inventados.
141
+
142
+ - Nunca bloqueia nem quebra um render — falhas são logadas em nível debug e descartadas.
143
+ - Passe `request_id`, `user` e `metadata` para tornar os casos gravados filtráveis.
144
+ - Desabilite completamente com `report_usage=False` (ex.: em testes), ou no servidor deixando `OPENSEARCH_HOST` vazio.
145
+
146
+ ## Referência do construtor
147
+
148
+ | Parâmetro | Padrão | Significado |
149
+ |-----------|--------|-------------|
150
+ | `application` | *(obrigatório)* | Nome da aplicação registrada no manager |
151
+ | `base_url` | `$PROMPT_MANAGER_URL`, senão `http://promptmanager-prod.datalawyer.local` | Raiz da API do Prompt Manager |
152
+ | `environment` | `"default"` | Ambiente padrão de todas as chamadas |
153
+ | `ttl` | `45` | Segundos que um prompt buscado é servido sem revalidação |
154
+ | `fallback_dir` | `None` | Diretório com snapshots offline do `prompt-manager pull` |
155
+ | `timeout` | `10` | Timeout HTTP em segundos |
156
+ | `session` / `client` | `None` | Traga seu próprio `requests.Session` / `httpx.AsyncClient` |
157
+ | `report_usage` | `True` | Relata as variáveis de cada render para a tela de Registros |
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "prompt-manager-client-dl"
3
+ version = "0.1.0"
4
+ description = "Live-updating client for Prompt Manager"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "cachetools>=5,<7",
9
+ "httpx>=0.27,<1",
10
+ "requests>=2.31,<3",
11
+ ]
12
+
13
+ [project.scripts]
14
+ prompt-manager = "prompt_manager.cli:main"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/prompt_manager"]
22
+
@@ -0,0 +1,12 @@
1
+ from .async_client import AsyncPromptManager
2
+ from .client import PromptManager
3
+ from .models import Prompt, PromptUnavailableError, PromptValidationError
4
+
5
+ __all__ = [
6
+ "AsyncPromptManager",
7
+ "Prompt",
8
+ "PromptManager",
9
+ "PromptUnavailableError",
10
+ "PromptValidationError",
11
+ ]
12
+
@@ -0,0 +1,132 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .client import resolve_base_url, usage_payload
11
+ from .models import Prompt, PromptUnavailableError
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class AsyncPromptManager:
17
+ def __init__(
18
+ self,
19
+ *,
20
+ application: str,
21
+ base_url: str | None = None,
22
+ environment: str = "default",
23
+ ttl: int = 45,
24
+ fallback_dir: str | Path | None = None,
25
+ timeout: float = 10,
26
+ client: httpx.AsyncClient | None = None,
27
+ report_usage: bool = True,
28
+ ):
29
+ self.base_url = resolve_base_url(base_url)
30
+ self.application = application
31
+ self.environment = environment
32
+ self.ttl = ttl
33
+ self.fallback_dir = Path(fallback_dir) if fallback_dir else None
34
+ self.client = client or httpx.AsyncClient(timeout=timeout)
35
+ self._owns_client = client is None
36
+ # Every render() reports the variables it was filled with, so the UI can
37
+ # replay real cases. Fire-and-forget: never blocks or breaks a render.
38
+ self.report_usage = report_usage
39
+ # Keeps fire-and-forget tasks referenced until they finish.
40
+ self._usage_tasks: set[asyncio.Task] = set()
41
+ # Keyed by (name, environment, with-examples): the two resolutions are
42
+ # different prompts and must not share a cache entry or an ETag.
43
+ self._cache: dict[tuple[str, str, bool], tuple[float, Prompt]] = {}
44
+ self._last_known_good: dict[tuple[str, str, bool], Prompt] = {}
45
+ self._etags: dict[tuple[str, str, bool], str] = {}
46
+
47
+ async def __aenter__(self):
48
+ return self
49
+
50
+ async def __aexit__(self, *_: object):
51
+ if self._usage_tasks:
52
+ await asyncio.gather(*self._usage_tasks, return_exceptions=True)
53
+ if self._owns_client:
54
+ await self.client.aclose()
55
+
56
+ def _fallback(self, name: str, environment: str) -> Prompt | None:
57
+ if not self.fallback_dir:
58
+ return None
59
+ for path in (
60
+ self.fallback_dir / f"{name}.{environment}.json",
61
+ self.fallback_dir / f"{name}.json",
62
+ ):
63
+ if path.exists():
64
+ return Prompt.from_dict(json.loads(path.read_text(encoding="utf-8")))
65
+ return None
66
+
67
+ async def get_prompt(
68
+ self, name: str, *, environment: str | None = None, fewshots: bool = True
69
+ ) -> Prompt:
70
+ """Fetch a prompt. `fewshots=False` resolves it without its example banks,
71
+ which is how an application measures what those examples are worth."""
72
+ env = environment or self.environment
73
+ key = (name, env, fewshots)
74
+ cached = self._cache.get(key)
75
+ if cached and cached[0] > time.monotonic():
76
+ return cached[1]
77
+ headers = {"If-None-Match": self._etags[key]} if key in self._etags else {}
78
+ params = {"environment": env}
79
+ if not fewshots:
80
+ params["fewshots"] = "off"
81
+ try:
82
+ response = await self.client.get(
83
+ f"{self.base_url}/api/v1/fetch/{self.application}/{name}",
84
+ params=params,
85
+ headers=headers,
86
+ )
87
+ if response.status_code == 304 and key in self._last_known_good:
88
+ prompt = self._last_known_good[key]
89
+ else:
90
+ response.raise_for_status()
91
+ prompt = Prompt.from_dict(response.json())
92
+ self._etags[key] = prompt.hash
93
+ self._cache[key] = (time.monotonic() + self.ttl, prompt)
94
+ self._last_known_good[key] = prompt
95
+ return prompt
96
+ except httpx.HTTPError as exc:
97
+ prompt = self._last_known_good.get(key) or self._fallback(name, env)
98
+ if prompt:
99
+ logger.warning("Prompt API unavailable; using stale/fallback prompt")
100
+ return prompt
101
+ raise PromptUnavailableError(
102
+ f"Unable to fetch {self.application}/{name} ({env})"
103
+ ) from exc
104
+
105
+ async def render(
106
+ self,
107
+ name: str,
108
+ *,
109
+ variables: dict[str, Any],
110
+ environment: str | None = None,
111
+ fewshots: bool = True,
112
+ request_id: str | None = None,
113
+ user: str | None = None,
114
+ metadata: dict[str, Any] | None = None,
115
+ ) -> str:
116
+ """`request_id`, `user` and `metadata` describe the original request; they
117
+ travel with the usage report and end up attached to the recorded case."""
118
+ prompt = await self.get_prompt(name, environment=environment, fewshots=fewshots)
119
+ rendered = prompt.render(**variables)
120
+ if self.report_usage:
121
+ payload = usage_payload(prompt, variables, request_id, user, metadata)
122
+ task = asyncio.create_task(self._post_usage(payload))
123
+ self._usage_tasks.add(task)
124
+ task.add_done_callback(self._usage_tasks.discard)
125
+ return rendered
126
+
127
+ async def _post_usage(self, payload: dict[str, Any]) -> None:
128
+ try:
129
+ await self.client.post(f"{self.base_url}/api/v1/usage", json=payload)
130
+ except httpx.HTTPError:
131
+ logger.debug("Usage report failed", exc_info=True)
132
+
@@ -0,0 +1,57 @@
1
+ import argparse
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import requests
6
+
7
+ from .client import PromptManager, resolve_base_url
8
+
9
+
10
+ def pull(args: argparse.Namespace) -> None:
11
+ base_url = resolve_base_url(args.url)
12
+ output = Path(args.out)
13
+ output.mkdir(parents=True, exist_ok=True)
14
+ response = requests.get(
15
+ f"{base_url}/api/v1/applications/{args.app}/prompts",
16
+ timeout=30,
17
+ )
18
+ response.raise_for_status()
19
+ manager = PromptManager(
20
+ base_url=base_url,
21
+ application=args.app,
22
+ environment=args.env,
23
+ ttl=1,
24
+ )
25
+ count = 0
26
+ for item in response.json():
27
+ if item["kind"] != "prompt" or item.get("archived_at"):
28
+ continue
29
+ prompt = manager.get_prompt(item["name"])
30
+ path = output / f"{item['name']}.{args.env}.json"
31
+ path.write_text(
32
+ json.dumps(prompt.to_dict(), indent=2, ensure_ascii=False, default=str),
33
+ encoding="utf-8",
34
+ )
35
+ count += 1
36
+ print(f"Pulled {count} prompt(s) to {output}")
37
+
38
+
39
+ def main() -> None:
40
+ parser = argparse.ArgumentParser(prog="prompt-manager")
41
+ commands = parser.add_subparsers(required=True)
42
+ command = commands.add_parser("pull", help="Download offline prompt snapshots")
43
+ command.add_argument(
44
+ "--url",
45
+ default=None,
46
+ help="API base URL (default: $PROMPT_MANAGER_URL or the production instance)",
47
+ )
48
+ command.add_argument("--app", required=True)
49
+ command.add_argument("--env", default="default")
50
+ command.add_argument("--out", required=True)
51
+ command.set_defaults(handler=pull)
52
+ args = parser.parse_args()
53
+ args.handler(args)
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
@@ -0,0 +1,172 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import threading
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import requests
9
+ from cachetools import TTLCache
10
+
11
+ from .models import Prompt, PromptUnavailableError
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ DEFAULT_BASE_URL = "http://promptmanager-prod.datalawyer.local"
16
+
17
+
18
+ def resolve_base_url(base_url: str | None) -> str:
19
+ """Explicit argument first, then $PROMPT_MANAGER_URL, then the production
20
+ default — so deployments can repoint every client without a code change."""
21
+ return (
22
+ base_url or os.environ.get("PROMPT_MANAGER_URL") or DEFAULT_BASE_URL
23
+ ).rstrip("/")
24
+
25
+
26
+ def usage_payload(
27
+ prompt: Prompt,
28
+ variables: dict[str, Any],
29
+ request_id: str | None,
30
+ user: str | None,
31
+ metadata: dict[str, Any] | None,
32
+ ) -> dict[str, Any]:
33
+ """The document POSTed to /api/v1/usage. Values are coerced to JSON-safe
34
+ (str fallback) so an odd variable type never breaks the report."""
35
+ return json.loads(
36
+ json.dumps(
37
+ {
38
+ "application": prompt.application,
39
+ "prompt": prompt.name,
40
+ "environment": prompt.environment,
41
+ "prompt_version": prompt.version,
42
+ "snapshot_hash": prompt.hash,
43
+ "variables": variables,
44
+ "request_id": request_id,
45
+ "user": user,
46
+ "metadata": metadata or {},
47
+ },
48
+ ensure_ascii=False,
49
+ default=str,
50
+ )
51
+ )
52
+
53
+
54
+ class PromptManager:
55
+ def __init__(
56
+ self,
57
+ *,
58
+ application: str,
59
+ base_url: str | None = None,
60
+ environment: str = "default",
61
+ ttl: int = 45,
62
+ fallback_dir: str | Path | None = None,
63
+ timeout: float = 10,
64
+ session: requests.Session | None = None,
65
+ report_usage: bool = True,
66
+ ):
67
+ self.base_url = resolve_base_url(base_url)
68
+ self.application = application
69
+ self.environment = environment
70
+ self.fallback_dir = Path(fallback_dir) if fallback_dir else None
71
+ self.timeout = timeout
72
+ self.session = session or requests.Session()
73
+ # Every render() reports the variables it was filled with, so the UI can
74
+ # replay real cases. Fire-and-forget: never blocks or breaks a render.
75
+ self.report_usage = report_usage
76
+ self._cache: TTLCache = TTLCache(maxsize=512, ttl=ttl)
77
+ # Keyed by (name, environment, with-examples): the two resolutions are
78
+ # different prompts and must not share a cache entry or an ETag.
79
+ self._last_known_good: dict[tuple[str, str, bool], Prompt] = {}
80
+ self._etags: dict[tuple[str, str, bool], str] = {}
81
+
82
+ def _fallback(self, name: str, environment: str) -> Prompt | None:
83
+ if not self.fallback_dir:
84
+ return None
85
+ candidates = [
86
+ self.fallback_dir / f"{name}.{environment}.json",
87
+ self.fallback_dir / f"{name}.json",
88
+ ]
89
+ for path in candidates:
90
+ if path.exists():
91
+ return Prompt.from_dict(json.loads(path.read_text(encoding="utf-8")))
92
+ return None
93
+
94
+ def get_prompt(
95
+ self, name: str, *, environment: str | None = None, fewshots: bool = True
96
+ ) -> Prompt:
97
+ """Fetch a prompt. `fewshots=False` resolves it without its example banks,
98
+ which is how an application measures what those examples are worth."""
99
+ env = environment or self.environment
100
+ key = (name, env, fewshots)
101
+ cached = self._cache.get(key)
102
+ if cached:
103
+ return cached
104
+ headers: dict[str, str] = {}
105
+ if key in self._etags:
106
+ headers["If-None-Match"] = self._etags[key]
107
+ params = {"environment": env}
108
+ if not fewshots:
109
+ params["fewshots"] = "off"
110
+ try:
111
+ response = self.session.get(
112
+ f"{self.base_url}/api/v1/fetch/{self.application}/{name}",
113
+ params=params,
114
+ headers=headers,
115
+ timeout=self.timeout,
116
+ )
117
+ if response.status_code == 304 and key in self._last_known_good:
118
+ prompt = self._last_known_good[key]
119
+ else:
120
+ response.raise_for_status()
121
+ prompt = Prompt.from_dict(response.json())
122
+ self._etags[key] = prompt.hash
123
+ self._cache[key] = prompt
124
+ self._last_known_good[key] = prompt
125
+ return prompt
126
+ except requests.RequestException as exc:
127
+ if key in self._last_known_good:
128
+ logger.warning("Prompt API unavailable; using stale %s/%s", name, env)
129
+ return self._last_known_good[key]
130
+ fallback = self._fallback(name, env)
131
+ if fallback:
132
+ logger.warning("Prompt API unavailable; using local fallback %s", name)
133
+ self._last_known_good[key] = fallback
134
+ return fallback
135
+ raise PromptUnavailableError(
136
+ f"Unable to fetch {self.application}/{name} ({env})"
137
+ ) from exc
138
+
139
+ def render(
140
+ self,
141
+ name: str,
142
+ *,
143
+ variables: dict[str, Any],
144
+ environment: str | None = None,
145
+ fewshots: bool = True,
146
+ request_id: str | None = None,
147
+ user: str | None = None,
148
+ metadata: dict[str, Any] | None = None,
149
+ ) -> str:
150
+ """`request_id`, `user` and `metadata` describe the original request; they
151
+ travel with the usage report and end up attached to the recorded case."""
152
+ prompt = self.get_prompt(name, environment=environment, fewshots=fewshots)
153
+ rendered = prompt.render(**variables)
154
+ if self.report_usage:
155
+ payload = usage_payload(prompt, variables, request_id, user, metadata)
156
+ threading.Thread(
157
+ target=self._post_usage, args=(payload,), daemon=True
158
+ ).start()
159
+ return rendered
160
+
161
+ def _post_usage(self, payload: dict[str, Any]) -> None:
162
+ # requests.post (not self.session): sessions are not thread-safe.
163
+ try:
164
+ requests.post(
165
+ f"{self.base_url}/api/v1/usage", json=payload, timeout=self.timeout
166
+ )
167
+ except requests.RequestException:
168
+ logger.debug("Usage report failed", exc_info=True)
169
+
170
+ def clear_cache(self) -> None:
171
+ self._cache.clear()
172
+
@@ -0,0 +1,69 @@
1
+ import json
2
+ import re
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ class PromptUnavailableError(RuntimeError):
8
+ pass
9
+
10
+
11
+ class PromptValidationError(ValueError):
12
+ pass
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Prompt:
17
+ name: str
18
+ application: str
19
+ environment: str
20
+ version: int
21
+ template: str
22
+ variables: list[dict[str, Any]]
23
+ suggested_model: str | None
24
+ suggested_params: dict[str, Any]
25
+ hash: str
26
+ resolved_at: str
27
+ fragment_versions: dict[str, Any] = field(default_factory=dict)
28
+ # Which example banks this resolution injected, and how many examples each
29
+ # contributed — empty when the prompt uses none, or when they were switched off.
30
+ fewshots: dict[str, Any] = field(default_factory=dict)
31
+
32
+ @classmethod
33
+ def from_dict(cls, data: dict[str, Any]) -> "Prompt":
34
+ fields = cls.__dataclass_fields__
35
+ return cls(**{key: value for key, value in data.items() if key in fields})
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ return {
39
+ field_name: getattr(self, field_name)
40
+ for field_name in self.__dataclass_fields__
41
+ }
42
+
43
+ def render(self, **values: Any) -> str:
44
+ result = self.template
45
+ known = {variable["name"] for variable in self.variables}
46
+ unknown = sorted(set(values) - known)
47
+ if unknown:
48
+ raise PromptValidationError(f"Unknown variables: {', '.join(unknown)}")
49
+ for variable in self.variables:
50
+ name = variable["name"]
51
+ value = values.get(name, variable.get("default_value"))
52
+ if value is None and variable.get("required", True):
53
+ raise PromptValidationError(f"Missing required variable: {name}")
54
+ if value is None:
55
+ value = ""
56
+ if isinstance(value, (dict, list)):
57
+ value = json.dumps(value, ensure_ascii=False)
58
+ result = re.sub(
59
+ r"\{\{\s*" + re.escape(name) + r"\s*\}\}",
60
+ lambda _: str(value),
61
+ result,
62
+ )
63
+ leftovers = re.findall(r"\{\{\s*([a-z_][a-z0-9_]*)\s*\}\}", result)
64
+ if leftovers:
65
+ raise PromptValidationError(
66
+ f"Unresolved variables: {', '.join(sorted(set(leftovers)))}"
67
+ )
68
+ return result
69
+
@@ -0,0 +1,230 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "anyio"
7
+ version = "4.14.2"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ dependencies = [
10
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
11
+ { name = "idna" },
12
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
13
+ ]
14
+ sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
15
+ wheels = [
16
+ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
17
+ ]
18
+
19
+ [[package]]
20
+ name = "cachetools"
21
+ version = "6.2.6"
22
+ source = { registry = "https://pypi.org/simple" }
23
+ sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" }
24
+ wheels = [
25
+ { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" },
26
+ ]
27
+
28
+ [[package]]
29
+ name = "certifi"
30
+ version = "2026.6.17"
31
+ source = { registry = "https://pypi.org/simple" }
32
+ sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
33
+ wheels = [
34
+ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
35
+ ]
36
+
37
+ [[package]]
38
+ name = "charset-normalizer"
39
+ version = "3.4.9"
40
+ source = { registry = "https://pypi.org/simple" }
41
+ sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
42
+ wheels = [
43
+ { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" },
44
+ { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" },
45
+ { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" },
46
+ { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" },
47
+ { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" },
48
+ { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" },
49
+ { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" },
50
+ { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" },
51
+ { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" },
52
+ { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" },
53
+ { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" },
54
+ { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" },
55
+ { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" },
56
+ { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" },
57
+ { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" },
58
+ { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" },
59
+ { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" },
60
+ { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" },
61
+ { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" },
62
+ { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" },
63
+ { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" },
64
+ { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" },
65
+ { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" },
66
+ { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" },
67
+ { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" },
68
+ { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" },
69
+ { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" },
70
+ { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" },
71
+ { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" },
72
+ { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" },
73
+ { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" },
74
+ { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" },
75
+ { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" },
76
+ { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" },
77
+ { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" },
78
+ { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" },
79
+ { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" },
80
+ { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" },
81
+ { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" },
82
+ { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" },
83
+ { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" },
84
+ { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" },
85
+ { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" },
86
+ { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" },
87
+ { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" },
88
+ { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" },
89
+ { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" },
90
+ { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" },
91
+ { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" },
92
+ { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" },
93
+ { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" },
94
+ { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" },
95
+ { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
96
+ { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
97
+ { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
98
+ { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
99
+ { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
100
+ { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
101
+ { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
102
+ { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
103
+ { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
104
+ { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
105
+ { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
106
+ { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
107
+ { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
108
+ { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
109
+ { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
110
+ { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
111
+ { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
112
+ { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
113
+ { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
114
+ { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
115
+ { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
116
+ { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
117
+ { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
118
+ { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
119
+ { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
120
+ { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
121
+ { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
122
+ ]
123
+
124
+ [[package]]
125
+ name = "exceptiongroup"
126
+ version = "1.3.1"
127
+ source = { registry = "https://pypi.org/simple" }
128
+ dependencies = [
129
+ { name = "typing-extensions" },
130
+ ]
131
+ sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
132
+ wheels = [
133
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
134
+ ]
135
+
136
+ [[package]]
137
+ name = "h11"
138
+ version = "0.16.0"
139
+ source = { registry = "https://pypi.org/simple" }
140
+ sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
141
+ wheels = [
142
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
143
+ ]
144
+
145
+ [[package]]
146
+ name = "httpcore"
147
+ version = "1.0.9"
148
+ source = { registry = "https://pypi.org/simple" }
149
+ dependencies = [
150
+ { name = "certifi" },
151
+ { name = "h11" },
152
+ ]
153
+ sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
154
+ wheels = [
155
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
156
+ ]
157
+
158
+ [[package]]
159
+ name = "httpx"
160
+ version = "0.28.1"
161
+ source = { registry = "https://pypi.org/simple" }
162
+ dependencies = [
163
+ { name = "anyio" },
164
+ { name = "certifi" },
165
+ { name = "httpcore" },
166
+ { name = "idna" },
167
+ ]
168
+ sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
169
+ wheels = [
170
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
171
+ ]
172
+
173
+ [[package]]
174
+ name = "idna"
175
+ version = "3.18"
176
+ source = { registry = "https://pypi.org/simple" }
177
+ sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
178
+ wheels = [
179
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
180
+ ]
181
+
182
+ [[package]]
183
+ name = "prompt-manager-client"
184
+ version = "0.1.0"
185
+ source = { editable = "." }
186
+ dependencies = [
187
+ { name = "cachetools" },
188
+ { name = "httpx" },
189
+ { name = "requests" },
190
+ ]
191
+
192
+ [package.metadata]
193
+ requires-dist = [
194
+ { name = "cachetools", specifier = ">=5,<7" },
195
+ { name = "httpx", specifier = ">=0.27,<1" },
196
+ { name = "requests", specifier = ">=2.31,<3" },
197
+ ]
198
+
199
+ [[package]]
200
+ name = "requests"
201
+ version = "2.34.2"
202
+ source = { registry = "https://pypi.org/simple" }
203
+ dependencies = [
204
+ { name = "certifi" },
205
+ { name = "charset-normalizer" },
206
+ { name = "idna" },
207
+ { name = "urllib3" },
208
+ ]
209
+ sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
210
+ wheels = [
211
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
212
+ ]
213
+
214
+ [[package]]
215
+ name = "typing-extensions"
216
+ version = "4.16.0"
217
+ source = { registry = "https://pypi.org/simple" }
218
+ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
219
+ wheels = [
220
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
221
+ ]
222
+
223
+ [[package]]
224
+ name = "urllib3"
225
+ version = "2.7.0"
226
+ source = { registry = "https://pypi.org/simple" }
227
+ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
228
+ wheels = [
229
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
230
+ ]