diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..dce2918 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,43 @@ +name: Bug report +description: A command fails or writes a wrong result +labels: [bug] +body: + - type: markdown + attributes: + value: | + Please leave out keys, server addresses, account data and game files: none of them is needed to report a bug. + - type: input + id: version + attributes: + label: nnnotes version + description: the output of `nnnotes --version` + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + description: operating system, Python version, and the vgmstream / FFmpeg / Node.js versions when the command uses them + validations: + required: true + - type: textarea + id: command + attributes: + label: Command + description: the command you ran, with its options + render: shell + validations: + required: true + - type: textarea + id: result + attributes: + label: What happened + description: the error message or the wrong output, and what you expected instead + validations: + required: true + - type: textarea + id: config + attributes: + label: Configuration check + description: the output of `nnnotes config check` (it lists where each setting comes from, never a value) + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..815b7eb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Documentation + url: https://metasekailab.github.io/nnnotes/ + about: Configuration, commands, output formats and the Python API diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..fee66a8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,16 @@ +name: Feature request +description: A new command, option or output +labels: [enhancement] +body: + - type: textarea + id: use + attributes: + label: What you want to do + description: the task, and what nnnotes would need to read or write for it + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: the command, option or output format you have in mind, if any diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d128e0e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Summary + + + +## Checks + +- [ ] `ruff check .`, `ruff format --check .` and `lint-imports` pass +- [ ] `pytest` passes; new behaviour has tests on synthetic inputs +- [ ] A stage whose output changed has a new version and rewritten golden records +- [ ] `docs/cli.md` is regenerated after a command or option change +- [ ] The documentation describes the change diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6532bd4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing + +Thank you for helping with nnnotes. This page describes how to set up a checkout, run the checks and send a change. + +## Setup + +```sh +git clone https://github.com/MetaSekaiLab/nnnotes +cd nnnotes +python -m venv .venv +. .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[test,deck,fonts,docs]" +``` + +Python 3.11 or later. The tests need no game data, no keys and no network. + +## Checks + +These are the checks CI runs on every pull request: + +```sh +ruff check . # lint +ruff format --check . # formatting (line length 120) +lint-imports # the layer contract of pyproject.toml +pytest # the test suite +mkdocs build --strict # the documentation site +``` + +- **Layers.** Each subpackage imports only the layers below it ([architecture](docs/architecture.md)). A module + that needs something from a higher layer takes it as an argument instead. +- **Golden records.** `tests/golden/` records the output of each stage version. When a change alters a stage's + output, raise that stage's (or converter's) version and rewrite the records with + `GOLDENS_UPDATE=1 pytest tests/pipeline/test_goldens.py`, using the library versions of + `.github/goldens-constraints.txt` (`pip install -c .github/goldens-constraints.txt -e ".[test]"`). +- **Command reference.** `docs/cli.md` is generated from the parser. After changing a command or an option, run + `python -m nnnotes.cli.reference docs/cli.md`; a test fails while the file is out of date. + +## Tests and fixtures + +Tests build their inputs synthetically: small Unity-like objects, catalogs, master tables and audio made by the tests +themselves (`tests/helpers/`). Fixtures contain no game files, no data extracted from the game and no keys or server +addresses, so the suite runs anywhere. Test a change with the smallest synthetic input that shows the behaviour. + +Exports must stay deterministic ([determinism](docs/determinism.md)): sort what the game does not order, keep +times and machine paths out of outputs, and write JSON through `nnnotes.core.jsonio`. + +## Commits and pull requests + +- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) with a lowercase type + (`feat`, `fix`, `docs`, `refactor`, `test`, `build`, `ci`, `chore`) and an optional scope (`feat(cli): ...`). + The body says what changed and what users see. A breaking change uses `!` after the type or scope and a + `BREAKING CHANGE:` footer. The commit messages and the pull request title are checked in CI. +- One topic per pull request. Pull requests are merged by rebase, so each commit should build and pass the checks. +- `CHANGELOG.md` is generated from the commits by [git-cliff](https://git-cliff.org/) when a version is released; + do not edit it by hand. + +## Releases + +Versions follow [Semantic Versioning](https://semver.org/) over the command line, the configuration keys, the JSON +schemas and the documented Python API. A release commit sets `__version__` in `src/nnnotes/__init__.py` and +regenerates the changelog: + +```sh +git cliff --tag vX.Y.Z -o CHANGELOG.md +``` + +Pushing the tag `vX.Y.Z` builds the sdist and the wheel, checks them and publishes them to PyPI and the GitHub +release. + +## License + +By contributing you agree that your contribution is licensed under the [MIT License](LICENSE). diff --git a/README.md b/README.md index ce4056e..84ce615 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ The [command reference](https://metasekailab.github.io/nnnotes/cli/) lists every - Reference: [commands](https://metasekailab.github.io/nnnotes/cli/), [command outputs](https://metasekailab.github.io/nnnotes/commands/), [JSON schemas](https://metasekailab.github.io/nnnotes/schemas/), + [Python API](https://metasekailab.github.io/nnnotes/api/), [contracts](https://metasekailab.github.io/nnnotes/contracts/), [stages](https://metasekailab.github.io/nnnotes/stages/), [views](https://metasekailab.github.io/nnnotes/views/). - Explanation: [architecture](https://metasekailab.github.io/nnnotes/architecture/), @@ -89,6 +90,11 @@ line endings and non-finite numbers written as `1e999`. Other versions can encod Versions follow [Semantic Versioning](https://semver.org/) over the command line, the configuration keys, the JSON schemas and the documented Python API. +## Contributing + +Setup, checks and commit conventions are in +[CONTRIBUTING.md](https://github.com/MetaSekaiLab/nnnotes/blob/main/CONTRIBUTING.md). + ## License MIT, see [LICENSE](https://github.com/MetaSekaiLab/nnnotes/blob/main/LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md index 425339e..243f5f2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -70,6 +70,7 @@ nnnotes site build -o out/site --all --player path/to/ournotes-player - 参考:[命令](https://metasekailab.github.io/nnnotes/cli/)、 [命令输出](https://metasekailab.github.io/nnnotes/commands/)、 [JSON schema](https://metasekailab.github.io/nnnotes/schemas/)、 + [Python API](https://metasekailab.github.io/nnnotes/api/)、 [契约](https://metasekailab.github.io/nnnotes/contracts/)、[stage](https://metasekailab.github.io/nnnotes/stages/)、 [视图](https://metasekailab.github.io/nnnotes/views/)。 - 原理:[架构](https://metasekailab.github.io/nnnotes/architecture/)、 @@ -85,6 +86,11 @@ Pillow 写出的 PNG 像素相同,字节可能不同)。 每个版本的改动见 [CHANGELOG.md](https://github.com/MetaSekaiLab/nnnotes/blob/main/CHANGELOG.md)。版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/),约束的接口是命令行、配置键、JSON schema 和文档中的 Python API。 +## 参与贡献 + +开发环境、检查与提交规范见 +[CONTRIBUTING.md](https://github.com/MetaSekaiLab/nnnotes/blob/main/CONTRIBUTING.md)。 + ## License MIT,见 [LICENSE](https://github.com/MetaSekaiLab/nnnotes/blob/main/LICENSE)。 diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..9b04a4d --- /dev/null +++ b/docs/api.md @@ -0,0 +1,43 @@ +# Python API + +The command line is built on the modules below; scripts can use them directly. The names each module lists in its +`__all__` are its documented API, and the most used ones are also importable from the top-level package: + +```python +from nnnotes import Config, Session + +session = Session(Config.load()) # the config file and the NNNOTES_* environment variables +catalog = session.catalog() # the region's catalog, with its bundles +master = session.master_dir() # the decoded master data directory +``` + +A missing or invalid setting raises `ConfigError`, naming the setting and never its value; a failed game API call +raises `GameApiError`. Other names are internal and can change in any version. + +## Configuration and sources + +::: nnnotes.source.config + +::: nnnotes.source.session + +::: nnnotes.source.catalog + +::: nnnotes.source.addressables + +::: nnnotes.source.master + +::: nnnotes.source.gameapi + +::: nnnotes.source.jp + +## Music data + +::: nnnotes.musicdata + +::: nnnotes.musicdata.deckdata + +## Site and schemas + +::: nnnotes.site.webmodel + +::: nnnotes.schemas diff --git a/docs/index.md b/docs/index.md index f6df314..faf386f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,8 +26,9 @@ configuration, and the exports stay in the local directories you choose. - **Reference** - [Commands](cli.md), [command outputs](commands.md), [JSON schemas](schemas.md), [contracts](contracts.md), - [stages](stages.md), [views](views.md), [replay data](replay.md), [UI library](ui.md). + [Commands](cli.md), [command outputs](commands.md), [JSON schemas](schemas.md), [Python API](api.md), + [contracts](contracts.md), [stages](stages.md), [views](views.md), [replay data](replay.md), + [UI library](ui.md). - **Explanation** diff --git a/mkdocs.yml b/mkdocs.yml index 2d0e209..faa526a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -41,6 +41,7 @@ nav: - Commands: cli.md - Command outputs: commands.md - JSON schemas: schemas.md + - Python API: api.md - Contracts: contracts.md - Stages: stages.md - Views: views.md @@ -63,6 +64,24 @@ markdown_extensions: - pymdownx.inlinehilite - pymdownx.superfences +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: [src] + options: + docstring_style: null + filters: public + heading_level: 3 + members_order: source + separate_signature: true + show_root_heading: true + show_root_full_path: true + show_source: false + show_signature_annotations: true + show_symbol_type_heading: true + validation: links: anchors: warn diff --git a/pyproject.toml b/pyproject.toml index 701e391..81c652b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dynamic = ["version"] [project.optional-dependencies] test = ["pytest>=8", "ruff>=0.15", "jsonschema>=4.18", "import-linter>=2.1"] fonts = ["fonttools>=4.50", "freetype-py>=2.5", "scipy>=1.13"] -docs = ["mkdocs>=1.6,<2", "mkdocs-material>=9.5"] +docs = ["mkdocs>=1.6,<2", "mkdocs-material>=9.5", "mkdocstrings[python]>=0.29"] # the deck model of music-data: one nnnotes version carries one ournotes-deck release (.github/workflows/deck.yml moves # the pin when ournotes-deck publishes a newer one) deck = ["ournotes-sim==0.0.8"] @@ -49,7 +49,7 @@ path = "src/nnnotes/__init__.py" packages = ["src/nnnotes"] [tool.hatch.build.targets.sdist] -include = ["/src", "/tests", "/docs", "/scripts", "/CHANGELOG.md", "/README.md", "/README.zh-CN.md", "/mkdocs.yml", "/LICENSE"] +include = ["/src", "/tests", "/docs", "/scripts", "/CHANGELOG.md", "/CONTRIBUTING.md", "/README.md", "/README.zh-CN.md", "/mkdocs.yml", "/LICENSE"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/nnnotes/__init__.py b/src/nnnotes/__init__.py index 0eac44d..7d6ba71 100644 --- a/src/nnnotes/__init__.py +++ b/src/nnnotes/__init__.py @@ -3,6 +3,51 @@ Reads Addressables catalogs, fetches and decrypts asset bundles, decodes master data and exports stories, Live2D models, spots, shaders, CRI audio and live charts as JSON and common file formats. Game files, keys and server addresses are supplied by the user through the configuration; nothing of the game is included. + +The names below are the entry points of the Python API, imported on first use: + + from nnnotes import Config, Session + + session = Session(Config.load()) + catalog = session.catalog() + +The subpackages and the names in their `__all__` are the documented API; other names are internal. """ +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING + __version__ = "0.2.1" + +_LAZY = { + "Catalog": "nnnotes.source.catalog", + "Config": "nnnotes.source.config", + "ConfigError": "nnnotes.source.config", + "GameApiError": "nnnotes.source.gameapi", + "MusicDataError": "nnnotes.musicdata", + "Session": "nnnotes.source.session", +} + +__all__ = ["Catalog", "Config", "ConfigError", "GameApiError", "MusicDataError", "Session", "__version__"] + +if TYPE_CHECKING: + from .musicdata import MusicDataError + from .source.catalog import Catalog + from .source.config import Config, ConfigError + from .source.gameapi import GameApiError + from .source.session import Session + + +def __getattr__(name: str): + module = _LAZY.get(name) + if module is None: + raise AttributeError(f"module 'nnnotes' has no attribute {name!r}") + value = getattr(importlib.import_module(module), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted({*globals(), *_LAZY}) diff --git a/src/nnnotes/musicdata/__init__.py b/src/nnnotes/musicdata/__init__.py index 9b6b007..b1d0ded 100644 --- a/src/nnnotes/musicdata/__init__.py +++ b/src/nnnotes/musicdata/__init__.py @@ -25,7 +25,6 @@ import hashlib import io -import json import math import sys from collections import Counter @@ -36,6 +35,16 @@ from ..source.languages import LANGUAGES from . import deckdata +__all__ = [ + "Deck", + "MusicDataError", + "build", + "export", + "published_seeds", + "validate_aptitude_statistics", + "validate_expectation", +] + FORMAT = "nnnotes.music-data/2" DIFFICULTIES = ("easy", "normal", "hard", "expert") MUSIC_LENGTH_TAIL_MS = 1000 # the live's music length: the last note time + 1000 ms (LiveScore skip path) @@ -429,7 +438,7 @@ def stats(self, deck_input: dict) -> dict: except ValueError as e: raise MusicDataError(f"deck model: {e}") from None try: - doc = json.loads(text, parse_float=deckdata._Num) + doc = deckdata.loads(text) except (TypeError, ValueError): raise MusicDataError("deck model: the statistics are not a JSON document") from None if not isinstance(doc, dict) or doc.get("format") != DECK_STATS_FORMAT: @@ -599,8 +608,10 @@ def _check_expectation_check( raise MusicDataError(f"{where}: expectation check exceeds its bound {check['bound']}: {error}") -def _check_expectation(stats: dict, kinds: int, where: str) -> None: - """Validate /3 expectations and the independent deterministic Free Live measurements.""" +def validate_expectation(stats: dict, kinds: int, where: str) -> None: + """Check a chart's deck statistics (`charts[].deck` with the chart's `judgedNotes`, numbers read by + deckdata.loads): the nominal expectations of the Gekisou lives with their checks, and the deterministic Free Live + measurements. Raises MusicDataError naming `where`; `kinds`: the number of skill kinds.""" positions, infos = stats.get("positions"), stats.get("ranges") if not _int(positions) or positions < 0: raise MusicDataError(f"{where}: invalid number of performance positions") @@ -745,7 +756,7 @@ def plain_kind(kinds: list[dict]) -> int | None: def _number(v) -> bool: """Whether v is a finite number as the deck model writes it.""" - if isinstance(v, bool) or not isinstance(v, (int, deckdata._Num)): + if isinstance(v, bool) or not isinstance(v, (int, deckdata.Num)): return False try: return math.isfinite(float(v)) @@ -1190,7 +1201,7 @@ def chart_deck( replay = stats.get("replaySeeds") if not isinstance(replay, list) or not all(_int(seed) for seed in replay) or replay != want: raise MusicDataError(f"{where}: replay seeds {replay!r} are not the chart's seed set {want}") - _check_expectation(stats, kinds, where) + validate_expectation(stats, kinds, where) return dict({k: stats.get(k) for k in DECK_CHART_KEYS}, gekisouAptitude=aptitude.chart(where, stats, plain)) diff --git a/src/nnnotes/musicdata/deckdata.py b/src/nnnotes/musicdata/deckdata.py index 5643b43..8556550 100644 --- a/src/nnnotes/musicdata/deckdata.py +++ b/src/nnnotes/musicdata/deckdata.py @@ -31,6 +31,8 @@ from ..source.apkset import ApkSet +__all__ = ["DeckDataError", "MasterSource", "Num", "apk_master", "decoded_master", "loads", "master_files"] + DECK_FORMAT = "nnnotes.deck-data/1" # the deck model's input format (ournotes-sim data::FORMAT) CHART_FORMAT = "nnnotes.live-score/1" # score.convert's format: the converter the notes come from CHART_PREFIX = "Live/MusicScore/" # + MasterLiveMusicScore._musicScoreTextFileName @@ -294,11 +296,17 @@ def read_master(src: MasterSource, key, tables=None) -> tuple[dict[str, list[dic # ---------------------------------------------------------------- canonical values -class _Num(str): - """A number token, written as it is.""" +class Num(str): + """A number token of a JSON document, written back as it is.""" + + +def loads(text: str | bytes): + """The JSON document `text` with every non-integer number kept as its token (a Num), so that checks and writes + see the number as written.""" + return json.loads(text, parse_float=Num) -def f32_token(v: float) -> _Num: +def f32_token(v: float) -> Num: """The shortest decimal that reads back as binary32(v); +/-infinity as 1e999 / -1e999; NaN raises ValueError.""" import numpy as np @@ -307,10 +315,10 @@ def f32_token(v: float) -> _Num: if np.isnan(x): raise ValueError("NaN (no JSON representation)") if np.isinf(x): - return _Num("1e999" if x > 0 else "-1e999") + return Num("1e999" if x > 0 else "-1e999") if x == 0 or 1e-4 <= abs(x) < 1e16: - return _Num(np.format_float_positional(x, unique=True, trim="0")) - return _Num(np.format_float_scientific(x, unique=True, trim="0")) + return Num(np.format_float_positional(x, unique=True, trim="0")) + return Num(np.format_float_scientific(x, unique=True, trim="0")) def _value(v, where: str): @@ -472,7 +480,7 @@ def build(tables: dict[str, list[dict]], chart_records: list[dict], provenance: def _emit(v, out: list) -> None: - if isinstance(v, _Num): + if isinstance(v, Num): out.append(v) elif isinstance(v, str): out.append(json.dumps(v, ensure_ascii=False)) @@ -505,7 +513,7 @@ def _emit(v, out: list) -> None: out.append(",") _emit(x, out) out.append("]") - else: # a float must have been made a _Num (f32_token) + else: # a float must have been made a Num (f32_token) raise TypeError(f"no canonical form for {type(v).__name__}") diff --git a/src/nnnotes/py.typed b/src/nnnotes/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/nnnotes/schemas/__init__.py b/src/nnnotes/schemas/__init__.py index f153dd8..87f4af6 100644 --- a/src/nnnotes/schemas/__init__.py +++ b/src/nnnotes/schemas/__init__.py @@ -4,6 +4,8 @@ import json from importlib import resources +__all__ = ["load", "names"] + SUFFIX = ".schema.json" diff --git a/src/nnnotes/site/webmodel.py b/src/nnnotes/site/webmodel.py index 5b927d7..e28c62b 100644 --- a/src/nnnotes/site/webmodel.py +++ b/src/nnnotes/site/webmodel.py @@ -78,6 +78,8 @@ ) from .siteindex import write_index +__all__ = ["catalog_models", "model_id", "model_keys"] + LIVE2D_PREFIX = "Character/Live2D/" MODEL_KEY = re.compile(r"Character/Live2D/(?P[^/]+)/(?P[^/]+)/model/(?P=name)") MODEL_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*") diff --git a/src/nnnotes/source/addressables.py b/src/nnnotes/source/addressables.py index f249e80..8d5d756 100644 --- a/src/nnnotes/source/addressables.py +++ b/src/nnnotes/source/addressables.py @@ -24,6 +24,8 @@ from Crypto.Cipher import AES +__all__ = ["BundleKey", "decrypt", "parse", "parse_header", "parse_keys", "parse_locations"] + UNITYFS = b"UnityFS\0" ENCRYPTED_BYTES = 16384 # only the first 16 KiB of a bundle are encrypted CATALOG_MAGIC = 0x0DE38942 diff --git a/src/nnnotes/source/catalog.py b/src/nnnotes/source/catalog.py index 732e89b..43434e8 100644 --- a/src/nnnotes/source/catalog.py +++ b/src/nnnotes/source/catalog.py @@ -21,6 +21,8 @@ from .apkset import ApkSet from .config import ConfigError, apk_missing, check_catalog_version +__all__ = ["Bundle", "Catalog"] + LOCAL_PREFIX = "{UnityEngine.AddressableAssets.Addressables.RuntimePath}" APK_AA_DIR = "assets/aa/" # + "Android/" APK_CATALOG = "assets/aa/catalog.bin" diff --git a/src/nnnotes/source/config.py b/src/nnnotes/source/config.py index a31d3bc..3fe5627 100644 --- a/src/nnnotes/source/config.py +++ b/src/nnnotes/source/config.py @@ -22,6 +22,8 @@ import tomllib from pathlib import Path +__all__ = ["Config", "ConfigError", "config_files", "user_file"] + ENV_PREFIX = "NNNOTES_" ENV_CONFIG = "NNNOTES_CONFIG" DEFAULT_FILE = "nnnotes.toml" diff --git a/src/nnnotes/source/gameapi.py b/src/nnnotes/source/gameapi.py index 1d9cc4a..ed8596f 100644 --- a/src/nnnotes/source/gameapi.py +++ b/src/nnnotes/source/gameapi.py @@ -32,6 +32,8 @@ from .apkset import MANIFEST_IN_APK, ApkSet, manifest_version_name from .config import Config, ConfigError, describe +__all__ = ["GameApiError", "MasterVersion", "Server", "apk_version_name", "fetch_master_version", "fetch_server_list"] + VERSION_METHOD = "/app.masterdata.MasterdataService/Version" SERVER_LIST_METHOD = "/app.playerlogin.PlayerLoginService/GetServerList" PLATFORM = "android" @@ -239,6 +241,8 @@ def failure(name: str, setting: str, code, trailers) -> str: def fetch_master_version( root: str, client_version: str, *, timeout: float = TIMEOUT, setting: str = "the API root" ) -> MasterVersion: + """The master data version the API root `root` serves now (an anonymous Version call with `client_version`); + a GameApiError naming `setting` when the call fails or the answer has no version.""" body = call(root, VERSION_METHOD, b"", client_version, timeout=timeout, setting=setting) try: v = parse_version(body) @@ -252,6 +256,8 @@ def fetch_master_version( def fetch_server_list( root: str, client_version: str, *, timeout: float = TIMEOUT, setting: str = "the bootstrap API root" ) -> list[Server]: + """The server list the bootstrap API root `root` serves (an anonymous GetServerList call with + `client_version`); a GameApiError naming `setting` when the call fails or the list is empty.""" body = call(root, SERVER_LIST_METHOD, b"", client_version, timeout=timeout, setting=setting) try: servers = parse_server_list(body) diff --git a/src/nnnotes/source/jp.py b/src/nnnotes/source/jp.py index 960c3c3..e269c5d 100644 --- a/src/nnnotes/source/jp.py +++ b/src/nnnotes/source/jp.py @@ -23,6 +23,8 @@ from .catalog import Catalog from .config import ConfigError +__all__ = ["Client", "Observation", "Source", "origin"] + PLACEHOLDER = "{Fwk.Resource.RemoteAssetDir}/" CATALOG_LIMIT = 64 * 1024 * 1024 FILE_LIMIT = 1024 * 1024 * 1024 @@ -79,6 +81,8 @@ def select_asset(raw, client): def origin(value): + """The origin (`scheme://host[:port]`, without a default port) of an HTTPS URL with no path, query or + credentials (HTTP only on a loopback host); a ConfigError for any other value.""" try: u = urlsplit(value) if ( @@ -156,6 +160,10 @@ class Observation: class Client: + """The JP game API and CDN of one configured region: `observe()` asks the API for the current master version, + asset source and CDN credential; `get()` downloads a file of one source or master version with that credential, + observing again when it is older than five minutes.""" + def __init__(self, cfg, region): self.cfg, self.region = cfg.for_region(region), region self._observation = None @@ -163,6 +171,7 @@ def __init__(self, cfg, region): self._lock = threading.RLock() def observe(self, *, timeout=gameapi.TIMEOUT): + """Ask the API for the current Observation: master version, asset source, CDN and its credential.""" with self._lock: section = f"servers.{self.region}" api = gameapi.api_root(self.cfg, section).strip() diff --git a/src/nnnotes/source/master.py b/src/nnnotes/source/master.py index 4574a79..3c79c1b 100644 --- a/src/nnnotes/source/master.py +++ b/src/nnnotes/source/master.py @@ -31,6 +31,8 @@ from ..core import jsonio +__all__ = ["DownloadError", "MasterKey", "decode", "decode_files", "download", "has_row", "table"] + PREFIX = 64 # bytes before the ciphertext BLOCK = 32 # 256-bit block NB = NK = 8 # block / key length in 32-bit words diff --git a/src/nnnotes/source/session.py b/src/nnnotes/source/session.py index d652272..081383f 100644 --- a/src/nnnotes/source/session.py +++ b/src/nnnotes/source/session.py @@ -15,6 +15,8 @@ from .config import Config, ConfigError, check_catalog_version, describe from .master import MasterKey +__all__ = ["Session"] + class Session: """The data sources `cfg` names. `region` arguments select another configured region; None: `[catalog] region`.""" diff --git a/tests/musicdata/test_musicdata.py b/tests/musicdata/test_musicdata.py index 8fe8bc3..f7d4d3f 100644 --- a/tests/musicdata/test_musicdata.py +++ b/tests/musicdata/test_musicdata.py @@ -850,7 +850,7 @@ def expectation_fixture(): charts = deckdata.charts([tables["MasterLiveMusicScore"][1]], CHARTS.__getitem__) source = deckdata.build(tables, charts, {}) document = FakeDeck().chart_stats(deckdata.encode(source).decode("utf-8"), 8, 1) - return json.loads(document, parse_float=deckdata._Num)["charts"][0] + return deckdata.loads(document)["charts"][0] @pytest.mark.parametrize( @@ -862,15 +862,15 @@ def expectation_fixture(): [True, 0], [100, False], [100, -1], - [deckdata._Num("NaN"), 0], - [100, deckdata._Num("Infinity")], - [deckdata._Num("1e308"), deckdata._Num("1e308")], + [deckdata.Num("NaN"), 0], + [100, deckdata.Num("Infinity")], + [deckdata.Num("1e308"), deckdata.Num("1e308")], ], ) def test_expectation_rejects_invalid_enclosures(expectation_fixture, estimate): expectation_fixture["expectation"]["score"] = estimate with pytest.raises(musicdata.MusicDataError, match="outward radius"): - musicdata._check_expectation(expectation_fixture, 1, "synthetic") + musicdata.validate_expectation(expectation_fixture, 1, "synthetic") @pytest.mark.parametrize( @@ -891,7 +891,7 @@ def test_expectation_rejects_invalid_enclosures(expectation_fixture, estimate): def test_expectation_requires_the_new_contract(expectation_fixture, change, message): change(expectation_fixture) with pytest.raises(musicdata.MusicDataError, match=message): - musicdata._check_expectation(expectation_fixture, 1, "synthetic") + musicdata.validate_expectation(expectation_fixture, 1, "synthetic") def test_expected_bonus_is_not_truncation_of_the_expected_score(expectation_fixture): @@ -899,12 +899,12 @@ def test_expected_bonus_is_not_truncation_of_the_expected_score(expectation_fixt # Truncating the mean would incorrectly produce 34. Native /3 explicitly measures both bonuses. row = expectation_fixture["expectation"]["ranges"][0] row.update( - rangeScore=[deckdata._Num("99.5"), 0], - rangeScorePerfect=[deckdata._Num("99.5"), 0], - rankBonus=[deckdata._Num("34.5"), 0], - rankBonusPerfect=[deckdata._Num("34.5"), 0], + rangeScore=[deckdata.Num("99.5"), 0], + rangeScorePerfect=[deckdata.Num("99.5"), 0], + rankBonus=[deckdata.Num("34.5"), 0], + rankBonusPerfect=[deckdata.Num("34.5"), 0], ) - musicdata._check_expectation(expectation_fixture, 1, "synthetic") + musicdata.validate_expectation(expectation_fixture, 1, "synthetic") assert trunc_percent(float(row["rangeScore"][0]), 35) != float(row["rankBonus"][0]) @@ -913,9 +913,9 @@ def test_expectation_check_bounds_both_interval_endpoints(expectation_fixture): check.update(expected=[1000, 1], predicted=[1000, 1], bound=1) # Identical centers alone cannot certify a bound smaller than the farthest endpoint separation. with pytest.raises(musicdata.MusicDataError, match="expectation check exceeds"): - musicdata._check_expectation(expectation_fixture, 1, "synthetic") + musicdata.validate_expectation(expectation_fixture, 1, "synthetic") check["bound"] = 2 - musicdata._check_expectation(expectation_fixture, 1, "synthetic") + musicdata.validate_expectation(expectation_fixture, 1, "synthetic") def test_unplayable_keeps_only_free_live_statistics(expectation_fixture): @@ -923,12 +923,12 @@ def test_unplayable_keeps_only_free_live_statistics(expectation_fixture): expectation_fixture.update( unplayable="synthetic unsupported fourth fever", expectation=None, replaySeeds=[], gekisouAptitude=None ) - musicdata._check_expectation(expectation_fixture, 1, "synthetic") + musicdata.validate_expectation(expectation_fixture, 1, "synthetic") assert musicdata.validate_aptitude_statistics(None, expectation_fixture, "synthetic") is None assert expectation_fixture["offSeeds"] == original expectation_fixture.pop("expectation") with pytest.raises(musicdata.MusicDataError, match="expectation field"): - musicdata._check_expectation(expectation_fixture, 1, "synthetic") + musicdata.validate_expectation(expectation_fixture, 1, "synthetic") def test_deck_without_range_weights(tmp_path): @@ -1319,9 +1319,8 @@ def aptitude_fixture(): """A header and a two-range chart, independently authored above, through JSON as the extension returns it.""" tables = deepcopy({t: TABLE_ROWS.get(t, rows_of(t)) for t in musicdata.tables_of(True, False)}) a = musicdata.Aptitude(tables, musicdata.gekisou_catalog(tables, musicdata.Texts(tables["MasterText"]))) - header = json.loads( + header = deckdata.loads( json.dumps({"plainKind": 0, "host": "synthetic host", "law": musicdata.EXPECTATION_LAW, "shapes": SHAPES}), - parse_float=deckdata._Num, ) chart = { "positions": 2, @@ -1349,7 +1348,7 @@ def aptitude_fixture(): }, } chart["gekisouAptitude"] = aptitude_of(chart, 2) - chart = json.loads(json.dumps(chart), parse_float=deckdata._Num) + chart = deckdata.loads(json.dumps(chart)) return a, header, chart @@ -1415,18 +1414,18 @@ def test_aptitude_header_rejects(aptitude_fixture, change): lambda c: c["gekisouAptitude"]["variants"][0].update(shape=False), lambda c: c["gekisouAptitude"]["variants"][2].update(bandMatch=False), lambda c: c["gekisouAptitude"]["variants"][0].update(score=[1]), - lambda c: c["gekisouAptitude"]["variants"][0].update(score=[deckdata._Num("NaN"), 0]), + lambda c: c["gekisouAptitude"]["variants"][0].update(score=[deckdata.Num("NaN"), 0]), lambda c: c["gekisouAptitude"]["variants"][0].update(score=[10**400, 0]), lambda c: c["gekisouAptitude"]["variants"][0].update(score=[1, -1]), - lambda c: c["gekisouAptitude"]["variants"][0].update(tail=[7, deckdata._Num("Infinity")]), + lambda c: c["gekisouAptitude"]["variants"][0].update(tail=[7, deckdata.Num("Infinity")]), lambda c: c["gekisouAptitude"]["variants"][0].update(tail=[8, 0]), lambda c: c["gekisouAptitude"]["variants"][0]["ranges"].pop(), - lambda c: c["gekisouAptitude"]["variants"][0].update(converted=[deckdata._Num("0.5"), 0]), + lambda c: c["gekisouAptitude"]["variants"][0].update(converted=[deckdata.Num("0.5"), 0]), lambda c: c["gekisouAptitude"]["variants"][0]["ranges"][0].pop("rankBonusPerfect"), lambda c: c["gekisouAptitude"]["variants"][2].update(tailPerfect=[9, 0]), lambda c: c["gekisouAptitude"]["variants"][2]["weights"].__setitem__(0, [0, -1]), lambda c: c["gekisouAptitude"]["variants"][0]["check"].update(bound=-1), - lambda c: c["gekisouAptitude"]["variants"][0]["check"].update(predicted=deckdata._Num("NaN")), + lambda c: c["gekisouAptitude"]["variants"][0]["check"].update(predicted=deckdata.Num("NaN")), lambda c: c["gekisouAptitude"]["variants"][0]["check"].update(expected=[0, 0]), lambda c: c["gekisouAptitude"]["variants"][0]["check"].update(ranks=[0, 2]), lambda c: c["gekisouAptitude"]["variants"][0]["check"].update(deck=[[9, 100], None]), @@ -1444,9 +1443,9 @@ def test_aptitude_rounding(aptitude_fixture): a, h, c = aptitude_fixture aptitude_header(a, h) v = c["gekisouAptitude"]["variants"][2] - v["tail"] = [deckdata._Num("7.002"), deckdata._Num("0.002")] + v["tail"] = [deckdata.Num("7.002"), deckdata.Num("0.002")] a.chart("synthetic", c, 0) # outward radius, not a standard error or an implicit rounding tolerance - v["tail"][1] = deckdata._Num("0.001") + v["tail"][1] = deckdata.Num("0.001") with pytest.raises(musicdata.MusicDataError, match="tail"): a.chart("synthetic", c, 0) @@ -1456,8 +1455,8 @@ def test_aptitude_perfect_tail_uses_the_measured_perfect_bonus(aptitude_fixture) header = aptitude_header(a, h) v = c["gekisouAptitude"]["variants"][0] c["justNotes"] = 1 - v["ranges"][0]["rankBonusPerfect"][0] = deckdata._Num("4.25") - v["scorePerfect"][0] = deckdata._Num("49.25") + v["ranges"][0]["rankBonusPerfect"][0] = deckdata.Num("4.25") + v["scorePerfect"][0] = deckdata.Num("49.25") assert v["ranges"][0]["rankBonus"][0] == 3 # The Perfect and best channels have different independently measured bonuses. # Their separate interval identities must both hold, without a seed or standard-error field. diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..98905d3 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,55 @@ +"""The public Python API: the top-level names and the `__all__` of the public modules.""" + +import importlib +import importlib.resources +import subprocess +import sys + +import pytest + +import nnnotes + +PUBLIC_MODULES = [ + "nnnotes.musicdata", + "nnnotes.musicdata.deckdata", + "nnnotes.schemas", + "nnnotes.site.webmodel", + "nnnotes.source.addressables", + "nnnotes.source.catalog", + "nnnotes.source.config", + "nnnotes.source.gameapi", + "nnnotes.source.jp", + "nnnotes.source.master", + "nnnotes.source.session", +] + + +def test_the_top_level_names_are_the_modules_own(): + assert sorted(nnnotes.__all__) == sorted([*nnnotes._LAZY, "__version__"]) + for name in nnnotes.__all__: + value = getattr(nnnotes, name) + if name != "__version__": + module = importlib.import_module(nnnotes._LAZY[name]) + assert value is getattr(module, name) + assert set(nnnotes.__all__) <= set(dir(nnnotes)) + with pytest.raises(AttributeError): + nnnotes.cli_main # noqa: B018 + + +def test_importing_the_package_imports_no_subpackage(): + code = "import sys, nnnotes; print(sorted(m for m in sys.modules if m.startswith('nnnotes.')))" + out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True).stdout + assert out.strip() == "[]" + + +@pytest.mark.parametrize("name", PUBLIC_MODULES) +def test_every_public_name_is_defined_and_documented(name): + module = importlib.import_module(name) + assert module.__all__ == sorted(module.__all__) + for attr in module.__all__: + value = getattr(module, attr) + assert value.__doc__ and value.__module__ == name, attr + + +def test_the_package_is_typed(): + assert (importlib.resources.files("nnnotes") / "py.typed").is_file()