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.
- scooped-1.0.0/.github/workflows/ci.yml +67 -0
- scooped-1.0.0/.github/workflows/release.yml +38 -0
- scooped-1.0.0/.gitignore +14 -0
- scooped-1.0.0/.python-version +1 -0
- scooped-1.0.0/PKG-INFO +20 -0
- scooped-1.0.0/README.md +7 -0
- scooped-1.0.0/pyproject.toml +48 -0
- scooped-1.0.0/src/scooped/__init__.py +7 -0
- scooped-1.0.0/src/scooped/cli/__init__.py +0 -0
- scooped-1.0.0/src/scooped/cli/app.py +209 -0
- scooped-1.0.0/src/scooped/cli/ui.py +52 -0
- scooped-1.0.0/src/scooped/cli/util.py +51 -0
- scooped-1.0.0/src/scooped/models/__init__.py +0 -0
- scooped-1.0.0/src/scooped/models/metadata.py +9 -0
- scooped-1.0.0/src/scooped/models/settings.py +16 -0
- scooped-1.0.0/tests/conftest.py +41 -0
- scooped-1.0.0/tests/resources/bad_test_template.zip +0 -0
- scooped-1.0.0/tests/resources/missing_name_template/scooped.toml +2 -0
- scooped-1.0.0/tests/resources/missing_name_template.zip +0 -0
- scooped-1.0.0/tests/resources/test_template/scooped.toml +13 -0
- scooped-1.0.0/tests/resources/test_template/test.txt.ignore +1 -0
- scooped-1.0.0/tests/resources/test_template/test.txt.j2 +1 -0
- scooped-1.0.0/tests/resources/test_template.zip +0 -0
- scooped-1.0.0/tests/test_cli.py +129 -0
|
@@ -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
|
scooped-1.0.0/.gitignore
ADDED
|
@@ -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.
|
scooped-1.0.0/README.md
ADDED
|
@@ -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"]
|
|
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,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")
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This should never appear in the generated project!
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Hello {{ user.name }}!
|
|
Binary file
|
|
@@ -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
|