tagpoint 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.
- tagpoint/__init__.py +4 -0
- tagpoint/__main__.py +4 -0
- tagpoint/checker.py +221 -0
- tagpoint/cli.py +45 -0
- tagpoint-0.1.0.dist-info/METADATA +88 -0
- tagpoint-0.1.0.dist-info/RECORD +10 -0
- tagpoint-0.1.0.dist-info/WHEEL +5 -0
- tagpoint-0.1.0.dist-info/entry_points.txt +2 -0
- tagpoint-0.1.0.dist-info/licenses/LICENSE +21 -0
- tagpoint-0.1.0.dist-info/top_level.txt +1 -0
tagpoint/__init__.py
ADDED
tagpoint/__main__.py
ADDED
tagpoint/checker.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Core consistency checks between a PyPI release and its GitHub tag.
|
|
2
|
+
|
|
3
|
+
tagpoint answers one question: does the tag named after the PyPI version
|
|
4
|
+
actually point at the code the release was built from? It fetches the PyPI
|
|
5
|
+
metadata, resolves the matching GitHub tag, downloads the sdist, and compares
|
|
6
|
+
what can be compared without a full git checkout:
|
|
7
|
+
|
|
8
|
+
1. the tag exists (v{version} or {version})
|
|
9
|
+
2. the sdist PKG-INFO version matches the PyPI version
|
|
10
|
+
3. (deep) the sdist file list matches the tag's git tree file list,
|
|
11
|
+
flagging files the tag gained/lost after the release was built
|
|
12
|
+
4. (deep) the tag commit date is not newer than the release upload time
|
|
13
|
+
(a tag moved *after* the build is suspicious drift)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import io
|
|
20
|
+
import json
|
|
21
|
+
import tarfile
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
import zipfile
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
PYPI_JSON_URL = "https://pypi.org/pypi/{name}/json"
|
|
28
|
+
GITHUB_API = "https://api.github.com"
|
|
29
|
+
|
|
30
|
+
_USER_AGENT = "tagpoint/0.1.0 (+https://github.com/hahahahahahahahah6/tagpoint)"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CheckError(Exception):
|
|
34
|
+
"""Raised when a check cannot be completed (network, missing data, ...)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Finding:
|
|
39
|
+
ok: bool
|
|
40
|
+
name: str
|
|
41
|
+
detail: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Report:
|
|
46
|
+
package: str
|
|
47
|
+
version: str
|
|
48
|
+
repo: str
|
|
49
|
+
tag: str | None
|
|
50
|
+
commit: str | None
|
|
51
|
+
findings: list[Finding] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def consistent(self) -> bool:
|
|
55
|
+
return bool(self.findings) and all(f.ok for f in self.findings)
|
|
56
|
+
|
|
57
|
+
def render(self) -> str:
|
|
58
|
+
lines = [
|
|
59
|
+
f"tagpoint report: {self.package}=={self.version} repo={self.repo}",
|
|
60
|
+
f"tag: {self.tag or '(none)'} commit: {(self.commit or '(none)')[:12]}",
|
|
61
|
+
"",
|
|
62
|
+
]
|
|
63
|
+
for f in self.findings:
|
|
64
|
+
mark = "PASS" if f.ok else "FAIL"
|
|
65
|
+
lines.append(f"[{mark}] {f.name}: {f.detail}")
|
|
66
|
+
lines.append("")
|
|
67
|
+
verdict = "CONSISTENT" if self.consistent else "INCONSISTENT"
|
|
68
|
+
lines.append(f"verdict: {verdict}")
|
|
69
|
+
return "\n".join(lines)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _fetch_json(url: str, token: str | None = None) -> dict:
|
|
73
|
+
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
|
74
|
+
if token:
|
|
75
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
76
|
+
try:
|
|
77
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
78
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
79
|
+
except urllib.error.HTTPError as e:
|
|
80
|
+
raise CheckError(f"HTTP {e.code} fetching {url}") from e
|
|
81
|
+
except urllib.error.URLError as e:
|
|
82
|
+
raise CheckError(f"network error fetching {url}: {e.reason}") from e
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _fetch_bytes(url: str) -> bytes:
|
|
86
|
+
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
|
87
|
+
try:
|
|
88
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
89
|
+
return resp.read()
|
|
90
|
+
except urllib.error.HTTPError as e:
|
|
91
|
+
raise CheckError(f"HTTP {e.code} downloading {url}") from e
|
|
92
|
+
except urllib.error.URLError as e:
|
|
93
|
+
raise CheckError(f"network error downloading {url}: {e.reason}") from e
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _sdist_files(blob: bytes, filename: str) -> tuple[str, list[str]]:
|
|
97
|
+
"""Return (PKG-INFO version, sorted file list) from an sdist blob."""
|
|
98
|
+
version = ""
|
|
99
|
+
names: list[str] = []
|
|
100
|
+
if filename.endswith(".zip"):
|
|
101
|
+
with zipfile.ZipFile(io.BytesIO(blob)) as zf:
|
|
102
|
+
for info in zf.infolist():
|
|
103
|
+
names.append(info.filename)
|
|
104
|
+
if info.filename.endswith("PKG-INFO"):
|
|
105
|
+
version = _pkg_info_version(zf.read(info.filename).decode("utf-8", "replace"))
|
|
106
|
+
else: # .tar.gz
|
|
107
|
+
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tf:
|
|
108
|
+
for member in tf.getmembers():
|
|
109
|
+
names.append(member.name)
|
|
110
|
+
if member.name.endswith("PKG-INFO") and member.isfile():
|
|
111
|
+
f = tf.extractfile(member)
|
|
112
|
+
if f:
|
|
113
|
+
version = _pkg_info_version(f.read().decode("utf-8", "replace"))
|
|
114
|
+
return version, sorted(names)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _pkg_info_version(text: str) -> str:
|
|
118
|
+
for line in text.splitlines():
|
|
119
|
+
if line.lower().startswith("version:"):
|
|
120
|
+
return line.split(":", 1)[1].strip()
|
|
121
|
+
return ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _sha256_file(path: str) -> str:
|
|
125
|
+
h = hashlib.sha256()
|
|
126
|
+
with open(path, "rb") as f:
|
|
127
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
128
|
+
h.update(chunk)
|
|
129
|
+
return h.hexdigest()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _strip_top_dir(names: list[str]) -> set[str]:
|
|
133
|
+
"""Drop the sdist's single top-level directory so paths compare to a git tree."""
|
|
134
|
+
out = set()
|
|
135
|
+
for n in names:
|
|
136
|
+
parts = n.strip("/").split("/")
|
|
137
|
+
if len(parts) > 1:
|
|
138
|
+
out.add("/".join(parts[1:]))
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check(package: str, repo: str, *, deep: bool = True, token: str | None = None) -> Report:
|
|
143
|
+
"""Run the full consistency check and return a Report."""
|
|
144
|
+
meta = _fetch_json(PYPI_JSON_URL.format(name=package), token)
|
|
145
|
+
version = meta["info"]["version"]
|
|
146
|
+
report = Report(package=package, version=version, repo=repo, tag=None, commit=None)
|
|
147
|
+
|
|
148
|
+
# 1. resolve the tag
|
|
149
|
+
tag_name = None
|
|
150
|
+
commit = None
|
|
151
|
+
for candidate in (f"v{version}", version):
|
|
152
|
+
try:
|
|
153
|
+
ref = _fetch_json(f"{GITHUB_API}/repos/{repo}/git/refs/tags/{candidate}", token)
|
|
154
|
+
except CheckError:
|
|
155
|
+
continue
|
|
156
|
+
tag_name = candidate
|
|
157
|
+
obj = ref["object"]
|
|
158
|
+
if obj["type"] == "tag": # annotated tag -> peel to commit
|
|
159
|
+
peeled = _fetch_json(obj["url"], token)
|
|
160
|
+
commit = peeled["object"]["sha"]
|
|
161
|
+
else:
|
|
162
|
+
commit = obj["sha"]
|
|
163
|
+
break
|
|
164
|
+
report.tag, report.commit = tag_name, commit
|
|
165
|
+
if tag_name is None:
|
|
166
|
+
report.findings.append(Finding(False, "tag-exists",
|
|
167
|
+
f"no tag v{version} or {version} on {repo}"))
|
|
168
|
+
return report
|
|
169
|
+
report.findings.append(Finding(True, "tag-exists",
|
|
170
|
+
f"tag {tag_name} -> {commit[:12]}"))
|
|
171
|
+
|
|
172
|
+
# 2. sdist sanity: PKG-INFO version must match PyPI version
|
|
173
|
+
files = meta["releases"].get(version, [])
|
|
174
|
+
sdists = [f for f in files if f["packagetype"] == "sdist"]
|
|
175
|
+
if not sdists:
|
|
176
|
+
report.findings.append(Finding(False, "sdist-present",
|
|
177
|
+
"no sdist file in this release"))
|
|
178
|
+
return report
|
|
179
|
+
sdist = sdists[0]
|
|
180
|
+
blob = _fetch_bytes(sdist["url"])
|
|
181
|
+
pkg_version, sdist_names = _sdist_files(blob, sdist["filename"])
|
|
182
|
+
if pkg_version == version:
|
|
183
|
+
report.findings.append(Finding(True, "sdist-version",
|
|
184
|
+
f"PKG-INFO version {pkg_version} matches PyPI"))
|
|
185
|
+
else:
|
|
186
|
+
report.findings.append(Finding(False, "sdist-version",
|
|
187
|
+
f"PKG-INFO says {pkg_version!r}, PyPI says {version!r}"))
|
|
188
|
+
|
|
189
|
+
if not deep:
|
|
190
|
+
return report
|
|
191
|
+
|
|
192
|
+
# 3. file-list comparison: sdist contents vs tag tree
|
|
193
|
+
tree = _fetch_json(f"{GITHUB_API}/repos/{repo}/git/trees/{commit}?recursive=1", token)
|
|
194
|
+
tag_paths = {t["path"] for t in tree.get("tree", []) if t["type"] == "blob"}
|
|
195
|
+
# PKG-INFO is generated at build time; it never lives in the git tree.
|
|
196
|
+
sdist_paths = _strip_top_dir(sdist_names) - {"PKG-INFO"}
|
|
197
|
+
only_tag = sorted(tag_paths - sdist_paths)
|
|
198
|
+
only_sdist = sorted(sdist_paths - tag_paths)
|
|
199
|
+
if not only_tag and not only_sdist:
|
|
200
|
+
report.findings.append(Finding(True, "file-list",
|
|
201
|
+
f"{len(tag_paths)} files match the tag tree"))
|
|
202
|
+
else:
|
|
203
|
+
detail = []
|
|
204
|
+
if only_tag:
|
|
205
|
+
detail.append(f"{len(only_tag)} only in tag (e.g. {', '.join(only_tag[:3])})")
|
|
206
|
+
if only_sdist:
|
|
207
|
+
detail.append(f"{len(only_sdist)} only in sdist (e.g. {', '.join(only_sdist[:3])})")
|
|
208
|
+
report.findings.append(Finding(False, "file-list", "; ".join(detail)))
|
|
209
|
+
|
|
210
|
+
# 4. tag commit must not be newer than the release upload
|
|
211
|
+
commit_meta = _fetch_json(f"{GITHUB_API}/repos/{repo}/commits/{commit}", token)
|
|
212
|
+
commit_date = commit_meta["commit"]["committer"]["date"]
|
|
213
|
+
upload_time = sdist["upload_time_iso_8601"]
|
|
214
|
+
if commit_date <= upload_time:
|
|
215
|
+
report.findings.append(Finding(True, "tag-before-build",
|
|
216
|
+
f"tag commit {commit_date} <= upload {upload_time}"))
|
|
217
|
+
else:
|
|
218
|
+
report.findings.append(Finding(False, "tag-before-build",
|
|
219
|
+
f"tag commit {commit_date} is NEWER than upload {upload_time} — tag drifted"))
|
|
220
|
+
|
|
221
|
+
return report
|
tagpoint/cli.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Command-line interface for tagpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .checker import CheckError, check
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
p = argparse.ArgumentParser(
|
|
15
|
+
prog="tagpoint",
|
|
16
|
+
description="Verify a PyPI release matches its GitHub tag (name AND content).",
|
|
17
|
+
)
|
|
18
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
19
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
20
|
+
|
|
21
|
+
c = sub.add_parser("check", help="check one package release against its repo tag")
|
|
22
|
+
c.add_argument("package", help="PyPI package name, e.g. requests")
|
|
23
|
+
c.add_argument("--repo", required=True, help="GitHub repo as owner/name, e.g. psf/requests")
|
|
24
|
+
c.add_argument("--shallow", action="store_true",
|
|
25
|
+
help="skip file-list and tag-date checks (tag existence + sdist version only)")
|
|
26
|
+
c.add_argument("--token", default=os.environ.get("GITHUB_TOKEN"),
|
|
27
|
+
help="GitHub token (or set GITHUB_TOKEN) to raise API rate limits")
|
|
28
|
+
return p
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: list[str] | None = None) -> int:
|
|
32
|
+
args = build_parser().parse_args(argv)
|
|
33
|
+
if args.command == "check":
|
|
34
|
+
try:
|
|
35
|
+
report = check(args.package, args.repo, deep=not args.shallow, token=args.token)
|
|
36
|
+
except CheckError as e:
|
|
37
|
+
print(f"tagpoint: error: {e}", file=sys.stderr)
|
|
38
|
+
return 2
|
|
39
|
+
print(report.render())
|
|
40
|
+
return 0 if report.consistent else 1
|
|
41
|
+
return 2
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tagpoint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Verify a PyPI release actually matches its GitHub tag — name AND content
|
|
5
|
+
Author-email: hao li <haoli5933@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hahahahahahahahah6/tagpoint
|
|
8
|
+
Project-URL: Repository, https://github.com/hahahahahahahahah6/tagpoint
|
|
9
|
+
Keywords: pypi,github,supply-chain,release,tag,integrity
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# tagpoint
|
|
23
|
+
|
|
24
|
+
Verify that a PyPI release **actually matches** its GitHub tag — the name *and* the content.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install tagpoint
|
|
28
|
+
tagpoint check requests --repo psf/requests
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Output:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
tagpoint report: requests==2.32.3 repo=psf/requests
|
|
35
|
+
tag: v2.32.3 commit: abc123def456
|
|
36
|
+
|
|
37
|
+
[PASS] tag-exists: tag v2.32.3 -> abc123def456
|
|
38
|
+
[PASS] sdist-version: PKG-INFO version 2.32.3 matches PyPI
|
|
39
|
+
[PASS] file-list: 128 files match the tag tree
|
|
40
|
+
[PASS] tag-before-build: tag commit 2024-05-29T... <= upload 2024-05-29T...
|
|
41
|
+
|
|
42
|
+
verdict: CONSISTENT
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Exit code `0` = consistent, `1` = inconsistent, `2` = check error (network, missing data).
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install tagpoint
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
tagpoint is stdlib-only — no third-party dependencies.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
tagpoint check <pypi-package> --repo owner/name [--shallow] [--token TOKEN]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- `tagpoint check flask --repo pallets/flask` — full check (default)
|
|
62
|
+
- `--shallow` — only verify the tag exists and the sdist version matches
|
|
63
|
+
- `--token` — GitHub token (or `GITHUB_TOKEN` env var) to raise API rate limits
|
|
64
|
+
|
|
65
|
+
Four checks run by default:
|
|
66
|
+
|
|
67
|
+
1. **tag-exists** — a tag named `v{version}` or `{version}` exists and resolves (annotated tags are peeled to the commit).
|
|
68
|
+
2. **sdist-version** — the sdist's `PKG-INFO` version equals the PyPI version.
|
|
69
|
+
3. **file-list** — the sdist file list matches the tag's git tree (catches files added/removed after the build).
|
|
70
|
+
4. **tag-before-build** — the tag commit date is not newer than the release upload time (a tag moved *after* the build is drift).
|
|
71
|
+
|
|
72
|
+
## Differentiation
|
|
73
|
+
|
|
74
|
+
There are tools that check whether a release tag **exists** (e.g. my
|
|
75
|
+
[tagtruth](https://github.com/hahahahahahahahah6/tagtruth) project checks tag
|
|
76
|
+
presence as part of release hygiene). tagpoint goes one step further: it checks
|
|
77
|
+
whether the tag **points at the code the release was actually built from**.
|
|
78
|
+
|
|
79
|
+
The failure mode it catches is real: a tag named `v1.2.3` exists, CI is green,
|
|
80
|
+
everything looks fine — but the tag was force-moved after the sdist was built,
|
|
81
|
+
or the release was cut from a dirty working tree. The name matches; the
|
|
82
|
+
content doesn't. tagpoint compares the sdist contents against the tag's git
|
|
83
|
+
tree and flags the drift. Think of it as a "name matches reality" check for
|
|
84
|
+
your supply chain.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
tagpoint/__init__.py,sha256=YbfTUvCFBDCwxYIIsEe1tNigQUaTyqNmvYpPr4OGEd0,116
|
|
2
|
+
tagpoint/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
|
|
3
|
+
tagpoint/checker.py,sha256=q3auiXS5kxNh955xoGi3CVOXg6eaxteNBeXdoaT_L1w,8547
|
|
4
|
+
tagpoint/cli.py,sha256=bfPuUP6Q8nSlgAzOxW5g8b6KfbsdBV8ZlkC7wg93ifw,1596
|
|
5
|
+
tagpoint-0.1.0.dist-info/licenses/LICENSE,sha256=OYhQDg7nxWoFtnF6J8NkFUA8NMw5TKv9a1WTXHY-fu4,1063
|
|
6
|
+
tagpoint-0.1.0.dist-info/METADATA,sha256=U6XeDIAQuCo7UfyR5aSDjFLj4ZvXOFO0zNBpt6MU70c,3126
|
|
7
|
+
tagpoint-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
tagpoint-0.1.0.dist-info/entry_points.txt,sha256=mS2lD2VWfursgDpObq_lfEcm2zyIA1coj-9pvUQUGZA,47
|
|
9
|
+
tagpoint-0.1.0.dist-info/top_level.txt,sha256=Jfx1jtggOTB7aJXBuN-RkmXTfjOST9dJ1666SxNeDwM,9
|
|
10
|
+
tagpoint-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hao li
|
|
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
|
+
tagpoint
|