laminate-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.
laminate/__init__.py ADDED
File without changes
laminate/categorize.py ADDED
@@ -0,0 +1,280 @@
1
+ import json
2
+ import logging
3
+
4
+ from typing import Callable, Literal
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from .gitlog import Commit
9
+ from .llm import get_llm
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ Category = Literal["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]
14
+
15
+ DEFAULT_BATCH_SIZE = 15
16
+
17
+ SYSTEM_PROMPT = """You are a precise changelog generator.
18
+
19
+ You will be given a numbered list of raw git commit subjects.
20
+
21
+ Your job is to turn these commits into concise, human-readable changelog entries.
22
+
23
+ Related commits may be GROUPED together when they describe the same user-facing
24
+ change, feature, bug fix, or piece of functionality. A group can contain one
25
+ or multiple commits.
26
+
27
+ Do not group unrelated changes merely because they are similar in implementation.
28
+
29
+ Prefer a small number of meaningful changelog entries over one entry per commit.
30
+ Intermediate implementation commits, refactors, tests, and small follow-up fixes
31
+ may be grouped with the larger change they belong to.
32
+
33
+ For each changelog entry, produce:
34
+
35
+ - "category": exactly one of "Added", "Changed", "Deprecated", "Removed", "Fixed" or "Security"
36
+ - "description": a short, human-readable rewrite of the grouped commits,
37
+ written for someone reading a changelog rather than a git log
38
+ - "commits": a list of the input commit numbers belonging to this entry
39
+
40
+ Category definitions:
41
+ - Added: a new feature, capability, command, option, or user-facing functionality
42
+ - Changed: a modification to existing behavior or functionality
43
+ - Deprecated: functionality being phased out or marked for future removal
44
+ - Removed: functionality that has been removed
45
+ - Fixed: a bug fix or correction to broken behavior
46
+ - Security: a security vulnerability fix or security-related change
47
+
48
+ For descriptions:
49
+ - no ticket numbers
50
+ - no "fix:" or "feat:" prefixes
51
+ - plain sentence case
52
+ - describe the user-facing result rather than implementation details
53
+
54
+ Every input commit number must appear in exactly one group.
55
+ Do not omit commits.
56
+ Do not duplicate commit numbers.
57
+
58
+ Respond with ONLY a JSON array.
59
+ No markdown fences, no commentary, and no extra text before or after the array.
60
+
61
+ Example:
62
+
63
+ [
64
+ {
65
+ "category": "Added",
66
+ "description": "Added user authentication and OAuth support.",
67
+ "commits": [1, 2, 4]
68
+ },
69
+ {
70
+ "category": "Fixed",
71
+ "description": "Fixed authentication callback handling.",
72
+ "commits": [3]
73
+ }
74
+ ]
75
+ """
76
+
77
+
78
+ class CategorizedEntry(BaseModel):
79
+ category: Category
80
+ description: str
81
+
82
+
83
+ class CategorizeError(Exception):
84
+ """Raised when a batch can't be categorized at all."""
85
+
86
+ pass
87
+
88
+
89
+ def _chunk(items: list, size: int) -> list[list]:
90
+ return [items[i:i + size] for i in range(0, len(items), size)]
91
+
92
+
93
+ def _build_prompt(commits: list[Commit]) -> str:
94
+ lines = [
95
+ f"{i + 1}. {c.subject}"
96
+ for i, c in enumerate(commits)
97
+ ]
98
+ return "\n".join(lines)
99
+
100
+
101
+ def _strip_fences(text: str) -> str:
102
+ """Prevents the model from adding markdown fences around the JSON."""
103
+ text = text.strip()
104
+
105
+ if text.startswith("```"):
106
+ newline_idx = text.find("\n")
107
+ if newline_idx != -1:
108
+ text = text[newline_idx + 1:]
109
+ else:
110
+ text = text[3:]
111
+
112
+ if text.endswith("```"):
113
+ text = text.rsplit("```", 1)[0]
114
+
115
+ return text.strip()
116
+
117
+
118
+ def _fallback_entries(commits: list[Commit]) -> list[CategorizedEntry]:
119
+ return [
120
+ CategorizedEntry(
121
+ category="Changed",
122
+ description=c.subject,
123
+ )
124
+ for c in commits
125
+ ]
126
+
127
+
128
+ def _categorize_batch(
129
+ commits: list[Commit],
130
+ llm,
131
+ ) -> list[CategorizedEntry]:
132
+ """Categorize and intelligently group a batch of commits."""
133
+
134
+ prompt = _build_prompt(commits)
135
+
136
+ try:
137
+ response = llm.create_chat_completion(
138
+ messages=[
139
+ {"role": "system", "content": SYSTEM_PROMPT},
140
+ {"role": "user", "content": prompt},
141
+ ],
142
+ temperature=0.3,
143
+ )
144
+
145
+ text = response["choices"][0]["message"]["content"]
146
+
147
+ except Exception as e:
148
+ logger.warning(f"LLM error: {e}")
149
+ return _fallback_entries(commits)
150
+
151
+ text = _strip_fences(text)
152
+
153
+ try:
154
+ parsed = json.loads(text)
155
+
156
+ except json.JSONDecodeError as e:
157
+ logger.warning(f"Invalid JSON: {e}")
158
+ return _fallback_entries(commits)
159
+
160
+ if not isinstance(parsed, list):
161
+ logger.warning(
162
+ f"Expected list, got {type(parsed).__name__}"
163
+ )
164
+ return _fallback_entries(commits)
165
+
166
+ entries: list[CategorizedEntry] = []
167
+ used_commits: set[int] = set()
168
+
169
+ for item in parsed:
170
+ try:
171
+ category = item["category"]
172
+ description = item["description"]
173
+ commit_indices = item["commits"]
174
+
175
+ if not isinstance(commit_indices, list):
176
+ raise ValueError("commits must be a list")
177
+
178
+ if not commit_indices:
179
+ raise ValueError("commits list cannot be empty")
180
+
181
+ normalized_indices = []
182
+
183
+ for index in commit_indices:
184
+ if not isinstance(index, int):
185
+ raise ValueError(
186
+ f"commit index must be an integer, got {index!r}"
187
+ )
188
+
189
+ if not 1 <= index <= len(commits):
190
+ raise ValueError(
191
+ f"commit index {index} is out of range"
192
+ )
193
+
194
+ if index in used_commits:
195
+ raise ValueError(
196
+ f"commit {index} appears in multiple groups"
197
+ )
198
+
199
+ normalized_indices.append(index)
200
+
201
+ entry = CategorizedEntry(
202
+ category=category,
203
+ description=description,
204
+ )
205
+
206
+ entries.append(entry)
207
+ used_commits.update(normalized_indices)
208
+
209
+ except (KeyError, ValueError, TypeError) as e:
210
+ logger.warning(f"Invalid grouped entry: {e}")
211
+ continue
212
+
213
+ missing_commits = [
214
+ i
215
+ for i in range(1, len(commits) + 1)
216
+ if i not in used_commits
217
+ ]
218
+
219
+ if missing_commits:
220
+ logger.info(
221
+ f"Model omitted {len(missing_commits)} commits; "
222
+ "using fallback entries for them"
223
+ )
224
+
225
+ for index in missing_commits:
226
+ entries.append(
227
+ CategorizedEntry(
228
+ category="Changed",
229
+ description=commits[index - 1].subject,
230
+ )
231
+ )
232
+
233
+ if not entries:
234
+ return _fallback_entries(commits)
235
+
236
+ return entries
237
+
238
+
239
+ def categorize_commits(
240
+ commits: list[Commit],
241
+ batch_size: int = DEFAULT_BATCH_SIZE,
242
+ on_progress: Callable[[int, int, int], None] | None = None,
243
+ ) -> list[CategorizedEntry]:
244
+ if not commits:
245
+ return []
246
+
247
+ llm = get_llm()
248
+ all_entries: list[CategorizedEntry] = []
249
+ batches = _chunk(commits, batch_size)
250
+
251
+ for i, batch in enumerate(batches):
252
+ if on_progress:
253
+ on_progress(
254
+ i + 1,
255
+ len(batches),
256
+ len(batch),
257
+ )
258
+
259
+ all_entries.extend(
260
+ _categorize_batch(batch, llm)
261
+ )
262
+
263
+ return all_entries
264
+
265
+ # testing purposes only
266
+ if __name__ == "__main__":
267
+ from .gitlog import get_commits
268
+
269
+ commits = get_commits()[:15]
270
+
271
+ if not commits:
272
+ print("No commits found in current directory.")
273
+ else:
274
+ print(f"Categorizing {len(commits)} commits...\n")
275
+ results = categorize_commits(commits)
276
+ for entry in results:
277
+ print(
278
+ f"[{entry.category}] "
279
+ f"{entry.description}"
280
+ )
laminate/cli.py ADDED
@@ -0,0 +1,91 @@
1
+ import typer
2
+
3
+ from concurrent.futures import ThreadPoolExecutor
4
+
5
+ import sys
6
+
7
+ from rich.console import Console
8
+ from rich.live import Live
9
+ from rich.spinner import Spinner
10
+
11
+ from .gitlog import get_commits, GitLogError
12
+ from .categorize import categorize_commits
13
+ from .llm import LLMError
14
+ from .render import render_changelog, write_changelog
15
+
16
+ app = typer.Typer(add_completion=False)
17
+ console = Console()
18
+
19
+
20
+ @app.command()
21
+ def generate(
22
+ repo: str = typer.Option(".", help="Path to the git repository."),
23
+ since: str = typer.Option(None, help="Only include commits after this date, e.g. 2026-01-01."),
24
+ until: str = typer.Option(None, help="Only include commits before this date, e.g. 2026-01-01."),
25
+ commit_range: str = typer.Option(None, "--range", help="A git commit range, e.g. v1.0.0..v1.1.0. Overrides --since/--until."),
26
+ output: str = typer.Option("CHANGELOG.md", help="Path to write the generated changelog to."),
27
+ batch_size: int = typer.Option(15, "--batch-size", help="Number of commits sent to the LLM per batch."),
28
+ release: str = typer.Option(None, "--release", help="Version number for this release, e.g. 1.1.0. If omitted, the changelog is headed [Unreleased]."),
29
+ ):
30
+ if repo != ".":
31
+ typer.echo(f"Reading commits from '{repo}'...")
32
+ else:
33
+ typer.echo("Reading commits from working directory...")
34
+
35
+
36
+ try:
37
+ commits = get_commits(
38
+ repo_path=repo,
39
+ since=since,
40
+ until=until,
41
+ commit_range=commit_range,
42
+ )
43
+ except GitLogError as e:
44
+ typer.echo(f"Error reading git log: {e}", err=True)
45
+ raise typer.Exit(code=1)
46
+
47
+ if not commits:
48
+ typer.echo("No commits found for the given range - nothing to do.")
49
+ raise typer.Exit(code=0)
50
+
51
+ console.print(f"Found {len(commits)} commits.")
52
+
53
+ try:
54
+ spinner = Spinner(
55
+ "dots",
56
+ text="Categorizing commits with the local model...",
57
+ )
58
+
59
+ def progress(current, total, count):
60
+ spinner.update(
61
+ text=f"Categorizing commits... batch {current}/{total} ({count} commits)"
62
+ )
63
+
64
+ with Live(spinner, refresh_per_second=10):
65
+ with ThreadPoolExecutor(max_workers=1) as executor:
66
+ future = executor.submit(
67
+ categorize_commits,
68
+ commits,
69
+ batch_size=batch_size,
70
+ on_progress=progress,
71
+ )
72
+ entries = future.result()
73
+
74
+ console.print(f"✓ Categorized {len(commits)} commits.")
75
+
76
+ except LLMError as e:
77
+ typer.echo(
78
+ f"Error loading or running the local model: {e}",
79
+ err=True,
80
+ )
81
+ raise typer.Exit(code=1)
82
+
83
+ changelog = render_changelog(entries, version=release)
84
+ write_changelog(changelog, output)
85
+
86
+ typer.echo(f"\nDone. Changelog written to {output}", file=sys.stdout)
87
+
88
+
89
+
90
+ if __name__ == "__main__":
91
+ app()
laminate/gitlog.py ADDED
@@ -0,0 +1,99 @@
1
+ import subprocess
2
+ from pydantic import BaseModel
3
+
4
+
5
+ FIELD_SEP = "\x1f"
6
+ RECORD_SEP = "\x1e"
7
+
8
+
9
+ class Commit(BaseModel):
10
+ hash: str
11
+ short_hash: str
12
+ author: str
13
+ date: str # ISO 8601, e.g. 2026-09-30T14:22:01-05:00
14
+ subject: str
15
+
16
+ class GitLogError(Exception):
17
+ pass
18
+
19
+ def get_commits(
20
+ repo_path: str = ".",
21
+ since: str | None = None,
22
+ until: str | None = None,
23
+ commit_range: str | None = None,
24
+ ) -> list[Commit]:
25
+ """
26
+ Returns commits from the given repo, newest first.
27
+
28
+ repo_path: path to the git repository (defaults to current directory)
29
+ since / until: date bounds, e.g. since="2026-01-01"
30
+ commit_range: a git revision range, e.g. "v1.0.0..v1.1.0" - if given,
31
+ this takes priority over since/until
32
+
33
+ Raises GitLogError if the path isn't a git repo, the range is
34
+ invalid, or git itself isn't installed/on PATH.
35
+ """
36
+ pretty_format = FIELD_SEP.join(["%H", "%h", "%an", "%aI", "%s"]) + RECORD_SEP
37
+
38
+ cmd = ["git", "-C", repo_path, "log", f"--pretty=format:{pretty_format}"]
39
+
40
+ if commit_range:
41
+ cmd.append(commit_range)
42
+ else:
43
+ if since:
44
+ cmd.append(f"--since={since}")
45
+ if until:
46
+ cmd.append(f"--until={until}")
47
+
48
+ try:
49
+ result = subprocess.run(
50
+ cmd,
51
+ capture_output=True,
52
+ text=True,
53
+ check=True,
54
+ )
55
+ except FileNotFoundError:
56
+ raise GitLogError(
57
+ "git doesn't appear to be installed, or isn't on your PATH."
58
+ )
59
+ except subprocess.CalledProcessError as e:
60
+ raise GitLogError(
61
+ f"git log failed (is '{repo_path}' a git repo, and is the "
62
+ f"commit range valid?): {e.stderr.strip()}"
63
+ )
64
+
65
+ raw_output = result.stdout
66
+ if not raw_output.strip():
67
+ return []
68
+
69
+ commits = []
70
+ for record in raw_output.split(RECORD_SEP):
71
+ record = record.strip()
72
+ if not record:
73
+ continue
74
+
75
+ fields = record.split(FIELD_SEP)
76
+ if len(fields) != 5:
77
+ continue
78
+
79
+ full_hash, short_hash, author, date, subject = fields
80
+ commits.append(
81
+ Commit(
82
+ hash=full_hash,
83
+ short_hash=short_hash,
84
+ author=author,
85
+ date=date,
86
+ subject=subject,
87
+ )
88
+ )
89
+
90
+ return commits
91
+
92
+ # testing purposes only
93
+ if __name__ == "__main__":
94
+ commits = get_commits()
95
+ print(f"Found {len(commits)} commits:\n")
96
+ for c in commits[:10]:
97
+ print(f"{c.short_hash} {c.date} {c.author}: {c.subject}")
98
+ if len(commits) > 10:
99
+ print(f"... and {len(commits) - 10} more")
laminate/llm.py ADDED
@@ -0,0 +1,95 @@
1
+ from pathlib import Path
2
+ from llama_cpp import Llama
3
+
4
+ MODELS_DIR = Path(__file__).resolve().parent.parent / "models"
5
+
6
+ DEFAULT_N_CTX = 4096
7
+ _llm: Llama | None = None
8
+
9
+
10
+ class LLMError(Exception):
11
+ pass
12
+
13
+
14
+ def _find_model_file() -> Path:
15
+ if not MODELS_DIR.exists():
16
+ raise LLMError(
17
+ f"Models folder not found at {MODELS_DIR}. Create it and "
18
+ "place a .gguf model file inside (see README for a "
19
+ "recommended model and download link)."
20
+ )
21
+
22
+ gguf_files = list(MODELS_DIR.glob("*.gguf"))
23
+
24
+ if not gguf_files:
25
+ raise LLMError(
26
+ f"No .gguf model file found in {MODELS_DIR}. Download a "
27
+ "compatible model (see README) and place it in that folder."
28
+ )
29
+
30
+ if len(gguf_files) > 1:
31
+ names = ", ".join(f.name for f in gguf_files)
32
+ raise LLMError(
33
+ f"Multiple .gguf files found in {MODELS_DIR} ({names}) - "
34
+ "keep only one so Laminate knows which to load."
35
+ )
36
+
37
+ return gguf_files[0]
38
+
39
+
40
+ def get_llm(n_ctx: int = DEFAULT_N_CTX) -> Llama:
41
+ global _llm
42
+
43
+ if _llm is None:
44
+ model_path = _find_model_file()
45
+ try:
46
+ _llm = Llama(
47
+ model_path=str(model_path),
48
+ n_ctx=n_ctx,
49
+ verbose=False,
50
+ )
51
+ except Exception as e:
52
+ raise LLMError(f"Failed to load model at {model_path}: {e}")
53
+
54
+ return _llm
55
+
56
+
57
+ def generate(
58
+ prompt: str,
59
+ system_prompt: str | None = None,
60
+ max_tokens: int = 512,
61
+ temperature: float = 0.3,
62
+ ) -> str:
63
+ """
64
+ Sends a prompt to the local model and returns its text response.
65
+
66
+ system_prompt: optional instruction framing the model's role/task
67
+ (e.g. "You are a precise changelog generator...")
68
+ temperature: kept low by default (0.3) to prevent rambling. Increase the temperature for more "creative" responses.
69
+ """
70
+ llm = get_llm()
71
+
72
+ messages = []
73
+ if system_prompt:
74
+ messages.append({"role": "system", "content": system_prompt})
75
+ messages.append({"role": "user", "content": prompt})
76
+
77
+ response = llm.create_chat_completion(
78
+ messages=messages,
79
+ max_tokens=max_tokens,
80
+ temperature=temperature,
81
+ )
82
+
83
+ return response["choices"][0]["message"]["content"].strip()
84
+
85
+ # testing purposes only
86
+ if __name__ == "__main__":
87
+ print("Loading model...")
88
+ get_llm()
89
+ print("Model loaded. Sending a test prompt...\n")
90
+
91
+ reply = generate(
92
+ "Reply with exactly one short sentence confirming you're working.",
93
+ max_tokens=50,
94
+ )
95
+ print(f"Response: {reply}")
laminate/render.py ADDED
@@ -0,0 +1,71 @@
1
+ from collections import defaultdict
2
+ from datetime import date
3
+
4
+ from .categorize import CategorizedEntry
5
+
6
+ CATEGORY_ORDER = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]
7
+
8
+
9
+ def render_changelog(entries: list[CategorizedEntry], version: str | None = None) -> str:
10
+ """
11
+ Renders categorized entries into Keep a Changelog formatted
12
+ markdown.
13
+
14
+ version: if given, the header becomes "## [version] - today's date"
15
+ (for cutting an actual release). If omitted, defaults to the
16
+ standard "## [Unreleased]" header.
17
+ """
18
+ if version:
19
+ header = f"## [{version}] - {date.today().isoformat()}"
20
+ else:
21
+ header = "## [Unreleased]"
22
+
23
+ lines = [
24
+ "# Changelog",
25
+ "",
26
+ "All notable changes to this project will be documented in this file.",
27
+ "",
28
+ "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), "
29
+ "and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).",
30
+ "",
31
+ header,
32
+ "",
33
+ ]
34
+
35
+ by_category: dict[str, list[str]] = defaultdict(list)
36
+ for entry in entries:
37
+ by_category[entry.category].append(entry.description)
38
+
39
+ for category in CATEGORY_ORDER:
40
+ if category not in by_category:
41
+ continue
42
+ lines.append(f"### {category}")
43
+ for desc in by_category[category]:
44
+ lines.append(f"- {desc}")
45
+ lines.append("")
46
+
47
+ return "\n".join(lines).rstrip() + "\n"
48
+
49
+
50
+ def write_changelog(content: str, output_path: str = "CHANGELOG.md") -> None:
51
+ with open(output_path, "w", encoding="utf-8") as f:
52
+ f.write(content)
53
+
54
+ # testing purposes only
55
+ if __name__ == "__main__":
56
+
57
+ from .gitlog import get_commits
58
+ from .categorize import categorize_commits
59
+
60
+ commits = get_commits()[:10]
61
+
62
+ if not commits:
63
+ print("No commits found in current directory.")
64
+ else:
65
+ print(f"Categorizing {len(commits)} commits...\n")
66
+ entries = categorize_commits(commits)
67
+ changelog = render_changelog(entries)
68
+
69
+ print(changelog)
70
+ write_changelog(changelog)
71
+ print("Wrote changelog.md")
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: laminate-cli
3
+ Version: 0.1.0
4
+ Summary: Generate release-ready changelogs from git history using a local LLM
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: llama-cpp-python
9
+ Requires-Dist: pydantic
10
+ Requires-Dist: typer
11
+ Requires-Dist: rich
12
+ Dynamic: license-file
13
+
14
+ <div style="text-align: center;">
15
+ <img src="assets/laminate-logo.svg" alt="Laminate Logo" width=200>
16
+ <h1>Laminate</h1>
17
+ <h4><a href="https://apleut.dev">My Website</a> - <a href="changelog.md">Changelog</a></h4>
18
+ <hr></hr>
19
+ </div>
20
+
21
+ > [!NOTE]
22
+ > Laminate is in **extremely early development.** Everything stated here is subject to change. If you want to contribute to Laminate, contact me via email at __me@apleut.dev__.
23
+
24
+ > [!WARNING]
25
+ > When installing requirements on Windows machines, make sure to install `llama-cpp-python` using this command first:
26
+ > `pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu`
27
+ > Otherwise, the path lengths of the source code will be too long for Windows to handle, and it will raise an error.
28
+
29
+ A CLI to write release-ready changelogs for you using llama-cpp-python.
@@ -0,0 +1,12 @@
1
+ laminate/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ laminate/categorize.py,sha256=YPkkT1GywPJENXY95anTxheoOWB5DmtO5s9lzXuaidQ,7630
3
+ laminate/cli.py,sha256=4phL0uJBMaQst9FpdPBFteg5Duzt1qsax99wqKlKhCc,2959
4
+ laminate/gitlog.py,sha256=dy5SGEvjYeL-5U6CZ2popTr8fbfnWWG0VcZAdgfj9po,2657
5
+ laminate/llm.py,sha256=ykzT1jyxGLtHNaG7cPx6AbIoh6lLdamhxLBKBdqzv_0,2602
6
+ laminate/render.py,sha256=nNZfF-J7LhI9UhICOy_pw66-wQfRdMNcEkr1XrJschM,2169
7
+ laminate_cli-0.1.0.dist-info/licenses/LICENSE,sha256=YF6QR6Vjxcg5b_sYIyqkME7FZYau5TfEUGTG-0JeRK0,35129
8
+ laminate_cli-0.1.0.dist-info/METADATA,sha256=tlQOZEMSM_hfksp6g3_EgcZfD_Obw2lPyB4JjC3c2HE,1196
9
+ laminate_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ laminate_cli-0.1.0.dist-info/entry_points.txt,sha256=uY2f2xhLPxYDUagcq3qBB0K6pD3DmFZnMxbINJ3QeSY,46
11
+ laminate_cli-0.1.0.dist-info/top_level.txt,sha256=GSm5tt407xo7z7F-vi6xxgNZzAXCDwOEFh-OxTWKrJw,9
12
+ laminate_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ laminate = laminate.cli:app