src-py-lib 0.1.5__tar.gz → 0.1.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/.github/workflows/release.yml +55 -23
  2. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/.github/workflows/validate.yml +2 -0
  3. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/.gitignore +2 -1
  4. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/AGENTS.md +14 -97
  5. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/PKG-INFO +1 -1
  6. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/pyproject.toml +5 -2
  7. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/renovate.json +3 -0
  8. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/__init__.py +4 -0
  9. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/clients/sourcegraph.py +28 -5
  10. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/utils/config.py +121 -18
  11. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/utils/logging.py +4 -0
  12. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/tests/test_import.py +2 -0
  13. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/tests/test_logging_http_clients.py +137 -3
  14. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/uv.lock +0 -1
  15. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/.github/workflows/ci.yml +0 -0
  16. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/.markdownlint-cli2.yaml +0 -0
  17. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/.python-version +0 -0
  18. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/LICENSE +0 -0
  19. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/README.md +0 -0
  20. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/SECURITY.md +0 -0
  21. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/clients/__init__.py +0 -0
  22. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/clients/github.py +0 -0
  23. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/clients/google_sheets.py +0 -0
  24. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/clients/graphql.py +0 -0
  25. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/clients/linear.py +0 -0
  26. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/clients/one_password.py +0 -0
  27. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/clients/slack.py +0 -0
  28. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/py.typed +0 -0
  29. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/utils/__init__.py +0 -0
  30. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/utils/http.py +0 -0
  31. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/utils/json_cache.py +0 -0
  32. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/utils/json_types.py +0 -0
  33. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/src/src_py_lib/utils/tsv.py +0 -0
  34. {src_py_lib-0.1.5 → src_py_lib-0.1.8}/tests/test_tsv.py +0 -0
@@ -6,8 +6,8 @@ on:
6
6
  - "v*"
7
7
  workflow_dispatch:
8
8
  inputs:
9
- tag:
10
- description: "Existing release tag to publish, for example v0.1.0"
9
+ version:
10
+ description: "Package version to publish, for example 0.1.0 or v0.1.0"
11
11
  required: true
12
12
  type: string
13
13
 
@@ -16,7 +16,7 @@ permissions:
16
16
  pull-requests: read
17
17
 
18
18
  concurrency:
19
- group: release-${{ github.event.inputs.tag || github.ref_name }}
19
+ group: release-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }}
20
20
  cancel-in-progress: false
21
21
 
22
22
  defaults:
@@ -24,15 +24,41 @@ defaults:
24
24
  shell: bash
25
25
 
26
26
  jobs:
27
+ release_ref:
28
+ name: Resolve release tag
29
+ runs-on: ubuntu-24.04
30
+ outputs:
31
+ tag: ${{ steps.release.outputs.tag }}
32
+ version: ${{ steps.release.outputs.version }}
33
+
34
+ steps:
35
+ - name: Resolve release tag
36
+ id: release
37
+ env:
38
+ RELEASE_INPUT: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }}
39
+ run: |
40
+ release_input="${RELEASE_INPUT}"
41
+ if [[ ! "${release_input}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
42
+ echo "::error title=Invalid release version::Use MAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH, got '${release_input}'."
43
+ exit 1
44
+ fi
45
+
46
+ release_version="${release_input#v}"
47
+ release_tag="v${release_version}"
48
+ echo "tag=${release_tag}" >> "${GITHUB_OUTPUT}"
49
+ echo "version=${release_version}" >> "${GITHUB_OUTPUT}"
50
+
27
51
  validate:
28
52
  name: Validate
53
+ needs: release_ref
29
54
  uses: ./.github/workflows/validate.yml
30
55
  with:
31
- ref: ${{ github.event.inputs.tag || github.ref }}
56
+ ref: ${{ needs.release_ref.outputs.tag }}
32
57
  build-package: false
33
58
 
34
59
  wheel:
35
60
  name: Build wheel
61
+ needs: release_ref
36
62
  runs-on: ubuntu-24.04
37
63
  env:
38
64
  IMPORT_NAME: src_py_lib
@@ -45,7 +71,7 @@ jobs:
45
71
  with:
46
72
  fetch-depth: 0
47
73
  persist-credentials: false
48
- ref: ${{ github.event.inputs.tag || github.ref }}
74
+ ref: ${{ needs.release_ref.outputs.tag }}
49
75
 
50
76
  - name: Set up Python
51
77
  uses: actions/setup-python@v6
@@ -66,7 +92,7 @@ jobs:
66
92
  - name: Validate release inputs
67
93
  id: release
68
94
  env:
69
- RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
95
+ RELEASE_TAG: ${{ needs.release_ref.outputs.tag }}
70
96
  run: |
71
97
  release_tag="${RELEASE_TAG}"
72
98
  if [[ ! "${release_tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
@@ -80,29 +106,19 @@ jobs:
80
106
  tag_revision="$(git rev-list -n 1 "${release_tag}")"
81
107
  git fetch --no-tags origin main
82
108
  main_revision="$(git rev-parse origin/main)"
83
- if ! git merge-base --is-ancestor "${tag_revision}" "${main_revision}"; then
84
- echo "::error title=Tag is not on main::Tag '${release_tag}' points at ${tag_revision}, which is not reachable from origin/main."
85
- echo "::error::Merge the release PR first, then tag the main commit."
86
- exit 1
87
- fi
88
-
89
- project_version=$(uv run --frozen python - <<'PY'
90
- import tomllib
91
-
92
- with open("pyproject.toml", "rb") as pyproject_file:
93
- print(tomllib.load(pyproject_file)["project"]["version"])
94
- PY
95
- )
96
- if [[ "v${project_version}" != "${release_tag}" ]]; then
97
- echo "::error title=Version mismatch::pyproject.toml version '${project_version}' does not match tag '${release_tag}'."
109
+ if [[ "${tag_revision}" != "${main_revision}" ]]; then
110
+ echo "::error title=Tag is not origin/main::Tag '${release_tag}' points at ${tag_revision}, but origin/main is ${main_revision}."
111
+ echo "::error::Tag the remote head of main, then rerun the release."
98
112
  exit 1
99
113
  fi
100
114
 
101
115
  echo "tag=${release_tag}" >> "${GITHUB_OUTPUT}"
116
+ echo "version=${release_tag#v}" >> "${GITHUB_OUTPUT}"
102
117
 
103
118
  - name: Build distributions
104
119
  id: build
105
120
  run: |
121
+ release_version="${{ steps.release.outputs.version }}"
106
122
  dist_dir="build/release/dist"
107
123
  rm -rf build/release
108
124
  mkdir -p "${dist_dir}"
@@ -123,6 +139,22 @@ jobs:
123
139
  wheel_name="$(basename "${wheel_path}")"
124
140
  source_distribution_path="${source_distributions[0]}"
125
141
  source_distribution_name="$(basename "${source_distribution_path}")"
142
+ case "${wheel_name}" in
143
+ src_py_lib-"${release_version}"-*.whl)
144
+ ;;
145
+ *)
146
+ echo "::error title=Wheel version mismatch::Expected wheel version ${release_version}, got '${wheel_name}'."
147
+ exit 1
148
+ ;;
149
+ esac
150
+ case "${source_distribution_name}" in
151
+ src_py_lib-"${release_version}".tar.gz)
152
+ ;;
153
+ *)
154
+ echo "::error title=Source distribution version mismatch::Expected source distribution version ${release_version}, got '${source_distribution_name}'."
155
+ exit 1
156
+ ;;
157
+ esac
126
158
  wheel_checksum_path="${wheel_path}.sha256"
127
159
  source_distribution_checksum_path="${source_distribution_path}.sha256"
128
160
 
@@ -214,7 +246,7 @@ jobs:
214
246
 
215
247
  github-release:
216
248
  name: Publish GitHub release assets
217
- needs: [validate, wheel]
249
+ needs: [release_ref, validate, wheel]
218
250
  runs-on: ubuntu-24.04
219
251
  permissions:
220
252
  contents: write
@@ -230,7 +262,7 @@ jobs:
230
262
  env:
231
263
  GH_TOKEN: ${{ github.token }}
232
264
  GH_REPO: ${{ github.repository }}
233
- RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
265
+ RELEASE_TAG: ${{ needs.release_ref.outputs.tag }}
234
266
  run: |
235
267
  release_tag="${RELEASE_TAG}"
236
268
  notes_path="$(find release-assets -name release-notes.md -print -quit)"
@@ -158,6 +158,7 @@ jobs:
158
158
  - name: Check out code
159
159
  uses: actions/checkout@v6
160
160
  with:
161
+ fetch-depth: 0
161
162
  persist-credentials: false
162
163
  ref: ${{ inputs.ref || github.ref }}
163
164
 
@@ -217,6 +218,7 @@ jobs:
217
218
  - name: Check out code
218
219
  uses: actions/checkout@v6
219
220
  with:
221
+ fetch-depth: 0
220
222
  persist-credentials: false
221
223
  ref: ${{ inputs.ref || github.ref }}
222
224
 
@@ -10,6 +10,7 @@ __pycache__
10
10
  *.gql
11
11
  *.py[cod]
12
12
  *.py[oc]
13
+ *.swp
13
14
  *.yaml
14
15
  build/
15
16
  dist/
@@ -17,4 +18,4 @@ wheels/
17
18
 
18
19
  # Allow
19
20
  !.env.example
20
- !.markdownlint-cli2.yaml
21
+ !.markdownlint-cli2.yaml
@@ -51,111 +51,28 @@ uv run python -m unittest discover -s tests
51
51
 
52
52
  ## Release process
53
53
 
54
- - The tagged source commit must already contain the package version it
55
- releases. Do not make the release workflow edit `pyproject.toml`.
56
- - The tag must be `vMAJOR.MINOR.PATCH`, and `.github/workflows/release.yml`
57
- verifies that it matches `project.version` before building GitHub release
58
- assets and publishing to PyPI.
59
- - Prepare releases on a branch from current `main`. Set `VERSION`, then run:
60
- - As part of every release bump, find old release-version literals in
61
- `AGENTS.md`, `README.md`, and release snippets, and replace them with the
62
- new version where they are meant to stay current.
54
+ - Package versions are derived from Git tags through `hatch-vcs`.
55
+ - `pyproject.toml` must use `dynamic = ["version"]`; do not add a hard-coded
56
+ `project.version` for releases.
57
+ - The release tag must be `vMAJOR.MINOR.PATCH` and point at a commit reachable
58
+ from `origin/main`.
59
+ - The release workflow builds from the tag and checks that wheel and source
60
+ distribution filenames match the tag version before publishing.
61
+ - Do not make the release workflow edit `pyproject.toml` or `uv.lock`.
62
+ - Tag the remote head of `main` directly:
63
63
 
64
64
  ```sh
65
65
  set -euo pipefail
66
66
 
67
- VERSION=0.1.4
68
- BRANCH="release-v${VERSION}"
69
-
70
- git fetch origin --tags --prune
71
- git switch main
72
- git pull --ff-only
73
- git switch -c "${BRANCH}"
74
-
75
- uv run python - "${VERSION}" <<'PY'
76
- from pathlib import Path
77
- import re
78
- import sys
79
-
80
- version = sys.argv[1]
81
- path = Path("pyproject.toml")
82
- text = path.read_text()
83
- new_text = re.sub(
84
- r'(?m)^version = "[^"]+"$',
85
- f'version = "{version}"',
86
- text,
87
- count=1,
88
- )
89
- if new_text == text:
90
- raise SystemExit("pyproject.toml version was not updated")
91
- path.write_text(new_text)
92
- PY
93
-
94
- uv lock
95
- ```
96
-
97
- - Validate before opening the PR:
98
-
99
- ```sh
100
- set -euo pipefail
101
-
102
- uv lock --check
103
- actionlint
104
- npx --yes markdownlint-cli2@0.22.1
105
- uv run ruff check .
106
- uv run ruff format --check .
107
- uv run pyright
108
- uv run python -m unittest discover -s tests
109
- uv build --wheel --sdist --out-dir /tmp/src-py-lib-release-check --no-create-gitignore
110
- rm -rf /tmp/src-py-lib-release-check
111
- ```
112
-
113
- - Commit, push, open the PR, wait for checks, then merge it. If review is
114
- required, stop after `gh pr checks` and ask for review before merging.
115
-
116
- ```sh
117
- set -euo pipefail
118
-
119
- VERSION=0.1.4
120
- BRANCH="release-v${VERSION}"
67
+ VERSION_INPUT=<next-version>
68
+ VERSION="${VERSION_INPUT#v}"
121
69
  GH_REPO="sourcegraph/src-py-lib"
122
70
 
123
- git add pyproject.toml uv.lock
124
- git commit -m "Release v${VERSION}"
125
- git push -u origin "${BRANCH}"
126
-
127
- gh pr create \
128
- --repo "${GH_REPO}" \
129
- --base main \
130
- --head "${BRANCH}" \
131
- --title "Release v${VERSION}" \
132
- --body "Bump src-py-lib package metadata to ${VERSION}."
133
-
134
- gh pr checks "${BRANCH}" --repo "${GH_REPO}" --watch --fail-fast
135
- gh pr merge "${BRANCH}" --repo "${GH_REPO}" --squash --delete-branch
136
- ```
137
-
138
- - Tag the merged `main` commit. Do not tag a branch commit.
139
-
140
- ```sh
141
- set -euo pipefail
142
-
143
- VERSION=0.1.4
144
-
71
+ [[ "${VERSION_INPUT}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]
145
72
  git fetch origin --tags --prune
146
- git switch main
147
- git pull --ff-only
148
- git tag "v${VERSION}"
73
+ MAIN_COMMIT="$(git rev-parse origin/main)"
74
+ git tag -a "v${VERSION}" "${MAIN_COMMIT}" -m "Release v${VERSION}"
149
75
  git push origin "v${VERSION}"
150
- ```
151
-
152
- - Watch the release workflow and confirm the GitHub release and PyPI project.
153
-
154
- ```sh
155
- set -euo pipefail
156
-
157
- VERSION=0.1.4
158
- GH_REPO="sourcegraph/src-py-lib"
159
76
 
160
77
  RUN_ID="$(
161
78
  gh run list \
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: src-py-lib
3
- Version: 0.1.5
3
+ Version: 0.1.8
4
4
  Summary: Reusable libraries for Sourcegraph projects
5
5
  Project-URL: Homepage, https://github.com/sourcegraph/src-py-lib
6
6
  Project-URL: Issues, https://github.com/sourcegraph/src-py-lib/issues
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["hatchling"]
2
+ requires = ["hatchling", "hatch-vcs"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [dependency-groups]
@@ -10,7 +10,7 @@ dev = [
10
10
 
11
11
  [project]
12
12
  name = "src-py-lib"
13
- version = "0.1.5"
13
+ dynamic = ["version"]
14
14
  description = "Reusable libraries for Sourcegraph projects"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.11"
@@ -44,6 +44,9 @@ Issues = "https://github.com/sourcegraph/src-py-lib/issues"
44
44
  [tool.hatch.build.targets.wheel]
45
45
  packages = ["src/src_py_lib"]
46
46
 
47
+ [tool.hatch.version]
48
+ source = "vcs"
49
+
47
50
  [tool.pyright]
48
51
  include = ["src/src_py_lib", "tests"]
49
52
  typeCheckingMode = "strict"
@@ -10,5 +10,8 @@
10
10
  ],
11
11
  "allowedVersions": "<3.12"
12
12
  }
13
+ ],
14
+ "schedule": [
15
+ "at any time"
13
16
  ]
14
17
  }
@@ -52,6 +52,8 @@ from src_py_lib.utils.config import (
52
52
  Config,
53
53
  ConfigError,
54
54
  config_field,
55
+ config_field_names,
56
+ config_help_formatter,
55
57
  config_snapshot,
56
58
  )
57
59
  from src_py_lib.utils.config import (
@@ -150,6 +152,8 @@ __all__ = [
150
152
  "TraceContext",
151
153
  "aliased_batched_query",
152
154
  "config_field",
155
+ "config_field_names",
156
+ "config_help_formatter",
153
157
  "config_snapshot",
154
158
  "configure_logging",
155
159
  "critical",
@@ -25,7 +25,6 @@ from src_py_lib.utils.logging import (
25
25
  traceparent_header,
26
26
  )
27
27
 
28
- DEFAULT_SOURCEGRAPH_ENDPOINT = "https://sourcegraph.com"
29
28
  SOURCEGRAPH_EXTERNAL_SERVICE_NODE_TYPE: Final[str] = "ExternalService"
30
29
  SOURCEGRAPH_REPOSITORY_NODE_TYPE: Final[str] = "Repository"
31
30
  REQUEST_TRACE_HEADER: Final[str] = "X-Sourcegraph-Request-Trace"
@@ -158,11 +157,13 @@ class SourcegraphClientConfig(Config):
158
157
  """Config fields needed to build a Sourcegraph API client."""
159
158
 
160
159
  src_endpoint: str = config_field(
161
- default=DEFAULT_SOURCEGRAPH_ENDPOINT,
160
+ default="",
162
161
  env_var="SRC_ENDPOINT",
163
162
  cli_flag="--src-endpoint",
164
163
  metavar="URL",
165
- help=f"Sourcegraph instance URL (default: {DEFAULT_SOURCEGRAPH_ENDPOINT})",
164
+ help="Sourcegraph instance URL",
165
+ help_group="Sourcegraph",
166
+ required=True,
166
167
  )
167
168
  src_access_token: str = config_field(
168
169
  default="",
@@ -170,6 +171,7 @@ class SourcegraphClientConfig(Config):
170
171
  cli_flag="--src-access-token",
171
172
  metavar="TOKEN",
172
173
  help="Sourcegraph access token, or op:// secret reference",
174
+ help_group="Sourcegraph",
173
175
  secret=True,
174
176
  required=True,
175
177
  )
@@ -206,8 +208,29 @@ class SourcegraphClient:
206
208
  require_https=not self.allow_insecure_http,
207
209
  )
208
210
 
209
- def graphql(self, query: str, variables: Mapping[str, JSONValue] | None = None) -> JSONDict:
210
- return self._client().execute(query, variables)
211
+ def graphql(
212
+ self,
213
+ query: str,
214
+ variables: Mapping[str, JSONValue] | None = None,
215
+ *,
216
+ follow_pages: bool = True,
217
+ page_size: int | None = None,
218
+ first_variable: str = "first",
219
+ after_variable: str = "after",
220
+ ) -> JSONDict:
221
+ """Execute one Sourcegraph GraphQL operation.
222
+
223
+ Set `follow_pages=False` when the caller owns pagination, such as
224
+ aliased queries with one cursor per alias.
225
+ """
226
+ return self._client().execute(
227
+ query,
228
+ variables,
229
+ follow_pages=follow_pages,
230
+ page_size=page_size,
231
+ first_variable=first_variable,
232
+ after_variable=after_variable,
233
+ )
211
234
 
212
235
  def stream_connection_nodes(
213
236
  self,
@@ -16,7 +16,7 @@ from collections.abc import Iterable, Mapping, Sequence
16
16
  from dataclasses import dataclass, replace
17
17
  from pathlib import Path
18
18
  from types import UnionType
19
- from typing import Any, Final, Literal, TypeVar, Union, cast, get_args, get_origin
19
+ from typing import Any, Final, Literal, TypeAlias, TypeVar, Union, cast, get_args, get_origin
20
20
 
21
21
  from dotenv import dotenv_values
22
22
  from pydantic import BaseModel, ConfigDict, Field, ValidationError
@@ -33,6 +33,7 @@ DEFAULT_CONFIG_ENV_FILE: Final[Path] = Path(".env")
33
33
  CONFIG_HELP_MIN_POSITION: Final[int] = 24
34
34
  CONFIG_HELP_MAX_POSITION_LIMIT: Final[int] = 48
35
35
  CONFIG_HELP_PADDING: Final[int] = 4
36
+ DEFAULT_CONFIG_HELP_GROUP: Final[str] = "Config"
36
37
  _CONFIG_OPTION_KEY: Final[str] = "src_py_lib_config_option"
37
38
  _MISSING: Final[object] = object()
38
39
 
@@ -67,6 +68,7 @@ class ConfigOption:
67
68
  cli_const: object | None = None
68
69
  metavar: str | None = None
69
70
  help: str = ""
71
+ help_group: str = DEFAULT_CONFIG_HELP_GROUP
70
72
  secret: bool = False
71
73
  required: bool = False
72
74
 
@@ -78,6 +80,7 @@ class Config(BaseModel):
78
80
 
79
81
 
80
82
  ConfigType = TypeVar("ConfigType", bound=Config)
83
+ ConfigFieldSource: TypeAlias = str | type[Config]
81
84
 
82
85
 
83
86
  def config_field(
@@ -91,6 +94,7 @@ def config_field(
91
94
  cli_const: object | None = None,
92
95
  metavar: str | None = None,
93
96
  help: str = "",
97
+ help_group: str = DEFAULT_CONFIG_HELP_GROUP,
94
98
  secret: bool = False,
95
99
  required: bool = False,
96
100
  gt: int | float | None = None,
@@ -110,6 +114,7 @@ def config_field(
110
114
  cli_const=cli_const,
111
115
  metavar=metavar,
112
116
  help=help,
117
+ help_group=help_group,
113
118
  secret=secret,
114
119
  required=required,
115
120
  )
@@ -140,6 +145,38 @@ def config_options(config_cls: type[Config]) -> tuple[ConfigOption, ...]:
140
145
  return tuple(options)
141
146
 
142
147
 
148
+ def config_field_names(*sources: ConfigFieldSource) -> tuple[str, ...]:
149
+ """Return Config field names from Config classes and explicit field names.
150
+
151
+ Use this to define reusable CLI argument sets from Config mixins while
152
+ keeping the field metadata defined once on the Config classes.
153
+ """
154
+ names: list[str] = []
155
+ for source in sources:
156
+ if isinstance(source, str):
157
+ names.append(source)
158
+ continue
159
+ names.extend(option.field_name for option in config_options(source))
160
+ return tuple(dict.fromkeys(names))
161
+
162
+
163
+ def config_help_formatter(
164
+ config_cls: type[Config],
165
+ *,
166
+ include_env_file: bool = True,
167
+ include_fields: Iterable[str] | None = None,
168
+ exclude_fields: Iterable[str] = (),
169
+ ) -> type[argparse.HelpFormatter]:
170
+ """Return a help formatter aligned for the selected Config fields."""
171
+ max_help_position = _config_help_max_position(
172
+ config_cls,
173
+ include_env_file=include_env_file,
174
+ include_fields=include_fields,
175
+ exclude_fields=exclude_fields,
176
+ )
177
+ return _config_help_formatter(max_help_position)
178
+
179
+
143
180
  def load_config_env_file(path: Path | None) -> dict[str, str]:
144
181
  """Load key/value pairs from a `.env` file.
145
182
 
@@ -192,22 +229,19 @@ def add_config_arguments(
192
229
  config_cls: type[Config],
193
230
  *,
194
231
  include_env_file: bool = True,
232
+ include_fields: Iterable[str] | None = None,
233
+ exclude_fields: Iterable[str] = (),
195
234
  ) -> None:
196
235
  """Add Config CLI flags to an argparse parser."""
197
- group = parser.add_argument_group(
198
- "Config",
199
- "These options override matching environment variables and .env values",
200
- )
201
- if include_env_file:
202
- group.add_argument(
203
- "--env-file",
204
- dest="env_file",
205
- default=None,
206
- metavar="PATH",
207
- help="Read Config .env values from PATH (default: .env)",
208
- )
236
+ groups: dict[str, Any] = {}
209
237
 
210
- for option in config_options(config_cls):
238
+ def argument_group(title: str) -> Any:
239
+ if title not in groups:
240
+ groups[title] = parser.add_argument_group(title)
241
+ return groups[title]
242
+
243
+ for option in _selected_config_options(config_cls, include_fields, exclude_fields):
244
+ group = argument_group(option.help_group or DEFAULT_CONFIG_HELP_GROUP)
211
245
  field_info = config_cls.model_fields[option.field_name]
212
246
  argument_kwargs: dict[str, Any] = {
213
247
  "dest": option.field_name,
@@ -227,6 +261,15 @@ def add_config_arguments(
227
261
  argument_kwargs["action"] = option.cli_action
228
262
  group.add_argument(option.cli_flag, *option.cli_aliases, **argument_kwargs)
229
263
 
264
+ if include_env_file:
265
+ argument_group(DEFAULT_CONFIG_HELP_GROUP).add_argument(
266
+ "--env-file",
267
+ dest="env_file",
268
+ default=None,
269
+ metavar="PATH",
270
+ help="Read Config .env values from PATH (default: .env)",
271
+ )
272
+
230
273
 
231
274
  def config_parse_args(
232
275
  config_cls: type[ConfigType],
@@ -235,6 +278,8 @@ def config_parse_args(
235
278
  argv: Sequence[str] | None = None,
236
279
  description: str | None = None,
237
280
  include_env_file: bool = True,
281
+ include_fields: Iterable[str] | None = None,
282
+ exclude_fields: Iterable[str] = (),
238
283
  env: Mapping[str, str] | None = None,
239
284
  base_dir: Path | None = None,
240
285
  resolve_op_refs: bool = True,
@@ -242,12 +287,23 @@ def config_parse_args(
242
287
  require: Iterable[str] = (),
243
288
  ) -> ConfigType:
244
289
  """Parse Config CLI flags and return a validated Config model."""
245
- max_help_position = _config_help_max_position(config_cls, include_env_file=include_env_file)
290
+ formatter_class = config_help_formatter(
291
+ config_cls,
292
+ include_env_file=include_env_file,
293
+ include_fields=include_fields,
294
+ exclude_fields=exclude_fields,
295
+ )
246
296
  argument_parser = parser or argparse.ArgumentParser(
247
297
  description=description,
248
- formatter_class=_config_help_formatter(max_help_position),
298
+ formatter_class=formatter_class,
299
+ )
300
+ add_config_arguments(
301
+ argument_parser,
302
+ config_cls,
303
+ include_env_file=include_env_file,
304
+ include_fields=include_fields,
305
+ exclude_fields=exclude_fields,
249
306
  )
250
- add_config_arguments(argument_parser, config_cls, include_env_file=include_env_file)
251
307
  args = argument_parser.parse_args(argv)
252
308
  try:
253
309
  return load_config_from_args(
@@ -277,12 +333,14 @@ def _config_help_max_position(
277
333
  config_cls: type[Config],
278
334
  *,
279
335
  include_env_file: bool,
336
+ include_fields: Iterable[str] | None = None,
337
+ exclude_fields: Iterable[str] = (),
280
338
  ) -> int:
281
339
  """Return help-column width based on this Config's CLI arguments."""
282
340
  invocation_lengths = [len("--env-file PATH")] if include_env_file else []
283
341
  invocation_lengths.extend(
284
342
  _config_option_invocation_length(config_cls, option)
285
- for option in config_options(config_cls)
343
+ for option in _selected_config_options(config_cls, include_fields, exclude_fields)
286
344
  )
287
345
  longest_invocation = max(invocation_lengths, default=0)
288
346
  return min(
@@ -291,6 +349,48 @@ def _config_help_max_position(
291
349
  )
292
350
 
293
351
 
352
+ def _selected_config_options(
353
+ config_cls: type[Config],
354
+ include_fields: Iterable[str] | None,
355
+ exclude_fields: Iterable[str],
356
+ ) -> tuple[ConfigOption, ...]:
357
+ """Return options selected by field or env-var names.
358
+
359
+ When include_fields is set, its order controls the returned option order.
360
+ Without include_fields, Config model field order is preserved.
361
+ """
362
+ options = config_options(config_cls)
363
+ excluded = _selected_config_field_names(options, exclude_fields) or set()
364
+ if include_fields is None:
365
+ return tuple(option for option in options if not _option_is_selected(option, excluded))
366
+
367
+ options_by_field_name = {option.field_name: option for option in options}
368
+ return tuple(
369
+ options_by_field_name[field_name]
370
+ for field_name in _selected_config_field_names_in_order(options, include_fields)
371
+ if field_name not in excluded
372
+ )
373
+
374
+
375
+ def _selected_config_field_names_in_order(
376
+ options: tuple[ConfigOption, ...],
377
+ selected: Iterable[str],
378
+ ) -> tuple[str, ...]:
379
+ """Return selected field names in caller order after validating names."""
380
+ names = (_option_by_name(options, name).field_name for name in selected)
381
+ return tuple(dict.fromkeys(names))
382
+
383
+
384
+ def _selected_config_field_names(
385
+ options: tuple[ConfigOption, ...],
386
+ selected: Iterable[str] | None,
387
+ ) -> set[str] | None:
388
+ """Return selected field names after validating field or env-var names."""
389
+ if selected is None:
390
+ return None
391
+ return {_option_by_name(options, name).field_name for name in selected}
392
+
393
+
294
394
  def _config_option_invocation_length(config_cls: type[Config], option: ConfigOption) -> int:
295
395
  """Return argparse-style option invocation length for help alignment."""
296
396
  field_info = config_cls.model_fields[option.field_name]
@@ -452,6 +552,7 @@ def _config_option_payload(option: ConfigOption) -> dict[str, object]:
452
552
  "cli_const": option.cli_const,
453
553
  "metavar": option.metavar,
454
554
  "help": option.help,
555
+ "help_group": option.help_group,
455
556
  "secret": option.secret,
456
557
  "required": option.required,
457
558
  }
@@ -467,6 +568,7 @@ def _config_option_from_payload(payload: Mapping[str, object]) -> ConfigOption |
467
568
  cli_nargs = payload.get("cli_nargs")
468
569
  metavar = payload.get("metavar")
469
570
  help_text = payload.get("help")
571
+ help_group = payload.get("help_group")
470
572
  return ConfigOption(
471
573
  field_name="",
472
574
  env_var=env_var,
@@ -477,6 +579,7 @@ def _config_option_from_payload(payload: Mapping[str, object]) -> ConfigOption |
477
579
  cli_const=payload.get("cli_const"),
478
580
  metavar=metavar if isinstance(metavar, str) else None,
479
581
  help=help_text if isinstance(help_text, str) else "",
582
+ help_group=help_group if isinstance(help_group, str) else DEFAULT_CONFIG_HELP_GROUP,
480
583
  secret=payload.get("secret") is True,
481
584
  required=payload.get("required") is True,
482
585
  )
@@ -100,6 +100,7 @@ class LoggingConfig(Config):
100
100
  cli_flag="--src-log-level",
101
101
  metavar="LEVEL",
102
102
  help="Log level (default: INFO)",
103
+ help_group="Logging",
103
104
  )
104
105
  verbose: bool = config_field(
105
106
  default=False,
@@ -108,6 +109,7 @@ class LoggingConfig(Config):
108
109
  cli_aliases=("-v",),
109
110
  cli_action="store_true",
110
111
  help="Alias for --src-log-level DEBUG",
112
+ help_group="Logging",
111
113
  )
112
114
  quiet: bool = config_field(
113
115
  default=False,
@@ -116,6 +118,7 @@ class LoggingConfig(Config):
116
118
  cli_aliases=("-q",),
117
119
  cli_action="store_true",
118
120
  help="Alias for --src-log-level WARNING",
121
+ help_group="Logging",
119
122
  )
120
123
  silent: bool = config_field(
121
124
  default=False,
@@ -124,6 +127,7 @@ class LoggingConfig(Config):
124
127
  cli_aliases=("-s",),
125
128
  cli_action="store_true",
126
129
  help="Alias for --src-log-level ERROR",
130
+ help_group="Logging",
127
131
  )
128
132
 
129
133
  @model_validator(mode="after")
@@ -27,6 +27,8 @@ class PackageImportTest(unittest.TestCase):
27
27
  self.assertIsNotNone(src_py_lib.SourcegraphClient)
28
28
  self.assertIsNotNone(src_py_lib.SourcegraphClientConfig)
29
29
  self.assertIsNotNone(src_py_lib.config_field)
30
+ self.assertIsNotNone(src_py_lib.config_field_names)
31
+ self.assertIsNotNone(src_py_lib.config_help_formatter)
30
32
  self.assertIsNotNone(src_py_lib.gh_cli_token)
31
33
  self.assertIsNotNone(src_py_lib.gcloud_adc_access_token)
32
34
  self.assertIsNotNone(src_py_lib.info)
@@ -50,6 +50,7 @@ from src_py_lib.utils.config import (
50
50
  add_config_arguments,
51
51
  config_env_file_from_args,
52
52
  config_field,
53
+ config_field_names,
53
54
  config_overrides_from_args,
54
55
  config_parse_args,
55
56
  config_snapshot,
@@ -198,6 +199,32 @@ class MultilineHelpConfig(Config):
198
199
  )
199
200
 
200
201
 
202
+ class GroupedHelpConfig(Config):
203
+ """Config model with grouped help sections."""
204
+
205
+ alpha: str = config_field(
206
+ default="",
207
+ env_var="GROUPED_HELP_ALPHA",
208
+ cli_flag="--alpha",
209
+ help="Alpha option",
210
+ help_group="First group",
211
+ )
212
+ beta: str = config_field(
213
+ default="",
214
+ env_var="GROUPED_HELP_BETA",
215
+ cli_flag="--beta",
216
+ help="Beta option",
217
+ help_group="Second group",
218
+ )
219
+ gamma: str = config_field(
220
+ default="",
221
+ env_var="GROUPED_HELP_GAMMA",
222
+ cli_flag="--gamma",
223
+ help="Gamma option",
224
+ help_group="First group",
225
+ )
226
+
227
+
201
228
  class SnapshotOrderConfig(Config):
202
229
  """Config model whose field names and env-var names sort differently."""
203
230
 
@@ -340,6 +367,8 @@ class ConfigTest(unittest.TestCase):
340
367
  add_config_arguments(parser, SourcegraphExampleConfig)
341
368
  args = parser.parse_args(
342
369
  [
370
+ "--src-endpoint",
371
+ "https://sourcegraph.example.com",
343
372
  "--src-access-token",
344
373
  "test-token",
345
374
  "--repo-query",
@@ -355,10 +384,10 @@ class ConfigTest(unittest.TestCase):
355
384
  )
356
385
  client = sourcegraph_client_from_config(config)
357
386
 
358
- self.assertEqual(config.src_endpoint, "https://sourcegraph.com")
387
+ self.assertEqual(config.src_endpoint, "https://sourcegraph.example.com")
359
388
  self.assertEqual(config.src_access_token, "test-token")
360
389
  self.assertEqual(config.repo_query, "repo:example")
361
- self.assertEqual(client.endpoint, "https://sourcegraph.com")
390
+ self.assertEqual(client.endpoint, "https://sourcegraph.example.com")
362
391
  self.assertEqual(client.token, "test-token")
363
392
 
364
393
  def test_load_config_uses_precedence_and_pydantic_types(self) -> None:
@@ -456,6 +485,82 @@ class ConfigTest(unittest.TestCase):
456
485
  },
457
486
  )
458
487
 
488
+ def test_config_field_names_combines_config_classes_and_fields(self) -> None:
489
+ self.assertEqual(
490
+ config_field_names(SourcegraphClientConfig, LoggingConfig, "page_size"),
491
+ (
492
+ "src_endpoint",
493
+ "src_access_token",
494
+ "src_log_level",
495
+ "verbose",
496
+ "quiet",
497
+ "silent",
498
+ "page_size",
499
+ ),
500
+ )
501
+
502
+ def test_add_config_arguments_can_select_reusable_field_sets(self) -> None:
503
+ parser = argparse.ArgumentParser()
504
+ add_config_arguments(
505
+ parser,
506
+ ExampleConfig,
507
+ include_fields=("token", "page_size", "EXAMPLE_LABELS"),
508
+ exclude_fields=("page_size",),
509
+ )
510
+
511
+ args = parser.parse_args(["--token", "raw-token", "--labels", "one,two"])
512
+
513
+ self.assertEqual(
514
+ config_overrides_from_args(ExampleConfig, args),
515
+ {
516
+ "token": "raw-token",
517
+ "labels": "one,two",
518
+ },
519
+ )
520
+ with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
521
+ parser.parse_args(["--page-size", "50"])
522
+
523
+ def test_config_parse_args_help_only_shows_selected_fields(self) -> None:
524
+ stdout = io.StringIO()
525
+
526
+ with redirect_stdout(stdout), self.assertRaises(SystemExit) as raised:
527
+ config_parse_args(
528
+ ExampleConfig,
529
+ argv=["--help"],
530
+ env={},
531
+ resolve_op_refs=False,
532
+ include_fields=("labels", "token"),
533
+ )
534
+
535
+ self.assertEqual(raised.exception.code, 0)
536
+ help_text = stdout.getvalue()
537
+ self.assertIn("--token TOKEN", help_text)
538
+ self.assertIn("--labels CSV", help_text)
539
+ self.assertLess(help_text.index("--labels CSV"), help_text.index("--token TOKEN"))
540
+ self.assertNotIn("--page-size", help_text)
541
+ self.assertNotIn("--include-archived", help_text)
542
+
543
+ def test_config_parse_args_groups_help_by_field_metadata(self) -> None:
544
+ stdout = io.StringIO()
545
+
546
+ with redirect_stdout(stdout), self.assertRaises(SystemExit) as raised:
547
+ config_parse_args(
548
+ GroupedHelpConfig,
549
+ argv=["--help"],
550
+ env={},
551
+ resolve_op_refs=False,
552
+ include_fields=("beta", "alpha", "gamma"),
553
+ )
554
+
555
+ self.assertEqual(raised.exception.code, 0)
556
+ help_text = stdout.getvalue()
557
+ self.assertLess(help_text.index("Second group:"), help_text.index("First group:"))
558
+ self.assertLess(help_text.index("First group:"), help_text.index("Config:"))
559
+ self.assertLess(help_text.index("--alpha"), help_text.index("--gamma"))
560
+ self.assertIn("Second group:\n --beta", help_text)
561
+ self.assertIn("First group:\n --alpha", help_text)
562
+ self.assertNotIn("override matching environment variables", help_text)
563
+
459
564
  def test_config_arguments_support_aliases_actions_and_optional_values(self) -> None:
460
565
  parser = argparse.ArgumentParser()
461
566
  add_config_arguments(parser, CommandStyleConfig)
@@ -1424,6 +1529,35 @@ class ClientTest(unittest.TestCase):
1424
1529
  self.assertEqual(http.calls[0]["url"], "https://sourcegraph.example.com/.api/graphql")
1425
1530
  self.assertEqual(http.calls[0]["headers"], {"Authorization": "token token"})
1426
1531
 
1532
+ def test_sourcegraph_client_graphql_can_disable_auto_pagination(self) -> None:
1533
+ http = RecordingHTTP(
1534
+ [
1535
+ {
1536
+ "data": {
1537
+ "users": {
1538
+ "nodes": [{"username": "alice"}],
1539
+ "pageInfo": {"hasNextPage": True, "endCursor": "cursor-1"},
1540
+ }
1541
+ }
1542
+ }
1543
+ ]
1544
+ )
1545
+ client = SourcegraphClient("https://sourcegraph.example.com", "token", http=http)
1546
+ query = """
1547
+ query Users($first: Int!, $after: String) {
1548
+ users(first: $first, after: $after) {
1549
+ nodes { username }
1550
+ pageInfo { hasNextPage endCursor }
1551
+ }
1552
+ }
1553
+ """
1554
+
1555
+ data = client.graphql(query, page_size=1, follow_pages=False)
1556
+
1557
+ self.assertEqual(json_dict(data.get("users"))["nodes"], [{"username": "alice"}])
1558
+ self.assertEqual(len(http.calls), 1)
1559
+ self.assertEqual(http.calls[0]["json_body"]["variables"], {"first": 1})
1560
+
1427
1561
  def test_sourcegraph_client_rejects_http_endpoint_by_default(self) -> None:
1428
1562
  with self.assertRaisesRegex(ValueError, "https:// URL"):
1429
1563
  SourcegraphClient("http://sourcegraph.example.com", "token")
@@ -1536,7 +1670,7 @@ class ClientTest(unittest.TestCase):
1536
1670
 
1537
1671
  with src.trace_context(root_context):
1538
1672
  self.assertEqual(
1539
- client.graphql("query Viewer { currentUser { username } }"),
1673
+ client.graphql("query Viewer { currentUser { username } }", follow_pages=False),
1540
1674
  {"currentUser": {"username": "alice"}},
1541
1675
  )
1542
1676
  traces = client.drain_traces()
@@ -254,7 +254,6 @@ wheels = [
254
254
 
255
255
  [[package]]
256
256
  name = "src-py-lib"
257
- version = "0.1.5"
258
257
  source = { editable = "." }
259
258
  dependencies = [
260
259
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes
File without changes