bmsdna-devtools 0.2.0__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.
@@ -0,0 +1,37 @@
1
+ # This workflow will upload a Python Package using Twine when a release is created
2
+ # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3
+
4
+ # This workflow uses actions that are not certified by GitHub.
5
+ # They are provided by a third-party and are governed by
6
+ # separate terms of service, privacy policy, and support
7
+ # documentation.
8
+
9
+ name: Upload Python Package
10
+
11
+ on:
12
+ release:
13
+ types: [published]
14
+ workflow_dispatch:
15
+
16
+ jobs:
17
+ deploy:
18
+ runs-on: ubuntu-latest
19
+ environment:
20
+ name: pypi
21
+ url: https://pypi.org/p/bmsdna-devtools
22
+ permissions:
23
+ id-token: write
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - name: Set up Python
27
+ uses: actions/setup-python@v5
28
+ with:
29
+ python-version: "3.12"
30
+ - name: Install uv
31
+ run: curl -LsSf https://astral.sh/uv/install.sh | sh
32
+ - name: Install dependencies
33
+ run: uv sync --all-extras --all-groups
34
+ - name: Build package
35
+ run: uv build
36
+ - name: Publish package to PyPI
37
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,34 @@
1
+ name: Python Test
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ paths-ignore: ["README.md", ".github"]
7
+ pull_request:
8
+ branches: ["main"]
9
+ paths-ignore: ["README.md", ".github"]
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ python-version: ["3.11", "3.12", "3.13"]
18
+
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - name: Set up Python ${{ matrix.python-version }}
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+ - name: Install uv
26
+ run: curl -LsSf https://astral.sh/uv/install.sh | sh
27
+ - name: Install project dependencies
28
+ run: uv sync --all-extras --all-groups
29
+ - name: Lint (ruff)
30
+ run: uv run ruff check bmsdna tests
31
+ - name: Type check (ty)
32
+ run: uv run ty check bmsdna
33
+ - name: Test with pytest
34
+ run: uv run -m pytest --capture=tee-sys --maxfail=3 tests
@@ -0,0 +1,218 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: bmsdna-devtools
3
+ Version: 0.2.0
4
+ Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: requests>=2.32.0
7
+ Requires-Dist: rich>=13.0.0
8
+ Requires-Dist: typer>=0.26.7
9
+ Description-Content-Type: text/markdown
10
+
11
+ # bmsdna-devtools
12
+
13
+ Shared developer tooling for BMS projects: PR build/check status, PR
14
+ creation, git worktrees, a commit-and-push helper with pre-flight checks,
15
+ and Azure log queries. `bdt pr *` auto-detects whether the current repo's
16
+ `origin` remote is Azure DevOps or GitHub and uses `az`/`gh` accordingly.
17
+ Consolidates near-duplicate scripts that used to be copy-pasted across
18
+ OneSales, ccmt2, and MDMApp into one versioned package with a `bdt` CLI.
19
+
20
+ Requires `git` always, plus `az` (Azure DevOps commands, and all `bdt logs`
21
+ commands) and/or `gh` (GitHub commands) on PATH as needed — each is checked
22
+ lazily, only when a command actually needs it, with a clear error and an
23
+ install link if missing rather than a raw traceback. Works on Windows: CLI
24
+ shims (e.g. `az.cmd`) are resolved via `shutil.which` (which honors
25
+ `PATHEXT`) rather than shelling out, output is decoded as UTF-8 rather than
26
+ relying on the console's default codepage, and file arguments accept either
27
+ slash style.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ uv tool install bmsdna-devtools
33
+ ```
34
+
35
+ Or as a project dependency: `uv add bmsdna-devtools`.
36
+
37
+ ## `bdt pr status`
38
+
39
+ Find the PR opened from the current branch and report build/check status
40
+ (failed steps print their logs inline). Works against Azure DevOps or
41
+ GitHub — whichever `origin` points at.
42
+
43
+ ```bash
44
+ bdt pr status [--target-branch main] [--wait]
45
+ ```
46
+
47
+ If the PR can't be merged, that's reported immediately instead of polling
48
+ for builds/checks that will never run — e.g. on Azure DevOps:
49
+ `PR #42 ('feat: widgets') has merge conflicts with the target branch (mergeStatus=conflicts)`;
50
+ on GitHub: `PR #42 ('feat: widgets') has merge conflicts with 'main' (mergeable=CONFLICTING)`.
51
+ Exit code 1 either way.
52
+
53
+ **Azure DevOps**: org/project/repo are auto-detected from
54
+ `git remote get-url origin` (handles SSH, `dev.azure.com` HTTPS, and
55
+ `*.visualstudio.com` HTTPS forms). Auth is an explicit PAT (`--pat` or
56
+ `AZURE_DEVOPS_PAT` env var), falling back to a short-lived token from the
57
+ caller's own `az login` — never embed a PAT literal in a script or CI file.
58
+ `--target-branch` selects which PR to look at (ADO's search API needs one).
59
+
60
+ **GitHub**: uses `gh`'s own auth (`gh auth login`) and always resolves the
61
+ PR opened from the current branch — `gh pr view` has no target-branch
62
+ filter, so `--target-branch` is ignored here; the PR's actual base branch
63
+ is shown in the output. Check status is computed from
64
+ `gh pr view --json statusCheckRollup` rather than `gh pr checks --json`,
65
+ since the latter flag isn't available in all `gh` releases.
66
+
67
+ ## `bdt pr create`
68
+
69
+ ```bash
70
+ bdt pr create --target main # or --target test
71
+ ```
72
+
73
+ Creates a PR from the current branch into `--target`. On Azure DevOps,
74
+ a thin wrapper around `az repos pr create` (org/project/repo inferred by
75
+ `az` itself from the git remote). On GitHub, `gh pr create --fill` (autofills
76
+ title/body from commit info so it never blocks on an interactive prompt).
77
+ Extra arguments pass through either way, e.g.
78
+ `bdt pr create --target main -- --title "..."`.
79
+
80
+ ## `bdt worktree`
81
+
82
+ ```bash
83
+ bdt worktree my-feature [--base dev] [--env-file .local_env] [--no-submodules] [--install "just install"]
84
+ ```
85
+
86
+ Creates `.worktrees/<name>` branched from `--base`, initializes submodules
87
+ (unless `--no-submodules`), and copies an env file into the new worktree as
88
+ `.env` (auto-detects `.local_env` then `.env` if `--env-file` isn't given).
89
+
90
+ ## `bdt commit`
91
+
92
+ ```bash
93
+ bdt commit "feat(x): add widget support" file1.py file2.py [--json] [--no-verify] [--subrepo database]
94
+ ```
95
+
96
+ Stages, commits, and pushes the given files. Pre-flight checks: files
97
+ exist, commit message looks like `type: description` (skip with
98
+ `--skip-message-check`), not on `main`/`master` (skip with `--allow-main`).
99
+ Retries once (re-`git add`) if a pre-commit hook reformats files. Pass
100
+ `--subrepo <dir>` (repeatable) for repos that vendor a submodule (e.g.
101
+ `database`) — files under that prefix are committed/pushed inside the
102
+ submodule first, then the bump is staged in the parent repo.
103
+
104
+ Set `IS_BMS_AI_SANDBOX=1` to skip the push step (commit only) — used when
105
+ an AI coding sandbox pushes on its own schedule separately.
106
+
107
+ `--json` emits a machine-readable result for AI-agent callers:
108
+
109
+ ```json
110
+ {
111
+ "success": true, "committed": true, "pushed": true,
112
+ "message": "...", "files": ["..."], "commit_sha": "abc1234",
113
+ "error": null, "hint": null
114
+ }
115
+ ```
116
+
117
+ ## `bdt logs roles` / `bdt logs tail`
118
+
119
+ Query Application Insights (KQL over `traces`/`exceptions`) via
120
+ `az monitor app-insights query`. No defaults are baked in — pass
121
+ `--resource-group`/`--app-insights` explicitly (or set
122
+ `AZURE_RESOURCE_GROUP`/`AZURE_APP_INSIGHTS`), since which Azure resource
123
+ "this repo" maps to isn't derivable from the git remote.
124
+
125
+ ```bash
126
+ bdt logs roles --resource-group my-rg --app-insights my-app-insights --minutes 60
127
+ bdt logs tail --resource-group my-rg --app-insights my-app-insights --role my-service --level warning
128
+ ```
129
+
130
+ ## `bdt logs fetch`
131
+
132
+ Downloads the App Service log archive for a webapp/slot via
133
+ `az webapp log download`, unzips it, and writes every line matching a
134
+ common error/warning marker (`ERROR`, `CRITICAL`, `WARNING`, tracebacks,
135
+ `4xx`/`5xx`, `FAILED`, `FATAL`) to `<out>/<slot>_errors.log`. Simpler and
136
+ often preferable to the KQL commands above when you just want "what broke
137
+ recently" rather than a queryable trace stream — no defaults are baked in
138
+ here either.
139
+
140
+ ```bash
141
+ bdt logs fetch --webapp my-webapp --resource-group my-rg --slot production
142
+ bdt logs fetch --webapp my-webapp --resource-group my-rg --slot production --out logs/ --keep-archive
143
+ ```
@@ -0,0 +1,133 @@
1
+ # bmsdna-devtools
2
+
3
+ Shared developer tooling for BMS projects: PR build/check status, PR
4
+ creation, git worktrees, a commit-and-push helper with pre-flight checks,
5
+ and Azure log queries. `bdt pr *` auto-detects whether the current repo's
6
+ `origin` remote is Azure DevOps or GitHub and uses `az`/`gh` accordingly.
7
+ Consolidates near-duplicate scripts that used to be copy-pasted across
8
+ OneSales, ccmt2, and MDMApp into one versioned package with a `bdt` CLI.
9
+
10
+ Requires `git` always, plus `az` (Azure DevOps commands, and all `bdt logs`
11
+ commands) and/or `gh` (GitHub commands) on PATH as needed — each is checked
12
+ lazily, only when a command actually needs it, with a clear error and an
13
+ install link if missing rather than a raw traceback. Works on Windows: CLI
14
+ shims (e.g. `az.cmd`) are resolved via `shutil.which` (which honors
15
+ `PATHEXT`) rather than shelling out, output is decoded as UTF-8 rather than
16
+ relying on the console's default codepage, and file arguments accept either
17
+ slash style.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ uv tool install bmsdna-devtools
23
+ ```
24
+
25
+ Or as a project dependency: `uv add bmsdna-devtools`.
26
+
27
+ ## `bdt pr status`
28
+
29
+ Find the PR opened from the current branch and report build/check status
30
+ (failed steps print their logs inline). Works against Azure DevOps or
31
+ GitHub — whichever `origin` points at.
32
+
33
+ ```bash
34
+ bdt pr status [--target-branch main] [--wait]
35
+ ```
36
+
37
+ If the PR can't be merged, that's reported immediately instead of polling
38
+ for builds/checks that will never run — e.g. on Azure DevOps:
39
+ `PR #42 ('feat: widgets') has merge conflicts with the target branch (mergeStatus=conflicts)`;
40
+ on GitHub: `PR #42 ('feat: widgets') has merge conflicts with 'main' (mergeable=CONFLICTING)`.
41
+ Exit code 1 either way.
42
+
43
+ **Azure DevOps**: org/project/repo are auto-detected from
44
+ `git remote get-url origin` (handles SSH, `dev.azure.com` HTTPS, and
45
+ `*.visualstudio.com` HTTPS forms). Auth is an explicit PAT (`--pat` or
46
+ `AZURE_DEVOPS_PAT` env var), falling back to a short-lived token from the
47
+ caller's own `az login` — never embed a PAT literal in a script or CI file.
48
+ `--target-branch` selects which PR to look at (ADO's search API needs one).
49
+
50
+ **GitHub**: uses `gh`'s own auth (`gh auth login`) and always resolves the
51
+ PR opened from the current branch — `gh pr view` has no target-branch
52
+ filter, so `--target-branch` is ignored here; the PR's actual base branch
53
+ is shown in the output. Check status is computed from
54
+ `gh pr view --json statusCheckRollup` rather than `gh pr checks --json`,
55
+ since the latter flag isn't available in all `gh` releases.
56
+
57
+ ## `bdt pr create`
58
+
59
+ ```bash
60
+ bdt pr create --target main # or --target test
61
+ ```
62
+
63
+ Creates a PR from the current branch into `--target`. On Azure DevOps,
64
+ a thin wrapper around `az repos pr create` (org/project/repo inferred by
65
+ `az` itself from the git remote). On GitHub, `gh pr create --fill` (autofills
66
+ title/body from commit info so it never blocks on an interactive prompt).
67
+ Extra arguments pass through either way, e.g.
68
+ `bdt pr create --target main -- --title "..."`.
69
+
70
+ ## `bdt worktree`
71
+
72
+ ```bash
73
+ bdt worktree my-feature [--base dev] [--env-file .local_env] [--no-submodules] [--install "just install"]
74
+ ```
75
+
76
+ Creates `.worktrees/<name>` branched from `--base`, initializes submodules
77
+ (unless `--no-submodules`), and copies an env file into the new worktree as
78
+ `.env` (auto-detects `.local_env` then `.env` if `--env-file` isn't given).
79
+
80
+ ## `bdt commit`
81
+
82
+ ```bash
83
+ bdt commit "feat(x): add widget support" file1.py file2.py [--json] [--no-verify] [--subrepo database]
84
+ ```
85
+
86
+ Stages, commits, and pushes the given files. Pre-flight checks: files
87
+ exist, commit message looks like `type: description` (skip with
88
+ `--skip-message-check`), not on `main`/`master` (skip with `--allow-main`).
89
+ Retries once (re-`git add`) if a pre-commit hook reformats files. Pass
90
+ `--subrepo <dir>` (repeatable) for repos that vendor a submodule (e.g.
91
+ `database`) — files under that prefix are committed/pushed inside the
92
+ submodule first, then the bump is staged in the parent repo.
93
+
94
+ Set `IS_BMS_AI_SANDBOX=1` to skip the push step (commit only) — used when
95
+ an AI coding sandbox pushes on its own schedule separately.
96
+
97
+ `--json` emits a machine-readable result for AI-agent callers:
98
+
99
+ ```json
100
+ {
101
+ "success": true, "committed": true, "pushed": true,
102
+ "message": "...", "files": ["..."], "commit_sha": "abc1234",
103
+ "error": null, "hint": null
104
+ }
105
+ ```
106
+
107
+ ## `bdt logs roles` / `bdt logs tail`
108
+
109
+ Query Application Insights (KQL over `traces`/`exceptions`) via
110
+ `az monitor app-insights query`. No defaults are baked in — pass
111
+ `--resource-group`/`--app-insights` explicitly (or set
112
+ `AZURE_RESOURCE_GROUP`/`AZURE_APP_INSIGHTS`), since which Azure resource
113
+ "this repo" maps to isn't derivable from the git remote.
114
+
115
+ ```bash
116
+ bdt logs roles --resource-group my-rg --app-insights my-app-insights --minutes 60
117
+ bdt logs tail --resource-group my-rg --app-insights my-app-insights --role my-service --level warning
118
+ ```
119
+
120
+ ## `bdt logs fetch`
121
+
122
+ Downloads the App Service log archive for a webapp/slot via
123
+ `az webapp log download`, unzips it, and writes every line matching a
124
+ common error/warning marker (`ERROR`, `CRITICAL`, `WARNING`, tracebacks,
125
+ `4xx`/`5xx`, `FAILED`, `FATAL`) to `<out>/<slot>_errors.log`. Simpler and
126
+ often preferable to the KQL commands above when you just want "what broke
127
+ recently" rather than a queryable trace stream — no defaults are baked in
128
+ here either.
129
+
130
+ ```bash
131
+ bdt logs fetch --webapp my-webapp --resource-group my-rg --slot production
132
+ bdt logs fetch --webapp my-webapp --resource-group my-rg --slot production --out logs/ --keep-archive
133
+ ```
File without changes
@@ -0,0 +1,37 @@
1
+ """Azure DevOps auth: explicit PAT, or fall back to the caller's `az` login."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import subprocess
7
+ import sys
8
+
9
+ from .cli_tools import require_az
10
+
11
+ # Well-known Azure DevOps resource ID for `az account get-access-token`.
12
+ ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
13
+
14
+
15
+ def get_az_devops_token() -> str:
16
+ az = require_az()
17
+ result = subprocess.run(
18
+ [az, "account", "get-access-token", "--resource", ADO_RESOURCE_ID, "--query", "accessToken", "-o", "tsv"],
19
+ capture_output=True,
20
+ encoding="utf-8",
21
+ )
22
+ if result.returncode != 0:
23
+ sys.exit(f"az login required and no PAT provided.\n{result.stderr.strip()}")
24
+ return result.stdout.strip()
25
+
26
+
27
+ def auth_header(pat: str | None) -> dict[str, str]:
28
+ """Basic-auth header for an explicit PAT, else Bearer via `az` token.
29
+
30
+ Never hardcode a PAT literal in a caller — pass it in from an env var
31
+ (e.g. AZURE_DEVOPS_PAT) or a CLI flag, or omit it and let `az` supply a
32
+ short-lived token from the operator's own login.
33
+ """
34
+ if pat:
35
+ token = base64.b64encode(f":{pat}".encode()).decode()
36
+ return {"Authorization": f"Basic {token}"}
37
+ return {"Authorization": f"Bearer {get_az_devops_token()}"}
@@ -0,0 +1,76 @@
1
+ """Download an Azure App Service log archive and filter it for errors/warnings.
2
+
3
+ Pulls the log zip via `az webapp log download`, unpacks it in memory, and
4
+ writes every line matching common error/warning markers to a filtered file.
5
+ The webapp/resource-group/slot for a given `--env` come from the consuming
6
+ repo's pyproject.toml (see env_config.py) rather than being baked in here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pathlib
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ import zipfile
16
+
17
+ from .cli_tools import require_az
18
+
19
+ # Matches common error/warning markers across granian, uvicorn, and python tracebacks.
20
+ ERROR_RE = re.compile(r"\b(ERROR|CRITICAL|WARNING|Traceback|Exception|\b[45]\d\d\b|FAILED|FATAL)\b")
21
+
22
+
23
+ def run_az(args: list[str]) -> str:
24
+ az = require_az()
25
+ proc = subprocess.run([az, *args], capture_output=True, encoding="utf-8")
26
+ if proc.returncode != 0:
27
+ sys.exit(proc.stderr.strip() or proc.stdout.strip())
28
+ return proc.stdout
29
+
30
+
31
+ def download_logs(webapp: str, resource_group: str, slot: str, archive: pathlib.Path) -> None:
32
+ print(f"Downloading logs for {webapp}/{slot}...", flush=True)
33
+ run_az([
34
+ "webapp", "log", "download",
35
+ "--name", webapp,
36
+ "--resource-group", resource_group,
37
+ "--slot", slot,
38
+ "--log-file", str(archive),
39
+ ])
40
+
41
+
42
+ def extract_errors(archive: pathlib.Path, error_file: pathlib.Path) -> int:
43
+ """Unzip the archive and write all matching lines to error_file. Returns count."""
44
+ count = 0
45
+ with zipfile.ZipFile(archive) as zf, error_file.open("w") as out:
46
+ for member in zf.namelist():
47
+ if member.endswith("/"):
48
+ continue
49
+ with zf.open(member) as fh:
50
+ for raw in fh:
51
+ line = raw.decode("utf-8", "replace")
52
+ if ERROR_RE.search(line):
53
+ out.write(f"{member}: {line}")
54
+ count += 1
55
+ return count
56
+
57
+
58
+ def fetch(
59
+ webapp: str,
60
+ resource_group: str,
61
+ slot: str,
62
+ out_dir: pathlib.Path,
63
+ keep_archive: bool = False,
64
+ ) -> pathlib.Path:
65
+ out_dir.mkdir(parents=True, exist_ok=True)
66
+ archive = out_dir / f"{slot}_logs.zip"
67
+ error_file = out_dir / f"{slot}_errors.log"
68
+
69
+ download_logs(webapp, resource_group, slot, archive)
70
+ count = extract_errors(archive, error_file)
71
+
72
+ if not keep_archive:
73
+ archive.unlink()
74
+
75
+ print(f"✓ {count} error/warning lines → {error_file}")
76
+ return error_file