pyh5p 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.
pyh5p/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """Generic, schema-driven H5P package tooling.
2
+
3
+ The core package has no dependency on a particular H5P content type. A library
4
+ store supplies `library.json` and `semantics.json`; the same engine handles
5
+ every supported and future semantic definition.
6
+ """
7
+
8
+ from .content import H5PContent, build, load, schema, schema_json
9
+ from .errors import H5PError, PackageError, SecurityError, ValidationError
10
+ from .library import (
11
+ CoreApiVersion,
12
+ Dependency,
13
+ HubClient,
14
+ Library,
15
+ LibraryId,
16
+ LibraryStore,
17
+ RegistryClient,
18
+ )
19
+ from .package import H5PPackage, PackageLimits, read_package
20
+ from .semantics import SemanticField, Semantics
21
+
22
+ __all__ = [
23
+ "Dependency",
24
+ "CoreApiVersion",
25
+ "H5PContent",
26
+ "H5PError",
27
+ "H5PPackage",
28
+ "HubClient",
29
+ "Library",
30
+ "LibraryId",
31
+ "LibraryStore",
32
+ "PackageError",
33
+ "PackageLimits",
34
+ "RegistryClient",
35
+ "SecurityError",
36
+ "SemanticField",
37
+ "Semantics",
38
+ "ValidationError",
39
+ "build",
40
+ "load",
41
+ "read_package",
42
+ "schema",
43
+ "schema_json",
44
+ ]
pyh5p/_util.py ADDED
@@ -0,0 +1,90 @@
1
+ """Small internal helpers with deterministic and security-conscious behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import mimetypes
8
+ import os
9
+ import re
10
+ from collections.abc import Iterator, Mapping
11
+ from pathlib import Path, PurePosixPath
12
+ from typing import Any
13
+
14
+ JSONValue = None | bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"]
15
+
16
+
17
+ def canonical_json(value: Any) -> str:
18
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
19
+
20
+
21
+ def json_bytes(value: Any) -> bytes:
22
+ return (canonical_json(value) + "\n").encode("utf-8")
23
+
24
+
25
+ def sha256(value: bytes) -> str:
26
+ return hashlib.sha256(value).hexdigest()
27
+
28
+
29
+ def is_safe_relative_path(value: str, *, allow_empty: bool = False) -> bool:
30
+ if (
31
+ not isinstance(value, str)
32
+ or "\x00" in value
33
+ or ":" in value
34
+ or (not value and not allow_empty)
35
+ ):
36
+ return False
37
+ if "\\" in value or value.startswith("/") or re.match(r"^[A-Za-z]:", value):
38
+ return False
39
+ parts = PurePosixPath(value).parts
40
+ return all(part not in {"", ".", ".."} for part in parts)
41
+
42
+
43
+ def safe_join(root: Path, relative: str) -> Path:
44
+ if not is_safe_relative_path(relative):
45
+ raise ValueError(f"unsafe relative path: {relative!r}")
46
+ target = (root / Path(*PurePosixPath(relative).parts)).resolve()
47
+ root_resolved = root.resolve()
48
+ if os.path.commonpath([str(root_resolved), str(target)]) != str(root_resolved):
49
+ raise ValueError(f"path escapes root: {relative!r}")
50
+ return target
51
+
52
+
53
+ def walk_files(root: Path) -> Iterator[tuple[str, Path]]:
54
+ for path in sorted(root.rglob("*"), key=lambda item: item.as_posix()):
55
+ if path.is_file():
56
+ yield path.relative_to(root).as_posix(), path
57
+
58
+
59
+ def version_tuple(value: Any, default: int = 0) -> tuple[int, int, int]:
60
+ if isinstance(value, (tuple, list)):
61
+ parts = list(value)
62
+ else:
63
+ parts = re.findall(r"\d+", str(value))
64
+ numbers = [int(item) for item in parts[:3]]
65
+ numbers.extend([default] * (3 - len(numbers)))
66
+ return tuple(numbers[:3]) # type: ignore[return-value]
67
+
68
+
69
+ def guess_mime(path: str) -> str:
70
+ return mimetypes.guess_type(path)[0] or "application/octet-stream"
71
+
72
+
73
+ def deep_copy_json(value: Any) -> Any:
74
+ return json.loads(json.dumps(value, ensure_ascii=False))
75
+
76
+
77
+ def get_path(value: Any, path: str, default: Any = None) -> Any:
78
+ current = value
79
+ if path in {"", "$"}:
80
+ return current
81
+ for part in path.replace("$.", "", 1).split("."):
82
+ if not part:
83
+ continue
84
+ if isinstance(current, Mapping):
85
+ current = current.get(part, default)
86
+ elif isinstance(current, list) and part.isdigit() and int(part) < len(current):
87
+ current = current[int(part)]
88
+ else:
89
+ return default
90
+ return current
pyh5p/cli.py ADDED
@@ -0,0 +1,169 @@
1
+ """Command-line interface for inspection and reproducible package workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from .content import build
11
+ from .errors import H5PError, LibraryNotFoundError
12
+ from .hooks import JavaScriptCompatibility
13
+ from .library import LibraryStore
14
+ from .package import read_package
15
+
16
+
17
+ def _core_api(value: str) -> tuple[int, int]:
18
+ parts = value.split(".")
19
+ if len(parts) != 2 or not all(part.isdigit() for part in parts):
20
+ raise argparse.ArgumentTypeError("Core API must have the form MAJOR.MINOR")
21
+ return int(parts[0]), int(parts[1])
22
+
23
+
24
+ def _store(paths: list[str], *, cache_dir: Path | None = None) -> LibraryStore:
25
+ return LibraryStore(*paths, cache_dir=cache_dir)
26
+
27
+
28
+ def _common(parser: argparse.ArgumentParser) -> None:
29
+ parser.add_argument(
30
+ "--libraries", action="append", default=[], help="library source directory (repeatable)"
31
+ )
32
+
33
+
34
+ def make_parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ prog="pyh5p", description="Generic H5P inspection, validation and packaging"
37
+ )
38
+ sub = parser.add_subparsers(dest="command", required=True)
39
+
40
+ inspect = sub.add_parser("inspect", help="show package metadata and libraries")
41
+ inspect.add_argument("package", type=Path)
42
+
43
+ validate = sub.add_parser("validate", help="validate package structure and content")
44
+ validate.add_argument("package", type=Path)
45
+ validate.add_argument("--libraries", action="append", default=[])
46
+ validate.add_argument(
47
+ "--content-only", action="store_true", help="allow a package without library files"
48
+ )
49
+
50
+ schema = sub.add_parser("schema", help="emit JSON Schema for an installed library")
51
+ schema.add_argument("library")
52
+ _common(schema)
53
+ schema.add_argument("-o", "--output", type=Path)
54
+
55
+ build_parser = sub.add_parser("build", help="build an H5P package from JSON content")
56
+ build_parser.add_argument("library")
57
+ build_parser.add_argument("content", type=Path, help="JSON file containing semantic content")
58
+ build_parser.add_argument("output", type=Path)
59
+ _common(build_parser)
60
+ build_parser.add_argument("--title")
61
+ build_parser.add_argument("--language", default="und")
62
+ build_parser.add_argument("--content-only", action="store_true")
63
+ build_parser.add_argument(
64
+ "--fetch", dest="fetch", action="store_true", default=True,
65
+ help="download missing official libraries and dependencies (default)",
66
+ )
67
+ build_parser.add_argument(
68
+ "--offline", dest="fetch", action="store_false",
69
+ help="use only libraries already present in --libraries",
70
+ )
71
+ build_parser.add_argument(
72
+ "--cache-dir", type=Path, default=Path(".pyh5p-libraries"),
73
+ help="library cache used with --fetch",
74
+ )
75
+ build_parser.add_argument(
76
+ "--core-api", type=_core_api,
77
+ help="target an H5P Core API, such as 1.27; useful for older hosts",
78
+ )
79
+
80
+ unpack = sub.add_parser("unpack", help="securely unpack an H5P archive")
81
+ unpack.add_argument("package", type=Path)
82
+ unpack.add_argument("destination", type=Path)
83
+ return parser
84
+
85
+
86
+ def main(argv: list[str] | None = None) -> int:
87
+ args = make_parser().parse_args(argv)
88
+ try:
89
+ if args.command == "inspect":
90
+ package = read_package(args.package)
91
+ hooks = {
92
+ str(item.id): JavaScriptCompatibility().inspect_files(item.files).note
93
+ for item in package.libraries
94
+ if "presave.js" in item.files or "upgrades.js" in item.files
95
+ }
96
+ print(
97
+ json.dumps(
98
+ {
99
+ "metadata": package.metadata,
100
+ "content": package.content is not None,
101
+ "libraries": [str(item.id) for item in package.libraries],
102
+ "files": len(package.files),
103
+ "hooks": hooks,
104
+ },
105
+ indent=2,
106
+ ensure_ascii=False,
107
+ )
108
+ )
109
+ return 0
110
+ if args.command == "validate":
111
+ package = read_package(args.package)
112
+ issues = package.validate(
113
+ require_content=True,
114
+ allow_content_only=args.content_only,
115
+ )
116
+ if args.libraries and package.content is not None:
117
+ store = _store(args.libraries)
118
+ main = package.metadata.get("mainLibrary")
119
+ if isinstance(main, str):
120
+ issues.extend(store.schema(main).validate(package.content))
121
+ if issues:
122
+ for issue in issues:
123
+ print(issue, file=sys.stderr)
124
+ return 1
125
+ print("valid")
126
+ return 0
127
+ if args.command == "schema":
128
+ payload = _store(args.libraries).schema(args.library).json_schema()
129
+ encoded = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
130
+ if args.output:
131
+ args.output.write_text(encoded, encoding="utf-8")
132
+ else:
133
+ print(encoded, end="")
134
+ return 0
135
+ if args.command == "build":
136
+ content = json.loads(args.content.read_text(encoding="utf-8"))
137
+ store = _store(args.libraries, cache_dir=args.cache_dir if args.fetch else None)
138
+ if args.fetch:
139
+ try:
140
+ installed = store.get(args.library)
141
+ except LibraryNotFoundError:
142
+ store.install_from_hub(args.library, core_api=args.core_api)
143
+ else:
144
+ if args.core_api is not None:
145
+ closure = store.resolve_closure(installed.id)
146
+ if any(
147
+ not item.core_api.compatible_with(args.core_api)
148
+ for item in closure.all
149
+ ):
150
+ store.install_from_hub(args.library, core_api=args.core_api)
151
+ result = build(
152
+ args.library,
153
+ content,
154
+ store=store,
155
+ title=args.title,
156
+ language=args.language,
157
+ target_core_api=args.core_api,
158
+ )
159
+ result.save(args.output, content_only=args.content_only)
160
+ print(args.output)
161
+ return 0
162
+ if args.command == "unpack":
163
+ read_package(args.package).extract(args.destination)
164
+ print(args.destination)
165
+ return 0
166
+ except (OSError, ValueError, H5PError, json.JSONDecodeError) as exc:
167
+ print(f"pyh5p: {exc}", file=sys.stderr)
168
+ return 2
169
+ return 2