From 3c71d65d99fe3bdf8e0dcbc5e5fc72b3b0b7d30f Mon Sep 17 00:00:00 2001 From: Emilio Bottoni Date: Tue, 5 May 2026 10:34:03 -0300 Subject: [PATCH 1/3] feat: move example components and add extra Fields Signed-off-by: Emilio Bottoni --- example_components/__init__.py | 0 example_components/driver/__init__.py | 0 .../driver}/driver.py | 7 +- .../driver/templates}/CMakeLists.txt.j2 | 0 .../driver/templates}/Kconfig.j2 | 0 .../driver/templates}/README.md.j2 | 0 .../driver/templates}/name.c.j2 | 0 .../driver/templates}/name.h.j2 | 0 example_components/lib/__init__.py | 0 .../lib}/lib.py | 7 +- .../lib/templates}/CMakeLists.txt.j2 | 0 .../lib/templates}/Kconfig.j2 | 2 +- .../lib/templates}/README.md.j2 | 4 +- .../lib/templates}/name.c.j2 | 0 .../lib/templates}/name.h.j2 | 0 example_components/sample/__init__.py | 0 .../sample}/sample.py | 7 +- .../sample/templates}/CMakeLists.txt.j2 | 0 .../sample/templates}/README.md.j2 | 4 +- .../sample/templates}/main.c.j2 | 0 .../sample/templates}/prj.conf.j2 | 0 .../sample/templates}/sample.yaml.j2 | 0 example_components/service/__init__.py | 0 .../service}/service.py | 7 +- .../service/templates}/CMakeLists.txt.j2 | 0 .../service/templates}/Kconfig.j2 | 0 .../service/templates}/README.md.j2 | 0 .../service/templates}/access.h.j2 | 0 .../service/templates}/impl.c.j2 | 0 .../service/templates}/internal.h.j2 | 0 example_components/test/__init__.py | 0 .../test/templates}/CMakeLists.txt.j2 | 4 +- .../test/templates}/README.md.j2 | 4 +- .../test/templates}/main.c.j2 | 5 ++ .../test/templates}/prj.conf.j2 | 0 .../test/templates}/testcase.yaml.j2 | 0 .../test}/test.py | 9 ++- external_templates/module.py | 26 ------- external_templates/shell_cmd.py | 25 ------- .../templates/module/CMakeLists.txt.j2 | 4 -- external_templates/templates/module/name.c.j2 | 5 -- external_templates/templates/module/name.h.j2 | 8 --- .../templates/shell_cmd/CMakeLists.txt.j2 | 4 -- .../templates/shell_cmd/name_cmd.c.j2 | 12 ---- pyproject.toml | 7 +- scaffold/components/__init__.py | 14 ++++ scaffold/descriptors.py | 37 +++++++++- scaffold/engine.py | 60 +++++++++++++--- scaffold/validators.py | 16 +++++ tests/test_descriptors.py | 71 +++++++++++++++++++ 50 files changed, 233 insertions(+), 116 deletions(-) create mode 100644 example_components/__init__.py create mode 100644 example_components/driver/__init__.py rename {scaffold/components => example_components/driver}/driver.py (73%) rename {scaffold/templates/driver => example_components/driver/templates}/CMakeLists.txt.j2 (100%) rename {scaffold/templates/driver => example_components/driver/templates}/Kconfig.j2 (100%) rename {scaffold/templates/driver => example_components/driver/templates}/README.md.j2 (100%) rename {scaffold/templates/driver => example_components/driver/templates}/name.c.j2 (100%) rename {scaffold/templates/driver => example_components/driver/templates}/name.h.j2 (100%) create mode 100644 example_components/lib/__init__.py rename {scaffold/components => example_components/lib}/lib.py (73%) rename {scaffold/templates/lib => example_components/lib/templates}/CMakeLists.txt.j2 (100%) rename {scaffold/templates/lib => example_components/lib/templates}/Kconfig.j2 (87%) rename {scaffold/templates/lib => example_components/lib/templates}/README.md.j2 (85%) rename {scaffold/templates/lib => example_components/lib/templates}/name.c.j2 (100%) rename {scaffold/templates/lib => example_components/lib/templates}/name.h.j2 (100%) create mode 100644 example_components/sample/__init__.py rename {scaffold/components => example_components/sample}/sample.py (82%) rename {scaffold/templates/sample => example_components/sample/templates}/CMakeLists.txt.j2 (100%) rename {scaffold/templates/sample => example_components/sample/templates}/README.md.j2 (63%) rename {scaffold/templates/sample => example_components/sample/templates}/main.c.j2 (100%) rename {scaffold/templates/sample => example_components/sample/templates}/prj.conf.j2 (100%) rename {scaffold/templates/sample => example_components/sample/templates}/sample.yaml.j2 (100%) create mode 100644 example_components/service/__init__.py rename {scaffold/components => example_components/service}/service.py (75%) rename {scaffold/templates/service => example_components/service/templates}/CMakeLists.txt.j2 (100%) rename {scaffold/templates/service => example_components/service/templates}/Kconfig.j2 (100%) rename {scaffold/templates/service => example_components/service/templates}/README.md.j2 (100%) rename {scaffold/templates/service => example_components/service/templates}/access.h.j2 (100%) rename {scaffold/templates/service => example_components/service/templates}/impl.c.j2 (100%) rename {scaffold/templates/service => example_components/service/templates}/internal.h.j2 (100%) create mode 100644 example_components/test/__init__.py rename {scaffold/templates/test => example_components/test/templates}/CMakeLists.txt.j2 (64%) rename {scaffold/templates/test => example_components/test/templates}/README.md.j2 (57%) rename {scaffold/templates/test => example_components/test/templates}/main.c.j2 (83%) rename {scaffold/templates/test => example_components/test/templates}/prj.conf.j2 (100%) rename {scaffold/templates/test => example_components/test/templates}/testcase.yaml.j2 (100%) rename {scaffold/components => example_components/test}/test.py (77%) delete mode 100644 external_templates/module.py delete mode 100644 external_templates/shell_cmd.py delete mode 100644 external_templates/templates/module/CMakeLists.txt.j2 delete mode 100644 external_templates/templates/module/name.c.j2 delete mode 100644 external_templates/templates/module/name.h.j2 delete mode 100644 external_templates/templates/shell_cmd/CMakeLists.txt.j2 delete mode 100644 external_templates/templates/shell_cmd/name_cmd.c.j2 create mode 100644 tests/test_descriptors.py diff --git a/example_components/__init__.py b/example_components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example_components/driver/__init__.py b/example_components/driver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scaffold/components/driver.py b/example_components/driver/driver.py similarity index 73% rename from scaffold/components/driver.py rename to example_components/driver/driver.py index fc3e9ba..ba9414a 100644 --- a/scaffold/components/driver.py +++ b/example_components/driver/driver.py @@ -1,4 +1,6 @@ -from scaffold.descriptors import Component, Field, TemplateFile +from pathlib import Path + +from scaffold.descriptors import Component, Field, FieldDir, TemplateFile from scaffold.validators import check_path_conflict, validate_snake_case COMPONENT = Component( @@ -10,7 +12,7 @@ prompt="Driver name (snake_case)", validator=validate_snake_case, ), - Field(name="path", prompt="Output path", default="drivers/"), + FieldDir(name="path", prompt="Output path", default="drivers/"), ], validators=[check_path_conflict], templates=[ @@ -20,4 +22,5 @@ TemplateFile("name.h.j2", "{name}.h"), TemplateFile("name.c.j2", "{name}.c"), ], + templates_dir=Path(__file__).parent / "templates", ) diff --git a/scaffold/templates/driver/CMakeLists.txt.j2 b/example_components/driver/templates/CMakeLists.txt.j2 similarity index 100% rename from scaffold/templates/driver/CMakeLists.txt.j2 rename to example_components/driver/templates/CMakeLists.txt.j2 diff --git a/scaffold/templates/driver/Kconfig.j2 b/example_components/driver/templates/Kconfig.j2 similarity index 100% rename from scaffold/templates/driver/Kconfig.j2 rename to example_components/driver/templates/Kconfig.j2 diff --git a/scaffold/templates/driver/README.md.j2 b/example_components/driver/templates/README.md.j2 similarity index 100% rename from scaffold/templates/driver/README.md.j2 rename to example_components/driver/templates/README.md.j2 diff --git a/scaffold/templates/driver/name.c.j2 b/example_components/driver/templates/name.c.j2 similarity index 100% rename from scaffold/templates/driver/name.c.j2 rename to example_components/driver/templates/name.c.j2 diff --git a/scaffold/templates/driver/name.h.j2 b/example_components/driver/templates/name.h.j2 similarity index 100% rename from scaffold/templates/driver/name.h.j2 rename to example_components/driver/templates/name.h.j2 diff --git a/example_components/lib/__init__.py b/example_components/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scaffold/components/lib.py b/example_components/lib/lib.py similarity index 73% rename from scaffold/components/lib.py rename to example_components/lib/lib.py index 1b00284..f729c89 100644 --- a/scaffold/components/lib.py +++ b/example_components/lib/lib.py @@ -1,4 +1,6 @@ -from scaffold.descriptors import Component, Field, TemplateFile +from pathlib import Path + +from scaffold.descriptors import Component, Field, FieldDir, TemplateFile from scaffold.validators import check_path_conflict, validate_snake_case COMPONENT = Component( @@ -10,7 +12,7 @@ prompt="Library name (snake_case)", validator=validate_snake_case, ), - Field(name="path", prompt="Output path", default="lib/"), + FieldDir(name="path", prompt="Output path", default="lib/"), ], validators=[check_path_conflict], templates=[ @@ -20,4 +22,5 @@ TemplateFile("name.h.j2", "{name}.h"), TemplateFile("name.c.j2", "{name}.c"), ], + templates_dir=Path(__file__).parent / "templates", ) diff --git a/scaffold/templates/lib/CMakeLists.txt.j2 b/example_components/lib/templates/CMakeLists.txt.j2 similarity index 100% rename from scaffold/templates/lib/CMakeLists.txt.j2 rename to example_components/lib/templates/CMakeLists.txt.j2 diff --git a/scaffold/templates/lib/Kconfig.j2 b/example_components/lib/templates/Kconfig.j2 similarity index 87% rename from scaffold/templates/lib/Kconfig.j2 rename to example_components/lib/templates/Kconfig.j2 index f40ae9e..7d96c4c 100644 --- a/scaffold/templates/lib/Kconfig.j2 +++ b/example_components/lib/templates/Kconfig.j2 @@ -1,4 +1,4 @@ -{% set sym = name | upper %} +{% set sym = "LIB_" ~ name | upper %} config {{ sym }} bool "{{ name | replace('_', ' ') | title }} library support" help diff --git a/scaffold/templates/lib/README.md.j2 b/example_components/lib/templates/README.md.j2 similarity index 85% rename from scaffold/templates/lib/README.md.j2 rename to example_components/lib/templates/README.md.j2 index 6d7b9a8..a3da282 100644 --- a/scaffold/templates/lib/README.md.j2 +++ b/example_components/lib/templates/README.md.j2 @@ -1,12 +1,12 @@ {% set sym = name | upper %} -# `lib/{{ name }}` +# `{{ path }}{{ name }}` TODO: Describe the {{ name | replace('_', ' ') }} library. ## Structure ```text -lib/{{ name }}/ +{{ path }}{{ name }}/ |-- CMakeLists.txt |-- Kconfig |-- README.md diff --git a/scaffold/templates/lib/name.c.j2 b/example_components/lib/templates/name.c.j2 similarity index 100% rename from scaffold/templates/lib/name.c.j2 rename to example_components/lib/templates/name.c.j2 diff --git a/scaffold/templates/lib/name.h.j2 b/example_components/lib/templates/name.h.j2 similarity index 100% rename from scaffold/templates/lib/name.h.j2 rename to example_components/lib/templates/name.h.j2 diff --git a/example_components/sample/__init__.py b/example_components/sample/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scaffold/components/sample.py b/example_components/sample/sample.py similarity index 82% rename from scaffold/components/sample.py rename to example_components/sample/sample.py index 8090ec5..cb3a286 100644 --- a/scaffold/components/sample.py +++ b/example_components/sample/sample.py @@ -1,4 +1,6 @@ -from scaffold.descriptors import Component, Field, TemplateFile +from pathlib import Path + +from scaffold.descriptors import Component, Field, FieldDir, TemplateFile from scaffold.validators import check_path_conflict, validate_snake_case # Samples are standalone cmake projects — no parent patching needed. @@ -12,7 +14,7 @@ prompt="Sample name (snake_case)", validator=validate_snake_case, ), - Field( + FieldDir( name="path", prompt="Output path", default="samples/", @@ -28,4 +30,5 @@ TemplateFile("prj.conf.j2", "prj.conf"), TemplateFile("main.c.j2", "src/main.c"), ], + templates_dir=Path(__file__).parent / "templates", ) diff --git a/scaffold/templates/sample/CMakeLists.txt.j2 b/example_components/sample/templates/CMakeLists.txt.j2 similarity index 100% rename from scaffold/templates/sample/CMakeLists.txt.j2 rename to example_components/sample/templates/CMakeLists.txt.j2 diff --git a/scaffold/templates/sample/README.md.j2 b/example_components/sample/templates/README.md.j2 similarity index 63% rename from scaffold/templates/sample/README.md.j2 rename to example_components/sample/templates/README.md.j2 index 296e978..e39327d 100644 --- a/scaffold/templates/sample/README.md.j2 +++ b/example_components/sample/templates/README.md.j2 @@ -1,11 +1,11 @@ -# `samples/{{ name }}` +# `{{ path }}{{ name }}` TODO: Describe the {{ name | replace('_', ' ') }} sample. ## Building ```sh -west build -b samples/{{ name }} +west build -b {{ path }}{{ name }} ``` ## Flashing diff --git a/scaffold/templates/sample/main.c.j2 b/example_components/sample/templates/main.c.j2 similarity index 100% rename from scaffold/templates/sample/main.c.j2 rename to example_components/sample/templates/main.c.j2 diff --git a/scaffold/templates/sample/prj.conf.j2 b/example_components/sample/templates/prj.conf.j2 similarity index 100% rename from scaffold/templates/sample/prj.conf.j2 rename to example_components/sample/templates/prj.conf.j2 diff --git a/scaffold/templates/sample/sample.yaml.j2 b/example_components/sample/templates/sample.yaml.j2 similarity index 100% rename from scaffold/templates/sample/sample.yaml.j2 rename to example_components/sample/templates/sample.yaml.j2 diff --git a/example_components/service/__init__.py b/example_components/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scaffold/components/service.py b/example_components/service/service.py similarity index 75% rename from scaffold/components/service.py rename to example_components/service/service.py index 76ef27a..d17ec6b 100644 --- a/scaffold/components/service.py +++ b/example_components/service/service.py @@ -1,4 +1,6 @@ -from scaffold.descriptors import Component, Field, TemplateFile +from pathlib import Path + +from scaffold.descriptors import Component, Field, FieldDir, TemplateFile from scaffold.validators import check_path_conflict, validate_snake_case COMPONENT = Component( @@ -10,7 +12,7 @@ prompt="Service name (snake_case)", validator=validate_snake_case, ), - Field(name="path", prompt="Output path", default="services/"), + FieldDir(name="path", prompt="Output path", default="services/"), ], validators=[check_path_conflict], templates=[ @@ -21,4 +23,5 @@ TemplateFile("impl.c.j2", "src/{name}.c"), TemplateFile("internal.h.j2", "src/{name}_internal.h"), ], + templates_dir=Path(__file__).parent / "templates", ) diff --git a/scaffold/templates/service/CMakeLists.txt.j2 b/example_components/service/templates/CMakeLists.txt.j2 similarity index 100% rename from scaffold/templates/service/CMakeLists.txt.j2 rename to example_components/service/templates/CMakeLists.txt.j2 diff --git a/scaffold/templates/service/Kconfig.j2 b/example_components/service/templates/Kconfig.j2 similarity index 100% rename from scaffold/templates/service/Kconfig.j2 rename to example_components/service/templates/Kconfig.j2 diff --git a/scaffold/templates/service/README.md.j2 b/example_components/service/templates/README.md.j2 similarity index 100% rename from scaffold/templates/service/README.md.j2 rename to example_components/service/templates/README.md.j2 diff --git a/scaffold/templates/service/access.h.j2 b/example_components/service/templates/access.h.j2 similarity index 100% rename from scaffold/templates/service/access.h.j2 rename to example_components/service/templates/access.h.j2 diff --git a/scaffold/templates/service/impl.c.j2 b/example_components/service/templates/impl.c.j2 similarity index 100% rename from scaffold/templates/service/impl.c.j2 rename to example_components/service/templates/impl.c.j2 diff --git a/scaffold/templates/service/internal.h.j2 b/example_components/service/templates/internal.h.j2 similarity index 100% rename from scaffold/templates/service/internal.h.j2 rename to example_components/service/templates/internal.h.j2 diff --git a/example_components/test/__init__.py b/example_components/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scaffold/templates/test/CMakeLists.txt.j2 b/example_components/test/templates/CMakeLists.txt.j2 similarity index 64% rename from scaffold/templates/test/CMakeLists.txt.j2 rename to example_components/test/templates/CMakeLists.txt.j2 index fa6074a..f16d3b5 100644 --- a/scaffold/templates/test/CMakeLists.txt.j2 +++ b/example_components/test/templates/CMakeLists.txt.j2 @@ -3,6 +3,6 @@ cmake_minimum_required(VERSION 3.20.0) find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) -project({{ name }}) +project({{ name }}_test) -target_sources(app PRIVATE src/main.c) +target_sources(app PRIVATE src/{{ name }}_test.c) diff --git a/scaffold/templates/test/README.md.j2 b/example_components/test/templates/README.md.j2 similarity index 57% rename from scaffold/templates/test/README.md.j2 rename to example_components/test/templates/README.md.j2 index 71209e3..f20155b 100644 --- a/scaffold/templates/test/README.md.j2 +++ b/example_components/test/templates/README.md.j2 @@ -1,10 +1,10 @@ -# `tests/{{ name }}` +# `{{ path }}{{ name }}` TODO: Describe what this test suite covers. ## Building and Running ```sh -west build -p -b native_sim tests/{{ name }} +west build -p -b native_sim {{ path }}{{ name }} west build -t run ``` diff --git a/scaffold/templates/test/main.c.j2 b/example_components/test/templates/main.c.j2 similarity index 83% rename from scaffold/templates/test/main.c.j2 rename to example_components/test/templates/main.c.j2 index 027e627..2fd6009 100644 --- a/scaffold/templates/test/main.c.j2 +++ b/example_components/test/templates/main.c.j2 @@ -1,3 +1,8 @@ +/** + * @file {{ name }}_test.c + * + */ + #include ZTEST_SUITE({{ name }}_suite, NULL, NULL, NULL, NULL, NULL); diff --git a/scaffold/templates/test/prj.conf.j2 b/example_components/test/templates/prj.conf.j2 similarity index 100% rename from scaffold/templates/test/prj.conf.j2 rename to example_components/test/templates/prj.conf.j2 diff --git a/scaffold/templates/test/testcase.yaml.j2 b/example_components/test/templates/testcase.yaml.j2 similarity index 100% rename from scaffold/templates/test/testcase.yaml.j2 rename to example_components/test/templates/testcase.yaml.j2 diff --git a/scaffold/components/test.py b/example_components/test/test.py similarity index 77% rename from scaffold/components/test.py rename to example_components/test/test.py index ee856b1..2458a63 100644 --- a/scaffold/components/test.py +++ b/example_components/test/test.py @@ -1,4 +1,6 @@ -from scaffold.descriptors import Component, Field, TemplateFile +from pathlib import Path + +from scaffold.descriptors import Component, Field, FieldDir, TemplateFile from scaffold.validators import check_path_conflict, validate_snake_case # Tests are standalone cmake projects — no parent patching needed. @@ -12,7 +14,7 @@ prompt="Test name (snake_case)", validator=validate_snake_case, ), - Field( + FieldDir( name="path", prompt="Output path", default="tests/", @@ -26,6 +28,7 @@ TemplateFile("README.md.j2", "README.md"), TemplateFile("testcase.yaml.j2", "testcase.yaml"), TemplateFile("prj.conf.j2", "prj.conf"), - TemplateFile("main.c.j2", "src/main.c"), + TemplateFile("main.c.j2", "src/{name}_test.c"), ], + templates_dir=Path(__file__).parent / "templates", ) diff --git a/external_templates/module.py b/external_templates/module.py deleted file mode 100644 index 464a425..0000000 --- a/external_templates/module.py +++ /dev/null @@ -1,26 +0,0 @@ -from pathlib import Path - -from scaffold.descriptors import Component, Field, TemplateFile -from scaffold.validators import check_path_conflict, validate_snake_case - -_HERE = Path(__file__).parent - -COMPONENT = Component( - name="module", - default_path="src/", - fields=[ - Field( - name="name", - prompt="Module name (snake_case)", - validator=validate_snake_case, - ), - Field(name="path", prompt="Output path", default="src/"), - ], - validators=[check_path_conflict], - templates=[ - TemplateFile("CMakeLists.txt.j2", "CMakeLists.txt"), - TemplateFile("name.h.j2", "{name}.h"), - TemplateFile("name.c.j2", "{name}.c"), - ], - templates_dir=_HERE / "templates" / "module", -) diff --git a/external_templates/shell_cmd.py b/external_templates/shell_cmd.py deleted file mode 100644 index a8986be..0000000 --- a/external_templates/shell_cmd.py +++ /dev/null @@ -1,25 +0,0 @@ -from pathlib import Path - -from scaffold.descriptors import Component, Field, TemplateFile -from scaffold.validators import check_path_conflict, validate_snake_case - -_HERE = Path(__file__).parent - -COMPONENT = Component( - name="shell_cmd", - default_path="src/", - fields=[ - Field( - name="name", - prompt="Command name (snake_case)", - validator=validate_snake_case, - ), - Field(name="path", prompt="Output path", default="src/"), - ], - validators=[check_path_conflict], - templates=[ - TemplateFile("CMakeLists.txt.j2", "CMakeLists.txt"), - TemplateFile("name_cmd.c.j2", "{name}_cmd.c"), - ], - templates_dir=_HERE / "templates" / "shell_cmd", -) diff --git a/external_templates/templates/module/CMakeLists.txt.j2 b/external_templates/templates/module/CMakeLists.txt.j2 deleted file mode 100644 index 09e7c63..0000000 --- a/external_templates/templates/module/CMakeLists.txt.j2 +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 - -zephyr_library() -zephyr_library_sources({{ name }}.c) diff --git a/external_templates/templates/module/name.c.j2 b/external_templates/templates/module/name.c.j2 deleted file mode 100644 index d53df0b..0000000 --- a/external_templates/templates/module/name.c.j2 +++ /dev/null @@ -1,5 +0,0 @@ -/* SPDX-License-Identifier: Apache-2.0 */ - -#include "{{ name }}.h" - -/* TODO: Add {{ name }} implementation. */ diff --git a/external_templates/templates/module/name.h.j2 b/external_templates/templates/module/name.h.j2 deleted file mode 100644 index a7290f4..0000000 --- a/external_templates/templates/module/name.h.j2 +++ /dev/null @@ -1,8 +0,0 @@ -/* SPDX-License-Identifier: Apache-2.0 */ - -#ifndef {{ name | upper }}_H -#define {{ name | upper }}_H - -/* TODO: Add {{ name }} declarations. */ - -#endif /* {{ name | upper }}_H */ diff --git a/external_templates/templates/shell_cmd/CMakeLists.txt.j2 b/external_templates/templates/shell_cmd/CMakeLists.txt.j2 deleted file mode 100644 index 44c65e8..0000000 --- a/external_templates/templates/shell_cmd/CMakeLists.txt.j2 +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 - -zephyr_library() -zephyr_library_sources({{ name }}_cmd.c) diff --git a/external_templates/templates/shell_cmd/name_cmd.c.j2 b/external_templates/templates/shell_cmd/name_cmd.c.j2 deleted file mode 100644 index 68872c4..0000000 --- a/external_templates/templates/shell_cmd/name_cmd.c.j2 +++ /dev/null @@ -1,12 +0,0 @@ -/* SPDX-License-Identifier: Apache-2.0 */ - -#include -#include - -static int cmd_{{ name }}(const struct shell *sh, size_t argc, char **argv) -{ - /* TODO: Implement {{ name }} command. */ - return 0; -} - -SHELL_CMD_REGISTER({{ name }}, NULL, "{{ name | replace('_', ' ') }} command", cmd_{{ name }}); diff --git a/pyproject.toml b/pyproject.toml index 23194a6..eade6d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,12 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["."] -include = ["scaffold*"] +include = ["scaffold*", "example_components*"] [tool.setuptools.package-data] scaffold = ["config_schema.json"] +"example_components.driver" = ["templates/*"] +"example_components.lib" = ["templates/*"] +"example_components.sample" = ["templates/*"] +"example_components.service" = ["templates/*"] +"example_components.test" = ["templates/*"] diff --git a/scaffold/components/__init__.py b/scaffold/components/__init__.py index 52f619a..fad1423 100644 --- a/scaffold/components/__init__.py +++ b/scaffold/components/__init__.py @@ -61,7 +61,21 @@ def load_from_path(path: Path) -> None: console.print(f"[yellow]⚠ Skipping {py_file.name}:[/yellow] {exc}") +def _discover_example_components() -> None: + """Auto-load components from the installed example_components package.""" + try: + import example_components as _ec_pkg + + ext_root = Path(_ec_pkg.__file__).parent + for subdir in sorted(ext_root.iterdir()): + if subdir.is_dir() and not subdir.name.startswith("_"): + load_from_path(subdir) + except ImportError: + pass + + _discover() +_discover_example_components() def get(name: str) -> Component: diff --git a/scaffold/descriptors.py b/scaffold/descriptors.py index 4681e87..e1728cf 100644 --- a/scaffold/descriptors.py +++ b/scaffold/descriptors.py @@ -14,7 +14,7 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import Callable, Optional +from typing import Callable, Literal, Optional @dataclass @@ -36,19 +36,50 @@ class Field: # failure, None on success. validator: Optional[Callable[[str], Optional[str]]] = None + def normalize(self, value: str) -> str: + """Apply type-specific normalization to a resolved value.""" + return value + + +@dataclass +class FieldDir(Field): + """A directory path field — always ends with '/'.""" + + def normalize(self, value: str) -> str: + return value.rstrip("/") + "/" + + +@dataclass +class FieldPath(Field): + """A filesystem path field — no trailing slash.""" + + def normalize(self, value: str) -> str: + return value.rstrip("/") + @dataclass class TemplateFile: """Maps a Jinja2 template to its rendered output path. `template` — filename inside `templates//`, e.g. "name.h.j2" - `output` — path relative to the component root directory. - Supports Python format-string substitution from context, + `output` — output path with Python format-string substitution from context. + For "create" mode: relative to the component root directory, e.g. "{name}.h" or "include/{name}.h". + For "append" mode: relative to cwd, e.g. "{path}/CMakeLists.txt". + `mode` — "create" writes a new file; "append" adds content to an existing + file (or creates it if absent). Append mode shows a diff and asks + for confirmation before writing, and is idempotent. + `insert_before` — only for "append" mode. When set, new lines are inserted + immediately before the last line in the file that exactly matches + this value, instead of being appended at the end. Accepts a plain + string or a callable `(context: dict) -> str` for dynamic anchors + (e.g. `lambda ctx: f"endif # {ctx['subsystem'].upper()}`). """ template: str output: str + mode: Literal["create", "append"] = "create" + insert_before: Optional[str | Callable[[dict], str]] = None @dataclass diff --git a/scaffold/engine.py b/scaffold/engine.py index d46bf3c..ec7dd8c 100644 --- a/scaffold/engine.py +++ b/scaffold/engine.py @@ -17,7 +17,7 @@ from rich.tree import Tree from scaffold.descriptors import Component -from scaffold.prompt import ask_field +from scaffold.prompt import ask_field, show_diff_and_confirm console = Console() @@ -51,9 +51,9 @@ def _resolve_context( for f in component.fields: if cli_params.get(f.name) is not None: - context[f.name] = cli_params[f.name] + context[f.name] = f.normalize(cli_params[f.name]) else: - context[f.name] = ask_field(f, context, skip_defaults) + context[f.name] = f.normalize(ask_field(f, context, skip_defaults)) return context @@ -71,6 +71,15 @@ def _run_validators( sys.exit(1) +def _build_patched(existing: str, new_content: str, insert_before: str | None) -> str: + if insert_before is not None: + lines = existing.splitlines(keepends=True) + for i in range(len(lines) - 1, -1, -1): + if lines[i].rstrip("\r\n") == insert_before: + return "".join(lines[:i]) + new_content + "".join(lines[i:]) + return existing + ("" if existing.endswith("\n") else "\n") + new_content + + def _render_templates( component: Component, context: dict, @@ -93,12 +102,47 @@ def _render_templates( ) for tpl in component.templates: - output_path = output_base / tpl.output.format(**context) - output_path.parent.mkdir(parents=True, exist_ok=True) - rendered = env.get_template(tpl.template).render(**context) - output_path.write_text(rendered, encoding="utf-8") - tree.add(f"[green]{output_path.relative_to(output_base)}[/green]") + if tpl.mode == "create": + output_path = output_base / tpl.output.format(**context) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(rendered, encoding="utf-8") + tree.add(f"[green]+ {output_path.relative_to(output_base)}[/green]") + + elif tpl.mode == "append": + output_path = cwd / tpl.output.format(**context) + target_label = str(output_path.relative_to(cwd)) + + if not output_path.exists(): + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(rendered, encoding="utf-8") + tree.add(f"[green]+ {target_label}[/green]") + else: + existing = output_path.read_text(encoding="utf-8") + existing_lines = set(existing.splitlines()) + + new_lines = [ + line for line in rendered.splitlines() + if line.strip() and line not in existing_lines + ] + + if not new_lines: + tree.add(f"[dim]~ {target_label} (skipped, already applied)[/dim]") + continue + + new_content = "\n".join(new_lines) + "\n" + anchor = ( + tpl.insert_before(context) + if callable(tpl.insert_before) + else tpl.insert_before + ) + patched = _build_patched(existing, new_content, anchor) + + if show_diff_and_confirm(existing, patched, target_label): + output_path.write_text(patched, encoding="utf-8") + tree.add(f"[yellow]~ {target_label}[/yellow]") + else: + tree.add(f"[dim]~ {target_label} (skipped)[/dim]") console.print(tree) diff --git a/scaffold/validators.py b/scaffold/validators.py index 4cd854d..069d7ab 100644 --- a/scaffold/validators.py +++ b/scaffold/validators.py @@ -18,6 +18,22 @@ def validate_snake_case(value: str) -> Optional[str]: return None +def validate_dt_compat(value: str) -> Optional[str]: + """Enforce the 'vendor,device' devicetree compatible format.""" + if not value: + return "Compatible string cannot be empty." + if "," not in value: + return "Compatible must follow the 'vendor,device' format (e.g. microchip,pic64gd-flash)." + vendor, _, device = value.partition(",") + if not vendor or not device: + return "Both vendor and device parts must be non-empty (e.g. microchip,pic64gd-flash)." + if not re.match(r"^[a-z0-9][a-z0-9-]*$", vendor): + return f"Vendor '{vendor}' must be lowercase letters, digits, or hyphens." + if not re.match(r"^[a-z0-9][a-z0-9-]*$", device): + return f"Device '{device}' must be lowercase letters, digits, or hyphens." + return None + + def check_path_conflict(context: dict, cwd: Path) -> None: """Fail if the target directory already exists.""" target = cwd / context["path"] / context["name"] diff --git a/tests/test_descriptors.py b/tests/test_descriptors.py new file mode 100644 index 0000000..c1e2123 --- /dev/null +++ b/tests/test_descriptors.py @@ -0,0 +1,71 @@ +import pytest + +from scaffold.descriptors import Field, FieldDir, FieldPath + + +class TestFieldNormalize: + def test_base_is_noop(self): + f = Field(name="x", prompt="X") + assert f.normalize("anything") == "anything" + assert f.normalize("path/to/") == "path/to/" + + +class TestFieldDir: + def test_adds_trailing_slash(self): + f = FieldDir(name="path", prompt="Path") + assert f.normalize("tests") == "tests/" + + def test_preserves_existing_slash(self): + f = FieldDir(name="path", prompt="Path") + assert f.normalize("tests/") == "tests/" + + def test_collapses_multiple_slashes(self): + f = FieldDir(name="path", prompt="Path") + assert f.normalize("tests///") == "tests/" + + def test_nested_path(self): + f = FieldDir(name="path", prompt="Path") + assert f.normalize("drivers/sensors") == "drivers/sensors/" + + def test_nested_path_with_slash(self): + f = FieldDir(name="path", prompt="Path") + assert f.normalize("drivers/sensors/") == "drivers/sensors/" + + +class TestFieldPath: + def test_strips_trailing_slash(self): + f = FieldPath(name="path", prompt="Path") + assert f.normalize("path/to/") == "path/to" + + def test_no_slash_unchanged(self): + f = FieldPath(name="path", prompt="Path") + assert f.normalize("path/to") == "path/to" + + def test_collapses_multiple_slashes(self): + f = FieldPath(name="path", prompt="Path") + assert f.normalize("path/to///") == "path/to" + + +class TestFieldDirInEngine: + def test_engine_normalizes_cli_param(self, monkeypatch, tmp_path): + from scaffold import engine + from scaffold.descriptors import Component, FieldDir, TemplateFile + from scaffold.validators import check_path_conflict + + comp = Component( + name="x", + default_path="out/", + fields=[ + Field(name="name", prompt="Name"), + FieldDir(name="path", prompt="Path", default="out/"), + ], + validators=[], + templates=[], + ) + monkeypatch.setattr( + engine, "ask_field", lambda *a, **kw: pytest.fail("ask_field called") + ) + ctx = engine._resolve_context( + comp, cli_params={"name": "foo", "path": "out"}, skip_defaults=False + ) + assert ctx["path"] == "out/" From d2274a6effe28f8ff64a8e3f95154de02f80f6aa Mon Sep 17 00:00:00 2001 From: Emilio Bottoni Date: Tue, 5 May 2026 11:24:26 -0300 Subject: [PATCH 2/3] feat: add `hide_defaults` Signed-off-by: Emilio Bottoni --- scaffold/cli.py | 49 +++++++++++++++++++++++++++++++-- scaffold/components/__init__.py | 17 ++++++++++++ scaffold/config_schema.json | 3 ++ scaffold/engine.py | 3 +- 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/scaffold/cli.py b/scaffold/cli.py index c517ec8..dfd2b39 100644 --- a/scaffold/cli.py +++ b/scaffold/cli.py @@ -22,6 +22,31 @@ from scaffold import config, engine from scaffold.prompt import ask_component_type + +def _warn_empty(components_dir: Path | None, hide_defaults: bool) -> None: + if components_dir and hide_defaults: + resolved = Path(components_dir).resolve() + console.print( + f"[yellow]⚠ No components available:[/yellow] no valid components found in " + f"[bold]{resolved}[/bold] and defaults are hidden. " + "Fix the components directory or unset [bold]hide_defaults[/bold]." + ) + elif components_dir: + resolved = Path(components_dir).resolve() + console.print( + f"[yellow]⚠ No components found in[/yellow] [bold]{resolved}[/bold]. " + "Check that the directory contains valid component modules." + ) + elif hide_defaults: + console.print( + "[yellow]⚠ No components available.[/yellow] " + "Add external components with [bold]--components-dir[/bold] " + "or unset [bold]hide_defaults[/bold]." + ) + else: + console.print("[yellow]⚠ No components available.[/yellow]") + + app = typer.Typer( name="scaffold", help="Scaffold a new project component from templates.", @@ -59,6 +84,13 @@ def main( help="Directory to load extra component modules from.", ), ] = None, + hide_defaults: Annotated[ + Optional[bool], + typer.Option( + "--hide-defaults/--no-hide-defaults", + help="Suppress built-in components; show only external ones.", + ), + ] = None, ) -> None: try: cfg = config.load() @@ -74,12 +106,23 @@ def main( console.print(f"[red]✗[/red] {exc}") raise typer.Exit(1) - component_name = component or ask_component_type(components.all_names()) + effective_hide = ( + hide_defaults if hide_defaults is not None else cfg.get("hide_defaults", False) + ) + if effective_hide: + components.purge_defaults() + + available = components.all_names() + if not available: + _warn_empty(effective_dir, effective_hide) + raise typer.Exit(0) + + component_name = component or ask_component_type(available) - if component_name not in components.all_names(): + if component_name not in available: console.print( f"[red]✗ Unknown component type:[/red] {component_name!r}\n" - f"Valid types: {', '.join(components.all_names())}" + f"Valid types: {', '.join(available)}" ) raise typer.Exit(1) diff --git a/scaffold/components/__init__.py b/scaffold/components/__init__.py index fad1423..0558d9d 100644 --- a/scaffold/components/__init__.py +++ b/scaffold/components/__init__.py @@ -19,6 +19,7 @@ from scaffold.descriptors import Component _registry: dict[str, Component] = {} +_builtins: dict[str, Component] = {} # snapshot of auto-discovered components def _discover() -> None: @@ -76,6 +77,22 @@ def _discover_example_components() -> None: _discover() _discover_example_components() +_builtins = dict(_registry) + + +def purge_defaults() -> None: + """Remove built-in components from the registry. + + Only removes entries still pointing to the original built-in object, so + external components that override a built-in name are preserved. + """ + to_remove = [ + name + for name, comp in _registry.items() + if name in _builtins and comp is _builtins[name] + ] + for name in to_remove: + del _registry[name] def get(name: str) -> Component: diff --git a/scaffold/config_schema.json b/scaffold/config_schema.json index 7f6e159..db8c5e5 100644 --- a/scaffold/config_schema.json +++ b/scaffold/config_schema.json @@ -6,6 +6,9 @@ "components-dir": { "type": ["string", "null"], "x-scaffold-type": "path" + }, + "hide_defaults": { + "type": "boolean" } } } diff --git a/scaffold/engine.py b/scaffold/engine.py index ec7dd8c..8456877 100644 --- a/scaffold/engine.py +++ b/scaffold/engine.py @@ -123,7 +123,8 @@ def _render_templates( existing_lines = set(existing.splitlines()) new_lines = [ - line for line in rendered.splitlines() + line + for line in rendered.splitlines() if line.strip() and line not in existing_lines ] From 603dbe074be123def58c5fd669b196fa173f1616 Mon Sep 17 00:00:00 2001 From: Emilio Bottoni Date: Tue, 5 May 2026 12:04:02 -0300 Subject: [PATCH 3/3] fix: line-by-line deduplication swaped to whole-block Signed-off-by: Emilio Bottoni --- .pre-commit-config.yaml | 12 +++ CONTRIBUTING.md | 36 ++++++++ README.md | 94 ++++++++++---------- pyproject.toml | 2 +- scaffold/components/__init__.py | 11 +++ scaffold/descriptors.py | 18 +++- scaffold/engine.py | 14 +-- scaffold/west_ext.py | 14 +++ tests/conftest.py | 21 +++++ tests/fixtures/templates/append/patch.txt.j2 | 3 + tests/test_components.py | 47 ++++++++++ tests/test_descriptors.py | 13 ++- tests/test_engine.py | 90 +++++++++++++++++++ tests/test_validators.py | 44 ++++++++- 14 files changed, 355 insertions(+), 64 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 CONTRIBUTING.md create mode 100644 tests/fixtures/templates/append/patch.txt.j2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..63aab38 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.9 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/adrienverge/yamllint + rev: v1.37.0 + hooks: + - id: yamllint diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..16c7524 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Contributing + +## Development setup + +```sh +git clone https://github.com/edgebr/scaffold +cd scaffold +pip install -e ".[dev]" +pre-commit install +``` + +`pre-commit install` sets up a Git hook that runs automatically on every `git commit`. It enforces the same checks as CI so formatting and lint issues are caught locally before they ever reach a pull request. + +## Checks + +| Hook | What it checks | +| ---- | -------------- | +| `ruff` | Python lint (auto-fixes where possible) | +| `ruff-format` | Python formatting | +| `yamllint` | YAML style (configured in `.yamllint`) | + +Run all checks manually at any time: + +```sh +pre-commit run --all-files +``` + +## Running tests + +```sh +pytest tests/ +``` + +## Project layout + +See [`CLAUDE.md`](CLAUDE.md) for a full description of the architecture, data model, and conventions for adding new component types. diff --git a/README.md b/README.md index 0e8973e..babc325 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,19 @@ python -m scaffold driver --name my_drv --path drivers/ python -m scaffold driver --name my_drv --path drivers/ --skip-defaults # Load external component definitions from a directory -python -m scaffold --components-dir external_templates/ +python -m scaffold --components-dir my-components/widget ``` ### Options -| Option | Description | -|---|---| -| `[component]` | Component type to scaffold. Omit to select interactively. | -| `--name`, `-n` | Component name in snake_case. | -| `--path`, `-p` | Output path relative to the current directory. | -| `--skip-defaults`, `-S` | Accept all defaults without prompting. | -| `--components-dir`, `-C` | Directory to load extra component modules from. | +| Option | Description | +| ------------------------ | --------------------------------------------------------- | +| `[component]` | Component type to scaffold. Omit to select interactively. | +| `--name`, `-n` | Component name in snake_case. | +| `--path`, `-p` | Output path relative to the current directory. | +| `--skip-defaults`, `-S` | Accept all defaults without prompting. | +| `--components-dir`, `-C` | Directory to load extra component modules from. | +| `--hide-defaults` | Suppress built-in components; show only external ones. | ## Configuration @@ -45,11 +46,11 @@ Scaffold loads configuration from up to two files before applying CLI flags. Thi ### Resolution order (lowest → highest priority) -| Priority | Source | -|---|---| -| 1 | `$HOME/.config/scaffold/config.yml` — user-level | -| 2 | `/scaffold.yml` — project-level (git-trackable) | -| 3 | CLI flags | +| Priority | Source | +| -------- | ---------------------------------------------------- | +| 1 | `$HOME/.config/scaffold/config.yml` — user-level | +| 2 | `/scaffold.yml` — project-level (git-trackable) | +| 3 | CLI flags | Keys from lower-priority sources are preserved when a higher-priority source does not set them. @@ -58,6 +59,7 @@ Keys from lower-priority sources are preserved when a higher-priority source doe ```yaml # scaffold.yml components-dir: path/to/components +hide_defaults: true # suppress built-in components; show only external ones ``` `components-dir` is always resolved relative to the file that declares it: `scaffold.yml` paths resolve against ``, user-config paths resolve against `~/.config/scaffold/`, and `--components-dir` on the CLI resolves against the current working directory. @@ -77,29 +79,32 @@ All team members get the same `components-dir` automatically after cloning, with ## Built-in components -| Component | Default path | Generated files | -|---|---|---| -| `driver` | `drivers/` | `CMakeLists.txt`, `Kconfig`, `README.md`, `{name}.h`, `{name}.c` | -| `service` | `services/` | `CMakeLists.txt`, `Kconfig`, `README.md`, `include/service/{name}_access.h`, `src/{name}.c`, `src/{name}_internal.h` | -| `lib` | `lib/` | `CMakeLists.txt`, `Kconfig`, `README.md`, `{name}.h`, `{name}.c` | -| `sample` | `samples/` | `CMakeLists.txt`, `README.md`, `sample.yaml`, `prj.conf`, `src/main.c` | -| `test` | `tests/` | `CMakeLists.txt`, `README.md`, `testcase.yaml`, `prj.conf`, `src/main.c` | +| Component | Default path | Generated files | +| --------- | ------------ | -------------------------------------------------------------------------------------------------------------------- | +| `driver` | `drivers/` | `CMakeLists.txt`, `Kconfig`, `README.md`, `{name}.h`, `{name}.c` | +| `service` | `services/` | `CMakeLists.txt`, `Kconfig`, `README.md`, `include/service/{name}_access.h`, `src/{name}.c`, `src/{name}_internal.h` | +| `lib` | `lib/` | `CMakeLists.txt`, `Kconfig`, `README.md`, `{name}.h`, `{name}.c` | +| `sample` | `samples/` | `CMakeLists.txt`, `README.md`, `sample.yaml`, `prj.conf`, `src/main.c` | +| `test` | `tests/` | `CMakeLists.txt`, `README.md`, `testcase.yaml`, `prj.conf`, `src/main.c` | ## External components Component definitions can live outside this repository. Point `--components-dir` at any directory containing `.py` files that define a `COMPONENT` constant. Each component declares its own template directory, making external component sets fully self-contained. ``` -external_templates/ -├── module.py # COMPONENT definition -├── shell_cmd.py -└── templates/ - ├── module/ # Jinja2 templates for module - └── shell_cmd/ # Jinja2 templates for shell_cmd +my-components/ +├── widget/ +│ ├── __init__.py +│ ├── widget.py # COMPONENT definition +│ └── templates/ # Jinja2 templates for widget +│ └── widget.c.j2 +└── sensor/ + ├── __init__.py + ├── sensor.py + └── templates/ + └── sensor.c.j2 ``` -See [`external_templates/`](external_templates/) for working examples. - A minimal external component: ```python @@ -120,17 +125,26 @@ COMPONENT = Component( templates=[ TemplateFile("main.c.j2", "{name}.c"), ], - templates_dir=_HERE / "templates" / "my_type", + templates_dir=_HERE / "templates", ) ``` +Load the component set at runtime: + +```sh +scaffold --components-dir my-components/widget widget --name my_widget +``` + +External components with the same name as a built-in will override it. Use `--hide-defaults` (or `hide_defaults: true` in `scaffold.yml`) to suppress built-ins entirely and show only external components. + ## Adding a built-in component type -1. Create `scaffold/components/.py` with a `COMPONENT` constant. -2. Add templates under `scaffold/templates//`. -3. Auto-discovery picks it up — nothing else needs to change. +1. Create a subdirectory `example_components//`. +2. Add `__init__.py` (empty) and `.py` with a `COMPONENT` constant. Set `templates_dir=Path(__file__).parent / "templates"`. +3. Add Jinja2 templates under `example_components//templates/`. +4. Auto-discovery picks it up — nothing else needs to change. -See `scaffold/components/driver.py` as a reference. +See `example_components/driver/driver.py` as a reference. ## West integration @@ -150,17 +164,5 @@ Once added, run `west update` and use `west scaffold` with the same arguments as ```sh west scaffold driver --name my_drv --path drivers/ --skip-defaults -``` - -## Development - -**Lint:** -```sh -ruff check scaffold/ -ruff format scaffold/ -``` - -**Test:** -```sh -pytest tests/ +west scaffold --components-dir my-components/ --hide-defaults widget --name my_widget ``` diff --git a/pyproject.toml b/pyproject.toml index eade6d1..688eb7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ ] [project.optional-dependencies] -dev = ["ruff", "pytest>=8.0"] +dev = ["ruff", "pytest>=8.0", "pre-commit"] [project.scripts] scaffold = "scaffold.cli:app" diff --git a/scaffold/components/__init__.py b/scaffold/components/__init__.py index 0558d9d..9410eac 100644 --- a/scaffold/components/__init__.py +++ b/scaffold/components/__init__.py @@ -85,6 +85,7 @@ def purge_defaults() -> None: Only removes entries still pointing to the original built-in object, so external components that override a built-in name are preserved. + Call `restore_defaults()` to undo. """ to_remove = [ name @@ -95,6 +96,16 @@ def purge_defaults() -> None: del _registry[name] +def restore_defaults() -> None: + """Re-add built-in components that were removed by `purge_defaults()`. + + External components that shadow a built-in name are left untouched. + """ + for name, comp in _builtins.items(): + if name not in _registry: + _registry[name] = comp + + def get(name: str) -> Component: """Return the Component for the given name, or raise KeyError.""" return _registry[name] diff --git a/scaffold/descriptors.py b/scaffold/descriptors.py index e1728cf..0fa6172 100644 --- a/scaffold/descriptors.py +++ b/scaffold/descriptors.py @@ -43,18 +43,28 @@ def normalize(self, value: str) -> str: @dataclass class FieldDir(Field): - """A directory path field — always ends with '/'.""" + """A directory path field — always ends with '/'. + + An empty value is returned as-is (empty string means "current directory" + in the engine context; normalizing it to "/" would resolve to root). + """ def normalize(self, value: str) -> str: - return value.rstrip("/") + "/" + stripped = value.rstrip("/") + return stripped + "/" if stripped else value @dataclass class FieldPath(Field): - """A filesystem path field — no trailing slash.""" + """A filesystem path field — no trailing slash. + + A lone "/" is preserved; stripping it would produce an empty string, + silently losing the root-path intent. + """ def normalize(self, value: str) -> str: - return value.rstrip("/") + stripped = value.rstrip("/") + return stripped if stripped else value @dataclass diff --git a/scaffold/engine.py b/scaffold/engine.py index 8456877..34f4c94 100644 --- a/scaffold/engine.py +++ b/scaffold/engine.py @@ -9,6 +9,7 @@ from __future__ import annotations +import os import sys from pathlib import Path @@ -112,7 +113,7 @@ def _render_templates( elif tpl.mode == "append": output_path = cwd / tpl.output.format(**context) - target_label = str(output_path.relative_to(cwd)) + target_label = os.path.relpath(output_path, cwd) if not output_path.exists(): output_path.parent.mkdir(parents=True, exist_ok=True) @@ -120,19 +121,12 @@ def _render_templates( tree.add(f"[green]+ {target_label}[/green]") else: existing = output_path.read_text(encoding="utf-8") - existing_lines = set(existing.splitlines()) - new_lines = [ - line - for line in rendered.splitlines() - if line.strip() and line not in existing_lines - ] - - if not new_lines: + if rendered.strip() in existing: tree.add(f"[dim]~ {target_label} (skipped, already applied)[/dim]") continue - new_content = "\n".join(new_lines) + "\n" + new_content = rendered anchor = ( tpl.insert_before(context) if callable(tpl.insert_before) diff --git a/scaffold/west_ext.py b/scaffold/west_ext.py index b5c74c7..fc42a86 100644 --- a/scaffold/west_ext.py +++ b/scaffold/west_ext.py @@ -55,6 +55,12 @@ def do_add_parser(self, parser_adder): metavar="DIR", help="Directory to load extra component modules from.", ) + parser.add_argument( + "--hide-defaults", + action="store_true", + default=None, + help="Suppress built-in components; show only external ones.", + ) return parser def do_run(self, args, unknown_args): @@ -70,6 +76,14 @@ def do_run(self, args, unknown_args): except ValueError as exc: self.die(str(exc)) + effective_hide = ( + args.hide_defaults + if args.hide_defaults is not None + else cfg.get("hide_defaults", False) + ) + if effective_hide: + components.purge_defaults() + component_name = args.component or ask_component_type(components.all_names()) if component_name not in components.all_names(): diff --git a/tests/conftest.py b/tests/conftest.py index c1aeee3..bd8286c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,6 +39,22 @@ ) +# Component with append-mode template — for testing file patching behaviour. +APPEND_COMPONENT = Component( + name="append_test", + default_path="out/", + fields=[ + Field(name="name", prompt="Name"), + Field(name="path", prompt="Path", default="out/"), + ], + validators=[], + templates=[ + TemplateFile("patch.txt.j2", "Kconfig", mode="append"), + ], + templates_dir=FIXTURES_TEMPLATES / "append", +) + + @pytest.fixture def simple_component(): return SIMPLE_COMPONENT @@ -49,6 +65,11 @@ def external_component(): return EXTERNAL_COMPONENT +@pytest.fixture +def append_component(): + return APPEND_COMPONENT + + @pytest.fixture def use_test_templates(monkeypatch): monkeypatch.setattr(engine, "_TEMPLATES_DIR", FIXTURES_TEMPLATES) diff --git a/tests/fixtures/templates/append/patch.txt.j2 b/tests/fixtures/templates/append/patch.txt.j2 new file mode 100644 index 0000000..cb18a5b --- /dev/null +++ b/tests/fixtures/templates/append/patch.txt.j2 @@ -0,0 +1,3 @@ +config {{ name | upper }}_ENABLED + bool "Enable {{ name }}" + default n diff --git a/tests/test_components.py b/tests/test_components.py index be5b913..82ba656 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -52,3 +52,50 @@ def test_path_field_has_default(name): def test_load_from_path_nonexistent_raises(tmp_path): with pytest.raises(ValueError, match="not found"): components.load_from_path(tmp_path / "nonexistent") + + +# --------------------------------------------------------------------------- +# purge_defaults / restore_defaults +# --------------------------------------------------------------------------- + + +def test_purge_defaults_removes_builtins(): + try: + components.purge_defaults() + assert not any(n in components.all_names() for n in EXPECTED_COMPONENTS) + finally: + components.restore_defaults() + + +def test_restore_defaults_brings_builtins_back(): + components.purge_defaults() + components.restore_defaults() + assert EXPECTED_COMPONENTS.issubset(set(components.all_names())) + + +def test_purge_then_restore_is_idempotent(): + # Two purge+restore cycles must leave registry in the original state. + original = set(components.all_names()) + try: + components.purge_defaults() + components.restore_defaults() + components.purge_defaults() + components.restore_defaults() + assert set(components.all_names()) == original + finally: + components.restore_defaults() + + +def test_purge_does_not_remove_external_override(tmp_path): + # An external component that shadows a built-in name must survive purge. + py_file = tmp_path / "driver.py" + py_file.write_text( + "from scaffold.descriptors import Component, Field\n" + "COMPONENT = Component(name='driver', default_path='out/', fields=[], validators=[], templates=[])\n" + ) + components.load_from_path(tmp_path) + try: + components.purge_defaults() + assert "driver" in components.all_names() + finally: + components.restore_defaults() diff --git a/tests/test_descriptors.py b/tests/test_descriptors.py index c1e2123..1fefa55 100644 --- a/tests/test_descriptors.py +++ b/tests/test_descriptors.py @@ -31,6 +31,11 @@ def test_nested_path_with_slash(self): f = FieldDir(name="path", prompt="Path") assert f.normalize("drivers/sensors/") == "drivers/sensors/" + def test_empty_string_unchanged(self): + # Empty string must not become "/" (which resolves to filesystem root). + f = FieldDir(name="path", prompt="Path") + assert f.normalize("") == "" + class TestFieldPath: def test_strips_trailing_slash(self): @@ -45,12 +50,16 @@ def test_collapses_multiple_slashes(self): f = FieldPath(name="path", prompt="Path") assert f.normalize("path/to///") == "path/to" + def test_root_slash_preserved(self): + # "/" must not be stripped to "" — that silently loses the root intent. + f = FieldPath(name="path", prompt="Path") + assert f.normalize("/") == "/" + class TestFieldDirInEngine: def test_engine_normalizes_cli_param(self, monkeypatch, tmp_path): from scaffold import engine - from scaffold.descriptors import Component, FieldDir, TemplateFile - from scaffold.validators import check_path_conflict + from scaffold.descriptors import Component, FieldDir comp = Component( name="x", diff --git a/tests/test_engine.py b/tests/test_engine.py index 98745ca..97f2ca6 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -172,3 +172,93 @@ def test_templates_dir_none_falls_back_to_default( simple_component, {"name": "my_comp", "path": "out/"}, tmp_path ) assert (tmp_path / "out" / "my_comp" / "file.txt").exists() + + +# --------------------------------------------------------------------------- +# append mode +# --------------------------------------------------------------------------- + + +def test_append_creates_file_when_target_missing(tmp_path, append_component): + engine._render_templates( + append_component, {"name": "my_drv", "path": "out/"}, tmp_path + ) + + kconfig = tmp_path / "Kconfig" + assert kconfig.exists() + content = kconfig.read_text() + assert "MY_DRV_ENABLED" in content + assert 'bool "Enable my_drv"' in content + assert "default n" in content + + +def test_append_preserves_full_block_with_common_lines( + tmp_path, append_component, monkeypatch +): + # Regression test for the line-by-line deduplication bug: + # if a common token like "default n" already exists in the target file, + # the old code would silently drop that line from the appended block, + # producing broken output. The new whole-block check must write the + # rendered content intact. + kconfig = tmp_path / "Kconfig" + kconfig.write_text('# existing content\n\tdefault n\n\tbool "Other"\n}\n') + + monkeypatch.setattr(engine, "show_diff_and_confirm", lambda *a, **kw: True) + engine._render_templates( + append_component, {"name": "my_drv", "path": "out/"}, tmp_path + ) + + content = kconfig.read_text() + # All lines of the rendered block must be present together. + assert "MY_DRV_ENABLED" in content + assert 'bool "Enable my_drv"' in content + assert "default n" in content + + +def test_append_is_idempotent(tmp_path, append_component, monkeypatch): + # Running append twice with the same block must not duplicate content. + monkeypatch.setattr(engine, "show_diff_and_confirm", lambda *a, **kw: True) + + engine._render_templates( + append_component, {"name": "my_drv", "path": "out/"}, tmp_path + ) + engine._render_templates( + append_component, {"name": "my_drv", "path": "out/"}, tmp_path + ) + + content = (tmp_path / "Kconfig").read_text() + assert content.count("MY_DRV_ENABLED") == 1 + + +def test_append_path_outside_cwd_does_not_crash(monkeypatch): + # Regression test for output_path.relative_to(cwd) raising ValueError + # when the target file lives outside the working directory. + import tempfile + from pathlib import Path + from scaffold.descriptors import Component + + fixtures_templates = Path(__file__).parent / "fixtures" / "templates" + + with tempfile.TemporaryDirectory() as outer, tempfile.TemporaryDirectory() as inner: + outer_path = Path(outer) + inner_path = Path(inner) + + # Target file is in outer; cwd is inner — genuinely outside cwd. + target = outer_path / "Kconfig" + target.write_text("# pre-existing\n") + + comp = Component( + name="outside_test", + default_path="out/", + fields=[ + Field(name="name", prompt="Name"), + Field(name="path", prompt="Path", default="out/"), + ], + validators=[], + templates=[TemplateFile("patch.txt.j2", str(target), mode="append")], + templates_dir=fixtures_templates / "append", + ) + + monkeypatch.setattr(engine, "show_diff_and_confirm", lambda *a, **kw: True) + # Must not raise ValueError from relative_to(). + engine._render_templates(comp, {"name": "my_drv", "path": "out/"}, inner_path) diff --git a/tests/test_validators.py b/tests/test_validators.py index 6a6cc6d..d41ed6f 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,6 +1,10 @@ import pytest -from scaffold.validators import check_path_conflict, validate_snake_case +from scaffold.validators import ( + check_path_conflict, + validate_dt_compat, + validate_snake_case, +) class TestValidateSnakeCase: @@ -37,3 +41,41 @@ def test_conflict_raises(self, tmp_path): context = {"path": "drivers", "name": "my_drv"} with pytest.raises(ValueError, match="already exists"): check_path_conflict(context, tmp_path) + + +class TestValidateDtCompat: + @pytest.mark.parametrize( + "value", + [ + "microchip,pic64gd-flash", + "nordic,nrf52840", + "st,stm32f4", + "ti,cc2652r", + ], + ) + def test_valid(self, value): + assert validate_dt_compat(value) is None + + def test_empty(self): + assert validate_dt_compat("") is not None + + def test_missing_comma(self): + err = validate_dt_compat("nodcomma") + assert err is not None + assert "vendor,device" in err + + @pytest.mark.parametrize("value", [",device", "vendor,"]) + def test_empty_segment(self, value): + err = validate_dt_compat(value) + assert err is not None + assert "non-empty" in err + + @pytest.mark.parametrize( + "value", ["Bad_Vendor,device", "UPPER,device", "vend or,dev"] + ) + def test_invalid_vendor(self, value): + assert validate_dt_compat(value) is not None + + @pytest.mark.parametrize("value", ["vendor,BAD_Device", "vendor,dev ice"]) + def test_invalid_device(self, value): + assert validate_dt_compat(value) is not None