summaryrefslogtreecommitdiffstats
path: root/debian/test_timezone_conversions
diff options
context:
space:
mode:
Diffstat (limited to 'debian/test_timezone_conversions')
-rwxr-xr-xdebian/test_timezone_conversions213
1 files changed, 213 insertions, 0 deletions
diff --git a/debian/test_timezone_conversions b/debian/test_timezone_conversions
new file mode 100755
index 0000000..768e6fd
--- /dev/null
+++ b/debian/test_timezone_conversions
@@ -0,0 +1,213 @@
+#!/usr/bin/python3
+
+# Author: Benjamin Drung <bdrung@ubuntu.com>
+
+"""Check convert_timezone from tzdata.config for consistency."""
+
+import argparse
+import logging
+import pathlib
+import re
+import subprocess
+import sys
+import typing
+import zoneinfo
+
+import debian.deb822
+
+LOG_FORMAT = "%(levelname)s: %(message)s"
+# Special timezones that should not be selectable in debconf
+SPECIAL = {"Factory", "localtime"}
+# Not selectable timezones that are not mentioned in the backward file.
+# See also https://launchpad.net/bugs/2030684
+EXCLUDE_UNSELECTABLE = {
+ "CET",
+ "CST6CDT",
+ "EET",
+ "EST",
+ "EST5EDT",
+ "GMT",
+ "HST",
+ "MET",
+ "MST",
+ "MST7MDT",
+ "PST8PDT",
+ "UTC",
+ "WET",
+}
+
+
+class ConvertTimezone:
+ """Wrap convert_timezone from tzdata.config."""
+
+ def __init__(self, tzdata_config: pathlib.Path):
+ self.tzdata_config = tzdata_config
+ content = tzdata_config.read_text(encoding="utf-8")
+ match = re.search(r"convert_timezone\(\).*\n}", content, flags=re.DOTALL)
+ assert match, f"convert_timezone function not found in {tzdata_config}"
+ self.convert_timezone = match.group(0)
+
+ def __call__(self, timezone: str) -> str:
+ shell_script = f"{self.convert_timezone}\nconvert_timezone '{timezone}'\n"
+ shell = subprocess.run(
+ ["/bin/sh", "-c", shell_script],
+ capture_output=True,
+ check=True,
+ encoding="utf-8",
+ )
+ return shell.stdout.strip()
+
+ def filter_converted_timezones(self, timezones: set[str]) -> set[str]:
+ """Return set of timezones that will be converted by convert_timezone."""
+ converted = set()
+ for timezone in timezones:
+ if self(timezone) != timezone:
+ converted.add(timezone)
+ return converted
+
+ def filter_unconverted_timezones(self, timezones: set[str]) -> set[str]:
+ """Return set of timezones that will not be converted by convert_timezone."""
+ return timezones - self.filter_converted_timezones(timezones)
+
+ def get_targets(self) -> set[str]:
+ """Return set of conversion targets."""
+ targets = set(re.findall('echo "([^"$]+)"', self.convert_timezone))
+ logging.getLogger(__name__).info(
+ "Available conversion targets in %s: %i", self.tzdata_config, len(targets)
+ )
+ return targets
+
+
+def get_available_timezones(directory: typing.Optional[pathlib.Path]) -> set[str]:
+ """Return a set of available timezones in the directory.
+
+ If directory is not set, use the sytem's default.
+ """
+ logger = logging.getLogger(__name__)
+ if directory:
+ zoneinfo.reset_tzpath(to=[directory.absolute()])
+ available = set(zoneinfo.available_timezones())
+ if not available:
+ logger.error("Found no timezones in %s.", directory)
+ sys.exit(1)
+ logger.info("Available timezones in %s: %i", directory or "system", len(available))
+ return available
+
+
+def get_debconf_choices(template_filename: pathlib.Path) -> set[str]:
+ """Extract the timezone choices from the debconf template."""
+ logger = logging.getLogger(__name__)
+ debconf_choices = set()
+ with template_filename.open(encoding="utf-8") as template_file:
+ for paragraph in debian.deb822.Deb822.iter_paragraphs(template_file):
+ area_match = re.match("tzdata/Zones/(.*)", paragraph["Template"])
+ if not area_match:
+ continue
+ area = area_match.group(1)
+ choices = paragraph.get("Choices", paragraph.get("__Choices", ""))
+ debconf_choices.update([f"{area}/{c}" for c in choices.split(", ")])
+ if not debconf_choices:
+ logger.error("Found no selectable timezones in %s.", template_filename)
+ sys.exit(1)
+ logger.info(
+ "Selectable timezones in %s: %i", template_filename, len(debconf_choices)
+ )
+ return debconf_choices
+
+
+def existing_dir_path(string: str) -> pathlib.Path:
+ """Convert string to existing dir path or raise ArgumentTypeError."""
+ path = pathlib.Path(string)
+ if not path.is_dir():
+ raise argparse.ArgumentTypeError(f"Directory {string} does not exist")
+ return path
+
+
+def parse_args() -> argparse.Namespace:
+ """Parse command line arguments and return namespace."""
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "-z",
+ "--zoneinfo-directory",
+ type=existing_dir_path,
+ help="Directory containing the generated zoneinfo files (default: system)",
+ )
+ parser.add_argument(
+ "-d",
+ "--debian-directory",
+ default=pathlib.Path("debian"),
+ type=existing_dir_path,
+ help="Path to debian directory containing tzdata.config"
+ " and tzdata.templates (default: %(default)s)",
+ )
+ parser.add_argument(
+ "--all-selectable",
+ action="store_true",
+ help="Require all available timezones to be selectable in debconf",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ """Check convert_timezone from tzdata.config for consistency."""
+ args = parse_args()
+ logging.basicConfig(format=LOG_FORMAT, level=logging.INFO)
+ logger = logging.getLogger(__name__)
+
+ selectable = get_debconf_choices(args.debian_directory / "tzdata.templates")
+ available = get_available_timezones(args.zoneinfo_directory)
+ convert_timezone = ConvertTimezone(args.debian_directory / "tzdata.config")
+ conversion_targets = convert_timezone.get_targets()
+ failures = 0
+
+ converted = convert_timezone.filter_converted_timezones(selectable)
+ if converted:
+ logger.error(
+ "Following %i timezones can be selected, but will be converted:\n%s",
+ len(converted),
+ "\n".join(sorted(converted)),
+ )
+ failures += 1
+
+ unselectable = available - selectable - SPECIAL - EXCLUDE_UNSELECTABLE
+ if args.all_selectable and unselectable:
+ logger.error(
+ "Following %i timezones cannot be selected, but are available:\n%s",
+ len(unselectable),
+ "\n".join(sorted(unselectable)),
+ )
+ failures += 1
+
+ missing = convert_timezone.filter_unconverted_timezones(unselectable)
+ if missing:
+ logger.error(
+ "Following %i timezones cannot be selected, but are not converted:\n%s",
+ len(missing),
+ "\n".join(sorted(missing)),
+ )
+ failures += 1
+
+ targets = conversion_targets - available
+ if targets:
+ logger.error(
+ "Following %i timezones are conversion targets, but are not available:\n%s",
+ len(targets),
+ "\n".join(sorted(targets)),
+ )
+ failures += 1
+
+ targets = conversion_targets - selectable
+ if targets:
+ logger.error(
+ "Following %i timezones are conversion targets,"
+ " but are not selectable:\n%s",
+ len(targets),
+ "\n".join(sorted(targets)),
+ )
+ failures += 1
+
+ return failures
+
+
+if __name__ == "__main__":
+ sys.exit(main())