ZMAPI / ZMNET Package Updater
Interactive NuGet package updater for ZMAPI/ZMNET packages across all projects
Metadata
- Author: ropean, Claude Sonnet (Anthropic)
- Version: 1.0.0
- Dependencies: dotnet CLI, NuGet source "github"
Code
python
#!/usr/bin/env python3
"""
@title ZMAPI / ZMNET Package Updater
@description Interactive NuGet package updater for ZMAPI/ZMNET packages across all projects
@author ropean, Claude Sonnet (Anthropic)
@version 1.0.0
Cross-platform replacement for Update-Packages.ps1.
Workflow:
1. Presents a menu listing each package individually, plus an "Update All" option.
2. If a single package is selected, shows the 5 most recent versions to choose from.
3. If "Update All" is selected, each package is updated to its latest version.
4. Updates all packages.config files and .csproj HintPath / Version= references.
5. Downloads the .nupkg and extracts it to the packages/ directory.
6. Copies content files from the package's content/ directory to the web project.
7. Ensures all copied content files are listed as <Content Include> in the .csproj.
Prerequisites:
- dotnet CLI must be installed and on PATH (run dotnet-setup.py --install).
- A NuGet source named "github" must be configured (run dotnet-registry.py --nuget).
Exit codes:
0 - all updates succeeded (or nothing to do)
1 - one or more updates failed
@example
Usage example:
python dotnet-update-packages.py # interactive menu
python dotnet-update-packages.py --all # update all to latest
python dotnet-update-packages.py --package ZMAPI-Windows-X64.Resource
python dotnet-update-packages.py --package ZMAPI-Windows-X64.Resource --version 4.91.2604.6124
python dotnet-update-packages.py --dry-run # preview without changes
@requires dotnet CLI, NuGet source "github"
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import NamedTuple
# ── Constants ──────────────────────────────────────────────────────────────────
TARGET_PACKAGES = [
"ZMAPI-Windows-X64.Resource",
"ZMAPI-Windows-X64.SaaS",
]
GITHUB_SOURCE_NAME = "github"
WIKI_URL = "https://moodysanalytics.atlassian.net/wiki/spaces/CAO/pages/470036157/Connect+to+GitHub+Package+Registry"
CONTENT_INCLUDE_IGNORE_FILES: list[str] = [
# Example: "resource\\SomeFile.dat"
]
# ── Colored output helpers ─────────────────────────────────────────────────────
_NO_COLOR = not sys.stdout.isatty()
def _colored(text: str, code: str) -> str:
if _NO_COLOR:
return text
return f"\033[{code}m{text}\033[0m"
def green(text: str) -> str:
return _colored(text, "32")
def yellow(text: str) -> str:
return _colored(text, "33")
def red(text: str) -> str:
return _colored(text, "31")
def cyan(text: str) -> str:
return _colored(text, "36")
def dim(text: str) -> str:
return _colored(text, "90")
# ── BOM-preserving file I/O ───────────────────────────────────────────────────
# CRITICAL: .csproj and packages.config files use UTF-8 with BOM in this repo.
# Writing without BOM will corrupt project files and break Visual Studio / MSBuild.
_UTF8_BOM = b"\xef\xbb\xbf"
class FileContent(NamedTuple):
content: str
has_bom: bool
path: str
def read_file(path: str) -> FileContent:
raw = Path(path).read_bytes()
has_bom = raw[:3] == _UTF8_BOM
content = raw.decode("utf-8-sig")
return FileContent(content=content, has_bom=has_bom, path=path)
def write_file(fc: FileContent, content: str | None = None, *, dry_run: bool = False) -> None:
text = content if content is not None else fc.content
if dry_run:
print(dim(f" [dry-run] Would write {fc.path}"))
return
payload = (_UTF8_BOM if fc.has_bom else b"") + text.encode("utf-8")
Path(fc.path).write_bytes(payload)
# ── File scan cache ────────────────────────────────────────────────────────────
class FileCache:
"""Scan packages.config and .csproj files once, reuse across updates."""
def __init__(self, root: str) -> None:
self.root = root
self._packages_configs: list[str] | None = None
self._csprojs: list[str] | None = None
def packages_configs(self) -> list[str]:
if self._packages_configs is None:
self._packages_configs = [
str(p) for p in Path(self.root).rglob("packages.config")
]
return self._packages_configs
def csprojs(self) -> list[str]:
if self._csprojs is None:
self._csprojs = [str(p) for p in Path(self.root).rglob("*.csproj")]
return self._csprojs
# ── Prerequisites ──────────────────────────────────────────────────────────────
def check_prerequisites(source_name: str) -> bool:
if not shutil.which("dotnet"):
print(red(" ERROR: 'dotnet' CLI not found. Run: python3 dotnet-setup.py --install"))
return False
result = subprocess.run(
["dotnet", "nuget", "list", "source"],
capture_output=True, text=True, timeout=15,
)
if source_name not in result.stdout:
print(red(f" ERROR: NuGet source '{source_name}' is not configured."))
print(yellow(" Run: python3 dotnet-registry.py --nuget"))
print(f" See: {WIKI_URL}")
return False
return True
# ── Version query ──────────────────────────────────────────────────────────────
def _parse_version(v: str) -> tuple[int, ...] | None:
try:
return tuple(int(x) for x in v.split("."))
except (ValueError, AttributeError):
return None
def query_versions_dotnet(package_id: str, source_name: str) -> list[str]:
"""Query versions via `dotnet package search`. Returns sorted desc."""
print(dim(f" Querying '{package_id}' from source '{source_name}'..."))
try:
result = subprocess.run(
["dotnet", "package", "search", package_id,
"--source", source_name, "--exact-match", "--format", "json"],
capture_output=True, text=True, timeout=60,
)
except (OSError, subprocess.TimeoutExpired):
return []
if result.returncode != 0:
return []
try:
data = json.loads(result.stdout)
except (json.JSONDecodeError, ValueError):
return []
versions: list[str] = []
for source in data.get("searchResult", []):
for pkg in source.get("packages", []):
v = pkg.get("version")
if v:
versions.append(v)
return _sort_versions(versions)
def _sort_versions(versions: list[str]) -> list[str]:
parsed = [(v, _parse_version(v)) for v in versions]
valid = [(v, t) for v, t in parsed if t is not None]
valid.sort(key=lambda x: x[1], reverse=True)
seen: set[tuple[int, ...]] = set()
result: list[str] = []
for v, t in valid:
if t not in seen:
seen.add(t)
result.append(v)
return result
def get_package_versions(package_id: str, source_name: str) -> list[str]:
"""Query versions via dotnet CLI."""
versions = query_versions_dotnet(package_id, source_name)
if not versions:
print(yellow(f" No versions found for '{package_id}'. Check access permissions."))
print(dim(f" See: {WIKI_URL}"))
return versions
# ── Current version from packages.config ───────────────────────────────────────
def get_current_version(package_id: str, web_proj_dir: str) -> str | None:
config_path = os.path.join(web_proj_dir, "packages.config")
if not os.path.isfile(config_path):
print(yellow(f" WARNING: {config_path} not found."))
return None
content = Path(config_path).read_text(encoding="utf-8-sig")
pattern = rf'<package\s+id="{re.escape(package_id)}"\s+version="([^"]+)"'
m = re.search(pattern, content)
return m.group(1) if m else None
# ── Update packages.config ────────────────────────────────────────────────────
def update_packages_configs(
package_id: str, old_version: str, new_version: str,
file_cache: FileCache, *, dry_run: bool = False,
) -> None:
pattern = rf'(<package\s+id="{re.escape(package_id)}"\s+version=")([^"]+)(")'
for cfg_path in file_cache.packages_configs():
fc = read_file(cfg_path)
if package_id not in fc.content:
continue
replaced = re.sub(pattern, rf"\g<1>{new_version}\3", fc.content)
if replaced != fc.content:
write_file(fc, replaced, dry_run=dry_run)
print(dim(f" Updated {cfg_path}"))
# ── Update .csproj HintPath + Version= references ────────────────────────────
def update_csproj_references(
package_id: str, old_version: str, new_version: str,
file_cache: FileCache, *, dry_run: bool = False,
) -> None:
old_fragment = f"{package_id}.{old_version}"
new_fragment = f"{package_id}.{new_version}"
for proj_path in file_cache.csprojs():
fc = read_file(proj_path)
if old_fragment not in fc.content:
continue
replaced = fc.content.replace(old_fragment, new_fragment)
if replaced != fc.content:
write_file(fc, replaced, dry_run=dry_run)
print(dim(f" Updated references in {proj_path}"))
# ── Download package via dotnet restore ─────────────────────────────────────────
def download_package(
package_id: str, version: str, packages_dir: str,
*, dry_run: bool = False,
) -> bool:
"""Download package via `dotnet restore` and place it in packages_dir. Return True on success."""
target_dir = os.path.join(packages_dir, f"{package_id}.{version}")
if os.path.isdir(target_dir):
print(dim(f" Package already present: {target_dir}"))
return True
if dry_run:
print(dim(f" [dry-run] Would download '{package_id}' v{version}"))
return True
print(cyan(f" Downloading '{package_id}' v{version} via dotnet restore..."))
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
# Minimal .csproj to trigger package download only.
# Use netstandard2.0 to avoid downloading .NET Framework targeting packs.
csproj_content = (
'<Project Sdk="Microsoft.NET.Sdk">\n'
' <PropertyGroup>\n'
' <TargetFramework>netstandard2.0</TargetFramework>\n'
' <AutoGenerateBindingRedirects>false</AutoGenerateBindingRedirects>\n'
' </PropertyGroup>\n'
' <ItemGroup>\n'
f' <PackageReference Include="{package_id}" Version="{version}" />\n'
' </ItemGroup>\n'
'</Project>\n'
)
tmp_csproj = os.path.join(tmp_dir, "tmp_restore.csproj")
Path(tmp_csproj).write_text(csproj_content)
tmp_packages = os.path.join(tmp_dir, "pkgs")
log_file = os.path.join(tmp_dir, "restore.log")
with open(log_file, "w") as log_f:
result = subprocess.run(
["dotnet", "restore", tmp_csproj, "--packages", tmp_packages],
stdout=log_f, stderr=subprocess.STDOUT, text=True, timeout=180,
)
if result.returncode != 0:
print(red(" dotnet restore failed:"))
try:
log_content = Path(log_file).read_text()
for line in log_content.splitlines():
if "error" in line.lower():
print(red(f" {line.strip()}"))
except OSError:
pass
print(yellow(f" Check your PAT and source config. See: {WIKI_URL}"))
return False
# dotnet restore puts packages at {tmp_packages}/{lowercase_id}/{version}/
restored_dir = os.path.join(tmp_packages, package_id.lower(), version)
if not os.path.isdir(restored_dir):
print(red(f" Package not found in restore output: {restored_dir}"))
return False
os.makedirs(packages_dir, exist_ok=True)
shutil.copytree(restored_dir, target_dir)
if not os.path.isdir(target_dir):
print(red(f" Package directory not created: {target_dir}"))
return False
print(green(f" Downloaded to {target_dir}"))
return True
# ── Copy content files to web project ──────────────────────────────────────────
def copy_package_content(
package_id: str, version: str, packages_dir: str, web_proj_dir: str,
*, dry_run: bool = False,
) -> list[str]:
"""Copy content/ files from package to web project. Return list of relative paths."""
content_dir = os.path.join(packages_dir, f"{package_id}.{version}", "content")
if not os.path.isdir(content_dir):
return []
content_path = Path(content_dir)
files = [f for f in content_path.rglob("*") if f.is_file()]
if not files:
return []
copied: list[str] = []
print(dim(f" Copying content to {os.path.basename(web_proj_dir)}..."))
for f in files:
rel_path = str(f.relative_to(content_path))
dest = os.path.join(web_proj_dir, rel_path)
if dry_run:
print(dim(f" [dry-run] Would copy {rel_path}"))
else:
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copy2(str(f), dest)
copied.append(rel_path)
action = "Would copy" if dry_run else "Copied"
print(dim(f" {action} {len(copied)} content file(s)."))
return copied
# ── Update <Content Include> in .csproj ────────────────────────────────────────
def update_csproj_content_includes(
content_rel_paths: list[str], web_proj_dir: str,
*, dry_run: bool = False,
) -> None:
if not content_rel_paths:
return
csproj_files = list(Path(web_proj_dir).glob("*.csproj"))
for csproj_path in csproj_files:
fc = read_file(str(csproj_path))
modified = False
for rel_path in content_rel_paths:
if rel_path.lower() in (ig.lower() for ig in CONTENT_INCLUDE_IGNORE_FILES):
continue
# csproj may use \ or / as separator — match either
pat = re.escape(rel_path).replace("/", r"[/\\]")
if re.search(rf'(?i)<Content\s+Include="{pat}"', fc.content):
continue
new_entry = f' <Content Include="{rel_path}" />'
last_close = fc.content.rfind("</ItemGroup>")
if last_close != -1:
fc = fc._replace(
content=fc.content[:last_close] + new_entry + "\r\n " + fc.content[last_close:]
)
modified = True
print(cyan(f" Added <Content Include=\"{rel_path}\"> to {csproj_path.name}"))
if modified:
write_file(fc, dry_run=dry_run)
# ── Single package update orchestration ────────────────────────────────────────
class UpdateResult(NamedTuple):
package_id: str
old_version: str | None
new_version: str
status: str # "updated", "skipped", "failed"
def update_single_package(
package_id: str, new_version: str,
root: str, web_proj_dir: str, packages_dir: str,
file_cache: FileCache,
*, dry_run: bool = False,
) -> UpdateResult:
current = get_current_version(package_id, web_proj_dir)
if not current:
print(yellow(f" '{package_id}' not found in packages.config. Skipping."))
return UpdateResult(package_id, None, new_version, "skipped")
if current == new_version:
print(yellow(f" '{package_id}' is already at version {new_version}. Skipping."))
return UpdateResult(package_id, current, new_version, "skipped")
print(green(f" Updating '{package_id}': {current} -> {new_version}"))
# Download FIRST — only modify project files after a successful download
ok = download_package(package_id, new_version, packages_dir, dry_run=dry_run)
if not ok:
print(red(f" Download failed. No files were modified for '{package_id}'."))
return UpdateResult(package_id, current, new_version, "failed")
update_packages_configs(package_id, current, new_version, file_cache, dry_run=dry_run)
update_csproj_references(package_id, current, new_version, file_cache, dry_run=dry_run)
copied = copy_package_content(
package_id, new_version, packages_dir, web_proj_dir, dry_run=dry_run,
)
update_csproj_content_includes(copied, web_proj_dir, dry_run=dry_run)
return UpdateResult(package_id, current, new_version, "updated")
# ── Interactive menus ──────────────────────────────────────────────────────────
def show_package_menu(current_versions: dict[str, str | None]) -> str | None:
"""Show package selection menu. Return package name, 'ALL', or None to exit."""
print()
print(yellow("Select a package to update:"))
print()
print(f" {green('[0]')} Update ALL packages to latest version")
for i, pkg in enumerate(TARGET_PACKAGES):
cur = current_versions.get(pkg)
if cur:
print(f" {cyan(f'[{i + 1}]')} {pkg} (current: {cur})")
else:
print(f" {dim(f'[{i + 1}]')} {pkg} {dim('(not installed)')}")
pkg_max = len(TARGET_PACKAGES)
print()
while True:
try:
raw = input(f" Enter your choice (0-{pkg_max}, or X to exit): ").strip()
except (EOFError, KeyboardInterrupt):
print()
return None
if raw.upper() == "X":
return None
try:
num = int(raw)
except ValueError:
print(red(f" Invalid input. Enter 0-{pkg_max}, or X."))
continue
if num == 0:
return "ALL"
if 1 <= num <= pkg_max:
return TARGET_PACKAGES[num - 1]
print(red(f" Invalid input. Enter 0-{pkg_max}, or X."))
def show_version_menu(versions: list[str]) -> str | None:
"""Show version selection menu. Return selected version or None to exit."""
show_count = min(5, len(versions))
total = len(versions)
displayed = show_count # how many are currently listed
def _print_list(count: int) -> None:
print()
header = "All versions:" if count == total else f"Available versions (latest {count}):"
print(yellow(header))
for i in range(count):
label = " (latest)" if i == 0 else ""
print(f" {cyan(f'[{i + 1}]')} {versions[i]}{label}")
print()
_print_list(displayed)
while True:
try:
raw = input(f" Select version (1-{displayed}, version number, or X to exit): ").strip()
except (EOFError, KeyboardInterrupt):
print()
return None
if raw.upper() == "X":
return None
# Direct version number input (e.g. "4.91.2604.6124")
if "." in raw and any(c.isdigit() for c in raw):
if raw in versions:
return raw
# Show full list so user can pick
print(red(f" Version '{raw}' not found."))
if displayed < total:
displayed = total
_print_list(displayed)
continue
try:
num = int(raw)
except ValueError:
print(red(f" Invalid input. Enter 1-{displayed}, a version number, or X."))
continue
if 1 <= num <= displayed:
return versions[num - 1]
print(red(f" Invalid input. Enter 1-{displayed}, a version number, or X."))
# ── Summary ────────────────────────────────────────────────────────────────────
def print_summary(results: list[UpdateResult]) -> None:
if not results:
return
print()
print(cyan("── Summary ──"))
print()
status_map = {
"updated": green("updated"),
"skipped": yellow("skipped"),
"failed": red("FAILED"),
}
# Build plain-text rows for width calculation, and display rows with color
plain_rows: list[list[str]] = []
color_rows: list[list[str]] = []
for r in results:
old = r.old_version or "n/a"
arrow = "->" if r.status != "skipped" else ""
new = r.new_version if r.status != "skipped" else ""
status_plain = r.status.upper() if r.status == "failed" else r.status
plain_rows.append([r.package_id, old, arrow, new, status_plain])
color_rows.append([r.package_id, old, arrow, new, status_map.get(r.status, r.status)])
# Compute widths from plain text, then apply to color rows
col_count = len(plain_rows[0])
widths = [0] * col_count
for row in plain_rows:
for i, cell in enumerate(row):
widths[i] = max(widths[i], len(cell))
for plain, color in zip(plain_rows, color_rows):
parts = []
for i, cell in enumerate(color):
if i == col_count - 1:
parts.append(cell)
else:
pad = widths[i] - len(plain[i])
parts.append(cell + " " * pad)
print(" " + " ".join(parts))
print()
# ── CLI ────────────────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Update ZMAPI/ZMNET NuGet packages across all projects.",
)
parser.add_argument(
"--web-proj", default="ZMnetAdmin",
help="Primary project directory (default: ZMnetAdmin)",
)
parser.add_argument(
"--packages-dir", default="packages",
help="Solution-level packages directory (default: packages)",
)
parser.add_argument(
"--source", default=GITHUB_SOURCE_NAME,
help=f"NuGet source name (default: {GITHUB_SOURCE_NAME})",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Preview changes without modifying any files",
)
parser.add_argument(
"--package",
help="Update a specific package (skip menu)",
)
parser.add_argument(
"--version",
help="Target version (use with --package, skip version menu)",
)
parser.add_argument(
"--all", action="store_true",
help="Update all packages to latest version",
)
return parser.parse_args()
# ── Main ───────────────────────────────────────────────────────────────────────
def main() -> int:
args = parse_args()
print()
print(cyan("=== ZMAPI / ZMNET Package Updater ==="))
print("Updates packages.config, .csproj references, and content files across all projects.")
print()
if args.dry_run:
print(yellow(" *** DRY-RUN MODE — no files will be modified ***"))
print()
# Resolve root directory (script location or cwd)
root = os.path.dirname(os.path.abspath(__file__)) or os.getcwd()
web_proj_dir = os.path.join(root, args.web_proj)
packages_dir = os.path.join(root, args.packages_dir)
if not check_prerequisites(args.source):
return 1
file_cache = FileCache(root)
# Build current-version table
current_versions: dict[str, str | None] = {}
for pkg in TARGET_PACKAGES:
current_versions[pkg] = get_current_version(pkg, web_proj_dir)
results: list[UpdateResult] = []
if args.all:
# Update all packages to latest
print(cyan("Fetching latest versions..."))
for pkg in TARGET_PACKAGES:
versions = get_package_versions(pkg, args.source)
if not versions:
results.append(UpdateResult(pkg, current_versions.get(pkg), "?", "failed"))
continue
r = update_single_package(
pkg, versions[0], root, web_proj_dir, packages_dir,
file_cache, dry_run=args.dry_run,
)
results.append(r)
elif args.package:
# Update a specific package
if args.package not in TARGET_PACKAGES:
print(red(f" Unknown package: {args.package}"))
print(f" Valid packages: {', '.join(TARGET_PACKAGES)}")
return 1
if args.version:
target_version = args.version
else:
versions = get_package_versions(args.package, args.source)
if not versions:
return 1
target_version = show_version_menu(versions)
if not target_version:
print(yellow(" Exiting."))
return 0
r = update_single_package(
args.package, target_version, root, web_proj_dir, packages_dir,
file_cache, dry_run=args.dry_run,
)
results.append(r)
else:
# Interactive menu
choice = show_package_menu(current_versions)
if choice is None:
print(yellow(" Exiting."))
return 0
if choice == "ALL":
print()
print(cyan("Fetching latest versions..."))
for pkg in TARGET_PACKAGES:
versions = get_package_versions(pkg, args.source)
if not versions:
results.append(UpdateResult(pkg, current_versions.get(pkg), "?", "failed"))
continue
r = update_single_package(
pkg, versions[0], root, web_proj_dir, packages_dir,
file_cache, dry_run=args.dry_run,
)
results.append(r)
else:
print()
print(cyan(f"Fetching versions for '{choice}'..."))
versions = get_package_versions(choice, args.source)
if not versions:
return 1
target_version = show_version_menu(versions)
if not target_version:
print(yellow(" Exiting."))
return 0
print()
r = update_single_package(
choice, target_version, root, web_proj_dir, packages_dir,
file_cache, dry_run=args.dry_run,
)
results.append(r)
print_summary(results)
any_failed = any(r.status == "failed" for r in results)
any_updated = any(r.status == "updated" for r in results)
if any_failed:
print(red("Some updates failed. See messages above."))
elif any_updated:
print(green("Done. Please rebuild the solution to verify the update."))
else:
print(yellow("Nothing to update."))
print()
return 1 if any_failed else 0
if __name__ == "__main__":
sys.exit(main())File Information
- Filename:
dotnet-update-packages.py - Category: python
- Language: PYTHON