This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new ad7fcac11f9 fix(export): stamp ZIP entries with the export time
(#44391)
ad7fcac11f9 is described below
commit ad7fcac11f9b5b4cd6ee455df1afa60849addebc
Author: Devansh Khetan <[email protected]>
AuthorDate: Thu Sep 24 19:35:39 2026 +0530
fix(export): stamp ZIP entries with the export time (#44391)
---
superset/charts/api.py | 7 +--
superset/cli/importexport.py | 8 ++-
superset/dashboards/api.py | 6 ++-
superset/databases/api.py | 6 ++-
superset/datasets/api.py | 11 ++--
superset/importexport/api.py | 9 ++--
superset/queries/saved_queries/api.py | 7 +--
superset/themes/api.py | 7 +--
superset/utils/core.py | 27 ++++++++--
tests/unit_tests/utils/create_zip_tests.py | 84 ++++++++++++++++++++++++++++++
10 files changed, 144 insertions(+), 28 deletions(-)
diff --git a/superset/charts/api.py b/superset/charts/api.py
index 64f7ef3dbc6..fdcc0d6a2d8 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -122,7 +122,7 @@ from superset.subjects.filters import (
from superset.tasks.thumbnails import cache_chart_thumbnail
from superset.tasks.utils import get_current_user
from superset.utils import json
-from superset.utils.core import send_export_zip
+from superset.utils.core import send_export_zip, write_zip_entry
from superset.utils.screenshots import (
ChartScreenshot,
DEFAULT_CHART_WINDOW_SIZE,
@@ -1419,8 +1419,9 @@ class ChartRestApi(SoftDeleteApiMixin,
BaseSupersetModelRestApi):
with ZipFile(buf, "w") as bundle:
try:
for file_name, file_content in
ExportChartsCommand(requested_ids).run():
- with bundle.open(f"{root}/{file_name}", "w") as fp:
- fp.write(file_content().encode())
+ write_zip_entry(
+ bundle, f"{root}/{file_name}", file_content().encode()
+ )
except ChartNotFoundError:
return self.response_404()
buf.seek(0)
diff --git a/superset/cli/importexport.py b/superset/cli/importexport.py
index f271e9318f0..bffa2961242 100755
--- a/superset/cli/importexport.py
+++ b/superset/cli/importexport.py
@@ -28,7 +28,7 @@ from flask.cli import with_appcontext
from superset import security_manager
from superset.extensions import db
-from superset.utils.core import override_user
+from superset.utils.core import override_user, write_zip_entry
logger = logging.getLogger(__name__)
@@ -105,8 +105,7 @@ def export_dashboards(dashboard_file: Optional[str] = None)
-> None:
try:
with ZipFile(dashboard_file, "w") as bundle:
for file_name, file_content in
ExportDashboardsCommand(dashboard_ids).run():
- with bundle.open(f"{root}/{file_name}", "w") as fp:
- fp.write(file_content().encode())
+ write_zip_entry(bundle, f"{root}/{file_name}",
file_content().encode())
except Exception: # pylint: disable=broad-except
logger.exception(
"There was an error when exporting the dashboards, please check "
@@ -138,8 +137,7 @@ def export_datasources(datasource_file: Optional[str] =
None) -> None:
try:
with ZipFile(datasource_file, "w") as bundle:
for file_name, file_content in
ExportDatasetsCommand(dataset_ids).run():
- with bundle.open(f"{root}/{file_name}", "w") as fp:
- fp.write(file_content().encode())
+ write_zip_entry(bundle, f"{root}/{file_name}",
file_content().encode())
except Exception: # pylint: disable=broad-except
logger.exception(
"There was an error when exporting the datasets, please check "
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 45499da0e9b..c12ed00661c 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -189,6 +189,7 @@ from superset.utils.core import (
get_user_id,
parse_boolean_string,
send_export_zip,
+ write_zip_entry,
)
from superset.utils.file import get_filename
from superset.utils.pdf import build_pdf_from_screenshots
@@ -1701,8 +1702,9 @@ class DashboardRestApi(
for file_name, file_content in ExportDashboardsCommand(
requested_ids
).run():
- with bundle.open(f"{root}/{file_name}", "w") as fp:
- fp.write(file_content().encode())
+ write_zip_entry(
+ bundle, f"{root}/{file_name}", file_content().encode()
+ )
except DashboardNotFoundError:
return self.response_404()
buf.seek(0)
diff --git a/superset/databases/api.py b/superset/databases/api.py
index da34fc21572..be972fb38f7 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -131,6 +131,7 @@ from superset.utils.core import (
get_username,
parse_js_uri_path_item,
send_export_zip,
+ write_zip_entry,
)
from superset.utils.decorators import transaction
from superset.utils.oauth2 import decode_oauth2_state
@@ -1565,8 +1566,9 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
for file_name, file_content in ExportDatabasesCommand(
requested_ids
).run():
- with bundle.open(f"{root}/{file_name}", "w") as fp:
- fp.write(file_content().encode())
+ write_zip_entry(
+ bundle, f"{root}/{file_name}", file_content().encode()
+ )
except DatabaseNotFoundError:
return self.response_404()
buf.seek(0)
diff --git a/superset/datasets/api.py b/superset/datasets/api.py
index 6bed4223aaf..4c84e41a2e7 100644
--- a/superset/datasets/api.py
+++ b/superset/datasets/api.py
@@ -108,7 +108,11 @@ from superset.exceptions import (
from superset.jinja_context import BaseTemplateProcessor,
get_template_processor
from superset.subjects.filters import FilterRelatedSubjects,
subject_type_filter
from superset.utils import json
-from superset.utils.core import parse_boolean_string, send_export_zip
+from superset.utils.core import (
+ parse_boolean_string,
+ send_export_zip,
+ write_zip_entry,
+)
from superset.versioning.api_helpers import (
concurrency_token_from,
current_entity_version_info,
@@ -1018,8 +1022,9 @@ class DatasetRestApi(SoftDeleteApiMixin,
BaseSupersetModelRestApi):
for file_name, file_content in ExportDatasetsCommand(
requested_ids
).run():
- with bundle.open(f"{root}/{file_name}", "w") as fp:
- fp.write(file_content().encode())
+ write_zip_entry(
+ bundle, f"{root}/{file_name}", file_content().encode()
+ )
except DatasetNotFoundError:
return self.response_404()
buf.seek(0)
diff --git a/superset/importexport/api.py b/superset/importexport/api.py
index 066915f9411..edffa8979df 100644
--- a/superset/importexport/api.py
+++ b/superset/importexport/api.py
@@ -30,7 +30,11 @@ from superset.commands.importers.v1.assets import
ImportAssetsCommand
from superset.commands.importers.v1.utils import get_contents_from_bundle
from superset.extensions import event_logger
from superset.utils import json
-from superset.utils.core import parse_boolean_string, send_export_zip
+from superset.utils.core import (
+ parse_boolean_string,
+ send_export_zip,
+ write_zip_entry,
+)
from superset.views.base_api import BaseSupersetApi, requires_form_data,
statsd_metrics
@@ -80,8 +84,7 @@ class ImportExportRestApi(BaseSupersetApi):
buf = BytesIO()
with ZipFile(buf, "w") as bundle:
for file_name, file_content in ExportAssetsCommand().run():
- with bundle.open(f"{root}/{file_name}", "w") as fp:
- fp.write(file_content().encode())
+ write_zip_entry(bundle, f"{root}/{file_name}",
file_content().encode())
buf.seek(0)
return send_export_zip(buf, filename)
diff --git a/superset/queries/saved_queries/api.py
b/superset/queries/saved_queries/api.py
index 971f2300c60..43090d64a04 100644
--- a/superset/queries/saved_queries/api.py
+++ b/superset/queries/saved_queries/api.py
@@ -55,7 +55,7 @@ from superset.queries.saved_queries.schemas import (
validate_label,
)
from superset.utils import json
-from superset.utils.core import send_export_zip
+from superset.utils.core import send_export_zip, write_zip_entry
from superset.views.base_api import (
BaseSupersetModelRestApi,
RelatedFieldFilter,
@@ -301,8 +301,9 @@ class SavedQueryRestApi(BaseSupersetModelRestApi):
for file_name, file_content in ExportSavedQueriesCommand(
requested_ids
).run():
- with bundle.open(f"{root}/{file_name}", "w") as fp:
- fp.write(file_content().encode())
+ write_zip_entry(
+ bundle, f"{root}/{file_name}", file_content().encode()
+ )
except SavedQueryNotFoundError:
return self.response_404()
buf.seek(0)
diff --git a/superset/themes/api.py b/superset/themes/api.py
index 2d2a7628acd..9b829b558a5 100644
--- a/superset/themes/api.py
+++ b/superset/themes/api.py
@@ -65,7 +65,7 @@ from superset.themes.schemas import (
ThemePostSchema,
ThemePutSchema,
)
-from superset.utils.core import send_export_zip
+from superset.utils.core import send_export_zip, write_zip_entry
from superset.views.base_api import (
BaseSupersetModelRestApi,
RelatedFieldFilter,
@@ -529,8 +529,9 @@ class ThemeRestApi(BaseSupersetModelRestApi):
with ZipFile(buf, "w") as bundle:
try:
for file_name, file_content in
ExportThemesCommand(requested_ids).run():
- with bundle.open(f"{root}/{file_name}", "w") as fp:
- fp.write(file_content().encode())
+ write_zip_entry(
+ bundle, f"{root}/{file_name}", file_content().encode()
+ )
except ThemeNotFoundError:
return self.response_404()
buf.seek(0)
diff --git a/superset/utils/core.py b/superset/utils/core.py
index 63c8ccc69d9..ba059d9905d 100644
--- a/superset/utils/core.py
+++ b/superset/utils/core.py
@@ -39,7 +39,7 @@ import zlib
from collections.abc import Collection, Iterable, Iterator, Sequence
from contextlib import closing, contextmanager
from dataclasses import dataclass
-from datetime import timedelta
+from datetime import datetime, timedelta
from email.mime.application import MIMEApplication
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
@@ -60,7 +60,7 @@ from typing import (
TypeVar,
)
from urllib.parse import unquote_plus, urlparse
-from zipfile import ZipFile
+from zipfile import ZipFile, ZipInfo
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import markdown as md
@@ -2222,12 +2222,31 @@ def apply_max_row_limit(
return max_limit
+def write_zip_entry(bundle: ZipFile, filename: str, contents: bytes) -> None:
+ """Add a file to an open ZIP bundle, stamped with the current local time.
+
+ ``ZipFile.open(name, "w")`` falls back to the 1980-01-01 DOS epoch, which
+ extractors surface as a bogus (Windows Explorer) or empty (7-Zip)
+ modification date on every extracted file. Passing an explicit ``ZipInfo``
+ gives the entry the time the export was generated instead.
+ """
+ info = ZipInfo(filename=filename, date_time=datetime.now().timetuple()[:6])
+ # A pre-built ZipInfo bypasses the bundle's own compression settings, which
+ # zipfile only copies onto entries it creates from a plain filename, so
pass
+ # both through explicitly.
+ bundle.writestr(
+ info,
+ contents,
+ compress_type=bundle.compression,
+ compresslevel=bundle.compresslevel,
+ )
+
+
def create_zip(files: dict[str, Any]) -> BytesIO:
buf = BytesIO()
with ZipFile(buf, "w") as bundle:
for filename, contents in files.items():
- with bundle.open(filename, "w") as fp:
- fp.write(contents)
+ write_zip_entry(bundle, filename, contents)
buf.seek(0)
return buf
diff --git a/tests/unit_tests/utils/create_zip_tests.py
b/tests/unit_tests/utils/create_zip_tests.py
new file mode 100644
index 00000000000..6db613d0ffc
--- /dev/null
+++ b/tests/unit_tests/utils/create_zip_tests.py
@@ -0,0 +1,84 @@
+# 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.
+
+from datetime import datetime, timedelta
+from io import BytesIO
+from zipfile import ZIP_DEFLATED, ZipFile
+
+from superset.utils.core import create_zip, write_zip_entry
+
+DOS_EPOCH = (1980, 1, 1, 0, 0, 0)
+
+
+def test_create_zip_stamps_entries_with_the_current_time() -> None:
+ """
+ Entries must carry a real modification date.
+
+ Without an explicit ZipInfo, zipfile falls back to the 1980-01-01 DOS
epoch,
+ which extractors show as a bogus or empty modification date on every
+ extracted file (#44388).
+ """
+ # DOS timestamps have a two-second resolution, so an entry can be stamped
+ # slightly before the moment the archive was built.
+ before = datetime.now() - timedelta(seconds=2)
+ archive = create_zip({"query_1.csv": b"value\n1\n", "query_2.csv":
b"value\n2\n"})
+ after = datetime.now()
+
+ with ZipFile(archive) as bundle:
+ infos = bundle.infolist()
+
+ assert [info.filename for info in infos] == ["query_1.csv", "query_2.csv"]
+ for info in infos:
+ assert info.date_time != DOS_EPOCH
+ assert before <= datetime(*info.date_time) <= after
+
+
+def test_write_zip_entry_writes_nested_paths_intact() -> None:
+ buf = BytesIO()
+ with ZipFile(buf, "w") as bundle:
+ write_zip_entry(bundle, "root/metadata.yaml", b"version: 1.0.0")
+
+ with ZipFile(buf) as bundle:
+ info = bundle.getinfo("root/metadata.yaml")
+ assert bundle.read("root/metadata.yaml") == b"version: 1.0.0"
+
+ assert info.date_time != DOS_EPOCH
+
+
+def test_write_zip_entry_honors_the_bundle_compression_settings() -> None:
+ """
+ A pre-built ZipInfo bypasses the settings zipfile copies onto entries it
+ creates itself, so the helper has to forward both of them.
+ """
+ payload = b"superset " * 2000
+
+ def build(compresslevel: int) -> bytes:
+ buf = BytesIO()
+ with ZipFile(
+ buf, "w", compression=ZIP_DEFLATED, compresslevel=compresslevel
+ ) as bundle:
+ write_zip_entry(bundle, "data.csv", payload)
+ return buf.getvalue()
+
+ fastest, smallest = build(1), build(9)
+
+ with ZipFile(BytesIO(smallest)) as bundle:
+ info = bundle.getinfo("data.csv")
+ assert info.compress_type == ZIP_DEFLATED
+ assert bundle.read("data.csv") == payload
+
+ assert len(smallest) < len(fastest)