Skip to content
Merged
2 changes: 1 addition & 1 deletion .copier-answers.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Changes here will be overwritten by Copier
_commit: v0.0.31
_commit: v0.0.32
_src_path: gh:LabAutomationAndScreening/copier-base-template.git
description: Copier template for creating Python libraries and executables
python_ci_versions:
Expand Down
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,5 @@
"initializeCommand": "sh .devcontainer/initialize-command.sh",
"onCreateCommand": "sh .devcontainer/on-create-command.sh",
"postStartCommand": "sh .devcontainer/post-start-command.sh"
// Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): bbebc7c9
// Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 2308a2ae # spellchecker:disable-line
}
19 changes: 14 additions & 5 deletions .github/workflows/hash_git_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
" // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): "
)

DEVCONTAINER_COMMENT_LINE_SUFFIX = (
" # spellchecker:disable-line" # the typos hook can sometimes mess with the hash without this
)


def get_tracked_files(repo_path: Path) -> list[str]:
"""Return a list of files tracked by Git in the given repository folder, using the 'git ls-files' command."""
Expand Down Expand Up @@ -76,9 +80,13 @@ def find_devcontainer_hash_line(lines: list[str]) -> tuple[int, str | None]:
for i in range(len(lines) - 1, -1, -1):
if lines[i].strip() == "}":
# Check the line above it
if i > 0 and lines[i - 1].startswith(DEVCONTAINER_COMMENT_LINE_PREFIX):
current_hash = lines[i - 1].split(": ", 1)[1].strip()
return i - 1, current_hash
if i > 0:
above_line = lines[i - 1]
if above_line.startswith(DEVCONTAINER_COMMENT_LINE_PREFIX):
part_after_prefix = above_line.split(": ", 1)[1]
part_before_suffix = part_after_prefix.split("#")[0]
current_hash = part_before_suffix.strip()
return i - 1, current_hash
return i, None
return -1, None

Expand All @@ -102,12 +110,13 @@ def update_devcontainer_context_hash(devcontainer_json_file: Path, new_hash: str
lines = file.readlines()

line_index, current_hash = find_devcontainer_hash_line(lines)
new_hash_line = f"{DEVCONTAINER_COMMENT_LINE_PREFIX}{new_hash}{DEVCONTAINER_COMMENT_LINE_SUFFIX}\n"
if current_hash is not None:
# Replace the old hash with the new hash
lines[line_index] = f"{DEVCONTAINER_COMMENT_LINE_PREFIX}{new_hash}\n"
lines[line_index] = new_hash_line
else:
# Insert the new hash line above the closing `}`
lines.insert(line_index, f"{DEVCONTAINER_COMMENT_LINE_PREFIX}{new_hash}\n")
lines.insert(line_index, new_hash_line)

# Write the updated lines back to the file
with devcontainer_json_file.open("w", encoding="utf-8") as file:
Expand Down
6 changes: 6 additions & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
[default]
extend-ignore-re = [
# Line ignore with trailing: # spellchecker:disable-line
"(?Rm)^.*(#|//)\\s*spellchecker:disable-line$"
]

[default.extend-words]
# Words managed by the base template
# `astroid` is the name of a python library, and it is used in pylint configuration. should not be corrected to asteroid
Expand Down
2 changes: 2 additions & 0 deletions extensions/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ def hook(self, context: dict[Any, Any]) -> dict[Any, Any]:
context["gha_setup_node"] = "v4.3.0"
context["gha_action_gh_release"] = "v2.2.1"
context["gha_mutex"] = "1ebad517141198e08d47cf72f3c0975316620a65 # v1.0.0-alpha.10"
context["gha_pypi_publish"] = "v1.12.4"
context["gha_sleep"] = "v2.0.3"
context["gha_linux_runner"] = "ubuntu-24.04"
context["gha_windows_runner"] = "windows-2025"

Expand Down
95 changes: 95 additions & 0 deletions src/git_tag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import argparse
import subprocess
import tomllib
from pathlib import Path


def extract_version(toml_path: Path | str) -> str:
"""Load toml_path and return the version string.

Checks [project].version (PEP 621) first, then [tool.poetry].version. Raises KeyError if no version field is found.
"""
path = Path(toml_path)
with path.open("rb") as f:
data = tomllib.load(f)

project = data.get("project", {})
if version := project.get("version"):
return version

tool = data.get("tool", {})
poetry = tool.get("poetry", {})
if version := poetry.get("version"):
return version

raise KeyError(f"No version field found in {path!r}") # noqa: TRY003 # not worth a custom exception


def ensure_tag_not_present(tag: str, remote: str) -> None:
try:
_ = subprocess.run( # noqa: S603 # this is trusted input, it's our own arguments being passed in
["git", "ls-remote", "--exit-code", "--tags", remote, f"refs/tags/{tag}"], # noqa: S607 # if `git` isn't in PATH already, then there are bigger problems to solve
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
raise Exception(f"Error: tag '{tag}' exists on remote '{remote}'") # noqa: TRY002,TRY003 # not worth a custom exception
Copy link

Copilot AI May 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider using a custom exception for tag collision errors instead of a generic Exception to improve error clarity and allow for more specific handling.

Suggested change
raise Exception(f"Error: tag '{tag}' exists on remote '{remote}'") # noqa: TRY002,TRY003 # not worth a custom exception
raise TagCollisionError(f"Error: tag '{tag}' exists on remote '{remote}'") # noqa: TRY002,TRY003

Copilot uses AI. Check for mistakes.
except subprocess.CalledProcessError:
# tag not present, continue
return


def main():
parser = argparse.ArgumentParser(
description=(
"Extract the version from a pyproject.toml file, "
"confirm that git tag v<version> is not present, or "
"create and push the tag to a remote."
)
)
_ = parser.add_argument(
"file",
nargs="?",
default="pyproject.toml",
help="Path to pyproject.toml (default: pyproject.toml)",
)
_ = parser.add_argument(
"--confirm-tag-not-present",
action="store_true",
help=("Check that git tag v<version> is NOT present on the remote. If the tag exists, exit with an error."),
)
_ = parser.add_argument(
"--push-tag-to-remote",
action="store_true",
help=(
"Create git tag v<version> locally and push it to the remote. "
"Internally confirms the tag is not already present."
),
)
_ = parser.add_argument(
"--remote",
default="origin",
help="Name of git remote to query/push (default: origin)",
)
args = parser.parse_args()

ver = extract_version(args.file)

tag = f"v{ver}"

if args.push_tag_to_remote:
ensure_tag_not_present(tag, args.remote)
_ = subprocess.run(["git", "tag", tag], check=True) # noqa: S603,S607 # this is trusted input, it's our own pyproject.toml file. and if `git` isn't in PATH, then there are larger problems anyway
_ = subprocess.run(["git", "push", args.remote, tag], check=True) # noqa: S603,S607 # this is trusted input, it's our own pyproject.toml file. and if `git` isn't in PATH, then there are larger problems anyway
return

if args.confirm_tag_not_present:
ensure_tag_not_present(tag, args.remote)
return

# Default behavior: just print the version
print(ver) # noqa: T201 # specifically printing this out so CI pipelines can read the value from stdout


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions template/.github/workflows/git_tag.py
19 changes: 14 additions & 5 deletions template/.github/workflows/hash_git_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
" // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): "
)

DEVCONTAINER_COMMENT_LINE_SUFFIX = (
" # spellchecker:disable-line" # the typos hook can sometimes mess with the hash without this
)


def get_tracked_files(repo_path: Path) -> list[str]:
"""Return a list of files tracked by Git in the given repository folder, using the 'git ls-files' command."""
Expand Down Expand Up @@ -76,9 +80,13 @@ def find_devcontainer_hash_line(lines: list[str]) -> tuple[int, str | None]:
for i in range(len(lines) - 1, -1, -1):
if lines[i].strip() == "}":
# Check the line above it
if i > 0 and lines[i - 1].startswith(DEVCONTAINER_COMMENT_LINE_PREFIX):
current_hash = lines[i - 1].split(": ", 1)[1].strip()
return i - 1, current_hash
if i > 0:
above_line = lines[i - 1]
if above_line.startswith(DEVCONTAINER_COMMENT_LINE_PREFIX):
part_after_prefix = above_line.split(": ", 1)[1]
part_before_suffix = part_after_prefix.split("#")[0]
current_hash = part_before_suffix.strip()
return i - 1, current_hash
return i, None
return -1, None

Expand All @@ -102,12 +110,13 @@ def update_devcontainer_context_hash(devcontainer_json_file: Path, new_hash: str
lines = file.readlines()

line_index, current_hash = find_devcontainer_hash_line(lines)
new_hash_line = f"{DEVCONTAINER_COMMENT_LINE_PREFIX}{new_hash}{DEVCONTAINER_COMMENT_LINE_SUFFIX}\n"
if current_hash is not None:
# Replace the old hash with the new hash
lines[line_index] = f"{DEVCONTAINER_COMMENT_LINE_PREFIX}{new_hash}\n"
lines[line_index] = new_hash_line
else:
# Insert the new hash line above the closing `}`
lines.insert(line_index, f"{DEVCONTAINER_COMMENT_LINE_PREFIX}{new_hash}\n")
lines.insert(line_index, new_hash_line)

# Write the updated lines back to the file
with devcontainer_json_file.open("w", encoding="utf-8") as file:
Expand Down
Loading