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 +1 -1
- cli_output/status.py +14 -9
- {codeplain-0.3.2.dist-info → codeplain-0.3.3.dist-info}/METADATA +8 -2
- {codeplain-0.3.2.dist-info → codeplain-0.3.3.dist-info}/RECORD +16 -14
- plain2code.py +28 -23
- plain2code_state.py +4 -0
- plain2code_telemetry.py +114 -0
- render_machine/code_renderer.py +2 -0
- render_machine/render_context.py +23 -44
- render_machine/state_machine_config.py +22 -63
- render_machine/triggers.py +1 -1
- tests/test_cli_output.py +119 -13
- tests/test_telemetry.py +204 -0
- {codeplain-0.3.2.dist-info → codeplain-0.3.3.dist-info}/WHEEL +0 -0
- {codeplain-0.3.2.dist-info → codeplain-0.3.3.dist-info}/entry_points.txt +0 -0
- {codeplain-0.3.2.dist-info → codeplain-0.3.3.dist-info}/licenses/LICENSE +0 -0
_version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.3.
|
|
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
|
|
54
|
-
"""Display a
|
|
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"
|
|
74
|
+
console.print(f" {label:10} {bar} expired {formatted_date}")
|
|
75
75
|
else:
|
|
76
|
-
console.print(f"
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
91
|
-
codeplain-0.3.
|
|
92
|
-
codeplain-0.3.
|
|
93
|
-
codeplain-0.3.
|
|
94
|
-
codeplain-0.3.
|
|
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
|
plain2code_telemetry.py
ADDED
|
@@ -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
|
render_machine/code_renderer.py
CHANGED
|
@@ -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
|
render_machine/render_context.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
289
|
+
on_limit_exceeded()
|
|
305
290
|
|
|
306
|
-
def
|
|
307
|
-
self.
|
|
291
|
+
def _on_unit_test_limit_exceeded_in_implementation(self):
|
|
292
|
+
self.machine.dispatch(triggers.RESTART_FRID_PROCESSING)
|
|
308
293
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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(
|
|
141
|
-
|
|
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":
|
|
158
|
-
"on_exit":
|
|
159
|
-
"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(
|
|
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(
|
|
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
|
|
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(
|
|
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,
|
render_machine/triggers.py
CHANGED
|
@@ -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"
|
|
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
|
|
136
|
-
"""Tests for
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
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,
|
|
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
|
|
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."""
|
tests/test_telemetry.py
ADDED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|