From 49cad252865260bb020a1474fb34947109fa1a61 Mon Sep 17 00:00:00 2001 From: MrTango Date: Tue, 26 May 2026 17:58:36 +0300 Subject: [PATCH] Green, i18n-ready scaffolding and a language subtemplate - generated tree is ruff-clean out of the box (isort config + import fixes, drop unused imports, no shell=True in locales/update.py) - ct tests honour global_allow=false; restapi service_for scans the package's own interfaces (+ manual entry) - i18n: MessageFactory in i18n.py (not __init__), zope_i18n_compile_mo_files in dev zope.conf + test layer, new language subtemplate for locale .po catalogs - fix behavior test to look up the real registered IBehavior name - zope-setup full version derived from the addon minor; copier invoked via sys.executable -m copier; track .pot, ignore only .mo - tests: e2e ruff-clean generation, subtemplate idempotency, version derivation --- backend_addon/copier.yml | 5 ++ backend_addon/template/.gitignore.jinja | 3 +- backend_addon/template/pyproject.toml.jinja | 14 +++-- .../src/{{package_folder}}/i18n.py.jinja | 5 ++ .../locales/update.py.jinja | 36 +++++++---- .../src/{{package_folder}}/testing.py.jinja | 9 ++- .../template/tests/test_setup.py.jinja | 7 +-- .../behaviors/{{behavior_module}}.py.jinja | 13 ++-- ...test_behavior_{{behavior_module}}.py.jinja | 5 +- .../content/{{content_type_module}}.py.jinja | 1 - .../test_ct_{{content_type_module}}.py.jinja | 33 +++++++--- ...trolpanel_{{controlpanel_module}}.py.jinja | 1 - .../tests/test_form_{{form_module}}.py.jinja | 1 - .../test_indexer_{{indexer_name}}.py.jinja | 1 - language/copier.yml | 58 +++++++++++++++++ language/copier_hooks.py | 59 ++++++++++++++++++ .../LC_MESSAGES/{{package_name}}.po.jinja | 15 +++++ .../test_portlet_{{portlet_module}}.py.jinja | 1 - restapi_service/copier.yml | 31 +++++++--- restapi_service/extensions.py | 27 ++++++++ .../test_service_{{service_module}}.py.jinja | 1 - ...riber_{{subscriber_handler_name}}.py.jinja | 1 - tests/conftest.py | 6 ++ tests/helpers.py | 11 +++- tests/test_combinations.py | 43 +++++++++++++ tests/test_content_type.py | 33 ++++++++++ tests/test_generated_ruff_clean.py | 62 +++++++++++++++++++ tests/test_language.py | 48 ++++++++++++++ tests/test_restapi_service.py | 43 +++++++++++++ tests/test_version_derivation.py | 42 +++++++++++++ ...{ theme_id | replace('-', '_') }}.py.jinja | 1 - ...t_upgrade_{{destination_version}}.py.jinja | 1 - .../views/{{view_module}}.py.jinja | 15 ++--- .../test_viewlet_{{viewlet_module}}.py.jinja | 1 - .../{{vocabulary_module}}.py.jinja | 4 +- .../test_vocab_{{vocabulary_module}}.py.jinja | 1 - zope-setup/copier.yml | 2 +- zope-setup/copier_hooks.py | 2 +- zope-setup/extensions.py | 17 +++++ zope-setup/template/.gitignore.jinja | 3 +- .../{{ instance_name }}/etc/zope.conf.jinja | 5 ++ 41 files changed, 594 insertions(+), 73 deletions(-) create mode 100644 backend_addon/template/src/{{package_folder}}/i18n.py.jinja create mode 100644 language/copier.yml create mode 100644 language/copier_hooks.py create mode 100644 language/template/src/{{package_folder}}/locales/{{language_code}}/LC_MESSAGES/{{package_name}}.po.jinja create mode 100644 restapi_service/extensions.py create mode 100644 tests/test_generated_ruff_clean.py create mode 100644 tests/test_language.py create mode 100644 tests/test_version_derivation.py diff --git a/backend_addon/copier.yml b/backend_addon/copier.yml index b2d66b6..5aeef7f 100644 --- a/backend_addon/copier.yml +++ b/backend_addon/copier.yml @@ -68,6 +68,11 @@ author_email: help: "Author email" default: "dev@plone.org" +github_organization: + type: str + help: "GitHub organization or user for project URLs" + default: "{{ package_name.split('.')[:-1] | join('.') or 'collective' }}" + # Computed values - package folder structure package_folder: type: str diff --git a/backend_addon/template/.gitignore.jinja b/backend_addon/template/.gitignore.jinja index c2679ce..cf75d11 100644 --- a/backend_addon/template/.gitignore.jinja +++ b/backend_addon/template/.gitignore.jinja @@ -51,9 +51,8 @@ coverage.xml .pytype/ .ruff_cache/ -# Translations +# Translations (compiled catalogs are generated; .pot/.po are tracked) *.mo -*.pot # Environments .env diff --git a/backend_addon/template/pyproject.toml.jinja b/backend_addon/template/pyproject.toml.jinja index 57e2cf9..c9a184c 100644 --- a/backend_addon/template/pyproject.toml.jinja +++ b/backend_addon/template/pyproject.toml.jinja @@ -41,10 +41,10 @@ test = [ ] [project.urls] -Homepage = "https://github.com/collective/{{ package_name }}" -Documentation = "https://github.com/collective/{{ package_name }}" -Repository = "https://github.com/collective/{{ package_name }}.git" -Issues = "https://github.com/collective/{{ package_name }}/issues" +Homepage = "https://github.com/{{ github_organization }}/{{ package_name }}" +Documentation = "https://github.com/{{ github_organization }}/{{ package_name }}" +Repository = "https://github.com/{{ github_organization }}/{{ package_name }}.git" +Issues = "https://github.com/{{ github_organization }}/{{ package_name }}/issues" [project.entry-points."z3c.autoinclude.plugin"] target = "plone" @@ -99,8 +99,14 @@ ignore = ["E731"] [tool.ruff.lint.per-file-ignores] "tests/**" = ["S101"] +# Locale update helper shells out to fixed translation tools (i18ndude/msginit). +"src/**/locales/update.py" = ["S603", "S607"] [tool.ruff.lint.isort] +# Match the single-import-per-line, alphabetical layout the templates emit. +force-single-line = true +order-by-type = false +lines-after-imports = 2 known-first-party = ["{{ package_name.split('.')[0] }}"] [tool.ruff.format] diff --git a/backend_addon/template/src/{{package_folder}}/i18n.py.jinja b/backend_addon/template/src/{{package_folder}}/i18n.py.jinja new file mode 100644 index 0000000..32c2531 --- /dev/null +++ b/backend_addon/template/src/{{package_folder}}/i18n.py.jinja @@ -0,0 +1,5 @@ +"""Message factory for the {{ package_name }} i18n domain.""" +from zope.i18nmessageid import MessageFactory + + +_ = MessageFactory("{{ package_name }}") diff --git a/backend_addon/template/src/{{package_folder}}/locales/update.py.jinja b/backend_addon/template/src/{{package_folder}}/locales/update.py.jinja index be486f4..14008ce 100644 --- a/backend_addon/template/src/{{package_folder}}/locales/update.py.jinja +++ b/backend_addon/template/src/{{package_folder}}/locales/update.py.jinja @@ -4,6 +4,7 @@ Usage: python update.py """ +import glob import os import subprocess from importlib.resources import files @@ -17,7 +18,7 @@ locale_path = target_path + "locales/" i18ndude = "i18ndude" # ignore node_modules files resulting in errors -excludes = '"*.html *json-schema*.xml"' +excludes = "*.html *json-schema*.xml" def locale_folder_setup(): @@ -29,25 +30,38 @@ def locale_folder_setup(): continue lc_messages_path = lang + "/LC_MESSAGES/" os.mkdir(lc_messages_path) - cmd = f"msginit --locale={lang} --input={domain}.pot --output={lang}/LC_MESSAGES/{domain}.po" - subprocess.call(cmd, shell=True) + subprocess.call( + [ + "msginit", + f"--locale={lang}", + f"--input={domain}.pot", + f"--output={lang}/LC_MESSAGES/{domain}.po", + ] + ) os.chdir("../../../../") def _rebuild(): - cmd = ( - f"{i18ndude} rebuild-pot --pot {locale_path}/{domain}.pot " - f"--exclude {excludes} --create {domain} {target_path}" + subprocess.call( + [ + i18ndude, + "rebuild-pot", + "--pot", + f"{locale_path}/{domain}.pot", + "--exclude", + excludes, + "--create", + domain, + target_path, + ] ) - subprocess.call(cmd, shell=True) def _sync(): - cmd = ( - f"{i18ndude} sync --pot {locale_path}/{domain}.pot " - f"{locale_path}*/LC_MESSAGES/{domain}.po" + po_files = glob.glob(f"{locale_path}*/LC_MESSAGES/{domain}.po") + subprocess.call( + [i18ndude, "sync", "--pot", f"{locale_path}/{domain}.pot", *po_files] ) - subprocess.call(cmd, shell=True) def update_locale(): diff --git a/backend_addon/template/src/{{package_folder}}/testing.py.jinja b/backend_addon/template/src/{{package_folder}}/testing.py.jinja index b6ba065..6847b11 100644 --- a/backend_addon/template/src/{{package_folder}}/testing.py.jinja +++ b/backend_addon/template/src/{{package_folder}}/testing.py.jinja @@ -1,4 +1,8 @@ """Testing setup for {{ package_name }}.""" +import os + +import plone.app.theming +import plone.restapi from plone.app.testing import FunctionalTesting from plone.app.testing import IntegrationTesting from plone.app.testing import PloneSandboxLayer @@ -6,9 +10,6 @@ from plone.app.testing import SITE_OWNER_NAME from plone.app.testing import SITE_OWNER_PASSWORD from plone.testing.zope import WSGI_SERVER_FIXTURE -import plone.app.theming -import plone.restapi - import {{ package_name }} @@ -17,6 +18,8 @@ class {{ package_name.replace('.', ' ').title().replace(' ', '') }}Layer(PloneSa def setUpZope(self, app, configurationContext): """Set up Zope.""" + # Compile .po -> .mo so add-on translations load during tests. + os.environ.setdefault("zope_i18n_compile_mo_files", "true") self.loadZCML(package=plone.app.theming) self.loadZCML(package=plone.restapi) self.loadZCML(package={{ package_name }}) diff --git a/backend_addon/template/tests/test_setup.py.jinja b/backend_addon/template/tests/test_setup.py.jinja index abede69..43f9731 100644 --- a/backend_addon/template/tests/test_setup.py.jinja +++ b/backend_addon/template/tests/test_setup.py.jinja @@ -1,6 +1,5 @@ """Test {{ package_name }} installation.""" import pytest - from plone import api from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID @@ -20,11 +19,11 @@ class TestSetup: def test_browserlayer(self): """Test browserlayer is registered.""" - from plone.browserlayer import utils - # Add actual browserlayer check if you have one + # Add an actual browserlayer check if your addon registers one, e.g.: + # from plone.browserlayer import utils # from {{ package_name }}.interfaces import I{{ package_name.replace('.', ' ').title().replace(' ', '') }}Layer # assert I{{ package_name.replace('.', ' ').title().replace(' ', '') }}Layer in utils.registered_layers() - pass + assert True class TestUninstall: diff --git a/behavior/template/src/{{package_folder}}/behaviors/{{behavior_module}}.py.jinja b/behavior/template/src/{{package_folder}}/behaviors/{{behavior_module}}.py.jinja index 87c026e..8ba70a7 100644 --- a/behavior/template/src/{{package_folder}}/behaviors/{{behavior_module}}.py.jinja +++ b/behavior/template/src/{{package_folder}}/behaviors/{{behavior_module}}.py.jinja @@ -1,14 +1,17 @@ """{{ behavior_name }} behavior.""" from plone.autoform.interfaces import IFormFieldProvider -from plone.supermodel import model -from zope import schema -from zope.interface import Interface -from zope.interface import provider {%- if behavior_factory %} -from zope.interface import implementer from plone.dexterity.interfaces import IDexterityContent +{%- endif %} +from plone.supermodel import model +{%- if behavior_factory %} from zope.component import adapter +from zope.interface import implementer +{%- endif %} +{%- if behavior_marker %} +from zope.interface import Interface {%- endif %} +from zope.interface import provider {%- if behavior_marker %} diff --git a/behavior/template/tests/test_behavior_{{behavior_module}}.py.jinja b/behavior/template/tests/test_behavior_{{behavior_module}}.py.jinja index 28280c4..1033b27 100644 --- a/behavior/template/tests/test_behavior_{{behavior_module}}.py.jinja +++ b/behavior/template/tests/test_behavior_{{behavior_module}}.py.jinja @@ -1,6 +1,5 @@ """Tests for {{ behavior_interface }} behavior.""" import pytest - from plone.behavior.interfaces import IBehavior from zope.component import getUtility @@ -20,7 +19,7 @@ class TestBehavior{{ behavior_class }}: """Test behavior is registered.""" behavior = getUtility( IBehavior, - name="{{ package_name }}.{{ behavior_module }}", + name="{{ package_name }}.behaviors.{{ behavior_module }}.{{ behavior_interface }}", ) assert behavior is not None assert behavior.title @@ -30,7 +29,7 @@ class TestBehavior{{ behavior_class }}: """Test behavior marker interface.""" behavior = getUtility( IBehavior, - name="{{ package_name }}.{{ behavior_module }}", + name="{{ package_name }}.behaviors.{{ behavior_module }}.{{ behavior_interface }}", ) from {{ package_name }}.behaviors.{{ behavior_module }} import {{ behavior_interface }}Marker assert behavior.marker == {{ behavior_interface }}Marker diff --git a/content_type/template/src/{{package_folder}}/content/{{content_type_module}}.py.jinja b/content_type/template/src/{{package_folder}}/content/{{content_type_module}}.py.jinja index 6345057..9d9e85d 100644 --- a/content_type/template/src/{{package_folder}}/content/{{content_type_module}}.py.jinja +++ b/content_type/template/src/{{package_folder}}/content/{{content_type_module}}.py.jinja @@ -1,7 +1,6 @@ """{{ content_type_name }} content type.""" from plone.dexterity.content import {{ content_type_base }} from plone.supermodel import model -from zope import schema from zope.interface import implementer diff --git a/content_type/template/tests/test_ct_{{content_type_module}}.py.jinja b/content_type/template/tests/test_ct_{{content_type_module}}.py.jinja index 4abe282..0f1b5bf 100644 --- a/content_type/template/tests/test_ct_{{content_type_module}}.py.jinja +++ b/content_type/template/tests/test_ct_{{content_type_module}}.py.jinja @@ -1,6 +1,5 @@ """Tests for {{ content_type_name }} content type.""" import pytest - from plone import api from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID @@ -19,6 +18,19 @@ class TestContentType{{ content_type_class }}: def _setup(self, integration): self.portal = integration["portal"] setRoles(self.portal, TEST_USER_ID, ["Manager"]) +{%- if global_allow %} + # Globally addable: create directly in the portal root. + self.container = self.portal +{%- else %} + # Not globally addable: create a {{ parent_content_type_resolved }} + # parent that allows this type, and add inside it. + self.container = api.content.create( + container=self.portal, + type="{{ parent_content_type_resolved }}", + id="parent-container", + title="Parent Container", + ) +{%- endif %} def test_fti(self): """Test FTI is installed.""" @@ -38,10 +50,8 @@ class TestContentType{{ content_type_class }}: def test_factory(self): """Test content factory.""" - fti = queryUtility(IDexterityFTI, name="{{ content_type_portal_type }}") - factory = fti.factory obj = api.content.create( - container=self.portal, + container=self.container, type="{{ content_type_portal_type }}", id="test-{{ content_type_module }}", title="Test {{ content_type_name }}", @@ -52,7 +62,7 @@ class TestContentType{{ content_type_class }}: def test_adding(self): """Test content can be added.""" obj = api.content.create( - container=self.portal, + container=self.container, type="{{ content_type_portal_type }}", id="test-{{ content_type_module }}", title="Test {{ content_type_name }}", @@ -62,15 +72,24 @@ class TestContentType{{ content_type_class }}: def test_deleting(self): """Test content can be deleted.""" obj = api.content.create( - container=self.portal, + container=self.container, type="{{ content_type_portal_type }}", id="test-{{ content_type_module }}", title="Test {{ content_type_name }}", ) api.content.delete(obj=obj) - assert "test-{{ content_type_module }}" not in self.portal.objectIds() + assert "test-{{ content_type_module }}" not in self.container.objectIds() def test_global_allow(self): """Test content type global_allow setting.""" fti = queryUtility(IDexterityFTI, name="{{ content_type_portal_type }}") assert fti.global_allow is {{ global_allow | string | title }} +{%- if not global_allow %} + + def test_addable_in_parent(self): + """Test the type is addable inside its {{ parent_content_type_resolved }} parent.""" + addable = [ + fti.getId() for fti in self.container.allowedContentTypes() + ] + assert "{{ content_type_portal_type }}" in addable +{%- endif %} diff --git a/controlpanel/template/tests/test_controlpanel_{{controlpanel_module}}.py.jinja b/controlpanel/template/tests/test_controlpanel_{{controlpanel_module}}.py.jinja index 588bd5d..cc00e95 100644 --- a/controlpanel/template/tests/test_controlpanel_{{controlpanel_module}}.py.jinja +++ b/controlpanel/template/tests/test_controlpanel_{{controlpanel_module}}.py.jinja @@ -1,6 +1,5 @@ """Tests for {{ controlpanel_name }} control panel.""" import pytest - from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID diff --git a/form/template/tests/test_form_{{form_module}}.py.jinja b/form/template/tests/test_form_{{form_module}}.py.jinja index 48c694f..8f577b0 100644 --- a/form/template/tests/test_form_{{form_module}}.py.jinja +++ b/form/template/tests/test_form_{{form_module}}.py.jinja @@ -1,6 +1,5 @@ """Tests for {{ form_class_name }} form.""" import pytest - from plone import api from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID diff --git a/indexer/template/tests/test_indexer_{{indexer_name}}.py.jinja b/indexer/template/tests/test_indexer_{{indexer_name}}.py.jinja index b4fbb98..b6de879 100644 --- a/indexer/template/tests/test_indexer_{{indexer_name}}.py.jinja +++ b/indexer/template/tests/test_indexer_{{indexer_name}}.py.jinja @@ -1,6 +1,5 @@ """Tests for {{ indexer_name }} indexer.""" import pytest - from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID diff --git a/language/copier.yml b/language/copier.yml new file mode 100644 index 0000000..b795f67 --- /dev/null +++ b/language/copier.yml @@ -0,0 +1,58 @@ +_min_copier_version: "9.0.0" +_subdirectory: template +_answers_file: ".copier-answers.language-{{ language_code }}.yml" + +_plonecli: + type: sub + parent: backend_addon + description: "Add a translation language (locale .po catalog) to the addon" + +_tasks: + - command: + - "uv" + - "run" + - "--no-project" + - "--with" + - "tomlkit" + - "python3" + - "{{ _copier_conf.src_path }}/copier_hooks.py" + - "validate" + - "{{ _copier_conf.dst_path }}" + when: "{{ _copier_conf.operation == 'copy' }}" + - command: + - "uv" + - "run" + - "--no-project" + - "--with" + - "tomlkit" + - "python3" + - "{{ _copier_conf.src_path }}/copier_hooks.py" + - "post_copy" + - "{{ _copier_conf.dst_path }}" + - "{{ language_code }}" + +# Language to add +language_code: + type: str + help: "Language code (ISO 639-1, e.g. 'de', 'fr', 'es')" + validator: "{% if not language_code %}Language code is required{% endif %}" + +language_name: + type: str + help: "Human-readable language name (e.g. 'German')" + default: "{{ language_code }}" + +# Package info - read from parent addon +package_name: + type: str + help: "Parent addon package name" + +package_folder: + type: str + help: "Parent addon package folder (e.g., collective/mypackage)" + default: "{{ package_name | replace('.', '/') }}" + +current_date: + type: str + default: "{{ '%Y-%m-%d' | strftime }}" + when: false diff --git a/language/copier_hooks.py b/language/copier_hooks.py new file mode 100644 index 0000000..469d862 --- /dev/null +++ b/language/copier_hooks.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Tasks for the language subtemplate.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "shared")) + +from exceptions import AddonContextError, CopierTemplateError # noqa: E402 +from hooks.addon_context import ( # noqa: E402 + find_addon_context, + resolve_post_copy_context, +) +from hooks.git_check import warn_git_unclean # noqa: E402 + + +def validate(dest_path: str) -> None: + dest = Path(dest_path) + warn_git_unclean(dest) + if not find_addon_context(dest): + raise AddonContextError( + f"No parent addon detected at {dest}. " + "This template must be run inside an existing backend_addon." + ) + + +def post_copy(dest_path: str, language_code: str) -> None: + ctx = resolve_post_copy_context(dest_path) + if ctx is None: + print( + "Warning: could not detect parent addon (no pyproject.toml, " + "bobtemplate.cfg, or setup.py). Skipping configuration updates." + ) + return + + if ctx.register_subtemplate("languages", language_code): + print(f"Registered language '{language_code}' in addon settings.") + + +def main() -> None: + if len(sys.argv) < 2: + print("Usage: copier_hooks.py [args...]") + sys.exit(1) + + command = sys.argv[1] + try: + if command == "validate": + validate(sys.argv[2]) + elif command == "post_copy": + post_copy(sys.argv[2], sys.argv[3]) + else: + print(f"Unknown command: {command}") + sys.exit(1) + except CopierTemplateError as e: + print(f"\nERROR: {e}\n") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/language/template/src/{{package_folder}}/locales/{{language_code}}/LC_MESSAGES/{{package_name}}.po.jinja b/language/template/src/{{package_folder}}/locales/{{language_code}}/LC_MESSAGES/{{package_name}}.po.jinja new file mode 100644 index 0000000..3d9e9ee --- /dev/null +++ b/language/template/src/{{package_folder}}/locales/{{language_code}}/LC_MESSAGES/{{package_name}}.po.jinja @@ -0,0 +1,15 @@ +msgid "" +msgstr "" +"Project-Id-Version: {{ package_name }}\n" +"POT-Creation-Date: {{ current_date }}\n" +"PO-Revision-Date: {{ current_date }}\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language-Code: {{ language_code }}\n" +"Language-Name: {{ language_name }}\n" +"Preferred-Encodings: utf-8 latin1\n" +"Domain: {{ package_name }}\n" diff --git a/portlet/template/tests/test_portlet_{{portlet_module}}.py.jinja b/portlet/template/tests/test_portlet_{{portlet_module}}.py.jinja index d0b36c5..54ffc83 100644 --- a/portlet/template/tests/test_portlet_{{portlet_module}}.py.jinja +++ b/portlet/template/tests/test_portlet_{{portlet_module}}.py.jinja @@ -1,6 +1,5 @@ """Tests for {{ portlet_name }} portlet.""" import pytest - from plone.portlets.interfaces import IPortletType from zope.component import getUtility diff --git a/restapi_service/copier.yml b/restapi_service/copier.yml index cf3283d..8784018 100644 --- a/restapi_service/copier.yml +++ b/restapi_service/copier.yml @@ -2,6 +2,10 @@ _min_copier_version: "9.0.0" _subdirectory: template _answers_file: .copier-answers.restapi-service.yml +_jinja_extensions: + - copier_template_extensions.TemplateExtensionLoader + - extensions.py:ServiceForInterfacesHook + _plonecli: type: sub parent: backend_addon @@ -30,7 +34,7 @@ _tasks: - "post_copy" - "{{ _copier_conf.dst_path }}" - "{{ service_name }}" - - "{{ service_for }}" + - "{{ service_for_resolved }}" - "{{ service_module }}" - "{{ service_class }}" - "{{ service_endpoint }}" @@ -88,16 +92,27 @@ http_delete: help: "Support DELETE requests?" default: false -# Context +# Context — the interface the service is registered for. Choices are the +# default Plone interfaces plus any content type interfaces found in this +# package, with "" to type a custom dotted name. service_for: type: str - help: "Context for the service" + help: "Context interface the service is registered for" default: "plone.dexterity.interfaces.IDexterityContainer" - choices: - - "plone.dexterity.interfaces.IDexterityContainer" - - "plone.dexterity.interfaces.IDexterityContent" - - "Products.CMFPlone.interfaces.IPloneSiteRoot" - - "zope.interface.Interface" + choices: "{{ service_for_choices }}" + +service_for_manual: + type: str + help: "Custom interface dotted name (e.g. 'my.package.interfaces.IFoo')" + default: "" + when: "{{ service_for == '' }}" + validator: "{% if not service_for_manual %}Custom interface is required{% endif %}" + +# Computed: resolve service_for to the final interface string +service_for_resolved: + type: str + default: "{{ service_for_manual if service_for == '' else service_for }}" + when: false # Computed values service_module: diff --git a/restapi_service/extensions.py b/restapi_service/extensions.py new file mode 100644 index 0000000..092b3c4 --- /dev/null +++ b/restapi_service/extensions.py @@ -0,0 +1,27 @@ +"""Jinja2 extensions for the restapi_service subtemplate.""" + +import sys +from pathlib import Path + +from copier_template_extensions import ContextHook + +# Ensure shared package is importable +_repo_root = Path(__file__).resolve().parent.parent +if str(_repo_root) not in sys.path: + sys.path.insert(0, str(_repo_root)) + +from shared.utils.content_types_scanner import ( # noqa: E402 + all_content_type_interfaces, +) + + +class ServiceForInterfacesHook(ContextHook): + """Populate ``service_for_choices`` with default + package interfaces.""" + + update = False + + def hook(self, context): + dst_path = Path(context.get("_copier_conf", {}).get("dst_path", "") or ".") + choices = all_content_type_interfaces(dst_path) + choices.append("") + context["service_for_choices"] = choices diff --git a/restapi_service/template/tests/test_service_{{service_module}}.py.jinja b/restapi_service/template/tests/test_service_{{service_module}}.py.jinja index 1c4a29f..8960a3a 100644 --- a/restapi_service/template/tests/test_service_{{service_module}}.py.jinja +++ b/restapi_service/template/tests/test_service_{{service_module}}.py.jinja @@ -1,6 +1,5 @@ """Tests for {{ service_name }} REST API service.""" import pytest - from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID diff --git a/subscriber/template/tests/test_subscriber_{{subscriber_handler_name}}.py.jinja b/subscriber/template/tests/test_subscriber_{{subscriber_handler_name}}.py.jinja index f29ba4a..67462fe 100644 --- a/subscriber/template/tests/test_subscriber_{{subscriber_handler_name}}.py.jinja +++ b/subscriber/template/tests/test_subscriber_{{subscriber_handler_name}}.py.jinja @@ -1,6 +1,5 @@ """Tests for {{ subscriber_handler_name }} event subscriber.""" import pytest - from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID diff --git a/tests/conftest.py b/tests/conftest.py index bf4688c..0696c8b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -174,6 +174,12 @@ def svelte_app_template(templates_dir): return templates_dir / "svelte_app" +@pytest.fixture +def language_template(templates_dir): + """Return path to language template.""" + return templates_dir / "language" + + @pytest.fixture(scope="session") def prebuilt_addon_source(tmp_path_factory, backend_addon_session_template): """ diff --git a/tests/helpers.py b/tests/helpers.py index 8f1ac49..b7ea1b7 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,6 +1,7 @@ """Test helper utilities for copier-templates tests.""" import shutil import subprocess +import sys from pathlib import Path from typing import Any @@ -27,7 +28,15 @@ def run_copier( Returns: CompletedProcess with stdout, stderr, and returncode """ - cmd = ["copier", "copy", "--trust", str(template_path), str(dest_path)] + cmd = [ + sys.executable, + "-m", + "copier", + "copy", + "--trust", + str(template_path), + str(dest_path), + ] if defaults: cmd.append("--defaults") diff --git a/tests/test_combinations.py b/tests/test_combinations.py index 739199d..270794f 100644 --- a/tests/test_combinations.py +++ b/tests/test_combinations.py @@ -114,6 +114,49 @@ def test_addon_with_all_subtemplates( assert "@analytics" in subtemplates["services"] +class TestSubtemplateIdempotency: + """Re-running a subtemplate with the same answers must not duplicate wiring.""" + + def test_rerun_does_not_duplicate( + self, + temp_dir, + backend_addon_template, + content_type_template, + restapi_service_template, + behavior_template, + ): + pkg = temp_dir / "mypackage" + run_copier(backend_addon_template, pkg, data={"package_name": "collective.mypackage"}) + + def _add(template, **data): + data["package_name"] = "collective.mypackage" + run_copier(template, pkg, data=data) + + for _ in range(2): + _add(content_type_template, content_type_name="Article") + _add(restapi_service_template, service_name="stats") + _add(behavior_template, behavior_name="IFeatured") + + src = pkg / "src/collective/mypackage" + # No duplicate registrations in the parent configure.zcml. + parent = (src / "configure.zcml").read_text() + assert parent.count('package=".content"') == 1 + assert parent.count('package=".services"') == 1 + assert parent.count('package=".behaviors"') == 1 + # No duplicate elements in the feature configure.zcml files. + assert (src / "services/configure.zcml").read_text().count('name="@stats"') == 1 + assert ( + (src / "behaviors/configure.zcml").read_text().count("' in content + def test_not_global_allow_test_uses_parent_container( + self, addon_dir, content_type_template + ): + """Generated test creates a parent and asserts addability when not global.""" + run_copier( + content_type_template, + addon_dir, + data={ + "content_type_name": "Book", + "global_allow": False, + "parent_content_type": "Folder", + }, + ) + content = (addon_dir / "tests/test_ct_book.py").read_text() + assert 'type="Folder"' in content + assert "self.container = api.content.create(" in content + assert "container=self.container," in content + assert "def test_addable_in_parent" in content + assert "allowedContentTypes()" in content + + def test_global_allow_test_uses_portal( + self, addon_dir, content_type_template + ): + """Generated test creates directly in the portal when globally addable.""" + run_copier( + content_type_template, + addon_dir, + data={"content_type_name": "Article", "global_allow": True}, + ) + content = (addon_dir / "tests/test_ct_article.py").read_text() + assert "self.container = self.portal" in content + assert "def test_addable_in_parent" not in content + def test_disable_navigation(self, addon_dir, content_type_template): """Content type without navigation behavior.""" run_copier( diff --git a/tests/test_generated_ruff_clean.py b/tests/test_generated_ruff_clean.py new file mode 100644 index 0000000..1dcf42d --- /dev/null +++ b/tests/test_generated_ruff_clean.py @@ -0,0 +1,62 @@ +"""End-to-end: a scaffolded addon must be ruff-clean out of the box. + +Generates a backend addon plus a representative set of subtemplates and runs +``ruff check`` (the same lint the generated CI runs) on the result. This locks +in the contract that scaffolded code passes linting with zero hand-edits. +""" +import shutil +import subprocess +import sys + +import pytest +from helpers import run_copier + + +def _ruff_available(): + return shutil.which("ruff") is not None + + +@pytest.mark.skipif(not _ruff_available(), reason="ruff not installed") +def test_generated_addon_is_ruff_clean( + temp_dir, + backend_addon_template, + content_type_template, + behavior_template, + restapi_service_template, + vocabulary_template, + view_template, + language_template, +): + pkg = temp_dir / "mypackage" + pkg_name = "collective.demo" + + def _run(template, **data): + data.setdefault("package_name", pkg_name) + result = run_copier(template, pkg, data=data) + assert result.returncode == 0, f"copier failed: {result.stderr}" + + _run(backend_addon_template) + # Globally addable + contained content types exercise both test paths. + _run(content_type_template, content_type_name="Todos", global_allow=True) + _run( + content_type_template, + content_type_name="Todo", + global_allow=False, + parent_content_type="Todos", + ) + _run(behavior_template, behavior_name="IFeatured") + _run(restapi_service_template, service_name="stats") + _run(vocabulary_template, vocabulary_name="Priorities", vocabulary_type="simple") + _run(view_template, view_name="my-view") + _run(language_template, language_code="de", language_name="German") + + result = subprocess.run( + [sys.executable, "-m", "ruff", "check", "."], + cwd=pkg, + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + "Generated addon is not ruff-clean:\n" + f"{result.stdout}\n{result.stderr}" + ) diff --git a/tests/test_language.py b/tests/test_language.py new file mode 100644 index 0000000..4315bbe --- /dev/null +++ b/tests/test_language.py @@ -0,0 +1,48 @@ +"""Tests for the language subtemplate. + +Adds a translation locale (``locales//LC_MESSAGES/.po``) to an +existing backend addon. +""" +from helpers import apply_subtemplate, assert_file_exists, run_copier + + +class TestLanguageRequiresAddon: + def test_fails_without_parent_addon(self, temp_dir, language_template): + result = run_copier( + language_template, + temp_dir, # No addon here + data={"language_code": "de"}, + ) + assert not (temp_dir / "src").exists() or result.returncode != 0 + + +class TestLanguageCreation: + def _apply(self, fresh_addon, language_template, **extra): + data = {"language_code": "de", "package_name": "collective.mypackage"} + data.update(extra) + result = apply_subtemplate(language_template, fresh_addon, data=data) + assert result.returncode == 0, f"copier failed: {result.stderr}" + + def test_creates_po_catalog(self, fresh_addon, language_template): + self._apply(fresh_addon, language_template) + po = ( + fresh_addon + / "src/collective/mypackage/locales/de/LC_MESSAGES/collective.mypackage.po" + ) + assert_file_exists(po, content_contains="Language-Code: de") + assert_file_exists(po, content_contains="Domain: collective.mypackage") + + def test_language_name_recorded(self, fresh_addon, language_template): + self._apply(fresh_addon, language_template, language_name="German") + po = ( + fresh_addon + / "src/collective/mypackage/locales/de/LC_MESSAGES/collective.mypackage.po" + ) + assert_file_exists(po, content_contains="Language-Name: German") + + def test_addon_has_message_factory(self, fresh_addon, language_template): + """The addon ships an i18n module with a MessageFactory (not in __init__).""" + i18n = fresh_addon / "src/collective/mypackage/i18n.py" + assert_file_exists(i18n, content_contains='MessageFactory("collective.mypackage")') + init = fresh_addon / "src/collective/mypackage/__init__.py" + assert "MessageFactory" not in init.read_text() diff --git a/tests/test_restapi_service.py b/tests/test_restapi_service.py index 0e4b501..455ab43 100644 --- a/tests/test_restapi_service.py +++ b/tests/test_restapi_service.py @@ -229,3 +229,46 @@ def test_service_for_site_root(self, addon_dir, restapi_service_template): zcml_file, content_contains="Products.CMFPlone.interfaces.IPloneSiteRoot", ) + + def test_service_for_own_content_type_interface( + self, addon_dir, content_type_template, restapi_service_template + ): + """service_for can target a content type interface from this package.""" + run_copier( + content_type_template, + addon_dir, + data={ + "content_type_name": "Article", + "package_name": "collective.mypackage", + }, + ) + own_iface = "collective.mypackage.content.article.IArticle" + run_copier( + restapi_service_template, + addon_dir, + data={ + "service_name": "article-info", + "package_name": "collective.mypackage", + "service_for": own_iface, + }, + ) + zcml_file = addon_dir / "src/collective/mypackage/services/configure.zcml" + assert_file_exists(zcml_file, content_contains=f'for="{own_iface}"') + + def test_service_for_manual_entry( + self, addon_dir, restapi_service_template + ): + """'' resolves to the custom interface dotted name.""" + custom = "my.package.interfaces.ICustom" + run_copier( + restapi_service_template, + addon_dir, + data={ + "service_name": "custom-svc", + "package_name": "collective.mypackage", + "service_for": "", + "service_for_manual": custom, + }, + ) + zcml_file = addon_dir / "src/collective/mypackage/services/configure.zcml" + assert_file_exists(zcml_file, content_contains=f'for="{custom}"') diff --git a/tests/test_version_derivation.py b/tests/test_version_derivation.py new file mode 100644 index 0000000..89867e7 --- /dev/null +++ b/tests/test_version_derivation.py @@ -0,0 +1,42 @@ +"""Unit tests for zope-setup full-version derivation in the composite.""" +import importlib.util +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _load_addon_context_hook(): + spec = importlib.util.spec_from_file_location( + "zope_setup_extensions", REPO_ROOT / "zope-setup" / "extensions.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.AddonContextHook + + +ALL = ["6.1.2", "6.1.1", "6.0.10", "5.2.14"] + + +def test_derive_matches_addon_minor(): + hook = _load_addon_context_hook() + assert hook._derive_full_version({"plone_version": "6.0"}, ALL) == "6.0.10" + + +def test_derive_picks_latest_for_minor(): + hook = _load_addon_context_hook() + assert hook._derive_full_version({"plone_version": "6.1"}, ALL) == "6.1.2" + + +def test_derive_falls_back_to_latest_without_addon(): + hook = _load_addon_context_hook() + assert hook._derive_full_version({}, ALL) == "6.1.2" + + +def test_derive_falls_back_when_minor_unavailable(): + hook = _load_addon_context_hook() + assert hook._derive_full_version({"plone_version": "7.0"}, ALL) == "6.1.2" + + +def test_derive_empty_versions(): + hook = _load_addon_context_hook() + assert hook._derive_full_version({"plone_version": "6.1"}, []) == "" diff --git a/theme_barceloneta/template/tests/test_theme_{{ theme_id | replace('-', '_') }}.py.jinja b/theme_barceloneta/template/tests/test_theme_{{ theme_id | replace('-', '_') }}.py.jinja index 6ea29a7..80a136d 100644 --- a/theme_barceloneta/template/tests/test_theme_{{ theme_id | replace('-', '_') }}.py.jinja +++ b/theme_barceloneta/template/tests/test_theme_{{ theme_id | replace('-', '_') }}.py.jinja @@ -1,6 +1,5 @@ """Test the {{ theme_name }} theme is installed and active.""" import pytest - from plone import api from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID diff --git a/upgrade_step/template/tests/test_upgrade_{{destination_version}}.py.jinja b/upgrade_step/template/tests/test_upgrade_{{destination_version}}.py.jinja index 35f71a6..29a7d50 100644 --- a/upgrade_step/template/tests/test_upgrade_{{destination_version}}.py.jinja +++ b/upgrade_step/template/tests/test_upgrade_{{destination_version}}.py.jinja @@ -1,6 +1,5 @@ """Tests for upgrade step {{ source_version }} -> {{ destination_version }}.""" import pytest - from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID diff --git a/view/template/src/{{package_folder}}/views/{{view_module}}.py.jinja b/view/template/src/{{package_folder}}/views/{{view_module}}.py.jinja index a8c5d36..7006e8f 100644 --- a/view/template/src/{{package_folder}}/views/{{view_module}}.py.jinja +++ b/view/template/src/{{package_folder}}/views/{{view_module}}.py.jinja @@ -10,26 +10,23 @@ from plone.dexterity.browser.view import DefaultView from plone.app.contenttypes.browser.collection import CollectionView {%- endif %} {%- if view_marker %} -from zope.interface import Interface from zope.interface import implementer -{%- endif %} -{%- if view_template %} -# from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile -{%- endif %} -{%- if view_marker %} +from zope.interface import Interface class I{{ view_class_name }}(Interface): """Marker interface for {{ view_class_name }}.""" -{%- endif %} -{%- if view_marker %} @implementer(I{{ view_class_name }}) -{%- endif %} class {{ view_class_name }}({{ view_base_class }}): """{{ view_description }}""" +{%- else %} + +class {{ view_class_name }}({{ view_base_class }}): + """{{ view_description }}""" +{%- endif %} {%- if view_template %} # If you need to override the template registered in configure.zcml: diff --git a/viewlet/template/tests/test_viewlet_{{viewlet_module}}.py.jinja b/viewlet/template/tests/test_viewlet_{{viewlet_module}}.py.jinja index dc32e65..01cde0f 100644 --- a/viewlet/template/tests/test_viewlet_{{viewlet_module}}.py.jinja +++ b/viewlet/template/tests/test_viewlet_{{viewlet_module}}.py.jinja @@ -1,6 +1,5 @@ """Tests for {{ viewlet_class_name }} viewlet.""" import pytest - from plone import api from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID diff --git a/vocabulary/template/src/{{package_folder}}/vocabularies/{{vocabulary_module}}.py.jinja b/vocabulary/template/src/{{package_folder}}/vocabularies/{{vocabulary_module}}.py.jinja index ef89708..252130a 100644 --- a/vocabulary/template/src/{{package_folder}}/vocabularies/{{vocabulary_module}}.py.jinja +++ b/vocabulary/template/src/{{package_folder}}/vocabularies/{{vocabulary_module}}.py.jinja @@ -34,6 +34,8 @@ from zope.schema.interfaces import IVocabularyFactory from zope.schema.vocabulary import SimpleTerm from zope.schema.vocabulary import SimpleVocabulary +from {{ package_name }}.i18n import _ + class VocabItem: """Lightweight holder for a (token, value) pair.""" @@ -53,7 +55,7 @@ class {{ vocabulary_class }}: def __call__(self, context): items = [ - VocabItem("example-token", "Example value"), + VocabItem("example-token", _("Example value")), ] terms = [ SimpleTerm(value=item.token, token=item.token, title=item.value) diff --git a/vocabulary/template/tests/test_vocab_{{vocabulary_module}}.py.jinja b/vocabulary/template/tests/test_vocab_{{vocabulary_module}}.py.jinja index e8e051c..6bb85cb 100644 --- a/vocabulary/template/tests/test_vocab_{{vocabulary_module}}.py.jinja +++ b/vocabulary/template/tests/test_vocab_{{vocabulary_module}}.py.jinja @@ -1,6 +1,5 @@ """Tests for {{ vocabulary_class }} vocabulary.""" import pytest - from zope.component import getUtility from zope.schema.interfaces import IVocabularyFactory from zope.schema.interfaces import IVocabularyTokenized diff --git a/zope-setup/copier.yml b/zope-setup/copier.yml index 1cf47b2..a08c3ee 100644 --- a/zope-setup/copier.yml +++ b/zope-setup/copier.yml @@ -76,7 +76,7 @@ project_description: plone_version: type: str help: "Plone version to use" - default: "{{ plone_versions_full[0] }}" + default: "{{ plone_version_full_default | default(plone_versions_full[0]) }}" choices: "{{ plone_versions_full }}" distribution: diff --git a/zope-setup/copier_hooks.py b/zope-setup/copier_hooks.py index 4a5bfdc..2f82fb8 100644 --- a/zope-setup/copier_hooks.py +++ b/zope-setup/copier_hooks.py @@ -87,7 +87,7 @@ def create_initial_instance(dest_path, db_storage, base_path="var", template_path = Path(__file__).resolve().parent.parent / "zope_instance" cmd = [ - "copier", "copy", "--trust", "--defaults", "--overwrite", + sys.executable, "-m", "copier", "copy", "--trust", "--defaults", "--overwrite", "--data", f"db_storage={db_storage}", "--data", f"base_path={base_path}", "--data", f"initial_zope_username={initial_zope_username}", diff --git a/zope-setup/extensions.py b/zope-setup/extensions.py index 5863c32..3a26a75 100644 --- a/zope-setup/extensions.py +++ b/zope-setup/extensions.py @@ -33,6 +33,23 @@ def hook(self, context): dst_path = Path(context.get("_copier_conf", {}).get("dst_path", "")) addon_context = self._read_context(dst_path) context["addon_context"] = addon_context + context["plone_version_full_default"] = self._derive_full_version( + addon_context, context.get("plone_versions_full") or [] + ) + + @staticmethod + def _derive_full_version(addon_context, all_versions): + """Pick a full version matching the addon's minor series, else latest.""" + default_full = all_versions[0] if all_versions else "" + minor = (addon_context or {}).get("plone_version") + if minor: + match = next( + (v for v in all_versions if v == minor or v.startswith(minor + ".")), + None, + ) + if match: + default_full = match + return default_full @staticmethod def _read_context(dst_path): diff --git a/zope-setup/template/.gitignore.jinja b/zope-setup/template/.gitignore.jinja index b171724..cf4256a 100644 --- a/zope-setup/template/.gitignore.jinja +++ b/zope-setup/template/.gitignore.jinja @@ -45,9 +45,8 @@ coverage.xml .hypothesis/ .pytest_cache/ -# Translations +# Translations (compiled catalogs are generated; .pot/.po are tracked) *.mo -*.pot # Environments .env diff --git a/zope_instance/template/{{ base_path }}/{{ instance_name }}/etc/zope.conf.jinja b/zope_instance/template/{{ base_path }}/{{ instance_name }}/etc/zope.conf.jinja index 5225195..3354276 100644 --- a/zope_instance/template/{{ base_path }}/{{ instance_name }}/etc/zope.conf.jinja +++ b/zope_instance/template/{{ base_path }}/{{ instance_name }}/etc/zope.conf.jinja @@ -10,6 +10,11 @@ verbose-security off default-zpublisher-encoding utf-8 +# Compile .po -> .mo on startup so add-on translations load without a build step. + + zope_i18n_compile_mo_files true + + {%- if db_storage == "instance" %}