diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1d9ddce..fbda96e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,6 +24,11 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.14' + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24.19.0' + - name: Require Node conformance runner + run: node --version - name: Install build tools run: python -m pip install --upgrade build twine - name: Build sdist and wheel from sdist @@ -31,6 +36,10 @@ jobs: - name: Check distribution metadata run: python -m twine check --strict dist/* - name: Install wheel with published dependencies - run: python -m pip install --find-links dist Python-JavaScript==0.1.0 + run: python scripts/install_distribution.py wheel - name: Test installed wheel run: python -m unittest discover -s tests -v + - name: Install source distribution with published dependencies + run: python scripts/install_distribution.py sdist + - name: Test installed source distribution + run: python -m unittest discover -s tests -v diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..f6509e8 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,80 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: read + +concurrency: + group: pypi-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.14' + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24.19.0' + - name: Require Node conformance runner + run: node --version + - name: Verify release tag matches package version + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + python - <<'PYTHON' + import os + import tomllib + from pathlib import Path + + project = tomllib.loads(Path("pyproject.toml").read_text())["project"] + expected = "v" + project["version"] + if os.environ["RELEASE_TAG"] != expected: + raise SystemExit(f"Release tag must be {expected}") + PYTHON + - name: Install build tools + run: python -m pip install --upgrade build twine + - name: Build sdist and wheel from the sdist + run: python -m build + - name: Check distribution metadata + run: python -m twine check --strict dist/* + - name: Install the wheel + run: python scripts/install_distribution.py wheel + - name: Test the installed wheel + run: python -m unittest discover -s tests -v + - name: Install and test source distribution + run: | + python scripts/install_distribution.py sdist + python -m unittest discover -s tests -v + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: distributions + path: dist/ + if-no-files-found: error + retention-days: 7 + + publish: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: pypi + url: https://pypi.org/project/Python-JavaScript/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: distributions + path: dist/ + - name: Publish to PyPI using Trusted Publishing + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aab8dd7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 0.1.0 + +- Introduce the case-sensitive `JavaScript` namespace with Object, sparse Array, + UTF-16 String methods, Number, Boolean, selected Math functions and JSON. +- Add undefined, truthiness, conversion, equality and parsing helpers. +- Depend on standalone JavaScript-Fetch-API 0.2 and JavaScript-JSON 0.1; expose + Fetch and Promise without duplicating their implementations. +- Document the supported compatibility subset and future translation direction. diff --git a/README.md b/README.md index b0c161a..769e2d7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,99 @@ # Python-JavaScript -JavaScript-style objects and standard-library methods in Python, using the -case-sensitive `JavaScript` namespace. Development is tracked in this -repository's issues and the organization Project. +JavaScript-style objects, methods and functions for Python 3.14+, using the +case-sensitive `JavaScript` namespace. An alpha compatibility layer with a +documented subset of ECMAScript behavior. + +```sh +python -m pip install Python-JavaScript==0.1.0 +``` + +```python +from JavaScript import Array, JSON, Math, Object, String, undefined + +people = JSON.parse('[{"name":"Ada","score":8},{"name":"Lin","score":6}]') +names = people.filter(lambda person: person.score >= 7).map( + lambda person: person.name.toUpperCase() +) +assert names.join(", ") == "ADA" + +profile = Object({"name": String("Ada"), "scores": Array.of(8, 6)}) +assert profile.missing is undefined +assert profile.scores.reduce(lambda total, score: total + score, 0) == 14 +assert Math.round(2.5) == 3 +assert JSON.stringify(profile) == '{"name":"Ada","scores":[8,6]}' +``` + +Explicit `Object`, `Array`, `String` and `Number` values expose JavaScript names +and behavior. Ordinary Python literals and operators retain Python semantics. +Objects and arrays compare by identity and remain truthy when empty. Use +`Boolean(value)` or `truthy(value)` when testing JavaScript truthiness of values +that might include Python containers. + +## Standalone packages + +Installation includes two independently useful dependencies: + +| Distribution | Import | Purpose | +| --- | --- | --- | +| `JavaScript-Fetch-API>=0.2.0,<0.3.0` | `fetch_api` | Standard-library HTTP Fetch and Promise APIs | +| `JavaScript-JSON>=0.1.0,<0.2.0` | `JSON` | Python-friendly JSON parsing, serialization and dot traversal | + +```python +import asyncio +from fetch_api import fetch +from JavaScript import JSON + +async def main(): + response = await fetch("https://example.com/api/people") + if not response.ok: + raise RuntimeError(f"HTTP {response.status}") + people = JSON.parse(await response.text()) + return people + +# Run against your own JSON endpoint: +# people = asyncio.run(main()) +``` + +`JavaScript.fetch`, `Promise`, `Headers`, `Request`, `Response` and `FetchError` +are the same objects exported by `fetch_api`. Fetch 0.2.0 changes the old +`from javascript import fetch` spelling to `from fetch_api import fetch`. + +`response.json()` retains Fetch's standalone `JSONObject`/list result. Use +`JavaScript.JSON.parse(await response.text())` for runtime Object/Array/String +values. Standalone `import JSON` preserves Python integers and containers; +`from JavaScript import JSON` uses binary64 numbers and JavaScript serialization +rules for its supported subset. + +## Supported surface + +Core data objects and sparse arrays; UTF-16 String methods; Number predicates and +prefix parsers; Boolean conversion; selected Math functions; JSON; and the +standalone Fetch/Promise APIs. See the complete +[compatibility matrix](docs/compatibility.md) for method lists and limits. + +Python keywords require `Array.from_`, `Object.is_`, and `Promise.finally_`. +`None` represents `null`; `undefined` is a separate singleton. Array bracket +indices follow JavaScript rules: `values[-1]` is a named property, and +`values.at(-1)` accesses the last element. `Array(3)` creates three holes; +`Array.of(3)` creates one element containing 3. + +The longer-term goal is a binding-aware Python-to-JavaScript translator. v0.1.0 +provides the runtime foundation; it does not translate source or execute +JavaScript. Prototypes, descriptors, Date, RegExp, Map/Set, typed arrays, browser +globals and other unlisted APIs remain future work. Development is tracked in +the [issues](https://github.com/outside-labs/Python-JavaScript/issues) and +[Project](https://github.com/orgs/outside-labs/projects/11). + +## Development + +```sh +uv sync +uv run python -m unittest discover -s tests -v +uv build +``` + +Node.js 24 runs the native ECMAScript differential fixture. That test is skipped +when Node is absent locally; CI and release builds require it. Runtime code +needs only Python and the two declared companions. Tests make no external +network requests. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..baf5b52 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,35 @@ +# Publishing Python-JavaScript + +The distribution name is `Python-JavaScript`; the Python import is `JavaScript`. +Requires Python 3.14 or later. + +## Trusted Publisher + +The PyPI publisher must match these values exactly: + +| Field | Value | +| --- | --- | +| PyPI project | `Python-JavaScript` | +| GitHub owner | `outside-labs` | +| Repository | `Python-JavaScript` | +| Workflow | `release.yaml` | +| Environment | `pypi` | + +The GitHub environment is named `pypi`. Publishing uses OpenID Connect; no API +token belongs in the repository or workflow. + +## Release + +1. Merge the passing task PRs and confirm final main CI on all three platforms. +2. Confirm both declared companions are available on PyPI. Verify clean wheel + and sdist installs and review the packaged file list. +3. Set the package version and matching changelog. Create and publish the GitHub + release with tag `v` targeting the verified main commit. +4. Monitor Release: tag/version validation, source/wheel build, strict metadata, + installed-wheel and installed-sdist tests, then publication via `pypi`. +5. Verify a new environment can install `Python-JavaScript==` from PyPI, + run the examples/tests, and compare published SHA256 hashes with build artifacts. + +Publishing a GitHub release triggers upload; a tag push alone does not. PyPI +versions are immutable. Repository visibility stays private; published artifacts +include package source. No open-source license has been selected for this release. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..99235a2 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,119 @@ +# v0.1.0 compatibility + +This is a selected ECMAScript-compatible runtime API implemented in Python. +Method names and the tested behaviors below follow the +[ECMAScript specification](https://tc39.es/ecma262/). It is not a complete language +implementation. The native Node.js fixture checks the adopted subset, rather +than asserting general Test262 conformance. + +## Supported API + +| Surface | Implemented members | +| --- | --- | +| Values | `undefined`, `null`, `NaN`, `Infinity`, `typeof`, `truthy`, `strictEqual`, global `isNaN`, global `isFinite` | +| `Object` | Mapping/empty construction, dot/bracket data properties, deletion, missing `undefined`, `keys`, `values`, `entries`, `assign`, `fromEntries`, `hasOwn`, `is_`, `toString` | +| `Array` | Numeric-length/element construction, `of`, iterable `from_`, `isArray`, `length`, bracket access/deletion, `at`, `push`, `pop`, `shift`, `unshift`, `slice`, `splice`, `concat`, `join`, `toString`, `includes`, `indexOf`, `lastIndexOf` | +| Array callbacks | `map`, `filter`, `forEach`, `some`, `every`, `find`, `findIndex`, `findLast`, `findLastIndex`, `reduce`, `reduceRight` | +| Array mutation/iteration | `reverse`, `fill`, `sort`, `keys`, `values`, `entries`, Python iteration | +| `String` | Conversion, `length`, bracket code units, `at`, `charAt`, `charCodeAt`, `codePointAt`, `slice`, `substring`, `indexOf`, `lastIndexOf`, `includes`, `startsWith`, `endsWith`, `split`, `concat`, `repeat`, `trim`, `trimStart`, `trimEnd`, `toLowerCase`, `toUpperCase`, `toString`, `valueOf`, `fromCharCode`, `fromCodePoint` | +| `Number` | Conversion, `isNaN`, `isFinite`, `isInteger`, `isSafeInteger`, `parseInt`, `parseFloat`, decimal `toString`, `valueOf`, `EPSILON`, `MAX_SAFE_INTEGER`, `MIN_SAFE_INTEGER`, `MAX_VALUE`, `MIN_VALUE`, `NaN`, `POSITIVE_INFINITY`, `NEGATIVE_INFINITY` | +| `Boolean` | Primitive conversion returning a Python bool | +| `Math` | `abs`, `floor`, `ceil`, `trunc`, `round`, `sign`, `min`, `max`, `sqrt`, `pow`, `random`; `PI`, `E`, `LN2`, `LN10`, `LOG2E`, `LOG10E`, `SQRT1_2`, `SQRT2` | +| `JSON` | `parse(text)`, `stringify(value, replacer=undefined, space=undefined)`, `JSONDecodeError` | +| Global parsing | `parseInt`, `parseFloat` | +| Companions | `fetch`, `FetchError`, `Headers`, `Request`, `Response`, `Promise`, re-exported unchanged from `fetch_api` | + +## Data model and Python boundaries + +- `Object` stores own enumerable writable data properties. Keys convert to + strings. Array-index keys enumerate numerically before other keys in insertion + order. Deleting and re-adding a non-index key moves it to the end. +- `Object()`/`Object(None)` create empty objects; `Object(existing)` returns the + existing Object. Other construction accepts a Python mapping. Primitive + boxing and prototype/descriptor operations are unsupported. +- Object-to-primitive conversion uses the built-in data-object representation. + Custom `valueOf`/`toString` conversion hooks and Symbol.toPrimitive are not + implemented. +- Objects preserve supplied child values; passing a nested Python dict/list + does not recursively convert it. JSON parsing creates runtime containers. +- Own Object keys shadow ordinary Python attributes. Use `Object.keys(obj)` + for static methods and brackets for special names such as `__class__`. + Missing ordinary attributes return `undefined`; Python `hasattr` therefore + cannot test property presence. Use `Object.hasOwn` or `key in obj`. +- Arrays store holes separately from explicit undefined values. Length changes + truncate or expand sparsely. Callback methods skip holes where ECMAScript does; + find methods and includes observe holes as undefined. `Object.hasOwn(array, i)` + distinguishes them. `length` is an own, non-enumerable property. +- Use bracket notation for named Array properties; arbitrary dot property + assignment and overriding Array methods are outside this subset. Python `in` + tests property presence, matching JavaScript `in`; use `includes` for values. +- `Array.from_` accepts Python iterables, including runtime arrays and strings. + JavaScript array-like objects, custom iterators/Symbol protocols, `thisArg`, + subclass species and `Symbol.isConcatSpreadable` are not implemented. `concat` + spreads runtime Array values; ordinary Python lists remain single elements. +- Callbacks receive JavaScript's arguments, truncated to their inspectable Python + positional arity. Zero-argument and variadic callbacks work. Uninspectable + callables receive all arguments; wrap them in a lambda if needed. Keyword-only + callback parameters and JavaScript `this` binding are unsupported. Exceptions + propagate once without retrying the callback. +- `sort` is stable and uses UTF-16 lexicographic order by default. Undefined + elements follow defined elements; holes follow undefined. Comparators may + return a number; mutating the sorted array from its comparator is unsupported. +- String methods and brackets index UTF-16 code units. String iteration visits + code points. Python `len(string)` and inherited str methods/operators retain + Python semantics; use `.length` for JavaScript length. Case conversion uses + Python's Unicode tables, which can differ from a JavaScript engine's version. +- `Number` is a float subclass with binary64 conversion. Plain Python integers, + arithmetic, division, modulo, comparisons, `and`/`or`, `==`, container literals + and inherited methods retain Python behavior. Use explicit helpers for + JavaScript semantics. No operator rewriting or automatic primitive boxing is + provided. `Boolean` does not create boxed Boolean objects. +- Methods can return native Python primitives, including Array `join` and Math + results. Wrap such results with `String` or `Number` to call their JavaScript + methods; Python does not automatically box primitive values. +- Decimal number strings use Python's shortest float representation adjusted to + JavaScript exponent/fixed-format thresholds. Tested ordinary values agree; + rare shortest-decimal tie choices may differ between Python and ECMAScript. + Other number radices and `toFixed`/`toPrecision` are not implemented. +- Errors use Python `TypeError`, `ValueError` and `JSONDecodeError`; no full + ECMAScript Error hierarchy or matching message text is promised. Invalid array + lengths, repeat counts and code points raise ValueError. Invalid JSON numeric + constants raise ValueError. Unsupported overloads raise TypeError or + NotImplementedError. + +## JSON boundaries + +`JavaScript.JSON.parse` creates Object, Array and String values, and binary64 +Python floats for numbers. Negative zero and integer rounding follow the number +model. It uses the standard-library decoder's numeric hooks because standalone +`JSON.parse` intentionally preserves Python integers. The companion's serializer +provides string escaping; its public decoder exception type is shared. + +`stringify` emits compact JSON by default, orders object keys, writes non-finite +numbers as null, omits undefined/callable object properties, and writes them as +null in arrays. Top-level undefined/callables return undefined. Shared references +are allowed; cycles raise TypeError. A numeric/string `space` is capped at ten +characters. Lone surrogates are escaped. Plain Python mappings/lists/tuples are +accepted as a convenience. + +Revivers, replacer functions/property lists, `toJSON` hooks, symbols and BigInt +are unsupported. A non-null replacer or callable own toJSON hook raises +NotImplementedError. Byte input is a standalone JSON extension and is rejected +by the runtime facade. The facade does not change standalone `import JSON` or +Fetch's `response.json()` result types. + +## Fetch and Promise boundaries + +The [standalone Fetch documentation](https://github.com/outside-labs/JavaScript-Fetch-API) +is the complete contract. Core HTTP(S) requests, buffered bodies, headers, +redirects, promises and await work. Browser policies, streaming, abort, cookies +and browser globals are not provided. Promise callbacks use a background Python +thread, not JavaScript microtask scheduling. Rejections use Python exceptions; +handlers return values or Promise objects, rather than raw Python coroutines. + +## Future translation + +The runtime deliberately keeps recognizable call shapes, but a Python program +using it is not automatically safe to translate. The future translator must +resolve bindings and validate an approved Python AST subset. It must diagnose +unsupported operators and Python-only constructs before emitting JavaScript. diff --git a/pyproject.toml b/pyproject.toml index 4bbf0ae..8361f5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,3 +30,11 @@ build-backend = "uv_build" [tool.uv.build-backend] module-name = "JavaScript" +source-include = [ + "tests/test_*.py", + "tests/fixtures/conformance.mjs", + "docs/compatibility.md", + "scripts/install_distribution.py", + "CHANGELOG.md", + "RELEASING.md", +] diff --git a/scripts/install_distribution.py b/scripts/install_distribution.py new file mode 100644 index 0000000..5762935 --- /dev/null +++ b/scripts/install_distribution.py @@ -0,0 +1,32 @@ +"""Install exactly the locally built artifact, including its PyPI dependencies.""" + +from pathlib import Path +import subprocess +import sys +import tomllib + + +def main(): + suffix = "*.whl" if sys.argv[1:] == ["wheel"] else "*.tar.gz" if sys.argv[1:] == ["sdist"] else None + if suffix is None: + raise SystemExit("Usage: install_distribution.py wheel|sdist") + artifacts = list(Path("dist").glob(suffix)) + if len(artifacts) != 1: + raise SystemExit(f"Expected one {suffix} artifact, found {len(artifacts)}") + version = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] + subprocess.run([sys.executable, "-m", "pip", "install", "--force-reinstall", str(artifacts[0])], check=True) + subprocess.run([sys.executable, "-m", "pip", "check"], check=True) + # New interpreter: imports must resolve from the installed artifact, not src. + subprocess.run([ + sys.executable, "-c", + "from importlib.metadata import version; import sys; import JavaScript, fetch_api, JSON; " + "assert version('Python-JavaScript') == sys.argv[1]; " + "assert 'site-packages' in JavaScript.__file__; " + "assert JavaScript.fetch is fetch_api.fetch; " + "print('Installed namespace:', JavaScript.__file__)", + version, + ], check=True) + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/conformance.mjs b/tests/fixtures/conformance.mjs new file mode 100644 index 0000000..8e3b650 --- /dev/null +++ b/tests/fixtures/conformance.mjs @@ -0,0 +1,97 @@ +// Native ECMAScript oracle for the explicitly supported runtime subset. +function encode(value) { + if (value === undefined) return {type: "undefined"}; + if (value === null) return {type: "null"}; + if (typeof value === "number") { + return {type: "number", value: Number.isNaN(value) ? "NaN" : + value === Infinity ? "Infinity" : value === -Infinity ? "-Infinity" : + Object.is(value, -0) ? "-0" : value}; + } + if (typeof value === "string" || typeof value === "boolean") { + return {type: typeof value, value}; + } + if (Array.isArray(value)) { + return {type: "array", length: value.length, + items: Object.keys(value).map(key => [key, encode(value[key])])}; + } + return {type: "object", entries: Object.entries(value).map(([key, child]) => [key, encode(child)])}; +} + +const cases = {}; +const values = [undefined, null, false, true, 0, -0, NaN, Infinity, -Infinity, + "", "0", "false", " \ufeff42\n", "0xff", "-0x1", "0b11", "0o10", ".5e2", "1_000", + "inf", "\x851", "οΌ‘οΌ’", [], [1], [1, 2], {}]; +cases.conversion = values.map(value => [Boolean(value), Number(value), String(value), typeof value]); +cases.equality = [1 === true, NaN === NaN, 0 === -0, Object.is(0, -0), + Object.is(NaN, NaN), {} === {}, "πŸ˜€" === "\ud83d\ude00"]; +const obj = {b: 1, 10: 2, 2: 3, "01": 4, 0: 5, "4294967295": 6}; +cases.objectOrder = Object.entries(obj); +delete obj.b; obj.b = 7; +cases.objectReinsert = Object.keys(obj); +cases.objectCopy = Object.assign({}, {a: 1}, null, undefined, {a: 2, b: 3}); +cases.fromEntries = Object.fromEntries([["a", 1], ["a", 2], ["b"]]); + +const sparse = Array(4); sparse[1] = undefined; sparse[3] = 4; +cases.sparse = sparse; +cases.sparseMap = sparse.map((value, index) => [value, index]); +cases.sparseFilter = sparse.filter(() => true); +cases.sparseFind = [sparse.findIndex(value => value === undefined), + sparse.findLastIndex(value => value === undefined)]; +cases.sparseSearch = [sparse.includes(undefined), sparse.indexOf(undefined), + sparse.lastIndexOf(undefined), sparse.lastIndexOf(undefined, undefined)]; +cases.sparseSlice = sparse.slice(0, -1); +const spliced = sparse.slice(); +cases.sparseSpliceRemoved = spliced.splice(1, 2, "x", "y", "z"); +cases.sparseSpliceResult = spliced; +cases.sparseReverse = sparse.slice().reverse(); +cases.sparseSort = Array(1).concat([10, 2, undefined, 1]).sort(); +cases.numericSort = [10, 2, 1].sort((a, b) => a - b); +cases.arraySearch = [NaN, true, 1, -0, undefined].map(value => + [1, true, NaN, 0, undefined].indexOf(value)); +cases.arrayIncludes = [NaN, true, 1, -0, undefined].map(value => + [1, true, NaN, 0].includes(value)); +const mutated = [1, 2, 3], seen = []; +cases.callbackMutation = mutated.map((value, index, array) => { + seen.push(value); + if (index === 0) { array[1] = 9; delete array[2]; array.push(4); } + return value; +}); +cases.callbackSeen = seen; +cases.reduce = [sparse.reduce((total, value) => total + (value || 0), 0), + [2, 3].reduceRight((a, b) => a - b)]; +cases.fromSparse = Array.from(Array(2)); +cases.join = [1, null, undefined, [2, 3]].join("|"); +cases.arrayPredicates = [[].every(() => false), [].some(() => true), + [1].some(() => []), Array.isArray([]), Array.isArray({})]; + +const text = "aπŸ˜€bπŸ˜€"; +cases.stringIndex = [text.length, text[1], text[-1], text.at(-1), + text.charAt(20), text.charCodeAt(20), text.charCodeAt(1), text.codePointAt(1), text.codePointAt(2)]; +cases.stringSearch = [text.indexOf("πŸ˜€"), text.lastIndexOf("πŸ˜€"), text.lastIndexOf("πŸ˜€", 3), + text.indexOf("", Infinity), text.startsWith("πŸ˜€", 1), text.endsWith("b", 4), text.includes("\ude00")]; +cases.stringSlice = [text.slice(1, 3), text.slice(-2), text.substring(3, 1), text.substring(-5, 1)]; +cases.stringSplit = ["πŸ˜€".split(""), "a,b,c".split(",", 2), "a,".split(","), "".split(""), "ab".split(), "ab".split("", 2**32)]; +cases.stringConstructors = [String.fromCharCode(0xD83D, 0xDE00), String.fromCharCode(65537), String.fromCodePoint(0x1F600), Array.from(text)]; +cases.stringOther = ["\ufeff hi \n".trim(), "\x85hi\x85".trim(), "Straße".toUpperCase(), "AbC".toLowerCase(), "ab".repeat(2.8)]; +cases.stringBoundary = ["abc".lastIndexOf("", 0), "abc".lastIndexOf("", -1), "abc".lastIndexOf("", NaN), "abc".substring(Infinity, -Infinity)]; + +const nums = [undefined, null, true, "1", NaN, Infinity, -0, 3.5, 3, 2**53 - 1, 2**53]; +cases.numberPredicates = nums.map(value => [Number.isNaN(value), Number.isFinite(value), Number.isInteger(value), Number.isSafeInteger(value)]); +cases.parseInt = ["12px", "0xff", "010", "1e3", "-0", "bad", "0x"].map(value => parseInt(value)); +cases.parseRadix = [["ffz", 16], ["11", 2], ["12", 1], ["z", 36], ["0b11", 0]].map(([text, radix]) => parseInt(text, radix)); +cases.parseFloat = ["-1.2e2px", "1e+", "0xff", "Infinity!", "bad"].map(value => parseFloat(value)); +const rounding = [-Infinity, -2.5, -1.5, -0.5, -0.2, -0, 0, 0.49999999999999994, 0.5, 1.5, Infinity, NaN]; +cases.mathRounding = rounding.map(value => [Math.floor(value), Math.ceil(value), Math.trunc(value), Math.round(value), Math.sign(value)]); +cases.mathExtrema = [Math.min(), Math.max(), Math.min(0, -0), Math.max(-0, 0), Math.min(NaN, 1), Math.sqrt(-1), Math.sqrt(-0)]; +const powers = [[NaN, 0], [1, NaN], [1, Infinity], [-1, Infinity], [-0, -3], [-2, 1025], [-1, .5], [-Infinity, 3], [-Infinity, -3], [0, -Infinity]]; +cases.mathPow = powers.map(([base, exponent]) => Math.pow(base, exponent)); +cases.numberString = [1e-7, 1e-6, 1e20, 1e21, 5e-324, -0, 9007199254740993].map(value => String(value)); + +cases.jsonParse = JSON.parse('{"items":[{"name":"Ada"}],"big":9007199254740993,"zero":-0}'); +cases.jsonOmit = JSON.stringify({missing: undefined, fn: () => null, null: null, + array: Array(1).concat([undefined, NaN, Infinity, -0])}); +cases.jsonUnicode = JSON.stringify({b: "ι›ͺπŸ˜€", 2: 1, 1: "\ud800", number: 1e-7}); +cases.jsonSpace = JSON.stringify({x: [1, {y: true}]}, null, 2); +cases.jsonTop = [JSON.stringify(undefined), JSON.stringify(NaN), JSON.stringify(-0), JSON.stringify("\ud83d\ude00")]; + +process.stdout.write(JSON.stringify(encode(cases))); diff --git a/tests/test_conformance.py b/tests/test_conformance.py new file mode 100644 index 0000000..00098df --- /dev/null +++ b/tests/test_conformance.py @@ -0,0 +1,120 @@ +"""Compare independent native-JavaScript fixtures with Python observations.""" + +import json +import math +from pathlib import Path +import shutil +import subprocess +import unittest + +from JavaScript import Array, Boolean, JSON, Math, Number, Object, String, parseFloat, parseInt, strictEqual, typeof, undefined + + +def encode(value): + if value is undefined: + return {"type": "undefined"} + if value is None: + return {"type": "null"} + if isinstance(value, bool): + return {"type": "boolean", "value": value} + if isinstance(value, (int, float)): + if math.isnan(value): + value = "NaN" + elif math.isinf(value): + value = "Infinity" if value > 0 else "-Infinity" + elif value == 0 and math.copysign(1, value) < 0: + value = "-0" + return {"type": "number", "value": value} + if isinstance(value, str): + # Canonicalize equivalent Python astral and explicit surrogate sequences. + value = value.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "surrogatepass") + return {"type": "string", "value": value} + if isinstance(value, (Array, list, tuple)): + entries = Object.entries(value) if isinstance(value, Array) else enumerate(value) + return {"type": "array", "length": len(value), "items": [[str(key), encode(child)] for key, child in entries]} + entries = Object.entries(value) if isinstance(value, Object) else value.items() + return {"type": "object", "entries": [[key, encode(child)] for key, child in entries]} + + +def observations(): + cases = {} + values = [undefined, None, False, True, 0, -0.0, math.nan, math.inf, -math.inf, + "", "0", "false", " \ufeff42\n", "0xff", "-0x1", "0b11", "0o10", ".5e2", "1_000", + "inf", "\x851", "οΌ‘οΌ’", Array(), Array.of(1), Array.of(1, 2), Object()] + cases["conversion"] = [[Boolean(value), Number(value), String(value), typeof(value)] for value in values] + cases["equality"] = [strictEqual(1, True), strictEqual(math.nan, math.nan), strictEqual(0, -0.0), Object.is_(0, -0.0), Object.is_(math.nan, math.nan), strictEqual(Object(), Object()), strictEqual("πŸ˜€", "\ud83d\ude00")] + obj = Object({"b": 1, "10": 2, "2": 3, "01": 4, "0": 5, "4294967295": 6}) + cases["objectOrder"] = Object.entries(obj) + del obj.b + obj.b = 7 + cases["objectReinsert"] = Object.keys(obj) + cases["objectCopy"] = Object.assign(Object(), {"a": 1}, None, undefined, {"a": 2, "b": 3}) + cases["fromEntries"] = Object.fromEntries([["a", 1], ["a", 2], ["b"]]) + sparse = Array(4) + sparse[1], sparse[3] = undefined, 4 + cases["sparse"] = sparse + cases["sparseMap"] = sparse.map(lambda value, index: Array.of(value, index)) + cases["sparseFilter"] = sparse.filter(lambda: True) + cases["sparseFind"] = [sparse.findIndex(lambda value: value is undefined), sparse.findLastIndex(lambda value: value is undefined)] + cases["sparseSearch"] = [sparse.includes(undefined), sparse.indexOf(undefined), sparse.lastIndexOf(undefined), sparse.lastIndexOf(undefined, undefined)] + cases["sparseSlice"] = sparse.slice(0, -1) + spliced = sparse.slice() + cases["sparseSpliceRemoved"] = spliced.splice(1, 2, "x", "y", "z") + cases["sparseSpliceResult"] = spliced + cases["sparseReverse"] = sparse.slice().reverse() + cases["sparseSort"] = Array(1).concat(Array.of(10, 2, undefined, 1)).sort() + cases["numericSort"] = Array.of(10, 2, 1).sort(lambda a, b: a - b) + cases["arraySearch"] = [Array.of(1, True, math.nan, 0, undefined).indexOf(value) for value in [math.nan, True, 1, -0.0, undefined]] + cases["arrayIncludes"] = [Array.of(1, True, math.nan, 0).includes(value) for value in [math.nan, True, 1, -0.0, undefined]] + mutated, seen = Array.of(1, 2, 3), [] + def visit(value, index, array): + seen.append(value) + if index == 0: + array[1] = 9 + del array[2] + array.push(4) + return value + cases["callbackMutation"] = mutated.map(visit) + cases["callbackSeen"] = seen + cases["reduce"] = [sparse.reduce(lambda total, value: total + (value if Boolean(value) else 0), 0), Array.of(2, 3).reduceRight(lambda a, b: a - b)] + cases["fromSparse"] = Array.from_(Array(2)) + cases["join"] = Array.of(1, None, undefined, Array.of(2, 3)).join("|") + cases["arrayPredicates"] = [Array().every(lambda: False), Array().some(lambda: True), Array.of(1).some(lambda: Array()), Array.isArray(Array()), Array.isArray(Object())] + text = String("aπŸ˜€bπŸ˜€") + cases["stringIndex"] = [text.length, text[1], text[-1], text.at(-1), text.charAt(20), text.charCodeAt(20), text.charCodeAt(1), text.codePointAt(1), text.codePointAt(2)] + cases["stringSearch"] = [text.indexOf("πŸ˜€"), text.lastIndexOf("πŸ˜€"), text.lastIndexOf("πŸ˜€", 3), text.indexOf("", math.inf), text.startsWith("πŸ˜€", 1), text.endsWith("b", 4), text.includes("\ude00")] + cases["stringSlice"] = [text.slice(1, 3), text.slice(-2), text.substring(3, 1), text.substring(-5, 1)] + cases["stringSplit"] = [String("πŸ˜€").split(""), String("a,b,c").split(",", 2), String("a,").split(","), String("").split(""), String("ab").split(), String("ab").split("", 2**32)] + cases["stringConstructors"] = [String.fromCharCode(0xD83D, 0xDE00), String.fromCharCode(65537), String.fromCodePoint(0x1F600), Array.from_(text)] + cases["stringOther"] = [String("\ufeff hi \n").trim(), String("\x85hi\x85").trim(), String("Straße").toUpperCase(), String("AbC").toLowerCase(), String("ab").repeat(2.8)] + cases["stringBoundary"] = [String("abc").lastIndexOf("", 0), String("abc").lastIndexOf("", -1), String("abc").lastIndexOf("", math.nan), String("abc").substring(math.inf, -math.inf)] + nums = [undefined, None, True, "1", math.nan, math.inf, -0.0, 3.5, 3, 2**53 - 1, 2**53] + cases["numberPredicates"] = [[Number.isNaN(value), Number.isFinite(value), Number.isInteger(value), Number.isSafeInteger(value)] for value in nums] + cases["parseInt"] = [parseInt(value) for value in ["12px", "0xff", "010", "1e3", "-0", "bad", "0x"]] + cases["parseRadix"] = [parseInt(text, radix) for text, radix in [("ffz", 16), ("11", 2), ("12", 1), ("z", 36), ("0b11", 0)]] + cases["parseFloat"] = [parseFloat(value) for value in ["-1.2e2px", "1e+", "0xff", "Infinity!", "bad"]] + rounding = [-math.inf, -2.5, -1.5, -0.5, -0.2, -0.0, 0, 0.49999999999999994, 0.5, 1.5, math.inf, math.nan] + cases["mathRounding"] = [[Math.floor(value), Math.ceil(value), Math.trunc(value), Math.round(value), Math.sign(value)] for value in rounding] + cases["mathExtrema"] = [Math.min(), Math.max(), Math.min(0, -0.0), Math.max(-0.0, 0), Math.min(math.nan, 1), Math.sqrt(-1), Math.sqrt(-0.0)] + powers = [(math.nan, 0), (1, math.nan), (1, math.inf), (-1, math.inf), (-0.0, -3), (-2, 1025), (-1, .5), (-math.inf, 3), (-math.inf, -3), (0, -math.inf)] + cases["mathPow"] = [Math.pow(base, exponent) for base, exponent in powers] + cases["numberString"] = [String(value) for value in [1e-7, 1e-6, 1e20, 1e21, 5e-324, -0.0, 9007199254740993]] + cases["jsonParse"] = JSON.parse('{"items":[{"name":"Ada"}],"big":9007199254740993,"zero":-0}') + cases["jsonOmit"] = JSON.stringify(Object({"missing": undefined, "fn": lambda: None, "null": None, "array": Array(1).concat(Array.of(undefined, math.nan, math.inf, -0.0))})) + cases["jsonUnicode"] = JSON.stringify(Object({"b": "ι›ͺπŸ˜€", "2": 1, "1": "\ud800", "number": 1e-7})) + cases["jsonSpace"] = JSON.stringify(Object({"x": Array.of(1, Object({"y": True}))}), None, 2) + cases["jsonTop"] = [JSON.stringify(undefined), JSON.stringify(math.nan), JSON.stringify(-0.0), JSON.stringify("\ud83d\ude00")] + return cases + + +@unittest.skipUnless(shutil.which("node"), "Node.js is required for differential conformance") +class ConformanceTests(unittest.TestCase): + def test_native_javascript_observations(self): + fixture = Path(__file__).parent / "fixtures" / "conformance.mjs" + result = subprocess.run(["node", str(fixture)], capture_output=True, text=True, encoding="utf-8", timeout=30, check=True) + expected = dict(json.loads(result.stdout)["entries"]) + actual = observations() + self.assertEqual(set(actual), set(expected)) + for name, value in actual.items(): + with self.subTest(case=name): + self.assertEqual(encode(value), expected[name]) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..318649e --- /dev/null +++ b/uv.lock @@ -0,0 +1,36 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "javascript-fetch-api" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/48/10ff71084c91abe71951a1ec2d5e0609828f93dd2b715da1ce969181b71a/javascript_fetch_api-0.2.0.tar.gz", hash = "sha256:3af21b723fd570d94beb91c9e1080fa50fed6c3fdc4c9cee3c85dca04fc93d76", size = 11907, upload-time = "2026-10-16T19:45:57.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/50/6255cbb902d7b4ec20c42e7a2bb13c81de87bd0572ac44abb286dd6638c1/javascript_fetch_api-0.2.0-py3-none-any.whl", hash = "sha256:b8a4965645b37928bb3348706994d22de72d0599ed81e9ed3baf1c519c6f60d1", size = 13654, upload-time = "2026-10-16T19:45:55.663Z" }, +] + +[[package]] +name = "javascript-json" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/92/d542ba9ae59528359389ad2afcc5dbd3283755ed9ffc95e7c173a98f74b9/javascript_json-0.1.0.tar.gz", hash = "sha256:19ce727971b44f39fd2a2a59b18ea774fb1c4f7421599964ad42a4aedceb4d4b", size = 4173, upload-time = "2026-10-16T19:08:54.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/a6/7c057df3ddd6799e7f6381da326917903ba52babb972a109702e1077b4b6/javascript_json-0.1.0-py3-none-any.whl", hash = "sha256:e119be47ba76924bb1757af6a55c0b7cd53e0d08b55f8157fc0081fc65052bef", size = 5047, upload-time = "2026-10-16T19:08:53.384Z" }, +] + +[[package]] +name = "python-javascript" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "javascript-fetch-api" }, + { name = "javascript-json" }, +] + +[package.metadata] +requires-dist = [ + { name = "javascript-fetch-api", specifier = ">=0.2.0,<0.3.0" }, + { name = "javascript-json", specifier = ">=0.1.0,<0.2.0" }, +]