gitvars-render 0.1.0__py3-none-any.whl

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.
gitvars/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Git provenance rendering with no runtime dependencies."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+
6
+ try:
7
+ __version__ = version("gitvars-render")
8
+ except PackageNotFoundError:
9
+ # A source checkout can be imported before the distribution is installed.
10
+ __version__ = "0+unknown"
gitvars/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
gitvars/cli.py ADDED
@@ -0,0 +1,126 @@
1
+ """Console commands; all user-facing output is sent to stdout."""
2
+
3
+ import argparse
4
+ from html import escape
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .config import load_config
10
+ from .errors import GitvarsError
11
+ from .files import (
12
+ Stats, absolute, atomic_write, copy_file, discover_entries, prepare_inputs,
13
+ prepare_output, validate_output,
14
+ )
15
+ from .git import collect_snapshot, require_clean_inputs
16
+ from .render import Renderer, unified_diff
17
+
18
+
19
+ class Parser(argparse.ArgumentParser):
20
+ def _print_message(self, message, file=None):
21
+ if message:
22
+ sys.stdout.write(message)
23
+
24
+
25
+ def build_parser() -> Parser:
26
+ parser = Parser(prog="gitvars", description="Render Git provenance into selected UTF-8 files.")
27
+ parser.add_argument("--version", action="version", version=f"gitvars {__version__}")
28
+ commands = parser.add_subparsers(dest="command", required=True)
29
+ for name in ("render", "check"):
30
+ command = commands.add_parser(name, help="Render files" if name == "render" else "Check whether rendering would change files")
31
+ command.add_argument("paths", nargs="*", metavar="PATH", help="Files or directories (default: current directory)")
32
+ command.add_argument("--config", metavar="FILE")
33
+ command.add_argument("--prefix", metavar="PREFIX")
34
+ command.add_argument("--include", action="append", metavar="GLOB", help="Add a repository-relative rendering glob")
35
+ command.add_argument("--exclude", action="append", metavar="GLOB", help="Add a repository-relative exclusion glob")
36
+ command.add_argument("--exclude-regex", action="append", metavar="REGEX", help="Add a regex exclusion against repository-relative paths")
37
+ command.add_argument("--escape", action=argparse.BooleanOptionalAction, default=None, help="Recognize backslash escapes (default: enabled)")
38
+ command.add_argument("--html-escape", action="store_true", help="Escape inserted values for HTML text and quoted attributes")
39
+ verbosity = command.add_mutually_exclusive_group()
40
+ verbosity.add_argument("-v", "--verbose", action="store_true")
41
+ verbosity.add_argument("-q", "--quiet", action="store_true")
42
+ if name == "render":
43
+ command.add_argument("--dry-run", action="store_true", help="Print unified diffs without writing")
44
+ mode = command.add_mutually_exclusive_group()
45
+ mode.add_argument("--in-place", action="store_true", help="Replace clean tracked inputs (default)")
46
+ mode.add_argument("--output-dir", metavar="DIR", help="Copy files under DIR using repository-relative paths")
47
+ command.add_argument("-f", "--force", action="store_true", help="Clear a nonempty output directory; requires --output-dir")
48
+ return parser
49
+
50
+
51
+ def run(args, stats: Stats) -> int:
52
+ output_arg = getattr(args, "output_dir", None)
53
+ force = getattr(args, "force", False)
54
+ if force and output_arg is None:
55
+ raise GitvarsError("-f/--force requires --output-dir; it cannot bypass clean-input checks")
56
+ config = load_config(Path.cwd(), args)
57
+ root, inputs = prepare_inputs(args.paths or ["."], config)
58
+ output = absolute(output_arg) if output_arg is not None else None
59
+ if output is not None:
60
+ validate_output(output, inputs, root, force)
61
+ entries = discover_entries(root, inputs, config, stats)
62
+ snapshot = collect_snapshot(root, config)
63
+ dry_run = getattr(args, "dry_run", False)
64
+ writing = args.command == "render" and not dry_run
65
+ if writing and output is None:
66
+ require_clean_inputs(snapshot, [entry.relative for entry in entries if entry.render])
67
+ if writing and output is not None:
68
+ prepare_output(output)
69
+ renderer = Renderer(config.prefix, config.escape)
70
+ for entry in entries:
71
+ stats.scanned += 1
72
+ destination = output / entry.relative if output else entry.source
73
+ if not entry.render:
74
+ stats.skipped += 1
75
+ if output is not None:
76
+ if writing:
77
+ copy_file(entry.source, destination)
78
+ stats.copied += 1
79
+ if args.verbose:
80
+ print(f"{'Would copy' if dry_run else 'Copied'} {entry.relative} unchanged")
81
+ continue
82
+ try:
83
+ original = entry.source.read_bytes()
84
+ text = original.decode("utf-8")
85
+ except UnicodeDecodeError as exc:
86
+ raise GitvarsError(f"Selected file is not valid UTF-8: {entry.source}; adjust include/exclude patterns") from exc
87
+ values = snapshot.for_file(entry.relative)
88
+ if args.html_escape:
89
+ values = {name: escape(value) for name, value in values.items()}
90
+ rendered, unavailable = renderer.render(text, values)
91
+ changed = rendered != text
92
+ if changed:
93
+ stats.changed += 1
94
+ if args.verbose:
95
+ for name in sorted(unavailable):
96
+ print(f"Warning: {entry.relative}: {config.prefix}{name}__ is unavailable; using an empty string")
97
+ action = "Would render" if not writing else "Rendered"
98
+ print(f"{action} {entry.relative}" if changed else f"Unchanged {entry.relative}")
99
+ if dry_run and changed:
100
+ print(unified_diff(text, rendered, entry.relative, str(destination) if output else None), end="")
101
+ if writing:
102
+ if changed:
103
+ atomic_write(destination, rendered.encode("utf-8"), entry.source.stat().st_mode)
104
+ elif output is not None:
105
+ copy_file(entry.source, destination)
106
+ if output is not None:
107
+ stats.copied += 1
108
+ return 1 if args.command == "check" and stats.changed else 0
109
+
110
+
111
+ def main(argv: list[str] | None = None) -> int:
112
+ args = build_parser().parse_args(argv)
113
+ stats = Stats()
114
+ try:
115
+ result = run(args, stats)
116
+ except (GitvarsError, OSError) as exc:
117
+ print(f"gitvars: error: {exc}")
118
+ stats.errors += 1
119
+ result = 2
120
+ if not args.quiet:
121
+ preview = "Dry run: " if getattr(args, "dry_run", False) else ""
122
+ print(
123
+ f"{preview}scanned {stats.scanned}; changed {stats.changed}; "
124
+ f"copied {stats.copied}; skipped {stats.skipped}; errors {stats.errors}"
125
+ )
126
+ return result
gitvars/config.py ADDED
@@ -0,0 +1,115 @@
1
+ """Configuration discovery and repository-relative file selection."""
2
+
3
+ from dataclasses import dataclass
4
+ from fnmatch import fnmatchcase
5
+ from functools import cache
6
+ from pathlib import Path
7
+ import re
8
+ import tomllib
9
+
10
+ from .errors import GitvarsError
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Config:
15
+ prefix: str = "__git_"
16
+ remote: str = "origin"
17
+ provider: str = "auto"
18
+ url_prefix: str | None = None
19
+ include: tuple[str, ...] = ()
20
+ exclude: tuple[str, ...] = ()
21
+ exclude_regex: tuple[str, ...] = ()
22
+ escape: bool = True
23
+
24
+
25
+ def discover_config(cwd: Path) -> Path | None:
26
+ for directory in (cwd, *cwd.parents):
27
+ candidate = directory / ".gitvars"
28
+ if candidate.exists():
29
+ return candidate
30
+ return None
31
+
32
+
33
+ def load_config(cwd: Path, args) -> Config:
34
+ path = Path(args.config) if args.config else discover_config(cwd)
35
+ values = {}
36
+ if path is not None:
37
+ try:
38
+ with path.open("rb") as stream:
39
+ values = tomllib.load(stream)
40
+ except (OSError, tomllib.TOMLDecodeError) as exc:
41
+ raise GitvarsError(f"Cannot read configuration {path}: {exc}") from exc
42
+ unknown = values.keys() - Config.__dataclass_fields__.keys()
43
+ if unknown:
44
+ raise GitvarsError(f"Unknown configuration keys: {', '.join(sorted(unknown))}")
45
+ for key in ("prefix", "remote", "provider", "url_prefix"):
46
+ if key in values and (not isinstance(values[key], str) or not values[key]):
47
+ raise GitvarsError(f"Configuration '{key}' must be a nonempty string")
48
+ for key in ("include", "exclude", "exclude_regex"):
49
+ items = values.get(key, [])
50
+ if not isinstance(items, list) or any(
51
+ not isinstance(item, str) or not item for item in items
52
+ ):
53
+ raise GitvarsError(f"Configuration '{key}' must be a list of nonempty strings")
54
+ values[key] = tuple(items) + tuple(getattr(args, key, None) or ())
55
+ if "escape" in values and not isinstance(values["escape"], bool):
56
+ raise GitvarsError("Configuration 'escape' must be true or false")
57
+ if args.prefix is not None:
58
+ values["prefix"] = args.prefix
59
+ if args.escape is not None:
60
+ values["escape"] = args.escape
61
+ config = Config(**values)
62
+ if not config.prefix or any(char in config.prefix for char in "\r\n\\\x00"):
63
+ raise GitvarsError("Prefix must be nonempty and contain no newline, NUL, or backslash")
64
+ if config.provider not in {"auto", "github", "gitlab", "bitbucket_cloud"}:
65
+ raise GitvarsError("Provider must be auto, github, gitlab, or bitbucket_cloud")
66
+ for pattern in (*config.include, *config.exclude):
67
+ if pattern.startswith("/") or ".." in pattern.split("/"):
68
+ raise GitvarsError(f"Glob must be repository-relative: {pattern!r}")
69
+ for pattern in config.exclude_regex:
70
+ try:
71
+ re.compile(pattern)
72
+ except re.error as exc:
73
+ raise GitvarsError(f"Invalid exclusion regex {pattern!r}: {exc}") from exc
74
+ return config
75
+
76
+
77
+ def match_glob(path: str, pattern: str) -> bool:
78
+ """Match whole POSIX paths; ** matches zero or more complete segments."""
79
+ parts = tuple(path.split("/"))
80
+ patterns = tuple(pattern.split("/"))
81
+
82
+ @cache
83
+ def match(index: int, pattern_index: int) -> bool:
84
+ if pattern_index == len(patterns):
85
+ return index == len(parts)
86
+ token = patterns[pattern_index]
87
+ if token == "**":
88
+ return match(index, pattern_index + 1) or (
89
+ index < len(parts) and match(index + 1, pattern_index)
90
+ )
91
+ return (
92
+ index < len(parts)
93
+ and fnmatchcase(parts[index], token)
94
+ and match(index + 1, pattern_index + 1)
95
+ )
96
+
97
+ return match(0, 0)
98
+
99
+
100
+ class Selector:
101
+ def __init__(self, config: Config):
102
+ self.config = config
103
+ self.regexes = tuple(re.compile(pattern) for pattern in config.exclude_regex)
104
+
105
+ def excluded(self, relative: str) -> bool:
106
+ path = Path(relative)
107
+ candidates = (relative, *(parent.as_posix() for parent in path.parents if parent != Path(".")))
108
+ return any(
109
+ any(match_glob(candidate, pattern) for pattern in self.config.exclude)
110
+ or any(regex.search(candidate) for regex in self.regexes)
111
+ for candidate in candidates
112
+ )
113
+
114
+ def included(self, relative: str) -> bool:
115
+ return any(match_glob(relative, pattern) for pattern in self.config.include)
gitvars/errors.py ADDED
@@ -0,0 +1,2 @@
1
+ class GitvarsError(Exception):
2
+ """An actionable configuration, Git, input, or filesystem error."""
gitvars/files.py ADDED
@@ -0,0 +1,137 @@
1
+ """Traversal, destination validation, and per-file atomic writes."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import os
6
+ import shutil
7
+ import stat
8
+ import tempfile
9
+
10
+ from .config import Config, Selector
11
+ from .errors import GitvarsError
12
+ from .git import git, repository_root
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Entry:
17
+ source: Path
18
+ relative: str
19
+ render: bool
20
+
21
+
22
+ @dataclass
23
+ class Stats:
24
+ scanned: int = 0
25
+ changed: int = 0
26
+ copied: int = 0
27
+ skipped: int = 0
28
+ errors: int = 0
29
+
30
+
31
+ def absolute(path: str | Path) -> Path:
32
+ return Path(os.path.abspath(path))
33
+
34
+
35
+ def has_symlink(path: Path) -> bool:
36
+ return any(part.is_symlink() for part in (path, *path.parents))
37
+
38
+
39
+ def prepare_inputs(paths: list[str], config: Config) -> tuple[Path, list[Path]]:
40
+ inputs = list(dict.fromkeys(absolute(path) for path in paths))
41
+ for path in inputs:
42
+ if not path.exists() and not path.is_symlink():
43
+ raise GitvarsError(f"Input does not exist: {path}")
44
+ if path.is_dir() and not has_symlink(path) and not config.include:
45
+ raise GitvarsError("Directory inputs require include patterns in .gitvars or --include; alternatively supply files explicitly")
46
+ root = repository_root(inputs[0])
47
+ for path in inputs:
48
+ if has_symlink(path):
49
+ continue
50
+ if repository_root(path) != root:
51
+ raise GitvarsError("Each invocation must use one Git repository; inputs span multiple repositories")
52
+ return root, inputs
53
+
54
+
55
+ def discover_entries(root: Path, inputs: list[Path], config: Config, stats: Stats) -> list[Entry]:
56
+ selector = Selector(config)
57
+ entries: dict[Path, Entry] = {}
58
+
59
+ def visit(path: Path, explicit: bool = False) -> None:
60
+ if has_symlink(path) or ".git" in path.relative_to(root).parts:
61
+ stats.skipped += 1
62
+ return
63
+ relative = path.relative_to(root).as_posix()
64
+ if relative != "." and selector.excluded(relative):
65
+ stats.skipped += 1
66
+ return
67
+ if path.is_dir():
68
+ if path != root and (path / ".git").exists():
69
+ stats.skipped += 1
70
+ return
71
+ for child in sorted(path.iterdir()):
72
+ visit(child)
73
+ elif path.is_file():
74
+ render = explicit or selector.included(relative)
75
+ previous = entries.get(path)
76
+ entries[path] = Entry(path, relative, render or bool(previous and previous.render))
77
+ else:
78
+ stats.skipped += 1
79
+
80
+ for path in inputs:
81
+ # Explicit symlinks can point outside the root; never traverse them.
82
+ if has_symlink(path):
83
+ stats.skipped += 1
84
+ continue
85
+ visit(path, explicit=path.is_file())
86
+ return sorted(entries.values(), key=lambda entry: entry.relative)
87
+
88
+
89
+ def validate_output(output: Path, inputs: list[Path], root: Path, force: bool) -> None:
90
+ if has_symlink(output):
91
+ raise GitvarsError(f"Output directory and its parents must not be symlinks: {output}")
92
+ for path in inputs:
93
+ source = path.resolve()
94
+ if output == source or output in source.parents or (path.is_dir() and source in output.parents):
95
+ raise GitvarsError(f"Output directory overlaps an input: {output}; choose a disjoint destination")
96
+ if ".git" in output.parts:
97
+ raise GitvarsError("Output must not be inside Git metadata (.git)")
98
+ if output.exists():
99
+ if not output.is_dir():
100
+ raise GitvarsError(f"Output path is not a directory: {output}")
101
+ if any(output.iterdir()) and not force:
102
+ raise GitvarsError(f"Output directory is not empty: {output}; pass -f/--force to clear it")
103
+ # --separate-git-dir and linked worktrees need not store metadata under .git.
104
+ for option in ("--absolute-git-dir", "--git-common-dir"):
105
+ location = Path(os.fsdecode(git(root, "rev-parse", option).rstrip(b"\n")))
106
+ metadata = (root / location).resolve()
107
+ if output == metadata or output in metadata.parents or metadata in output.parents:
108
+ raise GitvarsError("Output directory must not overlap Git metadata")
109
+
110
+
111
+ def prepare_output(output: Path) -> None:
112
+ if output.exists():
113
+ for child in output.iterdir():
114
+ if child.is_dir() and not child.is_symlink():
115
+ shutil.rmtree(child)
116
+ else:
117
+ child.unlink()
118
+ else:
119
+ output.mkdir(parents=True)
120
+
121
+
122
+ def atomic_write(destination: Path, content: bytes, mode: int) -> None:
123
+ destination.parent.mkdir(parents=True, exist_ok=True)
124
+ descriptor, name = tempfile.mkstemp(prefix=f".{destination.name}.gitvars-", dir=destination.parent)
125
+ temporary = Path(name)
126
+ try:
127
+ with os.fdopen(descriptor, "wb") as stream:
128
+ stream.write(content)
129
+ temporary.chmod(stat.S_IMODE(mode))
130
+ os.replace(temporary, destination)
131
+ finally:
132
+ temporary.unlink(missing_ok=True)
133
+
134
+
135
+ def copy_file(source: Path, destination: Path) -> None:
136
+ destination.parent.mkdir(parents=True, exist_ok=True)
137
+ shutil.copy2(source, destination)
gitvars/git.py ADDED
@@ -0,0 +1,98 @@
1
+ """Read one immutable commit snapshot using the installed Git executable."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import os
6
+ import subprocess
7
+
8
+ from .config import Config
9
+ from .errors import GitvarsError
10
+ from .providers import RepositoryURL, resolve_repository_url
11
+
12
+
13
+ def git(directory: Path, *arguments: str, optional: bool = False) -> bytes:
14
+ try:
15
+ result = subprocess.run(
16
+ ["git", "-C", str(directory), *arguments],
17
+ stdout=subprocess.PIPE,
18
+ stderr=subprocess.PIPE,
19
+ check=False,
20
+ )
21
+ except FileNotFoundError as exc:
22
+ raise GitvarsError("Git is not installed or not on PATH; install Git to use gitvars") from exc
23
+ if result.returncode and not optional:
24
+ detail = result.stderr.decode("utf-8", errors="replace").strip()
25
+ raise GitvarsError(f"Git failed in {directory}: {detail}")
26
+ return result.stdout if result.returncode == 0 else b""
27
+
28
+
29
+ def repository_root(path: Path) -> Path:
30
+ directory = path if path.is_dir() and not path.is_symlink() else path.parent
31
+ result = git(directory, "rev-parse", "--show-toplevel", optional=True)
32
+ if not result:
33
+ raise GitvarsError(f"Input is outside a Git working tree: {path}")
34
+ return Path(os.fsdecode(result.rstrip(b"\n"))).resolve()
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Snapshot:
39
+ root: Path
40
+ values: dict[str, str]
41
+ files: frozenset[str]
42
+ repository_url: RepositoryURL
43
+
44
+ def for_file(self, relative: str) -> dict[str, str]:
45
+ values = dict(self.values)
46
+ url = self.repository_url
47
+ values["file_url"] = (
48
+ url.provider.file_url(url.base, values["commit"], relative)
49
+ if relative in self.files and url.base and url.provider else ""
50
+ )
51
+ return values
52
+
53
+
54
+ def collect_snapshot(root: Path, config: Config) -> Snapshot:
55
+ commit = git(root, "rev-parse", "--verify", "HEAD", optional=True).decode().strip()
56
+ if not commit:
57
+ raise GitvarsError(f"Repository has no commit at HEAD: {root}; create a commit before rendering")
58
+ fields = (
59
+ "commit", "commit_short", "author_name", "author_email", "committer_name",
60
+ "committer_email", "commit_subject", "commit_message", "commit_date",
61
+ )
62
+ output = git(
63
+ root, "show", "--no-patch", "--no-show-signature", "--encoding=UTF-8",
64
+ "--format=%H%x00%h%x00%an%x00%ae%x00%cn%x00%ce%x00%s%x00%B%x00%aI%x00", commit,
65
+ )
66
+ values = dict(zip(fields, output.decode("utf-8", errors="replace").split("\x00")[:len(fields)]))
67
+ values["branch"] = git(root, "symbolic-ref", "--quiet", "--short", "HEAD", optional=True).decode().strip()
68
+ tags = git(
69
+ root, "tag", "--points-at", commit, "--sort=-version:refname", "--no-column", "--color=never",
70
+ ).decode().splitlines()
71
+ values["tag"] = tags[0] if tags else ""
72
+ remote = git(root, "remote", "get-url", "--", config.remote, optional=True).decode().strip() or None
73
+ url = resolve_repository_url(config, remote, root.name)
74
+ values["repository"] = url.name
75
+ values["commit_url"] = url.provider.commit_url(url.base, commit) if url.base and url.provider else ""
76
+ files = set()
77
+ for record in git(root, "ls-tree", "-rz", "--full-tree", commit).split(b"\0"):
78
+ if record:
79
+ info, path = record.split(b"\t", 1)
80
+ if info.split()[0] in {b"100644", b"100755"}:
81
+ files.add(os.fsdecode(path))
82
+ return Snapshot(root, values, frozenset(files), url)
83
+
84
+
85
+ def require_clean_inputs(snapshot: Snapshot, relatives: list[str]) -> None:
86
+ dirty = set()
87
+ for staged in (False, True):
88
+ arguments = ["diff", "--no-ext-diff", "--no-renames", "--name-only", "-z"]
89
+ if staged:
90
+ arguments.append("--cached")
91
+ dirty.update(os.fsdecode(path) for path in git(snapshot.root, *arguments).split(b"\0") if path)
92
+ unsafe = [path for path in relatives if path not in snapshot.files or path in dirty]
93
+ if unsafe:
94
+ sample = ", ".join(unsafe[:5])
95
+ raise GitvarsError(
96
+ f"In-place rendering requires clean tracked inputs: {sample}. "
97
+ "Commit or restore those files, or use --output-dir"
98
+ )
gitvars/providers.py ADDED
@@ -0,0 +1,99 @@
1
+ """Offline URL parsing and small provider-specific route adapters."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import PurePosixPath
5
+ import re
6
+ from urllib.parse import quote, unquote, urlsplit, urlunsplit
7
+
8
+ from .config import Config
9
+ from .errors import GitvarsError
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Provider:
14
+ commit_route: str
15
+ file_route: str
16
+
17
+ def commit_url(self, base: str, commit: str) -> str:
18
+ return f"{base}/{self.commit_route}/{commit}"
19
+
20
+ def file_url(self, base: str, commit: str, path: str) -> str:
21
+ return f"{base}/{self.file_route}/{commit}/{quote(path, safe='/')}"
22
+
23
+
24
+ PROVIDERS = {
25
+ "github": Provider("commit", "blob"),
26
+ "gitlab": Provider("-/commit", "-/blob"),
27
+ "bitbucket_cloud": Provider("commits", "src"),
28
+ }
29
+ HOSTS = {"github.com": "github", "gitlab.com": "gitlab", "bitbucket.org": "bitbucket_cloud"}
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class RepositoryURL:
34
+ name: str
35
+ base: str | None = None
36
+ provider: Provider | None = None
37
+
38
+
39
+ def remote_parts(remote: str) -> tuple[str | None, str | None, str]:
40
+ """Return browser base, hostname, and repository name for common remotes."""
41
+ if "://" in remote:
42
+ parsed = urlsplit(remote)
43
+ path = parsed.path.rstrip("/")
44
+ hostname = parsed.hostname
45
+ if parsed.scheme in {"http", "https", "ssh", "git"} and hostname:
46
+ scheme = parsed.scheme if parsed.scheme in {"http", "https"} else "https"
47
+ host = f"[{hostname}]" if ":" in hostname else hostname
48
+ if scheme == parsed.scheme and parsed.port:
49
+ host += f":{parsed.port}"
50
+ base = urlunsplit((scheme, host, path, "", ""))
51
+ else:
52
+ base = None
53
+ else:
54
+ scp = re.fullmatch(r"(?:[^@/]+@)?([^/:]+):(.+)", remote)
55
+ if scp:
56
+ hostname, path = scp.groups()
57
+ path = "/" + path.strip("/")
58
+ base = f"https://{hostname}{path}"
59
+ else:
60
+ hostname, path, base = None, remote.rstrip("/"), None
61
+ if path.endswith(".git"):
62
+ path = path[:-4]
63
+ if base is not None:
64
+ base = base[:-4]
65
+ name = unquote(PurePosixPath(path).name)
66
+ if base is not None and len([part for part in path.split("/") if part]) < 2:
67
+ base = None
68
+ return base, hostname.lower() if hostname else None, name
69
+
70
+
71
+ def resolve_repository_url(config: Config, remote: str | None, directory_name: str) -> RepositoryURL:
72
+ source = config.url_prefix or remote
73
+ if source is None:
74
+ return RepositoryURL(directory_name)
75
+ try:
76
+ base, host, name = remote_parts(source)
77
+ if config.url_prefix:
78
+ parsed = urlsplit(source)
79
+ if (
80
+ parsed.scheme not in {"http", "https"}
81
+ or not parsed.hostname
82
+ or parsed.username is not None
83
+ or parsed.password is not None
84
+ or parsed.query
85
+ or parsed.fragment
86
+ or len([part for part in parsed.path.split("/") if part]) < 2
87
+ or any(char.isspace() for char in source)
88
+ ):
89
+ raise GitvarsError("url_prefix must be an HTTP(S) repository web root, including owner and repository")
90
+ except ValueError as exc:
91
+ if config.url_prefix:
92
+ raise GitvarsError(f"Invalid url_prefix: {exc}") from exc
93
+ return RepositoryURL(directory_name)
94
+ provider_name = config.provider if config.provider != "auto" else HOSTS.get(host)
95
+ if config.url_prefix and provider_name is None:
96
+ raise GitvarsError("Cannot infer provider from url_prefix; set provider explicitly for self-hosted domains")
97
+ if provider_name == "bitbucket_cloud" and host != "bitbucket.org":
98
+ raise GitvarsError("bitbucket_cloud supports bitbucket.org only")
99
+ return RepositoryURL(name or directory_name, base, PROVIDERS.get(provider_name))
gitvars/render.py ADDED
@@ -0,0 +1,44 @@
1
+ """Single-pass substitution of supported tokens, with optional escaping."""
2
+
3
+ import difflib
4
+ import re
5
+
6
+
7
+ NAMES = (
8
+ "commit", "commit_short", "author_name", "author_email", "committer_name",
9
+ "committer_email", "commit_subject", "commit_message", "commit_date",
10
+ "branch", "tag", "repository", "commit_url", "file_url",
11
+ )
12
+
13
+
14
+ class Renderer:
15
+ def __init__(self, prefix: str, escape: bool):
16
+ escaped = r"(?P<escaped>\\)?" if escape else ""
17
+ self.pattern = re.compile(escaped + re.escape(prefix) + r"(?P<name>" + "|".join(NAMES) + r")__")
18
+ self.escape = escape
19
+
20
+ def render(self, text: str, values: dict[str, str]) -> tuple[str, set[str]]:
21
+ unavailable = set()
22
+
23
+ def replace(match: re.Match) -> str:
24
+ if self.escape and match.group("escaped"):
25
+ return match.group()[1:]
26
+ name = match.group("name")
27
+ value = values[name]
28
+ if not value:
29
+ unavailable.add(name)
30
+ return value
31
+
32
+ return self.pattern.sub(replace, text), unavailable
33
+
34
+
35
+ def unified_diff(before: str, after: str, relative: str, destination: str | None = None) -> str:
36
+ parts = []
37
+ for line in difflib.unified_diff(
38
+ before.splitlines(keepends=True), after.splitlines(keepends=True),
39
+ fromfile=f"a/{relative}", tofile=destination or f"b/{relative}",
40
+ ):
41
+ parts.append(line)
42
+ if not line.endswith("\n"):
43
+ parts.append("\n\\n")
44
+ return "".join(parts)
@@ -0,0 +1,426 @@
1
+ Metadata-Version: 2.4
2
+ Name: gitvars-render
3
+ Version: 0.1.0
4
+ Summary: Render Git provenance into text templates without network access.
5
+ Author: Andres Velasco
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/AndresVelasco/gitvars-render
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Provides-Extra: demo
15
+ Requires-Dist: markdown-it-py<5,>=4; extra == "demo"
16
+ Dynamic: license-file
17
+
18
+ # gitvars-render
19
+
20
+ `gitvars` exists so that ***your deployed assets can point back to their source.***
21
+
22
+ Git knows the commit, its author, and the code behind a change. `gitvars` carries
23
+ that context into the files you deploy. Add placeholders to your configuration,
24
+ code, or documentation, and replace them with Git metadata during your build—
25
+ including links to the exact commit and source file.
26
+
27
+ ## Why gitvars exists
28
+
29
+ I built it for a recurring need in data engineering: **labeling data assets and
30
+ pipelines with the version of code behind them.** When inspecting a table or an
31
+ Airflow DAG, I wanted a direct route back to the change that produced it.
32
+ Embedding Git metadata in labels, descriptions, and configuration makes that
33
+ connection easier to follow when investigating an issue or understanding a
34
+ deployment.
35
+
36
+ The same idea works for Terraform resources, application configuration, and
37
+ documentation. The [example page](https://andresvelasco.github.io/gitvars-render/) uses `gitvars` to fill in its
38
+ Git metadata at build time.
39
+
40
+ ## Installation
41
+
42
+ The Python distribution is named `gitvars-render` and installs the `gitvars` command.
43
+ The Python import remains `gitvars`. It
44
+ requires Python 3.11+ and Git on `PATH`, with no runtime dependencies or network
45
+ calls during rendering.
46
+
47
+ Install the package from a source checkout into your Python environment,
48
+ replacing `/path/to/gitvars-render` with the directory containing its `pyproject.toml`:
49
+
50
+ ```sh
51
+ python -m pip install /path/to/gitvars-render
52
+ ```
53
+
54
+ Once this project is published to PyPI, install its release by package name:
55
+
56
+ ```sh
57
+ python -m pip install gitvars-render
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ Run `gitvars` in the Git repository containing the files you want to process.
63
+ There are two commands:
64
+
65
+ | Command | Behavior |
66
+ | --- | --- |
67
+ | `gitvars render [PATH ...]` | Replace placeholders in selected files, in place by default, or write copies with `--output-dir`. |
68
+ | `gitvars check [PATH ...]` | Read selected files without writing. Exit `1` if rendering would change any file, or `0` if no changes are needed. |
69
+
70
+ Add `--dry-run` to `render` to preview a unified diff without writing anything.
71
+ Both commands read configuration from the nearest `.gitvars` found by searching
72
+ upward from the **working directory**, not from each input path. Use
73
+ `--config FILE` to select a specific configuration file. A configuration file is optional:
74
+ you can supply file selection and other options on the command line instead.
75
+
76
+ ### Select files and directories
77
+
78
+ Both commands accept **zero, one, or several positional paths**. Paths are files
79
+ or directories relative to your working directory; absolute paths also work.
80
+ You may mix files and directories, but every input must belong to the same Git
81
+ repository. Overlapping inputs are deduplicated.
82
+
83
+ | Inputs | Selection |
84
+ | --- | --- |
85
+ | No paths | Scan the current directory recursively. |
86
+ | One or more directories | Scan those directories recursively. |
87
+ | One or more files | Select those files directly; no include patterns are required. |
88
+
89
+ **Paths choose where to scan; include patterns choose which files to render.**
90
+ Directory scans require `include` patterns in `.gitvars` or repeatable
91
+ `--include` options. Globs are relative to the repository root. Exclusions still
92
+ apply to explicitly supplied files, even though include patterns are unnecessary
93
+ for them.
94
+
95
+ For example, preview SQL files under `models/` and Markdown files under `docs/`,
96
+ without requiring a configuration file:
97
+
98
+ ```sh
99
+ gitvars render models docs --include '**/*.sql' --include '**/*.md' --dry-run
100
+ ```
101
+
102
+ Or preview two specific files without `--include`:
103
+
104
+ ```sh
105
+ gitvars render models/model.sql docs/pipeline.md --dry-run
106
+ ```
107
+
108
+ To keep selection in configuration, create `.gitvars` at the repository root:
109
+
110
+ ```toml
111
+ include = ["**/*.sql", "**/*.md"]
112
+ exclude = ["build/**", "dist/**", ".venv/**"]
113
+ ```
114
+
115
+ With that configuration, no CLI `--include` is needed. Narrow the scan to the
116
+ two directories, or omit paths to scan the current directory:
117
+
118
+ ```sh
119
+ gitvars render models docs --dry-run
120
+ gitvars check models docs
121
+ gitvars render --dry-run
122
+ ```
123
+
124
+ For the complete configuration options and matching rules, see
125
+ [Configuration](#configuration).
126
+
127
+ ### Choose where rendering writes
128
+
129
+ `render` defaults to **in-place replacement**. This is intended for fresh CI
130
+ checkouts: substitution consumes the placeholders. Local use is supported, but
131
+ every selected input must be tracked and have no staged or unstaged changes.
132
+ Unrelated modified files do not block rendering. Restore the templates from Git
133
+ before rendering them again for a different commit.
134
+
135
+ Using the configuration above, render both directories in place:
136
+
137
+ ```sh
138
+ gitvars render models docs
139
+ ```
140
+
141
+ For generated artifacts, keep templates intact and render copies instead:
142
+
143
+ ```sh
144
+ gitvars render models docs --output-dir build
145
+ ```
146
+
147
+ `models/nested/model.sql` becomes `build/models/nested/model.sql`: paths
148
+ are always relative to the repository root, without flattening. Unselected files
149
+ in input directories are copied unchanged as bytes, including images and other
150
+ assets. Excluded files are omitted.
151
+
152
+ The output directory must be empty or absent. Use `-f` / `--force` to clear a
153
+ nonempty output directory before generating it again. Its contents are removed,
154
+ including files unrelated to the previous run. The flag never permits overlapping
155
+ inputs and outputs: an output directory cannot be inside an input directory,
156
+ equal an input, or contain an input. Symlink destinations and destinations inside
157
+ `.git` are refused. A common safe layout is sibling `models/`, `docs/`, and
158
+ `build/` directories, with only `models docs` as the inputs. Omitting the paths
159
+ would scan the current directory, so a `build/` destination inside it would be
160
+ rejected even if `build/**` is excluded.
161
+
162
+ ```sh
163
+ gitvars render models docs --output-dir build --force
164
+ gitvars render models docs --output-dir build --force --dry-run
165
+ ```
166
+
167
+ `--dry-run` prints unified diffs without changing any files, creating directories,
168
+ or clearing existing output. `check` reports whether rendering would change
169
+ selected files. Both can inspect dirty or untracked inputs.
170
+
171
+ ## Configuration
172
+
173
+ Use TOML in `.gitvars`. The nearest file found by searching upward from the
174
+ working directory wins. `--config FILE` overrides discovery. A directory input
175
+ requires include patterns from configuration or `--include`; with no paths,
176
+ the input is the working directory.
177
+
178
+ ```toml
179
+ prefix = "__git_"
180
+ escape = true
181
+ remote = "origin"
182
+ provider = "auto"
183
+ include = ["**/*.sql", "**/*.md", "**/*.yml", "**/*.yaml"]
184
+ exclude = ["build/**", "dist/**", ".venv/**"]
185
+ exclude_regex = ['(^|/)README\.md$']
186
+ # Optional repository web root, including owner/group and repository:
187
+ # url_prefix = "https://git.company.com/team/analytics"
188
+ # provider = "gitlab" # Required for self-hosted hosts.
189
+ ```
190
+
191
+ All selection patterns are **repository-relative**, regardless of the working
192
+ directory or configuration location. Paths use `/`, and matching is case-sensitive:
193
+
194
+ | Pattern | Matches |
195
+ | --- | --- |
196
+ | `*.md` | Markdown files at the repository root |
197
+ | `**/*.md` | Markdown files at any depth, including the root |
198
+ | `build/**` | The root build directory and everything beneath it |
199
+ | `templates/?.sql` | One-character SQL filenames directly under `templates/` |
200
+
201
+ `*`, `?`, and character classes match within a path segment. A standalone `**`
202
+ segment matches zero or more directories. Globs match the whole relative path;
203
+ regex exclusions search it. Matching an excluded directory excludes its subtree.
204
+ There are no negation/reinclude rules. `.gitignore` is not implicitly consulted.
205
+
206
+ CLI `--include`, `--exclude`, and `--exclude-regex` are repeatable and extend their
207
+ configuration lists. CLI `--prefix` and `--escape` / `--no-escape` override scalar
208
+ configuration. Matching any exclusion wins over inclusion or an explicit file.
209
+
210
+ Symlinks, `.git`, and nested repositories are skipped during traversal. Explicit
211
+ inputs from different repositories are rejected. All files in an invocation use
212
+ one commit snapshot. A repository without a commit at `HEAD` is an error.
213
+
214
+ ## Placeholders
215
+
216
+ | Placeholder | Value |
217
+ | --- | --- |
218
+ | `__git_commit__` | Full `HEAD` commit SHA |
219
+ | `__git_commit_short__` | Git's abbreviated SHA |
220
+ | `__git_author_name__` | Commit author name |
221
+ | `__git_author_email__` | Commit author email |
222
+ | `__git_committer_name__` | Committer name |
223
+ | `__git_committer_email__` | Committer email |
224
+ | `__git_commit_subject__` | Commit subject |
225
+ | `__git_commit_message__` | Full commit message, including newlines |
226
+ | `__git_commit_date__` | Author date in ISO 8601 with timezone |
227
+ | `__git_branch__` | Current branch; empty in detached HEAD |
228
+ | `__git_tag__` | Highest version-ordered tag pointing at `HEAD`; empty if none |
229
+ | `__git_repository__` | Repository name |
230
+ | `__git_commit_url__` | Commit browser URL, if available |
231
+ | `__git_file_url__` | Input-file permalink at `HEAD`, if the file exists there |
232
+
233
+ Commit metadata describes checked-out `HEAD`, including when output-directory
234
+ inputs have local edits. It does not identify the deployer or the last author of
235
+ each file. File links refer to the version committed at `HEAD`, which can differ
236
+ from a dirty local input. Tags are ordered using Git's version sort, not creation
237
+ date; only tags available in the local checkout participate.
238
+
239
+ Supported placeholders are replaced everywhere, including Markdown inline code
240
+ and fenced code blocks. Unknown or incomplete placeholders are left untouched.
241
+ Prefix strings are literal, not regexes. Unavailable known values become empty
242
+ strings, with warnings under `--verbose`.
243
+
244
+ Escape a supported placeholder with an immediately preceding backslash:
245
+
246
+ ```text
247
+ __git_commit__ -> the actual SHA
248
+ \__git_commit__ -> literal __git_commit__
249
+ ```
250
+
251
+ One escape backslash is consumed; all other backslashes remain unchanged.
252
+ Disable escape handling with `escape = false` or `--no-escape`. Then a backslash
253
+ is ordinary text: `\__git_commit__` becomes a backslash followed by the SHA.
254
+ Replacement is always one-pass; inserted metadata and escaped tokens are never
255
+ processed again during that invocation.
256
+
257
+ Values are inserted verbatim by default. Use `--html-escape` with `render` or
258
+ `check` to encode inserted values for HTML text and quoted attributes, including
259
+ `&`, `<`, `>`, and quotes. Template text and literal placeholders stay unchanged.
260
+ This is separate from backslash placeholder escaping. Template authors remain
261
+ responsible for quoting and escaping values for other output formats.
262
+
263
+ ## Repository URLs
264
+
265
+ GitHub, GitLab, and Bitbucket Cloud are supported, including common HTTPS,
266
+ SCP-style SSH, and `ssh://` remotes. `provider = "auto"` recognizes `github.com`,
267
+ `gitlab.com`, and `bitbucket.org`. Self-hosted GitHub/GitLab installations require
268
+ an explicit `provider`. A self-hosted `url_prefix` with `auto` is a configuration
269
+ error; an unrecognized remote without an override simply produces empty links.
270
+
271
+ `url_prefix` takes precedence over the selected remote for links and repository
272
+ names. Name discovery uses its final path component, then the selected remote's,
273
+ then the local repository-root directory name. A trailing `.git` is removed from
274
+ URL-derived names. The default remote is `origin`. No hosting APIs are used.
275
+ File URLs use the original input's repository-relative path, with components
276
+ URL-encoded, and the full commit SHA. A file absent from `HEAD` has no permalink.
277
+
278
+ ## Files, failures, and exit codes
279
+
280
+ Only selected files are decoded as UTF-8; there is no binary detection. A selected
281
+ file that cannot be decoded produces an error. Original newline characters,
282
+ including mixed line endings and the UTF-8 BOM, are preserved outside substituted
283
+ values. Multiline metadata is inserted verbatim. File permissions are preserved;
284
+ unchanged in-place files are not rewritten. Each changed file is replaced atomically.
285
+
286
+ Inputs, repository membership, selections, and destination safety are checked
287
+ before writing or clearing output. In-place clean-input checks also happen before
288
+ writing. Files are then processed sequentially: there is no bulk token validation
289
+ or automatic rollback. A later decoding, reading, or writing failure can leave
290
+ earlier files rendered. Restore affected in-place files from Git, or delete and
291
+ regenerate output-directory contents.
292
+
293
+ | Exit | Meaning |
294
+ | --- | --- |
295
+ | `0` | Successful render/dry-run, even with changes; or check needs no changes |
296
+ | `1` | Check found files rendering would change |
297
+ | `2` | Invalid arguments/configuration, missing Git/commit/repository, dirty in-place inputs, or an I/O/encoding error |
298
+
299
+ All logging and diagnostics go to stdout. Default output is a concise summary;
300
+ `--verbose` adds paths and unavailable-value warnings, and `--quiet` suppresses
301
+ routine logging. Dry-run diffs and errors remain visible with `--quiet`.
302
+
303
+ ## Development and demo
304
+
305
+ ```sh
306
+ uv sync --locked --extra demo
307
+ uv run --locked --extra demo python -m unittest discover -s tests -v
308
+ ```
309
+
310
+ `uv.lock` is versioned so local development, CI, and demo builds use the same
311
+ resolved dependencies. `--locked` fails if it needs updating. After changing
312
+ dependencies, run `uv lock` and commit both `pyproject.toml` and `uv.lock`.
313
+ The runtime package still has no dependencies; the default development group
314
+ adds the version-comparison helper used by release automation.
315
+
316
+ To build and preview the page locally:
317
+
318
+ ```sh
319
+ uv run --locked --extra demo python demo/build.py
320
+ uv run --locked --extra demo gitvars render demo/index.html --html-escape --output-dir build --force
321
+ ```
322
+
323
+ | Path | Purpose |
324
+ | --- | --- |
325
+ | `README.md` | Page content, maintained in Markdown. |
326
+ | `demo/layout.html` | Tracked HTML layout and styles. |
327
+ | `demo/index.html` | Generated HTML with Git placeholders intact; ignored by Git. |
328
+ | `build/demo/index.html` | Final rendered page with actual Git values; ignored by Git. Open this file in your browser. |
329
+
330
+ `demo/build.py` only combines the README and layout into `demo/index.html`,
331
+ leaving the Git placeholders intact. The separate `gitvars render` command
332
+ replaces them in a copy at `build/demo/index.html`, preserving the input path
333
+ under `build/`. `--force` clears an existing `build/` directory before rendering.
334
+ Neither generated HTML file is committed. Run both commands again after editing
335
+ the README or layout. CI uses the same two steps on pushes and pull requests.
336
+
337
+ The table is inserted immediately after the introduction. Its three columns show
338
+ the literal variable, its replaced value, and its explanation. README examples
339
+ stay literal; only the table's value cells are substituted. Values are escaped
340
+ for HTML, including multiline commit messages. The file URL cell is empty
341
+ because the generated input is absent from `HEAD`; the commit URL still links
342
+ to the source revision. Both commands run once and exit. The optional `demo`
343
+ dependencies are needed only to convert the README to HTML; the gitvars CLI
344
+ itself still has no runtime dependencies.
345
+
346
+ ### GitHub Pages output
347
+
348
+ On a push to `main`, the Pages workflow first runs `demo/build.py`, then invokes
349
+ `gitvars render` to produce `build/demo/index.html`. It uploads **`build/demo/` as the
350
+ Pages artifact**. That directory becomes the website root, so its `index.html`
351
+ is the homepage. With the default project-site URL, it appears at
352
+ [https://andresvelasco.github.io/gitvars-render/](https://andresvelasco.github.io/gitvars-render/),
353
+ without `build/demo/` in the URL. The workflow can also be run manually.
354
+
355
+ Select **GitHub Actions** as the repository's Pages publishing source to enable
356
+ deployment, following the [GitHub Pages workflow documentation](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages).
357
+ Generated output is not committed, so the page can link to the exact source
358
+ commit without a self-referential SHA update.
359
+
360
+ ## Publishing releases
361
+
362
+ The distribution on PyPI and TestPyPI is **`gitvars-render`**. The GitHub
363
+ repository is `AndresVelasco/gitvars-render`; the executable and Python import
364
+ remain `gitvars`. `pyproject.toml` is the single version source, and
365
+ `gitvars --version` reads the installed distribution's metadata.
366
+
367
+ The publishing workflow is `.github/workflows/publish.yml`:
368
+
369
+ | Event | Result after successful checks |
370
+ | --- | --- |
371
+ | Pull request | Tests and distribution builds; no publishing. |
372
+ | Push to `main` with no release tags yet | Publish a development build to TestPyPI. |
373
+ | Push to `main` with an unchanged released version | Publish a development build to TestPyPI. |
374
+ | Push to `main` with a version above the latest release tag | Create the matching `vMAJOR.MINOR.PATCH` tag and publish to PyPI. |
375
+ | Push of a matching version tag | Verify the tag, version, and tests, then publish to PyPI. |
376
+
377
+ With no existing release tags, the first successful `main` build publishes
378
+ `0.1.0.dev...` to TestPyPI. After verifying that build, request the initial
379
+ production release explicitly:
380
+
381
+ ```sh
382
+ git tag v0.1.0
383
+ git push origin v0.1.0
384
+ ```
385
+
386
+ This starts the production publishing workflow, including tests and a check that
387
+ the tag matches the version in `pyproject.toml`. For subsequent releases, update
388
+ the version instead:
389
+
390
+ ```sh
391
+ uv version 0.1.1
392
+ ```
393
+
394
+ Commit the updated `pyproject.toml` and `uv.lock`, preferably in a release PR,
395
+ and merge into `main`. Release versions use stable `MAJOR.MINOR.PATCH` numbers.
396
+ The workflow compares versions numerically, rejects version regressions and
397
+ tag/version mismatches, and never moves an existing tag. It tags the commit CI
398
+ actually tested, even if `main` has advanced. Tag creation and publishing are
399
+ jobs in the same workflow, so no additional token is needed to trigger another
400
+ workflow from an automated tag.
401
+
402
+ After `v0.1.0`, ordinary pushes publish versions such as
403
+ `0.1.1.dev1230001`: the next patch version plus a serial derived from the
404
+ workflow run ID and attempt. Only the temporary build checkout gets this
405
+ development version; it is not committed. Re-running a failed release on its
406
+ tagged commit retries publishing and skips distributions already uploaded.
407
+ Wheel and source distributions are installed and checked before publishing.
408
+
409
+ Configure pending or existing trusted publishers separately on PyPI and
410
+ TestPyPI, and create the corresponding GitHub environments:
411
+
412
+ | Setting | PyPI | TestPyPI |
413
+ | --- | --- | --- |
414
+ | Project name | `gitvars-render` | `gitvars-render` |
415
+ | Repository owner | `AndresVelasco` | `AndresVelasco` |
416
+ | Repository name | `gitvars-render` | `gitvars-render` |
417
+ | Workflow filename | `publish.yml` | `publish.yml` |
418
+ | Environment | `pypi` | `testpypi` |
419
+
420
+ Publishing uses OIDC trusted publishing; no PyPI API token is stored in GitHub.
421
+ The first upload creates a project configured with a pending publisher. See
422
+ the [PyPI trusted-publishing documentation](https://docs.pypi.org/trusted-publishers/).
423
+
424
+ CI is configured to run the standard-library test suite on Python 3.11–3.14 on Linux, plus Python
425
+ 3.12 on Windows and macOS. The local implementation has been validated on Python
426
+ 3.12 and Git 2.43; hosted CI results determine broader compatibility.
@@ -0,0 +1,15 @@
1
+ gitvars/__init__.py,sha256=dOrcGUCGwsI7eY5okoMN5tfGZTiIwFLGbA0emrCr_0w,311
2
+ gitvars/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ gitvars/cli.py,sha256=3_foDtQKQE_JQap5ywVX9P_5bXuqhd2Ahb_BYKp6WwY,6268
4
+ gitvars/config.py,sha256=ENaJnO8sQUzLzt6jIQS6zV66qSjjpF0n3oOmsL1VMBY,4473
5
+ gitvars/errors.py,sha256=o5eyqRt54gjpQO4q3licTIX4Im0ym6UQXea_uhGY2Ck,103
6
+ gitvars/files.py,sha256=FOx-oPusce5AbP35Cza0f6ePpc5oxfw8HqKSAcjeIH4,5076
7
+ gitvars/git.py,sha256=O7va92e3z53YrB6V0BfZ0C71hqA_85DEJITeQWEVpEs,4174
8
+ gitvars/providers.py,sha256=BjMvEn5hLshJluJuUq4ldnHcryk4mEVKVplqI5AMztc,3858
9
+ gitvars/render.py,sha256=P4rc5gZ2UpEGECjWI3C8rEkN7uvqg2-2L_3V6Wb0sDQ,1520
10
+ gitvars_render-0.1.0.dist-info/licenses/LICENSE,sha256=rz0gH8KmOlLUJMlhOe6Wl5WBf2t8C9JYdzNIP8P17Vc,1078
11
+ gitvars_render-0.1.0.dist-info/METADATA,sha256=wpeYFHdRwZl9tMPzOxb-M4FtgFEhQXoOsbmRF3PlJCU,19645
12
+ gitvars_render-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
13
+ gitvars_render-0.1.0.dist-info/entry_points.txt,sha256=UiBID9z_jDVs7eQL1-sn-nnbYBNHw8aCMfbHSMGIcMU,45
14
+ gitvars_render-0.1.0.dist-info/top_level.txt,sha256=o73PHSI1P11rB1LXdeotvqhHenjzJPiXWutOGmrCAJw,8
15
+ gitvars_render-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gitvars = gitvars.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andres Velasco Garcia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ gitvars