@follenfang/fupload 0.0.9 → 0.0.10

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.
@@ -0,0 +1,175 @@
1
+ """Derive ModUs release metadata from an addon ZIP archive.
2
+
3
+ The Creator release form sends two related values for a package:
4
+
5
+ * ``tocVersion`` is the Interface value(s) found in addon ``.toc`` files.
6
+ * ``supportedGameVersionsReqs`` contains ``{gameVersion, server}`` objects.
7
+
8
+ This module intentionally uses an explicit interface table. A numeric
9
+ Interface value is not enough to safely infer a ModUs game choice when the
10
+ Creator adds a new client, so unknown values fail deterministically instead
11
+ of being silently classified.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import os
18
+ import re
19
+ import zipfile
20
+ from pathlib import Path
21
+ from typing import Any, BinaryIO, Dict, Iterable, List, Mapping, Sequence, Tuple, Union
22
+
23
+ from .errors import ValidationError
24
+
25
+
26
+ # These are the game-version labels loaded by Creator's
27
+ # ReleaseFormPageViewModel.LoadDefaultGameVersions. 12.1.0 is also included
28
+ # because it is the live ModUs response used by the integration fixture.
29
+ # Classic values are the Interface values used by the corresponding WoW
30
+ # clients; all entries remain explicit so a new client cannot be guessed.
31
+ INTERFACE_GAME_VERSION_MAP: Mapping[str, Mapping[str, str]] = {
32
+ "110000": {"gameVersion": "11.0.0", "server": "wow_retail"},
33
+ "110002": {"gameVersion": "11.0.2", "server": "wow_retail"},
34
+ "111000": {"gameVersion": "11.1.0", "server": "wow_retail"},
35
+ "111005": {"gameVersion": "11.1.5", "server": "wow_retail"},
36
+ "120100": {"gameVersion": "12.1.0", "server": "wow_retail"},
37
+ "11506": {"gameVersion": "Classic Era", "server": "wow_classic_era"},
38
+ "11507": {"gameVersion": "Classic Era", "server": "wow_classic_era"},
39
+ "11508": {"gameVersion": "Classic Era", "server": "wow_classic_era"},
40
+ "40401": {"gameVersion": "Cataclysm Classic", "server": "wow_classic_cata"},
41
+ "40402": {"gameVersion": "Cataclysm Classic", "server": "wow_classic_cata"},
42
+ }
43
+
44
+ _INTERFACE_RE = re.compile(r"^\s*##\s*Interface\s*:\s*(.*?)\s*$", re.IGNORECASE)
45
+ _INTERFACE_VALUE_RE = re.compile(r"^\d+$")
46
+ _SOURCE = Union[str, os.PathLike[str], bytes, bytearray, memoryview, BinaryIO]
47
+
48
+
49
+ def _read_source(source: _SOURCE) -> Tuple[bytes, str]:
50
+ """Read a path, byte buffer, or seekable stream without leaking content."""
51
+ if isinstance(source, (bytes, bytearray, memoryview)):
52
+ return bytes(source), "<bytes>"
53
+ if hasattr(source, "read"):
54
+ try:
55
+ data = source.read() # type: ignore[union-attr]
56
+ except OSError as exc:
57
+ raise ValidationError("cannot read ZIP archive: %s" % exc, path="$.file") from exc
58
+ if not isinstance(data, (bytes, bytearray, memoryview)):
59
+ raise ValidationError("ZIP source must return bytes", path="$.file")
60
+ return bytes(data), "<stream>"
61
+ path = Path(source)
62
+ if not path.is_file():
63
+ raise ValidationError("file does not exist or is not a regular file", path="$.file")
64
+ try:
65
+ return path.read_bytes(), str(path)
66
+ except OSError as exc:
67
+ raise ValidationError("cannot read ZIP archive: %s" % exc, path="$.file") from exc
68
+
69
+
70
+ def _decode_toc(raw: bytes, name: str) -> str:
71
+ # Creator-generated TOCs are UTF-8. A small number of legacy addon TOCs
72
+ # use Windows-1252, which is deterministic to support without guessing.
73
+ try:
74
+ return raw.decode("utf-8-sig")
75
+ except UnicodeDecodeError:
76
+ try:
77
+ return raw.decode("cp1252")
78
+ except UnicodeDecodeError as exc:
79
+ raise ValidationError("addon TOC is not valid UTF-8 or Windows-1252", path="$.file:%s" % name) from exc
80
+
81
+
82
+ def _interface_values(text: str, name: str) -> List[str]:
83
+ values: List[str] = []
84
+ for line in text.splitlines():
85
+ match = _INTERFACE_RE.match(line)
86
+ if not match:
87
+ continue
88
+ value_text = match.group(1).strip()
89
+ if not value_text:
90
+ raise ValidationError("addon TOC Interface value is missing", path="$.file:%s" % name)
91
+ candidates = [item for item in re.split(r"[;,\s]+", value_text) if item]
92
+ if not candidates or any(not _INTERFACE_VALUE_RE.fullmatch(item) for item in candidates):
93
+ raise ValidationError("addon TOC Interface value must contain decimal codes", path="$.file:%s" % name)
94
+ values.extend(candidates)
95
+ if not values:
96
+ raise ValidationError("addon TOC has no Interface field", path="$.file:%s" % name)
97
+ unique = list(dict.fromkeys(values))
98
+ return sorted(unique, key=lambda item: (int(item), item))
99
+
100
+
101
+ def _zip_entries(raw: bytes, source_name: str) -> Iterable[Tuple[str, bytes]]:
102
+ try:
103
+ archive = zipfile.ZipFile(io.BytesIO(raw))
104
+ except (zipfile.BadZipFile, OSError) as exc:
105
+ raise ValidationError("file must be a valid ZIP archive", path="$.file") from exc
106
+ with archive:
107
+ seen: set[str] = set()
108
+ toc_infos = []
109
+ for info in archive.infolist():
110
+ name = info.filename
111
+ if info.is_dir() or name.endswith("/") or not name.lower().endswith(".toc"):
112
+ continue
113
+ normalized = name.replace("\\", "/").casefold()
114
+ if normalized in seen:
115
+ raise ValidationError("ZIP contains duplicate addon TOC paths", path="$.file:%s" % name)
116
+ seen.add(normalized)
117
+ toc_infos.append(info)
118
+ if not toc_infos:
119
+ raise ValidationError("ZIP contains no addon .toc file", path="$.file")
120
+ for info in sorted(toc_infos, key=lambda item: item.filename.casefold()):
121
+ try:
122
+ yield info.filename, archive.read(info)
123
+ except (KeyError, RuntimeError, OSError) as exc:
124
+ raise ValidationError("cannot read addon TOC from ZIP", path="$.file:%s" % info.filename) from exc
125
+
126
+
127
+ def parse_modus_zip(source: _SOURCE) -> Dict[str, Any]:
128
+ """Return ModUs metadata inferred from ``source``.
129
+
130
+ Every ``.toc`` in the archive must declare the same Interface set. This
131
+ avoids choosing an arbitrary addon when a multi-addon archive contains
132
+ incompatible game versions. Multiple Interface values in one TOC are
133
+ supported and become a deterministic comma-separated ``toc_version``.
134
+
135
+ Returned keys are JSON-ready and use the exact snake_case names accepted
136
+ by the Fupload ModUs schema. ``interface_values`` and ``toc_files`` are
137
+ diagnostic fields for callers and can be omitted from the wire request.
138
+ """
139
+ raw, source_name = _read_source(source)
140
+ signatures: List[Tuple[str, Tuple[str, ...]]] = []
141
+ for name, toc_raw in _zip_entries(raw, source_name):
142
+ values = tuple(_interface_values(_decode_toc(toc_raw, name), name))
143
+ signatures.append((name, values))
144
+ expected = signatures[0][1]
145
+ mismatches = [name for name, values in signatures[1:] if values != expected]
146
+ if mismatches:
147
+ names = ", ".join([signatures[0][0], *mismatches])
148
+ raise ValidationError("addon TOC Interface values are ambiguous across files: %s" % names, path="$.file")
149
+
150
+ unknown = [value for value in expected if value not in INTERFACE_GAME_VERSION_MAP]
151
+ if unknown:
152
+ raise ValidationError(
153
+ "unsupported addon TOC Interface value(s): %s" % ", ".join(unknown),
154
+ path="$.file",
155
+ )
156
+
157
+ games: List[Dict[str, str]] = []
158
+ for interface in expected:
159
+ candidate = dict(INTERFACE_GAME_VERSION_MAP[interface])
160
+ if candidate not in games:
161
+ games.append(candidate)
162
+ return {
163
+ "toc_version": ",".join(expected),
164
+ "supported_game_versions": games,
165
+ "interface_values": list(expected),
166
+ "toc_files": [name for name, _ in signatures],
167
+ }
168
+
169
+
170
+ # Short alias for integration code that already calls metadata parsers by a
171
+ # generic name.
172
+ parse_zip_metadata = parse_modus_zip
173
+
174
+
175
+ __all__ = ["INTERFACE_GAME_VERSION_MAP", "parse_modus_zip", "parse_zip_metadata"]
@@ -61,11 +61,16 @@ class Schema:
61
61
  raise ValidationError("null is not allowed", path="$.%s" % name)
62
62
  continue
63
63
  expected = JSON_TYPES[spec.type]
64
+ # Older Fupload documents used the Creator's display-name
65
+ # license string. Keep that input valid while structured license
66
+ # content is preferred for full-field round trips.
67
+ if self.name.startswith("fupload.v1.modus") and name == "license" and isinstance(item, str):
68
+ continue
64
69
  if spec.type in ("integer", "number") and isinstance(item, bool):
65
70
  raise ValidationError("expected %s" % spec.type, path="$.%s" % name)
66
71
  if not isinstance(item, expected):
67
72
  raise ValidationError("expected %s" % spec.type, path="$.%s" % name)
68
- if spec.nonempty and item in ("", []):
73
+ if spec.nonempty and item in ("", [], {}):
69
74
  raise ValidationError("must not be empty", path="$.%s" % name)
70
75
  if spec.max_length is not None and len(item) > spec.max_length:
71
76
  raise ValidationError(
@@ -93,7 +98,24 @@ class Schema:
93
98
  return checked
94
99
 
95
100
  def _validate_conditionals(self, value: Dict[str, Any]) -> None:
96
- for name in ("id", "mod_id", "file_id", "content_id", "source_id", "module_id", "version_id", "game_version_id", "cloud_id"):
101
+ if self.name in ("fupload.v1.modus.project.create", "fupload.v1.modus.project.edit"):
102
+ snapshot = value.get("project_state")
103
+ if snapshot is None:
104
+ raise ValidationError(
105
+ "completed project_state is required; submit choose_game, basic_info, then license",
106
+ path="$.project_state",
107
+ )
108
+ # Keep the persisted form contract in one place. Restoring the
109
+ # snapshot validates every completed prerequisite and its order.
110
+ from .state_machine import COMPLETE, ProjectStateMachine
111
+
112
+ machine = ProjectStateMachine.from_snapshot(snapshot)
113
+ if machine.state != COMPLETE:
114
+ raise ValidationError(
115
+ "project state must be complete before submission",
116
+ path="$.project_state.state",
117
+ )
118
+ for name in ("id", "project_id", "mod_id", "file_id", "content_id", "source_id", "module_id", "version_id", "game_version_id", "cloud_id"):
97
119
  if name in value and isinstance(value[name], int) and value[name] <= 0:
98
120
  raise ValidationError("must be greater than zero", path="$.%s" % name)
99
121
  if value.get("public") is True and value.get("submit_for_review") is not True:
@@ -351,6 +373,74 @@ class Schema:
351
373
  raise ValidationError("array must contain nonempty folder names", path="$.core_folders")
352
374
  if "file" in value and not zipfile.is_zipfile(value["file"]):
353
375
  raise ValidationError("file must be a valid ZIP archive", path="$.file")
376
+ if self.name.startswith("fupload.v1.modus"):
377
+ object_array(
378
+ "supported_game_versions",
379
+ {"gameVersion", "server", "game_version"},
380
+ ("gameVersion", "server"),
381
+ )
382
+ # The Creator UI treats publishing targets as an independent
383
+ # multi-select state: at least one target is required, and ModUs
384
+ # and BigFoot may be selected together.
385
+ if "publish_platforms" in value:
386
+ platforms = value["publish_platforms"]
387
+ if not isinstance(platforms, list):
388
+ raise ValidationError("expected array", path="$.publish_platforms")
389
+ if not platforms:
390
+ raise ValidationError("must contain at least one platform", path="$.publish_platforms")
391
+ allowed_platforms = {"modus", "bigfoot"}
392
+ if any(not isinstance(item, str) or item not in allowed_platforms for item in platforms):
393
+ raise ValidationError("platform must be modus or bigfoot", path="$.publish_platforms")
394
+ if len(set(platforms)) != len(platforms):
395
+ raise ValidationError("platforms must not contain duplicates", path="$.publish_platforms")
396
+ # A license may be supplied as the desktop client's display name
397
+ # or as its underlying editable content object.
398
+ if "license" in value and isinstance(value["license"], dict):
399
+ license_value = value["license"]
400
+ allowed_license = {"type", "holder", "year", "content"}
401
+ unknown_license = sorted(set(license_value) - allowed_license)
402
+ if unknown_license:
403
+ raise ValidationError(
404
+ "unknown field(s): %s" % ", ".join(unknown_license),
405
+ path="$.license.%s" % unknown_license[0],
406
+ )
407
+ for name in allowed_license:
408
+ if name in license_value and license_value[name] is not None:
409
+ if not isinstance(license_value[name], str):
410
+ raise ValidationError("expected string or null", path="$.license.%s" % name)
411
+ if not license_value[name].strip():
412
+ raise ValidationError("must not be empty", path="$.license.%s" % name)
413
+ # Empty object is the explicit clear/omitted state used by
414
+ # presence-aware edit schemas; non-empty objects are checked
415
+ # field-by-field above.
416
+ if "required_tier_id" in value and value["required_tier_id"] is not None:
417
+ tier = value["required_tier_id"]
418
+ if isinstance(tier, bool) or not isinstance(tier, int) or tier <= 0:
419
+ raise ValidationError("must be a positive integer", path="$.required_tier_id")
420
+ for name in ("categories", "screenshot_base64s"):
421
+ scalar_array(name, (str, int), "array must contain nonempty values")
422
+ if name in value and any((isinstance(item, str) and not item.strip()) or (isinstance(item, bool)) for item in value[name]):
423
+ raise ValidationError("array must contain nonempty values", path="$.%s" % name)
424
+ if "categories" in value and len(value["categories"]) > 5:
425
+ raise ValidationError("must contain at most 5 items", path="$.categories")
426
+ if "image_ops" in value:
427
+ object_array("image_ops", {"op", "name", "base64"}, ("op", "name"))
428
+ if "images" not in value:
429
+ raise ValidationError("images is required when image_ops is supplied", path="$.images")
430
+ for index, operation in enumerate(value["image_ops"]):
431
+ if operation["op"] not in ("upload", "delete"):
432
+ raise ValidationError("must be upload or delete", path="$.image_ops[%d].op" % index)
433
+ if not isinstance(operation["name"], str) or not operation["name"].strip():
434
+ raise ValidationError("must not be empty", path="$.image_ops[%d].name" % index)
435
+ if operation["op"] == "upload" and (not isinstance(operation.get("base64"), str) or not operation["base64"].strip()):
436
+ raise ValidationError("base64 is required for upload", path="$.image_ops[%d].base64" % index)
437
+ if operation["op"] == "delete" and "base64" in operation:
438
+ raise ValidationError("base64 is not allowed for delete", path="$.image_ops[%d].base64" % index)
439
+ for name in ("version", "type"):
440
+ if name in value and isinstance(value[name], str) and not value[name].strip():
441
+ raise ValidationError("must not be empty", path="$.%s" % name)
442
+ if "file" in value and not zipfile.is_zipfile(value["file"]):
443
+ raise ValidationError("file must be a valid ZIP archive", path="$.file")
354
444
 
355
445
 
356
446
  def f(type_name: str, **kwargs: Any) -> Field:
@@ -563,6 +653,55 @@ register("curseforge", "plugin", "upload", required({
563
653
  "is_marked_for_manual_release": f("boolean"),
564
654
  }, ("project_id", "file", "changelog", "release_type")))
565
655
 
656
+ # ModUs.Creator author project and release contracts. The provider translates
657
+ # snake_case input names to the desktop client's camelCase wire fields.
658
+ MODUS_PROJECT_COMMON = {
659
+ "name": f("string", nonempty=True, max_length=120),
660
+ "alt_name": f("string", max_length=120),
661
+ "summary": f("string", nonempty=True, max_length=500),
662
+ "categories": f("array", nonempty=True, max_items=5),
663
+ "license": f("object", nullable=True),
664
+ "repo_url": f("string", max_length=500),
665
+ "required_tier_id": f("integer", nullable=True, minimum=1),
666
+ "publish_platforms": f("array", nonempty=True),
667
+ "project_state": f("object", nonempty=True),
668
+ }
669
+ MODUS_PROJECT_CREATE = {
670
+ **MODUS_PROJECT_COMMON,
671
+ "logo_base64": f("string", nonempty=True),
672
+ "screenshot_base64s": f("array"),
673
+ }
674
+ MODUS_PROJECT_EDIT = {
675
+ **MODUS_PROJECT_COMMON,
676
+ "description": f("string", nullable=True, max_length=100000),
677
+ "required_dependencies": f("string", nullable=True, max_length=4000),
678
+ "images": f("integer", minimum=0),
679
+ "image_ops": f("array", nonempty=True),
680
+ }
681
+ MODUS_RELEASE = {
682
+ "project_id": f("integer", minimum=1),
683
+ "file_id": f("integer", minimum=1),
684
+ "version": f("string", nonempty=True, max_length=120),
685
+ "type": f("string", nonempty=True, max_length=40),
686
+ "supported_game_versions": f("array", nonempty=True),
687
+ "md5": f("string", max_length=64),
688
+ "zip_size": f("integer", minimum=0),
689
+ "unzip_size": f("integer", minimum=0),
690
+ "path": f("string", max_length=500),
691
+ "toc_version": f("string", max_length=80),
692
+ "changelog": f("string", nullable=True, max_length=10000),
693
+ "file": f("string", local_file=True),
694
+ "transaction_log": f("string", max_length=1000),
695
+ }
696
+ register("modus", "project", "create", required(MODUS_PROJECT_CREATE, ("project_state",)))
697
+ register("modus", "project", "edit", required(with_id(MODUS_PROJECT_EDIT, "project_id"), ("project_id", "project_state")))
698
+ register("modus", "project", "delete", required({"project_id": f("integer", minimum=1), "confirm": f("string", choices=("DELETE",))}, ("project_id", "confirm")))
699
+ register("modus", "plugin", "create", required(MODUS_RELEASE, ("project_id", "file")))
700
+ register("modus", "plugin", "upload", required(MODUS_RELEASE, ("project_id", "file")))
701
+ register("modus", "plugin", "update", required(MODUS_RELEASE, ("project_id", "file_id")))
702
+ register("modus", "plugin", "edit", required(MODUS_RELEASE, ("project_id", "file_id")))
703
+ register("modus", "plugin", "delete", required({"project_id": f("integer", minimum=1), "file_id": f("integer", minimum=1), "confirm": f("string", choices=("DELETE",))}, ("project_id", "file_id", "confirm")))
704
+
566
705
  register("blackbox", "plugin", "edit", required({
567
706
  "id": f("integer"), "name": f("string"), "logo_url": f("string"), "category_ids": f("array"),
568
707
  "type": f("integer", choices=(1, 9)), "desc": f("string"), "official": f("string"),