tears-cli 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.
- tears/__init__.py +1 -0
- tears/__main__.py +4 -0
- tears/checker.py +153 -0
- tears/cli.py +341 -0
- tears/config.py +235 -0
- tears/exclude.py +38 -0
- tears/graph/__init__.py +42 -0
- tears/graph/grimp_builder.py +145 -0
- tears/header.py +30 -0
- tears/hook.py +84 -0
- tears/mutate.py +124 -0
- tears/rules.py +52 -0
- tears/scan.py +83 -0
- tears/styles.py +91 -0
- tears_cli-0.1.0.dist-info/METADATA +29 -0
- tears_cli-0.1.0.dist-info/RECORD +18 -0
- tears_cli-0.1.0.dist-info/WHEEL +4 -0
- tears_cli-0.1.0.dist-info/entry_points.txt +3 -0
tears/mutate.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# @tear: 3
|
|
2
|
+
"""Core tear-mutation primitives shared by the hook and CLI subcommands.
|
|
3
|
+
|
|
4
|
+
`set_tear` sets a @tear header to an arbitrary level (replacing an existing one
|
|
5
|
+
or inserting a new one). `process_file` is the filesystem boundary. `find_repo_root`
|
|
6
|
+
locates the nearest .git dir or .tears.toml.
|
|
7
|
+
|
|
8
|
+
The hook always calls process_file with tear=max_tear. The CLI subcommands
|
|
9
|
+
`tears up`, `tears down`, and `tears set` call it after validating direction.
|
|
10
|
+
`tears init` calls set_tear directly (it already holds the file content).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from tears.exclude import is_excluded
|
|
18
|
+
from tears.styles import (
|
|
19
|
+
COMMENT_STYLES,
|
|
20
|
+
ENCODING_RE,
|
|
21
|
+
FILENAME_STYLES,
|
|
22
|
+
LINE_HEADER_RE,
|
|
23
|
+
MAX_HEADER_LINES,
|
|
24
|
+
SHEBANG_RE,
|
|
25
|
+
CommentStyle,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def set_tear(
|
|
30
|
+
content: str,
|
|
31
|
+
*,
|
|
32
|
+
tear: int = 3,
|
|
33
|
+
extension: str = ".py",
|
|
34
|
+
filename: str = "",
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Return `content` with the @tear header set to `tear`.
|
|
37
|
+
|
|
38
|
+
Replaces any existing @tear line in the first 5 lines. If none is found and
|
|
39
|
+
the file type is known (by extension or filename), inserts a new header
|
|
40
|
+
respecting shebangs and PEP 263 encoding declarations.
|
|
41
|
+
"""
|
|
42
|
+
lines = content.splitlines(keepends=True)
|
|
43
|
+
|
|
44
|
+
replaced = False
|
|
45
|
+
for i, line in enumerate(lines[:MAX_HEADER_LINES]):
|
|
46
|
+
new_line, n = LINE_HEADER_RE.subn(rf"\g<1>{tear}", line, count=1)
|
|
47
|
+
if n:
|
|
48
|
+
lines[i] = new_line
|
|
49
|
+
replaced = True
|
|
50
|
+
|
|
51
|
+
if replaced:
|
|
52
|
+
return "".join(lines)
|
|
53
|
+
|
|
54
|
+
style = _resolve_style(extension, filename)
|
|
55
|
+
if style is None:
|
|
56
|
+
return content
|
|
57
|
+
|
|
58
|
+
insert_at = 0
|
|
59
|
+
if lines and SHEBANG_RE.match(lines[0]):
|
|
60
|
+
insert_at = 1
|
|
61
|
+
if insert_at < len(lines) and ENCODING_RE.search(lines[insert_at]):
|
|
62
|
+
insert_at += 1
|
|
63
|
+
|
|
64
|
+
ending = _detect_line_ending(lines)
|
|
65
|
+
lines.insert(insert_at, _format_header(style, tear) + ending)
|
|
66
|
+
return "".join(lines)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def process_file(
|
|
70
|
+
path: Path,
|
|
71
|
+
*,
|
|
72
|
+
tear: int,
|
|
73
|
+
exclude: list[str],
|
|
74
|
+
repo_root: Path,
|
|
75
|
+
) -> bool:
|
|
76
|
+
"""Apply `set_tear` to a single file. Returns True iff the file was modified."""
|
|
77
|
+
if not path.is_file():
|
|
78
|
+
return False
|
|
79
|
+
if is_excluded(path, repo_root, exclude):
|
|
80
|
+
return False
|
|
81
|
+
content = path.read_text()
|
|
82
|
+
new_content = set_tear(content, tear=tear, extension=path.suffix, filename=path.name)
|
|
83
|
+
if new_content == content:
|
|
84
|
+
return False
|
|
85
|
+
path.write_text(new_content)
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def find_repo_root(start: Path) -> Path:
|
|
90
|
+
"""Walk up from `start` to find the repo root (.git dir wins, then .tears.toml)."""
|
|
91
|
+
here = start.resolve()
|
|
92
|
+
if here.is_file():
|
|
93
|
+
here = here.parent
|
|
94
|
+
ancestors = (here, *here.parents)
|
|
95
|
+
for ancestor in ancestors:
|
|
96
|
+
if (ancestor / ".git").exists():
|
|
97
|
+
return ancestor
|
|
98
|
+
for ancestor in ancestors:
|
|
99
|
+
if (ancestor / ".tears.toml").exists():
|
|
100
|
+
return ancestor
|
|
101
|
+
return Path.cwd()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _resolve_style(extension: str, filename: str) -> CommentStyle | None:
|
|
105
|
+
style = COMMENT_STYLES.get(extension.lower())
|
|
106
|
+
if style is not None:
|
|
107
|
+
return style
|
|
108
|
+
return FILENAME_STYLES.get(filename)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _format_header(style: CommentStyle, tear: int) -> str:
|
|
112
|
+
opener, closer = style
|
|
113
|
+
if closer is None:
|
|
114
|
+
return f"{opener} @tear: {tear}"
|
|
115
|
+
return f"{opener} @tear: {tear} {closer}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _detect_line_ending(lines: list[str]) -> str:
|
|
119
|
+
for line in lines:
|
|
120
|
+
if line.endswith("\r\n"):
|
|
121
|
+
return "\r\n"
|
|
122
|
+
if line.endswith("\n"):
|
|
123
|
+
return "\n"
|
|
124
|
+
return "\n"
|
tears/rules.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @tear: 3
|
|
2
|
+
"""Pure rule functions: tier comparison and directory requirements.
|
|
3
|
+
|
|
4
|
+
These functions know nothing about files, imports, or graphs — they take primitive
|
|
5
|
+
inputs and return booleans. The checker composes them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def can_import(
|
|
12
|
+
importer_tier: int,
|
|
13
|
+
target_tier: int,
|
|
14
|
+
resolved_rules: dict[int, frozenset[int]],
|
|
15
|
+
) -> bool:
|
|
16
|
+
"""Is `importer_tier` allowed to import from `target_tier`?
|
|
17
|
+
|
|
18
|
+
`resolved_rules` is the pre-computed full matrix from
|
|
19
|
+
`TearsConfig.resolved_import_rules()`. Per-edge check is one set membership.
|
|
20
|
+
"""
|
|
21
|
+
return target_tier in resolved_rules[importer_tier]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_directory_requirement(
|
|
25
|
+
file_path: str,
|
|
26
|
+
file_tier: int,
|
|
27
|
+
requirements: dict[str, int],
|
|
28
|
+
) -> bool:
|
|
29
|
+
"""Does `file_tier` satisfy the longest-prefix-matching directory requirement?
|
|
30
|
+
|
|
31
|
+
Matching is path-segment aware: `src/auth` matches `src/auth/tokens.py` but NOT
|
|
32
|
+
`src/authentic/foo.py`. Files in unrestricted directories pass.
|
|
33
|
+
"""
|
|
34
|
+
file_segments = _segments(file_path)
|
|
35
|
+
longest_match: int | None = None
|
|
36
|
+
longest_len = -1
|
|
37
|
+
for dir_key, required_tier in requirements.items():
|
|
38
|
+
dir_segments = _segments(dir_key)
|
|
39
|
+
if len(dir_segments) > len(file_segments):
|
|
40
|
+
continue
|
|
41
|
+
if file_segments[: len(dir_segments)] != dir_segments:
|
|
42
|
+
continue
|
|
43
|
+
if len(dir_segments) > longest_len:
|
|
44
|
+
longest_len = len(dir_segments)
|
|
45
|
+
longest_match = required_tier
|
|
46
|
+
if longest_match is None:
|
|
47
|
+
return True
|
|
48
|
+
return file_tier <= longest_match
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _segments(path: str) -> tuple[str, ...]:
|
|
52
|
+
return tuple(p for p in path.strip("/").split("/") if p)
|
tears/scan.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @tear: 3
|
|
2
|
+
"""Scan orchestration and output formatting.
|
|
3
|
+
|
|
4
|
+
Loads the config, builds the import graph via grimp, runs the checker, prints a
|
|
5
|
+
human-readable report. The exact output format here is pinned by snapshot tests
|
|
6
|
+
in `tests/scan/fixtures/`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from io import StringIO
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from tears.checker import CheckReport, FileReport, check
|
|
16
|
+
from tears.config import load_config
|
|
17
|
+
from tears.graph.grimp_builder import build_grimp_graph
|
|
18
|
+
|
|
19
|
+
_ANSI = {
|
|
20
|
+
"ok": "\033[32m",
|
|
21
|
+
"warn": "\033[33m",
|
|
22
|
+
"fail": "\033[31m",
|
|
23
|
+
"reset": "\033[0m",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_scan(
|
|
28
|
+
repo_root: Path,
|
|
29
|
+
*,
|
|
30
|
+
color: bool = False,
|
|
31
|
+
default_tear: int | None = None,
|
|
32
|
+
) -> tuple[CheckReport, str]:
|
|
33
|
+
"""Run a full scan of `repo_root`. Returns the report and formatted output."""
|
|
34
|
+
config = load_config(repo_root)
|
|
35
|
+
if default_tear is not None:
|
|
36
|
+
config = replace(config, default_tear=default_tear)
|
|
37
|
+
graph = build_grimp_graph(repo_root, config)
|
|
38
|
+
report = check(graph, config, repo_root=repo_root)
|
|
39
|
+
return report, format_report(report, repo_root=repo_root, color=color)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def format_report(report: CheckReport, *, repo_root: Path, color: bool = False) -> str:
|
|
43
|
+
"""Format a `CheckReport` for human consumption."""
|
|
44
|
+
out = StringIO()
|
|
45
|
+
for fr in report.files:
|
|
46
|
+
out.write(_format_file(fr, repo_root=repo_root, color=color))
|
|
47
|
+
out.write(_format_summary(report))
|
|
48
|
+
return out.getvalue()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _format_file(fr: FileReport, *, repo_root: Path, color: bool = False) -> str:
|
|
52
|
+
label = {"ok": "OK ", "warn": "WARN ", "fail": "FAIL "}[fr.status]
|
|
53
|
+
if color:
|
|
54
|
+
label = f"{_ANSI[fr.status]}{label}{_ANSI['reset']}"
|
|
55
|
+
rel = _relative(fr.path, repo_root)
|
|
56
|
+
show_tier = fr.tier is not None or fr.is_defaulted
|
|
57
|
+
tier_suffix = f" (tear {fr.effective_tier})" if show_tier else ""
|
|
58
|
+
line = f"{label} {rel}{tier_suffix}\n"
|
|
59
|
+
issues = "".join(f" - {i.message}\n" for i in fr.issues)
|
|
60
|
+
suffix = "\n" if fr.issues else ""
|
|
61
|
+
return line + issues + suffix
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _format_summary(report: CheckReport) -> str:
|
|
65
|
+
n = len(report.files)
|
|
66
|
+
failures = report.failure_count
|
|
67
|
+
warnings = report.warning_count
|
|
68
|
+
return (
|
|
69
|
+
f"{n} {_plural(n, 'file', 'files')} checked, "
|
|
70
|
+
f"{failures} {_plural(failures, 'failure', 'failures')}, "
|
|
71
|
+
f"{warnings} {_plural(warnings, 'warning', 'warnings')}\n"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _plural(n: int, singular: str, plural: str) -> str:
|
|
76
|
+
return singular if n == 1 else plural
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _relative(path: Path, root: Path) -> str:
|
|
80
|
+
try:
|
|
81
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
82
|
+
except ValueError:
|
|
83
|
+
return path.as_posix()
|
tears/styles.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# @tear: 2
|
|
2
|
+
"""Comment-style lookup tables for @tear header insertion.
|
|
3
|
+
|
|
4
|
+
`COMMENT_STYLES` keys on file extension; `FILENAME_STYLES` keys on bare filename
|
|
5
|
+
for extensionless files (Makefile, Dockerfile, etc.). Each value is
|
|
6
|
+
`(opener, closer)` — `closer` is None for line comments, a string for block comments.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
LINE_HEADER_RE = re.compile(r"^([ \t]*[^A-Za-z0-9\s]+[ \t]*@tear:[ \t]*)(\d+)")
|
|
14
|
+
SHEBANG_RE = re.compile(r"^#!")
|
|
15
|
+
ENCODING_RE = re.compile(r"coding[=:]\s*[-\w.]+")
|
|
16
|
+
|
|
17
|
+
MAX_HEADER_LINES = 5
|
|
18
|
+
|
|
19
|
+
CommentStyle = tuple[str, str | None]
|
|
20
|
+
|
|
21
|
+
COMMENT_STYLES: dict[str, CommentStyle] = {
|
|
22
|
+
# Hash line comment
|
|
23
|
+
".py": ("#", None),
|
|
24
|
+
".rb": ("#", None),
|
|
25
|
+
".pl": ("#", None),
|
|
26
|
+
".sh": ("#", None),
|
|
27
|
+
".bash": ("#", None),
|
|
28
|
+
".zsh": ("#", None),
|
|
29
|
+
".fish": ("#", None),
|
|
30
|
+
".toml": ("#", None),
|
|
31
|
+
".yml": ("#", None),
|
|
32
|
+
".yaml": ("#", None),
|
|
33
|
+
".r": ("#", None),
|
|
34
|
+
".ex": ("#", None),
|
|
35
|
+
".exs": ("#", None),
|
|
36
|
+
# Double-slash line comment
|
|
37
|
+
".js": ("//", None),
|
|
38
|
+
".mjs": ("//", None),
|
|
39
|
+
".cjs": ("//", None),
|
|
40
|
+
".ts": ("//", None),
|
|
41
|
+
".tsx": ("//", None),
|
|
42
|
+
".jsx": ("//", None),
|
|
43
|
+
".go": ("//", None),
|
|
44
|
+
".rs": ("//", None),
|
|
45
|
+
".java": ("//", None),
|
|
46
|
+
".kt": ("//", None),
|
|
47
|
+
".swift": ("//", None),
|
|
48
|
+
".c": ("//", None),
|
|
49
|
+
".cpp": ("//", None),
|
|
50
|
+
".cc": ("//", None),
|
|
51
|
+
".cxx": ("//", None),
|
|
52
|
+
".h": ("//", None),
|
|
53
|
+
".hpp": ("//", None),
|
|
54
|
+
".cs": ("//", None),
|
|
55
|
+
".scala": ("//", None),
|
|
56
|
+
".dart": ("//", None),
|
|
57
|
+
".zig": ("//", None),
|
|
58
|
+
# Double-dash line comment
|
|
59
|
+
".sql": ("--", None),
|
|
60
|
+
".lua": ("--", None),
|
|
61
|
+
".hs": ("--", None),
|
|
62
|
+
".elm": ("--", None),
|
|
63
|
+
# Semicolon line comment
|
|
64
|
+
".ini": (";", None),
|
|
65
|
+
".cfg": (";", None),
|
|
66
|
+
".clj": (";", None),
|
|
67
|
+
".lisp": (";", None),
|
|
68
|
+
# HTML / XML / Markdown block comment
|
|
69
|
+
".html": ("<!--", "-->"),
|
|
70
|
+
".htm": ("<!--", "-->"),
|
|
71
|
+
".xml": ("<!--", "-->"),
|
|
72
|
+
".md": ("<!--", "-->"),
|
|
73
|
+
".markdown": ("<!--", "-->"),
|
|
74
|
+
".svg": ("<!--", "-->"),
|
|
75
|
+
# CSS block comment
|
|
76
|
+
".css": ("/*", "*/"),
|
|
77
|
+
".scss": ("/*", "*/"),
|
|
78
|
+
".less": ("/*", "*/"),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
FILENAME_STYLES: dict[str, CommentStyle] = {
|
|
82
|
+
"Makefile": ("#", None),
|
|
83
|
+
"Dockerfile": ("#", None),
|
|
84
|
+
"Rakefile": ("#", None),
|
|
85
|
+
"Gemfile": ("#", None),
|
|
86
|
+
".gitignore": ("#", None),
|
|
87
|
+
".gitattributes": ("#", None),
|
|
88
|
+
".dockerignore": ("#", None),
|
|
89
|
+
".env": ("#", None),
|
|
90
|
+
".notears": ("#", None),
|
|
91
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: tears-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tiered Enforcement, Authorship Review System — vibe-code responsibly.
|
|
5
|
+
Author: Hillel Twersky
|
|
6
|
+
Author-email: Hillel Twersky <35217356+Thillel@users.noreply.github.com>
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 HillelT
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
Requires-Dist: grimp>=3.4
|
|
29
|
+
Requires-Python: >=3.11
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
tears/__init__.py,sha256=jgPo5r8xynvu78EjGYFfiPXPxQ_xY5XhdEgYXwPh9lM,11
|
|
2
|
+
tears/__main__.py,sha256=6nzjgYLoIrtot_o2FvWuKb6j6jo_rMGczVmiSEL3vEE,64
|
|
3
|
+
tears/checker.py,sha256=ZPCEoSOPR3UPupWUI6ivD7dk529Ug2M7Y3oFA0dsVeM,4952
|
|
4
|
+
tears/cli.py,sha256=GcvDwiqnwwY8DwbPHso0SQhQu7mEFMLgXm17QfLJfZE,10682
|
|
5
|
+
tears/config.py,sha256=3zHwGe95ObqOXVN_1HH3FXvrpzxt_5IBL4X6cs6hxEA,9745
|
|
6
|
+
tears/exclude.py,sha256=nkqvDESnkQhcIEAHw6zET2mW6ikHkVYDp_F6XusmG2I,1197
|
|
7
|
+
tears/graph/__init__.py,sha256=RW5Mr-3bCmsCOchh-zLxdJvUMQE1br2oz2DLZ2Mdda0,1236
|
|
8
|
+
tears/graph/grimp_builder.py,sha256=3pvBN19FI7tJV_oJCcmJYyEg_O20wHxlAAIjd9Z3RJs,4909
|
|
9
|
+
tears/header.py,sha256=MGWFgAK-YbrEKnaD_BgT6KbFn4rfi-QMbXfAbXpgKN4,973
|
|
10
|
+
tears/hook.py,sha256=_gfp_Qnp9jXiJFtuOyZaP63w64Jo0gwt5MuDFTGx_Ac,2636
|
|
11
|
+
tears/mutate.py,sha256=9rlJTkv_jQ6Gsj33NHhpN8oyXNSeJjzg91Y5aCAnTjQ,3556
|
|
12
|
+
tears/rules.py,sha256=3kwo91eepQYgBmbHNZEYdhXTttQcAPqqaG0eUdPe_fA,1693
|
|
13
|
+
tears/scan.py,sha256=rcyFqRSrWQEzNez29BdBUQpT49G9XmZiDafbKeUGGxA,2693
|
|
14
|
+
tears/styles.py,sha256=uZta1ScXHaUuLZARBuujgQwh0lxae2qY4EK_Vmg_Lsc,2457
|
|
15
|
+
tears_cli-0.1.0.dist-info/WHEEL,sha256=fWriCkzqm-pffF5af4gJC9iI5FMFaJTuN9UxxxzOmdY,81
|
|
16
|
+
tears_cli-0.1.0.dist-info/entry_points.txt,sha256=LQ_6hCwT5_mYfi1Tu3ad5F6YrSYuGYhhyVb7qkgaHyQ,42
|
|
17
|
+
tears_cli-0.1.0.dist-info/METADATA,sha256=KAYxrwESRrFtBzwQcgytm3plHZuHxomomhKNmJyYZac,1533
|
|
18
|
+
tears_cli-0.1.0.dist-info/RECORD,,
|