cookieplone 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.1
2
+ Name: cookieplone
3
+ Version: 0.1.0
4
+ Summary: Create Plone projects, addons, documentation with ease!
5
+ Home-page: https://github.com/plone/cookieplone
6
+ Author: Plone Community
7
+ Author-email: dev@plone.org
8
+ Requires-Python: >=3.10,<4.0
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: BSD License
13
+ Classifier: Natural Language :: English
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: Implementation :: CPython
24
+ Classifier: Topic :: Software Development
25
+ Classifier: Topic :: Software Development :: Code Generators
26
+ Requires-Dist: cookiecutter (>=2.6.0,<3.0.0)
27
+ Requires-Dist: semver (>=3.0.2,<4.0.0)
28
+ Requires-Dist: typer[all] (>=0.12.3,<0.13.0)
29
+ Project-URL: Documentation, https://plone.github.io/cookieplone/
30
+ Project-URL: Repository, https://github.com/plone/cookieplone
31
+ Description-Content-Type: text/markdown
32
+
33
+ <p align="center">
34
+ <img alt="Plone Logo" width="200px" src="https://raw.githubusercontent.com/plone/.github/main/plone-logo.png">
35
+ </p>
36
+
37
+ <h1 align="center">
38
+ cookieplone
39
+ </h1>
40
+
41
+
42
+ <div align="center">
43
+
44
+ [![PyPI](https://img.shields.io/pypi/v/cookieplone)](https://pypi.org/project/cookieplone/)
45
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/cookieplone)](https://pypi.org/project/cookieplone/)
46
+ [![PyPI - Wheel](https://img.shields.io/pypi/wheel/cookieplone)](https://pypi.org/project/cookieplone/)
47
+ [![PyPI - License](https://img.shields.io/pypi/l/cookieplone)](https://pypi.org/project/cookieplone/)
48
+ [![PyPI - Status](https://img.shields.io/pypi/status/cookieplone)](https://pypi.org/project/cookieplone/)
49
+
50
+
51
+ [![PyPI - Plone Versions](https://img.shields.io/pypi/frameworkversions/plone/cookieplone)](https://pypi.org/project/cookieplone/)
52
+
53
+ [![Tests](https://github.com/plone/cookieplone/actions/workflows/main.yml/badge.svg)](https://github.com/plone/cookieplone/actions/workflows/main.yml)
54
+
55
+ [![GitHub contributors](https://img.shields.io/github/contributors/plone/cookieplone)](https://github.com/plone/cookieplone)
56
+ [![GitHub Repo stars](https://img.shields.io/github/stars/plone/cookieplone?style=social)](https://github.com/plone/cookieplone)
57
+
58
+ </div>
59
+
@@ -0,0 +1,26 @@
1
+ <p align="center">
2
+ <img alt="Plone Logo" width="200px" src="https://raw.githubusercontent.com/plone/.github/main/plone-logo.png">
3
+ </p>
4
+
5
+ <h1 align="center">
6
+ cookieplone
7
+ </h1>
8
+
9
+
10
+ <div align="center">
11
+
12
+ [![PyPI](https://img.shields.io/pypi/v/cookieplone)](https://pypi.org/project/cookieplone/)
13
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/cookieplone)](https://pypi.org/project/cookieplone/)
14
+ [![PyPI - Wheel](https://img.shields.io/pypi/wheel/cookieplone)](https://pypi.org/project/cookieplone/)
15
+ [![PyPI - License](https://img.shields.io/pypi/l/cookieplone)](https://pypi.org/project/cookieplone/)
16
+ [![PyPI - Status](https://img.shields.io/pypi/status/cookieplone)](https://pypi.org/project/cookieplone/)
17
+
18
+
19
+ [![PyPI - Plone Versions](https://img.shields.io/pypi/frameworkversions/plone/cookieplone)](https://pypi.org/project/cookieplone/)
20
+
21
+ [![Tests](https://github.com/plone/cookieplone/actions/workflows/main.yml/badge.svg)](https://github.com/plone/cookieplone/actions/workflows/main.yml)
22
+
23
+ [![GitHub contributors](https://img.shields.io/github/contributors/plone/cookieplone)](https://github.com/plone/cookieplone)
24
+ [![GitHub Repo stars](https://img.shields.io/github/stars/plone/cookieplone?style=social)](https://github.com/plone/cookieplone)
25
+
26
+ </div>
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow cookieplone to be executable through `python -m cookieplone`."""
2
+
3
+ from cookieplone.cli import main
4
+
5
+ if __name__ == "__main__": # pragma: no cover
6
+ main(prog_name="cookiecutter")
@@ -0,0 +1,172 @@
1
+ """Main `cookieplone` CLI."""
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from cookiecutter import __version__ as __cookiecutter_version__
10
+ from cookiecutter.log import configure_logger
11
+ from rich import print
12
+ from rich.prompt import Prompt
13
+
14
+ from cookieplone import __version__, data
15
+ from cookieplone.exceptions import GeneratorException
16
+ from cookieplone.generator import generate
17
+ from cookieplone.repository import get_base_repository, get_template_options
18
+ from cookieplone.utils import console
19
+
20
+
21
+ def validate_extra_context(value: list[str] | None = None):
22
+ """Validate extra content follows the correct pattern."""
23
+ if not value:
24
+ return {}
25
+ for string in value:
26
+ if "=" not in string:
27
+ raise typer.BadParameter(
28
+ f"EXTRA_CONTEXT should contain items of the form key=value; "
29
+ f"'{string}' doesn't match that form"
30
+ )
31
+ # Convert list -- e.g.: ['program_name=foobar', 'startsecs=66']
32
+ # to dict -- e.g.: {'program_name': 'foobar', 'startsecs': '66'}
33
+ return dict([s.split("=", 1) for s in value])
34
+
35
+
36
+ def prompt_for_template(base_path: Path) -> str:
37
+ """Parse cookiecutter.json in base_path and prompt user to choose."""
38
+ templates = get_template_options(base_path)
39
+ choices = {i[0]: i[1] for i in templates}
40
+ console.welcome_screen(templates)
41
+ answer = Prompt.ask("Select a template", choices=list(choices.keys()), default=1)
42
+ return choices[answer]
43
+
44
+
45
+ def version_info() -> str:
46
+ """Return the Cookieplone version, location and Python powering it."""
47
+ python_version = sys.version
48
+ location = Path(__file__).parent
49
+ return (
50
+ f"Cookieplone {__version__} from {location} "
51
+ f"(Cookiecutter {__cookiecutter_version__}, "
52
+ f"Python {python_version})"
53
+ )
54
+
55
+
56
+ def cli(
57
+ template: Annotated[str, typer.Argument(help="Template to be used.")] = "",
58
+ extra_context: Annotated[
59
+ data.OptionalListStr,
60
+ typer.Argument(callback=validate_extra_context, help="Extra context."),
61
+ ] = None,
62
+ output_dir: Annotated[
63
+ data.OptionalPath,
64
+ typer.Option("--output-dir", "-o", help="Where to generate the code."),
65
+ ] = None,
66
+ tag: Annotated[str, typer.Option(help="Tag.")] = "",
67
+ version: Annotated[
68
+ bool, typer.Option("--version", help="Display the version of cookieplone.")
69
+ ] = False,
70
+ no_input: Annotated[
71
+ bool,
72
+ typer.Option(
73
+ "--no_input",
74
+ help=(
75
+ "Do not prompt for parameters and only use cookiecutter.json "
76
+ "file content. Defaults to deleting any cached resources and "
77
+ "redownloading them. Cannot be combined with the --replay flag."
78
+ ),
79
+ ),
80
+ ] = False,
81
+ replay: Annotated[bool, typer.Option("--replay", "-r")] = False,
82
+ replay_file: Annotated[data.OptionalPath, typer.Option("--replay-file")] = None,
83
+ skip_if_file_exists: Annotated[
84
+ bool,
85
+ typer.Option(
86
+ "--skip-if-file-exists",
87
+ "-s",
88
+ help=(
89
+ "Skip the files in the corresponding directories "
90
+ "if they already exist"
91
+ ),
92
+ ),
93
+ ] = False,
94
+ overwrite_if_exists: Annotated[
95
+ bool, typer.Option("--overwrite-if-exists", "-f")
96
+ ] = False,
97
+ config_file: Annotated[
98
+ data.OptionalPath, typer.Option("--config-file", help="User configuration file")
99
+ ] = None,
100
+ default_config: Annotated[
101
+ bool,
102
+ typer.Option(
103
+ "--default-config",
104
+ help="Do not load a config file. Use the defaults instead",
105
+ ),
106
+ ] = False,
107
+ keep_project_on_failure: Annotated[
108
+ bool,
109
+ typer.Option(
110
+ "--keep-project-on-failure", help="Do not delete project folder on failure"
111
+ ),
112
+ ] = False,
113
+ debug_file: Annotated[
114
+ data.OptionalPath,
115
+ typer.Option(
116
+ "--debug-file", help="File to be used as a stream for DEBUG logging"
117
+ ),
118
+ ] = None,
119
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
120
+ ):
121
+ """Generate a new Plone codebase."""
122
+ if version:
123
+ info = version_info
124
+ print(info)
125
+ raise typer.Exit()
126
+ repository = os.environ.get("COOKIEPLONE_REPOSITORY")
127
+ if not repository:
128
+ repository = "gh:plone/cookiecutter-plone"
129
+
130
+ if not template:
131
+ # Display template options
132
+ repo_path = get_base_repository(repository)
133
+ template = prompt_for_template(Path(repo_path))
134
+ else:
135
+ console.welcome_screen()
136
+
137
+ if replay_file:
138
+ replay = replay_file
139
+ passwd = os.environ.get(
140
+ "COOKIECUTTER_REPO_PASSWORD", os.environ.get("COOKIEPLONE_REPO_PASSWORD")
141
+ )
142
+ if not output_dir:
143
+ output_dir = Path().cwd()
144
+ configure_logger(stream_level="DEBUG" if verbose else "INFO", debug_file=debug_file)
145
+ # Run generator
146
+ try:
147
+ generate(
148
+ repository,
149
+ tag,
150
+ no_input,
151
+ extra_context,
152
+ replay,
153
+ overwrite_if_exists,
154
+ output_dir,
155
+ config_file,
156
+ default_config,
157
+ passwd,
158
+ template,
159
+ skip_if_file_exists,
160
+ keep_project_on_failure,
161
+ )
162
+ except GeneratorException:
163
+ # TODO: Handle error
164
+ raise typer.Exit(1) # noQA:B904
165
+ except Exception:
166
+ # TODO: Handle error
167
+ raise typer.Exit(1) # noQA:B904
168
+
169
+
170
+ def main():
171
+ """Run the cli."""
172
+ typer.run(cli)
@@ -0,0 +1,62 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Any, Literal, Optional, TypeAlias
5
+
6
+ OptionalPath: TypeAlias = Optional[Path] # noQA:UP007
7
+ OptionalListStr: TypeAlias = Optional[list[str]] # noQA:UP007
8
+
9
+
10
+ @dataclass
11
+ class SanityCheck:
12
+ """Definition of a sanity check."""
13
+
14
+ name: str
15
+ func: Callable
16
+ args: list[Any]
17
+ level: Literal["info", "warning", "error"]
18
+
19
+
20
+ @dataclass
21
+ class SanityCheckResult:
22
+ """Result of a sanity check."""
23
+
24
+ name: str
25
+ status: bool
26
+ message: str = ""
27
+
28
+
29
+ @dataclass
30
+ class SanityCheckResults:
31
+ """Results of all sanity checks."""
32
+
33
+ status: bool
34
+ checks: list[SanityCheckResult]
35
+ message: str = ""
36
+
37
+
38
+ @dataclass
39
+ class ItemValidator:
40
+ """Validate an answer provided by the user."""
41
+
42
+ key: str
43
+ func: Callable
44
+ level: Literal["info", "warning", "error"] = "error"
45
+
46
+
47
+ @dataclass
48
+ class ItemValidatorResult:
49
+ """Result of an item validation."""
50
+
51
+ key: str
52
+ status: bool
53
+ message: str = ""
54
+
55
+
56
+ @dataclass
57
+ class ContextValidatorResult:
58
+ """Results of all validations checks."""
59
+
60
+ status: bool
61
+ validations: list[ItemValidatorResult]
62
+ message: str = ""
@@ -0,0 +1,18 @@
1
+ class CookieploneException(Exception):
2
+ """Cookieplone base exception."""
3
+
4
+ message: str
5
+
6
+ def __init__(self, message: str):
7
+ self.message = message
8
+
9
+
10
+ class GeneratorException(CookieploneException):
11
+ """Cookieplone generator exception."""
12
+
13
+ message: str
14
+ original: Exception | None = None
15
+
16
+ def __init__(self, message: str, original: Exception | None = None):
17
+ self.message = message
18
+ self.original = original
@@ -0,0 +1,84 @@
1
+ import os
2
+
3
+ from cookiecutter.utils import simple_filter
4
+
5
+ from cookieplone.utils import containers, versions
6
+
7
+
8
+ @simple_filter
9
+ def package_name(v) -> str:
10
+ """Return the Python package name (without namespace)."""
11
+ return v.split(".")[1]
12
+
13
+
14
+ @simple_filter
15
+ def package_namespace(v) -> str:
16
+ """Return the Python package namespace."""
17
+ return v.split(".")[0]
18
+
19
+
20
+ @simple_filter
21
+ def pascal_case(package_name: str) -> str:
22
+ """Return the package name as a string in the PascalCase format ."""
23
+ parts = [name.title() for name in package_name.split("_")]
24
+ return "".join(parts)
25
+
26
+
27
+ @simple_filter
28
+ def extract_host(hostname: str) -> str:
29
+ """Get the host part of a hostname."""
30
+ parts = hostname.split(".")
31
+ return parts[0]
32
+
33
+
34
+ @simple_filter
35
+ def use_prerelease_versions(_: str) -> str:
36
+ """Should we use prerelease versions of packages."""
37
+ use_prerelease_versions = "USE_PRERELEASE" in os.environ
38
+ return "Yes" if use_prerelease_versions else "No"
39
+
40
+
41
+ @simple_filter
42
+ def latest_volto(use_prerelease_versions: str) -> str:
43
+ """Return the latest released version of Volto."""
44
+ allow_prerelease = use_prerelease_versions == "Yes"
45
+ return versions.latest_volto(allow_prerelease=allow_prerelease)
46
+
47
+
48
+ @simple_filter
49
+ def latest_plone(use_prerelease_versions: str) -> str:
50
+ """Return the latest released version of Plone."""
51
+ allow_prerelease = use_prerelease_versions == "Yes"
52
+ return versions.latest_plone(allow_prerelease=allow_prerelease)
53
+
54
+
55
+ @simple_filter
56
+ def node_version_for_volto(volto_version: str) -> int:
57
+ """Return the Node Version to be used."""
58
+ return versions.node_version_for_volto(volto_version)
59
+
60
+
61
+ @simple_filter
62
+ def gs_language_code(code: str) -> str:
63
+ """Return the language code as expected by Generic Setup."""
64
+ gs_code = code.lower()
65
+ if "-" in code:
66
+ base_language, country = code.split("-")
67
+ gs_code = f"{base_language}-{country.lower()}"
68
+ return gs_code
69
+
70
+
71
+ @simple_filter
72
+ def locales_language_code(code: str) -> str:
73
+ """Return the language code as expected by gettext."""
74
+ gs_code = code.lower()
75
+ if "-" in code:
76
+ base_language, country = code.split("-")
77
+ gs_code = f"{base_language}_{country.upper()}"
78
+ return gs_code
79
+
80
+
81
+ @simple_filter
82
+ def image_prefix(registry: str) -> str:
83
+ """Return the a prefix to be used with all Docker images."""
84
+ return containers.image_prefix(registry)
@@ -0,0 +1,118 @@
1
+ import json
2
+ from collections import OrderedDict
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from cookiecutter import exceptions as exc
7
+ from cookiecutter.main import cookiecutter
8
+
9
+ from cookieplone.exceptions import GeneratorException
10
+ from cookieplone.utils import console, files
11
+
12
+
13
+ def _remove_internal_keys(context: OrderedDict) -> dict:
14
+ """Remove internal and computed keys."""
15
+ new_context = {
16
+ key: value for key, value in context.items() if not key.startswith("_")
17
+ }
18
+ return new_context
19
+
20
+
21
+ def generate(
22
+ repository,
23
+ tag,
24
+ no_input,
25
+ extra_context,
26
+ replay,
27
+ overwrite_if_exists,
28
+ output_dir,
29
+ config_file,
30
+ default_config,
31
+ passwd,
32
+ template,
33
+ skip_if_file_exists,
34
+ keep_project_on_failure,
35
+ ) -> Path:
36
+ try:
37
+ result = cookiecutter(
38
+ repository,
39
+ tag,
40
+ no_input,
41
+ extra_context=extra_context,
42
+ replay=replay,
43
+ overwrite_if_exists=overwrite_if_exists,
44
+ output_dir=output_dir,
45
+ config_file=config_file,
46
+ default_config=default_config,
47
+ password=passwd,
48
+ directory=template,
49
+ skip_if_file_exists=skip_if_file_exists,
50
+ accept_hooks=True,
51
+ keep_project_on_failure=keep_project_on_failure,
52
+ )
53
+ except (
54
+ exc.ContextDecodingException,
55
+ exc.OutputDirExistsException,
56
+ exc.InvalidModeException,
57
+ exc.FailedHookException,
58
+ exc.UnknownExtension,
59
+ exc.InvalidZipRepository,
60
+ exc.RepositoryNotFound,
61
+ exc.RepositoryCloneFailed,
62
+ ) as e:
63
+ raise GeneratorException(message=str(e), original=e) # noQA:B904
64
+ except exc.UndefinedVariableInTemplate as undefined_err:
65
+ context_str = json.dumps(undefined_err.context, indent=2, sort_keys=True)
66
+ msg = f"""{undefined_err.message}
67
+ Error message: {undefined_err.error.message}
68
+ Context: {context_str}
69
+ """
70
+ raise GeneratorException(message=msg, original=undefined_err) # noQA:B904
71
+ else:
72
+ return Path(result)
73
+
74
+
75
+ def generate_subtemplate(
76
+ template: str, output_dir: Path, folder_name: str, context: OrderedDict
77
+ ) -> Path:
78
+ # Extract path to repository
79
+ repository = context.get("_checkout") or context.get("_template")
80
+
81
+ if not repository or not (Path(repository) / template).exists():
82
+ # TODO: Error message
83
+ raise typer.Exit(code=1)
84
+ # Cleanup context
85
+ extra_context = _remove_internal_keys(context)
86
+ ## Add folder name again
87
+ extra_context["__folder_name"] = folder_name
88
+ ## Disable GHA for subcomponent
89
+ extra_context["__gha_enable"] = False
90
+ # Enable quiet mode
91
+ console.enable_quiet_mode()
92
+ # Call generate
93
+ try:
94
+ result = generate(
95
+ repository,
96
+ None, # We should have the tag already locally
97
+ True, # No input
98
+ extra_context,
99
+ False, # Not running a replay
100
+ True, # overwrite_if_exists
101
+ output_dir,
102
+ None, # config_file
103
+ None, # default_config,
104
+ None, # password
105
+ template,
106
+ False, # skip_if_file_exists,
107
+ False, # keep_project_on_failure
108
+ )
109
+ except GeneratorException as exc:
110
+ console.disable_quiet_mode()
111
+ raise exc
112
+ else:
113
+ console.disable_quiet_mode()
114
+ path = Path(result)
115
+ # Remove GHA folder
116
+ files.remove_gha(path)
117
+ # Return path
118
+ return path
@@ -0,0 +1,44 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from cookiecutter.config import get_user_config
5
+ from cookiecutter.repository import determine_repo_dir
6
+
7
+ from cookieplone import data
8
+
9
+
10
+ def get_base_repository(
11
+ repository: str,
12
+ tag: str | None = None,
13
+ password: str = "",
14
+ config_file: data.OptionalPath = None,
15
+ default_config: bool = False,
16
+ ):
17
+ config_dict = get_user_config(
18
+ config_file=config_file,
19
+ default_config=default_config,
20
+ )
21
+ base_repo_dir, _ = determine_repo_dir(
22
+ template=repository,
23
+ abbreviations=config_dict["abbreviations"],
24
+ clone_to_dir=config_dict["cookiecutters_dir"],
25
+ checkout=tag,
26
+ no_input=True, # Force download
27
+ password=password,
28
+ directory="",
29
+ )
30
+ return base_repo_dir
31
+
32
+
33
+ def get_template_options(base_path: Path) -> list[list[str]]:
34
+ """Parse cookiecutter.json and return a list of template options."""
35
+ config = json.loads((base_path / "cookiecutter.json").read_text())
36
+ available_templates = config.get("templates", {})
37
+ templates = []
38
+ for idx, name in enumerate(available_templates, start=1):
39
+ idx = str(idx)
40
+ value = available_templates[name]
41
+ title = value["title"]
42
+ description = value["description"]
43
+ templates.append((idx, name, title, description))
44
+ return templates
@@ -0,0 +1,31 @@
1
+ PLONE_MIN_VERSION = "6"
2
+
3
+ SUPPORTED_PYTHON_VERSIONS = [
4
+ "3.8",
5
+ "3.9",
6
+ "3.10",
7
+ "3.11",
8
+ "3.12",
9
+ ]
10
+
11
+ DEFAULT_NODE = 18
12
+ SUPPORTED_NODE_VERSIONS = [
13
+ "16",
14
+ "17",
15
+ "18",
16
+ "19",
17
+ "20",
18
+ ]
19
+
20
+
21
+ VOLTO_MIN_VERSION = "16"
22
+ VOLTO_NODE = {
23
+ 16: 16,
24
+ 17: DEFAULT_NODE,
25
+ 18: 20,
26
+ }
27
+ MIN_DOCKER_VERSION = "20.10"
28
+
29
+
30
+ ## Config
31
+ QUIET_MODE_VAR = "COOKIEPLONE_QUIET_MODE_SWITCH"
@@ -0,0 +1,3 @@
1
+ from cookiecutter.utils import rmtree
2
+
3
+ __all__ = ["rmtree"]
@@ -0,0 +1,85 @@
1
+ import re
2
+ import subprocess
3
+ import sys
4
+
5
+ from cookieplone import settings
6
+
7
+
8
+ def _get_command_version(cmd: str) -> str:
9
+ """Get the reported version of a command line utility."""
10
+ try:
11
+ raw_version = (
12
+ subprocess.run([cmd, "--version"], capture_output=True) # noQA: S603
13
+ .stdout.decode()
14
+ .strip()
15
+ )
16
+ except FileNotFoundError:
17
+ raw_version = ""
18
+ return raw_version
19
+
20
+
21
+ def _parse_node_major_version(value: str) -> str:
22
+ """Parse value and return the major version of Node."""
23
+ match = re.match(r"v(\d{1,3})\.\d{1,3}.\d{1,3}", value)
24
+ return match.groups()[0] if match else ""
25
+
26
+
27
+ def _parse_docker_version(value: str) -> str:
28
+ """Parse value and return the docker version."""
29
+ value = value.strip()
30
+ match = re.match(r"Docker version (\d{2}).(\d{1,2}).(\d{1,2})", value)
31
+ if match:
32
+ groups = match.groups()
33
+ return f"{groups[0]}.{groups[1]}"
34
+ return ""
35
+
36
+
37
+ def check_python_version(supported_versions: list[str] | None = None) -> str:
38
+ """Check if Python version is supported."""
39
+ supported = (
40
+ supported_versions if supported_versions else settings.SUPPORTED_PYTHON_VERSIONS
41
+ )
42
+ version = f"{sys.version_info.major}.{sys.version_info.minor}"
43
+ return (
44
+ ""
45
+ if version in supported
46
+ else f"Python version is not supported: Got {sys.version}"
47
+ )
48
+
49
+
50
+ def check_node_version(supported_versions: list[str] | None = None) -> str:
51
+ """Check if node version is supported."""
52
+ supported = (
53
+ supported_versions if supported_versions else settings.SUPPORTED_NODE_VERSIONS
54
+ )
55
+ raw_version = _get_command_version("node")
56
+ if not raw_version:
57
+ return "NodeJS not found."
58
+ else:
59
+ version = _parse_node_major_version(raw_version)
60
+ return (
61
+ ""
62
+ if version in supported
63
+ else f"Node version is not supported: Got {raw_version}"
64
+ )
65
+
66
+
67
+ def check_docker_version(min_version: str) -> str:
68
+ """Check if docker version is supported."""
69
+ min_version = min_version if min_version else settings.MIN_DOCKER_VERSION
70
+ raw_version = _get_command_version("docker")
71
+ if not raw_version:
72
+ return "Docker not found."
73
+ else:
74
+ version = _parse_docker_version(raw_version)
75
+ return (
76
+ ""
77
+ if version >= settings.MIN_DOCKER_VERSION
78
+ else f"Docker version is not supported: Got {raw_version}"
79
+ )
80
+
81
+
82
+ def check_command_is_available(command: str) -> str:
83
+ """Check if a command line utility is available."""
84
+ raw_version = _get_command_version(command)
85
+ return "" if raw_version else f"Command {command} is not available."
@@ -0,0 +1,134 @@
1
+ import os
2
+ from textwrap import dedent
3
+
4
+ from rich import print as base_print
5
+ from rich.markup import escape
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+
9
+ from cookieplone.settings import QUIET_MODE_VAR
10
+
11
+ BANNER = """
12
+ .xxxxxxxxxxxxxx.
13
+ ;xxxxxxxxxxxxxxxxxxxxxx;
14
+ ;xxxxxxxxxxxxxxxxxxxxxxxxxxxx;
15
+ xxxxxxxxxx xxxxxxxxxx
16
+ xxxxxxxx. .xxxxxxxx
17
+ xxxxxxx xxxxxxx: xxxxxxx
18
+ :xxxxxx xxxxxxxxxx xxxxxx:
19
+ :xxxxx+ xxxxxxxxxxx +xxxxx:
20
+ .xxxxx. :xxxxxxxxxx .xxxxx.
21
+ xxxxx+ ;xxxxxxxx +xxxxx
22
+ xxxxx +xx. xxxxx.
23
+ xxxxx: .xxxxxxxx :xxxxx
24
+ xxxxx .xxxxxxxxxx xxxxx
25
+ xxxxx xxxxxxxxxxx xxxxx
26
+ xxxxx .xxxxxxxxxx xxxxx
27
+ xxxxx: .xxxxxxxx :xxxxx
28
+ .xxxxx ;xx. ... xxxxx.
29
+ xxxxx+ :xxxxxxxx +xxxxx
30
+ .xxxxx. :xxxxxxxxxx .xxxxx.
31
+ :xxxxx+ xxxxxxxxxxx ;xxxxx:
32
+ :xxxxxx xxxxxxxxxx xxxxxx:
33
+ xxxxxxx xxxxxxx; xxxxxxx
34
+ xxxxxxxx. .xxxxxxxx
35
+ xxxxxxxxxx xxxxxxxxxx
36
+ ;xxxxxxxxxxxxxxxxxxxxxxxxxxxx+
37
+ ;xxxxxxxxxxxxxxxxxxxxxx;
38
+ .xxxxxxxxxxxxxx.
39
+ """
40
+
41
+
42
+ def _print(msg: str):
43
+ """Wrapper around rich.print."""
44
+ if not os.environ.get(QUIET_MODE_VAR):
45
+ base_print(msg)
46
+
47
+
48
+ def print(msg: str, style: str = "", color: str = ""): # noQA:A001
49
+ """Print to console, using style and color.
50
+
51
+ style: https://rich.readthedocs.io/en/latest/reference/style.html#rich.style.Style
52
+ """
53
+ tag_open = ""
54
+ tag_close = ""
55
+ markup = " ".join([item.strip() for item in (style, color) if item.strip()])
56
+ if markup:
57
+ tag_open = f"[{markup}]"
58
+ tag_close = f"[/{markup}]"
59
+ _print(f"{tag_open}{escape(msg)}{tag_close}")
60
+
61
+
62
+ def print_plone_banner():
63
+ """Print Plone banner."""
64
+ style: str = "bold"
65
+ color: str = "blue"
66
+ print(BANNER, style, color)
67
+
68
+
69
+ def info(msg: str):
70
+ style: str = "bold"
71
+ color: str = "white"
72
+ print(msg, style, color)
73
+
74
+
75
+ def success(msg: str):
76
+ style: str = "bold"
77
+ color: str = "green"
78
+ print(msg, style, color)
79
+
80
+
81
+ def error(msg: str):
82
+ style: str = "bold"
83
+ color: str = "red"
84
+ print(msg, style, color)
85
+
86
+
87
+ def warning(msg: str):
88
+ style: str = "bold"
89
+ color: str = "yellow"
90
+ print(msg, style, color)
91
+
92
+
93
+ def panel(title: str, msg: str = "", subtitle: str = "", url: str = ""):
94
+ msg = dedent(msg)
95
+ if url:
96
+ msg = f"{msg}\n[link]{url}[/link]"
97
+ _print(
98
+ Panel(
99
+ msg,
100
+ title=title,
101
+ subtitle=subtitle,
102
+ )
103
+ )
104
+
105
+
106
+ def table_available_templates(title: str, rows: list[list[str]]):
107
+ """Display a table of options."""
108
+ table = Table(title=title, expand=True)
109
+
110
+ table.add_column("#", justify="center", style="cyan", no_wrap=True)
111
+ table.add_column("Title", style="blue")
112
+ table.add_column("Description", justify="left", style="blue")
113
+
114
+ for idx, _, title, description in rows:
115
+ table.add_row(idx, title, description)
116
+
117
+ return table
118
+
119
+
120
+ def welcome_screen(templates: list[list[str]] | None = None):
121
+ print_plone_banner()
122
+ if templates:
123
+ table = table_available_templates("Templates", templates)
124
+ _print(table)
125
+
126
+
127
+ def enable_quiet_mode():
128
+ """Enable quiet mode."""
129
+ os.environ[QUIET_MODE_VAR] = "1"
130
+
131
+
132
+ def disable_quiet_mode():
133
+ """Disable quiet mode."""
134
+ os.environ.pop(QUIET_MODE_VAR, "")
@@ -0,0 +1,6 @@
1
+ REGISTRIES = {"docker_hub": "", "github": "ghcr.io/", "gitlab": "registry.gitlab.com/"}
2
+
3
+
4
+ def image_prefix(registry: str) -> str:
5
+ """Return the image prefix to be used based on the registry used."""
6
+ return REGISTRIES.get(registry, "")
@@ -0,0 +1,19 @@
1
+ from pathlib import Path
2
+
3
+ from cookiecutter.utils import rmtree
4
+
5
+
6
+ def remove_files(base_path: Path, paths: list[str]):
7
+ """Remove files."""
8
+ for filepath in paths:
9
+ path = base_path / filepath
10
+ exists = path.exists()
11
+ if exists and path.is_dir():
12
+ rmtree(path)
13
+ elif exists and path.is_file():
14
+ path.unlink()
15
+
16
+
17
+ def remove_gha(base_path: Path):
18
+ """Remove GHA folder."""
19
+ remove_files(base_path=base_path, paths=[".github"])
@@ -0,0 +1,28 @@
1
+ from cookieplone import data
2
+
3
+
4
+ def run_sanity_checks(checks: list[data.SanityCheck]) -> data.SanityCheckResults:
5
+ """Run sanity checks."""
6
+ global_status = True
7
+ results = []
8
+ for check in checks:
9
+ name = check.name
10
+ func = check.func
11
+ args = check.args
12
+ level = check.level
13
+ message = func(*args)
14
+ if not message:
15
+ status = True
16
+ message = "✓"
17
+ elif level == "warning":
18
+ status = True
19
+ elif level == "error":
20
+ status = False
21
+ global_status = global_status and status
22
+ results.append(data.SanityCheckResult(name, status, message))
23
+ global_message = (
24
+ f"Ran {len(checks)} checks and they {'passed' if global_status else 'failed'}."
25
+ )
26
+ return data.SanityCheckResults(
27
+ status=global_status, message=global_message, checks=results
28
+ )
@@ -0,0 +1,134 @@
1
+ import re
2
+ from typing import Any
3
+ from urllib.parse import urlparse
4
+
5
+ from packaging.version import InvalidVersion, Version
6
+
7
+ from cookieplone import data, settings
8
+
9
+
10
+ def _version_from_str(value: str) -> Version | None:
11
+ """Parse a value and return a Version"""
12
+ try:
13
+ version = Version(value)
14
+ except InvalidVersion:
15
+ version = None
16
+ return version
17
+
18
+
19
+ def validate_not_empty(value: Any, key: str = "") -> str:
20
+ """Value should not be empty."""
21
+ status = True
22
+ if isinstance(value, str):
23
+ status = bool(value.strip())
24
+ elif isinstance(value, int | float):
25
+ # We accept 0 as valid
26
+ status = True
27
+ else:
28
+ status = bool(value)
29
+
30
+ return "" if status else f"{key} should be provided"
31
+
32
+
33
+ def validate_component_version(component: str, version: str, min_version: str) -> str:
34
+ """Validate if a component version is bigger than the min_version."""
35
+ version_ = _version_from_str(version)
36
+ min_version = _version_from_str(min_version)
37
+ check = version_ and min_version and (version_ >= min_version)
38
+ return "" if check else f"{version} is not a valid {component} version."
39
+
40
+
41
+ def validate_language_code(value: str) -> str:
42
+ """Language code should be valid."""
43
+ pattern = r"^([a-z]{2}|[a-z]{2}-[a-z]{2})$"
44
+ return (
45
+ "" if re.match(pattern, value) else f"'{value}' is not a valid language code."
46
+ )
47
+
48
+
49
+ def validate_python_package_name(value: str) -> str:
50
+ """Validate python_package_name is an identifier."""
51
+ status = False
52
+ if "." in value:
53
+ namespace, package = value.split(".")
54
+ status = namespace.isidentifier() and package.isidentifier()
55
+ else:
56
+ status = value.isidentifier()
57
+ return "" if status else f"'{value}' is not a valid Python identifier."
58
+
59
+
60
+ def validate_hostname(value: str) -> str:
61
+ """Check if hostname is valid."""
62
+ valid = False
63
+ if value and value.strip():
64
+ value_with_protocol = f"https://{value}"
65
+ result = urlparse(value_with_protocol)
66
+ valid = str(result.hostname) == value
67
+ return "" if valid else f"'{value}' is not a valid hostname."
68
+
69
+
70
+ def validate_volto_addon_name(value: str) -> str:
71
+ """Validate the volto addon name is valid."""
72
+ pattern = "^[a-z0-9-~][a-z0-9-._~]*$"
73
+ return "" if re.match(pattern, value) else f"'{value}' is not a valid name."
74
+
75
+
76
+ def validate_npm_package_name(value: str) -> str:
77
+ """Validate the npm package name is valid."""
78
+ pattern = r"^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$"
79
+ return "" if re.match(pattern, value) else f"'{value}' is not a valid package name."
80
+
81
+
82
+ def validate_plone_version(value: str) -> str:
83
+ """Validate Plone Version."""
84
+ status = False
85
+ version = _version_from_str(value)
86
+ if version:
87
+ status = version >= _version_from_str(settings.PLONE_MIN_VERSION)
88
+ return "" if status else f"{value} is not a valid Plone version."
89
+
90
+
91
+ def validate_volto_version(value: str) -> str:
92
+ """Validate Volto Version."""
93
+ status = False
94
+ version = _version_from_str(value)
95
+ if version:
96
+ status = version >= _version_from_str(settings.VOLTO_MIN_VERSION)
97
+ return "" if status else f"{value} is not a valid Volto version."
98
+
99
+
100
+ def run_context_validations(
101
+ context: dict, validations: list[data.ItemValidator], allow_empty: bool = False
102
+ ) -> data.ContextValidatorResult:
103
+ """Run validations for context."""
104
+ global_status = True
105
+ results = []
106
+ if not allow_empty:
107
+ func = validate_not_empty
108
+ for key in context:
109
+ if key.startswith("_"):
110
+ # Ignore computed values
111
+ continue
112
+ validations.append(data.ItemValidator(key, func, "error"))
113
+ for validation in validations:
114
+ key = validation.key
115
+ func = validation.func
116
+ value = context.get(key, "")
117
+ level = validation.level
118
+ message = func(value, key) if func == validate_not_empty else func(value)
119
+ if not message:
120
+ status = True
121
+ message = "✓"
122
+ elif level == "warning":
123
+ status = True
124
+ elif level == "error":
125
+ status = False
126
+ global_status = global_status and status
127
+ results.append(data.ItemValidatorResult(key, status, message))
128
+ global_message = (
129
+ f"Ran {len(results)} validations and "
130
+ f"they {'passed' if global_status else 'failed'}."
131
+ )
132
+ return data.ContextValidatorResult(
133
+ status=global_status, message=global_message, validations=results
134
+ )
@@ -0,0 +1,92 @@
1
+ import requests
2
+ from packaging.version import Version
3
+
4
+ from cookieplone import settings
5
+
6
+
7
+ def get_npm_package_versions(package: str) -> list[str]:
8
+ """Get versions for a NPM package."""
9
+ url: str = f"https://registry.npmjs.org/{package}"
10
+ resp = requests.get(url, headers={"Accept": "application/vnd.npm.install-v1+json"}) # noQA: S113
11
+ data = resp.json()
12
+ return list(data["dist-tags"].values())
13
+
14
+
15
+ def get_pypi_package_versions(package: str) -> list[str]:
16
+ """Get versions for a PyPi package."""
17
+ url: str = f"https://pypi.org/pypi/{package}/json"
18
+ resp = requests.get(url) # noQA: S113
19
+ data = resp.json()
20
+ return list(data.get("releases").keys())
21
+
22
+
23
+ def is_valid_version(
24
+ version: Version,
25
+ min_version: Version | None = None,
26
+ max_version: Version | None = None,
27
+ allow_prerelease: bool = False,
28
+ ) -> bool:
29
+ """Check if version is valid."""
30
+ status = True
31
+ if version.is_prerelease:
32
+ status = allow_prerelease
33
+ if status and min_version:
34
+ status = version >= min_version
35
+ if status and max_version:
36
+ status = version < max_version
37
+ return status
38
+
39
+
40
+ def latest_version(
41
+ versions: list[str],
42
+ min_version: str | None = None,
43
+ max_version: str | None = None,
44
+ allow_prerelease: bool = False,
45
+ ) -> str | None:
46
+ min_version = Version(min_version) if min_version else None
47
+ max_version = Version(max_version) if max_version else None
48
+ versions_ = sorted(
49
+ [(Version(v.replace("v", "")), v) for v in versions], reverse=True
50
+ )
51
+ valid = [
52
+ (version, raw_version)
53
+ for version, raw_version in versions_
54
+ if is_valid_version(version, min_version, max_version, allow_prerelease)
55
+ ]
56
+ return valid[0][1] if valid else None
57
+
58
+
59
+ def latest_volto(
60
+ min_version: str | None = None,
61
+ max_version: str | None = None,
62
+ allow_prerelease: bool = False,
63
+ ) -> str | None:
64
+ """Return the latest volto version."""
65
+ versions = get_npm_package_versions("@plone/volto")
66
+ return latest_version(
67
+ versions,
68
+ min_version=min_version,
69
+ max_version=max_version,
70
+ allow_prerelease=allow_prerelease,
71
+ )
72
+
73
+
74
+ def latest_plone(
75
+ min_version: str | None = None,
76
+ max_version: str | None = None,
77
+ allow_prerelease: bool = False,
78
+ ) -> str | None:
79
+ """Return the latest Plone version."""
80
+ versions = get_pypi_package_versions("Plone")
81
+ return latest_version(
82
+ versions,
83
+ min_version=min_version,
84
+ max_version=max_version,
85
+ allow_prerelease=allow_prerelease,
86
+ )
87
+
88
+
89
+ def node_version_for_volto(volto_version: str) -> int:
90
+ """Return the Node Version to be used with Volto."""
91
+ major = Version(volto_version).major
92
+ return settings.VOLTO_NODE.get(major, settings.DEFAULT_NODE)
@@ -0,0 +1,105 @@
1
+ [tool.poetry]
2
+ name = "cookieplone"
3
+ version = "0"
4
+ description = "Create Plone projects, addons, documentation with ease!"
5
+ authors = ["Plone Community <dev@plone.org>"]
6
+ repository = "https://github.com/plone/cookieplone"
7
+ documentation = "https://plone.github.io/cookieplone/"
8
+ readme = "README.md"
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Environment :: Console",
12
+ "Intended Audience :: Developers",
13
+ "Natural Language :: English",
14
+ "License :: OSI Approved :: BSD License",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: Implementation :: CPython",
21
+ "Programming Language :: Python",
22
+ "Topic :: Software Development",
23
+ "Topic :: Software Development :: Code Generators"
24
+ ]
25
+ packages = [
26
+ {include = "cookieplone"}
27
+ ]
28
+
29
+ [tool.poetry.dependencies]
30
+ python = "^3.10"
31
+ cookiecutter = "^2.6.0"
32
+ semver = "^3.0.2"
33
+ typer = {extras = ["all"], version = "^0.12.3"}
34
+
35
+ [tool.poetry.group.dev.dependencies]
36
+ pytest = "^8.1.1"
37
+ pytest-cov = "^5.0.0"
38
+ pre-commit = "^3.7.0"
39
+ tox = "^4.14.2"
40
+
41
+ [tool.poetry.scripts]
42
+ cookieplone = 'cookieplone.__main__:main'
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+
47
+ [tool.ruff]
48
+ target-version = "py310"
49
+ line-length = 88
50
+ fix = true
51
+ lint.select = [
52
+ # flake8-2020
53
+ "YTT",
54
+ # flake8-bandit
55
+ "S",
56
+ # flake8-bugbear
57
+ "B",
58
+ # flake8-builtins
59
+ "A",
60
+ # flake8-comprehensions
61
+ "C4",
62
+ # flake8-debugger
63
+ "T10",
64
+ # flake8-simplify
65
+ "SIM",
66
+ # isort
67
+ "I",
68
+ # mccabe
69
+ "C90",
70
+ # pycodestyle
71
+ "E", "W",
72
+ # pyflakes
73
+ "F",
74
+ # pygrep-hooks
75
+ "PGH",
76
+ # pyupgrade
77
+ "UP",
78
+ # ruff
79
+ "RUF",
80
+ ]
81
+ lint.ignore = [
82
+ # DoNotAssignLambda
83
+ "E731",
84
+ ]
85
+
86
+ [tool.ruff.format]
87
+ preview = true
88
+
89
+ [tool.coverage.report]
90
+ skip_empty = true
91
+
92
+ [tool.coverage.run]
93
+ branch = true
94
+ source = ["cookieplone"]
95
+
96
+
97
+ [tool.ruff.lint.per-file-ignores]
98
+ "tests/*" = ["S101"]
99
+
100
+ [tool.poetry-version-plugin]
101
+ source = "init"
102
+
103
+ [build-system]
104
+ requires = ["poetry-core"]
105
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,39 @@
1
+ # -*- coding: utf-8 -*-
2
+ from setuptools import setup
3
+
4
+ packages = \
5
+ ['cookieplone',
6
+ 'cookieplone.filters',
7
+ 'cookieplone.utils',
8
+ 'cookieplone.utils.commands']
9
+
10
+ package_data = \
11
+ {'': ['*']}
12
+
13
+ install_requires = \
14
+ ['cookiecutter>=2.6.0,<3.0.0',
15
+ 'semver>=3.0.2,<4.0.0',
16
+ 'typer[all]>=0.12.3,<0.13.0']
17
+
18
+ entry_points = \
19
+ {'console_scripts': ['cookieplone = cookieplone.__main__:main']}
20
+
21
+ setup_kwargs = {
22
+ 'name': 'cookieplone',
23
+ 'version': '0.1.0',
24
+ 'description': 'Create Plone projects, addons, documentation with ease!',
25
+ 'long_description': '<p align="center">\n <img alt="Plone Logo" width="200px" src="https://raw.githubusercontent.com/plone/.github/main/plone-logo.png">\n</p>\n\n<h1 align="center">\n cookieplone\n</h1>\n\n\n<div align="center">\n\n[![PyPI](https://img.shields.io/pypi/v/cookieplone)](https://pypi.org/project/cookieplone/)\n[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/cookieplone)](https://pypi.org/project/cookieplone/)\n[![PyPI - Wheel](https://img.shields.io/pypi/wheel/cookieplone)](https://pypi.org/project/cookieplone/)\n[![PyPI - License](https://img.shields.io/pypi/l/cookieplone)](https://pypi.org/project/cookieplone/)\n[![PyPI - Status](https://img.shields.io/pypi/status/cookieplone)](https://pypi.org/project/cookieplone/)\n\n\n[![PyPI - Plone Versions](https://img.shields.io/pypi/frameworkversions/plone/cookieplone)](https://pypi.org/project/cookieplone/)\n\n[![Tests](https://github.com/plone/cookieplone/actions/workflows/main.yml/badge.svg)](https://github.com/plone/cookieplone/actions/workflows/main.yml)\n\n[![GitHub contributors](https://img.shields.io/github/contributors/plone/cookieplone)](https://github.com/plone/cookieplone)\n[![GitHub Repo stars](https://img.shields.io/github/stars/plone/cookieplone?style=social)](https://github.com/plone/cookieplone)\n\n</div>\n',
26
+ 'author': 'Plone Community',
27
+ 'author_email': 'dev@plone.org',
28
+ 'maintainer': 'None',
29
+ 'maintainer_email': 'None',
30
+ 'url': 'https://github.com/plone/cookieplone',
31
+ 'packages': packages,
32
+ 'package_data': package_data,
33
+ 'install_requires': install_requires,
34
+ 'entry_points': entry_points,
35
+ 'python_requires': '>=3.10,<4.0',
36
+ }
37
+
38
+
39
+ setup(**setup_kwargs)