codeplain 0.3.9__py3-none-any.whl → 0.3.10.dev2__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: codeplain
3
- Version: 0.3.9
3
+ Version: 0.3.10.dev2
4
4
  Summary: Transform plain language specifications into working code
5
5
  License-File: LICENSE
6
6
  Classifier: Environment :: Console
@@ -8,15 +8,15 @@ Classifier: Intended Audience :: Developers
8
8
  Classifier: Operating System :: OS Independent
9
9
  Classifier: Topic :: Software Development :: Code Generators
10
10
  Requires-Python: ==3.11.*
11
- Requires-Dist: gitpython==3.1.52
12
- Requires-Dist: mistletoe==1.5.1
11
+ Requires-Dist: gitpython==3.1.55
12
+ Requires-Dist: mistletoe==1.6.0
13
13
  Requires-Dist: networkx==3.6.1
14
14
  Requires-Dist: python-frontmatter==1.3.0
15
15
  Requires-Dist: python-liquid2==0.4.0
16
16
  Requires-Dist: pyyaml==6.0.3
17
17
  Requires-Dist: requests==2.34.2
18
18
  Requires-Dist: rich==15.0.0
19
- Requires-Dist: sentry-sdk==2.66.0
19
+ Requires-Dist: sentry-sdk==2.66.1
20
20
  Requires-Dist: textual>=7.5.0
21
21
  Requires-Dist: tiktoken==0.13.0
22
22
  Requires-Dist: transitions==0.9.3
@@ -52,6 +52,7 @@ macOS and Linux:
52
52
 
53
53
  ```bash
54
54
  curl -fsSL https://codeplain.ai/install.sh | bash
55
+ # re-run to update to the latest version
55
56
  ```
56
57
 
57
58
  Windows PowerShell:
@@ -74,12 +75,39 @@ export CODEPLAIN_API_KEY="your_actual_api_key_here"
74
75
 
75
76
  You describe what you want in plain English, an agent helps you write a spec in \*\*\*plain language, and \*codeplain renders the actual code. This is what [plain-forge](https://github.com/Codeplain-ai/plain-forge) sets up: a toolkit of skills, rules, and docs that plugs into your AI coding agent of choice — Claude Code, Codex, ForgeCode, OpenCode, or any other agent that reads from a standard skills directory — and turns a conversation into a complete `.plain` spec, then keeps maintaining it across the lifetime of the project.
76
77
 
77
- If you didn't install it during step 1, run it any time:
78
+ If you didn't install it during step 1, run it any time using your favorite Node package runner:
78
79
 
79
80
  ```bash
80
81
  npx plain-forge install
81
82
  ```
82
83
 
84
+ The same command works with any other runner — pick whichever matches your toolchain:
85
+
86
+ ```bash
87
+ # npm (bundled with Node.js)
88
+ npx plain-forge install
89
+
90
+ # pnpm
91
+ pnpm dlx plain-forge install
92
+
93
+ # Bun
94
+ bunx plain-forge install
95
+
96
+ # Yarn (Berry, v2+)
97
+ yarn dlx plain-forge install
98
+
99
+ # Deno (npm: specifier, needs permissions to write the skills directory)
100
+ deno run -A npm:plain-forge install
101
+ ```
102
+
103
+ Every runner downloads `plain-forge` on demand and runs it without adding it to your project's dependencies. The package exposes a `plain-forge` binary, so no runner needs an extra `--package` flag.
104
+
105
+ `install` fails if plain-forge is already installed at the target — to refresh an existing installation to the latest skills, rules, and docs, use `update` instead (swap in your runner of choice):
106
+
107
+ ```bash
108
+ npx plain-forge update
109
+ ```
110
+
83
111
  ### 3. Convert specs into tested and validated code
84
112
 
85
113
  \*codeplain extends coding agents with agentic skills for working at the specification layer, and with tools that convert specifications into tested and validated implementation code.
@@ -101,9 +129,9 @@ You can immediately test the system with a simple "Hello World" example:
101
129
 
102
130
  _Note: Rendering will take a few minutes to complete._
103
131
 
104
- - The system will generate a Python application in the `build` directory. You can run it with:
132
+ - The system will generate a Python application in the `dist` directory. You can run it with:
105
133
  ```bash
106
- cd build
134
+ cd dist
107
135
  python hello_world.py
108
136
  ```
109
137
 
@@ -1,30 +1,30 @@
1
1
  change_detection.py,sha256=CazORZrezeZSw2I6VBvieN-c2SEjieZPo6pNrKwmMVA,6915
2
2
  codeplain_REST_api.py,sha256=x-sCbbBSswID4eKXDMvv84nzWynNSbBPoyAtq__6tas,21644
3
- concept_utils.py,sha256=D4qVHFBRvfJZwiIq1AD_SV0Ev-RnOMg2yGWo6Cal7PM,7913
3
+ concept_utils.py,sha256=TpyiQ4DfMtO231j-lPj65i0suRTPr8t9j3QNf9KiM4A,8080
4
4
  diff_utils.py,sha256=AjiQlqo5pRos_8hVXZo5yBurl5BzSrTMGrQv4dCtRCg,1198
5
5
  event_bus.py,sha256=g5XLwRfjrvTIcB6urjREuCJzZj5TA93hy8lKXLGXuCE,650
6
6
  file_utils.py,sha256=MNZ5li1ofIMOYB6FCrMa_UkX6nkGCgSZuaqzhqVc1Ok,11682
7
7
  git_utils.py,sha256=gCUA6_9zg19f6k_0aVcRnIVlAJSBD0Vt6takx-0pKME,16547
8
8
  memory_management.py,sha256=SPoK0BXB9_yDyrrOKjFtOOlKkJIq0I5EXV7dE0OsE8g,5972
9
9
  metadata_utils.py,sha256=YO17o7IAma5TT1ZN0pTwrLVr_KxFQ8APv02KqKPpI5k,1641
10
- module_renderer.py,sha256=AUbjGiE6USMe-uTfbOs9gEQSEMzO9qLwGTxy3_tC_M4,6615
11
- partial_rendering.py,sha256=0ChOaGytogBUEGISpbWkbjT9Hqy8pnOkJdJXcitJDgk,12219
10
+ module_renderer.py,sha256=IJBle8mbBARn_xrekjSpsVID7719yy2rNPkM15-ONoI,6982
11
+ partial_rendering.py,sha256=uzZc2j4d3pAXzfiHw_y1TK4yLRsLOb1Mm21570AGxJ8,13837
12
12
  path_resolution.py,sha256=0tMqU0a1g4DYhTOp7QjLVSEkv_cgasb4NI1mK809dUM,2233
13
- plain2code.py,sha256=Hai5_Ho1nY0Hau7Y6QEGkYeg8NKVx5jDMtQV0UVHsLU,15706
14
- plain2code_arguments.py,sha256=knY7gqfpj2IKnPSl-dEdxBUpzrbohEQXamNYkFVC00Y,17426
15
- plain2code_console.py,sha256=Ifs8tmfNX31UujQ5_0sk9AJcajMG_6qhF-6QZcfbcEE,5773
13
+ plain2code.py,sha256=NsZf7JS2bGmOQ6HsbiTqNA3EMXXY5YQW6nBBXCG7m-U,17392
14
+ plain2code_arguments.py,sha256=xMK-R13Vu005bHz3MUDY8F0Cf3ED9hCPqNsC6jGZmkk,17620
15
+ plain2code_console.py,sha256=5N4_GIV8qE-2zvFh6qoOHhhsuwCF_3r7jG19S-YWtfk,5906
16
16
  plain2code_events.py,sha256=rgDF5PDtNr_lEPvJmd1tIQXZrzwgzZW5b-_pykugqe4,1587
17
- plain2code_exceptions.py,sha256=wkkEXw2k4LCd09PIH3_Hu0HyZvtXYjFaA653Sfey4FA,2287
18
- plain2code_logger.py,sha256=NRPLVIMnCHNVl6dII9gFoIzMdr8xa7icnL0T_Fx2XLw,4839
17
+ plain2code_exceptions.py,sha256=rtFEQbs1ruzSlGXIGJZoZry9sjYzmhPKHiUQIECcbL0,2567
18
+ plain2code_logger.py,sha256=YQ-ENYBHKXROVv6Q52xh7eXrFkj_3Lex__N9zfxlw9Y,5729
19
19
  plain2code_nodes.py,sha256=IwjQqJxRSJKgP6eEnkYGy3VQHIyoazgkFxAEkxkjfJ8,4426
20
20
  plain2code_read_config.py,sha256=D5CxnmTRjddupecWvstcUm0kIJWWzq5-Zmk6sU0wbIg,2235
21
21
  plain2code_state.py,sha256=uePQlC29Pbhm1yXfdCFebONYdy6jo13gTTmXSwC11cQ,3168
22
22
  plain2code_telemetry.py,sha256=e4UCAoFklJMDWGQsL50i7nTLiOBy8gs86A0XO4WLgPo,4597
23
23
  plain2code_utils.py,sha256=unOxuqSQRpwG8YF6uoQhnZ3ZSTJRIUCPLdJ-1lRZR5M,1534
24
- plain_file.py,sha256=k2rcq53q0kOf9_8egrZILpkrVkZdfJt7HU0gHBFskU4,35116
25
- plain_modules.py,sha256=VgOwEWVhp7dyRs5NM-jVaC5GKn8KzT1sWuSNkM7YLIA,17375
24
+ plain_file.py,sha256=Kn8m_w6c04_RR-FBjAU05UcLOTDk4zcctpJXghYdZ8I,35757
25
+ plain_modules.py,sha256=kUGR2mX908FEKOO8b2CJuk2rn9iRj8qyWGFMWTdfzoY,25977
26
26
  plain_spec.py,sha256=7nb5lLJKHZ3uu9THkSXyJ5brIKkbGW0ooVfRclLdlzA,16062
27
- system_config.py,sha256=nyeDS1WkyA-5m3TflB_XMDyhBvC-tVRAG3VgJaZYG_M,3694
27
+ system_config.py,sha256=sA8wiPmBMtRsLwSQ40U5pVdLazAafbV-1Z19Yp0HiSQ,5314
28
28
  usage_summary.py,sha256=MA4WcVaAwz6IWCN4k_rgxVcwO4I157wN7BZlFi3h0R4,1282
29
29
  cli_output/__init__.py,sha256=MBKl0YbVpWE-tBa3V5_aVbB7vbaqlIK2NsHTjRUZqis,284
30
30
  cli_output/dry_run.py,sha256=6NKhNWPtD0CBK_QHSsSqIKLTzibTwxS9d8ltP9d1bgU,1473
@@ -36,9 +36,9 @@ docs/generate_cli.py,sha256=0FHVhICbM8g7ahdInTNih3hNKpty1wbV5rdGECMJj48,656
36
36
  examples/example_hello_world_python/harness_tests/hello_world_display/test_hello_world.py,sha256=dwTowrHiVKKbrDv21v8xJC30Q57AXZkQasdGOO5JsBE,470
37
37
  render_machine/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
38
  render_machine/code_renderer.py,sha256=-vp_ltU7e3ssX9hbS8IY4ETSp-39JAuoJ3ZltKpdt1M,3959
39
- render_machine/conformance_tests.py,sha256=jvdWRx-ZURSq4JMase1svKzYJ98Lc4svPvfHXglHeFE,7163
40
- render_machine/implementation_code_helpers.py,sha256=LdHNLoKgVtrDaGH2APX8C9vjNjLBUhu2V7OAaJvm4rM,2009
41
- render_machine/render_context.py,sha256=pdosCY7QMzfPy-cJxEsiM9m4I5K2CoNJ7N-5Fzg0NdU,25584
39
+ render_machine/conformance_tests.py,sha256=PNTCnJNd_xNIhpTPPS8vlx8qws3cTgQSTLdADRab2YM,9617
40
+ render_machine/implementation_code_helpers.py,sha256=Fxm-IInoZjAIZn41_-WXWj7VIYczpW4yH44Gt8CYMAQ,2378
41
+ render_machine/render_context.py,sha256=iaJWI_EMSn51LNF_b7KRvlir46ZgUkjbaEiVgSvMTCI,26314
42
42
  render_machine/render_types.py,sha256=cT3D0lmxQ3enIo0jGp3LEGCY2t3X1Q1_tqwcadzSAv0,8761
43
43
  render_machine/render_utils.py,sha256=yA3X15E_Lebk_-MxkSz9rM_qy-Pes8EQ9Ye-smkHCDw,9320
44
44
  render_machine/state_machine_config.py,sha256=NnX5sBGkc6cNG8OOQ4PrJeJBCADRGZKJYQXV7ndR4bs,26789
@@ -53,7 +53,7 @@ render_machine/actions/exit_with_error.py,sha256=W6TMULHJcHEaXE4WUnlELEu0_l7IZy9
53
53
  render_machine/actions/finish_functional_requirement.py,sha256=usQgPSg6PHpBOR1RhK7lPz9zjY-2VJJDqi3T0nlqdLw,841
54
54
  render_machine/actions/fix_conformance_test.py,sha256=EZLVjF4A8zSTmyqdgcLoz-2pLpeieq4M_Rb71pY3IKA,10095
55
55
  render_machine/actions/fix_unit_tests.py,sha256=hvwvCJDk_-8fHMpkNFyeBSG1IshIcufDpymF00ulPXY,2301
56
- render_machine/actions/prepare_repositories.py,sha256=dnh4cYeTZS0snWEeD8gAxtW2ot3KBq4nStRG052qc9U,3124
56
+ render_machine/actions/prepare_repositories.py,sha256=8poItxPrUZWKCFReqdAKL__bxjM71Ig2zLkg1TDGmpY,3439
57
57
  render_machine/actions/prepare_testing_environment.py,sha256=kIsXgV6-jOlqp-04cimPgTQoHPrVIu0zW-H_Jj-jowE,2106
58
58
  render_machine/actions/refactor_code.py,sha256=gyPsnqgHseL2bkENu4WeGCRyV0R18_GH3JOrPdoxKvg,2609
59
59
  render_machine/actions/render_conformance_tests.py,sha256=5gOQn-G1T9UWEI8sDfALcVdArk0ln9DgUprOPjccS-U,9268
@@ -69,11 +69,12 @@ tests/__init__.py,sha256=Wk73Io62J15BtlLVIzxmASDWaaJkQLevS4BLK5LDAQg,16
69
69
  tests/conftest.py,sha256=QZcp08htUlJGgmHDlRWFgsWXZ8o8IBWTD5QqJaUMlU8,790
70
70
  tests/test_arg_provenance.py,sha256=d_J7aCeHMXBpctBWTt60WU-I8pEKN7XRXRTJZZGCdo0,3441
71
71
  tests/test_change_detection.py,sha256=jH8SEhndX400DNqIKmcYLzxHqq9WFdgmuvY3XPq-ZFc,13534
72
- tests/test_cli_output.py,sha256=iKS1Li4yhoIL0ezJ58o4oEf9V8TiKcQkvQLVtJpdM4c,18420
72
+ tests/test_cli_output.py,sha256=ZV2F1NNW4EiZvMNkhF9XUZH6bFJbddfkZof5qAFxe3Y,18885
73
73
  tests/test_console_log_styles.py,sha256=63DQWiwWNhe_rMP70bW9DAB6-EPaqU3Wpwyh8eR09pc,4080
74
74
  tests/test_file_utils.py,sha256=R_ZIhrTtwlhHxgmbBtA8PFEU5-QB4Fi5skJ7PS2KzdU,3614
75
75
  tests/test_folder_path_resolution.py,sha256=K9v684xkPE0HOAsovg8uDixpZmaIk7ho-Wd3rTjKLF0,6560
76
76
  tests/test_git_utils.py,sha256=h3rWhhs4XVRYqzVzdmj51siF_AYD-GkbZiefK2iGfWo,15614
77
+ tests/test_headless_logging.py,sha256=soNESeotKQaU-SQyxtjpq35l-dLVsauZ_lJ-wk9i2mI,6419
77
78
  tests/test_imports.py,sha256=1ncbqQhtQW237kIjyV-Ufl7-R-PuduvoYFj2iS-DXW4,2608
78
79
  tests/test_log_file_name_resolution.py,sha256=99g9NgPF-BZ98CvNyqDcltBAx4sLBE8gpQPAAieKiPc,3619
79
80
  tests/test_logging_config_path_resolution.py,sha256=avz_ehRd18INwIXvAy1AwVzNNUz0vS8eu1nRe0bra8g,2650
@@ -82,15 +83,15 @@ tests/test_path_resolution.py,sha256=yQpd-Unq1lwNv0wkRPwVs5pDT8JmyyDgbpiTQsyt--Y
82
83
  tests/test_plain2code.py,sha256=lyb7iYK8XHu1dmTFh9Q31YgX0faNlCHiyI5M35VQwOg,3780
83
84
  tests/test_plain2code_state.py,sha256=cowPZS9oRbv9J49CE4P3Rc47Q5oQOby4bTf2OlnRlBI,1217
84
85
  tests/test_plain2code_utils.py,sha256=-W8a7FquMJQ_pTdBoBF1XprzNL2G555QhdtXCy6BDyQ,1237
85
- tests/test_plain_modules.py,sha256=jDjPv1JyKCgIMaoy3GYpjxTSQkfkI2ppmJgqzHdQ6E4,21592
86
+ tests/test_plain_modules.py,sha256=Fn3FeH-M4exnyFJDZzc9YWaLEfBmiqPH5TpiBT9jgAs,33033
86
87
  tests/test_plainfile.py,sha256=wZ_PN4GULPWIpl_AK_r_6jkgtX0Ch8EHmN5IhX7huOE,11027
87
- tests/test_plainfileparser.py,sha256=xd9P0KqZJlJPn3RKT510IQbfBotk6nQ9O8p9iLwJiQk,22815
88
+ tests/test_plainfileparser.py,sha256=l8GfXkVaSzgH74fX45Wp5jX0q3QlO6t_TgDG72dc28o,23867
88
89
  tests/test_plainspec.py,sha256=ng55E8ajrH2-gPC1p_QDIe_zXplPvoqHJ-d57UWKFTw,3577
89
- tests/test_prepare_repositories_layout.py,sha256=9T9lf92HhZC_ziUZxiyogbbeqJfmg131FZS81BuUNfs,4751
90
+ tests/test_prepare_repositories_layout.py,sha256=iHDfZi2OFLDp4SyjR9o11imKKiTGzycb_1nWBkW7QFE,12515
90
91
  tests/test_requires.py,sha256=6tnHYaKudxN-RiQjSFoi9nHe_x0ze3QF-f-j67zAE74,1250
91
92
  tests/test_resolve_config_file.py,sha256=b87tDOvyzbsnE-QRWbZ97ou7NuyaAgskv-dbYBum31c,2830
92
93
  tests/test_script_path_resolution.py,sha256=tiEqMevQRXr9YpfHN0xldOOQpLAUgnSqTzeZWkKHaUY,4156
93
- tests/test_system_config.py,sha256=poH6eviXqAxAzTcvdP0TeS0wBp6CW1IW5irqto7FNyI,1784
94
+ tests/test_system_config.py,sha256=gH5qs9q4lh5CljAdz28HUrJzuKqCvkZherFuv7enMak,2787
94
95
  tests/test_telemetry.py,sha256=LINvx1_Oz1OJ3ho2RcIcAA29IsNfUbkVVCbFaQcrBaA,8133
95
96
  tests/test_tui_usage.py,sha256=sfiAGTpRrT3MRr0-8DH3qjuECSxjODVgwzrK_K3iPXs,7959
96
97
  tests/test_usage_summary.py,sha256=Ps0kVzvmdZYeXrBR-cqkn3szTV5neYKs1PmmURngeIc,1079
@@ -101,13 +102,13 @@ tui/__init__.py,sha256=_fy7FowY0RkWdn4kC7XV2jgvQPCj-2gE8fNUb-Mny0A,60
101
102
  tui/components.py,sha256=XkC4Aq6VyTnFnBQs8PEHwVdAOczhm0ebKN92kiYMbq0,25791
102
103
  tui/models.py,sha256=KMjlWubmPD41GA-BjgiqkaV9v7tLJ0fuWKBDPmZj4JA,1493
103
104
  tui/plain2code_tui.py,sha256=8F7ZYRpmTiWRdTeQ63LPf2LGrreYjIYXvf_9XNSPyHs,17104
104
- tui/plain_module_render_choice_tui.py,sha256=DRl_xhUbUJsyOaCYt_uNDSLUAA3Ty9NYI9zFrVDwp9Y,11270
105
+ tui/plain_module_render_choice_tui.py,sha256=XwBMMzyDat9FYqMkKdIV7kmIevddfwGA5Qk9kgy5sYY,12112
105
106
  tui/spinner.py,sha256=Ro6Gd9Przf-whuHqPRY6HwI0T57yJjyNPbhDbigZKZE,2471
106
107
  tui/state_handlers.py,sha256=4r_6m5YMcprQtgiTP0De3XFfGAF8VzgaWXHxPweYPbQ,16325
107
108
  tui/styles.css,sha256=kiC7Og_2G1E7owoiZgV95-3_dSeQnsNj3qzaY04xkDg,6727
108
109
  tui/widget_helpers.py,sha256=ToO9MV2bF2Rp-XMNcjIYclnB_qmgO5sLw4UtQ2CyTMg,6652
109
- codeplain-0.3.9.dist-info/METADATA,sha256=v3UbwneHulzIUZMJI9ZHf032CyyhAef-BBmn-RRsq14,6351
110
- codeplain-0.3.9.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
111
- codeplain-0.3.9.dist-info/entry_points.txt,sha256=oDZkBqu9WhtZApb_K6ia8-fn9aojwmAsgnKELceX5T4,46
112
- codeplain-0.3.9.dist-info/licenses/LICENSE,sha256=pCeKgQ1mXE5OmNUuKOfOZh1T1vqkeCUxghE7N8qgnzc,11345
113
- codeplain-0.3.9.dist-info/RECORD,,
110
+ codeplain-0.3.10.dev2.dist-info/METADATA,sha256=mbvu-UgyiC8prG3kCe0j2rUG0CvhfqYFLMEuOrcw1Ao,7256
111
+ codeplain-0.3.10.dev2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
112
+ codeplain-0.3.10.dev2.dist-info/entry_points.txt,sha256=oDZkBqu9WhtZApb_K6ia8-fn9aojwmAsgnKELceX5T4,46
113
+ codeplain-0.3.10.dev2.dist-info/licenses/LICENSE,sha256=pCeKgQ1mXE5OmNUuKOfOZh1T1vqkeCUxghE7N8qgnzc,11345
114
+ codeplain-0.3.10.dev2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
concept_utils.py CHANGED
@@ -12,8 +12,14 @@ from plain2code_exceptions import PlainSyntaxError
12
12
  DEFAULT_CONCEPTS = {
13
13
  ":ConformanceTests:",
14
14
  ":UnitTests:",
15
+ ":AcceptanceTest:",
15
16
  ":AcceptanceTests:",
16
17
  ":Implementation:",
18
+ ":plainDefinitions:",
19
+ ":plainImplementationReqs:",
20
+ ":plainFunctionality:",
21
+ ":plainTestReqs:",
22
+ ":plainImplementationCode:",
17
23
  }
18
24
 
19
25
 
module_renderer.py CHANGED
@@ -111,6 +111,12 @@ class ModuleRenderer:
111
111
  ):
112
112
  return False, False
113
113
 
114
+ # We are going to (re)render this module. If it currently exists only as a
115
+ # "<module>.module" archive, unpack it into the real plain_modules/<module>/ folder now,
116
+ # before RenderContext snapshots the build folder path and before the git repos, metadata,
117
+ # and memory folder are touched.
118
+ plain_module.ensure_module_unpacked()
119
+
114
120
  memory_manager = MemoryManager(
115
121
  self.codeplainAPI,
116
122
  plain_module.module_memory_folder,
partial_rendering.py CHANGED
@@ -12,7 +12,7 @@ class PlainModuleRenderState:
12
12
  last_render_module: PlainModule
13
13
  last_render_frid: str | None
14
14
  change: PlainModule | None = None
15
- change_type: Literal["spec_change", "code_change"] | None = None
15
+ change_type: Literal["spec_change", "code_change", "missing_conformance_tests"] | None = None
16
16
 
17
17
 
18
18
  @dataclass
@@ -56,6 +56,19 @@ def code_change(plain_module: PlainModule) -> PlainModule | None:
56
56
  return None
57
57
 
58
58
 
59
+ def archive_missing_conformance_tests(plain_module: PlainModule, render_conformance_tests: bool) -> PlainModule | None:
60
+ """Return the first module available only as a "<module>.module" archive that has no conformance
61
+ tests while conformance testing is enabled. Such an archive cannot be consumed as-is (regression
62
+ needs the tests), so the module must be re-rendered."""
63
+ if not render_conformance_tests:
64
+ return None
65
+ all_modules = plain_module.all_required_modules + [plain_module]
66
+ for _module in all_modules:
67
+ if _module.is_archived_only() and not _module.archive_has_conformance_tests():
68
+ return _module
69
+ return None
70
+
71
+
59
72
  def module_comes_before_or_equal(
60
73
  all_required_modules: list[PlainModule],
61
74
  module1: PlainModule,
@@ -70,15 +83,24 @@ def module_comes_before_or_equal(
70
83
  raise ValueError(f"Module {module1.module_name} and {module2.module_name} not found in {all_required_modules}")
71
84
 
72
85
 
73
- def get_plain_module_render_state(plain_module: PlainModule) -> PlainModuleRenderState | None:
86
+ def get_plain_module_render_state(
87
+ plain_module: PlainModule, render_conformance_tests: bool = False
88
+ ) -> PlainModuleRenderState | None:
74
89
  sc = spec_change(plain_module)
75
90
  cc = code_change(plain_module)
91
+ mt = archive_missing_conformance_tests(plain_module, render_conformance_tests)
76
92
  all_required_modules = plain_module.all_required_modules
77
93
  last_rendered_module_name, last_rendered_frid = plain_module.get_module_render_status()
78
- if last_rendered_module_name is None and last_rendered_frid is None:
94
+ if last_rendered_module_name is None and last_rendered_frid is None and mt is None:
79
95
  return None
80
96
 
81
- if last_rendered_module_name == plain_module.module_name:
97
+ if last_rendered_module_name is None:
98
+ # Nothing has been rendered yet, but an archived module blocks consumption (no tests).
99
+ # Anchor the state on that module so the user is prompted to re-render it.
100
+ assert mt is not None # guaranteed by the early return above
101
+ module = mt
102
+ last_rendered_frid = None
103
+ elif last_rendered_module_name == plain_module.module_name:
82
104
  module = plain_module
83
105
  else:
84
106
  found_module: PlainModule | None = None
@@ -99,6 +121,13 @@ def get_plain_module_render_state(plain_module: PlainModule) -> PlainModuleRende
99
121
  change_type=None,
100
122
  )
101
123
 
124
+ # An archive that lacks conformance tests (while testing is enabled) is a hard blocker: it cannot
125
+ # be consumed as-is, so it takes precedence over spec/code changes.
126
+ if mt is not None:
127
+ pr.change = mt
128
+ pr.change_type = "missing_conformance_tests"
129
+ return pr
130
+
102
131
  if sc is None and cc is None:
103
132
  return pr
104
133
 
@@ -129,6 +158,8 @@ def get_all_affected_modules_from_change(
129
158
  start_module = plain_module.get_next_module(plain_module_render_state.change.module_name)
130
159
  else:
131
160
  start_module = plain_module_render_state.change
161
+ elif plain_module_render_state.change_type == "missing_conformance_tests":
162
+ start_module = plain_module_render_state.change
132
163
  else:
133
164
  raise ValueError(f"Unknown change type: {plain_module_render_state.change_type}")
134
165
 
plain2code.py CHANGED
@@ -28,6 +28,7 @@ from plain2code_exceptions import (
28
28
  ImportedModuleWithFunctionalitiesError,
29
29
  InvalidAPIKey,
30
30
  InvalidFridArgument,
31
+ InvalidModuleArchiveError,
31
32
  MissingAPIKey,
32
33
  MissingFunctionalitiesError,
33
34
  MissingPreviousFunctionalitiesError,
@@ -43,11 +44,13 @@ from plain2code_exceptions import (
43
44
  )
44
45
  from plain2code_logger import (
45
46
  LOGGER_NAME,
47
+ NARRATION_HANDLER_NAME,
46
48
  CrashLogHandler,
47
49
  ElapsedTimeFormatter,
48
50
  IndentedFormatter,
49
51
  LoggingHandler,
50
52
  dump_crash_logs,
53
+ stop_narrating,
51
54
  )
52
55
  from plain2code_state import RunState
53
56
  from plain2code_telemetry import capture_crash, initialize_telemetry
@@ -79,6 +82,7 @@ EXPECTED_EXCEPTIONS = (
79
82
  UnsupportedResourceType,
80
83
  UnsupportedBase64Content,
81
84
  GitNotInstalledError,
85
+ InvalidModuleArchiveError,
82
86
  SystemExit,
83
87
  )
84
88
 
@@ -123,6 +127,17 @@ def setup_logging(
123
127
  handler = LoggingHandler(event_bus, run_state)
124
128
  handler.setFormatter(formatter)
125
129
  root_logger.addHandler(handler)
130
+ else:
131
+ # Headless has no TUI to narrate the render and suppresses Rich output, so
132
+ # without this the process says nothing on stdout for its entire run: a CI job
133
+ # cannot distinguish a render wedged for hours from a healthy one until the
134
+ # log file is collected at the end. StreamHandler flushes
135
+ # per record, so these arrive as they happen.
136
+ stdout_handler = logging.StreamHandler(sys.stdout)
137
+ stdout_handler.setFormatter(file_formatter)
138
+ stdout_handler.setLevel(configured_log_level)
139
+ stdout_handler.set_name(NARRATION_HANDLER_NAME)
140
+ root_logger.addHandler(stdout_handler)
126
141
 
127
142
  if log_to_file:
128
143
  try:
@@ -212,6 +227,15 @@ def render( # noqa: C901
212
227
 
213
228
  warn_if_acceptance_tests_without_conformance_script(plain_module, args)
214
229
 
230
+ # A built module can be distributed as a "<module>.module" zip archive instead of an unpacked directory.
231
+ # Extract any such module to a scratch location up front. Scratch dirs are removed in main()'s finally.
232
+ # Skip an archive-only module that lacks conformance tests while testing is enabled: it cannot be
233
+ # consumed as-is, so leave it unmaterialized so it is detected below as needing a re-render.
234
+ for module in plain_module.all_required_modules + [plain_module]:
235
+ if args.render_conformance_tests and module.is_archived_only() and not module.archive_has_conformance_tests():
236
+ continue
237
+ module.materialize()
238
+
215
239
  # The module_metadata file lives outside the code git repo. That means that a crash mid-render can leave it
216
240
  # claiming a functionality was implemented even thought it wasn't yet committed (because of the crash).
217
241
  # Out of precaution, this reconciles every module_metadata against the code repo.
@@ -220,7 +244,7 @@ def render( # noqa: C901
220
244
 
221
245
  render_choice = None
222
246
  if render_range is None:
223
- plain_module_render_state = get_plain_module_render_state(plain_module)
247
+ plain_module_render_state = get_plain_module_render_state(plain_module, args.render_conformance_tests)
224
248
  if plain_module_render_state is not None:
225
249
  render_choices = get_render_choices(plain_module, plain_module_render_state, args.force_render)
226
250
  ask_user = True
@@ -334,7 +358,7 @@ def main(): # noqa: C901
334
358
  sys.exit(1)
335
359
 
336
360
  if not args.api:
337
- args.api = "https://api.codeplain.ai"
361
+ args.api = system_config.default_api_url
338
362
 
339
363
  try:
340
364
  print_status(args.api_key, args.api, system_config.client_version)
@@ -378,7 +402,7 @@ def main(): # noqa: C901
378
402
  event_bus = EventBus()
379
403
 
380
404
  if not args.api:
381
- args.api = "https://api.codeplain.ai"
405
+ args.api = system_config.default_api_url
382
406
 
383
407
  run_state = RunState(spec_filename=args.filename, replay_with=args.replay_with)
384
408
 
@@ -412,11 +436,15 @@ def main(): # noqa: C901
412
436
  if exc_info:
413
437
  dump_crash_logs(args, run_state)
414
438
  capture_crash(exc_info, run_state, args)
439
+ stop_narrating()
415
440
  print_exit_summary(
416
441
  run_state,
417
442
  args.filename,
418
443
  error_message=error_message,
419
444
  )
445
+ # Remove any scratch extractions created for archive-only ("<module>.module") modules.
446
+ for module in plain_module.all_required_modules + [plain_module]:
447
+ module.cleanup_scratch()
420
448
 
421
449
  if args.headless and (exc_info is not None or not run_state.render_succeeded):
422
450
  sys.exit(1)
plain2code_arguments.py CHANGED
@@ -6,6 +6,7 @@ from path_resolution import resolve_path
6
6
  from plain2code_console import console
7
7
  from plain2code_exceptions import AmbiguousConfigFileError
8
8
  from plain2code_read_config import get_args_from_config
9
+ from system_config import system_config
9
10
 
10
11
  # Attribute on the parsed Namespace mapping each argument dest to its source:
11
12
  # "cli" (explicit on the command line), "config" (from config.yaml), or
@@ -329,8 +330,10 @@ def create_parser():
329
330
  "--api",
330
331
  type=str,
331
332
  nargs="?",
332
- const="https://api.codeplain.ai",
333
- help="Alternative base URL for the API. Default: `https://api.codeplain.ai`",
333
+ # Pre-release builds default to the test API; see
334
+ # system_config._resolve_default_api_url.
335
+ const=system_config.default_api_url,
336
+ help=f"Alternative base URL for the API. Default: `{system_config.default_api_url}`",
334
337
  )
335
338
  _add_arg(
336
339
  parser,
@@ -424,8 +427,8 @@ def create_parser():
424
427
  "--headless",
425
428
  action="store_true",
426
429
  default=False,
427
- help="Run in headless mode: no TUI, no terminal output except a single render-started message. "
428
- "All logs are written to the log file.",
430
+ help="Run in headless mode: no TUI. Progress is written to the log file and streamed "
431
+ "to stdout as it happens; the specification text itself stays in the log file only.",
429
432
  )
430
433
 
431
434
  parser.add_argument(
plain2code_console.py CHANGED
@@ -67,8 +67,10 @@ class Plain2CodeConsole(Console):
67
67
  logger.log(level, " ".join(map(str, args)), extra={"log_color": color})
68
68
  style = base_style + Style(color=color) if color else base_style
69
69
  # Log messages must render exactly as logged: don't interpret square brackets
70
- # in interpolated content (error texts, file names) as Rich markup.
70
+ # in interpolated content (error texts, file names) as Rich markup, and don't
71
+ # let the repr highlighter restyle brackets and numbers inside them.
71
72
  kwargs.setdefault("markup", False)
73
+ kwargs.setdefault("highlight", False)
72
74
  super().print(*args, **kwargs, style=style)
73
75
 
74
76
  def print_list(self, items, style=None):
plain2code_exceptions.py CHANGED
@@ -117,3 +117,11 @@ class GitNotInstalledError(Exception):
117
117
  """Raised when git is not installed or not found on PATH."""
118
118
 
119
119
  pass
120
+
121
+
122
+ class InvalidModuleArchiveError(Exception):
123
+ """Raised when a ``<module>.module`` archive is missing, corrupt, or has an
124
+ unexpected layout (not a zip, missing ``code/``/``tests/``, ``.git`` not a real
125
+ directory, detached HEAD, or an unsafe member path)."""
126
+
127
+ pass
plain2code_logger.py CHANGED
@@ -1,3 +1,4 @@
1
+ import copy
1
2
  import logging
2
3
 
3
4
  from event_bus import EventBus
@@ -17,18 +18,28 @@ FILE_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
17
18
  FILE_LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
18
19
 
19
20
 
21
+ def _with_indented_message(record, indent: str):
22
+ """A copy of the record whose continuation lines are indented.
23
+
24
+ One record is handed to every attached handler in turn, so a formatter that
25
+ rewrites `record.msg` in place is rewriting it for the handlers that come after
26
+ it too — a headless render logging to both stdout and a file would indent each
27
+ continuation line twice, once per formatter. Copying keeps each handler's
28
+ formatting local to that handler.
29
+ """
30
+ indented = copy.copy(record)
31
+ indented.msg = record.getMessage().replace("\n", "\n" + indent)
32
+ indented.args = None # getMessage() already interpolated them into msg
33
+ return indented
34
+
35
+
20
36
  class IndentedFormatter(logging.Formatter):
21
37
  def __init__(self, fmt=None, datefmt=None, indent=16):
22
38
  super().__init__(fmt=fmt, datefmt=datefmt)
23
39
  self._indent = " " * indent
24
40
 
25
41
  def format(self, record):
26
- original_message = record.getMessage()
27
-
28
- modified_message = original_message.replace("\n", "\n" + self._indent)
29
-
30
- record.msg = modified_message
31
- return super().format(record)
42
+ return super().format(_with_indented_message(record, self._indent))
32
43
 
33
44
 
34
45
  class ElapsedTimeFormatter(logging.Formatter):
@@ -51,16 +62,11 @@ class ElapsedTimeFormatter(logging.Formatter):
51
62
  seconds = offset_seconds % 60
52
63
  elapsed_time = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]"
53
64
 
54
- # Add elapsed_time to the record so it can be used in the format string
55
- record.elapsed_time = elapsed_time
56
-
57
- # Handle multi-line messages with proper indentation
58
- original_message = record.getMessage()
59
- indent = " " * len(elapsed_time + " ")
60
- modified_message = original_message.replace("\n", "\n" + indent)
61
- record.msg = modified_message
65
+ # Continuation lines line up under the message, past the timestamp column.
66
+ indented = _with_indented_message(record, " " * len(elapsed_time + " "))
67
+ indented.elapsed_time = elapsed_time
62
68
 
63
- return super().format(record)
69
+ return super().format(indented)
64
70
 
65
71
 
66
72
  class LoggingHandler(logging.Handler):
@@ -132,3 +138,19 @@ def dump_crash_logs(args, run_state: RunState, formatter=None):
132
138
 
133
139
  if crash_handler and args.filename:
134
140
  crash_handler.dump_to_file(args.log_file_name, formatter)
141
+
142
+
143
+ NARRATION_HANDLER_NAME = "headless-narration"
144
+
145
+
146
+ def stop_narrating() -> None:
147
+ """Detaches the headless stdout sink once the render is over.
148
+
149
+ The summary prints with Rich and bypasses logging, so a still-attached sink reports
150
+ every failure twice.
151
+ """
152
+ root_logger = logging.getLogger(LOGGER_NAME)
153
+ for handler in list(root_logger.handlers):
154
+ if handler.get_name() == NARRATION_HANDLER_NAME:
155
+ root_logger.removeHandler(handler)
156
+ handler.close()
plain_file.py CHANGED
@@ -455,6 +455,19 @@ def process_imports(
455
455
  return required_concepts
456
456
 
457
457
 
458
+ def normalize_line_endings(plain_source_text: str) -> str:
459
+ return plain_source_text.replace("\r\n", "\n").replace("\r", "\n")
460
+
461
+
462
+ def restore_stripped_lines(plain_source_text: str, content: str) -> str:
463
+ stripped_source = plain_source_text.rstrip()
464
+ if not content or not stripped_source.endswith(content):
465
+ return content
466
+
467
+ stripped_line_count = stripped_source[: len(stripped_source) - len(content)].count("\n")
468
+ return "\n" * stripped_line_count + content
469
+
470
+
458
471
  def read_plain_source_metadata(plain_source_text):
459
472
  try:
460
473
  plain_source_obj = frontmatter.loads(plain_source_text)
@@ -490,6 +503,8 @@ def parse_plain_source( # noqa: C901
490
503
  imported_modules: list[str],
491
504
  modules_trace: list[str],
492
505
  ) -> PlainFileParseResult:
506
+ plain_source_text = normalize_line_endings(plain_source_text)
507
+
493
508
  plain_source_obj = read_plain_source_metadata(plain_source_text)
494
509
 
495
510
  plain_source = PLAIN_SOURCE_TEMPLATE.copy()
@@ -508,7 +523,9 @@ def parse_plain_source( # noqa: C901
508
523
 
509
524
  [_, loaded_templates] = file_utils.get_loaded_templates(template_dirs, plain_source_text)
510
525
 
511
- plain_source_full_text = render_plain_source(plain_source_obj.content, loaded_templates, code_variables)
526
+ plain_source_content = restore_stripped_lines(plain_source_text, plain_source_obj.content)
527
+
528
+ plain_source_full_text = render_plain_source(plain_source_content, loaded_templates, code_variables)
512
529
 
513
530
  plain_file = mistletoe.Document(io.StringIO(plain_source_full_text))
514
531