unity-devkit 0.1.17__tar.gz → 0.1.19__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/PKG-INFO +1 -1
  2. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/pyproject.toml +1 -1
  3. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/src/unity_devkit/build_unity.py +1 -1
  4. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/src/unity_devkit/ci_build_unity.py +27 -2
  5. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/src/unity_devkit/compile_check_unity.py +1 -1
  6. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/src/unity_devkit/dispatch_workflow_generator.py +4 -4
  7. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/src/unity_devkit/install.py +87 -35
  8. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/src/unity_devkit/lock_unity.py +2 -2
  9. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/src/unity_devkit/matrix.py +4 -4
  10. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/src/unity_devkit/player_build.py +4 -4
  11. unity_devkit-0.1.19/src/unity_devkit/projects.py +73 -0
  12. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/src/unity_devkit/test_unity.py +3 -3
  13. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/tests/test_build_player.py +3 -3
  14. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/tests/test_matrix.py +8 -5
  15. unity_devkit-0.1.19/tests/test_projects.py +120 -0
  16. unity_devkit-0.1.17/src/unity_devkit/projects.py +0 -55
  17. unity_devkit-0.1.17/tests/test_projects.py +0 -88
  18. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/.gitignore +0 -0
  19. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/src/unity_devkit/__init__.py +0 -0
  20. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/src/unity_devkit/license.py +0 -0
  21. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/src/unity_devkit/license_restore.py +0 -0
  22. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/src/unity_devkit/py.typed +0 -0
  23. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/tests/fixtures/environment-dump.json +0 -0
  24. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/tests/test_dispatch_workflow_generator.py +0 -0
  25. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/tests/test_prepare.py +0 -0
  26. {unity_devkit-0.1.17 → unity_devkit-0.1.19}/tests/test_run_unity_batchmode.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: unity-devkit
3
- Version: 0.1.17
3
+ Version: 0.1.19
4
4
  Summary: Unity build, license, and CI tooling
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: bashrun>=0.1.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unity-devkit"
3
- version = "0.1.17"
3
+ version = "0.1.19"
4
4
  description = "Unity build, license, and CI tooling"
5
5
  requires-python = ">=3.13"
6
6
  dependencies = [
@@ -9,7 +9,7 @@ app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
9
9
 
10
10
  @app.command()
11
11
  def build_unity(
12
- project: Annotated[str, typer.Option(help="Unity project name (catalog key in unity-devkit.json)")],
12
+ project: Annotated[str, typer.Option(help="Unity project name (the 'name' field of its unity-devkit.json)")],
13
13
  build: Annotated[str, typer.Option(help="Build target from the project's builds list (e.g. AndroidMobile)")],
14
14
  version: Annotated[
15
15
  str,
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  from typing import Annotated
6
6
 
7
7
  import typer
8
+ from bashrun.bash import bash
8
9
  from pydantic_settings import BaseSettings
9
10
 
10
11
  from ci_devkit.cache import restore, save
@@ -28,7 +29,8 @@ def ci_build_unity(
28
29
  project_path: Annotated[Path, typer.Option(help="Path to Unity project")],
29
30
  platform: Annotated[str, typer.Option(help="Target platform")],
30
31
  cache_key: Annotated[str, typer.Option(help="Cache key prefix")],
31
- registry: Annotated[str, typer.Option(help="OCI registry path")],
32
+ registry: Annotated[str, typer.Option(help="OCI registry path for the library cache")],
33
+ builds_registry: Annotated[str, typer.Option(help="OCI registry path for build artifacts")],
32
34
  run_number: Annotated[int, typer.Option(help="CI run number")] = 0,
33
35
  branch: Annotated[str, typer.Option(help="Git branch name")] = "dev",
34
36
  environment_preset: Annotated[
@@ -63,7 +65,7 @@ def ci_build_unity(
63
65
  unity_project_path = resolve_unity_project(project).path
64
66
  if unity_project_path.resolve() != project_path.resolve():
65
67
  raise SystemExit(
66
- f"--project-path {project_path} does not match catalog entry '{project}' at {unity_project_path}"
68
+ f"--project-path {project_path} does not match discovered project '{project}' at {unity_project_path}"
67
69
  )
68
70
 
69
71
  with ci_step(f"Build {project} [{platform}]"):
@@ -96,3 +98,26 @@ def ci_build_unity(
96
98
  for file in build_directory.rglob("*"):
97
99
  if file.suffix in {".apk", ".exe"}:
98
100
  shutil.copy2(file, artifact_directory / file.name)
101
+
102
+ with ci_step("Push build artifacts"):
103
+ artifact_directory = Path("/tmp/unity-builds")
104
+ if not artifact_directory.is_dir() or not any(artifact_directory.iterdir()):
105
+ print("No build artifacts to push")
106
+ else:
107
+ reference_base = f"{builds_registry}/{project}-{platform}".lower()
108
+ tags = (branch_slug, f"run-{run_number}")
109
+ if platform == "Linux":
110
+ staging = Path("/tmp/unity-builds-push")
111
+ staging.mkdir(parents=True, exist_ok=True)
112
+ tar_path = staging / "build.tar"
113
+ bash(f"tar -cf {tar_path} -C {artifact_directory} .")
114
+ try:
115
+ for build_tag in tags:
116
+ bash(f"oras push {reference_base}:{build_tag} build.tar", cwd=staging)
117
+ finally:
118
+ tar_path.unlink(missing_ok=True)
119
+ else:
120
+ joined = " ".join(sorted(p.name for p in artifact_directory.iterdir() if p.is_file()))
121
+ for build_tag in tags:
122
+ bash(f"oras push {reference_base}:{build_tag} {joined}", cwd=artifact_directory)
123
+ print(f"Pushed build artifacts: {reference_base} ({', '.join(tags)})")
@@ -11,7 +11,7 @@ COMPILE_ERROR_SIGNATURES = ("error CS",)
11
11
 
12
12
  @app.command()
13
13
  def compile_check_unity(
14
- project: Annotated[str, typer.Option(help="Unity project name (catalog key in unity-devkit.json)")],
14
+ project: Annotated[str, typer.Option(help="Unity project name (the 'name' field of its unity-devkit.json)")],
15
15
  execute_method: Annotated[
16
16
  str | None,
17
17
  typer.Option(help="Static method to run after load (Class.Method) — one editor session per invocation"),
@@ -7,7 +7,7 @@ import typer
7
7
  from pydantic import BaseModel, ConfigDict
8
8
 
9
9
  from .compile_check_unity import COMPILE_ERROR_SIGNATURES
10
- from .projects import load_catalog
10
+ from .projects import discover_projects
11
11
  from .player_build import prepare_unity_project, run_unity_batchmode
12
12
 
13
13
  app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
@@ -58,7 +58,7 @@ class EnvironmentDump(BaseModel):
58
58
 
59
59
  @app.command()
60
60
  def unity_dispatch(
61
- project: Annotated[str, typer.Option(help="Unity project name (catalog key in unity-devkit.json)")],
61
+ project: Annotated[str, typer.Option(help="Unity project name (the 'name' field of its unity-devkit.json)")],
62
62
  output: Annotated[Path, typer.Option(help="Workflow file to write (or check with --check)")],
63
63
  build_workflow: Annotated[
64
64
  str,
@@ -69,13 +69,13 @@ def unity_dispatch(
69
69
  ],
70
70
  environment_config_class: Annotated[
71
71
  str | None,
72
- typer.Option(help="Env class file path override (defaults to build-config.json's environment_config)"),
72
+ typer.Option(help="Env class file path override (defaults to unity-devkit.json's environment_config)"),
73
73
  ] = None,
74
74
  check: Annotated[
75
75
  bool, typer.Option("--check", help="Compare against the existing file instead of writing; fail on drift")
76
76
  ] = False,
77
77
  ) -> None:
78
- projects = load_catalog()
78
+ projects = discover_projects()
79
79
  if project not in projects:
80
80
  raise SystemExit(f"Unknown project '{project}'. Valid: {', '.join(projects)}")
81
81
 
@@ -1,14 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
4
3
  import os
4
+ import shutil
5
5
  from pathlib import Path
6
- from typing import Annotated, Any
6
+ from typing import Annotated
7
7
 
8
8
  import typer
9
- from bashrun.bash import bash, bash_handoff, bash_output
9
+ from bashrun.bash import bash, bash_check, bash_handoff, bash_output
10
10
 
11
- from .projects import load_catalog
11
+ from .projects import discover_projects
12
12
  from .player_build import build_player
13
13
 
14
14
  INSTALLABLE_TARGETS = {"AndroidMobile", "MagicLeap2", "Linux"}
@@ -27,9 +27,12 @@ def main(
27
27
  ] = None,
28
28
  branch: Annotated[
29
29
  str | None,
30
- typer.Option("--branch", "-b", help="Branch to find latest successful run (default: current git branch)"),
30
+ typer.Option("--branch", "-b", help="Branch to pull the build from (default: current git branch)"),
31
+ ] = None,
32
+ run: Annotated[
33
+ int | None,
34
+ typer.Option("--run", "-r", help="Specific CI run number (github.run_number) to pull"),
31
35
  ] = None,
32
- run: Annotated[int | None, typer.Option("--run", "-r", help="Specific GitHub Actions run ID")] = None,
33
36
  serial: Annotated[str | None, typer.Option("--serial", "-s", help="adb device serial")] = None,
34
37
  build_locally: Annotated[
35
38
  bool,
@@ -38,12 +41,20 @@ def main(
38
41
  "-B",
39
42
  help=(
40
43
  "Build the project locally via `build-unity` and install the produced APK / "
41
- "linux executable. Skips the GitHub Actions artifact lookup; --branch / --run are ignored."
44
+ "linux executable. Skips the OCI pull; --branch / --run are ignored."
42
45
  ),
43
46
  ),
44
47
  ] = False,
48
+ list_tags: Annotated[
49
+ bool,
50
+ typer.Option("--list", help="List available build tags for (project, target) and exit"),
51
+ ] = False,
52
+ dry_run: Annotated[
53
+ bool,
54
+ typer.Option("--dry-run", help="Print what would be fetched/installed without doing it"),
55
+ ] = False,
45
56
  ) -> None:
46
- projects = load_catalog()
57
+ projects = discover_projects()
47
58
 
48
59
  project_name = next((name for name in projects if name.lower() == project.lower()), None)
49
60
  if project_name is None:
@@ -68,51 +79,73 @@ def main(
68
79
  if serial and target_name not in ADB_TARGETS:
69
80
  print(f"Warning: --serial is ignored for target '{target_name}'")
70
81
 
82
+ package_name = f"{project_name}-{target_name}".lower()
83
+
84
+ if list_tags:
85
+ _require_oras()
86
+ reference_base = _resolve_reference_base(package_name)
87
+ _login_ghcr()
88
+ if bash_check(f"oras repo tags {reference_base}"):
89
+ output = bash_output(f"oras repo tags {reference_base}")
90
+ if output.strip():
91
+ print(f"Available tags for {reference_base}:")
92
+ print(output, end="")
93
+ else:
94
+ print(f"No tags found for {reference_base}")
95
+ else:
96
+ print(f"No tags found for {reference_base}")
97
+ return
98
+
71
99
  if build_locally:
72
100
  if branch or run:
73
101
  print("Warning: --branch / --run are ignored when --build is set")
102
+ if dry_run:
103
+ print(f"Would build {project_name} [{target_name}] locally")
104
+ if target_name in ADB_TARGETS:
105
+ serial_part = f" -s {serial}" if serial else ""
106
+ print(f"Would install: adb{serial_part} install <apk>")
107
+ elif target_name == "Linux":
108
+ print("Would launch the produced executable")
109
+ return
74
110
  produced = build_player(project_name, target_name)
75
111
  apks = [path for path in produced if path.suffix == ".apk"]
76
112
  executables = [path for path in produced if path.suffix in {".exe", ".x86_64"}]
77
113
  else:
78
- artifact_name = f"{project_name}-{target_name}"
79
-
80
114
  resolved_branch = branch
81
115
  if not resolved_branch:
82
116
  resolved_branch = bash_output("git rev-parse --abbrev-ref HEAD").strip()
83
117
  if resolved_branch == "HEAD":
84
118
  raise typer.BadParameter("HEAD is detached; pass --branch explicitly")
85
119
 
86
- if run:
87
- run_id = str(run)
88
- else:
89
- owner_repo = bash_output("gh repo view --json nameWithOwner --jq .nameWithOwner").strip()
90
- output = bash_output(
91
- f"gh api repos/{owner_repo}/actions/artifacts --method GET -f name={artifact_name}"
92
- f" -f per_page=10 --jq .artifacts"
93
- )
94
- artifacts: list[dict[str, Any]] = json.loads(output)
95
- run_id = next(
96
- (
97
- str(artifact["workflow_run"]["id"])
98
- for artifact in artifacts
99
- if artifact["workflow_run"]["head_branch"] == resolved_branch
100
- ),
101
- None,
102
- )
103
- if run_id is None:
104
- print(f"No artifact '{artifact_name}' found on branch '{resolved_branch}'")
105
- raise SystemExit(1)
120
+ tag = f"run-{run}" if run else resolved_branch.replace("/", "-")
106
121
 
107
- print(f"Run: {run_id}")
108
- print(f"Artifact: {artifact_name}")
122
+ reference_base = _resolve_reference_base(package_name)
123
+ reference = f"{reference_base}:{tag}"
124
+ cache_path = CACHE_ROOT / tag / package_name
125
+
126
+ if dry_run:
127
+ print(f"Would pull: {reference}")
128
+ print(f"Cache: {cache_path}")
129
+ if target_name in ADB_TARGETS:
130
+ serial_part = f" -s {serial}" if serial else ""
131
+ print(f"Would install: adb{serial_part} install <apk>")
132
+ elif target_name == "Linux":
133
+ print("Would extract build.tar and launch the executable")
134
+ return
135
+
136
+ _require_oras()
137
+ _login_ghcr()
109
138
 
110
- cache_path = CACHE_ROOT / run_id / artifact_name
111
139
  if cache_path.is_dir() and any(cache_path.iterdir()):
112
- print(f"Using cached artifact: {cache_path}")
140
+ print(f"Using cached build: {cache_path}")
113
141
  else:
114
142
  cache_path.mkdir(parents=True, exist_ok=True)
115
- bash(f"gh run download {run_id} --name {artifact_name} --dir {cache_path}")
143
+ bash(f"oras pull {reference} -o {cache_path}")
144
+ if target_name == "Linux":
145
+ tar_path = cache_path / "build.tar"
146
+ if tar_path.exists():
147
+ bash(f"tar -xf {tar_path} -C {cache_path}")
148
+ tar_path.unlink()
116
149
 
117
150
  apks = sorted(cache_path.rglob("*.apk"))
118
151
 
@@ -148,3 +181,22 @@ def main(
148
181
  os.chmod(executable, executable.stat().st_mode | 0o755)
149
182
  print(f"Launching: {executable.name}")
150
183
  bash_handoff(str(executable))
184
+
185
+
186
+ def _require_oras() -> None:
187
+ if not shutil.which("oras"):
188
+ print("Error: oras is not on PATH. Install it:")
189
+ print(" macOS: brew install oras")
190
+ print(" Linux: https://oras.land/docs/install")
191
+ print(" Windows: scoop install oras")
192
+ raise SystemExit(1)
193
+
194
+
195
+ def _resolve_reference_base(package_name: str) -> str:
196
+ owner_repo = bash_output("gh repo view --json nameWithOwner --jq .nameWithOwner").strip()
197
+ return f"ghcr.io/{owner_repo}/builds/{package_name}".lower()
198
+
199
+
200
+ def _login_ghcr() -> None:
201
+ token = bash_output("gh auth token").strip()
202
+ bash("oras login ghcr.io --username oauth2 --password-stdin", stdin_text=token)
@@ -2,7 +2,7 @@ from typing import Annotated
2
2
 
3
3
  import typer
4
4
 
5
- from .projects import load_catalog
5
+ from .projects import discover_projects
6
6
  from .player_build import prepare_unity_project, run_unity_batchmode
7
7
 
8
8
  app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
@@ -10,7 +10,7 @@ app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
10
10
 
11
11
  @app.command()
12
12
  def lock_unity(project: Annotated[str | None, typer.Option(help="Limit to a specific project.")] = None) -> None:
13
- config = load_catalog()
13
+ config = discover_projects()
14
14
 
15
15
  if project is not None and project not in config:
16
16
  raise typer.BadParameter(f"Unknown project '{project}'. Valid: {', '.join(config)}")
@@ -1,12 +1,12 @@
1
1
  import json
2
2
  from pathlib import Path
3
3
 
4
- from .projects import load_catalog
4
+ from .projects import discover_projects
5
5
  from .player_build import LICENSE_IMAGE_MODULE, PLATFORM_CONFIGS, UNITYCI_IMAGE_REVISION, read_editor_version
6
6
 
7
7
 
8
8
  def build_matrix() -> None:
9
- projects = load_catalog()
9
+ projects = discover_projects()
10
10
  matrix: list[dict[str, str]] = []
11
11
  editor_versions: set[str] = set()
12
12
 
@@ -28,7 +28,7 @@ def build_matrix() -> None:
28
28
 
29
29
  if not editor_versions:
30
30
  raise SystemExit(
31
- "No projects with builds declared — build-unity-matrix needs at least one unity-devkit.json entry with a 'builds' list"
31
+ "No projects with builds declared — build-unity-matrix needs at least one unity-devkit.json with a non-empty 'platforms' map"
32
32
  )
33
33
  license_version = max(editor_versions)
34
34
  print(f"matrix={json.dumps({'include': matrix})}")
@@ -36,7 +36,7 @@ def build_matrix() -> None:
36
36
 
37
37
 
38
38
  def compile_check_matrix() -> None:
39
- projects = load_catalog()
39
+ projects = discover_projects()
40
40
  matrix: list[dict[str, str]] = []
41
41
  editor_versions: set[str] = set()
42
42
 
@@ -9,7 +9,7 @@ from typing import TypedDict
9
9
 
10
10
  from bashrun.bash import CalledProcessError, bash, bash_pipe
11
11
 
12
- from .projects import CatalogEntry, load_catalog
12
+ from .projects import ProjectEntry, discover_projects
13
13
 
14
14
 
15
15
  class PlatformConfig(TypedDict):
@@ -135,8 +135,8 @@ def editor_version(project_path: Path) -> str | None:
135
135
  return None
136
136
 
137
137
 
138
- def resolve_unity_project(project: str) -> CatalogEntry:
139
- projects = load_catalog()
138
+ def resolve_unity_project(project: str) -> ProjectEntry:
139
+ projects = discover_projects()
140
140
  if project not in projects:
141
141
  raise SystemExit(f"Unknown project '{project}'. Valid: {', '.join(projects)}")
142
142
  return projects[project]
@@ -182,7 +182,7 @@ def build_player(
182
182
  valid_builds = project_config.builds or []
183
183
  if not valid_builds:
184
184
  raise SystemExit(
185
- f"Project '{project}' declares no builds — add a 'builds' list to its entry in unity-devkit.json"
185
+ f"Project '{project}' declares no builds — add platform keys to its unity-devkit.json 'platforms' map"
186
186
  )
187
187
  if build not in valid_builds:
188
188
  raise SystemExit(f"Unknown build '{build}' for project '{project}'. Valid: {', '.join(valid_builds)}")
@@ -0,0 +1,73 @@
1
+ import os
2
+ from collections.abc import Iterator
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, ConfigDict, field_validator
7
+
8
+ BUILD_CONFIG_FILENAME = "unity-devkit.json"
9
+ PROJECT_MARKER = Path("ProjectSettings") / "ProjectVersion.txt"
10
+ PRUNE_DIRECTORIES = {".git", "Library", "Temp", "obj", "Build", "node_modules", "__pycache__"}
11
+
12
+
13
+ class BuildConfigFile(BaseModel):
14
+ model_config = ConfigDict(extra="ignore")
15
+
16
+ name: str
17
+ platforms: dict[str, Any] | None = None
18
+
19
+ @field_validator("name")
20
+ @classmethod
21
+ def nonempty_name(cls, value: str) -> str:
22
+ if not value.strip():
23
+ raise ValueError("unity-devkit.json 'name' must be a non-empty string")
24
+ return value
25
+
26
+
27
+ class ProjectEntry(BaseModel):
28
+ path: Path
29
+ builds: list[str] | None = None
30
+
31
+
32
+ def discover_projects() -> dict[str, ProjectEntry]:
33
+ root = Path.cwd()
34
+ discovered: dict[str, ProjectEntry] = {}
35
+ first_seen: dict[str, Path] = {}
36
+
37
+ for directory in visible_directories(root):
38
+ config_path = directory / BUILD_CONFIG_FILENAME
39
+ if not config_path.is_file():
40
+ continue
41
+ if not (directory / PROJECT_MARKER).is_file():
42
+ raise SystemExit(
43
+ f"{config_path} sits in {directory}, which is not a Unity project: missing {PROJECT_MARKER}"
44
+ )
45
+ parsed = BuildConfigFile.model_validate_json(config_path.read_text(encoding="utf-8"))
46
+ if parsed.name in discovered:
47
+ raise SystemExit(
48
+ f"Duplicate project name '{parsed.name}' — declared at {config_path} "
49
+ f"and previously at {first_seen[parsed.name]}"
50
+ )
51
+ first_seen[parsed.name] = config_path
52
+ builds = list(parsed.platforms.keys()) if parsed.platforms else None
53
+ discovered[parsed.name] = ProjectEntry(path=directory, builds=builds)
54
+
55
+ if not discovered:
56
+ raise SystemExit(
57
+ f"No {BUILD_CONFIG_FILENAME} found under {root} — declare Unity projects with a "
58
+ f"{BUILD_CONFIG_FILENAME} per project root (carrying a 'name' field; 'platforms' "
59
+ f"keys are the build targets)"
60
+ )
61
+ return discovered
62
+
63
+
64
+ def visible_directories(root: Path) -> Iterator[Path]:
65
+ for directory, subdirectories, _filenames in os.walk(root):
66
+ subdirectories[:] = sorted(
67
+ name for name in subdirectories if name not in PRUNE_DIRECTORIES and not name.startswith(".")
68
+ )
69
+ yield Path(directory)
70
+
71
+
72
+ def directories_containing(root: Path, filename: str) -> list[Path]:
73
+ return [directory for directory in visible_directories(root) if (directory / filename).is_file()]
@@ -5,7 +5,7 @@ from typing import Annotated
5
5
 
6
6
  import typer
7
7
 
8
- from .projects import load_catalog
8
+ from .projects import discover_projects
9
9
  from .player_build import run_unity_batchmode
10
10
 
11
11
  app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
@@ -13,13 +13,13 @@ app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
13
13
 
14
14
  @app.command()
15
15
  def main(
16
- project: Annotated[str, typer.Option(help="Unity project name (catalog key in unity-devkit.json)")],
16
+ project: Annotated[str, typer.Option(help="Unity project name (the 'name' field of its unity-devkit.json)")],
17
17
  test_platform: Annotated[str, typer.Option(help="Unity test platform (EditMode or PlayMode)")] = "EditMode",
18
18
  results: Annotated[Path, typer.Option(help="Output path for NUnit XML results")] = Path(
19
19
  "artifacts/unity-test-results.xml"
20
20
  ),
21
21
  ) -> None:
22
- projects = load_catalog()
22
+ projects = discover_projects()
23
23
  if project not in projects:
24
24
  raise SystemExit(f"Unknown project '{project}'. Valid: {', '.join(projects)}")
25
25
 
@@ -21,10 +21,10 @@ def write_repository(tmp_path: Path, project_settings: str, *, builds: list[str]
21
21
  (project / "ProjectSettings").mkdir(parents=True)
22
22
  (project / "ProjectSettings" / "ProjectSettings.asset").write_text(project_settings)
23
23
  (project / "ProjectSettings" / "ProjectVersion.txt").write_text("m_EditorVersion: 6000.0.66f1\n")
24
- entry: dict[str, object] = {"path": "apps/Tool"}
24
+ config: dict[str, object] = {"name": "tool"}
25
25
  if builds is not None:
26
- entry["builds"] = builds
27
- (tmp_path / "unity-devkit.json").write_text(json.dumps({"tool": entry}))
26
+ config["platforms"] = {platform: {} for platform in builds}
27
+ (project / "unity-devkit.json").write_text(json.dumps(config))
28
28
  return project
29
29
 
30
30
 
@@ -6,17 +6,20 @@ import pytest
6
6
  from unity_devkit import matrix
7
7
 
8
8
 
9
- def write_repository(tmp_path: Path, entry: dict[str, object]) -> None:
9
+ def write_repository(tmp_path: Path, builds: list[str] | None = None) -> None:
10
10
  project = tmp_path / "apps" / "Tool"
11
11
  (project / "ProjectSettings").mkdir(parents=True)
12
12
  (project / "ProjectSettings" / "ProjectVersion.txt").write_text("m_EditorVersion: 6000.0.66f1\n")
13
- (tmp_path / "unity-devkit.json").write_text(json.dumps({"tool": entry}))
13
+ config: dict[str, object] = {"name": "tool"}
14
+ if builds is not None:
15
+ config["platforms"] = {platform: {} for platform in builds}
16
+ (project / "unity-devkit.json").write_text(json.dumps(config))
14
17
 
15
18
 
16
19
  def test_matrix_emits_entries_from_catalog(
17
20
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
18
21
  ) -> None:
19
- write_repository(tmp_path, {"path": "apps/Tool", "builds": ["Linux"]})
22
+ write_repository(tmp_path, builds=["Linux"])
20
23
  monkeypatch.chdir(tmp_path)
21
24
 
22
25
  matrix.build_matrix()
@@ -37,7 +40,7 @@ def test_matrix_emits_entries_from_catalog(
37
40
 
38
41
 
39
42
  def test_matrix_fails_without_builds(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
40
- write_repository(tmp_path, {"path": "apps/Tool"})
43
+ write_repository(tmp_path)
41
44
  monkeypatch.chdir(tmp_path)
42
45
 
43
46
  with pytest.raises(SystemExit, match="No projects with builds declared"):
@@ -47,7 +50,7 @@ def test_matrix_fails_without_builds(tmp_path: Path, monkeypatch: pytest.MonkeyP
47
50
  def test_check_matrix_includes_path_only_projects(
48
51
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
49
52
  ) -> None:
50
- write_repository(tmp_path, {"path": "apps/Tool"})
53
+ write_repository(tmp_path)
51
54
  monkeypatch.chdir(tmp_path)
52
55
 
53
56
  matrix.compile_check_matrix()
@@ -0,0 +1,120 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+ from pydantic import ValidationError
6
+
7
+ from unity_devkit.projects import directories_containing, discover_projects
8
+
9
+
10
+ def create_unity_project(root: Path, relative: str) -> Path:
11
+ project = root / relative
12
+ (project / "ProjectSettings").mkdir(parents=True)
13
+ (project / "ProjectSettings" / "ProjectVersion.txt").write_text("m_EditorVersion: 6000.0.66f1\n")
14
+ return project
15
+
16
+
17
+ def write_build_config(project_dir: Path, config: dict[str, object]) -> Path:
18
+ config_path = project_dir / "unity-devkit.json"
19
+ config_path.write_text(json.dumps(config))
20
+ return config_path
21
+
22
+
23
+ def test_discovers_project_with_platforms_keys_as_builds(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
24
+ project = create_unity_project(tmp_path, "Alpha")
25
+ write_build_config(project, {"name": "Alpha", "platforms": {"Linux": {}, "AndroidMobile": {}}})
26
+ monkeypatch.chdir(tmp_path)
27
+
28
+ projects = discover_projects()
29
+
30
+ assert set(projects) == {"Alpha"}
31
+ assert projects["Alpha"].path == project
32
+ assert projects["Alpha"].builds == ["Linux", "AndroidMobile"]
33
+
34
+
35
+ def test_name_is_decoupled_from_directory_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
36
+ project = create_unity_project(tmp_path, "apps/capture-tool")
37
+ write_build_config(project, {"name": "capture"})
38
+ monkeypatch.chdir(tmp_path)
39
+
40
+ projects = discover_projects()
41
+
42
+ assert set(projects) == {"capture"}
43
+ assert projects["capture"].path == project
44
+
45
+
46
+ def test_path_only_project_has_no_builds(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
47
+ project = create_unity_project(tmp_path, "Harness")
48
+ write_build_config(project, {"name": "Harness"})
49
+ monkeypatch.chdir(tmp_path)
50
+
51
+ projects = discover_projects()
52
+
53
+ assert set(projects) == {"Harness"}
54
+ assert projects["Harness"].builds is None
55
+
56
+
57
+ def test_no_build_config_found_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
58
+ monkeypatch.chdir(tmp_path)
59
+
60
+ with pytest.raises(SystemExit, match=r"No unity-devkit\.json found"):
61
+ discover_projects()
62
+
63
+
64
+ def test_config_outside_unity_project_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
65
+ not_a_project = tmp_path / "not-a-project"
66
+ not_a_project.mkdir()
67
+ write_build_config(not_a_project, {"name": "broken"})
68
+ monkeypatch.chdir(tmp_path)
69
+
70
+ with pytest.raises(SystemExit, match="not a Unity project"):
71
+ discover_projects()
72
+
73
+
74
+ def test_duplicate_project_name_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
75
+ first = create_unity_project(tmp_path, "apps/one")
76
+ write_build_config(first, {"name": "dup"})
77
+ second = create_unity_project(tmp_path, "apps/two")
78
+ write_build_config(second, {"name": "dup"})
79
+ monkeypatch.chdir(tmp_path)
80
+
81
+ with pytest.raises(SystemExit, match="Duplicate project name 'dup'"):
82
+ discover_projects()
83
+
84
+
85
+ def test_ignores_csharp_owned_fields(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
86
+ project = create_unity_project(tmp_path, "App")
87
+ write_build_config(
88
+ project,
89
+ {
90
+ "name": "App",
91
+ "environment_config": "Assets/App/UnityEnv.cs",
92
+ "platforms": {"AndroidMobile": {"render_pipeline": "Assets/Settings/Android.asset"}},
93
+ },
94
+ )
95
+ monkeypatch.chdir(tmp_path)
96
+
97
+ projects = discover_projects()
98
+
99
+ assert projects["App"].builds == ["AndroidMobile"]
100
+
101
+
102
+ def test_missing_name_field_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
103
+ project = create_unity_project(tmp_path, "Alpha")
104
+ write_build_config(project, {"platforms": {"Linux": {}}})
105
+ monkeypatch.chdir(tmp_path)
106
+
107
+ with pytest.raises(ValidationError):
108
+ discover_projects()
109
+
110
+
111
+ def test_directories_containing_honors_prune_list(tmp_path: Path) -> None:
112
+ vendored = tmp_path / "vendor" / "Library" / "SomeProject"
113
+ (vendored / "ProjectSettings").mkdir(parents=True)
114
+ (vendored / "ProjectSettings" / "ProjectVersion.txt").write_text("m_EditorVersion: 6000.0.66f1\n")
115
+ real = create_unity_project(tmp_path, "Real")
116
+
117
+ settings_directories = directories_containing(tmp_path, "ProjectVersion.txt")
118
+
119
+ assert real / "ProjectSettings" in settings_directories
120
+ assert vendored / "ProjectSettings" not in settings_directories
@@ -1,55 +0,0 @@
1
- import os
2
- from collections.abc import Iterator
3
- from pathlib import Path
4
-
5
- from pydantic import BaseModel, ConfigDict, TypeAdapter, field_validator
6
-
7
- CATALOG_FILENAME = "unity-devkit.json"
8
- PROJECT_MARKER = Path("ProjectSettings") / "ProjectVersion.txt"
9
- PRUNE_DIRECTORIES = {".git", "Library", "Temp", "obj", "Build", "node_modules", "__pycache__"}
10
-
11
-
12
- class CatalogEntry(BaseModel):
13
- model_config = ConfigDict(extra="forbid")
14
-
15
- path: Path
16
- builds: list[str] | None = None
17
-
18
- @field_validator("path")
19
- @classmethod
20
- def anchor_relative_path(cls, value: Path) -> Path:
21
- return value if value.is_absolute() else Path.cwd() / value
22
-
23
-
24
- CATALOG_ADAPTER = TypeAdapter(dict[str, CatalogEntry])
25
-
26
-
27
- def load_catalog() -> dict[str, CatalogEntry]:
28
- catalog_path = Path.cwd() / CATALOG_FILENAME
29
- if not catalog_path.is_file():
30
- raise SystemExit(
31
- f"No {CATALOG_FILENAME} catalog at {catalog_path} — declare Unity projects as catalog entries at the repo root"
32
- )
33
-
34
- catalog = CATALOG_ADAPTER.validate_json(catalog_path.read_text(encoding="utf-8"))
35
- if not catalog:
36
- raise SystemExit(f"{catalog_path} declares no projects — at least one catalog entry is required")
37
-
38
- for name, entry in catalog.items():
39
- if not (entry.path / PROJECT_MARKER).is_file():
40
- raise SystemExit(
41
- f"Catalog entry '{name}' points at {entry.path}, which is not a Unity project: missing {PROJECT_MARKER}"
42
- )
43
- return catalog
44
-
45
-
46
- def visible_directories(root: Path) -> Iterator[Path]:
47
- for directory, subdirectories, _filenames in os.walk(root):
48
- subdirectories[:] = sorted(
49
- name for name in subdirectories if name not in PRUNE_DIRECTORIES and not name.startswith(".")
50
- )
51
- yield Path(directory)
52
-
53
-
54
- def directories_containing(root: Path, filename: str) -> list[Path]:
55
- return [directory for directory in visible_directories(root) if (directory / filename).is_file()]
@@ -1,88 +0,0 @@
1
- import json
2
- from pathlib import Path
3
-
4
- import pytest
5
- from pydantic import ValidationError
6
-
7
- from unity_devkit.projects import directories_containing, load_catalog
8
-
9
-
10
- def write_catalog(root: Path, catalog: dict[str, object]) -> Path:
11
- catalog_path = root / "unity-devkit.json"
12
- catalog_path.write_text(json.dumps(catalog))
13
- return catalog_path
14
-
15
-
16
- def create_unity_project(root: Path, relative: str) -> Path:
17
- project = root / relative
18
- (project / "ProjectSettings").mkdir(parents=True)
19
- (project / "ProjectSettings" / "ProjectVersion.txt").write_text("m_EditorVersion: 6000.0.66f1\n")
20
- return project
21
-
22
-
23
- def test_catalog_entry_loads_with_intent_fields(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
24
- create_unity_project(tmp_path, "Alpha")
25
- write_catalog(tmp_path, {"Alpha": {"path": "Alpha", "builds": ["Linux"]}})
26
- monkeypatch.chdir(tmp_path)
27
-
28
- projects = load_catalog()
29
-
30
- assert set(projects) == {"Alpha"}
31
- assert projects["Alpha"].path == Path.cwd() / "Alpha"
32
- assert projects["Alpha"].builds == ["Linux"]
33
-
34
-
35
- def test_catalog_name_is_decoupled_from_directory_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
36
- create_unity_project(tmp_path, "apps/capture-tool")
37
- write_catalog(tmp_path, {"capture": {"path": "apps/capture-tool"}})
38
- monkeypatch.chdir(tmp_path)
39
-
40
- projects = load_catalog()
41
-
42
- assert set(projects) == {"capture"}
43
- assert projects["capture"].path == Path.cwd() / "apps" / "capture-tool"
44
-
45
-
46
- def test_missing_catalog_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
47
- monkeypatch.chdir(tmp_path)
48
-
49
- with pytest.raises(SystemExit, match=r"No unity-devkit\.json catalog"):
50
- load_catalog()
51
-
52
-
53
- def test_empty_catalog_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
54
- write_catalog(tmp_path, {})
55
- monkeypatch.chdir(tmp_path)
56
-
57
- with pytest.raises(SystemExit, match="declares no projects"):
58
- load_catalog()
59
-
60
-
61
- def test_entry_outside_unity_project_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
62
- (tmp_path / "not-a-project").mkdir()
63
- write_catalog(tmp_path, {"broken": {"path": "not-a-project"}})
64
- monkeypatch.chdir(tmp_path)
65
-
66
- with pytest.raises(SystemExit, match="not a Unity project"):
67
- load_catalog()
68
-
69
-
70
- def test_unknown_entry_key_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
71
- create_unity_project(tmp_path, "Alpha")
72
- write_catalog(tmp_path, {"Alpha": {"path": "Alpha", "unity-build": True}})
73
- monkeypatch.chdir(tmp_path)
74
-
75
- with pytest.raises(ValidationError):
76
- load_catalog()
77
-
78
-
79
- def test_directories_containing_honors_prune_list(tmp_path: Path) -> None:
80
- vendored = tmp_path / "vendor" / "Library" / "SomeProject"
81
- (vendored / "ProjectSettings").mkdir(parents=True)
82
- (vendored / "ProjectSettings" / "ProjectVersion.txt").write_text("m_EditorVersion: 6000.0.66f1\n")
83
- real = create_unity_project(tmp_path, "Real")
84
-
85
- settings_directories = directories_containing(tmp_path, "ProjectVersion.txt")
86
-
87
- assert real / "ProjectSettings" in settings_directories
88
- assert vendored / "ProjectSettings" not in settings_directories
File without changes