ccrt 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.
ccrt/tui.py ADDED
@@ -0,0 +1,349 @@
1
+ """Optional full-screen terminal dashboard for reviewing candidate links.
2
+
3
+ The module uses only the standard-library curses package and can be removed
4
+ without changing the matching or review data formats.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ import textwrap
11
+ from collections import defaultdict
12
+
13
+ from ccrt.interactive import _coordinates, _date
14
+
15
+ try:
16
+ import curses as _curses
17
+ except ImportError: # Optional on platforms without curses.
18
+ _curses = None
19
+
20
+
21
+ class ReviewDashboard:
22
+ """Navigation and decisions, independent of the terminal renderer."""
23
+
24
+ def __init__(self, result, review, *, reviewer, save, left_id=None):
25
+ review.validate(result)
26
+ if not reviewer.strip():
27
+ raise ValueError("--reviewer needs a nonempty name")
28
+ self.result = result
29
+ self.review = review
30
+ self.reviewer = reviewer
31
+ self.save = save
32
+ self.groups = defaultdict(list)
33
+ for candidate in result.candidates:
34
+ self.groups[candidate.left.source_id].append(candidate)
35
+ if left_id is not None and left_id not in self.groups:
36
+ raise ValueError(f"no candidate group for left source ID {left_id}")
37
+ self.decisions = {decision.candidate_id: decision for decision in review.decisions}
38
+ self.show_all = False
39
+ self.group_ids = []
40
+ self.group_index = 0
41
+ self.candidate_index = 0
42
+ self.detail_scroll = 0
43
+ self.message = "Arrows: groups/candidates a/r/u: decide e: note f: filter g: jump q: quit"
44
+ self.refresh(start=left_id)
45
+
46
+ def refresh(self, *, start=None):
47
+ current = start or (self.group_ids[self.group_index] if self.group_ids else None)
48
+ self.group_ids = sorted(
49
+ (
50
+ key
51
+ for key, items in self.groups.items()
52
+ if key == current
53
+ or self.show_all
54
+ or any(self.decisions[c.candidate_id].status == "unresolved" for c in items)
55
+ ),
56
+ key=lambda key: (-self.groups[key][0].score, key),
57
+ )
58
+ self.group_index = self.group_ids.index(current) if current in self.group_ids else 0
59
+ self.candidate_index = 0
60
+ self.detail_scroll = 0
61
+
62
+ @property
63
+ def candidates(self):
64
+ return self.groups[self.group_ids[self.group_index]] if self.group_ids else []
65
+
66
+ @property
67
+ def candidate(self):
68
+ return self.candidates[self.candidate_index] if self.candidates else None
69
+
70
+ def move_group(self, delta):
71
+ if self.group_ids:
72
+ self.group_index = (self.group_index + delta) % len(self.group_ids)
73
+ self.candidate_index = 0
74
+ self.detail_scroll = 0
75
+
76
+ def move_candidate(self, delta):
77
+ if self.candidates:
78
+ self.candidate_index = (self.candidate_index + delta) % len(self.candidates)
79
+ self.detail_scroll = 0
80
+
81
+ def jump(self, left_id):
82
+ if left_id not in self.groups:
83
+ self.message = f"No candidate group for {left_id}"
84
+ return
85
+ self.refresh(start=left_id)
86
+ self.message = f"Jumped to {left_id}"
87
+
88
+ def toggle_filter(self):
89
+ self.show_all = not self.show_all
90
+ self.refresh()
91
+ self.message = "Showing all groups" if self.show_all else "Showing unresolved groups"
92
+
93
+ def decide(self, status, *, note=None):
94
+ candidate = self.candidate
95
+ if candidate is None:
96
+ return
97
+ old = self.decisions[candidate.candidate_id]
98
+ updated = self.review.decide(
99
+ candidate.candidate_id,
100
+ status,
101
+ reviewer=self.reviewer,
102
+ note=old.note if note is None else note,
103
+ )
104
+ self.save(updated)
105
+ self.review = updated
106
+ self.decisions[candidate.candidate_id] = next(
107
+ decision for decision in updated.decisions if decision.candidate_id == candidate.candidate_id
108
+ )
109
+ self.message = f"Saved {status}: {candidate.left.source_id}/{candidate.right.source_id}"
110
+
111
+ def set_note(self, note):
112
+ candidate = self.candidate
113
+ if candidate is not None:
114
+ self.decide(self.decisions[candidate.candidate_id].status, note=note)
115
+
116
+
117
+ def _put(screen, y, x, value, width, attr=0):
118
+ if width < 1 or y < 0 or x < 0:
119
+ return
120
+ try:
121
+ screen.addnstr(y, x, str(value).replace("\n", " "), width, attr)
122
+ except Exception as exc:
123
+ # curses.error is raised when a terminal rejects a bottom-right write.
124
+ if _curses is None or not isinstance(exc, _curses.error):
125
+ raise
126
+
127
+
128
+ def _field(lines, label, value, width):
129
+ value = " ".join(str("not supplied" if value is None or value == "" else value).split())
130
+ prefix = f"{label}: "
131
+ lines.extend(textwrap.wrap(prefix + value, width=max(12, width), subsequent_indent=" ") or [prefix])
132
+
133
+
134
+ def _account_lines(lines, record, width):
135
+ raw = record.raw
136
+ lines.append(f"{record.source.upper()} {record.source_id}")
137
+ _field(lines, "Date", _date(record), width)
138
+ _field(lines, "Place", raw.get("where_description") or raw.get("location"), width)
139
+ _field(lines, "Coordinates", _coordinates(record), width)
140
+ _field(lines, "Actor 1", record.actor1, width)
141
+ _field(lines, "Actor 2", record.actor2, width)
142
+ _field(lines, "Deaths", record.total_deaths_best, width)
143
+ _field(lines, "Location precision", record.geo_precision, width)
144
+ for label, key in (
145
+ ("Notes", "notes"),
146
+ ("Source articles", "source_article"),
147
+ ("Original sources", "source_original"),
148
+ ):
149
+ if raw.get(key):
150
+ _field(lines, label, raw[key], width)
151
+ lines.append("")
152
+
153
+
154
+ def detail_lines(dashboard, width):
155
+ candidate = dashboard.candidate
156
+ if candidate is None:
157
+ return ["No candidate groups in this view. Press f to show all groups."]
158
+ decision = dashboard.decisions[candidate.candidate_id]
159
+ lines = []
160
+ _account_lines(lines, candidate.left, width)
161
+ _account_lines(lines, candidate.right, width)
162
+ lines.append("COMPARISON")
163
+ _field(
164
+ lines,
165
+ "Distance",
166
+ f"{candidate.distance_km:.3f} km (limit {dashboard.result.config.max_km:g} km)",
167
+ width,
168
+ )
169
+ _field(
170
+ lines,
171
+ "Date gap",
172
+ f"{candidate.days_apart} days (limit {dashboard.result.config.max_days} days)",
173
+ width,
174
+ )
175
+ _field(lines, "Proximity score", f"{candidate.score:.3f} (ranking only)", width)
176
+ _field(lines, "Warnings", ", ".join(candidate.warnings) or "none", width)
177
+ _field(lines, "Decision", decision.status, width)
178
+ _field(lines, "Reviewer note", decision.note, width)
179
+ lines.append("")
180
+ lines.extend(
181
+ textwrap.wrap(
182
+ "Events can share coordinates. Check the dates, actors, and notes.",
183
+ width=max(12, width),
184
+ )
185
+ )
186
+ return lines
187
+
188
+
189
+ def _draw(screen, dashboard, curses):
190
+ height, width = screen.getmaxyx()
191
+ screen.erase()
192
+ if height < 20 or width < 95:
193
+ _put(screen, 0, 0, "CCRT review dashboard", width - 1, curses.A_BOLD)
194
+ _put(screen, 2, 0, "Resize terminal to at least 95 columns x 20 rows. Press q to quit.", width - 1)
195
+ screen.refresh()
196
+ return
197
+ left_width = 23
198
+ middle_width = 39
199
+ detail_x = left_width + middle_width + 2
200
+ detail_width = width - detail_x - 1
201
+ visible = height - 6
202
+ counts = defaultdict(int)
203
+ for decision in dashboard.review.decisions:
204
+ counts[decision.status] += 1
205
+ accent = curses.color_pair(1) if curses.has_colors() else 0
206
+ _put(screen, 0, 0, " CCRT/Review ", width - 1, curses.A_REVERSE | curses.A_BOLD | accent)
207
+ _put(
208
+ screen,
209
+ 1,
210
+ 1,
211
+ f"Reviewer {dashboard.reviewer} | {counts['accepted']} accepted {counts['rejected']} rejected"
212
+ f" {counts['unresolved']} unresolved | {len(dashboard.group_ids)} groups",
213
+ width - 2,
214
+ )
215
+ _put(screen, 3, 1, "UCDP EVENTS", left_width - 2, curses.A_BOLD)
216
+ _put(screen, 3, left_width + 1, "ACLED CANDIDATES", middle_width - 2, curses.A_BOLD)
217
+ _put(screen, 3, detail_x, "SOURCE ACCOUNTS", detail_width, curses.A_BOLD)
218
+ for y in range(3, height - 2):
219
+ _put(screen, y, left_width, "|", 1)
220
+ _put(screen, y, detail_x - 1, "|", 1)
221
+ if dashboard.group_ids:
222
+ start = max(0, dashboard.group_index - visible // 2)
223
+ start = min(start, max(0, len(dashboard.group_ids) - visible))
224
+ for offset, index in enumerate(range(start, min(start + visible, len(dashboard.group_ids)))):
225
+ group_id = dashboard.group_ids[index]
226
+ pending = sum(
227
+ dashboard.decisions[c.candidate_id].status == "unresolved" for c in dashboard.groups[group_id]
228
+ )
229
+ marker = ">" if index == dashboard.group_index else " "
230
+ value = f"{marker} {group_id} ({pending} open)"
231
+ attr = curses.A_REVERSE if index == dashboard.group_index else 0
232
+ _put(screen, 4 + offset, 1, value, left_width - 2, attr)
233
+ candidates = dashboard.candidates
234
+ start = max(0, dashboard.candidate_index - visible // 2)
235
+ start = min(start, max(0, len(candidates) - visible))
236
+ for offset, index in enumerate(range(start, min(start + visible, len(candidates)))):
237
+ candidate = candidates[index]
238
+ status = dashboard.decisions[candidate.candidate_id].status
239
+ code = {"accepted": "A", "rejected": "R", "unresolved": "?"}[status]
240
+ marker = ">" if index == dashboard.candidate_index else " "
241
+ value = (
242
+ f"{marker} {code} {candidate.right.source_id} {candidate.score:.3f}"
243
+ f" {candidate.distance_km:.1f}km"
244
+ )
245
+ attr = curses.A_REVERSE if index == dashboard.candidate_index else 0
246
+ if curses.has_colors() and index != dashboard.candidate_index:
247
+ attr = curses.color_pair({"accepted": 2, "rejected": 3, "unresolved": 4}[status])
248
+ _put(screen, 4 + offset, left_width + 1, value, middle_width - 2, attr)
249
+ lines = detail_lines(dashboard, detail_width)
250
+ max_scroll = max(0, len(lines) - visible)
251
+ dashboard.detail_scroll = min(dashboard.detail_scroll, max_scroll)
252
+ for offset, line in enumerate(lines[dashboard.detail_scroll : dashboard.detail_scroll + visible]):
253
+ _put(screen, 4 + offset, detail_x, line, detail_width)
254
+ _put(
255
+ screen,
256
+ height - 2,
257
+ 0,
258
+ " Left/Right group Up/Down candidate PgUp/PgDn scroll a/r/u decide e note f filter g jump q quit ",
259
+ width - 1,
260
+ curses.A_REVERSE,
261
+ )
262
+ _put(screen, height - 1, 1, dashboard.message, width - 2)
263
+ screen.refresh()
264
+
265
+
266
+ def _prompt(screen, curses, label):
267
+ height, width = screen.getmaxyx()
268
+ screen.move(height - 1, 0)
269
+ screen.clrtoeol()
270
+ _put(screen, height - 1, 0, label, width - 1)
271
+ screen.refresh()
272
+ curses.echo()
273
+ try:
274
+ curses.curs_set(1)
275
+ except curses.error:
276
+ pass
277
+ try:
278
+ value = screen.getstr(height - 1, min(len(label), width - 2), 1000)
279
+ return value.decode("utf-8", errors="replace").strip()
280
+ finally:
281
+ curses.noecho()
282
+ try:
283
+ curses.curs_set(0)
284
+ except curses.error:
285
+ pass
286
+
287
+
288
+ def _loop(screen, dashboard, curses):
289
+ if curses.has_colors():
290
+ curses.start_color()
291
+ try:
292
+ curses.use_default_colors()
293
+ background = -1
294
+ except curses.error:
295
+ background = curses.COLOR_BLACK
296
+ for number, foreground in (
297
+ (1, curses.COLOR_CYAN),
298
+ (2, curses.COLOR_GREEN),
299
+ (3, curses.COLOR_RED),
300
+ (4, curses.COLOR_YELLOW),
301
+ ):
302
+ curses.init_pair(number, foreground, background)
303
+ try:
304
+ curses.curs_set(0)
305
+ except curses.error:
306
+ pass
307
+ screen.keypad(True)
308
+ while True:
309
+ _draw(screen, dashboard, curses)
310
+ key = screen.getch()
311
+ if key in (ord("q"), ord("Q")):
312
+ return
313
+ if key in (curses.KEY_LEFT, ord("h")):
314
+ dashboard.move_group(-1)
315
+ elif key in (curses.KEY_RIGHT, ord("l")):
316
+ dashboard.move_group(1)
317
+ elif key in (curses.KEY_UP, ord("k")):
318
+ dashboard.move_candidate(-1)
319
+ elif key in (curses.KEY_DOWN, ord("j")):
320
+ dashboard.move_candidate(1)
321
+ elif key == curses.KEY_PPAGE:
322
+ dashboard.detail_scroll = max(0, dashboard.detail_scroll - 8)
323
+ elif key == curses.KEY_NPAGE:
324
+ dashboard.detail_scroll += 8
325
+ elif key in (ord("a"), ord("r"), ord("u")):
326
+ dashboard.decide({ord("a"): "accepted", ord("r"): "rejected", ord("u"): "unresolved"}[key])
327
+ elif key == ord("e") and dashboard.candidate is not None:
328
+ dashboard.set_note(_prompt(screen, curses, "Reviewer note (blank clears): "))
329
+ elif key == ord("f"):
330
+ dashboard.toggle_filter()
331
+ elif key == ord("g"):
332
+ value = _prompt(screen, curses, "UCDP/source ID: ")
333
+ if value:
334
+ dashboard.jump(value)
335
+
336
+
337
+ def run_tui(result, review, *, reviewer, save, left_id=None):
338
+ """Start the optional dashboard in an interactive terminal."""
339
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
340
+ raise ValueError("review-tui needs an interactive terminal; use review-interactive for pipes")
341
+ if _curses is None:
342
+ raise ValueError("review-tui requires a terminal with Python curses support") from None
343
+ dashboard = ReviewDashboard(result, review, reviewer=reviewer, save=save, left_id=left_id)
344
+ try:
345
+ _curses.wrapper(lambda screen: _loop(screen, dashboard, _curses))
346
+ except KeyboardInterrupt:
347
+ return
348
+ except _curses.error as exc:
349
+ raise ValueError(f"terminal could not start review-tui: {exc}") from exc
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: ccrt
3
+ Version: 0.1.0
4
+ Summary: Experimental toolkit for reviewing candidate links between conflict incident records
5
+ Author: Fabian Figueroa
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/FabianFig/ccrt
8
+ Project-URL: Issues, https://github.com/FabianFig/ccrt/issues
9
+ Keywords: conflict,record-linkage,ucdp,acled,research
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Operating System :: OS Independent
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: httpx<1,>=0.27
23
+ Requires-Dist: python-dotenv<2,>=1
24
+ Provides-Extra: tables
25
+ Requires-Dist: pandas>=2.2; extra == "tables"
26
+ Requires-Dist: pyarrow>=15; extra == "tables"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8; extra == "dev"
29
+ Requires-Dist: ruff>=0.9; extra == "dev"
30
+ Requires-Dist: build>=1.2; extra == "dev"
31
+ Requires-Dist: twine>=6; extra == "dev"
32
+ Requires-Dist: nbformat>=5.10; extra == "dev"
33
+ Requires-Dist: nbclient>=0.10; extra == "dev"
34
+ Requires-Dist: ipykernel>=6; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # Conflict Casualty Reconciliation Toolkit (CCRT)
38
+
39
+ CCRT compares source accounts of conflict incidents. It imports UCDP GED and ACLED
40
+ records, proposes possible links, supports human review, and exports results while
41
+ preserving each source's original claims.
42
+
43
+ **Status:** `0.1.0` is an experimental research library.
44
+
45
+ ## Installation
46
+
47
+ CCRT supports Python 3.10–3.14. To install a published release:
48
+
49
+ ```bash
50
+ python -m pip install ccrt
51
+ ```
52
+
53
+ Install `ccrt[tables]` for pandas and Parquet support. From a checkout, use
54
+ `python -m pip install -e .`, or `python -m pip install -e '.[tables,dev]'` for the
55
+ project's development checks.
56
+
57
+ ## Quickstart
58
+
59
+ The example files contain invented incidents in Exampleland. From the repository
60
+ root, with an unused `work/` directory:
61
+
62
+ ```bash
63
+ mkdir -p work
64
+ ccrt import ucdp examples/ucdp_synthetic.csv work/ucdp.json --version 26.1
65
+ ccrt import acled examples/acled_synthetic.csv work/acled.json --version synthetic-2026-09-24
66
+ ccrt match work/ucdp.json work/acled.json work/matches
67
+ ccrt report work/matches/run.json --review work/matches/review.json
68
+ ```
69
+
70
+ The match run contains three candidate links and four unlinked source records.
71
+ Inspect `work/matches/candidates.csv`, then use the [usage guide](docs/usage.md) to
72
+ record decisions and export reviewed results. CCRT does not overwrite existing
73
+ outputs.
74
+
75
+ ## Python API
76
+
77
+ ```python
78
+ from ccrt import MatchConfig, load_acled_csv, load_ucdp_csv, reconcile
79
+
80
+ ucdp = load_ucdp_csv("examples/ucdp_synthetic.csv", version="26.1")
81
+ acled = load_acled_csv("examples/acled_synthetic.csv", version="synthetic-2026-09-24")
82
+ result = reconcile(ucdp, acled, config=MatchConfig(max_days=3, max_km=25))
83
+
84
+ for candidate in result.candidates:
85
+ print(candidate.left.source_id, candidate.right.source_id, candidate.score)
86
+ ```
87
+
88
+ The [API reference](docs/api.md) covers review decisions, saved runs, reports, and
89
+ exports.
90
+
91
+ ## Sources and capabilities
92
+
93
+ - UCDP GED 25.1 and 26.1 CSV files with modern field names, and authenticated GED
94
+ API access.
95
+ - ACLED event CSV exports with current lowercase field names. The supplied version
96
+ is a label for the exact export snapshot.
97
+ - Source-preserving JSON bundles, candidate generation, CSV and terminal review,
98
+ review reports, CSV export, and optional Parquet export.
99
+
100
+ See [source mappings](docs/source-mappings.md) for supported fields, precision
101
+ handling, and provenance. The [teaching notebook](examples/reconciliation_walkthrough.ipynb)
102
+ provides another offline example.
103
+
104
+ ## Limitations
105
+
106
+ CCRT compares source accounts; it does not establish objective incident identity.
107
+ Candidate scores are uncalibrated proximity rankings, and generating a candidate
108
+ does not confirm correspondence. Real-world linkage accuracy has not been
109
+ independently evaluated. CCRT does not reconcile casualty totals or estimate
110
+ underreporting. See [validation notes](docs/validation.md) for software checks and
111
+ research limits.
112
+
113
+ ## Contributing and licence
114
+
115
+ See [CONTRIBUTING.md](CONTRIBUTING.md), the [changelog](CHANGELOG.md), and the
116
+ [release procedure](docs/releasing.md). The code and synthetic examples are under
117
+ the [MIT licence](LICENSE). UCDP and ACLED data have separate access, attribution,
118
+ and redistribution terms; obtain source access and check the terms for your snapshot.
@@ -0,0 +1,25 @@
1
+ ccrt/__init__.py,sha256=uFFoUQpSU5orZCnTh37Wq1dGL7aXbp4QaXH_8ulHdk8,1045
2
+ ccrt/__main__.py,sha256=S0BYDZBiP__Qc2xpoQCvXphAZERBBVWnJuiDpP1Fu3s,52
3
+ ccrt/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
4
+ ccrt/cli.py,sha256=oe_Hvy98BB5X_Q8P1wrE4w5joEB9erupm_sIihIWLmQ,10654
5
+ ccrt/client.py,sha256=xGdfjFlehAq5Zkl6434uG6O2iBAqqTW0iTB3a8H7p-A,7467
6
+ ccrt/config.py,sha256=krGpm8JVF4Jris-S41e3DESnBFoAQwMaalZ256Z9Deo,821
7
+ ccrt/evaluation.py,sha256=GYkhkFQ64t0_ywqzo-FhXpSrbjSsyoGOXGMm1IvmyGs,1182
8
+ ccrt/export.py,sha256=AbY4MNtJkwWxCmMwtch3Pu0MdBLDIwG3SN8HYLoA7HY,5854
9
+ ccrt/interactive.py,sha256=TV9a4eGyKU_zeJRUI1DWIkFYXEqaW-2kHg_WrycMI5U,6893
10
+ ccrt/merge.py,sha256=hsqb9Z_FagH3Gz5sls295DILW-T7LlB0epbMF-hc29Q,6826
11
+ ccrt/report.py,sha256=fLjYf6gTfPjYjvWFNxCX-a7hSPguYl8hjPz4Dtoy7Dw,7246
12
+ ccrt/review.py,sha256=mWwdtBIm4D8HxHyRb8oDAQpVYBnDZ3tE5sQQS05S-O0,2208
13
+ ccrt/schema.py,sha256=xv7CPAQAhoyO4pTi5VFNHWKZB0K7FqurKugjbtqnAdY,6086
14
+ ccrt/storage.py,sha256=C7bjhUOD_1q0LoQPiuzwnekFydaXBsGCxsQglEbNnd8,5646
15
+ ccrt/tui.py,sha256=HEQXAmvkUUl0DnZvu_Nigmb92vHQtb287KfwMvjoEtc,13777
16
+ ccrt/loaders/__init__.py,sha256=w3QJVZn_aAah7TRRdTi50hMy5ccwgUBr7LSM5BZ8S-s,224
17
+ ccrt/loaders/acled.py,sha256=TR2j1E0rYLoDv6xdFHd6kM9wJb6s6VFrMjTGeMgTFYk,2231
18
+ ccrt/loaders/base.py,sha256=j8WAtTUMl9jpJGqQXzKCs4KaQvX_h80IFmVu9bL60gg,5406
19
+ ccrt/loaders/ucdp.py,sha256=c1WdSaOvF3pI03BkD76BJtpKpSOHiJZbSdp4cYQemE8,2619
20
+ ccrt-0.1.0.dist-info/licenses/LICENSE,sha256=9YcilvTlVUEkAU2AGRryIa5Cm8ifuarTcJNnJysqPgk,1072
21
+ ccrt-0.1.0.dist-info/METADATA,sha256=ogoB2_DPetdbQouA-twn20GGbqD6-Q6zO__plsak2lU,4691
22
+ ccrt-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
23
+ ccrt-0.1.0.dist-info/entry_points.txt,sha256=AuzMBnYrcAv0YiFeALcmq5KGG1bk8XuF27hDlS6IXYU,39
24
+ ccrt-0.1.0.dist-info/top_level.txt,sha256=Le69kL4hkte_9YHyXhkdW3ZpK75LnHDyvhKQ5GkQBTk,5
25
+ ccrt-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ccrt = ccrt.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fabian Figueroa
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
+ ccrt