Skip to content

Commit 31e37f6

Browse files
committed
added tests workflow
1 parent 7f8e7c2 commit 31e37f6

8 files changed

Lines changed: 223 additions & 4 deletions

File tree

.github/workflows/ci.yml

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
lint:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- uses: actions/setup-python@v5
19+
with:
20+
python-version: "3.12"
21+
22+
- name: Install
23+
run: pip install -e ".[dev]"
24+
25+
- name: Lint
26+
run: |
27+
ruff check .
28+
ruff format --check .
29+
30+
test:
31+
name: test (${{ matrix.os }}, ${{ matrix.python }})
32+
runs-on: ${{ matrix.os }}
33+
strategy:
34+
fail-fast: false
35+
matrix:
36+
os: [ubuntu-latest, macos-latest, windows-latest]
37+
python: ["3.9", "3.12"]
38+
39+
steps:
40+
- uses: actions/checkout@v4
41+
42+
- uses: actions/setup-python@v5
43+
with:
44+
python-version: ${{ matrix.python }}
45+
46+
- name: Install
47+
run: pip install -e ".[dev]"
48+
49+
- name: Test
50+
run: pytest -m "not dotnet"
51+
52+
# Builds the example bundles into actual .ghuser components and reads them
53+
# back. Windows is what the downstream `compas-actions/ghpython-components`
54+
# runs on; macOS is where the runtime needs the setup this package does itself.
55+
components:
56+
name: components (${{ matrix.os }})
57+
runs-on: ${{ matrix.os }}
58+
strategy:
59+
fail-fast: false
60+
matrix:
61+
os: [windows-latest, macos-latest]
62+
63+
env:
64+
GHPYTHON_COMPONENTIZER_REQUIRE_DOTNET: "1"
65+
66+
steps:
67+
- uses: actions/checkout@v4
68+
69+
- uses: actions/setup-python@v5
70+
with:
71+
python-version: "3.12"
72+
73+
- name: Install Mono and libgdiplus
74+
if: runner.os == 'macOS'
75+
run: brew install mono mono-libgdiplus
76+
77+
- name: Install
78+
run: pip install -e ".[dev]"
79+
80+
- name: Build the example components
81+
run: pytest -m dotnet -v

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### Changed
1313

14+
* Changed `discover_source_bundles` to skip folders that contain none of `code.py`, `icon.png` and `metadata.json`, so a build target inside the source directory no longer breaks the build.
15+
1416
### Removed
1517

1618

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,15 @@ ruff check .
239239
ruff format .
240240
```
241241

242-
The test suite covers the parts that do not require a .NET runtime, so it runs on any platform.
242+
Most of the test suite covers the parts that do not require a .NET runtime, so it runs on any
243+
platform. The tests marked `dotnet` build the [examples](examples/cpy) into actual components and
244+
read them back, and skip themselves when there is no runtime to do it with:
245+
246+
```bash
247+
pytest -m "not dotnet" # unit tests only
248+
pytest -m dotnet # build the example components
249+
```
250+
243251
Linting and formatting follow the same [ruff](https://docs.astral.sh/ruff/) rules as the other
244252
COMPAS packages, configured in `pyproject.toml`.
245253

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,6 @@ docstring-code-line-length = "dynamic"
105105

106106
[tool.pytest.ini_options]
107107
testpaths = ["tests"]
108+
markers = [
109+
"dotnet: tests that build actual components, and need a .NET runtime for it",
110+
]

src/ghpython_componentizer/bundle.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,25 @@
2323

2424
IGNORED_DIRS = ("__pycache__", ".git")
2525

26+
BUNDLE_FILES = ("code.py", "icon.png", "metadata.json")
27+
28+
29+
def _looks_like_bundle(path):
30+
"""Tell a source bundle from any other folder that happens to be around.
31+
32+
A folder holding none of the bundle files is not a bundle at all, typically
33+
a build target sitting inside the source directory. One holding only some of
34+
them is an incomplete bundle, which :func:`validate_source_bundle` reports.
35+
"""
36+
return any(os.path.exists(os.path.join(path, name)) for name in BUNDLE_FILES)
37+
2638

2739
def discover_source_bundles(sourcedir):
2840
"""List the names of all component bundles found in a source directory.
2941
42+
Folders that contain none of ``code.py``, ``icon.png`` and ``metadata.json``
43+
are not bundles, and are left out.
44+
3045
Parameters
3146
----------
3247
sourcedir : str
@@ -37,7 +52,11 @@ def discover_source_bundles(sourcedir):
3752
list of str
3853
Bundle folder names, sorted alphabetically.
3954
"""
40-
return sorted(d for d in os.listdir(sourcedir) if os.path.isdir(os.path.join(sourcedir, d)) and d not in IGNORED_DIRS and not d.startswith("."))
55+
return sorted(
56+
d
57+
for d in os.listdir(sourcedir)
58+
if os.path.isdir(os.path.join(sourcedir, d)) and d not in IGNORED_DIRS and not d.startswith(".") and _looks_like_bundle(os.path.join(sourcedir, d))
59+
)
4160

4261

4362
def validate_source_bundle(source):

tests/test_build_examples.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""End to end: build the example bundles and read the components back.
2+
3+
These tests need a working .NET runtime, so they are marked `dotnet` and skip
4+
themselves when there is none. Set `GHPYTHON_COMPONENTIZER_REQUIRE_DOTNET=1` to
5+
turn that skip into a failure, which is what CI does.
6+
"""
7+
8+
import json
9+
import os
10+
from pathlib import Path
11+
12+
import pytest
13+
14+
from ghpython_componentizer import run_componentizer
15+
from ghpython_componentizer.bundle import discover_source_bundles
16+
17+
EXAMPLES = Path(__file__).resolve().parent.parent / "examples" / "cpy"
18+
PREFIX = "(TEST) "
19+
VERSION = "1.2.3"
20+
21+
pytestmark = pytest.mark.dotnet
22+
23+
24+
def dotnet_is_available():
25+
try:
26+
import clr # noqa: F401
27+
except Exception:
28+
return False
29+
30+
return True
31+
32+
33+
if not EXAMPLES.exists():
34+
pytest.skip("example bundles are not part of the distribution", allow_module_level=True)
35+
36+
if not dotnet_is_available() and os.environ.get("GHPYTHON_COMPONENTIZER_REQUIRE_DOTNET") != "1":
37+
pytest.skip("no .NET runtime available", allow_module_level=True)
38+
39+
40+
def read_component(path):
41+
"""Read a .ghuser file back into the chunk it was serialized from."""
42+
from ghpython_componentizer.ghio import ensure_ghio
43+
44+
ensure_ghio()
45+
46+
import System
47+
from GH_IO.Serialization import GH_LooseChunk
48+
49+
chunk = GH_LooseChunk("UserObject")
50+
chunk.Deserialize_Binary(System.IO.File.ReadAllBytes(str(path)))
51+
52+
return chunk
53+
54+
55+
@pytest.fixture(scope="module")
56+
def built(tmp_path_factory):
57+
"""Build every example bundle once, and return the target folder."""
58+
target = tmp_path_factory.mktemp("ghuser")
59+
run_componentizer(EXAMPLES, target, version=VERSION, prefix=PREFIX)
60+
61+
return target
62+
63+
64+
def test_builds_one_component_per_bundle(built):
65+
expected = sorted(name + ".ghuser" for name in discover_source_bundles(str(EXAMPLES)))
66+
67+
assert sorted(p.name for p in built.iterdir()) == expected
68+
69+
70+
def test_components_are_readable_by_gh_io(built):
71+
for bundle in discover_source_bundles(str(EXAMPLES)):
72+
metadata = json.loads((EXAMPLES / bundle / "metadata.json").read_text())
73+
component = read_component(built / (bundle + ".ghuser"))
74+
75+
assert component.GetString("Name") == PREFIX + metadata["name"]
76+
assert component.GetString("NickName") == metadata["nickname"]
77+
assert component.GetString("Category") == metadata["category"]
78+
assert component.GetString("SubCategory") == metadata["subcategory"]
79+
80+
81+
def test_components_carry_their_icon(built):
82+
for bundle in discover_source_bundles(str(EXAMPLES)):
83+
component = read_component(built / (bundle + ".ghuser"))
84+
85+
icon = component.GetByteArray("Icon")
86+
assert icon.Length == (EXAMPLES / bundle / "icon.png").stat().st_size

tests/test_bundle.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,10 @@ def test_validate_source_bundle_rejects_invalid_exposure(make_bundle):
9191
validate_source_bundle(bundle)
9292

9393

94-
def test_discover_source_bundles(tmp_path):
95-
for name in ("B_Component", "A_Component", "__pycache__", ".hidden"):
94+
def test_discover_source_bundles(make_bundle, tmp_path):
95+
for name in ("B_Component", "A_Component"):
96+
make_bundle(name=name)
97+
for name in ("__pycache__", ".hidden"):
9698
(tmp_path / name).mkdir()
9799
(tmp_path / "README.md").write_text("not a bundle")
98100

@@ -104,3 +106,19 @@ def test_discover_source_bundles_finds_valid_bundles(make_bundle, tmp_path):
104106

105107
assert discover_source_bundles(str(tmp_path)) == ["Test_Component"]
106108
validate_source_bundle(str(tmp_path / "Test_Component"))
109+
110+
111+
def test_discover_source_bundles_skips_a_target_folder(make_bundle, tmp_path):
112+
make_bundle(name="Test_Component")
113+
(tmp_path / "ghuser").mkdir()
114+
(tmp_path / "ghuser" / "Test_Component.ghuser").write_bytes(b"a built component")
115+
116+
assert discover_source_bundles(str(tmp_path)) == ["Test_Component"]
117+
118+
119+
def test_discover_source_bundles_keeps_incomplete_bundles(make_bundle, tmp_path):
120+
make_bundle(name="Test_Component", files=("code", "metadata"))
121+
122+
assert discover_source_bundles(str(tmp_path)) == ["Test_Component"]
123+
with pytest.raises(ValueError, match="icon missing"):
124+
validate_source_bundle(str(tmp_path / "Test_Component"))

tests/test_runtime.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ def macos(monkeypatch):
2121
@pytest.fixture
2222
def not_macos(monkeypatch):
2323
monkeypatch.setattr(runtime.platform, "system", lambda: "Windows")
24+
# Anything that loaded the .NET runtime before may have selected mono.
25+
monkeypatch.delenv("PYTHONNET_RUNTIME", raising=False)
2426
monkeypatch.setattr(runtime, "_drawing_checked", False)
2527

2628

0 commit comments

Comments
 (0)