• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

localstack / localstack / 16820655284

07 Aug 2025 05:03PM UTC coverage: 86.841% (-0.05%) from 86.892%
16820655284

push

github

web-flow
CFNV2: support CDK bootstrap and deployment (#12967)

32 of 38 new or added lines in 5 files covered. (84.21%)

2013 existing lines in 125 files now uncovered.

66606 of 76699 relevant lines covered (86.84%)

0.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.33
/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py
1
import copy
1✔
2
import logging
1✔
3
import uuid
1✔
4
from dataclasses import dataclass
1✔
5
from datetime import UTC, datetime
1✔
6
from typing import Final, Protocol
1✔
7

8
from localstack import config
1✔
9
from localstack.aws.api.cloudformation import (
1✔
10
    ChangeAction,
11
    ResourceStatus,
12
    StackStatus,
13
)
14
from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY
1✔
15
from localstack.services.cloudformation.analytics import track_resource_operation
1✔
16
from localstack.services.cloudformation.deployment_utils import log_not_available_message
1✔
17
from localstack.services.cloudformation.engine.template_deployer import REGEX_OUTPUT_APIGATEWAY
1✔
18
from localstack.services.cloudformation.engine.v2.change_set_model import (
1✔
19
    NodeDependsOn,
20
    NodeOutput,
21
    NodeResource,
22
    TerminalValueCreated,
23
    TerminalValueModified,
24
    TerminalValueUnchanged,
25
    is_nothing,
26
)
27
from localstack.services.cloudformation.engine.v2.change_set_model_preproc import (
1✔
28
    MOCKED_REFERENCE,
29
    ChangeSetModelPreproc,
30
    PreprocEntityDelta,
31
    PreprocOutput,
32
    PreprocProperties,
33
    PreprocResource,
34
)
35
from localstack.services.cloudformation.resource_provider import (
1✔
36
    Credentials,
37
    OperationStatus,
38
    ProgressEvent,
39
    ResourceProviderExecutor,
40
    ResourceProviderPayload,
41
)
42
from localstack.services.cloudformation.v2.entities import ChangeSet, ResolvedResource
1✔
43
from localstack.utils.urls import localstack_host
1✔
44

45
LOG = logging.getLogger(__name__)
1✔
46

47
EventOperationFromAction = {"Add": "CREATE", "Modify": "UPDATE", "Remove": "DELETE"}
1✔
48

49

50
@dataclass
1✔
51
class ChangeSetModelExecutorResult:
1✔
52
    resources: dict[str, ResolvedResource]
1✔
53
    outputs: dict
1✔
54
    exports: dict
1✔
55

56

57
class DeferredAction(Protocol):
1✔
58
    def __call__(self) -> None: ...
1✔
59

60

61
class ChangeSetModelExecutor(ChangeSetModelPreproc):
1✔
62
    # TODO: add typing for resolved resources and parameters.
63
    resources: Final[dict[str, ResolvedResource]]
1✔
64
    outputs: Final[dict]
1✔
65
    exports: Final[dict]
1✔
66
    _deferred_actions: list[DeferredAction]
1✔
67

68
    def __init__(self, change_set: ChangeSet):
1✔
69
        super().__init__(change_set=change_set)
1✔
70
        self.resources = dict()
1✔
71
        self.outputs = dict()
1✔
72
        self.exports = dict()
1✔
73
        self._deferred_actions = list()
1✔
74
        self.resource_provider_executor = ResourceProviderExecutor(
1✔
75
            stack_name=change_set.stack.stack_name,
76
            stack_id=change_set.stack.stack_id,
77
        )
78

79
    def execute(self) -> ChangeSetModelExecutorResult:
1✔
80
        # constructive process
81
        self.process()
1✔
82

83
        if self._deferred_actions:
1✔
84
            self._change_set.stack.set_stack_status(StackStatus.UPDATE_COMPLETE_CLEANUP_IN_PROGRESS)
1✔
85

86
            # perform all deferred actions such as deletions. These must happen in reverse from their
87
            # defined order so that resource dependencies are honoured
88
            # TODO: errors will stop all rollbacks; get parity on this behaviour
89
            for action in self._deferred_actions[::-1]:
1✔
90
                action()
1✔
91

92
        return ChangeSetModelExecutorResult(
1✔
93
            resources=self.resources,
94
            outputs=self.outputs,
95
            exports=self.exports,
96
        )
97

98
    def _defer_action(self, action: DeferredAction):
1✔
99
        self._deferred_actions.append(action)
1✔
100

101
    def _get_physical_id(self, logical_resource_id, strict: bool = True) -> str | None:
1✔
102
        physical_resource_id = None
1✔
103
        try:
1✔
104
            physical_resource_id = self._after_resource_physical_id(logical_resource_id)
1✔
105
        except RuntimeError:
1✔
106
            # The physical id is missing or is set to None, which is invalid.
107
            pass
1✔
108
        if physical_resource_id is None:
1✔
109
            # The physical resource id is None after an update that didn't rewrite the resource, the previous
110
            # resource id is therefore the current physical id of this resource.
111

112
            try:
1✔
113
                physical_resource_id = self._before_resource_physical_id(logical_resource_id)
1✔
114
            except RuntimeError as e:
1✔
115
                if strict:
1✔
UNCOV
116
                    raise e
×
117
        return physical_resource_id
1✔
118

119
    def _process_event(
1✔
120
        self,
121
        *,
122
        action: ChangeAction,
123
        logical_resource_id,
124
        event_status: OperationStatus,
125
        resource_type: str,
126
        special_action: str = None,
127
        reason: str = None,
128
    ):
129
        status_from_action = special_action or EventOperationFromAction[action.value]
1✔
130
        if event_status == OperationStatus.SUCCESS:
1✔
131
            status = f"{status_from_action}_COMPLETE"
1✔
132
        else:
133
            status = f"{status_from_action}_{event_status.name}"
1✔
134

135
        self._change_set.stack.set_resource_status(
1✔
136
            logical_resource_id=logical_resource_id,
137
            physical_resource_id=self._get_physical_id(logical_resource_id, False) or "",
138
            resource_type=resource_type,
139
            status=ResourceStatus(status),
140
            resource_status_reason=reason,
141
        )
142

143
        if event_status == OperationStatus.FAILED:
1✔
144
            self._change_set.stack.set_stack_status(StackStatus(status))
1✔
145

146
    def _after_deployed_property_value_of(
1✔
147
        self, resource_logical_id: str, property_name: str
148
    ) -> str:
149
        after_resolved_resources = self.resources
1✔
150
        return self._deployed_property_value_of(
1✔
151
            resource_logical_id=resource_logical_id,
152
            property_name=property_name,
153
            resolved_resources=after_resolved_resources,
154
        )
155

156
    def _after_resource_physical_id(self, resource_logical_id: str) -> str:
1✔
157
        after_resolved_resources = self.resources
1✔
158
        return self._resource_physical_resource_id_from(
1✔
159
            logical_resource_id=resource_logical_id, resolved_resources=after_resolved_resources
160
        )
161

162
    def visit_node_depends_on(self, node_depends_on: NodeDependsOn) -> PreprocEntityDelta:
1✔
163
        array_identifiers_delta = super().visit_node_depends_on(node_depends_on=node_depends_on)
1✔
164

165
        # Visit depends_on resources before returning.
166
        depends_on_resource_logical_ids: set[str] = set()
1✔
167
        if array_identifiers_delta.before:
1✔
168
            depends_on_resource_logical_ids.update(array_identifiers_delta.before)
1✔
169
        if array_identifiers_delta.after:
1✔
170
            depends_on_resource_logical_ids.update(array_identifiers_delta.after)
1✔
171
        for depends_on_resource_logical_id in depends_on_resource_logical_ids:
1✔
172
            node_resource = self._get_node_resource_for(
1✔
173
                resource_name=depends_on_resource_logical_id,
174
                node_template=self._change_set.update_model.node_template,
175
            )
176
            self.visit(node_resource)
1✔
177

178
        return array_identifiers_delta
1✔
179

180
    def visit_node_resource(
1✔
181
        self, node_resource: NodeResource
182
    ) -> PreprocEntityDelta[PreprocResource, PreprocResource]:
183
        """
184
        Overrides the default preprocessing for NodeResource objects by annotating the
185
        `after` delta with the physical resource ID, if side effects resulted in an update.
186
        """
187
        try:
1✔
188
            delta = super().visit_node_resource(node_resource=node_resource)
1✔
UNCOV
189
        except Exception as e:
×
UNCOV
190
            self._process_event(
×
191
                action=node_resource.change_type.to_change_action(),
192
                logical_resource_id=node_resource.name,
193
                event_status=OperationStatus.FAILED,
194
                resource_type=node_resource.type_.value,
195
                reason=str(e),
196
            )
UNCOV
197
            raise e
×
198

199
        before = delta.before
1✔
200
        after = delta.after
1✔
201

202
        if before != after:
1✔
203
            # There are changes for this resource.
204
            self._execute_resource_change(name=node_resource.name, before=before, after=after)
1✔
205
        else:
206
            # There are no updates for this resource; iff the resource was previously
207
            # deployed, then the resolved details are copied in the current state for
208
            # references or other downstream operations.
209
            if not is_nothing(before):
1✔
210
                before_logical_id = delta.before.logical_id
1✔
211
                before_resource = self._before_resolved_resources.get(before_logical_id, dict())
1✔
212
                self.resources[before_logical_id] = before_resource
1✔
213

214
        # Update the latest version of this resource for downstream references.
215
        if not is_nothing(after):
1✔
216
            after_logical_id = after.logical_id
1✔
217
            after_physical_id: str = self._after_resource_physical_id(
1✔
218
                resource_logical_id=after_logical_id
219
            )
220
            after.physical_resource_id = after_physical_id
1✔
221
        return delta
1✔
222

223
    def visit_node_output(
1✔
224
        self, node_output: NodeOutput
225
    ) -> PreprocEntityDelta[PreprocOutput, PreprocOutput]:
226
        delta = super().visit_node_output(node_output=node_output)
1✔
227
        after = delta.after
1✔
228
        if is_nothing(after) or (isinstance(after, PreprocOutput) and after.condition is False):
1✔
229
            return delta
1✔
230

231
        # TODO validate export name duplication in same template and all exports
232
        if delta.after.export:
1✔
233
            self.exports[delta.after.export.get("Name")] = delta.after.value
1✔
234

235
        self.outputs[delta.after.name] = delta.after.value
1✔
236
        return delta
1✔
237

238
    def _execute_resource_change(
1✔
239
        self, name: str, before: PreprocResource | None, after: PreprocResource | None
240
    ) -> None:
241
        # Changes are to be made about this resource.
242
        # TODO: this logic is a POC and should be revised.
243
        if not is_nothing(before) and not is_nothing(after):
1✔
244
            # Case: change on same type.
245
            if before.resource_type == after.resource_type:
1✔
246
                # Register a Modified if changed.
247
                # XXX hacky, stick the previous resources' properties into the payload
248
                before_properties = self._merge_before_properties(name, before)
1✔
249

250
                self._process_event(
1✔
251
                    action=ChangeAction.Modify,
252
                    logical_resource_id=name,
253
                    event_status=OperationStatus.IN_PROGRESS,
254
                    resource_type=before.resource_type,
255
                )
256
                if after.requires_replacement:
1✔
257
                    event = self._execute_resource_action(
1✔
258
                        action=ChangeAction.Add,
259
                        logical_resource_id=name,
260
                        resource_type=before.resource_type,
261
                        before_properties=None,
262
                        after_properties=after.properties,
263
                    )
264
                    self._process_event(
1✔
265
                        action=ChangeAction.Modify,
266
                        logical_resource_id=name,
267
                        event_status=event.status,
268
                        resource_type=before.resource_type,
269
                        reason=event.message,
270
                    )
271

272
                    def cleanup():
1✔
273
                        self._process_event(
1✔
274
                            action=ChangeAction.Remove,
275
                            logical_resource_id=name,
276
                            event_status=OperationStatus.IN_PROGRESS,
277
                            resource_type=before.resource_type,
278
                        )
279
                        event = self._execute_resource_action(
1✔
280
                            action=ChangeAction.Remove,
281
                            logical_resource_id=name,
282
                            resource_type=before.resource_type,
283
                            before_properties=before_properties,
284
                            after_properties=None,
285
                        )
286
                        self._process_event(
1✔
287
                            action=ChangeAction.Remove,
288
                            logical_resource_id=name,
289
                            event_status=event.status,
290
                            resource_type=before.resource_type,
291
                            reason=event.message,
292
                        )
293

294
                    self._defer_action(cleanup)
1✔
295
                else:
296
                    event = self._execute_resource_action(
1✔
297
                        action=ChangeAction.Modify,
298
                        logical_resource_id=name,
299
                        resource_type=before.resource_type,
300
                        before_properties=before_properties,
301
                        after_properties=after.properties,
302
                    )
303
                    self._process_event(
1✔
304
                        action=ChangeAction.Modify,
305
                        logical_resource_id=name,
306
                        event_status=event.status,
307
                        resource_type=before.resource_type,
308
                        reason=event.message,
309
                    )
310
            # Case: type migration.
311
            # TODO: Add test to assert that on type change the resources are replaced.
312
            else:
313
                # XXX hacky, stick the previous resources' properties into the payload
UNCOV
314
                before_properties = self._merge_before_properties(name, before)
×
315
                # Register a Removed for the previous type.
316

UNCOV
317
                def perform_deletion():
×
UNCOV
318
                    event = self._execute_resource_action(
×
319
                        action=ChangeAction.Remove,
320
                        logical_resource_id=name,
321
                        resource_type=before.resource_type,
322
                        before_properties=before_properties,
323
                        after_properties=None,
324
                    )
UNCOV
325
                    self._process_event(
×
326
                        action=ChangeAction.Modify,
327
                        logical_resource_id=name,
328
                        event_status=event.status,
329
                        resource_type=before.resource_type,
330
                        reason=event.message,
331
                    )
332

UNCOV
333
                self._defer_action(perform_deletion)
×
334

335
                event = self._execute_resource_action(
×
336
                    action=ChangeAction.Add,
337
                    logical_resource_id=name,
338
                    resource_type=after.resource_type,
339
                    before_properties=None,
340
                    after_properties=after.properties,
341
                )
UNCOV
342
                self._process_event(
×
343
                    action=ChangeAction.Modify,
344
                    logical_resource_id=name,
345
                    event_status=event.status,
346
                    resource_type=before.resource_type,
347
                    reason=event.message,
348
                )
349
        elif not is_nothing(before):
1✔
350
            # Case: removal
351
            # XXX hacky, stick the previous resources' properties into the payload
352
            # XXX hacky, stick the previous resources' properties into the payload
353
            before_properties = self._merge_before_properties(name, before)
1✔
354

355
            def perform_deletion():
1✔
356
                self._process_event(
1✔
357
                    action=ChangeAction.Remove,
358
                    logical_resource_id=name,
359
                    resource_type=before.resource_type,
360
                    event_status=OperationStatus.IN_PROGRESS,
361
                )
362
                event = self._execute_resource_action(
1✔
363
                    action=ChangeAction.Remove,
364
                    logical_resource_id=name,
365
                    resource_type=before.resource_type,
366
                    before_properties=before_properties,
367
                    after_properties=None,
368
                )
369
                self._process_event(
1✔
370
                    action=ChangeAction.Remove,
371
                    logical_resource_id=name,
372
                    event_status=event.status,
373
                    resource_type=before.resource_type,
374
                    reason=event.message,
375
                )
376

377
            self._defer_action(perform_deletion)
1✔
378
        elif not is_nothing(after):
1✔
379
            # Case: addition
380
            self._process_event(
1✔
381
                action=ChangeAction.Add,
382
                logical_resource_id=name,
383
                event_status=OperationStatus.IN_PROGRESS,
384
                resource_type=after.resource_type,
385
            )
386
            event = self._execute_resource_action(
1✔
387
                action=ChangeAction.Add,
388
                logical_resource_id=name,
389
                resource_type=after.resource_type,
390
                before_properties=None,
391
                after_properties=after.properties,
392
            )
393
            self._process_event(
1✔
394
                action=ChangeAction.Add,
395
                logical_resource_id=name,
396
                event_status=event.status,
397
                resource_type=after.resource_type,
398
                reason=event.message,
399
            )
400

401
    def _merge_before_properties(
1✔
402
        self, name: str, preproc_resource: PreprocResource
403
    ) -> PreprocProperties:
404
        if previous_resource_properties := self._change_set.stack.resolved_resources.get(
1✔
405
            name, {}
406
        ).get("Properties"):
407
            return PreprocProperties(properties=previous_resource_properties)
1✔
408

409
        # XXX fall back to returning the input value
410
        return copy.deepcopy(preproc_resource.properties)
1✔
411

412
    def _execute_resource_action(
1✔
413
        self,
414
        action: ChangeAction,
415
        logical_resource_id: str,
416
        resource_type: str,
417
        before_properties: PreprocProperties | None,
418
        after_properties: PreprocProperties | None,
419
    ) -> ProgressEvent:
420
        LOG.debug("Executing resource action: %s for resource '%s'", action, logical_resource_id)
1✔
421
        payload = self.create_resource_provider_payload(
1✔
422
            action=action,
423
            logical_resource_id=logical_resource_id,
424
            resource_type=resource_type,
425
            before_properties=before_properties,
426
            after_properties=after_properties,
427
        )
428
        resource_provider = self.resource_provider_executor.try_load_resource_provider(
1✔
429
            resource_type
430
        )
431
        track_resource_operation(action, resource_type, missing=resource_provider is not None)
1✔
432

433
        extra_resource_properties = {}
1✔
434
        if resource_provider is not None:
1✔
435
            try:
1✔
436
                event = self.resource_provider_executor.deploy_loop(
1✔
437
                    resource_provider, extra_resource_properties, payload
438
                )
439
            except Exception as e:
1✔
440
                reason = str(e)
1✔
441
                LOG.warning(
1✔
442
                    "Resource provider operation failed: '%s'",
443
                    reason,
444
                    exc_info=LOG.isEnabledFor(logging.DEBUG),
445
                )
446
                event = ProgressEvent(
1✔
447
                    OperationStatus.FAILED,
448
                    resource_model={},
449
                    message=f"Resource provider operation failed: {reason}",
450
                )
451
        elif config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES:
1✔
452
            log_not_available_message(
1✔
453
                resource_type,
454
                f'No resource provider found for "{resource_type}"',
455
            )
456
            LOG.warning(
1✔
457
                "Deployment of resource type %s successful due to config CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES"
458
            )
459
            event = ProgressEvent(
1✔
460
                OperationStatus.SUCCESS,
461
                resource_model={},
462
                message=f"Resource type {resource_type} is not supported but was deployed as a fallback",
463
            )
464
        else:
UNCOV
465
            log_not_available_message(
×
466
                resource_type,
467
                f'No resource provider found for "{resource_type}"',
468
            )
UNCOV
469
            event = ProgressEvent(
×
470
                OperationStatus.FAILED,
471
                resource_model={},
472
                message=f"Resource type {resource_type} not supported",
473
            )
474

475
        match event.status:
1✔
476
            case OperationStatus.SUCCESS:
1✔
477
                # merge the resources state with the external state
478
                # TODO: this is likely a duplicate of updating from extra_resource_properties
479

480
                # TODO: add typing
481
                # TODO: avoid the use of string literals for sampling from the object, use typed classes instead
482
                # TODO: avoid sampling from resources and use tmp var reference
483
                # TODO: add utils functions to abstract this logic away (resource.update(..))
484
                # TODO: avoid the use of setdefault (debuggability/readability)
485
                # TODO: review the use of merge
486

487
                # Don't update the resolved resources if we have deleted that resource
488
                if action != ChangeAction.Remove:
1✔
489
                    status_from_action = EventOperationFromAction[action.value]
1✔
490
                    physical_resource_id = (
1✔
491
                        extra_resource_properties["PhysicalResourceId"]
492
                        if resource_provider
493
                        else MOCKED_REFERENCE
494
                    )
495
                    resolved_resource = ResolvedResource(
1✔
496
                        Properties=event.resource_model,
497
                        LogicalResourceId=logical_resource_id,
498
                        Type=resource_type,
499
                        LastUpdatedTimestamp=datetime.now(UTC),
500
                        ResourceStatus=ResourceStatus(f"{status_from_action}_COMPLETE"),
501
                        PhysicalResourceId=physical_resource_id,
502
                    )
503
                    # TODO: do we actually need this line?
504
                    resolved_resource.update(extra_resource_properties)
1✔
505

506
                    self.resources[logical_resource_id] = resolved_resource
1✔
507

508
            case OperationStatus.FAILED:
1✔
509
                reason = event.message
1✔
510
                LOG.warning(
1✔
511
                    "Resource provider operation failed: '%s'",
512
                    reason,
513
                )
UNCOV
514
            case other:
×
515
                raise NotImplementedError(f"Event status '{other}' not handled")
516
        return event
1✔
517

518
    def create_resource_provider_payload(
1✔
519
        self,
520
        action: ChangeAction,
521
        logical_resource_id: str,
522
        resource_type: str,
523
        before_properties: PreprocProperties | None,
524
        after_properties: PreprocProperties | None,
525
    ) -> ResourceProviderPayload | None:
526
        # FIXME: use proper credentials
527
        creds: Credentials = {
1✔
528
            "accessKeyId": self._change_set.stack.account_id,
529
            "secretAccessKey": INTERNAL_AWS_SECRET_ACCESS_KEY,
530
            "sessionToken": "",
531
        }
532
        before_properties_value = before_properties.properties if before_properties else None
1✔
533
        after_properties_value = after_properties.properties if after_properties else None
1✔
534

535
        match action:
1✔
536
            case ChangeAction.Add:
1✔
537
                resource_properties = after_properties_value or {}
1✔
538
                previous_resource_properties = None
1✔
539
            case ChangeAction.Modify | ChangeAction.Dynamic:
1✔
540
                resource_properties = after_properties_value or {}
1✔
541
                previous_resource_properties = before_properties_value or {}
1✔
542
            case ChangeAction.Remove:
1✔
543
                resource_properties = before_properties_value or {}
1✔
544
                # previous_resource_properties = None
545
                # HACK: our providers use a mix of `desired_state` and `previous_state` so ensure the payload is present for both
546
                previous_resource_properties = resource_properties
1✔
UNCOV
547
            case _:
×
548
                raise NotImplementedError(f"Action '{action}' not handled")
549

550
        resource_provider_payload: ResourceProviderPayload = {
1✔
551
            "awsAccountId": self._change_set.stack.account_id,
552
            "callbackContext": {},
553
            "stackId": self._change_set.stack.stack_name,
554
            "resourceType": resource_type,
555
            "resourceTypeVersion": "000000",
556
            # TODO: not actually a UUID
557
            "bearerToken": str(uuid.uuid4()),
558
            "region": self._change_set.stack.region_name,
559
            "action": str(action),
560
            "requestData": {
561
                "logicalResourceId": logical_resource_id,
562
                "resourceProperties": resource_properties,
563
                "previousResourceProperties": previous_resource_properties,
564
                "callerCredentials": creds,
565
                "providerCredentials": creds,
566
                "systemTags": {},
567
                "previousSystemTags": {},
568
                "stackTags": {},
569
                "previousStackTags": {},
570
            },
571
        }
572
        return resource_provider_payload
1✔
573

574
    @staticmethod
1✔
575
    def _replace_url_outputs_if_required(value: str) -> str:
1✔
576
        api_match = REGEX_OUTPUT_APIGATEWAY.match(value)
1✔
577
        if api_match and value not in config.CFN_STRING_REPLACEMENT_DENY_LIST:
1✔
578
            prefix = api_match[1]
1✔
579
            host = api_match[2]
1✔
580
            path = api_match[3]
1✔
581
            port = localstack_host().port
1✔
582
            value = f"{prefix}{host}:{port}/{path}"
1✔
583
            return value
1✔
584

585
        return value
1✔
586

587
    def visit_terminal_value_created(
1✔
588
        self, value: TerminalValueCreated
589
    ) -> PreprocEntityDelta[str, str]:
590
        if isinstance(value.value, str):
1✔
591
            after = self._replace_url_outputs_if_required(value.value)
1✔
592
        else:
593
            after = value.value
1✔
594
        return PreprocEntityDelta(after=after)
1✔
595

596
    def visit_terminal_value_modified(
1✔
597
        self, value: TerminalValueModified
598
    ) -> PreprocEntityDelta[str, str]:
599
        # we only need to transform the after
600
        if isinstance(value.modified_value, str):
1✔
601
            after = self._replace_url_outputs_if_required(value.modified_value)
1✔
602
        else:
603
            after = value.modified_value
1✔
604
        return PreprocEntityDelta(before=value.value, after=after)
1✔
605

606
    def visit_terminal_value_unchanged(
1✔
607
        self, terminal_value_unchanged: TerminalValueUnchanged
608
    ) -> PreprocEntityDelta:
609
        if isinstance(terminal_value_unchanged.value, str):
1✔
610
            value = self._replace_url_outputs_if_required(terminal_value_unchanged.value)
1✔
611
        else:
612
            value = terminal_value_unchanged.value
1✔
613
        return PreprocEntityDelta(before=value, after=value)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc