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
Binary file not shown.
26 changes: 9 additions & 17 deletions Source/Workspace/Celbridge.Python/Celbridge.Python.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -49,30 +49,22 @@
<PythonPackageFolder>$(MSBuildThisFileDirectory)packages\celbridge</PythonPackageFolder>
<PythonAssetsFolder>$(MSBuildThisFileDirectory)Assets\Python</PythonAssetsFolder>
<PythonWheelPath>$(PythonAssetsFolder)\celbridge-0.1.0-py3-none-any.whl</PythonWheelPath>
<!-- Use a TFM-independent temp folder so parallel builds don't conflict -->
<UvTempFolder>$(MSBuildThisFileDirectory)obj\uv_temp</UvTempFolder>
</PropertyGroup>

<ItemGroup>
<PythonSourceFiles Include="$(PythonPackageFolder)\src\celbridge\*.py" />
<PythonSourceFiles Include="$(PythonPackageFolder)\src\celbridge\**\*.py" />
<PythonSourceFiles Include="$(PythonPackageFolder)\pyproject.toml" />
</ItemGroup>

<!-- Build the wheel for one TFM only. Other TFMs pick it up via IncludePythonAssets. -->
<Target Name="BuildPythonWheel" BeforeTargets="Build" DependsOnTargets="DownloadUv"
<!-- Build the wheel for one TFM only. Other TFMs pick it up via IncludePythonAssets.
Delegates to build.py so wheel-building logic lives in one place; build.py is
also the script developers run manually outside Visual Studio. Requires a
system Python on PATH at build time. -->
<Target Name="BuildPythonWheel" BeforeTargets="Build"
Inputs="@(PythonSourceFiles)" Outputs="$(PythonWheelPath)"
Condition="'$(UvPlatform)' != '' AND '$(TargetFramework)' == 'net9.0-windows10.0.22621'">
<Message Text="Building celbridge Python wheel..." Importance="high" />

<!-- Extract uv from the zip to a temp folder -->
<RemoveDir Directories="$(UvTempFolder)" />
<Unzip SourceFiles="$(UvZipPath)" DestinationFolder="$(UvTempFolder)" />

<!-- Build the wheel using uv -->
<Exec Command="&quot;$(UvTempFolder)\uv.exe&quot; build --wheel --out-dir &quot;$(PythonAssetsFolder)&quot; &quot;$(PythonPackageFolder)&quot;" />

<!-- Clean up temp folder -->
<RemoveDir Directories="$(UvTempFolder)" />
Condition="'$(TargetFramework)' == 'net9.0-windows10.0.22621'">
<Message Text="Building celbridge Python wheel via build.py..." Importance="high" />
<Exec Command="python &quot;$(MSBuildThisFileDirectory)build.py&quot;" />
</Target>

<!-- Note: the wheel is NOT cleaned during 'Clean' because MSBuild evaluates Content items
Expand Down
2 changes: 1 addition & 1 deletion Source/Workspace/Celbridge.Python/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def build_wheel(pkg_dir):

subprocess.run(
["python", "-m", "pip", "wheel", "--no-deps", str(pkg_dir), "-w", str(dist)],
check=True, capture_output=True
check=True,
)
return list(dist.glob("*.whl"))[0]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ classifiers = [
keywords = ["celbridge", "repl"]
dependencies = [
"ipython>=8.0",
"pytest>=7.0",
]

[project.urls]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ def run_test(class_filter=None):
class names (e.g. "Spreadsheet"). When omitted, every
test class runs.
"""
from celbridge.test_suite import main as run_integration_test
run_integration_test(class_filter)
from celbridge.integration_tests import run_suite
run_suite(class_filter)

object.__setattr__(self, "test", run_test)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"""Celbridge MCP integration test suite.

Launched from the REPL via cel.test([class_filter]); see celbridge.cel_proxy.

Pytest does collection, fixtures, and assertion rewriting under the hood,
but its terminal reporter is disabled and its output capture is turned off
so it leaves the Windows console untouched. CelbridgeReporter below provides
the [n/N] PASS/FAIL/SKIP/ERROR output cel.test() users expect, mirrors
results into the Celbridge log, and implements the class-substring filter.
"""
from collections import Counter
from pathlib import Path

import pytest

import celbridge


GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
RESET = "\033[0m"


class CelbridgeReporter:

def __init__(self, class_filter=None):
if class_filter is None:
self._filters = None
elif isinstance(class_filter, str):
self._filters = [class_filter.lower()]
else:
self._filters = [name.lower() for name in class_filter]
self._total = 0
self._current = 0
self._failures = []
self._errors = []
self._available_classes = []

def pytest_collection_modifyitems(self, items):
seen = set()
for item in items:
cls_name = item.parent.name if item.parent else None
if cls_name and cls_name not in seen:
seen.add(cls_name)
self._available_classes.append(cls_name)

if self._filters is not None:
items[:] = [
item for item in items
if item.parent
and any(needle in item.parent.name.lower() for needle in self._filters)
]

self._total = len(items)

def pytest_collection_finish(self, session):
if self._total == 0:
if self._filters is not None:
names = ", ".join(self._available_classes)
shown = self._filters[0] if len(self._filters) == 1 else self._filters
print(f"\nNo test classes match {shown!r}. Available: {names}\n")
else:
print("\nNo tests collected.\n")
return
by_class = Counter()
for item in session.items:
if item.parent:
by_class[item.parent.name] += 1
if self._filters is not None:
running = ", ".join(sorted(by_class))
shown = self._filters[0] if len(self._filters) == 1 else self._filters
print(f"\nFilter: {shown!r} -> {running}")
print(f"Running {self._total} tests across {len(by_class)} classes...\n")

def pytest_runtest_logreport(self, report):
if report.when == "setup" and report.skipped:
self._current += 1
reason = self._skip_reason(report)
self._emit(report.nodeid, f"SKIP -- {reason}", YELLOW, celbridge.app.log_warning)
elif report.when == "setup" and report.failed:
self._current += 1
self._emit(report.nodeid, "ERROR", RED, celbridge.app.log_error)
self._errors.append((report.nodeid, str(report.longrepr)))
elif report.when == "call":
self._current += 1
if report.passed:
self._emit(report.nodeid, "PASS", GREEN, celbridge.app.log)
elif report.failed:
self._emit(report.nodeid, "FAIL", RED, celbridge.app.log_error)
self._failures.append((report.nodeid, str(report.longrepr)))

def pytest_sessionfinish(self, session, exitstatus):
if self._total == 0:
return
passed = self._current - len(self._failures) - len(self._errors)
print()
if self._failures or self._errors:
print(
f"Results: {GREEN}{passed} passed{RESET}, "
f"{RED}{len(self._failures)} failed{RESET}, "
f"{RED}{len(self._errors)} errors{RESET}"
)
else:
print(f"Results: {GREEN}{passed} passed{RESET}, 0 failed, 0 errors")

if self._failures:
print(f"\n{RED}Failures:{RESET}")
for nodeid, repr_ in self._failures:
print(f" {RED}{self._short_nodeid(nodeid)}{RESET}")
_print_failure_detail(repr_)

if self._errors:
print(f"\n{RED}Errors:{RESET}")
for nodeid, repr_ in self._errors:
print(f" {RED}{self._short_nodeid(nodeid)}{RESET}")
_print_failure_detail(repr_)

def pytest_internalerror(self, excrepr, excinfo):
print(f"\n{RED}Pytest internal error:{RESET}")
print(str(excrepr))

def _emit(self, nodeid, label, colour, logger):
prefix = f"[{self._current}/{self._total}]"
short = self._short_nodeid(nodeid)
print(f" {prefix} {colour}{label}{RESET}: {short}")
logger(f" {prefix} {label}: {short}")

@staticmethod
def _short_nodeid(nodeid):
# "test_app.py::TestApp::test_get_status" -> "TestApp.get_status".
# The "test_" prefix is required by pytest discovery but redundant
# in output, where [N/M] PASS and the TestX class already signal
# this is a test result.
parts = nodeid.split("::")
method = parts[-1]
if method.startswith("test_"):
method = method[len("test_"):]
if len(parts) >= 3:
return f"{parts[-2]}.{method}"
if len(parts) == 2:
return method
return nodeid

@staticmethod
def _skip_reason(report):
# report.longrepr for skips is (filename, lineno, reason).
if isinstance(report.longrepr, tuple) and len(report.longrepr) == 3:
return report.longrepr[2]
return "skipped"


def _print_failure_detail(traceback_str):
# The full traceback is verbose; the AssertionError block (and any
# diff that pytest writes for dict/list/string mismatches) is the
# informative part. Print every line from the first AssertionError
# to the end so we do not truncate diagnostic context.
lines = traceback_str.rstrip().splitlines()
start_index = 0
for index, line in enumerate(lines):
if "AssertionError" in line:
start_index = index
break
for line in lines[start_index:]:
print(f" {line}")


def run_suite(class_filter=None):
"""Run the integration suite.

class_filter is a case-insensitive substring match against test class
names (e.g. "Spreadsheet"), or an iterable of such substrings. When
omitted, every test class runs.
"""
suite_path = Path(__file__).parent
args = [
str(suite_path),
# Disable pytest's terminal reporter so it produces no output of
# its own; CelbridgeReporter handles every visible line. The
# --color flag is registered by this plugin, so we cannot pass it
# alongside no:terminal — but with the reporter disabled, pytest
# has no path that loads colorama anyway.
"-p", "no:terminal",
# No output capture. The default fd-level capture redirects file
# descriptors and leaves the Windows console in a state IPython's
# prompt_toolkit cannot recover from once pytest.main() returns.
"--capture=no",
]
return pytest.main(args, plugins=[CelbridgeReporter(class_filter)])
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Session-scoped fixtures wrapping the celbridge proxy modules.

These replace the module-level globals used by the previous test_suite.py.
"""
import pytest

import celbridge


@pytest.fixture(scope="session")
def app():
return celbridge.app


@pytest.fixture(scope="session")
def file():
return celbridge.file


@pytest.fixture(scope="session")
def query():
return celbridge.query


@pytest.fixture(scope="session")
def explorer():
return celbridge.explorer


@pytest.fixture(scope="session")
def document():
return celbridge.document


@pytest.fixture(scope="session")
def package():
return celbridge.package


@pytest.fixture(scope="session")
def webview():
return celbridge.webview


@pytest.fixture(scope="session")
def spreadsheet():
return celbridge.spreadsheet
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Shared test helpers.

These take the celbridge module objects as parameters because the new layout
has no module-level globals.
"""
import base64


def delete_if_exists(explorer, resource):
"""Delete a resource, ignoring errors if it does not exist."""
try:
explorer.delete(resource)
except Exception:
pass


def close_if_open(document, resource):
"""Close a document if it is currently open."""
try:
ctx = document.get_context()
if any(d["resource"] == resource for d in ctx.get("openDocuments", [])):
document.close(resource, force_close=True)
except Exception:
pass


def write_with_line_endings(file, resource, text_with_lf, line_ending):
"""Write a file with explicit line endings, bypassing file.write's
platform-default conversion. Used by line-ending preservation tests
to set up a file with known endings regardless of host OS.
"""
text = text_with_lf.replace("\n", line_ending)
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
file.write_binary(resource, encoded)
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
class TestApp:

def test_get_status(self, app):
result = app.get_status()
assert result["isLoaded"]
assert len(result["projectName"]) > 0

def test_get_version(self, app):
version = app.get_version()
parts = version.split(".")
assert len(parts) == 3, f"Expected 3-part version, got: {version}"

def test_log(self, app):
app.log("Integration test: log message")

def test_log_warning(self, app):
app.log_warning("Integration test: warning message")

def test_log_error(self, app):
app.log_error("Integration test: error message")

def test_refresh_files(self, app):
app.refresh_files()

def test_log_empty_message(self, app):
app.log("")

def test_log_unicode(self, app):
app.log("Unicode test: éèê 世界 😀")
Loading
Loading