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

localstack / localstack / 17086927072

19 Aug 2025 10:02PM UTC coverage: 86.889% (+0.01%) from 86.875%
17086927072

push

github

web-flow
APIGW: fix TestInvokeMethod path logic (#13030)

4 of 23 new or added lines in 1 file covered. (17.39%)

264 existing lines in 17 files now uncovered.

67018 of 77131 relevant lines covered (86.89%)

0.87 hits per line

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

93.75
/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
    Output,
12
    ResourceStatus,
13
    StackStatus,
14
)
15
from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY
1✔
16
from localstack.services.cloudformation.analytics import track_resource_operation
1✔
17
from localstack.services.cloudformation.deployment_utils import log_not_available_message
1✔
18
from localstack.services.cloudformation.engine.template_deployer import REGEX_OUTPUT_APIGATEWAY
1✔
19
from localstack.services.cloudformation.engine.v2.change_set_model import (
1✔
20
    NodeDependsOn,
21
    NodeIntrinsicFunction,
22
    NodeOutput,
23
    NodeResource,
24
    TerminalValueCreated,
25
    TerminalValueModified,
26
    TerminalValueUnchanged,
27
    is_nothing,
28
)
29
from localstack.services.cloudformation.engine.v2.change_set_model_preproc import (
1✔
30
    MOCKED_REFERENCE,
31
    ChangeSetModelPreproc,
32
    PreprocEntityDelta,
33
    PreprocOutput,
34
    PreprocProperties,
35
    PreprocResource,
36
)
37
from localstack.services.cloudformation.resource_provider import (
1✔
38
    Credentials,
39
    OperationStatus,
40
    ProgressEvent,
41
    ResourceProviderExecutor,
42
    ResourceProviderPayload,
43
)
44
from localstack.services.cloudformation.v2.entities import ChangeSet, ResolvedResource
1✔
45
from localstack.utils.urls import localstack_host
1✔
46

47
LOG = logging.getLogger(__name__)
1✔
48

49
EventOperationFromAction = {"Add": "CREATE", "Modify": "UPDATE", "Remove": "DELETE"}
1✔
50

51

52
@dataclass
1✔
53
class ChangeSetModelExecutorResult:
1✔
54
    resources: dict[str, ResolvedResource]
1✔
55
    outputs: list[Output]
1✔
56

57

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

61

62
@dataclass
1✔
63
class Deferred:
1✔
64
    name: str
1✔
65
    action: DeferredAction
1✔
66

67

68
class ChangeSetModelExecutor(ChangeSetModelPreproc):
1✔
69
    # TODO: add typing for resolved resources and parameters.
70
    resources: Final[dict[str, ResolvedResource]]
1✔
71
    outputs: Final[list[Output]]
1✔
72
    _deferred_actions: list[Deferred]
1✔
73

74
    def __init__(self, change_set: ChangeSet):
1✔
75
        super().__init__(change_set=change_set)
1✔
76
        self.resources = {}
1✔
77
        self.outputs = []
1✔
78
        self._deferred_actions = []
1✔
79
        self.resource_provider_executor = ResourceProviderExecutor(
1✔
80
            stack_name=change_set.stack.stack_name,
81
            stack_id=change_set.stack.stack_id,
82
        )
83

84
    def execute(self) -> ChangeSetModelExecutorResult:
1✔
85
        # constructive process
86
        self.process()
1✔
87

88
        if self._deferred_actions:
1✔
89
            self._change_set.stack.set_stack_status(StackStatus.UPDATE_COMPLETE_CLEANUP_IN_PROGRESS)
1✔
90

91
            # perform all deferred actions such as deletions. These must happen in reverse from their
92
            # defined order so that resource dependencies are honoured
93
            # TODO: errors will stop all rollbacks; get parity on this behaviour
94
            for deferred in self._deferred_actions[::-1]:
1✔
95
                LOG.debug("executing deferred action: '%s'", deferred.name)
1✔
96
                deferred.action()
1✔
97

98
        return ChangeSetModelExecutorResult(
1✔
99
            resources=self.resources,
100
            outputs=self.outputs,
101
        )
102

103
    def _defer_action(self, name: str, action: DeferredAction):
1✔
104
        self._deferred_actions.append(Deferred(name=name, action=action))
1✔
105

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

117
            try:
1✔
118
                physical_resource_id = self._before_resource_physical_id(logical_resource_id)
1✔
119
            except RuntimeError as e:
1✔
120
                if strict:
1✔
UNCOV
121
                    raise e
×
122
        return physical_resource_id
1✔
123

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

140
        self._change_set.stack.set_resource_status(
1✔
141
            logical_resource_id=logical_resource_id,
142
            physical_resource_id=self._get_physical_id(logical_resource_id, False) or "",
143
            resource_type=resource_type,
144
            status=ResourceStatus(status),
145
            resource_status_reason=reason,
146
        )
147

148
        if event_status == OperationStatus.FAILED:
1✔
149
            self._change_set.stack.set_stack_status(StackStatus(status))
1✔
150

151
    def _after_deployed_property_value_of(
1✔
152
        self, resource_logical_id: str, property_name: str
153
    ) -> str:
154
        after_resolved_resources = self.resources
1✔
155
        return self._deployed_property_value_of(
1✔
156
            resource_logical_id=resource_logical_id,
157
            property_name=property_name,
158
            resolved_resources=after_resolved_resources,
159
        )
160

161
    def _after_resource_physical_id(self, resource_logical_id: str) -> str:
1✔
162
        after_resolved_resources = self.resources
1✔
163
        return self._resource_physical_resource_id_from(
1✔
164
            logical_resource_id=resource_logical_id, resolved_resources=after_resolved_resources
165
        )
166

167
    def visit_node_depends_on(self, node_depends_on: NodeDependsOn) -> PreprocEntityDelta:
1✔
168
        array_identifiers_delta = super().visit_node_depends_on(node_depends_on=node_depends_on)
1✔
169

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

183
        return array_identifiers_delta
1✔
184

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

204
        before = delta.before
1✔
205
        after = delta.after
1✔
206

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

219
        # Update the latest version of this resource for downstream references.
220
        if not is_nothing(after):
1✔
221
            after_logical_id = after.logical_id
1✔
222
            after_physical_id: str = self._after_resource_physical_id(
1✔
223
                resource_logical_id=after_logical_id
224
            )
225
            after.physical_resource_id = after_physical_id
1✔
226
        return delta
1✔
227

228
    def visit_node_output(
1✔
229
        self, node_output: NodeOutput
230
    ) -> PreprocEntityDelta[PreprocOutput, PreprocOutput]:
231
        delta = super().visit_node_output(node_output=node_output)
1✔
232
        after = delta.after
1✔
233
        if is_nothing(after) or (isinstance(after, PreprocOutput) and after.condition is False):
1✔
234
            return delta
1✔
235

236
        output = Output(
1✔
237
            OutputKey=delta.after.name,
238
            OutputValue=delta.after.value,
239
            # TODO
240
            # Description=delta.after.description
241
        )
242
        if after.export:
1✔
243
            output["ExportName"] = after.export["Name"]
1✔
244
        self.outputs.append(output)
1✔
245
        return delta
1✔
246

247
    def _execute_resource_change(
1✔
248
        self, name: str, before: PreprocResource | None, after: PreprocResource | None
249
    ) -> None:
250
        # Changes are to be made about this resource.
251
        # TODO: this logic is a POC and should be revised.
252
        if not is_nothing(before) and not is_nothing(after):
1✔
253
            # Case: change on same type.
254
            if before.resource_type == after.resource_type:
1✔
255
                # Register a Modified if changed.
256
                # XXX hacky, stick the previous resources' properties into the payload
257
                before_properties = self._merge_before_properties(name, before)
1✔
258

259
                self._process_event(
1✔
260
                    action=ChangeAction.Modify,
261
                    logical_resource_id=name,
262
                    event_status=OperationStatus.IN_PROGRESS,
263
                    resource_type=before.resource_type,
264
                )
265
                if after.requires_replacement:
1✔
266
                    event = self._execute_resource_action(
1✔
267
                        action=ChangeAction.Add,
268
                        logical_resource_id=name,
269
                        resource_type=before.resource_type,
270
                        before_properties=None,
271
                        after_properties=after.properties,
272
                    )
273
                    self._process_event(
1✔
274
                        action=ChangeAction.Modify,
275
                        logical_resource_id=name,
276
                        event_status=event.status,
277
                        resource_type=before.resource_type,
278
                        reason=event.message,
279
                    )
280

281
                    def cleanup():
1✔
282
                        self._process_event(
1✔
283
                            action=ChangeAction.Remove,
284
                            logical_resource_id=name,
285
                            event_status=OperationStatus.IN_PROGRESS,
286
                            resource_type=before.resource_type,
287
                        )
288
                        event = self._execute_resource_action(
1✔
289
                            action=ChangeAction.Remove,
290
                            logical_resource_id=name,
291
                            resource_type=before.resource_type,
292
                            before_properties=before_properties,
293
                            after_properties=None,
294
                        )
295
                        self._process_event(
1✔
296
                            action=ChangeAction.Remove,
297
                            logical_resource_id=name,
298
                            event_status=event.status,
299
                            resource_type=before.resource_type,
300
                            reason=event.message,
301
                        )
302

303
                    self._defer_action(f"cleanup-from-replacement-{name}", cleanup)
1✔
304
                else:
305
                    event = self._execute_resource_action(
1✔
306
                        action=ChangeAction.Modify,
307
                        logical_resource_id=name,
308
                        resource_type=before.resource_type,
309
                        before_properties=before_properties,
310
                        after_properties=after.properties,
311
                    )
312
                    self._process_event(
1✔
313
                        action=ChangeAction.Modify,
314
                        logical_resource_id=name,
315
                        event_status=event.status,
316
                        resource_type=before.resource_type,
317
                        reason=event.message,
318
                    )
319
            # Case: type migration.
320
            # TODO: Add test to assert that on type change the resources are replaced.
321
            else:
322
                # XXX hacky, stick the previous resources' properties into the payload
UNCOV
323
                before_properties = self._merge_before_properties(name, before)
×
324
                # Register a Removed for the previous type.
325

326
                def perform_deletion():
×
UNCOV
327
                    event = self._execute_resource_action(
×
328
                        action=ChangeAction.Remove,
329
                        logical_resource_id=name,
330
                        resource_type=before.resource_type,
331
                        before_properties=before_properties,
332
                        after_properties=None,
333
                    )
334
                    self._process_event(
×
335
                        action=ChangeAction.Modify,
336
                        logical_resource_id=name,
337
                        event_status=event.status,
338
                        resource_type=before.resource_type,
339
                        reason=event.message,
340
                    )
341

UNCOV
342
                self._defer_action(f"type-migration-{name}", perform_deletion)
×
343

UNCOV
344
                event = self._execute_resource_action(
×
345
                    action=ChangeAction.Add,
346
                    logical_resource_id=name,
347
                    resource_type=after.resource_type,
348
                    before_properties=None,
349
                    after_properties=after.properties,
350
                )
UNCOV
351
                self._process_event(
×
352
                    action=ChangeAction.Modify,
353
                    logical_resource_id=name,
354
                    event_status=event.status,
355
                    resource_type=before.resource_type,
356
                    reason=event.message,
357
                )
358
        elif not is_nothing(before):
1✔
359
            # Case: removal
360
            # XXX hacky, stick the previous resources' properties into the payload
361
            # XXX hacky, stick the previous resources' properties into the payload
362
            before_properties = self._merge_before_properties(name, before)
1✔
363

364
            def perform_deletion():
1✔
365
                self._process_event(
1✔
366
                    action=ChangeAction.Remove,
367
                    logical_resource_id=name,
368
                    resource_type=before.resource_type,
369
                    event_status=OperationStatus.IN_PROGRESS,
370
                )
371
                event = self._execute_resource_action(
1✔
372
                    action=ChangeAction.Remove,
373
                    logical_resource_id=name,
374
                    resource_type=before.resource_type,
375
                    before_properties=before_properties,
376
                    after_properties=None,
377
                )
378
                self._process_event(
1✔
379
                    action=ChangeAction.Remove,
380
                    logical_resource_id=name,
381
                    event_status=event.status,
382
                    resource_type=before.resource_type,
383
                    reason=event.message,
384
                )
385

386
            self._defer_action(f"remove-{name}", perform_deletion)
1✔
387
        elif not is_nothing(after):
1✔
388
            # Case: addition
389
            self._process_event(
1✔
390
                action=ChangeAction.Add,
391
                logical_resource_id=name,
392
                event_status=OperationStatus.IN_PROGRESS,
393
                resource_type=after.resource_type,
394
            )
395
            event = self._execute_resource_action(
1✔
396
                action=ChangeAction.Add,
397
                logical_resource_id=name,
398
                resource_type=after.resource_type,
399
                before_properties=None,
400
                after_properties=after.properties,
401
            )
402
            self._process_event(
1✔
403
                action=ChangeAction.Add,
404
                logical_resource_id=name,
405
                event_status=event.status,
406
                resource_type=after.resource_type,
407
                reason=event.message,
408
            )
409

410
    def _merge_before_properties(
1✔
411
        self, name: str, preproc_resource: PreprocResource
412
    ) -> PreprocProperties:
413
        if previous_resource_properties := self._change_set.stack.resolved_resources.get(
1✔
414
            name, {}
415
        ).get("Properties"):
416
            return PreprocProperties(properties=previous_resource_properties)
1✔
417

418
        # XXX fall back to returning the input value
419
        return copy.deepcopy(preproc_resource.properties)
1✔
420

421
    def _execute_resource_action(
1✔
422
        self,
423
        action: ChangeAction,
424
        logical_resource_id: str,
425
        resource_type: str,
426
        before_properties: PreprocProperties | None,
427
        after_properties: PreprocProperties | None,
428
    ) -> ProgressEvent:
429
        LOG.debug("Executing resource action: %s for resource '%s'", action, logical_resource_id)
1✔
430
        payload = self.create_resource_provider_payload(
1✔
431
            action=action,
432
            logical_resource_id=logical_resource_id,
433
            resource_type=resource_type,
434
            before_properties=before_properties,
435
            after_properties=after_properties,
436
        )
437
        resource_provider = self.resource_provider_executor.try_load_resource_provider(
1✔
438
            resource_type
439
        )
440
        track_resource_operation(action, resource_type, missing=resource_provider is not None)
1✔
441

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

484
        match event.status:
1✔
485
            case OperationStatus.SUCCESS:
1✔
486
                # merge the resources state with the external state
487
                # TODO: this is likely a duplicate of updating from extra_resource_properties
488

489
                # TODO: add typing
490
                # TODO: avoid the use of string literals for sampling from the object, use typed classes instead
491
                # TODO: avoid sampling from resources and use tmp var reference
492
                # TODO: add utils functions to abstract this logic away (resource.update(..))
493
                # TODO: avoid the use of setdefault (debuggability/readability)
494
                # TODO: review the use of merge
495

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

515
                    self.resources[logical_resource_id] = resolved_resource
1✔
516

517
            case OperationStatus.FAILED:
1✔
518
                reason = event.message
1✔
519
                LOG.warning(
1✔
520
                    "Resource provider operation failed: '%s'",
521
                    reason,
522
                )
UNCOV
523
            case other:
×
524
                raise NotImplementedError(f"Event status '{other}' not handled")
525
        return event
1✔
526

527
    def create_resource_provider_payload(
1✔
528
        self,
529
        action: ChangeAction,
530
        logical_resource_id: str,
531
        resource_type: str,
532
        before_properties: PreprocProperties | None,
533
        after_properties: PreprocProperties | None,
534
    ) -> ResourceProviderPayload | None:
535
        # FIXME: use proper credentials
536
        creds: Credentials = {
1✔
537
            "accessKeyId": self._change_set.stack.account_id,
538
            "secretAccessKey": INTERNAL_AWS_SECRET_ACCESS_KEY,
539
            "sessionToken": "",
540
        }
541
        before_properties_value = before_properties.properties if before_properties else None
1✔
542
        after_properties_value = after_properties.properties if after_properties else None
1✔
543

544
        match action:
1✔
545
            case ChangeAction.Add:
1✔
546
                resource_properties = after_properties_value or {}
1✔
547
                previous_resource_properties = None
1✔
548
            case ChangeAction.Modify | ChangeAction.Dynamic:
1✔
549
                resource_properties = after_properties_value or {}
1✔
550
                previous_resource_properties = before_properties_value or {}
1✔
551
            case ChangeAction.Remove:
1✔
552
                resource_properties = before_properties_value or {}
1✔
553
                # previous_resource_properties = None
554
                # HACK: our providers use a mix of `desired_state` and `previous_state` so ensure the payload is present for both
555
                previous_resource_properties = resource_properties
1✔
UNCOV
556
            case _:
×
557
                raise NotImplementedError(f"Action '{action}' not handled")
558

559
        resource_provider_payload: ResourceProviderPayload = {
1✔
560
            "awsAccountId": self._change_set.stack.account_id,
561
            "callbackContext": {},
562
            "stackId": self._change_set.stack.stack_name,
563
            "resourceType": resource_type,
564
            "resourceTypeVersion": "000000",
565
            # TODO: not actually a UUID
566
            "bearerToken": str(uuid.uuid4()),
567
            "region": self._change_set.stack.region_name,
568
            "action": str(action),
569
            "requestData": {
570
                "logicalResourceId": logical_resource_id,
571
                "resourceProperties": resource_properties,
572
                "previousResourceProperties": previous_resource_properties,
573
                "callerCredentials": creds,
574
                "providerCredentials": creds,
575
                "systemTags": {},
576
                "previousSystemTags": {},
577
                "stackTags": {},
578
                "previousStackTags": {},
579
            },
580
        }
581
        return resource_provider_payload
1✔
582

583
    @staticmethod
1✔
584
    def _replace_url_outputs_if_required(value: str) -> str:
1✔
585
        api_match = REGEX_OUTPUT_APIGATEWAY.match(value)
1✔
586
        if api_match and value not in config.CFN_STRING_REPLACEMENT_DENY_LIST:
1✔
587
            prefix = api_match[1]
1✔
588
            host = api_match[2]
1✔
589
            path = api_match[3]
1✔
590
            port = localstack_host().port
1✔
591
            value = f"{prefix}{host}:{port}/{path}"
1✔
592
            return value
1✔
593

594
        return value
1✔
595

596
    def _replace_url_outputs_in_delta_if_required(
1✔
597
        self, delta: PreprocEntityDelta
598
    ) -> PreprocEntityDelta:
599
        if isinstance(delta.before, str):
1✔
600
            delta.before = self._replace_url_outputs_if_required(delta.before)
1✔
601
        if isinstance(delta.after, str):
1✔
602
            delta.after = self._replace_url_outputs_if_required(delta.after)
1✔
603
        return delta
1✔
604

605
    def visit_terminal_value_created(
1✔
606
        self, value: TerminalValueCreated
607
    ) -> PreprocEntityDelta[str, str]:
608
        if isinstance(value.value, str):
1✔
609
            after = self._replace_url_outputs_if_required(value.value)
1✔
610
        else:
611
            after = value.value
1✔
612
        return PreprocEntityDelta(after=after)
1✔
613

614
    def visit_terminal_value_modified(
1✔
615
        self, value: TerminalValueModified
616
    ) -> PreprocEntityDelta[str, str]:
617
        # we only need to transform the after
618
        if isinstance(value.modified_value, str):
1✔
619
            after = self._replace_url_outputs_if_required(value.modified_value)
1✔
620
        else:
621
            after = value.modified_value
1✔
622
        return PreprocEntityDelta(before=value.value, after=after)
1✔
623

624
    def visit_terminal_value_unchanged(
1✔
625
        self, terminal_value_unchanged: TerminalValueUnchanged
626
    ) -> PreprocEntityDelta:
627
        if isinstance(terminal_value_unchanged.value, str):
1✔
628
            value = self._replace_url_outputs_if_required(terminal_value_unchanged.value)
1✔
629
        else:
630
            value = terminal_value_unchanged.value
1✔
631
        return PreprocEntityDelta(before=value, after=value)
1✔
632

633
    def visit_node_intrinsic_function_fn_join(
1✔
634
        self, node_intrinsic_function: NodeIntrinsicFunction
635
    ) -> PreprocEntityDelta:
636
        delta = super().visit_node_intrinsic_function_fn_join(node_intrinsic_function)
1✔
637
        return self._replace_url_outputs_in_delta_if_required(delta)
1✔
638

639
    def visit_node_intrinsic_function_fn_sub(
1✔
640
        self, node_intrinsic_function: NodeIntrinsicFunction
641
    ) -> PreprocEntityDelta:
642
        delta = super().visit_node_intrinsic_function_fn_sub(node_intrinsic_function)
1✔
643
        return self._replace_url_outputs_in_delta_if_required(delta)
1✔
644

645
    # TODO: other intrinsic functions
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