lucid-graphql 0.2.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,315 @@
1
+ Metadata-Version: 2.4
2
+ Name: lucid-graphql
3
+ Version: 0.2.0
4
+ Summary: Turn natural-language questions into valid GraphQL queries with an agentic LLM workflow.
5
+ Keywords: graphql,llm,agent,natural-language
6
+ Author: Martin Galpin
7
+ Author-email: Martin Galpin <galpin@galpin.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Dist: strands-agents>=1.0
15
+ Requires-Dist: graphql-core>=3.2
16
+ Requires-Dist: httpx>=0.27
17
+ Requires-Dist: click>=8.1
18
+ Requires-Dist: strands-agents[anthropic]>=1.0 ; extra == 'anthropic'
19
+ Requires-Dist: strands-agents[litellm]>=1.0 ; extra == 'litellm'
20
+ Requires-Python: >=3.13
21
+ Project-URL: Homepage, https://github.com/galpin/lucid
22
+ Provides-Extra: anthropic
23
+ Provides-Extra: litellm
24
+ Description-Content-Type: text/markdown
25
+
26
+ # lucid
27
+
28
+ **lucid** turns a natural-language question into a valid GraphQL query — and, if you
29
+ want, executes it and hands you the data.
30
+
31
+ ```python
32
+ import lucid
33
+
34
+ SpaceX = "https://spacex-production.up.railway.app/"
35
+
36
+ response = lucid.ask("the 5 latest launches and their rocket names", url=SpaceX)
37
+
38
+ response.data # dict — the raw GraphQL `data` payload
39
+ response.query # str — the generated GraphQL query that produced it
40
+ response.errors # list — GraphQL errors, if any
41
+ ```
42
+
43
+ Query generation is driven by an agentic workflow built on the
44
+ [Strands Agents SDK](https://strandsagents.com). The agent authors the query by
45
+ navigating the schema through tools — searching it, inspecting individual types,
46
+ validating candidates — so the full schema **never enters the model context**. That is
47
+ what makes lucid cheap and scalable on very large schemas.
48
+
49
+ ## Install
50
+
51
+ ```sh
52
+ uv add lucid-graphql # or: pip install lucid-graphql
53
+ uv add "lucid-graphql[litellm]" # to select models by LiteLLM id string
54
+ ```
55
+
56
+ ## Command line
57
+
58
+ Ask an endpoint a question straight from the terminal — no install needed with
59
+ `uvx` (the `--from` form pulls the Anthropic provider extra; set
60
+ `ANTHROPIC_API_KEY`):
61
+
62
+ ```sh
63
+ uvx --from "lucid-graphql[anthropic]" lucid \
64
+ "the 5 latest launches and their rocket names" \
65
+ --url https://spacex-production.up.railway.app/
66
+
67
+ # or install it as a tool:
68
+ uv tool install "lucid-graphql[anthropic]"
69
+ lucid "the 5 latest launches and their rocket names" --url https://spacex-production.up.railway.app/
70
+ ```
71
+
72
+ The result data prints as JSON on stdout. `--generate/-g` prints the generated
73
+ query instead of executing it; `--verbose/-v` streams the agent's progress
74
+ (thinking, tool calls, attempts) to stderr, so stdout stays pipeable:
75
+
76
+ ```sh
77
+ lucid "electric SUVs under \$60k" --url $CARS -v | jq '.cars[].model.name'
78
+ ```
79
+
80
+ ## Usage
81
+
82
+ Generate a query without executing it:
83
+
84
+ ```python
85
+ query: str = lucid.generate("the 5 latest launches and their rocket names", url=SpaceX)
86
+ ```
87
+
88
+ Custom HTTP headers (auth tokens, etc.) are honoured on every request lucid makes —
89
+ introspection and execution alike:
90
+
91
+ ```python
92
+ response = lucid.ask(
93
+ "my recent orders",
94
+ url="https://api.example.com/graphql",
95
+ headers={"Authorization": "Bearer <token>"},
96
+ )
97
+ ```
98
+
99
+ Create a client that closes over the endpoint and model configuration:
100
+
101
+ ```python
102
+ client = lucid.create(
103
+ url=SpaceX,
104
+ headers={"Authorization": "Bearer <token>"},
105
+ model="openrouter/anthropic/claude-3.5-sonnet",
106
+ )
107
+ client.ask("...")
108
+ client.generate("...")
109
+ ```
110
+
111
+ ### Steering generation with instructions
112
+
113
+ Applications embedding lucid can append domain guidance to the agent's system
114
+ prompt with `instructions=`. It steers *how* queries are written — conventions,
115
+ field preferences, limits — while the workflow contract (schema navigation,
116
+ mandatory validation) stays intact and cannot be overridden:
117
+
118
+ ```python
119
+ client = lucid.create(
120
+ url=API,
121
+ instructions=(
122
+ "Always include the id field on every record.\n"
123
+ "'latest' or 'newest' means orderBy: YEAR_DESC.\n"
124
+ "Never fetch more than 50 items in one request."
125
+ ),
126
+ )
127
+ ```
128
+
129
+ ### Endpoints with introspection disabled
130
+
131
+ Many production endpoints disable introspection. Supply the schema directly with
132
+ `schema=` and introspection is never attempted — SDL text, a `.graphql`/`.gql` file,
133
+ a saved introspection-result JSON file, and a built `graphql.GraphQLSchema` are all
134
+ accepted and auto-detected:
135
+
136
+ ```python
137
+ response = lucid.ask(
138
+ "the 5 latest cars and their manufacturer names",
139
+ url="https://api.example.com/graphql", # still needed to execute
140
+ schema="schema.graphql",
141
+ headers={"Authorization": "Bearer <token>"},
142
+ )
143
+
144
+ # Generate-only needs no url at all — fully offline.
145
+ query = lucid.generate("electric SUVs under $60k", schema="schema.graphql")
146
+ ```
147
+
148
+ An explicit `schema` wins over the on-disk cache, which wins over live
149
+ introspection. If introspection is attempted and the endpoint refuses it, lucid
150
+ raises `SchemaError` with a pointer to `schema=`.
151
+
152
+ ### Watching progress (experimental)
153
+
154
+ `ask_stream` is `ask` with progress events — same pipeline, instrumented. Each event
155
+ has a ready-to-print `message`; kinds are `thinking`, `tool`, `attempt` (one per
156
+ validation/execution round, carrying the error that was fed back), and a final
157
+ `done` carrying the `Response`. The event schema is a prototype and may change.
158
+
159
+ ```python
160
+ for event in lucid.ask_stream("the 5 latest cars and their rocket names", url=Cars):
161
+ print(event.message)
162
+ response = event.response # the final event is kind="done"
163
+
164
+ # Or skip the loop:
165
+ response = lucid.ask_stream(question, url=Cars, on_event=lambda e: print(e.message)).result()
166
+ ```
167
+
168
+ `generate_stream` is the same for the generate-only path (works offline with
169
+ `schema=`, no `url` needed): its `done` event carries the validated query and
170
+ `.result()` returns it as a `str`.
171
+
172
+ ```python
173
+ query = lucid.generate_stream(
174
+ "electric SUVs under $60k",
175
+ schema="schema.graphql",
176
+ on_event=lambda e: print(e.message),
177
+ ).result()
178
+ ```
179
+
180
+ ## Choosing a model
181
+
182
+ Provider choice is one line. `model` accepts:
183
+
184
+ - **Nothing** — defaults to Anthropic Claude (requires `lucid-graphql[anthropic]` and
185
+ `ANTHROPIC_API_KEY`).
186
+ - **A string** — treated as a [LiteLLM](https://docs.litellm.ai) model id (requires
187
+ `lucid-graphql[litellm]`); the provider prefix swaps the whole backend:
188
+
189
+ | model | reads |
190
+ | ------------------------------------------ | -------------------- |
191
+ | `"anthropic/claude-3.5-sonnet"` | `ANTHROPIC_API_KEY` |
192
+ | `"openai/gpt-4o"` | `OPENAI_API_KEY` |
193
+ | `"openrouter/anthropic/claude-3.5-sonnet"` | `OPENROUTER_API_KEY` |
194
+ | `"ollama/llama3"` | local Ollama, no key |
195
+
196
+ - **Any Strands model instance** — the escape hatch for full control:
197
+
198
+ ```python
199
+ import os
200
+ from strands.models.litellm import LiteLLMModel
201
+
202
+ model = LiteLLMModel(
203
+ client_args={
204
+ "api_key": os.environ["OPENROUTER_API_KEY"],
205
+ "base_url": "https://openrouter.ai/api/v1",
206
+ },
207
+ model_id="anthropic/claude-3.5-sonnet",
208
+ params={"temperature": 0},
209
+ )
210
+ lucid.ask("...", url=SpaceX, model=model)
211
+ ```
212
+
213
+ Temperature defaults to 0 for determinism.
214
+
215
+ ## How it works
216
+
217
+ 1. **Introspect (deterministic, no LLM).** The standard introspection query runs once
218
+ per endpoint; the schema is converted to SDL and cached on disk, keyed by a hash of
219
+ the URL (`cache_dir` to relocate, `refresh_schema=True` to re-introspect).
220
+ 2. **Author (the agent).** A Strands agent explores the schema file through tools —
221
+ `list_root_fields`, `search_schema`, `get_type` — and drafts a query. It never sees
222
+ the whole schema.
223
+ 3. **Validate.** The agent must pass `validate_query` (graphql-core validation against
224
+ the real schema) before it can finish; validation errors are fed back for
225
+ self-correction.
226
+ 4. **Execute (`ask` only).** The query runs against the endpoint; execution errors are
227
+ fed back the same way. The loop is hard-capped by `max_iterations` (default 5).
228
+
229
+ Exhausting the cap raises a typed error carrying the last query and errors:
230
+ `QueryValidationError` or `QueryExecutionError`. All lucid failures subclass
231
+ `LucidError` — `SchemaError` for introspection/schema problems and `TransportError`
232
+ when the endpoint can't be reached (connection refused, timeout) — so callers never
233
+ see a raw `httpx` exception.
234
+
235
+ ## Testing without a network
236
+
237
+ Transport is a two-method seam (`introspect()` / `execute()`). The default is httpx;
238
+ tests inject an in-process transport that runs against a local executable schema:
239
+
240
+ ```python
241
+ client = lucid.create(url="local://cars", model=model, transport=my_local_transport)
242
+ ```
243
+
244
+ See `tests/cars.py` for a complete example — a deliberately large car-marketplace
245
+ schema with interfaces, a union, Relay pagination, input filters, and mock data.
246
+ The test suite runs every core scenario twice: once through the in-process
247
+ transport, and once over real HTTP against an in-process localhost server, so the
248
+ default httpx transport (serialization, headers, status handling) is covered too.
249
+
250
+ ## Evaluating models
251
+
252
+ `evals/` contains an opt-in bake-off harness (real API keys, spends money, never runs
253
+ in CI), built on the [Strands Evals SDK](https://github.com/strands-agents/evals):
254
+ each question is a `strands_evals.Case`, one `Experiment` runs per model, and a
255
+ custom result-equivalence `Evaluator` grades correctness objectively — the generated
256
+ query and a hand-written reference execute against the same in-process schema and
257
+ their normalized results are compared. Latency, tokens, cost, and correction
258
+ iterations are measured over the whole agent trajectory.
259
+
260
+ ```sh
261
+ just evals # the standard OpenRouter model set (needs OPENROUTER_API_KEY)
262
+ just evals "ollama/llama3" 5 # any model list and repeat count
263
+
264
+ # or drive the runner directly:
265
+ uv run python -m evals.runner \
266
+ --models "openrouter/anthropic/claude-3.5-sonnet,openai/gpt-4o,openai/gpt-4o-mini,ollama/llama3" \
267
+ --repeats 5
268
+ ```
269
+
270
+ The report prints a per-model table and highlights the Pareto frontier across
271
+ accuracy, latency, and cost. `cost/correct` is usually the column to decide by.
272
+
273
+ ## Development
274
+
275
+ Day-to-day tasks are [Just](https://just.systems) recipes:
276
+
277
+ ```sh
278
+ just sync # uv sync --all-extras
279
+ just test # pytest (pass args through: just test -k schema)
280
+ just lint # ruff check + format check
281
+ just typecheck # ty
282
+ just check # lint + typecheck + test
283
+ just build # wheel + sdist into dist/
284
+ just evals # model bake-off (OpenRouter by default)
285
+ ```
286
+
287
+ One live end-to-end test against the SpaceX API is gated behind
288
+ `LUCID_LIVE=1` + `ANTHROPIC_API_KEY` and skipped everywhere else.
289
+
290
+ ### Releasing
291
+
292
+ The version lives in one place (`pyproject.toml`) and is exposed at runtime as
293
+ `lucid.__version__`. Cutting a release is one command — it runs the full check
294
+ suite, bumps the version ([SemVer](https://semver.org)), stamps the `CHANGELOG`,
295
+ tags, publishes to PyPI, and pushes:
296
+
297
+ ```sh
298
+ just release patch # 0.1.0 -> 0.1.1 (bug fixes)
299
+ just release minor # 0.1.0 -> 0.2.0 (backwards-compatible features)
300
+ just release major # 0.1.0 -> 1.0.0 (breaking changes)
301
+ ```
302
+
303
+ It needs a clean tree and `UV_PUBLISH_TOKEN` (loaded from `.env`). Publish runs
304
+ before the push, so a failed upload leaves nothing pushed to recover from. Move
305
+ the changes you're releasing into a `## [Unreleased]` CHANGELOG section first.
306
+
307
+ ## Non-goals (v1)
308
+
309
+ - Data-frame transformation — that's [pluck](https://github.com/galpin/pluck).
310
+ - Mutations and subscriptions.
311
+ - CLI or web UI.
312
+
313
+ ## License
314
+
315
+ MIT
@@ -0,0 +1,290 @@
1
+ # lucid
2
+
3
+ **lucid** turns a natural-language question into a valid GraphQL query — and, if you
4
+ want, executes it and hands you the data.
5
+
6
+ ```python
7
+ import lucid
8
+
9
+ SpaceX = "https://spacex-production.up.railway.app/"
10
+
11
+ response = lucid.ask("the 5 latest launches and their rocket names", url=SpaceX)
12
+
13
+ response.data # dict — the raw GraphQL `data` payload
14
+ response.query # str — the generated GraphQL query that produced it
15
+ response.errors # list — GraphQL errors, if any
16
+ ```
17
+
18
+ Query generation is driven by an agentic workflow built on the
19
+ [Strands Agents SDK](https://strandsagents.com). The agent authors the query by
20
+ navigating the schema through tools — searching it, inspecting individual types,
21
+ validating candidates — so the full schema **never enters the model context**. That is
22
+ what makes lucid cheap and scalable on very large schemas.
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ uv add lucid-graphql # or: pip install lucid-graphql
28
+ uv add "lucid-graphql[litellm]" # to select models by LiteLLM id string
29
+ ```
30
+
31
+ ## Command line
32
+
33
+ Ask an endpoint a question straight from the terminal — no install needed with
34
+ `uvx` (the `--from` form pulls the Anthropic provider extra; set
35
+ `ANTHROPIC_API_KEY`):
36
+
37
+ ```sh
38
+ uvx --from "lucid-graphql[anthropic]" lucid \
39
+ "the 5 latest launches and their rocket names" \
40
+ --url https://spacex-production.up.railway.app/
41
+
42
+ # or install it as a tool:
43
+ uv tool install "lucid-graphql[anthropic]"
44
+ lucid "the 5 latest launches and their rocket names" --url https://spacex-production.up.railway.app/
45
+ ```
46
+
47
+ The result data prints as JSON on stdout. `--generate/-g` prints the generated
48
+ query instead of executing it; `--verbose/-v` streams the agent's progress
49
+ (thinking, tool calls, attempts) to stderr, so stdout stays pipeable:
50
+
51
+ ```sh
52
+ lucid "electric SUVs under \$60k" --url $CARS -v | jq '.cars[].model.name'
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ Generate a query without executing it:
58
+
59
+ ```python
60
+ query: str = lucid.generate("the 5 latest launches and their rocket names", url=SpaceX)
61
+ ```
62
+
63
+ Custom HTTP headers (auth tokens, etc.) are honoured on every request lucid makes —
64
+ introspection and execution alike:
65
+
66
+ ```python
67
+ response = lucid.ask(
68
+ "my recent orders",
69
+ url="https://api.example.com/graphql",
70
+ headers={"Authorization": "Bearer <token>"},
71
+ )
72
+ ```
73
+
74
+ Create a client that closes over the endpoint and model configuration:
75
+
76
+ ```python
77
+ client = lucid.create(
78
+ url=SpaceX,
79
+ headers={"Authorization": "Bearer <token>"},
80
+ model="openrouter/anthropic/claude-3.5-sonnet",
81
+ )
82
+ client.ask("...")
83
+ client.generate("...")
84
+ ```
85
+
86
+ ### Steering generation with instructions
87
+
88
+ Applications embedding lucid can append domain guidance to the agent's system
89
+ prompt with `instructions=`. It steers *how* queries are written — conventions,
90
+ field preferences, limits — while the workflow contract (schema navigation,
91
+ mandatory validation) stays intact and cannot be overridden:
92
+
93
+ ```python
94
+ client = lucid.create(
95
+ url=API,
96
+ instructions=(
97
+ "Always include the id field on every record.\n"
98
+ "'latest' or 'newest' means orderBy: YEAR_DESC.\n"
99
+ "Never fetch more than 50 items in one request."
100
+ ),
101
+ )
102
+ ```
103
+
104
+ ### Endpoints with introspection disabled
105
+
106
+ Many production endpoints disable introspection. Supply the schema directly with
107
+ `schema=` and introspection is never attempted — SDL text, a `.graphql`/`.gql` file,
108
+ a saved introspection-result JSON file, and a built `graphql.GraphQLSchema` are all
109
+ accepted and auto-detected:
110
+
111
+ ```python
112
+ response = lucid.ask(
113
+ "the 5 latest cars and their manufacturer names",
114
+ url="https://api.example.com/graphql", # still needed to execute
115
+ schema="schema.graphql",
116
+ headers={"Authorization": "Bearer <token>"},
117
+ )
118
+
119
+ # Generate-only needs no url at all — fully offline.
120
+ query = lucid.generate("electric SUVs under $60k", schema="schema.graphql")
121
+ ```
122
+
123
+ An explicit `schema` wins over the on-disk cache, which wins over live
124
+ introspection. If introspection is attempted and the endpoint refuses it, lucid
125
+ raises `SchemaError` with a pointer to `schema=`.
126
+
127
+ ### Watching progress (experimental)
128
+
129
+ `ask_stream` is `ask` with progress events — same pipeline, instrumented. Each event
130
+ has a ready-to-print `message`; kinds are `thinking`, `tool`, `attempt` (one per
131
+ validation/execution round, carrying the error that was fed back), and a final
132
+ `done` carrying the `Response`. The event schema is a prototype and may change.
133
+
134
+ ```python
135
+ for event in lucid.ask_stream("the 5 latest cars and their rocket names", url=Cars):
136
+ print(event.message)
137
+ response = event.response # the final event is kind="done"
138
+
139
+ # Or skip the loop:
140
+ response = lucid.ask_stream(question, url=Cars, on_event=lambda e: print(e.message)).result()
141
+ ```
142
+
143
+ `generate_stream` is the same for the generate-only path (works offline with
144
+ `schema=`, no `url` needed): its `done` event carries the validated query and
145
+ `.result()` returns it as a `str`.
146
+
147
+ ```python
148
+ query = lucid.generate_stream(
149
+ "electric SUVs under $60k",
150
+ schema="schema.graphql",
151
+ on_event=lambda e: print(e.message),
152
+ ).result()
153
+ ```
154
+
155
+ ## Choosing a model
156
+
157
+ Provider choice is one line. `model` accepts:
158
+
159
+ - **Nothing** — defaults to Anthropic Claude (requires `lucid-graphql[anthropic]` and
160
+ `ANTHROPIC_API_KEY`).
161
+ - **A string** — treated as a [LiteLLM](https://docs.litellm.ai) model id (requires
162
+ `lucid-graphql[litellm]`); the provider prefix swaps the whole backend:
163
+
164
+ | model | reads |
165
+ | ------------------------------------------ | -------------------- |
166
+ | `"anthropic/claude-3.5-sonnet"` | `ANTHROPIC_API_KEY` |
167
+ | `"openai/gpt-4o"` | `OPENAI_API_KEY` |
168
+ | `"openrouter/anthropic/claude-3.5-sonnet"` | `OPENROUTER_API_KEY` |
169
+ | `"ollama/llama3"` | local Ollama, no key |
170
+
171
+ - **Any Strands model instance** — the escape hatch for full control:
172
+
173
+ ```python
174
+ import os
175
+ from strands.models.litellm import LiteLLMModel
176
+
177
+ model = LiteLLMModel(
178
+ client_args={
179
+ "api_key": os.environ["OPENROUTER_API_KEY"],
180
+ "base_url": "https://openrouter.ai/api/v1",
181
+ },
182
+ model_id="anthropic/claude-3.5-sonnet",
183
+ params={"temperature": 0},
184
+ )
185
+ lucid.ask("...", url=SpaceX, model=model)
186
+ ```
187
+
188
+ Temperature defaults to 0 for determinism.
189
+
190
+ ## How it works
191
+
192
+ 1. **Introspect (deterministic, no LLM).** The standard introspection query runs once
193
+ per endpoint; the schema is converted to SDL and cached on disk, keyed by a hash of
194
+ the URL (`cache_dir` to relocate, `refresh_schema=True` to re-introspect).
195
+ 2. **Author (the agent).** A Strands agent explores the schema file through tools —
196
+ `list_root_fields`, `search_schema`, `get_type` — and drafts a query. It never sees
197
+ the whole schema.
198
+ 3. **Validate.** The agent must pass `validate_query` (graphql-core validation against
199
+ the real schema) before it can finish; validation errors are fed back for
200
+ self-correction.
201
+ 4. **Execute (`ask` only).** The query runs against the endpoint; execution errors are
202
+ fed back the same way. The loop is hard-capped by `max_iterations` (default 5).
203
+
204
+ Exhausting the cap raises a typed error carrying the last query and errors:
205
+ `QueryValidationError` or `QueryExecutionError`. All lucid failures subclass
206
+ `LucidError` — `SchemaError` for introspection/schema problems and `TransportError`
207
+ when the endpoint can't be reached (connection refused, timeout) — so callers never
208
+ see a raw `httpx` exception.
209
+
210
+ ## Testing without a network
211
+
212
+ Transport is a two-method seam (`introspect()` / `execute()`). The default is httpx;
213
+ tests inject an in-process transport that runs against a local executable schema:
214
+
215
+ ```python
216
+ client = lucid.create(url="local://cars", model=model, transport=my_local_transport)
217
+ ```
218
+
219
+ See `tests/cars.py` for a complete example — a deliberately large car-marketplace
220
+ schema with interfaces, a union, Relay pagination, input filters, and mock data.
221
+ The test suite runs every core scenario twice: once through the in-process
222
+ transport, and once over real HTTP against an in-process localhost server, so the
223
+ default httpx transport (serialization, headers, status handling) is covered too.
224
+
225
+ ## Evaluating models
226
+
227
+ `evals/` contains an opt-in bake-off harness (real API keys, spends money, never runs
228
+ in CI), built on the [Strands Evals SDK](https://github.com/strands-agents/evals):
229
+ each question is a `strands_evals.Case`, one `Experiment` runs per model, and a
230
+ custom result-equivalence `Evaluator` grades correctness objectively — the generated
231
+ query and a hand-written reference execute against the same in-process schema and
232
+ their normalized results are compared. Latency, tokens, cost, and correction
233
+ iterations are measured over the whole agent trajectory.
234
+
235
+ ```sh
236
+ just evals # the standard OpenRouter model set (needs OPENROUTER_API_KEY)
237
+ just evals "ollama/llama3" 5 # any model list and repeat count
238
+
239
+ # or drive the runner directly:
240
+ uv run python -m evals.runner \
241
+ --models "openrouter/anthropic/claude-3.5-sonnet,openai/gpt-4o,openai/gpt-4o-mini,ollama/llama3" \
242
+ --repeats 5
243
+ ```
244
+
245
+ The report prints a per-model table and highlights the Pareto frontier across
246
+ accuracy, latency, and cost. `cost/correct` is usually the column to decide by.
247
+
248
+ ## Development
249
+
250
+ Day-to-day tasks are [Just](https://just.systems) recipes:
251
+
252
+ ```sh
253
+ just sync # uv sync --all-extras
254
+ just test # pytest (pass args through: just test -k schema)
255
+ just lint # ruff check + format check
256
+ just typecheck # ty
257
+ just check # lint + typecheck + test
258
+ just build # wheel + sdist into dist/
259
+ just evals # model bake-off (OpenRouter by default)
260
+ ```
261
+
262
+ One live end-to-end test against the SpaceX API is gated behind
263
+ `LUCID_LIVE=1` + `ANTHROPIC_API_KEY` and skipped everywhere else.
264
+
265
+ ### Releasing
266
+
267
+ The version lives in one place (`pyproject.toml`) and is exposed at runtime as
268
+ `lucid.__version__`. Cutting a release is one command — it runs the full check
269
+ suite, bumps the version ([SemVer](https://semver.org)), stamps the `CHANGELOG`,
270
+ tags, publishes to PyPI, and pushes:
271
+
272
+ ```sh
273
+ just release patch # 0.1.0 -> 0.1.1 (bug fixes)
274
+ just release minor # 0.1.0 -> 0.2.0 (backwards-compatible features)
275
+ just release major # 0.1.0 -> 1.0.0 (breaking changes)
276
+ ```
277
+
278
+ It needs a clean tree and `UV_PUBLISH_TOKEN` (loaded from `.env`). Publish runs
279
+ before the push, so a failed upload leaves nothing pushed to recover from. Move
280
+ the changes you're releasing into a `## [Unreleased]` CHANGELOG section first.
281
+
282
+ ## Non-goals (v1)
283
+
284
+ - Data-frame transformation — that's [pluck](https://github.com/galpin/pluck).
285
+ - Mutations and subscriptions.
286
+ - CLI or web UI.
287
+
288
+ ## License
289
+
290
+ MIT
@@ -0,0 +1,80 @@
1
+ [project]
2
+ name = "lucid-graphql"
3
+ version = "0.2.0"
4
+ description = "Turn natural-language questions into valid GraphQL queries with an agentic LLM workflow."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Martin Galpin", email = "galpin@galpin.com" }
8
+ ]
9
+ license = "MIT"
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "strands-agents>=1.0",
13
+ "graphql-core>=3.2",
14
+ "httpx>=0.27",
15
+ "click>=8.1",
16
+ ]
17
+ keywords = ["graphql", "llm", "agent", "natural-language"]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Intended Audience :: Developers",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: Software Development :: Libraries",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ litellm = [
28
+ "strands-agents[litellm]>=1.0",
29
+ ]
30
+ anthropic = [
31
+ "strands-agents[anthropic]>=1.0",
32
+ ]
33
+
34
+ [project.scripts]
35
+ lucid = "lucid.cli:main"
36
+ lucid-graphql = "lucid.cli:main"
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/galpin/lucid"
40
+
41
+ [dependency-groups]
42
+ evals = [
43
+ "strands-agents-evals>=0.1",
44
+ "pyyaml>=6.0",
45
+ ]
46
+ dev = [
47
+ "pytest>=8.0",
48
+ "respx>=0.22",
49
+ "ruff>=0.9",
50
+ "ty>=0.0.1a15",
51
+ "pre-commit>=4.0",
52
+ { include-group = "evals" },
53
+ ]
54
+
55
+ [build-system]
56
+ requires = ["uv_build>=0.9.21,<0.10.0"]
57
+ build-backend = "uv_build"
58
+
59
+ [tool.uv.build-backend]
60
+ module-name = "lucid"
61
+
62
+ [tool.ty.environment]
63
+ python-version = "3.13"
64
+
65
+ [tool.ruff]
66
+ line-length = 100
67
+ target-version = "py313"
68
+
69
+ [tool.ruff.lint]
70
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
71
+
72
+ [tool.ruff.lint.per-file-ignores]
73
+ "tests/cars.py" = ["E501"] # seed-data tables read better as one row per line
74
+
75
+ [tool.pytest.ini_options]
76
+ testpaths = ["tests"]
77
+ markers = [
78
+ "live: tests that hit live endpoints and real LLMs (deselected by default)",
79
+ ]
80
+ addopts = "-m 'not live'"