programasweights 0.4.2__tar.gz → 0.4.3__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.
Files changed (65) hide show
  1. programasweights-0.4.3/.github/workflows/test.yml +41 -0
  2. {programasweights-0.4.2 → programasweights-0.4.3}/CHANGELOG.md +12 -0
  3. {programasweights-0.4.2 → programasweights-0.4.3}/PKG-INFO +1 -1
  4. {programasweights-0.4.2 → programasweights-0.4.3}/docs/api-reference/python-sdk.md +2 -2
  5. {programasweights-0.4.2 → programasweights-0.4.3}/docs/api-reference/rest-api.md +6 -1
  6. programasweights-0.4.3/docs/case-studies/alien-taboo.md +117 -0
  7. {programasweights-0.4.2 → programasweights-0.4.3}/mkdocs.yml +1 -0
  8. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/__init__.py +1 -1
  9. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/cli.py +16 -24
  10. {programasweights-0.4.2 → programasweights-0.4.3}/pyproject.toml +1 -1
  11. programasweights-0.4.3/tests/test_cli_auth.py +186 -0
  12. {programasweights-0.4.2 → programasweights-0.4.3}/tests/test_sdk.py +60 -102
  13. {programasweights-0.4.2 → programasweights-0.4.3}/.gitignore +0 -0
  14. {programasweights-0.4.2 → programasweights-0.4.3}/.readthedocs.yaml +0 -0
  15. {programasweights-0.4.2 → programasweights-0.4.3}/AGENTS.md +0 -0
  16. {programasweights-0.4.2 → programasweights-0.4.3}/LICENSE +0 -0
  17. {programasweights-0.4.2 → programasweights-0.4.3}/PYPI_README.md +0 -0
  18. {programasweights-0.4.2 → programasweights-0.4.3}/README.md +0 -0
  19. {programasweights-0.4.2 → programasweights-0.4.3}/docs/adr/001-llama-cpp-over-pytorch.md +0 -0
  20. {programasweights-0.4.2 → programasweights-0.4.3}/docs/adr/002-q4_0-adapter-format.md +0 -0
  21. {programasweights-0.4.2 → programasweights-0.4.3}/docs/adr/003-single-spec-field.md +0 -0
  22. {programasweights-0.4.2 → programasweights-0.4.3}/docs/adr/004-compiler-naming.md +0 -0
  23. {programasweights-0.4.2 → programasweights-0.4.3}/docs/adr/005-vllm-hidden-states.md +0 -0
  24. {programasweights-0.4.2 → programasweights-0.4.3}/docs/adr/006-email-api-key-auth.md +0 -0
  25. {programasweights-0.4.2 → programasweights-0.4.3}/docs/advanced/adrs.md +0 -0
  26. {programasweights-0.4.2 → programasweights-0.4.3}/docs/advanced/architecture.md +0 -0
  27. {programasweights-0.4.2 → programasweights-0.4.3}/docs/api-reference/cli.md +0 -0
  28. {programasweights-0.4.2 → programasweights-0.4.3}/docs/architecture.md +0 -0
  29. {programasweights-0.4.2 → programasweights-0.4.3}/docs/case-studies/log-monitoring.md +0 -0
  30. {programasweights-0.4.2 → programasweights-0.4.3}/docs/case-studies/semantic-search.md +0 -0
  31. {programasweights-0.4.2 → programasweights-0.4.3}/docs/case-studies/site-navigation.md +0 -0
  32. {programasweights-0.4.2 → programasweights-0.4.3}/docs/case-studies/tool-calling.md +0 -0
  33. {programasweights-0.4.2 → programasweights-0.4.3}/docs/getting-started/first-program.md +0 -0
  34. {programasweights-0.4.2 → programasweights-0.4.3}/docs/getting-started/installation.md +0 -0
  35. {programasweights-0.4.2 → programasweights-0.4.3}/docs/getting-started/naming-programs.md +0 -0
  36. {programasweights-0.4.2 → programasweights-0.4.3}/docs/guide/browser-inference.md +0 -0
  37. {programasweights-0.4.2 → programasweights-0.4.3}/docs/guide/how-it-works.md +0 -0
  38. {programasweights-0.4.2 → programasweights-0.4.3}/docs/guide/local-inference.md +0 -0
  39. {programasweights-0.4.2 → programasweights-0.4.3}/docs/guide/writing-good-specs.md +0 -0
  40. {programasweights-0.4.2 → programasweights-0.4.3}/docs/hub/browsing-programs.md +0 -0
  41. {programasweights-0.4.2 → programasweights-0.4.3}/docs/hub/feedback-cases.md +0 -0
  42. {programasweights-0.4.2 → programasweights-0.4.3}/docs/hub/publishing-programs.md +0 -0
  43. {programasweights-0.4.2 → programasweights-0.4.3}/docs/index.md +0 -0
  44. {programasweights-0.4.2 → programasweights-0.4.3}/docs/requirements.txt +0 -0
  45. {programasweights-0.4.2 → programasweights-0.4.3}/examples/flask_app.py +0 -0
  46. {programasweights-0.4.2 → programasweights-0.4.3}/examples/jupyter_notebook.py +0 -0
  47. {programasweights-0.4.2 → programasweights-0.4.3}/examples/langchain_integration.py +0 -0
  48. {programasweights-0.4.2 → programasweights-0.4.3}/examples/paw_monitor.py +0 -0
  49. {programasweights-0.4.2 → programasweights-0.4.3}/examples/replace_openai.py +0 -0
  50. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/_output.py +0 -0
  51. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/artifacts.py +0 -0
  52. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/cache.py +0 -0
  53. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/client.py +0 -0
  54. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/compiler/__init__.py +0 -0
  55. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/compiler/dummy.py +0 -0
  56. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/config.py +0 -0
  57. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/convert_peft_to_paw.py +0 -0
  58. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/paw_format.py +0 -0
  59. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/runtime/__init__.py +0 -0
  60. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/runtime/interpreter.py +0 -0
  61. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/runtime/interpreter_onnx.py +0 -0
  62. {programasweights-0.4.2 → programasweights-0.4.3}/programasweights/runtime_llamacpp.py +0 -0
  63. {programasweights-0.4.2 → programasweights-0.4.3}/tests/test_offline_cache.py +0 -0
  64. {programasweights-0.4.2 → programasweights-0.4.3}/tests/test_runtime_registry_sdk.py +0 -0
  65. {programasweights-0.4.2 → programasweights-0.4.3}/tests/test_sdk.sh +0 -0
@@ -0,0 +1,41 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Set up Python ${{ matrix.python-version }}
19
+ uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+
23
+ - name: Install (hermetic deps only)
24
+ # Install httpx + pytest and the package itself without pulling the heavy
25
+ # llama-cpp-python build. The hermetic tests below never import the
26
+ # llama.cpp runtime (it is imported lazily only when running inference).
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ python -m pip install httpx pytest
30
+ python -m pip install -e . --no-deps
31
+
32
+ - name: Run hermetic tests
33
+ # Scoped to tests that need no network, no model download, and no
34
+ # PAW_API_KEY. Auth tests (@needs_auth) auto-skip without a key; the
35
+ # network/model-download tests in test_sdk.py are excluded here and can
36
+ # be run separately against a live server.
37
+ run: |
38
+ pytest \
39
+ tests/test_cli_auth.py \
40
+ tests/test_runtime_registry_sdk.py \
41
+ tests/test_sdk.py::TestInstallAndImport
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.3 (2026-07-06)
4
+
5
+ - Fix `paw info` / `paw rename` crashing with `AttributeError` when run without
6
+ `--api-url`/`--api-key` (they read the removed `paw.api_url`/`paw.api_key`
7
+ module attributes; now resolved via the function-based config API).
8
+ - `--api-url` / `--api-key` now take effect on `compile`, `run`, and `login`
9
+ (previously accepted but silently ignored on those commands).
10
+ - Docs: reference `paw.get_api_url()` / `paw.get_api_key()` instead of the
11
+ removed module attributes.
12
+ - Add hermetic CLI auth test suite (`tests/test_cli_auth.py`) and a GitHub
13
+ Actions CI workflow running it on Python 3.9-3.12.
14
+
3
15
  ## 0.2.4 (2026-04-01)
4
16
 
5
17
  - Download reliability: asset endpoints return 202 Retry-After when program is still generating, eliminating 404 race conditions
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programasweights
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Compile natural language specifications into neural programs that run locally via llama.cpp.
5
5
  Project-URL: Homepage, https://programasweights.com
6
6
  Project-URL: Repository, https://github.com/programasweights/programasweights-python
@@ -128,8 +128,8 @@ export PAW_API_KEY=paw_sk_...
128
128
 
129
129
  | Name | Description |
130
130
  |------|-------------|
131
- | `paw.api_url` | Base URL for API requests. Default: `https://programasweights.com`. Override with `PAW_API_URL` env var. |
132
- | `paw.api_key` | API key for authenticated calls. Set via `paw.login()` or `PAW_API_KEY` env var. |
131
+ | `paw.get_api_url()` | Base URL for API requests. Default: `https://programasweights.com`. Override with `PAW_API_URL` env var. |
132
+ | `paw.get_api_key()` | API key for authenticated calls. Set via `paw.login()` or `PAW_API_KEY` env var. |
133
133
  | `paw.__version__` | Installed package version string. |
134
134
 
135
135
  ## Related
@@ -158,7 +158,12 @@ Successful responses may include:
158
158
  | `X-RateLimit-Limit` | Maximum requests per window. |
159
159
  | `X-RateLimit-Remaining` | Remaining requests in the current window. |
160
160
 
161
- Clients should backoff when receiving `429 Too Many Requests` and respect `Retry-After` when present.
161
+ Hosted compile quotas:
162
+
163
+ - Anonymous: **20 compiles/hour**, **1 concurrent compile**
164
+ - Authenticated: **60 compiles/hour**, **2 concurrent compiles**
165
+
166
+ Hosted server-side endpoints may also enforce additional operational concurrency or safety limits. Clients should back off when receiving `429 Too Many Requests` and respect `Retry-After` when present.
162
167
 
163
168
  ## Related
164
169
 
@@ -0,0 +1,117 @@
1
+ # Alien Taboo: A Word-Guessing Game on a 0.6B Interpreter
2
+
3
+ Most of our case studies are about *infrastructure* — log triage, search reranking, agent routing. This one is a game. We wanted to show that a tiny PAW program is enough to power a real, playable, in-browser product, not just internal tooling.
4
+
5
+ **Try it live:** [programasweights.com/alien](https://programasweights.com/alien) (English) or [programasweights.com/alien-cn](https://programasweights.com/alien-cn) (中文). You describe a secret word, the alien guesses. The alien is one compiled PAW program running on a 0.6B-parameter interpreter — no GPT-4, no API roulette, just one .paw file and a Qwen3 0.6B base model.
6
+
7
+ ## Why this is interesting for PAW
8
+
9
+ Word-guessing is a textbook *fuzzy function*: easy to describe in one sentence, impossible to write as rules. "Given a player's hint, output the secret word." A keyword matcher would never bridge "fluffy thing that purrs" → `cat`. A retrieval system would force you to enumerate every possible hint. A 32B prompted LLM would work but you can't ship one to a browser-tab user for free.
10
+
11
+ A 0.6B interpreter with a focused PAW spec is exactly the right size: small enough to run on a single inference server with no per-request cost, expressive enough to recognise the dozens of ways a child might describe "watermelon."
12
+
13
+ ## How we built it
14
+
15
+ ### Attempt 1: One huge spec listing every word
16
+
17
+ The first instinct was to bake the whole word list into the spec — "the secret word is one of: cat, dog, banana, …" — so the model only ever picks from a known vocabulary.
18
+
19
+ **Result:** The spec ballooned to thousands of tokens, the compiler timed out, and accuracy *dropped*: the model started biasing toward whichever words appeared earliest in the list regardless of the description.
20
+
21
+ **Lesson:** Don't dump your domain into the spec. PAW programs work best when the spec teaches a *behaviour* with a few examples, not when it tries to be a database.
22
+
23
+ ### Attempt 2: Free-form generation with a few examples
24
+
25
+ We replaced the word list with a short instruction and ~15 input/output exemplars covering the diversity of hints (animals, foods, household objects, abstract concepts, multi-word objects).
26
+
27
+ ```
28
+ You are playing a word-guessing game. The user describes a common English
29
+ word in their own words without saying the word itself. Your job is to
30
+ guess the word from the description.
31
+
32
+ Return ONLY the single word being described. Lowercase. No punctuation,
33
+ no explanation, no extra words.
34
+
35
+ Input: furry animal that meows and purrs
36
+ Output: cat
37
+
38
+ Input: yellow curved fruit, monkeys like it
39
+ Output: banana
40
+
41
+ Input: thing you use to unlock a door, metal, small, has teeth
42
+ Output: key
43
+
44
+ ... (≈12 more)
45
+ ```
46
+
47
+ **Result:** Compiled cleanly, ran in ~80 ms per guess, and produced sensible answers for almost every test description. This is the version shipping today.
48
+
49
+ **Lesson:** This is the opposite of what we found in our [site navigation](site-navigation.md) and [semantic search](semantic-search.md) studies — there, generation hallucinated and we reframed as classification. Here, generation works because (a) the answer space is implicit (any English noun) so we couldn't classify even if we wanted to, and (b) the answer is short and over-determined by the input. **Generation works when the output is short and the input is rich.**
50
+
51
+ ### Attempt 3: The actual hard part — curating the word bank
52
+
53
+ With the alien working on the first realistic hint we threw at it, we expected to ship in an afternoon. Instead we spent days on word selection. The reason:
54
+
55
+ - **Dead words.** "Sundial" is a word every adult knows, but no one can describe it without saying "sun" or "shadow" or "time," all of which produce the wrong guess. A word that never gets solved isn't unfun for the alien — it's unfun for the *player*. Every dead word in the bank is a session-killing dead end.
56
+ - **Fairness.** "Bullfrog" passes the alien's accuracy test but stumps anyone under 12. Mass-appeal games need words a 6-year-old or non-native speaker can describe.
57
+ - **Variety.** A 50-word bank gets boring after two sessions. We wanted ≥300 viable words.
58
+
59
+ The vetting process became its own pipeline:
60
+
61
+ 1. **Generate candidates** with GPT-5.4 (~4000 raw words across 40 themes).
62
+ 2. **Simulate playthroughs** — for each candidate, prompt GPT-5.4-mini to play the role of a human describing the word; route those descriptions through the actual deployed alien program; keep words solved within ≤8 rounds across ≥4 of 5 random-seed trials.
63
+ 3. **Filter for commonness** with `wordfreq` (Zipf ≥ 5.0) so kids and ESL players have a fair shot.
64
+ 4. **Manual pass** by a human (us) on the survivors to remove anything ambiguous, edgy, or culturally narrow.
65
+
66
+ The final bank is 361 ultra-common English words. The full vetting script lives at [`server/scripts/vet_alien_words.py`](https://github.com/programasweights/website/blob/main/server/scripts/vet_alien_words.py).
67
+
68
+ **Lesson:** **The PAW program is usually the easy part of the product.** Treat it as one ingredient. The data, the UI, and the fairness work around the program are typically what take the time.
69
+
70
+ ### The multilingual finale: a Chinese version with one new file
71
+
72
+ After the English game shipped, we wanted a Chinese version. The intuition was that this would mean fine-tuning a Chinese-specific compiler, retraining the 0.6B interpreter on a Chinese corpus, or at minimum prompt-engineering around tokeniser quirks.
73
+
74
+ What we actually did:
75
+
76
+ 1. Wrote a new spec — `spec_cn.txt` — in Mandarin, with 20 Chinese hint→word examples.
77
+ 2. Compiled it with the same `paw-4b-qwen3-0.6b` compiler.
78
+ 3. Pointed a sister `/api/v1/alien-cn/guess` endpoint at the new program ID.
79
+
80
+ ```python
81
+ import programasweights as paw
82
+
83
+ with open("spec_cn.txt", encoding="utf-8") as f:
84
+ spec = f.read()
85
+
86
+ program = paw.compile(spec, compiler="paw-4b-qwen3-0.6b")
87
+ # That's it. program.id is now ready to serve Chinese players.
88
+ ```
89
+
90
+ The first compile produced **67.5% per-description accuracy** and **80% at-least-one-of-two accuracy** on a held-out 20-word Chinese test set, comparable to the English program's accuracy on its English test set. We changed exactly zero lines in the SDK or interpreter.
91
+
92
+ **Lesson:** **Spec language is just data.** PAW's compiler doesn't carry English-specific assumptions; the only language-aware things in the system are (a) your spec, and (b) any output post-processing your client does (we had to extend the server's "first-word extractor" from `[a-z]+` to also accept `[\u4e00-\u9fff]+`). For a multilingual product, this is several orders of magnitude less work than maintaining a separate stack per language.
93
+
94
+ ## The solution at a glance
95
+
96
+ The entire alien — both languages — is one PAW function per language plus thin glue:
97
+
98
+ ```python
99
+ import programasweights as paw
100
+
101
+ alien_en = paw.function(EN_PROGRAM_ID) # compiled from spec.txt
102
+ alien_cn = paw.function(CN_PROGRAM_ID) # compiled from spec_cn.txt
103
+
104
+ def guess(description: str, lang: str) -> str:
105
+ fn = alien_cn if lang == "zh" else alien_en
106
+ return fn(description).strip().lower()
107
+ ```
108
+
109
+ Everything else — the timer, the lives, the share card, the leaderboard — is plain product code. No model orchestration, no prompt-management framework, no second LLM behind it.
110
+
111
+ ## Takeaways
112
+
113
+ - **Generation is the right tool when the output is short and the input is rich.** Classification wins for routing; generation wins for "name this thing." Pick the right one for your task.
114
+ - **The PAW program is usually the easy part.** Most product time goes into data quality, UX, and edge cases — exactly as it would with any function in your codebase.
115
+ - **Use small models to vet content for small models.** Running GPT-5.4-mini in a multi-trial simulation against your deployed PAW program is faster and cheaper than human playtesting, and it scales to thousands of candidates overnight.
116
+ - **Spec language is just data.** A new spec in a new language is a new product, with no SDK or interpreter changes required.
117
+ - **A 0.6B interpreter is enough** for tasks where the input over-determines the output. You don't always need 70B; you need the right factoring.
@@ -52,6 +52,7 @@ nav:
52
52
  - Publishing Programs: hub/publishing-programs.md
53
53
  - Feedback & Cases: hub/feedback-cases.md
54
54
  - Case Studies:
55
+ - Alien Taboo (Word Game): case-studies/alien-taboo.md
55
56
  - Log Monitoring: case-studies/log-monitoring.md
56
57
  - Site Navigation: case-studies/site-navigation.md
57
58
  - Semantic Search: case-studies/semantic-search.md
@@ -27,7 +27,7 @@ try:
27
27
  from importlib.metadata import version as _meta_version
28
28
  __version__ = _meta_version("programasweights")
29
29
  except Exception:
30
- __version__ = "0.4.2"
30
+ __version__ = "0.4.3"
31
31
 
32
32
  from .config import get_api_url, get_api_key, set_api_key
33
33
 
@@ -14,15 +14,23 @@ All commands support --json for structured output (agent-friendly).
14
14
 
15
15
  import argparse
16
16
  import json
17
+ import os
17
18
  import sys
18
19
 
19
20
 
21
+ def _apply_auth_overrides(args):
22
+ """Let the global --api-url/--api-key flags take effect on commands that go
23
+ through the high-level ``paw.compile()``/``paw.function()``/``paw.login()``
24
+ paths, which resolve credentials from the environment/config only."""
25
+ if getattr(args, "api_url", None):
26
+ os.environ["PAW_API_URL"] = args.api_url
27
+ if getattr(args, "api_key", None):
28
+ os.environ["PAW_API_KEY"] = args.api_key
29
+
30
+
20
31
  def cmd_compile(args):
21
32
  import programasweights as paw
22
- if args.api_url:
23
- paw.api_url = args.api_url
24
- if args.api_key:
25
- paw.api_key = args.api_key
33
+ _apply_auth_overrides(args)
26
34
 
27
35
  if not args.json:
28
36
  print(f"Compiling: {args.spec[:80]}...")
@@ -63,10 +71,7 @@ def cmd_compile(args):
63
71
 
64
72
  def cmd_run(args):
65
73
  import programasweights as paw
66
- if args.api_url:
67
- paw.api_url = args.api_url
68
- if args.api_key:
69
- paw.api_key = args.api_key
74
+ _apply_auth_overrides(args)
70
75
 
71
76
  fn = paw.function(
72
77
  args.program, verbose=args.verbose,
@@ -82,22 +87,15 @@ def cmd_run(args):
82
87
 
83
88
  def cmd_login(args):
84
89
  import programasweights as paw
85
- if args.api_url:
86
- paw.api_url = args.api_url
90
+ _apply_auth_overrides(args)
87
91
  paw.login(args.key)
88
92
  return 0
89
93
 
90
94
 
91
95
  def cmd_rename(args):
92
- import programasweights as paw
93
- if args.api_url:
94
- paw.api_url = args.api_url
95
- if args.api_key:
96
- paw.api_key = args.api_key
97
-
98
96
  import httpx
99
97
  from programasweights.client import PAWClient
100
- client = PAWClient(api_url=paw.api_url, api_key=paw.api_key)
98
+ client = PAWClient(api_url=args.api_url, api_key=args.api_key)
101
99
 
102
100
  resp = httpx.patch(
103
101
  f"{client._api_url}/api/v1/programs/{args.program}",
@@ -120,14 +118,8 @@ def cmd_rename(args):
120
118
 
121
119
 
122
120
  def cmd_info(args):
123
- import programasweights as paw
124
- if args.api_url:
125
- paw.api_url = args.api_url
126
- if args.api_key:
127
- paw.api_key = args.api_key
128
-
129
121
  from programasweights.client import PAWClient
130
- client = PAWClient(api_url=paw.api_url, api_key=paw.api_key)
122
+ client = PAWClient(api_url=args.api_url, api_key=args.api_key)
131
123
 
132
124
  try:
133
125
  meta = client.get_program_meta(args.program)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "programasweights"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "Compile natural language specifications into neural programs that run locally via llama.cpp."
9
9
  readme = "PYPI_README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,186 @@
1
+ """
2
+ CLI authentication plumbing tests.
3
+
4
+ These are hermetic: no network, no model load. They cover two classes of bug:
5
+
6
+ 1. Regression guard for the AttributeError crash fixed in PR #1 -- `paw info`
7
+ and `paw rename` used to read the removed module attributes
8
+ `paw.api_url`/`paw.api_key` and crashed when no flags were given.
9
+
10
+ 2. `--api-url`/`--api-key` must actually take effect on `compile`, `run`, and
11
+ `login`. Before the 0.4.3 fix these were global flags that the high-level
12
+ `paw.compile()`/`paw.function()`/`paw.login()` paths silently ignored
13
+ (they only read env/config). The flag-plumbing tests below fail on that
14
+ older behavior and pass once the flags are wired through the environment.
15
+ """
16
+
17
+ from types import SimpleNamespace
18
+
19
+ import pytest
20
+
21
+ import programasweights as paw
22
+ from programasweights import cli, config
23
+ import programasweights.client as paw_client
24
+
25
+
26
+ class FakeProgram:
27
+ id = "prog123"
28
+ slug = None
29
+ status = "ready"
30
+ error = None
31
+ timings = None
32
+ version = 1
33
+ version_action = "created"
34
+
35
+
36
+ def _install_fake_client(monkeypatch, sink):
37
+ """Replace PAWClient with a fake that records the api_url/api_key it was
38
+ constructed with, and returns canned data instead of hitting the network."""
39
+
40
+ class FakeClient:
41
+ def __init__(self, api_url=None, api_key=None):
42
+ sink["api_url"] = api_url
43
+ sink["api_key"] = api_key
44
+ self._api_url = (api_url or "https://programasweights.com").rstrip("/")
45
+ self._api_key = api_key
46
+
47
+ def _headers(self):
48
+ return {"Content-Type": "application/json"}
49
+
50
+ def get_program_meta(self, program):
51
+ return {"id": program, "spec": "canned"}
52
+
53
+ def compile(self, *a, **k):
54
+ return FakeProgram()
55
+
56
+ monkeypatch.setattr(paw_client, "PAWClient", FakeClient)
57
+ return sink
58
+
59
+
60
+ @pytest.fixture(autouse=True)
61
+ def _hermetic_env(monkeypatch):
62
+ # Ignore any real ~/.config/programasweights/config.json and env so the
63
+ # only source of api_url/api_key is what the command threads through.
64
+ monkeypatch.setattr(config, "_load_config", lambda: {})
65
+ monkeypatch.delenv("PAW_API_KEY", raising=False)
66
+ monkeypatch.delenv("PAW_API_URL", raising=False)
67
+
68
+
69
+ # ── Regression guards: info/rename must not crash without flags ──
70
+
71
+
72
+ def test_cmd_info_no_flags_no_attributeerror(monkeypatch):
73
+ sink = _install_fake_client(monkeypatch, {})
74
+ args = SimpleNamespace(program="prog123", api_url=None, api_key=None, json=True)
75
+ rc = cli.cmd_info(args) # crashed with AttributeError before PR #1
76
+ assert rc == 0
77
+ assert sink["api_url"] is None and sink["api_key"] is None
78
+
79
+
80
+ def test_cmd_rename_no_flags_no_attributeerror(monkeypatch):
81
+ sink = _install_fake_client(monkeypatch, {})
82
+
83
+ import httpx
84
+
85
+ class FakeResp:
86
+ def raise_for_status(self):
87
+ return None
88
+
89
+ def json(self):
90
+ return {"slug": "new-slug"}
91
+
92
+ monkeypatch.setattr(httpx, "patch", lambda *a, **k: FakeResp())
93
+ args = SimpleNamespace(
94
+ program="prog123", new_slug="new-slug", api_url=None, api_key=None, json=True
95
+ )
96
+ rc = cli.cmd_rename(args) # crashed with AttributeError before PR #1
97
+ assert rc == 0
98
+ assert sink["api_url"] is None and sink["api_key"] is None
99
+
100
+
101
+ def test_cmd_info_honors_flags(monkeypatch):
102
+ sink = _install_fake_client(monkeypatch, {})
103
+ args = SimpleNamespace(
104
+ program="prog123",
105
+ api_url="http://test.local",
106
+ api_key="paw_sk_test",
107
+ json=True,
108
+ )
109
+ cli.cmd_info(args)
110
+ assert sink["api_url"] == "http://test.local"
111
+ assert sink["api_key"] == "paw_sk_test"
112
+
113
+
114
+ # ── Flag plumbing: compile/run/login must honor --api-url/--api-key ──
115
+
116
+
117
+ def test_apply_auth_overrides_sets_env(monkeypatch):
118
+ cli._apply_auth_overrides(
119
+ SimpleNamespace(api_url="http://test.local", api_key="paw_sk_test")
120
+ )
121
+ assert config.get_api_url() == "http://test.local"
122
+ assert config.get_api_key() == "paw_sk_test"
123
+
124
+
125
+ def test_apply_auth_overrides_ignores_absent(monkeypatch):
126
+ cli._apply_auth_overrides(SimpleNamespace(api_url=None, api_key=None))
127
+ # Falls back to default url, no key.
128
+ assert config.get_api_url() == "https://programasweights.com"
129
+ assert config.get_api_key() is None
130
+
131
+
132
+ def test_compile_flag_plumbing(monkeypatch):
133
+ sink = _install_fake_client(monkeypatch, {})
134
+ args = SimpleNamespace(
135
+ spec="Classify sentiment as positive or negative.",
136
+ compiler=None,
137
+ slug=None,
138
+ private=False,
139
+ api_url="http://test.local",
140
+ api_key="paw_sk_test",
141
+ json=True,
142
+ )
143
+ cli.cmd_compile(args)
144
+ # The flag must reach the client through get_api_url()/get_api_key().
145
+ assert sink["api_url"] == "http://test.local"
146
+ assert sink["api_key"] == "paw_sk_test"
147
+
148
+
149
+ def test_run_flag_plumbing(monkeypatch):
150
+ seen = {}
151
+
152
+ def fake_function(program_id, **kwargs):
153
+ seen["api_url"] = config.get_api_url()
154
+ seen["api_key"] = config.get_api_key()
155
+ return lambda *a, **k: "ok"
156
+
157
+ monkeypatch.setattr(paw, "function", fake_function)
158
+ args = SimpleNamespace(
159
+ program="prog123",
160
+ input="hello",
161
+ max_tokens=512,
162
+ temperature=0.0,
163
+ verbose=False,
164
+ api_url="http://test.local",
165
+ api_key="paw_sk_test",
166
+ json=True,
167
+ )
168
+ cli.cmd_run(args)
169
+ assert seen["api_url"] == "http://test.local"
170
+ assert seen["api_key"] == "paw_sk_test"
171
+
172
+
173
+ def test_login_flag_plumbing(monkeypatch):
174
+ seen = {}
175
+
176
+ def fake_login(key=None):
177
+ seen["api_url"] = config.get_api_url()
178
+ seen["key"] = key
179
+
180
+ monkeypatch.setattr(paw, "login", fake_login)
181
+ args = SimpleNamespace(
182
+ key="paw_sk_test", api_url="http://test.local", api_key=None
183
+ )
184
+ cli.cmd_login(args)
185
+ assert seen["api_url"] == "http://test.local"
186
+ assert seen["key"] == "paw_sk_test"
@@ -291,31 +291,29 @@ class TestAuth:
291
291
  def test_compile_with_slug_authenticated(self):
292
292
  import time
293
293
  slug = f"test-sdk-{int(time.time()) % 100000}"
294
- old_key = paw.api_key
295
- try:
296
- paw.api_key = API_KEY
297
- program = paw.compile(
298
- "Classify text as positive or negative sentiment.",
299
- slug=slug,
300
- )
301
- assert program.id
302
- assert program.slug
303
- assert slug in program.slug
304
- finally:
305
- paw.api_key = old_key
294
+ program = paw.compile(
295
+ "Classify text as positive or negative sentiment.",
296
+ slug=slug,
297
+ )
298
+ assert program.id
299
+ assert program.slug
300
+ assert slug in program.slug
306
301
 
307
302
  @needs_auth
308
303
  def test_compile_with_slug_bad_key(self):
309
- old_key = paw.api_key
304
+ old_key = os.environ.get("PAW_API_KEY")
305
+ os.environ["PAW_API_KEY"] = "paw_sk_invalid_key_12345"
310
306
  try:
311
- paw.api_key = "paw_sk_invalid_key_12345"
312
307
  with pytest.raises(Exception):
313
308
  paw.compile(
314
309
  "Classify text as positive or negative sentiment.",
315
310
  slug="should-fail-auth",
316
311
  )
317
312
  finally:
318
- paw.api_key = old_key
313
+ if old_key is None:
314
+ del os.environ["PAW_API_KEY"]
315
+ else:
316
+ os.environ["PAW_API_KEY"] = old_key
319
317
 
320
318
  @needs_auth
321
319
  def test_cli_compile_with_api_key_flag(self):
@@ -336,34 +334,19 @@ class TestAuth:
336
334
  class TestSlugValidation:
337
335
  @needs_auth
338
336
  def test_slug_too_short(self):
339
- old_key = paw.api_key
340
- try:
341
- paw.api_key = API_KEY
342
- with pytest.raises(Exception) as exc_info:
343
- paw.compile("Classify text.", slug="x")
344
- assert "422" in str(exc_info.value) or "invalid" in str(exc_info.value).lower()
345
- finally:
346
- paw.api_key = old_key
337
+ with pytest.raises(Exception) as exc_info:
338
+ paw.compile("Classify text.", slug="x")
339
+ assert "422" in str(exc_info.value) or "invalid" in str(exc_info.value).lower()
347
340
 
348
341
  @needs_auth
349
342
  def test_slug_invalid_characters(self):
350
- old_key = paw.api_key
351
- try:
352
- paw.api_key = API_KEY
353
- with pytest.raises(Exception):
354
- paw.compile("Classify text.", slug="BAD SLUG!")
355
- finally:
356
- paw.api_key = old_key
343
+ with pytest.raises(Exception):
344
+ paw.compile("Classify text.", slug="BAD SLUG!")
357
345
 
358
346
  @needs_auth
359
347
  def test_slug_with_uppercase_rejected(self):
360
- old_key = paw.api_key
361
- try:
362
- paw.api_key = API_KEY
363
- with pytest.raises(Exception):
364
- paw.compile("Classify text.", slug="MyProgram")
365
- finally:
366
- paw.api_key = old_key
348
+ with pytest.raises(Exception):
349
+ paw.compile("Classify text.", slug="MyProgram")
367
350
 
368
351
 
369
352
  # ── Phase 10: Privacy ──
@@ -373,36 +356,26 @@ class TestPrivacy:
373
356
  @needs_auth
374
357
  def test_compile_private_program(self):
375
358
  import httpx
376
- old_key = paw.api_key
377
- try:
378
- paw.api_key = API_KEY
379
- program = paw.compile("Private test program for counting vowels.", public=False)
380
- assert program.id
381
-
382
- resp = httpx.get(
383
- f"{paw.api_url}/api/v1/programs/{program.id}",
384
- timeout=10.0,
385
- )
386
- assert resp.status_code == 404, "Private program should return 404 to unauthenticated users"
387
- finally:
388
- paw.api_key = old_key
359
+ program = paw.compile("Private test program for counting vowels.", public=False)
360
+ assert program.id
361
+
362
+ resp = httpx.get(
363
+ f"{paw.get_api_url()}/api/v1/programs/{program.id}",
364
+ timeout=10.0,
365
+ )
366
+ assert resp.status_code == 404, "Private program should return 404 to unauthenticated users"
389
367
 
390
368
  @needs_auth
391
369
  def test_compile_public_default(self):
392
370
  import httpx
393
- old_key = paw.api_key
394
- try:
395
- paw.api_key = API_KEY
396
- program = paw.compile("Public test program for counting words.")
397
- assert program.id
398
-
399
- resp = httpx.get(
400
- f"{paw.api_url}/api/v1/programs/{program.id}",
401
- timeout=10.0,
402
- )
403
- assert resp.status_code == 200, "Public program should be accessible"
404
- finally:
405
- paw.api_key = old_key
371
+ program = paw.compile("Public test program for counting words.")
372
+ assert program.id
373
+
374
+ resp = httpx.get(
375
+ f"{paw.get_api_url()}/api/v1/programs/{program.id}",
376
+ timeout=10.0,
377
+ )
378
+ assert resp.status_code == 200, "Public program should be accessible"
406
379
 
407
380
 
408
381
  # ── Phase 11: CLI rename ──
@@ -412,24 +385,19 @@ class TestCLIRename:
412
385
  @needs_auth
413
386
  def test_cli_rename(self):
414
387
  import time
415
- old_key = paw.api_key
416
- try:
417
- paw.api_key = API_KEY
418
- program = paw.compile("Test rename program for validation.")
419
- slug = f"rename-test-{int(time.time()) % 100000}"
420
-
421
- result = subprocess.run(
422
- [sys.executable, "-m", "programasweights.cli",
423
- "--api-key", API_KEY,
424
- "rename", program.id, slug, "--json"],
425
- capture_output=True, text=True,
426
- )
427
- assert result.returncode == 0
428
- data = json.loads(result.stdout)
429
- assert data.get("slug")
430
- assert slug in data["slug"]
431
- finally:
432
- paw.api_key = old_key
388
+ program = paw.compile("Test rename program for validation.")
389
+ slug = f"rename-test-{int(time.time()) % 100000}"
390
+
391
+ result = subprocess.run(
392
+ [sys.executable, "-m", "programasweights.cli",
393
+ "--api-key", API_KEY,
394
+ "rename", program.id, slug, "--json"],
395
+ capture_output=True, text=True,
396
+ )
397
+ assert result.returncode == 0
398
+ data = json.loads(result.stdout)
399
+ assert data.get("slug")
400
+ assert slug in data["slug"]
433
401
 
434
402
  @needs_auth
435
403
  def test_cli_compile_private(self):
@@ -481,30 +449,20 @@ class TestFunctionAcceptsProgram:
481
449
  class TestListPrograms:
482
450
  @needs_auth
483
451
  def test_list_programs_returns_dict(self):
484
- old_key = paw.api_key
485
- try:
486
- paw.api_key = API_KEY
487
- result = paw.list_programs()
488
- assert isinstance(result, dict)
489
- assert "programs" in result
490
- assert "total" in result
491
- assert isinstance(result["programs"], list)
492
- finally:
493
- paw.api_key = old_key
452
+ result = paw.list_programs()
453
+ assert isinstance(result, dict)
454
+ assert "programs" in result
455
+ assert "total" in result
456
+ assert isinstance(result["programs"], list)
494
457
 
495
458
  @needs_auth
496
459
  def test_list_programs_has_entries(self):
497
- old_key = paw.api_key
498
- try:
499
- paw.api_key = API_KEY
500
- result = paw.list_programs(per_page=5)
501
- assert len(result["programs"]) <= 5
502
- if result["programs"]:
503
- p = result["programs"][0]
504
- assert "id" in p
505
- assert "spec" in p
506
- finally:
507
- paw.api_key = old_key
460
+ result = paw.list_programs(per_page=5)
461
+ assert len(result["programs"]) <= 5
462
+ if result["programs"]:
463
+ p = result["programs"][0]
464
+ assert "id" in p
465
+ assert "spec" in p
508
466
 
509
467
 
510
468
  class TestStderrSuppression: