nnnotes 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.
Files changed (79) hide show
  1. nnnotes/__init__.py +7 -0
  2. nnnotes/__main__.py +4 -0
  3. nnnotes/acb.py +190 -0
  4. nnnotes/addressables.py +413 -0
  5. nnnotes/adv.py +243 -0
  6. nnnotes/advcommand.py +29 -0
  7. nnnotes/advmedia.py +178 -0
  8. nnnotes/advscene.py +113 -0
  9. nnnotes/advui.py +1154 -0
  10. nnnotes/advvideo.py +684 -0
  11. nnnotes/atoms/__init__.py +151 -0
  12. nnnotes/atoms/astc.py +52 -0
  13. nnnotes/atoms/gltf.py +290 -0
  14. nnnotes/atoms/mesh.py +289 -0
  15. nnnotes/atoms/png.py +28 -0
  16. nnnotes/atoms/reader.py +269 -0
  17. nnnotes/atoms/sniff.py +198 -0
  18. nnnotes/atoms/sprite.py +236 -0
  19. nnnotes/atoms/texture.py +80 -0
  20. nnnotes/cache.py +289 -0
  21. nnnotes/catalog.py +373 -0
  22. nnnotes/catalogdb.py +457 -0
  23. nnnotes/census.py +276 -0
  24. nnnotes/cli.py +744 -0
  25. nnnotes/cli_assets.py +1539 -0
  26. nnnotes/config.py +238 -0
  27. nnnotes/contract.py +497 -0
  28. nnnotes/cri.py +371 -0
  29. nnnotes/crikey.py +71 -0
  30. nnnotes/crilips.py +265 -0
  31. nnnotes/cristages.py +558 -0
  32. nnnotes/deckdata.py +462 -0
  33. nnnotes/export.py +1462 -0
  34. nnnotes/gameapi.py +318 -0
  35. nnnotes/jsonio.py +98 -0
  36. nnnotes/languages.py +44 -0
  37. nnnotes/layout.py +596 -0
  38. nnnotes/link.py +418 -0
  39. nnnotes/live.py +115 -0
  40. nnnotes/live2d.py +235 -0
  41. nnnotes/liveaudio.py +331 -0
  42. nnnotes/livenotes.py +291 -0
  43. nnnotes/liveoptions.py +250 -0
  44. nnnotes/livescene.py +450 -0
  45. nnnotes/liveui.py +328 -0
  46. nnnotes/master.py +251 -0
  47. nnnotes/modelstages.py +424 -0
  48. nnnotes/motion.py +131 -0
  49. nnnotes/objexport.py +1564 -0
  50. nnnotes/orchestrate.py +630 -0
  51. nnnotes/plan.py +218 -0
  52. nnnotes/player.py +392 -0
  53. nnnotes/room.py +391 -0
  54. nnnotes/score.py +1139 -0
  55. nnnotes/shader.py +217 -0
  56. nnnotes/spot.py +201 -0
  57. nnnotes/stages.py +285 -0
  58. nnnotes/store.py +518 -0
  59. nnnotes/story.py +142 -0
  60. nnnotes/storyfonts.py +1258 -0
  61. nnnotes/storyhost.py +1190 -0
  62. nnnotes/storysite.py +662 -0
  63. nnnotes/storyvoices.py +102 -0
  64. nnnotes/textstyle.py +355 -0
  65. nnnotes/tmpfont.py +636 -0
  66. nnnotes/typetrees/6000.3.12f1.json +19 -0
  67. nnnotes/unity.py +368 -0
  68. nnnotes/viewrules.json +441 -0
  69. nnnotes/views.py +966 -0
  70. nnnotes/voices.py +1363 -0
  71. nnnotes/web.py +1624 -0
  72. nnnotes/webaudio.py +14 -0
  73. nnnotes/webmodel.py +397 -0
  74. nnnotes-0.1.0.dist-info/METADATA +200 -0
  75. nnnotes-0.1.0.dist-info/RECORD +79 -0
  76. nnnotes-0.1.0.dist-info/WHEEL +5 -0
  77. nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
  78. nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/config.py ADDED
@@ -0,0 +1,238 @@
1
+ """Settings from a TOML file, environment variables and command-line flags.
2
+
3
+ Sources, lowest to highest precedence:
4
+
5
+ 1. the TOML file: `--config <file>`, else the file named by `NNNOTES_CONFIG`, else `./nnnotes.toml` when present;
6
+ 2. environment variables `NNNOTES_<SECTION>_<KEY>`: the setting's dotted name upper-cased, dots and dashes as
7
+ underscores (`servers.tw.cdn` -> `NNNOTES_SERVERS_TW_CDN`, `bundle.nonce_seed` -> `NNNOTES_BUNDLE_NONCE_SEED`);
8
+ 3. command-line flags.
9
+
10
+ No setting has a default and an empty value counts as unset. A command that needs a setting nobody gave stops with
11
+ a ConfigError naming the TOML key and the environment variable. Values are never printed, logged or put into an
12
+ error message. Relative paths in the TOML file are relative to the file's directory; relative paths from the
13
+ environment or the command line are relative to the working directory.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ import shutil
20
+ import tomllib
21
+ from pathlib import Path
22
+
23
+ ENV_PREFIX = "NNNOTES_"
24
+ ENV_CONFIG = "NNNOTES_CONFIG"
25
+ DEFAULT_FILE = "nnnotes.toml"
26
+
27
+
28
+ class ConfigError(Exception):
29
+ """A setting is missing or malformed (the message names the setting, never its value)."""
30
+
31
+
32
+ def env_name(section: str, key: str) -> str:
33
+ return ENV_PREFIX + re.sub(r"[^A-Za-z0-9]", "_", f"{section}.{key}").upper()
34
+
35
+
36
+ def apk_missing(what: str) -> "ConfigError":
37
+ """The error for data that is only in the game's APK when `[paths] apk` is not set."""
38
+ return ConfigError(f"{what} needs the game's APK: give base.apk as {describe('paths', 'apk', '--apk')}")
39
+
40
+
41
+ def describe(section: str, key: str, flag: str | None = None) -> str:
42
+ """Where a setting can be given: `[section] key` in the TOML file, the environment variable, the flag."""
43
+ where = f"`{key}` in the [{section}] table of the config file, the environment variable {env_name(section, key)}"
44
+ return where + (f" or {flag}" if flag else "")
45
+
46
+
47
+ class Config:
48
+ """Merged settings. `overrides`: {(section, key): value} from command-line flags (None = not given)."""
49
+
50
+ def __init__(self, data: dict | None = None, base: Path | None = None, source: str | None = None,
51
+ environ: dict[str, str] | None = None, overrides: dict | None = None, flags: dict | None = None):
52
+ self._data = data or {}
53
+ self._base = base
54
+ self.source = source # the TOML file read, if any (a path, shown in messages)
55
+ env = os.environ if environ is None else environ
56
+ self._env = {k: v for k, v in env.items() if k.startswith(ENV_PREFIX) and k != ENV_CONFIG}
57
+ self._over = {k: v for k, v in (overrides or {}).items() if v not in (None, "")}
58
+ self._flags = dict(flags or {}) # {(section, key): "--flag"} for messages
59
+
60
+ def __repr__(self) -> str: # never the values
61
+ return f"Config(source={self.source!r})"
62
+
63
+ # ---------------------------------------------------------------- loading
64
+ @classmethod
65
+ def load(cls, path: str | Path | None = None, *, environ: dict[str, str] | None = None,
66
+ overrides: dict | None = None, flags: dict | None = None) -> "Config":
67
+ env = os.environ if environ is None else environ
68
+ if path:
69
+ file = Path(path)
70
+ if not file.is_file():
71
+ raise ConfigError(f"config file {file} (--config) not found")
72
+ elif env.get(ENV_CONFIG):
73
+ file = Path(env[ENV_CONFIG])
74
+ if not file.is_file():
75
+ raise ConfigError(f"config file {file} ({ENV_CONFIG}) not found")
76
+ else:
77
+ file = Path(DEFAULT_FILE)
78
+ if not file.is_file():
79
+ return cls(environ=env, overrides=overrides, flags=flags)
80
+ try:
81
+ data = tomllib.loads(file.read_text(encoding="utf-8"))
82
+ except tomllib.TOMLDecodeError as e:
83
+ raise ConfigError(f"config file {file}: invalid TOML ({e})") from None
84
+ return cls(data, base=file.resolve().parent, source=str(file), environ=env, overrides=overrides,
85
+ flags=flags)
86
+
87
+ # ---------------------------------------------------------------- lookup
88
+ def _table(self, section: str) -> dict:
89
+ t = self._data
90
+ for part in section.split("."):
91
+ t = t.get(part) if isinstance(t, dict) else None
92
+ return t if isinstance(t, dict) else {}
93
+
94
+ def _raw(self, section: str, key: str):
95
+ """(value, origin) with origin 'flag', 'env' or 'file'; (None, None) when unset."""
96
+ if (section, key) in self._over:
97
+ return self._over[(section, key)], "flag"
98
+ v = self._env.get(env_name(section, key))
99
+ if v:
100
+ return v, "env"
101
+ v = self._table(section).get(key)
102
+ if v in (None, "", []):
103
+ return None, None
104
+ return v, "file"
105
+
106
+ def has(self, section: str, key: str) -> bool:
107
+ return self._raw(section, key)[0] is not None
108
+
109
+ def origin(self, section: str, key: str) -> str | None:
110
+ """Where a setting comes from: 'flag', 'env', 'file', or None when it is unset."""
111
+ return self._raw(section, key)[1]
112
+
113
+ def missing(self, section: str, key: str) -> ConfigError:
114
+ return ConfigError(f"setting {section}.{key} is not set: give it as "
115
+ f"{describe(section, key, self._flags.get((section, key)))}")
116
+
117
+ def _bad(self, section: str, key: str, what: str) -> ConfigError:
118
+ return ConfigError(f"setting {section}.{key}: {what}")
119
+
120
+ def get(self, section: str, key: str) -> str | None:
121
+ v, _ = self._raw(section, key)
122
+ if v is None:
123
+ return None
124
+ if not isinstance(v, str):
125
+ raise self._bad(section, key, "must be a string")
126
+ return v
127
+
128
+ def require(self, section: str, key: str) -> str:
129
+ v = self.get(section, key)
130
+ if v is None:
131
+ raise self.missing(section, key)
132
+ return v
133
+
134
+ def get_list(self, section: str, key: str) -> list[str]:
135
+ """A list of strings (TOML array; comma-separated in the environment or on the command line)."""
136
+ v, origin = self._raw(section, key)
137
+ if v is None:
138
+ return []
139
+ if isinstance(v, str):
140
+ return [s.strip() for s in v.split(",") if s.strip()]
141
+ if not isinstance(v, list) or not all(isinstance(s, str) for s in v):
142
+ raise self._bad(section, key, "must be a list of strings")
143
+ return list(v)
144
+
145
+ def path(self, section: str, key: str) -> Path | None:
146
+ v, origin = self._raw(section, key)
147
+ if v is None:
148
+ return None
149
+ if not isinstance(v, (str, os.PathLike)):
150
+ raise self._bad(section, key, "must be a path string")
151
+ p = Path(v).expanduser()
152
+ if origin == "file" and not p.is_absolute() and self._base is not None:
153
+ p = self._base / p
154
+ return p
155
+
156
+ def require_path(self, section: str, key: str) -> Path:
157
+ p = self.path(section, key)
158
+ if p is None:
159
+ raise self.missing(section, key)
160
+ return p
161
+
162
+ def hex(self, section: str, key: str, size: int | None = None) -> bytes:
163
+ """A required hex string as bytes (`size`: the exact byte length)."""
164
+ v = self.require(section, key)
165
+ s = v.strip()
166
+ if s[:2].lower() == "0x":
167
+ s = s[2:]
168
+ try:
169
+ b = bytes.fromhex(s)
170
+ except ValueError:
171
+ raise self._bad(section, key, "must be a hex string") from None
172
+ if size is not None and len(b) != size:
173
+ raise self._bad(section, key, f"must be {size} bytes ({2 * size} hex digits)")
174
+ return b
175
+
176
+ # ---------------------------------------------------------------- servers
177
+ def regions(self) -> list[str]:
178
+ """Region names: the [servers.<region>] tables and the regions given by NNNOTES_SERVERS_<REGION>_CDN."""
179
+ names = [k for k, v in self._table("servers").items() if isinstance(v, dict)]
180
+ for k in self._env:
181
+ m = re.fullmatch(ENV_PREFIX + r"SERVERS_([A-Z0-9_]+)_CDN", k)
182
+ if m and m.group(1).lower() not in names:
183
+ names.append(m.group(1).lower())
184
+ return names
185
+
186
+ def region(self) -> str:
187
+ return self.require("catalog", "region")
188
+
189
+ def cdn(self, region: str) -> str:
190
+ """CDN base of a region, without a trailing slash."""
191
+ return self.require(f"servers.{region}", "cdn").rstrip("/")
192
+
193
+ def master(self, region: str | None) -> tuple[str, str]:
194
+ """The setting that names the decoded master data of `region`: the --master flag, else
195
+ `[servers.<region>] master`, else `[paths] master` -> (section, key)."""
196
+ if region and self.origin("paths", "master") != "flag" and self.has(f"servers.{region}", "master"):
197
+ return f"servers.{region}", "master"
198
+ return "paths", "master"
199
+
200
+
201
+ # ---------------------------------------------------------------- the process's settings
202
+ _active: Config | None = None
203
+
204
+
205
+ def use(cfg: Config) -> Config:
206
+ """Make `cfg` the settings of this process (external tools; worker processes call it with the parent's)."""
207
+ global _active
208
+ _active = cfg
209
+ return cfg
210
+
211
+
212
+ def active() -> Config | None:
213
+ return _active
214
+
215
+
216
+ def usable_cpus() -> int:
217
+ """The CPUs this process may run on, which sizes the default worker counts: its affinity mask (taskset, a
218
+ container's cpuset), not the machine's CPU count. os.process_cpu_count (Python 3.13), else the affinity mask where
219
+ the platform reports one, else os.cpu_count; at least 1."""
220
+ count = getattr(os, "process_cpu_count", None)
221
+ if count is not None:
222
+ n = count()
223
+ elif hasattr(os, "sched_getaffinity"):
224
+ n = len(os.sched_getaffinity(0))
225
+ else:
226
+ n = os.cpu_count()
227
+ return max(1, n or 1)
228
+
229
+
230
+ def tool(name: str, exe: str) -> str:
231
+ """An external program: `[paths] <name>` of the active settings, else `exe` on PATH."""
232
+ p = _active.path("paths", name) if _active is not None else None
233
+ if p is not None:
234
+ return str(p)
235
+ found = shutil.which(exe)
236
+ if not found:
237
+ raise ConfigError(f"{exe} not found on PATH: give its path as {describe('paths', name, '--' + name)}")
238
+ return found