onlyworlds 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.
- onlyworlds/__init__.py +79 -0
- onlyworlds/_ids.py +28 -0
- onlyworlds/_jsonfmt.py +135 -0
- onlyworlds/_schema.py +556 -0
- onlyworlds/_version.py +1 -0
- onlyworlds/errors.py +133 -0
- onlyworlds/export.py +41 -0
- onlyworlds/folder.py +413 -0
- onlyworlds/http.py +606 -0
- onlyworlds/push.py +375 -0
- onlyworlds/py.typed +0 -0
- onlyworlds-0.1.0.dist-info/METADATA +122 -0
- onlyworlds-0.1.0.dist-info/RECORD +15 -0
- onlyworlds-0.1.0.dist-info/WHEEL +4 -0
- onlyworlds-0.1.0.dist-info/licenses/LICENSE +21 -0
onlyworlds/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""OnlyWorlds for Python: the world folder (read, write, push) and a client for the v2 API.
|
|
2
|
+
|
|
3
|
+
Folder format: v0.3.7 (``FORMAT_VERSION``). Schema: generated from the pinned
|
|
4
|
+
schema distribution (``SCHEMA_DIST_TAG``, ``SCHEMA_VERSION``); the package
|
|
5
|
+
version runs on its own semver.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._ids import uuid7
|
|
9
|
+
from ._schema import ELEMENT_TYPES, FIELD_KINDS, SCHEMA_DIST_TAG, SCHEMA_MANIFEST_SHA256, SCHEMA_VERSION
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .errors import parse_retry_after
|
|
12
|
+
from .export import export_world
|
|
13
|
+
from .folder import (
|
|
14
|
+
FORMAT_VERSION,
|
|
15
|
+
Folder,
|
|
16
|
+
FolderElement,
|
|
17
|
+
SkippedFile,
|
|
18
|
+
WriteReport,
|
|
19
|
+
element_filename,
|
|
20
|
+
read_folder,
|
|
21
|
+
resolve_filenames,
|
|
22
|
+
slugify,
|
|
23
|
+
write_folder,
|
|
24
|
+
)
|
|
25
|
+
from .http import (
|
|
26
|
+
API_BASE,
|
|
27
|
+
READ_ONLY_FIELDS,
|
|
28
|
+
ApiError,
|
|
29
|
+
BulkResult,
|
|
30
|
+
ChangeWalk,
|
|
31
|
+
Client,
|
|
32
|
+
Response,
|
|
33
|
+
RetryPolicy,
|
|
34
|
+
Transport,
|
|
35
|
+
UrllibTransport,
|
|
36
|
+
check_kinds,
|
|
37
|
+
sanitize_payload,
|
|
38
|
+
)
|
|
39
|
+
from .push import Patch, PushPlan, PushResult, plan_push, push, verify_level
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"API_BASE",
|
|
43
|
+
"ELEMENT_TYPES",
|
|
44
|
+
"FIELD_KINDS",
|
|
45
|
+
"FORMAT_VERSION",
|
|
46
|
+
"READ_ONLY_FIELDS",
|
|
47
|
+
"SCHEMA_DIST_TAG",
|
|
48
|
+
"SCHEMA_MANIFEST_SHA256",
|
|
49
|
+
"SCHEMA_VERSION",
|
|
50
|
+
"ApiError",
|
|
51
|
+
"BulkResult",
|
|
52
|
+
"ChangeWalk",
|
|
53
|
+
"Client",
|
|
54
|
+
"Folder",
|
|
55
|
+
"FolderElement",
|
|
56
|
+
"Patch",
|
|
57
|
+
"PushPlan",
|
|
58
|
+
"PushResult",
|
|
59
|
+
"Response",
|
|
60
|
+
"RetryPolicy",
|
|
61
|
+
"SkippedFile",
|
|
62
|
+
"Transport",
|
|
63
|
+
"UrllibTransport",
|
|
64
|
+
"WriteReport",
|
|
65
|
+
"__version__",
|
|
66
|
+
"check_kinds",
|
|
67
|
+
"element_filename",
|
|
68
|
+
"export_world",
|
|
69
|
+
"parse_retry_after",
|
|
70
|
+
"plan_push",
|
|
71
|
+
"push",
|
|
72
|
+
"read_folder",
|
|
73
|
+
"resolve_filenames",
|
|
74
|
+
"sanitize_payload",
|
|
75
|
+
"slugify",
|
|
76
|
+
"uuid7",
|
|
77
|
+
"verify_level",
|
|
78
|
+
"write_folder",
|
|
79
|
+
]
|
onlyworlds/_ids.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""UUIDv7 minting (RFC 9562), because Python's ``uuid`` only gains ``uuid7`` in 3.14.
|
|
2
|
+
|
|
3
|
+
keel mints v7 ids itself and the standard says elements carry them, so a client that
|
|
4
|
+
creates an element without an id gives it one that sorts by creation time. v7 is a
|
|
5
|
+
default, never a requirement: a caller's own v4 id stays valid forever. Never order
|
|
6
|
+
elements by id (worlds also hold v4 and legacy ids); use ``created_at``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
__all__ = ["uuid7"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def uuid7(now_ms: int | None = None, rand: Callable[[int], bytes] = os.urandom) -> str:
|
|
20
|
+
"""48-bit Unix milliseconds, version 7, variant 10, 74 random bits."""
|
|
21
|
+
ms = int(time.time() * 1000) if now_ms is None else now_ms
|
|
22
|
+
if not 0 <= ms < 2**48:
|
|
23
|
+
raise ValueError(f"timestamp {ms} ms does not fit 48 bits")
|
|
24
|
+
b = bytearray(rand(16))
|
|
25
|
+
b[0:6] = ms.to_bytes(6, "big")
|
|
26
|
+
b[6] = (b[6] & 0x0F) | 0x70
|
|
27
|
+
b[8] = (b[8] & 0x3F) | 0x80
|
|
28
|
+
return str(uuid.UUID(bytes=bytes(b)))
|
onlyworlds/_jsonfmt.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""On-disk JSON bytes for the folder format (spec §5, ruled v0.3.3).
|
|
2
|
+
|
|
3
|
+
Normative: UTF-8 without BOM, LF, 2-space indent, one trailing newline, key order
|
|
4
|
+
as received, *matching the reference implementation's*
|
|
5
|
+
``JSON.stringify(value, null, 2)``. Two writers that agree on layout but not on
|
|
6
|
+
bytes make every diff noise, which defeats the format's purpose.
|
|
7
|
+
|
|
8
|
+
``json.dumps(obj, indent=2, ensure_ascii=False)`` gets most of the way there and
|
|
9
|
+
differs from ``JSON.stringify`` in exactly these places, each handled here:
|
|
10
|
+
|
|
11
|
+
- **Floats with an integral value**: Python writes ``1.0``, JavaScript ``1``.
|
|
12
|
+
- **Exponent ranges**: Python writes ``1e+16`` and ``1e-05``; JavaScript writes
|
|
13
|
+
``10000000000000000`` and ``0.00001`` (ECMA-262 Number::toString switches to
|
|
14
|
+
exponent form only outside 1e-7 <= |x| < 1e21, and writes ``1e-7``, not ``1e-07``).
|
|
15
|
+
- **Negative zero**: Python ``-0.0``, JavaScript ``0``.
|
|
16
|
+
- **Lone surrogates** (legal in a Python str after ``json.loads("\\ud800")``):
|
|
17
|
+
Python writes the raw code point and the UTF-8 encode then fails; JavaScript
|
|
18
|
+
(ES2019 well-formed stringify) writes ``\\ud800``.
|
|
19
|
+
|
|
20
|
+
Deliberately NOT emulated (each reported, not hidden):
|
|
21
|
+
|
|
22
|
+
- **Non-finite numbers**: ``JSON.stringify`` silently writes ``null``. That is a
|
|
23
|
+
value change, so this raises instead.
|
|
24
|
+
- **Integers beyond 2**53**: JavaScript has already lost precision at parse time;
|
|
25
|
+
Python keeps the exact value and writes it. Python is the faithful one.
|
|
26
|
+
- **Array-index key order**: a JavaScript object enumerates integer-like keys
|
|
27
|
+
("0", "12") first, ascending, whatever order they arrived in. §5 says key
|
|
28
|
+
order *as received*, so this writer keeps the received order and differs from
|
|
29
|
+
JavaScript only on objects that have such keys.
|
|
30
|
+
|
|
31
|
+
The digits themselves agree: Python's ``repr(float)`` and ECMAScript both emit the
|
|
32
|
+
shortest string that round-trips, so only the formatting around them is ported.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import math
|
|
38
|
+
import re
|
|
39
|
+
from collections.abc import Mapping, Sequence
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
_REPR = re.compile(r"(-?)(\d+)(?:\.(\d+))?(?:e([+-]\d+))?")
|
|
43
|
+
_ESCAPE = re.compile(r'[\x00-\x1f"\\\ud800-\udfff]')
|
|
44
|
+
_SHORT = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def js_number(x: float) -> str:
|
|
48
|
+
"""Format a float exactly as ECMAScript Number::toString (and so JSON.stringify) does."""
|
|
49
|
+
if not math.isfinite(x):
|
|
50
|
+
raise ValueError(f"non-finite number {x!r} has no JSON form (JSON.stringify would silently write null)")
|
|
51
|
+
if x == 0:
|
|
52
|
+
return "0" # covers -0.0
|
|
53
|
+
m = _REPR.fullmatch(repr(x))
|
|
54
|
+
if m is None: # pragma: no cover - repr(float) always matches
|
|
55
|
+
raise ValueError(f"unexpected float repr {x!r}")
|
|
56
|
+
sign, intpart, frac, exp = m.group(1), m.group(2), m.group(3) or "", m.group(4)
|
|
57
|
+
raw = intpart + frac
|
|
58
|
+
stripped = raw.lstrip("0")
|
|
59
|
+
# value = 0.<digits> x 10**n
|
|
60
|
+
n = len(intpart) + int(exp or 0) - (len(raw) - len(stripped))
|
|
61
|
+
digits = stripped.rstrip("0")
|
|
62
|
+
k = len(digits)
|
|
63
|
+
if k <= n <= 21:
|
|
64
|
+
body = digits + "0" * (n - k)
|
|
65
|
+
elif 0 < n <= 21:
|
|
66
|
+
body = digits[:n] + "." + digits[n:]
|
|
67
|
+
elif -6 < n <= 0:
|
|
68
|
+
body = "0." + "0" * (-n) + digits
|
|
69
|
+
else:
|
|
70
|
+
e = n - 1
|
|
71
|
+
body = digits[0] + ("." + digits[1:] if k > 1 else "") + "e" + ("+" if e >= 0 else "-") + str(abs(e))
|
|
72
|
+
return sign + body
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _string(s: str) -> str:
|
|
76
|
+
def esc(m: re.Match[str]) -> str:
|
|
77
|
+
c = m.group(0)
|
|
78
|
+
return _SHORT.get(c) or f"\\u{ord(c):04x}"
|
|
79
|
+
|
|
80
|
+
return '"' + _ESCAPE.sub(esc, s) + '"'
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _dump(v: Any, indent: str, out: list[str]) -> None:
|
|
84
|
+
if v is None:
|
|
85
|
+
out.append("null")
|
|
86
|
+
elif v is True:
|
|
87
|
+
out.append("true")
|
|
88
|
+
elif v is False:
|
|
89
|
+
out.append("false")
|
|
90
|
+
elif isinstance(v, int):
|
|
91
|
+
out.append(str(v))
|
|
92
|
+
elif isinstance(v, float):
|
|
93
|
+
out.append(js_number(v))
|
|
94
|
+
elif isinstance(v, str):
|
|
95
|
+
out.append(_string(v))
|
|
96
|
+
elif isinstance(v, Mapping):
|
|
97
|
+
if not v:
|
|
98
|
+
out.append("{}")
|
|
99
|
+
return
|
|
100
|
+
inner = indent + " "
|
|
101
|
+
out.append("{")
|
|
102
|
+
first = True
|
|
103
|
+
for k, item in v.items():
|
|
104
|
+
if not isinstance(k, str):
|
|
105
|
+
raise TypeError(f"JSON object keys must be str, got {type(k).__name__}")
|
|
106
|
+
out.append("\n" + inner if first else ",\n" + inner)
|
|
107
|
+
first = False
|
|
108
|
+
out.append(_string(k) + ": ")
|
|
109
|
+
_dump(item, inner, out)
|
|
110
|
+
out.append("\n" + indent + "}")
|
|
111
|
+
elif isinstance(v, Sequence) and not isinstance(v, (bytes, bytearray)):
|
|
112
|
+
if not v:
|
|
113
|
+
out.append("[]")
|
|
114
|
+
return
|
|
115
|
+
inner = indent + " "
|
|
116
|
+
out.append("[")
|
|
117
|
+
for i, item in enumerate(v):
|
|
118
|
+
out.append(("\n" if i == 0 else ",\n") + inner)
|
|
119
|
+
_dump(item, inner, out)
|
|
120
|
+
out.append("\n" + indent + "]")
|
|
121
|
+
else:
|
|
122
|
+
raise TypeError(f"not JSON-serializable: {type(v).__name__}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def serialize(value: Any) -> str:
|
|
126
|
+
"""The canonical on-disk text: ``JSON.stringify(value, null, 2) + "\\n"``."""
|
|
127
|
+
out: list[str] = []
|
|
128
|
+
_dump(value, "", out)
|
|
129
|
+
out.append("\n")
|
|
130
|
+
return "".join(out)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def encode(value: Any) -> bytes:
|
|
134
|
+
"""The canonical on-disk bytes: UTF-8, no BOM, LF."""
|
|
135
|
+
return serialize(value).encode("utf-8")
|