From 084244b723c1b2d236ce22e623f50c8be838f0d2 Mon Sep 17 00:00:00 2001 From: Konboi Date: Fri, 23 May 2025 13:43:18 +0900 Subject: [PATCH 01/11] support `--timestamp` option to report historical data --- launchable/commands/record/build.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/launchable/commands/record/build.py b/launchable/commands/record/build.py index 21dcba07b..4f12ac5e6 100644 --- a/launchable/commands/record/build.py +++ b/launchable/commands/record/build.py @@ -1,3 +1,4 @@ +import datetime import os import re import sys @@ -96,13 +97,19 @@ 'lineage', hidden=True, ) +@click.option( + '--timestamp', 'timestamp', + help='Import historical data with the original build timestamp. Note: Format is `YYYY-MM-DD HH:MM:SS`, timezone is UTC by default.', # noqa: E501 + type=click.DateTime(formats=["%Y-%m-%d %H:%M:%S"]), + default=None, +) @click.pass_context def build( ctx: click.core.Context, build_name: str, source: List[str], max_days: int, no_submodules: bool, no_commit_collection: bool, scrub_pii: bool, commits: Sequence[Tuple[str, str]], links: Sequence[Tuple[str, str]], - branches: Sequence[str], lineage: str): + branches: Sequence[str], lineage: str, timestamp: Optional[datetime.datetime]): if "/" in build_name or "%2f" in build_name.lower(): sys.exit("--name must not contain a slash and an encoded slash") @@ -326,7 +333,8 @@ def compute_links(): 'commitHash': w.commit_hash, 'branchName': w.branch or "" } for w in ws], - "links": compute_links() + "links": compute_links(), + "timestamp": timestamp.isoformat() if timestamp else None, } res = client.request("post", "builds", payload=payload) From a34caf33e081f9ff4064f787dc112a54bfbf4c7d Mon Sep 17 00:00:00 2001 From: Konboi Date: Fri, 23 May 2025 13:44:06 +0900 Subject: [PATCH 02/11] add test case with --timestamp option --- tests/commands/record/test_build.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/commands/record/test_build.py b/tests/commands/record/test_build.py index 5c9dca486..4f576b2b7 100644 --- a/tests/commands/record/test_build.py +++ b/tests/commands/record/test_build.py @@ -260,3 +260,32 @@ def test_build_name_validation(self): result = self.cli("record", "build", "--no-commit-collection", "--name", "foo%2Fhoge") self.assert_exit_code(result, 1) + +# make sure the output of git-submodule is properly parsed + @responses.activate + @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) + @mock.patch.dict(os.environ, {"GITHUB_ACTIONS": ""}) + @mock.patch('launchable.utils.subprocess.check_output') + def test_with_timestamp(self, mock_check_output): + self.assertEqual(read_build(), None) + result = self.cli( + "record", + "build", + "--no-commit-collection", + "--name", + self.build_name, + '--timestamp', + "2025-01-23 12:34:56") + self.assert_success(result) + + payload = json.loads(responses.calls[0].request.body.decode()) + self.assert_json_orderless_equal( + { + "buildNumber": "123", + "lineage": "main", + "commitHashes": [], + "links": [], + "timestamp": "2025-01-23T12:34:56Z" + }, payload) + + self.assertEqual(read_build(), self.build_name) From a5102e8f8d0dbf93187739469f1420c250bfa196 Mon Sep 17 00:00:00 2001 From: Konboi Date: Fri, 23 May 2025 13:44:22 +0900 Subject: [PATCH 03/11] fix other tests --- tests/commands/record/test_build.py | 39 +++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/tests/commands/record/test_build.py b/tests/commands/record/test_build.py index 4f576b2b7..bf1a7a4d7 100644 --- a/tests/commands/record/test_build.py +++ b/tests/commands/record/test_build.py @@ -59,7 +59,8 @@ def test_submodule(self, mock_check_output): "branchName": "" }, ], - "links": [] + "links": [], + "timestamp": None }, payload) self.assertEqual(read_build(), self.build_name) @@ -93,7 +94,8 @@ def test_no_submodule(self, mock_check_output): "branchName": "" }, ], - "links": [] + "links": [], + "timestamp": None }, payload) self.assertEqual(read_build(), self.build_name) @@ -124,7 +126,8 @@ def test_no_git_directory(self): "branchName": "", }, ], - "links": [] + "links": [], + "timestamp": None }, payload) self.assertEqual(read_build(), self.build_name) @@ -153,7 +156,8 @@ def test_commit_option_and_build_option(self): "branchName": "" }, ], - "links": [] + "links": [], + 'timestamp': None }, payload) responses.calls.reset() @@ -182,7 +186,8 @@ def test_commit_option_and_build_option(self): "branchName": "feature-xxx" }, ], - "links": [] + "links": [], + "timestamp": None }, payload) responses.calls.reset() @@ -211,7 +216,8 @@ def test_commit_option_and_build_option(self): "branchName": "" }, ], - "links": [] + "links": [], + "timestamp": None }, payload) responses.calls.reset() self.assertIn("Invalid repository name B in a --branch option. ", result.output) @@ -250,7 +256,8 @@ def test_commit_option_and_build_option(self): "branchName": "feature-yyy" }, ], - "links": [] + "links": [], + "timestamp": None }, payload) responses.calls.reset() @@ -264,7 +271,9 @@ def test_build_name_validation(self): # make sure the output of git-submodule is properly parsed @responses.activate @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) + # to tests on GitHub Actions @mock.patch.dict(os.environ, {"GITHUB_ACTIONS": ""}) + @mock.patch.dict(os.environ, {"GITHUB_PULL_REQUEST_URL": ""}) @mock.patch('launchable.utils.subprocess.check_output') def test_with_timestamp(self, mock_check_output): self.assertEqual(read_build(), None) @@ -272,20 +281,28 @@ def test_with_timestamp(self, mock_check_output): "record", "build", "--no-commit-collection", + "--commit", + "repo=abc12", "--name", self.build_name, '--timestamp', - "2025-01-23 12:34:56") + "2025-01-23 12:34:56Z") self.assert_success(result) payload = json.loads(responses.calls[0].request.body.decode()) self.assert_json_orderless_equal( { "buildNumber": "123", - "lineage": "main", - "commitHashes": [], + "lineage": None, + "commitHashes": [ + { + "repositoryName": "repo", + "commitHash": "abc12", + "branchName": "" + }, + ], "links": [], - "timestamp": "2025-01-23T12:34:56Z" + "timestamp": "2025-01-23T12:34:56+00:00" }, payload) self.assertEqual(read_build(), self.build_name) From 830a2491d2bdbf03120eb42106819c07d493e708 Mon Sep 17 00:00:00 2001 From: Konboi Date: Fri, 23 May 2025 15:48:48 +0900 Subject: [PATCH 04/11] click.Datetime doesn't support time zone, so implemented a custom type --- launchable/commands/record/build.py | 7 ++++--- launchable/utils/click.py | 14 ++++++++++++++ tests/utils/test_click.py | 28 +++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/launchable/commands/record/build.py b/launchable/commands/record/build.py index 4f12ac5e6..1893e5c03 100644 --- a/launchable/commands/record/build.py +++ b/launchable/commands/record/build.py @@ -12,7 +12,7 @@ from ...utils import subprocess from ...utils.authentication import get_org_workspace -from ...utils.click import KEY_VALUE +from ...utils.click import DATETIME_WITH_TZ, KEY_VALUE from ...utils.launchable_client import LaunchableClient from ...utils.session import clean_session_files, write_build from .commit import commit @@ -98,9 +98,10 @@ hidden=True, ) @click.option( - '--timestamp', 'timestamp', + '--timestamp', + 'timestamp', help='Import historical data with the original build timestamp. Note: Format is `YYYY-MM-DD HH:MM:SS`, timezone is UTC by default.', # noqa: E501 - type=click.DateTime(formats=["%Y-%m-%d %H:%M:%S"]), + type=DATETIME_WITH_TZ, default=None, ) @click.pass_context diff --git a/launchable/utils/click.py b/launchable/utils/click.py index 9f2dfab7b..4dc0da978 100644 --- a/launchable/utils/click.py +++ b/launchable/utils/click.py @@ -1,5 +1,6 @@ import re import sys +from datetime import datetime, timezone from typing import Dict, Optional, Tuple import click @@ -94,10 +95,23 @@ def convert(self, value: str, param: Optional[click.core.Parameter], ctx: Option self.fail("Expected fraction like 1/2 but got '{}'".format(value), param, ctx) +class DateTimeWithTimezoneType(ParamType): + name = "datetime" + + def convert(self, value: str, param: Optional[click.core.Parameter], ctx: Optional[click.core.Context]): + + try: + dt = datetime.fromisoformat(value.replace(" ", "T")) + return dt.replace(tzinfo=timezone.utc) + except ValueError: + self.fail("Expected datetime like 2023-10-01T12:00:00but got '{}'".format(value), param, ctx) + + PERCENTAGE = PercentageType() DURATION = DurationType() FRACTION = FractionType() KEY_VALUE = KeyValueType() +DATETIME_WITH_TZ = DateTimeWithTimezoneType() # Can the output deal with Unicode emojis? try: diff --git a/tests/utils/test_click.py b/tests/utils/test_click.py index f56e3e371..0bea21de2 100644 --- a/tests/utils/test_click.py +++ b/tests/utils/test_click.py @@ -1,10 +1,11 @@ +import datetime from typing import Sequence, Tuple from unittest import TestCase import click from click.testing import CliRunner -from launchable.utils.click import KEY_VALUE, convert_to_seconds +from launchable.utils.click import DATETIME_WITH_TZ, KEY_VALUE, convert_to_seconds class DurationTypeTest(TestCase): @@ -42,3 +43,28 @@ def hello(args: Sequence[Tuple[str, str]]): scenario([]) scenario([('bar', 'zot')], '-f', 'bar=zot') scenario([('bar', 'zot'), ('a', 'b')], '-f', 'bar=zot', '-f', 'a=b') + + +class TimestampTypeTest(TestCase): + def test_conversion(self): + def scenario(expected: str, *args): + actual: datetime.datetime = datetime.datetime.now() + + @click.command() + @click.option( + '-t', + 'timestamp', + type=DATETIME_WITH_TZ, + ) + def time(timestamp: datetime.datetime): + nonlocal actual + actual = timestamp + + result = CliRunner().invoke(time, args) + + self.assertEqual(0, result.exit_code, result.stdout) + self.assertEqual(expected, actual) + + scenario(datetime.datetime(2023, 10, 1, 12, 0, 0, tzinfo=tzlocal()), '-t', '2023-10-01 12:00:00') + scenario(datetime.datetime(2023, 10, 1, 20, 0, 0, tzinfo=timezone.utc), '-t', '2023-10-01 20:00:00+00:00') + scenario(datetime.datetime(2023, 10, 1, 20, 0, 0, tzinfo=timezone.utc), '-t', '2023-10-01T20:00:00Z') From 41a35f515970f781819ea05a20d846756e7f9207 Mon Sep 17 00:00:00 2001 From: Konboi Date: Fri, 23 May 2025 17:44:26 +0900 Subject: [PATCH 05/11] use dateutil instead of datetime directly to be able to parse flexibly --- launchable/commands/record/build.py | 2 +- launchable/utils/click.py | 11 +++++++---- tests/utils/test_click.py | 3 +++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/launchable/commands/record/build.py b/launchable/commands/record/build.py index 1893e5c03..d159bfbf0 100644 --- a/launchable/commands/record/build.py +++ b/launchable/commands/record/build.py @@ -100,7 +100,7 @@ @click.option( '--timestamp', 'timestamp', - help='Import historical data with the original build timestamp. Note: Format is `YYYY-MM-DD HH:MM:SS`, timezone is UTC by default.', # noqa: E501 + help='Used to overwrite the build time when importing historical data. Note: Format must be `YYYY-MM-DDThh:mm:ssTZD` or `YYYY-MM-DDThh:mm:ss` (local timezone applied)', # noqa: E501 type=DATETIME_WITH_TZ, default=None, ) diff --git a/launchable/utils/click.py b/launchable/utils/click.py index 4dc0da978..9245201b6 100644 --- a/launchable/utils/click.py +++ b/launchable/utils/click.py @@ -1,10 +1,11 @@ import re import sys -from datetime import datetime, timezone from typing import Dict, Optional, Tuple import click +import dateutil.parser from click import ParamType +from dateutil.tz import tzlocal # click.Group has the notion of hidden commands but it doesn't allow us to easily add # the same command under multiple names and hide all but one. @@ -101,10 +102,12 @@ class DateTimeWithTimezoneType(ParamType): def convert(self, value: str, param: Optional[click.core.Parameter], ctx: Optional[click.core.Context]): try: - dt = datetime.fromisoformat(value.replace(" ", "T")) - return dt.replace(tzinfo=timezone.utc) + dt = dateutil.parser.parse(value) + if dt.tzinfo is None: + return dt.replace(tzinfo=tzlocal()) + return dt except ValueError: - self.fail("Expected datetime like 2023-10-01T12:00:00but got '{}'".format(value), param, ctx) + self.fail("Expected datetime like 2023-10-01T12:00:00 but got '{}'".format(value), param, ctx) PERCENTAGE = PercentageType() diff --git a/tests/utils/test_click.py b/tests/utils/test_click.py index 0bea21de2..e0f2120ea 100644 --- a/tests/utils/test_click.py +++ b/tests/utils/test_click.py @@ -1,9 +1,12 @@ import datetime +from datetime import timezone from typing import Sequence, Tuple from unittest import TestCase +from zoneinfo import ZoneInfo import click from click.testing import CliRunner +from dateutil.tz import tzlocal from launchable.utils.click import DATETIME_WITH_TZ, KEY_VALUE, convert_to_seconds From f81b51a522be081e8a9880d11d89d4194d7a506f Mon Sep 17 00:00:00 2001 From: Konboi Date: Fri, 23 May 2025 20:34:08 +0900 Subject: [PATCH 06/11] support `--timestamp` option to report historical test results --- launchable/commands/record/tests.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/launchable/commands/record/tests.py b/launchable/commands/record/tests.py index 75df09d99..29d01d64c 100644 --- a/launchable/commands/record/tests.py +++ b/launchable/commands/record/tests.py @@ -16,7 +16,7 @@ from launchable.utils.tracking import Tracking, TrackingClient from ...testpath import FilePathNormalizer, TestPathComponent, unparse_test_path -from ...utils.click import KEY_VALUE +from ...utils.click import DATETIME_WITH_TZ, KEY_VALUE from ...utils.exceptions import InvalidJUnitXMLException from ...utils.launchable_client import LaunchableClient from ...utils.logger import Logger @@ -159,6 +159,13 @@ def _validate_group(ctx, param, value): type=str, metavar='TEST_SUITE', ) +@click.option( + '--timestamp', + 'timestamp', + help='Used to overwrite the test executed times when importing historical data. Note: Format must be `YYYY-MM-DDThh:mm:ssTZD` or `YYYY-MM-DDThh:mm:ss` (local timezone applied)', # noqa: E501 + type=DATETIME_WITH_TZ, + default=None, +) @click.pass_context def tests( context: click.core.Context, @@ -177,6 +184,7 @@ def tests( session_name: Optional[str] = None, lineage: Optional[str] = None, test_suite: Optional[str] = None, + timestamp: Optional[datetime.datetime] = None, ): logger = Logger() @@ -437,6 +445,10 @@ def testcases(reports: List[str]) -> Generator[CaseEventType, None, None]: if len(tc.get('testPath', [])) == 0: continue + # Set specific time for importing historical data + if timestamp is not None: + tc["createdAt"] = timestamp.isoformat() + yield tc except Exception as e: From d98c0b5fbb290d8b4f71e021dd9fd7a8ae19cb8d Mon Sep 17 00:00:00 2001 From: Konboi Date: Fri, 23 May 2025 20:46:59 +0900 Subject: [PATCH 07/11] skip to check the timestamp of the report files when `--timestamp` option is enabled --- launchable/commands/record/tests.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/launchable/commands/record/tests.py b/launchable/commands/record/tests.py index 29d01d64c..843baec2d 100644 --- a/launchable/commands/record/tests.py +++ b/launchable/commands/record/tests.py @@ -412,8 +412,13 @@ def report(self, junit_report_file: str): ctime = datetime.datetime.fromtimestamp( os.path.getctime(junit_report_file)) - if not self.is_allow_test_before_build and not self.is_no_build and ( - self.check_timestamp and ctime.timestamp() < record_start_at.timestamp()): + if ( + not self.is_allow_test_before_build # nlqa: W503 + and not self.is_no_build # noqa: W503 + and timestamp is None # noqa: W503 + and self.check_timestamp # noqa: W503 + and ctime.timestamp() < record_start_at.timestamp() # noqa: W503 + ): format = "%Y-%m-%d %H:%M:%S" logger.warning("skip: {} is too old to report. start_record_at: {} file_created_at: {}".format( junit_report_file, record_start_at.strftime(format), ctime.strftime(format))) From ae6449374bc811b01c3c526790625e82a2198d2f Mon Sep 17 00:00:00 2001 From: Konboi Date: Fri, 23 May 2025 22:01:50 +0900 Subject: [PATCH 08/11] don't need zoneinfo lib --- tests/utils/test_click.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/utils/test_click.py b/tests/utils/test_click.py index e0f2120ea..b04029d64 100644 --- a/tests/utils/test_click.py +++ b/tests/utils/test_click.py @@ -2,7 +2,6 @@ from datetime import timezone from typing import Sequence, Tuple from unittest import TestCase -from zoneinfo import ZoneInfo import click from click.testing import CliRunner From d787caf6d8d48417f408083ab74bf63cc1ea24cd Mon Sep 17 00:00:00 2001 From: Konboi Date: Mon, 26 May 2025 10:06:25 +0900 Subject: [PATCH 09/11] support `--timestamp` option when create a test session --- launchable/commands/record/session.py | 11 +++++++++++ tests/commands/record/test_session.py | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/launchable/commands/record/session.py b/launchable/commands/record/session.py index a5e9d7771..71a30d300 100644 --- a/launchable/commands/record/session.py +++ b/launchable/commands/record/session.py @@ -1,3 +1,4 @@ +import datetime import os import re import sys @@ -6,6 +7,7 @@ import click +from launchable.utils.click import DATETIME_WITH_TZ from launchable.utils.link import LinkKind, capture_link from launchable.utils.tracking import Tracking, TrackingClient @@ -98,6 +100,13 @@ def _validate_session_name(ctx, param, value): type=str, metavar='TEST_SUITE', ) +@click.option( + '--timestamp', + 'timestamp', + help='Used to overwrite the session time when importing historical data. Note: Format must be `YYYY-MM-DDThh:mm:ssTZD` or `YYYY-MM-DDThh:mm:ss` (local timezone applied)', # noqa: E501 + type=DATETIME_WITH_TZ, + default=None, +) @click.pass_context def session( ctx: click.core.Context, @@ -111,6 +120,7 @@ def session( session_name: Optional[str] = None, lineage: Optional[str] = None, test_suite: Optional[str] = None, + timestamp: Optional[datetime.datetime] = None, ): """ print_session is for backward compatibility. @@ -166,6 +176,7 @@ def session( "noBuild": is_no_build, "lineage": lineage, "testSuite": test_suite, + "timestamp": timestamp.isoformat() if timestamp else None, } _links = capture_link(os.environ) diff --git a/tests/commands/record/test_session.py b/tests/commands/record/test_session.py index 44aa90b1c..6655c6d09 100644 --- a/tests/commands/record/test_session.py +++ b/tests/commands/record/test_session.py @@ -160,4 +160,26 @@ def test_run_session_with_test_suite(self): "noBuild": False, "lineage": None, "testSuite": "example-test-suite", + "timestamp": None, + }, payload) + + @responses.activate + @mock.patch.dict(os.environ, { + "LAUNCHABLE_TOKEN": CliTestCase.launchable_token, + 'LANG': 'C.UTF-8', + }, clear=True) + def test_run_session_with_timestamp(self): + result = self.cli("record", "session", "--build", self.build_name, + "--timestamp", "2023-10-01T12:00:00Z") + self.assert_success(result) + + payload = json.loads(responses.calls[0].request.body.decode()) + self.assert_json_orderless_equal({ + "flavors": {}, + "isObservation": False, + "links": [], + "noBuild": False, + "lineage": None, + "testSuite": None, + "timestamp": "2023-10-01T12:00:00+00:00", }, payload) From 18c1951d39af284444023ab759e566c775554fac Mon Sep 17 00:00:00 2001 From: Konboi Date: Mon, 26 May 2025 10:06:47 +0900 Subject: [PATCH 10/11] fix tests --- tests/commands/record/test_session.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/commands/record/test_session.py b/tests/commands/record/test_session.py index 6655c6d09..014e4a124 100644 --- a/tests/commands/record/test_session.py +++ b/tests/commands/record/test_session.py @@ -34,6 +34,7 @@ def test_run_session_without_flavor(self): "noBuild": False, "lineage": None, "testSuite": None, + "timestamp": None, }, payload) @responses.activate @@ -58,6 +59,7 @@ def test_run_session_with_flavor(self): "noBuild": False, "lineage": None, "testSuite": None, + "timestamp": None, }, payload) result = self.cli("record", "session", "--build", self.build_name, "--flavor", "only-key") @@ -82,6 +84,7 @@ def test_run_session_with_observation(self): "noBuild": False, "lineage": None, "testSuite": None, + "timestamp": None, }, payload) @responses.activate @@ -120,6 +123,7 @@ def test_run_session_with_session_name(self): "noBuild": False, "lineage": None, "testSuite": None, + "timestamp": None, }, payload) @responses.activate @@ -140,6 +144,7 @@ def test_run_session_with_lineage(self): "noBuild": False, "lineage": "example-lineage", "testSuite": None, + "timestamp": None, }, payload) @responses.activate From b9acf19b5c4704bef1b00cf093b2f848b50df004 Mon Sep 17 00:00:00 2001 From: Konboi Date: Mon, 26 May 2025 10:10:55 +0900 Subject: [PATCH 11/11] support `--timestamp` option when create a test session via find_or_create method --- launchable/commands/helper.py | 3 +++ launchable/commands/record/tests.py | 1 + 2 files changed, 4 insertions(+) diff --git a/launchable/commands/helper.py b/launchable/commands/helper.py index 181d99ce6..685ab6e20 100644 --- a/launchable/commands/helper.py +++ b/launchable/commands/helper.py @@ -1,3 +1,4 @@ +import datetime from time import time from typing import Optional, Sequence, Tuple @@ -64,6 +65,7 @@ def find_or_create_session( is_no_build: bool = False, lineage: Optional[str] = None, test_suite: Optional[str] = None, + timestamp: Optional[datetime.datetime] = None, ) -> Optional[str]: """Determine the test session ID to be used. @@ -134,6 +136,7 @@ def find_or_create_session( is_no_build=is_no_build, lineage=lineage, test_suite=test_suite, + timestamp=timestamp, ) return read_session(saved_build_name) diff --git a/launchable/commands/record/tests.py b/launchable/commands/record/tests.py index 843baec2d..249334313 100644 --- a/launchable/commands/record/tests.py +++ b/launchable/commands/record/tests.py @@ -244,6 +244,7 @@ def tests( links=links, lineage=lineage, test_suite=test_suite, + timestamp=timestamp, tracking_client=tracking_client)) build_name = read_build() record_start_at = get_record_start_at(session_id, client)