gitcalver 20260418.5__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.
- gitcalver/__init__.py +50 -0
- gitcalver/__main__.py +7 -0
- gitcalver/_branch.py +59 -0
- gitcalver/_errors.py +13 -0
- gitcalver/_format.py +24 -0
- gitcalver/_git.py +130 -0
- gitcalver/_hatch_hooks.py +18 -0
- gitcalver/_hatch_source.py +28 -0
- gitcalver/_version.py +183 -0
- gitcalver/cli.py +178 -0
- gitcalver/py.typed +0 -0
- gitcalver-20260418.5.dist-info/METADATA +174 -0
- gitcalver-20260418.5.dist-info/RECORD +16 -0
- gitcalver-20260418.5.dist-info/WHEEL +4 -0
- gitcalver-20260418.5.dist-info/entry_points.txt +5 -0
- gitcalver-20260418.5.dist-info/licenses/LICENSE +19 -0
gitcalver/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Copyright © 2026 Michael Shields
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from gitcalver._errors import EXIT_DIRTY, EXIT_ERROR, EXIT_WRONG_BRANCH, ExitError
|
|
5
|
+
from gitcalver._format import Format
|
|
6
|
+
from gitcalver._version import forward, reverse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_version(
|
|
10
|
+
*,
|
|
11
|
+
revision: str | None = None,
|
|
12
|
+
prefix: str = "",
|
|
13
|
+
dirty: str = "",
|
|
14
|
+
dirty_hash: bool = True,
|
|
15
|
+
branch: str | None = None,
|
|
16
|
+
repo: str | None = None,
|
|
17
|
+
) -> str:
|
|
18
|
+
fmt = Format(prefix=prefix, dirty_suffix=dirty or None, dirty_hash=dirty_hash)
|
|
19
|
+
return forward(
|
|
20
|
+
dir=repo,
|
|
21
|
+
revision=revision,
|
|
22
|
+
fmt=fmt,
|
|
23
|
+
branch_override=branch or None,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_commit(
|
|
28
|
+
version: str,
|
|
29
|
+
*,
|
|
30
|
+
prefix: str = "",
|
|
31
|
+
branch: str | None = None,
|
|
32
|
+
repo: str | None = None,
|
|
33
|
+
short: bool = False,
|
|
34
|
+
) -> str:
|
|
35
|
+
return reverse(
|
|
36
|
+
dir=repo,
|
|
37
|
+
version_str=version.removeprefix(prefix),
|
|
38
|
+
branch_override=branch or None,
|
|
39
|
+
short=short,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"EXIT_DIRTY",
|
|
45
|
+
"EXIT_ERROR",
|
|
46
|
+
"EXIT_WRONG_BRANCH",
|
|
47
|
+
"ExitError",
|
|
48
|
+
"find_commit",
|
|
49
|
+
"get_version",
|
|
50
|
+
]
|
gitcalver/__main__.py
ADDED
gitcalver/_branch.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Copyright © 2026 Michael Shields
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from gitcalver import _git
|
|
5
|
+
from gitcalver._errors import ExitError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def detect_branch(
|
|
9
|
+
dir: str | None = None, override: str | None = None
|
|
10
|
+
) -> tuple[str, str]:
|
|
11
|
+
if override is not None:
|
|
12
|
+
if "/" in override:
|
|
13
|
+
candidates = [override]
|
|
14
|
+
else:
|
|
15
|
+
candidates = [
|
|
16
|
+
f"refs/remotes/origin/{override}",
|
|
17
|
+
f"refs/heads/{override}",
|
|
18
|
+
]
|
|
19
|
+
for ref in candidates:
|
|
20
|
+
hash_ = _git.try_ref_hash(ref, dir=dir)
|
|
21
|
+
if hash_ is not None:
|
|
22
|
+
name = override.rsplit("/", 1)[-1]
|
|
23
|
+
return name, hash_
|
|
24
|
+
msg = f"branch not found: {override}"
|
|
25
|
+
raise ExitError(msg)
|
|
26
|
+
|
|
27
|
+
target = _git.symbolic_ref("refs/remotes/origin/HEAD", dir=dir)
|
|
28
|
+
if target:
|
|
29
|
+
hash_ = _git.try_ref_hash(target, dir=dir)
|
|
30
|
+
if hash_ is not None:
|
|
31
|
+
name = target.removeprefix("refs/remotes/origin/")
|
|
32
|
+
return name, hash_
|
|
33
|
+
|
|
34
|
+
for name in ("main", "master"):
|
|
35
|
+
hash_ = _git.try_ref_hash(f"refs/remotes/origin/{name}", dir=dir)
|
|
36
|
+
if hash_ is not None:
|
|
37
|
+
return name, hash_
|
|
38
|
+
|
|
39
|
+
for name in ("main", "master"):
|
|
40
|
+
hash_ = _git.try_ref_hash(f"refs/heads/{name}", dir=dir)
|
|
41
|
+
if hash_ is not None:
|
|
42
|
+
return name, hash_
|
|
43
|
+
|
|
44
|
+
msg = "cannot determine default branch"
|
|
45
|
+
raise ExitError(msg)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_on_branch(
|
|
49
|
+
target_hash: str,
|
|
50
|
+
branch_hash: str,
|
|
51
|
+
dir: str | None = None,
|
|
52
|
+
) -> bool:
|
|
53
|
+
# Optimization: git treats a commit as its own ancestor, so the
|
|
54
|
+
# is_ancestor call below would handle this case correctly, but
|
|
55
|
+
# this avoids spawning git for the common same-hash case.
|
|
56
|
+
if target_hash == branch_hash:
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
return _git.is_ancestor(target_hash, branch_hash, dir=dir)
|
gitcalver/_errors.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Copyright © 2026 Michael Shields
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
EXIT_ERROR = 1
|
|
5
|
+
EXIT_DIRTY = 2
|
|
6
|
+
EXIT_WRONG_BRANCH = 3
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExitError(Exception):
|
|
10
|
+
def __init__(self, message: str, code: int = EXIT_ERROR) -> None:
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.code = code
|
|
13
|
+
self.message = message
|
gitcalver/_format.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copyright © 2026 Michael Shields
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Format:
|
|
9
|
+
prefix: str
|
|
10
|
+
# None means dirty workspaces are not allowed; otherwise the string
|
|
11
|
+
# is appended to versions built from a dirty workspace.
|
|
12
|
+
dirty_suffix: str | None
|
|
13
|
+
dirty_hash: bool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def format_version(
|
|
17
|
+
fmt: Format, date: str, count: int, dirty: bool, short_hash: str
|
|
18
|
+
) -> str:
|
|
19
|
+
version = f"{fmt.prefix}{date}.{count}"
|
|
20
|
+
if dirty and fmt.dirty_suffix is not None:
|
|
21
|
+
version += fmt.dirty_suffix
|
|
22
|
+
if fmt.dirty_hash:
|
|
23
|
+
version += f".{short_hash}"
|
|
24
|
+
return version
|
gitcalver/_git.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Copyright © 2026 Michael Shields
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_SHORT_HASH_LEN = 7
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _os_error_message(e: OSError) -> str:
|
|
17
|
+
if e.filename == "git":
|
|
18
|
+
return "git not found on PATH"
|
|
19
|
+
return str(e)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _run(*args: str, dir: str | None = None) -> subprocess.CompletedProcess[str]:
|
|
23
|
+
try:
|
|
24
|
+
return subprocess.run(
|
|
25
|
+
["git", *args],
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
cwd=dir,
|
|
29
|
+
check=False,
|
|
30
|
+
)
|
|
31
|
+
except OSError as e:
|
|
32
|
+
raise GitError(_os_error_message(e)) from e
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def git(*args: str, dir: str | None = None) -> str:
|
|
36
|
+
result = _run(*args, dir=dir)
|
|
37
|
+
if result.returncode != 0:
|
|
38
|
+
raise GitError(result.stderr.strip())
|
|
39
|
+
return result.stdout.strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def git_ok(*args: str, dir: str | None = None) -> bool:
|
|
43
|
+
return _run(*args, dir=dir).returncode == 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def rev_parse(rev: str, dir: str | None = None) -> str:
|
|
47
|
+
return git("rev-parse", rev, dir=dir)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def rev_parse_short(rev: str, dir: str | None = None) -> str:
|
|
51
|
+
# Pin the minimum length so output doesn't vary with core.abbrev.
|
|
52
|
+
return git("rev-parse", f"--short={_SHORT_HASH_LEN}", rev, dir=dir)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_git_repo(dir: str | None = None) -> bool:
|
|
56
|
+
return git_ok("rev-parse", "--git-dir", dir=dir)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_shallow(dir: str | None = None) -> bool:
|
|
60
|
+
return git("rev-parse", "--is-shallow-repository", dir=dir) == "true"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def has_commits(dir: str | None = None) -> bool:
|
|
64
|
+
return git_ok("rev-parse", "--verify", "HEAD", dir=dir)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_dirty(dir: str | None = None) -> bool:
|
|
68
|
+
try:
|
|
69
|
+
return git("status", "--porcelain", dir=dir) != ""
|
|
70
|
+
except GitError:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def symbolic_ref(ref: str, dir: str | None = None) -> str | None:
|
|
75
|
+
try:
|
|
76
|
+
return git("symbolic-ref", ref, dir=dir)
|
|
77
|
+
except GitError:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def try_ref_hash(ref: str, dir: str | None = None) -> str | None:
|
|
82
|
+
try:
|
|
83
|
+
return git("rev-parse", "--verify", ref, dir=dir)
|
|
84
|
+
except GitError:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_ancestor(commit: str, ancestor_of: str, dir: str | None = None) -> bool:
|
|
89
|
+
return git_ok("merge-base", "--is-ancestor", commit, ancestor_of, dir=dir)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def merge_base(rev1: str, rev2: str, dir: str | None = None) -> str | None:
|
|
93
|
+
try:
|
|
94
|
+
return git("merge-base", rev1, rev2, dir=dir)
|
|
95
|
+
except GitError:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def first_parent_log(
|
|
100
|
+
rev: str, dir: str | None = None
|
|
101
|
+
) -> Generator[tuple[str, str], None, None]:
|
|
102
|
+
env = {**os.environ, "TZ": "UTC"}
|
|
103
|
+
try:
|
|
104
|
+
proc = subprocess.Popen(
|
|
105
|
+
[
|
|
106
|
+
"git",
|
|
107
|
+
"log",
|
|
108
|
+
rev,
|
|
109
|
+
"--first-parent",
|
|
110
|
+
"--format=%H %cd",
|
|
111
|
+
"--date=format-local:%Y%m%d",
|
|
112
|
+
],
|
|
113
|
+
stdout=subprocess.PIPE,
|
|
114
|
+
stderr=subprocess.DEVNULL,
|
|
115
|
+
text=True,
|
|
116
|
+
cwd=dir,
|
|
117
|
+
env=env,
|
|
118
|
+
)
|
|
119
|
+
except OSError as e:
|
|
120
|
+
raise GitError(_os_error_message(e)) from e
|
|
121
|
+
with proc:
|
|
122
|
+
if proc.stdout is None:
|
|
123
|
+
return
|
|
124
|
+
for line in proc.stdout:
|
|
125
|
+
hash_, _, date = line.strip().partition(" ")
|
|
126
|
+
if date:
|
|
127
|
+
yield hash_, date
|
|
128
|
+
if proc.wait() != 0:
|
|
129
|
+
msg = f"git log {rev} failed"
|
|
130
|
+
raise GitError(msg)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copyright © 2026 Michael Shields
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from hatchling.plugin import hookimpl
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from hatchling.version.source.plugin.interface import VersionSourceInterface
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@hookimpl
|
|
15
|
+
def hatch_register_version_source() -> type[VersionSourceInterface]:
|
|
16
|
+
from gitcalver._hatch_source import GitCalverSource
|
|
17
|
+
|
|
18
|
+
return GitCalverSource
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Copyright © 2026 Michael Shields
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from hatchling.version.source.plugin.interface import VersionSourceInterface
|
|
5
|
+
|
|
6
|
+
from gitcalver import ExitError, get_version
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitCalverSource(VersionSourceInterface):
|
|
10
|
+
PLUGIN_NAME = "gitcalver"
|
|
11
|
+
|
|
12
|
+
def get_version_data(self) -> dict[str, str]:
|
|
13
|
+
config = self.config
|
|
14
|
+
if config.get("no-dirty-hash", False) and not config.get("dirty", ""):
|
|
15
|
+
msg = "gitcalver: no-dirty-hash requires dirty"
|
|
16
|
+
raise RuntimeError(msg)
|
|
17
|
+
try:
|
|
18
|
+
version = get_version(
|
|
19
|
+
prefix=str(config.get("prefix", "")),
|
|
20
|
+
dirty=str(config.get("dirty", "")),
|
|
21
|
+
dirty_hash=not config.get("no-dirty-hash", False),
|
|
22
|
+
branch=config.get("branch") or None,
|
|
23
|
+
repo=self.root,
|
|
24
|
+
)
|
|
25
|
+
except ExitError as e:
|
|
26
|
+
msg = f"gitcalver: {e.message}"
|
|
27
|
+
raise RuntimeError(msg) from e
|
|
28
|
+
return {"version": version}
|
gitcalver/_version.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Copyright © 2026 Michael Shields
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import datetime
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from gitcalver import _git
|
|
9
|
+
from gitcalver._branch import detect_branch, is_on_branch
|
|
10
|
+
from gitcalver._errors import EXIT_DIRTY, EXIT_WRONG_BRANCH, ExitError
|
|
11
|
+
from gitcalver._format import Format, format_version
|
|
12
|
+
|
|
13
|
+
VERSION_RE = re.compile(r"^(\d{8})\.([1-9]\d*)$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_version_string(s: str) -> bool:
|
|
17
|
+
return VERSION_RE.match(s) is not None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _date_went_backwards(older: str, newer: str) -> ExitError:
|
|
21
|
+
msg = (
|
|
22
|
+
f"committer date not monotonic: "
|
|
23
|
+
f"older commit dated {older} has a later date than "
|
|
24
|
+
f"newer commit dated {newer}"
|
|
25
|
+
)
|
|
26
|
+
return ExitError(msg)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _validate_repo(dir: str | None) -> None:
|
|
30
|
+
try:
|
|
31
|
+
is_repo = _git.is_git_repo(dir=dir)
|
|
32
|
+
except _git.GitError as e:
|
|
33
|
+
msg = str(e)
|
|
34
|
+
raise ExitError(msg) from e
|
|
35
|
+
if not is_repo:
|
|
36
|
+
msg = "not a git repository"
|
|
37
|
+
raise ExitError(msg)
|
|
38
|
+
try:
|
|
39
|
+
shallow = _git.is_shallow(dir=dir)
|
|
40
|
+
except _git.GitError as e:
|
|
41
|
+
msg = f"cannot determine repository state: {e}"
|
|
42
|
+
raise ExitError(msg) from e
|
|
43
|
+
if shallow:
|
|
44
|
+
msg = (
|
|
45
|
+
"shallow clone detected; full history is required"
|
|
46
|
+
" (use git fetch --unshallow)"
|
|
47
|
+
)
|
|
48
|
+
raise ExitError(msg)
|
|
49
|
+
if not _git.has_commits(dir=dir):
|
|
50
|
+
msg = "no commits in repository"
|
|
51
|
+
raise ExitError(msg)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def forward(
|
|
55
|
+
*,
|
|
56
|
+
dir: str | None,
|
|
57
|
+
revision: str | None,
|
|
58
|
+
fmt: Format,
|
|
59
|
+
branch_override: str | None,
|
|
60
|
+
) -> str:
|
|
61
|
+
_validate_repo(dir)
|
|
62
|
+
|
|
63
|
+
is_head = revision is None
|
|
64
|
+
rev_spec = f"{'HEAD' if is_head else revision}^{{commit}}"
|
|
65
|
+
try:
|
|
66
|
+
target_hash = _git.rev_parse(rev_spec, dir=dir)
|
|
67
|
+
except _git.GitError:
|
|
68
|
+
msg = (
|
|
69
|
+
"no commits in repository"
|
|
70
|
+
if is_head
|
|
71
|
+
else f"not a gitcalver version or git revision: {revision}"
|
|
72
|
+
)
|
|
73
|
+
raise ExitError(msg) from None
|
|
74
|
+
|
|
75
|
+
branch_name, branch_hash = detect_branch(dir=dir, override=branch_override)
|
|
76
|
+
|
|
77
|
+
version_rev = target_hash
|
|
78
|
+
off_branch = False
|
|
79
|
+
|
|
80
|
+
if not is_on_branch(target_hash, branch_hash, dir=dir):
|
|
81
|
+
if not is_head:
|
|
82
|
+
msg = f"{revision} is not on the default branch ({branch_name})"
|
|
83
|
+
raise ExitError(msg, EXIT_WRONG_BRANCH)
|
|
84
|
+
mb = _git.merge_base(target_hash, branch_hash, dir=dir)
|
|
85
|
+
if mb is None:
|
|
86
|
+
msg = f"HEAD is not traceable to the default branch ({branch_name})"
|
|
87
|
+
raise ExitError(msg, EXIT_WRONG_BRANCH)
|
|
88
|
+
off_branch = True
|
|
89
|
+
version_rev = mb
|
|
90
|
+
|
|
91
|
+
dirty = False
|
|
92
|
+
if off_branch or (is_head and _git.is_dirty(dir=dir)):
|
|
93
|
+
if fmt.dirty_suffix is None:
|
|
94
|
+
if off_branch:
|
|
95
|
+
msg = (
|
|
96
|
+
f"HEAD is off the default branch ({branch_name});"
|
|
97
|
+
" use --dirty to produce a divergent version"
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
msg = "workspace is dirty; use --dirty to allow"
|
|
101
|
+
raise ExitError(msg, EXIT_DIRTY)
|
|
102
|
+
dirty = True
|
|
103
|
+
|
|
104
|
+
date, count = walk_first_parent(dir=dir, rev=version_rev)
|
|
105
|
+
|
|
106
|
+
short_hash = ""
|
|
107
|
+
if dirty and fmt.dirty_hash:
|
|
108
|
+
short_hash = _git.rev_parse_short(target_hash, dir=dir)
|
|
109
|
+
|
|
110
|
+
return format_version(fmt, date, count, dirty, short_hash)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def walk_first_parent(*, dir: str | None, rev: str) -> tuple[str, int]:
|
|
114
|
+
with contextlib.closing(_git.first_parent_log(rev, dir=dir)) as entries:
|
|
115
|
+
first = next(entries, None)
|
|
116
|
+
if first is None:
|
|
117
|
+
msg = "no commits found"
|
|
118
|
+
raise ExitError(msg)
|
|
119
|
+
date = first[1]
|
|
120
|
+
count = 1
|
|
121
|
+
|
|
122
|
+
# Only the first date transition matters: once the date changes,
|
|
123
|
+
# we're done counting. Monotonicity is only checked within the
|
|
124
|
+
# target date's run; earlier violations are not surfaced here.
|
|
125
|
+
for _, entry_date in entries:
|
|
126
|
+
if entry_date != date:
|
|
127
|
+
if entry_date > date:
|
|
128
|
+
raise _date_went_backwards(entry_date, date)
|
|
129
|
+
break
|
|
130
|
+
count += 1
|
|
131
|
+
|
|
132
|
+
return date, count
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def reverse(
|
|
136
|
+
*,
|
|
137
|
+
dir: str | None,
|
|
138
|
+
version_str: str,
|
|
139
|
+
branch_override: str | None,
|
|
140
|
+
short: bool,
|
|
141
|
+
) -> str:
|
|
142
|
+
_validate_repo(dir)
|
|
143
|
+
|
|
144
|
+
match = VERSION_RE.match(version_str)
|
|
145
|
+
if not match:
|
|
146
|
+
msg = f"not a gitcalver version or git revision: {version_str}"
|
|
147
|
+
raise ExitError(msg)
|
|
148
|
+
|
|
149
|
+
date_str = match.group(1)
|
|
150
|
+
n = int(match.group(2))
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
datetime.date(int(date_str[:4]), int(date_str[4:6]), int(date_str[6:8]))
|
|
154
|
+
except ValueError:
|
|
155
|
+
msg = f"invalid date in version: {version_str}"
|
|
156
|
+
raise ExitError(msg) from None
|
|
157
|
+
|
|
158
|
+
_, branch_hash = detect_branch(dir=dir, override=branch_override)
|
|
159
|
+
|
|
160
|
+
candidates: list[str] = []
|
|
161
|
+
# git log is newest-first; a later date on an older commit is non-monotonic.
|
|
162
|
+
newer_date: str | None = None
|
|
163
|
+
with contextlib.closing(_git.first_parent_log(branch_hash, dir=dir)) as entries:
|
|
164
|
+
for commit_hash, commit_date in entries:
|
|
165
|
+
if newer_date is not None and commit_date > newer_date:
|
|
166
|
+
raise _date_went_backwards(commit_date, newer_date)
|
|
167
|
+
newer_date = commit_date
|
|
168
|
+
if commit_date == date_str:
|
|
169
|
+
candidates.append(commit_hash)
|
|
170
|
+
elif commit_date < date_str:
|
|
171
|
+
# Dates are non-increasing (checked above); no earlier matches.
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
if n > len(candidates):
|
|
175
|
+
msg = f"version not found: {version_str}"
|
|
176
|
+
raise ExitError(msg)
|
|
177
|
+
|
|
178
|
+
# N=1 is oldest on that date; candidates are newest-first.
|
|
179
|
+
target_hash = candidates[-n]
|
|
180
|
+
|
|
181
|
+
if short:
|
|
182
|
+
return _git.rev_parse_short(target_hash, dir=dir)
|
|
183
|
+
return target_hash
|
gitcalver/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Copyright © 2026 Michael Shields
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import NoReturn
|
|
9
|
+
|
|
10
|
+
from gitcalver._errors import ExitError
|
|
11
|
+
from gitcalver._format import Format
|
|
12
|
+
from gitcalver._version import forward, is_version_string, reverse
|
|
13
|
+
|
|
14
|
+
USAGE = """\
|
|
15
|
+
Usage: gitcalver [options] [REVISION | VERSION]
|
|
16
|
+
|
|
17
|
+
Compute a gitcalver version for a git commit, or find the commit for a version.
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--prefix PREFIX Prepend PREFIX to the version (default: none)
|
|
21
|
+
--dirty STRING Allow dirty workspace; append STRING.HASH as suffix
|
|
22
|
+
--no-dirty Refuse dirty workspace (overrides --dirty)
|
|
23
|
+
--no-dirty-hash Suppress .HASH in dirty suffix (requires --dirty)
|
|
24
|
+
--branch BRANCH Override default branch detection
|
|
25
|
+
--short Output short commit hash (reverse mode only)
|
|
26
|
+
--version Show version and exit
|
|
27
|
+
--help Show this help
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _package_version() -> str:
|
|
32
|
+
try:
|
|
33
|
+
return importlib.metadata.version("gitcalver")
|
|
34
|
+
except importlib.metadata.PackageNotFoundError:
|
|
35
|
+
return "unknown"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Args:
|
|
40
|
+
help: bool = False
|
|
41
|
+
version: bool = False
|
|
42
|
+
prefix: str = ""
|
|
43
|
+
dirty: str = ""
|
|
44
|
+
no_dirty: bool = False
|
|
45
|
+
no_dirty_hash: bool = False
|
|
46
|
+
branch: str | None = None
|
|
47
|
+
short: bool = False
|
|
48
|
+
positional: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _Parser(argparse.ArgumentParser):
|
|
52
|
+
def error(self, message: str) -> NoReturn:
|
|
53
|
+
raise ExitError(message)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _NonEmptyStrAction(argparse.Action):
|
|
57
|
+
def __call__(
|
|
58
|
+
self,
|
|
59
|
+
parser: argparse.ArgumentParser,
|
|
60
|
+
namespace: argparse.Namespace,
|
|
61
|
+
values: object,
|
|
62
|
+
option_string: str | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
if not values:
|
|
65
|
+
parser.error(f"{option_string} requires a non-empty string")
|
|
66
|
+
setattr(namespace, self.dest, values)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Options that take a string value; used by `_normalize_argv` to rewrite the
|
|
70
|
+
# space-separated form to `--opt=value` so argparse accepts values starting
|
|
71
|
+
# with `-`. Keep in sync with the non-boolean options declared in `_parse_args`.
|
|
72
|
+
_OPTS_TAKING_VALUE = frozenset({"--prefix", "--dirty", "--branch"})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _normalize_argv(argv: list[str]) -> list[str]:
|
|
76
|
+
"""Rewrite `--opt value` to `--opt=value` for options in `_OPTS_TAKING_VALUE`.
|
|
77
|
+
|
|
78
|
+
argparse otherwise refuses values that begin with `-` in the space-separated
|
|
79
|
+
form (so `--dirty -dirty` fails, though `--dirty=-dirty` works). Rewriting
|
|
80
|
+
makes both forms equivalent. Stops at `--` so values after the terminator
|
|
81
|
+
are passed through unchanged.
|
|
82
|
+
"""
|
|
83
|
+
result: list[str] = []
|
|
84
|
+
i = 0
|
|
85
|
+
while i < len(argv):
|
|
86
|
+
arg = argv[i]
|
|
87
|
+
if arg == "--":
|
|
88
|
+
result.extend(argv[i:])
|
|
89
|
+
return result
|
|
90
|
+
if arg in _OPTS_TAKING_VALUE and i + 1 < len(argv):
|
|
91
|
+
result.append(f"{arg}={argv[i + 1]}")
|
|
92
|
+
i += 2
|
|
93
|
+
else:
|
|
94
|
+
result.append(arg)
|
|
95
|
+
i += 1
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_args(argv: list[str]) -> Args:
|
|
100
|
+
parser = _Parser(prog="gitcalver", add_help=False, allow_abbrev=False)
|
|
101
|
+
parser.add_argument("--help", action="store_true")
|
|
102
|
+
parser.add_argument("--version", action="store_true")
|
|
103
|
+
parser.add_argument("--prefix", default="")
|
|
104
|
+
parser.add_argument("--dirty", action=_NonEmptyStrAction, default="")
|
|
105
|
+
parser.add_argument("--no-dirty", action="store_true")
|
|
106
|
+
parser.add_argument("--no-dirty-hash", action="store_true")
|
|
107
|
+
parser.add_argument("--branch", default=None)
|
|
108
|
+
parser.add_argument("--short", action="store_true")
|
|
109
|
+
parser.add_argument("positional", nargs="?", default=None)
|
|
110
|
+
|
|
111
|
+
args = parser.parse_args(_normalize_argv(argv), namespace=Args())
|
|
112
|
+
|
|
113
|
+
if args.no_dirty_hash and not args.dirty:
|
|
114
|
+
parser.error("--no-dirty-hash requires --dirty")
|
|
115
|
+
|
|
116
|
+
return args
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _build_format(args: Args) -> Format:
|
|
120
|
+
dirty_suffix: str | None = None if args.no_dirty else (args.dirty or None)
|
|
121
|
+
return Format(
|
|
122
|
+
prefix=args.prefix,
|
|
123
|
+
dirty_suffix=dirty_suffix,
|
|
124
|
+
dirty_hash=not args.no_dirty_hash,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def run(argv: list[str], *, dir: str | None = None) -> tuple[str, int]:
|
|
129
|
+
try:
|
|
130
|
+
args = _parse_args(argv)
|
|
131
|
+
except ExitError as e:
|
|
132
|
+
return f"gitcalver: {e.message}", e.code
|
|
133
|
+
|
|
134
|
+
if args.help:
|
|
135
|
+
return USAGE.rstrip("\n"), 0
|
|
136
|
+
|
|
137
|
+
if args.version:
|
|
138
|
+
return f"gitcalver {_package_version()}", 0
|
|
139
|
+
|
|
140
|
+
lookup: str | None = None
|
|
141
|
+
# Require the prefix to match before reverse-lookup; avoids treating a
|
|
142
|
+
# bare `20260410.1` as a version when --prefix is set.
|
|
143
|
+
if args.positional is not None and args.positional.startswith(args.prefix):
|
|
144
|
+
candidate = args.positional.removeprefix(args.prefix)
|
|
145
|
+
if is_version_string(candidate):
|
|
146
|
+
lookup = candidate
|
|
147
|
+
|
|
148
|
+
if args.short and lookup is None:
|
|
149
|
+
return "gitcalver: --short is only valid in reverse lookup mode", 1
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
if lookup is not None:
|
|
153
|
+
result = reverse(
|
|
154
|
+
dir=dir,
|
|
155
|
+
version_str=lookup,
|
|
156
|
+
branch_override=args.branch,
|
|
157
|
+
short=args.short,
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
fmt = _build_format(args)
|
|
161
|
+
result = forward(
|
|
162
|
+
dir=dir,
|
|
163
|
+
revision=args.positional,
|
|
164
|
+
fmt=fmt,
|
|
165
|
+
branch_override=args.branch,
|
|
166
|
+
)
|
|
167
|
+
except ExitError as e:
|
|
168
|
+
return f"gitcalver: {e.message}", e.code
|
|
169
|
+
|
|
170
|
+
return result, 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def main(argv: list[str] | None = None) -> NoReturn:
|
|
174
|
+
if argv is None:
|
|
175
|
+
argv = sys.argv[1:]
|
|
176
|
+
output, code = run(argv)
|
|
177
|
+
print(output, file=sys.stderr if code != 0 else sys.stdout)
|
|
178
|
+
sys.exit(code)
|
gitcalver/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gitcalver
|
|
3
|
+
Version: 20260418.5
|
|
4
|
+
Summary: Deterministic calendar versioning from git history
|
|
5
|
+
Project-URL: Homepage, https://gitcalver.org
|
|
6
|
+
Project-URL: Source, https://github.com/gitcalver/python
|
|
7
|
+
Project-URL: Issues, https://github.com/gitcalver/python/issues
|
|
8
|
+
Author-email: Michael Shields <shields@gitcalver.org>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: calendar-versioning,calver,git,versioning
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
16
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# gitcalver
|
|
21
|
+
|
|
22
|
+
A Python implementation of [GitCalVer](https://gitcalver.org), which derives
|
|
23
|
+
calendar-based version numbers from git history.
|
|
24
|
+
|
|
25
|
+
Each commit on the default branch gets a unique, strictly increasing version of
|
|
26
|
+
the form `YYYYMMDD.N`, where `N` is the number of commits on that UTC date.
|
|
27
|
+
|
|
28
|
+
See the [GitCalVer specification](https://gitcalver.org) for full details.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
uv add gitcalver
|
|
34
|
+
# or
|
|
35
|
+
pip install gitcalver
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
See [Requirements](#requirements) below.
|
|
39
|
+
|
|
40
|
+
## CLI usage
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
gitcalver [OPTIONS] [REVISION | VERSION]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
With no arguments, prints the version for `HEAD`:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
$ gitcalver
|
|
50
|
+
20260411.3
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Pass a revision to compute its version:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
$ gitcalver HEAD~1
|
|
57
|
+
20260411.2
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Version prefix
|
|
61
|
+
|
|
62
|
+
Use `--prefix` to prepend a literal string:
|
|
63
|
+
|
|
64
|
+
| Use case | Command | Example output |
|
|
65
|
+
|----------|------------------------------|------------------|
|
|
66
|
+
| Default | `gitcalver` | `20260411.3` |
|
|
67
|
+
| SemVer | `gitcalver --prefix "0."` | `0.20260411.3` |
|
|
68
|
+
| Go | `gitcalver --prefix "v0."` | `v0.20260411.3` |
|
|
69
|
+
|
|
70
|
+
### Dirty workspace
|
|
71
|
+
|
|
72
|
+
By default, `gitcalver` exits with status 2 if the workspace has uncommitted
|
|
73
|
+
changes. Use `--dirty STRING` to produce a version instead; the output will
|
|
74
|
+
include the given string and a short commit hash
|
|
75
|
+
(e.g. `--dirty "-dirty"` produces `20260411.3-dirty.abc1234`).
|
|
76
|
+
|
|
77
|
+
Use `--no-dirty-hash` with `--dirty` to suppress the hash suffix.
|
|
78
|
+
Use `--no-dirty` to explicitly refuse dirty versions (overrides `--dirty`).
|
|
79
|
+
|
|
80
|
+
Dirty versions are a convenience and are not necessarily unique.
|
|
81
|
+
|
|
82
|
+
### Reverse lookup
|
|
83
|
+
|
|
84
|
+
Pass a version number to get the corresponding commit hash:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
$ gitcalver 20260411.3
|
|
88
|
+
a1b2c3d4e5f6...
|
|
89
|
+
|
|
90
|
+
$ gitcalver --short --prefix "0." 0.20260411.3
|
|
91
|
+
a1b2c3d
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
If the version was generated with `--prefix`, pass the same `--prefix` for
|
|
95
|
+
reverse lookup. Dirty versions cannot be reversed.
|
|
96
|
+
|
|
97
|
+
### Options
|
|
98
|
+
|
|
99
|
+
| Option | Description |
|
|
100
|
+
|---------------------|------------------------------------------------|
|
|
101
|
+
| `--prefix PREFIX` | Literal string prepended to version |
|
|
102
|
+
| `--dirty STRING` | Enable dirty versions; append `STRING.HASH` |
|
|
103
|
+
| `--no-dirty` | Refuse dirty versions (overrides `--dirty`) |
|
|
104
|
+
| `--no-dirty-hash` | Suppress `.HASH` suffix (requires `--dirty`) |
|
|
105
|
+
| `--branch BRANCH` | Base branch name; overrides auto-detection. This is the branch versions are minted on, not the branch you are working on. |
|
|
106
|
+
| `--short` | Output short commit hash (reverse lookup mode) |
|
|
107
|
+
| `--help` | Show help |
|
|
108
|
+
|
|
109
|
+
### Exit codes
|
|
110
|
+
|
|
111
|
+
| Code | Meaning |
|
|
112
|
+
|------|----------------------------------------|
|
|
113
|
+
| 0 | Success |
|
|
114
|
+
| 1 | Error (not a git repo, no commits, non-monotonic dates, shallow clone) |
|
|
115
|
+
| 2 | Dirty workspace or off default branch (without `--dirty`) |
|
|
116
|
+
| 3 | Cannot trace to default branch |
|
|
117
|
+
|
|
118
|
+
## Python API
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
import gitcalver
|
|
122
|
+
|
|
123
|
+
# Forward: compute a version for HEAD (or a specific revision).
|
|
124
|
+
version = gitcalver.get_version(repo="/path/to/repo")
|
|
125
|
+
# e.g. "20260411.3"
|
|
126
|
+
|
|
127
|
+
version = gitcalver.get_version(
|
|
128
|
+
repo="/path/to/repo",
|
|
129
|
+
revision="HEAD~1",
|
|
130
|
+
prefix="v0.",
|
|
131
|
+
dirty="-dirty",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Reverse: resolve a version back to a commit hash.
|
|
135
|
+
commit = gitcalver.find_commit("20260411.3", repo="/path/to/repo")
|
|
136
|
+
|
|
137
|
+
# If the version was generated with --prefix, pass the same prefix:
|
|
138
|
+
commit = gitcalver.find_commit(
|
|
139
|
+
"v0.20260411.3", prefix="v0.", repo="/path/to/repo"
|
|
140
|
+
)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Errors are raised as `gitcalver.ExitError`, which carries a `code` attribute
|
|
144
|
+
matching the CLI exit codes above.
|
|
145
|
+
|
|
146
|
+
## Hatch plugin
|
|
147
|
+
|
|
148
|
+
`gitcalver` ships a [Hatch](https://hatch.pypa.io/) version source plugin. To
|
|
149
|
+
use it in `pyproject.toml`:
|
|
150
|
+
|
|
151
|
+
```toml
|
|
152
|
+
[build-system]
|
|
153
|
+
requires = ["hatchling", "gitcalver"]
|
|
154
|
+
build-backend = "hatchling.build"
|
|
155
|
+
|
|
156
|
+
[tool.hatch.version]
|
|
157
|
+
source = "gitcalver"
|
|
158
|
+
# Optional:
|
|
159
|
+
# prefix = "0."
|
|
160
|
+
# dirty = "-dirty"
|
|
161
|
+
# no-dirty-hash = true
|
|
162
|
+
# branch = "main"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Requirements
|
|
166
|
+
|
|
167
|
+
- Python 3.10+
|
|
168
|
+
- `git` on `$PATH`
|
|
169
|
+
- Full commit history (shallow clones made with `--depth` are rejected; partial
|
|
170
|
+
clones made with `--filter=blob:none` are fine)
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
gitcalver/__init__.py,sha256=7896Q4BsaA7d2DdXCSvjrAfRd7LfuWvJfPNzhcZm7cY,1096
|
|
2
|
+
gitcalver/__main__.py,sha256=8sjB1kMwKos_iow-KbnxPrfBUbZvZ7vH-EE4Zo6ct_k,138
|
|
3
|
+
gitcalver/_branch.py,sha256=7GY1RFwF9nX9WpnduIILkTwIr2UjL2fyxRg4ENqo_74,1836
|
|
4
|
+
gitcalver/_errors.py,sha256=6kB1YEdObCWsEtWR1EYtUG8r4wbNqZFHPbDPdh8ihEs,310
|
|
5
|
+
gitcalver/_format.py,sha256=yJyq99VbM3dzCXXTXHWsNBQvv3ACiys9Fusc2uRhx4g,654
|
|
6
|
+
gitcalver/_git.py,sha256=iZJhSDpZ9nX4p-3SOnBmos7XpFdznACQUaTzKrqxNF4,3395
|
|
7
|
+
gitcalver/_hatch_hooks.py,sha256=Iz8jhG7a_NMYCujrFfor4pfRxv3uFDfkwgRTwLY9Up4,441
|
|
8
|
+
gitcalver/_hatch_source.py,sha256=oclwg-P4aVEHL-7VA-2D_7mbV9SungeJ7O-Xs1adsAs,999
|
|
9
|
+
gitcalver/_version.py,sha256=S9cY8XG5jGlzIBYBjWES-9a7QullpNiM7ezNEBSgXvE,5735
|
|
10
|
+
gitcalver/cli.py,sha256=tpNNauoo6HlaILl_dejaFEZkEsNcZnfEE1daeBxBPOQ,5687
|
|
11
|
+
gitcalver/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
gitcalver-20260418.5.dist-info/METADATA,sha256=Qdg9lhDf0bVY2ZH-kQ_9dqekbZnJNO9ZXZkUi2DGlwI,5041
|
|
13
|
+
gitcalver-20260418.5.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
14
|
+
gitcalver-20260418.5.dist-info/entry_points.txt,sha256=IXcVpCaN0XiVcET1HWKbV_KGUsjFW9ecD77_YgFpju8,93
|
|
15
|
+
gitcalver-20260418.5.dist-info/licenses/LICENSE,sha256=yMt4bLjW824Sn1DDZSbUz0K4TeJsl8jX4mVQfENnRe0,1058
|
|
16
|
+
gitcalver-20260418.5.dist-info/RECORD,,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright © 2026 Michael Shields
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|