codeplain 0.3.4__py3-none-any.whl → 0.3.5__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.
_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.3.4"
1
+ __version__ = "0.3.5"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeplain
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Summary: Transform plain language specifications into working code
5
5
  License-File: LICENSE
6
6
  Classifier: Environment :: Console
@@ -12,20 +12,20 @@ Requires-Dist: gitpython==3.1.50
12
12
  Requires-Dist: mistletoe==1.3.0
13
13
  Requires-Dist: networkx==3.6.1
14
14
  Requires-Dist: python-frontmatter==1.3.0
15
- Requires-Dist: python-liquid2==0.3.0
15
+ Requires-Dist: python-liquid2==0.4.0
16
16
  Requires-Dist: pyyaml==6.0.3
17
- Requires-Dist: requests==2.34.0
17
+ Requires-Dist: requests==2.34.2
18
18
  Requires-Dist: rich==15.0.0
19
- Requires-Dist: sentry-sdk==2.62.0
19
+ Requires-Dist: sentry-sdk==2.63.0
20
20
  Requires-Dist: textual>=7.5.0
21
- Requires-Dist: tiktoken==0.12.0
21
+ Requires-Dist: tiktoken==0.13.0
22
22
  Requires-Dist: transitions==0.9.3
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: black==26.3.1; extra == 'dev'
25
25
  Requires-Dist: flake8==7.3.0; extra == 'dev'
26
26
  Requires-Dist: isort==5.13.2; extra == 'dev'
27
- Requires-Dist: mypy==1.11.2; extra == 'dev'
28
- Requires-Dist: pytest==8.3.5; extra == 'dev'
27
+ Requires-Dist: mypy==2.1.0; extra == 'dev'
28
+ Requires-Dist: pytest==9.1.1; extra == 'dev'
29
29
  Description-Content-Type: text/markdown
30
30
 
31
31
  # Codeplain plain2code renderer
@@ -1,28 +1,28 @@
1
- _version.py,sha256=oYLGMpySamd16KLiaBTfRyrAS7_oyp-TOEHmzmeumwg,22
1
+ _version.py,sha256=ThnCuF3X7rsQSd5PAea_jfYA70ZmhLvkFcLBxBPwZnY,22
2
2
  change_detection.py,sha256=CazORZrezeZSw2I6VBvieN-c2SEjieZPo6pNrKwmMVA,6915
3
3
  codeplain_REST_api.py,sha256=h_XoNws8fvf7OTtoVXhHh6OfLGg77UNFuggMBwaqBwE,21533
4
4
  concept_utils.py,sha256=D4qVHFBRvfJZwiIq1AD_SV0Ev-RnOMg2yGWo6Cal7PM,7913
5
5
  diff_utils.py,sha256=AjiQlqo5pRos_8hVXZo5yBurl5BzSrTMGrQv4dCtRCg,1198
6
6
  event_bus.py,sha256=g5XLwRfjrvTIcB6urjREuCJzZj5TA93hy8lKXLGXuCE,650
7
- file_utils.py,sha256=grPx2Rl6-q2ijSRoaAOZcsrpOjTqb4i_mtF-jzwhKcQ,10953
7
+ file_utils.py,sha256=q-ApwiLLJCgmmoy6-nf1QM9DaFXwzCJek6B-jQTI9ak,11665
8
8
  git_utils.py,sha256=LQQnL5DAFpH5fDBQgUZX2WfjWY4YC5OEGBPIXryTmnY,16896
9
9
  memory_management.py,sha256=nHNudgaOqp5UGkIMi62TRCc2nUeFOM0w-I1FfxBFaQE,6120
10
10
  module_renderer.py,sha256=pXMXKKGMhUwLLlSxppE_hpZlwpJ3WSHSfR9FQxt6rSI,6860
11
11
  partial_rendering.py,sha256=0ChOaGytogBUEGISpbWkbjT9Hqy8pnOkJdJXcitJDgk,12219
12
- plain2code.py,sha256=Yi6W9R3HNedbaeIgfFffQnfauwTXmJTi8ZRD0LdmfvA,14042
12
+ plain2code.py,sha256=etLa8WagRjtHrWIUt1SYO1cQNAW0sc7AqMqZQTk9gGo,15269
13
13
  plain2code_arguments.py,sha256=J05NwaY-O04rIWVF2yIXysNB_ofZS0wJFn0u1WaCCSA,15399
14
14
  plain2code_console.py,sha256=YdE-Kz9qRAzIIAg7aYA1bnXRcwy_aTpbFtkR2zHbd-I,4918
15
15
  plain2code_events.py,sha256=AM8KJS68uiVXRMLt861KaGhs2H4yzujlMB_xiYPfHQc,1478
16
- plain2code_exceptions.py,sha256=Fj1pwCffn06aivEplsTJJGdg6lAOR8gmWd4ctLl9cj4,1745
16
+ plain2code_exceptions.py,sha256=WQeBiqKC-gGqKuj_eBbXEtCC3X7mUJAALE9zrDBBEAE,1800
17
17
  plain2code_logger.py,sha256=kNKIfkS6Ydtut7Ptw5jmv5bFPgXlxB-_Ds5nd-j4d9o,4609
18
18
  plain2code_nodes.py,sha256=IwjQqJxRSJKgP6eEnkYGy3VQHIyoazgkFxAEkxkjfJ8,4426
19
19
  plain2code_read_config.py,sha256=D5CxnmTRjddupecWvstcUm0kIJWWzq5-Zmk6sU0wbIg,2235
20
- plain2code_state.py,sha256=gIHOmJe9Ml_Xifi6uV5VBMK3zXRpaRoqQNoMhjD1dvQ,2187
21
- plain2code_telemetry.py,sha256=ZtBgdpWmPZQ_SnC0O9A2XSG30kRJ9kWitQPN4mmT2S8,4245
22
- plain2code_utils.py,sha256=HYDJ4lTC3RwBgcJadPvcoU3-Y6krNiuDP4_t2xYKA2Q,874
23
- plain_file.py,sha256=VUfMCskTJZnHrFYgBlK8DXbJGnk7peuC6YY22zRNAek,29968
20
+ plain2code_state.py,sha256=1VwyxgKdA13fAJNKSiPBfBb3A20TKfGCNnOm06YdcS4,2233
21
+ plain2code_telemetry.py,sha256=7yuTH7mQE_wGVW2H1oiVZ3UJg98ArB5HcUUm9isnv9g,4354
22
+ plain2code_utils.py,sha256=id04t9Iez5pYygrE3fKEyrcfWDcniKFT2iQoF2CzTJs,1409
23
+ plain_file.py,sha256=WMz4DqlBAGpPa1xqf9vuUj6vzXJTKeNyUtSRz1EOvso,32282
24
24
  plain_modules.py,sha256=uvRPfxBJwstaWi5lgYKgtQdhhf0Naiy2mS41tLw_TjY,16186
25
- plain_spec.py,sha256=E2Yp2eXMExE7kx7GPQ7v9boHN8XhFUcqprn1DGMaFMs,15669
25
+ plain_spec.py,sha256=7nb5lLJKHZ3uu9THkSXyJ5brIKkbGW0ooVfRclLdlzA,16062
26
26
  system_config.py,sha256=trgytfXKXoCn0Pgy9u00x2D0ZRHlh_tADlbkwZnF8QE,1038
27
27
  cli_output/__init__.py,sha256=MBKl0YbVpWE-tBa3V5_aVbB7vbaqlIK2NsHTjRUZqis,284
28
28
  cli_output/dry_run.py,sha256=6NKhNWPtD0CBK_QHSsSqIKLTzibTwxS9d8ltP9d1bgU,1473
@@ -36,10 +36,10 @@ render_machine/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
36
  render_machine/code_renderer.py,sha256=-vp_ltU7e3ssX9hbS8IY4ETSp-39JAuoJ3ZltKpdt1M,3959
37
37
  render_machine/conformance_tests.py,sha256=MeEveEGJTClLGxWw04P4TR6U9kX9Qgz9qtxNrB9a8QI,7085
38
38
  render_machine/implementation_code_helpers.py,sha256=LdHNLoKgVtrDaGH2APX8C9vjNjLBUhu2V7OAaJvm4rM,2009
39
- render_machine/render_context.py,sha256=5W_PVrlPZMPlWmXlHQtYUiCIFvjNFsjNiJ42z5Hidv8,28447
39
+ render_machine/render_context.py,sha256=fjU_S5W5HoAX3-WlEM8hp4DKMX_NDgVlfwQUAp0yaf8,25633
40
40
  render_machine/render_types.py,sha256=cT3D0lmxQ3enIo0jGp3LEGCY2t3X1Q1_tqwcadzSAv0,8761
41
41
  render_machine/render_utils.py,sha256=ao0e64VSOGYfb8hwJXIZlbqer5HjNMYJWHlLDoHnJyc,9123
42
- render_machine/state_machine_config.py,sha256=F-pSrNPF31eYMmJMo0STj85q7rp-4qFyy7kEmJ8iVc4,26944
42
+ render_machine/state_machine_config.py,sha256=NnX5sBGkc6cNG8OOQ4PrJeJBCADRGZKJYQXV7ndR4bs,26789
43
43
  render_machine/states.py,sha256=niFXqayyA_rezCrlzo0hYeHAruErcPzQVF-BeIBGnL8,1968
44
44
  render_machine/triggers.py,sha256=VekNhHGR-u5JXIboqc_Eyqf73LNH7wezLK7r3WWtodk,1269
45
45
  render_machine/actions/analyze_specification_ambiguity.py,sha256=SykZAozGvr1jySxbpKCgt11nMpZhOK0sN-J66M42T5Y,2847
@@ -49,17 +49,16 @@ render_machine/actions/commit_implementation_code_changes.py,sha256=yi51oAArLCgW
49
49
  render_machine/actions/create_dist.py,sha256=xMPPyws7swVkaUSQg3dwMGScCi1BuII9y2C_XSP0N80,1067
50
50
  render_machine/actions/exit_with_error.py,sha256=-LkQsFhmV-Nr741APTx38dqu473b6OSiIvKayFFsXxI,1270
51
51
  render_machine/actions/finish_functional_requirement.py,sha256=usQgPSg6PHpBOR1RhK7lPz9zjY-2VJJDqi3T0nlqdLw,841
52
- render_machine/actions/fix_conformance_test.py,sha256=ePfxz9Xm0IXht0ch7H4tLPdRzoKnVdlbcDC9EgeDAAQ,8786
52
+ render_machine/actions/fix_conformance_test.py,sha256=jtVeL11J8K-a5YvoR3uW6TVytQZkiBgXlDyFR3wXYEs,10022
53
53
  render_machine/actions/fix_unit_tests.py,sha256=hvwvCJDk_-8fHMpkNFyeBSG1IshIcufDpymF00ulPXY,2301
54
54
  render_machine/actions/prepare_repositories.py,sha256=eE-uU6H_K5IQMraVWSRi4TKk_XPrnA878T_JwtI9cWo,3410
55
- render_machine/actions/prepare_testing_environment.py,sha256=GQNVzc_Ap5n74OAmeQuAhXXHK2FVVY374Z0lutrN2UQ,1867
56
- render_machine/actions/refactor_code.py,sha256=_1C16bQbpxdzJkEwl8jDhEKVyXcyGTd-5YYDNTq2vA8,1782
55
+ render_machine/actions/prepare_testing_environment.py,sha256=kIsXgV6-jOlqp-04cimPgTQoHPrVIu0zW-H_Jj-jowE,2106
56
+ render_machine/actions/refactor_code.py,sha256=gyPsnqgHseL2bkENu4WeGCRyV0R18_GH3JOrPdoxKvg,2609
57
57
  render_machine/actions/render_conformance_tests.py,sha256=5gOQn-G1T9UWEI8sDfALcVdArk0ln9DgUprOPjccS-U,9268
58
- render_machine/actions/render_functional_requirement.py,sha256=2SbPGsLbrrQx3kasXpSpphzaMsq9GD8ASnSzvIdodT4,3748
58
+ render_machine/actions/render_functional_requirement.py,sha256=uiEwrMdArRdPX7eWysYMTI5y2pXacUySTpIgL2O-lOI,4602
59
59
  render_machine/actions/run_conformance_tests.py,sha256=4sJTORl6YLFXQIdLMBQHDeJd2hztGyt3tsVvrCviBko,4008
60
60
  render_machine/actions/run_unit_tests.py,sha256=A4WXtSzpCLDHYR8fQAbfxUf_E19GYflZSHZaSreoFPk,2101
61
61
  render_machine/actions/summarize_conformance_tests.py,sha256=Vy1kRK8vyazK_BbsKuz1MLDN_TQWVxxzkwEEm-50byc,1639
62
- standard_template_library/__init__.py,sha256=LtsHl0c3VV46DUInIqyOw4KKVoxKEMOx8dAZpMXtAXc,43
63
62
  standard_template_library/golang-console-app-template.plain,sha256=JOusEjCj7jagnXUzaI-SdIr3hj1wTo2uShK017aNtVM,949
64
63
  standard_template_library/python-console-app-template.plain,sha256=HW-REeF8Crk0r_w4-MxnleVHeW6PdOB2C8gO8FDG61g,931
65
64
  standard_template_library/typescript-react-app-boilerplate.plain,sha256=6LFxhEOzWnDHdVpFj-XBTq6s4_2dy7LKV3lVkCzv8i4,114
@@ -68,17 +67,18 @@ tests/__init__.py,sha256=Wk73Io62J15BtlLVIzxmASDWaaJkQLevS4BLK5LDAQg,16
68
67
  tests/conftest.py,sha256=QZcp08htUlJGgmHDlRWFgsWXZ8o8IBWTD5QqJaUMlU8,790
69
68
  tests/test_change_detection.py,sha256=jH8SEhndX400DNqIKmcYLzxHqq9WFdgmuvY3XPq-ZFc,13534
70
69
  tests/test_cli_output.py,sha256=owDP9pLHdErtPAr1paNKp1o044kuH-HxInHLIBLZQFI,18420
71
- tests/test_file_utils.py,sha256=-O5zImiFVz9JJ4xi8TbsUwNgPFQoQUOl9Frls4BHCBI,1358
70
+ tests/test_file_utils.py,sha256=R_ZIhrTtwlhHxgmbBtA8PFEU5-QB4Fi5skJ7PS2KzdU,3614
72
71
  tests/test_git_utils.py,sha256=h3rWhhs4XVRYqzVzdmj51siF_AYD-GkbZiefK2iGfWo,15614
73
72
  tests/test_imports.py,sha256=EWYLUxGunUUdsG9iCiY_tts6T7DIVdhdB_MZlB1LsmU,2081
74
73
  tests/test_partial_rendering.py,sha256=r0RUUd1pI4-2ZkRrM1j8_kUqv-70hrOGjuKBhZoRmH0,22758
74
+ tests/test_plain2code.py,sha256=DQYFc0JVSkMI8LZCNaQ__2Nph-DcZZW17rprJA54eyw,3851
75
75
  tests/test_plain_modules.py,sha256=S7sd4ZYNotXOgCyZzmBd714QFLih69nlZ3tV8WNtVPo,13766
76
76
  tests/test_plainfile.py,sha256=wZ_PN4GULPWIpl_AK_r_6jkgtX0Ch8EHmN5IhX7huOE,11027
77
- tests/test_plainfileparser.py,sha256=7AassxH7AKX4lfW6E_58FYxRe-IzIiA16BMVWPjWqCM,18750
78
- tests/test_plainspec.py,sha256=JrzjJ4VFYDmWTZDq-8WFyt2oMx76IEuO1CAUPQeGOsw,2404
77
+ tests/test_plainfileparser.py,sha256=DXY7HSKDMaB4nqUwn_T5eyySEZhnfqDjHntkuTsGhY0,21442
78
+ tests/test_plainspec.py,sha256=ng55E8ajrH2-gPC1p_QDIe_zXplPvoqHJ-d57UWKFTw,3577
79
79
  tests/test_requires.py,sha256=6tnHYaKudxN-RiQjSFoi9nHe_x0ze3QF-f-j67zAE74,1250
80
80
  tests/test_resolve_config_file.py,sha256=b87tDOvyzbsnE-QRWbZ97ou7NuyaAgskv-dbYBum31c,2830
81
- tests/test_telemetry.py,sha256=04mboTP6iXedSF7E4ZL6MPRYCw__-5qlVerjX1Ve7VI,6627
81
+ tests/test_telemetry.py,sha256=hjm81aRQQZcZ0RB6aVbS3WO46_nIAii_csmbbtnUh74,7110
82
82
  tests/e2e/conftest.py,sha256=5Dm67UTOE-M6hMb-mqZvwa6Ek_wmZgsR7fUv9ZEqCwY,4164
83
83
  tests/e2e/test_hello_world_python.py,sha256=PQy4elzB7WvpiWLKa6_HFO0K8HA6_6hGLaAqRi0J3OU,1627
84
84
  tests/e2e/test_hello_world_python_windows.py,sha256=kJQZIuzj8OEEtgBVxnzFvyQJ_wvP2oBu9L-E9geg8sE,2010
@@ -91,8 +91,8 @@ tui/spinner.py,sha256=Ro6Gd9Przf-whuHqPRY6HwI0T57yJjyNPbhDbigZKZE,2471
91
91
  tui/state_handlers.py,sha256=4r_6m5YMcprQtgiTP0De3XFfGAF8VzgaWXHxPweYPbQ,16325
92
92
  tui/styles.css,sha256=c13WY1nOgmtkT_FH9Ad2PjXrfi8LYwr6zzD6rBOh9tU,6685
93
93
  tui/widget_helpers.py,sha256=mWDPrALsxLOzJg8KD9MXXafJKRiqG-EIp797jbOT77o,5972
94
- codeplain-0.3.4.dist-info/METADATA,sha256=FxZHLPsuVS6_3QE8vziDqspvfX4f1JfIvQGQIjBZE5U,5381
95
- codeplain-0.3.4.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
96
- codeplain-0.3.4.dist-info/entry_points.txt,sha256=oDZkBqu9WhtZApb_K6ia8-fn9aojwmAsgnKELceX5T4,46
97
- codeplain-0.3.4.dist-info/licenses/LICENSE,sha256=pCeKgQ1mXE5OmNUuKOfOZh1T1vqkeCUxghE7N8qgnzc,11345
98
- codeplain-0.3.4.dist-info/RECORD,,
94
+ codeplain-0.3.5.dist-info/METADATA,sha256=Jr8-9-HWWeLthEkBUPqOklrg_gvyrjehDlD_GZUWiUw,5380
95
+ codeplain-0.3.5.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
96
+ codeplain-0.3.5.dist-info/entry_points.txt,sha256=oDZkBqu9WhtZApb_K6ia8-fn9aojwmAsgnKELceX5T4,46
97
+ codeplain-0.3.5.dist-info/licenses/LICENSE,sha256=pCeKgQ1mXE5OmNUuKOfOZh1T1vqkeCUxghE7N8qgnzc,11345
98
+ codeplain-0.3.5.dist-info/RECORD,,
file_utils.py CHANGED
@@ -8,8 +8,9 @@ from liquid2.exceptions import UndefinedError
8
8
 
9
9
  import plain_spec
10
10
  from plain2code_console import console
11
- from plain2code_exceptions import UnsupportedResourceType
11
+ from plain2code_exceptions import UnsupportedBase64Content, UnsupportedResourceType
12
12
  from plain2code_nodes import Plain2CodeIncludeTag, Plain2CodeLoaderMixin
13
+ from plain2code_utils import find_large_base64_blob
13
14
  from plain_modules import CODEPLAIN_MEMORY_SUBFOLDER, CODEPLAIN_METADATA_FOLDER
14
15
 
15
16
  BINARY_FILE_EXTENSIONS = [".pyc"]
@@ -157,7 +158,7 @@ def store_response_files(target_folder, response_files, existing_files):
157
158
 
158
159
  os.makedirs(os.path.dirname(full_file_name), exist_ok=True)
159
160
 
160
- with open(full_file_name, "w") as f:
161
+ with open(full_file_name, "w", encoding="utf-8") as f:
161
162
  f.write(response_files[file_name])
162
163
 
163
164
  if file_name not in existing_files:
@@ -206,6 +207,16 @@ def load_linked_resources(template_dirs: list[str], resources_list, module_name:
206
207
  Please ensure that the resource exists in one of these locations, or specify the correct --template-dir if using custom templates.
207
208
  """)
208
209
 
210
+ blob = find_large_base64_blob(content)
211
+ if blob is not None:
212
+ raise UnsupportedBase64Content(
213
+ f"Referenced resource '{file_name}' in module '{module_name}' contains a large "
214
+ f"base64-encoded blob ({len(blob)} characters), such as an embedded image. Inline "
215
+ "base64 data is not supported. Remove the data from the resource. "
216
+ "If the data should be used by the end software, "
217
+ "save the data to a separate file and include the file path in the specification without it being a reference file."
218
+ )
219
+
209
220
  linked_resources[file_name] = content
210
221
 
211
222
  return linked_resources
plain2code.py CHANGED
@@ -1,4 +1,3 @@
1
- import importlib.resources
2
1
  import logging
3
2
  import logging.config
4
3
  import os
@@ -37,6 +36,7 @@ from plain2code_exceptions import (
37
36
  PlainSyntaxError,
38
37
  RenderCancelledError,
39
38
  RenderingCreditBalanceTooLow,
39
+ UnsupportedBase64Content,
40
40
  UnsupportedResourceType,
41
41
  )
42
42
  from plain2code_logger import (
@@ -54,7 +54,7 @@ from system_config import system_config
54
54
  from tui.plain2code_tui import Plain2CodeTUI
55
55
  from tui.plain_module_render_choice_tui import PlainModuleRenderChoiceTUI
56
56
 
57
- DEFAULT_TEMPLATE_DIRS = importlib.resources.files("standard_template_library")
57
+ DEFAULT_TEMPLATE_DIRS = "standard_template_library"
58
58
  RENDER_THREAD_SHUTDOWN_TIMEOUT = 0.7
59
59
 
60
60
  # Exceptions that represent expected, user-facing error conditions. They are
@@ -74,6 +74,7 @@ EXPECTED_EXCEPTIONS = (
74
74
  NetworkConnectionError,
75
75
  ModuleDoesNotExistError,
76
76
  UnsupportedResourceType,
77
+ UnsupportedBase64Content,
77
78
  GitNotInstalledError,
78
79
  SystemExit,
79
80
  )
@@ -139,8 +140,8 @@ def setup_logging(
139
140
  return logging.getLevelName(configured_log_level)
140
141
 
141
142
 
142
- def _check_connection(codeplainAPI: codeplain_api.CodeplainAPI):
143
- """Check API connectivity and validate API key and client version."""
143
+ def _check_connection(codeplainAPI: codeplain_api.CodeplainAPI) -> Optional[str]:
144
+ """Check API connectivity, validate API key and client version, and return the user email."""
144
145
  response = codeplainAPI.connection_check(system_config.client_version)
145
146
 
146
147
  if not response.get("api_key_valid", False):
@@ -157,33 +158,58 @@ def _check_connection(codeplainAPI: codeplain_api.CodeplainAPI):
157
158
  "Please update using: uv tool upgrade codeplain"
158
159
  )
159
160
 
161
+ return response.get("user_email")
160
162
 
161
- def render(args, run_state: RunState, event_bus: EventBus, default_log_level: str = "INFO"): # noqa: C901
162
- template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS)
163
163
 
164
+ def warn_if_acceptance_tests_without_conformance_script(plain_module, args) -> None:
165
+ """Warn when any loaded module (including required modules) defines acceptance tests
166
+ but no conformance tests script is configured.
167
+
168
+ Acceptance tests are treated as conformance tests, so a conformance tests script is required
169
+ to actually run them. Without it, the acceptance tests cannot be executed.
170
+ """
171
+ if args.conformance_tests_script:
172
+ return
173
+
174
+ module_names_with_acceptance_tests = [
175
+ module.module_name
176
+ for module in plain_module.all_required_modules + [plain_module]
177
+ if plain_spec.has_acceptance_tests(module.plain_source)
178
+ ]
179
+ if not module_names_with_acceptance_tests:
180
+ return
181
+
182
+ module_names = ", ".join(module_names_with_acceptance_tests)
183
+ console.warning(
184
+ f"Acceptance tests were found ({module_names}) but no conformance tests script is configured. "
185
+ "Acceptance tests are treated as conformance tests and require a conformance tests script "
186
+ "(--conformance-tests-script or 'conformance_tests_script' in config) to be executed."
187
+ )
188
+
189
+
190
+ def render( # noqa: C901
191
+ plain_module: plain_modules.PlainModule,
192
+ args,
193
+ run_state: RunState,
194
+ event_bus: EventBus,
195
+ default_log_level: str = "INFO",
196
+ ):
164
197
  # Compute render range from either --render-range or --render-from
165
198
  render_range = None
166
199
  if args.render_range or args.render_from:
167
- # Parse the plain file to get the plain_source for FRID extraction
168
- _, plain_source, _ = plain_file.plain_file_parser(args.filename, template_dirs)
169
- render_range = plain_spec.compute_render_range(args, plain_source)
200
+ render_range = plain_spec.compute_render_range(args, plain_module.plain_source)
170
201
 
171
202
  codeplainAPI = codeplain_api.CodeplainAPI(args.api_key, console)
172
203
  assert args.api is not None and args.api != "", "API URL is required"
173
204
  codeplainAPI.api_url = args.api
174
205
 
175
- _check_connection(codeplainAPI)
206
+ run_state.user_email = _check_connection(codeplainAPI)
176
207
 
177
208
  stop_event = threading.Event()
178
209
  enter_pause_event = threading.Event()
179
210
  signal.signal(signal.SIGTERM, lambda _signum, _frame: stop_event.set())
180
211
 
181
- plain_module = plain_modules.PlainModule(
182
- args.filename,
183
- args.build_folder,
184
- args.conformance_tests_folder,
185
- template_dirs,
186
- )
212
+ warn_if_acceptance_tests_without_conformance_script(plain_module, args)
187
213
 
188
214
  render_choice = None
189
215
  if render_range is None:
@@ -308,28 +334,38 @@ def main(): # noqa: C901
308
334
  console.error(f"Error fetching status: {str(e)}")
309
335
  return
310
336
 
311
- # Handle early-exit flags before heavy initialization
312
- if args.dry_run or args.full_plain:
313
- template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS)
337
+ template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS)
314
338
 
339
+ # Handle full plain early-exit (raw text dump; does not require a parsed module).
340
+ if args.full_plain:
315
341
  try:
316
- if args.full_plain:
317
- module_name = Path(args.filename).stem
318
- plain_source = plain_file.read_module_plain_source(module_name, template_dirs)
319
- [full_plain_source, _] = file_utils.get_loaded_templates(template_dirs, plain_source)
320
- console.info("Full plain text:\n")
321
- console.info(full_plain_source)
322
- return
323
-
324
- if args.dry_run:
325
- console.info("Printing dry run output...\n")
326
- _, plain_source_tree, _ = plain_file.plain_file_parser(args.filename, template_dirs)
327
- render_range = plain_spec.compute_render_range(args, plain_source_tree)
328
- print_dry_run_output(plain_source_tree, render_range)
329
- return
342
+ module_name = Path(args.filename).stem
343
+ plain_source = plain_file.read_module_plain_source(module_name, template_dirs)
344
+ [full_plain_source, _] = file_utils.get_loaded_templates(template_dirs, plain_source)
345
+ console.info("Full plain text:\n")
346
+ console.info(full_plain_source)
330
347
  except Exception as e:
331
348
  console.error(f"Error: {str(e)}")
332
- return
349
+ return
350
+
351
+ # Parse the plain file (and its required modules) once; reused by dry-run and rendering.
352
+ try:
353
+ plain_module = plain_modules.PlainModule(
354
+ args.filename,
355
+ args.build_folder,
356
+ args.conformance_tests_folder,
357
+ template_dirs,
358
+ )
359
+ except Exception as e:
360
+ console.error(f"Error: {str(e)}")
361
+ return
362
+
363
+ if args.dry_run:
364
+ console.info("Printing dry run output...\n")
365
+ render_range = plain_spec.compute_render_range(args, plain_module.plain_source)
366
+ print_dry_run_output(plain_module.plain_source, render_range)
367
+ warn_if_acceptance_tests_without_conformance_script(plain_module, args)
368
+ return
333
369
 
334
370
  event_bus = EventBus()
335
371
 
@@ -357,7 +393,7 @@ def main(): # noqa: C901
357
393
  raise MissingAPIKey(
358
394
  "Your API key is required. Please set the CODEPLAIN_API_KEY environment variable or provide it with the --api-key argument.\n"
359
395
  )
360
- render(args, run_state, event_bus, default_log_level)
396
+ render(plain_module, args, run_state, event_bus, default_log_level)
361
397
  except BaseException as e:
362
398
  if isinstance(e, KeyboardInterrupt):
363
399
  error_message = "Keyboard interrupt"
plain2code_exceptions.py CHANGED
@@ -29,6 +29,11 @@ class PlainSyntaxError(Exception):
29
29
  pass
30
30
 
31
31
 
32
+ class UnsupportedBase64Content(Exception):
33
+
34
+ pass
35
+
36
+
32
37
  class InternalClientError(Exception):
33
38
  pass
34
39
 
plain2code_state.py CHANGED
@@ -27,6 +27,7 @@ class RunState:
27
27
  self.current_module: Optional[str] = None
28
28
  self.current_frid: Optional[str] = None
29
29
  self.current_render_state: Optional[str] = None
30
+ self.user_email: Optional[str] = None
30
31
 
31
32
  def increment_call_count(self):
32
33
  self.call_count += 1
plain2code_telemetry.py CHANGED
@@ -98,6 +98,8 @@ def capture_crash(exc_info, run_state: Optional[RunState], args) -> bool:
98
98
  scope.set_tag("render_state", run_state.current_render_state)
99
99
  scope.set_tag("current_module", run_state.current_module)
100
100
  scope.set_tag("current_frid", run_state.current_frid)
101
+ if run_state.user_email:
102
+ scope.set_user({"email": run_state.user_email})
101
103
  scope.set_tag("headless", bool(getattr(args, "headless", False)))
102
104
  scope.set_tag("unittests_script_provided", bool(getattr(args, "unittests_script", None)))
103
105
  scope.set_tag("conformance_tests_script_provided", bool(getattr(args, "conformance_tests_script", None)))
plain2code_utils.py CHANGED
@@ -1,3 +1,21 @@
1
+ import re
2
+ from typing import Optional
3
+
4
+ #
5
+ MAX_BASE64_BLOB_LENGTH = 8192
6
+
7
+ # Matches a long contiguous base64 / base64url run, optionally preceded by a data: URI header.
8
+ _BASE64_BLOB_PATTERN = re.compile(
9
+ r"(?:data:[\w.+-]+/[\w.+-]+;base64,)?[A-Za-z0-9+/_-]{%d,}={0,2}" % MAX_BASE64_BLOB_LENGTH
10
+ )
11
+
12
+
13
+ def find_large_base64_blob(text: str) -> Optional[str]:
14
+ """Return the first contiguous base64 blob at or above the threshold, or None."""
15
+ match = _BASE64_BLOB_PATTERN.search(text)
16
+ return match.group(0) if match else None
17
+
18
+
1
19
  def format_duration_hms(total_seconds: int) -> str:
2
20
  """Format a duration in seconds as hours, minutes, and seconds (e.g. ``1h 2m 3.45s``, ``45.67s``)."""
3
21
  if total_seconds < 0:
plain_file.py CHANGED
@@ -17,8 +17,9 @@ from mistletoe.utils import traverse
17
17
  import concept_utils
18
18
  import file_utils
19
19
  import plain_spec
20
- from plain2code_exceptions import ModuleDoesNotExistError, PlainSyntaxError
20
+ from plain2code_exceptions import ModuleDoesNotExistError, PlainSyntaxError, UnsupportedBase64Content
21
21
  from plain2code_nodes import Plain2CodeIncludeTag, Plain2CodeLoaderMixin
22
+ from plain2code_utils import find_large_base64_blob
22
23
 
23
24
  RESOURCE_MARKER = "[resource]"
24
25
 
@@ -203,6 +204,26 @@ def _is_acceptance_test_heading(token) -> tuple[bool, str | None]:
203
204
  return False, None
204
205
 
205
206
 
207
+ def _find_acceptance_test_heading(token):
208
+ """
209
+ Recursively search a token tree for an `acceptance tests` heading.
210
+
211
+ Returns the heading token if one is found anywhere in the subtree, otherwise None.
212
+ Used to detect acceptance tests nested under sections other than functional specs.
213
+ """
214
+ is_acceptance_test_heading, _ = _is_acceptance_test_heading(token)
215
+ if is_acceptance_test_heading:
216
+ return token
217
+
218
+ if getattr(token, "children", None):
219
+ for child in token.children:
220
+ found = _find_acceptance_test_heading(child)
221
+ if found is not None:
222
+ return found
223
+
224
+ return None
225
+
226
+
206
227
  def _process_single_acceptance_test_requirement(functional_requirement: mistletoe.block_token.ListItem):
207
228
  """
208
229
  Process a single functionality to extract acceptance tests.
@@ -473,6 +494,11 @@ def parse_plain_source( # noqa: C901
473
494
 
474
495
  specification_heading = token.children[0].children[0].children[0].content
475
496
 
497
+ if specification_heading == plain_spec.ACCEPTANCE_TEST_HEADING:
498
+ raise PlainSyntaxError(
499
+ f"Plain syntax error: Syntax error at line {token.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec."
500
+ )
501
+
476
502
  if specification_heading not in plain_spec.ALLOWED_SPECIFICATION_HEADINGS:
477
503
  raise PlainSyntaxError(
478
504
  f"Plain syntax error: Syntax error at line {token.line_number}: Invalid specification heading (`{specification_heading}`). Allowed headings: {', '.join(plain_spec.ALLOWED_SPECIFICATION_HEADINGS)}"
@@ -511,6 +537,22 @@ def parse_plain_source( # noqa: C901
511
537
  f"Plain syntax error: Syntax error at line {token.line_number}: Invalid source structure (`{token_text}`)"
512
538
  )
513
539
 
540
+ # Acceptance tests are only allowed nested under functional specs. Reject them when
541
+ # they are nested under any other section (e.g. definitions, implementation reqs, test reqs).
542
+ for non_functional_heading in (
543
+ plain_spec.DEFINITIONS,
544
+ plain_spec.NON_FUNCTIONAL_REQUIREMENTS,
545
+ plain_spec.TEST_REQUIREMENTS,
546
+ ):
547
+ section = plain_source.get(non_functional_heading)
548
+ if section is None:
549
+ continue
550
+ nested_acceptance_test_heading = _find_acceptance_test_heading(section)
551
+ if nested_acceptance_test_heading is not None:
552
+ raise PlainSyntaxError(
553
+ f"Plain syntax error: Syntax error at line {nested_acceptance_test_heading.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec."
554
+ )
555
+
514
556
  if plain_source[plain_spec.DEFINITIONS] is not None:
515
557
  with PlainRenderer() as renderer:
516
558
  for token in plain_source[plain_spec.DEFINITIONS].children:
@@ -536,6 +578,16 @@ def read_module_plain_source(module_name: str, template_dirs: list[str]) -> str:
536
578
  plain_source_text = file_utils.open_from(template_dirs, module_name + PLAIN_SOURCE_FILE_EXTENSION)
537
579
  if plain_source_text is None:
538
580
  raise ModuleDoesNotExistError(f"Module does not exist ({module_name}).")
581
+
582
+ blob = find_large_base64_blob(plain_source_text)
583
+ if blob is not None:
584
+ raise UnsupportedBase64Content(
585
+ f"Module '{module_name}' contains a base64-encoded blob ({len(blob)} characters) "
586
+ "inlined in the specification. This is not supported."
587
+ "Remove the base64 data from the .plain file or if necessary,"
588
+ "include the binary file path in the specification."
589
+ )
590
+
539
591
  return plain_source_text
540
592
 
541
593
 
plain_spec.py CHANGED
@@ -25,6 +25,15 @@ ALLOWED_SPECIFICATION_HEADINGS = [
25
25
  ALLOWED_IMPORT_SPECIFICATION_HEADINGS = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS]
26
26
 
27
27
 
28
+ def has_acceptance_tests(plain_source_tree) -> bool:
29
+ """Return True if any functional requirement in the plain source defines acceptance tests."""
30
+ functional_requirements = plain_source_tree.get(FUNCTIONAL_REQUIREMENTS) or []
31
+ for functional_requirement in functional_requirements:
32
+ if functional_requirement.get(ACCEPTANCE_TESTS):
33
+ return True
34
+ return False
35
+
36
+
28
37
  def collect_specification_linked_resources(specification, specification_heading, linked_resources_list):
29
38
  linked_resources = []
30
39
  if "linked_resources" in specification:
@@ -9,12 +9,17 @@ from plain2code_exceptions import InternalClientError
9
9
  from render_machine.actions.base_action import BaseAction
10
10
  from render_machine.implementation_code_helpers import ImplementationCodeHelpers
11
11
  from render_machine.render_context import RenderContext
12
- from render_machine.render_types import TestExecutionPhase
12
+ from render_machine.render_types import RenderError, TestExecutionPhase
13
+
14
+ MAX_CONFORMANCE_TEST_FIX_ATTEMPTS = 20
15
+ MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS = 1
13
16
 
14
17
 
15
18
  class FixConformanceTest(BaseAction):
16
19
  IMPLEMENTATION_CODE_NOT_UPDATED = "implementation_code_not_updated"
17
20
  IMPLEMENTATION_CODE_UPDATED = "implementation_code_updated"
21
+ LIMIT_EXCEEDED_OUTCOME = "conformance_test_fix_limit_exceeded"
22
+ REGENERATE_CONFORMANCE_TESTS_OUTCOME = "regenerate_conformance_tests"
18
23
 
19
24
  ISSUE_REASON_CODE_CONFORMANCE_TESTS = 0
20
25
  ISSUE_REASON_CODE_IMPLEMENTATION_CODE = 1
@@ -22,6 +27,23 @@ class FixConformanceTest(BaseAction):
22
27
  ISSUE_REASON_CODE_CONFLICTING_ACCEPTANCE_TESTS = 3
23
28
 
24
29
  def execute(self, render_context: RenderContext, previous_action_payload: Any | None):
30
+ ctx = render_context.conformance_tests_running_context
31
+ ctx.fix_attempts += 1
32
+
33
+ if ctx.fix_attempts >= MAX_CONFORMANCE_TEST_FIX_ATTEMPTS:
34
+ if ctx.conformance_tests_render_attempts >= MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS:
35
+ error_msg = f"The renderer was unable to produce an implementation that passes conformance tests for functionality '{render_context.frid_context.frid}' after many attempts. Please review and rewrite the specification. (Render ID: {render_context.run_state.render_id})"
36
+ render_context.last_error_message = error_msg
37
+ return (
38
+ self.LIMIT_EXCEEDED_OUTCOME,
39
+ RenderError.encode(message=error_msg).to_payload(),
40
+ )
41
+ else:
42
+ ctx.regenerating_conformance_tests = True
43
+ return self.REGENERATE_CONFORMANCE_TESTS_OUTCOME, None
44
+
45
+ console.info(f"Running conformance tests attempt {ctx.fix_attempts + 1}.")
46
+
25
47
  console.info(
26
48
  f"Fixing conformance test for functionality {render_context.conformance_tests_running_context.current_testing_frid} in module {render_context.conformance_tests_running_context.current_testing_module_name}."
27
49
  )
@@ -12,6 +12,12 @@ class PrepareTestingEnvironment(BaseAction):
12
12
  FAILED_OUTCOME = "testing_environment_preparation_failed"
13
13
 
14
14
  def execute(self, render_context: RenderContext, _previous_action_payload: Any | None):
15
+ if (
16
+ render_context.prepare_environment_script is None
17
+ or not render_context.conformance_tests_running_context.should_prepare_testing_environment
18
+ ):
19
+ return self.SUCCESSFUL_OUTCOME, None
20
+
15
21
  console.info(
16
22
  f"Running testing environment preparation script {render_context.prepare_environment_script} for build folder {render_context.build_folder}."
17
23
  )
@@ -5,13 +5,31 @@ from plain2code_console import console
5
5
  from render_machine.actions.base_action import BaseAction
6
6
  from render_machine.implementation_code_helpers import ImplementationCodeHelpers
7
7
  from render_machine.render_context import RenderContext
8
+ from render_machine.render_types import RenderError
9
+
10
+ MAX_REFACTORING_ITERATIONS = 5
8
11
 
9
12
 
10
13
  class RefactorCode(BaseAction):
11
14
  SUCCESSFUL_OUTCOME = "refactoring_successful"
12
15
  NO_FILES_REFACTORED_OUTCOME = "no_files_refactored"
16
+ ITERATION_LIMIT_EXCEEDED_OUTCOME = "refactoring_iteration_limit_exceeded"
13
17
 
14
18
  def execute(self, render_context: RenderContext, _previous_action_payload: Any | None):
19
+ if render_context.frid_context.refactoring_iteration == 0:
20
+ console.info("Refactoring the generated code...")
21
+
22
+ render_context.frid_context.refactoring_iteration += 1
23
+
24
+ if render_context.frid_context.refactoring_iteration >= MAX_REFACTORING_ITERATIONS:
25
+ error_message = "Refactoring iterations limit of {MAX_REFACTORING_ITERATIONS} reached for functionality {render_context.frid_context.frid}."
26
+ render_context.last_error_message = error_message
27
+
28
+ return (
29
+ self.ITERATION_LIMIT_EXCEEDED_OUTCOME,
30
+ RenderError.encode(message=error_message).to_payload(),
31
+ )
32
+
15
33
  existing_files, existing_files_content = ImplementationCodeHelpers.fetch_existing_files(
16
34
  render_context.build_folder
17
35
  )
@@ -16,8 +16,22 @@ MAX_CODE_GENERATION_RETRIES = 2
16
16
  class RenderFunctionalRequirement(BaseAction):
17
17
  SUCCESSFUL_OUTCOME = "code_and_unit_tests_generated"
18
18
  FUNCTIONAL_REQUIREMENT_TOO_COMPLEX_OUTCOME = "functional_requirement_too_complex"
19
+ ITERATION_LIMIT_EXCEEDED_OUTCOME = "frid_iteration_limit_exceeded"
19
20
 
20
21
  def execute(self, render_context: RenderContext, _previous_action_payload: Any | None):
22
+ if render_context.frid_context.functional_requirement_render_attempts >= MAX_CODE_GENERATION_RETRIES:
23
+ error_msg = f"Unittests could not be fixed after rendering the functionality {render_context.frid_context.frid} for the {MAX_CODE_GENERATION_RETRIES} times."
24
+ render_context.last_error_message = error_msg
25
+ return self.ITERATION_LIMIT_EXCEEDED_OUTCOME, None
26
+
27
+ render_context.frid_context.functional_requirement_render_attempts += 1
28
+
29
+ if render_context.frid_context.functional_requirement_render_attempts > 1:
30
+ console.info(
31
+ f"Unittests could not be fixed after rendering the functionality. "
32
+ f"Restarting rendering functionality {render_context.frid_context.frid} from scratch."
33
+ )
34
+
21
35
  render_utils.revert_changes_for_frid(render_context)
22
36
  existing_files, existing_files_content = ImplementationCodeHelpers.fetch_existing_files(
23
37
  render_context.build_folder
@@ -48,7 +62,7 @@ class RenderFunctionalRequirement(BaseAction):
48
62
  render_context.run_state,
49
63
  )
50
64
  except FunctionalRequirementTooComplex as e:
51
- error_message = f"The functionality:\n{render_context.frid_context.functional_requirement_text}\n is too complex to be implemented. Please break down the functionality into smaller parts ({str(e)})."
65
+ error_message = f"The functionality:\n{render_context.frid_context.functional_requirement_text}\n is too complex to be implemented. Please break down the functionality into smaller parts."
52
66
  if e.proposed_breakdown:
53
67
  error_message += "\nProposed breakdown:"
54
68
  for _, part in e.proposed_breakdown["functional_requirements"].items():
@@ -23,10 +23,6 @@ from render_machine.render_types import (
23
23
  )
24
24
 
25
25
  MAX_UNITTEST_FIX_ATTEMPTS = 20
26
- MAX_CODE_GENERATION_RETRIES = 2
27
- MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS = 1
28
- MAX_REFACTORING_ITERATIONS = 5
29
- MAX_CONFORMANCE_TEST_FIX_ATTEMPTS = 20
30
26
  MAX_FUNCTIONAL_REQUIREMENT_RENDER_ATTEMPTS_FAILED_UNIT_DURING_CONFORMANCE_TESTS = 2
31
27
 
32
28
 
@@ -165,20 +161,6 @@ class RenderContext:
165
161
  self.run_state.current_frid = frid
166
162
  return
167
163
 
168
- def check_frid_iteration_limit(self):
169
- if self.frid_context.functional_requirement_render_attempts >= MAX_CODE_GENERATION_RETRIES:
170
- error_msg = f"Unittests could not be fixed after rendering the functionality {self.frid_context.frid} for the {MAX_CODE_GENERATION_RETRIES} times."
171
- self.dispatch_error(error_msg)
172
-
173
- self.frid_context.functional_requirement_render_attempts += 1
174
-
175
- if self.frid_context.functional_requirement_render_attempts > 1:
176
- # this if is intended just for logging
177
- console.info(
178
- f"Unittests could not be fixed after rendering the functionality. "
179
- f"Restarting rendering the functionality {self.frid_context.frid} from scratch."
180
- )
181
-
182
164
  def has_next_frid(self) -> bool:
183
165
  next_frid = plain_spec.get_next_frid(self.plain_source_tree, self.frid_context.frid)
184
166
  if self.render_range is None or len(self.render_range) == 0:
@@ -310,26 +292,6 @@ class RenderContext:
310
292
  git_utils.revert_changes(self.build_folder)
311
293
  self.machine.dispatch(triggers.START_NEW_REFACTORING_ITERATION)
312
294
 
313
- def start_refactoring_code(self):
314
-
315
- if self.frid_context.refactoring_iteration == 0:
316
- console.info("Refactoring the generated code...")
317
-
318
- self.frid_context.refactoring_iteration += 1
319
-
320
- if self.frid_context.refactoring_iteration >= MAX_REFACTORING_ITERATIONS:
321
- console.info(
322
- f"Refactoring iterations limit of {MAX_REFACTORING_ITERATIONS} reached for functionality {self.frid_context.frid}."
323
- )
324
- self.machine.dispatch(triggers.PROCEED_FRID_PROCESSING)
325
-
326
- def start_testing_environment_preparation(self):
327
- if (
328
- self.prepare_environment_script is None
329
- or not self.conformance_tests_running_context.should_prepare_testing_environment
330
- ):
331
- self.machine.dispatch(triggers.MARK_TESTING_ENVIRONMENT_PREPARED)
332
-
333
295
  def start_conformance_tests_processing(self):
334
296
  console.info("Implementing conformance tests...")
335
297
  current_frid_specifications, _ = plain_spec.get_specifications_for_frid(
@@ -589,23 +551,6 @@ class RenderContext:
589
551
  # Should never reach here
590
552
  raise RuntimeError(f"Unexpected execution phase: {ctx.execution_phase}")
591
553
 
592
- def start_fixing_conformance_tests(self):
593
- self.conformance_tests_running_context.fix_attempts += 1
594
-
595
- if self.conformance_tests_running_context.fix_attempts >= MAX_CONFORMANCE_TEST_FIX_ATTEMPTS:
596
- if (
597
- self.conformance_tests_running_context.conformance_tests_render_attempts
598
- >= MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS
599
- ):
600
- error_msg = f"The renderer was unable to produce an implementation that passes conformance tests for functionality '{self.frid_context.frid}' after many attempts. Please review and rewrite the specification. (Render ID: {self.run_state.render_id})"
601
- self.dispatch_error(error_msg)
602
- else:
603
- self.conformance_tests_running_context.regenerating_conformance_tests = True
604
- self.machine.dispatch(triggers.MARK_REGENERATION_OF_CONFORMANCE_TESTS)
605
-
606
- def finish_fixing_conformance_tests(self):
607
- console.info(f"Running conformance tests attempt {self.conformance_tests_running_context.fix_attempts + 1}.")
608
-
609
554
  def start_render_completed(self):
610
555
  self.run_state.set_render_succeeded(True)
611
556
 
@@ -73,12 +73,14 @@ class StateMachineConfig:
73
73
  PrepareRepositories.SUCCESSFUL_OUTCOME: triggers.START_RENDER,
74
74
  RenderFunctionalRequirement.SUCCESSFUL_OUTCOME: triggers.RENDER_FUNCTIONAL_REQUIREMENT,
75
75
  RenderFunctionalRequirement.FUNCTIONAL_REQUIREMENT_TOO_COMPLEX_OUTCOME: triggers.HANDLE_ERROR,
76
+ RenderFunctionalRequirement.ITERATION_LIMIT_EXCEEDED_OUTCOME: triggers.HANDLE_ERROR,
76
77
  RunUnitTests.SUCCESSFUL_OUTCOME: triggers.MARK_UNIT_TESTS_PASSED,
77
78
  RunUnitTests.FAILED_OUTCOME: triggers.MARK_UNIT_TESTS_FAILED,
78
79
  RunUnitTests.UNRECOVERABLE_ERROR_OUTCOME: triggers.HANDLE_ERROR,
79
80
  FixUnitTests.SUCCESSFUL_OUTCOME: triggers.MARK_UNIT_TESTS_READY,
80
81
  RefactorCode.SUCCESSFUL_OUTCOME: triggers.REFACTOR_CODE,
81
82
  RefactorCode.NO_FILES_REFACTORED_OUTCOME: triggers.PROCEED_FRID_PROCESSING,
83
+ RefactorCode.ITERATION_LIMIT_EXCEEDED_OUTCOME: triggers.PROCEED_FRID_PROCESSING,
82
84
  CommitImplementationCodeChanges.SUCCESSFUL_OUTCOME: triggers.PROCEED_FRID_PROCESSING,
83
85
  FinishFunctionalRequirement.SUCCESSFUL_OUTCOME: triggers.PROCEED_FRID_PROCESSING,
84
86
  CreateDist.SUCCESSFUL_OUTCOME: triggers.FINISH_RENDER,
@@ -90,6 +92,8 @@ class StateMachineConfig:
90
92
  RunConformanceTests.UNRECOVERABLE_ERROR_OUTCOME: triggers.HANDLE_ERROR,
91
93
  FixConformanceTest.IMPLEMENTATION_CODE_NOT_UPDATED: triggers.MARK_CONFORMANCE_TESTS_READY,
92
94
  FixConformanceTest.IMPLEMENTATION_CODE_UPDATED: triggers.MARK_UNIT_TESTS_READY,
95
+ FixConformanceTest.LIMIT_EXCEEDED_OUTCOME: triggers.HANDLE_ERROR,
96
+ FixConformanceTest.REGENERATE_CONFORMANCE_TESTS_OUTCOME: triggers.MARK_REGENERATION_OF_CONFORMANCE_TESTS,
93
97
  CommitConformanceTestsChanges.SUCCESSFUL_OUTCOME_IMPLEMENTATION_UPDATED: triggers.MARK_NEXT_CONFORMANCE_TESTS_POSTPROCESSING_STEP,
94
98
  CommitConformanceTestsChanges.SUCCESSFUL_OUTCOME_IMPLEMENTATION_NOT_UPDATED: triggers.PROCEED_FRID_PROCESSING,
95
99
  SummarizeConformanceTests.SUCCESSFUL_OUTCOME: triggers.MARK_NEXT_CONFORMANCE_TESTS_POSTPROCESSING_STEP,
@@ -135,16 +139,9 @@ class StateMachineConfig:
135
139
  "name": States.CONFORMANCE_TESTING_INITIALISED.value,
136
140
  "on_enter": render_context.start_conformance_tests_for_frid,
137
141
  },
138
- {
139
- "name": States.CONFORMANCE_TEST_GENERATED.value,
140
- "on_enter": render_context.start_testing_environment_preparation,
141
- },
142
+ States.CONFORMANCE_TEST_GENERATED.value,
142
143
  States.CONFORMANCE_TEST_ENV_PREPARED.value,
143
- {
144
- "name": States.CONFORMANCE_TEST_FAILED.value,
145
- "on_enter": render_context.start_fixing_conformance_tests,
146
- "on_exit": render_context.finish_fixing_conformance_tests,
147
- },
144
+ States.CONFORMANCE_TEST_FAILED.value,
148
145
  self.get_processing_unit_tests_states(
149
146
  render_context, render_context._on_unit_test_limit_exceeded_in_conformance_tests
150
147
  ),
@@ -164,7 +161,6 @@ class StateMachineConfig:
164
161
  refactoring_code_states = {
165
162
  "name": States.REFACTORING_CODE.value,
166
163
  "initial": States.READY_FOR_REFACTORING.value,
167
- "on_enter": render_context.start_refactoring_code,
168
164
  "children": [
169
165
  States.READY_FOR_REFACTORING.value,
170
166
  self.get_processing_unit_tests_states(
@@ -183,10 +179,7 @@ class StateMachineConfig:
183
179
  "on_exit": render_context.finish_implementing_frid,
184
180
  "children": [
185
181
  {"name": States.STEP_COMPLETED.value},
186
- {
187
- "name": States.READY_FOR_FRID_IMPLEMENTATION.value,
188
- "on_enter": render_context.check_frid_iteration_limit,
189
- },
182
+ States.READY_FOR_FRID_IMPLEMENTATION.value,
190
183
  self.get_processing_unit_tests_states(
191
184
  render_context, render_context._on_unit_test_limit_exceeded_in_implementation
192
185
  ),
tests/test_file_utils.py CHANGED
@@ -3,8 +3,9 @@ import tempfile
3
3
 
4
4
  import pytest
5
5
 
6
- from file_utils import load_linked_resources
7
- from plain2code_exceptions import UnsupportedResourceType
6
+ from file_utils import load_linked_resources, store_response_files
7
+ from plain2code_exceptions import UnsupportedBase64Content, UnsupportedResourceType
8
+ from plain2code_utils import MAX_BASE64_BLOB_LENGTH
8
9
 
9
10
 
10
11
  @pytest.fixture
@@ -39,3 +40,53 @@ def test_load_linked_resources_binary_file_raises_unsupported_resource_type(temp
39
40
  def test_load_linked_resources_missing_file_raises_file_not_found(template_dir):
40
41
  with pytest.raises(FileNotFoundError):
41
42
  load_linked_resources([template_dir], [{"text": "Missing", "target": "missing.md"}], "my_thing")
43
+
44
+
45
+ def test_load_linked_resources_base64_blob_raises(template_dir):
46
+ file_path = os.path.join(template_dir, "request.txt")
47
+ with open(file_path, "w") as f:
48
+ f.write("curl -d 'selfie_image=" + "A" * (MAX_BASE64_BLOB_LENGTH + 100) + "'")
49
+
50
+ with pytest.raises(UnsupportedBase64Content) as exc_info:
51
+ load_linked_resources([template_dir], [{"text": "Request", "target": "request.txt"}], "my_thing")
52
+
53
+ assert "request.txt" in str(exc_info.value)
54
+ assert "my_thing" in str(exc_info.value)
55
+
56
+
57
+ def test_load_linked_resources_small_base64_allowed(template_dir):
58
+ file_path = os.path.join(template_dir, "token.txt")
59
+ with open(file_path, "w") as f:
60
+ f.write("token=" + "A" * (MAX_BASE64_BLOB_LENGTH - 100))
61
+
62
+ result = load_linked_resources([template_dir], [{"text": "Token", "target": "token.txt"}], "my_thing")
63
+
64
+ assert "token.txt" in result
65
+
66
+
67
+ def test_load_linked_resources_real_base64_image_raises(template_dir):
68
+ # Real base64-encoded JPEG that made Gemini 3 fail, inlined into a curl example resource.
69
+ sample_path = os.path.join(os.path.dirname(__file__), "data", "sample_base64_image.txt")
70
+ with open(sample_path) as f:
71
+ blob = f.read()
72
+
73
+ file_path = os.path.join(template_dir, "face_match_request.txt")
74
+ with open(file_path, "w") as f:
75
+ f.write(f"curl -d 'selfie_image={blob}' https://example.com/face-match")
76
+
77
+ with pytest.raises(UnsupportedBase64Content) as exc_info:
78
+ load_linked_resources([template_dir], [{"text": "Request", "target": "face_match_request.txt"}], "face_match")
79
+
80
+ assert "face_match_request.txt" in str(exc_info.value)
81
+ assert str(len(blob)) in str(exc_info.value)
82
+
83
+
84
+ def test_store_response_files_writes_unicode_as_utf8(template_dir):
85
+ # Content with a non-cp1252 character (📍 U+1F4CD) must be written as UTF-8
86
+ # regardless of the platform's default text encoding (e.g. cp1252 on Windows).
87
+ content = "Location 📍 marker"
88
+ store_response_files(template_dir, {"notes.md": content}, [])
89
+
90
+ file_path = os.path.join(template_dir, "notes.md")
91
+ with open(file_path, "rb") as f:
92
+ assert f.read().decode("utf-8") == content
@@ -0,0 +1,94 @@
1
+ import tempfile
2
+ from argparse import Namespace
3
+ from types import SimpleNamespace
4
+ from unittest.mock import patch
5
+
6
+ import plain2code
7
+ import plain_spec
8
+ from plain_modules import PlainModule
9
+
10
+
11
+ def _make_module(module_name, has_acceptance_tests, required_modules=None):
12
+ """Build a minimal stand-in for a PlainModule.
13
+
14
+ The functional requirements only need an `acceptance_tests` key when the module
15
+ is expected to contain acceptance tests, because that is all
16
+ `plain_spec.has_acceptance_tests` inspects.
17
+ """
18
+ functional_requirement = {"markdown": f"- {module_name} functionality."}
19
+ if has_acceptance_tests:
20
+ functional_requirement[plain_spec.ACCEPTANCE_TESTS] = [{"markdown": "- Test it."}]
21
+
22
+ return SimpleNamespace(
23
+ module_name=module_name,
24
+ plain_source={plain_spec.FUNCTIONAL_REQUIREMENTS: [functional_requirement]},
25
+ all_required_modules=required_modules or [],
26
+ )
27
+
28
+
29
+ def test_warns_when_acceptance_tests_present_and_no_conformance_script():
30
+ plain_module = _make_module("top", has_acceptance_tests=True)
31
+ args = Namespace(conformance_tests_script=None)
32
+
33
+ with patch("plain2code.console") as mock_console:
34
+ plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args)
35
+
36
+ mock_console.warning.assert_called_once()
37
+ warning_message = mock_console.warning.call_args.args[0]
38
+ assert "top" in warning_message
39
+ assert "conformance tests script" in warning_message
40
+
41
+
42
+ def test_no_warning_when_conformance_script_configured():
43
+ plain_module = _make_module("top", has_acceptance_tests=True)
44
+ args = Namespace(conformance_tests_script="run_conformance_tests.sh")
45
+
46
+ with patch("plain2code.console") as mock_console:
47
+ plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args)
48
+
49
+ mock_console.warning.assert_not_called()
50
+
51
+
52
+ def test_no_warning_when_no_acceptance_tests():
53
+ plain_module = _make_module("top", has_acceptance_tests=False)
54
+ args = Namespace(conformance_tests_script=None)
55
+
56
+ with patch("plain2code.console") as mock_console:
57
+ plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args)
58
+
59
+ mock_console.warning.assert_not_called()
60
+
61
+
62
+ def test_warns_when_acceptance_tests_only_in_required_module():
63
+ required_module = _make_module("dependency", has_acceptance_tests=True)
64
+ plain_module = _make_module("top", has_acceptance_tests=False, required_modules=[required_module])
65
+ args = Namespace(conformance_tests_script=None)
66
+
67
+ with patch("plain2code.console") as mock_console:
68
+ plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args)
69
+
70
+ mock_console.warning.assert_called_once()
71
+ warning_message = mock_console.warning.call_args.args[0]
72
+ assert "dependency" in warning_message
73
+
74
+
75
+ def test_warning_covers_required_modules_for_real_plain_module(get_test_data_path):
76
+ """Integration test: a main module without acceptance tests that requires a
77
+ module with acceptance tests should still trigger the warning, naming the
78
+ required module. This mirrors the dry-run path which builds a real PlainModule."""
79
+ fixtures_dir = get_test_data_path("data/acceptance_tests_warning")
80
+ with tempfile.TemporaryDirectory() as build, tempfile.TemporaryDirectory() as conformance:
81
+ plain_module = PlainModule(
82
+ "main_requiring_acceptance_tests.plain",
83
+ build,
84
+ conformance,
85
+ [fixtures_dir],
86
+ )
87
+
88
+ args = Namespace(conformance_tests_script=None)
89
+ with patch("plain2code.console") as mock_console:
90
+ plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args)
91
+
92
+ mock_console.warning.assert_called_once()
93
+ warning_message = mock_console.warning.call_args.args[0]
94
+ assert "required_with_acceptance_tests" in warning_message
@@ -376,6 +376,114 @@ def test_acceptance_tests_block_include_with_trailing_newline_keeps_structure_an
376
376
  plain_file.plain_file_parser("block_level_include.plain", [get_test_data_path("data/templates")])
377
377
 
378
378
 
379
+ def test_acceptance_tests_top_level_rejected():
380
+ plain_source = """
381
+ ***acceptance tests***
382
+
383
+ - Test something.
384
+ """
385
+ with pytest.raises(
386
+ PlainSyntaxError,
387
+ match=re.escape(
388
+ "Syntax error at line 1: acceptance tests heading should be nested under specific functional spec."
389
+ ),
390
+ ):
391
+ plain_file.parse_plain_source(plain_source, {}, [], [], [])
392
+
393
+
394
+ def test_acceptance_tests_top_level_after_other_headings_rejected():
395
+ plain_source = """***definitions***
396
+
397
+ - :concept: is a concept.
398
+
399
+ ***acceptance tests***
400
+
401
+ - Test something.
402
+ """
403
+ with pytest.raises(
404
+ PlainSyntaxError,
405
+ match=re.escape(
406
+ "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec."
407
+ ),
408
+ ):
409
+ plain_file.parse_plain_source(plain_source, {}, [], [], [])
410
+
411
+
412
+ def test_acceptance_tests_nested_under_definitions_rejected():
413
+ plain_source = """***definitions***
414
+
415
+ - :concept: is a concept.
416
+
417
+ ***acceptance tests***
418
+
419
+ - Test the :concept:.
420
+
421
+ ***functional specs***
422
+
423
+ - Display "hello, world"
424
+ """
425
+ with pytest.raises(
426
+ PlainSyntaxError,
427
+ match=re.escape(
428
+ "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec."
429
+ ),
430
+ ):
431
+ plain_file.parse_plain_source(plain_source, {}, [], [], [])
432
+
433
+
434
+ def test_acceptance_tests_nested_under_implementation_reqs_rejected():
435
+ plain_source = """***implementation reqs***
436
+
437
+ - First implementation requirement.
438
+
439
+ ***acceptance tests***
440
+
441
+ - Test something.
442
+ """
443
+ with pytest.raises(
444
+ PlainSyntaxError,
445
+ match=re.escape(
446
+ "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec."
447
+ ),
448
+ ):
449
+ plain_file.parse_plain_source(plain_source, {}, [], [], [])
450
+
451
+
452
+ def test_acceptance_tests_nested_under_test_reqs_rejected():
453
+ plain_source = """***test reqs***
454
+
455
+ - First test requirement.
456
+
457
+ ***acceptance tests***
458
+
459
+ - Test something.
460
+ """
461
+ with pytest.raises(
462
+ PlainSyntaxError,
463
+ match=re.escape(
464
+ "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec."
465
+ ),
466
+ ):
467
+ plain_file.parse_plain_source(plain_source, {}, [], [], [])
468
+
469
+
470
+ def test_acceptance_tests_nested_under_functional_spec_allowed():
471
+ plain_source = """***definitions***
472
+
473
+ - :concept: is a concept.
474
+
475
+ ***functional specs***
476
+
477
+ - Display "hello, world"
478
+
479
+ ***acceptance tests***
480
+
481
+ - Test the :concept:.
482
+ """
483
+ result = plain_file.parse_plain_source(plain_source, {}, [], [], [])
484
+ assert plain_spec.FUNCTIONAL_REQUIREMENTS in result.plain_source
485
+
486
+
379
487
  def test_concept_validation_definitions(get_test_data_path):
380
488
  plain_file.plain_file_parser(
381
489
  "concept_validation_definition.plain",
tests/test_plainspec.py CHANGED
@@ -9,6 +9,42 @@ def test_get_linked_resources_invalid_input():
9
9
  plain_spec.collect_linked_resources([], [], None, True)
10
10
 
11
11
 
12
+ def test_has_acceptance_tests_true():
13
+ plain_source = {
14
+ plain_spec.FUNCTIONAL_REQUIREMENTS: [
15
+ {"markdown": "- First functionality."},
16
+ {
17
+ "markdown": "- Second functionality.",
18
+ plain_spec.ACCEPTANCE_TESTS: [{"markdown": "- Test the second functionality."}],
19
+ },
20
+ ]
21
+ }
22
+ assert plain_spec.has_acceptance_tests(plain_source) is True
23
+
24
+
25
+ def test_has_acceptance_tests_false_when_no_acceptance_tests():
26
+ plain_source = {
27
+ plain_spec.FUNCTIONAL_REQUIREMENTS: [
28
+ {"markdown": "- First functionality."},
29
+ {"markdown": "- Second functionality."},
30
+ ]
31
+ }
32
+ assert plain_spec.has_acceptance_tests(plain_source) is False
33
+
34
+
35
+ def test_has_acceptance_tests_false_when_empty_acceptance_tests():
36
+ plain_source = {
37
+ plain_spec.FUNCTIONAL_REQUIREMENTS: [
38
+ {"markdown": "- First functionality.", plain_spec.ACCEPTANCE_TESTS: []},
39
+ ]
40
+ }
41
+ assert plain_spec.has_acceptance_tests(plain_source) is False
42
+
43
+
44
+ def test_has_acceptance_tests_false_when_no_functional_requirements():
45
+ assert plain_spec.has_acceptance_tests({}) is False
46
+
47
+
12
48
  def test_get_frids_simple(get_test_data_path):
13
49
  _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")])
14
50
 
tests/test_telemetry.py CHANGED
@@ -80,12 +80,14 @@ def test_capture_crash_sends_event_with_tags(transport):
80
80
  run_state.current_module = "my_module"
81
81
  run_state.current_frid = "2.1"
82
82
  run_state.current_render_state = "IMPLEMENTING_FRID"
83
+ run_state.user_email = "user@codeplain.ai"
83
84
 
84
85
  assert capture_crash(make_exc_info(KeyError("boom")), run_state, make_args(headless=True))
85
86
  sentry_sdk.flush(timeout=2)
86
87
 
87
88
  assert len(transport.events) == 1
88
- tags = transport.events[0]["tags"]
89
+ event = transport.events[0]
90
+ tags = event["tags"]
89
91
  assert tags["render_id"] == run_state.render_id
90
92
  assert tags["current_module"] == "my_module"
91
93
  assert tags["current_frid"] == "2.1"
@@ -94,6 +96,19 @@ def test_capture_crash_sends_event_with_tags(transport):
94
96
  assert tags["unittests_script_provided"] is True
95
97
  assert tags["conformance_tests_script_provided"] is False
96
98
  assert tags["prepare_environment_script_provided"] is False
99
+ assert event["user"]["email"] == "user@codeplain.ai"
100
+
101
+
102
+ def test_capture_crash_without_user_email(transport):
103
+ init_with_transport(transport)
104
+
105
+ run_state = RunState(spec_filename="test.plain")
106
+
107
+ assert capture_crash(make_exc_info(KeyError("boom")), run_state, make_args())
108
+ sentry_sdk.flush(timeout=2)
109
+
110
+ assert len(transport.events) == 1
111
+ assert "email" not in transport.events[0].get("user", {})
97
112
 
98
113
 
99
114
  def test_capture_crash_without_run_state(transport):
@@ -1 +0,0 @@
1
- # Standard template library for plain2code