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
5 changes: 5 additions & 0 deletions .github/workflows/trigger-site-rebuild.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ on:
- '.github/workflows/validate-examples.yml'
- '.github/workflows/trigger-site-rebuild.yml'
- 'validate_examples.py'
- 'test_validate_examples.py'
- 'build_examples.py'
- 'download_bob.py'
- 'create_archives.py'
Expand All @@ -27,6 +28,10 @@ jobs:
steps: [
{ name: 'Checkout', uses: actions/checkout@v4, with: { fetch-depth: 0 } },
{ name: 'Install Python', uses: actions/setup-python@v4, with: { python-version: 3.10.5, architecture: x64 } },
{
name: 'Test example validation',
run: 'python -m unittest test_validate_examples.py'
},
{
name: 'Validate examples',
env: {
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/validate-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
- '.github/workflows/validate-examples.yml'
- '.github/workflows/trigger-site-rebuild.yml'
- 'validate_examples.py'
- 'test_validate_examples.py'
- 'build_examples.py'
- 'download_bob.py'

Expand All @@ -16,6 +17,10 @@ jobs:
steps: [
{ name: 'Checkout', uses: actions/checkout@v4, with: { fetch-depth: 0 } },
{ name: 'Install Python', uses: actions/setup-python@v4, with: { python-version: 3.10.5, architecture: x64 } },
{
name: 'Test example validation',
run: 'python -m unittest test_validate_examples.py'
},
{
name: 'Validate examples',
run: 'python validate_examples.py --changed-from "${{ github.event.pull_request.base.sha }}" --changed-to "${{ github.event.pull_request.head.sha }}"'
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,5 @@ thumbnail: myimage.png

* Use the `authors` array for one or more contributors. The older single `author` field is still supported for existing examples.
* Examples use the repository-wide CC0-1.0 licence by default. Add a `license` field only when an example needs a different licence.
* List any scripts your example uses in the `scripts` field of the file header
* List any scripts your example uses in the `scripts` field of the file header. A file name is enough when it is unique in the project. If multiple scripts have the same file name, use the exact path relative to the example project root (for example, `main/player/player.script`).
* The thumbnail image will be used on https://defold.com/examples
2 changes: 1 addition & 1 deletion material/wave_background/example.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ brief: Shows how to use a Time shader constant to achieve a moving wave effect
authors:
- JuLongZhiLu(巨龙之路)
- Brian Kramer
scripts: wave_background.fp
scripts: example/wave_background.fp
thumbnail: thumbnail.png
---

Expand Down
76 changes: 76 additions & 0 deletions test_validate_examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import tempfile
import unittest
from pathlib import Path

from validate_examples import example_scripts, resolve_script_reference


class ExampleScriptResolutionTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.project_dir = Path(self.temp_dir.name)

def tearDown(self):
self.temp_dir.cleanup()

def add_script(self, relative_path: str) -> None:
path = self.project_dir / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("function init(self) end\n", encoding="utf-8")

def test_unique_filename_is_found_anywhere_in_project(self):
self.add_script("main/scroll_manager/scroll_item.script")
available = example_scripts(self.project_dir)

self.assertEqual(
resolve_script_reference("scroll_item.script", available),
"main/scroll_manager/scroll_item.script",
)

def test_exact_project_relative_path_is_supported(self):
self.add_script("main/scroll_manager/scroll_item.script")
available = example_scripts(self.project_dir)

self.assertEqual(
resolve_script_reference("main/scroll_manager/scroll_item.script", available),
"main/scroll_manager/scroll_item.script",
)
with self.assertRaisesRegex(ValueError, "does not exist"):
resolve_script_reference("scroll_manager/scroll_item.script", available)

def test_ambiguous_filename_lists_exact_paths(self):
self.add_script("main/first/controller.script")
self.add_script("main/second/controller.script")
available = example_scripts(self.project_dir)

with self.assertRaisesRegex(
ValueError,
"main/first/controller.script, main/second/controller.script",
):
resolve_script_reference("controller.script", available)
self.assertEqual(
resolve_script_reference("main/second/controller.script", available),
"main/second/controller.script",
)

def test_generated_and_dependency_directories_are_ignored(self):
self.add_script("main/controller.script")
self.add_script("build/controller.script")
self.add_script(".internal/lib/controller.script")
self.add_script("node_modules/package/controller.script")

self.assertEqual(example_scripts(self.project_dir), ["main/controller.script"])

def test_unsafe_or_non_normalized_paths_are_rejected(self):
for script in (
"/main/controller.script",
"./main/controller.script",
"main/../controller.script",
"main\\controller.script",
):
with self.subTest(script=script), self.assertRaisesRegex(ValueError, "normalized"):
resolve_script_reference(script, [])


if __name__ == "__main__":
unittest.main()
67 changes: 54 additions & 13 deletions validate_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import os
import subprocess
import sys
from pathlib import Path
from pathlib import Path, PurePosixPath


SCRIPT_EXTENSIONS = {
Expand All @@ -20,6 +20,16 @@
".render_script",
}

IGNORED_SCRIPT_DIRS = {
".deps",
".git",
".internal",
"build",
"builtins",
"js-web",
"node_modules",
}


def tracked_example_dirs() -> list[Path]:
try:
Expand Down Expand Up @@ -90,17 +100,48 @@ def split_scripts(value: str | None) -> list[str]:
return [script.strip().strip("\"'") for script in value.split(",") if script.strip()]


def example_scripts(example_dir: Path) -> set[str]:
scripts: set[str] = set()
source_dir = example_dir / "example"
if not source_dir.exists():
def example_scripts(example_dir: Path) -> list[str]:
scripts: list[str] = []
if not example_dir.exists():
return scripts

for path in source_dir.rglob("*"):
if path.is_file() and path.suffix in SCRIPT_EXTENSIONS:
scripts.add(path.name)
for root, dirnames, filenames in os.walk(example_dir):
dirnames[:] = [dirname for dirname in dirnames if dirname not in IGNORED_SCRIPT_DIRS]
root_path = Path(root)
for filename in filenames:
path = root_path / filename
if path.suffix in SCRIPT_EXTENSIONS:
scripts.append(path.relative_to(example_dir).as_posix())

return sorted(scripts)


def resolve_script_reference(script: str, available_scripts: list[str]) -> str:
path = PurePosixPath(script)
if (
not script
or "\\" in script
or path.is_absolute()
or str(path) != script
or any(part in {"", ".", ".."} for part in path.parts)
):
raise ValueError(
f"scripts entry must be a file name or normalized project-relative path, got '{script}'"
)

return scripts
if len(path.parts) > 1:
if script not in available_scripts:
raise ValueError(f"scripts entry '{script}' does not exist in the project")
return script

matches = [candidate for candidate in available_scripts if PurePosixPath(candidate).name == script]
if not matches:
raise ValueError(f"scripts entry '{script}' does not exist in the project")
if len(matches) > 1:
raise ValueError(
f"scripts entry '{script}' is ambiguous; use an exact project-relative path: {', '.join(matches)}"
)
return matches[0]


def parse_args() -> argparse.Namespace:
Expand Down Expand Up @@ -130,10 +171,10 @@ def validate() -> int:
available_scripts = example_scripts(example_dir)

for script in split_scripts(frontmatter_value(markdown_file, "scripts")):
if script != os.path.basename(script):
errors.append(f"{markdown_file}: scripts entry must be a file name, got '{script}'")
elif script not in available_scripts:
errors.append(f"{markdown_file}: scripts entry '{script}' does not exist in {example_dir / 'example'}")
try:
resolve_script_reference(script, available_scripts)
except ValueError as error:
errors.append(f"{markdown_file}: {error}")

if errors:
print("Example validation failed:")
Expand Down
Loading