This is an automated email from the ASF dual-hosted git repository.
maximebeauchemin pushed a commit to branch folder-api
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/folder-api by this push:
new 165a1cd6d3 feat: export/import for dataset folders (#32534)
165a1cd6d3 is described below
commit 165a1cd6d3fa9eed70942604f89e5a79ef3818e0
Author: Beto Dealmeida <[email protected]>
AuthorDate: Thu Apr 10 13:57:47 2025 -0400
feat: export/import for dataset folders (#32534)
Co-authored-by: Maxime Beauchemin <[email protected]>
---
superset/commands/dataset/update.py | 19 ++-
superset/connectors/sqla/models.py | 1 +
superset/datasets/schemas.py | 6 +-
...25-03-03_20-52_94e7a3499973_add_folder_table.py | 42 ++++++
tests/integration_tests/datasets/api_tests.py | 14 ++
tests/integration_tests/datasets/commands_tests.py | 2 +
tests/unit_tests/commands/dataset/test_update.py | 96 ++++++++++++
tests/unit_tests/datasets/commands/export_test.py | 67 +++++++++
.../datasets/commands/importers/v1/import_test.py | 166 +++++++++++++++++++++
9 files changed, 407 insertions(+), 6 deletions(-)
diff --git a/superset/commands/dataset/update.py
b/superset/commands/dataset/update.py
index 7f6134d20a..e36fbc0bf1 100644
--- a/superset/commands/dataset/update.py
+++ b/superset/commands/dataset/update.py
@@ -222,8 +222,8 @@ def validate_folders( # noqa: C901
raise ValidationError("Dataset folders are not enabled")
existing = {
- *[metric.uuid for metric in metrics],
- *[column.uuid for column in columns],
+ "metric": {metric.uuid: metric.metric_name for metric in metrics},
+ "column": {column.uuid: column.column_name for column in columns},
}
queue: list[tuple[FolderSchema, list[str]]] = [(folder, []) for folder in
folders]
@@ -231,7 +231,7 @@ def validate_folders( # noqa: C901
seen_fqns = set() # fully qualified folder names
while queue:
obj, path = queue.pop(0)
- uuid, name = obj["uuid"], obj.get("name")
+ uuid, name, type = obj["uuid"], obj.get("name"), obj["type"]
if uuid in path:
raise ValidationError(f"Cycle detected: {uuid} appears in its
ancestry")
@@ -243,13 +243,22 @@ def validate_folders( # noqa: C901
# folders can have duplicate name as long as they're not siblings
if name:
fqn = tuple(path + [name])
- if name and fqn in seen_fqns:
+ if type == "folder" and fqn in seen_fqns:
raise ValidationError(f"Duplicate folder name: {name}")
seen_fqns.add(fqn)
- if name.lower() in {"metrics", "columns"}:
+ if type == "folder" and name.lower() in {
+ "metrics",
+ "columns",
+ }:
raise ValidationError(f"Folder cannot have name '{name}'")
+ if type in {"metric", "column"}:
+ if uuid not in existing[type]:
+ raise ValidationError(f"Invalid UUID for {type} '{name}':
{uuid}")
+ if name != existing[type][uuid]:
+ raise ValidationError(f"Mismatched name '{name}' for UUID
'{uuid}'")
+
# check if metric/column UUID exists
elif not name and uuid not in existing:
raise ValidationError(f"Invalid UUID: {uuid}")
diff --git a/superset/connectors/sqla/models.py
b/superset/connectors/sqla/models.py
index b143e09805..d74b2ef5c0 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -1220,6 +1220,7 @@ class SqlaTable(
"extra",
"normalize_columns",
"always_filter_main_dttm",
+ "folders",
]
update_from_object_fields = [f for f in export_fields if f !=
"database_id"]
export_parent = "database"
diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py
index deb6d4a355..c6e13325dd 100644
--- a/superset/datasets/schemas.py
+++ b/superset/datasets/schemas.py
@@ -20,7 +20,7 @@ from typing import Any
from dateutil.parser import isoparse
from flask_babel import lazy_gettext as _
from marshmallow import fields, pre_load, Schema, validates_schema,
ValidationError
-from marshmallow.validate import Length
+from marshmallow.validate import Length, OneOf
from superset.exceptions import SupersetMarshmallowValidationError
from superset.utils import json
@@ -90,6 +90,10 @@ class DatasetMetricsPutSchema(Schema):
class FolderSchema(Schema):
uuid = fields.UUID(required=True)
+ type = fields.String(
+ required=False,
+ validate=OneOf(["metric", "column", "folder"]),
+ )
name = fields.String(required=False, validate=Length(1, 250))
description = fields.String(
required=False,
diff --git
a/superset/migrations/versions/2025-03-03_20-52_94e7a3499973_add_folder_table.py
b/superset/migrations/versions/2025-03-03_20-52_94e7a3499973_add_folder_table.py
new file mode 100644
index 0000000000..e95e3bbac1
--- /dev/null
+++
b/superset/migrations/versions/2025-03-03_20-52_94e7a3499973_add_folder_table.py
@@ -0,0 +1,42 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+"""Add folder table
+
+Revision ID: 94e7a3499973
+Revises: 74ad1125881c
+Create Date: 2025-03-03 20:52:24.585143
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.types import JSON
+
+# revision identifiers, used by Alembic.
+revision = "94e7a3499973"
+down_revision = "74ad1125881c"
+
+
+def upgrade():
+ op.add_column(
+ "tables",
+ sa.Column("folders", JSON, nullable=True),
+ )
+
+
+def downgrade():
+ op.drop_column("tables", "folders")
diff --git a/tests/integration_tests/datasets/api_tests.py
b/tests/integration_tests/datasets/api_tests.py
index 97f22bbdd2..cb05260460 100644
--- a/tests/integration_tests/datasets/api_tests.py
+++ b/tests/integration_tests/datasets/api_tests.py
@@ -1574,24 +1574,31 @@ class TestDatasetApi(SupersetTestCase):
dataset_data = {
"folders": [
{
+ "type": "folder",
"uuid": "b49ac3dd-c79b-42a4-9082-39ee74f3b369",
"name": "My metrics",
"children": [
{
+ "type": "metric",
"uuid": dataset.metrics[0].uuid,
+ "name": dataset.metrics[0].metric_name,
},
],
},
{
+ "type": "folder",
"uuid": "f5db85fa-75d6-45e5-bdce-c6194db80642",
"name": "My columns",
"children": [
{
+ "type": "folder",
"uuid": "b5330233-e323-4157-b767-98b16f00ca93",
"name": "Dimensions",
"children": [
{
+ "type": "column",
"uuid": dataset.columns[1].uuid,
+ "name": dataset.columns[1].column_name,
},
],
},
@@ -1608,23 +1615,30 @@ class TestDatasetApi(SupersetTestCase):
assert model.folders == [
{
"uuid": "b49ac3dd-c79b-42a4-9082-39ee74f3b369",
+ "type": "folder",
"name": "My metrics",
"children": [
{
"uuid": str(dataset.metrics[0].uuid),
+ "type": "metric",
+ "name": "count",
}
],
},
{
"uuid": "f5db85fa-75d6-45e5-bdce-c6194db80642",
+ "type": "folder",
"name": "My columns",
"children": [
{
"uuid": "b5330233-e323-4157-b767-98b16f00ca93",
+ "type": "folder",
"name": "Dimensions",
"children": [
{
"uuid": str(dataset.columns[1].uuid),
+ "type": "column",
+ "name": "name",
}
],
}
diff --git a/tests/integration_tests/datasets/commands_tests.py
b/tests/integration_tests/datasets/commands_tests.py
index 2c0a9ce618..fb802bd1bf 100644
--- a/tests/integration_tests/datasets/commands_tests.py
+++ b/tests/integration_tests/datasets/commands_tests.py
@@ -171,6 +171,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"warning_text": None,
},
],
+ "folders": None,
"normalize_columns": False,
"always_filter_main_dttm": False,
"offset": 0,
@@ -235,6 +236,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"extra",
"normalize_columns",
"always_filter_main_dttm",
+ "folders",
"uuid",
"metrics",
"columns",
diff --git a/tests/unit_tests/commands/dataset/test_update.py
b/tests/unit_tests/commands/dataset/test_update.py
index 66dc1414c4..c38f978728 100644
--- a/tests/unit_tests/commands/dataset/test_update.py
+++ b/tests/unit_tests/commands/dataset/test_update.py
@@ -83,16 +83,23 @@ def test_validate_folders(mocker: MockerFixture) -> None:
[
{
"uuid": "uuid4",
+ "type": "folder",
"name": "My folder",
"children": [
{
"uuid": "uuid1",
+ "type": "metric",
+ "name": "metric1",
},
{
"uuid": "uuid2",
+ "type": "column",
+ "name": "column1",
},
{
"uuid": "uuid3",
+ "type": "column",
+ "name": "column2",
},
],
},
@@ -111,14 +118,17 @@ def test_validate_folders_cycle(mocker: MockerFixture) ->
None:
[
{
"uuid": "uuid1",
+ "type": "folder",
"name": "My folder",
"children": [
{
"uuid": "uuid2",
+ "type": "folder",
"name": "My other folder",
"children": [
{
"uuid": "uuid1",
+ "type": "folder",
"name": "My folder",
"children": [],
},
@@ -144,10 +154,12 @@ def test_validate_folders_inter_cycle(mocker:
MockerFixture) -> None:
[
{
"uuid": "uuid1",
+ "type": "folder",
"name": "My folder",
"children": [
{
"uuid": "uuid2",
+ "type": "folder",
"name": "My other folder",
"children": [],
},
@@ -155,10 +167,12 @@ def test_validate_folders_inter_cycle(mocker:
MockerFixture) -> None:
},
{
"uuid": "uuid2",
+ "type": "folder",
"name": "My other folder",
"children": [
{
"uuid": "uuid1",
+ "type": "folder",
"name": "My folder",
"children": [],
},
@@ -183,19 +197,25 @@ def test_validate_folders_duplicates(mocker:
MockerFixture) -> None:
[
{
"uuid": "uuid1",
+ "type": "folder",
"name": "My folder",
"children": [
{
"uuid": "uuid2",
+ "type": "metric",
+ "name": "count",
},
],
},
{
"uuid": "uuid2",
+ "type": "folder",
"name": "My other folder",
"children": [
{
"uuid": "uuid2",
+ "type": "metric",
+ "name": "count",
},
],
},
@@ -217,10 +237,12 @@ def
test_validate_folders_duplicate_name_not_siblings(mocker: MockerFixture) ->
[
{
"uuid": "uuid1",
+ "type": "folder",
"name": "Sales",
"children": [
{
"uuid": "uuid2",
+ "type": "folder",
"name": "Core",
"children": [],
},
@@ -228,10 +250,12 @@ def
test_validate_folders_duplicate_name_not_siblings(mocker: MockerFixture) ->
},
{
"uuid": "uuid3",
+ "type": "folder",
"name": "Engineering",
"children": [
{
"uuid": "uuid4",
+ "type": "folder",
"name": "Core",
"children": [],
},
@@ -253,10 +277,12 @@ def test_validate_folders_duplicate_name_siblings(mocker:
MockerFixture) -> None
[
{
"uuid": "uuid1",
+ "type": "folder",
"name": "Sales",
"children": [
{
"uuid": "uuid2",
+ "type": "folder",
"name": "Core",
"children": [],
},
@@ -264,10 +290,12 @@ def test_validate_folders_duplicate_name_siblings(mocker:
MockerFixture) -> None
},
{
"uuid": "uuid3",
+ "type": "folder",
"name": "Sales",
"children": [
{
"uuid": "uuid4",
+ "type": "folder",
"name": "Other",
"children": [],
},
@@ -291,6 +319,7 @@ def test_validate_folders_invalid_names(mocker:
MockerFixture) -> None:
[
{
"uuid": "uuid1",
+ "type": "folder",
"name": "Metrics",
"children": [],
},
@@ -301,6 +330,7 @@ def test_validate_folders_invalid_names(mocker:
MockerFixture) -> None:
[
{
"uuid": "uuid1",
+ "type": "folder",
"name": "Columns",
"children": [],
},
@@ -314,3 +344,69 @@ def test_validate_folders_invalid_names(mocker:
MockerFixture) -> None:
with pytest.raises(ValidationError) as excinfo:
validate_folders(folders=folders_with_columns, metrics=[], columns=[])
assert str(excinfo.value) == "Folder cannot have name 'Columns'"
+
+
+@with_feature_flags(DATASET_FOLDERS=True)
+def test_validate_folders_invalid_uuid(mocker: MockerFixture) -> None:
+ """
+ Test that we can detect invalid UUIDs.
+ """
+ metrics = [mocker.MagicMock(metric_name="metric1", uuid="uuid1")]
+ columns = [
+ mocker.MagicMock(column_name="column1", uuid="uuid2"),
+ mocker.MagicMock(column_name="column2", uuid="uuid3"),
+ ]
+ folders = cast(
+ list[FolderSchema],
+ [
+ {
+ "uuid": "uuid4",
+ "type": "folder",
+ "name": "My folder",
+ "children": [
+ {
+ "uuid": "uuid2",
+ "type": "metric",
+ "name": "metric1",
+ },
+ ],
+ },
+ ],
+ )
+
+ with pytest.raises(ValidationError) as excinfo:
+ validate_folders(folders=folders, metrics=metrics, columns=columns)
+ assert str(excinfo.value) == "Invalid UUID for metric 'metric1': uuid2"
+
+
+@with_feature_flags(DATASET_FOLDERS=True)
+def test_validate_folders_mismatched_name(mocker: MockerFixture) -> None:
+ """
+ Test that we can detect mismatched names.
+ """
+ metrics = [mocker.MagicMock(metric_name="metric1", uuid="uuid1")]
+ columns = [
+ mocker.MagicMock(column_name="column1", uuid="uuid2"),
+ mocker.MagicMock(column_name="column2", uuid="uuid3"),
+ ]
+ folders = cast(
+ list[FolderSchema],
+ [
+ {
+ "uuid": "uuid4",
+ "type": "folder",
+ "name": "My folder",
+ "children": [
+ {
+ "uuid": "uuid1",
+ "type": "metric",
+ "name": "metric2",
+ },
+ ],
+ },
+ ],
+ )
+
+ with pytest.raises(ValidationError) as excinfo:
+ validate_folders(folders=folders, metrics=metrics, columns=columns)
+ assert str(excinfo.value) == "Mismatched name 'metric2' for UUID 'uuid1'"
diff --git a/tests/unit_tests/datasets/commands/export_test.py
b/tests/unit_tests/datasets/commands/export_test.py
index f42bbfc9cb..04bc989ba5 100644
--- a/tests/unit_tests/datasets/commands/export_test.py
+++ b/tests/unit_tests/datasets/commands/export_test.py
@@ -16,6 +16,8 @@
# under the License.
# pylint: disable=import-outside-toplevel, unused-argument, unused-import
+from uuid import UUID
+
from sqlalchemy.orm.session import Session
from superset import db
@@ -47,6 +49,7 @@ def test_export(session: Session) -> None:
type="INTEGER",
expression="revenue-expenses",
extra=json.dumps({"certified_by": "User"}),
+ uuid=UUID("00000000-0000-0000-0000-000000000005"),
),
]
metrics = [
@@ -54,6 +57,7 @@ def test_export(session: Session) -> None:
metric_name="cnt",
expression="COUNT(*)",
extra=json.dumps({"warning_markdown": None}),
+ uuid=UUID("00000000-0000-0000-0000-000000000004"),
),
]
@@ -61,6 +65,46 @@ def test_export(session: Session) -> None:
table_name="my_table",
columns=columns,
metrics=metrics,
+ folders=[
+ {
+ "uuid": "00000000-0000-0000-0000-000000000000",
+ "type": "folder",
+ "name": "Engineering",
+ "children": [
+ {
+ "uuid": "00000000-0000-0000-0000-000000000001",
+ "type": "folder",
+ "name": "Core",
+ "children": [
+ {
+ "uuid": "00000000-0000-0000-0000-000000000004",
+ "type": "metric",
+ "name": "cnt",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ "uuid": "00000000-0000-0000-0000-000000000002",
+ "type": "folder",
+ "name": "Sales",
+ "children": [
+ {
+ "uuid": "00000000-0000-0000-0000-000000000003",
+ "type": "folder",
+ "name": "Core",
+ "children": [
+ {
+ "uuid": "00000000-0000-0000-0000-000000000005",
+ "type": "column",
+ "name": "profit",
+ },
+ ],
+ },
+ ],
+ },
+ ],
main_dttm_col="ds",
database=database,
offset=-8,
@@ -126,6 +170,29 @@ extra:
warning_markdown: '*WARNING*'
normalize_columns: false
always_filter_main_dttm: false
+folders:
+- uuid: 00000000-0000-0000-0000-000000000000
+ type: folder
+ name: Engineering
+ children:
+ - uuid: 00000000-0000-0000-0000-000000000001
+ type: folder
+ name: Core
+ children:
+ - uuid: 00000000-0000-0000-0000-000000000004
+ type: metric
+ name: cnt
+- uuid: 00000000-0000-0000-0000-000000000002
+ type: folder
+ name: Sales
+ children:
+ - uuid: 00000000-0000-0000-0000-000000000003
+ type: folder
+ name: Core
+ children:
+ - uuid: 00000000-0000-0000-0000-000000000005
+ type: column
+ name: profit
uuid: {payload["uuid"]}
metrics:
- metric_name: cnt
diff --git a/tests/unit_tests/datasets/commands/importers/v1/import_test.py
b/tests/unit_tests/datasets/commands/importers/v1/import_test.py
index 6bba6f039d..2d8c0ede4d 100644
--- a/tests/unit_tests/datasets/commands/importers/v1/import_test.py
+++ b/tests/unit_tests/datasets/commands/importers/v1/import_test.py
@@ -89,6 +89,7 @@ def test_import_dataset(mocker: MockerFixture, session:
Session) -> None:
"d3format": None,
"extra": {"warning_markdown": None},
"warning_text": None,
+ "uuid": "00000000-0000-0000-0000-000000000001",
}
],
"columns": [
@@ -106,8 +107,49 @@ def test_import_dataset(mocker: MockerFixture, session:
Session) -> None:
"extra": {
"certified_by": "User",
},
+ "uuid": "00000000-0000-0000-0000-000000000002",
}
],
+ "folders": [
+ {
+ "uuid": "00000000-0000-0000-0000-000000000000",
+ "type": "folder",
+ "name": "Engineering",
+ "children": [
+ {
+ "uuid": "00000000-0000-0000-0000-000000000001",
+ "type": "folder",
+ "name": "Core",
+ "children": [
+ {
+ "uuid": "00000000-0000-0000-0000-000000000004",
+ "type": "metric",
+ "name": "cnt",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ "uuid": "00000000-0000-0000-0000-000000000002",
+ "type": "folder",
+ "name": "Sales",
+ "children": [
+ {
+ "uuid": "00000000-0000-0000-0000-000000000003",
+ "type": "folder",
+ "name": "Core",
+ "children": [
+ {
+ "uuid": "00000000-0000-0000-0000-000000000005",
+ "type": "column",
+ "name": "profit",
+ },
+ ],
+ },
+ ],
+ },
+ ],
"database_uuid": database.uuid,
"database_id": database.id,
}
@@ -139,6 +181,9 @@ def test_import_dataset(mocker: MockerFixture, session:
Session) -> None:
assert sqla_table.metrics[0].d3format is None
assert sqla_table.metrics[0].extra == '{"warning_markdown": null}'
assert sqla_table.metrics[0].warning_text is None
+ assert sqla_table.metrics[0].uuid == uuid.UUID(
+ "00000000-0000-0000-0000-000000000001"
+ )
assert len(sqla_table.columns) == 1
assert sqla_table.columns[0].column_name == "profit"
assert sqla_table.columns[0].verbose_name is None
@@ -151,10 +196,131 @@ def test_import_dataset(mocker: MockerFixture, session:
Session) -> None:
assert sqla_table.columns[0].description is None
assert sqla_table.columns[0].python_date_format is None
assert sqla_table.columns[0].extra == '{"certified_by": "User"}'
+ assert sqla_table.columns[0].uuid == uuid.UUID(
+ "00000000-0000-0000-0000-000000000002"
+ )
+ assert sqla_table.folders == [
+ {
+ "uuid": "00000000-0000-0000-0000-000000000000",
+ "type": "folder",
+ "name": "Engineering",
+ "children": [
+ {
+ "uuid": "00000000-0000-0000-0000-000000000001",
+ "type": "folder",
+ "name": "Core",
+ "children": [
+ {
+ "uuid": "00000000-0000-0000-0000-000000000004",
+ "type": "metric",
+ "name": "cnt",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ "uuid": "00000000-0000-0000-0000-000000000002",
+ "type": "folder",
+ "name": "Sales",
+ "children": [
+ {
+ "uuid": "00000000-0000-0000-0000-000000000003",
+ "type": "folder",
+ "name": "Core",
+ "children": [
+ {
+ "uuid": "00000000-0000-0000-0000-000000000005",
+ "type": "column",
+ "name": "profit",
+ },
+ ],
+ },
+ ],
+ },
+ ]
assert sqla_table.database.uuid == database.uuid
assert sqla_table.database.id == database.id
+def test_import_dataset_no_folder(mocker: MockerFixture, session: Session) ->
None:
+ """
+ Test importing a dataset that was exported without folders.
+ """
+ from superset import security_manager
+ from superset.commands.dataset.importers.v1.utils import import_dataset
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.models.core import Database
+
+ mocker.patch.object(security_manager, "can_access", return_value=True)
+
+ engine = db.session.get_bind()
+ SqlaTable.metadata.create_all(engine) # pylint: disable=no-member
+
+ database = Database(database_name="my_database",
sqlalchemy_uri="sqlite://")
+ db.session.add(database)
+ db.session.flush()
+
+ dataset_uuid = uuid.uuid4()
+ config = {
+ "table_name": "my_table",
+ "main_dttm_col": "ds",
+ "description": "This is the description",
+ "default_endpoint": None,
+ "offset": -8,
+ "cache_timeout": 3600,
+ "catalog": "public",
+ "schema": "my_schema",
+ "sql": None,
+ "params": {
+ "remote_id": 64,
+ "database_name": "examples",
+ "import_time": 1606677834,
+ },
+ "template_params": {
+ "answer": "42",
+ },
+ "filter_select_enabled": True,
+ "fetch_values_predicate": "foo IN (1, 2)",
+ "extra": {"warning_markdown": "*WARNING*"},
+ "uuid": dataset_uuid,
+ "metrics": [
+ {
+ "metric_name": "cnt",
+ "verbose_name": None,
+ "metric_type": None,
+ "expression": "COUNT(*)",
+ "description": None,
+ "d3format": None,
+ "extra": {"warning_markdown": None},
+ "warning_text": None,
+ }
+ ],
+ "columns": [
+ {
+ "column_name": "profit",
+ "verbose_name": None,
+ "is_dttm": None,
+ "is_active": None,
+ "type": "INTEGER",
+ "groupby": None,
+ "filterable": None,
+ "expression": "revenue-expenses",
+ "description": None,
+ "python_date_format": None,
+ "extra": {
+ "certified_by": "User",
+ },
+ }
+ ],
+ "database_uuid": database.uuid,
+ "database_id": database.id,
+ }
+
+ sqla_table = import_dataset(config)
+ assert sqla_table.folders is None
+
+
def test_import_dataset_duplicate_column(
mocker: MockerFixture, session: Session
) -> None: