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 +44 -0
- pyh5p/_util.py +90 -0
- pyh5p/cli.py +169 -0
- pyh5p/content.py +567 -0
- pyh5p/errors.py +53 -0
- pyh5p/hooks.py +55 -0
- pyh5p/library.py +850 -0
- pyh5p/package.py +267 -0
- pyh5p/py.typed +1 -0
- pyh5p/security.py +183 -0
- pyh5p/semantics.py +643 -0
- pyh5p-0.1.0.dist-info/METADATA +245 -0
- pyh5p-0.1.0.dist-info/RECORD +16 -0
- pyh5p-0.1.0.dist-info/WHEEL +4 -0
- pyh5p-0.1.0.dist-info/entry_points.txt +2 -0
- pyh5p-0.1.0.dist-info/licenses/LICENSE +22 -0
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
|