codeplain 0.3.2__py3-none-any.whl → 0.3.4__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.2"
1
+ __version__ = "0.3.4"
change_detection.py ADDED
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Literal
5
+
6
+ if TYPE_CHECKING:
7
+ from plain_modules import PlainModule
8
+
9
+ MODULE_FUNCTIONALITIES_KEY = "functionalities"
10
+ NON_FUNCTIONAL_SOURCE_HASH_KEY = "non_functional_source_hash"
11
+
12
+
13
+ @dataclass
14
+ class FunctionalityChange:
15
+ module: str
16
+ frid: str
17
+ change_type: Literal["added", "removed", "edited", "moved"]
18
+ detail: str | None = None
19
+
20
+
21
+ @dataclass
22
+ class PartialRenderStart:
23
+ module: "PlainModule"
24
+ frid: str
25
+
26
+
27
+ def determine_partial_render_start(plain_module: "PlainModule") -> PartialRenderStart | None:
28
+ """Determine where to start partial rendering based on spec changes.
29
+
30
+ Returns None (only full render is safe) if non-FR sections changed
31
+ (e.g. definitions, implementation reqs) since previously-rendered FRs
32
+ were generated without that context, if no changes are found, or if
33
+ all changes are trailing removals that don't require rendering.
34
+ """
35
+ all_modules = plain_module.all_required_modules + [plain_module]
36
+
37
+ for module in all_modules:
38
+ if _non_functional_content_changed(module):
39
+ return None
40
+
41
+ changes = _detect_module_changes(module)
42
+ if not changes:
43
+ continue
44
+
45
+ current_fr_count = len(module._get_module_functional_requirements())
46
+ earliest_frid = _get_earliest_affected_frid(changes, current_fr_count)
47
+ if earliest_frid is None:
48
+ continue
49
+ return PartialRenderStart(module=module, frid=earliest_frid)
50
+
51
+ return None
52
+
53
+
54
+ def _non_functional_content_changed(module: "PlainModule") -> bool:
55
+ """Check whether anything outside functional specs changed since last render.
56
+
57
+ A missing stored hash (older builds) is treated as changed — partial rendering
58
+ is unsafe without a known baseline.
59
+ """
60
+ metadata = module.load_module_metadata()
61
+ if not metadata:
62
+ return False
63
+ stored_hash = metadata.get(NON_FUNCTIONAL_SOURCE_HASH_KEY)
64
+ if stored_hash is None:
65
+ return True
66
+ return stored_hash != module.get_module_non_functional_source_hash()
67
+
68
+
69
+ def _get_earliest_affected_frid(changes: list[FunctionalityChange], current_fr_count: int) -> str | None:
70
+ """Earliest FRID (in current spec numbering) that must be re-rendered.
71
+
72
+ Returns the minimum position across all changes:
73
+ - added / edited: the FRID itself.
74
+ - removed: the position the removal opened up (now occupied by the next FR).
75
+ - moved: the FR's old position.
76
+
77
+ Correctness does not rely on any single change type's position being individually
78
+ "the" earliest (in particular, a move's old position is *not* always its earliest
79
+ touched position once moves mix with adds/removes). The guarantee is structural:
80
+ the first index at which the new spec diverges from the old is always emitted as
81
+ some change anchored at that index — a move with that old position, or an
82
+ edit/removal/addition there — so the minimum over all change FRIDs lands at or
83
+ before the true first divergence. Rendering runs from this FRID to the end of the
84
+ module, so an at-or-before start point is always safe (it can re-render unchanged
85
+ trailing FRs, but never skips a changed one).
86
+
87
+ Returns None when every change is a removal beyond the current spec length
88
+ (only trailing FRs were removed, so nothing needs rendering).
89
+ """
90
+ earliest = None
91
+ for change in changes:
92
+ frid_int = int(change.frid)
93
+ if change.change_type == "removed" and frid_int > current_fr_count:
94
+ continue
95
+ if earliest is None or frid_int < earliest:
96
+ earliest = frid_int
97
+ if earliest is None:
98
+ return None
99
+ return str(earliest)
100
+
101
+
102
+ def _detect_module_changes(module: "PlainModule") -> list[FunctionalityChange]:
103
+ metadata = module.load_module_metadata()
104
+ old_frs: list[str] = metadata.get(MODULE_FUNCTIONALITIES_KEY, []) if metadata else []
105
+ new_frs: list[str] = module._get_module_functional_requirements()
106
+
107
+ if old_frs == new_frs:
108
+ return []
109
+
110
+ moves, edits, removed, added = _classify_changes(old_frs, new_frs)
111
+
112
+ changes: list[FunctionalityChange] = []
113
+ name = module.module_name
114
+
115
+ for old_idx, new_idx in moves:
116
+ old_frid = _frid_from_index(old_idx)
117
+ new_frid = _frid_from_index(new_idx)
118
+ changes.append(FunctionalityChange(module=name, frid=old_frid, change_type="moved", detail=new_frid))
119
+
120
+ for idx in edits:
121
+ changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="edited"))
122
+
123
+ for idx in removed:
124
+ changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="removed"))
125
+
126
+ for idx in added:
127
+ changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="added"))
128
+
129
+ return changes
130
+
131
+
132
+ def _classify_changes(
133
+ old_frs: list[str], new_frs: list[str]
134
+ ) -> tuple[list[tuple[int, int]], list[int], list[int], list[int]]:
135
+ matched_old: set[int] = set()
136
+ matched_new: set[int] = set()
137
+
138
+ for i in range(min(len(old_frs), len(new_frs))):
139
+ if old_frs[i] == new_frs[i]:
140
+ matched_old.add(i)
141
+ matched_new.add(i)
142
+
143
+ content_matches: list[tuple[int, int]] = []
144
+ for old_idx in range(len(old_frs)):
145
+ if old_idx in matched_old:
146
+ continue
147
+ for new_idx in range(len(new_frs)):
148
+ if new_idx in matched_new:
149
+ continue
150
+ if old_frs[old_idx] == new_frs[new_idx]:
151
+ content_matches.append((old_idx, new_idx))
152
+ matched_old.add(old_idx)
153
+ matched_new.add(new_idx)
154
+ break
155
+
156
+ moves: list[tuple[int, int]] = []
157
+ if content_matches and _has_relative_order_change(content_matches):
158
+ moves = content_matches
159
+
160
+ edits: list[int] = []
161
+ for i in range(min(len(old_frs), len(new_frs))):
162
+ if i not in matched_old and i not in matched_new:
163
+ edits.append(i)
164
+ matched_old.add(i)
165
+ matched_new.add(i)
166
+
167
+ removed = [i for i in range(len(old_frs)) if i not in matched_old]
168
+ added = [i for i in range(len(new_frs)) if i not in matched_new]
169
+
170
+ return moves, edits, removed, added
171
+
172
+
173
+ def _has_relative_order_change(matches: list[tuple[int, int]]) -> bool:
174
+ """Check if content matches represent a true reorder (relative order changed).
175
+
176
+ If all matches preserve relative order (sorted by old_idx gives same ordering
177
+ as sorted by new_idx), it's just a positional shift from insertions/removals.
178
+ """
179
+ if len(matches) <= 1:
180
+ return False
181
+ sorted_by_old = sorted(matches, key=lambda m: m[0])
182
+ new_indices = [m[1] for m in sorted_by_old]
183
+ for i in range(len(new_indices) - 1):
184
+ if new_indices[i] > new_indices[i + 1]:
185
+ return True
186
+ return False
187
+
188
+
189
+ def _frid_from_index(index: int) -> str:
190
+ return str(index + 1)
cli_output/status.py CHANGED
@@ -50,8 +50,8 @@ def _display_credit_line(plan_credits: dict) -> None:
50
50
  console.print(f" {plan_label:10} {bar} {remaining:2} of {total} remaining expires {formatted_date}")
51
51
 
52
52
 
53
- def _display_purchased_credit_line(bucket: dict) -> None:
54
- """Display a purchased credit line with progress bar."""
53
+ def _display_bucket_credit_line(bucket: dict, label: str) -> None:
54
+ """Display a credit bucket line (purchased or promo) with progress bar."""
55
55
  remaining = bucket["remaining"]
56
56
  total = bucket["total"]
57
57
  expiry_date = bucket["expiry_date"]
@@ -71,12 +71,12 @@ def _display_purchased_credit_line(bucket: dict) -> None:
71
71
  bar = _create_progress_bar(remaining, total, width=30)
72
72
 
73
73
  if is_expired:
74
- console.print(f" Purchased {bar} expired {formatted_date}")
74
+ console.print(f" {label:10} {bar} expired {formatted_date}")
75
75
  else:
76
- console.print(f" Purchased {bar} {remaining:2} of {total} remaining expires {formatted_date}")
76
+ console.print(f" {label:10} {bar} {remaining:2} of {total} remaining expires {formatted_date}")
77
77
 
78
78
 
79
- def _display_status_message(plan_credits: Optional[dict], purchased_credits: list) -> None:
79
+ def _display_status_message(plan_credits: Optional[dict], purchased_credits: list, promo_credits: list) -> None:
80
80
  """Display appropriate status message based on credit state."""
81
81
  has_remaining = False
82
82
 
@@ -93,8 +93,8 @@ def _display_status_message(plan_credits: Optional[dict], purchased_credits: lis
93
93
  if remaining > 0 and period_end > now:
94
94
  has_remaining = True
95
95
 
96
- # Check if any purchased credits have remaining balance and not expired
97
- for bucket in purchased_credits:
96
+ # Check if any purchased or promo credits have remaining balance and not expired
97
+ for bucket in [*purchased_credits, *promo_credits]:
98
98
  dt_str = bucket["expiry_date"].replace("Z", "+00:00")
99
99
  expiry_date = datetime.fromisoformat(dt_str)
100
100
  # If naive datetime, assume UTC
@@ -127,6 +127,7 @@ def print_status(api_key: str, api_url: str, client_version: str) -> None:
127
127
  org_owner = response.get("organization_owner_email")
128
128
  plan_credits = response.get("plan_credits")
129
129
  purchased_credits = response.get("purchased_credits", [])
130
+ promo_credits = response.get("promo_credits", [])
130
131
 
131
132
  # Display header information
132
133
  if client_version_valid:
@@ -153,8 +154,12 @@ def print_status(api_key: str, api_url: str, client_version: str) -> None:
153
154
 
154
155
  # Display purchased credits
155
156
  for bucket in purchased_credits:
156
- _display_purchased_credit_line(bucket)
157
+ _display_bucket_credit_line(bucket, "Purchased")
158
+
159
+ # Display promo credits
160
+ for bucket in promo_credits:
161
+ _display_bucket_credit_line(bucket, "Promo")
157
162
 
158
163
  # Display status messages and management link
159
- _display_status_message(plan_credits, purchased_credits)
164
+ _display_status_message(plan_credits, purchased_credits, promo_credits)
160
165
  console.print("\nTo manage your plan navigate to https://platform.codeplain.ai/plans")
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeplain
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Transform plain language specifications into working code
5
5
  License-File: LICENSE
6
6
  Classifier: Environment :: Console
7
7
  Classifier: Intended Audience :: Developers
8
8
  Classifier: Operating System :: OS Independent
9
9
  Classifier: Topic :: Software Development :: Code Generators
10
- Requires-Python: >=3.11
10
+ Requires-Python: ==3.11
11
11
  Requires-Dist: gitpython==3.1.50
12
12
  Requires-Dist: mistletoe==1.3.0
13
13
  Requires-Dist: networkx==3.6.1
@@ -16,6 +16,7 @@ Requires-Dist: python-liquid2==0.3.0
16
16
  Requires-Dist: pyyaml==6.0.3
17
17
  Requires-Dist: requests==2.34.0
18
18
  Requires-Dist: rich==15.0.0
19
+ Requires-Dist: sentry-sdk==2.62.0
19
20
  Requires-Dist: textual>=7.5.0
20
21
  Requires-Dist: tiktoken==0.12.0
21
22
  Requires-Dist: transitions==0.9.3
@@ -103,6 +104,11 @@ After completing the installation steps above, you can immediately test the syst
103
104
  python hello_world.py
104
105
  ```
105
106
 
107
+ ### Crash reporting
108
+
109
+ If the plain2code client crashes unexpectedly, it sends an anonymous crash report to Codeplain to help improve the tool. To disable crash reporting, set the `CODEPLAIN_NO_TELEMETRY=1` flag.
110
+
111
+
106
112
  ## Releasing
107
113
 
108
114
  Releases are built and published with [uv](https://docs.astral.sh/uv/). The version is read from `_version.py`.
@@ -1,4 +1,5 @@
1
- _version.py,sha256=vNiWJ14r_cw5t_7UDqDQIVZvladKFGyHH2avsLpN7Vg,22
1
+ _version.py,sha256=oYLGMpySamd16KLiaBTfRyrAS7_oyp-TOEHmzmeumwg,22
2
+ change_detection.py,sha256=CazORZrezeZSw2I6VBvieN-c2SEjieZPo6pNrKwmMVA,6915
2
3
  codeplain_REST_api.py,sha256=h_XoNws8fvf7OTtoVXhHh6OfLGg77UNFuggMBwaqBwE,21533
3
4
  concept_utils.py,sha256=D4qVHFBRvfJZwiIq1AD_SV0Ev-RnOMg2yGWo6Cal7PM,7913
4
5
  diff_utils.py,sha256=AjiQlqo5pRos_8hVXZo5yBurl5BzSrTMGrQv4dCtRCg,1198
@@ -7,8 +8,8 @@ file_utils.py,sha256=grPx2Rl6-q2ijSRoaAOZcsrpOjTqb4i_mtF-jzwhKcQ,10953
7
8
  git_utils.py,sha256=LQQnL5DAFpH5fDBQgUZX2WfjWY4YC5OEGBPIXryTmnY,16896
8
9
  memory_management.py,sha256=nHNudgaOqp5UGkIMi62TRCc2nUeFOM0w-I1FfxBFaQE,6120
9
10
  module_renderer.py,sha256=pXMXKKGMhUwLLlSxppE_hpZlwpJ3WSHSfR9FQxt6rSI,6860
10
- partial_rendering.py,sha256=prWEJo9m1TbF7SKzpNInbcefyTF0o3oXE7Qf1l4SmBs,8409
11
- plain2code.py,sha256=gyPeZtLe7VkGChWwGVNNZRXvkqpH8uC27szHbrZgmeY,14010
11
+ partial_rendering.py,sha256=0ChOaGytogBUEGISpbWkbjT9Hqy8pnOkJdJXcitJDgk,12219
12
+ plain2code.py,sha256=Yi6W9R3HNedbaeIgfFffQnfauwTXmJTi8ZRD0LdmfvA,14042
12
13
  plain2code_arguments.py,sha256=J05NwaY-O04rIWVF2yIXysNB_ofZS0wJFn0u1WaCCSA,15399
13
14
  plain2code_console.py,sha256=YdE-Kz9qRAzIIAg7aYA1bnXRcwy_aTpbFtkR2zHbd-I,4918
14
15
  plain2code_events.py,sha256=AM8KJS68uiVXRMLt861KaGhs2H4yzujlMB_xiYPfHQc,1478
@@ -16,37 +17,38 @@ plain2code_exceptions.py,sha256=Fj1pwCffn06aivEplsTJJGdg6lAOR8gmWd4ctLl9cj4,1745
16
17
  plain2code_logger.py,sha256=kNKIfkS6Ydtut7Ptw5jmv5bFPgXlxB-_Ds5nd-j4d9o,4609
17
18
  plain2code_nodes.py,sha256=IwjQqJxRSJKgP6eEnkYGy3VQHIyoazgkFxAEkxkjfJ8,4426
18
19
  plain2code_read_config.py,sha256=D5CxnmTRjddupecWvstcUm0kIJWWzq5-Zmk6sU0wbIg,2235
19
- plain2code_state.py,sha256=gmqbGfM2dx6ieiqRQRlLPD49JsMAyb3lOWspljcTj9g,1944
20
+ plain2code_state.py,sha256=gIHOmJe9Ml_Xifi6uV5VBMK3zXRpaRoqQNoMhjD1dvQ,2187
21
+ plain2code_telemetry.py,sha256=ZtBgdpWmPZQ_SnC0O9A2XSG30kRJ9kWitQPN4mmT2S8,4245
20
22
  plain2code_utils.py,sha256=HYDJ4lTC3RwBgcJadPvcoU3-Y6krNiuDP4_t2xYKA2Q,874
21
- plain_file.py,sha256=vJKCkIA12o7LHAGugIsTbzcpoQrTQX2uqlXEC_EqfPo,29470
22
- plain_modules.py,sha256=NXUtxW8fEpJAuEIeVE_iwRBCbHFh2ji-p9xdkgojLqU,14423
23
+ plain_file.py,sha256=VUfMCskTJZnHrFYgBlK8DXbJGnk7peuC6YY22zRNAek,29968
24
+ plain_modules.py,sha256=uvRPfxBJwstaWi5lgYKgtQdhhf0Naiy2mS41tLw_TjY,16186
23
25
  plain_spec.py,sha256=E2Yp2eXMExE7kx7GPQ7v9boHN8XhFUcqprn1DGMaFMs,15669
24
26
  system_config.py,sha256=trgytfXKXoCn0Pgy9u00x2D0ZRHlh_tADlbkwZnF8QE,1038
25
27
  cli_output/__init__.py,sha256=MBKl0YbVpWE-tBa3V5_aVbB7vbaqlIK2NsHTjRUZqis,284
26
28
  cli_output/dry_run.py,sha256=6NKhNWPtD0CBK_QHSsSqIKLTzibTwxS9d8ltP9d1bgU,1473
27
29
  cli_output/render_summary.py,sha256=QrW3Iq15xUoWXuVXpqClAKncAvHdQR0lWG1_OMUBsWk,1307
28
- cli_output/status.py,sha256=5jr9XFU1MWD3pN404qw7SNYCGdvqfA9j4xv9z1tZG4s,5890
30
+ cli_output/status.py,sha256=PFhHqFaC0nt2zsPR0VbD-JBPB3sEVPfhopJmOylFxbU,6160
29
31
  config/__init__.py,sha256=beYSsJWmBNHDP5rYmVDouqgEeP3t1lkkepbXJ-oq0F8,37
30
32
  config/system_config.yaml,sha256=of5R9vukSohU9AlkGJOcAVQA1UJgxYmDy6islU6iFuE,508
31
33
  docs/generate_cli.py,sha256=0FHVhICbM8g7ahdInTNih3hNKpty1wbV5rdGECMJj48,656
32
34
  examples/example_hello_world_python/harness_tests/hello_world_display/test_hello_world.py,sha256=dwTowrHiVKKbrDv21v8xJC30Q57AXZkQasdGOO5JsBE,470
33
35
  render_machine/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
- render_machine/code_renderer.py,sha256=bLdoZcWaLGvuR0GygnWY9y9kycGySD-IEWY5DFGcGsg,3781
36
+ render_machine/code_renderer.py,sha256=-vp_ltU7e3ssX9hbS8IY4ETSp-39JAuoJ3ZltKpdt1M,3959
35
37
  render_machine/conformance_tests.py,sha256=MeEveEGJTClLGxWw04P4TR6U9kX9Qgz9qtxNrB9a8QI,7085
36
38
  render_machine/implementation_code_helpers.py,sha256=LdHNLoKgVtrDaGH2APX8C9vjNjLBUhu2V7OAaJvm4rM,2009
37
- render_machine/render_context.py,sha256=NHDtJkhQP8tW0iyRH6PR7oSk-PO_pEGMKkX38x52YMc,29128
39
+ render_machine/render_context.py,sha256=5W_PVrlPZMPlWmXlHQtYUiCIFvjNFsjNiJ42z5Hidv8,28447
38
40
  render_machine/render_types.py,sha256=cT3D0lmxQ3enIo0jGp3LEGCY2t3X1Q1_tqwcadzSAv0,8761
39
41
  render_machine/render_utils.py,sha256=ao0e64VSOGYfb8hwJXIZlbqer5HjNMYJWHlLDoHnJyc,9123
40
- render_machine/state_machine_config.py,sha256=uqrU9Pu4cAKxu_-e1eiuUQK-tZWxZko0U0Bfvl67v_c,28813
42
+ render_machine/state_machine_config.py,sha256=F-pSrNPF31eYMmJMo0STj85q7rp-4qFyy7kEmJ8iVc4,26944
41
43
  render_machine/states.py,sha256=niFXqayyA_rezCrlzo0hYeHAruErcPzQVF-BeIBGnL8,1968
42
- render_machine/triggers.py,sha256=o1N5c9xC7fsKY6ZaWcWBtALT8i1acSSharAXKAqBu8E,1285
44
+ render_machine/triggers.py,sha256=VekNhHGR-u5JXIboqc_Eyqf73LNH7wezLK7r3WWtodk,1269
43
45
  render_machine/actions/analyze_specification_ambiguity.py,sha256=SykZAozGvr1jySxbpKCgt11nMpZhOK0sN-J66M42T5Y,2847
44
46
  render_machine/actions/base_action.py,sha256=vVzMp-LanrZyq-jamHyWaQxpRl-pLqtTdtiEzUBhfyE,534
45
47
  render_machine/actions/commit_conformance_tests_changes.py,sha256=08yxCPCpf5TlrzLPsEh5SJXVVvnLfwdNKSr7BlzlYxo,2589
46
48
  render_machine/actions/commit_implementation_code_changes.py,sha256=yi51oAArLCgWcmFbynes-BK2IGnZwA7wygrTJGoL8dY,826
47
49
  render_machine/actions/create_dist.py,sha256=xMPPyws7swVkaUSQg3dwMGScCi1BuII9y2C_XSP0N80,1067
48
50
  render_machine/actions/exit_with_error.py,sha256=-LkQsFhmV-Nr741APTx38dqu473b6OSiIvKayFFsXxI,1270
49
- render_machine/actions/finish_functional_requirement.py,sha256=tj9CzuxuHcbR-2-jMLcAM0gOAcbWpMLg9HIiyngI6gU,739
51
+ render_machine/actions/finish_functional_requirement.py,sha256=usQgPSg6PHpBOR1RhK7lPz9zjY-2VJJDqi3T0nlqdLw,841
50
52
  render_machine/actions/fix_conformance_test.py,sha256=ePfxz9Xm0IXht0ch7H4tLPdRzoKnVdlbcDC9EgeDAAQ,8786
51
53
  render_machine/actions/fix_unit_tests.py,sha256=hvwvCJDk_-8fHMpkNFyeBSG1IshIcufDpymF00ulPXY,2301
52
54
  render_machine/actions/prepare_repositories.py,sha256=eE-uU6H_K5IQMraVWSRi4TKk_XPrnA878T_JwtI9cWo,3410
@@ -64,17 +66,19 @@ standard_template_library/typescript-react-app-boilerplate.plain,sha256=6LFxhEOz
64
66
  standard_template_library/typescript-react-app-template.plain,sha256=DN5wEJcT8ZqQTf0vFKi9Z1isi9cin2UVNOZb3r28d3M,715
65
67
  tests/__init__.py,sha256=Wk73Io62J15BtlLVIzxmASDWaaJkQLevS4BLK5LDAQg,16
66
68
  tests/conftest.py,sha256=QZcp08htUlJGgmHDlRWFgsWXZ8o8IBWTD5QqJaUMlU8,790
67
- tests/test_cli_output.py,sha256=YdIQtplncTeMf82E0nWNrGYoYAFI8Man4zneYkWmbxk,14425
69
+ tests/test_change_detection.py,sha256=jH8SEhndX400DNqIKmcYLzxHqq9WFdgmuvY3XPq-ZFc,13534
70
+ tests/test_cli_output.py,sha256=owDP9pLHdErtPAr1paNKp1o044kuH-HxInHLIBLZQFI,18420
68
71
  tests/test_file_utils.py,sha256=-O5zImiFVz9JJ4xi8TbsUwNgPFQoQUOl9Frls4BHCBI,1358
69
72
  tests/test_git_utils.py,sha256=h3rWhhs4XVRYqzVzdmj51siF_AYD-GkbZiefK2iGfWo,15614
70
73
  tests/test_imports.py,sha256=EWYLUxGunUUdsG9iCiY_tts6T7DIVdhdB_MZlB1LsmU,2081
71
- tests/test_partial_rendering.py,sha256=4xXZhy9yStn0awSw8B-yrJ3kVYyzJrlR-dKwg9ODXMY,11188
72
- tests/test_plain_modules.py,sha256=9bWsketDnjpCWkCbMZzYvLqT1ojPhLyxxKljPUq42oY,11393
74
+ tests/test_partial_rendering.py,sha256=r0RUUd1pI4-2ZkRrM1j8_kUqv-70hrOGjuKBhZoRmH0,22758
75
+ tests/test_plain_modules.py,sha256=S7sd4ZYNotXOgCyZzmBd714QFLih69nlZ3tV8WNtVPo,13766
73
76
  tests/test_plainfile.py,sha256=wZ_PN4GULPWIpl_AK_r_6jkgtX0Ch8EHmN5IhX7huOE,11027
74
77
  tests/test_plainfileparser.py,sha256=7AassxH7AKX4lfW6E_58FYxRe-IzIiA16BMVWPjWqCM,18750
75
78
  tests/test_plainspec.py,sha256=JrzjJ4VFYDmWTZDq-8WFyt2oMx76IEuO1CAUPQeGOsw,2404
76
79
  tests/test_requires.py,sha256=6tnHYaKudxN-RiQjSFoi9nHe_x0ze3QF-f-j67zAE74,1250
77
80
  tests/test_resolve_config_file.py,sha256=b87tDOvyzbsnE-QRWbZ97ou7NuyaAgskv-dbYBum31c,2830
81
+ tests/test_telemetry.py,sha256=04mboTP6iXedSF7E4ZL6MPRYCw__-5qlVerjX1Ve7VI,6627
78
82
  tests/e2e/conftest.py,sha256=5Dm67UTOE-M6hMb-mqZvwa6Ek_wmZgsR7fUv9ZEqCwY,4164
79
83
  tests/e2e/test_hello_world_python.py,sha256=PQy4elzB7WvpiWLKa6_HFO0K8HA6_6hGLaAqRi0J3OU,1627
80
84
  tests/e2e/test_hello_world_python_windows.py,sha256=kJQZIuzj8OEEtgBVxnzFvyQJ_wvP2oBu9L-E9geg8sE,2010
@@ -82,13 +86,13 @@ tui/__init__.py,sha256=_fy7FowY0RkWdn4kC7XV2jgvQPCj-2gE8fNUb-Mny0A,60
82
86
  tui/components.py,sha256=DoMDhwNVVpv--OY6o8YQYPzOQPG9Dyh9kgMOqUYtNiE,25412
83
87
  tui/models.py,sha256=KMjlWubmPD41GA-BjgiqkaV9v7tLJ0fuWKBDPmZj4JA,1493
84
88
  tui/plain2code_tui.py,sha256=t-0DonjrK6_x6jPUMYW0w6tvjZZlk89E-DEor7GzjG4,13785
85
- tui/plain_module_render_choice_tui.py,sha256=MLc-Ps3O2DMBKRCPJAxUY059SdDIYq2ha7-sWxuguj0,9611
89
+ tui/plain_module_render_choice_tui.py,sha256=DRl_xhUbUJsyOaCYt_uNDSLUAA3Ty9NYI9zFrVDwp9Y,11270
86
90
  tui/spinner.py,sha256=Ro6Gd9Przf-whuHqPRY6HwI0T57yJjyNPbhDbigZKZE,2471
87
91
  tui/state_handlers.py,sha256=4r_6m5YMcprQtgiTP0De3XFfGAF8VzgaWXHxPweYPbQ,16325
88
92
  tui/styles.css,sha256=c13WY1nOgmtkT_FH9Ad2PjXrfi8LYwr6zzD6rBOh9tU,6685
89
93
  tui/widget_helpers.py,sha256=mWDPrALsxLOzJg8KD9MXXafJKRiqG-EIp797jbOT77o,5972
90
- codeplain-0.3.2.dist-info/METADATA,sha256=_m-JTZCg09Ur5aGhureD24GwhQpQravmQnsFxfT37bc,5134
91
- codeplain-0.3.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
92
- codeplain-0.3.2.dist-info/entry_points.txt,sha256=oDZkBqu9WhtZApb_K6ia8-fn9aojwmAsgnKELceX5T4,46
93
- codeplain-0.3.2.dist-info/licenses/LICENSE,sha256=pCeKgQ1mXE5OmNUuKOfOZh1T1vqkeCUxghE7N8qgnzc,11345
94
- codeplain-0.3.2.dist-info/RECORD,,
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,,
partial_rendering.py CHANGED
@@ -2,6 +2,7 @@ from dataclasses import dataclass
2
2
  from typing import Literal
3
3
 
4
4
  import plain_spec
5
+ from change_detection import PartialRenderStart, determine_partial_render_start
5
6
  from plain2code_exceptions import ModuleDoesNotExistError
6
7
  from plain_modules import PlainModule
7
8
 
@@ -143,86 +144,154 @@ def get_all_affected_modules_from_change(
143
144
  return list(all_affected_modules.values())
144
145
 
145
146
 
146
- def get_render_choices(
147
+ def change_is_only_future_work(
147
148
  plain_module: PlainModule,
148
149
  plain_module_render_state: PlainModuleRenderState,
149
- force_render: bool = False,
150
- ) -> dict[str, RenderChoice]:
151
- choices = dict[str, RenderChoice]()
152
- choice_idx = 1
153
- module_start_points = list[str]()
150
+ partial_start: "PartialRenderStart | None",
151
+ ) -> bool:
152
+ """Return True when a spec change only adds work *after* the last-rendered functionality.
153
+
154
+ Appending a new functionality past the render frontier (e.g. a new functionality at the
155
+ end of the last module) does not affect anything already rendered, so it needs no user
156
+ decision. The normal "continue" path renders the outstanding functionalities, starting
157
+ from the first one that was never rendered.
158
+ """
159
+ if partial_start is None:
160
+ return False
161
+
162
+ all_modules = plain_module.all_required_modules + [plain_module]
163
+ order = {module.module_name: index for index, module in enumerate(all_modules)}
164
+ last_index = order[plain_module_render_state.last_render_module.module_name]
165
+ start_index = order[partial_start.module.module_name]
166
+
167
+ if start_index != last_index:
168
+ return start_index > last_index
154
169
 
155
- if plain_module_render_state.last_render_module.is_initial_module():
156
- choices[str(choice_idx)] = RenderChoice(
157
- module=plain_module_render_state.last_render_module,
170
+ if plain_module_render_state.last_render_frid is None:
171
+ return True
172
+
173
+ return int(partial_start.frid) > int(plain_module_render_state.last_render_frid)
174
+
175
+
176
+ def _resume_render_choice(
177
+ plain_module: PlainModule,
178
+ plain_module_render_state: PlainModuleRenderState,
179
+ force_render: bool,
180
+ ) -> RenderChoice:
181
+ """The natural next step from the last-rendered position when no decision is needed:
182
+ start an unrendered module, continue a partially-rendered one, or advance to the next.
183
+ """
184
+ pr = plain_module_render_state
185
+
186
+ if pr.last_render_module.has_no_rendered_functionality():
187
+ return RenderChoice(
188
+ module=pr.last_render_module,
158
189
  render_range=None,
159
190
  choice_type="module_start",
160
191
  wipe_later_modules=True,
161
192
  is_destructive=False,
162
193
  )
163
- choice_idx += 1
164
194
 
165
- elif not plain_module_render_state.last_render_module.is_module_fully_rendered():
166
- if not plain_module_render_state.last_render_frid:
195
+ if not pr.last_render_module.is_module_fully_rendered():
196
+ if not pr.last_render_frid:
167
197
  raise ValueError("Last render FRID is not set for a non-initial module")
168
198
 
169
- next_frid, next_module = plain_module.get_next_frid(
170
- plain_module_render_state.last_render_frid, plain_module_render_state.last_render_module.module_name
171
- )
199
+ next_frid, next_module = plain_module.get_next_frid(pr.last_render_frid, pr.last_render_module.module_name)
172
200
  render_range = plain_spec.get_render_range_from(next_frid, next_module.plain_source)
173
-
174
- choices[str(choice_idx)] = RenderChoice(
201
+ return RenderChoice(
175
202
  module=next_module,
176
- render_range=render_range if not force_render else None,
203
+ render_range=None if force_render else render_range,
177
204
  wipe_later_modules=force_render,
178
205
  choice_type="continue_from_frid",
179
206
  )
180
- choice_idx += 1
181
207
 
182
- else:
183
- next_module = plain_module.get_next_module(plain_module_render_state.last_render_module.module_name)
184
- if next_module is None:
185
- next_module = plain_module_render_state.last_render_module
208
+ next_module = plain_module.get_next_module(pr.last_render_module.module_name) or pr.last_render_module
209
+ return RenderChoice(
210
+ module=next_module,
211
+ render_range=None,
212
+ choice_type="module_start",
213
+ is_destructive=pr.last_render_module.module_name == plain_module.module_name,
214
+ )
186
215
 
187
- choices[str(choice_idx)] = RenderChoice(
188
- module=next_module,
189
- render_range=None,
190
- is_destructive=plain_module_render_state.last_render_module.module_name == plain_module.module_name,
191
- choice_type="module_start",
192
- )
193
- module_start_points.append(next_module.module_name)
194
- choice_idx += 1
195
216
 
196
- if plain_module_render_state.change:
217
+ def get_render_choices(
218
+ plain_module: PlainModule,
219
+ plain_module_render_state: PlainModuleRenderState,
220
+ force_render: bool = False,
221
+ ) -> dict[str, RenderChoice]:
222
+ render_choices = list[RenderChoice]()
223
+ module_start_points = list[str]()
224
+
225
+ # Decide whether the detected change actually requires a decision. A spec change that only
226
+ # adds work *after* the last-rendered functionality (e.g. a new functionality appended to
227
+ # the end of the last module) does not affect anything already rendered, so it is treated
228
+ # like a normal "continue" rather than a change that requires choosing where to restart.
229
+ partial_start = None
230
+ treat_as_continue = plain_module_render_state.change is None
231
+ if plain_module_render_state.change is not None and plain_module_render_state.change_type == "spec_change":
232
+ partial_start = determine_partial_render_start(plain_module)
233
+ if change_is_only_future_work(plain_module, plain_module_render_state, partial_start):
234
+ treat_as_continue = True
235
+
236
+ if treat_as_continue:
237
+ # Continue / resume from the last-rendered position. The change-driven blocks below decide
238
+ # the start otherwise — once a change touches already-rendered work, resuming from the old
239
+ # position would build on stale code.
240
+ render_choices.append(_resume_render_choice(plain_module, plain_module_render_state, force_render))
241
+
242
+ else:
243
+ # change is set here (otherwise treat_as_continue would be True), so change_type is always
244
+ # "spec_change" or "code_change" and get_all_affected_modules_from_change is well-defined.
197
245
  all_affected_modules = get_all_affected_modules_from_change(plain_module, plain_module_render_state)
198
246
 
247
+ if plain_module_render_state.change_type == "spec_change" and partial_start is not None:
248
+ # Optimized partial render: render from the changed functionality, keeping the
249
+ # module's earlier (unchanged) functionalities.
250
+ render_range = plain_spec.get_render_range_from(partial_start.frid, partial_start.module.plain_source)
251
+ render_choices.append(
252
+ RenderChoice(
253
+ module=partial_start.module,
254
+ render_range=render_range,
255
+ choice_type="render_from_change",
256
+ wipe_later_modules=len(all_affected_modules) > 1,
257
+ is_destructive=True,
258
+ )
259
+ )
260
+
261
+ # Full re-render of the affected module(s) from scratch. For a code change this is always
262
+ # the action; for a spec change with no partial start (non-FR sections changed, or only
263
+ # trailing FR removals) it is the only safe option, and when a partial start does exist it
264
+ # is offered alongside it as the "rebuild this module cleanly" alternative. The "re-render
265
+ # from first module" choice below remains the full-chain reset.
199
266
  if len(all_affected_modules) > 0 and all_affected_modules[0].module_name not in module_start_points:
200
- choices[str(choice_idx)] = RenderChoice(
201
- module=all_affected_modules[0],
202
- render_range=None,
203
- choice_type="rerender_affected",
204
- wipe_later_modules=True,
205
- is_destructive=True,
267
+ render_choices.append(
268
+ RenderChoice(
269
+ module=all_affected_modules[0],
270
+ render_range=None,
271
+ choice_type="rerender_affected",
272
+ wipe_later_modules=True,
273
+ is_destructive=True,
274
+ )
206
275
  )
207
276
  module_start_points.append(all_affected_modules[0].module_name)
208
- choice_idx += 1
209
-
210
- if len(plain_module.all_required_modules) > 0:
211
- first_module = plain_module.all_required_modules[0]
212
- if first_module.module_name != plain_module_render_state.last_render_module.module_name and (
213
- plain_module_render_state.change is not None
214
- and first_module.module_name != plain_module_render_state.change.module_name
215
- and first_module.module_name not in module_start_points
216
- ):
217
- choices[str(choice_idx)] = RenderChoice(
218
- module=first_module,
219
- render_range=None,
220
- choice_type="rerender_from_first",
221
- wipe_later_modules=True,
222
- is_destructive=True,
223
- )
224
- module_start_points.append(first_module.module_name)
225
- choice_idx += 1
226
277
 
227
- choices[str(choice_idx)] = RenderChoice(module=None, render_range=None, choice_type="quit")
228
- return choices
278
+ if len(plain_module.all_required_modules) > 0:
279
+ first_module = plain_module.all_required_modules[0]
280
+ if first_module.module_name != plain_module_render_state.last_render_module.module_name and (
281
+ plain_module_render_state.change is not None
282
+ and first_module.module_name != plain_module_render_state.change.module_name
283
+ and first_module.module_name not in module_start_points
284
+ ):
285
+ render_choices.append(
286
+ RenderChoice(
287
+ module=first_module,
288
+ render_range=None,
289
+ choice_type="rerender_from_first",
290
+ wipe_later_modules=True,
291
+ is_destructive=True,
292
+ )
293
+ )
294
+ module_start_points.append(first_module.module_name)
295
+
296
+ render_choices.append(RenderChoice(module=None, render_range=None, choice_type="quit"))
297
+ return {str(idx): choice for idx, choice in enumerate(render_choices, start=1)}