mtangle 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.
mtangle/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from mtangle.core import tangle, parse_markdown, CodeBlock, TangleError, TangleWarning
2
+
3
+ __all__ = ["tangle", "parse_markdown", "CodeBlock", "TangleError", "TangleWarning"]
mtangle/cli.py ADDED
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from mtangle.core import TangleError, tangle
8
+
9
+
10
+ def main(argv: list[str] | None = None) -> int:
11
+ parser = argparse.ArgumentParser(
12
+ prog="mtangle",
13
+ description="Tangle codeblocks out of markdown files into source files.",
14
+ )
15
+ parser.add_argument(
16
+ "sources",
17
+ nargs="*",
18
+ help="Markdown files or directories to scan. Defaults to '.'",
19
+ )
20
+ parser.add_argument(
21
+ "-o",
22
+ "--output-dir",
23
+ default=".",
24
+ help="Directory to write tangled sources into (default: cwd)",
25
+ )
26
+ parser.add_argument(
27
+ "-n",
28
+ "--dry-run",
29
+ action="store_true",
30
+ help="Parse and resolve everything but don't write any files",
31
+ )
32
+ parser.add_argument(
33
+ "-v",
34
+ "--verbose",
35
+ action="store_true",
36
+ help="Print progress details to stderr",
37
+ )
38
+ parser.add_argument(
39
+ "--disable-path-safety",
40
+ action="store_true",
41
+ help=(
42
+ "Allow file= targets to be absolute or resolve outside the output "
43
+ "directory. Off by default; such targets are warned and skipped."
44
+ ),
45
+ )
46
+ parser.add_argument(
47
+ "-i",
48
+ "--ignore",
49
+ action="append",
50
+ default=[],
51
+ metavar="PATTERN",
52
+ help=(
53
+ "Gitignore-style pattern to skip during directory walks. "
54
+ "May be given multiple times."
55
+ ),
56
+ )
57
+ parser.add_argument(
58
+ "--disable-ignore-dotfiles",
59
+ action="store_true",
60
+ help="Include dotfiles/dotdirs in directory walks (default: skipped).",
61
+ )
62
+ parser.add_argument(
63
+ "--respect-gitignore",
64
+ action="store_true",
65
+ help="Also apply .gitignore patterns at each walk root (default: off).",
66
+ )
67
+ args = parser.parse_args(argv)
68
+
69
+ sources = args.sources or ["."]
70
+ inputs = [Path(s) for s in sources]
71
+ output_dir = Path(args.output_dir)
72
+
73
+ def info(msg: str) -> None:
74
+ if args.verbose:
75
+ print(msg, file=sys.stderr)
76
+
77
+ try:
78
+ results = tangle(
79
+ inputs,
80
+ output_dir,
81
+ on_info=info,
82
+ dry_run=args.dry_run,
83
+ path_safety=not args.disable_path_safety,
84
+ ignore_dotfiles=not args.disable_ignore_dotfiles,
85
+ ignore_patterns=args.ignore,
86
+ respect_gitignore=args.respect_gitignore,
87
+ )
88
+ except TangleError as e:
89
+ print(f"error: {e}", file=sys.stderr)
90
+ return 1
91
+
92
+ verb = "would write" if args.dry_run else "wrote"
93
+ for path in sorted(results):
94
+ print(f"{verb} {path}")
95
+ return 0
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(main())
mtangle/core.py ADDED
@@ -0,0 +1,417 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Callable, Iterable
9
+
10
+ import pathspec
11
+
12
+
13
+ class TangleError(Exception):
14
+ pass
15
+
16
+
17
+ class TangleWarning(UserWarning):
18
+ pass
19
+
20
+
21
+ FENCE_RE = re.compile(r"^(?P<indent>[ \t]*)(?P<fence>`{3,})(?P<info>.*)$")
22
+ ATTR_RE = re.compile(r'([A-Za-z_][A-Za-z0-9_.-]*)=("([^"]*)"|(\S+))')
23
+ IDENT_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_-]*)>>")
24
+
25
+
26
+ @dataclass
27
+ class CodeBlock:
28
+ lang: str | None
29
+ file: str | None
30
+ id: str | None
31
+ lines: list[str]
32
+ source: str
33
+ line_no: int
34
+
35
+
36
+ def _parse_info(info: str) -> tuple[str | None, dict[str, str]]:
37
+ info = info.strip()
38
+ if not info:
39
+ return None, {}
40
+ parts = info.split(None, 1)
41
+ first = parts[0]
42
+ attrs: dict[str, str] = {}
43
+ if "=" in first:
44
+ lang = None
45
+ rest = info
46
+ else:
47
+ lang = first
48
+ rest = parts[1] if len(parts) > 1 else ""
49
+ for m in ATTR_RE.finditer(rest):
50
+ key = m.group(1)
51
+ val = m.group(3) if m.group(3) is not None else m.group(4)
52
+ attrs[key] = val
53
+ return lang, attrs
54
+
55
+
56
+ def _left_align(lines: list[str]) -> list[str]:
57
+ non_blank = [ln for ln in lines if ln.strip()]
58
+ if not non_blank:
59
+ return ["" for _ in lines]
60
+ min_indent = min(len(ln) - len(ln.lstrip(" \t")) for ln in non_blank)
61
+ result: list[str] = []
62
+ for ln in lines:
63
+ if not ln.strip():
64
+ result.append("")
65
+ else:
66
+ result.append(ln[min_indent:])
67
+ return result
68
+
69
+
70
+ def parse_markdown(text: str, source: str = "<string>") -> list[CodeBlock]:
71
+ lines = text.splitlines()
72
+ blocks: list[CodeBlock] = []
73
+ i = 0
74
+ while i < len(lines):
75
+ m = FENCE_RE.match(lines[i])
76
+ if not m:
77
+ i += 1
78
+ continue
79
+ fence = m.group("fence")
80
+ info = m.group("info")
81
+ lang, attrs = _parse_info(info)
82
+ start = i
83
+ i += 1
84
+ body: list[str] = []
85
+ closed = False
86
+ close_re = re.compile(rf"^[ \t]*`{{{len(fence)},}}\s*$")
87
+ while i < len(lines):
88
+ if close_re.match(lines[i]):
89
+ closed = True
90
+ i += 1
91
+ break
92
+ body.append(lines[i])
93
+ i += 1
94
+ if not closed:
95
+ raise TangleError(f"{source}:{start+1}: unclosed fenced code block")
96
+ file_attr = attrs.get("file")
97
+ id_attr = attrs.get("id")
98
+ if file_attr or id_attr:
99
+ aligned = _left_align(body)
100
+ blocks.append(
101
+ CodeBlock(
102
+ lang=lang,
103
+ file=file_attr,
104
+ id=id_attr,
105
+ lines=aligned,
106
+ source=source,
107
+ line_no=start + 1,
108
+ )
109
+ )
110
+ return blocks
111
+
112
+
113
+ def _scan_line(line: str) -> tuple[str, tuple[int, int, str] | None, str]:
114
+ """Scan a line for at most one active marker.
115
+
116
+ Escape rules:
117
+ - `\\\\` → literal backslash
118
+ - `\\<<` → literal `<<` (marker suppressed)
119
+ - `<<IDENT>>` (no whitespace inside) → marker
120
+ - `<< IDENT >>` or any non-identifier content between `<<` and `>>` → literal
121
+
122
+ Returns (rendered_before, marker_or_None, rendered_after).
123
+ `marker_or_None` is `(start_col_in_rendered, end_col, ident)` where
124
+ start/end refer to positions in the fully-rendered line (before + placeholder + after).
125
+ If there is more than one marker on the line, raises AssertionError-style —
126
+ the caller detects multi-marker via a second scan.
127
+ """
128
+ out: list[str] = []
129
+ marker: tuple[int, int, str] | None = None
130
+ i = 0
131
+ n = len(line)
132
+ while i < n:
133
+ c = line[i]
134
+ if c == "\\":
135
+ if i + 1 < n and line[i + 1] == "\\":
136
+ out.append("\\")
137
+ i += 2
138
+ continue
139
+ if line.startswith("<<", i + 1):
140
+ out.append("<<")
141
+ i += 3
142
+ continue
143
+ out.append("\\")
144
+ i += 1
145
+ continue
146
+ if line.startswith("<<", i):
147
+ m = IDENT_RE.match(line, i + 2)
148
+ if m:
149
+ if marker is not None:
150
+ marker = (-1, -1, "__MULTI__")
151
+ out.append(line[i : m.end()])
152
+ else:
153
+ ident = m.group(1)
154
+ start = len("".join(out))
155
+ marker = (start, start, ident)
156
+ i = m.end()
157
+ continue
158
+ out.append(c)
159
+ i += 1
160
+ rendered = "".join(out)
161
+ if marker is None:
162
+ return rendered, None, ""
163
+ start, _, ident = marker
164
+ return rendered[:start], marker, rendered[start:]
165
+
166
+
167
+ def _count_markers(line: str) -> int:
168
+ """Count active markers on a line (for multi-marker detection)."""
169
+ count = 0
170
+ i = 0
171
+ n = len(line)
172
+ while i < n:
173
+ c = line[i]
174
+ if c == "\\":
175
+ if i + 1 < n and line[i + 1] == "\\":
176
+ i += 2
177
+ continue
178
+ if line.startswith("<<", i + 1):
179
+ i += 3
180
+ continue
181
+ i += 1
182
+ continue
183
+ if line.startswith("<<", i):
184
+ m = IDENT_RE.match(line, i + 2)
185
+ if m:
186
+ count += 1
187
+ i = m.end()
188
+ continue
189
+ i += 1
190
+ return count
191
+
192
+
193
+ def _expand(
194
+ block: CodeBlock,
195
+ id_map: dict[str, CodeBlock],
196
+ on_warning: Callable[[str], None],
197
+ stack: tuple[str, ...] = (),
198
+ ) -> list[str]:
199
+ out: list[str] = []
200
+ for line_idx, line in enumerate(block.lines):
201
+ n_markers = _count_markers(line)
202
+ if n_markers == 0:
203
+ before, _, _ = _scan_line(line)
204
+ out.append(before)
205
+ continue
206
+ if n_markers > 1:
207
+ raise TangleError(
208
+ f"{block.source}:{block.line_no}: line {line_idx + 1} of block "
209
+ f"has multiple substitution markers: {line!r}"
210
+ )
211
+ before, marker, after = _scan_line(line)
212
+ assert marker is not None
213
+ _, _, marker_id = marker
214
+ if before.strip() != "" or after.strip() != "":
215
+ on_warning(
216
+ f"{block.source}:{block.line_no}: substitution marker "
217
+ f"<<{marker_id}>> has non-whitespace neighbors "
218
+ f"(line {line_idx + 1}): {line!r}"
219
+ )
220
+ if before.strip() == "":
221
+ indent = before
222
+ else:
223
+ lead = re.match(r"[ \t]*", before)
224
+ indent = lead.group(0) if lead else ""
225
+ if marker_id not in id_map:
226
+ raise TangleError(
227
+ f"{block.source}:{block.line_no}: unknown id "
228
+ f"'<<{marker_id}>>' referenced"
229
+ )
230
+ if marker_id in stack:
231
+ cycle = " -> ".join(stack + (marker_id,))
232
+ raise TangleError(f"cyclic substitution: {cycle}")
233
+ sub_lines = _expand(id_map[marker_id], id_map, on_warning, stack + (marker_id,))
234
+ for sl in sub_lines:
235
+ out.append(indent + sl if sl else "")
236
+ return out
237
+
238
+
239
+ DOTFILE_PATTERNS = [".*", ".*/"]
240
+ MTANGLEIGNORE = ".mtangleignore"
241
+ GITIGNORE = ".gitignore"
242
+
243
+
244
+ def _read_pattern_file(path: Path) -> list[str]:
245
+ if not path.is_file():
246
+ return []
247
+ patterns: list[str] = []
248
+ for raw in path.read_text().splitlines():
249
+ line = raw.strip()
250
+ if not line or line.startswith("#"):
251
+ continue
252
+ patterns.append(line)
253
+ return patterns
254
+
255
+
256
+ def _build_spec(patterns: Iterable[str]) -> pathspec.PathSpec:
257
+ return pathspec.PathSpec.from_lines("gitignore", list(patterns))
258
+
259
+
260
+ def _walk_dir(
261
+ root: Path,
262
+ ignore_dotfiles: bool,
263
+ cli_patterns: list[str],
264
+ respect_gitignore: bool,
265
+ ) -> list[Path]:
266
+ patterns: list[str] = []
267
+ if ignore_dotfiles:
268
+ patterns.extend(DOTFILE_PATTERNS)
269
+ patterns.extend(cli_patterns)
270
+ patterns.extend(_read_pattern_file(root / MTANGLEIGNORE))
271
+ if respect_gitignore:
272
+ patterns.extend(_read_pattern_file(root / GITIGNORE))
273
+ spec = _build_spec(patterns)
274
+
275
+ result: list[Path] = []
276
+ for dirpath, dirnames, filenames in os.walk(root):
277
+ rel_dir = Path(dirpath).relative_to(root)
278
+ kept_dirs: list[str] = []
279
+ for d in dirnames:
280
+ rel = (rel_dir / d).as_posix() + "/"
281
+ if spec.match_file(rel):
282
+ continue
283
+ kept_dirs.append(d)
284
+ dirnames[:] = kept_dirs
285
+ for f in filenames:
286
+ if not f.endswith(".md"):
287
+ continue
288
+ rel = (rel_dir / f).as_posix()
289
+ if spec.match_file(rel):
290
+ continue
291
+ result.append(Path(dirpath) / f)
292
+ return result
293
+
294
+
295
+ def _collect_md_files(
296
+ inputs: Iterable[Path],
297
+ ignore_dotfiles: bool = True,
298
+ cli_patterns: list[str] | None = None,
299
+ respect_gitignore: bool = False,
300
+ ) -> list[Path]:
301
+ cli_patterns = cli_patterns or []
302
+ result: list[Path] = []
303
+ seen: set[Path] = set()
304
+ for p in inputs:
305
+ if p.is_dir():
306
+ for md in _walk_dir(p, ignore_dotfiles, cli_patterns, respect_gitignore):
307
+ rp = md.resolve()
308
+ if rp not in seen:
309
+ seen.add(rp)
310
+ result.append(md)
311
+ elif p.suffix == ".md":
312
+ rp = p.resolve()
313
+ if rp not in seen:
314
+ seen.add(rp)
315
+ result.append(p)
316
+ return result
317
+
318
+
319
+ def _default_warn(msg: str) -> None:
320
+ print(f"warning: {msg}", file=sys.stderr)
321
+
322
+
323
+ def _noop(_msg: str) -> None:
324
+ pass
325
+
326
+
327
+ def _is_safe_target(target: str, output_dir: Path) -> tuple[bool, Path, str]:
328
+ """Return (ok, resolved_path, reason_if_not_ok)."""
329
+ if Path(target).is_absolute():
330
+ return False, output_dir / target, "absolute path"
331
+ combined = (output_dir / target).resolve()
332
+ base = output_dir.resolve()
333
+ try:
334
+ combined.relative_to(base)
335
+ except ValueError:
336
+ return False, combined, f"escapes output directory {base}"
337
+ return True, combined, ""
338
+
339
+
340
+ def tangle(
341
+ inputs: Iterable[Path],
342
+ output_dir: Path,
343
+ on_warning: Callable[[str], None] | None = None,
344
+ on_info: Callable[[str], None] | None = None,
345
+ dry_run: bool = False,
346
+ path_safety: bool = True,
347
+ ignore_dotfiles: bool = True,
348
+ ignore_patterns: list[str] | None = None,
349
+ respect_gitignore: bool = False,
350
+ ) -> dict[Path, str]:
351
+ warn = on_warning if on_warning is not None else _default_warn
352
+ info = on_info if on_info is not None else _noop
353
+
354
+ md_files = sorted(
355
+ _collect_md_files(
356
+ inputs,
357
+ ignore_dotfiles=ignore_dotfiles,
358
+ cli_patterns=ignore_patterns or [],
359
+ respect_gitignore=respect_gitignore,
360
+ ),
361
+ key=lambda p: str(p),
362
+ )
363
+ for md in md_files:
364
+ info(f"reading {md}")
365
+
366
+ all_blocks: list[CodeBlock] = []
367
+ for md in md_files:
368
+ text = md.read_text()
369
+ all_blocks.extend(parse_markdown(text, source=str(md)))
370
+
371
+ id_map: dict[str, CodeBlock] = {}
372
+ for b in all_blocks:
373
+ if b.id:
374
+ if b.id in id_map:
375
+ prev = id_map[b.id]
376
+ raise TangleError(
377
+ f"{b.source}:{b.line_no}: duplicate id '{b.id}' "
378
+ f"(also at {prev.source}:{prev.line_no})"
379
+ )
380
+ id_map[b.id] = b
381
+ info(f"registered id '{b.id}' from {b.source}:{b.line_no}")
382
+
383
+ file_groups: dict[str, list[CodeBlock]] = {}
384
+ for b in all_blocks:
385
+ if b.file:
386
+ file_groups.setdefault(b.file, []).append(b)
387
+
388
+ results: dict[Path, str] = {}
389
+ for target, blocks in file_groups.items():
390
+ if path_safety:
391
+ ok, resolved, reason = _is_safe_target(target, output_dir)
392
+ if not ok:
393
+ locs = ", ".join(f"{b.source}:{b.line_no}" for b in blocks)
394
+ warn(
395
+ f"refusing to write '{target}' ({reason}); "
396
+ f"skipping. from: {locs}"
397
+ )
398
+ continue
399
+ out_path = resolved
400
+ else:
401
+ out_path = output_dir / target
402
+ if len(blocks) > 1:
403
+ locs = ", ".join(f"{b.source}:{b.line_no}" for b in blocks)
404
+ warn(f"multiple codeblocks target '{target}' — concatenating: {locs}")
405
+ chunks: list[str] = []
406
+ for b in blocks:
407
+ expanded = _expand(b, id_map, warn)
408
+ chunks.append("\n".join(expanded) + "\n")
409
+ content = "".join(chunks)
410
+ results[out_path] = content
411
+ if dry_run:
412
+ info(f"would write {out_path} ({len(content)} bytes)")
413
+ else:
414
+ out_path.parent.mkdir(parents=True, exist_ok=True)
415
+ out_path.write_text(content)
416
+ info(f"wrote {out_path} ({len(content)} bytes)")
417
+ return results
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: mtangle
3
+ Version: 0.1.0
4
+ Summary: A simple literate programming utility for markdown
5
+ Project-URL: Homepage, https://github.com/usergenic/mtangle
6
+ Project-URL: Repository, https://github.com/usergenic/mtangle
7
+ Author: Brendan Baldwin
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: pathspec>=0.12
12
+ Description-Content-Type: text/markdown
13
+
14
+ # mtangle
15
+
16
+ A simple literate-programming utility that extracts codeblocks from markdown
17
+ files and writes them to source files.
18
+
19
+ ## What it does
20
+
21
+ Given a markdown file:
22
+
23
+ ~~~markdown
24
+ ```python file=src/something.py
25
+ def cool():
26
+ return 0
27
+ ```
28
+ ~~~
29
+
30
+ Running `mtangle` writes the block's contents to `src/something.py`.
31
+
32
+ Codeblocks are recognized by attributes on the info string:
33
+
34
+ - `file=PATH` — write this block's contents to `PATH` (relative to the output
35
+ directory).
36
+ - `id=NAME` — register this block under a name so it can be referenced from
37
+ another block.
38
+
39
+ Blocks without either attribute are ignored.
40
+
41
+ ## Composition via `<<name>>`
42
+
43
+ Blocks with `file=` can reference `id=` blocks using `<<name>>` markers on their
44
+ own line:
45
+
46
+ ~~~markdown
47
+ ```python id=cool_func
48
+ def cool():
49
+ return 0
50
+ ```
51
+
52
+ ```python id=neat_func
53
+ def neat():
54
+ return cool()
55
+ ```
56
+
57
+ ```python file=src/my_funcs.py
58
+ <<cool_func>>
59
+
60
+
61
+ <<neat_func>>
62
+ ```
63
+ ~~~
64
+
65
+ Produces `src/my_funcs.py`:
66
+
67
+ ```python
68
+ def cool():
69
+ return 0
70
+
71
+
72
+ def neat():
73
+ return cool()
74
+ ```
75
+
76
+ ### Indentation
77
+
78
+ mtangle is language-agnostic. When a codeblock is parsed, its non-blank lines
79
+ are left-aligned against the block's least-indented line. When a `<<marker>>`
80
+ is substituted, the substituted content is re-indented to match the marker's
81
+ column. This handles the common case of embedding a snippet inside an already-
82
+ indented body.
83
+
84
+ ### Escaping
85
+
86
+ - `<< name >>` (spaces inside) is not a marker — emitted literally.
87
+ - `\<<name>>` emits `<<name>>` literally (no substitution).
88
+ - `\\` emits a literal backslash.
89
+ - To emit a literal `\<<name>>`: use `\\\<<name>>`.
90
+
91
+ ## Merging into the same file
92
+
93
+ Multiple codeblocks targeting the same `file=` are concatenated in the order
94
+ they are encountered (across all input markdown files, sorted by path).
95
+ mtangle warns when this happens.
96
+
97
+ ## CLI
98
+
99
+ ```
100
+ mtangle [SOURCES...] [-o OUTPUT_DIR] [OPTIONS]
101
+ ```
102
+
103
+ With no arguments, `mtangle` is equivalent to `mtangle . -o .` — it recursively
104
+ scans the current directory for `.md` files and writes tangled output to the
105
+ current directory.
106
+
107
+ ### Options
108
+
109
+ | Flag | Description |
110
+ |------|-------------|
111
+ | `-o`, `--output-dir DIR` | Where to write files (default: cwd) |
112
+ | `-n`, `--dry-run` | Parse and resolve everything but write nothing |
113
+ | `-v`, `--verbose` | Print progress to stderr |
114
+ | `-i`, `--ignore PATTERN` | Gitignore-style skip pattern (repeatable) |
115
+ | `--disable-ignore-dotfiles` | Include dotfiles/dotdirs (default: skipped) |
116
+ | `--respect-gitignore` | Also apply `.gitignore` at each input-dir root |
117
+ | `--disable-path-safety` | Permit `file=` targets outside the output dir |
118
+
119
+ ### Ignore behavior
120
+
121
+ - Dotfiles and dotdirs (`.git`, `.venv`, etc.) are skipped by default.
122
+ - A `.mtangleignore` file at the root of each input directory is respected;
123
+ every non-empty, non-`#` line is treated like an `-i` pattern
124
+ (gitignore syntax).
125
+ - `.gitignore` is **not** consulted unless `--respect-gitignore` is set.
126
+ - Explicitly-named files (e.g. `mtangle README.md`) bypass all ignore rules.
127
+
128
+ ### Path safety
129
+
130
+ By default, `file=` targets that are absolute or that resolve outside the
131
+ output directory are warned and skipped. Use `--disable-path-safety` to opt
132
+ out.
133
+
134
+ ## Install
135
+
136
+ ```
137
+ uv pip install git+https://github.com/usergenic/mtangle.git
138
+ ```
139
+
140
+ Or add to your project:
141
+
142
+ ```
143
+ uv add git+https://github.com/usergenic/mtangle.git
144
+ ```
145
+
146
+ ## Development
147
+
148
+ ```
149
+ uv sync
150
+ uv run pytest
151
+ ```
@@ -0,0 +1,8 @@
1
+ mtangle/__init__.py,sha256=yQ5LbKTcTcYC88fnG8ceUUcKfwPx7aqtObF15HQH8ok,172
2
+ mtangle/cli.py,sha256=TUUcmYxuljMigoVNL_JrDPwRiUPXzeRw3Zuec3eL1xs,2741
3
+ mtangle/core.py,sha256=wHMstu7s-lQPUoMoYd7EUV00Fd0wDAfZuPFZpSFXAQU,12798
4
+ mtangle-0.1.0.dist-info/METADATA,sha256=Txm-8rIG1wpZBdK87VQM3qQB8H0dWEZ2zeH2QfBbnok,3791
5
+ mtangle-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
6
+ mtangle-0.1.0.dist-info/entry_points.txt,sha256=4gSRi9I7qYMMEHh0yIfyxDqXqhBLUWZEZLn90UDX_ZA,45
7
+ mtangle-0.1.0.dist-info/licenses/LICENSE,sha256=q7JWdDPDSH_-CFwtCtYp7AtUmMYDdMMrd-jO_vdcxWE,1072
8
+ mtangle-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mtangle = mtangle.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brendan Baldwin
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.