diff --git a/.ci/generation/py2cs/__main__.py b/.ci/generation/py2cs/__main__.py index 1f60a48..07c89e9 100644 --- a/.ci/generation/py2cs/__main__.py +++ b/.ci/generation/py2cs/__main__.py @@ -218,6 +218,10 @@ def patch_with(patches: list[FilePatch]) -> list[PatchResult]: (args.out / "reports").mkdir(exist_ok=True) for repo in plan.repos(): (args.out / "reports" / (repo.replace("/", "__") + ".md")).write_text(render_report(plan, args.python_pr, repo), encoding="utf-8") + for message in plan.existing: + print(f"note: {message}; nothing to raise (py2cs align compares it with the Python)") + for message in plan.errors: + print(f"error: {message}") print(f"{len(plan.files)} files planned in {len(plan.repos())} repositories, {len(plan.errors)} problems") return 1 if plan.errors else 0 diff --git a/.ci/generation/py2cs/plan.py b/.ci/generation/py2cs/plan.py index b2ee5dc..2a5d6c5 100644 --- a/.ci/generation/py2cs/plan.py +++ b/.ci/generation/py2cs/plan.py @@ -37,6 +37,8 @@ class Plan: files: list[PlannedFile] = field(default_factory=lambda: []) removed: list[tuple[str, str]] = field(default_factory=lambda: []) # (repo or "?", clr name) errors: list[str] = field(default_factory=lambda: []) + # New in Python, but the C# already declares them (the C# pull request was merged): nothing to raise, `align` compares them + existing: list[str] = field(default_factory=lambda: []) def repos(self) -> list[str]: """Returns the sorted repositories that have a planned file or a removed type with a known repository. `plan` writes one report per repository and `pr` raises one pull request per repository. @@ -97,7 +99,7 @@ def make_plan(diff: Diff, head: Manifest, base: Manifest, clone_for: Callable[[s if found.path is not None: relative = found.path.relative_to(root).as_posix() if change.kind == "added": - plan.errors.append(f"{change.clr_name}: the type is new in Python, but {relative} in {repo} already declares {info.simple_name}; compare them by hand") + plan.existing.append(f"{change.clr_name}: {relative} in {repo} already declares {info.simple_name}") continue patch = build_patch(found.path, base[change.clr_name], info, head) if found.note: diff --git a/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/GoldenAndWorkflowTests.cs b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/GoldenAndWorkflowTests.cs index 0246549..edc6d9f 100644 --- a/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/GoldenAndWorkflowTests.cs +++ b/.ci/unit-tests/csharp/PythonSchemaGeneration.Tests/GoldenAndWorkflowTests.cs @@ -1,4 +1,7 @@ using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; using PythonSchemaGeneration; using PythonSchemaGeneration.Emission; @@ -25,21 +28,69 @@ public static void Matches(string goldenFolder, string relativePath, string actu public class GoldenTests { + //One fixture class per situation the generator must handle. Classes added to the fixture repository later are not compared, so a merged + //C# change does not break these tests; add a class here only when it covers a situation that no listed class covers. + private static readonly string[] m_PinnedClasses = + { + "FixtureAlpha_oM/Beam", + "FixtureAlpha_oM/Collections", + "FixtureAlpha_oM/CycleA", + "FixtureAlpha_oM/CycleB", + "FixtureAlpha_oM/GeometryHolder", + "FixtureAlpha_oM/GridHolder", + "FixtureAlpha_oM/ICurveLike", + "FixtureAlpha_oM/IElement1DLike", + "FixtureAlpha_oM/IGeometryLike", + "FixtureAlpha_oM/ILabelled", + "FixtureAlpha_oM/IOpeningLike_1", + "FixtureAlpha_oM/IPanelLike_2", + "FixtureAlpha_oM/ImmutableThing", + "FixtureAlpha_oM/LineLike", + "FixtureAlpha_oM/Options", + "FixtureAlpha_oM/PointLike", + "FixtureAlpha_oM/ProjectLike", + "FixtureAlpha_oM/Scalars", + "FixtureAlpha_oM/Sparse", + "FixtureAlpha_oM/SparseHolder", + "FixtureAlpha_oM/SparseParent", + "FixtureAlpha_oM/SupportKind", + "FixtureAlpha_oM/ThingIdentity", + "FixtureAlpha_oM/TreeLike_1", + "FixtureAlpha_oM/Wrapper_1", + "FixtureBeta_oM/Elements/UsesBoth", + "FixtureBeta_oM/PointLike" + }; + + /***************************************************/ + [Test] - [Description("Checks that the Python generated for every fixture type is identical to its committed golden file. Protects the exact output of the " + - "generator against unintended changes.")] + [Description("Checks that the Python generated for each pinned fixture class is identical to its committed golden file. Protects the exact output of the " + + "generator against unintended changes, without depending on the other classes of the fixture repository.")] public void FixtureOutputMatchesGoldenFiles() { - foreach (RenderedType type in FixtureOutput.Result.Types.Where(t => t.Ref.RelativePath.StartsWith("Fixture"))) + List pinned = FixtureOutput.Result.Types.Where(t => m_PinnedClasses.Contains(t.Ref.RelativePath)).ToList(); + Assert.That(pinned.Select(t => t.Ref.RelativePath).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(m_PinnedClasses), "every pinned class must still be generated"); + + foreach (RenderedType type in pinned) GoldenAssert.Matches("fixtures", type.Ref.RelativePath + ".py", type.Text); } + /***************************************************/ + [Test] - [Description("Checks that the manifest written for the fixture run is identical to the committed golden manifest. Protects the language-neutral manifest " + - "that py2cs and alignment read.")] + [Description("Checks that the entries of the pinned fixture classes in the manifest written for the fixture run are identical to the committed golden manifest. " + + "Protects the language-neutral manifest that py2cs and alignment read.")] public void FixtureManifestMatchesGolden() { - GoldenAssert.Matches("fixtures", "manifest.json", File.ReadAllText(Path.Combine(FixtureOutput.Folder, "manifest.json"))); + JsonNode manifest = JsonNode.Parse(File.ReadAllText(Path.Combine(FixtureOutput.Folder, "manifest.json"))); + JsonArray types = manifest["types"].AsArray(); + List kept = types.Where(t => m_PinnedClasses.Contains(((string)t["relativePath"]).Replace(".py", ""))).ToList(); + types.Clear(); + foreach (JsonNode type in kept) + types.Add(type.DeepClone()); + + string text = manifest.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }) + '\n'; + GoldenAssert.Matches("fixtures", "manifest.json", text); } } diff --git a/.ci/unit-tests/golden/fixtures/manifest.json b/.ci/unit-tests/golden/fixtures/manifest.json index 588a0c4..aaa52de 100644 --- a/.ci/unit-tests/golden/fixtures/manifest.json +++ b/.ci/unit-tests/golden/fixtures/manifest.json @@ -1,137 +1,6 @@ { "schemaVersion": 1, "types": [ - { - "clrName": "BH.oM.Base.BHoMObject", - "name": "BH.oM.Base.BHoMObject", - "pythonName": "BHoMObject", - "pythonModule": "bhom_schema.BHoM.BHoMObject", - "relativePath": "BHoM/BHoMObject.py", - "namespace": "BH.oM.Base", - "assembly": "BHoM", - "kind": "class", - "bases": [ - "BH.oM.Base.IBHoMObject" - ], - "repo": "BHoM/BHoM", - "properties": [ - { - "name": "BHoM_Guid", - "clrType": "System.Guid", - "pyName": "BHoM_Guid", - "hasDefault": true, - "default": " uuid4" - }, - { - "name": "Name", - "clrType": "string", - "pyName": "Name", - "hasDefault": true, - "default": "''" - }, - { - "name": "Fragments", - "clrType": "BH.oM.Base.FragmentSet", - "pyName": "Fragments", - "hasDefault": true, - "default": " list" - }, - { - "name": "Tags", - "clrType": "System.Collections.Generic.HashSet", - "pyName": "Tags", - "hasDefault": true, - "default": " set" - }, - { - "name": "CustomData", - "clrType": "System.Collections.Generic.Dictionary", - "pyName": "CustomData", - "hasDefault": true, - "default": " dict" - } - ] - }, - { - "clrName": "BH.oM.Base.IBHoMObject", - "name": "BH.oM.Base.IBHoMObject", - "pythonName": "IBHoMObject", - "pythonModule": "bhom_schema.BHoM.IBHoMObject", - "relativePath": "BHoM/IBHoMObject.py", - "namespace": "BH.oM.Base", - "assembly": "BHoM", - "kind": "interface", - "bases": [ - "BH.oM.Base.IObject" - ], - "repo": "BHoM/BHoM", - "properties": [ - { - "name": "BHoM_Guid", - "clrType": "System.Guid" - }, - { - "name": "Name", - "clrType": "string" - }, - { - "name": "Fragments", - "clrType": "BH.oM.Base.FragmentSet" - }, - { - "name": "Tags", - "clrType": "System.Collections.Generic.HashSet" - }, - { - "name": "CustomData", - "clrType": "System.Collections.Generic.Dictionary" - } - ] - }, - { - "clrName": "BH.oM.Base.IFragment", - "name": "BH.oM.Base.IFragment", - "pythonName": "IFragment", - "pythonModule": "bhom_schema.BHoM.IFragment", - "relativePath": "BHoM/IFragment.py", - "namespace": "BH.oM.Base", - "assembly": "BHoM", - "kind": "interface", - "bases": [ - "BH.oM.Base.IObject" - ], - "repo": "BHoM/BHoM", - "description": "Describes objects that can exist independently or can be attached to other BHoM objects as part of their `FragmentSet` property.", - "properties": [] - }, - { - "clrName": "BH.oM.Base.IImmutable", - "name": "BH.oM.Base.IImmutable", - "pythonName": "IImmutable", - "pythonModule": "bhom_schema.BHoM.IImmutable", - "relativePath": "BHoM/IImmutable.py", - "namespace": "BH.oM.Base", - "assembly": "BHoM", - "kind": "interface", - "bases": [ - "BH.oM.Base.IObject" - ], - "repo": "BHoM/BHoM", - "properties": [] - }, - { - "clrName": "BH.oM.Base.IObject", - "name": "BH.oM.Base.IObject", - "pythonName": "IObject", - "pythonModule": "bhom_schema.BHoM.IObject", - "relativePath": "BHoM/IObject.py", - "namespace": "BH.oM.Base", - "assembly": "BHoM", - "kind": "interface", - "bases": [], - "repo": "BHoM/BHoM", - "properties": [] - }, { "clrName": "BH.oM.FixtureAlpha.Beam", "name": "BH.oM.FixtureAlpha.Beam", @@ -1012,4 +881,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/.ci/unit-tests/python/test_alignment/test_verify.py b/.ci/unit-tests/python/test_alignment/test_verify.py index 7f9bb03..37f0a01 100644 --- a/.ci/unit-tests/python/test_alignment/test_verify.py +++ b/.ci/unit-tests/python/test_alignment/test_verify.py @@ -45,7 +45,7 @@ def test_a_package_that_differs_from_the_csharp_manifest_fails(tmp_path: Path): """ package = tmp_path / "bhom_schema" shutil.copytree(require_output("fixtures"), package) - (package / "FixtureAlpha_oM" / "Beam.py").unlink() + (package / "FixtureAlpha_oM" / "Scalars.py").unlink() _, problems = verify_package(package, OUTPUT / "fixtures" / "manifest.json") - assert "BH.oM.FixtureAlpha.Beam: missing" in problems + assert "BH.oM.FixtureAlpha.Scalars: missing" in problems diff --git a/.ci/unit-tests/python/test_py2cs/test_locate_resolve_plan.py b/.ci/unit-tests/python/test_py2cs/test_locate_resolve_plan.py index b3381b5..12c383e 100644 --- a/.ci/unit-tests/python/test_py2cs/test_locate_resolve_plan.py +++ b/.ci/unit-tests/python/test_py2cs/test_locate_resolve_plan.py @@ -226,6 +226,23 @@ def test_plan_for_a_modified_new_and_removed_type(tmp_path: Path): assert "## BHoM/Demo_Toolkit" in report and "property added: string ProjectNumber" in report and "BH.oM.Demo.Mode" in report +def test_a_new_python_class_that_the_csharp_already_declares_is_not_an_error(tmp_path: Path): + """Checks that make_plan lists a class that is new in Python but already declared in the C# clone as existing, with no error and no planned file. + + Protects a Python pull request whose linked C# pull request is merged from failing when it is pushed to again. + """ + clones = tmp_path / "clones" + repo = csharp_tree(clones) + base = read_package(demo_package(tmp_path / "base")) + head_root = demo_package(tmp_path / "head") + (head_root / "Demo_oM" / "Door.py").write_text(module("Door", "BH.oM.Demo.Elements.Door", "BH.oM.Demo.Elements", "Demo_oM", body=["X: float = 0.0"]), encoding="utf-8") + head = read_package(head_root) + + plan = make_plan(diff_manifests(base, head), head, base, lambda r: repo, {"BHoM/Demo_Toolkit": ["Demo_oM"]}) + assert plan.errors == [] and plan.files == [] + assert len(plan.existing) == 1 and plan.existing[0].startswith("BH.oM.Demo.Elements.Door: ") + + def test_plan_reports_problems_instead_of_guessing(tmp_path: Path): """Checks that make_plan reports an error when there is no checkout of the repository or when it is not in the listing, and plans no files. diff --git a/.github/workflows/linked-csharp-pr.yml b/.github/workflows/linked-csharp-pr.yml index b83dfdc..a4f274e 100644 --- a/.github/workflows/linked-csharp-pr.yml +++ b/.github/workflows/linked-csharp-pr.yml @@ -145,7 +145,7 @@ jobs: id: token uses: actions/create-github-app-token@v2 with: - app-id: ${{ vars.BHOM_APP_ID }} + app-id: ${{ secrets.BHOM_APP_ID }} private-key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ matrix.name }} @@ -218,7 +218,7 @@ jobs: id: token uses: actions/create-github-app-token@v2 with: - app-id: ${{ vars.BHOM_APP_ID }} + app-id: ${{ secrets.BHOM_APP_ID }} private-key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ matrix.name }} diff --git a/docs/CODING-PLAN.md b/docs/CODING-PLAN.md index f9c786b..34e6180 100644 --- a/docs/CODING-PLAN.md +++ b/docs/CODING-PLAN.md @@ -459,7 +459,7 @@ this repository. 3. **Store the credentials.** Note the App ID (or Client ID) and generate a private key (`.pem`). Add them where the workflow can read them: either an organisation secret/variable shared with `BHoM_PythonSchema`, or repository-level ones. Suggested names (reuse the names your other workflows already use if the secret exists): - - variable `BHOM_APP_ID` + - secret `BHOM_APP_ID` - secret `BHOM_APP_PRIVATE_KEY` (the full PEM text including the BEGIN/END lines) 4. **Protect the secrets.** Optionally attach them to a GitHub **Environment** (for example `linked-csharp-pr`) so only the linked-PR job can read them, with a required reviewer if you want a human gate before anything is raised. diff --git a/docs/Testing-Guide.md b/docs/Testing-Guide.md index 155351d..eb434aa 100644 --- a/docs/Testing-Guide.md +++ b/docs/Testing-Guide.md @@ -132,7 +132,7 @@ yet, or fails it when the `CI` environment variable is `true`. That is the Pytho A golden file is a committed copy of expected output. The test generates the output, compares it with the file, and fails on any difference. Use `golden/` when exact text matters: -- `golden/fixtures/`: the Python generated for the fixture types, and the C# manifest. +- `golden/fixtures/`: the Python generated for a pinned list of fixture classes (one per situation, listed in `GoldenTests.m_PinnedClasses`), and their entries of the C# manifest. Classes added to CSharpSchema_Test later are not compared, so a merged C# change never needs a golden update; add a class to the list only for a situation no listed class covers. - `golden/spike/`: a small, stable real type (`GradientCenteringOptions`). - `golden/py2cs/`: the C# that `py2cs` writes for small example classes. diff --git a/docs/Testing-On-GitHub.md b/docs/Testing-On-GitHub.md index 3b9436e..f06b478 100644 --- a/docs/Testing-On-GitHub.md +++ b/docs/Testing-On-GitHub.md @@ -133,6 +133,8 @@ prints what it would do and proves the App token; unticked it does it. 3. Re-run **pr-checks**. `py2cs align` must now pass: the Python generated from the merged C# equals the Python in the PR. If it still fails, the message lists what differs; either the C# review changed something (copy the change into the Python) or the generator and `py2cs` disagree (a bug to fix in this repository). + The NUnit golden files cover a pinned list of fixture classes only, so a class merged into CSharpSchema_Test does not break them and no golden + update is needed. A test that deletes a fixture class file must pick a class that no other fixture imports. 4. Merge the Python PR. 5. Run *Python schema generation* live (or wait for Saturday). Expected: `No changes: the Python already matches the C#`. If it commits, the commit shows exactly how the generated Python differs from what was written by hand (often only formatting or diff --git a/docs/Workflows.md b/docs/Workflows.md index 304f321..77e0d40 100644 --- a/docs/Workflows.md +++ b/docs/Workflows.md @@ -77,8 +77,8 @@ Someone with admin rights on the organisation does this once. 1. **A GitHub App** that can push a branch and open pull requests. Permissions: *Contents: read and write*, *Pull requests: read and write*, *Metadata: read*. Install it on **BHoM_PythonSchema and CSharpSchema_Test only**. A repository outside the installation cannot get a token, so the App cannot write to it. -2. **Credentials** in this repository (Settings, Secrets and variables, Actions): variable `BHOM_APP_ID` and secret - `BHOM_APP_PRIVATE_KEY` (the whole `.pem` text). If the App already exists for other workflows, reuse its names. +2. **Credentials** in this repository (Settings, Secrets and variables, Actions): secrets `BHOM_APP_ID` and + `BHOM_APP_PRIVATE_KEY` (the whole `.pem` text). The organisation may already hold both under these names for other workflows; a repository secret overrides it. 3. **`develop` must accept the weekly commit.** The generation pushes to `develop` with the workflow's own token. If `develop` is protected and requires pull requests, allow `github-actions[bot]` (or the App) to bypass that rule. 4. **Actions** are enabled, and the default workflow permissions can stay read-only: each workflow asks for what it needs. diff --git a/src/bhom_schema/FixtureAlpha_oM/BeamPair.py b/src/bhom_schema/FixtureAlpha_oM/BeamPair.py new file mode 100644 index 0000000..a5eb019 --- /dev/null +++ b/src/bhom_schema/FixtureAlpha_oM/BeamPair.py @@ -0,0 +1,27 @@ +# Generated by BHoM_PythonSchema from the C# oM type BH.oM.FixtureAlpha.Beam. Do not edit by hand. +# Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. +# Licensed under the GNU Lesser General Public License, version 3.0 or later. +# pyright: reportUnknownVariableType=false, reportIncompatibleVariableOverride=false + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import Field + +from bhom_schema.BHoM.BHoMObject import BHoMObject +from bhom_schema.FixtureAlpha_oM.Beam import Beam + + +class BeamPair(BHoMObject): + __bhom__: ClassVar[dict[str, Any]] = { + 'clr_name': 'BH.oM.FixtureAlpha.BeamPair', + 'assembly': 'FixtureAlpha_oM', + 'namespace': 'BH.oM.FixtureAlpha', + 'repo': 'BHoM/CSharpSchema_Test', + 'kind': 'class', + } + + First: Beam | None = Field(default=None, description='The first beam of the pair.') + Second: Beam | None = Field(default=None, description='The second beam of the pair.') +