diff --git a/plain2code.py b/plain2code.py index a7c93045..ed5796b2 100644 --- a/plain2code.py +++ b/plain2code.py @@ -482,7 +482,7 @@ def main(): # noqa: C901 codeplainAPI = codeplain_api.CodeplainAPI(args.api_key, console) codeplainAPI.api_url = args.api - run_state = RunState(spec_filename=args.filename, replay_with=args.replay_with) + run_state = RunState(spec_filename=args.filename, replay_with=args.replay_with, job_tag=args.job_tag) # Without a terminal nobody can watch or drive the TUI, so render as --headless does: # progress on stdout, and a non-zero exit status when the render stops. diff --git a/plain2code_arguments.py b/plain2code_arguments.py index 0aacbeb5..e7d9c559 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -19,6 +19,7 @@ DEFAULT_BUILD_FOLDER = "plain_modules" DEFAULT_BUILD_DEST = "dist" DEFAULT_CONFORMANCE_TESTS_DEST = "dist_conformance_tests" +MAX_JOB_TAG_LENGTH = 300 UNIT_TESTS_SCRIPT_NAME = "unittests_script" CONFORMANCE_TESTS_SCRIPT_NAME = "conformance_tests_script" @@ -76,6 +77,15 @@ def non_empty_string(s): return s +def job_tag_string(s): + s = str(s) + if not s: + raise argparse.ArgumentTypeError("The job tag cannot be empty.") + if len(s) > MAX_JOB_TAG_LENGTH: + raise argparse.ArgumentTypeError(f"The job tag cannot be longer than {MAX_JOB_TAG_LENGTH} characters.") + return s + + def frid_string(s): """Validate that the FRID is an integer.""" if not s: @@ -363,6 +373,15 @@ def create_parser(): help="", ) + _add_arg( + parser, + "--job-tag", + type=job_tag_string, + default=None, + help=f"Tag sent to the server with every request and attached to the render's LangSmith traces " + f"(max {MAX_JOB_TAG_LENGTH} characters).", + ) + _add_arg( parser, "--template-dir", @@ -450,6 +469,15 @@ def create_parser(): return parser +def _validate_job_tag(args, parser): + # Values from config.yaml bypass argparse's type conversion, so validate them here. + if args.job_tag is not None: + try: + args.job_tag = job_tag_string(args.job_tag) + except argparse.ArgumentTypeError as e: + parser.error(f"--job-tag: {e}") + + def parse_arguments(command_line: Optional[Sequence[str]] = None): parser = create_parser() @@ -496,6 +524,8 @@ def parse_arguments(command_line: Optional[Sequence[str]] = None): if not args.log_to_file and args.argument_sources.get("log_file_name") != "default": parser.error("--log-file-name cannot be used when --log-to-file is False.") + _validate_job_tag(args, parser) + if args.full_plain and args.dry_run: parser.error("--full-plain and --dry-run are mutually exclusive") diff --git a/plain2code_state.py b/plain2code_state.py index 19cc21ac..a295cb97 100644 --- a/plain2code_state.py +++ b/plain2code_state.py @@ -8,7 +8,7 @@ class RunState: """Contains information about the identifiable state of the rendering process.""" - def __init__(self, spec_filename: str, replay_with: Optional[str] = None): + def __init__(self, spec_filename: str, replay_with: Optional[str] = None, job_tag: Optional[str] = None): self.replay: bool = replay_with is not None self.render_succeeded: bool = False self.render_cancelled: bool = False @@ -19,6 +19,7 @@ def __init__(self, spec_filename: str, replay_with: Optional[str] = None): else: self.render_id: str = str(uuid.uuid4()) self.spec_filename: str = spec_filename + self.job_tag: Optional[str] = job_tag self.call_count: int = 0 self.unittest_batch_id: int = 0 self.render_time_accumulated: int = 0 @@ -70,9 +71,12 @@ def set_last_render_start_timestamp(self): self.last_render_start_timestamp = time.monotonic() def to_dict(self): - return { + state = { "render_id": self.render_id, "call_count": self.call_count, "replay": self.replay, "spec_filename": self.spec_filename, } + if self.job_tag is not None: + state["job_tag"] = self.job_tag + return state diff --git a/tests/test_arg_provenance.py b/tests/test_arg_provenance.py index 0ef6807f..4764fec1 100644 --- a/tests/test_arg_provenance.py +++ b/tests/test_arg_provenance.py @@ -87,3 +87,23 @@ def test_mixed_sources(project): assert srcs["conformance_tests_dest"] == "cli" assert srcs["build_folder"] == "config" assert srcs["build_dest"] == "default" + + +def test_job_tag_from_cli_and_config(project): + plain_file = os.path.join(project, "module.plain") + assert parse_arguments([plain_file]).job_tag is None + assert parse_arguments([plain_file, "--job-tag", "nightly"]).job_tag == "nightly" + + (Path(project) / "config.yaml").write_text("job-tag: 123\n") + assert parse_arguments([plain_file]).job_tag == "123" + assert parse_arguments([plain_file, "--job-tag", "from_cli"]).job_tag == "from_cli" + + +def test_job_tag_too_long_is_rejected(project): + plain_file = os.path.join(project, "module.plain") + with pytest.raises(SystemExit): + parse_arguments([plain_file, "--job-tag", "x" * 301]) + + (Path(project) / "config.yaml").write_text(f"job-tag: {'x' * 301}\n") + with pytest.raises(SystemExit): + parse_arguments([plain_file]) diff --git a/tests/test_plain2code_state.py b/tests/test_plain2code_state.py index 71c1c714..e9612ee3 100644 --- a/tests/test_plain2code_state.py +++ b/tests/test_plain2code_state.py @@ -34,3 +34,8 @@ def test_add_to_render_time_is_cumulative_across_segments(): run_state.add_to_render_time() assert run_state.render_time_accumulated == 7 + + +def test_to_dict_includes_job_tag_only_when_set(): + assert "job_tag" not in RunState(spec_filename="x.plain").to_dict() + assert RunState(spec_filename="x.plain", job_tag="nightly").to_dict()["job_tag"] == "nightly" diff --git a/tests/test_render_finished_report.py b/tests/test_render_finished_report.py index 811e79df..b38210fb 100644 --- a/tests/test_render_finished_report.py +++ b/tests/test_render_finished_report.py @@ -97,6 +97,7 @@ def make_args(api_key="test-key", headless=False): render_from=None, headless=headless, replay_with=None, + job_tag=None, log_to_file=False, log_file_name="codeplain.log", conformance_tests_script=None, diff --git a/tests/test_terminal_attachment.py b/tests/test_terminal_attachment.py index 2b59960c..3da6e24c 100644 --- a/tests/test_terminal_attachment.py +++ b/tests/test_terminal_attachment.py @@ -84,6 +84,7 @@ def make_args(): force_render=False, headless=False, replay_with=None, + job_tag=None, log_to_file=False, log_file_name="codeplain.log", render_conformance_tests=False,