Skip to content
Merged
3 changes: 3 additions & 0 deletions launchable/commands/helper.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
from time import time
from typing import Optional, Sequence, Tuple

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)

Expand Down
15 changes: 12 additions & 3 deletions launchable/commands/record/build.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
import os
import re
import sys
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions launchable/commands/record/session.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
import os
import re
import sys
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 21 additions & 3 deletions launchable/commands/record/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions launchable/utils/click.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
60 changes: 53 additions & 7 deletions tests/commands/record/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ def test_submodule(self, mock_check_output):
"branchName": ""
},
],
"links": []
"links": [],
"timestamp": None
}, payload)

self.assertEqual(read_build(), self.build_name)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -124,7 +126,8 @@ def test_no_git_directory(self):
"branchName": "",
},
],
"links": []
"links": [],
"timestamp": None
}, payload)

self.assertEqual(read_build(), self.build_name)
Expand Down Expand Up @@ -153,7 +156,8 @@ def test_commit_option_and_build_option(self):
"branchName": ""
},
],
"links": []
"links": [],
'timestamp': None
}, payload)
responses.calls.reset()

Expand Down Expand Up @@ -182,7 +186,8 @@ def test_commit_option_and_build_option(self):
"branchName": "feature-xxx"
},
],
"links": []
"links": [],
"timestamp": None
}, payload)
responses.calls.reset()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -250,7 +256,8 @@ def test_commit_option_and_build_option(self):
"branchName": "feature-yyy"
},
],
"links": []
"links": [],
"timestamp": None
}, payload)
responses.calls.reset()

Expand All @@ -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)
27 changes: 27 additions & 0 deletions tests/commands/record/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def test_run_session_without_flavor(self):
"noBuild": False,
"lineage": None,
"testSuite": None,
"timestamp": None,
}, payload)

@responses.activate
Expand All @@ -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")
Expand All @@ -82,6 +84,7 @@ def test_run_session_with_observation(self):
"noBuild": False,
"lineage": None,
"testSuite": None,
"timestamp": None,
}, payload)

@responses.activate
Expand Down Expand Up @@ -120,6 +123,7 @@ def test_run_session_with_session_name(self):
"noBuild": False,
"lineage": None,
"testSuite": None,
"timestamp": None,
}, payload)

@responses.activate
Expand All @@ -140,6 +144,7 @@ def test_run_session_with_lineage(self):
"noBuild": False,
"lineage": "example-lineage",
"testSuite": None,
"timestamp": None,
}, payload)

@responses.activate
Expand All @@ -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)
Loading