diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1dac51..7b23319 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,11 @@ on: tags: ["v*"] pull_request: workflow_dispatch: + workflow_call: + inputs: + publishing: + type: boolean + default: false jobs: # The payload path reads JSON that is generated, not committed: the SysML Toolkit's exports of @@ -145,6 +150,7 @@ jobs: node examples/typescript/tutorial.mjs node examples/queries/typescript/demo.mjs --payload | diff - examples/queries/expected/report.txt node ts/toolkit_checks.mjs + node ts/library_checks.mjs - name: TS strict type check working-directory: ts @@ -187,6 +193,7 @@ jobs: javac --enable-preview --release 21 -d /tmp/java-out $(find java/src -name '*.java') java --enable-preview -cp /tmp/java-out org.openmbee.sysml.checks.Checks java --enable-preview -cp /tmp/java-out org.openmbee.sysml.checks.ToolkitChecks + java --enable-preview -cp /tmp/java-out org.openmbee.sysml.checks.LibraryChecks javac --enable-preview --release 21 -cp /tmp/java-out -d /tmp/java-out examples/queries/java/*.java java --enable-preview -cp /tmp/java-out QueriesDemo --payload | diff - examples/queries/expected/report.txt @@ -204,6 +211,7 @@ jobs: g++ -std=c++17 -Wall -Werror -O0 -I cpp/include -I vendor \ cpp/checks/checks.cpp cpp/checks/conformance.g.cpp -o /tmp/cpp-checks g++ -std=c++17 -Wall -Werror -I cpp/include -I vendor cpp/checks/toolkit_checks.cpp -o /tmp/cpp-toolkit-checks && /tmp/cpp-toolkit-checks + g++ -std=c++17 -Wall -Werror -I cpp/include -I vendor cpp/checks/library_checks.cpp -o /tmp/cpp-library-checks && /tmp/cpp-library-checks /tmp/cpp-checks g++ -std=c++17 -Wall -Werror -I cpp/include -I vendor examples/queries/cpp/demo.cpp -o /tmp/queries-demo -ldl /tmp/queries-demo --payload | diff - examples/queries/expected/report.txt @@ -219,6 +227,7 @@ jobs: # on main, and when started by hand (Actions, "Run workflow"), not on every push. if: >- github.event_name == 'workflow_dispatch' || + inputs.publishing == true || startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main' strategy: @@ -392,10 +401,13 @@ jobs: python tools/package_abi.py wasm32-unknown-unknown --out "$RUNNER_TEMP/dist" fi + # The wheel carries the standard library models, so every runner fetches them here (the Linux + # one already has them, for the Annex A test). - name: Python wheel for this platform, installed and read through run: | + git -c core.longpaths=true -C ../sysml-toolkit submodule update --init --depth 1 spec-refs/SysML-v2-Release python -m pip install --quiet build wheel - python tools/package.py python --target ${{ matrix.target }} --out "$RUNNER_TEMP/dist" + python tools/package.py python --target ${{ matrix.target }} --library-dir ../sysml-toolkit/spec-refs/SysML-v2-Release/sysml.library --out "$RUNNER_TEMP/dist" python tools/check_packages.py python --dist "$RUNNER_TEMP/dist" # Packages that are the same on every platform and carry no native library: built and checked @@ -405,7 +417,7 @@ jobs: if: runner.os == 'Linux' run: | python tools/package.py sdist --out "$RUNNER_TEMP/dist" - python tools/package.py npm --out "$RUNNER_TEMP/dist" + python tools/package.py npm --library-dir ../sysml-toolkit/spec-refs/SysML-v2-Release/sysml.library --out "$RUNNER_TEMP/dist" python tools/check_packages.py npm --dist "$RUNNER_TEMP/dist" - uses: actions/upload-artifact@v4 @@ -442,11 +454,33 @@ jobs: pattern: packages-* path: ${{ runner.temp }}/abi merge-multiple: true + # The packages carry the standard library models: the specification's release, from the + # pinned SysML Toolkit's submodule. + - name: Read the SysML Toolkit pin + id: pin + run: | + python - >> "$GITHUB_OUTPUT" <<'EOF' + import json + k = json.load(open("vendor/sysml-toolkit/PIN.json"))["toolkit"] + print("repository=" + k["repository"].removeprefix("https://github.com/")) + print("tag=" + k["tag"]) + print("commit=" + k["commit"]) + EOF + - uses: actions/checkout@v4 + with: + repository: ${{ steps.pin.outputs.repository }} + ref: ${{ steps.pin.outputs.tag }} + path: sysml-toolkit + - name: The SysML Toolkit checkout is the pinned commit, with the specification's library + run: | + test "$(git -C sysml-toolkit rev-parse HEAD)" = "${{ steps.pin.outputs.commit }}" + git -C sysml-toolkit submodule update --init --depth 1 spec-refs/SysML-v2-Release - name: Jar, NuGet package and C++ archive, with every platform's binding library run: | - python tools/package.py java --javac22 "$JAVA_HOME_25_X64/bin/javac" --abi-archives "$RUNNER_TEMP/abi" --out "$RUNNER_TEMP/dist" - python tools/package.py csharp --abi-archives "$RUNNER_TEMP/abi" --out "$RUNNER_TEMP/dist" - python tools/package.py cpp --abi-archives "$RUNNER_TEMP/abi" --out "$RUNNER_TEMP/dist" + L=sysml-toolkit/spec-refs/SysML-v2-Release/sysml.library + python tools/package.py java --javac22 "$JAVA_HOME_25_X64/bin/javac" --abi-archives "$RUNNER_TEMP/abi" --library-dir "$L" --out "$RUNNER_TEMP/dist" + python tools/package.py csharp --abi-archives "$RUNNER_TEMP/abi" --library-dir "$L" --out "$RUNNER_TEMP/dist" + python tools/package.py cpp --abi-archives "$RUNNER_TEMP/abi" --library-dir "$L" --out "$RUNNER_TEMP/dist" python - <<'EOF' import glob, os, sys, zipfile libs = ("sysmlv2_abi.dll", "libsysmlv2_abi.so", "libsysmlv2_abi.dylib") @@ -540,7 +574,7 @@ jobs: # A version tag publishes a draft release with every package and the changelog section as its # notes; the draft is made public by hand. A tag with a suffix (v0.1.0-rc1) is a prerelease. release: - if: startsWith(github.ref, 'refs/tags/v') + if: startsWith(github.ref, 'refs/tags/v') && inputs.publishing != true needs: [example-json, generate-and-test, csharp, java, cpp, toolkit, portable, portable-check] runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..516599e --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,47 @@ +name: Validate distributions +on: + workflow_call: +permissions: + contents: read +jobs: + build: + # The shared CI contains the tag-only draft-release job, disabled by publishing. + permissions: + contents: write + uses: ./.github/workflows/ci.yml + with: + publishing: true + validate: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/download-artifact@v4 + with: + pattern: packages-* + path: packages/ + merge-multiple: true + - name: Select Python distributions + run: | + mkdir dist + cp packages/sysml-*.whl packages/sysml-*.tar.gz dist/ + - run: python -m pip install build twine + - run: python -m twine check --strict dist/* + - name: Check package name, version and platform coverage + run: python tools/check_python_distributions.py dist + - name: Build and check wheel from source distribution + run: | + python -m pip wheel --no-deps dist/*.tar.gz --wheel-dir "$RUNNER_TEMP/sdist-wheels" + python -m twine check --strict "$RUNNER_TEMP"/sdist-wheels/* + python -m venv "$RUNNER_TEMP/sdist-test" + "$RUNNER_TEMP/sdist-test/bin/python" -m pip install "$RUNNER_TEMP"/sdist-wheels/*.whl + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/sdist-test/bin/python" -c "from sysml import Model, PayloadLibrary; from sysml.classes import PartUsage; assert Model.from_full_json([]).all() == []" + - uses: actions/upload-artifact@v4 + with: + name: distributions + path: dist/* + if-no-files-found: error diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..b817f4b --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,40 @@ +name: Publish sysml +on: + release: + types: [published] + workflow_dispatch: +permissions: + contents: read +jobs: + build: + permissions: + contents: write + uses: ./.github/workflows/package.yml + testpypi: + if: github.event_name == 'workflow_dispatch' + needs: build + runs-on: ubuntu-latest + environment: testpypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: distributions + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + pypi: + if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: distributions + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test-sysml.yml b/.github/workflows/test-sysml.yml new file mode 100644 index 0000000..5df7971 --- /dev/null +++ b/.github/workflows/test-sysml.yml @@ -0,0 +1,27 @@ +name: Test sysml +on: + push: + branches: [main, develop, 'release/**'] + pull_request: +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install -e '.[dev]' + # Native and generated-example checks run in the shared CI pipeline. + - run: python -m pytest -v --tb=short --junitxml=test-results/results.xml + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ matrix.python-version }} + path: test-results/ diff --git a/.gitignore b/.gitignore index f2712e7..937c746 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ python/sysml/_native/ ts/sysmlv2_abi.wasm ts/LICENSE ts/NOTICE +python/sysml/stdlib/ +ts/stdlib/ # Query example build outputs examples/queries/java/out/ @@ -31,3 +33,4 @@ examples/sysml.library.full.json # Binding libraries tools/package.py stages for the NuGet package csharp/*/runtimes/ csharp/*/THIRD-PARTY/ +csharp/*/stdlib/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4288a4a..1203a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.1.0, preview +## 0.1.0 The first release. @@ -37,6 +37,15 @@ The first release. element id. The SysML Toolkit backend reads the standard library from a directory in every language, and in JavaScript also from the library's files given as sources, in a browser as well. A model file or library directory that does not exist is named in the error. +- The standard library at hand in every language, as the first step of a program: every package + carries its models (`standard_library()` in Python, `standardLibrary()` in JavaScript, + `StandardLibrary.directory()` in Java, `StandardLibrary.Directory()` in C#, and in C++ + `sysml::standard_library()` for the archive's `sysml.library/` copied beside the program), and + `standard_library_json()` and its counterparts download the library JSON from the release on their + first call, check it against the SHA-256 GitHub states for it, and keep it in a cache all languages + share (C++ reads the cache, and downloads nothing). `SYSML_LIBRARY_JSON`, `SYSML_LIBRARY_DIR` (C++) + and `SYSML_CACHE_DIR` name other places. The packages that carry the models declare their license + as Apache-2.0 AND EPL-2.0. - The standard library, `sysml_library-.zip`, in the two forms the backends read: the library models as the SysML v2 release has them (`sysml.library/`, for the SysML Toolkit), and the library as full-form JSON written by the SysML Toolkit under the element ids KerML 9.1 and @@ -125,4 +134,5 @@ Platforms and packages: on Windows by hand. - On macOS a library downloaded through a web browser is quarantined; remove the attribute with `xattr -d com.apple.quarantine`. -- The packages are release assets only; they are not on the language registries yet. +- Python is on PyPI (`pip install sysml`); the other packages are release assets, not on their + language registries yet. diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..22f7cd5 --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,76 @@ +# Publishing sysml + +The Python distribution and import name are both `sysml`. Publication runs from +`Open-MBEE/sysml-sdk`, using `.github/workflows/publish.yml`. + +## One-time registry setup + +Create GitHub environments named `pypi` and `testpypi` in `Open-MBEE/sysml-sdk`. +Configure a trusted publisher separately on PyPI and TestPyPI: + +| Field | PyPI | TestPyPI | +|---|---|---| +| Project name | `sysml` | `sysml` | +| Repository owner | `Open-MBEE` | `Open-MBEE` | +| Repository name | `sysml-sdk` | `sysml-sdk` | +| Workflow filename | `publish.yml` | `publish.yml` | +| Environment | `pypi` | `testpypi` | + +For a new project use a pending publisher in the registry's account publishing +settings; for an existing project its owner must add the publisher in the project's +publishing settings. The project name must be available or controlled by the publisher's +account. No API token secrets are required. + +See [PyPI trusted publisher setup](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) +and [creating a project](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/). + +## TestPyPI + +Run **Publish sysml** manually in GitHub Actions, selecting the branch or tag to test. +Every manual run targets TestPyPI. Each published version must be unique; update +`pyproject.toml` before uploading a version already present on that registry. + +```sh +python -m pip install --index-url https://test.pypi.org/simple/ sysml==0.1.0 +``` + +## PyPI + +Set the version in `pyproject.toml`, keep the other language versions and changelog +consistent, and push its matching tag (for example `v0.1.0`). Existing CI builds and +checks all release assets and creates a draft GitHub release. Publish that draft to +trigger **Publish sysml**, which rebuilds and validates before uploading to PyPI. +Publishing does not create another draft release. + +After committing the release version and changelog, create and push the tag: + +```sh +git tag v0.1.0 +git push origin v0.1.0 +``` + +Wait for the tag's **CI** run to succeed, open the draft under the repository's +**Releases**, and select **Publish release**. Watch **Publish sysml** in **Actions**; +its `pypi` job uploads the distributions. A manual workflow run uploads to TestPyPI, +even when you select a release tag. + +After publication, verify installation in a fresh virtual environment: + +```sh +python -m venv .venv-pypi-check +# Activate: source .venv-pypi-check/bin/activate (Linux/macOS) +# or .venv-pypi-check\Scripts\Activate.ps1 (PowerShell) +python -m pip install sysml==0.1.0 +python -c "from importlib.metadata import version; from sysml import Model; print(version('sysml'))" +``` + +Replace `0.1.0` with the release version. Uploaded distribution files cannot be +overwritten; use a new version when correcting a published package. + +The shared packaging workflow reuses the full CI pipeline, including native backend +and installed-package checks. It publishes exactly four native platform wheels and +one source distribution. Twine validates metadata, package names and versions are +checked, release tags must match `pyproject.toml`, and the source distribution is +rebuilt and imported outside the checkout before publication. Source installations +provide the payload backend; use a supported platform wheel for the bundled Toolkit +backend. The standard library remains a separate GitHub release asset. diff --git a/README.md b/README.md index e42d19e..2c2d520 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ The code in the guides is run against the release packages before every release. In Python, the whole thing is: ```python -from sysml import Model +from sysml import Model, standard_library -model = Model.from_toolkit("model.sysml", library_dir="sysml.library") +model = Model.from_toolkit("model.sysml", library_dir=standard_library()) vehicle = model.resolve("Vehicles::Vehicle") print([feature.declaredName for feature in vehicle.ownedFeature]) ``` @@ -56,21 +56,28 @@ checks), the same in every language and printing the same report. ## Install -The release assets of each version carry one package per language, the binding library for each -platform, and the standard library. The packages are not on the public registries yet. +Python is on PyPI: `pip install sysml`. The release assets of each version carry one package per +language (the Python wheels too), the binding library for each platform, and the standard library. +How the Python packages are published: [PUBLISHING.md](PUBLISHING.md). | Language | Asset | Install | SysML Toolkit backend | |---|---|---|---| -| Python 3.10+ | `sysml-0.1.0-py3-none-.whl` | `pip install sysml-0.1.0-py3-none-.whl` | included in the wheel | +| Python 3.10+ | `sysml-0.1.0-py3-none-.whl` | `pip install sysml` (or the wheel's file) | included in the wheel | | JavaScript / TypeScript (Node 20+, browsers) | `sysml-0.1.0.tgz` | `npm install ./sysml-0.1.0.tgz` | included, as WebAssembly | | Java 21+ | `sysml-sdk-0.1.0.jar` | put it on the class path | included in the jar | | C# (.NET 8) | `OpenMBEE.SysML.0.1.0.nupkg` | `dotnet add package OpenMBEE.SysML --version 0.1.0 --source ` | included in the package | | C++17 | `sysml-sdk-cpp-0.1.0.zip` | put its `include/` on the include path (header-only) | included in the archive (`lib//`): copy it beside your program | -- **The standard library**, `sysml_library-0.1.0.zip`: `sysml.library/` holds the library's - models, for the SysML Toolkit (pass the directory when a session opens), and - `sysml.library.full.json` the same library as JSON, for payloads. Most models refer to it - (`ScalarValues::Real`, `ISQ::mass`, and implicitly `Parts::parts` and the like). It is the +- **The standard library.** Most models refer to it (`ScalarValues::Real`, `ISQ::mass`, and + implicitly `Parts::parts` and the like), so a program gets it first, as each language's guide + shows. Every package carries its models, for the SysML Toolkit: `standard_library()` (Python), + `standardLibrary()` (JavaScript), `StandardLibrary.directory()` (Java), + `StandardLibrary.Directory()` (C#), and in C++ `sysml::standard_library()` finds the archive's + `sysml.library/` copied beside the program. The same library as JSON, for payloads, is too large + for a package: `standard_library_json()` and its counterparts download it from the release on + their first call (checked against the SHA-256 GitHub states, kept in the user cache, shared by all + languages; C++ reads that cache but does not download). `sysml_library-0.1.0.zip` on the release + has both, for machines without access to GitHub (`SYSML_LIBRARY_JSON` names its JSON). It is the SysML v2 release's library, under the Eclipse Public License 2.0. - **The binding library** comes with every package: the wheel carries the one for its platform, the npm package the WebAssembly build, and the jar, the NuGet package and the C++ archive one for @@ -117,7 +124,8 @@ in C#, and of `ToolkitBackend::open(paths, sources, library_dir)` in C++. The We reads no files itself: in JavaScript pass `libraryDir` in Node, or the library's files as `librarySources` (each file's name, without its directories, to its text), which works in a browser too. The library loads as a library: its elements are not listed as the model's, and they -report `isLibraryElement`. +report `isLibraryElement`. The packages' standard library helpers (see [Install](#install)) give the +directory. The SysML Toolkit also writes the model back out as full-form JSON: `full_json()` in Python, `fullJson()` in JavaScript and Java, `FullJson()` in C#, `full_json()` in C++. By default the diff --git a/cpp/checks/library_checks.cpp b/cpp/checks/library_checks.cpp new file mode 100644 index 0000000..ba21bf5 --- /dev/null +++ b/cpp/checks/library_checks.cpp @@ -0,0 +1,86 @@ +// The standard library helpers: the variables that name them, the cache the SDK languages share, and +// what they say when the library is not at hand. No network: C++ downloads nothing. + +#include +#include +#include +#include +#include + +#include + +namespace fs = std::filesystem; + +static int failures = 0; + +static void check(bool cond, const std::string& what) { + std::cout << (cond ? "ok: " : "FAIL: ") << what << "\n"; + if (!cond) ++failures; +} + +static void set_env(const char* name, const std::string& value) { +#ifdef _WIN32 + _putenv_s(name, value.c_str()); +#else + if (value.empty()) unsetenv(name); else setenv(name, value.c_str(), 1); +#endif +} + +template +static void rejects(F body, const std::string& pattern, const std::string& what) { + try { + body(); + check(false, what + " (no error)"); + } catch (const sysml::library_unavailable& e) { + check(std::string(e.what()).find(pattern) != std::string::npos, what + ": " + std::string(e.what()).substr(0, 90)); + } +} + +int main() { + if (std::getenv("SYSML_LIBRARY_JSON") || std::getenv("SYSML_LIBRARY_DIR")) { + std::cout << "skipped: SYSML_LIBRARY_JSON or SYSML_LIBRARY_DIR is set\n"; + return 0; + } + fs::path tmp = fs::temp_directory_path() / ("sysml-library-checks-" + std::to_string(std::rand())); + fs::create_directories(tmp); + + // The JSON: the shared cache, then the variable. + set_env("SYSML_CACHE_DIR", tmp.string()); + rejects([] { sysml::standard_library_json(); }, "SYSML_LIBRARY_JSON", "no JSON anywhere: says what to do"); + fs::path cached = tmp / (std::string("sysml_library-") + sysml::sdk_version) / "sysml.library.full.json"; + fs::create_directories(cached.parent_path()); + std::ofstream(cached) << "[]"; + check(fs::path(sysml::standard_library_json()) == cached, "the JSON another language downloaded into the shared cache"); + fs::path copy = tmp / "library.json"; + std::ofstream(copy) << "[]"; + set_env("SYSML_LIBRARY_JSON", copy.string()); + check(fs::path(sysml::standard_library_json()) == copy, "SYSML_LIBRARY_JSON names a copy"); + set_env("SYSML_LIBRARY_JSON", (tmp / "missing.json").string()); + rejects([] { sysml::standard_library_json(); }, "not a file", "SYSML_LIBRARY_JSON names nothing"); + set_env("SYSML_LIBRARY_JSON", ""); + + // The models: the variable, then beside the executable. + fs::path models = tmp / "models"; + fs::create_directories(models / "Kernel Libraries"); + set_env("SYSML_LIBRARY_DIR", models.string()); + rejects([] { sysml::standard_library(); }, "holds no library models", "SYSML_LIBRARY_DIR names an empty directory"); + std::ofstream(models / "Kernel Libraries" / "Base.kerml") << "standard library package Base;"; + check(fs::path(sysml::standard_library()) == models, "SYSML_LIBRARY_DIR names the models"); + set_env("SYSML_LIBRARY_DIR", ""); + try { + std::string beside = sysml::standard_library(); + check(fs::exists(fs::path(beside) / "LICENSE"), "sysml.library beside the executable: " + beside); + } catch (const sysml::library_unavailable& e) { + check(std::string(e.what()).find("beside the executable") != std::string::npos, + "no sysml.library beside the executable: says so"); + } + + set_env("SYSML_CACHE_DIR", ""); + fs::remove_all(tmp); + if (failures) { + std::cout << failures << " LIBRARY CHECK(S) FAILED\n"; + return 1; + } + std::cout << "ALL C++ LIBRARY CHECKS PASSED\n"; + return 0; +} diff --git a/cpp/include/sysml/library.hpp b/cpp/include/sysml/library.hpp new file mode 100644 index 0000000..a6b5e5d --- /dev/null +++ b/cpp/include/sysml/library.hpp @@ -0,0 +1,107 @@ +// The KerML and SysML standard library, in the two forms the backends read. +// +// sysml::standard_library() is the directory of the library's models, for the SysML Toolkit +// backend: ToolkitBackend::open({"model.sysml"}, {}, sysml::standard_library()). The release archive +// carries them in sysml.library/: copy that directory beside your executable (as you do the binding +// library), or name it in SYSML_LIBRARY_DIR. +// +// sysml::standard_library_json() is the same library as full-form interchange JSON, for the payload +// backend (PayloadLibrary). C++ downloads nothing: it is the file SYSML_LIBRARY_JSON names, else the +// copy in the user's cache that the other SDK languages download (sysml_library-/ in the +// cache directory). Get it once with any of them, or download sysml_library-.zip from the +// release and set SYSML_LIBRARY_JSON to the sysml.library.full.json it holds. +// +// Both are the library of the SysML v2 release the SDK was built with, licensed under the Eclipse +// Public License 2.0, not the SDK's Apache-2.0: see the LICENSE and NOTICE beside them. + +#pragma once + +#include +#include +#include +#include + +#include "toolkit.hpp" + +namespace sysml { + +/// This SDK's version, whose release the standard library helpers name. +inline constexpr const char* sdk_version = "0.1.0"; + +/// The standard library is not at hand: no models beside the executable, or no JSON. +class library_unavailable final : public sdk_error { +public: + using sdk_error::sdk_error; +}; + +namespace detail { + +inline std::string environment(const char* name) { + const char* v = std::getenv(name); + return v ? v : ""; +} + +/// The cache directory the SDK languages share: SYSML_CACHE_DIR, else the user's cache directory. +inline std::filesystem::path library_cache_root() { + namespace fs = std::filesystem; + if (std::string env = environment("SYSML_CACHE_DIR"); !env.empty()) return env; +#ifdef _WIN32 + std::string local = environment("LOCALAPPDATA"); + fs::path base = !local.empty() ? fs::path(local) : fs::path(environment("USERPROFILE")) / "AppData" / "Local"; + return base / "sysml" / "Cache"; +#elif defined(__APPLE__) + return fs::path(environment("HOME")) / "Library" / "Caches" / "sysml"; +#else + std::string xdg = environment("XDG_CACHE_HOME"); + return (!xdg.empty() ? fs::path(xdg) : fs::path(environment("HOME")) / ".cache") / "sysml"; +#endif +} + +inline bool holds_models(const std::filesystem::path& dir) { + namespace fs = std::filesystem; + std::error_code ec; + if (!fs::is_directory(dir, ec)) return false; + for (fs::recursive_directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) { + auto ext = it->path().extension(); + if (ext == ".sysml" || ext == ".kerml") return true; + } + return false; +} + +} // namespace detail + +/// The directory of the standard library models, for `library_dir`: SYSML_LIBRARY_DIR, else +/// sysml.library beside the running executable. +inline std::string standard_library() { + namespace fs = std::filesystem; + if (std::string named = detail::environment("SYSML_LIBRARY_DIR"); !named.empty()) { + if (!detail::holds_models(named)) + throw library_unavailable("SYSML_LIBRARY_DIR names " + named + ", which holds no library models"); + return named; + } + fs::path beside = fs::path(ToolkitBackend::Library::executable_dir()) / "sysml.library"; + if (detail::holds_models(beside)) return beside.string(); + throw library_unavailable(std::string("no standard library models beside the executable: copy the sysml.library " + "directory of the release's sysml-sdk-cpp-") + sdk_version + ".zip there, or set SYSML_LIBRARY_DIR to it"); +} + +/// The standard library as full-form JSON, for PayloadLibrary: the file SYSML_LIBRARY_JSON names, +/// else the copy in the cache the SDK languages share (which C++ reads but does not download). +inline std::string standard_library_json() { + namespace fs = std::filesystem; + std::error_code ec; + if (std::string named = detail::environment("SYSML_LIBRARY_JSON"); !named.empty()) { + if (!fs::is_regular_file(named, ec)) + throw library_unavailable("SYSML_LIBRARY_JSON names " + named + ", which is not a file"); + return named; + } + std::string stem = std::string("sysml_library-") + sdk_version; + fs::path cached = detail::library_cache_root() / stem / "sysml.library.full.json"; + if (fs::is_regular_file(cached, ec)) return cached.string(); + throw library_unavailable("no standard library JSON: download " + stem + ".zip from https://github.com/Open-MBEE/" + "sysml-sdk/releases/tag/v" + sdk_version + ", unpack it, and set SYSML_LIBRARY_JSON to the " + "sysml.library.full.json it holds (or let another SDK language download it once into " + + cached.parent_path().string() + ")"); +} + +} // namespace sysml diff --git a/cpp/include/sysml/toolkit.hpp b/cpp/include/sysml/toolkit.hpp index fd8d1cb..ab799f1 100644 --- a/cpp/include/sysml/toolkit.hpp +++ b/cpp/include/sysml/toolkit.hpp @@ -112,10 +112,6 @@ class ToolkitBackend final : public Backend { throw sdk_error("binding library not found: set SYSMLV2_ABI to the library file in the release's sysmlv2_abi archive for this platform (sysmlv2_abi.dll, libsysmlv2_abi.so or libsysmlv2_abi.dylib), or one built from abi/ in the SDK source"); } - private: - void* handle_ = nullptr; - std::unordered_map syms_; - /// The running executable's directory, with a trailing separator; empty if unknown. static std::string executable_dir() { std::string path; @@ -138,6 +134,10 @@ class ToolkitBackend final : public Backend { std::size_t slash = path.find_last_of("/\\"); return slash == std::string::npos ? std::string() : path.substr(0, slash + 1); } + + private: + void* handle_ = nullptr; + std::unordered_map syms_; }; static std::shared_ptr default_library() { diff --git a/csharp/OpenMBEE.SysML.Checks/LibraryChecks.cs b/csharp/OpenMBEE.SysML.Checks/LibraryChecks.cs new file mode 100644 index 0000000..0f70f7e --- /dev/null +++ b/csharp/OpenMBEE.SysML.Checks/LibraryChecks.cs @@ -0,0 +1,127 @@ +// The standard library helpers, offline: GitHub is replaced by a fake fetch that serves the release +// metadata and the library archive from memory. + +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; + +namespace OpenMBEE.SysML.Checks; + +public static class LibraryChecks +{ + const string Version = "9.9.9"; + const string Stem = "sysml_library-" + Version; + const string Api = "https://api.github.com/repos/Open-MBEE/sysml-sdk/releases/tags/v" + Version; + const string Asset = "https://github.com/Open-MBEE/sysml-sdk/releases/download/v" + Version + "/" + Stem + ".zip"; + + static void Check(bool cond, string what) + { + if (!cond) throw new Exception("FAILED: " + what); + Console.WriteLine("ok: " + what); + } + + static void Rejects(Action body, string pattern, string what) + { + try + { + body(); + } + catch (LibraryUnavailableException e) + { + Check(e.Message.Contains(pattern), what + ": " + e.Message[..Math.Min(90, e.Message.Length)]); + return; + } + Check(false, what + " (no error)"); + } + + static byte[] Zip(params (string Name, string Text)[] entries) + { + var buffer = new MemoryStream(); + using (var z = new ZipArchive(buffer, ZipArchiveMode.Create, true)) + foreach (var (name, text) in entries) + { + using var w = new StreamWriter(z.CreateEntry(name).Open()); + w.Write(text); + } + return buffer.ToArray(); + } + + static byte[] Archive() => Zip(($"{Stem}/sysml.library.full.json", "[{\"@id\": \"lib\"}]"), ($"{Stem}/LICENSE", "EPL"), + ($"{Stem}/NOTICE", "notice"), ($"{Stem}/README.txt", "readme")); + + static string Sha256(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant(); + + /// A fake GitHub: URL to bytes; every URL asked is recorded. + sealed class GitHub(Dictionary served) + { + public readonly List Asked = []; + public byte[] Fetch(string url, string accept) + { + Asked.Add(url); + return served.TryGetValue(url, out var b) ? b : throw new IOException("no route to host"); + } + } + + static GitHub Release(byte[] data, string digest) => new(new() + { + [Api] = Encoding.UTF8.GetBytes($"{{\"assets\": [{{\"name\": \"{Stem}.zip\", \"browser_download_url\": \"{Asset}\", \"digest\": \"{digest}\"}}]}}"), + [Asset] = data, + }); + + static string TempDir() => Directory.CreateTempSubdirectory("sysml-library-").FullName; + + public static void Run() + { + if (Environment.GetEnvironmentVariable("SYSML_LIBRARY_JSON") is not null) + { + Console.WriteLine("library checks skipped: SYSML_LIBRARY_JSON is set"); + return; + } + var good = Archive(); + { + var dir = TempDir(); + var path = StandardLibrary.Json(Version, dir, Release(good, "sha256:" + Sha256(good)).Fetch); + Check(path == Path.Combine(dir, Stem, "sysml.library.full.json"), "the JSON lands in the cache"); + Check(File.ReadAllText(path) == "[{\"@id\": \"lib\"}]", "the JSON is the archive's"); + Check(string.Join(",", Directory.GetFiles(Path.Combine(dir, Stem)).Select(Path.GetFileName).Order(StringComparer.Ordinal)) + == "LICENSE,NOTICE,README.txt,sysml.library.full.json", "with its license and notice"); + Check(Directory.GetFileSystemEntries(dir).Length == 1, "no work files left behind"); + var again = new GitHub(new()); + Check(StandardLibrary.Json(Version, dir, again.Fetch) == path && again.Asked.Count == 0, + "the second call reads the cache without asking GitHub"); + } + { + var dir = TempDir(); + Rejects(() => StandardLibrary.Json(Version, dir, Release(good, "sha256:" + new string('0', 64)).Fetch), + "does not match the SHA-256", "a digest mismatch"); + Check(Directory.GetFileSystemEntries(dir).Length == 0, "a digest mismatch keeps nothing"); + var g = Release(good, ""); + Rejects(() => StandardLibrary.Json(Version, dir, g.Fetch), "states no SHA-256", "no digest"); + Check(g.Asked.Count == 1, "no digest, no download"); + Rejects(() => StandardLibrary.Json(Version, dir, new GitHub(new() { [Api] = "{\"assets\": []}"u8.ToArray() }).Fetch), + $"has no {Stem}.zip", "no such asset"); + Rejects(() => StandardLibrary.Json(Version, dir, new GitHub(new()).Fetch), "SYSML_LIBRARY_JSON", "offline: says what to do"); + var other = Zip(("other.txt", "x")); + Rejects(() => StandardLibrary.Json(Version, dir, Release(other, "sha256:" + Sha256(other)).Fetch), + "not the archive", "not the expected archive"); + } + { + var dir = TempDir(); + Directory.CreateDirectory(Path.Combine(dir, Stem)); + File.WriteAllText(Path.Combine(dir, Stem, "LICENSE"), "left over"); + StandardLibrary.Json(Version, dir, Release(good, "sha256:" + Sha256(good)).Fetch); + Check(File.ReadAllText(Path.Combine(dir, Stem, "LICENSE")) == "EPL", "an incomplete copy is replaced"); + } + try + { + var lib = StandardLibrary.Directory(); + Check(File.Exists(Path.Combine(lib, "LICENSE")), "StandardLibrary.Directory(): " + lib); + } + catch (LibraryUnavailableException e) + { + Check(e.Message.Contains("carries no standard library models"), + "StandardLibrary.Directory() without the models in the assembly says so"); + } + Console.WriteLine("ALL C# LIBRARY CHECKS PASSED"); + } +} diff --git a/csharp/OpenMBEE.SysML.Checks/Program.cs b/csharp/OpenMBEE.SysML.Checks/Program.cs index 9f82a97..b9148a5 100644 --- a/csharp/OpenMBEE.SysML.Checks/Program.cs +++ b/csharp/OpenMBEE.SysML.Checks/Program.cs @@ -9,6 +9,7 @@ Conformance.Run(Path.Combine(root, "metamodel.json")); RunSmoke(Path.Combine(root, "data", "bigger.full.json")); CheckPayloadLibrary(); +LibraryChecks.Run(); Console.WriteLine(); ToolkitChecks.RunIfAvailable(); Console.WriteLine("ALL C#-BACKEND SMOKE TESTS PASSED"); diff --git a/csharp/OpenMBEE.SysML/OpenMBEE.SysML.csproj b/csharp/OpenMBEE.SysML/OpenMBEE.SysML.csproj index 039919c..ac75159 100644 --- a/csharp/OpenMBEE.SysML/OpenMBEE.SysML.csproj +++ b/csharp/OpenMBEE.SysML/OpenMBEE.SysML.csproj @@ -25,4 +25,15 @@ + + + + + + + Apache-2.0 AND EPL-2.0 + + diff --git a/csharp/OpenMBEE.SysML/StandardLibrary.cs b/csharp/OpenMBEE.SysML/StandardLibrary.cs new file mode 100644 index 0000000..3e4f19a --- /dev/null +++ b/csharp/OpenMBEE.SysML/StandardLibrary.cs @@ -0,0 +1,217 @@ +// The KerML and SysML standard library, in the two forms the backends read. +// +// StandardLibrary.Directory() is the directory of the library's models this package carries, for +// the SysML Toolkit backend: ToolkitBackend.Open(ToolkitBackend.DefaultLibrary, paths, null, +// StandardLibrary.Directory()). A session reads a directory, so the first call copies the models out +// of the assembly into the user's cache. +// +// StandardLibrary.Json() is the same library as full-form interchange JSON, for the payload backend +// (PayloadLibrary). It is too large for the package: the first call downloads +// sysml_library-.zip from this version's GitHub release, checks it against the SHA-256 +// GitHub states for that file, and keeps the JSON in the user's cache (the same place the other SDK +// languages keep it), where later calls find it. Nothing is downloaded unless it is called. +// SYSML_LIBRARY_JSON names a copy of the JSON to use instead. +// +// Both are the library of the SysML v2 release the SDK was built with, licensed under the Eclipse +// Public License 2.0, not the SDK's Apache-2.0: see the LICENSE and NOTICE beside them. + +using System.IO.Compression; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace OpenMBEE.SysML; + +/// The standard library is not at hand: this package carries no models, or the JSON could +/// not be downloaded and checked. +public sealed class LibraryUnavailableException : SdkException +{ + public LibraryUnavailableException(string message) : base(message) { } +} + +public static class StandardLibrary +{ + const string Repository = "Open-MBEE/sysml-sdk"; + const string JsonVariable = "SYSML_LIBRARY_JSON"; + const string CacheVariable = "SYSML_CACHE_DIR"; + const string ResourcePrefix = "stdlib/"; + static readonly object Gate = new(); + + /// The directory of the standard library models this package carries, copied out into + /// the user's cache on the first call. + public static string Directory() + { + lock (Gate) + { + var assembly = typeof(StandardLibrary).Assembly; + var names = assembly.GetManifestResourceNames().Where(n => n.StartsWith(ResourcePrefix)).OrderBy(n => n, StringComparer.Ordinal).ToList(); + if (names.Count == 0) + throw new LibraryUnavailableException("this copy of the SDK carries no standard library models (the NuGet package " + + "does); pass the sysml.library directory of sysml_library-.zip from " + + $"https://github.com/{Repository}/releases as the library directory"); + var digest = Sha256(Encoding.UTF8.GetBytes(string.Join("\n", names)))[..16]; + var dir = Path.Combine(CacheRoot(), $"sysml-stdlib-{Version()}-{digest}"); + var complete = Path.Combine(dir, ".complete"); + if (File.Exists(complete)) return dir; + try + { + foreach (var name in names) + { + // MSBuild spells the directories of a resource's name with the separator of the + // machine that built the package. + var parts = name[ResourcePrefix.Length..].Split('/', '\\'); + var file = Path.Combine([dir, .. parts]); + System.IO.Directory.CreateDirectory(Path.GetDirectoryName(file)!); + using var input = assembly.GetManifestResourceStream(name)!; + using var output = File.Create(file); + input.CopyTo(output); + } + File.WriteAllText(complete, string.Join("\n", names)); + } + catch (IOException e) + { + throw new LibraryUnavailableException($"cannot copy the standard library models out to {dir}: {e.Message}"); + } + return dir; + } + } + + /// The standard library as full-form JSON, for PayloadLibrary: the file + /// SYSML_LIBRARY_JSON names, else the cached copy (, else + /// SYSML_CACHE_DIR, else the user's cache directory), downloaded from the GitHub release of + /// (default: this SDK's) on the first call and checked against the + /// SHA-256 GitHub states for it. (URL, Accept header) to bytes replaces + /// HTTPS, for a proxy or a test. + public static string Json(string? version = null, string? cacheDir = null, Func? fetch = null) + { + lock (Gate) + { + var named = Environment.GetEnvironmentVariable(JsonVariable); + if (!string.IsNullOrEmpty(named)) + { + if (!File.Exists(named)) + throw new LibraryUnavailableException($"{JsonVariable} names {named}, which is not a file"); + return named; + } + version ??= Version(); + fetch ??= Https; + var stem = $"sysml_library-{version}"; + var root = cacheDir ?? CacheRoot(); + var target = Path.Combine(root, stem); + var found = Path.Combine(target, "sysml.library.full.json"); + if (File.Exists(found)) return found; + + var page = $"https://github.com/{Repository}/releases/tag/v{version}"; + var byHand = $"download {stem}.zip from {page}, unpack it, and set {JsonVariable} to the sysml.library.full.json it holds"; + JsonElement release; + try + { + var document = JsonDocument.Parse(fetch($"https://api.github.com/repos/{Repository}/releases/tags/v{version}", + "application/vnd.github+json")); + release = document.RootElement; + } + catch (Exception e) when (e is IOException or HttpRequestException or JsonException or TaskCanceledException) + { + throw new LibraryUnavailableException($"cannot read the release v{version} of {Repository} ({e.Message}); {byHand}"); + } + JsonElement? asset = null; + if (release.ValueKind == JsonValueKind.Object && release.TryGetProperty("assets", out var assets) && assets.ValueKind == JsonValueKind.Array) + foreach (var a in assets.EnumerateArray()) + if (a.TryGetProperty("name", out var n) && n.GetString() == $"{stem}.zip") + asset = a; + if (asset is not { } chosen) + throw new LibraryUnavailableException($"the release v{version} of {Repository} has no {stem}.zip ({page})"); + var digest = chosen.TryGetProperty("digest", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString()! : ""; + if (!digest.StartsWith("sha256:")) + throw new LibraryUnavailableException($"GitHub states no SHA-256 for {stem}.zip, so it is not downloaded; {byHand}"); + var url = chosen.GetProperty("browser_download_url").GetString()!; + byte[] zip; + try + { + zip = fetch(url, "application/octet-stream"); + } + catch (Exception e) when (e is IOException or HttpRequestException or TaskCanceledException) + { + throw new LibraryUnavailableException($"cannot download {url} ({e.Message}); {byHand}"); + } + var sha256 = Sha256(zip); + var want = digest["sha256:".Length..]; + if (sha256 != want) + throw new LibraryUnavailableException($"{stem}.zip does not match the SHA-256 GitHub states for it ({sha256}, not {want}); nothing was kept"); + + string[] wanted = ["sysml.library.full.json", "LICENSE", "NOTICE", "README.txt"]; + System.IO.Directory.CreateDirectory(root); + var work = Path.Combine(root, $"{stem}-{Guid.NewGuid():N}"); + try + { + var staged = System.IO.Directory.CreateDirectory(Path.Combine(work, stem)).FullName; + try + { + using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read); + foreach (var name in wanted) + { + var entry = archive.GetEntry($"{stem}/{name}") + ?? throw new InvalidDataException($"no {stem}/{name}"); + entry.ExtractToFile(Path.Combine(staged, name)); + } + } + catch (InvalidDataException e) + { + throw new LibraryUnavailableException($"{stem}.zip is not the archive the SDK expects ({e.Message}); {byHand}"); + } + if (System.IO.Directory.Exists(target)) System.IO.Directory.Delete(target, true); // left incomplete earlier + System.IO.Directory.Move(staged, target); + } + catch (IOException e) + { + throw new LibraryUnavailableException($"cannot keep {stem} in {root}: {e.Message}"); + } + finally + { + if (System.IO.Directory.Exists(work)) System.IO.Directory.Delete(work, true); + } + return found; + } + } + + /// This SDK's version, from the assembly. + static string Version() + { + var v = typeof(StandardLibrary).Assembly.GetCustomAttribute()?.InformationalVersion + ?? throw new LibraryUnavailableException("cannot tell this SDK's version; pass the version"); + var plus = v.IndexOf('+'); // build metadata the SDK appends (+commit) + return plus < 0 ? v : v[..plus]; + } + + static string CacheRoot() + { + var env = Environment.GetEnvironmentVariable(CacheVariable); + if (!string.IsNullOrEmpty(env)) return env; + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (OperatingSystem.IsWindows()) + { + var local = Environment.GetEnvironmentVariable("LOCALAPPDATA"); + return Path.Combine(string.IsNullOrEmpty(local) ? Path.Combine(home, "AppData", "Local") : local, "sysml", "Cache"); + } + if (OperatingSystem.IsMacOS()) return Path.Combine(home, "Library", "Caches", "sysml"); + var xdg = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); + return Path.Combine(string.IsNullOrEmpty(xdg) ? Path.Combine(home, ".cache") : xdg, "sysml"); + } + + static string Sha256(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant(); + + static byte[] Https(string url, string accept) + { + using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.UserAgent.ParseAdd("sysml-sdk"); + request.Headers.Accept.ParseAdd(accept); + using var response = client.Send(request); + if (!response.IsSuccessStatusCode) throw new HttpRequestException($"HTTP {(int)response.StatusCode}"); + using var body = response.Content.ReadAsStream(); + using var buffer = new MemoryStream(); + body.CopyTo(buffer); + return buffer.ToArray(); + } +} diff --git a/docs/python-publishing.md b/docs/python-publishing.md new file mode 100644 index 0000000..f0fd8af --- /dev/null +++ b/docs/python-publishing.md @@ -0,0 +1,4 @@ +# Python publishing + +See [PUBLISHING.md](../PUBLISHING.md) for the PyPI and TestPyPI setup, +release steps, and installation checks for `Open-MBEE/sysml-sdk`. diff --git a/examples/cpp/README.md b/examples/cpp/README.md index 0c8cac2..853b4fe 100644 --- a/examples/cpp/README.md +++ b/examples/cpp/README.md @@ -20,15 +20,34 @@ A C++17 compiler. Download from the release: - `sysml-sdk-cpp-0.1.0.zip`, and put its `include/` on your include path. For the SysML Toolkit, its `lib//` holds the binding library for Windows x64, Linux x64, and macOS on Apple silicon and on Intel: copy the one for your platform (`sysmlv2_abi.dll`, `libsysmlv2_abi.so` or - `libsysmlv2_abi.dylib`) beside your executable; -- `sysml_library-0.1.0.zip`, the standard library. Unpack it anywhere: it holds `sysml.library/` - (the library's models, for the SysML Toolkit) and `sysml.library.full.json` (the same library as - JSON, for payloads). + `libsysmlv2_abi.dylib`) beside your executable. Its `sysml.library/` holds the standard library + models: copy that directory beside your executable too. ```sh g++ -std=c++17 -I sysml-sdk-cpp-0.1.0/include read_model.cpp -o read_model # add -ldl on Linux, -static with MinGW ``` +## First: the standard library + +Most models refer to the KerML and SysML standard library: `ScalarValues::Real`, `ISQ::mass`, and +implicitly `Parts::parts` and the like. Get it once, at the start of your program, in the form your +backend reads: + +```cpp +#include + +std::string library_dir = sysml::standard_library(); // the library's models, for the SysML Toolkit +std::string library_json = sysml::standard_library_json(); // the library as JSON, for payloads +``` + +- `standard_library()` finds the `sysml.library` directory beside your executable, or the one the + environment variable `SYSML_LIBRARY_DIR` names. +- `standard_library_json()` is the file `SYSML_LIBRARY_JSON` names, else the copy in your user cache + that the other SDK languages download (`SYSML_CACHE_DIR` moves it). C++ downloads nothing: get it + once with another SDK language, or download `sysml_library-0.1.0.zip` from the release, unpack it, + and set `SYSML_LIBRARY_JSON` to the `sysml.library.full.json` it holds. +- The library is under the Eclipse Public License 2.0: see the LICENSE and NOTICE beside it. + ## Read a model through the SysML Toolkit Copy this, and put in your file names: @@ -39,15 +58,16 @@ Copy this, and put in your file names: #include #include +#include #include using namespace sysml; int main() { Model model = Model::from_backend(ToolkitBackend::open( - {"model.sysml"}, // your model: one or more .sysml / .kerml files - {}, // or in-memory sources: {file name, text} pairs - "sysml.library")); // the standard library, from sysml_library-0.1.0.zip + {"model.sysml"}, // your model: one or more .sysml / .kerml files + {}, // or in-memory sources: {file name, text} pairs + standard_library())); // the standard library // ... your code here ... return 0; @@ -59,8 +79,7 @@ int main() { - To load the binding library from elsewhere, point the environment variable `SYSMLV2_ABI` at it, or name it in code: pass `std::make_shared(path)` as the last argument of `open`. -- Without the standard library the model loads faster, but its references into it - (`ScalarValues::Real`, `ISQ::mass`, and the implicit ones such as `Parts::parts`) do not resolve, +- Without the standard library the model loads faster, but its references into it do not resolve, and reading one throws `unresolved_reference`. ## Read a model from a JSON export @@ -78,6 +97,7 @@ v2 tools. Its references into the standard library resolve against the library J #include #include +#include using namespace sysml; @@ -90,7 +110,7 @@ static std::string read_file(const std::string& path) { } int main() { - auto library = PayloadLibrary::parse(read_file("sysml.library.full.json")); // read once, share it + auto library = PayloadLibrary::parse(read_file(standard_library_json())); // the standard library; read once, share it Model model = Model::from_full_json(read_file("model.json"), library); // your export // ... your code here ... @@ -196,7 +216,7 @@ tool does). `full_json` belongs to the backend, so keep it before handing it to ```cpp -auto backend = ToolkitBackend::open({"model.sysml"}, {}, "sysml.library"); +auto backend = ToolkitBackend::open({"model.sysml"}, {}, standard_library()); std::cout << backend->full_json().size() << " bytes of JSON\n"; // write it where you need it Model exported = Model::from_backend(std::move(backend)); // the same session, as a model ``` diff --git a/examples/csharp/README.md b/examples/csharp/README.md index bd5c35b..1b3e0ee 100644 --- a/examples/csharp/README.md +++ b/examples/csharp/README.md @@ -18,10 +18,30 @@ A model is read through one of two backends, with the same interfaces over both: - `OpenMBEE.SysML.0.1.0.nupkg`, and add it to your project from the folder that holds it: `dotnet add package OpenMBEE.SysML --version 0.1.0 --source `. It carries the binding library for Windows x64, Linux x64, and macOS on Apple silicon and on - Intel, and .NET puts the one for your platform beside your program; -- `sysml_library-0.1.0.zip`, the standard library. Unpack it anywhere: it holds `sysml.library/` - (the library's models, for the SysML Toolkit) and `sysml.library.full.json` (the same library as - JSON, for payloads). + Intel, and .NET puts the one for your platform beside your program. It also carries the standard + library models. + +## First: the standard library + +Most models refer to the KerML and SysML standard library: `ScalarValues::Real`, `ISQ::mass`, and +implicitly `Parts::parts` and the like. Get it once, at the start of your program, in the form your +backend reads: + +```csharp +var libraryDir = StandardLibrary.Directory(); // the library's models the package carries, for the SysML Toolkit +var libraryJson = StandardLibrary.Json(); // the library as JSON, for payloads +``` + +- `StandardLibrary.Directory()` copies the models out of the package into your user cache on its + first call (a session reads a directory). +- `StandardLibrary.Json()` downloads the JSON (about 11 MB) from this version's GitHub release on its + first call, checks it against the SHA-256 GitHub states for the file, and keeps it in your user + cache (`SYSML_CACHE_DIR` moves it); later calls, from any SDK language, read the cache. Nothing is + downloaded unless you call it. +- Without access to GitHub, download `sysml_library-0.1.0.zip` from the release, unpack it, and set + `SYSML_LIBRARY_JSON` to the `sysml.library.full.json` it holds. Its `sysml.library/` directory is + the same models the package carries. +- The library is under the Eclipse Public License 2.0: see the LICENSE and NOTICE beside it. ## Read a model through the SysML Toolkit @@ -35,7 +55,7 @@ using SpecType = OpenMBEE.SysML.Type; // the metaclass Type, beside System.T using var toolkit = ToolkitBackend.Open(ToolkitBackend.DefaultLibrary, // the package's binding library, or SYSMLV2_ABI's new[] { "model.sysml" }, // your model: one or more .sysml / .kerml files null, // or in-memory sources: file name to text - "sysml.library"); // the standard library, from sysml_library-0.1.0.zip + StandardLibrary.Directory()); // the standard library var model = new Model(toolkit); // ... your code here ... @@ -47,8 +67,7 @@ var model = new Model(toolkit); - To use another build of the binding library, point the environment variable `SYSMLV2_ABI` at it, or name it in code: `new ToolkitBackend.Library(path)` in place of `ToolkitBackend.DefaultLibrary`. -- Without the standard library the model loads faster, but its references into it - (`ScalarValues::Real`, `ISQ::mass`, and the implicit ones such as `Parts::parts`) do not resolve, +- Without the standard library the model loads faster, but its references into it do not resolve, and reading one throws `UnresolvedReferenceException`. ## Read a model from a JSON export @@ -63,7 +82,7 @@ v2 tools. Its references into the standard library resolve against the library J using OpenMBEE.SysML; using SpecType = OpenMBEE.SysML.Type; // the metaclass Type, beside System.Type -var library = PayloadLibrary.FromJson(File.ReadAllText("sysml.library.full.json")); // read once, share it +var library = PayloadLibrary.FromJson(File.ReadAllText(StandardLibrary.Json())); // the standard library; read once, share it var model = Model.FromFullJson(File.ReadAllText("model.json"), library); // your export // ... your code here ... diff --git a/examples/java/README.md b/examples/java/README.md index 3a080bd..6a7e512 100644 --- a/examples/java/README.md +++ b/examples/java/README.md @@ -19,10 +19,7 @@ JDK 21 or newer; the jar has no dependencies. Download from the release: - `sysml-sdk-0.1.0.jar`, to put on the class path. It carries the binding library for Windows x64, Linux x64, and macOS on Apple silicon and on Intel, and copies the one for your - platform into the temporary directory on first use; -- `sysml_library-0.1.0.zip`, the standard library. Unpack it anywhere: it holds `sysml.library/` - (the library's models, for the SysML Toolkit) and `sysml.library.full.json` (the same library as - JSON, for payloads). + platform into the temporary directory on first use. It also carries the standard library models. Compile and run against the jar. On JDK 21 the foreign function API is a preview, so add `--enable-preview` (and `--release 21` to `javac`); from JDK 22 on, leave both out. @@ -33,6 +30,28 @@ javac --enable-preview --release 21 -cp sysml-sdk-0.1.0.jar ReadModel.java java --enable-preview --enable-native-access=ALL-UNNAMED -cp sysml-sdk-0.1.0.jar:. ReadModel # ; instead of : on Windows ``` +## First: the standard library + +Most models refer to the KerML and SysML standard library: `ScalarValues::Real`, `ISQ::mass`, and +implicitly `Parts::parts` and the like. Get it once, at the start of your program, in the form your +backend reads: + +```java +Path libraryDir = StandardLibrary.directory(); // the library's models the jar carries, for the SysML Toolkit +Path libraryJson = StandardLibrary.json(); // the library as JSON, for payloads +``` + +- `StandardLibrary.directory()` copies the models out of the jar into your user cache on its first + call (a session reads a directory). +- `StandardLibrary.json()` downloads the JSON (about 11 MB) from this version's GitHub release on its + first call, checks it against the SHA-256 GitHub states for the file, and keeps it in your user + cache (`SYSML_CACHE_DIR` moves it); later calls, from any SDK language, read the cache. Nothing is + downloaded unless you call it. +- Without access to GitHub, download `sysml_library-0.1.0.zip` from the release, unpack it, and set + `SYSML_LIBRARY_JSON` to the `sysml.library.full.json` it holds. Its `sysml.library/` directory is + the same models the jar carries. +- The library is under the Eclipse Public License 2.0: see the LICENSE and NOTICE beside it. + ## Read a model through the SysML Toolkit Copy this, and put in your file names: @@ -51,7 +70,7 @@ public class ReadModel { try (ToolkitBackend toolkit = ToolkitBackend.open(ToolkitBackend.library(), // the jar's binding library, or SYSMLV2_ABI's List.of("model.sysml"), // your model: one or more .sysml / .kerml files null, // or in-memory sources: a Map from file name to text - "sysml.library")) { // the standard library, from sysml_library-0.1.0.zip + StandardLibrary.directory().toString())) { // the standard library Model model = new Model(toolkit); // ... your code here ... @@ -65,8 +84,7 @@ public class ReadModel { - To use another build of the binding library, point the environment variable `SYSMLV2_ABI` at it, or name it in code: `new ToolkitBackend.Library(Path.of(...))` in place of `ToolkitBackend.library()`. -- Without the standard library the model loads faster, but its references into it - (`ScalarValues::Real`, `ISQ::mass`, and the implicit ones such as `Parts::parts`) do not resolve, +- Without the standard library the model loads faster, but its references into it do not resolve, and reading one throws `UnresolvedReferenceException`. ## Read a model from a JSON export @@ -87,7 +105,7 @@ import org.openmbee.sysml.classes.*; public class ReadExport { public static void main(String[] args) throws Exception { - PayloadLibrary library = PayloadLibrary.fromJson(Files.readString(Path.of("sysml.library.full.json"))); // read once, share it + PayloadLibrary library = PayloadLibrary.fromJson(Files.readString(StandardLibrary.json())); // the standard library; read once, share it Model model = Model.fromFullJson(Files.readString(Path.of("model.json")), library); // your export // ... your code here ... diff --git a/examples/python/README.md b/examples/python/README.md index d6233cc..2251cdc 100644 --- a/examples/python/README.md +++ b/examples/python/README.md @@ -13,16 +13,39 @@ A model is read through one of two backends, with the same classes over both: ## Install -Python 3.10 or newer. Download the wheel for your platform from the release and install it: +Python 3.10 or newer: ```sh -pip install sysml-0.1.0-py3-none-win_amd64.whl # or manylinux_2_28_x86_64, macosx_11_0_arm64, macosx_10_12_x86_64 +pip install sysml ``` -The wheel carries the binding library; nothing else is needed. The same release has -`sysml_library-0.1.0.zip`, the standard library. Unpack it anywhere: it holds `sysml.library/` -(the library's models, for the SysML Toolkit) and `sysml.library.full.json` (the same library as -JSON, for payloads). +The wheels (Windows x64, Linux x64 with glibc 2.28 or later, macOS on Apple silicon and on Intel) +carry the binding library and the standard library models; nothing else is needed. The release has +the same wheels to install from a file (`pip install sysml-0.1.0-py3-none-win_amd64.whl`, or +`manylinux_2_28_x86_64`, `macosx_11_0_arm64`, `macosx_10_12_x86_64`). On other platforms pip installs +the source distribution, which reads payloads only. + +## First: the standard library + +Most models refer to the KerML and SysML standard library: `ScalarValues::Real`, `ISQ::mass`, and +implicitly `Parts::parts` and the like. Get it once, at the start of your program, in the form your +backend reads: + +```python +from sysml import standard_library, standard_library_json + +library_dir = standard_library() # the library's models the wheel carries, for the SysML Toolkit +library_json = standard_library_json() # the library as JSON, for payloads +``` + +- `standard_library_json()` downloads the JSON (about 11 MB) from this version's GitHub release on + its first call, checks it against the SHA-256 GitHub states for the file, and keeps it in your + user cache (`SYSML_CACHE_DIR` moves it); later calls, from any SDK language, read the cache. + Nothing is downloaded unless you call it. +- Without access to GitHub, download `sysml_library-0.1.0.zip` from the release, unpack it, and set + `SYSML_LIBRARY_JSON` to the `sysml.library.full.json` it holds. Its `sysml.library/` directory is + the same models the wheel carries. +- The library is under the Eclipse Public License 2.0: see the LICENSE and NOTICE beside it. ## Read a model through the SysML Toolkit @@ -30,22 +53,21 @@ Copy this, and put in your file names: ```python -from sysml import Model, NotImplementedInToolkit, UnresolvedReference +from sysml import Model, NotImplementedInToolkit, UnresolvedReference, standard_library from sysml.classes import * model = Model.from_toolkit( - "model.sysml", # your model: one or more .sysml / .kerml files - library_dir="sysml.library", # the standard library, from sysml_library-0.1.0.zip + "model.sysml", # your model: one or more .sysml / .kerml files + library_dir=standard_library(), # the standard library ) # ... your code here ... ``` -- A model in several files: pass them all, `Model.from_toolkit("a.sysml", "b.sysml")`. +- A model in several files: pass them all, `Model.from_toolkit("a.sysml", "b.sysml", library_dir=...)`. - Text you already hold in memory: `Model.from_toolkit(sources={"model.sysml": text}, library_dir=...)`. - Without `library_dir` the model loads faster, but its references into the standard library - (`ScalarValues::Real`, `ISQ::mass`, and the implicit ones such as `Parts::parts`) do not resolve, - and reading one raises `UnresolvedReference`. + do not resolve, and reading one raises `UnresolvedReference`. - `SYSMLV2_ABI=` makes the SDK use another build of the binding library than the wheel's. @@ -60,11 +82,11 @@ v2 tools. Its references into the standard library resolve against the library J ```python import json -from sysml import Model, NotImplementedInToolkit, PayloadLibrary, UnresolvedReference +from sysml import Model, NotImplementedInToolkit, PayloadLibrary, UnresolvedReference, standard_library_json from sysml.classes import * -with open("sysml.library.full.json", encoding="utf-8") as f: - library = PayloadLibrary(json.load(f)) # from sysml_library-0.1.0.zip; read once, share it +with open(standard_library_json(), encoding="utf-8") as f: + library = PayloadLibrary(json.load(f)) # the standard library; read once, share it with open("model.json", encoding="utf-8") as f: model = Model.from_full_json(json.load(f), library=library) # your export @@ -164,7 +186,7 @@ tool does): ```python from sysml.toolkit import ToolkitBackend -backend = ToolkitBackend.open(["model.sysml"], library_dir="sysml.library") +backend = ToolkitBackend.open(["model.sysml"], library_dir=standard_library()) with open("model.full.json", "w", encoding="utf-8") as f: f.write(backend.full_json()) exported = Model(backend) # the same session, as a model diff --git a/examples/typescript/README.md b/examples/typescript/README.md index 0ab714e..f57accb 100644 --- a/examples/typescript/README.md +++ b/examples/typescript/README.md @@ -19,13 +19,35 @@ Node 20 or newer, or a browser. Download the package from the release and instal npm install ./sysml-0.1.0.tgz ``` -The package carries the SysML Toolkit as WebAssembly; nothing else is needed. The same release has -`sysml_library-0.1.0.zip`, the standard library. Unpack it anywhere: it holds `sysml.library/` -(the library's models, for the SysML Toolkit) and `sysml.library.full.json` (the same library as -JSON, for payloads). Type declarations are included. The code below is plain JavaScript (`.mjs`); -in TypeScript, narrow what `resolve` returns (an element or `null`) before using it, with +The package carries the SysML Toolkit as WebAssembly and the standard library models; nothing else +is needed. Type declarations are included. The code below is plain JavaScript (`.mjs`); in +TypeScript, narrow what `resolve` returns (an element or `null`) before using it, with `is(el, "PartDefinition")` or a non-null assertion. +## First: the standard library + +Most models refer to the KerML and SysML standard library: `ScalarValues::Real`, `ISQ::mass`, and +implicitly `Parts::parts` and the like. Get it once, at the start of your program, in the form your +backend reads (Node): + +```js +import { standardLibrary, standardLibraryJson } from "sysml"; + +const libraryDir = await standardLibrary(); // the library's models the package carries, for the SysML Toolkit +const libraryJson = await standardLibraryJson(); // the library as JSON, for payloads +``` + +- `standardLibraryJson()` downloads the JSON (about 11 MB) from this version's GitHub release on its + first call, checks it against the SHA-256 GitHub states for the file, and keeps it in your user + cache (`SYSML_CACHE_DIR` moves it); later calls, from any SDK language, read the cache. Nothing is + downloaded unless you call it. +- Without access to GitHub, download `sysml_library-0.1.0.zip` from the release, unpack it, and set + `SYSML_LIBRARY_JSON` to the `sysml.library.full.json` it holds. Its `sysml.library/` directory is + the same models the package carries. +- In a browser there is no file system: serve the library's files and pass them as + `librarySources`, and a library JSON you host yourself to `PayloadLibrary`. +- The library is under the Eclipse Public License 2.0: see the LICENSE and NOTICE beside it. + ## Read a model through the SysML Toolkit Copy this, and put in your file names: @@ -33,12 +55,12 @@ Copy this, and put in your file names: ```js import { readFileSync, writeFileSync } from "node:fs"; -import { ToolkitBackend, Model, NotImplementedInToolkit, UnresolvedReference, elementsOfType, is } from "sysml"; +import { ToolkitBackend, Model, NotImplementedInToolkit, UnresolvedReference, elementsOfType, is, standardLibrary } from "sysml"; const files = ["model.sysml"]; // your model: one or more .sysml / .kerml files const model = new Model(await ToolkitBackend.open({ sources: Object.fromEntries(files.map((f) => [f, readFileSync(f, "utf8")])), - libraryDir: "sysml.library", // the standard library, from sysml_library-0.1.0.zip + libraryDir: await standardLibrary(), // the standard library })); // ... your code here ... @@ -49,8 +71,7 @@ const model = new Model(await ToolkitBackend.open({ directories, to its text) instead of `libraryDir`, and the WebAssembly module as `library: await ToolkitLibrary.load(await fetch(url))`, where `url` serves `sysmlv2_abi.wasm` from the package. - Without the library the model loads faster, but its references into the standard library - (`ScalarValues::Real`, `ISQ::mass`, and the implicit ones such as `Parts::parts`) do not resolve, - and reading one throws `UnresolvedReference`. + do not resolve, and reading one throws `UnresolvedReference`. ## Read a model from a JSON export @@ -62,10 +83,10 @@ v2 tools. Its references into the standard library resolve against the library J ```js import { readFileSync } from "node:fs"; -import { Model, NotImplementedInToolkit, PayloadLibrary, UnresolvedReference, elementsOfType, is } from "sysml"; +import { Model, NotImplementedInToolkit, PayloadLibrary, UnresolvedReference, elementsOfType, is, standardLibraryJson } from "sysml"; const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); -const library = new PayloadLibrary(readJson("sysml.library.full.json")); // read once, share it +const library = new PayloadLibrary(readJson(await standardLibraryJson())); // the standard library; read once, share it const model = Model.fromFullJson(readJson("model.json"), { library }); // your export // ... your code here ... @@ -169,7 +190,7 @@ tool does): ```js -const backend = await ToolkitBackend.open({ sources: { "model.sysml": readFileSync("model.sysml", "utf8") }, libraryDir: "sysml.library" }); +const backend = await ToolkitBackend.open({ sources: { "model.sysml": readFileSync("model.sysml", "utf8") }, libraryDir: await standardLibrary() }); writeFileSync("model.full.json", backend.fullJson()); const exported = new Model(backend); // the same session, as a model ``` diff --git a/java/src/org/openmbee/sysml/LibraryUnavailableException.java b/java/src/org/openmbee/sysml/LibraryUnavailableException.java new file mode 100644 index 0000000..6ceb68c --- /dev/null +++ b/java/src/org/openmbee/sysml/LibraryUnavailableException.java @@ -0,0 +1,9 @@ +package org.openmbee.sysml; + +/** The standard library is not at hand: this jar carries no models, or the JSON could not be + * downloaded and checked. */ +public class LibraryUnavailableException extends SdkException { + public LibraryUnavailableException(String message) { + super(message); + } +} diff --git a/java/src/org/openmbee/sysml/StandardLibrary.java b/java/src/org/openmbee/sysml/StandardLibrary.java new file mode 100644 index 0000000..ebd81bb --- /dev/null +++ b/java/src/org/openmbee/sysml/StandardLibrary.java @@ -0,0 +1,231 @@ +package org.openmbee.sysml; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * The KerML and SysML standard library, in the two forms the backends read. + * + *

{@link #directory()} is the directory of the library's models this jar carries, for the SysML + * Toolkit backend: {@code ToolkitBackend.open(ToolkitBackend.library(), paths, null, + * StandardLibrary.directory().toString())}. A session reads a directory, so the first call copies + * the models out of the jar into the user's cache. + * + *

{@link #json()} is the same library as full-form interchange JSON, for the payload backend + * ({@link PayloadLibrary}). It is too large for the jar: the first call downloads + * {@code sysml_library-.zip} from this version's GitHub release, checks it against the + * SHA-256 GitHub states for that file, and keeps the JSON in the user's cache (the same place the + * other SDK languages keep it), where later calls find it. Nothing is downloaded unless it is called. + * {@code SYSML_LIBRARY_JSON} names a copy of the JSON to use instead. + * + *

Both are the library of the SysML v2 release the SDK was built with, licensed under the Eclipse + * Public License 2.0, not the SDK's Apache-2.0: see the LICENSE and NOTICE beside them. + */ +public final class StandardLibrary { + private StandardLibrary() {} + + static final String REPOSITORY = "Open-MBEE/sysml-sdk"; + static final String JSON_VARIABLE = "SYSML_LIBRARY_JSON"; + static final String CACHE_VARIABLE = "SYSML_CACHE_DIR"; + + /** Fetches a URL's bytes; a substitute (a proxy, a test) can be passed to {@link #json(String, Path, Fetcher)}. */ + @FunctionalInterface + public interface Fetcher { + byte[] get(String url, String accept) throws IOException; + } + + /** The directory of the standard library models this jar carries, copied out into the user's + * cache on the first call. */ + public static synchronized Path directory() { + String index = resource("stdlib/INDEX"); + if (index == null) + throw new LibraryUnavailableException("this copy of the SDK carries no standard library models (the " + + "release's jar does); pass the sysml.library directory of sysml_library-.zip from " + + "https://github.com/" + REPOSITORY + "/releases as the library directory"); + String digest = sha256(index.getBytes(StandardCharsets.UTF_8)).substring(0, 16); + Path dir = cacheRoot().resolve("sysml-stdlib-" + version() + "-" + digest); + Path complete = dir.resolve(".complete"); + if (Files.isRegularFile(complete)) return dir; + try { + for (String rel : index.split("\n")) { + if (rel.isBlank()) continue; + try (InputStream in = StandardLibrary.class.getResourceAsStream("stdlib/" + rel)) { + if (in == null) throw new IOException("the jar lists stdlib/" + rel + " but carries no such file"); + Path file = dir.resolve(rel); + Files.createDirectories(file.getParent()); + Files.copy(in, file, StandardCopyOption.REPLACE_EXISTING); + } + } + Files.writeString(complete, index); + } catch (IOException e) { + throw new LibraryUnavailableException("cannot copy the standard library models out to " + dir + ": " + e); + } + return dir; + } + + /** The standard library as full-form JSON, for {@link PayloadLibrary}: the file + * {@code SYSML_LIBRARY_JSON} names, else the cached copy, downloaded from this version's GitHub + * release on the first call and checked against the SHA-256 GitHub states for it. */ + public static Path json() { + return json(null, null, null); + } + + /** {@link #json()} for another {@code version} (null: this SDK's), cache directory (null: + * {@code SYSML_CACHE_DIR}, else the user's cache directory) and way of fetching (null: HTTPS). */ + public static synchronized Path json(String version, Path cacheDir, Fetcher fetch) { + String named = System.getenv(JSON_VARIABLE); + if (named != null && !named.isEmpty()) { + if (!Files.isRegularFile(Path.of(named))) + throw new LibraryUnavailableException(JSON_VARIABLE + " names " + named + ", which is not a file"); + return Path.of(named); + } + if (version == null) version = version(); + if (fetch == null) fetch = StandardLibrary::https; + String stem = "sysml_library-" + version; + Path root = cacheDir != null ? cacheDir : cacheRoot(); + Path target = root.resolve(stem); + Path found = target.resolve("sysml.library.full.json"); + if (Files.isRegularFile(found)) return found; + + String page = "https://github.com/" + REPOSITORY + "/releases/tag/v" + version; + String byHand = "download " + stem + ".zip from " + page + ", unpack it, and set " + JSON_VARIABLE + + " to the sysml.library.full.json it holds"; + Map release; + try { + byte[] body = fetch.get("https://api.github.com/repos/" + REPOSITORY + "/releases/tags/v" + version, + "application/vnd.github+json"); + release = (Map) Json.parse(new String(body, StandardCharsets.UTF_8)); + } catch (IOException | RuntimeException e) { + throw new LibraryUnavailableException("cannot read the release v" + version + " of " + REPOSITORY + + " (" + e.getMessage() + "); " + byHand); + } + Map asset = null; + if (release.get("assets") instanceof List assets) + for (Object a : assets) + if (a instanceof Map m && (stem + ".zip").equals(m.get("name"))) asset = m; + if (asset == null) + throw new LibraryUnavailableException("the release v" + version + " of " + REPOSITORY + " has no " + + stem + ".zip (" + page + ")"); + String digest = asset.get("digest") instanceof String s ? s : ""; + if (!digest.startsWith("sha256:")) + throw new LibraryUnavailableException("GitHub states no SHA-256 for " + stem + ".zip, so it is not downloaded; " + byHand); + String url = String.valueOf(asset.get("browser_download_url")); + byte[] zip; + try { + zip = fetch.get(url, "application/octet-stream"); + } catch (IOException | RuntimeException e) { + throw new LibraryUnavailableException("cannot download " + url + " (" + e.getMessage() + "); " + byHand); + } + String sha256 = sha256(zip), want = digest.substring("sha256:".length()); + if (!sha256.equals(want)) + throw new LibraryUnavailableException(stem + ".zip does not match the SHA-256 GitHub states for it (" + + sha256 + ", not " + want + "); nothing was kept"); + + Set wanted = Set.of("sysml.library.full.json", "LICENSE", "NOTICE", "README.txt"); + Path work = null; + try { + Files.createDirectories(root); + work = Files.createTempDirectory(root, stem + "-"); + Path staged = Files.createDirectories(work.resolve(stem)); + int extracted = 0; + try (ZipInputStream in = new ZipInputStream(new ByteArrayInputStream(zip))) { + for (ZipEntry e; (e = in.getNextEntry()) != null; ) { + String name = e.getName(); + if (name.startsWith(stem + "/") && wanted.contains(name.substring(stem.length() + 1))) { + Files.copy(in, staged.resolve(name.substring(stem.length() + 1))); + extracted++; + } + } + } + if (extracted != wanted.size()) + throw new LibraryUnavailableException(stem + ".zip is not the archive the SDK expects (it lacks some of " + + wanted + "); " + byHand); + if (Files.exists(target)) deleteTree(target); // a copy left incomplete by an earlier call + Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + throw new LibraryUnavailableException("cannot keep " + stem + " in " + root + ": " + e); + } finally { + if (work != null) try { deleteTree(work); } catch (IOException ignored) {} + } + return found; + } + + /** This SDK's version, from the jar's manifest. */ + static String version() { + String v = StandardLibrary.class.getPackage().getImplementationVersion(); + if (v == null) + throw new LibraryUnavailableException("cannot tell this SDK's version outside its jar; pass the version"); + return v; + } + + static Path cacheRoot() { + String env = System.getenv(CACHE_VARIABLE); + if (env != null && !env.isEmpty()) return Path.of(env); + String os = System.getProperty("os.name").toLowerCase(); + Path home = Path.of(System.getProperty("user.home")); + if (os.contains("win")) { + String local = System.getenv("LOCALAPPDATA"); + return (local != null && !local.isEmpty() ? Path.of(local) : home.resolve("AppData").resolve("Local")) + .resolve("sysml").resolve("Cache"); + } + if (os.contains("mac")) return home.resolve("Library").resolve("Caches").resolve("sysml"); + String xdg = System.getenv("XDG_CACHE_HOME"); + return (xdg != null && !xdg.isEmpty() ? Path.of(xdg) : home.resolve(".cache")).resolve("sysml"); + } + + private static String resource(String name) { + try (InputStream in = StandardLibrary.class.getResourceAsStream(name)) { + return in == null ? null : new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + return null; + } + } + + private static String sha256(byte[] data) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(data)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + private static byte[] https(String url, String accept) throws IOException { + HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(30)).build(); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofMinutes(5)) + .header("User-Agent", "sysml-sdk").header("Accept", accept).build(); + try { + HttpResponse r = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (r.statusCode() != 200) throw new IOException("HTTP " + r.statusCode()); + return r.body(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("interrupted"); + } + } + + private static void deleteTree(Path p) throws IOException { + if (!Files.exists(p)) return; + try (var walk = Files.walk(p)) { + for (Path q : walk.sorted(java.util.Comparator.reverseOrder()).toList()) Files.delete(q); + } + } +} diff --git a/java/src/org/openmbee/sysml/checks/LibraryChecks.java b/java/src/org/openmbee/sysml/checks/LibraryChecks.java new file mode 100644 index 0000000..de4ca7e --- /dev/null +++ b/java/src/org/openmbee/sysml/checks/LibraryChecks.java @@ -0,0 +1,140 @@ +// The standard library helpers, offline: GitHub is replaced by a fake fetcher that serves the +// release metadata and the library archive from memory. +package org.openmbee.sysml.checks; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.openmbee.sysml.LibraryUnavailableException; +import org.openmbee.sysml.StandardLibrary; + +public final class LibraryChecks { + static final String VERSION = "9.9.9", STEM = "sysml_library-" + VERSION; + static final String API = "https://api.github.com/repos/Open-MBEE/sysml-sdk/releases/tags/v" + VERSION; + static final String ASSET = "https://github.com/Open-MBEE/sysml-sdk/releases/download/v" + VERSION + "/" + STEM + ".zip"; + static int failures = 0; + + static void check(boolean cond, String what) { + System.out.println((cond ? "ok: " : "FAIL: ") + what); + if (!cond) failures++; + } + + static void rejects(Runnable body, String pattern, String what) { + try { + body.run(); + check(false, what + " (no error)"); + } catch (LibraryUnavailableException e) { + check(e.getMessage().contains(pattern), what + ": " + e.getMessage().substring(0, Math.min(90, e.getMessage().length()))); + } + } + + static byte[] zip(String[][] entries) throws IOException { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + try (ZipOutputStream z = new ZipOutputStream(buf)) { + for (String[] e : entries) { + z.putNextEntry(new ZipEntry(e[0])); + z.write(e[1].getBytes(StandardCharsets.UTF_8)); + z.closeEntry(); + } + } + return buf.toByteArray(); + } + + static byte[] archive() throws IOException { + return zip(new String[][] {{STEM + "/sysml.library.full.json", "[{\"@id\": \"lib\"}]"}, {STEM + "/LICENSE", "EPL"}, + {STEM + "/NOTICE", "notice"}, {STEM + "/README.txt", "readme"}}); + } + + static String sha256(byte[] data) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(data)); + } + + /** A fake GitHub: URL to bytes; every URL asked is recorded. */ + static final class GitHub implements StandardLibrary.Fetcher { + final Map served; + final List asked = new ArrayList<>(); + GitHub(Map served) { this.served = served; } + public byte[] get(String url, String accept) throws IOException { + asked.add(url); + byte[] b = served.get(url); + if (b == null) throw new IOException("no route to host"); + return b; + } + } + + static GitHub release(byte[] data, String digest) { + String api = "{\"assets\": [{\"name\": \"" + STEM + ".zip\", \"browser_download_url\": \"" + ASSET + "\", \"digest\": \"" + digest + "\"}]}"; + return new GitHub(Map.of(API, api.getBytes(StandardCharsets.UTF_8), ASSET, data)); + } + + public static void main(String[] args) throws Exception { + if (System.getenv("SYSML_LIBRARY_JSON") != null) { + System.out.println("skipped: SYSML_LIBRARY_JSON is set"); + return; + } + byte[] good = archive(); + { + Path dir = Files.createTempDirectory("sysml-library-"); + GitHub g = release(good, "sha256:" + sha256(good)); + Path path = StandardLibrary.json(VERSION, dir, g); + check(path.equals(dir.resolve(STEM).resolve("sysml.library.full.json")), "the JSON lands in the cache"); + check(Files.readString(path).equals("[{\"@id\": \"lib\"}]"), "the JSON is the archive's"); + try (var s = Files.list(dir.resolve(STEM))) { + check(s.map(p -> p.getFileName().toString()).sorted().toList() + .equals(List.of("LICENSE", "NOTICE", "README.txt", "sysml.library.full.json")), "with its license and notice"); + } + try (var s = Files.list(dir)) { check(s.count() == 1, "no work files left behind"); } + GitHub again = new GitHub(Map.of()); + check(StandardLibrary.json(VERSION, dir, again).equals(path) && again.asked.isEmpty(), + "the second call reads the cache without asking GitHub"); + } + { + Path dir = Files.createTempDirectory("sysml-library-"); + rejects(() -> StandardLibrary.json(VERSION, dir, release(good, "sha256:" + "0".repeat(64))), + "does not match the SHA-256", "a digest mismatch"); + try (var s = Files.list(dir)) { check(s.count() == 0, "a digest mismatch keeps nothing"); } + GitHub g = release(good, ""); + rejects(() -> StandardLibrary.json(VERSION, dir, g), "states no SHA-256", "no digest"); + check(g.asked.size() == 1, "no digest, no download"); + rejects(() -> StandardLibrary.json(VERSION, dir, new GitHub(Map.of(API, "{\"assets\": []}".getBytes()))), + "has no " + STEM + ".zip", "no such asset"); + rejects(() -> StandardLibrary.json(VERSION, dir, new GitHub(Map.of())), "SYSML_LIBRARY_JSON", "offline: says what to do"); + byte[] other = zip(new String[][] {{"other.txt", "x"}}); + rejects(() -> StandardLibrary.json(VERSION, dir, release(other, "sha256:" + sha256OrThrow(other))), + "not the archive", "not the expected archive"); + } + { + Path dir = Files.createTempDirectory("sysml-library-"); + Files.createDirectories(dir.resolve(STEM)); + Files.writeString(dir.resolve(STEM).resolve("LICENSE"), "left over"); + StandardLibrary.json(VERSION, dir, release(good, "sha256:" + sha256(good))); + check(Files.readString(dir.resolve(STEM).resolve("LICENSE")).equals("EPL"), "an incomplete copy is replaced"); + } + try { + Path lib = StandardLibrary.directory(); + check(Files.isRegularFile(lib.resolve("LICENSE")), "StandardLibrary.directory(): " + lib); + } catch (LibraryUnavailableException e) { + check(e.getMessage().contains("carries no standard library models"), + "StandardLibrary.directory() without the models in the class path says so"); + } + if (failures > 0) { + System.out.println(failures + " LIBRARY CHECK(S) FAILED"); + System.exit(1); + } + System.out.println("ALL JAVA LIBRARY CHECKS PASSED"); + } + + static String sha256OrThrow(byte[] data) { + try { return sha256(data); } catch (Exception e) { throw new IllegalStateException(e); } + } +} diff --git a/pyproject.toml b/pyproject.toml index 9eedbbf..9003de1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,15 @@ name = "sysml" version = "0.1.0" description = "Spec-shaped SysML v2 SDK: generated metaclass surface over the SysML Toolkit, or over interchange payloads" requires-python = ">=3.10" -readme = "README.md" +readme = "python/PYPI.md" license = "Apache-2.0" license-files = ["LICENSE", "NOTICE"] +[project.urls] +Homepage = "https://github.com/Open-MBEE/sysml-sdk" +Repository = "https://github.com/Open-MBEE/sysml-sdk" +Issues = "https://github.com/Open-MBEE/sysml-sdk/issues" + [project.optional-dependencies] dev = ["pytest>=7"] @@ -18,9 +23,10 @@ dev = ["pytest>=7"] packages = ["sysml"] package-dir = {"" = "python"} -# A platform wheel carries the binding library in _native/ (tools/package.py puts it there). +# A platform wheel carries the binding library in _native/ and the standard library models in +# stdlib/ (tools/package.py puts them there; the globs skip dot-files, hence the project files). [tool.setuptools.package-data] -sysml = ["*.pyi", "_native/*"] +sysml = ["*.pyi", "_native/*", "stdlib/**/*", "stdlib/**/.project.json", "stdlib/**/.meta.json"] [tool.pytest.ini_options] testpaths = ["python/tests"] diff --git a/python/PYPI.md b/python/PYPI.md new file mode 100644 index 0000000..7d2e4dd --- /dev/null +++ b/python/PYPI.md @@ -0,0 +1,69 @@ +# sysml + +Read SysML v2 and KerML models from Python. Every metaclass of the specification is a Python class, +and every property and operation keeps its specification name (`ownedFeature`, `qualifiedName`, +`effectiveName()`). A model is read through one of two backends, with the same classes over both: + +- **the OpenMBEE SysML Toolkit** reads your `.sysml` and `.kerml` files, resolves names and computes + derived properties; +- **a payload** is a model exported as full-form interchange JSON by any SysML v2 tool. + +```sh +pip install sysml +``` + +Python 3.10 or newer. The wheels for Windows x64, Linux x64 (glibc 2.28 or later), and macOS on Apple +silicon (11 or later) and on Intel (10.12 or later) carry the SysML Toolkit's binding library and the +standard library models. On other platforms pip installs the source distribution, which reads +payloads only. + +## First: the standard library + +Most models refer to the KerML and SysML standard library (`ScalarValues::Real`, `ISQ::mass`, and +implicitly `Parts::parts` and the like). Get it once, at the start of your program: + +```python +from sysml import standard_library, standard_library_json + +library_dir = standard_library() # the library models the wheel carries, for the SysML Toolkit +library_json = standard_library_json() # the library as JSON, for payloads +``` + +`standard_library_json()` downloads the JSON (about 11 MB) from this version's GitHub release on its +first call, checks it against the SHA-256 GitHub states for the file, and keeps it in your user cache; +later calls read the cache. Where GitHub cannot be reached, download `sysml_library-.zip` +from the release yourself and set `SYSML_LIBRARY_JSON` to the `sysml.library.full.json` it holds. + +## Read a model through the SysML Toolkit + +```python +from sysml import Model, standard_library + +model = Model.from_toolkit("model.sysml", library_dir=standard_library()) +vehicle = model.resolve("Vehicles::Vehicle") +print(vehicle.qualifiedName, [f.declaredName for f in vehicle.ownedFeature]) +``` + +## Read a payload + +```python +import json +from sysml import Model, PayloadLibrary, standard_library_json + +with open(standard_library_json(), encoding="utf-8") as f: + library = PayloadLibrary(json.load(f)) # read once, share it +with open("model.json", encoding="utf-8") as f: + model = Model.from_full_json(json.load(f), library=library) +``` + +## More + +- The Python guide: +- The SDK, its other languages (JavaScript/TypeScript, Java, C#, C++) and its documentation: + +- What is new, and what the SysML Toolkit does not answer yet: + + +The SDK is licensed under the Apache License 2.0. The standard library models the wheels carry, and the +library JSON, are licensed under the Eclipse Public License 2.0: see `sysml/stdlib/LICENSE` and +`sysml/stdlib/NOTICE` in the installed package. diff --git a/python/sysml/__init__.py b/python/sysml/__init__.py index 6207f53..6476b2d 100644 --- a/python/sysml/__init__.py +++ b/python/sysml/__init__.py @@ -2,3 +2,4 @@ PayloadLibrary, ReadOnly, UnresolvedReference) from .classes import * # noqa: F401,F403 from .classes import REGISTRY +from .library import LibraryUnavailable, standard_library, standard_library_json diff --git a/python/sysml/library.py b/python/sysml/library.py new file mode 100644 index 0000000..b7f17d8 --- /dev/null +++ b/python/sysml/library.py @@ -0,0 +1,151 @@ +"""The KerML and SysML standard library, in the two forms the backends read. + +`standard_library()` is the directory of the library's models that a platform wheel carries, for the +SysML Toolkit backend: ``Model.from_toolkit("model.sysml", library_dir=standard_library())``. + +`standard_library_json()` is the same library as full-form interchange JSON, for the payload backend +(`PayloadLibrary`). It is too large for a wheel: the first call downloads ``sysml_library-.zip`` +from this version's GitHub release, checks it against the SHA-256 that GitHub states for that file, +and keeps the JSON in the user's cache, where later calls find it. Nothing is downloaded unless the +function is called. ``SYSML_LIBRARY_JSON`` names a copy of the JSON to use instead, for machines +without access to GitHub. + +Both are the library of the SysML v2 release the SDK was built with, licensed under the Eclipse +Public License 2.0, not the SDK's Apache-2.0: see the LICENSE and NOTICE beside them. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import sys +import tempfile +import urllib.request +import zipfile +from pathlib import Path + +from .base import SdkError + +REPOSITORY = "Open-MBEE/sysml-sdk" +JSON_VARIABLE = "SYSML_LIBRARY_JSON" +CACHE_VARIABLE = "SYSML_CACHE_DIR" +_PACKAGE = Path(__file__).resolve().parent +_TIMEOUT = 60 + + +class LibraryUnavailable(SdkError): + """The standard library is not at hand: this installation does not carry it, or it could not be + downloaded and checked.""" + + +def standard_library() -> Path: + """The directory of the standard library models a platform wheel carries, to pass as + `library_dir`. The source distribution and a repository checkout carry none.""" + directory = _PACKAGE / "stdlib" + if not directory.is_dir() or next((p for p in directory.rglob("*") if p.suffix in (".kerml", ".sysml")), + None) is None: + raise LibraryUnavailable( + "this installation of the SDK carries no standard library models (only the platform " + "wheels do); pass the sysml.library directory of sysml_library-.zip from " + f"https://github.com/{REPOSITORY}/releases as library_dir") + return directory + + +def standard_library_json(*, version: str | None = None, cache_dir: str | os.PathLike | None = None) -> Path: + """The standard library as full-form JSON, for `PayloadLibrary`: the file ``SYSML_LIBRARY_JSON`` + names, else the copy in the cache (``cache_dir``, else ``SYSML_CACHE_DIR``, else the user's cache + directory), downloaded from the GitHub release of ``version`` (default: this SDK's) on the first + call and checked against the SHA-256 GitHub states for it.""" + named = os.environ.get(JSON_VARIABLE) + if named: + if not Path(named).is_file(): + raise LibraryUnavailable(f"{JSON_VARIABLE} names {named}, which is not a file") + return Path(named) + version = version or _version() + stem = f"sysml_library-{version}" + target = Path(cache_dir) if cache_dir is not None else _cache_root() + target = target / stem + found = target / "sysml.library.full.json" + if found.is_file(): + return found + + page = f"https://github.com/{REPOSITORY}/releases/tag/v{version}" + by_hand = (f"download {stem}.zip from {page}, unpack it, and set {JSON_VARIABLE} to the " + "sysml.library.full.json it holds") + try: + release = _get_json(f"https://api.github.com/repos/{REPOSITORY}/releases/tags/v{version}") + except (OSError, ValueError) as e: + raise LibraryUnavailable(f"cannot read the release v{version} of {REPOSITORY} ({e}); {by_hand}") from e + asset = next((a for a in release.get("assets", []) if a.get("name") == f"{stem}.zip"), None) + if asset is None: + raise LibraryUnavailable(f"the release v{version} of {REPOSITORY} has no {stem}.zip ({page})") + digest = asset.get("digest") or "" + if not digest.startswith("sha256:"): + raise LibraryUnavailable(f"GitHub states no SHA-256 for {stem}.zip, so it is not downloaded; {by_hand}") + + target.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=target.parent) as work: + archive = Path(work) / f"{stem}.zip" + try: + sha256 = _download(asset["browser_download_url"], archive) + except OSError as e: + raise LibraryUnavailable(f"cannot download {asset['browser_download_url']} ({e}); {by_hand}") from e + if sha256 != digest.removeprefix("sha256:"): + raise LibraryUnavailable(f"{stem}.zip does not match the SHA-256 GitHub states for it " + f"({sha256}, not {digest.removeprefix('sha256:')}); nothing was kept") + try: + with zipfile.ZipFile(archive) as z: + for name in ("sysml.library.full.json", "LICENSE", "NOTICE", "README.txt"): + z.extract(f"{stem}/{name}", work) + except (KeyError, zipfile.BadZipFile) as e: + raise LibraryUnavailable(f"{stem}.zip is not the archive the SDK expects ({e}); {by_hand}") from e + if target.exists(): + shutil.rmtree(target) # a copy left incomplete by an earlier call + os.replace(Path(work) / stem, target) + return found + + +def _version() -> str: + """This SDK's version: its installed metadata, else (in a repository checkout) pyproject.toml's.""" + from importlib import metadata + try: + return metadata.version(__package__) + except metadata.PackageNotFoundError: + pyproject = _PACKAGE.parent.parent / "pyproject.toml" + m = re.search(r'^version = "([^"]+)"', pyproject.read_text(encoding="utf-8"), re.M) + if m is None: + raise LibraryUnavailable("cannot tell this SDK's version; pass version=") + return m.group(1) + + +def _cache_root() -> Path: + if os.environ.get(CACHE_VARIABLE): + return Path(os.environ[CACHE_VARIABLE]) + if sys.platform == "win32": + return Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "sysml" / "Cache" + if sys.platform == "darwin": + return Path.home() / "Library" / "Caches" / "sysml" + return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "sysml" + + +def _request(url: str, accept: str) -> urllib.request.Request: + return urllib.request.Request(url, headers={"User-Agent": f"{__package__}-sdk", "Accept": accept}) + + +def _get_json(url: str) -> dict: + with urllib.request.urlopen(_request(url, "application/vnd.github+json"), timeout=_TIMEOUT) as response: + return json.load(response) + + +def _download(url: str, path: Path) -> str: + """Write the file at `url` to `path`; its SHA-256, as hex.""" + h = hashlib.sha256() + with urllib.request.urlopen(_request(url, "application/octet-stream"), timeout=_TIMEOUT) as response, \ + open(path, "wb") as out: + while chunk := response.read(1 << 20): + h.update(chunk) + out.write(chunk) + return h.hexdigest() diff --git a/python/sysml/toolkit.py b/python/sysml/toolkit.py index 12f7440..4f54e19 100644 --- a/python/sysml/toolkit.py +++ b/python/sysml/toolkit.py @@ -83,6 +83,16 @@ class Library: def __init__(self, path: Optional[str] = None): self.path = path or _default_library_path() + if path is None and not os.path.exists(self.path): + # The source distribution, on a platform no wheel is built for, carries no library. + raise FileNotFoundError( + errno.ENOENT, + "no binding library for the SysML Toolkit backend: this installation of the SDK " + f"carries none for this platform ({sys.platform}). Platform wheels carry one for Windows " + "x64, Linux x64 (glibc 2.28 or later), and macOS on Apple silicon (11 or later) and on " + "Intel (10.12 or later). Elsewhere, set SYSMLV2_ABI to a sysmlv2_abi library built for " + "this platform (abi/README.md). Reading interchange JSON (Model.from_full_json) needs " + "no binding library") self.lib = C.CDLL(self.path) lib = self.lib lib.sysmlv2_free.argtypes = [C.c_void_p] diff --git a/python/tests/test_library.py b/python/tests/test_library.py new file mode 100644 index 0000000..5211bb3 --- /dev/null +++ b/python/tests/test_library.py @@ -0,0 +1,139 @@ +"""The standard library helpers, offline: GitHub is replaced by a fake that serves the release +metadata and the library archive from memory.""" + +import hashlib +import io +import json +import urllib.error +import zipfile + +import pytest + +import sysml.library as L +from sysml import LibraryUnavailable, standard_library, standard_library_json + +VERSION = "9.9.9" +STEM = f"sysml_library-{VERSION}" +ASSET_URL = f"https://github.com/{L.REPOSITORY}/releases/download/v{VERSION}/{STEM}.zip" +API_URL = f"https://api.github.com/repos/{L.REPOSITORY}/releases/tags/v{VERSION}" + + +def archive(json_text: str = '[{"@id": "lib"}]') -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as z: + for name, text in (("sysml.library.full.json", json_text), ("LICENSE", "EPL"), ("NOTICE", "notice"), + ("README.txt", "readme")): + z.writestr(f"{STEM}/{name}", text) + return buf.getvalue() + + +@pytest.fixture +def github(monkeypatch): + """A fake GitHub: `served` maps URL to bytes (or an exception to raise); `asked` lists the URLs.""" + state = {"served": {}, "asked": []} + + def urlopen(request, timeout=None): + url = request.full_url + state["asked"].append(url) + answer = state["served"].get(url, urllib.error.URLError("no route to host")) + if isinstance(answer, Exception): + raise answer + return io.BytesIO(answer) + + monkeypatch.setattr(L.urllib.request, "urlopen", urlopen) + monkeypatch.delenv(L.JSON_VARIABLE, raising=False) + return state + + +def publish(github, data: bytes, digest: str | None = None) -> None: + asset = {"name": f"{STEM}.zip", "browser_download_url": ASSET_URL} + asset["digest"] = digest if digest is not None else "sha256:" + hashlib.sha256(data).hexdigest() + github["served"][API_URL] = json.dumps({"assets": [asset]}).encode() + github["served"][ASSET_URL] = data + + +def test_download_checks_and_caches(github, tmp_path): + publish(github, archive()) + path = standard_library_json(version=VERSION, cache_dir=tmp_path) + assert path == tmp_path / STEM / "sysml.library.full.json" + assert json.loads(path.read_text(encoding="utf-8")) == [{"@id": "lib"}] + assert sorted(p.name for p in path.parent.iterdir()) == ["LICENSE", "NOTICE", "README.txt", + "sysml.library.full.json"] + assert sorted(p.name for p in tmp_path.iterdir()) == [STEM] # no work files left behind + github["served"].clear() # the second call never asks GitHub + assert standard_library_json(version=VERSION, cache_dir=tmp_path) == path + assert github["asked"] == [API_URL, ASSET_URL] + + +def test_a_digest_mismatch_keeps_nothing(github, tmp_path): + publish(github, archive(), digest="sha256:" + "0" * 64) + with pytest.raises(LibraryUnavailable, match="does not match the SHA-256"): + standard_library_json(version=VERSION, cache_dir=tmp_path) + assert list(tmp_path.iterdir()) == [] + + +def test_no_digest_no_download(github, tmp_path): + publish(github, archive(), digest="") + with pytest.raises(LibraryUnavailable, match="states no SHA-256"): + standard_library_json(version=VERSION, cache_dir=tmp_path) + assert github["asked"] == [API_URL] + + +def test_no_such_asset(github, tmp_path): + github["served"][API_URL] = json.dumps({"assets": [{"name": "other.zip"}]}).encode() + with pytest.raises(LibraryUnavailable, match=f"has no {STEM}.zip"): + standard_library_json(version=VERSION, cache_dir=tmp_path) + + +def test_offline_says_what_to_do(github, tmp_path): + with pytest.raises(LibraryUnavailable) as e: + standard_library_json(version=VERSION, cache_dir=tmp_path) + assert L.JSON_VARIABLE in str(e.value) and f"releases/tag/v{VERSION}" in str(e.value) + + +def test_not_the_expected_archive(github, tmp_path): + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as z: + z.writestr("something/else.txt", "x") + publish(github, buf.getvalue()) + with pytest.raises(LibraryUnavailable, match="not the archive"): + standard_library_json(version=VERSION, cache_dir=tmp_path) + + +def test_an_incomplete_copy_is_replaced(github, tmp_path): + (tmp_path / STEM).mkdir() + (tmp_path / STEM / "LICENSE").write_text("left over", encoding="utf-8") + publish(github, archive()) + path = standard_library_json(version=VERSION, cache_dir=tmp_path) + assert path.is_file() and (path.parent / "LICENSE").read_text(encoding="utf-8") == "EPL" + + +def test_the_variable_names_a_copy(github, tmp_path, monkeypatch): + copy = tmp_path / "library.json" + copy.write_text("[]", encoding="utf-8") + monkeypatch.setenv(L.JSON_VARIABLE, str(copy)) + assert standard_library_json() == copy + monkeypatch.setenv(L.JSON_VARIABLE, str(tmp_path / "missing.json")) + with pytest.raises(LibraryUnavailable, match="not a file"): + standard_library_json() + assert github["asked"] == [] + + +def test_the_cache_directory_variable(github, tmp_path, monkeypatch): + monkeypatch.setenv(L.CACHE_VARIABLE, str(tmp_path)) + publish(github, archive()) + assert standard_library_json(version=VERSION) == tmp_path / STEM / "sysml.library.full.json" + + +def test_the_models_need_a_wheel(monkeypatch, tmp_path): + monkeypatch.setattr(L, "_PACKAGE", tmp_path) # a checkout or the sdist: no stdlib/ + with pytest.raises(LibraryUnavailable, match="only the platform wheels"): + standard_library() + (tmp_path / "stdlib" / "Kernel Libraries").mkdir(parents=True) + (tmp_path / "stdlib" / "Kernel Libraries" / "Base.kerml").write_text("standard library package Base;", + encoding="utf-8") + assert standard_library() == tmp_path / "stdlib" + + +def test_the_version_comes_from_the_package(): + assert L._version() == L._version().strip() and L._version()[0].isdigit() diff --git a/skills/sysml-sdk-cpp/SKILL.md b/skills/sysml-sdk-cpp/SKILL.md index 22872b3..733e65e 100644 --- a/skills/sysml-sdk-cpp/SKILL.md +++ b/skills/sysml-sdk-cpp/SKILL.md @@ -32,8 +32,24 @@ g++ -std=c++17 -I sysml-sdk-cpp-0.1.0/include main.cpp -o main # add -ldl on L executable, where `ToolkitBackend::open` finds it. To load it from elsewhere, set `SYSMLV2_ABI` to it, or name it in code: pass `std::make_shared(path)` as the last argument of `ToolkitBackend::open`. -- The release's `sysml_library-0.1.0.zip` is the standard library: `sysml.library/` (the models, - for the SysML Toolkit) and `sysml.library.full.json` (for payloads). +- The archive's `sysml.library/` holds the standard library models: copy that directory beside your + executable too, or name it in `SYSML_LIBRARY_DIR`. + +**Initialize first: get the standard library.** Nearly every model refers to it (`ScalarValues::Real`, +`ISQ::mass`, and implicitly `Parts::parts`, `Items::items`, ...). Do this once, at the start of the +program, before loading any model: + +```cpp +#include + +std::string library_dir = sysml::standard_library(); // sysml.library beside the executable, or SYSML_LIBRARY_DIR +std::string library_json = sysml::standard_library_json(); // the library as JSON, for a payload's PayloadLibrary +``` + +C++ downloads nothing: `standard_library_json()` is the file `SYSML_LIBRARY_JSON` names, else the +copy in the user cache that the other SDK languages download; otherwise it throws +`library_unavailable` with instructions (download the release's `sysml_library-0.1.0.zip` and set +`SYSML_LIBRARY_JSON` to its `sysml.library.full.json`). Call only the one the chosen backend needs. Costs: `classes.g.hpp` is about 4 MB, so a translation unit that includes it takes tens of seconds to compile: keep the SDK in one translation unit (or a precompiled header). Opening a model with the @@ -45,12 +61,14 @@ status; the SysML Toolkit's reason (the missing path, a parse error) is printed ## Loading a model `` includes the runtime (``, which alone has no -metaclasses): include it, and `` for the SysML Toolkit. +metaclasses): include it, `` for the standard library helpers, and +`` for the SysML Toolkit. Through the SysML Toolkit: ```cpp #include // the runtime and every metaclass (always include it) +#include // the standard library helpers #include // the SysML Toolkit backend using namespace sysml; @@ -58,7 +76,7 @@ int main() { Model model = Model::from_backend(ToolkitBackend::open( {"model.sysml"}, // your files {}, // or in-memory sources: {{"m.sysml", text}} - "sysml.library")); // the standard library directory + standard_library())); // the standard library, from the initialization // ... your code here ... } ``` @@ -69,6 +87,7 @@ From a payload (a JSON export you have): #include #include #include +#include using namespace sysml; static std::string read_file(const std::string& path) { @@ -79,7 +98,7 @@ static std::string read_file(const std::string& path) { } int main() { - auto library = PayloadLibrary::parse(read_file("sysml.library.full.json")); // once; a shared_ptr to share + auto library = PayloadLibrary::parse(read_file(standard_library_json())); // once; a shared_ptr to share Model model = Model::from_full_json(read_file("model.json"), library); // ... your code here ... } @@ -479,8 +498,8 @@ does not vouch for. Say so in your answer when the result depends on them. `full the backend, so take it before handing the backend to a model: ```cpp -auto library = PayloadLibrary::parse(read_file("sysml.library.full.json")); // once; read_file as above -auto backend = ToolkitBackend::open({"model.sysml"}, {}, "sysml.library"); +auto library = PayloadLibrary::parse(read_file(standard_library_json())); // once; read_file as above +auto backend = ToolkitBackend::open({"model.sysml"}, {}, standard_library()); Model exported = Model::from_full_json(backend->full_json(), library); // same element ids as the SysML Toolkit's Model model = Model::from_backend(std::move(backend)); // the same session ``` diff --git a/skills/sysml-sdk-csharp/SKILL.md b/skills/sysml-sdk-csharp/SKILL.md index 63d20c0..bd684a4 100644 --- a/skills/sysml-sdk-csharp/SKILL.md +++ b/skills/sysml-sdk-csharp/SKILL.md @@ -41,8 +41,22 @@ a `nuget.config` beside the project: `ToolkitBackend.DefaultLibrary` finds it. To use another build, set `SYSMLV2_ABI` to it, or name it in code with `new ToolkitBackend.Library(path)` in place of `ToolkitBackend.DefaultLibrary` (one per process; it is not disposable). -- The release's `sysml_library-0.1.0.zip` is the standard library: `sysml.library/` (the models, - for the SysML Toolkit) and `sysml.library.full.json` (for payloads). +- The package also carries the standard library models. + +**Initialize first: get the standard library.** Nearly every model refers to it (`ScalarValues::Real`, +`ISQ::mass`, and implicitly `Parts::parts`, `Items::items`, ...). Do this once, at the start of the +program, before loading any model: + +```csharp +var libraryDir = StandardLibrary.Directory(); // the models the package carries (copied out to the user cache once) +var libraryJson = StandardLibrary.Json(); // the library as JSON, for a payload's PayloadLibrary +``` + +`StandardLibrary.Json()` downloads the JSON from the SDK's GitHub release on its first call (checked +against the SHA-256 GitHub states, kept in the user cache; later calls read the cache) and throws +`LibraryUnavailableException` with instructions when it cannot. Offline, `SYSML_LIBRARY_JSON` names a +copy (the `sysml.library.full.json` of the release's `sysml_library-0.1.0.zip`). Call only the one +the chosen backend needs. Costs: opening a model with the standard library takes one to three seconds; the SysML Toolkit's JSON export of a model takes a few seconds; the library JSON is about 160 MB and takes a few seconds @@ -61,7 +75,7 @@ using SpecType = OpenMBEE.SysML.Type; // the metaclass Type, beside System. using var toolkit = ToolkitBackend.Open(ToolkitBackend.DefaultLibrary, // the package's binding library, or SYSMLV2_ABI's new[] { "model.sysml" }, // your files; Array.Empty() with sources null, // or in-memory sources: new Dictionary { ["m.sysml"] = text } - "sysml.library"); // the standard library directory + libraryDir); // the standard library, from the initialization var model = new Model(toolkit); // the session closes when `toolkit` is disposed ``` @@ -71,7 +85,7 @@ From a payload (a JSON export you have): using OpenMBEE.SysML; using SpecType = OpenMBEE.SysML.Type; -var library = PayloadLibrary.FromJson(File.ReadAllText("sysml.library.full.json")); // once, share it +var library = PayloadLibrary.FromJson(File.ReadAllText(libraryJson)); // once, share it var model = Model.FromFullJson(File.ReadAllText("model.json"), library); ``` @@ -396,7 +410,7 @@ value for every property, including the ones the checked reader refuses, which t does not vouch for. Say so in your answer when the result depends on them. ```csharp -var library = PayloadLibrary.FromJson(File.ReadAllText("sysml.library.full.json")); // once +var library = PayloadLibrary.FromJson(File.ReadAllText(StandardLibrary.Json())); // once var exported = Model.FromFullJson(toolkit.FullJson(), library); // same element ids as the SysML Toolkit's ``` diff --git a/skills/sysml-sdk-java/SKILL.md b/skills/sysml-sdk-java/SKILL.md index 9e30b4b..4b55546 100644 --- a/skills/sysml-sdk-java/SKILL.md +++ b/skills/sysml-sdk-java/SKILL.md @@ -36,8 +36,22 @@ value; read around them as described below, or report them. silicon and on Intel, and copies the one for the running platform into the temporary directory on first use. To use another build, set `SYSMLV2_ABI` to it, or name it in code with `new ToolkitBackend.Library(Path.of(...))` in place of `ToolkitBackend.library()`. -- The release's `sysml_library-0.1.0.zip` is the standard library: `sysml.library/` (the models, - for the SysML Toolkit) and `sysml.library.full.json` (for payloads). +- The jar also carries the standard library models. + +**Initialize first: get the standard library.** Nearly every model refers to it (`ScalarValues::Real`, +`ISQ::mass`, and implicitly `Parts::parts`, `Items::items`, ...). Do this once, at the start of the +program, before loading any model: + +```java +Path libraryDir = StandardLibrary.directory(); // the models the jar carries (copied out to the user cache once) +Path libraryJson = StandardLibrary.json(); // the library as JSON, for a payload's PayloadLibrary +``` + +`StandardLibrary.json()` downloads the JSON from the SDK's GitHub release on its first call (checked +against the SHA-256 GitHub states, kept in the user cache; later calls read the cache) and throws +`LibraryUnavailableException` with instructions when it cannot. Offline, `SYSML_LIBRARY_JSON` names a +copy (the `sysml.library.full.json` of the release's `sysml_library-0.1.0.zip`). Call only the one +the chosen backend needs. Costs: opening a model with the standard library takes one to three seconds; the SysML Toolkit's JSON export of a model takes a few seconds; the library JSON is about 160 MB and needs `-Xmx2g` to @@ -58,7 +72,7 @@ import org.openmbee.sysml.classes.*; // the metaclasses try (ToolkitBackend toolkit = ToolkitBackend.open(ToolkitBackend.library(), // the jar's binding library, or SYSMLV2_ABI's List.of("model.sysml"), // your files; List.of() with sources null, // or in-memory sources: Map.of("model.sysml", text) - "sysml.library")) { // the standard library directory + libraryDir.toString())) { // the standard library, from the initialization Model model = new Model(toolkit); // ... your code here ... } @@ -67,7 +81,7 @@ try (ToolkitBackend toolkit = ToolkitBackend.open(ToolkitBackend.library(), // From a payload (a JSON export you have; run with `-Xmx2g`): ```java -PayloadLibrary library = PayloadLibrary.fromJson(Files.readString(Path.of("sysml.library.full.json"))); // once, share it +PayloadLibrary library = PayloadLibrary.fromJson(Files.readString(libraryJson)); // once, share it Model model = Model.fromFullJson(Files.readString(Path.of("model.json")), library); ``` @@ -421,7 +435,7 @@ value for every property, including the ones the checked reader refuses, which t does not vouch for. Say so in your answer when the result depends on them. Run with `-Xmx2g`. ```java -PayloadLibrary library = PayloadLibrary.fromJson(Files.readString(Path.of("sysml.library.full.json"))); // once +PayloadLibrary library = PayloadLibrary.fromJson(Files.readString(StandardLibrary.json())); // once Model exported = Model.fromFullJson(toolkit.fullJson(), library); // same element ids as the SysML Toolkit's ``` diff --git a/skills/sysml-sdk-python/SKILL.md b/skills/sysml-sdk-python/SKILL.md index 7c2529f..59fb5e8 100644 --- a/skills/sysml-sdk-python/SKILL.md +++ b/skills/sysml-sdk-python/SKILL.md @@ -21,12 +21,28 @@ described below, or report them. ## Setup ```sh -pip install sysml-0.1.0-py3-none-.whl # the wheel carries the binding library +pip install sysml # the wheel carries the binding library and the standard library models ``` -The release's `sysml_library-0.1.0.zip` is the standard library: `sysml.library/` (the models, -for the SysML Toolkit's `library_dir`) and `sysml.library.full.json` (for payloads). `SYSMLV2_ABI` -makes the SDK use another build of the binding library than the wheel's. +`SYSMLV2_ABI` makes the SDK use another build of the binding library than the wheel's. On a platform +without a wheel, pip installs the source distribution, which reads payloads only. + +**Initialize first: get the standard library.** Nearly every model refers to it (`ScalarValues::Real`, +`ISQ::mass`, and implicitly `Parts::parts`, `Items::items`, ...). Do this once, at the start of the +program, before loading any model: + +```python +from sysml import standard_library, standard_library_json + +library_dir = standard_library() # the models the wheel carries: library_dir of the SysML Toolkit +library_json = standard_library_json() # the library as JSON, for a payload's PayloadLibrary +``` + +`standard_library_json()` downloads the JSON from the SDK's GitHub release on its first call (checked +against the SHA-256 GitHub states, kept in the user cache; later calls read the cache) and raises +`LibraryUnavailable` with instructions when it cannot. Offline, `SYSML_LIBRARY_JSON` names a copy +(the `sysml.library.full.json` of the release's `sysml_library-0.1.0.zip`). Call only the one the +chosen backend needs. Costs: opening a model with the standard library takes one to three seconds; the library JSON is about 160 MB and takes a few seconds to read (read it once); the SysML Toolkit's JSON export of a @@ -42,9 +58,9 @@ Through the SysML Toolkit (pick one of the three calls): from sysml import Model, NotImplementedInToolkit, UnresolvedReference from sysml.classes import * # every metaclass: PartUsage, Definition, FeatureTyping, ... -model = Model.from_toolkit("model.sysml", library_dir="sysml.library") # a file + standard library -model = Model.from_toolkit("a.sysml", "b.sysml", library_dir="sysml.library") # several files -model = Model.from_toolkit(sources={"model.sysml": text}, library_dir="sysml.library") # in-memory text +model = Model.from_toolkit("model.sysml", library_dir=library_dir) # a file + standard library +model = Model.from_toolkit("a.sysml", "b.sysml", library_dir=library_dir) # several files +model = Model.from_toolkit(sources={"model.sysml": text}, library_dir=library_dir) # in-memory text ``` From a payload (a JSON export you have): @@ -54,7 +70,7 @@ import json from sysml import Model, PayloadLibrary, UnresolvedReference from sysml.classes import * -library = PayloadLibrary(json.load(open("sysml.library.full.json", encoding="utf-8"))) # once, share it +library = PayloadLibrary(json.load(open(library_json, encoding="utf-8"))) # once, share it model = Model.from_full_json(json.load(open("model.json", encoding="utf-8")), library=library) ``` @@ -382,11 +398,11 @@ does not vouch for. Say so in your answer when the result depends on them. ```python import json -from sysml import Model, PayloadLibrary +from sysml import Model, PayloadLibrary, standard_library, standard_library_json from sysml.toolkit import ToolkitBackend -backend = ToolkitBackend.open(["model.sysml"], library_dir="sysml.library") # paths: str or Path -library = PayloadLibrary(json.load(open("sysml.library.full.json", encoding="utf-8"))) # once +backend = ToolkitBackend.open(["model.sysml"], library_dir=standard_library()) # paths: str or Path +library = PayloadLibrary(json.load(open(standard_library_json(), encoding="utf-8"))) # once payload = Model.from_full_json(json.loads(backend.full_json()), library=library) # same element ids toolkit = Model(backend) # the same session, for operations and checked reads ``` diff --git a/skills/sysml-sdk-typescript/SKILL.md b/skills/sysml-sdk-typescript/SKILL.md index 484348f..fbf977b 100644 --- a/skills/sysml-sdk-typescript/SKILL.md +++ b/skills/sysml-sdk-typescript/SKILL.md @@ -21,14 +21,30 @@ described below, or report them. ## Setup ```sh -npm install ./sysml-0.1.0.tgz # carries the WebAssembly module +npm install ./sysml-0.1.0.tgz # carries the WebAssembly module and the standard library models ``` -ES modules only: name files `.mjs`, or set `"type": "module"` in `package.json`. Node 20+. The -release's `sysml_library-0.1.0.zip` is the standard library: `sysml.library/` (the models, for the -SysML Toolkit's `libraryDir`) and `sysml.library.full.json` (for payloads). `SYSMLV2_ABI_WASM` makes -the SDK use another build of the module than the package's; in a browser, pass -`library: await ToolkitLibrary.load(await fetch(url))` to `ToolkitBackend.open`. +ES modules only: name files `.mjs`, or set `"type": "module"` in `package.json`. Node 20+. +`SYSMLV2_ABI_WASM` makes the SDK use another build of the module than the package's; in a browser, +pass `library: await ToolkitLibrary.load(await fetch(url))` to `ToolkitBackend.open`. + +**Initialize first: get the standard library.** Nearly every model refers to it (`ScalarValues::Real`, +`ISQ::mass`, and implicitly `Parts::parts`, `Items::items`, ...). Do this once, at the start of the +program, before loading any model (Node): + +```js +import { standardLibrary, standardLibraryJson } from "sysml"; + +const libraryDir = await standardLibrary(); // the models the package carries: libraryDir of the SysML Toolkit +const libraryJson = await standardLibraryJson(); // the library as JSON, for a payload's PayloadLibrary +``` + +`standardLibraryJson()` downloads the JSON from the SDK's GitHub release on its first call (checked +against the SHA-256 GitHub states, kept in the user cache; later calls read the cache) and throws +`LibraryUnavailable` with instructions when it cannot. Offline, `SYSML_LIBRARY_JSON` names a copy +(the `sysml.library.full.json` of the release's `sysml_library-0.1.0.zip`). Call only the one the +chosen backend needs. In a browser neither works (no file system): fetch the library's files for +`librarySources`, and a library JSON you host. Costs: opening a model with the standard library takes two to three seconds; the SysML Toolkit's JSON export (`fullJson()`) takes ten to thirty seconds under WebAssembly even for a small model, and @@ -46,7 +62,7 @@ import { ToolkitBackend, Model, NotImplementedInToolkit, UnresolvedReference, el const files = ["model.sysml"]; // your files const sources = Object.fromEntries(files.map((f) => [f, readFileSync(f, "utf8")])); // file name -> text -const toolkit = await ToolkitBackend.open({ sources, libraryDir: "sysml.library" }); // Node; relative to the cwd +const toolkit = await ToolkitBackend.open({ sources, libraryDir }); // Node; libraryDir from the initialization const model = new Model(toolkit); // toolkit.close() when done // In a browser: ToolkitBackend.open({ sources, librarySources }), librarySources being the library's // .sysml/.kerml files as { "": text } (fetch each file; the @@ -59,7 +75,7 @@ From a payload (a JSON export you have): import { readFileSync } from "node:fs"; import { Model, PayloadLibrary, UnresolvedReference, elementsOfType, is } from "sysml"; -const library = new PayloadLibrary(JSON.parse(readFileSync("sysml.library.full.json", "utf8"))); // once, share it +const library = new PayloadLibrary(JSON.parse(readFileSync(libraryJson, "utf8"))); // once, share it const model = Model.fromFullJson(JSON.parse(readFileSync("model.json", "utf8")), { library }); ``` @@ -359,10 +375,10 @@ value for every property, including the ones the checked reader refuses, which t does not vouch for. Say so in your answer when the result depends on them. ```js -import { PayloadLibrary } from "sysml"; // besides the SysML Toolkit block's imports +import { PayloadLibrary, standardLibraryJson } from "sysml"; // besides the SysML Toolkit block's imports const exportText = toolkit.fullJson(); // first: slow once the library JSON is in memory -const library = new PayloadLibrary(JSON.parse(readFileSync("sysml.library.full.json", "utf8"))); +const library = new PayloadLibrary(JSON.parse(readFileSync(await standardLibraryJson(), "utf8"))); const exported = Model.fromFullJson(JSON.parse(exportText), { library }); // same element ids ``` diff --git a/tools/check_packages.py b/tools/check_packages.py index d3cdd06..89aa956 100644 --- a/tools/check_packages.py +++ b/tools/check_packages.py @@ -167,9 +167,19 @@ def check_python(dist: Path, work: Path, library_dir: Path | None) -> None: print("payload owns", ", ".join(f.declaredName for f in p.ownedFeature), "|", ", ".join(g.superclassifier.qualifiedName for g in p.ownedSubclassification)) s = Model.from_toolkit({MODEL!r}).resolve("Vehicles::SportsCar") print("toolkit owns", ", ".join(f.declaredName for f in s.ownedFeature), "|", ", ".join(g.superclassifier.qualifiedName for g in s.ownedSubclassification), "|", s.effectiveName()) +from {PYTHON_MODULE} import standard_library +from importlib.metadata import distribution +lib = standard_library() +assert (lib / "LICENSE").is_file() and (lib / "NOTICE").is_file(), lib +x = Model.from_toolkit(sources={{"x.sysml": "package P {{ attribute x : ScalarValues::Real; }}"}}, library_dir=lib).resolve("P::x") +print("bundled library types x as", x.ownedTyping[0].type.qualifiedName) +d = distribution({S["python_dist"]!r}) +print("wheel:", d.read_text("WHEEL").split("Root-Is-Purelib: ")[1].split()[0], "|", d.metadata["License-Expression"]) """], work, env) expect(out, f"payload owns {SPORTS_CAR}", "Python wheel, payload with the library JSON") expect(out, f"toolkit owns {SPORTS_CAR} | SportsCar", "Python wheel, SysML Toolkit read with the packaged library") + expect(out, "bundled library types x as ScalarValues::Real", "Python wheel, the standard library models it carries") + expect(out, "wheel: false | Apache-2.0 AND EPL-2.0", "Python wheel, a platform wheel with the library's license") guide_files(work, library_dir) for kind, program in guide_programs("python").items(): (work / f"guide_{kind}.py").write_text(program, encoding="utf-8") @@ -194,11 +204,22 @@ def check_npm(dist: Path, work: Path, library_dir: Path | None) -> None: const text = readFileSync({MODEL!r}, "utf8"); const s = new Model(await ToolkitBackend.open({{ sources: {{ "vehicle.sysml": text }} }})).resolve("Vehicles::SportsCar"); console.log("toolkit owns", s.ownedFeature.map((f) => f.declaredName).join(", "), "|", s.ownedSubclassification.map((g) => g.superclassifier.qualifiedName).join(", "), "|", s.effectiveName()); +const {{ standardLibrary }} = await import("{name}"); +const lib = await standardLibrary(); +const x = new Model(await ToolkitBackend.open({{ sources: {{ "x.sysml": "package P {{ attribute x : ScalarValues::Real; }}" }}, libraryDir: lib }})).resolve("P::x"); +console.log("bundled library types x as", x.ownedTyping[0].type.qualifiedName); +const pkg = JSON.parse(readFileSync(new URL("./node_modules/{name}/package.json", import.meta.url), "utf8")); +console.log("package license:", pkg.license); """, encoding="utf-8") env = {k: v for k, v in os.environ.items() if k != "SYSMLV2_ABI_WASM"} # the package's own module out = run(["node", "check.mjs"], work, env) expect(out, f"payload owns {SPORTS_CAR}", "npm package, payload with the library JSON") expect(out, f"toolkit owns {SPORTS_CAR} | SportsCar", "npm package, SysML Toolkit read with the packaged module") + expect(out, "bundled library types x as ScalarValues::Real", "npm package, the standard library models it carries") + expect(out, "package license: (Apache-2.0 AND EPL-2.0)", "npm package, the library's license declared") + for notice in ("LICENSE", "NOTICE"): + if not (work / "node_modules" / name / "stdlib" / notice).is_file(): + sys.exit(f"npm package: no stdlib/{notice}") guide_files(work, library_dir) for kind, program in guide_programs("typescript").items(): (work / f"guide_{kind}.mjs").write_text(program, encoding="utf-8") @@ -227,6 +248,31 @@ def check_java(dist: Path, work: Path, java_home: str | None, library_dir: Path expect(payload, FIRST_LINE, f"Java jar on {version.splitlines()[0]}, payload") toolkit = run([java, *preview, "--enable-native-access=ALL-UNNAMED", "-cp", cp, "Tutorial", "--toolkit"], ROOT, native_env()) expect(toolkit, FIRST_LINE, f"Java jar on {version.splitlines()[0]}, SysML Toolkit") + lib_src = work / "libcheck" / "LibCheck.java" + lib_src.parent.mkdir() + lib_src.write_text(f""" +import java.nio.file.Files; +import java.util.List; +import java.util.Map; +import {JAVA_PACKAGE}.*; +import {JAVA_PACKAGE}.classes.Feature; + +public class LibCheck {{ + public static void main(String[] args) {{ + var lib = StandardLibrary.directory(); + if (!Files.isRegularFile(lib.resolve("LICENSE")) || !Files.isRegularFile(lib.resolve("NOTICE"))) + throw new AssertionError("no LICENSE or NOTICE in " + lib); + Model m = new Model(ToolkitBackend.open(ToolkitBackend.library(), List.of(), + Map.of("x.sysml", "package P {{ attribute x : ScalarValues::Real; }}"), lib.toString())); + Feature x = (Feature) m.resolve("P::x"); + System.out.println("bundled library types x as " + x.getOwnedTyping().get(0).getType().getQualifiedName()); + }} +}} +""", encoding="utf-8") + run([javac, *preview, "--release", release, "-cp", jar, "-d", lib_src.parent, lib_src], work) + lib_out = run([java, *preview, "--enable-native-access=ALL-UNNAMED", "-cp", + os.pathsep.join([str(jar), str(lib_src.parent)]), "LibCheck"], work, native_env()) + expect(lib_out, "bundled library types x as ScalarValues::Real", "Java jar, the standard library models it carries") guide_files(work, library_dir) for kind, program in guide_programs("java").items(): main_class = re.search(r"public class (\w+)", program).group(1) @@ -259,10 +305,22 @@ def check_csharp(dist: Path, work: Path, library_dir: Path | None) -> None: using var be = ToolkitBackend.Open(@"{MODEL}"); var fromToolkit = (PartDefinition)new Model(be).Resolve("Vehicles::SportsCar")!; Console.WriteLine("toolkit owns " + string.Join(", ", fromToolkit.ownedFeature.Select(f => f.declaredName)) + " | " + string.Join(", ", fromToolkit.ownedSubclassification.Select(g => g.superclassifier!.qualifiedName)) + " | " + fromToolkit.effectiveName()); +var lib = StandardLibrary.Directory(); +if (!File.Exists(Path.Combine(lib, "LICENSE")) || !File.Exists(Path.Combine(lib, "NOTICE"))) throw new Exception("no LICENSE or NOTICE in " + lib); +using var withLibrary = ToolkitBackend.Open(ToolkitBackend.DefaultLibrary, Array.Empty(), + new Dictionary {{ ["x.sysml"] = "package P {{ attribute x : ScalarValues::Real; }}" }}, lib); +var x = (Feature)new Model(withLibrary).Resolve("P::x")!; +Console.WriteLine("bundled library types x as " + x.ownedTyping[0].type!.qualifiedName); """, encoding="utf-8") out = run(["dotnet", "run"], work / "app", env) expect(out, f"payload owns {SPORTS_CAR}", "NuGet package, payload") expect(out, f"toolkit owns {SPORTS_CAR} | SportsCar", "NuGet package, SysML Toolkit") + expect(out, "bundled library types x as ScalarValues::Real", "NuGet package, the standard library models it carries") + with zipfile.ZipFile(nupkg) as z: + nuspec = z.read(next(n for n in z.namelist() if n.endswith(".nuspec"))).decode("utf-8") + if "Apache-2.0 AND EPL-2.0" not in nuspec: + sys.exit("the NuGet package does not declare the library's license (Apache-2.0 AND EPL-2.0)") + print("ok: the NuGet package declares Apache-2.0 AND EPL-2.0") guide_files(work, library_dir) for kind, program in guide_programs("csharp").items(): project = work / f"guide-{kind}" @@ -294,6 +352,30 @@ def check_cpp(dist: Path, work: Path, cxx: str, library_dir: Path | None) -> Non shutil.copy2(carried, work / NATIVE) # beside the programs, which are all built in work expect(run([exe], ROOT), FIRST_LINE, "C++ headers, payload") expect(run([exe, "--toolkit"], ROOT, native_env()), FIRST_LINE, "C++ headers, SysML Toolkit") + # The archive's sysml.library, copied beside a program as its README says, found by standard_library(). + [archived] = glob.glob(str(work / "*" / "sysml.library")) + for name in ("LICENSE", "NOTICE"): + if not (Path(archived) / name).is_file(): + sys.exit(f"the C++ archive's sysml.library has no {name}") + libcheck = work / "libcheck" + shutil.copytree(archived, libcheck / "sysml.library") + if carried: + shutil.copy2(carried, libcheck / NATIVE) + (libcheck / "libcheck.cpp").write_text("""#include +#include +#include +using namespace sysml; +int main() { + Model model = Model::from_backend(ToolkitBackend::open({}, {{"x.sysml", "package P { attribute x : ScalarValues::Real; }"}}, + standard_library())); + auto x = model.resolve("P::x").value().as(); + std::cout << "bundled library types x as " << x.getOwnedTyping().at(0).getType().value().getQualifiedName().value_or("?") << "\\n"; +} +""", encoding="utf-8") + libcheck_exe = libcheck / ("libcheck.exe" if os.name == "nt" else "libcheck") + run([cxx, "-std=c++17", "-Wall", "-Werror", "-I", include, libcheck / "libcheck.cpp", "-o", libcheck_exe, *flags], work) + expect(run([libcheck_exe], work, native_env()), "bundled library types x as ScalarValues::Real", + "C++ archive, the standard library models it carries, beside the program") guide_files(work, library_dir) for kind, program in guide_programs("cpp").items(): src = work / f"guide_{kind}.cpp" @@ -317,6 +399,12 @@ def main() -> None: BUNDLED = a.bundled dist = a.dist.resolve() work = Path(tempfile.mkdtemp(prefix=f"sysml-check-{a.what}-")) + # The standard library helpers: their cache inside the check (the models the jar and the NuGet + # package copy out land there, not in the user's cache), and the library JSON the guides' payload + # programs ask for, the copy the check puts beside them (no download). + os.environ["SYSML_CACHE_DIR"] = str(work / "cache") + os.environ["SYSML_LIBRARY_JSON"] = str(work / "sysml.library.full.json") + os.environ.pop("SYSML_LIBRARY_DIR", None) try: if a.what == "python": check_python(dist, work, a.library_dir) diff --git a/tools/check_python_distributions.py b/tools/check_python_distributions.py new file mode 100644 index 0000000..615d861 --- /dev/null +++ b/tools/check_python_distributions.py @@ -0,0 +1,45 @@ +"""Validate the Python artifacts before registry publication.""" + +import os +from email.parser import BytesParser +from pathlib import Path +import sys +import tarfile +import tomllib +import zipfile + + +def main(): + project = tomllib.loads(Path('pyproject.toml').read_text(encoding='utf-8'))['project'] + version = project['version'] + if os.environ.get('GITHUB_REF', '').startswith('refs/tags/'): + if os.environ['GITHUB_REF_NAME'] != f'v{version}': + raise SystemExit('Release tag must match the version in pyproject.toml') + dist = Path(sys.argv[1]) + wheels = sorted(dist.glob('*.whl')) + sources = sorted(dist.glob('*.tar.gz')) + expected = {'win_amd64', 'manylinux_2_28_x86_64', 'macosx_11_0_arm64', 'macosx_10_12_x86_64'} + if len(wheels) != 4 or {p.stem.rsplit('-', 1)[1] for p in wheels} != expected: + raise SystemExit('Expected exactly one native wheel for each of the four supported platforms') + if len(sources) != 1: + raise SystemExit('Expected exactly one source distribution') + for path in wheels + sources: + if path.suffix == '.whl': + with zipfile.ZipFile(path) as archive: + [metadata] = [n for n in archive.namelist() if n.endswith('.dist-info/METADATA')] + native = [n for n in archive.namelist() if n.startswith('sysml/_native/') and n.endswith(('.dll', '.so', '.dylib'))] + if len(native) != 1: + raise SystemExit(f'{path}: expected one bundled native library') + data = archive.read(metadata) + else: + with tarfile.open(path) as archive: + [metadata] = [m for m in archive.getmembers() if m.name.count('/') == 1 and m.name.endswith('/PKG-INFO')] + data = archive.extractfile(metadata).read() + metadata = BytesParser().parsebytes(data) + if metadata['Name'] != 'sysml' or metadata['Version'] != version: + raise SystemExit(f'{path}: package name or version differs from sysml {version}') + print(f'Validated {path.name}') + + +if __name__ == '__main__': + main() diff --git a/tools/package.py b/tools/package.py index b8363ed..d9e3d0f 100644 --- a/tools/package.py +++ b/tools/package.py @@ -40,11 +40,12 @@ import zipfile from pathlib import Path -ROOT = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(Path(__file__).resolve().parent)) +ROOT = Path(__file__).absolute().parent.parent +sys.path.insert(0, str(Path(__file__).absolute().parent)) from naming import JAVA_PACKAGE, JAVA_SRC # noqa: E402 from package_abi import library_path # noqa: E402 +import package_library # noqa: E402 import third_party_notices # noqa: E402 VERSION = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] @@ -107,31 +108,73 @@ def run(*cmd: str | os.PathLike, cwd: Path = ROOT) -> None: subprocess.run([str(c) for c in cmd], cwd=cwd, check=True) -def wheel(target: str, out: Path) -> Path: +def wheel(target: str, out: Path, library_dir: Path | None) -> Path: lib = library_path(target) if not lib.exists(): raise SystemExit(f"no library at {lib}; build abi/ for {target} first") + if library_dir is None: + print("note: no --library-dir; the wheel carries no standard library models") native = ROOT / "python" / "sysml" / "_native" + stdlib = ROOT / "python" / "sysml" / "stdlib" staging = out / "wheel-staging" shutil.rmtree(staging, ignore_errors=True) + shutil.rmtree(stdlib, ignore_errors=True) native.mkdir(exist_ok=True) try: shutil.copy2(lib, native / lib.name) third_party_notices.write(target, native) + if library_dir is not None: + package_library.write_models(library_dir, stdlib) run(sys.executable, "-m", "build", "--wheel", "--outdir", staging) finally: shutil.rmtree(native, ignore_errors=True) + shutil.rmtree(stdlib, ignore_errors=True) shutil.rmtree(ROOT / "build", ignore_errors=True) [built] = staging.glob("*.whl") run(sys.executable, "-m", "wheel", "tags", "--python-tag", "py3", "--abi-tag", "none", "--platform-tag", PLATFORM_TAGS[target], "--remove", built) [tagged] = staging.glob("*.whl") + platform_wheel(tagged, library_dir is not None) + [tagged] = staging.glob("*.whl") dest = out / tagged.name shutil.move(tagged, dest) shutil.rmtree(staging) return dest +def platform_wheel(whl: Path, carries_library: bool) -> None: + """Rewrite the wheel setuptools made (it sees no extension module, so it calls the wheel pure) as + the platform wheel it is: `Root-Is-Purelib: false`. When it carries the standard library models, + its license is `Apache-2.0 AND EPL-2.0`, and their LICENSE and NOTICE are license files of the + distribution too (under stdlib/ in its .dist-info/licenses).""" + unpacked = whl.parent / "unpacked" + shutil.rmtree(unpacked, ignore_errors=True) + run(sys.executable, "-m", "wheel", "unpack", "--dest", unpacked, whl) + [root] = unpacked.iterdir() + [info] = root.glob("*.dist-info") + wheel_file = info / "WHEEL" + text = wheel_file.read_text(encoding="utf-8") + if "Root-Is-Purelib: true" not in text: + raise SystemExit(f"{whl.name}: no 'Root-Is-Purelib: true' in WHEEL to correct") + wheel_file.write_text(text.replace("Root-Is-Purelib: true", "Root-Is-Purelib: false"), encoding="utf-8", + newline="\n") + if carries_library: + metadata = info / "METADATA" + head, sep, body = metadata.read_text(encoding="utf-8").partition("\n\n") + if "\nLicense-Expression: Apache-2.0\n" not in head + "\n": + raise SystemExit(f"{whl.name}: METADATA does not say License-Expression: Apache-2.0") + head = head.replace("License-Expression: Apache-2.0", "License-Expression: Apache-2.0 AND EPL-2.0") + licenses = info / "licenses" / "stdlib" + licenses.mkdir(parents=True, exist_ok=True) + for name in ("LICENSE", "NOTICE"): + shutil.copy2(root / "sysml" / "stdlib" / name, licenses / name) + head += f"\nLicense-File: stdlib/{name}" + metadata.write_text(head + sep + body, encoding="utf-8", newline="\n") + whl.unlink() + run(sys.executable, "-m", "wheel", "pack", "--dest-dir", whl.parent, root) + shutil.rmtree(unpacked) + + def sdist(out: Path) -> Path: staging = out / "sdist-staging" shutil.rmtree(staging, ignore_errors=True) @@ -143,23 +186,30 @@ def sdist(out: Path) -> Path: return dest -def npm(out: Path) -> Path: +def npm(out: Path, library_dir: Path | None) -> Path: wasm = library_path("wasm32-unknown-unknown") if not wasm.exists(): raise SystemExit(f"no WebAssembly build at {wasm}; build abi/ for wasm32-unknown-unknown first") + if library_dir is None: + print("note: no --library-dir; the npm package carries no standard library models") ts = ROOT / "ts" + stdlib = ts / "stdlib" copies = [ts / "sysmlv2_abi.wasm", ts / "LICENSE", ts / "NOTICE"] + shutil.rmtree(stdlib, ignore_errors=True) try: shutil.copy2(wasm, copies[0]) shutil.copy2(ROOT / "LICENSE", copies[1]) shutil.copy2(ROOT / "NOTICE", copies[2]) copies += third_party_notices.write("wasm32-unknown-unknown", ts) + if library_dir is not None: + package_library.write_models(library_dir, stdlib) before = set(out.glob("*.tgz")) npm_cmd = "npm.cmd" if os.name == "nt" else "npm" run(npm_cmd, "pack", "--pack-destination", out.resolve(), cwd=ts) finally: for c in copies: c.unlink(missing_ok=True) + shutil.rmtree(stdlib, ignore_errors=True) [made] = set(out.glob("*.tgz")) - before return made @@ -170,8 +220,23 @@ def _zip_tree(z: zipfile.ZipFile, src: Path, prefix: str) -> None: z.write(f, prefix + f.relative_to(src).as_posix()) -def java(out: Path, javac22: str | None, archives: Path | None = None) -> list[Path]: - src_root = ROOT / "java" / "src" +def library_models(library_dir: Path | None, staging: Path, what: str) -> list[tuple[str, bytes]]: + """The standard library models a package carries, as (path relative to the library, bytes), with + their LICENSE and NOTICE and an INDEX listing every other file (for the languages that copy the + models out of the package); none without `library_dir`.""" + if library_dir is None: + print(f"note: no --library-dir; {what} carries no standard library models") + return [] + shutil.rmtree(staging, ignore_errors=True) + package_library.write_models(library_dir, staging) + files = sorted(f.relative_to(staging).as_posix() for f in staging.rglob("*") if f.is_file()) + out = [(rel, (staging / rel).read_bytes()) for rel in files] + shutil.rmtree(staging) + return out + [("INDEX", "".join(f"{rel}\n" for rel in files).encode("utf-8"))] + + +def java(out: Path, javac22: str | None, archives: Path | None = None, library_dir: Path | None = None) -> list[Path]: + src_root = JAVA_SRC.parents[JAVA_PACKAGE.count(".")] # java/src, as naming spells it sources = sorted(p for p in JAVA_SRC.rglob("*.java") if "checks" not in p.relative_to(JAVA_SRC).parts) work = out / "java-staging" shutil.rmtree(work, ignore_errors=True) @@ -209,6 +274,9 @@ def java(out: Path, javac22: str | None, archives: Path | None = None) -> list[P platform = NATIVE_TARGETS[target][0] for name, data in files.items(): z.writestr(f"{package_path}/native/{platform}/{name}", data) + # The standard library models, with an index StandardLibrary reads to copy them out. + for rel, data in library_models(library_dir, work / "stdlib", "the jar"): + z.writestr(f"{package_path}/stdlib/{rel}", data) sources_jar = out / f"sysml-sdk-{VERSION}-sources.jar" with zipfile.ZipFile(sources_jar, "w", zipfile.ZIP_DEFLATED) as z: z.writestr("META-INF/MANIFEST.MF", "Manifest-Version: 1.0\r\n") @@ -220,14 +288,18 @@ def java(out: Path, javac22: str | None, archives: Path | None = None) -> list[P return [jar, sources_jar] -def csharp(out: Path, archives: Path | None = None) -> Path: +def csharp(out: Path, archives: Path | None = None, library_dir: Path | None = None) -> Path: projects = [p for p in (ROOT / "csharp").glob("*/*.csproj") if not p.stem.endswith(".Checks")] [project] = projects - # Staged beside the project, which packs runtimes/ and THIRD-PARTY/ when they exist. - staged = [project.parent / "runtimes", project.parent / "THIRD-PARTY"] + # Staged beside the project, which packs runtimes/ and THIRD-PARTY/ and embeds stdlib/ when they exist. + staged = [project.parent / "runtimes", project.parent / "THIRD-PARTY", project.parent / "stdlib"] for d in staged: shutil.rmtree(d, ignore_errors=True) try: + if library_dir is not None: + package_library.write_models(library_dir, staged[2]) + else: + print("note: no --library-dir; the NuGet package carries no standard library models") for target, files in natives(archives).items(): rid = NATIVE_TARGETS[target][1] for name, data in files.items(): @@ -251,6 +323,7 @@ def csharp(out: Path, archives: Path | None = None) -> Path: #include the metaclass structs, with the runtime and the payload backend #include the SysML Toolkit backend (loads the binding library at run time) + #include the standard library: sysml::standard_library(), standard_library_json() ( alone is the runtime and the payload backend, without the metaclass structs.) @@ -258,14 +331,22 @@ def csharp(out: Path, archives: Path | None = None) -> Path: each platform: copy the one for yours beside your executable, or name it in SYSMLV2_ABI. On Linux, link with -ldl. +The standard library: sysml.library/ holds its models. Copy that directory beside your executable +too (or name it in SYSML_LIBRARY_DIR); sysml::standard_library() from finds it, +for the library directory of ToolkitBackend::open. sysml::standard_library_json() is the library as +JSON, for payloads: the file SYSML_LIBRARY_JSON names, or the copy another SDK language downloaded +into the user's cache (C++ downloads nothing; sysml_library-{version}.zip on the release has it). + License: Apache-2.0, see LICENSE and NOTICE. include/nlohmann/json.hpp is JSON for Modern C++ by Niels -Lohmann, MIT license, see THIRD-PARTY/nlohmann-json-LICENSE.MIT. +Lohmann, MIT license, see THIRD-PARTY/nlohmann-json-LICENSE.MIT. sysml.library/ is the SysML v2 +release's standard library, Eclipse Public License 2.0, see sysml.library/LICENSE and NOTICE. """ -def cpp(out: Path, archives: Path | None = None) -> Path: +def cpp(out: Path, archives: Path | None = None, library_dir: Path | None = None) -> Path: stem = f"sysml-sdk-cpp-{VERSION}" dest = out / f"{stem}.zip" + models = library_models(library_dir, out / "cpp-stdlib-staging", "the C++ archive") with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z: for f in sorted((ROOT / "cpp" / "include" / "sysml").glob("*.hpp")): z.write(f, f"{stem}/include/sysml/{f.name}") @@ -277,6 +358,9 @@ def cpp(out: Path, archives: Path | None = None) -> Path: for target, files in natives(archives).items(): for name, data in files.items(): z.writestr(f"{stem}/lib/{target}/{name}", data) + for rel, data in models: + if rel != "INDEX": # a directory to copy, not to read out of a package + z.writestr(f"{stem}/sysml.library/{rel}", data) return dest @@ -288,22 +372,25 @@ def main() -> None: ap.add_argument("--javac22", help="a JDK 22 or newer javac, for the multi-release jar (java)") ap.add_argument("--abi-archives", type=Path, help="the binding library archives to carry (java, csharp, cpp)") + ap.add_argument("--library-dir", type=Path, + help="the standard library's sysml.library directory, in a git checkout of the SysML v2 " + "release, whose models the package carries (python, npm, java, csharp, cpp)") a = ap.parse_args() a.out.mkdir(parents=True, exist_ok=True) if a.what == "python": if not a.target: raise SystemExit("python needs --target") - made = [wheel(a.target, a.out)] + made = [wheel(a.target, a.out, a.library_dir)] elif a.what == "sdist": made = [sdist(a.out)] elif a.what == "npm": - made = [npm(a.out)] + made = [npm(a.out, a.library_dir)] elif a.what == "java": - made = java(a.out, a.javac22, a.abi_archives) + made = java(a.out, a.javac22, a.abi_archives, a.library_dir) elif a.what == "csharp": - made = [csharp(a.out, a.abi_archives)] + made = [csharp(a.out, a.abi_archives, a.library_dir)] else: - made = [cpp(a.out, a.abi_archives)] + made = [cpp(a.out, a.abi_archives, a.library_dir)] for m in made: print(m) diff --git a/tools/package_library.py b/tools/package_library.py index 1a646d9..436b0de 100644 --- a/tools/package_library.py +++ b/tools/package_library.py @@ -31,7 +31,7 @@ import zipfile from pathlib import Path -ROOT = Path(__file__).resolve().parent.parent +ROOT = Path(__file__).absolute().parent.parent NOTICE = """The KerML and SysML standard library, as its models and as full-form interchange JSON @@ -65,6 +65,27 @@ {holders} """ +# The NOTICE beside the library models a package carries (write_models): the models only, no JSON. +MODELS_NOTICE = """The KerML and SysML standard library models + +This directory holds the standard library models of the SysML v2 release named below, unmodified: +the .kerml and .sysml files the release tracks in its directory sysml.library, with their project +metadata. + + {release}, {url} + commit {release_commit} + +They are licensed under the Eclipse Public License 2.0; see LICENSE. They are not covered by the +SDK's Apache-2.0 license. + +Source Code: the library models are available under the Eclipse Public License 2.0 at +{url}/tree/{release_commit}/sysml.library + +The release licenses its models under the EPL 2.0 by the copyright holders it lists: + +{holders} +""" + README = """sysml_library {version} The KerML and SysML standard library for the SysML v2 SDK, in the two forms its backends read: @@ -120,6 +141,50 @@ def copyright_holders(readme: str) -> list[str]: return holders +def release_fields(library_dir: Path) -> dict: + """What the NOTICE and README say about the library: the SDK's version, the SysML v2 release the + directory `library_dir` (its sysml.library, in a git checkout of the release) comes from, the + release's copyright holders, and the SysML Toolkit the SDK pins.""" + version = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] + toolkit = json.loads((ROOT / "vendor" / "sysml-toolkit" / "PIN.json").read_text(encoding="utf-8"))["toolkit"] + release = library_dir.absolute().parent + project = json.loads((library_dir / "Systems Library" / ".project.json").read_text(encoding="utf-8")) + holders = copyright_holders((release / "README.md").read_text(encoding="utf-8")) + return dict( + version=version, release=git(release, "describe", "--tags", "--always"), + release_commit=git(release, "rev-parse", "HEAD"), + url=git(release, "remote", "get-url", "origin").removesuffix(".git"), + library_version=project["version"], + repository=toolkit["repository"], tag=toolkit["tag"], commit=toolkit["commit"], + holders="\n".join(holders)) + + +def tracked_models(library_dir: Path) -> list[str]: + """The library's models as the release tracks them, as paths relative to the release (starting with + the directory's name); its IDE files (.project, .settings, .gitignore) stay out.""" + library = library_dir.absolute() + models = [p for p in git(library.parent, "ls-files", "-z", "--", library.name).split("\0") if p] + models = [p for p in models if p.endswith((".kerml", ".sysml", ".project.json", ".meta.json"))] + if not any(p.endswith((".kerml", ".sysml")) for p in models): + sys.exit(f"no library models tracked under {library}") + return models + + +def write_models(library_dir: Path, dest: Path) -> None: + """Write the library models into `dest`, laid out as `library_dir` is (so that `dest` is a library + directory a session opens), with the release's LICENSE (EPL 2.0) and a NOTICE: what an SDK + package carries for its standard_library helper.""" + library = library_dir.absolute() + fields = release_fields(library) + dest.mkdir(parents=True, exist_ok=True) + for rel in tracked_models(library): + target = dest / Path(rel).relative_to(library.name) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((library.parent / rel).read_bytes()) + (dest / "LICENSE").write_bytes((library.parent / "LICENSE").read_bytes()) + (dest / "NOTICE").write_text(MODELS_NOTICE.format(**fields), encoding="utf-8", newline="\n") + + def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("json", type=Path) @@ -127,28 +192,15 @@ def main() -> None: ap.add_argument("--out", type=Path, default=ROOT / "dist") a = ap.parse_args() - version = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] - toolkit = json.loads((ROOT / "vendor" / "sysml-toolkit" / "PIN.json").read_text(encoding="utf-8"))["toolkit"] - release = a.library_dir.resolve().parent - project = json.loads((a.library_dir / "Systems Library" / ".project.json").read_text(encoding="utf-8")) - fields = dict( - version=version, release=git(release, "describe", "--tags", "--always"), - release_commit=git(release, "rev-parse", "HEAD"), - url=git(release, "remote", "get-url", "origin").removesuffix(".git"), - library_version=project["version"], - repository=toolkit["repository"], tag=toolkit["tag"], commit=toolkit["commit"]) - holders = copyright_holders((release / "README.md").read_text(encoding="utf-8")) - notice = NOTICE.format(**fields, holders="\n".join(holders)) + fields = release_fields(a.library_dir) + release = a.library_dir.absolute().parent + notice = NOTICE.format(**fields) + version = fields["version"] name = f"sysml_library-{version}" a.out.mkdir(parents=True, exist_ok=True) archive = a.out / f"{name}.zip" - # The models as the release tracks them; its IDE files (.project, .settings, .gitignore) stay out. - library = a.library_dir.resolve() - models = [p for p in git(release, "ls-files", "-z", "--", library.name).split("\0") if p] - models = [p for p in models if p.endswith((".kerml", ".sysml", ".project.json", ".meta.json"))] - if not any(p.endswith((".kerml", ".sysml")) for p in models): - sys.exit(f"no library models tracked under {library}") + models = tracked_models(a.library_dir) with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z: z.write(a.json, f"{name}/sysml.library.full.json") for rel in sorted(models): diff --git a/tools/release_notes.py b/tools/release_notes.py index 6174edf..5f308ca 100644 --- a/tools/release_notes.py +++ b/tools/release_notes.py @@ -5,7 +5,8 @@ The tag is v or v- (a rehearsal such as v0.1.0-rc1). The notes are the section of CHANGELOG.md headed with the version. The versions checked are the ones each package carries: pyproject.toml (the Python packages, the jar and the C++ archive take theirs from it), -ts/package.json and the C# project. +ts/package.json, the C# project, and the C++ headers' sdk_version (which the standard library +helpers name). """ import json @@ -33,6 +34,8 @@ def main() -> None: [csproj] = CS_PROJECT.glob("*.csproj") cs = re.search(r"([^<]+)", csproj.read_text(encoding="utf-8")) found[csproj.relative_to(ROOT).as_posix()] = cs.group(1) if cs else None + cpp = re.search(r'sdk_version = "([^"]+)"', (ROOT / "cpp" / "include" / "sysml" / "library.hpp").read_text(encoding="utf-8")) + found["cpp/include/sysml/library.hpp"] = cpp.group(1) if cpp else None wrong = {k: v for k, v in found.items() if v != version} if wrong: sys.exit(f"tag {tag} wants version {version}; these differ: {wrong}") diff --git a/ts/index.d.ts b/ts/index.d.ts index 1d98bd8..c55e708 100644 --- a/ts/index.d.ts +++ b/ts/index.d.ts @@ -84,6 +84,22 @@ export class ToolkitBackend implements Backend { call(handle: string, op: string, args: readonly unknown[]): unknown; } +/** The standard library is not at hand: this copy carries no models, or the JSON could not be + * downloaded and checked. */ +export class LibraryUnavailable extends Error {} + +/** The directory of the standard library models the npm package carries, for `libraryDir`. Node only. */ +export function standardLibrary(): Promise; + +/** The standard library as full-form JSON, for PayloadLibrary: the file SYSML_LIBRARY_JSON names, + * else the cached copy, downloaded from this version's GitHub release on the first call and checked + * against the SHA-256 GitHub states for it. Resolves to the file's path. Node only. */ +export function standardLibraryJson(options?: { + version?: string; + cacheDir?: string; + fetch?: typeof globalThis.fetch; +}): Promise; + export class Model { constructor(backend: Backend); /** A model read from a full-form interchange element array. With `library`, references into the diff --git a/ts/index.mjs b/ts/index.mjs index 8765f1e..ed8b32f 100644 --- a/ts/index.mjs +++ b/ts/index.mjs @@ -2,3 +2,4 @@ export * from "./runtime.mjs"; export { ABSTRACT, HIERARCHY, OPS, PROPS } from "./meta.mjs"; export { ToolkitBackend, ToolkitLibrary, ToolkitError } from "./toolkit.mjs"; +export { LibraryUnavailable, standardLibrary, standardLibraryJson } from "./library.mjs"; diff --git a/ts/library.mjs b/ts/library.mjs new file mode 100644 index 0000000..443fe8f --- /dev/null +++ b/ts/library.mjs @@ -0,0 +1,163 @@ +// The KerML and SysML standard library, in the two forms the backends read. Node only: a browser +// has no file system to hold them (fetch the files and pass `librarySources`, or a library JSON you +// host yourself, instead). +// +// standardLibrary() is the directory of the library's models this package carries, for the SysML +// Toolkit backend: ToolkitBackend.open({ sources, libraryDir: await standardLibrary() }). +// +// standardLibraryJson() is the same library as full-form interchange JSON, for the payload backend +// (PayloadLibrary). It is too large for the package: the first call downloads +// sysml_library-.zip from this version's GitHub release, checks it against the SHA-256 +// GitHub states for that file, and keeps the JSON in the user's cache (the same place the other +// SDK languages keep it), where later calls find it. Nothing is downloaded unless it is called. +// SYSML_LIBRARY_JSON names a copy of the JSON to use instead. +// +// Both are the library of the SysML v2 release the SDK was built with, licensed under the Eclipse +// Public License 2.0, not the SDK's Apache-2.0: see the LICENSE and NOTICE beside them. + +const REPOSITORY = "Open-MBEE/sysml-sdk"; +const JSON_VARIABLE = "SYSML_LIBRARY_JSON"; +const CACHE_VARIABLE = "SYSML_CACHE_DIR"; + +export class LibraryUnavailable extends Error {} + +function nodeOnly(what) { + if (typeof process === "undefined" || !process.versions?.node) + throw new LibraryUnavailable(`${what} needs Node: a browser has no file system to keep the library in`); +} + +async function here() { + const { dirname } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + return dirname(fileURLToPath(import.meta.url)); +} + +/** The directory of the standard library models this package carries, for `libraryDir`. */ +export async function standardLibrary() { + nodeOnly("standardLibrary()"); + const { join } = await import("node:path"); + const { existsSync, readdirSync } = await import("node:fs"); + const dir = join(await here(), "stdlib"); + const models = existsSync(dir) && + readdirSync(dir, { recursive: true }).some((f) => /\.(sysml|kerml)$/.test(String(f))); + if (!models) + throw new LibraryUnavailable( + "this copy of the SDK carries no standard library models (the npm package does); pass the " + + `sysml.library directory of sysml_library-.zip from https://github.com/${REPOSITORY}/releases as libraryDir`); + return dir; +} + +/** + * The standard library as full-form JSON, for PayloadLibrary: the file SYSML_LIBRARY_JSON names, + * else the copy in the cache (`cacheDir`, else SYSML_CACHE_DIR, else the user's cache directory), + * downloaded from the GitHub release of `version` (default: this SDK's) on the first call and checked + * against the SHA-256 GitHub states for it. Resolves to the file's path. + */ +export async function standardLibraryJson({ version, cacheDir, fetch: get = globalThis.fetch } = {}) { + nodeOnly("standardLibraryJson()"); + const { existsSync, statSync, mkdirSync, mkdtempSync, rmSync, renameSync, writeFileSync, readFileSync } = + await import("node:fs"); + const { join } = await import("node:path"); + const named = process.env[JSON_VARIABLE]; + if (named) { + if (!existsSync(named) || !statSync(named).isFile()) + throw new LibraryUnavailable(`${JSON_VARIABLE} names ${named}, which is not a file`); + return named; + } + version ??= JSON.parse(readFileSync(join(await here(), "package.json"), "utf8")).version; + const stem = `sysml_library-${version}`; + const root = cacheDir ?? (await cacheRoot()); + const target = join(root, stem); + const found = join(target, "sysml.library.full.json"); + if (existsSync(found)) return found; + + const page = `https://github.com/${REPOSITORY}/releases/tag/v${version}`; + const byHand = `download ${stem}.zip from ${page}, unpack it, and set ${JSON_VARIABLE} to the sysml.library.full.json it holds`; + const headers = { "User-Agent": "sysml-sdk" }; + let release; + try { + const r = await get(`https://api.github.com/repos/${REPOSITORY}/releases/tags/v${version}`, + { headers: { ...headers, Accept: "application/vnd.github+json" } }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + release = await r.json(); + } catch (e) { + throw new LibraryUnavailable(`cannot read the release v${version} of ${REPOSITORY} (${e.message}); ${byHand}`); + } + const asset = (release.assets ?? []).find((a) => a.name === `${stem}.zip`); + if (!asset) throw new LibraryUnavailable(`the release v${version} of ${REPOSITORY} has no ${stem}.zip (${page})`); + const digest = asset.digest ?? ""; + if (!digest.startsWith("sha256:")) + throw new LibraryUnavailable(`GitHub states no SHA-256 for ${stem}.zip, so it is not downloaded; ${byHand}`); + + let bytes; + try { + const r = await get(asset.browser_download_url, { headers: { ...headers, Accept: "application/octet-stream" } }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + bytes = Buffer.from(await r.arrayBuffer()); + } catch (e) { + throw new LibraryUnavailable(`cannot download ${asset.browser_download_url} (${e.message}); ${byHand}`); + } + const { createHash } = await import("node:crypto"); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const want = digest.slice("sha256:".length); + if (sha256 !== want) + throw new LibraryUnavailable(`${stem}.zip does not match the SHA-256 GitHub states for it (${sha256}, not ${want}); nothing was kept`); + + let entries; + try { + entries = await unzip(bytes, ["sysml.library.full.json", "LICENSE", "NOTICE", "README.txt"].map((n) => `${stem}/${n}`)); + } catch (e) { + throw new LibraryUnavailable(`${stem}.zip is not the archive the SDK expects (${e.message}); ${byHand}`); + } + mkdirSync(root, { recursive: true }); + const work = mkdtempSync(join(root, `${stem}-`)); + try { + mkdirSync(join(work, stem)); + for (const [name, data] of entries) writeFileSync(join(work, name), data); + if (existsSync(target)) rmSync(target, { recursive: true, force: true }); // left incomplete earlier + renameSync(join(work, stem), target); + } finally { + rmSync(work, { recursive: true, force: true }); + } + return found; +} + +async function cacheRoot() { + const { join } = await import("node:path"); + const { homedir } = await import("node:os"); + const env = process.env; + if (env[CACHE_VARIABLE]) return env[CACHE_VARIABLE]; + if (process.platform === "win32") return join(env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "sysml", "Cache"); + if (process.platform === "darwin") return join(homedir(), "Library", "Caches", "sysml"); + return join(env.XDG_CACHE_HOME || join(homedir(), ".cache"), "sysml"); +} + +/** The named entries of a zip archive (stored or deflated, as Python's zipfile writes them), as + * [name, Buffer] pairs; throws if one is missing. */ +async function unzip(zip, names) { + const { inflateRawSync } = await import("node:zlib"); + let end = zip.length - 22; + while (end >= 0 && zip.readUInt32LE(end) !== 0x06054b50) end--; + if (end < 0) throw new Error("no end of central directory"); + const count = zip.readUInt16LE(end + 10); + let p = zip.readUInt32LE(end + 16); + const found = new Map(); + for (let i = 0; i < count; i++) { + if (zip.readUInt32LE(p) !== 0x02014b50) throw new Error("bad central directory"); + const method = zip.readUInt16LE(p + 10), size = zip.readUInt32LE(p + 20); + const nameLen = zip.readUInt16LE(p + 28), extraLen = zip.readUInt16LE(p + 30), commentLen = zip.readUInt16LE(p + 32); + const local = zip.readUInt32LE(p + 42); + const name = zip.toString("utf8", p + 46, p + 46 + nameLen); + if (names.includes(name)) { + const start = local + 30 + zip.readUInt16LE(local + 26) + zip.readUInt16LE(local + 28); + const raw = zip.subarray(start, start + size); + if (method === 0) found.set(name, raw); + else if (method === 8) found.set(name, inflateRawSync(raw)); + else throw new Error(`${name}: compression method ${method}`); + } + p += 46 + nameLen + extraLen + commentLen; + } + const missing = names.filter((n) => !found.has(n)); + if (missing.length) throw new Error(`no ${missing.join(", ")}`); + return names.map((n) => [n, found.get(n)]); +} diff --git a/ts/library_checks.mjs b/ts/library_checks.mjs new file mode 100644 index 0000000..c16e751 --- /dev/null +++ b/ts/library_checks.mjs @@ -0,0 +1,134 @@ +// The standard library helpers, offline: GitHub is replaced by a fake fetch that serves the release +// metadata and the library archive from memory. Run with `node ts/library_checks.mjs`. + +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { deflateRawSync } from "node:zlib"; +import { LibraryUnavailable, standardLibrary, standardLibraryJson } from "./library.mjs"; + +const VERSION = "9.9.9"; +const STEM = `sysml_library-${VERSION}`; +const API = `https://api.github.com/repos/Open-MBEE/sysml-sdk/releases/tags/v${VERSION}`; +const ASSET = `https://github.com/Open-MBEE/sysml-sdk/releases/download/v${VERSION}/${STEM}.zip`; +let failures = 0; + +function check(cond, what) { + if (!cond) { failures++; console.log(`FAIL: ${what}`); } else console.log(`ok: ${what}`); +} + +async function rejects(promise, pattern, what) { + try { await promise; check(false, `${what} (no error)`); } + catch (e) { check(e instanceof LibraryUnavailable && pattern.test(e.message), `${what}: ${e.message.slice(0, 90)}`); } +} + +/** A zip of deflated entries, as Python's zipfile writes them (CRCs left 0: the reader does not check them). */ +function zip(entries) { + const locals = [], central = []; + let offset = 0; + for (const [name, text] of entries) { + const n = Buffer.from(name), data = deflateRawSync(Buffer.from(text)); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(8, 8); + local.writeUInt32LE(data.length, 18); local.writeUInt32LE(text.length, 22); local.writeUInt16LE(n.length, 26); + const dir = Buffer.alloc(46); + dir.writeUInt32LE(0x02014b50, 0); dir.writeUInt16LE(8, 10); + dir.writeUInt32LE(data.length, 20); dir.writeUInt32LE(text.length, 24); dir.writeUInt16LE(n.length, 28); + dir.writeUInt32LE(offset, 42); + locals.push(local, n, data); central.push(dir, n); + offset += 30 + n.length + data.length; + } + const dir = Buffer.concat(central), end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(dir.length, 12); end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, dir, end]); +} + +const archive = (json = '[{"@id": "lib"}]') => + zip([["sysml.library.full.json", json], ["LICENSE", "EPL"], ["NOTICE", "notice"], ["README.txt", "readme"]] + .map(([n, t]) => [`${STEM}/${n}`, t])); + +/** A fake GitHub serving `served` (URL to bytes, or an Error to throw); `asked` lists the URLs. */ +function github(served) { + const asked = []; + const fetch = async (url) => { + asked.push(url); + const answer = served[url]; + if (answer === undefined || answer instanceof Error) throw answer ?? new TypeError("fetch failed"); + return new Response(answer, { status: 200 }); + }; + return { fetch, asked }; +} + +function release(data, digest = "sha256:" + createHash("sha256").update(data).digest("hex")) { + return { [API]: JSON.stringify({ assets: [{ name: `${STEM}.zip`, browser_download_url: ASSET, digest }] }), [ASSET]: data }; +} + +const tmp = () => mkdtempSync(join(tmpdir(), "sysml-library-")); +delete process.env.SYSML_LIBRARY_JSON; + +{ + const dir = tmp(), g = github(release(archive())); + const path = await standardLibraryJson({ version: VERSION, cacheDir: dir, fetch: g.fetch }); + check(path === join(dir, STEM, "sysml.library.full.json"), "the JSON lands in the cache"); + check(readFileSync(path, "utf8") === '[{"@id": "lib"}]', "the JSON is the archive's"); + check(readdirSync(join(dir, STEM)).sort().join() === "LICENSE,NOTICE,README.txt,sysml.library.full.json", "with its license and notice"); + check(readdirSync(dir).join() === STEM, "no work files left behind"); + const again = github({}); + check(await standardLibraryJson({ version: VERSION, cacheDir: dir, fetch: again.fetch }) === path && again.asked.length === 0, + "the second call reads the cache without asking GitHub"); + rmSync(dir, { recursive: true }); +} +{ + const dir = tmp(); + await rejects(standardLibraryJson({ version: VERSION, cacheDir: dir, fetch: github(release(archive(), "sha256:" + "0".repeat(64))).fetch }), + /does not match the SHA-256/, "a digest mismatch"); + check(readdirSync(dir).length === 0, "a digest mismatch keeps nothing"); + rmSync(dir, { recursive: true }); +} +{ + const dir = tmp(), g = github(release(archive(), "")); + await rejects(standardLibraryJson({ version: VERSION, cacheDir: dir, fetch: g.fetch }), /states no SHA-256/, "no digest"); + check(g.asked.length === 1, "no digest, no download"); + await rejects(standardLibraryJson({ version: VERSION, cacheDir: dir, fetch: github({ [API]: '{"assets": []}' }).fetch }), + new RegExp(`has no ${STEM}\\.zip`), "no such asset"); + await rejects(standardLibraryJson({ version: VERSION, cacheDir: dir, fetch: github({}).fetch }), + /SYSML_LIBRARY_JSON.*releases\/tag\/v9\.9\.9|releases\/tag\/v9\.9\.9.*SYSML_LIBRARY_JSON/, "offline: says what to do"); + await rejects(standardLibraryJson({ version: VERSION, cacheDir: dir, fetch: github(release(zip([["other.txt", "x"]]))).fetch }), + /not the archive/, "not the expected archive"); + rmSync(dir, { recursive: true }); +} +{ + const dir = tmp(); + mkdirSync(join(dir, STEM)); + writeFileSync(join(dir, STEM, "LICENSE"), "left over"); + const path = await standardLibraryJson({ version: VERSION, cacheDir: dir, fetch: github(release(archive())).fetch }); + check(readFileSync(join(dir, STEM, "LICENSE"), "utf8") === "EPL" && readFileSync(path, "utf8").length > 0, "an incomplete copy is replaced"); + rmSync(dir, { recursive: true }); +} +{ + const dir = tmp(), copy = join(dir, "library.json"); + writeFileSync(copy, "[]"); + process.env.SYSML_LIBRARY_JSON = copy; + const g = github({}); + check(await standardLibraryJson({ fetch: g.fetch }) === copy && g.asked.length === 0, "SYSML_LIBRARY_JSON names a copy"); + process.env.SYSML_LIBRARY_JSON = join(dir, "missing.json"); + await rejects(standardLibraryJson({ fetch: g.fetch }), /not a file/, "SYSML_LIBRARY_JSON names nothing"); + delete process.env.SYSML_LIBRARY_JSON; + process.env.SYSML_CACHE_DIR = dir; + check(await standardLibraryJson({ version: VERSION, fetch: github(release(archive())).fetch }) === join(dir, STEM, "sysml.library.full.json"), + "SYSML_CACHE_DIR names the cache"); + delete process.env.SYSML_CACHE_DIR; + rmSync(dir, { recursive: true }); +} +try { + const dir = await standardLibrary(); + check(readdirSync(dir).includes("LICENSE"), `standardLibrary(): ${dir}`); +} catch (e) { + check(e instanceof LibraryUnavailable && /carries no standard library models/.test(e.message), + "standardLibrary() in a checkout without the models says so"); +} + +if (failures) { console.log(`${failures} LIBRARY CHECK(S) FAILED`); process.exit(1); } +console.log("ALL JS LIBRARY CHECKS PASSED"); diff --git a/ts/package.json b/ts/package.json index 6917136..85e7577 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,7 +1,7 @@ { "name": "sysml", "version": "0.1.0", - "license": "Apache-2.0", + "license": "(Apache-2.0 AND EPL-2.0)", "description": "Spec-shaped SysML v2 SDK: generated metaclass surface over the SysML Toolkit, or over interchange payloads", "type": "module", "main": "index.mjs", @@ -19,6 +19,8 @@ "meta.mjs", "classes.d.ts", "toolkit.mjs", + "library.mjs", + "stdlib/", "toolkit_table.mjs", "sysmlv2_abi.wasm", "LICENSE",