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/build.py b/launchable/commands/record/build.py index 21dcba07b..d159bfbf0 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 @@ -11,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 @@ -96,13 +97,20 @@ 'lineage', hidden=True, ) +@click.option( + '--timestamp', + 'timestamp', + 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, +) @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 +334,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) 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/launchable/commands/record/tests.py b/launchable/commands/record/tests.py index 75df09d99..249334313 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() @@ -236,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) @@ -404,8 +413,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))) @@ -437,6 +451,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: diff --git a/launchable/utils/click.py b/launchable/utils/click.py index 9f2dfab7b..9245201b6 100644 --- a/launchable/utils/click.py +++ b/launchable/utils/click.py @@ -3,7 +3,9 @@ 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. @@ -94,10 +96,25 @@ 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 = 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:00 but 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/commands/record/test_build.py b/tests/commands/record/test_build.py index 5c9dca486..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() @@ -260,3 +267,42 @@ 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}) + # 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) + result = self.cli( + "record", + "build", + "--no-commit-collection", + "--commit", + "repo=abc12", + "--name", + self.build_name, + '--timestamp', + "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": None, + "commitHashes": [ + { + "repositoryName": "repo", + "commitHash": "abc12", + "branchName": "" + }, + ], + "links": [], + "timestamp": "2025-01-23T12:34:56+00:00" + }, payload) + + self.assertEqual(read_build(), self.build_name) diff --git a/tests/commands/record/test_session.py b/tests/commands/record/test_session.py index 44aa90b1c..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 @@ -160,4 +165,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) diff --git a/tests/utils/test_click.py b/tests/utils/test_click.py index f56e3e371..b04029d64 100644 --- a/tests/utils/test_click.py +++ b/tests/utils/test_click.py @@ -1,10 +1,13 @@ +import datetime +from datetime import timezone from typing import Sequence, Tuple from unittest import TestCase import click from click.testing import CliRunner +from dateutil.tz import tzlocal -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 +45,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')