bmsdna-devtools 0.2.0__tar.gz → 0.2.1__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 (28) hide show
  1. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/PKG-INFO +20 -5
  2. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/README.md +19 -4
  3. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/bmsdna/devtools/app_service_logs.py +7 -6
  4. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/bmsdna/devtools/cli.py +1 -1
  5. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/bmsdna/devtools/commit.py +37 -5
  6. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/bmsdna/devtools/env_config.py +8 -2
  7. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/pyproject.toml +1 -1
  8. bmsdna_devtools-0.2.1/tests/test_app_service_logs.py +39 -0
  9. bmsdna_devtools-0.2.1/tests/test_commit.py +124 -0
  10. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/tests/test_env_config.py +12 -0
  11. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/uv.lock +1 -1
  12. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/.github/workflows/python-publish.yml +0 -0
  13. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/.github/workflows/python-test.yml +0 -0
  14. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/.gitignore +0 -0
  15. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/.python-version +0 -0
  16. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/bmsdna/devtools/__init__.py +0 -0
  17. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/bmsdna/devtools/ado_auth.py +0 -0
  18. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/bmsdna/devtools/cli_tools.py +0 -0
  19. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/bmsdna/devtools/gh_pr.py +0 -0
  20. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/bmsdna/devtools/gitrepo.py +0 -0
  21. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/bmsdna/devtools/logs.py +0 -0
  22. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/bmsdna/devtools/pr_build.py +0 -0
  23. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/bmsdna/devtools/worktree.py +0 -0
  24. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/justfile +0 -0
  25. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/skills/bmsdna-devtools/SKILL.md +0 -0
  26. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/tests/test_gh_pr.py +0 -0
  27. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/tests/test_gitrepo.py +0 -0
  28. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.1}/tests/test_pr_build.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bmsdna-devtools
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests>=2.32.0
@@ -134,10 +134,25 @@ Downloads the App Service log archive for a webapp/slot via
134
134
  common error/warning marker (`ERROR`, `CRITICAL`, `WARNING`, tracebacks,
135
135
  `4xx`/`5xx`, `FAILED`, `FATAL`) to `<out>/<slot>_errors.log`. Simpler and
136
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.
137
+ recently" rather than a queryable trace stream.
138
+
139
+ The webapp/resource-group/slot come from a named environment configured in
140
+ the calling repo's `pyproject.toml`. `slot` is optional — omit it for an
141
+ app's default/production slot (no `--slot` is passed to `az`); set it for a
142
+ named deployment slot:
143
+
144
+ ```toml
145
+ [tool.bdt.envs.prod]
146
+ webapp = "my-webapp"
147
+ resource_group = "my-rg"
148
+
149
+ [tool.bdt.envs.test]
150
+ webapp = "my-webapp"
151
+ resource_group = "my-rg"
152
+ slot = "test"
153
+ ```
139
154
 
140
155
  ```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
156
+ bdt logs fetch --env prod
157
+ bdt logs fetch --env prod --out logs/ --keep-archive
143
158
  ```
@@ -124,10 +124,25 @@ Downloads the App Service log archive for a webapp/slot via
124
124
  common error/warning marker (`ERROR`, `CRITICAL`, `WARNING`, tracebacks,
125
125
  `4xx`/`5xx`, `FAILED`, `FATAL`) to `<out>/<slot>_errors.log`. Simpler and
126
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.
127
+ recently" rather than a queryable trace stream.
128
+
129
+ The webapp/resource-group/slot come from a named environment configured in
130
+ the calling repo's `pyproject.toml`. `slot` is optional — omit it for an
131
+ app's default/production slot (no `--slot` is passed to `az`); set it for a
132
+ named deployment slot:
133
+
134
+ ```toml
135
+ [tool.bdt.envs.prod]
136
+ webapp = "my-webapp"
137
+ resource_group = "my-rg"
138
+
139
+ [tool.bdt.envs.test]
140
+ webapp = "my-webapp"
141
+ resource_group = "my-rg"
142
+ slot = "test"
143
+ ```
129
144
 
130
145
  ```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
146
+ bdt logs fetch --env prod
147
+ bdt logs fetch --env prod --out logs/ --keep-archive
133
148
  ```
@@ -28,13 +28,13 @@ def run_az(args: list[str]) -> str:
28
28
  return proc.stdout
29
29
 
30
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)
31
+ def download_logs(webapp: str, resource_group: str, slot: str | None, archive: pathlib.Path) -> None:
32
+ print(f"Downloading logs for {webapp}/{slot or '(production)'}...", flush=True)
33
33
  run_az([
34
34
  "webapp", "log", "download",
35
35
  "--name", webapp,
36
36
  "--resource-group", resource_group,
37
- "--slot", slot,
37
+ *(["--slot", slot] if slot else []),
38
38
  "--log-file", str(archive),
39
39
  ])
40
40
 
@@ -58,13 +58,14 @@ def extract_errors(archive: pathlib.Path, error_file: pathlib.Path) -> int:
58
58
  def fetch(
59
59
  webapp: str,
60
60
  resource_group: str,
61
- slot: str,
61
+ slot: str | None,
62
62
  out_dir: pathlib.Path,
63
63
  keep_archive: bool = False,
64
64
  ) -> pathlib.Path:
65
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"
66
+ label = slot or "production"
67
+ archive = out_dir / f"{label}_logs.zip"
68
+ error_file = out_dir / f"{label}_errors.log"
68
69
 
69
70
  download_logs(webapp, resource_group, slot, archive)
70
71
  count = extract_errors(archive, error_file)
@@ -131,7 +131,7 @@ def logs_fetch(
131
131
  ) -> None:
132
132
  """Download an App Service log archive and extract error/warning lines."""
133
133
  cfg = env_config.resolve_env(env)
134
- app_service_logs.fetch(cfg["webapp"], cfg["resource_group"], cfg["slot"], out, keep_archive=keep_archive)
134
+ app_service_logs.fetch(cfg["webapp"], cfg["resource_group"], cfg.get("slot"), out, keep_archive=keep_archive)
135
135
 
136
136
 
137
137
  if __name__ == "__main__":
@@ -30,6 +30,26 @@ def _in_sandbox() -> bool:
30
30
  return os.getenv(IS_SANDBOX_ENV_VAR, "0").lower() in ("1", "true", "yes")
31
31
 
32
32
 
33
+ def _staged_deletion(path: str, cwd: str | None = None) -> bool:
34
+ """True if `path` is already staged as a deletion (e.g. via `git rm`) in
35
+ the repo at `cwd`. This is a deliberate-intent signal, not just "HEAD used
36
+ to have this path" — a file that vanished because a write failed or raced
37
+ would be missing from disk but NOT staged as a deletion, so it still
38
+ fails the check below instead of silently being committed as removed."""
39
+ r = _run(["git", "diff", "--cached", "--name-only", "--diff-filter=D", "--", path], cwd=cwd)
40
+ return r.returncode == 0 and path in r.stdout.splitlines()
41
+
42
+
43
+ def _present_or_staged_deletion(path: str, subrepos: list[str]) -> bool:
44
+ if os.path.exists(path):
45
+ return True
46
+ for subrepo in subrepos:
47
+ prefix = subrepo + "/"
48
+ if path.startswith(prefix):
49
+ return _staged_deletion(path[len(prefix):], cwd=subrepo)
50
+ return _staged_deletion(path)
51
+
52
+
33
53
  @dataclass
34
54
  class CommitResult:
35
55
  success: bool
@@ -64,17 +84,29 @@ def _git_commit(message: str, cwd: str | None = None, no_verify: bool = False) -
64
84
  return _run(cmd, cwd=cwd)
65
85
 
66
86
 
87
+ def _add(files: list[str], cwd: str | None) -> None:
88
+ """`git add`, excluding paths already fully removed (disk + index) by a
89
+ prior `git rm` -- those have nothing left to add, and `git add` fails its
90
+ ENTIRE invocation (staging nothing, for any path in the same call) if even
91
+ one pathspec doesn't match anything. Passing only what's actually present
92
+ on disk avoids silently dropping the other files' staged content."""
93
+ root = cwd or "."
94
+ addable = [f for f in files if os.path.exists(os.path.join(root, f))]
95
+ if addable:
96
+ _run(["git", "add", *addable], cwd=cwd)
97
+
98
+
67
99
  def _commit_with_retry(
68
100
  message: str, files: list[str], cwd: str | None, no_verify: bool
69
101
  ) -> tuple[bool, subprocess.CompletedProcess | None]:
70
102
  """Commit, retrying once (re-`git add`) if a pre-commit hook reformatted files."""
71
- _run(["git", "add", *files], cwd=cwd)
103
+ _add(files, cwd)
72
104
  r = _git_commit(message, cwd=cwd, no_verify=no_verify)
73
105
  if r.returncode == 0:
74
106
  return True, None
75
107
  if "nothing to commit" in r.stdout + r.stderr:
76
108
  return False, None
77
- _run(["git", "add", *files], cwd=cwd)
109
+ _add(files, cwd)
78
110
  r2 = _git_commit(message, cwd=cwd, no_verify=no_verify)
79
111
  if r2.returncode == 0:
80
112
  return True, None
@@ -108,8 +140,8 @@ def commit_and_push(
108
140
  print(f" {'✓' if ok else '✗'} {label}", file=sys.stdout if ok else sys.stderr)
109
141
  return ok
110
142
 
111
- missing = [f for f in files if not os.path.exists(f)]
112
- if not check(not missing, "files exist"):
143
+ missing = [f for f in files if not _present_or_staged_deletion(f, subrepos)]
144
+ if not check(not missing, "files exist (or are a staged deletion)"):
113
145
  return CommitResult(
114
146
  False, False, False, message, files,
115
147
  error=f"File not found: {missing[0]} — did you typo the path? Run `git status` to see changed files",
@@ -177,7 +209,7 @@ def commit_and_push(
177
209
  pr = _run(["git", "push"])
178
210
  if pr.returncode != 0:
179
211
  return CommitResult(
180
- True, committed, False, message, files,
212
+ False, committed, False, message, files,
181
213
  commit_sha=_sha(),
182
214
  error=(pr.stdout + pr.stderr).strip(),
183
215
  hint="Push rejected. Run `git pull --rebase`, resolve conflicts, then retry.",
@@ -11,13 +11,19 @@ import sys
11
11
  import tomllib
12
12
  from pathlib import Path
13
13
 
14
- REQUIRED_KEYS = ("webapp", "resource_group", "slot")
14
+ REQUIRED_KEYS = ("webapp", "resource_group")
15
15
 
16
16
  CONFIG_EXAMPLE = """\
17
17
  [tool.bdt.envs.prod]
18
18
  webapp = "my-app"
19
19
  resource_group = "my-app-rg"
20
- slot = "production"
20
+ # slot is optional — omit it for an app's default/production slot,
21
+ # set it for a named deployment slot (e.g. "test", "stage").
22
+
23
+ [tool.bdt.envs.test]
24
+ webapp = "my-app"
25
+ resource_group = "my-app-rg"
26
+ slot = "test"
21
27
  """
22
28
 
23
29
 
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.2.0"
13
+ version = "0.2.1"
14
14
  description = "Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.11"
@@ -0,0 +1,39 @@
1
+ import zipfile
2
+
3
+ from bmsdna.devtools import app_service_logs
4
+
5
+
6
+ def test_download_logs_omits_slot_flag_when_none(monkeypatch, tmp_path):
7
+ captured = {}
8
+
9
+ def fake_run_az(args):
10
+ captured["args"] = args
11
+ return ""
12
+
13
+ monkeypatch.setattr(app_service_logs, "run_az", fake_run_az)
14
+ app_service_logs.download_logs("my-app", "my-rg", None, tmp_path / "archive.zip")
15
+ assert "--slot" not in captured["args"]
16
+
17
+
18
+ def test_download_logs_includes_slot_flag_when_given(monkeypatch, tmp_path):
19
+ captured = {}
20
+
21
+ def fake_run_az(args):
22
+ captured["args"] = args
23
+ return ""
24
+
25
+ monkeypatch.setattr(app_service_logs, "run_az", fake_run_az)
26
+ app_service_logs.download_logs("my-app", "my-rg", "test", tmp_path / "archive.zip")
27
+ assert "--slot" in captured["args"]
28
+ assert captured["args"][captured["args"].index("--slot") + 1] == "test"
29
+
30
+
31
+ def test_fetch_uses_production_label_when_slot_is_none(monkeypatch, tmp_path):
32
+ def fake_download(webapp, resource_group, slot, archive):
33
+ with zipfile.ZipFile(archive, "w"):
34
+ pass
35
+
36
+ monkeypatch.setattr(app_service_logs, "download_logs", fake_download)
37
+ error_file = app_service_logs.fetch("my-app", "my-rg", None, tmp_path, keep_archive=True)
38
+ assert error_file == tmp_path / "production_errors.log"
39
+ assert (tmp_path / "production_logs.zip").exists()
@@ -0,0 +1,124 @@
1
+ import subprocess
2
+
3
+ from bmsdna.devtools.commit import commit_and_push
4
+
5
+
6
+ def init_repo(path):
7
+ subprocess.run(["git", "init", "-q", "-b", "feature"], cwd=path, check=True)
8
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=path, check=True)
9
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=path, check=True)
10
+
11
+
12
+ def test_commit_and_push_fails_when_push_has_no_remote(tmp_path, monkeypatch):
13
+ init_repo(tmp_path)
14
+ (tmp_path / "a.txt").write_text("hello")
15
+ monkeypatch.chdir(tmp_path)
16
+
17
+ result = commit_and_push(
18
+ "feat(x): add a.txt",
19
+ ["a.txt"],
20
+ require_message_quality=False,
21
+ require_feature_branch=False,
22
+ )
23
+
24
+ assert result.committed is True
25
+ assert result.pushed is False
26
+ assert result.success is False
27
+ assert result.error
28
+
29
+
30
+ def test_commit_and_push_allows_staged_deletion(tmp_path, monkeypatch):
31
+ init_repo(tmp_path)
32
+ (tmp_path / "a.txt").write_text("hello")
33
+ monkeypatch.chdir(tmp_path)
34
+ subprocess.run(["git", "add", "a.txt"], cwd=tmp_path, check=True)
35
+ subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=tmp_path, check=True)
36
+
37
+ subprocess.run(["git", "rm", "-q", "a.txt"], cwd=tmp_path, check=True)
38
+
39
+ result = commit_and_push(
40
+ "feat(x): remove a.txt",
41
+ ["a.txt"],
42
+ require_message_quality=False,
43
+ require_feature_branch=False,
44
+ )
45
+
46
+ assert result.committed is True
47
+ assert result.error != "File not found: a.txt — did you typo the path? Run `git status` to see changed files"
48
+
49
+
50
+ def test_commit_and_push_rejects_unstaged_missing_file(tmp_path, monkeypatch):
51
+ """A file that vanished from disk without git being told (e.g. a failed
52
+ write) must still be rejected -- only an already-*staged* deletion (via
53
+ `git rm`) is treated as an intentional removal."""
54
+ init_repo(tmp_path)
55
+ (tmp_path / "a.txt").write_text("hello")
56
+ monkeypatch.chdir(tmp_path)
57
+ subprocess.run(["git", "add", "a.txt"], cwd=tmp_path, check=True)
58
+ subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=tmp_path, check=True)
59
+
60
+ (tmp_path / "a.txt").unlink()
61
+
62
+ result = commit_and_push(
63
+ "feat(x): update a.txt",
64
+ ["a.txt"],
65
+ require_message_quality=False,
66
+ require_feature_branch=False,
67
+ )
68
+
69
+ assert result.committed is False
70
+ assert result.error == "File not found: a.txt — did you typo the path? Run `git status` to see changed files"
71
+
72
+
73
+ def test_commit_and_push_stages_modified_file_alongside_staged_deletion(tmp_path, monkeypatch):
74
+ """Regression test: `git add <modified-file> <already-git-rm'd-file>` fails
75
+ its ENTIRE invocation (git errors "pathspec did not match any files" for
76
+ the already-removed path, staging nothing at all in that call) -- which
77
+ previously silently dropped the modified file's new content from the
78
+ commit, since _commit_with_retry didn't check git add's exit code."""
79
+ init_repo(tmp_path)
80
+ (tmp_path / "a.md").write_text("old")
81
+ (tmp_path / "gone.py").write_text("old")
82
+ subprocess.run(["git", "add", "a.md", "gone.py"], cwd=tmp_path, check=True)
83
+ subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=tmp_path, check=True)
84
+ monkeypatch.chdir(tmp_path)
85
+
86
+ subprocess.run(["git", "rm", "-q", "gone.py"], cwd=tmp_path, check=True)
87
+ (tmp_path / "a.md").write_text("new content")
88
+
89
+ result = commit_and_push(
90
+ "feat(x): update a.md and remove gone.py",
91
+ ["a.md", "gone.py"],
92
+ require_message_quality=False,
93
+ require_feature_branch=False,
94
+ )
95
+
96
+ assert result.committed is True
97
+ committed_content = subprocess.run(
98
+ ["git", "show", "HEAD:a.md"], cwd=tmp_path, capture_output=True, text=True, check=True
99
+ ).stdout
100
+ assert committed_content == "new content"
101
+
102
+
103
+ def test_commit_and_push_allows_staged_deletion_in_subrepo(tmp_path, monkeypatch):
104
+ subrepo = tmp_path / "database"
105
+ subrepo.mkdir()
106
+ init_repo(subrepo)
107
+ (subrepo / "schema.sql").write_text("create table t();")
108
+ subprocess.run(["git", "add", "schema.sql"], cwd=subrepo, check=True)
109
+ subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=subrepo, check=True)
110
+
111
+ init_repo(tmp_path)
112
+ monkeypatch.chdir(tmp_path)
113
+ subprocess.run(["git", "commit", "-q", "-m", "init", "--allow-empty"], cwd=tmp_path, check=True)
114
+ subprocess.run(["git", "rm", "-q", "schema.sql"], cwd=subrepo, check=True)
115
+
116
+ result = commit_and_push(
117
+ "feat(x): remove schema.sql",
118
+ ["database/schema.sql"],
119
+ require_message_quality=False,
120
+ require_feature_branch=False,
121
+ subrepos=["database"],
122
+ )
123
+
124
+ assert result.error != "File not found: database/schema.sql — did you typo the path? Run `git status` to see changed files"
@@ -93,3 +93,15 @@ slot = "production"
93
93
  "resource_group": "my-app-rg",
94
94
  "slot": "production",
95
95
  }
96
+
97
+
98
+ def test_resolve_env_allows_missing_slot(tmp_path):
99
+ write_pyproject(
100
+ tmp_path,
101
+ """
102
+ [tool.bdt.envs.prod]
103
+ webapp = "my-app"
104
+ resource_group = "my-app-rg"
105
+ """,
106
+ )
107
+ assert resolve_env("prod", tmp_path) == {"webapp": "my-app", "resource_group": "my-app-rg"}
@@ -13,7 +13,7 @@ wheels = [
13
13
 
14
14
  [[package]]
15
15
  name = "bmsdna-devtools"
16
- version = "0.2.0"
16
+ version = "0.2.1"
17
17
  source = { editable = "." }
18
18
  dependencies = [
19
19
  { name = "requests" },