svpbuild 0.1.1__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.
Files changed (27) hide show
  1. svpbuild-0.1.1/.github/workflows/release.yml +69 -0
  2. svpbuild-0.1.1/.gitignore +221 -0
  3. svpbuild-0.1.1/LICENSE +21 -0
  4. svpbuild-0.1.1/PKG-INFO +18 -0
  5. svpbuild-0.1.1/README.md +1 -0
  6. svpbuild-0.1.1/pyproject.toml +34 -0
  7. svpbuild-0.1.1/scripts/bump_version.py +58 -0
  8. svpbuild-0.1.1/src/svpbuild/__init__.py +6 -0
  9. svpbuild-0.1.1/src/svpbuild/builder/builder.py +81 -0
  10. svpbuild-0.1.1/src/svpbuild/loader/character.py +112 -0
  11. svpbuild-0.1.1/src/svpbuild/loader/loader.py +73 -0
  12. svpbuild-0.1.1/src/svpbuild/loader/types.py +36 -0
  13. svpbuild-0.1.1/src/svpbuild/main.py +113 -0
  14. svpbuild-0.1.1/src/svpbuild/schemas.py +112 -0
  15. svpbuild-0.1.1/tests/__init__.py +0 -0
  16. svpbuild-0.1.1/tests/conftest.py +8 -0
  17. svpbuild-0.1.1/tests/data/sample_pack/assets/Abigail/Abigail-Beach_Party-Outfit-Summer.png +0 -0
  18. svpbuild-0.1.1/tests/data/sample_pack/assets/Abigail/Abigail-Beach_Party-Outfit.png +0 -0
  19. svpbuild-0.1.1/tests/data/sample_pack/assets/Abigail/Abigail-Indoor.png +0 -0
  20. svpbuild-0.1.1/tests/data/sample_pack/assets/Abigail/Abigail-Outdoor.png +0 -0
  21. svpbuild-0.1.1/tests/data/sample_pack/assets/Abigail/Abigail-Spring.png +0 -0
  22. svpbuild-0.1.1/tests/data/sample_pack/assets/Abigail/Abigail.png +0 -0
  23. svpbuild-0.1.1/tests/data/sample_pack/assets/Sebastian/Sebastian-Goth-LocationName-Saloon.png +0 -0
  24. svpbuild-0.1.1/tests/data/sample_pack/assets/Sebastian/Sebastian.png +0 -0
  25. svpbuild-0.1.1/tests/data/sample_pack/manifest.json +7 -0
  26. svpbuild-0.1.1/tests/test_loader.py +76 -0
  27. svpbuild-0.1.1/uv.lock +108 -0
@@ -0,0 +1,69 @@
1
+ name: Release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ bump_type:
7
+ description: "Bump Type"
8
+ required: true
9
+ default: "patch"
10
+ type: choice
11
+ options:
12
+ - patch
13
+ - minor
14
+ - major
15
+
16
+ jobs:
17
+ release:
18
+ runs-on: ubuntu-latest
19
+
20
+ permissions:
21
+ id-token: write # Required for OIDC PyPI publishing
22
+ contents: write # Required to push commits, tags, and create GitHub Releases
23
+
24
+ steps:
25
+ - name: Checkout code
26
+ uses: actions/checkout@v4
27
+ with:
28
+ # Fetch full history so we can push back
29
+ fetch-depth: 0
30
+
31
+ - name: Set up Python
32
+ uses: actions/setup-python@v5
33
+ with:
34
+ python-version: "3.12"
35
+
36
+ - name: Install uv
37
+ uses: astral-sh/setup-uv@v5
38
+ with:
39
+ enable-cache: true
40
+
41
+ - name: Bump Version
42
+ id: bump
43
+ run: |
44
+ NEW_VERSION=$(python scripts/bump_version.py ${{ github.event.inputs.bump_type }})
45
+ echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV
46
+ echo "Bumped version to $NEW_VERSION"
47
+
48
+ - name: Commit and Tag
49
+ run: |
50
+ git config --local user.name "github-actions[bot]"
51
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
52
+ git add pyproject.toml
53
+ git commit -m "chore: bump version to ${{ env.NEW_VERSION }}"
54
+ git tag "v${{ env.NEW_VERSION }}"
55
+ git push origin main
56
+ git push origin "v${{ env.NEW_VERSION }}"
57
+
58
+ - name: Create GitHub Release
59
+ env:
60
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
61
+ run: |
62
+ gh release create "v${{ env.NEW_VERSION }}" \
63
+ --title "v${{ env.NEW_VERSION }}" \
64
+ --generate-notes
65
+
66
+ - name: Build and Publish to PyPI
67
+ run: |
68
+ uv build
69
+ uv publish
@@ -0,0 +1,221 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
219
+
220
+ # OpenCode
221
+ .opencode
svpbuild-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Taewon Kim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: svpbuild
3
+ Version: 0.1.1
4
+ Summary: A CLI tool to automatically compile Stardew Valley Content Patcher portrait mods without writing JSON.
5
+ Project-URL: Homepage, https://github.com/koreanmelon/svpbuild
6
+ Project-URL: Issues, https://github.com/koreanmelon/svpbuild/issues
7
+ Author-email: Taewon Kim <taewonkim2001@gmail.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: cli,content-patcher,modding,stardew-valley
11
+ Classifier: Environment :: Console
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+
18
+ # svpbuild
@@ -0,0 +1 @@
1
+ # svpbuild
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "svpbuild"
7
+ version = "0.1.1"
8
+ description = "A CLI tool to automatically compile Stardew Valley Content Patcher portrait mods without writing JSON."
9
+ authors = [{ name = "Taewon Kim", email = "taewonkim2001@gmail.com" }]
10
+ license = { text = "MIT" }
11
+ readme = "README.md"
12
+ requires-python = ">=3.12"
13
+ dependencies = []
14
+ keywords = ["stardew-valley", "content-patcher", "modding", "cli"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Environment :: Console",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/koreanmelon/svpbuild"
24
+ Issues = "https://github.com/koreanmelon/svpbuild/issues"
25
+
26
+ [project.scripts]
27
+ svpbuild = "svpbuild.main:main"
28
+
29
+ [dependency-groups]
30
+ dev = ["pytest>=9.0.3", "ruff"]
31
+
32
+ [tool.ruff]
33
+ line-length = 150
34
+ exclude = [".git", ".venv", "__pycache__", "build"]
@@ -0,0 +1,58 @@
1
+ import re
2
+ import sys
3
+ from pathlib import Path
4
+
5
+
6
+ def bump_version(version: str, bump_type: str) -> str:
7
+ parts = version.split(".")
8
+ if len(parts) != 3:
9
+ raise ValueError(f"Version string '{version}' does not match expected format X.Y.Z")
10
+
11
+ major, minor, patch = map(int, parts)
12
+
13
+ if bump_type == "major":
14
+ return f"{major + 1}.0.0"
15
+ elif bump_type == "minor":
16
+ return f"{major}.{minor + 1}.0"
17
+ elif bump_type == "patch":
18
+ return f"{major}.{minor}.{patch + 1}"
19
+ else:
20
+ raise ValueError(f"Invalid bump type: {bump_type}")
21
+
22
+
23
+ def main():
24
+ if len(sys.argv) != 2:
25
+ print("Usage: python scripts/bump_version.py [major|minor|patch]")
26
+ sys.exit(1)
27
+
28
+ bump_type = sys.argv[1].lower()
29
+ if bump_type not in ["major", "minor", "patch"]:
30
+ print(f"Error: Invalid bump type '{bump_type}'. Must be major, minor, or patch.")
31
+ sys.exit(1)
32
+
33
+ pyproject_path = Path("pyproject.toml")
34
+ if not pyproject_path.exists():
35
+ print("Error: pyproject.toml not found in current directory.")
36
+ sys.exit(1)
37
+
38
+ content = pyproject_path.read_text()
39
+
40
+ # Match the version line exactly in the [project] section
41
+ match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
42
+ if not match:
43
+ print("Error: Could not find version string in pyproject.toml")
44
+ sys.exit(1)
45
+
46
+ old_version = match.group(1)
47
+ new_version = bump_version(old_version, bump_type)
48
+
49
+ # Replace just the version string
50
+ new_content = content[: match.start(1)] + new_version + content[match.end(1) :]
51
+ pyproject_path.write_text(new_content)
52
+
53
+ # Print the new version to stdout so the GitHub Action can capture it
54
+ print(new_version)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()
@@ -0,0 +1,6 @@
1
+ """
2
+ svpbuild
3
+
4
+ A CLI tool designed to compile Stardew Valley Content Patcher
5
+ portrait mods from a simple folder structure.
6
+ """
@@ -0,0 +1,81 @@
1
+ # """
2
+ # Compiles dynamic configuration schemas and content changes.
3
+
4
+ # This module converts the discovered assets and expansions into valid
5
+ # Content Patcher content.json and config.json structures.
6
+ # """
7
+
8
+ # from __future__ import annotations
9
+
10
+ # from svpbuild.schemas import CPContentSpec, CP_FORMAT_VERSION
11
+ # from svpbuild.loader.loader import Loader
12
+
13
+
14
+ # class Builder:
15
+ # def __init__(self) -> None:
16
+ # pass
17
+
18
+
19
+ # def build_content(loader: Loader, expansion: str) -> CPContentSpec:
20
+ # """
21
+ # Generates the dynamic Content Patcher content.json structure for an expansion.
22
+
23
+ # Creates ConfigSchema entries for user selection and EditImage actions
24
+ # with appropriate Update and When conditions (e.g. OnDayStart, OnLocationChange).
25
+ # """
26
+ # character_variant_map = loader.character_variant_map.get(expansion, {})
27
+ # assets = loader.characters.get(expansion, [])
28
+
29
+ # content: CPContentSpec = {
30
+ # "Format": CP_FORMAT_VERSION,
31
+ # "ConfigSchema": {"SeasonalPortraits": {"AllowValues": "true, false", "Default": True}},
32
+ # "Changes": [],
33
+ # }
34
+
35
+ # content["ConfigSchema"] |= {name: {"AllowValues": ", ".join(variants), "Default": "Standard"} for name, variants in character_variant_map.items()}
36
+ # content["Changes"].extend(
37
+ # {
38
+ # "Action": "EditImage",
39
+ # "Target": f"Portraits/{name}",
40
+ # "FromFile": f"assets/{expansion}/{name}/{name}_{{{{{name}}}}}.png",
41
+ # "Update": "OnDayStart",
42
+ # "When": {"HasFile:{{FromFile}}": True},
43
+ # }
44
+ # for name in character_variant_map.keys()
45
+ # )
46
+
47
+ # for asset in assets:
48
+ # if asset.season:
49
+ # content["Changes"].append(
50
+ # {
51
+ # "Action": "EditImage",
52
+ # "Target": f"Portraits/{asset.name}",
53
+ # "FromFile": f"assets/{expansion}/{asset.name}/Season/{asset.name}_{{{{Season}}}}_{{{{{asset.name}}}}}.png",
54
+ # "Update": "OnLocationChange",
55
+ # "When": {"HasFile:{{FromFile}}": True, "SeasonalPortraits": True},
56
+ # }
57
+ # )
58
+
59
+ # if asset.day_event:
60
+ # content["Changes"].append(
61
+ # {
62
+ # "Action": "EditImage",
63
+ # "Target": f"Portraits/{asset.name}",
64
+ # "FromFile": f"assets/{expansion}/{asset.name}/DayEvent/{asset.name}_{{{{DayEvent}}}}_{{{{{asset.name}}}}}.png",
65
+ # "Update": "OnLocationChange",
66
+ # "When": {"HasFile:{{FromFile}}": True},
67
+ # }
68
+ # )
69
+
70
+ # if asset.location_name:
71
+ # content["Changes"].append(
72
+ # {
73
+ # "Action": "EditImage",
74
+ # "Target": f"Portraits/{asset.name}",
75
+ # "FromFile": f"assets/{expansion}/{asset.name}/LocationName/{asset.name}_{{{{LocationName}}}}_{{{{{asset.name}}}}}.png",
76
+ # "Update": "OnLocationChange",
77
+ # "When": {"HasFile:{{FromFile}}": True},
78
+ # }
79
+ # )
80
+
81
+ # return content
@@ -0,0 +1,112 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Iterable
4
+
5
+ from svpbuild.loader.types import CharacterAsset, PortraitAsset
6
+
7
+ RESERVED_VALUES = {
8
+ "Monday": ("DayOfWeek", "Monday"),
9
+ "Tuesday": ("DayOfWeek", "Tuesday"),
10
+ "Wednesday": ("DayOfWeek", "Wednesday"),
11
+ "Thursday": ("DayOfWeek", "Thursday"),
12
+ "Friday": ("DayOfWeek", "Friday"),
13
+ "Saturday": ("DayOfWeek", "Saturday"),
14
+ "Sunday": ("DayOfWeek", "Sunday"),
15
+ "Spring": ("Season", "Spring"),
16
+ "Summer": ("Season", "Summer"),
17
+ "Fall": ("Season", "Fall"),
18
+ "Winter": ("Season", "Winter"),
19
+ "Sun": ("Weather", "Sun"),
20
+ "Rain": ("Weather", "Rain"),
21
+ "Storm": ("Weather", "Storm"),
22
+ "GreenRain": ("Weather", "GreenRain"),
23
+ "Snow": ("Weather", "Snow"),
24
+ "Wind": ("Weather", "Wind"),
25
+ "Indoor": ("IsOutdoors", False),
26
+ "Outdoor": ("IsOutdoors", True),
27
+ }
28
+
29
+ KNOWN_TOKENS = {
30
+ "Day",
31
+ "DayEvent",
32
+ "DayOfWeek",
33
+ "Season",
34
+ "Weather",
35
+ "IsOutdoors",
36
+ "LocationContext",
37
+ "LocationName",
38
+ }
39
+
40
+
41
+ class CharacterLoader:
42
+ def __init__(self, root: Path) -> None:
43
+ self.root: Path = root
44
+ self.characters: dict[str, CharacterAsset] = {}
45
+
46
+ self._load_all(root)
47
+
48
+ def _parse_filename(self, filename: str) -> tuple[str, str, dict[str, str | int | bool]]:
49
+ """
50
+ Parses a portrait asset filename based on the required naming convention.
51
+ """
52
+ name, ext = filename.split(".", 1)
53
+ parts = name.split("-")
54
+
55
+ char_name = parts[0]
56
+ variant_parts = []
57
+ conditions = {}
58
+
59
+ i = 1
60
+ while i < len(parts):
61
+ part = parts[i]
62
+ if part in RESERVED_VALUES:
63
+ k, v = RESERVED_VALUES[part]
64
+ conditions[k] = v
65
+ elif part in KNOWN_TOKENS:
66
+ if i + 1 < len(parts):
67
+ val = parts[i + 1]
68
+ if val.lower() == "true":
69
+ conditions[part] = True
70
+ elif val.lower() == "false":
71
+ conditions[part] = False
72
+ elif val.isdigit():
73
+ if val.startswith("0") and len(val) > 1:
74
+ conditions[part] = val
75
+ else:
76
+ conditions[part] = int(val)
77
+ else:
78
+ conditions[part] = val
79
+ i += 1
80
+ else:
81
+ variant_parts.append(part)
82
+ else:
83
+ variant_parts.append(part)
84
+ i += 1
85
+
86
+ variant = " ".join(variant_parts).replace("_", " ") if variant_parts else "Standard"
87
+ return char_name, variant, conditions
88
+
89
+ def _load_all(self, directory: Path):
90
+ for file in directory.rglob("*.png"):
91
+ if file.is_file():
92
+ self._load_one(file)
93
+
94
+ def _load_one(self, file: Path):
95
+ if not file.is_file():
96
+ return
97
+
98
+ name, variant, conditions = self._parse_filename(file.name)
99
+
100
+ rel_path = file.relative_to(self.root).as_posix()
101
+ self.characters.setdefault(name, CharacterAsset(name=name))
102
+ self.characters[name].portraits.append(PortraitAsset(variant=variant, conditions=conditions, path=rel_path))
103
+ logging.debug(f"Parsed '{file.name}' -> Character: {name} | Variant: {variant} | Conditions: {conditions}")
104
+
105
+ def get_names(self) -> Iterable[str]:
106
+ return self.characters.keys()
107
+
108
+ def get_characters(self) -> Iterable[CharacterAsset]:
109
+ return self.characters.values()
110
+
111
+ def get_character(self, name: str) -> CharacterAsset:
112
+ return self.characters[name]
@@ -0,0 +1,73 @@
1
+ """
2
+ Handles discovering and loading assets, dependencies, and patches from the filesystem.
3
+
4
+ This module scans the source directory to build an in-memory representation of
5
+ all available expansions and character portraits before the builder compiles them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ from pathlib import Path
13
+
14
+ from svpbuild.loader.character import CharacterLoader
15
+ from svpbuild.schemas import CPContentSpec, SMAPIManifest
16
+
17
+
18
+ class Loader:
19
+ """
20
+ Stateful asset loader that discovers and caches Stardew Valley mod assets.
21
+ """
22
+
23
+ def __init__(self, root: Path):
24
+ """
25
+ Scans the given source directory and loads the manifest, expansions,
26
+ character assets, variants, dependencies, and patches into memory.
27
+ """
28
+
29
+ self.root: Path = root
30
+ self.manifest: SMAPIManifest = self._load_manifest(root)
31
+ self.characters = CharacterLoader(self.root.joinpath("assets"))
32
+
33
+ def _load_manifest(self, root: Path) -> SMAPIManifest:
34
+ manifest = root.joinpath("manifest.json")
35
+ if not manifest.exists():
36
+ raise FileNotFoundError(f"manifest.json not found in {root}")
37
+ manifest_data = json.loads(manifest.read_text())
38
+ manifest_data["$schema"] = "https://smapi.io/schemas/manifest.json"
39
+ logging.debug(f"Loaded manifest for '{manifest_data.get('Name')}' ({manifest_data.get('UniqueID')})")
40
+ return manifest_data
41
+
42
+ def build_config(self) -> dict[str, str]:
43
+ return {name: "Standard" for name in self.characters.get_names()}
44
+
45
+ def build_content(self) -> CPContentSpec:
46
+ changes = []
47
+ for character in self.characters.get_characters():
48
+ for portrait in character.portraits:
49
+ when_conditions: dict[str, str | int | bool] = {character.name: portrait.variant}
50
+ when_conditions.update(portrait.conditions)
51
+
52
+ changes.append(
53
+ {
54
+ "Action": "EditImage",
55
+ "Target": f"Portraits/{character.name}",
56
+ "FromFile": f"assets/{portrait.path}",
57
+ "Update": "OnDayStart",
58
+ "When": when_conditions,
59
+ }
60
+ )
61
+
62
+ content: CPContentSpec = {
63
+ "$schema": "https://smapi.io/schemas/content-patcher.json",
64
+ "Format": "2.9.0",
65
+ "ConfigSchema": {
66
+ character.name: {"AllowValues": ", ".join(character.variants), "Default": "Standard"}
67
+ for character in self.characters.get_characters()
68
+ },
69
+ "Changes": changes,
70
+ }
71
+
72
+ logging.debug(f"Generated {len(changes)} patch(es) across {len(list(self.characters.get_names()))} character(s).")
73
+ return content
@@ -0,0 +1,36 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass
5
+ class PortraitAsset:
6
+ """
7
+ Represents an individual portrait file.
8
+
9
+ Attributes:
10
+ variant: The variant name (e.g., 'Standard', 'Beach').
11
+ conditions: A dictionary of Content Patcher condition keys and values (e.g., {'Season': 'Spring'}).
12
+ path: The relative path to the image file.
13
+ """
14
+
15
+ path: str
16
+ variant: str
17
+ conditions: dict[str, str | int | bool] = field(default_factory=dict)
18
+
19
+
20
+ @dataclass
21
+ class CharacterAsset:
22
+ """
23
+ Represents a character's portrait assets discovered on disk.
24
+
25
+ Attributes:
26
+ name: The name of the character (e.g., 'Harvey').
27
+ portraits: A list of PortraitAsset objects.
28
+ """
29
+
30
+ name: str
31
+ portraits: list[PortraitAsset] = field(default_factory=list)
32
+
33
+ @property
34
+ def variants(self) -> set[str]:
35
+ """Returns a set of unique variants associated with this character."""
36
+ return set(portrait.variant for portrait in self.portraits)
@@ -0,0 +1,113 @@
1
+ """
2
+ The main entry point for the svpbuild CLI.
3
+
4
+ Parses command-line arguments, orchestrates the discovery and compilation
5
+ of assets, and writes the final JSON files and copied assets to the output directory.
6
+ """
7
+
8
+ import argparse
9
+ import json
10
+ import logging
11
+ import shutil
12
+ import time
13
+ from importlib.metadata import PackageNotFoundError, version
14
+ from pathlib import Path
15
+
16
+ try:
17
+ __version__ = version("svpbuild")
18
+ except PackageNotFoundError:
19
+ __version__ = "unknown"
20
+
21
+ from svpbuild.loader.loader import Loader
22
+
23
+
24
+ def main():
25
+ """Main execution loop for the svpbuild CLI."""
26
+ logging.Formatter.converter = time.gmtime
27
+ logging.basicConfig(
28
+ format="%(asctime)s [%(levelname)s] %(message)s",
29
+ datefmt="%Y-%m-%dT%H:%M:%SZ",
30
+ )
31
+ logger = logging.getLogger()
32
+
33
+ parser = argparse.ArgumentParser(description="Build Content Patcher portrait mods.")
34
+ parser.add_argument(
35
+ "--source",
36
+ help="Path to the mod source directory containing assets/ and manifest.json",
37
+ type=str,
38
+ default=".",
39
+ )
40
+ parser.add_argument(
41
+ "--out",
42
+ help="Path to the output directory",
43
+ type=str,
44
+ default="build/out",
45
+ )
46
+ parser.add_argument(
47
+ "-v",
48
+ "--verbose",
49
+ help="Enable verbose (DEBUG) logging",
50
+ action="store_true",
51
+ )
52
+ parser.add_argument(
53
+ "-V",
54
+ "--version",
55
+ action="version",
56
+ version=f"%(prog)s {__version__}",
57
+ )
58
+
59
+ args = parser.parse_args()
60
+
61
+ log_level = logging.DEBUG if args.verbose else logging.INFO
62
+ logger.setLevel(log_level)
63
+
64
+ source_dir = Path(args.source).resolve()
65
+ output_dir = Path(args.out).resolve()
66
+
67
+ logger.debug(f"Source directory resolved to: {source_dir}")
68
+
69
+ if not source_dir.exists() or not source_dir.is_dir():
70
+ logger.error(f"Source directory does not exist or is not a directory: {source_dir}")
71
+ return
72
+
73
+ try:
74
+ loader = Loader(source_dir)
75
+ except Exception as e:
76
+ logger.error(f"Failed to load assets: {e}")
77
+ return
78
+
79
+ logger.info("Building content pack...")
80
+
81
+ manifest_json = loader.manifest
82
+ config_json = loader.build_config()
83
+ content_json = loader.build_content()
84
+
85
+ mod_id = manifest_json["UniqueID"]
86
+ mod_version = manifest_json["Version"]
87
+
88
+ mod_out_dir = output_dir.joinpath(f"{mod_id}-{mod_version}")
89
+ logger.debug(f"Output directory resolved to: {mod_out_dir}")
90
+ mod_out_dir.mkdir(parents=True, exist_ok=True)
91
+
92
+ assets_src = source_dir.joinpath("assets")
93
+ if assets_src.exists() and assets_src.is_dir():
94
+ logger.debug("Copying assets directory...")
95
+ shutil.copytree(
96
+ src=assets_src,
97
+ dst=mod_out_dir.joinpath("assets"),
98
+ dirs_exist_ok=True,
99
+ )
100
+
101
+ logger.debug("Writing JSON configuration files...")
102
+ mod_out_dir.joinpath("manifest.json").write_text(json.dumps(manifest_json, indent=4))
103
+ mod_out_dir.joinpath("content.json").write_text(json.dumps(content_json, indent=4))
104
+ mod_out_dir.joinpath("config.json").write_text(json.dumps(config_json, indent=4))
105
+
106
+ # Consumed by GitHub Actions or similar scripts
107
+ output_dir.joinpath("LATEST").write_text(mod_version)
108
+
109
+ logger.info(f"Successfully built mod in {mod_out_dir}")
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
@@ -0,0 +1,112 @@
1
+ """
2
+ Defines the JSON schema structures used by SMAPI and Content Patcher.
3
+
4
+ These TypedDicts ensure type safety when generating the final manifest.json
5
+ and content.json files for Stardew Valley mods.
6
+
7
+ References:
8
+ - SMAPI Manifest: https://smapi.io/schemas/manifest.json
9
+ - Content Patcher: https://smapi.io/schemas/content-patcher.json
10
+ """
11
+
12
+ from typing import NotRequired, TypedDict
13
+
14
+ CP_FORMAT_VERSION = "2.9.0"
15
+
16
+
17
+ class SMAPIDependency(TypedDict):
18
+ """Represents a single mod dependency in a SMAPI manifest.json."""
19
+
20
+ UniqueID: str
21
+ MinimumVersion: NotRequired[str]
22
+ IsRequired: NotRequired[bool]
23
+
24
+
25
+ class SMAPIContentPackFor(TypedDict):
26
+ """Specifies the mod which can read this content pack."""
27
+
28
+ UniqueID: str
29
+ MinimumVersion: NotRequired[str]
30
+
31
+
32
+ class SMAPIManifestBase(TypedDict):
33
+ pass
34
+
35
+
36
+ SMAPIManifestSchema = TypedDict("SMAPIManifestSchema", {"$schema": NotRequired[str]})
37
+
38
+
39
+ class SMAPIManifest(SMAPIManifestSchema):
40
+ """Represents the complete structure of a SMAPI manifest.json file."""
41
+
42
+ Name: str
43
+ Author: str
44
+ Version: str
45
+ Description: str
46
+ UniqueID: str
47
+ EntryDll: NotRequired[str]
48
+ ContentPackFor: NotRequired[SMAPIContentPackFor]
49
+ MinimumApiVersion: NotRequired[str]
50
+ MinimumGameVersion: NotRequired[str]
51
+ Dependencies: NotRequired[list[SMAPIDependency]]
52
+ UpdateKeys: NotRequired[list[str]]
53
+
54
+
55
+ class CPContentConfigSchemaEntry(TypedDict):
56
+ """Represents a single configuration option in a Content Patcher content.json ConfigSchema."""
57
+
58
+ AllowValues: NotRequired[str]
59
+ AllowBlank: NotRequired[bool]
60
+ AllowMultiple: NotRequired[bool]
61
+ Default: NotRequired[str | bool | int | float]
62
+ Description: NotRequired[str]
63
+ Section: NotRequired[str]
64
+
65
+
66
+ class CPContentRectangle(TypedDict):
67
+ """Represents a spatial area definition for image or map patching."""
68
+
69
+ X: int | str
70
+ Y: int | str
71
+ Width: int | str
72
+ Height: int | str
73
+
74
+
75
+ class CPContentChange(TypedDict):
76
+ """Represents a single dynamic action/patch in a Content Patcher content.json Changes list."""
77
+
78
+ Action: str
79
+ Target: NotRequired[str]
80
+ TargetLocale: NotRequired[str]
81
+ LogName: NotRequired[str]
82
+ Update: NotRequired[str]
83
+ LocalTokens: NotRequired[dict[str, str | int | bool | float]]
84
+ FromFile: NotRequired[str]
85
+ FromArea: NotRequired[CPContentRectangle]
86
+ ToArea: NotRequired[CPContentRectangle]
87
+ PatchMode: NotRequired[str]
88
+ Priority: NotRequired[str]
89
+ TargetField: NotRequired[list[str]]
90
+ Fields: NotRequired[dict[str, dict]]
91
+ Entries: NotRequired[dict[str, str | int | bool | float | None] | None]
92
+ MoveEntries: NotRequired[list[dict]]
93
+ AddWarps: NotRequired[list[str]]
94
+ AddNpcWarps: NotRequired[list[str]]
95
+ MapProperties: NotRequired[dict[str, str | None]]
96
+ MapTiles: NotRequired[list[dict]]
97
+ TextOperations: NotRequired[list[dict]]
98
+ When: NotRequired[dict[str, str | int | bool]]
99
+
100
+
101
+ CPContentSpecSchema = TypedDict("CPContentSpecSchema", {"$schema": NotRequired[str]})
102
+
103
+
104
+ class CPContentSpec(CPContentSpecSchema):
105
+ """Represents the complete structure of a Content Patcher content.json file."""
106
+
107
+ Format: str
108
+ Changes: list[CPContentChange]
109
+ ConfigSchema: NotRequired[dict[str, CPContentConfigSchemaEntry]]
110
+ CustomLocations: NotRequired[list[dict]]
111
+ DynamicTokens: NotRequired[list[dict]]
112
+ AliasTokenNames: NotRequired[dict[str, str]]
File without changes
@@ -0,0 +1,8 @@
1
+ import pytest
2
+ from pathlib import Path
3
+
4
+
5
+ @pytest.fixture
6
+ def sample_pack_dir() -> Path:
7
+ """Returns the absolute path to the sample portrait content pack."""
8
+ return Path(__file__).parent / "data" / "sample_pack"
@@ -0,0 +1,7 @@
1
+ {
2
+ "Name": "Sample Portraits",
3
+ "Author": "Test",
4
+ "Version": "1.0.0",
5
+ "Description": "A test sample portrait content pack",
6
+ "UniqueID": "Test.SamplePortraits"
7
+ }
@@ -0,0 +1,76 @@
1
+ from svpbuild.loader.loader import Loader
2
+
3
+
4
+ def test_loader_discovery(sample_pack_dir):
5
+ loader = Loader(sample_pack_dir)
6
+
7
+ # Verify manifest loaded
8
+ assert loader.manifest["UniqueID"] == "Test.SamplePortraits"
9
+
10
+ # Verify character loaded
11
+ assert "Abigail" in loader.characters.get_names()
12
+ assert "Sebastian" in loader.characters.get_names()
13
+
14
+ # Check Abigail's variants
15
+ abigail = loader.characters.get_character("Abigail")
16
+ assert "Standard" in abigail.variants
17
+ assert "Beach Party Outfit" in abigail.variants
18
+
19
+ # Find specific portraits to verify parsing
20
+ beach_summer = next((p for p in abigail.portraits if p.variant == "Beach Party Outfit" and p.conditions.get("Season") == "Summer"), None)
21
+ assert beach_summer is not None
22
+ assert beach_summer.path == "Abigail/Abigail-Beach_Party-Outfit-Summer.png"
23
+
24
+ spring_standard = next((p for p in abigail.portraits if p.variant == "Standard" and p.conditions.get("Season") == "Spring"), None)
25
+ assert spring_standard is not None
26
+ assert spring_standard.path == "Abigail/Abigail-Spring.png"
27
+
28
+ outdoors_standard = next((p for p in abigail.portraits if p.variant == "Standard" and p.conditions.get("IsOutdoors") is True), None)
29
+ assert outdoors_standard is not None
30
+ assert outdoors_standard.path == "Abigail/Abigail-Outdoor.png"
31
+
32
+ indoors_standard = next((p for p in abigail.portraits if p.variant == "Standard" and p.conditions.get("IsOutdoors") is False), None)
33
+ assert indoors_standard is not None
34
+ assert indoors_standard.path == "Abigail/Abigail-Indoor.png"
35
+
36
+ # Check Sebastian's complex parsing fallback
37
+ sebastian = loader.characters.get_character("Sebastian")
38
+ assert "Goth" in sebastian.variants
39
+
40
+ goth_saloon = next((p for p in sebastian.portraits if p.variant == "Goth"), None)
41
+ assert goth_saloon is not None
42
+ assert goth_saloon.conditions.get("LocationName") == "Saloon"
43
+ assert goth_saloon.path == "Sebastian/Sebastian-Goth-LocationName-Saloon.png"
44
+
45
+ def test_loader_build_content(sample_pack_dir):
46
+ loader = Loader(sample_pack_dir)
47
+ content = loader.build_content()
48
+
49
+ # Verify changes array exists
50
+ changes = content["Changes"]
51
+ assert len(changes) > 0
52
+
53
+ # Check that Abigail's Beach Party Outfit variant with Summer condition generated correctly
54
+ abigail_beach_summer_patch = next(
55
+ (c for c in changes if c.get("Target") == "Portraits/Abigail" and c.get("When", {}).get("Season") == "Summer"),
56
+ None
57
+ )
58
+
59
+ assert abigail_beach_summer_patch is not None
60
+ assert abigail_beach_summer_patch["Action"] == "EditImage"
61
+ assert abigail_beach_summer_patch.get("FromFile") == "assets/Abigail/Abigail-Beach_Party-Outfit-Summer.png"
62
+ # Ensure BOTH conditions are present in the 'When' block
63
+ assert abigail_beach_summer_patch.get("When", {}).get("Abigail") == "Beach Party Outfit"
64
+ assert abigail_beach_summer_patch.get("When", {}).get("Season") == "Summer"
65
+
66
+ # Check that Abigail's IsOutdoors variant generated correctly
67
+ abigail_outdoors_patch = next(
68
+ (c for c in changes if c.get("Target") == "Portraits/Abigail" and c.get("When", {}).get("IsOutdoors") is True),
69
+ None
70
+ )
71
+
72
+ assert abigail_outdoors_patch is not None
73
+ assert abigail_outdoors_patch["Action"] == "EditImage"
74
+ assert abigail_outdoors_patch.get("FromFile") == "assets/Abigail/Abigail-Outdoor.png"
75
+ assert abigail_outdoors_patch.get("When", {}).get("Abigail") == "Standard"
76
+ assert abigail_outdoors_patch.get("When", {}).get("IsOutdoors") is True
svpbuild-0.1.1/uv.lock ADDED
@@ -0,0 +1,108 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "colorama"
7
+ version = "0.4.6"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "iniconfig"
16
+ version = "2.3.0"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
19
+ wheels = [
20
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
21
+ ]
22
+
23
+ [[package]]
24
+ name = "packaging"
25
+ version = "26.2"
26
+ source = { registry = "https://pypi.org/simple" }
27
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
28
+ wheels = [
29
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
30
+ ]
31
+
32
+ [[package]]
33
+ name = "pluggy"
34
+ version = "1.6.0"
35
+ source = { registry = "https://pypi.org/simple" }
36
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
37
+ wheels = [
38
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
39
+ ]
40
+
41
+ [[package]]
42
+ name = "pygments"
43
+ version = "2.20.0"
44
+ source = { registry = "https://pypi.org/simple" }
45
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
46
+ wheels = [
47
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
48
+ ]
49
+
50
+ [[package]]
51
+ name = "pytest"
52
+ version = "9.0.3"
53
+ source = { registry = "https://pypi.org/simple" }
54
+ dependencies = [
55
+ { name = "colorama", marker = "sys_platform == 'win32'" },
56
+ { name = "iniconfig" },
57
+ { name = "packaging" },
58
+ { name = "pluggy" },
59
+ { name = "pygments" },
60
+ ]
61
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
62
+ wheels = [
63
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
64
+ ]
65
+
66
+ [[package]]
67
+ name = "ruff"
68
+ version = "0.15.12"
69
+ source = { registry = "https://pypi.org/simple" }
70
+ sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
71
+ wheels = [
72
+ { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
73
+ { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
74
+ { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
75
+ { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
76
+ { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
77
+ { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
78
+ { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
79
+ { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
80
+ { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
81
+ { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
82
+ { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
83
+ { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
84
+ { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
85
+ { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
86
+ { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
87
+ { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
88
+ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
89
+ ]
90
+
91
+ [[package]]
92
+ name = "svpbuild"
93
+ version = "0.1.0"
94
+ source = { editable = "." }
95
+
96
+ [package.dev-dependencies]
97
+ dev = [
98
+ { name = "pytest" },
99
+ { name = "ruff" },
100
+ ]
101
+
102
+ [package.metadata]
103
+
104
+ [package.metadata.requires-dev]
105
+ dev = [
106
+ { name = "pytest", specifier = ">=9.0.3" },
107
+ { name = "ruff" },
108
+ ]