diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ad5d7cf9..f532c5dce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ ## Unreleased - Support global file-level tags in metrics.yaml ([bug 1745283](https://bugzilla.mozilla.org/show_bug.cgi?id=1745283)) +- Automatically generate a build date when generating build info ([#431](https://github.com/mozilla/glean_parser/pull/431)). + Enabled for Kotlin and Swift. + This can be changed with the `build_date` command line option. + `build_date=0` will use a static unix epoch time. + `build_date=2022-01-03T17:30:00` will parse the ISO8601 string to use (as a UTC timestamp). + Other values will throw an error. + + Example: + + glean_parser translate --format kotlin --option build_date=2021-11-01T01:00:00 path/to/metrics.yaml ## 4.3.1 diff --git a/glean_parser/kotlin.py b/glean_parser/kotlin.py index c9a5e32a9..edfda12f2 100644 --- a/glean_parser/kotlin.py +++ b/glean_parser/kotlin.py @@ -132,6 +132,29 @@ def class_name(obj_type: str) -> str: return util.Camelize(obj_type) + "MetricType" +def generate_build_date(date: Optional[str]) -> str: + """ + Generate the build timestamp. + """ + + ts = util.build_date(date) + + data = [ + str(ts.year), + # In Java the first month of the year in calendars is JANUARY which is 0. + # In Python it's 1-based + str(ts.month - 1), + str(ts.day), + str(ts.hour), + str(ts.minute), + str(ts.second), + ] + components = ", ".join(data) + + # DatetimeMetricType takes a `Calendar` instance. + return f'Calendar.getInstance(TimeZone.getTimeZone("GMT+0")).also {{ cal -> cal.set({components}) }}' # noqa + + def output_gecko_lookup( objs: metrics.ObjectTree, output_dir: Path, options: Optional[Dict[str, Any]] = None ) -> None: @@ -249,6 +272,11 @@ def output_kotlin( - `with_buildinfo`: If "true" a `GleanBuildInfo.kt` file is generated. Otherwise generation of that file is skipped. Defaults to "true". + - `build_date`: If set to `0` a static unix epoch time will be used. + If set to a ISO8601 datetime string (e.g. `2022-01-03T17:30:00`) + it will use that date. + Other values will throw an error. + If not set it will use the current date & time. """ if options is None: options = {} @@ -257,6 +285,7 @@ def output_kotlin( glean_namespace = options.get("glean_namespace", "mozilla.components.service.glean") namespace_package = namespace[: namespace.rfind(".")] with_buildinfo = options.get("with_buildinfo", "true").lower() == "true" + build_date = options.get("build_date", None) # Write out the special "build info" object template = util.get_jinja2_template( @@ -264,6 +293,7 @@ def output_kotlin( ) if with_buildinfo: + build_date = generate_build_date(build_date) # This filename needs to start with "Glean" so it can never clash with a # metric category with (output_dir / "GleanBuildInfo.kt").open("w", encoding="utf-8") as fd: @@ -272,6 +302,7 @@ def output_kotlin( namespace=namespace, namespace_package=namespace_package, glean_namespace=glean_namespace, + build_date=build_date, ) ) fd.write("\n") diff --git a/glean_parser/swift.py b/glean_parser/swift.py index 7dcc65ea8..712e6d947 100644 --- a/glean_parser/swift.py +++ b/glean_parser/swift.py @@ -134,6 +134,33 @@ def variable_name(var: str) -> str: return var +class BuildInfo: + def __init__(self, build_date): + self.build_date = build_date + + +def generate_build_date(date: Optional[str]) -> str: + """ + Generate the build timestamp. + """ + + ts = util.build_date(date) + + data = [ + ("year", ts.year), + ("month", ts.month), + ("day", ts.day), + ("hour", ts.hour), + ("minute", ts.minute), + ("second", ts.second), + ] + + # The internal DatetimeMetricType API can take a `DateComponents` object, + # which lets us easily specify the timezone. + components = ", ".join([f"{name}: {val}" for (name, val) in data]) + return f'DateComponents(calendar: Calendar.current, timeZone: TimeZone(abbreviation: "UTC"), {components})' # noqa + + class Category: """ Data struct holding information about a metric to be used in the template. @@ -157,6 +184,14 @@ def output_swift( - namespace: The namespace to generate metrics in - glean_namespace: The namespace to import Glean from - allow_reserved: When True, this is a Glean-internal build + - with_buildinfo: If "true" the `GleanBuildInfo` is generated. + Otherwise generation of that file is skipped. + Defaults to "true". + - build_date: If set to `0` a static unix epoch time will be used. + If set to a ISO8601 datetime string (e.g. `2022-01-03T17:30:00`) + it will use that date. + Other values will throw an error. + If not set it will use the current date & time. """ if options is None: options = {} @@ -174,6 +209,12 @@ def output_swift( namespace = options.get("namespace", "GleanMetrics") glean_namespace = options.get("glean_namespace", "Glean") + with_buildinfo = options.get("with_buildinfo", "true").lower() == "true" + build_date = options.get("build_date", None) + build_info = None + if with_buildinfo: + build_date = generate_build_date(build_date) + build_info = BuildInfo(build_date=build_date) filename = "Metrics.swift" filepath = output_dir / filename @@ -199,6 +240,7 @@ def output_swift( namespace=namespace, glean_namespace=glean_namespace, allow_reserved=options.get("allow_reserved", False), + build_info=build_info, ) ) # Jinja2 squashes the final newline, so we explicitly add it diff --git a/glean_parser/templates/kotlin.buildinfo.jinja2 b/glean_parser/templates/kotlin.buildinfo.jinja2 index 22b795644..2f86b90e1 100644 --- a/glean_parser/templates/kotlin.buildinfo.jinja2 +++ b/glean_parser/templates/kotlin.buildinfo.jinja2 @@ -14,14 +14,18 @@ Jinja2 template is not. Please file bugs! #} package {{ namespace }} +import java.util.Calendar +import java.util.TimeZone import {{ glean_namespace }}.BuildInfo import {{ namespace_package }}.BuildConfig +@Suppress("MagicNumber") internal object GleanBuildInfo { val buildInfo: BuildInfo by lazy { BuildInfo( versionCode = BuildConfig.VERSION_CODE.toString(), - versionName = BuildConfig.VERSION_NAME + versionName = BuildConfig.VERSION_NAME, + buildDate = {{ build_date }} ) } } diff --git a/glean_parser/templates/swift.jinja2 b/glean_parser/templates/swift.jinja2 index 7d0c14efe..2d9c87660 100644 --- a/glean_parser/templates/swift.jinja2 +++ b/glean_parser/templates/swift.jinja2 @@ -60,6 +60,16 @@ import {{ glean_namespace }} // swiftlint:disable force_try extension {{ namespace }} { + {% if build_info %} + class GleanBuild { + private init() { + // Intentionally left private, no external user can instantiate a new global object. + } + + public static let info = BuildInfo(buildDate: {{ build_info.build_date }}) + } + {% endif %} + {% for category in categories %} {% if category.contains_pings %} class {{ category.name|Camelize }} { diff --git a/glean_parser/util.py b/glean_parser/util.py index 9b631e17b..f12fdd75c 100644 --- a/glean_parser/util.py +++ b/glean_parser/util.py @@ -24,6 +24,26 @@ if sys.version_info < (3, 7): import iso8601 # type: ignore + def date_fromisoformat(datestr: str) -> datetime.date: + try: + return iso8601.parse_date(datestr).date() + except iso8601.ParseError: + raise ValueError() + + def datetime_fromisoformat(datestr: str) -> datetime.datetime: + try: + return iso8601.parse_date(datestr) + except iso8601.ParseError: + raise ValueError() + +else: + + def date_fromisoformat(datestr: str) -> datetime.date: + return datetime.date.fromisoformat(datestr) + + def datetime_fromisoformat(datestr: str) -> datetime.datetime: + return datetime.datetime.fromisoformat(datestr) + TESTING_MODE = "pytest" in sys.modules @@ -360,13 +380,7 @@ def parse_expires(expires: str) -> datetime.date: Raises a ValueError in case the string is not properly formatted. """ try: - if sys.version_info < (3, 7): - try: - return iso8601.parse_date(expires).date() - except iso8601.ParseError: - raise ValueError() - else: - return datetime.date.fromisoformat(expires) + return date_fromisoformat(expires) except ValueError: raise ValueError( f"Invalid expiration date '{expires}'. " @@ -407,6 +421,31 @@ def validate_expires(expires: str) -> None: ) +def build_date(date: Optional[str]) -> datetime.datetime: + """ + Generate the build timestamp. + + If `date` is set to `0` a static unix epoch time will be used. + If `date` it is set to a ISO8601 datetime string (e.g. `2022-01-03T17:30:00`) + it will use that date. + Note that any timezone offset will be ignored and UTC will be used. + Otherwise it will throw an error. + + If `date` is `None` it will use the current date & time. + """ + + if date is not None: + date = str(date) + if date == "0": + ts = datetime.datetime(1970, 1, 1, 0, 0, 0) + else: + ts = datetime_fromisoformat(date).replace(tzinfo=datetime.timezone.utc) + else: + ts = datetime.datetime.utcnow() + + return ts + + def report_validation_errors(all_objects): """ Report any validation errors found to the console. diff --git a/tests/test_cli.py b/tests/test_cli.py index a85db513d..edee4c9be 100755 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -96,6 +96,84 @@ def test_translate_no_buildinfo(tmpdir): assert "package Foo" in content +def test_translate_build_date(tmpdir): + """Test with a custom build date.""" + runner = CliRunner() + result = runner.invoke( + __main__.main, + [ + "translate", + str(ROOT / "data" / "core.yaml"), + "-o", + str(tmpdir), + "-f", + "kotlin", + "-s", + "namespace=Foo", + "-s", + "build_date=2020-01-01T17:30:00", + "--allow-reserved", + ], + ) + assert result.exit_code == 0 + + path = Path(str(tmpdir)) / "GleanBuildInfo.kt" + with path.open(encoding="utf-8") as fd: + content = fd.read() + assert "buildDate = Calendar.getInstance" in content + assert "cal.set(2020, 0, 1, 17, 30" in content + + +def test_translate_fixed_build_date(tmpdir): + """Test with a custom build date.""" + runner = CliRunner() + result = runner.invoke( + __main__.main, + [ + "translate", + str(ROOT / "data" / "core.yaml"), + "-o", + str(tmpdir), + "-f", + "kotlin", + "-s", + "namespace=Foo", + "-s", + "build_date=0", + "--allow-reserved", + ], + ) + assert result.exit_code == 0 + + path = Path(str(tmpdir)) / "GleanBuildInfo.kt" + with path.open(encoding="utf-8") as fd: + content = fd.read() + assert "buildDate = Calendar.getInstance" in content + assert "cal.set(1970" in content + + +def test_translate_borked_build_date(tmpdir): + """Test with a custom build date.""" + runner = CliRunner() + result = runner.invoke( + __main__.main, + [ + "translate", + str(ROOT / "data" / "core.yaml"), + "-o", + str(tmpdir), + "-f", + "kotlin", + "-s", + "namespace=Foo", + "-s", + "build_date=1", + "--allow-reserved", + ], + ) + assert result.exit_code == 1 + + def test_translate_errors(tmpdir): """Test the 'translate' command.""" runner = CliRunner() diff --git a/tests/test_kotlin.py b/tests/test_kotlin.py index 86bbdcc1a..31a2d71b6 100644 --- a/tests/test_kotlin.py +++ b/tests/test_kotlin.py @@ -88,6 +88,10 @@ def test_parser(tmpdir): content = fd.read() assert 'category = ""' in content + with (tmpdir / "GleanBuildInfo.kt").open("r", encoding="utf-8") as fd: + content = fd.read() + assert "buildDate = Calendar.getInstance" in content + run_linters(tmpdir.glob("*.kt")) diff --git a/tests/test_swift.py b/tests/test_swift.py index f52979225..7dcac08c5 100644 --- a/tests/test_swift.py +++ b/tests/test_swift.py @@ -49,6 +49,56 @@ def test_parser(tmpdir): assert "True if the user has set Firefox as the default browser." in content assert "جمع 搜集" in content assert 'category: ""' in content + assert "class GleanBuild" in content + assert "BuildInfo(buildDate:" in content + + run_linters(tmpdir.glob("*.swift")) + + +def test_parser_no_build_info(tmpdir): + """Test translating metrics to Swift files without build info.""" + tmpdir = Path(str(tmpdir)) + + translate.translate( + ROOT / "data" / "core.yaml", + "swift", + tmpdir, + {"with_buildinfo": "false"}, + {"allow_reserved": True}, + ) + + assert set(x.name for x in tmpdir.iterdir()) == set(["Metrics.swift"]) + + # Make sure descriptions made it in + with (tmpdir / "Metrics.swift").open("r", encoding="utf-8") as fd: + content = fd.read() + + assert "class GleanBuild" not in content + + run_linters(tmpdir.glob("*.swift")) + + +def test_parser_custom_build_date(tmpdir): + """Test translating metrics to Swift files without build info.""" + tmpdir = Path(str(tmpdir)) + + translate.translate( + ROOT / "data" / "core.yaml", + "swift", + tmpdir, + {"build_date": "2020-01-01T17:30:00"}, + {"allow_reserved": True}, + ) + + assert set(x.name for x in tmpdir.iterdir()) == set(["Metrics.swift"]) + + # Make sure descriptions made it in + with (tmpdir / "Metrics.swift").open("r", encoding="utf-8") as fd: + content = fd.read() + + assert "class GleanBuild" in content + assert "BuildInfo(buildDate:" in content + assert "year: 2020, month: 1, day: 1" in content run_linters(tmpdir.glob("*.swift"))