tentod 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.
tentod/GUIDE.md ADDED
@@ -0,0 +1,53 @@
1
+ # tentod — guide for automated (LLM) use
2
+
3
+ tentod recovers readable Python from .NET assemblies in stages. The
4
+ deterministic stages are commands; the one creative stage (writing the Python)
5
+ is done by you, the model, against the contract below. Print this any time with
6
+ `python -m tentod guide`.
7
+
8
+ ## Turnkey workflow
9
+
10
+ Given one assembly, or a list/folder of them, process **each assembly
11
+ independently** — they share nothing, so fan out one worker per assembly and run
12
+ these steps for each:
13
+
14
+ 1. **Recover.** `python -m tentod recover <assembly.dll>`
15
+ - If it prints `SKIP not a managed assembly`, the file is native or not a
16
+ .NET assembly — move on, it is not yours to process.
17
+ - Otherwise it prints the paths of the decompiled C#, the **deobfuscated C#**,
18
+ and `facts.json`, plus counts (`types`/`strings`/`routes`/`inlined`).
19
+ 2. **Read** the deobfuscated C# and `facts.json`. The deobfuscated C# is the
20
+ source of truth for control flow and every literal (routes, keys, headers).
21
+ 3. **Write** the Python module (see the contract below). Map the assembly's
22
+ namespace to directories: `A.B.C` → `A/B/C.py`.
23
+ 4. **Verify.** `python -m tentod verify <assembly.dll> <your_output.py>`
24
+ - Resolve every entry under `missing` (a required literal your Python lacks).
25
+ - Review every entry under `omitted`: each must be something you dropped on
26
+ purpose (an internal log line, a diagnostic message) — never a route, query
27
+ key, header, or secret name.
28
+ 5. **Report** the coverage line and any deliberate omissions for that assembly.
29
+
30
+ Work on independent assemblies concurrently; each worker owns one assembly end
31
+ to end (recover → write → verify).
32
+
33
+ ## Style contract for the Python you write
34
+
35
+ Faithful, idiomatic Python — not a mechanical transliteration of the C#.
36
+
37
+ 1. **One module** at the namespace-mapped path.
38
+ 2. **Class-based.** No module-level functions. The public surface is a single
39
+ class; each user-facing operation is one public method.
40
+ 3. **Private helpers are `__`-prefixed** (name-mangled) — request building, key
41
+ resolution, response handling.
42
+ 4. **Module-level `CONSTANTS`** (base URLs, defaults) are fine.
43
+ 5. **Faithful.** Use the exact literals from the C#/facts. Invent nothing.
44
+ Reproduce behavior, not the C# class hierarchy — skip DTOs, DI, and
45
+ async-state-machine scaffolding a client does not need.
46
+ 6. **Type hints throughout.** Prefer the standard library.
47
+ 7. **Docstring states provenance** and lists any endpoints/behavior you
48
+ intentionally left out, so the omission is a decision, not an oversight.
49
+
50
+ ## Done
51
+
52
+ `python -m tentod verify` reports `OK` (nothing under `missing`) and every
53
+ `omitted` entry is one you can justify.
tentod/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """tentod."""
tentod/__main__.py ADDED
@@ -0,0 +1,81 @@
1
+ """CLI."""
2
+
3
+ from pathlib import Path
4
+
5
+ from class_argparse import ClassArgParser
6
+
7
+ from .assembly import DotNetAssembly, NotManagedAssembly
8
+ from .pipeline import DotNetRecoveryPipeline
9
+
10
+ DEFAULT_OUT_ROOT = "out"
11
+ GUIDE = Path(__file__).with_name("GUIDE.md")
12
+
13
+
14
+ class Main(ClassArgParser):
15
+ """tentod."""
16
+
17
+ def __init__(self) -> None:
18
+ super().__init__(name="tentod")
19
+
20
+ def __resolve(self, assembly: str) -> str:
21
+ p = Path(assembly)
22
+ if p.exists():
23
+ return str(p)
24
+ if not p.suffix:
25
+ withdll = p.with_suffix(".dll")
26
+ if withdll.exists():
27
+ return str(withdll)
28
+ raise SystemExit(f"not found: {assembly}")
29
+
30
+ def guide(self):
31
+ print(GUIDE.read_text())
32
+
33
+ def recover(
34
+ self,
35
+ assembly: str,
36
+ out_dir: str = DEFAULT_OUT_ROOT,
37
+ force: bool = False,
38
+ ):
39
+ dll = self.__resolve(assembly)
40
+ try:
41
+ result = DotNetRecoveryPipeline(dll, out_dir).recover(force=force)
42
+ except NotManagedAssembly:
43
+ print(f"SKIP not a managed assembly: {dll}")
44
+ return
45
+ print(result.csharp)
46
+ print(result.deobfuscated)
47
+ print(result.facts_path)
48
+ print(
49
+ f"types={len(result.facts.types)} "
50
+ f"strings={len(result.facts.flat_strings())} "
51
+ f"routes={len(result.facts.routes)} "
52
+ f"inlined={result.replacements}"
53
+ )
54
+
55
+ def verify(
56
+ self,
57
+ assembly: str,
58
+ python: str,
59
+ out_dir: str = DEFAULT_OUT_ROOT,
60
+ ):
61
+ dll = self.__resolve(assembly)
62
+ report = DotNetRecoveryPipeline(dll, out_dir).verify(python)
63
+ print(report.render())
64
+ if not report.ok:
65
+ raise SystemExit(1)
66
+
67
+ def strings(self, assembly: str):
68
+ try:
69
+ asm = DotNetAssembly.load(self.__resolve(assembly))
70
+ except NotManagedAssembly:
71
+ print(f"SKIP not a managed assembly: {assembly}")
72
+ return
73
+ for bank, entries in asm.decrypt_strings().items():
74
+ print(f"### {bank}")
75
+ for name, value in entries.items():
76
+ print(f" {name}() = {value!r}")
77
+ print()
78
+
79
+
80
+ if __name__ == "__main__": # pragma: no cover
81
+ Main()()
tentod/_runtime.py ADDED
@@ -0,0 +1,21 @@
1
+ """Runtime bootstrap."""
2
+
3
+ import functools
4
+ import os
5
+
6
+
7
+ @functools.lru_cache(maxsize=1)
8
+ def runtime(): # pragma: no cover
9
+ os.environ.setdefault("PYTHONNET_RUNTIME", "mono")
10
+ import clr # noqa: F401
11
+
12
+ from System.Reflection import Assembly, BindingFlags
13
+
14
+ flags = (
15
+ BindingFlags.Static
16
+ | BindingFlags.Instance
17
+ | BindingFlags.Public
18
+ | BindingFlags.NonPublic
19
+ | BindingFlags.DeclaredOnly
20
+ )
21
+ return Assembly, flags
tentod/assembly.py ADDED
@@ -0,0 +1,134 @@
1
+ """Assembly reflection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from ._runtime import runtime
8
+
9
+
10
+ class NotManagedAssembly(Exception):
11
+ """Raised when a file is not a managed assembly (e.g. a native DLL)."""
12
+
13
+
14
+ @dataclass
15
+ class MethodInfo:
16
+ name: str
17
+ static: bool
18
+ returns: str
19
+ params: list[str] = field(default_factory=list)
20
+
21
+
22
+ @dataclass
23
+ class TypeInfo:
24
+ fullname: str
25
+ kind: str
26
+ methods: list[MethodInfo] = field(default_factory=list)
27
+ enum_members: list[str] = field(default_factory=list)
28
+
29
+
30
+ class DotNetAssembly:
31
+ """A loaded assembly queried through reflection."""
32
+
33
+ __STRING_BANK_MIN = 3
34
+
35
+ def __init__(self, asm, flags=None) -> None:
36
+ self.__asm = asm
37
+ self.__flags = flags
38
+
39
+ @classmethod
40
+ def load(cls, dll_path: str) -> "DotNetAssembly":
41
+ assembly, flags = runtime()
42
+ try:
43
+ obj = assembly.LoadFrom(str(dll_path))
44
+ except Exception as e:
45
+ if "BadImageFormat" in type(e).__name__:
46
+ raise NotManagedAssembly(str(dll_path)) from e
47
+ raise
48
+ return cls(obj, flags)
49
+
50
+ @property
51
+ def full_name(self) -> str:
52
+ return str(self.__asm.FullName)
53
+
54
+ def __types(self):
55
+ try:
56
+ return list(self.__asm.GetTypes())
57
+ except Exception as e:
58
+ loaded = getattr(e, "Types", None)
59
+ return [t for t in loaded if t is not None] if loaded else []
60
+
61
+ def __is_accessor(self, m) -> bool:
62
+ return (
63
+ m.IsStatic
64
+ and m.ReturnType.Name == "String"
65
+ and len(m.GetParameters()) == 0
66
+ and not m.Name.startswith("get_")
67
+ )
68
+
69
+ def __string_banks(self):
70
+ banks = []
71
+ for t in self.__types():
72
+ accessors = [m for m in t.GetMethods(self.__flags) if self.__is_accessor(m)]
73
+ if len(accessors) >= self.__STRING_BANK_MIN:
74
+ banks.append((t, accessors))
75
+ return banks
76
+
77
+ def decrypt_strings(self) -> dict[str, dict[str, str]]:
78
+ out: dict[str, dict[str, str]] = {}
79
+ for t, accessors in self.__string_banks():
80
+ bank: dict[str, str] = {}
81
+ for m in accessors:
82
+ try:
83
+ bank[m.Name] = str(m.Invoke(None, None))
84
+ except Exception:
85
+ continue
86
+ if bank:
87
+ out[t.Name] = bank
88
+ return out
89
+
90
+ def enums(self) -> dict[str, list[str]]:
91
+ out: dict[str, list[str]] = {}
92
+ for t in self.__types():
93
+ if t.IsEnum:
94
+ try:
95
+ out[t.FullName] = list(t.GetEnumNames())
96
+ except Exception:
97
+ out[t.FullName] = []
98
+ return out
99
+
100
+ def __kind(self, t) -> str:
101
+ if t.IsEnum:
102
+ return "enum"
103
+ if t.IsInterface:
104
+ return "interface"
105
+ if t.IsValueType:
106
+ return "struct"
107
+ return "class"
108
+
109
+ def types(self, include_generated: bool = False) -> list[TypeInfo]:
110
+ bank_names = {t.Name for t, _ in self.__string_banks()}
111
+ result: list[TypeInfo] = []
112
+ for t in self.__types():
113
+ if not include_generated and (
114
+ t.Name in bank_names or "PrivateImplementationDetails" in t.FullName
115
+ ):
116
+ continue
117
+ ti = TypeInfo(fullname=t.FullName, kind=self.__kind(t))
118
+ if t.IsEnum:
119
+ try:
120
+ ti.enum_members = list(t.GetEnumNames())
121
+ except Exception:
122
+ pass
123
+ else:
124
+ for m in t.GetMethods(self.__flags):
125
+ ti.methods.append(
126
+ MethodInfo(
127
+ name=m.Name,
128
+ static=m.IsStatic,
129
+ returns=m.ReturnType.Name,
130
+ params=[p.ParameterType.Name for p in m.GetParameters()],
131
+ )
132
+ )
133
+ result.append(ti)
134
+ return result
tentod/decompiler.py ADDED
@@ -0,0 +1,59 @@
1
+ """Decompilation stage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ import shutil
8
+ import subprocess
9
+
10
+
11
+ class IlspyDecompiler:
12
+ """Wrapper over the ``ilspycmd`` executable."""
13
+
14
+ __TOOL_CANDIDATES = ("ilspycmd", "~/.dotnet/tools/ilspycmd")
15
+
16
+ def __init__(self, ilspycmd: str | None = None) -> None:
17
+ self.__exe = ilspycmd or self.__discover()
18
+
19
+ def __discover(self) -> str:
20
+ for cand in self.__TOOL_CANDIDATES:
21
+ found = shutil.which(cand) or (
22
+ str(Path(cand).expanduser())
23
+ if Path(cand).expanduser().exists()
24
+ else None
25
+ )
26
+ if found:
27
+ return found
28
+ raise RuntimeError("ilspycmd not found")
29
+
30
+ def __env(self) -> dict[str, str]:
31
+ env = dict(os.environ)
32
+ env.setdefault("DOTNET_ROLL_FORWARD", "Major")
33
+ env.setdefault("DOTNET_CLI_TELEMETRY_OPTOUT", "1")
34
+ env.setdefault("DOTNET_NOLOGO", "1")
35
+ return env
36
+
37
+ def decompile(self, dll_path: str, out_dir: str, force: bool = False) -> Path:
38
+ dll = Path(dll_path)
39
+ out = Path(out_dir)
40
+ out.mkdir(parents=True, exist_ok=True)
41
+ expected = out / f"{dll.stem}.decompiled.cs"
42
+ if expected.exists() and not force:
43
+ return expected
44
+ proc = subprocess.run(
45
+ [self.__exe, str(dll), "-o", str(out)],
46
+ env=self.__env(),
47
+ capture_output=True,
48
+ text=True,
49
+ )
50
+ if proc.returncode != 0:
51
+ raise RuntimeError(
52
+ f"ilspycmd failed ({proc.returncode}):\n{proc.stderr or proc.stdout}"
53
+ )
54
+ if expected.exists():
55
+ return expected
56
+ produced = sorted(out.glob("*.cs"))
57
+ if not produced:
58
+ raise RuntimeError(f"no .cs produced in {out}")
59
+ return produced[0]
tentod/deobfuscator.py ADDED
@@ -0,0 +1,50 @@
1
+ """Deobfuscation stage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import re
7
+
8
+
9
+ class StringDeobfuscator:
10
+ """Rewrites accessor calls to their decrypted literals."""
11
+
12
+ def __init__(self, string_banks: dict[str, dict[str, str]]) -> None:
13
+ self.__banks = string_banks
14
+
15
+ @staticmethod
16
+ def __csharp_literal(value: str) -> str:
17
+ esc = (
18
+ value.replace("\\", "\\\\")
19
+ .replace('"', '\\"')
20
+ .replace("\n", "\\n")
21
+ .replace("\r", "\\r")
22
+ .replace("\t", "\\t")
23
+ )
24
+ return f'"{esc}"'
25
+
26
+ def __bank_pattern(self, qualifier: str, bank: dict[str, str]) -> re.Pattern:
27
+ names = "|".join(re.escape(n) for n in sorted(bank, key=len, reverse=True))
28
+ return re.compile(re.escape(qualifier) + r"\.(" + names + r")\(\)")
29
+
30
+ def deobfuscate_text(self, source: str) -> tuple[str, int]:
31
+ count = 0
32
+ for qualifier, bank in self.__banks.items():
33
+ if not bank:
34
+ continue
35
+ pattern = self.__bank_pattern(qualifier, bank)
36
+
37
+ def repl(m: re.Match) -> str:
38
+ nonlocal count
39
+ count += 1
40
+ return self.__csharp_literal(bank[m.group(1)])
41
+
42
+ source = pattern.sub(repl, source)
43
+ return source, count
44
+
45
+ def deobfuscate_file(self, cs_in: str, cs_out: str) -> tuple[Path, int]:
46
+ rewritten, count = self.deobfuscate_text(Path(cs_in).read_text())
47
+ out = Path(cs_out)
48
+ out.parent.mkdir(parents=True, exist_ok=True)
49
+ out.write_text(rewritten)
50
+ return out, count
tentod/facts.py ADDED
@@ -0,0 +1,51 @@
1
+ """Facts stage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ import json
7
+
8
+ from .assembly import DotNetAssembly
9
+
10
+
11
+ @dataclass
12
+ class AssemblyFacts:
13
+ assembly: str
14
+ strings: dict[str, dict[str, str]] = field(default_factory=dict)
15
+ enums: dict[str, list[str]] = field(default_factory=dict)
16
+ routes: list[str] = field(default_factory=list)
17
+ types: list[dict] = field(default_factory=list)
18
+
19
+ def flat_strings(self) -> list[str]:
20
+ seen: dict[str, None] = {}
21
+ for bank in self.strings.values():
22
+ for value in bank.values():
23
+ seen.setdefault(value, None)
24
+ return list(seen)
25
+
26
+ def to_json(self) -> str:
27
+ return json.dumps(asdict(self), indent=2, ensure_ascii=False)
28
+
29
+ @classmethod
30
+ def from_json(cls, text: str) -> "AssemblyFacts":
31
+ return cls(**json.loads(text))
32
+
33
+
34
+ class FactsExtractor:
35
+ def extract(self, asm: DotNetAssembly) -> AssemblyFacts:
36
+ strings = asm.decrypt_strings()
37
+ routes = sorted(
38
+ {
39
+ value
40
+ for bank in strings.values()
41
+ for value in bank.values()
42
+ if value.startswith("/")
43
+ }
44
+ )
45
+ return AssemblyFacts(
46
+ assembly=asm.full_name,
47
+ strings=strings,
48
+ enums=asm.enums(),
49
+ routes=routes,
50
+ types=[asdict(t) for t in asm.types()],
51
+ )
tentod/pipeline.py ADDED
@@ -0,0 +1,61 @@
1
+ """Pipeline orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .assembly import DotNetAssembly
9
+ from .decompiler import IlspyDecompiler
10
+ from .deobfuscator import StringDeobfuscator
11
+ from .facts import AssemblyFacts, FactsExtractor
12
+ from .verify import RecoveryVerifier, VerifyReport
13
+
14
+
15
+ @dataclass
16
+ class RecoverResult:
17
+ csharp: Path
18
+ deobfuscated: Path
19
+ facts_path: Path
20
+ facts: AssemblyFacts
21
+ replacements: int
22
+
23
+
24
+ class DotNetRecoveryPipeline:
25
+ def __init__(self, dll_path: str, out_root: str) -> None:
26
+ self.__dll = Path(dll_path)
27
+ self.__root = Path(out_root) / self.__dll.stem
28
+
29
+ @property
30
+ def root(self) -> Path:
31
+ return self.__root
32
+
33
+ @property
34
+ def facts_path(self) -> Path:
35
+ return self.__root / "facts.json"
36
+
37
+ def recover(self, force: bool = False) -> RecoverResult:
38
+ asm = DotNetAssembly.load(str(self.__dll)) # classifies before decompiling
39
+ banks = asm.decrypt_strings()
40
+ csharp = IlspyDecompiler().decompile(
41
+ str(self.__dll),
42
+ str(self.__root / "csharp"),
43
+ force=force,
44
+ )
45
+ deobf, replacements = StringDeobfuscator(banks).deobfuscate_file(
46
+ str(csharp),
47
+ str(self.__root / "deobf" / f"{self.__dll.stem}.deobf.cs"),
48
+ )
49
+ facts = FactsExtractor().extract(asm)
50
+ self.facts_path.write_text(facts.to_json())
51
+ return RecoverResult(csharp, deobf, self.facts_path, facts, replacements)
52
+
53
+ def load_facts(self) -> AssemblyFacts:
54
+ if self.facts_path.exists():
55
+ return AssemblyFacts.from_json(self.facts_path.read_text())
56
+ return FactsExtractor().extract(DotNetAssembly.load(str(self.__dll)))
57
+
58
+ def verify(self, python_path: str) -> VerifyReport:
59
+ facts = self.load_facts()
60
+ source = Path(python_path).read_text()
61
+ return RecoveryVerifier().verify(facts, source)
tentod/verify.py ADDED
@@ -0,0 +1,45 @@
1
+ """Verification stage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from .facts import AssemblyFacts
8
+
9
+
10
+ @dataclass
11
+ class VerifyReport:
12
+ matched: list[str] = field(default_factory=list)
13
+ omitted: list[str] = field(default_factory=list)
14
+ missing_routes: list[str] = field(default_factory=list)
15
+
16
+ @property
17
+ def ok(self) -> bool:
18
+ return not self.missing_routes
19
+
20
+ def render(self) -> str:
21
+ total = len(self.matched) + len(self.omitted)
22
+ lines = [f"coverage: {len(self.matched)}/{total}"]
23
+ if self.missing_routes:
24
+ lines.append("")
25
+ lines.append(f"missing ({len(self.missing_routes)}):")
26
+ lines += [f" ! {r}" for r in self.missing_routes]
27
+ if self.omitted:
28
+ lines.append("")
29
+ lines.append(f"omitted ({len(self.omitted)}):")
30
+ lines += [f" - {s!r}" for s in self.omitted]
31
+ lines.append("")
32
+ lines.append("OK" if self.ok else "FAIL")
33
+ return "\n".join(lines)
34
+
35
+
36
+ class RecoveryVerifier:
37
+ def verify(self, facts: AssemblyFacts, python_source: str) -> VerifyReport:
38
+ report = VerifyReport()
39
+ for literal in facts.flat_strings():
40
+ if not literal.strip():
41
+ continue
42
+ bucket = report.matched if literal in python_source else report.omitted
43
+ bucket.append(literal)
44
+ report.missing_routes = [r for r in facts.routes if r not in python_source]
45
+ return report
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: tentod
3
+ Version: 0.1.0
4
+ Summary: tentod
5
+ Home-page: https://github.com/rijulg/tentod
6
+ Author: Rijul Gupta
7
+ Author-email: rijulg@gmail.com
8
+ License: MIT
9
+ Keywords: dotnet
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.13
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: class-argparse==0.1.4
19
+ Requires-Dist: pythonnet==3.1.0
20
+ Dynamic: author
21
+ Dynamic: author-email
22
+ Dynamic: classifier
23
+ Dynamic: description
24
+ Dynamic: description-content-type
25
+ Dynamic: home-page
26
+ Dynamic: keywords
27
+ Dynamic: license
28
+ Dynamic: requires-dist
29
+ Dynamic: requires-python
30
+ Dynamic: summary
31
+
32
+ # tentod
33
+
34
+ ```bash
35
+ python -m tentod recover <assembly.dll>
36
+ python -m tentod guide # workflow for automated/LLM use
37
+ ```
@@ -0,0 +1,14 @@
1
+ tentod/GUIDE.md,sha256=79-M-TsopO0Nukalfg2NZRQsPETLE4ZicRKUHrhRGSk,2706
2
+ tentod/__init__.py,sha256=DT_m3tcHQluSwE01_2q8ksVcfwCXDcVO4OtrykDXX-w,14
3
+ tentod/__main__.py,sha256=rJ6tc5paiElOsl2UUsAKcUbgEwrGEGglGMN09XvG6W8,2267
4
+ tentod/_runtime.py,sha256=jxmXbZZyvvQ35UM7kPd6ahEz4Qj0qxl-aHxDlR4LE8M,471
5
+ tentod/assembly.py,sha256=CqibMBrYanSDGhNKBi6fuYEg0Dq5SbBzvdmUt1R3kdI,4047
6
+ tentod/decompiler.py,sha256=HvU5gW8zd5NXE6GVS-CaLAUVOqj7EDS9l2Dm-P3ljnU,1866
7
+ tentod/deobfuscator.py,sha256=JTMCwe3WwXkT2YOHih7Jvfrf5P_DgDMvW2028Tv9d2A,1613
8
+ tentod/facts.py,sha256=RN4v7ut3kWDoyjykQe37lGhJqp1l7FGJ4q5eGVGDST0,1439
9
+ tentod/pipeline.py,sha256=Kkg9cx8ZtlRBgvH6NMKyVXAsaHvOkaX2kLHzAXxMDJM,1958
10
+ tentod/verify.py,sha256=FcYNzWNKstZl9To3tGiTKmk2RzFaF43YwyKwD6UddTw,1499
11
+ tentod-0.1.0.dist-info/METADATA,sha256=Edhm3FsPg0DWPyN6XIOfDLSo39Olb62C31Ao9A3y6Vw,951
12
+ tentod-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
13
+ tentod-0.1.0.dist-info/top_level.txt,sha256=rrntAvE4_6vnipt8jAnAiuSVEkCAfGloWl0diEKK010,7
14
+ tentod-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ tentod