Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ jobs:
python -m venv .venv
.venv/bin/python -m pip wheel --no-deps ./packages/cso-python --wheel-dir artifacts
.venv/bin/python -m pip install --no-index --find-links artifacts cs-object
.venv/bin/python -m pip install -r packages/cso-python/tests/requirements.txt
echo "PYTHON=$GITHUB_WORKSPACE/.venv/bin/python" >> "$GITHUB_ENV"
- run: npx playwright install --with-deps chromium
- run: npm audit --audit-level=high
Expand Down
4 changes: 4 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ Run package behavior tests for the changed project, then the affected
root commands; package manifests define their local commands.

`npm test`, `npm run typecheck` and `npm run lint` cover the workspace.
Before running Python tests locally, install their declared test tools with
`"$PYTHON" -m pip install -r packages/cso-python/tests/requirements.txt`.
`npm run test:python` includes real mypy consumers of generated bindings. CI,
isolation and installed-package suites install the same test-only requirements.
`npm run test:isolation` builds/tests projects without root fixtures.
`npm run test:packages` installs actual archives and a wheel into fresh external
consumers. These installation checks need build prerequisites and dependency
Expand Down
7 changes: 7 additions & 0 deletions packages/cso-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
`cso bindings` generates typed calculation handles. `dev-export` and `dev-render`
retain the unverified development paths.

`dev-render` is a math-only preview. It omits standalone text, figures, methods,
tables and unknown document items, and warns on stderr when these are present.
Assumptions and reference text therefore require `cso html` or `cso pdf` for a
complete report. The omission warning is separate from the command's unverified
numerical status; a successful development render establishes neither numerical
verification nor full document retention.

## Run

Follow [setup](../../docs/development.md#setup). In the workspace use
Expand Down
4 changes: 2 additions & 2 deletions packages/cso-cli/src/arguments.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { PythonIdentifierSchema } from '@cs-object/core';
import { resolve } from 'node:path';
import { PythonIdentifierSchema } from '@cs-object/core';

export class UsageError extends Error {}

Expand Down Expand Up @@ -168,7 +168,7 @@ Commands:
dev Serve a verified calculation, editable inputs and report on localhost.
bindings Generate typed calculation handles; --check reports stale files without writing.
dev-export Export CSO JSON using the selected installed Python module.
dev-render Export once and render a development PDF with installed React/CSS.
dev-render Render an unverified math-only PDF; warn about omitted document content.
verify Execute once and verify formulas; use cso verify --help.
html Verify once and publish standalone HTML; --check-layout adds browser checks.
pdf Verify once, retain evidence unless --no-evidence, and atomically publish a PDF.
Expand Down
45 changes: 34 additions & 11 deletions packages/cso-cli/src/development.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
import { stringifyJson } from './json.ts';
import { randomUUID } from 'node:crypto';
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
import { dirname, join, basename } from 'node:path';
import { chromium } from 'playwright';
import * as React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { FormulaSheet } from '@cs-object/react';
import { basename, dirname, join } from 'node:path';
import {
type CalculationSourceObject,
CalculationSourceObjectSchema,
createSheetFromCalculationSourceObject,
type CalculationSourceObject,
type SheetDocument,
} from '@cs-object/core';
import { UsageError, type DevelopmentOptions } from './arguments.ts';
import { FormulaSheet } from '@cs-object/react';
import { chromium } from 'playwright';
import * as React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { type DevelopmentOptions, UsageError } from './arguments.ts';
import { stringifyJson } from './json.ts';
import { runPythonProcess } from './python-process.ts';
export { isolatedEnvironment } from './python-process.ts';

export { parseCliArgs } from './arguments.ts';
export { isolatedEnvironment } from './python-process.ts';
export type AnnotatedPythonPdfOptions = DevelopmentOptions;
export interface PythonExporterOptions {
readonly functionName: string;
Expand Down Expand Up @@ -168,6 +169,28 @@ export const renderAnnotatedPythonPdf = async (
options: AnnotatedPythonPdfOptions,
): Promise<void> => {
const source = runPythonExporter(options);
const omissions = new Map<string, number>();
for (const item of [
...source.sections.flatMap((section) => section.items),
...(source.detachedItems ?? []),
]) {
if (
item.kind !== 'symbol' &&
item.kind !== 'symbolRef' &&
item.kind !== 'section'
) {
omissions.set(item.kind, (omissions.get(item.kind) ?? 0) + 1);
}
}
if (omissions.size > 0) {
const counts = [...omissions]
.map(([kind, count]) => `${kind}: ${count}`)
.join(', ');
process.stderr.write(
`Warning: dev-render is a math-only preview and omits authored document content (${counts}). ` +
'Use cso html or cso pdf to retain document content. Development output remains numerically unverified.\n',
);
}
const calculation = createSheetFromCalculationSourceObject(source, {
id: source.source.id,
label: source.title,
Expand Down
5 changes: 4 additions & 1 deletion packages/cso-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ Use full captured execution or prepared HTML/PDF instead.
[execution](src/cso_python/execution.py) and [bindings](src/cso_python/bindings.py)
implement the pipeline described in [the code map](../../docs/code-map.md).

With the wheel installed, run `python -I tests/run.py` from this package directory.
With the wheel installed, run `python -m pip install -r tests/requirements.txt`
and `python -I tests/run.py` from this package directory. The test-only mypy pin
checks positive and negative consumers of freshly generated bindings, including
keyword arguments and typed public result keys. Runtime dependencies stay empty.
The tests use package-owned synthetic fixtures and temporary bindings; no root
examples or Node are required. [Workspace integration](../../tests/integration/README.md)
owns actual example, installed cross-package and PDF acceptance.
20 changes: 18 additions & 2 deletions packages/cso-python/src/cso_python/bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ def generate(directory: Path, *, check: bool = False) -> dict:
"INVALID_DEPENDENCY_PATH",
"Generated bindings cannot contain symbolic links",
)
files: dict[Path, bytes] = {output / "__init__.py": b"", output / "py.typed": b""}
modules: dict[Path, tuple[str, ...]] = {}
packages: dict[tuple[str, ...], Path] = {}
for source in sources:
relative = source.relative_to(root)
parts = [*relative.parts[:-1], relative.name[:-7]]
parts = (*relative.parts[:-1], relative.name[:-7])
if parts[0] == "_external":
raise SourceError(
"BINDING_OUTPUT_CONFLICT",
Expand All @@ -46,6 +47,21 @@ def generate(directory: Path, *, check: bool = False) -> dict:
"INVALID_BINDING_NAME",
f"Binding module needs identifier path components: {relative}",
)
modules[source] = parts
for length in range(1, len(parts)):
packages.setdefault(parts[:length], source)
# Python prefers a package over a module with the same import name. Check
# the complete plan before capturing definitions or touching existing files.
for source, parts in modules.items():
if parts in packages:
child = packages[parts].relative_to(root)
raise SourceError(
"BINDING_OUTPUT_CONFLICT",
f"Binding module {source.relative_to(root)} conflicts with "
f"package {'/'.join(parts)}/ required by {child}",
)
files: dict[Path, bytes] = {output / "__init__.py": b"", output / "py.typed": b""}
for source, parts in modules.items():
capture = Capture(source)
capture.root = root
capture.for_bindings = True
Expand Down
2 changes: 2 additions & 0 deletions packages/cso-python/tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Test-only consumer verification. The runtime package has no dependencies.
mypy==1.18.2
104 changes: 104 additions & 0 deletions packages/cso-python/tests/test_binding_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Check generated public interfaces through real mypy consumers."""

import re
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

from cso_python.bindings import generate


class BindingTypesTest(unittest.TestCase):
def test_generated_consumers_and_any_mutation(self):
with tempfile.TemporaryDirectory(prefix="cso binding types ") as temporary:
root = Path(temporary)
source = '''from typing import Annotated
from cso_python import calculation, section, symbol

@calculation(id="adjust", title="Adjust amount")
@section(id="adjust", title="Adjustment", root=True)
def calculate(
amount: Annotated[float, symbol(glyph="Q_{in}", description="Amount", unit="m")],
increment: Annotated[float, symbol(glyph="d_{inc}", description="Increment", unit="m")] = 3.0,
):
adjusted: Annotated[float, symbol(glyph="Q_{out}", description="Adjusted amount", unit="m")] = amount + increment
return {"adjusted": adjusted, "original": amount}
'''
(root / "sample.cso.py").write_text(source, encoding="utf-8")
(root / "nested").mkdir()
(root / "nested/count.cso.py").write_text(
source.replace("float", "int").replace("3.0", "3"), encoding="utf-8"
)
generate(root)
consumers = {
"valid.py": '''from _cso_bindings.sample import calculate
from _cso_bindings.nested.count import calculate as count
result = calculate(amount=2.0)
adjusted: float = result["adjusted"]
original: float = result["original"]
calculate(amount=2, increment=3.0)
counted: int = count(amount=2)["adjusted"]
count(amount=2, increment=4)
# Python typing permits bool as an int subtype. CSO runtime rejects bool.
count(amount=True)
''',
"unknown_keyword.py": 'calculate(ammount=2.0)',
"missing_input.py": 'calculate()',
"wrong_input.py": 'calculate(amount="two")',
"positional.py": 'calculate(2.0)',
"private_output.py": 'calculate(amount=2.0)["private_intermediate"]',
"wrong_result.py": 'result: str = calculate(amount=2.0)["adjusted"]',
"float_to_int.py": 'from _cso_bindings.nested.count import calculate\ncalculate(amount=2.5)',
}
for name, content in consumers.items():
if name not in ("valid.py", "float_to_int.py"):
content = "from _cso_bindings.sample import calculate\n" + content
(root / name).write_text(content + "\n", encoding="utf-8")

def check(*names):
result = subprocess.run(
[
sys.executable, "-I", "-m", "mypy", "--strict",
"--no-incremental", "--no-error-summary",
"--show-error-codes", "--no-pretty", *names,
],
cwd=root,
capture_output=True,
text=True,
)
# Missing checker/imports must never count as an expected failure.
self.assertEqual(result.stderr, "", result.stderr)
diagnostics = re.findall(
r"^([^:]+\.py):(\d+): error: .* \[([^\]]+)\]$",
result.stdout,
re.MULTILINE,
)
return result, set(diagnostics)

valid, diagnostics = check("valid.py")
self.assertEqual(valid.returncode, 0, valid.stdout)
self.assertEqual(diagnostics, set())
expected = {
("unknown_keyword.py", "2", "call-arg"),
("missing_input.py", "2", "call-arg"),
("wrong_input.py", "2", "arg-type"),
("positional.py", "2", "misc"),
("private_output.py", "2", "typeddict-item"),
("wrong_result.py", "2", "assignment"),
("float_to_int.py", "2", "arg-type"),
}
invalid_names = [name for name in consumers if name != "valid.py"]
invalid, diagnostics = check(*invalid_names)
self.assertEqual(invalid.returncode, 1, invalid.stdout)
self.assertEqual(diagnostics, expected, invalid.stdout)

# Prove these consumers detect loss of the callable's type contract.
(root / "_cso_bindings/sample.pyi").write_text(
"from typing import Any\ncalculate: Any\n", encoding="utf-8"
)
weakened, weakened_diagnostics = check(*invalid_names)
self.assertEqual(weakened.returncode, 1, weakened.stdout)
self.assertEqual(weakened_diagnostics, {("float_to_int.py", "2", "arg-type")})
self.assertNotEqual(weakened_diagnostics, expected)
63 changes: 63 additions & 0 deletions packages/cso-python/tests/test_bindings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Generated binding bytes must remain portable and detect file drift."""

import runpy
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
Expand Down Expand Up @@ -72,6 +74,67 @@ def test_non_utf8_authored_file_is_preserved(self):
self.assertEqual(raised.exception.diagnostic["code"], "BINDING_OUTPUT_CONFLICT")
self.assertEqual(path.read_bytes(), authored)

def test_module_package_collisions_fail_before_writing_in_both_modes(self):
source = self.root / "élément" / "café.cso.py"
content = source.read_bytes()
for module, child in (
("sample.cso.py", "sample/child.cso.py"),
("group/sample.cso.py", "group/sample/child.cso.py"),
):
with self.subTest(module=module):
calculation = self.root / module
nested = self.root / child
calculation.parent.mkdir(parents=True, exist_ok=True)
nested.parent.mkdir(parents=True, exist_ok=True)
calculation.write_bytes(content)
nested.write_bytes(content)
for check in (False, True):
with self.assertRaises(SourceError) as raised:
generate(self.root, check=check)
diagnostic = raised.exception.diagnostic
self.assertEqual(diagnostic["code"], "BINDING_OUTPUT_CONFLICT")
self.assertIn(str(Path(module)), diagnostic["message"])
self.assertIn(str(Path(child)), diagnostic["message"])
self.assertFalse((self.root / "_cso_bindings").exists())
calculation.unlink()
nested.unlink()

def test_collision_preserves_valid_output_and_valid_imports(self):
source = self.root / "élément" / "café.cso.py"
flat = self.root / "sample.cso.py"
flat.write_bytes(source.read_bytes())
generate(self.root)
output = self.root / "_cso_bindings"
saved = {
p.relative_to(output): p.read_bytes()
for p in output.rglob("*") if p.is_file()
}
# Resolve through Python's import machinery, rather than reading stub text.
result = subprocess.run(
[
sys.executable, "-c",
"from _cso_bindings.sample import calculate; "
"from _cso_bindings.élément.café import calculate as nested; "
"assert calculate(width=3) == nested(width=3) == {'café': 9}",
],
cwd=self.root,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
nested = self.root / "sample" / "child.cso.py"
nested.parent.mkdir()
nested.write_bytes(source.read_bytes())
for check in (False, True):
with self.assertRaises(SourceError):
generate(self.root, check=check)
current = {
p.relative_to(output): p.read_bytes()
for p in output.rglob("*")
if p.is_file() and "__pycache__" not in p.parts
}
self.assertEqual(current, saved)


if __name__ == "__main__":
unittest.main()
11 changes: 3 additions & 8 deletions packages/cso-react/src/ascii-math/NotationMathmlView.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { assertNever } from '@cs-object/core';
import type { NotationExpression, NotationNode } from '@cs-object/core';
import { assertNever } from '@cs-object/core';
import type { ReactElement } from 'react';
import type { MathMathMLAttributes } from '../mathml/attributes.ts';
import { MATHML_FONT_SIZE } from '../mathml/constants.ts';

interface NotationRenderOptions {
readonly identifierMathVariant?: 'normal';
Expand Down Expand Up @@ -116,12 +115,8 @@ const NotationNodeMathmlView = ({
case 'fraction':
return (
<mfrac>
<mstyle style={{ fontSize: MATHML_FONT_SIZE }}>
{renderNode(node.numerator)}
</mstyle>
<mstyle style={{ fontSize: MATHML_FONT_SIZE }}>
{renderNode(node.denominator)}
</mstyle>
<mrow>{renderNode(node.numerator)}</mrow>
<mrow>{renderNode(node.denominator)}</mrow>
</mfrac>
);
case 'subscript':
Expand Down
Loading
Loading