onekit-tools 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.
- onekit/__init__.py +10 -0
- onekit/__main__.py +5 -0
- onekit/api.py +61 -0
- onekit/catalog.py +113 -0
- onekit/cli.py +190 -0
- onekit/models.py +76 -0
- onekit/tools/__init__.py +3 -0
- onekit/tools/archives/__init__.py +73 -0
- onekit/tools/converters/__init__.py +178 -0
- onekit/tools/developer/__init__.py +150 -0
- onekit/tools/developer/node/package-lock.json +1509 -0
- onekit/tools/developer/node/package.json +17 -0
- onekit/tools/developer/node-engine.mjs +26 -0
- onekit/tools/diff/__init__.py +115 -0
- onekit/tools/fonts/__init__.py +22 -0
- onekit/tools/images/__init__.py +62 -0
- onekit/tools/inspector/__init__.py +101 -0
- onekit/tools/media/__init__.py +34 -0
- onekit/tools/native.py +164 -0
- onekit/tools/ocr/__init__.py +40 -0
- onekit/tools/pdf/__init__.py +99 -0
- onekit/tools/security/__init__.py +38 -0
- onekit_tools-0.1.0.dist-info/METADATA +155 -0
- onekit_tools-0.1.0.dist-info/RECORD +28 -0
- onekit_tools-0.1.0.dist-info/WHEEL +4 -0
- onekit_tools-0.1.0.dist-info/entry_points.txt +2 -0
- onekit_tools-0.1.0.dist-info/licenses/LICENSE +201 -0
- onekit_tools-0.1.0.dist-info/licenses/NOTICE +10 -0
onekit/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Copyright 2026 Mattia Piazzalunga
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""OneKit's shared, offline processing API."""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
from .api import execute, pipeline
|
|
8
|
+
from .models import Artifact, OneKitError, Result
|
|
9
|
+
|
|
10
|
+
__all__ = ["Artifact", "OneKitError", "Result", "execute", "pipeline"]
|
onekit/__main__.py
ADDED
onekit/api.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Copyright 2026 Mattia Piazzalunga
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
|
|
7
|
+
from .catalog import TOOLS
|
|
8
|
+
from .models import Artifact, OneKitError, Result
|
|
9
|
+
|
|
10
|
+
MAX_BYTES = 128 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def execute(tool_id: str, files: list[Artifact], options: dict | None = None, *, browser=False) -> Result:
|
|
14
|
+
tool = TOOLS.get(tool_id)
|
|
15
|
+
if tool is None:
|
|
16
|
+
raise OneKitError(f"Unknown tool: {tool_id}. Run 'onekit list'.")
|
|
17
|
+
if browser and not tool.browser:
|
|
18
|
+
raise OneKitError("This tool is in development for the browser. Use the CLI.")
|
|
19
|
+
if not files:
|
|
20
|
+
raise OneKitError("Choose at least one input file.")
|
|
21
|
+
if len(files) > 256 or sum(len(file.data) for file in files) > MAX_BYTES:
|
|
22
|
+
raise OneKitError("Input exceeds the limit of 256 files or 128 MiB.")
|
|
23
|
+
if not tool.multiple and len(files) != 1:
|
|
24
|
+
raise OneKitError("This tool accepts one input file.")
|
|
25
|
+
if tool.accepts != ("*",) and any(file.kind not in tool.accepts for file in files):
|
|
26
|
+
raise OneKitError(f"Expected file types: {', '.join(tool.accepts)}.")
|
|
27
|
+
try:
|
|
28
|
+
result = importlib.import_module(f"onekit.tools.{tool.handler}").run(tool, files, dict(options or {}))
|
|
29
|
+
except OneKitError:
|
|
30
|
+
raise
|
|
31
|
+
except ImportError as exc:
|
|
32
|
+
raise OneKitError(f"Missing optional dependency '{exc.name}'. Install the matching OneKit extra; see 'onekit doctor {tool_id}'.") from exc
|
|
33
|
+
except Exception as exc:
|
|
34
|
+
raise OneKitError(f"Cannot process this input: {str(exc)[:500]}") from exc
|
|
35
|
+
if tool.fidelity == "compromise" and tool.note not in result.warnings:
|
|
36
|
+
result.warnings.insert(0, tool.note)
|
|
37
|
+
if not result.files:
|
|
38
|
+
raise OneKitError("The engine produced no output.")
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pipeline(steps: list[dict], files: list[Artifact], *, browser=False) -> Result:
|
|
43
|
+
if not isinstance(steps, list) or not steps or len(steps) > 16:
|
|
44
|
+
raise OneKitError("A pipeline needs between 1 and 16 steps.")
|
|
45
|
+
warnings, trace = [], []
|
|
46
|
+
for step in steps:
|
|
47
|
+
if not isinstance(step, dict) or not isinstance(step.get("options", {}), dict):
|
|
48
|
+
raise OneKitError("Each step must be an object containing a tool and an options object.")
|
|
49
|
+
tool_id = step.get("tool", "")
|
|
50
|
+
tool = TOOLS.get(tool_id)
|
|
51
|
+
if tool is None:
|
|
52
|
+
raise OneKitError(f"Unknown tool: {tool_id}.")
|
|
53
|
+
if len(files) > 1 and not tool.multiple:
|
|
54
|
+
parts = [execute(tool_id, [file], step.get("options", {}), browser=browser) for file in files]
|
|
55
|
+
result = Result([file for part in parts for file in part.files], [warning for part in parts for warning in part.warnings])
|
|
56
|
+
else:
|
|
57
|
+
result = execute(tool_id, files, step.get("options", {}), browser=browser)
|
|
58
|
+
files = result.files
|
|
59
|
+
warnings.extend(result.warnings)
|
|
60
|
+
trace.append({"tool": tool_id, "outputs": [file.name for file in files]})
|
|
61
|
+
return Result(files, list(dict.fromkeys(warnings)), {"steps": trace})
|
onekit/catalog.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Copyright 2026 Mattia Piazzalunga
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Single source of truth for CLI discovery, web routes and runtime contracts."""
|
|
4
|
+
from dataclasses import asdict, dataclass, replace
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Tool:
|
|
9
|
+
id: str
|
|
10
|
+
name: str
|
|
11
|
+
group: str
|
|
12
|
+
handler: str
|
|
13
|
+
accepts: tuple[str, ...] = ("*",)
|
|
14
|
+
produces: str = "*"
|
|
15
|
+
browser: bool = False
|
|
16
|
+
fidelity: str = "exact"
|
|
17
|
+
note: str = "Preserves the selected content; validate important outputs."
|
|
18
|
+
packages: tuple[str, ...] = ()
|
|
19
|
+
engines: tuple[str, ...] = ()
|
|
20
|
+
multiple: bool = False
|
|
21
|
+
options: tuple[str, ...] = ()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
TOOLS: dict[str, Tool] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def add(id, name, group, handler, accepts=("*",), produces="*", **kwargs):
|
|
28
|
+
TOOLS[id] = Tool(id, name, group, handler, tuple(accepts), produces, **kwargs)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
CONVERSIONS = {
|
|
32
|
+
"md": ["latex", "json", "html", "pdf", "docx"],
|
|
33
|
+
"pdf": ["pdfa", "png", "jpeg", "webp", "tiff", "docx", "html", "pptx", "md"],
|
|
34
|
+
"image": ["base64", "pdf", "pptx"], "base64": ["image"],
|
|
35
|
+
"docx": ["pdf", "png", "md"], "pptx": ["pdf", "png"],
|
|
36
|
+
"latex": ["html", "pdf", "md"], "html": ["pdf", "latex", "json", "md"],
|
|
37
|
+
"xlsx": ["pdf", "csv", "json"], "json": ["xlsx"], "csv": ["xlsx", "json"],
|
|
38
|
+
}
|
|
39
|
+
LABELS = {"md": "Markdown", "latex": "LaTeX", "docx": "Word", "pptx": "PowerPoint", "xlsx": "Excel", "image": "Image", "base64": "Base64", "pdfa": "PDF/A", "json": "JSON", "pdf": "PDF", "notebook": "Jupyter notebook", "word": "Word"}
|
|
40
|
+
IMAGE_TYPES = ("png", "jpeg", "webp", "gif", "bmp", "tiff", "ico")
|
|
41
|
+
PURE = {("md", "json"), ("html", "json"), ("csv", "json"), ("base64", "image"), ("image", "base64")}
|
|
42
|
+
for source, targets in CONVERSIONS.items():
|
|
43
|
+
for target in targets:
|
|
44
|
+
pair = (source, target)
|
|
45
|
+
browser = pair in PURE or pair in {("md", "html"), ("html", "md"), ("image", "pdf"), ("xlsx", "csv"), ("xlsx", "json"), ("json", "xlsx"), ("csv", "xlsx")}
|
|
46
|
+
packages = ("markdown-it-py",) if pair == ("md", "html") else ("markdownify",) if pair == ("html", "md") else ("Pillow",) if source == "image" and target != "base64" else ("openpyxl",) if "xlsx" in pair else ()
|
|
47
|
+
native = not browser
|
|
48
|
+
engine = ("pandoc",) if source in ("md", "latex", "html") and target != "pdf" else ("pdflatex",) if pair == ("latex", "pdf") else ("soffice",) if source in ("docx", "pptx", "xlsx") else ()
|
|
49
|
+
if source == "pdf":
|
|
50
|
+
packages = ("pypdfium2", "Pillow") if target in IMAGE_TYPES else ("pypdf",) if target in ("md", "html", "docx") else ("pypdfium2", "Pillow", "python-pptx") if target == "pptx" else ()
|
|
51
|
+
engine = ("gs",) if target == "pdfa" else ()
|
|
52
|
+
if pair == ("md", "pdf") or pair == ("html", "pdf"):
|
|
53
|
+
engine = ("pandoc", "pdflatex")
|
|
54
|
+
if pair == ("image", "pptx"):
|
|
55
|
+
packages = ("python-pptx",)
|
|
56
|
+
if pair == ("pdf", "docx"):
|
|
57
|
+
packages = ("pypdf", "python-docx")
|
|
58
|
+
add(f"{source}-to-{target}", f"{LABELS.get(source, source.upper())} to {LABELS.get(target, target.upper())}", "converters", "converters", IMAGE_TYPES if source == "image" else (source,), target, browser=browser, packages=packages, engines=engine, fidelity="exact" if pair in PURE and source != "html" else "compromise", note="Formatting, fonts, layout, formulas or unsupported elements may change. Scanned PDFs require OCR first." if pair not in PURE else "Structured conversion; the output represents the source data.", options=("sheet",) if source == "xlsx" else ())
|
|
59
|
+
|
|
60
|
+
PDF_OPS = ["merge", "split", "remove-pages", "organize", "extract-pages", "compress", "repair", "edit", "rotate", "sign", "protect", "unlock", "validate-pdfa"]
|
|
61
|
+
for operation in PDF_OPS:
|
|
62
|
+
native = operation in ("sign", "validate-pdfa")
|
|
63
|
+
add(f"pdf-{operation}", f"{operation.replace('-', ' ').capitalize()} PDF", "pdf", "pdf", ("pdf",), "pdf" if operation != "validate-pdfa" else "json", browser=not native and operation not in ("protect", "unlock"), packages=("pypdf", "reportlab") if operation == "edit" else ("pypdf",) if not native else ("pyhanko",) if operation == "sign" else (), engines=("verapdf",) if operation == "validate-pdfa" else (), multiple=operation == "merge", fidelity="compromise" if operation in ("compress", "repair", "edit", "sign") else "exact", note="Repair is best effort. Editing adds text overlays. Signing requires a PKCS#12 certificate." if operation in ("repair", "edit", "sign") else "Compression is lossless and may not reduce already optimized files." if operation == "compress" else "Page operations preserve content. Encryption permissions are advisory.", options=("pages",) if operation in ("remove-pages", "organize", "extract-pages") else ("angle",) if operation == "rotate" else ("password",) if operation in ("protect", "unlock") else ("text", "x", "y", "page") if operation == "edit" else ("certificate", "password") if operation == "sign" else ())
|
|
64
|
+
|
|
65
|
+
for op in ("compress", "crop", "convert"):
|
|
66
|
+
add(f"image-{op}", f"{op.capitalize()} image", "images", "images", IMAGE_TYPES, "image", browser=True, packages=("Pillow",), fidelity="compromise" if op == "compress" else "exact", note="Lossy formats discard detail. Animation and metadata can be removed; use a lossless format to preserve pixels.", options=("format", "quality") if op != "crop" else ("box",))
|
|
67
|
+
add("image-extract-pdf", "Extract images from PDF", "images", "images", ("pdf",), "image", browser=True, packages=("pypdf", "Pillow"))
|
|
68
|
+
for group, formats in (("video", ("mp4", "webm", "mkv", "mov", "avi", "gif")), ("audio", ("mp3", "wav", "flac", "ogg", "aac", "m4a", "mp4"))):
|
|
69
|
+
add(f"{group}-convert", f"Convert {group}", group, "media", ("*",), "*", engines=("ffmpeg",), fidelity="compromise", note="Available codecs depend on your FFmpeg build; re-encoding can lose quality.", options=("format", "codec", "bitrate"))
|
|
70
|
+
add("archive-convert", "Convert archive", "archives", "archives", ("zip", "tar", "gz", "bz2", "xz"), "*", browser=True, note="ZIP and TAR variants are supported. Archive links and unsafe paths are rejected.", options=("format",))
|
|
71
|
+
add("font-convert", "Convert font", "fonts", "fonts", ("ttf", "otf", "woff", "woff2"), "*", packages=("fonttools",), note="Changes font containers; does not transform TrueType outlines into CFF outlines or vice versa.", options=("format",))
|
|
72
|
+
for op in ("inspect", "metadata-set", "metadata-remove", "count", "encoding-convert"):
|
|
73
|
+
add(f"file-{op}", {"inspect": "Inspect file", "metadata-set": "Set metadata", "metadata-remove": "Remove metadata", "count": "Count text", "encoding-convert": "Convert text encoding"}[op], "inspector", "inspector", browser=op in ("inspect", "count", "encoding-convert"), produces="json" if op in ("inspect", "count") else "*", engines=("exiftool",) if op.startswith("metadata") else (), options=("metadata",) if op == "metadata-set" else ("encoding",) if op == "encoding-convert" else ())
|
|
74
|
+
for op in ("encrypt", "decrypt"):
|
|
75
|
+
add(f"security-{op}", f"{op.capitalize()} files or folders", "security", "security", produces="onekit" if op == "encrypt" else "*", browser=True, packages=("pycryptodome",), options=("password",), multiple=op == "encrypt", note="Authenticated AES-256-GCM with scrypt. Losing the password makes recovery impossible.")
|
|
76
|
+
|
|
77
|
+
LANGUAGES = "JavaScript TypeScript JSX TSX Python Java C C++ C# Go Rust PHP Ruby Kotlin Swift Dart Scala Lua Bash Shell PowerShell SQL HTML CSS SCSS Less XML SVG JSON JSON5 JSONC YAML TOML Markdown MDX LaTeX GraphQL Vue Svelte Astro Solidity HCL Terraform Dockerfile R Julia Elixir Haskell".split()
|
|
78
|
+
VALIDATORS = LANGUAGES + ["XSD", "JSON-Schema", "CSV", "OpenAPI", "AsyncAPI"]
|
|
79
|
+
MINIFIERS = "JavaScript TypeScript JSX TSX HTML CSS JSON SVG GraphQL".split()
|
|
80
|
+
for operation, languages in (("format", LANGUAGES), ("validate", VALIDATORS), ("minify", MINIFIERS)):
|
|
81
|
+
for language in languages:
|
|
82
|
+
slug = language.lower().replace("++", "pp").replace("#", "sharp")
|
|
83
|
+
built_in = language == "JSON" or operation == "validate" and language in ("Python", "XML", "SVG", "TOML", "CSV")
|
|
84
|
+
add(f"dev-{operation}-{slug}", f"{operation.capitalize()} {language.replace('-', ' ')}", "developer", "developer", produces="json" if operation == "validate" else "*", browser=built_in, fidelity="compromise" if operation == "minify" else "exact", note="Validation checks the configured parser or compiler; project-wide types, imports and dependencies may require a local project.", options=())
|
|
85
|
+
for target in ("pdf", "docx", "md", "latex", "html", "json"):
|
|
86
|
+
add(f"ocr-to-{target}", f"OCR to {LABELS.get(target, target.upper())}", "ocr", "ocr", ("pdf",) + IMAGE_TYPES, target, packages=("pytesseract", "Pillow", "pypdfium2", "python-docx"), engines=("tesseract",), fidelity="compromise", note="Recognition depends on scan quality and installed language packs. Equations and layout may need manual correction.", options=("language", "dpi"))
|
|
87
|
+
for kind in ("text", "json", "notebook", "latex", "pdf", "word", "code"):
|
|
88
|
+
add(f"diff-{kind}", f"Compare {LABELS.get(kind, kind).replace('word', 'Word')} files", "diff", "diff", produces="json", browser=kind not in ("pdf", "word"), packages=("pypdf",) if kind == "pdf" else ("python-docx",) if kind == "word" else (), multiple=True, fidelity="compromise" if kind in ("pdf", "word", "latex") else "exact", note="Compares extracted text, not visual layout." if kind in ("pdf", "word") else "Notebook comparison can include Markdown, code or both. LaTeX text mode omits commands.", options=("mode",) if kind in ("notebook", "latex") else ())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
for id in ("docx-to-md", "md-to-docx", "image-to-pptx", "pdf-to-md", "pdf-to-html", "pdf-to-docx", "pdf-protect", "pdf-unlock", "diff-pdf", "diff-word", "font-convert", "dev-format-xml", "dev-format-svg", "dev-format-toml", "dev-validate-json5", "dev-validate-jsonc", "dev-validate-yaml", "dev-validate-json-schema", "dev-validate-xsd"):
|
|
92
|
+
TOOLS[id] = replace(TOOLS[id], browser=True, engines=())
|
|
93
|
+
for id, packages in {"docx-to-md": ("python-docx",), "md-to-docx": ("python-docx",), "font-convert": ("fonttools", "brotli"), "dev-format-toml": ("tomli-w",), "dev-validate-json5": ("json5",), "dev-validate-jsonc": ("json5",), "dev-validate-yaml": ("PyYAML",), "dev-validate-json-schema": ("jsonschema",), "dev-validate-xsd": ("xmlschema",)}.items():
|
|
94
|
+
TOOLS[id] = replace(TOOLS[id], packages=packages)
|
|
95
|
+
for id, tool in list(TOOLS.items()):
|
|
96
|
+
if "pypdf" in tool.packages:
|
|
97
|
+
TOOLS[id] = replace(tool, packages=("pycryptodome",) + tool.packages)
|
|
98
|
+
TOOLS["file-encoding-convert"] = replace(TOOLS["file-encoding-convert"], options=("source_encoding", "encoding"))
|
|
99
|
+
TOOLS["file-inspect"] = replace(TOOLS["file-inspect"], packages=("Pillow", "pycryptodome", "pypdf"))
|
|
100
|
+
TOOLS["image-convert"] = replace(TOOLS["image-convert"], fidelity="compromise")
|
|
101
|
+
TOOLS["image-crop"] = replace(TOOLS["image-crop"], fidelity="compromise", options=("box", "format", "quality"))
|
|
102
|
+
for id in ("csv-to-json", "csv-to-xlsx"):
|
|
103
|
+
TOOLS[id] = replace(TOOLS[id], options=("delimiter",))
|
|
104
|
+
for id in ("pdf-to-png", "pdf-to-jpeg", "pdf-to-webp", "pdf-to-tiff", "pdf-to-pptx"):
|
|
105
|
+
TOOLS[id] = replace(TOOLS[id], options=("dpi",))
|
|
106
|
+
TOOLS["pdf-rotate"] = replace(TOOLS["pdf-rotate"], options=("angle", "pages"))
|
|
107
|
+
for id in ("md-to-docx",):
|
|
108
|
+
TOOLS[id] = replace(TOOLS[id], packages=("markdown-it-py", "python-docx"))
|
|
109
|
+
TOOLS["md-to-docx"] = replace(TOOLS["md-to-docx"], note="CommonMark headings, lists, emphasis and code are exported. Images, advanced layouts and Markdown extensions may need adjustment.")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def manifest():
|
|
113
|
+
return [asdict(tool) for tool in TOOLS.values()]
|
onekit/cli.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Copyright 2026 Mattia Piazzalunga
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import getpass
|
|
7
|
+
import gettext
|
|
8
|
+
import importlib.util
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .api import execute, pipeline
|
|
18
|
+
from .catalog import TOOLS, manifest
|
|
19
|
+
from .models import Artifact, OneKitError
|
|
20
|
+
|
|
21
|
+
_ = gettext.translation("onekit", localedir=Path(__file__).parent / "locales", fallback=True).gettext
|
|
22
|
+
|
|
23
|
+
PACKAGE_MODULES = {"PyYAML": "yaml", "Pillow": "PIL", "markdown-it-py": "markdown_it", "python-docx": "docx", "python-pptx": "pptx", "fonttools": "fontTools", "pycryptodome": "Crypto"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def doctor(tool_id=None):
|
|
27
|
+
from .tools.native import COPYLEFT, executable
|
|
28
|
+
report = []
|
|
29
|
+
for tool in ([TOOLS[tool_id]] if tool_id else TOOLS.values()):
|
|
30
|
+
packages = list(tool.packages)
|
|
31
|
+
engines = list(tool.engines)
|
|
32
|
+
if tool.group == "developer" and not tool.browser:
|
|
33
|
+
from .tools.developer import engine_for
|
|
34
|
+
engine = engine_for(tool)
|
|
35
|
+
if engine:
|
|
36
|
+
engines.append(engine)
|
|
37
|
+
missing = [name for name in packages if importlib.util.find_spec(PACKAGE_MODULES.get(name, name.replace("-", "_"))) is None]
|
|
38
|
+
blocked = [name for name in engines if name in COPYLEFT and os.environ.get("ONEKIT_ALLOW_COPYLEFT") == "0"]
|
|
39
|
+
for name in engines:
|
|
40
|
+
if name in blocked:
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
executable(name)
|
|
44
|
+
except OneKitError:
|
|
45
|
+
missing.append(name)
|
|
46
|
+
if "node" in engines and not (Path(os.environ.get("ONEKIT_NODE_ENGINE_PATH", Path.home() / ".cache" / "onekit" / "node")) / "node_modules").is_dir():
|
|
47
|
+
missing.append("OneKit Node engines (onekit engines install)")
|
|
48
|
+
report.append({"tool": tool.id, "ready": not missing and not blocked, "missing": missing, "policyBlocked": blocked, "browser": tool.browser})
|
|
49
|
+
return report
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_files(paths, folders=False):
|
|
53
|
+
files = []
|
|
54
|
+
for value in paths:
|
|
55
|
+
path = Path(value)
|
|
56
|
+
if path.is_symlink():
|
|
57
|
+
raise OneKitError("Symlink inputs are not supported.")
|
|
58
|
+
if path.is_dir() and folders:
|
|
59
|
+
for child in sorted(path.rglob("*")):
|
|
60
|
+
if child.is_symlink():
|
|
61
|
+
raise OneKitError("Folders containing symlinks are not supported.")
|
|
62
|
+
if child.is_file():
|
|
63
|
+
if len(files) >= 256 or child.stat().st_size > 128 * 1024 * 1024:
|
|
64
|
+
raise OneKitError("Folder exceeds input limits.")
|
|
65
|
+
files.append(Artifact((Path(path.name) / child.relative_to(path)).as_posix(), child.read_bytes()))
|
|
66
|
+
elif path.is_file():
|
|
67
|
+
if path.stat().st_size > 128 * 1024 * 1024:
|
|
68
|
+
raise OneKitError("File exceeds 128 MiB.")
|
|
69
|
+
files.append(Artifact(path.name, path.read_bytes()))
|
|
70
|
+
else:
|
|
71
|
+
raise OneKitError(f"Cannot read '{path}'.")
|
|
72
|
+
return files
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def write_files(result, destination, force=False):
|
|
76
|
+
target = Path(destination)
|
|
77
|
+
as_directory = len(result.files) > 1 or target.is_dir() or not target.suffix
|
|
78
|
+
paths = [(target / file.name if as_directory else target, file) for file in result.files]
|
|
79
|
+
if len({str(path.resolve()) for path, _ in paths}) != len(paths):
|
|
80
|
+
raise OneKitError("Multiple outputs have the same file name; rename the inputs.")
|
|
81
|
+
for path, _ in paths:
|
|
82
|
+
for parent in (path, *path.parents):
|
|
83
|
+
if parent.is_symlink():
|
|
84
|
+
raise OneKitError("Output paths cannot contain symlinks.")
|
|
85
|
+
if path.exists() and not force:
|
|
86
|
+
raise OneKitError(f"'{path}' already exists. Choose another destination or pass --force.")
|
|
87
|
+
for path, file in paths:
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".onekit-", dir=path.parent)
|
|
90
|
+
try:
|
|
91
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
92
|
+
stream.write(file.data)
|
|
93
|
+
os.replace(temporary, path)
|
|
94
|
+
finally:
|
|
95
|
+
if os.path.exists(temporary):
|
|
96
|
+
os.unlink(temporary)
|
|
97
|
+
print(path)
|
|
98
|
+
for warning in result.warnings:
|
|
99
|
+
print(f"Warning: {warning}", file=sys.stderr)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parser():
|
|
103
|
+
root = argparse.ArgumentParser(prog="onekit", description=_("One simple toolkit. Your files stay yours."))
|
|
104
|
+
root.add_argument("--version", action="version", version=__version__)
|
|
105
|
+
commands = root.add_subparsers(dest="command", required=True)
|
|
106
|
+
listing = commands.add_parser("list", help="Browse all tools and their fidelity notes")
|
|
107
|
+
listing.add_argument("--json", action="store_true")
|
|
108
|
+
listing.add_argument("--group")
|
|
109
|
+
diagnosis = commands.add_parser("doctor", help="Check optional Python packages and system engines")
|
|
110
|
+
diagnosis.add_argument("tool", nargs="?", choices=TOOLS, metavar="TOOL")
|
|
111
|
+
diagnosis.add_argument("--json", action="store_true")
|
|
112
|
+
engines = commands.add_parser("engines", help="Install the optional, version-locked Node developer engines")
|
|
113
|
+
engines.add_argument("action", choices=["install"])
|
|
114
|
+
engines.add_argument("--directory", type=Path)
|
|
115
|
+
for name in ("run", "pipeline"):
|
|
116
|
+
command = commands.add_parser(name)
|
|
117
|
+
if name == "run":
|
|
118
|
+
command.add_argument("tool", choices=TOOLS, metavar="TOOL")
|
|
119
|
+
command.add_argument("--options", default="{}", help="JSON object of tool options")
|
|
120
|
+
else:
|
|
121
|
+
command.add_argument("--steps", required=True, help="JSON file with a list of {tool, options} objects")
|
|
122
|
+
command.add_argument("files", nargs="+")
|
|
123
|
+
command.add_argument("-o", "--output", required=True)
|
|
124
|
+
command.add_argument("--force", action="store_true")
|
|
125
|
+
command.add_argument("--ask-password", action="store_true", help="Read a password without exposing it in shell history")
|
|
126
|
+
return root
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main(argv=None):
|
|
130
|
+
args = parser().parse_args(argv)
|
|
131
|
+
try:
|
|
132
|
+
if args.command == "list":
|
|
133
|
+
tools = [item for item in manifest() if not args.group or item["group"] == args.group]
|
|
134
|
+
if args.json:
|
|
135
|
+
print(json.dumps(tools, indent=2))
|
|
136
|
+
else:
|
|
137
|
+
for tool in tools:
|
|
138
|
+
print(f"{tool['id']:34} {'browser + CLI' if tool['browser'] else 'CLI':14} {tool['fidelity']} {tool['name']}")
|
|
139
|
+
return 0
|
|
140
|
+
if args.command == "engines":
|
|
141
|
+
install_node_engines(args.directory)
|
|
142
|
+
return 0
|
|
143
|
+
if args.command == "doctor":
|
|
144
|
+
report = doctor(args.tool)
|
|
145
|
+
if args.json:
|
|
146
|
+
print(json.dumps(report, indent=2))
|
|
147
|
+
else:
|
|
148
|
+
for item in report:
|
|
149
|
+
print(f"{item['tool']:34} {'ready' if item['ready'] else 'needs setup'} {', '.join(item['missing'] + item['policyBlocked'])}")
|
|
150
|
+
return 0 if all(item["ready"] for item in report) else 1
|
|
151
|
+
if args.command == "run":
|
|
152
|
+
options = json.loads(args.options)
|
|
153
|
+
if not isinstance(options, dict):
|
|
154
|
+
raise OneKitError("--options must contain a JSON object.")
|
|
155
|
+
if args.ask_password:
|
|
156
|
+
options["password"] = getpass.getpass("Password: ")
|
|
157
|
+
result = execute(args.tool, read_files(args.files, args.tool == "security-encrypt"), options)
|
|
158
|
+
else:
|
|
159
|
+
steps = json.loads(Path(args.steps).read_text(encoding="utf-8"))
|
|
160
|
+
if args.ask_password:
|
|
161
|
+
password = getpass.getpass("Password: ")
|
|
162
|
+
for step in steps:
|
|
163
|
+
step.setdefault("options", {})["password"] = password
|
|
164
|
+
result = pipeline(steps, read_files(args.files))
|
|
165
|
+
write_files(result, args.output, args.force)
|
|
166
|
+
return 0
|
|
167
|
+
except (OneKitError, OSError, ValueError) as exc:
|
|
168
|
+
print(f"onekit: {exc}", file=sys.stderr)
|
|
169
|
+
return 2
|
|
170
|
+
except KeyboardInterrupt:
|
|
171
|
+
print("onekit: cancelled", file=sys.stderr)
|
|
172
|
+
return 130
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def install_node_engines(directory=None):
|
|
176
|
+
from .tools.native import command
|
|
177
|
+
target = directory or Path(os.environ.get("ONEKIT_NODE_ENGINE_PATH", Path.home() / ".cache" / "onekit" / "node"))
|
|
178
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
source = Path(__file__).parent / "tools" / "developer" / "node"
|
|
180
|
+
if (target / "package.json").exists() and json.loads((target / "package.json").read_text())["name"] != "onekit-developer-engines":
|
|
181
|
+
raise OneKitError("This directory contains a different Node project; choose another destination.")
|
|
182
|
+
for name in ("package.json", "package-lock.json"):
|
|
183
|
+
shutil.copyfile(source / name, target / name)
|
|
184
|
+
command("npm", ["ci", "--ignore-scripts", "--no-fund"], target, timeout=300)
|
|
185
|
+
command("npm", ["audit", "--audit-level=low"], target)
|
|
186
|
+
print(f"Developer engines installed in {target}.")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
sys.exit(main())
|
onekit/models.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Copyright 2026 Mattia Piazzalunga
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import PurePosixPath
|
|
7
|
+
from typing import Any
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OneKitError(ValueError):
|
|
12
|
+
"""An actionable processing error, safe to show in the CLI or UI."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Artifact:
|
|
17
|
+
name: str
|
|
18
|
+
data: bytes
|
|
19
|
+
|
|
20
|
+
def __post_init__(self):
|
|
21
|
+
name = self.name.replace("\\", "/")
|
|
22
|
+
if not name or name.startswith("/") or ".." in name.split("/") or ":" in name:
|
|
23
|
+
raise OneKitError("File names must be relative and cannot traverse directories.")
|
|
24
|
+
reserved = {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10))}
|
|
25
|
+
if any(ord(char) < 32 for char in name) or any(part.split(".")[0].upper() in reserved or part.endswith((" ", ".")) for part in name.split("/")):
|
|
26
|
+
raise OneKitError("This file name is not portable across supported operating systems.")
|
|
27
|
+
object.__setattr__(self, "name", name)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def kind(self) -> str:
|
|
31
|
+
aliases = {"jpg": "jpeg", "htm": "html", "markdown": "md", "yml": "yaml", "tex": "latex"}
|
|
32
|
+
ext = PurePosixPath(self.name).suffix.lower().lstrip(".")
|
|
33
|
+
return aliases.get(ext, ext)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def stem(self) -> str:
|
|
37
|
+
return PurePosixPath(self.name).stem
|
|
38
|
+
|
|
39
|
+
def text(self) -> str:
|
|
40
|
+
try:
|
|
41
|
+
return self.data.decode("utf-8-sig")
|
|
42
|
+
except UnicodeDecodeError as exc:
|
|
43
|
+
raise OneKitError("This operation requires UTF-8 text. Convert the encoding first.") from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Result:
|
|
48
|
+
files: list[Artifact]
|
|
49
|
+
warnings: list[str] = field(default_factory=list)
|
|
50
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def output(source: Artifact, extension: str, data: str | bytes) -> Artifact:
|
|
54
|
+
return Artifact(f"{source.stem}.{extension}", data.encode("utf-8") if isinstance(data, str) else data)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require(options: dict, key: str):
|
|
58
|
+
value = options.get(key)
|
|
59
|
+
if value is None or value == "":
|
|
60
|
+
raise OneKitError(f"The '{key}' option is required.")
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_json(text: str):
|
|
65
|
+
def constant(value):
|
|
66
|
+
raise OneKitError(f"{value} is not valid standard JSON.")
|
|
67
|
+
|
|
68
|
+
def pairs(items):
|
|
69
|
+
result = {}
|
|
70
|
+
for key, value in items:
|
|
71
|
+
if key in result:
|
|
72
|
+
raise OneKitError(f"Duplicate JSON key: {key}.")
|
|
73
|
+
result[key] = value
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
return json.loads(text, parse_constant=constant, object_pairs_hook=pairs)
|
onekit/tools/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Copyright 2026 Mattia Piazzalunga
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import io
|
|
4
|
+
import tarfile
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import PurePosixPath
|
|
7
|
+
|
|
8
|
+
from ...models import Artifact, OneKitError, Result, output
|
|
9
|
+
|
|
10
|
+
LIMIT = 128 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def safe_name(name):
|
|
14
|
+
path = PurePosixPath(name.replace("\\", "/"))
|
|
15
|
+
if path.is_absolute() or ".." in path.parts or ":" in name:
|
|
16
|
+
raise OneKitError("Archive contains an unsafe path.")
|
|
17
|
+
return str(path)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def unpack(data):
|
|
21
|
+
files, total = [], 0
|
|
22
|
+
if zipfile.is_zipfile(io.BytesIO(data)):
|
|
23
|
+
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
24
|
+
entries = archive.infolist()
|
|
25
|
+
if len(entries) > 4096:
|
|
26
|
+
raise OneKitError("Archive contains too many entries.")
|
|
27
|
+
for entry in entries:
|
|
28
|
+
name = safe_name(entry.filename)
|
|
29
|
+
if entry.is_dir():
|
|
30
|
+
continue
|
|
31
|
+
if entry.external_attr >> 16 & 0o170000 == 0o120000:
|
|
32
|
+
raise OneKitError("Archive symlinks are not supported.")
|
|
33
|
+
total += entry.file_size
|
|
34
|
+
if total > LIMIT or entry.file_size > max(entry.compress_size, 1) * 1000:
|
|
35
|
+
raise OneKitError("Archive expansion exceeds safety limits.")
|
|
36
|
+
files.append(Artifact(name, archive.read(entry)))
|
|
37
|
+
else:
|
|
38
|
+
with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as archive:
|
|
39
|
+
for index, entry in enumerate(archive):
|
|
40
|
+
name = safe_name(entry.name)
|
|
41
|
+
if entry.isdir():
|
|
42
|
+
continue
|
|
43
|
+
if not entry.isfile():
|
|
44
|
+
raise OneKitError("Archive links and devices are not supported.")
|
|
45
|
+
total += entry.size
|
|
46
|
+
if total > LIMIT or index > 4096:
|
|
47
|
+
raise OneKitError("Archive expansion exceeds safety limits.")
|
|
48
|
+
stream = archive.extractfile(entry)
|
|
49
|
+
files.append(Artifact(name, stream.read(LIMIT + 1)))
|
|
50
|
+
return files
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def pack(files, format="zip"):
|
|
54
|
+
stream = io.BytesIO()
|
|
55
|
+
if format == "zip":
|
|
56
|
+
with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
57
|
+
for file in files:
|
|
58
|
+
archive.writestr(safe_name(file.name), file.data)
|
|
59
|
+
else:
|
|
60
|
+
modes = {"tar": "w", "tar.gz": "w:gz", "tar.bz2": "w:bz2", "tar.xz": "w:xz"}
|
|
61
|
+
if format not in modes:
|
|
62
|
+
raise OneKitError("Supported archive formats: zip, tar, tar.gz, tar.bz2, tar.xz.")
|
|
63
|
+
with tarfile.open(fileobj=stream, mode=modes[format]) as archive:
|
|
64
|
+
for file in files:
|
|
65
|
+
info = tarfile.TarInfo(safe_name(file.name))
|
|
66
|
+
info.size = len(file.data)
|
|
67
|
+
info.mode = 0o600
|
|
68
|
+
archive.addfile(info, io.BytesIO(file.data))
|
|
69
|
+
return stream.getvalue()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run(tool, files, options):
|
|
73
|
+
return Result([output(files[0], options.get("format", "zip"), pack(unpack(files[0].data), options.get("format", "zip")))])
|