diff --git a/pyproject.toml b/pyproject.toml index 258ec28..4482417 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ dependencies = [ "json5>=0.15.0", "pyyaml>=6.0.3", - "src-py-lib[otel]==0.3.1", + "src-py-lib[otel]==0.3.2", ] keywords = [ "Sourcegraph" diff --git a/src/src_auth_perms_sync/cli.py b/src/src_auth_perms_sync/cli.py index a07edb7..2fd4430 100644 --- a/src/src_auth_perms_sync/cli.py +++ b/src/src_auth_perms_sync/cli.py @@ -263,23 +263,23 @@ class Config(src.SourcegraphClientConfig, src.LoggingConfig, src.OpenTelemetryCo "NOTE: This can be CPU intensive on the database server,\n" "Reduce --parallelism if instance performance is impacted" ), - help_group="Set scope (required: pass --full or filters)", + help_group="Scope (required: 1 of the following)", ) users: tuple[str, ...] = src.config_field( default=(), env_var="SRC_AUTH_PERMS_SYNC_USERS", cli_flag="--users", metavar="USERS", - help="Add perms for a comma-delimited list of Sourcegraph usernames and/or email addresses", - help_group="Set scope (required: pass --full or filters)", + help="Filter on a comma-delimited list of Sourcegraph usernames and/or email addresses", + help_group="Scope (required: 1 of the following)", ) users_without_explicit_perms: bool = src.config_field( default=False, env_var="SRC_AUTH_PERMS_SYNC_USERS_WITHOUT_EXPLICIT_PERMS", cli_flag="--users-without-explicit-perms", cli_action="store_true", - help="Add perms for Sourcegraph users without explicit permissions", - help_group="Set scope (required: pass --full or filters)", + help="Filter on Sourcegraph users without explicit permissions", + help_group="Scope (required: 1 of the following)", ) users_created_after: str | None = src.config_field( default=None, @@ -287,24 +287,24 @@ class Config(src.SourcegraphClientConfig, src.LoggingConfig, src.OpenTelemetryCo cli_flag="--users-created-after", metavar="YYYY-MM-DD", pattern=r"^\d{4}-\d{2}-\d{2}$", - help="Add perms for Sourcegraph users created on or after this date", - help_group="Set scope (required: pass --full or filters)", + help="Filter on Sourcegraph users created on or after this date", + help_group="Scope (required: 1 of the following)", ) repos: tuple[str, ...] = src.config_field( default=(), env_var="SRC_AUTH_PERMS_SYNC_REPOS", cli_flag="--repos", metavar="REPOS", - help="Add perms for a comma-delimited list of Sourcegraph repository names", - help_group="Set scope (required: pass --full or filters)", + help="Filter on a comma-delimited list of Sourcegraph repository names", + help_group="Scope (required: 1 of the following)", ) repos_without_explicit_perms: bool = src.config_field( default=False, env_var="SRC_AUTH_PERMS_SYNC_REPOS_WITHOUT_EXPLICIT_PERMS", cli_flag="--repos-without-explicit-perms", cli_action="store_true", - help="Add perms for repositories without explicit permissions", - help_group="Set scope (required: pass --full or filters)", + help="Filter on repositories without explicit permissions", + help_group="Scope (required: 1 of the following)", ) repos_created_after: str | None = src.config_field( default=None, @@ -312,8 +312,8 @@ class Config(src.SourcegraphClientConfig, src.LoggingConfig, src.OpenTelemetryCo cli_flag="--repos-created-after", metavar="YYYY-MM-DD", pattern=r"^\d{4}-\d{2}-\d{2}$", - help="Add perms for repositories cloned to the Sourcegraph instance on or after this date", - help_group="Set scope (required: pass --full or filters)", + help="Filter on repositories cloned to the Sourcegraph instance on or after this date", + help_group="Scope (required: 1 of the following)", ) sync_saml_orgs: bool = src.config_field( default=False, @@ -404,28 +404,40 @@ class Config(src.SourcegraphClientConfig, src.LoggingConfig, src.OpenTelemetryCo argument_name="get", command_name="get", help="Discover auth providers and code hosts", - description="Gather auth providers, code hosts, users, and permissions.", + description=( + "Gather auth providers, code hosts, users, and permissions.\n" + "NOTE: All args can be set via cli args or environment variables" + ), config_fields=GET_CONFIG_FIELDS, ), CliCommand( argument_name="set", command_name="set", help="Reconcile repo permissions from maps.yaml", - description="Reconcile Sourcegraph explicit repo permissions from maps.yaml.", + description=( + "Reconcile Sourcegraph explicit repo permissions from maps.yaml\n" + "NOTE: All args can be set via cli args or environment variables" + ), config_fields=SET_CONFIG_FIELDS, ), CliCommand( argument_name="restore", command_name="restore", help="Restore repo permissions from a snapshot", - description="Restore Sourcegraph explicit repo permissions from a snapshot JSON file.", + description=( + "Restore Sourcegraph explicit repo permissions from a snapshot JSON file.\n" + "NOTE: All args can be set via cli args or environment variables" + ), config_fields=RESTORE_CONFIG_FIELDS, ), CliCommand( argument_name="sync-saml-orgs", command_name="sync_saml_orgs", help="Sync orgs from SAML groups", - description="Create/update Sourcegraph organizations and memberships from SAML groups.", + description=( + "Create/update Sourcegraph organizations and memberships from SAML groups.\n" + "NOTE: All args can be set via cli args or environment variables" + ), config_fields=SYNC_SAML_ORGS_CONFIG_FIELDS, ), ) @@ -690,6 +702,12 @@ def load_cli(argv: Sequence[str] | None = None) -> CliInput: formatter_class=argparse.RawDescriptionHelpFormatter, allow_abbrev=False, ) + parser.add_argument( + "--version", + action="version", + version=f"src-auth-perms-sync {_package_version()}", + help="Show the installed src-auth-perms-sync version and exit", + ) subparsers = parser.add_subparsers( title="commands", metavar="COMMAND", diff --git a/tests/integration/test_cli_entrypoint.py b/tests/integration/test_cli_entrypoint.py index 6ecd88e..0a03152 100644 --- a/tests/integration/test_cli_entrypoint.py +++ b/tests/integration/test_cli_entrypoint.py @@ -85,9 +85,9 @@ def test_command_help_prints_command_specific_options(self) -> None: self.assertIn("--users USERS", set_help.stdout) self.assertIn("--sync-saml-orgs", set_help.stdout) self.assertNotIn("--restore-path", set_help.stdout) - self.assertIn("Set scope (required: pass --full or filters):", set_help.stdout) + self.assertIn("Scope (required: 1 of the following):", set_help.stdout) self.assertLess( - set_help.stdout.index("\nSet scope"), + set_help.stdout.index("\nScope (required:"), set_help.stdout.index("\nOrganization sync:"), ) self.assertNotIn("\nUser filters:", set_help.stdout) diff --git a/tests/live-maps-seed.yaml b/tests/live-maps-seed.yaml new file mode 100644 index 0000000..d9bc37f --- /dev/null +++ b/tests/live-maps-seed.yaml @@ -0,0 +1,80 @@ +# Synthetic-corpus mapping rules used to bootstrap live test runs. +# +# The live harness (tests/run.py --live) writes this file to +# src-auth-perms-sync-runs//maps.yaml when that path is missing or +# still holds the empty default template, so a fresh worktree can run the +# mutating permission cycles. An operator-maintained maps.yaml with real +# rules is left untouched. +# +# It targets the corpus that tests/setup.yaml provisions on the test +# instance: +# - users: test_user_00001 through test_user_10000 +# - repos: test-repo-00001 through test-repo-50000 +# +# Default active shape: +# - one large rectangular non-overlap rule: 10k users x 1k repos = 10M grants +# - two small overlapping rules near repo 49k to validate union correctness +# +# The 50k-repo all-repo selector below is intentionally not active by default: +# 10k users x 50k repos would produce 500M planned grants and very large +# dry-run after/diff files. Use it only for a deliberate long stress run. + +x-test-users-all: &test_users_all + - '^test_user_[0-9]{5}$' + +x-test-users-00001-00100: &test_users_00001_00100 + - '^test_user_(0000[1-9]|000[1-9][0-9]|00100)$' + +x-test-users-00101-00200: &test_users_00101_00200 + - '^test_user_(0010[1-9]|001[1-9][0-9]|00200)$' + +x-stress-code-host: &stress_code_host + kind: OTHER + displayName: 'OTHER #1' + url: http://src-serve-git:3434/ + +x-test-repos-00001-01000: &test_repos_00001_01000 + - '^test-repo-(00[0-9]{3}|01000)$' + +x-test-repos-49001-49100: &test_repos_49001_49100 + - '^test-repo-(4900[1-9]|490[1-9][0-9]|49100)$' + +x-test-repos-49051-49150: &test_repos_49051_49150 + - '^test-repo-(4905[1-9]|490[6-9][0-9]|491[0-4][0-9]|49150)$' + +x-test-repos-all-50000: &test_repos_all_50000 + - '^test-repo-[0-4][0-9]{4}$' + - '^test-repo-50000$' + +maps: + +- name: MEMORY phase1 rectangle 10k users to first 1000 test repos + users: + usernameRegexes: *test_users_all + repos: + codeHostConnection: *stress_code_host + nameRegexes: *test_repos_00001_01000 + +- name: MEMORY overlap A first 100 users to repos 49001-49100 + users: + usernameRegexes: *test_users_00001_00100 + repos: + codeHostConnection: *stress_code_host + nameRegexes: *test_repos_49001_49100 + +- name: MEMORY overlap B next 100 users to repos 49051-49150 + users: + usernameRegexes: *test_users_00101_00200 + repos: + codeHostConnection: *stress_code_host + nameRegexes: *test_repos_49051_49150 + +# Deliberate heavy stress profile; uncomment as the only active map for a +# 500M-grant planning run. Expect very large dry-run artifact files. +# +# - name: MEMORY max rectangle 10k users to all 50000 test repos +# users: +# usernameRegexes: *test_users_all +# repos: +# codeHostConnection: *stress_code_host +# nameRegexes: *test_repos_all_50000 diff --git a/tests/run.py b/tests/run.py index d4b470f..236d7ba 100644 --- a/tests/run.py +++ b/tests/run.py @@ -73,6 +73,19 @@ from tests.e2e.case_runner import FixtureRunResult, FixtureState FIXTURES_DIR = ROOT / "tests" / "e2e" / "fixtures" +# Canonical synthetic-corpus maps.yaml the live harness seeds into a fresh +# worktree's endpoint runs directory so the mutating permission cycles have a +# valid maps file to apply. See ensure_live_maps_seed(). +LIVE_MAPS_SEED_PATH = ROOT / "tests" / "live-maps-seed.yaml" +# Live checks that reach the pgsql pod over `kubectl exec` (and so need a +# valid AWS SSO session). check_database_connectivity() probes the pod when +# any of these is selected and skips them up front when it is unreachable. +DATABASE_BACKED_LIVE_CHECKS = ("live: perms follow saml group change",) +# When the pod is unreachable and the run is interactive, the harness offers +# to refresh AWS SSO, then re-probes on this cadence until it succeeds. +AWS_SSO_LOGIN_TIMEOUT_SECONDS = 300 +DATABASE_PROBE_RETRY_INTERVAL_SECONDS = 10 +DATABASE_PROBE_RETRY_TIMEOUT_SECONDS = 600 TEST_LOGS_DIR = ROOT / "logs" LOG_PATH_PATTERN = re.compile(r"Writing log events to (.+?/log\.json)\.") STRUCTURED_EVENT_LINE_PATTERN = re.compile(r"^[.]*event=\S+\s*$") @@ -880,6 +893,10 @@ class TestSuite: test_user: str = "" skipped_check_names: list[str] = field(default_factory=list[str]) filter_matched_count: int = 0 + # Set by check_database_connectivity(); None until the pgsql pod is probed. + # False means DB-backed live tests are skipped (the recorded probe failure + # still makes the run exit non-zero). + database_available: bool | None = None # -- bookkeeping -------------------------------------------------------- @@ -1436,6 +1453,7 @@ def run_live(self) -> None: return self.record("live prerequisites", "live", True, 0.0) + self.check_database_connectivity() self.check_live_hygiene() if self.select("wheel install smoke"): self.run_wheel_install_smoke() @@ -1445,6 +1463,110 @@ def run_live(self) -> None: self.run_live_permission_cycles(environment) self.check_live_hygiene() + def check_database_connectivity(self) -> None: + """Probe the pgsql pod once, up front, so an expired AWS SSO session + is reported as a single early failure instead of crashing a later + DB-backed test mid-run. + + Only DB-backed live tests need the pod (via `kubectl exec` against + EKS, which requires a valid AWS SSO session), so the probe is skipped + when none of them are selected. When the probe fails on an interactive + run, the harness refreshes AWS SSO and re-probes (see + recover_database_access). On unrecovered failure, DB-backed tests skip + themselves and the run still exits non-zero via this recorded failure. + """ + label = "live: database connectivity" + if not self.test_selected(label, *DATABASE_BACKED_LIVE_CHECKS): + return + started = time.monotonic() + error = self.probe_database() + if error is not None and sys.stdin.isatty(): + error = self.recover_database_access(error) + if error is None: + self.database_available = True + self.record(label, "live", True, time.monotonic() - started) + else: + self.database_available = False + self.record( + label, "live", False, time.monotonic() - started, database_error_detail(error) + ) + + def probe_database(self) -> str | None: + """Run `SELECT 1` on the pgsql pod; return None on success, else the error.""" + from tests import setup as instance_setup + + try: + kubectl_config = cast("dict[str, Any]", load_setup_config()["kubectl"]) + instance_setup.run_sql(kubectl_config, "SELECT 1;", timeout_seconds=30) + except Exception as exception: + return str(exception) + return None + + def recover_database_access(self, error: str) -> str | None: + """Refresh AWS SSO interactively, then re-probe until the pod responds. + + Runs `aws sso login` once, then retries the probe every 10s for up to + 10 minutes. Returns None once the pod is reachable, or the latest + error if SSO login could not run or the pod stayed unreachable. + """ + log.warning("Database probe failed: %s", database_error_detail(error)) + if not self.run_aws_sso_login(): + return error + deadline = time.monotonic() + DATABASE_PROBE_RETRY_TIMEOUT_SECONDS + while True: + probe_error = self.probe_database() + if probe_error is None: + log.info("Database reachable after AWS SSO refresh.") + return None + if time.monotonic() >= deadline: + return probe_error + log.info( + "Database still unreachable; retrying in %ds (up to 10 min total)...", + DATABASE_PROBE_RETRY_INTERVAL_SECONDS, + ) + time.sleep(DATABASE_PROBE_RETRY_INTERVAL_SECONDS) + + def resolve_aws_profile(self) -> str | None: + """AWS profile for SSO login: AWS_PROFILE, then setup.yaml + kubectl.awsProfile, else None (the default profile).""" + profile = os.environ.get("AWS_PROFILE") + if profile: + return profile + kubectl_config = cast("dict[str, Any]", load_setup_config().get("kubectl") or {}) + configured = kubectl_config.get("awsProfile") + return str(configured) if configured else None + + def run_aws_sso_login(self) -> bool: + """Run `aws sso login` for the resolved profile; return True if it ran ok.""" + command = ["aws", "sso", "login"] + profile = self.resolve_aws_profile() + if profile: + command += ["--profile", profile] + log.warning("Running `%s` - complete the browser prompt to continue...", " ".join(command)) + try: + completed = subprocess.run(command, timeout=AWS_SSO_LOGIN_TIMEOUT_SECONDS, check=False) + except FileNotFoundError: + log.error("aws CLI not found; cannot refresh AWS SSO automatically") + return False + except subprocess.TimeoutExpired: + log.error("`aws sso login` timed out after %ds", AWS_SSO_LOGIN_TIMEOUT_SECONDS) + return False + if completed.returncode != 0: + log.error("`aws sso login` failed (exit %d)", completed.returncode) + return False + return True + + def skip_if_database_unavailable(self, label: str) -> bool: + """Log and return True when a DB-backed test must skip the pgsql pod.""" + if self.database_available is False: + log.warning( + "SKIP [live] %s - database unavailable " + "(see 'live: database connectivity'); run `aws sso login`", + label, + ) + return True + return False + def check_live_hygiene(self) -> None: """Cheap small-state guard: no pending bindIDs should ever persist. @@ -2178,6 +2300,8 @@ def run_saml_group_change_check(self, environment: dict[str, str]) -> None: label = "live: perms follow saml group change" if not self.select(label): return + if self.skip_if_database_unavailable(label): + return log.info("\n--- Live: permissions follow a SAML group change ---") import yaml @@ -2428,6 +2552,32 @@ def check_pending_states( "; ".join(mismatches) if mismatches else f"{len(expected_pending)} repo(s) match", ) + def live_maps_path(self) -> Path: + """Endpoint root maps.yaml the permission cycles apply (CLI default).""" + from src_auth_perms_sync.shared import backups + + return ( + Path(backups.ARTIFACTS_DIR_NAME) + / backups.endpoint_directory_name(self.endpoint) + / backups.DEFAULT_MAPS_FILE_NAME + ) + + def ensure_live_maps_seed(self) -> None: + """Seed a valid maps.yaml so a fresh worktree can run the cycles. + + The permission cycles apply the endpoint root maps.yaml. On a fresh + worktree that file does not exist, so the baseline `get` writes the + empty default template, which then fails `set` validation. Seed the + synthetic-corpus maps when the file is missing or unusable; leave an + operator-maintained maps.yaml with real rules untouched. + """ + maps_path = self.live_maps_path() + if maps_file_has_usable_rule(maps_path): + return + maps_path.parent.mkdir(parents=True, exist_ok=True) + maps_path.write_text(LIVE_MAPS_SEED_PATH.read_text(encoding="utf-8"), encoding="utf-8") + log.info("Seeded live maps file from %s -> %s", LIVE_MAPS_SEED_PATH, maps_path) + def run_live_permission_cycles(self, environment: dict[str, str]) -> None: # The baseline get is a prerequisite for both cycles, so it runs when # any of them is selected. @@ -2445,6 +2595,7 @@ def run_live_permission_cycles(self, environment: dict[str, str]) -> None: if not want_baseline: return log.info("\n--- Live: permission cycles with independent read-back ---") + self.ensure_live_maps_seed() baseline = self.run_cli_case( CliCase( "live: get user baseline", @@ -3557,6 +3708,33 @@ def load_setup_config() -> dict[str, Any]: return cast("dict[str, Any]", yaml.safe_load(SETUP_CONFIG_PATH.read_text(encoding="utf-8"))) +def database_error_detail(error: str) -> str: + """Summarize a pgsql/kubectl failure, calling out expired AWS SSO.""" + lowered = error.lower() + if "sso session" in lowered and ("expired" in lowered or "invalid" in lowered): + return "pgsql pod unreachable: AWS SSO session expired - run `aws sso login`" + first_line = next((line for line in error.splitlines() if line.strip()), "unknown error") + return f"pgsql pod unreachable: {first_line.strip()}" + + +def maps_file_has_usable_rule(path: Path) -> bool: + """Return True if the maps file has a rule with both users and repos. + + A `set` run needs at least one rule carrying both sections; the empty + default template (a lone `- name: Map 1`) fails validation, so it is not + usable. + """ + import yaml + + if not path.is_file(): + return False + loaded = cast("dict[str, Any]", yaml.safe_load(path.read_text(encoding="utf-8")) or {}) + for rule in cast("list[dict[str, Any]]", loaded.get("maps") or []): + if rule.get("users") and rule.get("repos"): + return True + return False + + def fixture_grants(case_name: str, file_name: str) -> dict[str, set[str]] | None: """Return {repo name: usernames} from one fixture state file.""" path = FIXTURES_DIR / case_name / file_name diff --git a/tests/setup.yaml b/tests/setup.yaml index 9a7e5c6..c7df4b2 100644 --- a/tests/setup.yaml +++ b/tests/setup.yaml @@ -11,6 +11,10 @@ kubectl: databaseUser: sg database: sg tenantID: 1 + # Optional. AWS SSO profile the live harness refreshes when the pgsql pod + # is unreachable on an interactive run. Precedence: AWS_PROFILE env var, + # then this key, then the default profile. Omit to use the default. + # awsProfile: my-sso-profile users: # Synthetic users are pre-provisioned in bulk; setup verifies the count diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 79d16b7..6fdcfd0 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -5,6 +5,7 @@ import os import tempfile import unittest +from collections.abc import Sequence from concurrent.futures import ThreadPoolExecutor from pathlib import Path from types import SimpleNamespace @@ -429,6 +430,97 @@ def test_get_help_lists_artifact_options(self) -> None: self.assertIn("--artifacts-dir", help_text) self.assertIn("--no-files", help_text) + def test_set_help_lists_set_specific_options(self) -> None: + help_text = self.load_cli_help_text("set", "--help") + + self.assertIn("--full", help_text) + self.assertIn("--maps-path", help_text) + self.assertIn("--apply", help_text) + + def test_restore_help_lists_restore_specific_options(self) -> None: + help_text = self.load_cli_help_text("restore", "--help") + + self.assertIn("--restore-path", help_text) + self.assertIn("--apply", help_text) + self.assertNotIn("--maps-path", help_text) + + def test_sync_saml_orgs_help_lists_mode_options(self) -> None: + help_text = self.load_cli_help_text("sync-saml-orgs", "--help") + + self.assertIn("--full", help_text) + self.assertIn("--users", help_text) + self.assertNotIn("--maps-path", help_text) + + def test_version_flag_reports_package_version(self) -> None: + captured_stdout = io.StringIO() + + with ( + contextlib.redirect_stdout(captured_stdout), + self.assertRaises(SystemExit) as exit_context, + ): + cli.load_cli(["--version"]) + + self.assertEqual(0, exit_context.exception.code) + self.assertIn("src-auth-perms-sync", captured_stdout.getvalue()) + + def test_parallelism_config_is_loaded_from_env(self) -> None: + config = load_config_from_env(SRC_AUTH_PERMS_SYNC_PARALLELISM="8") + + self.assertEqual(8, config.parallelism) + + def test_parallelism_rejects_values_below_one(self) -> None: + with self.assertRaisesRegex(shared_config.ConfigError, "greater than or equal to 1"): + load_config_from_env(SRC_AUTH_PERMS_SYNC_PARALLELISM="0") + + def test_max_attempts_config_is_loaded_from_env(self) -> None: + config = load_config_from_env(SRC_AUTH_PERMS_SYNC_MAX_ATTEMPTS="3") + + self.assertEqual(3, config.max_attempts) + + def test_max_attempts_rejects_values_below_one(self) -> None: + with self.assertRaisesRegex(shared_config.ConfigError, "greater than or equal to 1"): + load_config_from_env(SRC_AUTH_PERMS_SYNC_MAX_ATTEMPTS="0") + + def test_sample_interval_config_is_loaded_from_env(self) -> None: + config = load_config_from_env(SRC_AUTH_PERMS_SYNC_SAMPLE_INTERVAL="2.5") + + self.assertEqual(2.5, config.sample_interval) + + def test_sample_interval_allows_zero_to_disable_sampling(self) -> None: + config = load_config_from_env(SRC_AUTH_PERMS_SYNC_SAMPLE_INTERVAL="0") + + self.assertEqual(0, config.sample_interval) + + def test_sample_interval_rejects_negative_values(self) -> None: + with self.assertRaisesRegex(shared_config.ConfigError, "greater than or equal to 0"): + load_config_from_env(SRC_AUTH_PERMS_SYNC_SAMPLE_INTERVAL="-5") + + def test_users_config_drops_empty_and_whitespace_only_entries(self) -> None: + config = load_config_from_env(SRC_AUTH_PERMS_SYNC_USERS=" , ,, ") + + self.assertEqual((), config.users) + + def test_load_cli_rejects_restore_path_env_var_on_set(self) -> None: + self.assert_load_cli_rejects_env( + ["set", "--full"], + {"SRC_AUTH_PERMS_SYNC_RESTORE_PATH": "snapshot.json"}, + "--restore-path requires the restore command", + ) + + def test_load_cli_rejects_maps_path_env_var_on_restore(self) -> None: + self.assert_load_cli_rejects_env( + ["restore", "--restore-path", "snapshot.json"], + {"SRC_AUTH_PERMS_SYNC_MAPS_PATH": "maps.yaml"}, + "--maps-path requires the get or set command", + ) + + def test_load_cli_rejects_full_env_var_on_get(self) -> None: + self.assert_load_cli_rejects_env( + ["get"], + {"SRC_AUTH_PERMS_SYNC_FULL": "true"}, + "--full requires the set or sync-saml-orgs command", + ) + def test_validate_config_rejects_restore_without_restore_path(self) -> None: self.assert_config_error("restore", make_config(), "restore requires --restore-path") @@ -953,3 +1045,42 @@ def assert_config_error( cli.validate_config(command_name, config) self.assertEqual(2, exit_context.exception.code) self.assertIn(expected_message, captured_stderr.getvalue()) + + def load_cli_help_text(self, *argv: str) -> str: + captured_stdout = io.StringIO() + with ( + contextlib.redirect_stdout(captured_stdout), + self.assertRaises(SystemExit) as exit_context, + ): + cli.load_cli([*argv]) + self.assertEqual(0, exit_context.exception.code) + return captured_stdout.getvalue() + + def assert_load_cli_rejects_env( + self, + argv: Sequence[str], + env: dict[str, str], + expected_message: str, + ) -> None: + with ( + tempfile.TemporaryDirectory() as directory, + mock.patch.dict( + os.environ, + { + "SRC_ENDPOINT": "https://sourcegraph.example.com", + "SRC_ACCESS_TOKEN": "secret", + **env, + }, + clear=True, + ), + ): + env_file = Path(directory) / ".env" + env_file.write_text("") + captured_stderr = io.StringIO() + with ( + contextlib.redirect_stderr(captured_stderr), + self.assertRaises(SystemExit) as exit_context, + ): + cli.load_cli([*argv, "--env-file", str(env_file)]) + self.assertEqual(2, exit_context.exception.code) + self.assertIn(expected_message, captured_stderr.getvalue()) diff --git a/uv.lock b/uv.lock index 936a07a..29482c2 100644 --- a/uv.lock +++ b/uv.lock @@ -548,7 +548,7 @@ dev = [ requires-dist = [ { name = "json5", specifier = ">=0.15.0" }, { name = "pyyaml", specifier = ">=6.0.3" }, - { name = "src-py-lib", extras = ["otel"], specifier = "==0.3.1" }, + { name = "src-py-lib", extras = ["otel"], specifier = "==0.3.2" }, ] [package.metadata.requires-dev] @@ -560,7 +560,7 @@ dev = [ [[package]] name = "src-py-lib" -version = "0.3.1" +version = "0.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -568,9 +568,9 @@ dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/bb/65afd84cf2c730a50d5b995236c5cf7700c35d397f375b23517f44f97d72/src_py_lib-0.3.1.tar.gz", hash = "sha256:e2fb46269fb0a493275a2e7701b7ae34532c11f7167f98481c09370abb3ab41f", size = 97312, upload-time = "2026-06-26T06:51:23.265Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/b5/e711949b9c27b3cbadfcc90cf56c43db308b319fd0732f12c0a4722bc9f1/src_py_lib-0.3.2.tar.gz", hash = "sha256:5675f2ee4eb7cde8daca066550f9bf684c94e76b3496618261794dcb765e85bc", size = 97377, upload-time = "2026-06-27T19:20:46.334Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/a1/cfc286ec004747082fa9f6d17058dcb4c80e45d68d29a4e9ebf456be9a70/src_py_lib-0.3.1-py3-none-any.whl", hash = "sha256:f507a4cf3738275fd5dc6b7efd954b1ed420d3c934ed53f21739f46464fd2705", size = 54382, upload-time = "2026-06-26T06:51:21.855Z" }, + { url = "https://files.pythonhosted.org/packages/38/1e/a41a634803bb330ba7cfdaa2d2757ae129e3d66bf35f90e1796663edfdc0/src_py_lib-0.3.2-py3-none-any.whl", hash = "sha256:9cd81d2a98d32137fc6e799a5b4decd61b72f836b5d049b21a70247dac0f7bd5", size = 54434, upload-time = "2026-06-27T19:20:44.937Z" }, ] [package.optional-dependencies]