From b1cc86bbfb4282cf27d907105854febd57b3ba3a Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:12:31 -0600 Subject: [PATCH 1/4] Remove default src endpoint --- src/src_py_lib/clients/sourcegraph.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/src_py_lib/clients/sourcegraph.py b/src/src_py_lib/clients/sourcegraph.py index 2edbb93..a549d24 100644 --- a/src/src_py_lib/clients/sourcegraph.py +++ b/src/src_py_lib/clients/sourcegraph.py @@ -16,16 +16,13 @@ from src_py_lib.clients.graphql import GraphQLClient, stream_connection_nodes from src_py_lib.utils.config import Config, config_field from src_py_lib.utils.http import HTTPClient, HTTPClientError, HTTPResponse -from src_py_lib.utils.json_types import JSONDict, JSONValue, json_dict, json_list -from src_py_lib.utils.logging import ( - current_trace_context, - new_trace_context, - submit_with_log_context, - trace_context_from_traceparent, - traceparent_header, -) - -DEFAULT_SOURCEGRAPH_ENDPOINT = "https://sourcegraph.com" +from src_py_lib.utils.json_types import (JSONDict, JSONValue, json_dict, + json_list) +from src_py_lib.utils.logging import (current_trace_context, new_trace_context, + submit_with_log_context, + trace_context_from_traceparent, + traceparent_header) + SOURCEGRAPH_EXTERNAL_SERVICE_NODE_TYPE: Final[str] = "ExternalService" SOURCEGRAPH_REPOSITORY_NODE_TYPE: Final[str] = "Repository" REQUEST_TRACE_HEADER: Final[str] = "X-Sourcegraph-Request-Trace" @@ -158,11 +155,12 @@ class SourcegraphClientConfig(Config): """Config fields needed to build a Sourcegraph API client.""" src_endpoint: str = config_field( - default=DEFAULT_SOURCEGRAPH_ENDPOINT, + default="", env_var="SRC_ENDPOINT", cli_flag="--src-endpoint", metavar="URL", - help=f"Sourcegraph instance URL (default: {DEFAULT_SOURCEGRAPH_ENDPOINT})", + help=f"Sourcegraph instance URL", + required=True, ) src_access_token: str = config_field( default="", From 264ff35d4b7c9ba00bf6647865f0de0a21cf8b6f Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:47:41 -0600 Subject: [PATCH 2/4] Group configs in helper text --- src/src_py_lib/__init__.py | 4 + src/src_py_lib/clients/sourcegraph.py | 18 ++-- src/src_py_lib/utils/config.py | 139 ++++++++++++++++++++++---- src/src_py_lib/utils/logging.py | 4 + tests/test_import.py | 2 + tests/test_logging_http_clients.py | 109 +++++++++++++++++++- 6 files changed, 249 insertions(+), 27 deletions(-) diff --git a/src/src_py_lib/__init__.py b/src/src_py_lib/__init__.py index 3518d9e..deaa203 100644 --- a/src/src_py_lib/__init__.py +++ b/src/src_py_lib/__init__.py @@ -52,6 +52,8 @@ Config, ConfigError, config_field, + config_field_names, + config_help_formatter, config_snapshot, ) from src_py_lib.utils.config import ( @@ -150,6 +152,8 @@ def _script_name() -> str: "TraceContext", "aliased_batched_query", "config_field", + "config_field_names", + "config_help_formatter", "config_snapshot", "configure_logging", "critical", diff --git a/src/src_py_lib/clients/sourcegraph.py b/src/src_py_lib/clients/sourcegraph.py index a549d24..79a77fb 100644 --- a/src/src_py_lib/clients/sourcegraph.py +++ b/src/src_py_lib/clients/sourcegraph.py @@ -16,12 +16,14 @@ from src_py_lib.clients.graphql import GraphQLClient, stream_connection_nodes from src_py_lib.utils.config import Config, config_field from src_py_lib.utils.http import HTTPClient, HTTPClientError, HTTPResponse -from src_py_lib.utils.json_types import (JSONDict, JSONValue, json_dict, - json_list) -from src_py_lib.utils.logging import (current_trace_context, new_trace_context, - submit_with_log_context, - trace_context_from_traceparent, - traceparent_header) +from src_py_lib.utils.json_types import JSONDict, JSONValue, json_dict, json_list +from src_py_lib.utils.logging import ( + current_trace_context, + new_trace_context, + submit_with_log_context, + trace_context_from_traceparent, + traceparent_header, +) SOURCEGRAPH_EXTERNAL_SERVICE_NODE_TYPE: Final[str] = "ExternalService" SOURCEGRAPH_REPOSITORY_NODE_TYPE: Final[str] = "Repository" @@ -159,7 +161,8 @@ class SourcegraphClientConfig(Config): env_var="SRC_ENDPOINT", cli_flag="--src-endpoint", metavar="URL", - help=f"Sourcegraph instance URL", + help="Sourcegraph instance URL", + help_group="Sourcegraph", required=True, ) src_access_token: str = config_field( @@ -168,6 +171,7 @@ class SourcegraphClientConfig(Config): cli_flag="--src-access-token", metavar="TOKEN", help="Sourcegraph access token, or op:// secret reference", + help_group="Sourcegraph", secret=True, required=True, ) diff --git a/src/src_py_lib/utils/config.py b/src/src_py_lib/utils/config.py index f186a3c..f924a5e 100644 --- a/src/src_py_lib/utils/config.py +++ b/src/src_py_lib/utils/config.py @@ -16,7 +16,7 @@ from dataclasses import dataclass, replace from pathlib import Path from types import UnionType -from typing import Any, Final, Literal, TypeVar, Union, cast, get_args, get_origin +from typing import Any, Final, Literal, TypeAlias, TypeVar, Union, cast, get_args, get_origin from dotenv import dotenv_values from pydantic import BaseModel, ConfigDict, Field, ValidationError @@ -33,6 +33,7 @@ CONFIG_HELP_MIN_POSITION: Final[int] = 24 CONFIG_HELP_MAX_POSITION_LIMIT: Final[int] = 48 CONFIG_HELP_PADDING: Final[int] = 4 +DEFAULT_CONFIG_HELP_GROUP: Final[str] = "Config" _CONFIG_OPTION_KEY: Final[str] = "src_py_lib_config_option" _MISSING: Final[object] = object() @@ -67,6 +68,7 @@ class ConfigOption: cli_const: object | None = None metavar: str | None = None help: str = "" + help_group: str = DEFAULT_CONFIG_HELP_GROUP secret: bool = False required: bool = False @@ -78,6 +80,7 @@ class Config(BaseModel): ConfigType = TypeVar("ConfigType", bound=Config) +ConfigFieldSource: TypeAlias = str | type[Config] def config_field( @@ -91,6 +94,7 @@ def config_field( cli_const: object | None = None, metavar: str | None = None, help: str = "", + help_group: str = DEFAULT_CONFIG_HELP_GROUP, secret: bool = False, required: bool = False, gt: int | float | None = None, @@ -110,6 +114,7 @@ def config_field( cli_const=cli_const, metavar=metavar, help=help, + help_group=help_group, secret=secret, required=required, ) @@ -140,6 +145,38 @@ def config_options(config_cls: type[Config]) -> tuple[ConfigOption, ...]: return tuple(options) +def config_field_names(*sources: ConfigFieldSource) -> tuple[str, ...]: + """Return Config field names from Config classes and explicit field names. + + Use this to define reusable CLI argument sets from Config mixins while + keeping the field metadata defined once on the Config classes. + """ + names: list[str] = [] + for source in sources: + if isinstance(source, str): + names.append(source) + continue + names.extend(option.field_name for option in config_options(source)) + return tuple(dict.fromkeys(names)) + + +def config_help_formatter( + config_cls: type[Config], + *, + include_env_file: bool = True, + include_fields: Iterable[str] | None = None, + exclude_fields: Iterable[str] = (), +) -> type[argparse.HelpFormatter]: + """Return a help formatter aligned for the selected Config fields.""" + max_help_position = _config_help_max_position( + config_cls, + include_env_file=include_env_file, + include_fields=include_fields, + exclude_fields=exclude_fields, + ) + return _config_help_formatter(max_help_position) + + def load_config_env_file(path: Path | None) -> dict[str, str]: """Load key/value pairs from a `.env` file. @@ -192,22 +229,19 @@ def add_config_arguments( config_cls: type[Config], *, include_env_file: bool = True, + include_fields: Iterable[str] | None = None, + exclude_fields: Iterable[str] = (), ) -> None: """Add Config CLI flags to an argparse parser.""" - group = parser.add_argument_group( - "Config", - "These options override matching environment variables and .env values", - ) - if include_env_file: - group.add_argument( - "--env-file", - dest="env_file", - default=None, - metavar="PATH", - help="Read Config .env values from PATH (default: .env)", - ) + groups: dict[str, Any] = {} - for option in config_options(config_cls): + def argument_group(title: str) -> Any: + if title not in groups: + groups[title] = parser.add_argument_group(title) + return groups[title] + + for option in _selected_config_options(config_cls, include_fields, exclude_fields): + group = argument_group(option.help_group or DEFAULT_CONFIG_HELP_GROUP) field_info = config_cls.model_fields[option.field_name] argument_kwargs: dict[str, Any] = { "dest": option.field_name, @@ -227,6 +261,15 @@ def add_config_arguments( argument_kwargs["action"] = option.cli_action group.add_argument(option.cli_flag, *option.cli_aliases, **argument_kwargs) + if include_env_file: + argument_group(DEFAULT_CONFIG_HELP_GROUP).add_argument( + "--env-file", + dest="env_file", + default=None, + metavar="PATH", + help="Read Config .env values from PATH (default: .env)", + ) + def config_parse_args( config_cls: type[ConfigType], @@ -235,6 +278,8 @@ def config_parse_args( argv: Sequence[str] | None = None, description: str | None = None, include_env_file: bool = True, + include_fields: Iterable[str] | None = None, + exclude_fields: Iterable[str] = (), env: Mapping[str, str] | None = None, base_dir: Path | None = None, resolve_op_refs: bool = True, @@ -242,12 +287,23 @@ def config_parse_args( require: Iterable[str] = (), ) -> ConfigType: """Parse Config CLI flags and return a validated Config model.""" - max_help_position = _config_help_max_position(config_cls, include_env_file=include_env_file) + formatter_class = config_help_formatter( + config_cls, + include_env_file=include_env_file, + include_fields=include_fields, + exclude_fields=exclude_fields, + ) argument_parser = parser or argparse.ArgumentParser( description=description, - formatter_class=_config_help_formatter(max_help_position), + formatter_class=formatter_class, + ) + add_config_arguments( + argument_parser, + config_cls, + include_env_file=include_env_file, + include_fields=include_fields, + exclude_fields=exclude_fields, ) - add_config_arguments(argument_parser, config_cls, include_env_file=include_env_file) args = argument_parser.parse_args(argv) try: return load_config_from_args( @@ -277,12 +333,14 @@ def _config_help_max_position( config_cls: type[Config], *, include_env_file: bool, + include_fields: Iterable[str] | None = None, + exclude_fields: Iterable[str] = (), ) -> int: """Return help-column width based on this Config's CLI arguments.""" invocation_lengths = [len("--env-file PATH")] if include_env_file else [] invocation_lengths.extend( _config_option_invocation_length(config_cls, option) - for option in config_options(config_cls) + for option in _selected_config_options(config_cls, include_fields, exclude_fields) ) longest_invocation = max(invocation_lengths, default=0) return min( @@ -291,6 +349,48 @@ def _config_help_max_position( ) +def _selected_config_options( + config_cls: type[Config], + include_fields: Iterable[str] | None, + exclude_fields: Iterable[str], +) -> tuple[ConfigOption, ...]: + """Return options selected by field or env-var names. + + When include_fields is set, its order controls the returned option order. + Without include_fields, Config model field order is preserved. + """ + options = config_options(config_cls) + excluded = _selected_config_field_names(options, exclude_fields) or set() + if include_fields is None: + return tuple(option for option in options if not _option_is_selected(option, excluded)) + + options_by_field_name = {option.field_name: option for option in options} + return tuple( + options_by_field_name[field_name] + for field_name in _selected_config_field_names_in_order(options, include_fields) + if field_name not in excluded + ) + + +def _selected_config_field_names_in_order( + options: tuple[ConfigOption, ...], + selected: Iterable[str], +) -> tuple[str, ...]: + """Return selected field names in caller order after validating names.""" + names = (_option_by_name(options, name).field_name for name in selected) + return tuple(dict.fromkeys(names)) + + +def _selected_config_field_names( + options: tuple[ConfigOption, ...], + selected: Iterable[str] | None, +) -> set[str] | None: + """Return selected field names after validating field or env-var names.""" + if selected is None: + return None + return {_option_by_name(options, name).field_name for name in selected} + + def _config_option_invocation_length(config_cls: type[Config], option: ConfigOption) -> int: """Return argparse-style option invocation length for help alignment.""" field_info = config_cls.model_fields[option.field_name] @@ -452,6 +552,7 @@ def _config_option_payload(option: ConfigOption) -> dict[str, object]: "cli_const": option.cli_const, "metavar": option.metavar, "help": option.help, + "help_group": option.help_group, "secret": option.secret, "required": option.required, } @@ -467,6 +568,7 @@ def _config_option_from_payload(payload: Mapping[str, object]) -> ConfigOption | cli_nargs = payload.get("cli_nargs") metavar = payload.get("metavar") help_text = payload.get("help") + help_group = payload.get("help_group") return ConfigOption( field_name="", env_var=env_var, @@ -477,6 +579,7 @@ def _config_option_from_payload(payload: Mapping[str, object]) -> ConfigOption | cli_const=payload.get("cli_const"), metavar=metavar if isinstance(metavar, str) else None, help=help_text if isinstance(help_text, str) else "", + help_group=help_group if isinstance(help_group, str) else DEFAULT_CONFIG_HELP_GROUP, secret=payload.get("secret") is True, required=payload.get("required") is True, ) diff --git a/src/src_py_lib/utils/logging.py b/src/src_py_lib/utils/logging.py index 3508062..d748b64 100644 --- a/src/src_py_lib/utils/logging.py +++ b/src/src_py_lib/utils/logging.py @@ -100,6 +100,7 @@ class LoggingConfig(Config): cli_flag="--src-log-level", metavar="LEVEL", help="Log level (default: INFO)", + help_group="Logging", ) verbose: bool = config_field( default=False, @@ -108,6 +109,7 @@ class LoggingConfig(Config): cli_aliases=("-v",), cli_action="store_true", help="Alias for --src-log-level DEBUG", + help_group="Logging", ) quiet: bool = config_field( default=False, @@ -116,6 +118,7 @@ class LoggingConfig(Config): cli_aliases=("-q",), cli_action="store_true", help="Alias for --src-log-level WARNING", + help_group="Logging", ) silent: bool = config_field( default=False, @@ -124,6 +127,7 @@ class LoggingConfig(Config): cli_aliases=("-s",), cli_action="store_true", help="Alias for --src-log-level ERROR", + help_group="Logging", ) @model_validator(mode="after") diff --git a/tests/test_import.py b/tests/test_import.py index 856d024..3868380 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -27,6 +27,8 @@ def test_root_public_api_exports_common_entrypoints(self) -> None: self.assertIsNotNone(src_py_lib.SourcegraphClient) self.assertIsNotNone(src_py_lib.SourcegraphClientConfig) self.assertIsNotNone(src_py_lib.config_field) + self.assertIsNotNone(src_py_lib.config_field_names) + self.assertIsNotNone(src_py_lib.config_help_formatter) self.assertIsNotNone(src_py_lib.gh_cli_token) self.assertIsNotNone(src_py_lib.gcloud_adc_access_token) self.assertIsNotNone(src_py_lib.info) diff --git a/tests/test_logging_http_clients.py b/tests/test_logging_http_clients.py index 32cbeac..a9e445d 100644 --- a/tests/test_logging_http_clients.py +++ b/tests/test_logging_http_clients.py @@ -50,6 +50,7 @@ add_config_arguments, config_env_file_from_args, config_field, + config_field_names, config_overrides_from_args, config_parse_args, config_snapshot, @@ -198,6 +199,32 @@ class MultilineHelpConfig(Config): ) +class GroupedHelpConfig(Config): + """Config model with grouped help sections.""" + + alpha: str = config_field( + default="", + env_var="GROUPED_HELP_ALPHA", + cli_flag="--alpha", + help="Alpha option", + help_group="First group", + ) + beta: str = config_field( + default="", + env_var="GROUPED_HELP_BETA", + cli_flag="--beta", + help="Beta option", + help_group="Second group", + ) + gamma: str = config_field( + default="", + env_var="GROUPED_HELP_GAMMA", + cli_flag="--gamma", + help="Gamma option", + help_group="First group", + ) + + class SnapshotOrderConfig(Config): """Config model whose field names and env-var names sort differently.""" @@ -340,6 +367,8 @@ def test_client_config_mixin_adds_sourcegraph_fields_and_builds_client(self) -> add_config_arguments(parser, SourcegraphExampleConfig) args = parser.parse_args( [ + "--src-endpoint", + "https://sourcegraph.example.com", "--src-access-token", "test-token", "--repo-query", @@ -355,10 +384,10 @@ def test_client_config_mixin_adds_sourcegraph_fields_and_builds_client(self) -> ) client = sourcegraph_client_from_config(config) - self.assertEqual(config.src_endpoint, "https://sourcegraph.com") + self.assertEqual(config.src_endpoint, "https://sourcegraph.example.com") self.assertEqual(config.src_access_token, "test-token") self.assertEqual(config.repo_query, "repo:example") - self.assertEqual(client.endpoint, "https://sourcegraph.com") + self.assertEqual(client.endpoint, "https://sourcegraph.example.com") self.assertEqual(client.token, "test-token") def test_load_config_uses_precedence_and_pydantic_types(self) -> None: @@ -456,6 +485,82 @@ def test_argparse_helpers_add_flags_and_collect_overrides(self) -> None: }, ) + def test_config_field_names_combines_config_classes_and_fields(self) -> None: + self.assertEqual( + config_field_names(SourcegraphClientConfig, LoggingConfig, "page_size"), + ( + "src_endpoint", + "src_access_token", + "src_log_level", + "verbose", + "quiet", + "silent", + "page_size", + ), + ) + + def test_add_config_arguments_can_select_reusable_field_sets(self) -> None: + parser = argparse.ArgumentParser() + add_config_arguments( + parser, + ExampleConfig, + include_fields=("token", "page_size", "EXAMPLE_LABELS"), + exclude_fields=("page_size",), + ) + + args = parser.parse_args(["--token", "raw-token", "--labels", "one,two"]) + + self.assertEqual( + config_overrides_from_args(ExampleConfig, args), + { + "token": "raw-token", + "labels": "one,two", + }, + ) + with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + parser.parse_args(["--page-size", "50"]) + + def test_config_parse_args_help_only_shows_selected_fields(self) -> None: + stdout = io.StringIO() + + with redirect_stdout(stdout), self.assertRaises(SystemExit) as raised: + config_parse_args( + ExampleConfig, + argv=["--help"], + env={}, + resolve_op_refs=False, + include_fields=("labels", "token"), + ) + + self.assertEqual(raised.exception.code, 0) + help_text = stdout.getvalue() + self.assertIn("--token TOKEN", help_text) + self.assertIn("--labels CSV", help_text) + self.assertLess(help_text.index("--labels CSV"), help_text.index("--token TOKEN")) + self.assertNotIn("--page-size", help_text) + self.assertNotIn("--include-archived", help_text) + + def test_config_parse_args_groups_help_by_field_metadata(self) -> None: + stdout = io.StringIO() + + with redirect_stdout(stdout), self.assertRaises(SystemExit) as raised: + config_parse_args( + GroupedHelpConfig, + argv=["--help"], + env={}, + resolve_op_refs=False, + include_fields=("beta", "alpha", "gamma"), + ) + + self.assertEqual(raised.exception.code, 0) + help_text = stdout.getvalue() + self.assertLess(help_text.index("Second group:"), help_text.index("First group:")) + self.assertLess(help_text.index("First group:"), help_text.index("Config:")) + self.assertLess(help_text.index("--alpha"), help_text.index("--gamma")) + self.assertIn("Second group:\n --beta", help_text) + self.assertIn("First group:\n --alpha", help_text) + self.assertNotIn("override matching environment variables", help_text) + def test_config_arguments_support_aliases_actions_and_optional_values(self) -> None: parser = argparse.ArgumentParser() add_config_arguments(parser, CommandStyleConfig) From 4c892f8138a8113a76c4c04b7e3ff8d130ac05aa Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:50:42 -0600 Subject: [PATCH 3/4] Update version for release --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bc5e3f4..de5e138 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dev = [ [project] name = "src-py-lib" -version = "0.1.6" +version = "0.1.7" description = "Reusable libraries for Sourcegraph projects" readme = "README.md" requires-python = ">=3.11" From 96483245fc56a820515fee982194de91e57f2201 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:53:00 -0600 Subject: [PATCH 4/4] Update uv.lock for release --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index efb7b83..5e5ccf9 100644 --- a/uv.lock +++ b/uv.lock @@ -254,7 +254,7 @@ wheels = [ [[package]] name = "src-py-lib" -version = "0.1.6" +version = "0.1.7" source = { editable = "." } dependencies = [ { name = "httpx" },