codeplain 0.3.2__py3-none-any.whl → 0.3.3__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.3"
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.3
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,4 @@
1
- _version.py,sha256=vNiWJ14r_cw5t_7UDqDQIVZvladKFGyHH2avsLpN7Vg,22
1
+ _version.py,sha256=8KcCYTXH99C2-gCLuPILJvtT9YftRWJsartIx6TQ2ZY,22
2
2
  codeplain_REST_api.py,sha256=h_XoNws8fvf7OTtoVXhHh6OfLGg77UNFuggMBwaqBwE,21533
3
3
  concept_utils.py,sha256=D4qVHFBRvfJZwiIq1AD_SV0Ev-RnOMg2yGWo6Cal7PM,7913
4
4
  diff_utils.py,sha256=AjiQlqo5pRos_8hVXZo5yBurl5BzSrTMGrQv4dCtRCg,1198
@@ -8,7 +8,7 @@ git_utils.py,sha256=LQQnL5DAFpH5fDBQgUZX2WfjWY4YC5OEGBPIXryTmnY,16896
8
8
  memory_management.py,sha256=nHNudgaOqp5UGkIMi62TRCc2nUeFOM0w-I1FfxBFaQE,6120
9
9
  module_renderer.py,sha256=pXMXKKGMhUwLLlSxppE_hpZlwpJ3WSHSfR9FQxt6rSI,6860
10
10
  partial_rendering.py,sha256=prWEJo9m1TbF7SKzpNInbcefyTF0o3oXE7Qf1l4SmBs,8409
11
- plain2code.py,sha256=gyPeZtLe7VkGChWwGVNNZRXvkqpH8uC27szHbrZgmeY,14010
11
+ plain2code.py,sha256=Yi6W9R3HNedbaeIgfFffQnfauwTXmJTi8ZRD0LdmfvA,14042
12
12
  plain2code_arguments.py,sha256=J05NwaY-O04rIWVF2yIXysNB_ofZS0wJFn0u1WaCCSA,15399
13
13
  plain2code_console.py,sha256=YdE-Kz9qRAzIIAg7aYA1bnXRcwy_aTpbFtkR2zHbd-I,4918
14
14
  plain2code_events.py,sha256=AM8KJS68uiVXRMLt861KaGhs2H4yzujlMB_xiYPfHQc,1478
@@ -16,7 +16,8 @@ plain2code_exceptions.py,sha256=Fj1pwCffn06aivEplsTJJGdg6lAOR8gmWd4ctLl9cj4,1745
16
16
  plain2code_logger.py,sha256=kNKIfkS6Ydtut7Ptw5jmv5bFPgXlxB-_Ds5nd-j4d9o,4609
17
17
  plain2code_nodes.py,sha256=IwjQqJxRSJKgP6eEnkYGy3VQHIyoazgkFxAEkxkjfJ8,4426
18
18
  plain2code_read_config.py,sha256=D5CxnmTRjddupecWvstcUm0kIJWWzq5-Zmk6sU0wbIg,2235
19
- plain2code_state.py,sha256=gmqbGfM2dx6ieiqRQRlLPD49JsMAyb3lOWspljcTj9g,1944
19
+ plain2code_state.py,sha256=gIHOmJe9Ml_Xifi6uV5VBMK3zXRpaRoqQNoMhjD1dvQ,2187
20
+ plain2code_telemetry.py,sha256=ZtBgdpWmPZQ_SnC0O9A2XSG30kRJ9kWitQPN4mmT2S8,4245
20
21
  plain2code_utils.py,sha256=HYDJ4lTC3RwBgcJadPvcoU3-Y6krNiuDP4_t2xYKA2Q,874
21
22
  plain_file.py,sha256=vJKCkIA12o7LHAGugIsTbzcpoQrTQX2uqlXEC_EqfPo,29470
22
23
  plain_modules.py,sha256=NXUtxW8fEpJAuEIeVE_iwRBCbHFh2ji-p9xdkgojLqU,14423
@@ -25,21 +26,21 @@ system_config.py,sha256=trgytfXKXoCn0Pgy9u00x2D0ZRHlh_tADlbkwZnF8QE,1038
25
26
  cli_output/__init__.py,sha256=MBKl0YbVpWE-tBa3V5_aVbB7vbaqlIK2NsHTjRUZqis,284
26
27
  cli_output/dry_run.py,sha256=6NKhNWPtD0CBK_QHSsSqIKLTzibTwxS9d8ltP9d1bgU,1473
27
28
  cli_output/render_summary.py,sha256=QrW3Iq15xUoWXuVXpqClAKncAvHdQR0lWG1_OMUBsWk,1307
28
- cli_output/status.py,sha256=5jr9XFU1MWD3pN404qw7SNYCGdvqfA9j4xv9z1tZG4s,5890
29
+ cli_output/status.py,sha256=PFhHqFaC0nt2zsPR0VbD-JBPB3sEVPfhopJmOylFxbU,6160
29
30
  config/__init__.py,sha256=beYSsJWmBNHDP5rYmVDouqgEeP3t1lkkepbXJ-oq0F8,37
30
31
  config/system_config.yaml,sha256=of5R9vukSohU9AlkGJOcAVQA1UJgxYmDy6islU6iFuE,508
31
32
  docs/generate_cli.py,sha256=0FHVhICbM8g7ahdInTNih3hNKpty1wbV5rdGECMJj48,656
32
33
  examples/example_hello_world_python/harness_tests/hello_world_display/test_hello_world.py,sha256=dwTowrHiVKKbrDv21v8xJC30Q57AXZkQasdGOO5JsBE,470
33
34
  render_machine/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
- render_machine/code_renderer.py,sha256=bLdoZcWaLGvuR0GygnWY9y9kycGySD-IEWY5DFGcGsg,3781
35
+ render_machine/code_renderer.py,sha256=-vp_ltU7e3ssX9hbS8IY4ETSp-39JAuoJ3ZltKpdt1M,3959
35
36
  render_machine/conformance_tests.py,sha256=MeEveEGJTClLGxWw04P4TR6U9kX9Qgz9qtxNrB9a8QI,7085
36
37
  render_machine/implementation_code_helpers.py,sha256=LdHNLoKgVtrDaGH2APX8C9vjNjLBUhu2V7OAaJvm4rM,2009
37
- render_machine/render_context.py,sha256=NHDtJkhQP8tW0iyRH6PR7oSk-PO_pEGMKkX38x52YMc,29128
38
+ render_machine/render_context.py,sha256=5W_PVrlPZMPlWmXlHQtYUiCIFvjNFsjNiJ42z5Hidv8,28447
38
39
  render_machine/render_types.py,sha256=cT3D0lmxQ3enIo0jGp3LEGCY2t3X1Q1_tqwcadzSAv0,8761
39
40
  render_machine/render_utils.py,sha256=ao0e64VSOGYfb8hwJXIZlbqer5HjNMYJWHlLDoHnJyc,9123
40
- render_machine/state_machine_config.py,sha256=uqrU9Pu4cAKxu_-e1eiuUQK-tZWxZko0U0Bfvl67v_c,28813
41
+ render_machine/state_machine_config.py,sha256=F-pSrNPF31eYMmJMo0STj85q7rp-4qFyy7kEmJ8iVc4,26944
41
42
  render_machine/states.py,sha256=niFXqayyA_rezCrlzo0hYeHAruErcPzQVF-BeIBGnL8,1968
42
- render_machine/triggers.py,sha256=o1N5c9xC7fsKY6ZaWcWBtALT8i1acSSharAXKAqBu8E,1285
43
+ render_machine/triggers.py,sha256=VekNhHGR-u5JXIboqc_Eyqf73LNH7wezLK7r3WWtodk,1269
43
44
  render_machine/actions/analyze_specification_ambiguity.py,sha256=SykZAozGvr1jySxbpKCgt11nMpZhOK0sN-J66M42T5Y,2847
44
45
  render_machine/actions/base_action.py,sha256=vVzMp-LanrZyq-jamHyWaQxpRl-pLqtTdtiEzUBhfyE,534
45
46
  render_machine/actions/commit_conformance_tests_changes.py,sha256=08yxCPCpf5TlrzLPsEh5SJXVVvnLfwdNKSr7BlzlYxo,2589
@@ -64,7 +65,7 @@ standard_template_library/typescript-react-app-boilerplate.plain,sha256=6LFxhEOz
64
65
  standard_template_library/typescript-react-app-template.plain,sha256=DN5wEJcT8ZqQTf0vFKi9Z1isi9cin2UVNOZb3r28d3M,715
65
66
  tests/__init__.py,sha256=Wk73Io62J15BtlLVIzxmASDWaaJkQLevS4BLK5LDAQg,16
66
67
  tests/conftest.py,sha256=QZcp08htUlJGgmHDlRWFgsWXZ8o8IBWTD5QqJaUMlU8,790
67
- tests/test_cli_output.py,sha256=YdIQtplncTeMf82E0nWNrGYoYAFI8Man4zneYkWmbxk,14425
68
+ tests/test_cli_output.py,sha256=owDP9pLHdErtPAr1paNKp1o044kuH-HxInHLIBLZQFI,18420
68
69
  tests/test_file_utils.py,sha256=-O5zImiFVz9JJ4xi8TbsUwNgPFQoQUOl9Frls4BHCBI,1358
69
70
  tests/test_git_utils.py,sha256=h3rWhhs4XVRYqzVzdmj51siF_AYD-GkbZiefK2iGfWo,15614
70
71
  tests/test_imports.py,sha256=EWYLUxGunUUdsG9iCiY_tts6T7DIVdhdB_MZlB1LsmU,2081
@@ -75,6 +76,7 @@ tests/test_plainfileparser.py,sha256=7AassxH7AKX4lfW6E_58FYxRe-IzIiA16BMVWPjWqCM
75
76
  tests/test_plainspec.py,sha256=JrzjJ4VFYDmWTZDq-8WFyt2oMx76IEuO1CAUPQeGOsw,2404
76
77
  tests/test_requires.py,sha256=6tnHYaKudxN-RiQjSFoi9nHe_x0ze3QF-f-j67zAE74,1250
77
78
  tests/test_resolve_config_file.py,sha256=b87tDOvyzbsnE-QRWbZ97ou7NuyaAgskv-dbYBum31c,2830
79
+ tests/test_telemetry.py,sha256=04mboTP6iXedSF7E4ZL6MPRYCw__-5qlVerjX1Ve7VI,6627
78
80
  tests/e2e/conftest.py,sha256=5Dm67UTOE-M6hMb-mqZvwa6Ek_wmZgsR7fUv9ZEqCwY,4164
79
81
  tests/e2e/test_hello_world_python.py,sha256=PQy4elzB7WvpiWLKa6_HFO0K8HA6_6hGLaAqRi0J3OU,1627
80
82
  tests/e2e/test_hello_world_python_windows.py,sha256=kJQZIuzj8OEEtgBVxnzFvyQJ_wvP2oBu9L-E9geg8sE,2010
@@ -87,8 +89,8 @@ tui/spinner.py,sha256=Ro6Gd9Przf-whuHqPRY6HwI0T57yJjyNPbhDbigZKZE,2471
87
89
  tui/state_handlers.py,sha256=4r_6m5YMcprQtgiTP0De3XFfGAF8VzgaWXHxPweYPbQ,16325
88
90
  tui/styles.css,sha256=c13WY1nOgmtkT_FH9Ad2PjXrfi8LYwr6zzD6rBOh9tU,6685
89
91
  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,,
92
+ codeplain-0.3.3.dist-info/METADATA,sha256=v2z452H-9kkFwQDigqMjkWeO-w7L9yf0IWEEam-W_Qg,5381
93
+ codeplain-0.3.3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
94
+ codeplain-0.3.3.dist-info/entry_points.txt,sha256=oDZkBqu9WhtZApb_K6ia8-fn9aojwmAsgnKELceX5T4,46
95
+ codeplain-0.3.3.dist-info/licenses/LICENSE,sha256=pCeKgQ1mXE5OmNUuKOfOZh1T1vqkeCUxghE7N8qgnzc,11345
96
+ codeplain-0.3.3.dist-info/RECORD,,
plain2code.py CHANGED
@@ -49,6 +49,7 @@ from plain2code_logger import (
49
49
  get_log_file_path,
50
50
  )
51
51
  from plain2code_state import RunState
52
+ from plain2code_telemetry import capture_crash, initialize_telemetry
52
53
  from system_config import system_config
53
54
  from tui.plain2code_tui import Plain2CodeTUI
54
55
  from tui.plain_module_render_choice_tui import PlainModuleRenderChoiceTUI
@@ -56,6 +57,27 @@ from tui.plain_module_render_choice_tui import PlainModuleRenderChoiceTUI
56
57
  DEFAULT_TEMPLATE_DIRS = importlib.resources.files("standard_template_library")
57
58
  RENDER_THREAD_SHUTDOWN_TIMEOUT = 0.7
58
59
 
60
+ # Exceptions that represent expected, user-facing error conditions. They are
61
+ # reported to the user directly and must never be sent to Sentry as crashes.
62
+ EXPECTED_EXCEPTIONS = (
63
+ InvalidFridArgument,
64
+ FileNotFoundError,
65
+ MissingResource,
66
+ TemplateNotFoundError,
67
+ PlainSyntaxError,
68
+ MissingPreviousFunctionalitiesError,
69
+ MissingAPIKey,
70
+ InvalidAPIKey,
71
+ OutdatedClientVersion,
72
+ ConflictingRequirements,
73
+ RenderingCreditBalanceTooLow,
74
+ NetworkConnectionError,
75
+ ModuleDoesNotExistError,
76
+ UnsupportedResourceType,
77
+ GitNotInstalledError,
78
+ SystemExit,
79
+ )
80
+
59
81
 
60
82
  def setup_logging(
61
83
  args,
@@ -324,6 +346,8 @@ def main(): # noqa: C901
324
346
  args, event_bus, run_state, args.log_to_file, args.log_file_name, args.filename, args.headless
325
347
  )
326
348
 
349
+ initialize_telemetry()
350
+
327
351
  exc_info = None
328
352
  error_message = None
329
353
 
@@ -340,36 +364,17 @@ def main(): # noqa: C901
340
364
  else:
341
365
  error_message = str(e) if str(e) else repr(e)
342
366
 
343
- if not isinstance(
344
- e,
345
- (
346
- InvalidFridArgument,
347
- FileNotFoundError,
348
- MissingResource,
349
- TemplateNotFoundError,
350
- PlainSyntaxError,
351
- MissingPreviousFunctionalitiesError,
352
- MissingAPIKey,
353
- InvalidAPIKey,
354
- OutdatedClientVersion,
355
- ConflictingRequirements,
356
- RenderingCreditBalanceTooLow,
357
- NetworkConnectionError,
358
- ModuleDoesNotExistError,
359
- UnsupportedResourceType,
360
- GitNotInstalledError,
361
- ),
362
- ):
367
+ if not isinstance(e, EXPECTED_EXCEPTIONS):
363
368
  exc_info = sys.exc_info()
364
369
  finally:
370
+ if exc_info:
371
+ dump_crash_logs(args, run_state)
372
+ capture_crash(exc_info, run_state, args)
365
373
  print_exit_summary(
366
374
  run_state,
367
375
  args.filename,
368
376
  error_message=error_message,
369
377
  )
370
- if exc_info:
371
- # Log traceback
372
- dump_crash_logs(args, run_state)
373
378
 
374
379
  if args.headless and (exc_info is not None or not run_state.render_succeeded):
375
380
  sys.exit(1)
plain2code_state.py CHANGED
@@ -23,6 +23,10 @@ class RunState:
23
23
  self.unittest_batch_id: int = 0
24
24
  self.render_time_accumulated: int = 0
25
25
  self.last_render_start_timestamp: float = time.monotonic()
26
+ # Mirrored from the render state machine for crash reporting; not authoritative.
27
+ self.current_module: Optional[str] = None
28
+ self.current_frid: Optional[str] = None
29
+ self.current_render_state: Optional[str] = None
26
30
 
27
31
  def increment_call_count(self):
28
32
  self.call_count += 1
@@ -0,0 +1,114 @@
1
+ """Crash reporting via Sentry.
2
+
3
+ Only unexpected exceptions are reported (the caller decides which exceptions are
4
+ expected; see EXPECTED_EXCEPTIONS in plain2code.py). Reporting is on by default
5
+ and can be disabled by setting the CODEPLAIN_NO_TELEMETRY environment variable
6
+ to any non-empty value.
7
+ """
8
+
9
+ import os
10
+ from typing import Any, Optional
11
+
12
+ import sentry_sdk
13
+ from sentry_sdk.integrations.atexit import AtexitIntegration
14
+ from sentry_sdk.integrations.dedupe import DedupeIntegration
15
+ from sentry_sdk.integrations.modules import ModulesIntegration
16
+ from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber
17
+
18
+ from plain2code_state import RunState
19
+ from system_config import system_config
20
+
21
+ SENTRY_DSN = "https://64d0d86b50b34e2dede3e4eaf5142282@o4510793955934208.ingest.us.sentry.io/4511540621213696"
22
+
23
+ NO_TELEMETRY_ENV_VAR = "CODEPLAIN_NO_TELEMETRY"
24
+ ENVIRONMENT_ENV_VAR = "CODEPLAIN_ENV"
25
+ DEFAULT_ENVIRONMENT = "production"
26
+
27
+ FLUSH_TIMEOUT_SECONDS = 2
28
+
29
+ # Local variable names whose values may contain proprietary spec or generated
30
+ # code content and must be scrubbed from stack traces (extends Sentry's
31
+ # default denylist, which already covers api_key, auth, secrets etc.).
32
+ # "headers" and "x-api-key" cover the request headers local in
33
+ # codeplain_REST_api.post_request; the default denylist only has the
34
+ # underscore form "x_api_key".
35
+ SCRUB_DENYLIST = DEFAULT_DENYLIST + [
36
+ "authorization",
37
+ "headers",
38
+ "x-api-key",
39
+ "plain_source",
40
+ "plain_source_tree",
41
+ "full_plain_source",
42
+ "existing_files_content",
43
+ "file_content",
44
+ "files_content",
45
+ "content",
46
+ "source",
47
+ "response_json",
48
+ "payload",
49
+ ]
50
+
51
+
52
+ def telemetry_enabled() -> bool:
53
+ """Return True if crash reporting should be active."""
54
+ if os.environ.get(NO_TELEMETRY_ENV_VAR):
55
+ return False
56
+ return True
57
+
58
+
59
+ def initialize_telemetry(**init_overrides: Any) -> bool:
60
+ """Initialize Sentry crash reporting. Returns True if initialized."""
61
+ if not telemetry_enabled():
62
+ return False
63
+
64
+ try:
65
+ init_kwargs: dict[str, Any] = dict(
66
+ dsn=SENTRY_DSN,
67
+ release=system_config.client_version,
68
+ environment=os.environ.get(ENVIRONMENT_ENV_VAR, DEFAULT_ENVIRONMENT),
69
+ send_default_pii=False,
70
+ server_name="", # hostname is identifying; don't send it
71
+ default_integrations=False,
72
+ auto_enabling_integrations=False,
73
+ integrations=[
74
+ AtexitIntegration(callback=lambda pending, timeout: None),
75
+ DedupeIntegration(),
76
+ ModulesIntegration(),
77
+ ],
78
+ include_local_variables=True,
79
+ event_scrubber=EventScrubber(denylist=SCRUB_DENYLIST, recursive=True),
80
+ shutdown_timeout=FLUSH_TIMEOUT_SECONDS,
81
+ )
82
+ init_kwargs.update(init_overrides)
83
+ sentry_sdk.init(**init_kwargs)
84
+ return True
85
+ except Exception:
86
+ return False
87
+
88
+
89
+ def capture_crash(exc_info, run_state: Optional[RunState], args) -> bool:
90
+ """Report an unexpected crash to Sentry. Returns True if an event was sent."""
91
+ if not telemetry_enabled():
92
+ return False
93
+
94
+ try:
95
+ with sentry_sdk.new_scope() as scope:
96
+ if run_state is not None:
97
+ scope.set_tag("render_id", run_state.render_id)
98
+ scope.set_tag("render_state", run_state.current_render_state)
99
+ scope.set_tag("current_module", run_state.current_module)
100
+ scope.set_tag("current_frid", run_state.current_frid)
101
+ scope.set_tag("headless", bool(getattr(args, "headless", False)))
102
+ scope.set_tag("unittests_script_provided", bool(getattr(args, "unittests_script", None)))
103
+ scope.set_tag("conformance_tests_script_provided", bool(getattr(args, "conformance_tests_script", None)))
104
+ scope.set_tag(
105
+ "prepare_environment_script_provided", bool(getattr(args, "prepare_environment_script", None))
106
+ )
107
+
108
+ event_id = sentry_sdk.capture_exception(exc_info[1])
109
+
110
+ sentry_sdk.flush(timeout=FLUSH_TIMEOUT_SECONDS)
111
+ return event_id is not None
112
+ except Exception:
113
+ # Telemetry must never break the CLI or mask the original crash.
114
+ return False
@@ -42,6 +42,7 @@ class CodeRenderer:
42
42
  def run(self):
43
43
  """Execute the main rendering workflow."""
44
44
  self.render_context.event_bus.publish(RenderModuleStarted(module_name=self.render_context.module_name))
45
+ self.render_context.run_state.current_module = self.render_context.module_name
45
46
  previous_action_payload = None
46
47
  previous_state = None
47
48
 
@@ -63,6 +64,7 @@ class CodeRenderer:
63
64
  )
64
65
  )
65
66
  previous_state = deepcopy(self.render_context.state)
67
+ self.render_context.run_state.current_render_state = self.render_context.state
66
68
  self.render_context.script_execution_history.should_update_script_outputs = False
67
69
 
68
70
  self.render_context.previous_action_payload = previous_action_payload
@@ -1,6 +1,6 @@
1
1
  import threading
2
2
  from copy import deepcopy
3
- from typing import Optional
3
+ from typing import Callable, Optional
4
4
 
5
5
  import file_utils
6
6
  import git_utils
@@ -162,6 +162,7 @@ class RenderContext:
162
162
  linked_resources=linked_resources,
163
163
  functional_requirement_render_attempts=0,
164
164
  )
165
+ self.run_state.current_frid = frid
165
166
  return
166
167
 
167
168
  def check_frid_iteration_limit(self):
@@ -195,19 +196,10 @@ class RenderContext:
195
196
  def should_run_conformance_tests(self) -> bool:
196
197
  return self.conformance_tests_script is not None
197
198
 
198
- def finish_frid_implementation_step(self):
199
- pass
200
-
201
199
  def start_unittests_processing(self):
202
200
  self.unit_tests_running_context = UnitTestsRunningContext(fix_attempts=0)
203
201
  self.run_state.increment_unittest_batch_id()
204
202
 
205
- def start_unittests_processing_in_implementation(self):
206
- self.start_unittests_processing()
207
-
208
- def start_unittests_processing_in_refactoring(self):
209
- self.start_unittests_processing()
210
-
211
203
  def _get_first_frid_conformance_test_running_context(self, module: PlainModule | None):
212
204
  conformance_tests_running_context = self.conformance_tests_running_context
213
205
 
@@ -280,9 +272,6 @@ class RenderContext:
280
272
 
281
273
  return conformance_tests_running_context
282
274
 
283
- def start_unittests_processing_in_conformance_tests(self):
284
- self.start_unittests_processing()
285
-
286
275
  def finish_unittests_processing(self):
287
276
  existing_files = file_utils.list_all_text_files(self.build_folder)
288
277
 
@@ -294,39 +283,32 @@ class RenderContext:
294
283
  self.frid_context.changed_files.add(file_name)
295
284
  self.unit_tests_running_context.fix_attempts = 1
296
285
 
297
- def finish_unittests_processing_during_implementation(self):
298
- self.finish_unittests_processing()
299
-
300
- def start_fixing_unit_tests(self):
286
+ def start_fixing_unit_tests(self, on_limit_exceeded: Callable):
301
287
  self.unit_tests_running_context.fix_attempts += 1
302
-
303
288
  if self.unit_tests_running_context.fix_attempts > MAX_UNITTEST_FIX_ATTEMPTS:
304
- self.machine.dispatch(triggers.RESTART_FRID_PROCESSING)
289
+ on_limit_exceeded()
305
290
 
306
- def start_fixing_unit_tests_in_conformance_tests(self):
307
- self.unit_tests_running_context.fix_attempts += 1
291
+ def _on_unit_test_limit_exceeded_in_implementation(self):
292
+ self.machine.dispatch(triggers.RESTART_FRID_PROCESSING)
308
293
 
309
- if self.unit_tests_running_context.fix_attempts > MAX_UNITTEST_FIX_ATTEMPTS:
310
- self.functional_requirements_render_attempts_failed_unit_during_conformance_tests += 1
311
- if (
312
- self.functional_requirements_render_attempts_failed_unit_during_conformance_tests
313
- >= MAX_FUNCTIONAL_REQUIREMENT_RENDER_ATTEMPTS_FAILED_UNIT_DURING_CONFORMANCE_TESTS
314
- ):
315
- error_msg = f"Failed to adjust unit tests after implementation code was update while fixing conformance tests for functionality {self.frid_context.frid} for the {MAX_FUNCTIONAL_REQUIREMENT_RENDER_ATTEMPTS_FAILED_UNIT_DURING_CONFORMANCE_TESTS} times."
316
- self.dispatch_error(error_msg)
317
- else:
318
- console.info(
319
- f"Failed to adjust unit tests after implementation code was update while fixing conformance tests for functionality {self.frid_context.frid}."
320
- )
321
- console.info(f"Restarting rendering the functionality {self.frid_context.frid} from scratch.")
322
- self.machine.dispatch(triggers.RESTART_FRID_PROCESSING)
323
-
324
- def start_fixing_unit_tests_in_refactoring(self):
325
- self.unit_tests_running_context.fix_attempts += 1
294
+ def _on_unit_test_limit_exceeded_in_conformance_tests(self):
295
+ self.functional_requirements_render_attempts_failed_unit_during_conformance_tests += 1
296
+ if (
297
+ self.functional_requirements_render_attempts_failed_unit_during_conformance_tests
298
+ >= MAX_FUNCTIONAL_REQUIREMENT_RENDER_ATTEMPTS_FAILED_UNIT_DURING_CONFORMANCE_TESTS
299
+ ):
300
+ error_msg = f"Failed to adjust the unit tests after implementation code was update while fixing the conformance tests for functionality {self.frid_context.frid} for the {MAX_FUNCTIONAL_REQUIREMENT_RENDER_ATTEMPTS_FAILED_UNIT_DURING_CONFORMANCE_TESTS} times."
301
+ self.dispatch_error(error_msg)
302
+ else:
303
+ console.info(
304
+ f"Failed to adjust the unit tests after implementation code was updated while fixing the conformance tests for functionality {self.frid_context.frid}."
305
+ )
306
+ console.info(f"Restarting rendering the functionality {self.frid_context.frid} from scratch.")
307
+ self.machine.dispatch(triggers.RESTART_FRID_PROCESSING)
326
308
 
327
- if self.unit_tests_running_context.fix_attempts > MAX_UNITTEST_FIX_ATTEMPTS:
328
- git_utils.revert_changes(self.build_folder)
329
- self.machine.dispatch(triggers.START_NEW_REFACTORING_ITERATION)
309
+ def _on_unit_test_limit_exceeded_in_refactoring(self):
310
+ git_utils.revert_changes(self.build_folder)
311
+ self.machine.dispatch(triggers.START_NEW_REFACTORING_ITERATION)
330
312
 
331
313
  def start_refactoring_code(self):
332
314
 
@@ -341,9 +323,6 @@ class RenderContext:
341
323
  )
342
324
  self.machine.dispatch(triggers.PROCEED_FRID_PROCESSING)
343
325
 
344
- def finish_refactoring_code(self):
345
- pass
346
-
347
326
  def start_testing_environment_preparation(self):
348
327
  if (
349
328
  self.prepare_environment_script is None
@@ -5,7 +5,6 @@ This module defines the hierarchical state machine structure, transitions, and a
5
5
  used by the CodeRenderer to orchestrate the code generation workflow.
6
6
  """
7
7
 
8
- from dataclasses import dataclass
9
8
  from typing import Any, Callable, Dict, List
10
9
 
11
10
  import git_utils
@@ -30,46 +29,6 @@ from render_machine.render_context import RenderContext
30
29
  from render_machine.states import States
31
30
 
32
31
 
33
- @dataclass
34
- class UnitTestsStateConfig:
35
- """Dataclass for unit test state configuration."""
36
-
37
- unit_tests_failed_on_enter_function: Callable
38
- on_enter_action: Callable
39
- on_exit_action: Callable
40
-
41
-
42
- class UnitTestsConfig:
43
- """Provides configurations for different unit test scenarios."""
44
-
45
- @staticmethod
46
- def for_refactoring(render_context: RenderContext) -> UnitTestsStateConfig:
47
- """Configuration for unit tests during refactoring."""
48
- return UnitTestsStateConfig(
49
- unit_tests_failed_on_enter_function=render_context.start_fixing_unit_tests_in_refactoring,
50
- on_enter_action=render_context.start_unittests_processing_in_refactoring,
51
- on_exit_action=render_context.finish_unittests_processing,
52
- )
53
-
54
- @staticmethod
55
- def for_conformance_tests(render_context: RenderContext) -> UnitTestsStateConfig:
56
- """Configuration for unit tests during conformance tests."""
57
- return UnitTestsStateConfig(
58
- unit_tests_failed_on_enter_function=render_context.start_fixing_unit_tests_in_conformance_tests,
59
- on_enter_action=render_context.start_unittests_processing_in_conformance_tests,
60
- on_exit_action=render_context.finish_unittests_processing,
61
- )
62
-
63
- @staticmethod
64
- def for_implementation(render_context: RenderContext) -> UnitTestsStateConfig:
65
- """Configuration for unit tests during initial implementation."""
66
- return UnitTestsStateConfig(
67
- unit_tests_failed_on_enter_function=render_context.start_fixing_unit_tests,
68
- on_enter_action=render_context.start_unittests_processing_in_implementation,
69
- on_exit_action=render_context.finish_unittests_processing_during_implementation,
70
- )
71
-
72
-
73
32
  class StateMachineConfig:
74
33
  """Configuration class for the render state machine."""
75
34
 
@@ -137,26 +96,21 @@ class StateMachineConfig:
137
96
  AnalyzeSpecificationAmbiguity.SUCCESSFUL_OUTCOME: triggers.PROCEED_FRID_PROCESSING,
138
97
  }
139
98
 
140
- def get_processing_unit_tests_states(self, config: UnitTestsStateConfig) -> Dict[str, Any]:
141
- """Create the processing unit tests state configuration based on the provided configuration.
142
-
143
- Args:
144
- config: A dataclass containing the configuration for the unit test state.
145
-
146
- Returns:
147
- Dictionary defining the processing unit tests hierarchical state.
148
- """
149
- children = [
150
- States.UNIT_TESTS_READY.value,
151
- {"name": States.UNIT_TESTS_FAILED.value, "on_enter": config.unit_tests_failed_on_enter_function},
152
- ]
153
-
99
+ def get_processing_unit_tests_states(
100
+ self, render_context: RenderContext, on_limit_exceeded: Callable
101
+ ) -> Dict[str, Any]:
154
102
  return {
155
103
  "name": States.PROCESSING_UNIT_TESTS.value,
156
104
  "initial": States.UNIT_TESTS_READY.value,
157
- "on_enter": config.on_enter_action,
158
- "on_exit": config.on_exit_action,
159
- "children": children,
105
+ "on_enter": render_context.start_unittests_processing,
106
+ "on_exit": render_context.finish_unittests_processing,
107
+ "children": [
108
+ States.UNIT_TESTS_READY.value,
109
+ {
110
+ "name": States.UNIT_TESTS_FAILED.value,
111
+ "on_enter": lambda: render_context.start_fixing_unit_tests(on_limit_exceeded),
112
+ },
113
+ ],
160
114
  }
161
115
 
162
116
  def get_postprocessing_conformance_tests_states(self) -> Dict[str, Any]:
@@ -191,7 +145,9 @@ class StateMachineConfig:
191
145
  "on_enter": render_context.start_fixing_conformance_tests,
192
146
  "on_exit": render_context.finish_fixing_conformance_tests,
193
147
  },
194
- self.get_processing_unit_tests_states(UnitTestsConfig.for_conformance_tests(render_context)),
148
+ self.get_processing_unit_tests_states(
149
+ render_context, render_context._on_unit_test_limit_exceeded_in_conformance_tests
150
+ ),
195
151
  self.get_postprocessing_conformance_tests_states(),
196
152
  ],
197
153
  }
@@ -209,10 +165,11 @@ class StateMachineConfig:
209
165
  "name": States.REFACTORING_CODE.value,
210
166
  "initial": States.READY_FOR_REFACTORING.value,
211
167
  "on_enter": render_context.start_refactoring_code,
212
- "on_exit": render_context.finish_refactoring_code,
213
168
  "children": [
214
169
  States.READY_FOR_REFACTORING.value,
215
- self.get_processing_unit_tests_states(UnitTestsConfig.for_refactoring(render_context)),
170
+ self.get_processing_unit_tests_states(
171
+ render_context, render_context._on_unit_test_limit_exceeded_in_refactoring
172
+ ),
216
173
  States.STEP_COMPLETED.value,
217
174
  ],
218
175
  }
@@ -225,12 +182,14 @@ class StateMachineConfig:
225
182
  "on_enter": render_context.start_implementing_frid,
226
183
  "on_exit": render_context.finish_implementing_frid,
227
184
  "children": [
228
- {"name": States.STEP_COMPLETED.value, "on_exit": render_context.finish_frid_implementation_step},
185
+ {"name": States.STEP_COMPLETED.value},
229
186
  {
230
187
  "name": States.READY_FOR_FRID_IMPLEMENTATION.value,
231
188
  "on_enter": render_context.check_frid_iteration_limit,
232
189
  },
233
- self.get_processing_unit_tests_states(UnitTestsConfig.for_implementation(render_context)),
190
+ self.get_processing_unit_tests_states(
191
+ render_context, render_context._on_unit_test_limit_exceeded_in_implementation
192
+ ),
234
193
  refactoring_code_states,
235
194
  self.get_processing_conformance_tests_states(render_context),
236
195
  States.FRID_FULLY_IMPLEMENTED.value,
@@ -7,7 +7,7 @@ that controls the code rendering process.
7
7
 
8
8
  # Trigger constants for state machine transitions
9
9
  START_RENDER = "start_render"
10
- RENDER_FUNCTIONAL_REQUIREMENT = "render_functional_requirement" # todo: rename
10
+ RENDER_FUNCTIONAL_REQUIREMENT = "render_functional_requirement"
11
11
  PROCEED_FRID_PROCESSING = "proceed_frid_processing"
12
12
  MARK_UNIT_TESTS_FAILED = "mark_unit_tests_failed"
13
13
  MARK_UNIT_TESTS_PASSED = "mark_unit_tests_passed"
tests/test_cli_output.py CHANGED
@@ -4,8 +4,8 @@ from unittest.mock import Mock, patch
4
4
 
5
5
  from cli_output.status import (
6
6
  _create_progress_bar,
7
+ _display_bucket_credit_line,
7
8
  _display_credit_line,
8
- _display_purchased_credit_line,
9
9
  _display_status_message,
10
10
  print_status,
11
11
  )
@@ -132,8 +132,8 @@ class TestDisplayCreditLine:
132
132
  mock_console.print.assert_called_once()
133
133
 
134
134
 
135
- class TestDisplayPurchasedCreditLine:
136
- """Tests for _display_purchased_credit_line function."""
135
+ class TestDisplayBucketCreditLine:
136
+ """Tests for _display_bucket_credit_line function."""
137
137
 
138
138
  @patch("cli_output.status.console")
139
139
  def test_display_active_purchased_credits(self, mock_console):
@@ -143,13 +143,28 @@ class TestDisplayPurchasedCreditLine:
143
143
  "remaining": 20,
144
144
  "expiry_date": "2028-12-12T00:00:00+00:00",
145
145
  }
146
- _display_purchased_credit_line(bucket)
146
+ _display_bucket_credit_line(bucket, "Purchased")
147
147
 
148
148
  call_args = mock_console.print.call_args[0][0]
149
149
  assert "Purchased" in call_args
150
150
  assert "20 of 100 remaining" in call_args
151
151
  assert "expires Dec 12, 2028" in call_args
152
152
 
153
+ @patch("cli_output.status.console")
154
+ def test_display_active_promo_credits(self, mock_console):
155
+ """Test displaying active promo credits."""
156
+ bucket = {
157
+ "total": 100,
158
+ "remaining": 20,
159
+ "expiry_date": "2028-12-12T00:00:00+00:00",
160
+ }
161
+ _display_bucket_credit_line(bucket, "Promo")
162
+
163
+ call_args = mock_console.print.call_args[0][0]
164
+ assert "Promo" in call_args
165
+ assert "20 of 100 remaining" in call_args
166
+ assert "expires Dec 12, 2028" in call_args
167
+
153
168
  @patch("cli_output.status.console")
154
169
  def test_display_expired_purchased_credits(self, mock_console):
155
170
  """Test displaying expired purchased credits."""
@@ -158,7 +173,7 @@ class TestDisplayPurchasedCreditLine:
158
173
  "remaining": 20,
159
174
  "expiry_date": "2024-01-01T00:00:00+00:00",
160
175
  }
161
- _display_purchased_credit_line(bucket)
176
+ _display_bucket_credit_line(bucket, "Purchased")
162
177
 
163
178
  call_args = mock_console.print.call_args[0][0]
164
179
  assert "expired Jan 1, 2024" in call_args
@@ -173,7 +188,7 @@ class TestDisplayPurchasedCreditLine:
173
188
  "expiry_date": "2026-06-12T00:00:00", # No timezone
174
189
  }
175
190
  # Should not raise exception
176
- _display_purchased_credit_line(bucket)
191
+ _display_bucket_credit_line(bucket, "Purchased")
177
192
  mock_console.print.assert_called_once()
178
193
 
179
194
 
@@ -187,7 +202,7 @@ class TestDisplayStatusMessage:
187
202
  "remaining": 10,
188
203
  "period_end": "2028-12-01T00:00:00+00:00",
189
204
  }
190
- _display_status_message(plan_credits, [])
205
+ _display_status_message(plan_credits, [], [])
191
206
 
192
207
  # Should not print warning message
193
208
  mock_console.print.assert_not_called()
@@ -198,10 +213,24 @@ class TestDisplayStatusMessage:
198
213
  purchased_credits = [
199
214
  {
200
215
  "remaining": 20,
201
- "expiry_date": "2026-06-12T00:00:00+00:00",
216
+ "expiry_date": "2030-06-12T00:00:00+00:00",
217
+ }
218
+ ]
219
+ _display_status_message(None, purchased_credits, [])
220
+
221
+ # Should not print warning message
222
+ mock_console.print.assert_not_called()
223
+
224
+ @patch("cli_output.status.console")
225
+ def test_has_active_promo_credits(self, mock_console):
226
+ """Test when user only has active promo credits."""
227
+ promo_credits = [
228
+ {
229
+ "remaining": 20,
230
+ "expiry_date": "2030-06-12T00:00:00+00:00",
202
231
  }
203
232
  ]
204
- _display_status_message(None, purchased_credits)
233
+ _display_status_message(None, [], promo_credits)
205
234
 
206
235
  # Should not print warning message
207
236
  mock_console.print.assert_not_called()
@@ -213,7 +242,7 @@ class TestDisplayStatusMessage:
213
242
  "remaining": 0,
214
243
  "period_end": "2028-12-01T00:00:00+00:00",
215
244
  }
216
- _display_status_message(plan_credits, [])
245
+ _display_status_message(plan_credits, [], [])
217
246
 
218
247
  mock_console.print.assert_called_once()
219
248
  call_args = mock_console.print.call_args[0][0]
@@ -226,7 +255,7 @@ class TestDisplayStatusMessage:
226
255
  "remaining": 10,
227
256
  "period_end": "2024-01-01T00:00:00+00:00",
228
257
  }
229
- _display_status_message(plan_credits, [])
258
+ _display_status_message(plan_credits, [], [])
230
259
 
231
260
  mock_console.print.assert_called_once()
232
261
  call_args = mock_console.print.call_args[0][0]
@@ -234,8 +263,8 @@ class TestDisplayStatusMessage:
234
263
 
235
264
  @patch("cli_output.status.console")
236
265
  def test_null_plan_credits_and_empty_purchased(self, mock_console):
237
- """Test when plan_credits is None and purchased_credits is empty."""
238
- _display_status_message(None, [])
266
+ """Test when plan_credits is None and purchased/promo credits are empty."""
267
+ _display_status_message(None, [], [])
239
268
 
240
269
  mock_console.print.assert_called_once()
241
270
  call_args = mock_console.print.call_args[0][0]
@@ -384,6 +413,83 @@ class TestPrintStatus:
384
413
  purchased_calls = [c for c in calls if "Purchased" in c]
385
414
  assert len(purchased_calls) == 2
386
415
 
416
+ @patch("cli_output.status.codeplain_api.CodeplainAPI")
417
+ @patch("cli_output.status.console")
418
+ def test_promo_credit_buckets(self, mock_console, mock_api_class):
419
+ """Test status display includes promo credit buckets."""
420
+ mock_api = Mock()
421
+ mock_api_class.return_value = mock_api
422
+ mock_api.connection_check.return_value = {
423
+ "client_version_valid": True,
424
+ "min_client_version": "0.3.0",
425
+ }
426
+ mock_api.status.return_value = {
427
+ "user": {
428
+ "first_name": "John",
429
+ "last_name": "Doe",
430
+ "email": "john@example.com",
431
+ },
432
+ "api_key_label": "test-key",
433
+ "organization_owner_email": "owner@example.com",
434
+ "plan_credits": None,
435
+ "purchased_credits": [
436
+ {
437
+ "total": 100,
438
+ "remaining": 50,
439
+ "expiry_date": "2030-06-12T00:00:00+00:00",
440
+ },
441
+ ],
442
+ "promo_credits": [
443
+ {
444
+ "total": 30,
445
+ "remaining": 15,
446
+ "expiry_date": "2030-12-31T00:00:00+00:00",
447
+ },
448
+ ],
449
+ }
450
+
451
+ print_status("fake-key", "http://localhost:5000", "0.3.0")
452
+
453
+ calls = [str(call) for call in mock_console.print.call_args_list]
454
+ promo_calls = [c for c in calls if "Promo" in c]
455
+ assert len(promo_calls) == 1
456
+ # Active credits remain, so no "no credits remaining" warning
457
+ assert not any("No rendering credits remaining" in c for c in calls)
458
+
459
+ @patch("cli_output.status.codeplain_api.CodeplainAPI")
460
+ @patch("cli_output.status.console")
461
+ def test_missing_promo_credits_key_is_backward_compatible(self, mock_console, mock_api_class):
462
+ """Test status display when API response omits promo_credits (older API)."""
463
+ mock_api = Mock()
464
+ mock_api_class.return_value = mock_api
465
+ mock_api.connection_check.return_value = {
466
+ "client_version_valid": True,
467
+ "min_client_version": "0.3.0",
468
+ }
469
+ mock_api.status.return_value = {
470
+ "user": {
471
+ "first_name": "John",
472
+ "last_name": "Doe",
473
+ "email": "john@example.com",
474
+ },
475
+ "api_key_label": "test-key",
476
+ "organization_owner_email": "owner@example.com",
477
+ "plan_credits": {
478
+ "type": "free",
479
+ "total": 50,
480
+ "remaining": 10,
481
+ "period_end": "2030-12-01T00:00:00+00:00",
482
+ },
483
+ "purchased_credits": [],
484
+ # promo_credits intentionally omitted
485
+ }
486
+
487
+ # Should not raise
488
+ print_status("fake-key", "http://localhost:5000", "0.3.0")
489
+
490
+ calls = [str(call) for call in mock_console.print.call_args_list]
491
+ assert not any("Promo" in c for c in calls)
492
+
387
493
 
388
494
  class TestVersionFlag:
389
495
  """Tests for --version flag."""
@@ -0,0 +1,204 @@
1
+ import json
2
+ import sys
3
+ from argparse import Namespace
4
+
5
+ import pytest
6
+ import sentry_sdk
7
+ from sentry_sdk.envelope import Envelope
8
+ from sentry_sdk.transport import Transport
9
+
10
+ import plain2code_telemetry
11
+ from plain2code_state import RunState
12
+ from plain2code_telemetry import NO_TELEMETRY_ENV_VAR, capture_crash, initialize_telemetry, telemetry_enabled
13
+
14
+
15
+ class CaptureTransport(Transport):
16
+ """Transport that records events instead of sending them over the network."""
17
+
18
+ def __init__(self, options=None):
19
+ super().__init__(options)
20
+ self.events = []
21
+
22
+ def capture_envelope(self, envelope: Envelope):
23
+ event = envelope.get_event()
24
+ if event is not None:
25
+ self.events.append(event)
26
+
27
+
28
+ def make_exc_info(exception):
29
+ try:
30
+ raise exception
31
+ except type(exception):
32
+ return sys.exc_info()
33
+
34
+
35
+ def make_args(**overrides):
36
+ args = Namespace(
37
+ headless=False,
38
+ unittests_script="run_unittests.sh",
39
+ conformance_tests_script=None,
40
+ prepare_environment_script=None,
41
+ )
42
+ for key, value in overrides.items():
43
+ setattr(args, key, value)
44
+ return args
45
+
46
+
47
+ @pytest.fixture(autouse=True)
48
+ def clean_telemetry_env(monkeypatch):
49
+ """Ensure tests are not affected by the developer's environment and never send real events."""
50
+ monkeypatch.delenv(NO_TELEMETRY_ENV_VAR, raising=False)
51
+ monkeypatch.delenv(plain2code_telemetry.ENVIRONMENT_ENV_VAR, raising=False)
52
+ yield
53
+ client = sentry_sdk.get_client()
54
+ if client.is_active():
55
+ client.close(timeout=0)
56
+
57
+
58
+ @pytest.fixture
59
+ def transport():
60
+ return CaptureTransport()
61
+
62
+
63
+ def init_with_transport(transport):
64
+ assert initialize_telemetry(transport=transport)
65
+
66
+
67
+ def test_no_telemetry_env_var_disables(monkeypatch, transport):
68
+ monkeypatch.setenv(NO_TELEMETRY_ENV_VAR, "1")
69
+
70
+ assert not telemetry_enabled()
71
+ assert not initialize_telemetry(transport=transport)
72
+ assert not capture_crash(make_exc_info(KeyError("boom")), None, make_args())
73
+ assert transport.events == []
74
+
75
+
76
+ def test_capture_crash_sends_event_with_tags(transport):
77
+ init_with_transport(transport)
78
+
79
+ run_state = RunState(spec_filename="test.plain")
80
+ run_state.current_module = "my_module"
81
+ run_state.current_frid = "2.1"
82
+ run_state.current_render_state = "IMPLEMENTING_FRID"
83
+
84
+ assert capture_crash(make_exc_info(KeyError("boom")), run_state, make_args(headless=True))
85
+ sentry_sdk.flush(timeout=2)
86
+
87
+ assert len(transport.events) == 1
88
+ tags = transport.events[0]["tags"]
89
+ assert tags["render_id"] == run_state.render_id
90
+ assert tags["current_module"] == "my_module"
91
+ assert tags["current_frid"] == "2.1"
92
+ assert tags["render_state"] == "IMPLEMENTING_FRID"
93
+ assert tags["headless"] is True
94
+ assert tags["unittests_script_provided"] is True
95
+ assert tags["conformance_tests_script_provided"] is False
96
+ assert tags["prepare_environment_script_provided"] is False
97
+
98
+
99
+ def test_capture_crash_without_run_state(transport):
100
+ init_with_transport(transport)
101
+
102
+ assert capture_crash(make_exc_info(ValueError("boom")), None, make_args())
103
+ sentry_sdk.flush(timeout=2)
104
+
105
+ assert len(transport.events) == 1
106
+ assert "render_id" not in transport.events[0]["tags"]
107
+
108
+
109
+ def test_local_variables_are_scrubbed(transport):
110
+ init_with_transport(transport)
111
+
112
+ def crash_with_sensitive_locals():
113
+ api_key = "super-secret-key" # noqa: F841
114
+ plain_source = "proprietary spec content" # noqa: F841
115
+ raise KeyError("boom")
116
+
117
+ try:
118
+ crash_with_sensitive_locals()
119
+ except KeyError:
120
+ exc_info = sys.exc_info()
121
+
122
+ assert capture_crash(exc_info, None, make_args())
123
+ sentry_sdk.flush(timeout=2)
124
+
125
+ frames = transport.events[0]["exception"]["values"][0]["stacktrace"]["frames"]
126
+ crash_frame_vars = frames[-1]["vars"]
127
+ assert crash_frame_vars["api_key"] == "[Filtered]"
128
+ assert crash_frame_vars["plain_source"] == "[Filtered]"
129
+
130
+
131
+ def test_request_headers_local_is_scrubbed(transport):
132
+ """The `headers` local in codeplain_REST_api.post_request holds the API key
133
+ (X-API-Key); the whole variable must be filtered from stack traces."""
134
+ init_with_transport(transport)
135
+
136
+ # Built at runtime so the secret never appears in the source-context lines
137
+ # that Sentry attaches to stack frames.
138
+ secret = "".join(["super", "-secret-", "key"])
139
+
140
+ def crash_with_headers_local():
141
+ headers = {"X-API-Key": secret, "Content-Type": "application/json"} # noqa: F841
142
+ raise KeyError("boom")
143
+
144
+ try:
145
+ crash_with_headers_local()
146
+ except KeyError:
147
+ exc_info = sys.exc_info()
148
+
149
+ assert capture_crash(exc_info, None, make_args())
150
+ sentry_sdk.flush(timeout=2)
151
+
152
+ frames = transport.events[0]["exception"]["values"][0]["stacktrace"]["frames"]
153
+ crash_frame_vars = frames[-1]["vars"]
154
+ assert crash_frame_vars["headers"] == "[Filtered]"
155
+ assert secret not in json.dumps(transport.events[0], default=str)
156
+
157
+
158
+ def test_nested_sensitive_keys_are_scrubbed(transport):
159
+ """Scrubbing is recursive: sensitive keys nested inside dict locals are
160
+ filtered even when the variable name itself is innocuous."""
161
+ init_with_transport(transport)
162
+
163
+ secret = "".join(["nested", "-secret-", "key"])
164
+
165
+ def crash_with_nested_secret():
166
+ request_info = {"url": "https://api.codeplain.ai", "x-api-key": secret} # noqa: F841
167
+ raise KeyError("boom")
168
+
169
+ try:
170
+ crash_with_nested_secret()
171
+ except KeyError:
172
+ exc_info = sys.exc_info()
173
+
174
+ assert capture_crash(exc_info, None, make_args())
175
+ sentry_sdk.flush(timeout=2)
176
+
177
+ assert secret not in json.dumps(transport.events[0], default=str)
178
+
179
+
180
+ def test_environment_defaults_to_production(transport):
181
+ init_with_transport(transport)
182
+ assert sentry_sdk.get_client().options["environment"] == "production"
183
+
184
+
185
+ def test_environment_env_var_respected(monkeypatch, transport):
186
+ monkeypatch.setenv(plain2code_telemetry.ENVIRONMENT_ENV_VAR, "development")
187
+ init_with_transport(transport)
188
+ assert sentry_sdk.get_client().options["environment"] == "development"
189
+
190
+
191
+ def test_release_is_client_version(transport):
192
+ from system_config import system_config
193
+
194
+ init_with_transport(transport)
195
+ assert sentry_sdk.get_client().options["release"] == system_config.client_version
196
+
197
+
198
+ def test_capture_crash_never_raises(monkeypatch):
199
+ monkeypatch.setattr(
200
+ sentry_sdk, "capture_exception", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("sdk broken"))
201
+ )
202
+ init_with_transport(CaptureTransport())
203
+
204
+ assert capture_crash(make_exc_info(KeyError("boom")), None, make_args()) is False