scooped 1.0.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,67 @@
1
+ # This workflow will install Python dependencies, run tests and lint with a variety of Python versions
2
+ # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
3
+
4
+ name: CI
5
+
6
+ on:
7
+ push:
8
+ branches: [ "main", "develop" ]
9
+ pull_request:
10
+ branches: [ "main", "develop" ]
11
+
12
+ jobs:
13
+ build:
14
+
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ python-version: ["3.12"]
20
+
21
+ steps:
22
+ - uses: actions/checkout@v6
23
+
24
+ - name: Set up Python ${{ matrix.python-version }}
25
+ uses: actions/setup-python@v6
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+
29
+ - name: Set up UV
30
+ uses: astral-sh/setup-uv@v8.1.0
31
+
32
+ - name: Install dependencies
33
+ run: uv sync
34
+
35
+ - name: Lint
36
+ run: uv run ruff check .
37
+
38
+ - name: Test with pytest
39
+ run: |
40
+ uv run pytest \
41
+ --junitxml=test-results.xml \
42
+ --html=report.html \
43
+ --self-contained-html \
44
+ --cov=. \
45
+ --cov-report=term-missing \
46
+ --cov-report=xml \
47
+ --cov-report=html
48
+
49
+ - name: Upload test reports
50
+ uses: actions/upload-artifact@v7
51
+ if: always()
52
+ with:
53
+ name: test-reports
54
+ path: |
55
+ report.html
56
+ test-results.xml
57
+ if-no-files-found: warn
58
+
59
+ - name: Upload coverage report
60
+ uses: actions/upload-artifact@v7
61
+ if: always()
62
+ with:
63
+ name: coverage-reports
64
+ path: |
65
+ coverage.xml
66
+ htmlcov/
67
+ if-no-files-found: warn
@@ -0,0 +1,38 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - v*
7
+
8
+ jobs:
9
+ publish:
10
+
11
+ name: Publish to PyPI
12
+ runs-on: ubuntu-latest
13
+
14
+ concurrency:
15
+ group: release
16
+ cancel-in-progress: false
17
+
18
+ environment:
19
+ name: pypi
20
+
21
+ permissions:
22
+ id-token: write
23
+ contents: read
24
+
25
+ steps:
26
+ - uses: actions/checkout@v6
27
+
28
+ - name: Setup Python
29
+ uses: astral-sh/setup-uv@v8.1.0
30
+
31
+ - name: Build
32
+ run: uv build
33
+
34
+ - name: Test
35
+ run: uv run pytest
36
+
37
+ - name: Publish
38
+ run: uv publish
@@ -0,0 +1,14 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Pytest
13
+ .coverage
14
+ htmlcov
@@ -0,0 +1 @@
1
+ 3.12
scooped-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: scooped
3
+ Version: 1.0.0
4
+ Summary: A CLI for project setup from generalised templates
5
+ Author-email: ArchmagePsy <SeaOfCodeSi@gmail.com>
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: fire>=0.7.1
8
+ Requires-Dist: jinja2>=3.1.6
9
+ Requires-Dist: platformdirs>=4.9.6
10
+ Requires-Dist: pydantic-settings>=2.14.1
11
+ Requires-Dist: rich>=15.0.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Scooped
15
+
16
+ A generalised project generation command using jinja2 templates
17
+
18
+ ## Why the name 'Scooped'?
19
+
20
+ To be 'scooped' is a slang term used to explain when you discover that an idea you have had has been previously explored by someone else. This project was originally called 'stencil' the idea being that you could create templates and use them to generate boilerplate projects and save time, the same way you would use a stencil to save you time and effort drawing a shape: precise and consistent results with minimal effort. As it turns out, when I attempted to publish my project to PyPI someone else had already had a similar idea to mine *15 years ago* with unfortunately the exact same name. Because I am proud of the tool I have written and believe it fills a different (albeit **very** similar) niche I have decided to stick with it and rename it to 'scooped' a nod to this hilariously embarrassing situation.
@@ -0,0 +1,7 @@
1
+ # Scooped
2
+
3
+ A generalised project generation command using jinja2 templates
4
+
5
+ ## Why the name 'Scooped'?
6
+
7
+ To be 'scooped' is a slang term used to explain when you discover that an idea you have had has been previously explored by someone else. This project was originally called 'stencil' the idea being that you could create templates and use them to generate boilerplate projects and save time, the same way you would use a stencil to save you time and effort drawing a shape: precise and consistent results with minimal effort. As it turns out, when I attempted to publish my project to PyPI someone else had already had a similar idea to mine *15 years ago* with unfortunately the exact same name. Because I am proud of the tool I have written and believe it fills a different (albeit **very** similar) niche I have decided to stick with it and rename it to 'scooped' a nod to this hilariously embarrassing situation.
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "scooped"
3
+ version = "1.0.0"
4
+ description = "A CLI for project setup from generalised templates"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "ArchmagePsy", email = "SeaOfCodeSi@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "fire>=0.7.1",
12
+ "jinja2>=3.1.6",
13
+ "platformdirs>=4.9.6",
14
+ "pydantic-settings>=2.14.1",
15
+ "rich>=15.0.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ scooped = "scooped:main"
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "pytest>=9.0.3",
28
+ "pytest-cov>=7.1.0",
29
+ "pytest-html>=4.2.0",
30
+ "pytest-mock>=3.15.1",
31
+ "ruff>=0.15.14",
32
+ ]
33
+
34
+ [tool.ruff.lint]
35
+ select = ["E4", "E7", "E9", "F", "B"]
36
+ ignore = ["E501"]
37
+ unfixable = ["B"]
38
+
39
+ [tool.ruff.lint.per-file-ignores]
40
+ "__init__.py" = ["E402"]
41
+ "**/{tests,docs,tools}/*" = ["E402"]
42
+
43
+ [tool.ruff.format]
44
+ quote-style = "single"
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ python_files = ["test_*.py"]
@@ -0,0 +1,7 @@
1
+ from scooped.cli.app import App
2
+
3
+ import fire
4
+
5
+
6
+ def main() -> None:
7
+ fire.Fire(App)
File without changes
@@ -0,0 +1,209 @@
1
+ from pathlib import Path, PurePosixPath
2
+ from typing import Dict, Optional
3
+ from io import BytesIO
4
+ from zipfile import ZipFile
5
+ from platformdirs import PlatformDirs
6
+ from jinja2 import Environment
7
+ from pydantic import ValidationError
8
+ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, TomlConfigSettingsSource
9
+ from rich.progress import Progress
10
+ from rich import print as rich_print
11
+
12
+ import urllib.request
13
+ import os
14
+ import tomllib
15
+ import re
16
+
17
+ from scooped.cli.ui import TemplateRowData, create_template_table, render_temlplate_data_row
18
+ from scooped.cli.util import ProjectNameFormat, Provider, ZipLoader, excluded, find_scooped_config
19
+ from scooped.models.settings import ScoopedSettings
20
+ from scooped.models.metadata import TemplateMetadata
21
+
22
+ class App:
23
+
24
+ def __init__(self, platform_dirs: Optional[PlatformDirs] = None):
25
+ self.platform_dirs = platform_dirs or PlatformDirs("scooped", "ArchmagePsy", ensure_exists=True)
26
+ os.makedirs(self.platform_dirs.user_data_path / "templates", exist_ok=True)
27
+
28
+ config_path = self.platform_dirs.user_config_path / "config.toml"
29
+
30
+ class Settings(BaseSettings):
31
+ scooped: ScoopedSettings = ScoopedSettings()
32
+
33
+ @classmethod
34
+ def settings_customise_sources(cls, settings_cls: type[BaseSettings],
35
+ init_settings: PydanticBaseSettingsSource,
36
+ env_settings: PydanticBaseSettingsSource,
37
+ dotenv_settings: PydanticBaseSettingsSource,
38
+ file_secret_settings: PydanticBaseSettingsSource,) -> tuple[PydanticBaseSettingsSource, ...]:
39
+ return (
40
+ init_settings,
41
+ env_settings,
42
+ dotenv_settings,
43
+ file_secret_settings,
44
+ TomlConfigSettingsSource(settings_cls, toml_file=config_path),
45
+ )
46
+
47
+ self.settings = Settings()
48
+
49
+ def install(self, repository: Optional[str] = None, url: Optional[str] = None, branch: str = "main", provider: Optional[Provider] = None):
50
+ """
51
+ Install a project template from the web
52
+ """
53
+ provider = provider or self.settings.scooped.install.default_provider or Provider.NONE
54
+ provider = Provider(provider)
55
+
56
+ if provider is Provider.NONE:
57
+ if url is None:
58
+ raise ValueError("You must provide a URL if no provider is set")
59
+
60
+ elif provider is Provider.GITHUB:
61
+ if repository is None or "/" not in repository:
62
+ raise ValueError("You must provide a repository in the format 'owner/repository' for this provider")
63
+
64
+ url = f"https://github.com/{repository}/archive/refs/heads/{branch}.zip"
65
+
66
+ with urllib.request.urlopen(url) as response:
67
+ total = int(response.headers.get("Content-Length", 0))
68
+
69
+ archive_bytes = BytesIO()
70
+
71
+ with Progress() as progress:
72
+ task = progress.add_task(f"Downloading from {url}", total=total)
73
+
74
+ while not progress.finished:
75
+ chunk = response.read(self.settings.scooped.install.chunk_size)
76
+
77
+ if not chunk:
78
+ break
79
+
80
+ archive_bytes.write(chunk)
81
+ progress.update(task, advance=len(chunk))
82
+
83
+ archive = ZipFile(archive_bytes)
84
+
85
+ if len(set(PurePosixPath(path).parts[0] for path in archive.namelist())) > 1 or not (config_file_path := find_scooped_config(archive)) :
86
+ raise FileNotFoundError("Repository is not a proper scooped template as there is no single directory at the root containing a 'scooped.toml' file")
87
+
88
+ with archive.open(config_file_path.as_posix()) as config_file:
89
+ template_config = tomllib.load(config_file)
90
+
91
+ template_metadata = TemplateMetadata(**template_config["template"])
92
+
93
+ if os.path.exists(template_output_path := (self.platform_dirs.user_data_path / f"templates/{template_metadata.name}.zip")):
94
+ raise FileExistsError(f"A template with this name already exists: {template_metadata.name}")
95
+
96
+ with open(template_output_path, "wb") as template_file:
97
+ template_file.write(archive_bytes.getbuffer())
98
+
99
+
100
+ def create(self, template_name: str, destination: Optional[str] = None, project_name: Optional[str] = None, project_name_format: Optional[ProjectNameFormat] = None, **parameters: Dict[str, str]):
101
+ """
102
+ Create a new project from an installed template
103
+ """
104
+ project_name_format = project_name_format or self.settings.scooped.create.default_project_name_format or ProjectNameFormat.NONE
105
+ project_name_format = ProjectNameFormat(project_name_format)
106
+
107
+ if destination is None and project_name is None:
108
+ raise ValueError("You must provide a project name and/or a destination to infer the project name from")
109
+
110
+ elif project_name is None:
111
+ project_name = os.path.basename(destination)
112
+
113
+ elif destination is None:
114
+ project_name = project_name_format.format(project_name)
115
+ os.mkdir(project_name)
116
+ destination = os.path.join(os.getcwd(), project_name)
117
+
118
+ else:
119
+ project_name = project_name_format.format(project_name)
120
+
121
+ with ZipFile(self.platform_dirs.user_data_path / f"templates/{template_name}.zip") as archive:
122
+ if len(set(PurePosixPath(path).parts[0] for path in archive.namelist())) > 1 or not (root_config_path := find_scooped_config(archive)) :
123
+ raise FileNotFoundError("Not a valid scooped template as there is no single directory at the root containing a 'scooped.toml' file")
124
+
125
+ with archive.open(root_config_path.as_posix()) as root_config_file:
126
+ root_config = tomllib.load(root_config_file)
127
+
128
+ template_metadata = TemplateMetadata(**root_config["template"])
129
+
130
+ env = Environment(loader=ZipLoader(archive))
131
+ env.globals.update({key: value for key, value in root_config.items() if key not in ["template"]})
132
+ env.globals.update(**parameters)
133
+ env.globals.update({"project_name": project_name, "project_name_format": project_name_format})
134
+
135
+ with Progress() as progress:
136
+ task = progress.add_task(f"Creating project [blue]{project_name}[/blue] from template [blue]{template_metadata.name}[/blue]", total=None)
137
+
138
+ for member in archive.infolist():
139
+ if excluded(member.filename, "*/scooped.toml", "**/scooped.toml", *template_metadata.ignore):
140
+ continue
141
+
142
+ path = PurePosixPath(member.filename)
143
+
144
+ if member.is_dir():
145
+ continue
146
+
147
+ parts = path.parts[1:]
148
+
149
+ if not parts:
150
+ continue
151
+
152
+ target_path = PurePosixPath(destination).joinpath(*parts)
153
+
154
+ os.makedirs(target_path.parent.as_posix(), exist_ok=True)
155
+ progress.update(task, advance=1, description=f"Creatig parent directories [yellow]{target_path.parent.as_posix()}[/yellow]")
156
+
157
+ if member.filename.endswith((".j2", ".jinja", ".jinja2")):
158
+ with open(re.sub(r"(.j2|.jinja|.jinja2)$", "", target_path.as_posix()), "w") as dst:
159
+ template = env.get_template(member.filename)
160
+ dst.write(template.render())
161
+ progress.update(task, advance=1, description=f"Rendering [yellow]{member.filename}[/yellow]")
162
+
163
+ else:
164
+ with archive.open(member) as src, open(target_path.as_posix(), "wb") as dst:
165
+ dst.write(src.read())
166
+ progress.update(task, advance=1, description=f"Extracting [yellow]{member.filename}[/yellow]")
167
+
168
+ progress.update(task, total=1, completed=1)
169
+
170
+ def list(self):
171
+ """
172
+ List all installed templates
173
+ """
174
+ template_table = create_template_table("Installed Templates", "templates you have installed with scooped under " + str(self.platform_dirs.user_data_path / "templates/"))
175
+
176
+ for path in os.listdir(self.platform_dirs.user_data_path / "templates"):
177
+ path = str(self.platform_dirs.user_data_path / f"templates/{path}")
178
+ if path.endswith(".zip"):
179
+
180
+ try:
181
+
182
+ with ZipFile(path) as archive:
183
+ if (root_config_path := find_scooped_config(archive)) is not None:
184
+ with archive.open(root_config_path.as_posix(), "r") as root_config_file:
185
+ root_config = tomllib.load(root_config_file)
186
+ metadata = TemplateMetadata(**root_config["template"])
187
+ template_row_data = TemplateRowData(valid=True, **{key: value for key, value in metadata.model_dump().items() if key in TemplateRowData.columns()})
188
+ else:
189
+ raise FileNotFoundError("No 'scooped.toml' found at the root directory")
190
+
191
+ except FileNotFoundError:
192
+ template_row_data = TemplateRowData(valid=False, name=f"{Path(path).stem}(?)", description="invalid template, no configuration file found")
193
+
194
+ except ValidationError:
195
+ template_row_data = TemplateRowData(valid=False, name=f"{Path(path).stem}(?)", description="invalid template, configuration file invalid")
196
+
197
+ except (PermissionError, OSError):
198
+ template_row_data = TemplateRowData(valid=False, name=f"{Path(path).stem}(?)", description="invalid template, could not read")
199
+
200
+ template_table.add_row(*render_temlplate_data_row(template_row_data))
201
+
202
+ rich_print(template_table)
203
+
204
+ def remove(self, template_name: str):
205
+ """
206
+ Remove an installed template
207
+ """
208
+ if os.path.exists(template_path := (self.platform_dirs.user_data_path / f"templates/{template_name}.zip")):
209
+ os.remove(template_path)
@@ -0,0 +1,52 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional, Tuple
3
+
4
+ from rich.text import Text
5
+ from rich.style import Style
6
+ from rich.table import Table
7
+ from rich import box
8
+
9
+ @dataclass
10
+ class TemplateRowData:
11
+ name: str
12
+ valid: bool
13
+ author: Optional[str] = None
14
+ description: Optional[str] = None
15
+
16
+ @staticmethod
17
+ def columns() -> Tuple[str]:
18
+ return ("name", "author", "description", "valid",)
19
+
20
+ class TemplateValidBadge:
21
+
22
+ VALID_COLOR = "green"
23
+ INVALID_COLOR = "red"
24
+ VALID_MARK = ":heavy_check_mark:"
25
+ INVALID_MARK = ":cross_mark:"
26
+
27
+ def __init__(self, valid: bool):
28
+ self.valid = valid
29
+
30
+ def __rich__(self):
31
+ if self.valid:
32
+ color = TemplateValidBadge.VALID_COLOR
33
+ mark = TemplateValidBadge.VALID_MARK
34
+
35
+ else:
36
+ color = TemplateValidBadge.INVALID_COLOR
37
+ mark = TemplateValidBadge.INVALID_MARK
38
+
39
+ return Text.from_markup(mark, style=Style(color=color, encircle=True, bold=True), justify="center")
40
+
41
+ def render_temlplate_data_row(data: TemplateRowData) -> Tuple:
42
+ return (data.name, data.author, data.description, TemplateValidBadge(data.valid),)
43
+
44
+ def create_template_table(title: str, caption: str) -> Table:
45
+ return Table(*TemplateRowData.columns(),
46
+ title=title,
47
+ title_style=Style.null(),
48
+ title_justify="center",
49
+ caption=caption,
50
+ caption_style=Style.null(),
51
+ caption_justify="left",
52
+ box=box.SIMPLE)
@@ -0,0 +1,51 @@
1
+ from enum import Enum
2
+ from pathlib import PurePosixPath
3
+ from typing import Callable, Tuple
4
+ from zipfile import ZipFile
5
+ import fnmatch
6
+ import re
7
+
8
+ from jinja2 import BaseLoader, TemplateNotFound
9
+
10
+
11
+ def find_scooped_config(archive: ZipFile) -> PurePosixPath | None:
12
+ return next((path for path in map(PurePosixPath, archive.namelist()) if len(path.parts) == 2 and path.parts[1] == "scooped.toml"), None)
13
+
14
+ def excluded(path, *patterns) -> bool:
15
+ return any(fnmatch.fnmatch(path, pattern) for pattern in patterns)
16
+
17
+ class ZipLoader(BaseLoader):
18
+ def __init__(self, archive: ZipFile):
19
+ self.archive = archive
20
+
21
+ self.files = {
22
+ name: name for name in self.archive.namelist()
23
+ if not name.endswith("/")
24
+ }
25
+
26
+ def get_source(self, environment, template) -> Tuple[str, str, Callable[[], bool]]:
27
+ if template not in self.files:
28
+ raise TemplateNotFound(template)
29
+
30
+ source = self.archive.read(template).decode()
31
+
32
+ return source, template, lambda: True
33
+
34
+ class ProjectNameFormat(Enum):
35
+ KEBAB = "kebab"
36
+ PASCAL = "pascal"
37
+ NONE = "none"
38
+
39
+ def format(self, project_name: str) -> str:
40
+ if self is ProjectNameFormat.KEBAB:
41
+ return re.sub(r"\s+", "-", project_name.lower())
42
+
43
+ elif self is ProjectNameFormat.PASCAL:
44
+ return "".join([part.capitaize() for part in project_name.split()])
45
+
46
+ else:
47
+ return project_name
48
+
49
+ class Provider(Enum):
50
+ GITHUB = "github"
51
+ NONE = "none"
File without changes
@@ -0,0 +1,9 @@
1
+ from typing import List, Optional
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class TemplateMetadata(BaseModel):
6
+ name: str
7
+ author: Optional[str] = None
8
+ description: Optional[str] = None
9
+ ignore: List[str] = []
@@ -0,0 +1,16 @@
1
+ from typing import Optional
2
+ from pydantic import BaseModel, Field
3
+
4
+ from scooped.cli.util import ProjectNameFormat, Provider
5
+
6
+
7
+ class InstallSettings(BaseModel):
8
+ chunk_size: int = 8192
9
+ default_provider: Optional[Provider] = Field(default=None, alias="provider")
10
+
11
+ class CreateSettings(BaseModel):
12
+ default_project_name_format: Optional[ProjectNameFormat] = Field(default=None, alias="project-name-format")
13
+
14
+ class ScoopedSettings(BaseModel):
15
+ install: InstallSettings = InstallSettings()
16
+ create: CreateSettings = CreateSettings()
@@ -0,0 +1,41 @@
1
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
2
+ from pathlib import Path
3
+ from platformdirs import PlatformDirs
4
+ import threading
5
+ import pytest
6
+
7
+
8
+ @pytest.fixture(scope="session")
9
+ def file_server():
10
+ test_files_dir = Path(__file__).parent / "resources"
11
+
12
+ class Handler(SimpleHTTPRequestHandler):
13
+ def __init__(self, *args, **kwargs):
14
+ super().__init__(*args, directory=str(test_files_dir), **kwargs)
15
+
16
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
17
+
18
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
19
+ thread.start()
20
+
21
+ host, port = server.server_address
22
+
23
+ yield f"http://{host}:{port}"
24
+
25
+ server.shutdown()
26
+ thread.join()
27
+
28
+ @pytest.fixture
29
+ def temp_dir(tmp_path_factory):
30
+ base = tmp_path_factory.mktemp("test-app-data")
31
+
32
+ class FakeDirs(PlatformDirs):
33
+ @property
34
+ def user_data_path(self):
35
+ return base
36
+
37
+ @property
38
+ def user_config_path(self):
39
+ return base
40
+
41
+ return FakeDirs("scooped", "ArchmagePsy")
@@ -0,0 +1,2 @@
1
+ [template]
2
+ description = "A template that is missing a name"
@@ -0,0 +1,13 @@
1
+ [template]
2
+ name = "TestTemplate"
3
+ author = "ArchmagePsy"
4
+ description = "A simple template used in the scooped tests"
5
+ ignore = [
6
+ ".venv/*",
7
+ ".idea/*",
8
+ ".vscode/*",
9
+ "*.ignore*"
10
+ ]
11
+
12
+ [user]
13
+ name = "World"
@@ -0,0 +1 @@
1
+ This should never appear in the generated project!
@@ -0,0 +1 @@
1
+ Hello {{ user.name }}!
@@ -0,0 +1,129 @@
1
+ from pathlib import Path
2
+ import shutil
3
+ import os
4
+ from unittest.mock import ANY, Mock
5
+ import pytest
6
+
7
+ from scooped.cli.app import App
8
+ from scooped.cli.ui import TemplateValidBadge
9
+ from scooped.cli.util import Provider
10
+
11
+
12
+ @pytest.fixture
13
+ def app(temp_dir) -> App:
14
+ return App(platform_dirs=temp_dir)
15
+
16
+ @pytest.fixture
17
+ def install_template(temp_dir):
18
+ shutil.copy(Path(__file__).parent / "resources/test_template.zip", temp_dir.user_data_path / "templates/TestTemplate.zip")
19
+
20
+ @pytest.fixture
21
+ def install_missing_config_template(temp_dir):
22
+ shutil.copy(Path(__file__).parent / "resources/bad_test_template.zip", temp_dir.user_data_path / "templates/BadTemplate.zip")
23
+
24
+ @pytest.fixture
25
+ def install_missing_name_template(temp_dir):
26
+ shutil.copy(Path(__file__).parent / "resources/missing_name_template.zip", temp_dir.user_data_path / "templates/MissingNameTemplate.zip")
27
+
28
+ @pytest.fixture
29
+ def project_dir(tmp_path_factory) -> Path:
30
+ return tmp_path_factory.mktemp("test-projects")
31
+
32
+ @pytest.fixture
33
+ def table_add_row_mock(mocker) -> Mock:
34
+ return mocker.patch("scooped.cli.ui.Table.add_row")
35
+
36
+ @pytest.fixture
37
+ def zip_permissions_mock(mocker) -> Mock:
38
+ return mocker.patch("scooped.cli.app.ZipFile", side_effect=PermissionError())
39
+
40
+ def test_install_template_success(app, file_server):
41
+ app.install(url=f"{file_server}/test_template.zip", provider=None)
42
+
43
+ assert os.path.exists(app.platform_dirs.user_data_path / "templates/TestTemplate.zip")
44
+
45
+ def test_install_template_already_installed(app, file_server):
46
+ app.install(url=f"{file_server}/test_template.zip", provider=None)
47
+
48
+ with pytest.raises(FileExistsError):
49
+ app.install(url=f"{file_server}/test_template.zip", provider=None)
50
+
51
+ def test_install_template_without_config(app, file_server):
52
+ with pytest.raises(FileNotFoundError):
53
+ app.install(url=f"{file_server}/bad_test_template.zip", provider=None)
54
+
55
+ def test_install_template_from_bad_repository_name(app, file_server):
56
+ with pytest.raises(ValueError, match="repository in the format 'owner/repository' for this provider"):
57
+ app.install(repository="bad-repository-name", provider=Provider.GITHUB)
58
+
59
+ def test_install_template_without_url(app, file_server):
60
+ with pytest.raises(ValueError, match="URL if no provider is set"):
61
+ app.install(provider=None)
62
+
63
+ def test_create_from_template(app, install_template, project_dir):
64
+ os.chdir(project_dir)
65
+ app.create(template_name="TestTemplate", project_name="my test project")
66
+
67
+ assert (test_txt_path := Path(project_dir) / "my test project/test.txt").exists()
68
+ assert not (Path(project_dir) / "my test project/test.txt.ignore").exists()
69
+ assert not (Path(project_dir) / "my test project/scooped.toml").exists()
70
+ assert test_txt_path.read_text() == "Hello World!"
71
+
72
+ def test_create_from_template_and_existing_directory(app, install_template, project_dir):
73
+ project_path = project_dir / "my-test-project2"
74
+ project_path.mkdir()
75
+ os.chdir(project_path)
76
+
77
+ app.create(template_name="TestTemplate", destination=".")
78
+
79
+ assert (test_txt_path := Path(project_dir) / "my-test-project2/test.txt").exists()
80
+ assert not (Path(project_dir) / "my-test-project2/test.txt.ignore").exists()
81
+ assert test_txt_path.read_text() == "Hello World!"
82
+
83
+ def test_create_from_bad_template(app, install_missing_config_template, project_dir):
84
+ os.chdir(project_dir)
85
+
86
+ with pytest.raises(FileNotFoundError):
87
+ app.create(template_name="BadTemplate", project_name="my-bad-project")
88
+
89
+ def test_create_no_project_name_or_destination(app, install_template, project_dir):
90
+ os.chdir(project_dir)
91
+
92
+ with pytest.raises(ValueError):
93
+ app.create(template_name="TestTemplate")
94
+
95
+ def test_list(app, install_template, table_add_row_mock):
96
+ app.list()
97
+
98
+ table_add_row_mock.assert_called_once()
99
+ table_add_row_mock.assert_called_with("TestTemplate", "ArchmagePsy", "A simple template used in the scooped tests", ANY)
100
+ badge = table_add_row_mock.call_args.args[3]
101
+ assert isinstance(badge, TemplateValidBadge)
102
+ assert badge.valid
103
+
104
+ def test_list_template_missing_config(app, install_missing_config_template, table_add_row_mock):
105
+ app.list()
106
+
107
+ table_add_row_mock.assert_called_once()
108
+ table_add_row_mock.assert_called_with("BadTemplate(?)", None, "invalid template, no configuration file found", ANY)
109
+ badge = table_add_row_mock.call_args.args[3]
110
+ assert isinstance(badge, TemplateValidBadge)
111
+ assert not badge.valid
112
+
113
+ def test_list_template_missing_metadata(app, install_missing_name_template, table_add_row_mock):
114
+ app.list()
115
+
116
+ table_add_row_mock.assert_called_once()
117
+ table_add_row_mock.assert_called_with("MissingNameTemplate(?)", None, "invalid template, configuration file invalid", ANY)
118
+ badge = table_add_row_mock.call_args.args[3]
119
+ assert isinstance(badge, TemplateValidBadge)
120
+ assert not badge.valid
121
+
122
+ def test_list_template_invalid_permissions(app, install_template, table_add_row_mock, zip_permissions_mock):
123
+ app.list()
124
+
125
+ table_add_row_mock.assert_called_once()
126
+ table_add_row_mock.assert_called_with("TestTemplate(?)", None, "invalid template, could not read", ANY)
127
+ badge = table_add_row_mock.call_args.args[3]
128
+ assert isinstance(badge, TemplateValidBadge)
129
+ assert not badge.valid