hatch-xclam 0.7.1.dev3__py3-none-any.whl → 0.8.0.dev1__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.
Files changed (81) hide show
  1. hatch/__init__.py +1 -1
  2. hatch/cli/__init__.py +71 -0
  3. hatch/cli/__main__.py +1035 -0
  4. hatch/cli/cli_env.py +865 -0
  5. hatch/cli/cli_mcp.py +1965 -0
  6. hatch/cli/cli_package.py +566 -0
  7. hatch/cli/cli_system.py +136 -0
  8. hatch/cli/cli_utils.py +1289 -0
  9. hatch/cli_hatch.py +160 -2838
  10. hatch/mcp_host_config/__init__.py +10 -10
  11. hatch/mcp_host_config/adapters/__init__.py +34 -0
  12. hatch/mcp_host_config/adapters/base.py +170 -0
  13. hatch/mcp_host_config/adapters/claude.py +105 -0
  14. hatch/mcp_host_config/adapters/codex.py +104 -0
  15. hatch/mcp_host_config/adapters/cursor.py +83 -0
  16. hatch/mcp_host_config/adapters/gemini.py +75 -0
  17. hatch/mcp_host_config/adapters/kiro.py +78 -0
  18. hatch/mcp_host_config/adapters/lmstudio.py +79 -0
  19. hatch/mcp_host_config/adapters/registry.py +149 -0
  20. hatch/mcp_host_config/adapters/vscode.py +83 -0
  21. hatch/mcp_host_config/backup.py +5 -3
  22. hatch/mcp_host_config/fields.py +126 -0
  23. hatch/mcp_host_config/models.py +161 -456
  24. hatch/mcp_host_config/reporting.py +57 -16
  25. hatch/mcp_host_config/strategies.py +155 -87
  26. hatch/template_generator.py +1 -1
  27. {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/METADATA +3 -2
  28. {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/RECORD +52 -43
  29. {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/WHEEL +1 -1
  30. hatch_xclam-0.8.0.dev1.dist-info/entry_points.txt +2 -0
  31. tests/cli_test_utils.py +280 -0
  32. tests/integration/cli/__init__.py +14 -0
  33. tests/integration/cli/test_cli_reporter_integration.py +2439 -0
  34. tests/integration/mcp/__init__.py +0 -0
  35. tests/integration/mcp/test_adapter_serialization.py +173 -0
  36. tests/regression/cli/__init__.py +16 -0
  37. tests/regression/cli/test_color_logic.py +268 -0
  38. tests/regression/cli/test_consequence_type.py +298 -0
  39. tests/regression/cli/test_error_formatting.py +328 -0
  40. tests/regression/cli/test_result_reporter.py +586 -0
  41. tests/regression/cli/test_table_formatter.py +211 -0
  42. tests/regression/mcp/__init__.py +0 -0
  43. tests/regression/mcp/test_field_filtering.py +162 -0
  44. tests/test_cli_version.py +7 -5
  45. tests/test_data/fixtures/cli_reporter_fixtures.py +184 -0
  46. tests/unit/__init__.py +0 -0
  47. tests/unit/mcp/__init__.py +0 -0
  48. tests/unit/mcp/test_adapter_protocol.py +138 -0
  49. tests/unit/mcp/test_adapter_registry.py +158 -0
  50. tests/unit/mcp/test_config_model.py +146 -0
  51. hatch_xclam-0.7.1.dev3.dist-info/entry_points.txt +0 -2
  52. tests/integration/test_mcp_kiro_integration.py +0 -153
  53. tests/regression/test_mcp_codex_backup_integration.py +0 -162
  54. tests/regression/test_mcp_codex_host_strategy.py +0 -163
  55. tests/regression/test_mcp_codex_model_validation.py +0 -117
  56. tests/regression/test_mcp_kiro_backup_integration.py +0 -241
  57. tests/regression/test_mcp_kiro_cli_integration.py +0 -141
  58. tests/regression/test_mcp_kiro_decorator_registration.py +0 -71
  59. tests/regression/test_mcp_kiro_host_strategy.py +0 -214
  60. tests/regression/test_mcp_kiro_model_validation.py +0 -116
  61. tests/regression/test_mcp_kiro_omni_conversion.py +0 -104
  62. tests/test_mcp_atomic_operations.py +0 -276
  63. tests/test_mcp_backup_integration.py +0 -308
  64. tests/test_mcp_cli_all_host_specific_args.py +0 -496
  65. tests/test_mcp_cli_backup_management.py +0 -295
  66. tests/test_mcp_cli_direct_management.py +0 -456
  67. tests/test_mcp_cli_discovery_listing.py +0 -582
  68. tests/test_mcp_cli_host_config_integration.py +0 -823
  69. tests/test_mcp_cli_package_management.py +0 -360
  70. tests/test_mcp_cli_partial_updates.py +0 -859
  71. tests/test_mcp_environment_integration.py +0 -520
  72. tests/test_mcp_host_config_backup.py +0 -257
  73. tests/test_mcp_host_configuration_manager.py +0 -331
  74. tests/test_mcp_host_registry_decorator.py +0 -348
  75. tests/test_mcp_pydantic_architecture_v4.py +0 -603
  76. tests/test_mcp_server_config_models.py +0 -242
  77. tests/test_mcp_server_config_type_field.py +0 -221
  78. tests/test_mcp_sync_functionality.py +0 -316
  79. tests/test_mcp_user_feedback_reporting.py +0 -359
  80. {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/licenses/LICENSE +0 -0
  81. {hatch_xclam-0.7.1.dev3.dist-info → hatch_xclam-0.8.0.dev1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,586 @@
1
+ """Regression tests for Consequence dataclass and ResultReporter class.
2
+
3
+ This module tests:
4
+ - Consequence dataclass invariants and nesting support
5
+ - ResultReporter state management and consequence tracking
6
+ - ResultReporter mode flags (dry_run, command_name)
7
+
8
+ Reference: R05 §3.3 (05-test_definition_v0.md) - Nested Consequence Invariants
9
+ Reference: R05 §3.2 (05-test_definition_v0.md) - ResultReporter State Management
10
+ Reference: R06 §3.3, §3.4 (06-dependency_analysis_v0.md)
11
+ """
12
+
13
+ import unittest
14
+
15
+
16
+ class TestConsequence(unittest.TestCase):
17
+ """Tests for Consequence dataclass invariants.
18
+
19
+ Reference: R06 §3.3 - Consequence interface contract
20
+ Reference: R04 §5.1 - Consequence data model invariants
21
+ """
22
+
23
+ def test_consequence_dataclass_exists(self):
24
+ """Consequence dataclass should be importable from cli_utils."""
25
+ from hatch.cli.cli_utils import Consequence
26
+ self.assertTrue(hasattr(Consequence, '__dataclass_fields__'))
27
+
28
+ def test_consequence_accepts_type_and_message(self):
29
+ """Consequence should accept type and message arguments."""
30
+ from hatch.cli.cli_utils import Consequence, ConsequenceType
31
+
32
+ c = Consequence(type=ConsequenceType.CREATE, message="Test resource")
33
+ self.assertEqual(c.type, ConsequenceType.CREATE)
34
+ self.assertEqual(c.message, "Test resource")
35
+
36
+ def test_consequence_accepts_children_list(self):
37
+ """Consequence should accept children list argument."""
38
+ from hatch.cli.cli_utils import Consequence, ConsequenceType
39
+
40
+ child1 = Consequence(type=ConsequenceType.UPDATE, message="field1: a → b")
41
+ child2 = Consequence(type=ConsequenceType.SKIP, message="field2: unsupported")
42
+
43
+ parent = Consequence(
44
+ type=ConsequenceType.CONFIGURE,
45
+ message="Server 'test'",
46
+ children=[child1, child2]
47
+ )
48
+
49
+ self.assertEqual(len(parent.children), 2)
50
+ self.assertEqual(parent.children[0], child1)
51
+ self.assertEqual(parent.children[1], child2)
52
+
53
+ def test_consequence_default_children_is_empty_list(self):
54
+ """Consequence should have empty list as default children."""
55
+ from hatch.cli.cli_utils import Consequence, ConsequenceType
56
+
57
+ c = Consequence(type=ConsequenceType.CREATE, message="Test")
58
+ self.assertEqual(c.children, [])
59
+ self.assertIsInstance(c.children, list)
60
+
61
+ def test_consequence_children_are_consequence_instances(self):
62
+ """Children should be Consequence instances."""
63
+ from hatch.cli.cli_utils import Consequence, ConsequenceType
64
+
65
+ child = Consequence(type=ConsequenceType.UPDATE, message="child")
66
+ parent = Consequence(
67
+ type=ConsequenceType.CONFIGURE,
68
+ message="parent",
69
+ children=[child]
70
+ )
71
+
72
+ self.assertIsInstance(parent.children[0], Consequence)
73
+
74
+ def test_consequence_children_default_not_shared(self):
75
+ """Each Consequence should have its own children list (no shared mutable default)."""
76
+ from hatch.cli.cli_utils import Consequence, ConsequenceType
77
+
78
+ c1 = Consequence(type=ConsequenceType.CREATE, message="First")
79
+ c2 = Consequence(type=ConsequenceType.CREATE, message="Second")
80
+
81
+ # Modify c1's children
82
+ c1.children.append(Consequence(type=ConsequenceType.UPDATE, message="child"))
83
+
84
+ # c2's children should still be empty
85
+ self.assertEqual(len(c2.children), 0)
86
+
87
+
88
+ class TestResultReporter(unittest.TestCase):
89
+ """Tests for ResultReporter state management.
90
+
91
+ Reference: R05 §3.2 - ResultReporter State Management test group
92
+ Reference: R06 §3.4 - ResultReporter interface contract
93
+ """
94
+
95
+ def test_result_reporter_exists(self):
96
+ """ResultReporter class should be importable from cli_utils."""
97
+ from hatch.cli.cli_utils import ResultReporter
98
+ self.assertTrue(callable(ResultReporter))
99
+
100
+ def test_result_reporter_accepts_command_name(self):
101
+ """ResultReporter should accept command_name argument."""
102
+ from hatch.cli.cli_utils import ResultReporter
103
+
104
+ reporter = ResultReporter(command_name="hatch env create")
105
+ self.assertEqual(reporter.command_name, "hatch env create")
106
+
107
+ def test_result_reporter_command_name_stored(self):
108
+ """ResultReporter should store command_name correctly."""
109
+ from hatch.cli.cli_utils import ResultReporter
110
+
111
+ reporter = ResultReporter("test-cmd")
112
+ self.assertEqual(reporter.command_name, "test-cmd")
113
+
114
+ def test_result_reporter_dry_run_default_false(self):
115
+ """ResultReporter dry_run should default to False."""
116
+ from hatch.cli.cli_utils import ResultReporter
117
+
118
+ reporter = ResultReporter("test")
119
+ self.assertFalse(reporter.dry_run)
120
+
121
+ def test_result_reporter_dry_run_stored(self):
122
+ """ResultReporter should store dry_run flag correctly."""
123
+ from hatch.cli.cli_utils import ResultReporter
124
+
125
+ reporter = ResultReporter("test", dry_run=True)
126
+ self.assertTrue(reporter.dry_run)
127
+
128
+ def test_result_reporter_empty_consequences(self):
129
+ """Empty reporter should have empty consequences list."""
130
+ from hatch.cli.cli_utils import ResultReporter
131
+
132
+ reporter = ResultReporter("test")
133
+ self.assertEqual(reporter.consequences, [])
134
+ self.assertIsInstance(reporter.consequences, list)
135
+
136
+ def test_result_reporter_add_consequence(self):
137
+ """ResultReporter.add() should add consequence to list."""
138
+ from hatch.cli.cli_utils import ResultReporter, ConsequenceType
139
+
140
+ reporter = ResultReporter("test")
141
+ reporter.add(ConsequenceType.CREATE, "Environment 'dev'")
142
+
143
+ self.assertEqual(len(reporter.consequences), 1)
144
+
145
+ def test_result_reporter_consequences_tracked_in_order(self):
146
+ """Consequences should be tracked in order of add() calls."""
147
+ from hatch.cli.cli_utils import ResultReporter, ConsequenceType
148
+
149
+ reporter = ResultReporter("test")
150
+ reporter.add(ConsequenceType.CREATE, "First")
151
+ reporter.add(ConsequenceType.REMOVE, "Second")
152
+ reporter.add(ConsequenceType.UPDATE, "Third")
153
+
154
+ self.assertEqual(len(reporter.consequences), 3)
155
+ self.assertEqual(reporter.consequences[0].message, "First")
156
+ self.assertEqual(reporter.consequences[1].message, "Second")
157
+ self.assertEqual(reporter.consequences[2].message, "Third")
158
+
159
+ def test_result_reporter_consequence_data_preserved(self):
160
+ """Consequence type and message should be preserved."""
161
+ from hatch.cli.cli_utils import ResultReporter, ConsequenceType
162
+
163
+ reporter = ResultReporter("test")
164
+ reporter.add(ConsequenceType.CONFIGURE, "Server 'weather'")
165
+
166
+ c = reporter.consequences[0]
167
+ self.assertEqual(c.type, ConsequenceType.CONFIGURE)
168
+ self.assertEqual(c.message, "Server 'weather'")
169
+
170
+ def test_result_reporter_add_with_children(self):
171
+ """ResultReporter.add() should support children argument."""
172
+ from hatch.cli.cli_utils import ResultReporter, ConsequenceType, Consequence
173
+
174
+ reporter = ResultReporter("test")
175
+ children = [
176
+ Consequence(type=ConsequenceType.UPDATE, message="field1"),
177
+ Consequence(type=ConsequenceType.SKIP, message="field2"),
178
+ ]
179
+ reporter.add(ConsequenceType.CONFIGURE, "Server", children=children)
180
+
181
+ self.assertEqual(len(reporter.consequences[0].children), 2)
182
+
183
+
184
+ if __name__ == '__main__':
185
+ unittest.main()
186
+
187
+
188
+ class TestConversionReportIntegration(unittest.TestCase):
189
+ """Tests for ConversionReport → ResultReporter integration.
190
+
191
+ Reference: R05 §3.5 - ConversionReport Integration test group
192
+ Reference: R06 §3.5 - add_from_conversion_report interface
193
+ Reference: R04 §1.2 - field operation → ConsequenceType mapping
194
+ """
195
+
196
+ def test_add_from_conversion_report_method_exists(self):
197
+ """ResultReporter should have add_from_conversion_report method."""
198
+ from hatch.cli.cli_utils import ResultReporter
199
+
200
+ reporter = ResultReporter("test")
201
+ self.assertTrue(hasattr(reporter, 'add_from_conversion_report'))
202
+ self.assertTrue(callable(reporter.add_from_conversion_report))
203
+
204
+ def test_updated_maps_to_update_type(self):
205
+ """FieldOperation 'UPDATED' should map to ConsequenceType.UPDATE."""
206
+ from hatch.cli.cli_utils import ResultReporter, ConsequenceType
207
+ from tests.test_data.fixtures.cli_reporter_fixtures import REPORT_SINGLE_UPDATE
208
+
209
+ reporter = ResultReporter("test")
210
+ reporter.add_from_conversion_report(REPORT_SINGLE_UPDATE)
211
+
212
+ # Should have one resource consequence with one child
213
+ self.assertEqual(len(reporter.consequences), 1)
214
+ self.assertEqual(len(reporter.consequences[0].children), 1)
215
+ self.assertEqual(reporter.consequences[0].children[0].type, ConsequenceType.UPDATE)
216
+
217
+ def test_unsupported_maps_to_skip_type(self):
218
+ """FieldOperation 'UNSUPPORTED' should map to ConsequenceType.SKIP."""
219
+ from hatch.cli.cli_utils import ResultReporter, ConsequenceType
220
+ from tests.test_data.fixtures.cli_reporter_fixtures import REPORT_ALL_UNSUPPORTED
221
+
222
+ reporter = ResultReporter("test")
223
+ reporter.add_from_conversion_report(REPORT_ALL_UNSUPPORTED)
224
+
225
+ # All children should be SKIP type
226
+ for child in reporter.consequences[0].children:
227
+ self.assertEqual(child.type, ConsequenceType.SKIP)
228
+
229
+ def test_unchanged_maps_to_unchanged_type(self):
230
+ """FieldOperation 'UNCHANGED' should map to ConsequenceType.UNCHANGED."""
231
+ from hatch.cli.cli_utils import ResultReporter, ConsequenceType
232
+ from tests.test_data.fixtures.cli_reporter_fixtures import REPORT_ALL_UNCHANGED
233
+
234
+ reporter = ResultReporter("test")
235
+ reporter.add_from_conversion_report(REPORT_ALL_UNCHANGED)
236
+
237
+ # All children should be UNCHANGED type
238
+ for child in reporter.consequences[0].children:
239
+ self.assertEqual(child.type, ConsequenceType.UNCHANGED)
240
+
241
+ def test_field_name_preserved_in_mapping(self):
242
+ """Field name should be preserved in consequence message."""
243
+ from hatch.cli.cli_utils import ResultReporter
244
+ from tests.test_data.fixtures.cli_reporter_fixtures import REPORT_SINGLE_UPDATE
245
+
246
+ reporter = ResultReporter("test")
247
+ reporter.add_from_conversion_report(REPORT_SINGLE_UPDATE)
248
+
249
+ child_message = reporter.consequences[0].children[0].message
250
+ self.assertIn("command", child_message)
251
+
252
+ def test_old_new_values_preserved(self):
253
+ """Old and new values should be preserved in consequence message."""
254
+ from hatch.cli.cli_utils import ResultReporter
255
+ from tests.test_data.fixtures.cli_reporter_fixtures import REPORT_MIXED_OPERATIONS
256
+
257
+ reporter = ResultReporter("test")
258
+ reporter.add_from_conversion_report(REPORT_MIXED_OPERATIONS)
259
+
260
+ # Find the command field child (first one with UPDATED)
261
+ command_child = reporter.consequences[0].children[0]
262
+ self.assertIn("node", command_child.message) # old value
263
+ self.assertIn("python", command_child.message) # new value
264
+
265
+ def test_all_fields_mapped_no_data_loss(self):
266
+ """All field operations should be mapped (no data loss)."""
267
+ from hatch.cli.cli_utils import ResultReporter
268
+ from tests.test_data.fixtures.cli_reporter_fixtures import REPORT_MIXED_OPERATIONS
269
+
270
+ reporter = ResultReporter("test")
271
+ reporter.add_from_conversion_report(REPORT_MIXED_OPERATIONS)
272
+
273
+ # REPORT_MIXED_OPERATIONS has 4 field operations
274
+ self.assertEqual(len(reporter.consequences[0].children), 4)
275
+
276
+ def test_empty_conversion_report_handled(self):
277
+ """Empty ConversionReport should not raise exception."""
278
+ from hatch.cli.cli_utils import ResultReporter
279
+ from tests.test_data.fixtures.cli_reporter_fixtures import REPORT_EMPTY_FIELDS
280
+
281
+ reporter = ResultReporter("test")
282
+ # Should not raise
283
+ reporter.add_from_conversion_report(REPORT_EMPTY_FIELDS)
284
+
285
+ # Should have resource consequence with no children
286
+ self.assertEqual(len(reporter.consequences), 1)
287
+ self.assertEqual(len(reporter.consequences[0].children), 0)
288
+
289
+ def test_resource_consequence_type_from_operation(self):
290
+ """Resource consequence type should be derived from report.operation."""
291
+ from hatch.cli.cli_utils import ResultReporter, ConsequenceType
292
+ from tests.test_data.fixtures.cli_reporter_fixtures import (
293
+ REPORT_SINGLE_UPDATE, # operation="create"
294
+ REPORT_MIXED_OPERATIONS, # operation="update"
295
+ )
296
+
297
+ reporter1 = ResultReporter("test")
298
+ reporter1.add_from_conversion_report(REPORT_SINGLE_UPDATE)
299
+ # "create" operation should map to CONFIGURE (for MCP server creation)
300
+ self.assertIn(
301
+ reporter1.consequences[0].type,
302
+ [ConsequenceType.CONFIGURE, ConsequenceType.CREATE]
303
+ )
304
+
305
+ reporter2 = ResultReporter("test")
306
+ reporter2.add_from_conversion_report(REPORT_MIXED_OPERATIONS)
307
+ # "update" operation should map to CONFIGURE or UPDATE
308
+ self.assertIn(
309
+ reporter2.consequences[0].type,
310
+ [ConsequenceType.CONFIGURE, ConsequenceType.UPDATE]
311
+ )
312
+
313
+ def test_server_name_in_resource_message(self):
314
+ """Server name should appear in resource consequence message."""
315
+ from hatch.cli.cli_utils import ResultReporter
316
+ from tests.test_data.fixtures.cli_reporter_fixtures import REPORT_MIXED_OPERATIONS
317
+
318
+ reporter = ResultReporter("test")
319
+ reporter.add_from_conversion_report(REPORT_MIXED_OPERATIONS)
320
+
321
+ self.assertIn("weather-server", reporter.consequences[0].message)
322
+
323
+ def test_target_host_in_resource_message(self):
324
+ """Target host should appear in resource consequence message."""
325
+ from hatch.cli.cli_utils import ResultReporter
326
+ from tests.test_data.fixtures.cli_reporter_fixtures import REPORT_MIXED_OPERATIONS
327
+
328
+ reporter = ResultReporter("test")
329
+ reporter.add_from_conversion_report(REPORT_MIXED_OPERATIONS)
330
+
331
+ self.assertIn("cursor", reporter.consequences[0].message.lower())
332
+
333
+
334
+ class TestReportError(unittest.TestCase):
335
+ """Tests for ResultReporter.report_error() method.
336
+
337
+ Reference: R13 §4.2.3 (13-error_message_formatting_v0.md)
338
+ Reference: R13 §7 - Contracts & Invariants
339
+ """
340
+
341
+ def test_report_error_basic(self):
342
+ """report_error should print [ERROR] prefix with summary."""
343
+ from hatch.cli.cli_utils import ResultReporter
344
+ import io
345
+ import sys
346
+
347
+ reporter = ResultReporter("test")
348
+
349
+ # Capture stdout
350
+ captured = io.StringIO()
351
+ sys.stdout = captured
352
+ try:
353
+ reporter.report_error("Test error message")
354
+ finally:
355
+ sys.stdout = sys.__stdout__
356
+
357
+ output = captured.getvalue()
358
+ self.assertIn("[ERROR]", output)
359
+ self.assertIn("Test error message", output)
360
+
361
+ def test_report_error_with_details(self):
362
+ """report_error should print details with indentation."""
363
+ from hatch.cli.cli_utils import ResultReporter
364
+ import io
365
+ import sys
366
+
367
+ reporter = ResultReporter("test")
368
+
369
+ captured = io.StringIO()
370
+ sys.stdout = captured
371
+ try:
372
+ reporter.report_error("Summary", details=["Detail 1", "Detail 2"])
373
+ finally:
374
+ sys.stdout = sys.__stdout__
375
+
376
+ output = captured.getvalue()
377
+ self.assertIn("Detail 1", output)
378
+ self.assertIn("Detail 2", output)
379
+ # Details should be indented (2 spaces)
380
+ self.assertIn(" Detail 1", output)
381
+
382
+ def test_report_error_empty_summary_no_output(self):
383
+ """report_error with empty summary should produce no output."""
384
+ from hatch.cli.cli_utils import ResultReporter
385
+ import io
386
+ import sys
387
+
388
+ reporter = ResultReporter("test")
389
+
390
+ captured = io.StringIO()
391
+ sys.stdout = captured
392
+ try:
393
+ reporter.report_error("")
394
+ finally:
395
+ sys.stdout = sys.__stdout__
396
+
397
+ output = captured.getvalue()
398
+ self.assertEqual(output, "")
399
+
400
+ def test_report_error_no_color_in_non_tty(self):
401
+ """report_error should not include ANSI codes when not in TTY."""
402
+ from hatch.cli.cli_utils import ResultReporter
403
+ import io
404
+ import sys
405
+
406
+ reporter = ResultReporter("test")
407
+
408
+ # StringIO is not a TTY, so colors should be disabled
409
+ captured = io.StringIO()
410
+ sys.stdout = captured
411
+ try:
412
+ reporter.report_error("Test error")
413
+ finally:
414
+ sys.stdout = sys.__stdout__
415
+
416
+ output = captured.getvalue()
417
+ # Should not contain ANSI escape codes
418
+ self.assertNotIn("\033[", output)
419
+
420
+ def test_report_error_none_details_handled(self):
421
+ """report_error should handle None details gracefully."""
422
+ from hatch.cli.cli_utils import ResultReporter
423
+ import io
424
+ import sys
425
+
426
+ reporter = ResultReporter("test")
427
+
428
+ captured = io.StringIO()
429
+ sys.stdout = captured
430
+ try:
431
+ reporter.report_error("Test error", details=None)
432
+ finally:
433
+ sys.stdout = sys.__stdout__
434
+
435
+ output = captured.getvalue()
436
+ self.assertIn("[ERROR]", output)
437
+ self.assertIn("Test error", output)
438
+
439
+
440
+ class TestReportPartialSuccess(unittest.TestCase):
441
+ """Tests for ResultReporter.report_partial_success() method.
442
+
443
+ Reference: R13 §4.2.3 (13-error_message_formatting_v0.md)
444
+ Reference: R13 §7 - Contracts & Invariants
445
+ """
446
+
447
+ def test_report_partial_success_basic(self):
448
+ """report_partial_success should print [WARNING] prefix."""
449
+ from hatch.cli.cli_utils import ResultReporter
450
+ import io
451
+ import sys
452
+
453
+ reporter = ResultReporter("test")
454
+
455
+ captured = io.StringIO()
456
+ sys.stdout = captured
457
+ try:
458
+ reporter.report_partial_success("Test summary", ["ok"], [("fail", "reason")])
459
+ finally:
460
+ sys.stdout = sys.__stdout__
461
+
462
+ output = captured.getvalue()
463
+ self.assertIn("[WARNING]", output)
464
+ self.assertIn("Test summary", output)
465
+
466
+ def test_report_partial_success_unicode_symbols(self):
467
+ """report_partial_success should use ✓/✗ symbols in UTF-8 terminals."""
468
+ from hatch.cli.cli_utils import ResultReporter, _supports_unicode
469
+ import io
470
+ import sys
471
+
472
+ reporter = ResultReporter("test")
473
+
474
+ captured = io.StringIO()
475
+ sys.stdout = captured
476
+ try:
477
+ reporter.report_partial_success("Test", ["success"], [("fail", "reason")])
478
+ finally:
479
+ sys.stdout = sys.__stdout__
480
+
481
+ output = captured.getvalue()
482
+ if _supports_unicode():
483
+ self.assertIn("✓", output)
484
+ self.assertIn("✗", output)
485
+ else:
486
+ self.assertIn("+", output)
487
+ self.assertIn("x", output)
488
+
489
+ def test_report_partial_success_ascii_fallback(self):
490
+ """report_partial_success should use +/x in non-UTF8 terminals."""
491
+ from hatch.cli.cli_utils import ResultReporter
492
+ import io
493
+ import sys
494
+ import unittest.mock as mock
495
+
496
+ reporter = ResultReporter("test")
497
+
498
+ captured = io.StringIO()
499
+ sys.stdout = captured
500
+ try:
501
+ # Mock _supports_unicode to return False
502
+ with mock.patch('hatch.cli.cli_utils._supports_unicode', return_value=False):
503
+ reporter.report_partial_success("Test", ["success"], [("fail", "reason")])
504
+ finally:
505
+ sys.stdout = sys.__stdout__
506
+
507
+ output = captured.getvalue()
508
+ self.assertIn("+", output)
509
+ self.assertIn("x", output)
510
+
511
+ def test_report_partial_success_summary_line(self):
512
+ """report_partial_success should include summary line with counts."""
513
+ from hatch.cli.cli_utils import ResultReporter
514
+ import io
515
+ import sys
516
+
517
+ reporter = ResultReporter("test")
518
+
519
+ captured = io.StringIO()
520
+ sys.stdout = captured
521
+ try:
522
+ reporter.report_partial_success(
523
+ "Test",
524
+ ["ok1", "ok2"],
525
+ [("fail1", "r1"), ("fail2", "r2"), ("fail3", "r3")]
526
+ )
527
+ finally:
528
+ sys.stdout = sys.__stdout__
529
+
530
+ output = captured.getvalue()
531
+ self.assertIn("Summary: 2/5 succeeded", output)
532
+
533
+ def test_report_partial_success_no_color_in_non_tty(self):
534
+ """report_partial_success should not include ANSI codes when not in TTY."""
535
+ from hatch.cli.cli_utils import ResultReporter
536
+ import io
537
+ import sys
538
+
539
+ reporter = ResultReporter("test")
540
+
541
+ captured = io.StringIO()
542
+ sys.stdout = captured
543
+ try:
544
+ reporter.report_partial_success("Test", ["ok"], [("fail", "reason")])
545
+ finally:
546
+ sys.stdout = sys.__stdout__
547
+
548
+ output = captured.getvalue()
549
+ self.assertNotIn("\033[", output)
550
+
551
+ def test_report_partial_success_failure_reason_shown(self):
552
+ """report_partial_success should show failure reason after colon."""
553
+ from hatch.cli.cli_utils import ResultReporter
554
+ import io
555
+ import sys
556
+
557
+ reporter = ResultReporter("test")
558
+
559
+ captured = io.StringIO()
560
+ sys.stdout = captured
561
+ try:
562
+ reporter.report_partial_success("Test", [], [("cursor", "Config file not found")])
563
+ finally:
564
+ sys.stdout = sys.__stdout__
565
+
566
+ output = captured.getvalue()
567
+ self.assertIn("cursor: Config file not found", output)
568
+
569
+ def test_report_partial_success_empty_lists(self):
570
+ """report_partial_success should handle empty success/failure lists."""
571
+ from hatch.cli.cli_utils import ResultReporter
572
+ import io
573
+ import sys
574
+
575
+ reporter = ResultReporter("test")
576
+
577
+ captured = io.StringIO()
578
+ sys.stdout = captured
579
+ try:
580
+ reporter.report_partial_success("Test", [], [])
581
+ finally:
582
+ sys.stdout = sys.__stdout__
583
+
584
+ output = captured.getvalue()
585
+ self.assertIn("[WARNING]", output)
586
+ self.assertIn("Summary: 0/0 succeeded", output)