From a2e711bc66820a6899cdd67468005de490404361 Mon Sep 17 00:00:00 2001 From: Michel El Nacouzi Date: Fri, 13 Dec 2024 16:35:01 -0500 Subject: [PATCH] Add release channels list support --- RELEASE-NOTES.md | 1 + .../cli/_plugins/nativeapp/commands.py | 4 + .../nativeapp/entities/application_package.py | 128 ++++++++++ .../nativeapp/release_channel/__init__.py | 13 + .../nativeapp/release_channel/commands.py | 71 ++++++ tests/__snapshots__/test_help_messages.ambr | 136 +++++++++++ .../test_application_package_entity.ambr | 57 +++++ .../test_application_package_entity.py | 226 +++++++++++++++++- 8 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 src/snowflake/cli/_plugins/nativeapp/release_channel/__init__.py create mode 100644 src/snowflake/cli/_plugins/nativeapp/release_channel/commands.py create mode 100644 tests/nativeapp/__snapshots__/test_application_package_entity.ambr diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index f158788b7c..a3fc8c01db 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -26,6 +26,7 @@ * Add support for release channels feature in native app version creation/drop. * `snow app version create` now returns version, patch, and label in JSON format. * Add ability to specify release channel when creating application instance from release directive: `snow app run --from-release-directive --channel=` +* Add ability to list release channels through `snow app release-channel list` command ## Fixes and improvements * Fixed crashes with older x86_64 Intel CPUs. diff --git a/src/snowflake/cli/_plugins/nativeapp/commands.py b/src/snowflake/cli/_plugins/nativeapp/commands.py index beb62bfee7..d33c0d2018 100644 --- a/src/snowflake/cli/_plugins/nativeapp/commands.py +++ b/src/snowflake/cli/_plugins/nativeapp/commands.py @@ -31,6 +31,9 @@ from snowflake.cli._plugins.nativeapp.entities.application_package import ( ApplicationPackageEntityModel, ) +from snowflake.cli._plugins.nativeapp.release_channel.commands import ( + app as release_channels_app, +) from snowflake.cli._plugins.nativeapp.release_directive.commands import ( app as release_directives_app, ) @@ -71,6 +74,7 @@ ) app.add_typer(versions_app) app.add_typer(release_directives_app) +app.add_typer(release_channels_app) log = logging.getLogger(__name__) diff --git a/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py b/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py index 8f76c7ac4c..8580c5ce16 100644 --- a/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py +++ b/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py @@ -3,6 +3,7 @@ import json import os import re +from datetime import datetime from pathlib import Path from textwrap import dedent from typing import Any, List, Literal, Optional, Set, Union @@ -65,6 +66,7 @@ ) from snowflake.cli._plugins.workspace.context import ActionContext from snowflake.cli.api.cli_global_context import span +from snowflake.cli.api.console.abc import AbstractConsole from snowflake.cli.api.entities.common import ( EntityBase, attach_spans_to_entity_actions, @@ -134,6 +136,99 @@ def ensure_app_roles_is_a_set( return application_roles +class ReleaseChannel(dict[str, Any]): + """ + Represents a release channel. + + This class is a dictionary with the following keys: + - name: The name of the release channel. + - description: The description of the release channel. + - created_on: The timestamp when the release channel was created. + - updated_on: The timestamp when the release channel was last updated. + - targets: The target accounts for the release channel. + - versions: The versions added to the release channel. + """ + + def __init__(self, data: dict[str, Any]): + targets = json.loads(str(data.get("targets"))) if data.get("targets") else {} + versions = json.loads(str(data.get("versions"))) if data.get("versions") else [] + + super().__init__( + { + "name": data.get("name") or "", + "description": data.get("description") or "", + "created_on": data.get("created_on"), + "updated_on": data.get("updated_on"), + "targets": targets, + "versions": versions, + } + ) + + @property + def name(self) -> str: + return self.get("name") # type: ignore + + @property + def description(self) -> str: + return self.get("description") # type: ignore + + @property + def created_on(self) -> Optional[datetime]: + return self.get("created_on") # type: ignore + + @property + def updated_on(self) -> Optional[datetime]: + return self.get("updated_on") # type: ignore + + @property + def targets(self) -> dict[str, Any]: + return self.get("targets") # type: ignore + + @property + def versions(self) -> list[str]: + return self.get("versions") # type: ignore + + def print_to_console(self, console: AbstractConsole) -> None: + """ + Prints the release channel details to the console. + """ + console.message(f"""[bold]{self.name}[/bold]""") + accounts_list: Optional[list[str]] = self.targets.get("accounts") + target_accounts = ( + f"({', '.join(accounts_list)})" + if accounts_list is not None + else "ALL ACCOUNTS" + ) + + formatted_created_on = ( + self.created_on.astimezone().strftime("%Y-%m-%d %H:%M:%S.%f %Z") + if self.created_on + else "" + ) + + formatted_updated_on = ( + self.updated_on.astimezone().strftime("%Y-%m-%d %H:%M:%S.%f %Z") + if self.updated_on + else "" + ) + with console.indented(): + console.message(f"Description: {self.description}") + console.message(f"Versions: ({', '.join(self.versions)})") + console.message(f"Created on: {formatted_created_on}") + console.message(f"Updated on: {formatted_updated_on}") + console.message(f"Target accounts: {target_accounts}") + + def matches_identifier(self, identifier: Optional[str]) -> bool: + """ + Checks if the release channel matches the provided identifier. + If the identifier is None, it matches everything. + """ + if identifier is None: + return True + + return same_identifiers(self.name, identifier) + + class ApplicationPackageChildField(UpdatableModel): target: str = Field(title="The key of the entity to include in this package") ensure_usable_by: Optional[EnsureUsableByField] = Field( @@ -766,6 +861,39 @@ def action_release_directive_unset( role=self.role, ) + def action_release_channel_list( + self, + action_ctx: ActionContext, + release_channel: Optional[str] = None, + *args, + **kwargs, + ) -> list[ReleaseChannel]: + """ + Get all existing release channels for an application package. + If `release_channel` is provided, only the specified release channel is listed. + """ + console = self._workspace_ctx.console + available_channels = [ + ReleaseChannel(channel) + for channel in get_snowflake_facade().show_release_channels( + self.name, self.role + ) + ] + + filtered_channels = [ + channel + for channel in available_channels + if channel.matches_identifier(release_channel) + ] + + if not filtered_channels: + console.message("No release channels found.") + else: + for channel in filtered_channels: + channel.print_to_console(console) + + return filtered_channels + def _bundle(self, action_ctx: ActionContext = None): model = self._entity_model bundle_map = build_bundle(self.project_root, self.deploy_root, model.artifacts) diff --git a/src/snowflake/cli/_plugins/nativeapp/release_channel/__init__.py b/src/snowflake/cli/_plugins/nativeapp/release_channel/__init__.py new file mode 100644 index 0000000000..ada0a4e13d --- /dev/null +++ b/src/snowflake/cli/_plugins/nativeapp/release_channel/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2024 Snowflake Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/src/snowflake/cli/_plugins/nativeapp/release_channel/commands.py b/src/snowflake/cli/_plugins/nativeapp/release_channel/commands.py new file mode 100644 index 0000000000..4614f6e06b --- /dev/null +++ b/src/snowflake/cli/_plugins/nativeapp/release_channel/commands.py @@ -0,0 +1,71 @@ +# Copyright (c) 2024 Snowflake Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import logging +from typing import Optional + +import typer +from snowflake.cli._plugins.nativeapp.v2_conversions.compat import ( + force_project_definition_v2, +) +from snowflake.cli._plugins.workspace.manager import WorkspaceManager +from snowflake.cli.api.cli_global_context import get_cli_context +from snowflake.cli.api.commands.decorators import with_project_definition +from snowflake.cli.api.commands.snow_typer import SnowTyperFactory +from snowflake.cli.api.entities.common import EntityActions +from snowflake.cli.api.output.formats import OutputFormat +from snowflake.cli.api.output.types import ( + CollectionResult, + CommandResult, +) + +app = SnowTyperFactory( + name="release-channel", + help="Manages release channels of an application package", +) + +log = logging.getLogger(__name__) + + +@app.command("list", requires_connection=True) +@with_project_definition() +@force_project_definition_v2() +def release_channel_list( + channel: Optional[str] = typer.Argument( + default=None, + show_default=False, + help="The release channel to list. If not provided, all release channels are listed.", + ), + **options, +) -> CommandResult: + """ + Lists the release channels available for an application package. + """ + + cli_context = get_cli_context() + ws = WorkspaceManager( + project_definition=cli_context.project_definition, + project_root=cli_context.project_root, + ) + package_id = options["package_entity_id"] + channels = ws.perform_action( + package_id, + EntityActions.RELEASE_CHANNEL_LIST, + release_channel=channel, + ) + + if cli_context.output_format == OutputFormat.JSON: + return CollectionResult(channels) diff --git a/tests/__snapshots__/test_help_messages.ambr b/tests/__snapshots__/test_help_messages.ambr index e367fb918f..91f09a4acf 100644 --- a/tests/__snapshots__/test_help_messages.ambr +++ b/tests/__snapshots__/test_help_messages.ambr @@ -578,6 +578,123 @@ +------------------------------------------------------------------------------+ + ''' +# --- +# name: test_help_messages[app.release-channel.list] + ''' + + Usage: default app release-channel list [OPTIONS] [CHANNEL] + + Lists the release channels available for an application package. + + +- Arguments ------------------------------------------------------------------+ + | channel [CHANNEL] The release channel to list. If not provided, all | + | release channels are listed. | + +------------------------------------------------------------------------------+ + +- Options --------------------------------------------------------------------+ + | --package-entity-id TEXT The ID of the package entity on which to | + | operate when definition_version is 2 or | + | higher. | + | --app-entity-id TEXT The ID of the application entity on which | + | to operate when definition_version is 2 | + | or higher. | + | --project -p TEXT Path where Snowflake project resides. | + | Defaults to current working directory. | + | --env TEXT String in format of key=value. Overrides | + | variables from env section used for | + | templates. | + | --help -h Show this message and exit. | + +------------------------------------------------------------------------------+ + +- Connection configuration ---------------------------------------------------+ + | --connection,--environment -c TEXT Name of the connection, as | + | defined in your config.toml | + | file. Default: default. | + | --host TEXT Host address for the | + | connection. Overrides the | + | value specified for the | + | connection. | + | --port INTEGER Port for the connection. | + | Overrides the value | + | specified for the | + | connection. | + | --account,--accountname TEXT Name assigned to your | + | Snowflake account. Overrides | + | the value specified for the | + | connection. | + | --user,--username TEXT Username to connect to | + | Snowflake. Overrides the | + | value specified for the | + | connection. | + | --password TEXT Snowflake password. | + | Overrides the value | + | specified for the | + | connection. | + | --authenticator TEXT Snowflake authenticator. | + | Overrides the value | + | specified for the | + | connection. | + | --private-key-file,--privateā€¦ TEXT Snowflake private key file | + | path. Overrides the value | + | specified for the | + | connection. | + | --token-file-path TEXT Path to file with an OAuth | + | token that should be used | + | when connecting to Snowflake | + | --database,--dbname TEXT Database to use. Overrides | + | the value specified for the | + | connection. | + | --schema,--schemaname TEXT Database schema to use. | + | Overrides the value | + | specified for the | + | connection. | + | --role,--rolename TEXT Role to use. Overrides the | + | value specified for the | + | connection. | + | --warehouse TEXT Warehouse to use. Overrides | + | the value specified for the | + | connection. | + | --temporary-connection -x Uses connection defined with | + | command line parameters, | + | instead of one defined in | + | config | + | --mfa-passcode TEXT Token to use for | + | multi-factor authentication | + | (MFA) | + | --enable-diag Run Python connector | + | diagnostic test | + | --diag-log-path TEXT Diagnostic report path | + | --diag-allowlist-path TEXT Diagnostic report path to | + | optional allowlist | + +------------------------------------------------------------------------------+ + +- Global configuration -------------------------------------------------------+ + | --format [TABLE|JSON] Specifies the output format. | + | [default: TABLE] | + | --verbose -v Displays log entries for log levels info | + | and higher. | + | --debug Displays log entries for log levels debug | + | and higher; debug logs contain additional | + | information. | + | --silent Turns off intermediate output to console. | + +------------------------------------------------------------------------------+ + + + ''' +# --- +# name: test_help_messages[app.release-channel] + ''' + + Usage: default app release-channel [OPTIONS] COMMAND [ARGS]... + + Manages release channels of an application package + + +- Options --------------------------------------------------------------------+ + | --help -h Show this message and exit. | + +------------------------------------------------------------------------------+ + +- Commands -------------------------------------------------------------------+ + | list Lists the release channels available for an application package. | + +------------------------------------------------------------------------------+ + + ''' # --- # name: test_help_messages[app.release-directive.list] @@ -1777,6 +1894,7 @@ | configured in Snowflake. | | open Opens the Snowflake Native App inside of your browser, | | once it has been installed in your account. | + | release-channel Manages release channels of an application package | | release-directive Manages release directives of an application package | | run Creates an application package in your Snowflake | | account, uploads code files to its stage, then creates | @@ -10122,6 +10240,23 @@ +------------------------------------------------------------------------------+ + ''' +# --- +# name: test_help_messages_no_help_flag[app.release-channel] + ''' + + Usage: default app release-channel [OPTIONS] COMMAND [ARGS]... + + Manages release channels of an application package + + +- Options --------------------------------------------------------------------+ + | --help -h Show this message and exit. | + +------------------------------------------------------------------------------+ + +- Commands -------------------------------------------------------------------+ + | list Lists the release channels available for an application package. | + +------------------------------------------------------------------------------+ + + ''' # --- # name: test_help_messages_no_help_flag[app.release-directive] @@ -10190,6 +10325,7 @@ | configured in Snowflake. | | open Opens the Snowflake Native App inside of your browser, | | once it has been installed in your account. | + | release-channel Manages release channels of an application package | | release-directive Manages release directives of an application package | | run Creates an application package in your Snowflake | | account, uploads code files to its stage, then creates | diff --git a/tests/nativeapp/__snapshots__/test_application_package_entity.ambr b/tests/nativeapp/__snapshots__/test_application_package_entity.ambr new file mode 100644 index 0000000000..4a66978a66 --- /dev/null +++ b/tests/nativeapp/__snapshots__/test_application_package_entity.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_given_no_release_channels_when_list_release_channels_then_success + ''' + No release channels found. + + ''' +# --- +# name: test_given_release_channel_with_missing_info_when_list_release_channels_then_success + ''' + channel1 + Description: + Versions: () + Created on: + Updated on: + Target accounts: ALL ACCOUNTS + + ''' +# --- +# name: test_given_release_channel_with_no_target_account_then_show_all_accounts_in_snapshot + ''' + channel1 + Description: desc + Versions: (v1, v2) + Created on: 2024-12-03 00:00:00.000000 + Updated on: 2024-12-05 00:00:00.000000 + Target accounts: ALL ACCOUNTS + + ''' +# --- +# name: test_given_release_channels_with_a_selected_channel_to_filter_when_list_release_channels_then_returned_selected_channel + ''' + chAnnEl1 + Description: desc + Versions: (v1, v2) + Created on: 2024-12-03 00:00:00.000000 + Updated on: 2024-12-05 00:00:00.000000 + Target accounts: (org1.acc1, org2.acc2) + + ''' +# --- +# name: test_given_release_channels_with_proper_values_when_list_release_channels_then_success + ''' + channel1 + Description: desc + Versions: (v1, v2) + Created on: 2024-12-03 00:00:00.000000 + Updated on: 2024-12-05 00:00:00.000000 + Target accounts: (org1.acc1, org2.acc2) + channel2 + Description: desc2 + Versions: (v3) + Created on: 2024-12-03 00:00:00.000000 + Updated on: 2024-12-05 00:00:00.000000 + Target accounts: (org3.acc3) + + ''' +# --- diff --git a/tests/nativeapp/test_application_package_entity.py b/tests/nativeapp/test_application_package_entity.py index 0772a5ada0..9cdb4276ea 100644 --- a/tests/nativeapp/test_application_package_entity.py +++ b/tests/nativeapp/test_application_package_entity.py @@ -13,6 +13,7 @@ # limitations under the License. from __future__ import annotations +from datetime import datetime from pathlib import Path from unittest import mock @@ -29,6 +30,7 @@ ApplicationPackageEntityModel, ) from snowflake.cli._plugins.workspace.context import ActionContext, WorkspaceContext +from snowflake.cli.api.console import cli_console from snowflake.connector.cursor import DictCursor from tests.nativeapp.utils import ( @@ -824,7 +826,7 @@ def test_given_channels_enabled_and_non_existing_channel_selected_when_release_d @mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[{"name": "default"}]) @mock.patch(SQL_FACADE_UNSET_RELEASE_DIRECTIVE) -def test_given_default_directive_selected_when_directive_unset_then_error( +def test_given_default_directive_selected_when_release_directive_unset_then_error( unset_release_directive, show_release_channels, application_package_entity, @@ -848,3 +850,225 @@ def test_given_default_directive_selected_when_directive_unset_then_error( show_release_channels.assert_not_called() unset_release_directive.assert_not_called() + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +def test_given_release_channels_with_proper_values_when_list_release_channels_then_success( + show_release_channels, + application_package_entity, + action_context, + capsys, + os_agnostic_snapshot, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + application_package_entity._workspace_ctx.console = cli_console # noqa SLF001 + + pkg_model.meta.role = "package_role" + + created_on_mock = mock.MagicMock() + updated_on_mock = mock.MagicMock() + created_on_mock.astimezone.return_value = datetime(2024, month=12, day=3) + updated_on_mock.astimezone.return_value = datetime(2024, month=12, day=5) + + show_release_channels.return_value = [ + { + "name": "channel1", + "description": "desc", + "created_on": created_on_mock, + "updated_on": updated_on_mock, + "extra_fields": "any", + "versions": '["v1", "v2"]', + "targets": '{"accounts": ["org1.acc1", "org2.acc2"]}', + }, + { + "name": "channel2", + "description": "desc2", + "created_on": created_on_mock, + "updated_on": updated_on_mock, + "extra_fields": "any", + "versions": '["v3"]', + "targets": '{"accounts": ["org3.acc3"]}', + }, + ] + + result = application_package_entity.action_release_channel_list(action_context) + captured = capsys.readouterr() + + assert result == [ + { + "name": "channel1", + "description": "desc", + "created_on": created_on_mock, + "updated_on": updated_on_mock, + "versions": ["v1", "v2"], + "targets": {"accounts": ["org1.acc1", "org2.acc2"]}, + }, + { + "name": "channel2", + "description": "desc2", + "created_on": created_on_mock, + "updated_on": updated_on_mock, + "versions": ["v3"], + "targets": {"accounts": ["org3.acc3"]}, + }, + ] + assert captured.out == os_agnostic_snapshot + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +def test_given_release_channel_with_no_target_account_then_show_all_accounts_in_snapshot( + show_release_channels, + application_package_entity, + action_context, + capsys, + os_agnostic_snapshot, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + application_package_entity._workspace_ctx.console = cli_console # noqa SLF001 + + pkg_model.meta.role = "package_role" + + created_on_mock = mock.MagicMock() + updated_on_mock = mock.MagicMock() + created_on_mock.astimezone.return_value = datetime(2024, month=12, day=3) + updated_on_mock.astimezone.return_value = datetime(2024, month=12, day=5) + + show_release_channels.return_value = [ + { + "name": "channel1", + "description": "desc", + "created_on": created_on_mock, + "updated_on": updated_on_mock, + "extra_fields": "any", + "versions": '["v1", "v2"]', + "targets": None, + } + ] + + result = application_package_entity.action_release_channel_list(action_context) + captured = capsys.readouterr() + + assert result == [ + { + "name": "channel1", + "description": "desc", + "created_on": created_on_mock, + "updated_on": updated_on_mock, + "versions": ["v1", "v2"], + "targets": {}, + } + ] + assert captured.out == os_agnostic_snapshot + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +def test_given_no_release_channels_when_list_release_channels_then_success( + show_release_channels, + application_package_entity, + action_context, + capsys, + os_agnostic_snapshot, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + application_package_entity._workspace_ctx.console = cli_console # noqa SLF001 + + pkg_model.meta.role = "package_role" + + show_release_channels.return_value = [] + + result = application_package_entity.action_release_channel_list(action_context) + captured = capsys.readouterr() + + assert result == [] + assert captured.out == os_agnostic_snapshot + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +def test_given_release_channel_with_missing_info_when_list_release_channels_then_success( + show_release_channels, + application_package_entity, + action_context, + capsys, + os_agnostic_snapshot, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + application_package_entity._workspace_ctx.console = cli_console # noqa SLF001 + + pkg_model.meta.role = "package_role" + + show_release_channels.return_value = [ + { + "name": "channel1", + } + ] + + result = application_package_entity.action_release_channel_list(action_context) + captured = capsys.readouterr() + + assert result == [ + { + "name": "channel1", + "description": "", + "created_on": None, + "updated_on": None, + "versions": [], + "targets": {}, + } + ] + assert captured.out == os_agnostic_snapshot + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS) +def test_given_release_channels_with_a_selected_channel_to_filter_when_list_release_channels_then_returned_selected_channel( + show_release_channels, + application_package_entity, + action_context, + capsys, + os_agnostic_snapshot, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + application_package_entity._workspace_ctx.console = cli_console # noqa SLF001 + + pkg_model.meta.role = "package_role" + + created_on_mock = mock.MagicMock() + updated_on_mock = mock.MagicMock() + created_on_mock.astimezone.return_value = datetime(2024, month=12, day=3) + updated_on_mock.astimezone.return_value = datetime(2024, month=12, day=5) + + show_release_channels.return_value = [ + { + "name": "chAnnEl1", + "description": "desc", + "created_on": created_on_mock, + "updated_on": updated_on_mock, + "extra_fields": "any", + "versions": '["v1", "v2"]', + "targets": '{"accounts": ["org1.acc1", "org2.acc2"]}', + }, + { + "name": "chANnel2", + "description": "desc2", + "created_on": created_on_mock, + "updated_on": updated_on_mock, + "extra_fields": "any", + "versions": '["v3"]', + "targets": '{"accounts": ["org3.acc3"]}', + }, + ] + + result = application_package_entity.action_release_channel_list( + action_context, release_channel="channel1" + ) + + assert result == [ + { + "name": "chAnnEl1", + "description": "desc", + "created_on": created_on_mock, + "updated_on": updated_on_mock, + "versions": ["v1", "v2"], + "targets": {"accounts": ["org1.acc1", "org2.acc2"]}, + } + ] + assert capsys.readouterr().out == os_agnostic_snapshot