diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d48b3058..e51ecd9b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,6 +12,16 @@ on: description: "Tag to publish (optional, e.g. v1.2.3)" required: false type: string + workflow_call: + inputs: + target: + description: "Publish target" + type: string + default: testpypi + tag: + description: "Tag to publish (optional, e.g. v1.2.3)" + required: false + type: string permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16d0bdab..77bed132 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,10 @@ jobs: release: if: ${{ github.repository == 'BrkRaw/brkraw' }} runs-on: ubuntu-latest + outputs: + prerelease: ${{ steps.prerelease.outputs.value }} + tag: ${{ steps.tag.outputs.value }} + zenodo_success: ${{ steps.zenodo.outcome == 'success' }} steps: - name: Resolve tag id: tag @@ -127,3 +131,38 @@ jobs: if url: print(f"Created discussion: {url}") PY + - name: Checkout main for Zenodo badge update + uses: actions/checkout@v4 + with: + ref: main + - name: Set up git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Update Zenodo badge + id: zenodo + if: ${{ steps.prerelease.outputs.value != 'true' }} + continue-on-error: true + run: | + for i in $(seq 1 10); do + echo "Zenodo badge update attempt $i/10" + python3 scripts/update_zenodo_badge.py --up-to-date-exit-code 2 + exit_code=$? + if [ "$exit_code" -eq 0 ]; then + echo "Zenodo badge updated." + exit 0 + fi + if [ "$exit_code" -ne 2 ]; then + exit "$exit_code" + fi + sleep 60 + done + echo "Zenodo badge still up to date after 10 attempts; giving up." + + publish: + if: ${{ github.repository == 'BrkRaw/brkraw' && (needs.release.outputs.prerelease == 'true' || needs.release.outputs.zenodo_success == 'true') }} + needs: [release] + uses: ./.github/workflows/publish.yml + with: + target: ${{ needs.release.outputs.prerelease == 'true' && 'testpypi' || 'pypi' }} + tag: ${{ needs.release.outputs.tag }} diff --git a/.gitignore b/.gitignore index a7638b70..1faa9f9a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,5 @@ site/ .direnv/ .envrc .idea/ -.vscode/ *.lock .tmp \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index c1a0cb26..d9e09824 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python" + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.terminal.activateEnvironment": true } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 50bd43c8..31153c26 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -11,9 +11,9 @@ { "label": "Standard: Setup venv + deps", "type": "shell", - "command": "python3 -m venv .venv && . .venv/bin/activate && python -m pip install -U pip && python -m pip install -e \".[dev]\"", + "command": "\"${command:python.interpreterPath}\" -m venv .venv && .venv/bin/python -m pip install -U pip && .venv/bin/python -m pip install -e \".[dev]\"", "windows": { - "command": "py -m venv .venv; .venv\\Scripts\\Activate.ps1; python -m pip install -U pip; python -m pip install -e \".[dev]\"", + "command": "\"${command:python.interpreterPath}\" -m venv .venv; .venv\\Scripts\\python -m pip install -U pip; .venv\\Scripts\\python -m pip install -e \".[dev]\"", "options": { "shell": { "executable": "pwsh" diff --git a/CITATION.cff b/CITATION.cff index fbe89ce0..86558344 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -34,4 +34,4 @@ keywords: - BIDS - neuroimaging -version: "0.5.4" +version: "0.5.5" diff --git a/README.md b/README.md index 2869a0f8..3377d49b 100755 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A modular toolkit for Bruker MRI raw-data handling. -BrkRaw (v0.5.4) converts raw data into standardized, neuroimaging-ready +BrkRaw (v0.5.5) converts raw data into standardized, neuroimaging-ready datasets, with extensible rules/specs and plugin hooks. - Documentation: [brkraw.github.io](https://brkraw.github.io/) @@ -31,7 +31,7 @@ If you use BrkRaw in your research, please cite it. @software{brkraw, author = {Lee, Sung-Ho and Devenyi, Gabriel A. and Ban, Woomi and Shih, Yen-Yu Ian}, title = {BrkRaw: A modular toolkit for Bruker MRI raw-data handling}, - version = {0.5.4}, + version = {0.5.5}, doi = {10.5281/zenodo.3818614}, url = {https://github.com/BrkRaw/brkraw}, note = {Documentation: https://brkraw.github.io}, diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c16aa847..1f4aa9d2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,38 +1,17 @@ -# Release v0.5.4 +# Release v0.5.5 -Date: 2026-01-26 -Changes since 0.5.2 +Date: 2026-01-27 +Changes since 0.5.4 -- chore: prepare release v0.5.4 (7634b95) -- docs: update contributors (7fa7594) -- feat(api): introduce public API layer (d38b088) -- perf: improve loader efficiency and reduce overhead (8998d68) -- prevent update of num_cycle when slice (e0932a7) -- Handle single-cycle datasets (09d37f6) -- update types in loader app (a921da7) -- patch wrapped get_dataobj in loader takes kwargs (4945816) -- perf: introduce lazy scan/image loading and enable partial cycle reads (3185440) -- update docstrings on cache.py (a23b8ae) -- bug: unit of cached files, updated (6d4323c) -- feat: add central cache management (CLI 'cache' command and core API) (5898e03) -- missing kwargs input (59c72fa) -- expose additional types for hook development (e25c52e) -- add opiotion to input version using argument (1b02ab6) -- Make Paravision path detection robust using pdata anchor (691c81b) -- Handle convert hook errors per scan (c4afa3d) -- Improve ci workflow and zenodo badge mapping (efeb725) -- Update Zenodo DOI badge (f6a531a) -- Refine GitHub Actions and release preparation scripts (d4dcde0) -- Normalize __version__ regex to use hex escapes for quotes (061d3c5) -- script: patch issue in finding version from the __init__.py (6ae9ea3) -- fix: parse __version__ regex in release workflow (73745d5) -- fix: send labels as array in gh api (23b5391) -- docs: release notes for v0.5.3 (20dc0a6) -- fix: avoid gh pr graphql in release_pr (6311e09) -- docs: release notes for v0.5.3 (2e427de) -- chore: prepare release v0.5.3 (15e1158) -- docs: update contributors (83d68bf) -- Add debug logging for converter hooks (db8943d) -- refactor: drop flip_x from nifti header (14e5304) -- refactor: route hook kwargs by signature and drop format option (4c3f4d6) -- readme.md biblatex update (b39e11d) +- chore: prepare release v0.5.5 (c3260df) +- docs: update contributors (a7bf3ef) +- update: vscode, auto activate environment setting. (95e6868) +- Chore: wire release → publish workflow (3419d97) +- Use VS Code interpreter for venv setup task (5aed156) +- Update release workflow and scripts for Zenodo badge + bibtex generation (e63e007) +- Patch for brkraw-viewer lazy loading (avoid eager data resolution) (c916625) +- chore(api): config module (2fa90fc) +- chore(api): hook resolver (9c36e95) +- chore(api): minor remapping (f2ef87e) +- chore(api): ensure naming consistency (9010542) +- refactor(loader): improve hook handling logic (5c5a0e7) diff --git a/docs/dev/contributors.md b/docs/dev/contributors.md index 3c2585cb..9f03b985 100644 --- a/docs/dev/contributors.md +++ b/docs/dev/contributors.md @@ -39,4 +39,4 @@ This page is auto-generated from GitHub contributors. [ricardorios46]: https://github.com/RicardoRios46 [ricardorios46-avatar]: https://avatars.githubusercontent.com/u/43252153?s=96 -Last updated: 2026-01-26 +Last updated: 2026-01-27 diff --git a/scripts/cff_to_biblatex.py b/scripts/cff_to_biblatex.py deleted file mode 100644 index b70622ab..00000000 --- a/scripts/cff_to_biblatex.py +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import re -from pathlib import Path -from typing import Any - -import yaml - - -def _norm(s: str) -> str: - return re.sub(r"\s+", " ", (s or "").strip()) - - -def _bib_escape(s: str) -> str: - # Minimal escaping for BibLaTeX - # Keep it conservative to avoid mangling titles. - s = s.replace("\\", "\\\\") - s = s.replace("{", "\\{").replace("}", "\\}") - return s - - -def _author_to_bib(a: dict[str, Any]) -> str: - family = _norm(a.get("family-names", "")) - given = _norm(a.get("given-names", "")) - if family and given: - return f"{family}, {given}" - return family or given or "" - - -def _affiliations_to_note(authors: list[dict[str, Any]]) -> str: - """Create a compact affiliations string for BibLaTeX. - - BibLaTeX does not have a standard `affiliation` field, so we store it in - `addendum` (preferred) or append into `note`. - - Format: - Affiliations: Family, Given: Aff1; Aff2 | Family2, Given2: Aff1; Aff2 - - Only authors that have an `affiliation` value are included. - """ - parts: list[str] = [] - for a in authors or []: - name = _author_to_bib(a) - aff = a.get("affiliation") - if not aff: - continue - if isinstance(aff, str): - affs = [_norm(aff)] if _norm(aff) else [] - elif isinstance(aff, list): - affs = [_norm(str(x)) for x in aff if _norm(str(x))] - else: - # Unknown type - ignore - affs = [] - if not affs: - continue - if name: - parts.append(f"{name}: " + "; ".join(affs)) - else: - parts.append("; ".join(affs)) - - if not parts: - return "" - return "Affiliations: " + " | ".join(parts) - - -def _pick_year(date_released: str) -> str: - m = re.match(r"^\s*(\d{4})", date_released or "") - return m.group(1) if m else "" - - -def _make_key(repo_url: str, year: str) -> str: - # Example: https://github.com/BrkRaw/brkraw -> brkraw_2026 - slug = "" - if repo_url: - parts = repo_url.rstrip("/").split("/") - slug = parts[-1] if parts else "" - slug = re.sub(r"[^0-9A-Za-z]+", "", slug).lower() or "software" - return f"{slug}_{year}" if year else slug - - -def cff_to_biblatex( - cff: dict[str, Any], - citekey: str | None = None, - *, - include_affiliations: bool = False, -) -> str: - title = _norm(cff.get("title", "")) - version = _norm(str(cff.get("version", ""))) - date_released = _norm(str(cff.get("date-released", ""))) - year = _pick_year(date_released) - doi = _norm(str(cff.get("doi", ""))) - url = _norm(str(cff.get("url", ""))) - repo = _norm(str(cff.get("repository-code", ""))) - license_id = _norm(str(cff.get("license", ""))) - - authors = cff.get("authors", []) or [] - author_strs = [a for a in (_author_to_bib(x) for x in authors) if a] - author_field = " and ".join(author_strs) - - affiliations_note = _affiliations_to_note(authors) if include_affiliations else "" - - entry_type = "software" - key = citekey or _make_key(repo or url, year) - - fields: list[tuple[str, str]] = [] - if author_field: - fields.append(("author", author_field)) - if title: - fields.append(("title", title)) - if year: - fields.append(("year", year)) - if version: - fields.append(("version", version)) - if doi: - fields.append(("doi", doi)) - # Prefer repository URL as main url if available - if repo: - fields.append(("url", repo)) - elif url: - fields.append(("url", url)) - # Keep docs url as note if both exist - if repo and url and (url != repo): - fields.append(("note", f"Documentation: {url}")) - if affiliations_note: - # Prefer addendum for extra metadata (BibLaTeX has no standard affiliation field) - fields.append(("addendum", affiliations_note)) - if license_id: - fields.append(("license", license_id)) - if date_released: - # BibLaTeX understands date = YYYY-MM-DD - fields.append(("date", date_released)) - - lines = [f"@{entry_type}{{{key},"] - for k, v in fields: - lines.append(f" {k} = {{{_bib_escape(v)}}},") - # Remove trailing comma on last field if exists - if len(lines) > 1: - lines[-1] = lines[-1].rstrip(",") - lines.append("}") - return "\n".join(lines) + "\n" - - -def main() -> int: - p = argparse.ArgumentParser(description="Convert CITATION.cff to a BibLaTeX @software entry.") - p.add_argument("--infile", default="CITATION.cff", help="Path to CITATION.cff") - p.add_argument("--outfile", default=None, help="Write output to file (optional)") - p.add_argument("--citekey", default=None, help="Override citekey (optional)") - p.add_argument( - "--include-affiliations", - action="store_true", - help="Include author affiliations as `addendum` (may be long).", - ) - args = p.parse_args() - - inpath = Path(args.infile) - if not inpath.exists(): - raise SystemExit(f"Input not found: {inpath}") - - cff = yaml.safe_load(inpath.read_text(encoding="utf-8")) - if not isinstance(cff, dict): - raise SystemExit("Invalid CITATION.cff content (expected mapping).") - - out = cff_to_biblatex( - cff, - citekey=args.citekey, - include_affiliations=bool(getattr(args, "include_affiliations", False)), - ) - - if args.outfile: - Path(args.outfile).write_text(out, encoding="utf-8") - else: - print(out, end="") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/release_notes.py b/scripts/release_notes.py deleted file mode 100644 index 6b997aa7..00000000 --- a/scripts/release_notes.py +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import datetime as dt -import re -import subprocess -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[1] -INIT_PATH = REPO_ROOT / "src" / "brkraw" / "__init__.py" -RELEASE_NOTES_PATH = REPO_ROOT / "RELEASE_NOTES.md" - - -def run_git(args: list[str]) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["git", *args], - cwd=REPO_ROOT, - capture_output=True, - text=True, - check=False, - ) - - -def fetch_tags(remote: str) -> None: - result = run_git(["fetch", "--tags", remote]) - if result.returncode != 0: - stderr = result.stderr.strip() - print(f"Warning: failed to fetch tags from {remote}: {stderr}") - if remote != "origin": - fallback = run_git(["fetch", "--tags", "origin"]) - if fallback.returncode != 0: - fallback_err = fallback.stderr.strip() - print(f"Warning: failed to fetch tags from origin: {fallback_err}") - - -def read_version() -> str: - init_text = INIT_PATH.read_text(encoding="utf-8") - match = re.search( - r"^__version__(?:\s*:\s*[^=]+)?\s*=\s*['\"]([^'\"]+)['\"]", - init_text, - re.M, - ) - if not match: - raise SystemExit("No __version__ found in src/brkraw/__init__.py") - return match.group(1) - - -def generate_release_notes(version: str) -> None: - tag_result = run_git(["describe", "--tags", "--abbrev=0"]) - last_tag = tag_result.stdout.strip() if tag_result.returncode == 0 else "" - log_range = f"{last_tag}..HEAD" if last_tag else "HEAD" - log_result = run_git( - ["log", log_range, "--no-merges", "--pretty=format:- %s (%h)"] - ) - changes = log_result.stdout.strip() - if not changes: - changes = "- (no changes found)" - - date_str = dt.date.today().isoformat() - header = f"# Release v{version}\n\n" - meta = f"Date: {date_str}\n" - scope = f"Changes since {last_tag}\n\n" if last_tag else "Changes\n\n" - RELEASE_NOTES_PATH.write_text(header + meta + scope + changes + "\n", encoding="utf-8") - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Generate RELEASE_NOTES.md without bumping versions." - ) - parser.add_argument( - "--version", - default=None, - help="Override version (default: read from src/brkraw/__init__.py).", - ) - parser.add_argument( - "--fetch-tags", - action="store_true", - help="Fetch tags from remote before generating notes", - ) - parser.add_argument( - "--remote", - default="upstream", - help="Remote name for fetching tags (default: upstream)", - ) - args = parser.parse_args() - - if args.fetch_tags: - fetch_tags(args.remote) - - version = args.version or read_version() - generate_release_notes(version) - print(f"Generated {RELEASE_NOTES_PATH}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/release_pr.py b/scripts/release_pr.py index 2a4387eb..44fab2b5 100644 --- a/scripts/release_pr.py +++ b/scripts/release_pr.py @@ -8,7 +8,7 @@ import subprocess import time from pathlib import Path -from typing import Iterable +from typing import Iterable, Literal REPO_ROOT = Path(__file__).resolve().parents[1] @@ -130,17 +130,19 @@ def commit_if_changed( return True -def gh_pr_number(upstream_repo: str, head_ref: str) -> str | None: +def gh_pr_number(upstream_repo: str, head_ref: str, state: Literal['open', 'closed', 'all']='open') -> str | None: owner, repo = upstream_repo.split("/", 1) result = run_cmd( [ "gh", "api", f"repos/{owner}/{repo}/pulls", + "-X", + "GET", "-f", f"head={head_ref}", "-f", - "state=all", + f"state={state}", "--jq", ".[0].number", ], @@ -228,11 +230,47 @@ def gh_pr_add_label(upstream_repo: str, pr_number: str, label: str, *, dry_run: "-X", "POST", "-f", - f'labels=["{label}"]', + f"labels[]={label}", ] ) +def gh_closed_pr_has_label( + upstream_repo: str, + *, + label: str, + mode: str, +) -> tuple[bool | None, str | None]: + owner, repo = upstream_repo.split("/", 1) + if mode == "closed-release": + jq = ( + '[.[] | select(.title | test("Release v"; "i"))][0]' + ' | (.labels // []) | map(.name) | index("' + label + '")' + ) + else: + jq = '.[0] | (.labels // []) | map(.name) | index("' + label + '")' + result = run_cmd( + [ + "gh", + "api", + f"repos/{owner}/{repo}/pulls", + "-X", + "GET", + "-f", + "state=closed", + "-f", + "per_page=30", + "--jq", + jq, + ], + check=False, + ) + if result.returncode != 0: + err = result.stderr.strip() or result.stdout.strip() + return None, err or None + return result.stdout.strip() != "null", None + + def ensure_pr( *, upstream_repo_full: str, @@ -247,8 +285,14 @@ def ensure_pr( logger.info("[no-pr] PR operations disabled; skipping PR lookup/create.") return None - pr_number = gh_pr_number(upstream_repo_full, head_ref) + pr_number = gh_pr_number(upstream_repo_full, head_ref, state="open") if pr_number: + logger.info( + "Existing PR found for %s (head=%s): #%s", + upstream_repo_full, + head_ref, + pr_number, + ) return pr_number created_pr = gh_pr_create( @@ -261,7 +305,7 @@ def ensure_pr( pr_number = None for attempt in range(5): - pr_number = gh_pr_number(upstream_repo_full, head_ref) + pr_number = gh_pr_number(upstream_repo_full, head_ref, state="open") if pr_number: break if attempt < 4: @@ -269,11 +313,15 @@ def ensure_pr( time.sleep(3) if not pr_number: raise SystemExit("PR created but could not retrieve PR number.") + else: + logger.info("Created PR #%s", pr_number) return pr_number def is_prerelease(version: str) -> bool: - return bool(re.search(r"(a|b|rc)\d*$", version.lower())) + result = bool(re.search(r"(a|b|rc)\d*$", version.lower())) + logger.info("Is prerelease: %s", result) + return result def get_changed_files(base_ref: str) -> list[str]: @@ -286,10 +334,13 @@ def get_changed_files(base_ref: str) -> list[str]: diff_result = run_git(["diff", "--name-only", f"{base_ref}..HEAD"], check=False) if diff_result.returncode != 0: diff_result = run_git(["diff", "--name-only", "HEAD~3..HEAD"], check=True) - return [line.strip() for line in diff_result.stdout.splitlines() if line.strip()] + changed_files = [line.strip() for line in diff_result.stdout.splitlines() if line.strip()] + logger.debug("Changed files: %s", changed_files) + return changed_files def run_release_prep(version: str, remote: str) -> None: + logger.debug("> Running release_prep.py") run_cmd( [ str(Path(__file__).resolve().parent / ".." / ".venv" / "bin" / "python"), @@ -304,6 +355,7 @@ def run_release_prep(version: str, remote: str) -> None: def run_update_readme_bibtex() -> None: + logger.debug("> Running update_readme_bibtex.py") run_cmd( [ str(Path(__file__).resolve().parent / ".." / ".venv" / "bin" / "python"), @@ -313,6 +365,7 @@ def run_update_readme_bibtex() -> None: def run_update_contributors(repo: str) -> None: + logger.debug("> Running update_contributors.py") run_cmd( [ str(Path(__file__).resolve().parent / ".." / ".venv" / "bin" / "python"), @@ -380,6 +433,15 @@ def main() -> int: action="store_true", help="Do not create or update a GitHub PR (commits/push still run).", ) + parser.add_argument( + "--label-check", + choices=["closed-latest", "closed-release"], + default=None, + help=( + "In --dry-run mode, check labels on a closed PR " + "(latest or latest release PR)." + ), + ) parser.add_argument( "--dry-run", action="store_true", @@ -451,6 +513,11 @@ def main() -> int: dry_run=args.dry_run, ) + # push early (unless dry-run) + # Rationale: ensure commits are on the PR branch even if later GitHub API steps fail. + if not args.dry_run: + run_git(["push", args.remote_origin, f"HEAD:{branch}"], check=True) + # PR body update + label (if enabled) if pr_number and (not args.no_pr): changed_files = get_changed_files(args.base) @@ -460,13 +527,27 @@ def main() -> int: if (not is_prerelease(args.version)) and (not args.dry_run): gh_pr_add_label(upstream_repo_full, pr_number, "release", dry_run=args.dry_run) - - # push (unless dry-run) + elif args.dry_run and args.label_check and (not is_prerelease(args.version)): + has_label, label_err = gh_closed_pr_has_label( + upstream_repo_full, + label="release", + mode=args.label_check, + ) + if has_label is None: + if label_err: + logger.warning("Label check failed (gh api error): %s", label_err) + else: + logger.warning("Label check failed (gh api error).") + elif has_label: + logger.info("Label check passed: closed PR contains label 'release'.") + else: + logger.warning("Label check: closed PR does not contain label 'release'.") + + # push (dry-run message only) if args.dry_run: logger.info("[dry-run] Would push branch to %s: %s", args.remote_origin, branch) return 0 - run_git(["push", args.remote_origin, f"HEAD:{branch}"], check=True) return 0 diff --git a/scripts/update_readme_bibtex.py b/scripts/update_readme_bibtex.py index c071c8b7..c480ccfa 100644 --- a/scripts/update_readme_bibtex.py +++ b/scripts/update_readme_bibtex.py @@ -2,37 +2,145 @@ from __future__ import annotations import re -import subprocess from pathlib import Path +from typing import Any + +import yaml REPO_ROOT = Path(__file__).resolve().parents[1] README_PATH = REPO_ROOT / "README.md" CITATION_PATH = REPO_ROOT / "CITATION.cff" -CFF_TO_BIBLATEX = REPO_ROOT / "scripts" / "cff_to_biblatex.py" - BEGIN = "" END = "" - -def run(args: list[str]) -> str: - p = subprocess.run(args, cwd=REPO_ROOT, text=True, capture_output=True) - if p.returncode != 0: - msg = p.stderr.strip() or p.stdout.strip() - raise SystemExit(f"{args[0]} failed: {msg}") - return p.stdout +def _norm(s: str) -> str: + return re.sub(r"\s+", " ", (s or "").strip()) + + +def _bib_escape(s: str) -> str: + # Minimal escaping for BibLaTeX + # Keep it conservative to avoid mangling titles. + s = s.replace("\\", "\\\\") + s = s.replace("{", "\\{").replace("}", "\\}") + return s + + +def _author_to_bib(a: dict[str, Any]) -> str: + family = _norm(a.get("family-names", "")) + given = _norm(a.get("given-names", "")) + if family and given: + return f"{family}, {given}" + return family or given or "" + + +def _affiliations_to_note(authors: list[dict[str, Any]]) -> str: + """Create a compact affiliations string for BibLaTeX.""" + parts: list[str] = [] + for a in authors or []: + name = _author_to_bib(a) + aff = a.get("affiliation") + if not aff: + continue + if isinstance(aff, str): + affs = [_norm(aff)] if _norm(aff) else [] + elif isinstance(aff, list): + affs = [_norm(str(x)) for x in aff if _norm(str(x))] + else: + affs = [] + if not affs: + continue + if name: + parts.append(f"{name}: " + "; ".join(affs)) + else: + parts.append("; ".join(affs)) + + if not parts: + return "" + return "Affiliations: " + " | ".join(parts) + + +def _pick_year(date_released: str) -> str: + m = re.match(r"^\s*(\d{4})", date_released or "") + return m.group(1) if m else "" + + +def _make_key(repo_url: str, year: str) -> str: + slug = "" + if repo_url: + parts = repo_url.rstrip("/").split("/") + slug = parts[-1] if parts else "" + slug = re.sub(r"[^0-9A-Za-z]+", "", slug).lower() or "software" + return f"{slug}_{year}" if year else slug + + +def cff_to_biblatex( + cff: dict[str, Any], + citekey: str | None = None, + *, + include_affiliations: bool = False, +) -> str: + title = _norm(cff.get("title", "")) + version = _norm(str(cff.get("version", ""))) + date_released = _norm(str(cff.get("date-released", ""))) + year = _pick_year(date_released) + doi = _norm(str(cff.get("doi", ""))) + url = _norm(str(cff.get("url", ""))) + repo = _norm(str(cff.get("repository-code", ""))) + license_id = _norm(str(cff.get("license", ""))) + + authors = cff.get("authors", []) or [] + author_strs = [a for a in (_author_to_bib(x) for x in authors) if a] + author_field = " and ".join(author_strs) + + affiliations_note = _affiliations_to_note(authors) if include_affiliations else "" + + entry_type = "software" + key = citekey or _make_key(repo or url, year) + + fields: list[tuple[str, str]] = [] + if author_field: + fields.append(("author", author_field)) + if title: + fields.append(("title", title)) + if year: + fields.append(("year", year)) + if version: + fields.append(("version", version)) + if doi: + fields.append(("doi", doi)) + if repo: + fields.append(("url", repo)) + elif url: + fields.append(("url", url)) + if repo and url and (url != repo): + fields.append(("note", f"Documentation: {url}")) + if affiliations_note: + fields.append(("addendum", affiliations_note)) + if license_id: + fields.append(("license", license_id)) + if date_released: + fields.append(("date", date_released)) + + lines = [f"@{entry_type}{{{key},"] + for k, v in fields: + lines.append(f" {k} = {{{_bib_escape(v)}}},") + if len(lines) > 1: + lines[-1] = lines[-1].rstrip(",") + lines.append("}") + return "\n".join(lines) + "\n" def render_biblatex() -> str: if not CITATION_PATH.exists(): raise SystemExit("CITATION.cff not found.") - if not CFF_TO_BIBLATEX.exists(): - raise SystemExit(f"Missing script: {CFF_TO_BIBLATEX}") - out = run(["python", str(CFF_TO_BIBLATEX), "--infile", str(CITATION_PATH)]) - out = out.strip() - if not out: + cff = yaml.safe_load(CITATION_PATH.read_text(encoding="utf-8")) + if not isinstance(cff, dict): + raise SystemExit("Invalid CITATION.cff content (expected mapping).") + out = cff_to_biblatex(cff) + if not out.strip(): raise SystemExit("BibLaTeX output is empty.") - return out + "\n" + return out def update_readme_block(biblatex: str) -> None: diff --git a/scripts/update_zenodo_badge.py b/scripts/update_zenodo_badge.py index cd99faf1..3de205a2 100644 --- a/scripts/update_zenodo_badge.py +++ b/scripts/update_zenodo_badge.py @@ -2,12 +2,18 @@ from __future__ import annotations +import argparse +import json +import re import subprocess import sys +import urllib.parse import urllib.request from pathlib import Path -ZENODO_BADGE_URL = "https://zenodo.org/badge/DOI/10.5281/zenodo.18329489.svg" +CONCEPT_DOI = "10.5281/zenodo.3818614" +ZENODO_BADGE_URL = "https://zenodo.org/badge/DOI/{doi}.svg" +ZENODO_RECORDS_API = "https://zenodo.org/api/records" REPO_ROOT = Path(__file__).resolve().parents[1] OUTPUT_PATH = REPO_ROOT / "docs" / "assets" / "zenodo_badge.svg" @@ -26,11 +32,61 @@ def has_remote(name: str) -> bool: return name in remotes +def fetch_latest_doi(concept_doi: str) -> str: + query = urllib.parse.urlencode( + { + "q": f'conceptdoi:"{concept_doi}"', + "sort": "mostrecent", + "size": 1, + } + ) + url = f"{ZENODO_RECORDS_API}?{query}" + with urllib.request.urlopen(url) as r: + payload = json.loads(r.read().decode("utf-8")) + hits = payload.get("hits", {}).get("hits", []) + if not hits: + raise RuntimeError(f"No Zenodo records found for concept DOI {concept_doi}") + doi = hits[0].get("doi") + if not doi: + raise RuntimeError(f"Zenodo record missing DOI for concept DOI {concept_doi}") + return doi + + +def extract_doi_from_badge(svg_bytes: bytes) -> str | None: + text = svg_bytes.decode("utf-8", errors="ignore") + match = re.search(r"10\.\d{4,9}/[^\s\"<>]+", text) + if not match: + return None + return match.group(0) + + def main() -> int: + parser = argparse.ArgumentParser( + description="Update Zenodo badge to latest version DOI." + ) + parser.add_argument( + "--up-to-date-exit-code", + type=int, + default=0, + help="Exit code to use when the badge is already up to date (default: 0).", + ) + args = parser.parse_args() + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) - print(f"Downloading Zenodo badge:\n {ZENODO_BADGE_URL}") - with urllib.request.urlopen(ZENODO_BADGE_URL) as r: + latest_doi = fetch_latest_doi(CONCEPT_DOI) + current_doi = None + if OUTPUT_PATH.exists(): + current_doi = extract_doi_from_badge(OUTPUT_PATH.read_bytes()) + badge_url = ZENODO_BADGE_URL.format(doi=latest_doi) + if current_doi: + print(f"Current badge DOI:\n {current_doi}") + print(f"Resolved concept DOI:\n {CONCEPT_DOI}\nLatest DOI:\n {latest_doi}") + if current_doi == latest_doi: + print("Badge already up to date. Skipping download.") + return args.up_to_date_exit_code + print(f"Downloading Zenodo badge:\n {badge_url}") + with urllib.request.urlopen(badge_url) as r: OUTPUT_PATH.write_bytes(r.read()) print(f"Saved:\n {OUTPUT_PATH}") diff --git a/scripts/verify_release_version.py b/scripts/verify_release_version.py deleted file mode 100644 index 101f2c14..00000000 --- a/scripts/verify_release_version.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import os -import re -from pathlib import Path - -try: - import tomllib -except ModuleNotFoundError: # pragma: no cover - for Python < 3.11 - import tomli as tomllib - -from packaging.version import parse - - -def read_version_from_pyproject(pyproject_path: Path) -> str: - pyproject = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) - version = pyproject.get("project", {}).get("version") - if version: - return version - - version_path = ( - pyproject.get("tool", {}).get("hatch", {}).get("version", {}).get("path") - ) - if not version_path: - raise SystemExit("No version in pyproject.toml and no hatch version path found.") - - text = Path(version_path).read_text(encoding="utf-8") - match = re.search(r"""__version__\s*=\s*["']([^"']+)["']""", text) - if not match: - raise SystemExit(f"No __version__ found in {version_path}") - return match.group(1) - - -def normalize_tag(tag: str) -> str: - tag = tag.strip() - if tag.startswith("refs/tags/"): - tag = tag.removeprefix("refs/tags/") - # Common convention: v1.2.3 - if tag.startswith("v") and len(tag) > 1 and tag[1].isdigit(): - tag = tag[1:] - return tag - - -def resolve_tag(cli_tag: str | None) -> str: - if cli_tag: - return cli_tag - - env_tag = os.environ.get("TAG") - if env_tag: - return env_tag - - ref_name = os.environ.get("GITHUB_REF_NAME") - if ref_name: - return ref_name - - ref = os.environ.get("GITHUB_REF") - if ref: - return ref # will be normalized later (refs/tags/...) - - raise SystemExit( - "No tag provided. Pass --tag , or set TAG env var, or run under GitHub Actions." - ) - - -def main() -> int: - ap = argparse.ArgumentParser( - description="Verify that the git tag matches the package version." - ) - ap.add_argument( - "--tag", - help="Release tag to verify (e.g. 1.2.3 or v1.2.3). If omitted, uses TAG/GITHUB_REF_NAME/GITHUB_REF.", - default=None, - ) - args = ap.parse_args() - - raw_tag = resolve_tag(args.tag) - tag = normalize_tag(raw_tag) - print(f"Target Tag: {raw_tag} (normalized: {tag})") - - version = read_version_from_pyproject(Path("pyproject.toml")) - print(f"Detected Package Version: {version}") - - if parse(tag) != parse(version): - raise SystemExit(f"Tag {raw_tag} does not match package version {version}.") - - print("Version check passed!") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/brkraw/__init__.py b/src/brkraw/__init__.py index 831ff73c..2cc48432 100644 --- a/src/brkraw/__init__.py +++ b/src/brkraw/__init__.py @@ -1,6 +1,6 @@ from __future__ import annotations -__version__ = '0.5.4' +__version__ = '0.5.5' from .apps.loader import BrukerLoader diff --git a/src/brkraw/api/__init__.py b/src/brkraw/api/__init__.py index a34f8987..8f0eb8f9 100644 --- a/src/brkraw/api/__init__.py +++ b/src/brkraw/api/__init__.py @@ -18,20 +18,21 @@ "loader", "hook", "hook_manager", + "hook_resolver", "pruner", "rules", "addon", "addon_manager", "validate_meta", "transform", - "scan_resolver", - "study_resolver", + "info_resolver", "affine_resolver", "shape_resolver", "image_resolver", "fid_resolver", "nifti_resolver", "types", + "config", ] # Lazy import map: name -> (module_path, attribute_name or None) @@ -45,10 +46,11 @@ "loader": ("brkraw.apps", "loader"), "hook_manager": ("brkraw.apps", "hook"), "addon_manager": ("brkraw.apps", "addon"), + "hook_resolver": ("brkraw.apps.loader.helper", "resolve_converter_hook"), + "config": ("brkraw.core", "config"), # apps.loader.info resolvers - "scan_resolver": ("brkraw.apps.loader.info", "scan"), - "study_resolver": ("brkraw.apps.loader.info", "study"), + "info_resolver": ("brkraw.apps.loader", "info"), "transform": ("brkraw.apps.loader.info", "transform"), # resolvers @@ -101,9 +103,12 @@ def __dir__() -> list[str]: # Importing these only for type-checking keeps runtime imports lazy. if TYPE_CHECKING: from brkraw.core import formatter as formatter + from brkraw.core import config as config from brkraw.apps.loader import BrukerLoader as BrukerLoader + from brkraw.apps.loader import info as info_resolver + from brkraw.apps.loader.info import transform as transform + from brkraw.apps.loader.helper import resolve_converter_hook as hook_resolver from brkraw.apps import addon as addon_manager, hook as hook_manager, loader as loader - from brkraw.apps.loader.info import scan as scan_resolver, study as study_resolver, transform as transform from brkraw.resolver import ( affine as affine_resolver, fid as fid_resolver, diff --git a/src/brkraw/apps/loader/__init__.py b/src/brkraw/apps/loader/__init__.py index 44206af9..a62ad0e7 100644 --- a/src/brkraw/apps/loader/__init__.py +++ b/src/brkraw/apps/loader/__init__.py @@ -1,10 +1,7 @@ -"""BrkRaw loader package entrypoint. - -Last updated: 2025-12-30 -""" from __future__ import annotations - from .core import BrukerLoader -__all__ = ["BrukerLoader"] +__all__ = [ + "BrukerLoader" +] diff --git a/src/brkraw/apps/loader/core.py b/src/brkraw/apps/loader/core.py index 22bb4f43..030df286 100644 --- a/src/brkraw/apps/loader/core.py +++ b/src/brkraw/apps/loader/core.py @@ -15,17 +15,16 @@ from typing import ( TYPE_CHECKING, cast, Optional, Union, - Any, Callable, Iterable, + Any, Iterable, Tuple, List, Mapping, Dict, Literal, ) from pathlib import Path from ...core import config as config_core from ...core.config import resolve_root -from ...specs import hook as converter_core from ...specs.pruner import prune_dataset_to_zip from ...specs.rules import load_rules, select_rule_use -from ...dataclasses import Scan, Study, LazyScan +from ...dataclasses import Study from .types import ( StudyLoader, ScanLoader, @@ -90,6 +89,7 @@ def __init__(self, if affine_decimals is None: affine_decimals = config_core.float_decimals(root=resolve_root(None)) self._base = resolve_root(None) + self._scans = {} self._affine_decimals = affine_decimals self._sw_version: Optional[str] = self._parse_sw_version() self._hook_disabled = disable_hook @@ -160,13 +160,6 @@ def _parse_visu(text: str) -> Optional[str]: except Exception: return None - def _load_rules(self): - try: - rules = load_rules(root=self._base, validate=False) - except Exception: - rules = {} - return rules - def _attach_helpers(self): """Resolve per-scan metadata and bind helper methods.""" logger.debug("Attaching helpers to study %s", getattr(self._study.fs, "root", "?")) @@ -183,11 +176,38 @@ def _attach_helpers(self): self.reset_converter(scan) scan.get_metadata = MethodType(_get_metadata, scan) scan.search_params = MethodType(_search_parameters, scan) + scan._hook_resolved = False + for reco in scan.avail.values(): reco = cast(RecoLoader, reco) reco.search_params = MethodType(_search_parameters, reco) - + def _prep_scan(self, scan_id: int, reco_id: Optional[int] = None, **kwargs: Any) -> ScanLoader: + scan = self.get_scan(scan_id) + + enable_hook = kwargs.get("enable_hook") # force enable + if enable_hook is not None: + del kwargs["enable_hook"] + else: + enable_hook = False + + hook_is_enabled = enable_hook or not self._hook_disabled + + if hook_is_enabled: + if enable_hook: + logger.debug("hook enabled by optional argument for get_dataobj()") + if scan._hook_resolved is False: # prevent multiple execution + _resolve_converter_hook(scan, self._base, affine_decimals=self._affine_decimals) + + logger.debug( + "scan=%s reco=%s hook_enabled=%s hook=%s", + scan_id, + reco_id, + hook_is_enabled, + getattr(scan, "_converter_hook_name", None), + ) + return scan + def search_params(self, key: str, *, file: Optional[Union[str, List[str]]] = None, @@ -207,26 +227,6 @@ def search_params(self, key: str, self._study = cast(StudyLoader, self._study) return self._study.search_params(key, file=file, scan_id=scan_id, reco_id=reco_id) - def override_converter( - self, - scan_id: int, - converter_hook: Mapping[str, Callable[..., Any]], - ) -> None: - """Override scan conversion methods with a converter hook. - - Args: - scan_id: Scan identifier. - converter_hook: Mapping of method names to callables. Only - provided keys are overridden. - """ - scan = self.avail[scan_id] - scan = cast(ScanLoader, scan) - _apply_converter_hook( - scan, - converter_hook, - affine_decimals=self._affine_decimals, - ) - def reset_converter(self, scan: ScanLoader) -> None: """Restore default conversion methods for a scan. @@ -300,7 +300,7 @@ def get_dataobj( Returns: Single ndarray when one slice pack exists; otherwise a tuple. """ - scan = self.get_scan(scan_id) + scan = self._prep_scan(scan_id, reco_id, **kwargs) return scan.get_dataobj(reco_id, **kwargs) def get_affine( @@ -327,7 +327,7 @@ def get_affine( Returns: Single affine matrix when one slice pack exists; otherwise a tuple. """ - scan = self.get_scan(scan_id) + scan = self._prep_scan(scan_id, reco_id, **kwargs) decimals = decimals or self._affine_decimals return scan.get_affine(reco_id, space=space, @@ -393,24 +393,11 @@ def convert( override_subject_type: Optional[SubjectType] = None, override_subject_pose: Optional[SubjectPose] = None, flatten_fg: bool = False, - xyz_units: XYZUNIT = "mm", - t_units: TUNIT = "sec", hook_args_by_name: Optional[Mapping[str, Mapping[str, Any]]] = None, **kwargs: Any, ) -> ConvertedObj: """Convert a scan/reco to output object(s) supporting to_filename().""" - scan = self.get_scan(scan_id) - if not self._hook_disabled: - _resolve_converter_hook(scan, self._base, affine_decimals=self._affine_decimals) - logger.debug( - "convert() scan=%s reco=%s type=%s is_lazy=%s materialized=%s hook=%s", - scan_id, - reco_id, - type(scan).__name__, - isinstance(scan, LazyScan), - hasattr(scan, "_scan"), - getattr(scan, "_converter_hook_name", None), - ) + scan = self._prep_scan(scan_id, reco_id, **kwargs) return scan.convert( reco_id, space=space, @@ -418,8 +405,6 @@ def convert( override_subject_type=override_subject_type, override_subject_pose=override_subject_pose, flatten_fg=flatten_fg, - xyz_units=xyz_units, - t_units=t_units, hook_args_by_name=hook_args_by_name, **kwargs, ) @@ -489,9 +474,11 @@ def prune_to_zip( return BrukerLoader(out_path, affine_decimals=self._affine_decimals) @property - def avail(self) -> Mapping[int, Union["Scan", "LazyScan", "ScanLoader"]]: + def avail(self) -> Mapping[int, "ScanLoader"]: """Available scans keyed by scan id.""" - return self._study.avail + if len(self._scans) != len(self._study.avail): + self._scans = {scan_id: cast(ScanLoader, scan.materialize()) for scan_id, scan in self._study.avail.items()} + return self._scans @property def subject(self) -> Optional[Dict[str, Any]]: diff --git a/src/brkraw/apps/loader/helper.py b/src/brkraw/apps/loader/helper.py index 1218b371..bdc0b348 100644 --- a/src/brkraw/apps/loader/helper.py +++ b/src/brkraw/apps/loader/helper.py @@ -3,7 +3,17 @@ from types import MethodType from functools import partial import inspect -from typing import TYPE_CHECKING, Optional, Tuple, Union, Any, Mapping, cast, List, Dict, Literal +from typing import ( + TYPE_CHECKING, + cast, + Optional, + Tuple, + Union, + Any, + Mapping, + List, + Dict +) from pathlib import Path from warnings import warn import logging @@ -16,7 +26,7 @@ from ...core.parameters import Parameters from ...specs.remapper import load_spec, map_parameters, load_context_map, apply_context_map from ...specs.rules import load_rules, select_rule_use -from ...dataclasses import Reco, Scan, Study, LazyScan +from ...dataclasses import Reco, Scan, Study from ...specs import hook as converter_core from ...resolver import affine as affine_resolver from ...resolver import image as image_resolver @@ -25,7 +35,6 @@ from ...resolver.helpers import get_file from .types import ( ScanLoader, - ToFilename, ConvertType, GetDataobjType, GetAffineType @@ -49,6 +58,7 @@ __all__ = [ "resolve_reco_id", "resolve_data_and_affine", + "resolve_converter_hook", "search_parameters", "get_dataobj", "get_affine", @@ -205,6 +215,7 @@ def resolve_converter_hook( logger.debug("Converter hook %r resolved to no entry.", hook_name) else: logger.debug("No converter hook selected for scan %s.", getattr(scan, "scan_id", "?")) + scan._hook_resolved = True def search_parameters( @@ -414,7 +425,7 @@ def get_dataobj( cycle_count = None cycle_args_requested = False - if image_info.get("dataobj") is None: + if cycle_args_requested or image_info.get("dataobj") is None: image_info = image_resolver.resolve( self, resolved_reco_id, diff --git a/src/brkraw/apps/loader/types.py b/src/brkraw/apps/loader/types.py index a93617d2..eb481388 100644 --- a/src/brkraw/apps/loader/types.py +++ b/src/brkraw/apps/loader/types.py @@ -115,6 +115,7 @@ class ScanLoader(Scan, BaseLoader): converter_func: Optional[ConvertType] _converter_hook: Optional[ConverterHook] _converter_hook_name: Optional[str] + _hook_resolved: bool = False def get_fid(self, diff --git a/src/brkraw/core/config.py b/src/brkraw/core/config.py index f823aa1a..7d73eb4a 100644 --- a/src/brkraw/core/config.py +++ b/src/brkraw/core/config.py @@ -281,7 +281,7 @@ def configure_logging( if level == logging.INFO: fmt = "%(message)s" else: - fmt = "%(asctime)s(%(levelname).1s): [%(name)s] - %(message)s" + fmt = "%(asctime)s(%(levelname).1s): %(name)s:%(funcName)s - %(message)s" logging.basicConfig(level=level, format=fmt, stream=stream) return logging.getLogger(name)