diff --git a/.gitignore b/.gitignore
index 18ae9ba8..ab0fb757 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ __pycache__/
build/
develop-eggs/
dist/
+dist_js/
downloads/
eggs/
.eggs/
@@ -151,11 +152,17 @@ cython_debug/
# VSCode
.vscode/
-# SPECIFIC TO THIS PROJECT
+# Node modules
+node_modules/
+
+## SPECIFIC TO THIS PROJECT
+
# Art input folder
/art
# Render output folder
/out
+# Temporary files
+/tmp
# Temporary logging directory
/logs
# For personal testing
@@ -166,6 +173,8 @@ cython_debug/
/src/data/versions.yml
# User defined environment
/src/data/env.yml
+# User preferences
+/src/data/preferences
# Generated new release
/release
# Environmental variables
@@ -175,6 +184,7 @@ cython_debug/
!/plugins/Investigamer
!/plugins/SilvanMTG
/plugins/*/config_ini
+/plugins/*/__init__.py
# PSD templates directories
/templates/*
!/templates/tools
@@ -191,3 +201,12 @@ cython_debug/
/src/data/hexproof
# Allow the internal build tools
!src/data/build/
+# Backups
+/backup
+# Built executable
+proxyshop.exe
+# Autogenerated Qt files
+src/gui/qml/models/*.json
+src/gui/qml/models/*.cpp
+src/gui/qml/models/*.qmltypes
+src/gui/qml/models/qmldir
diff --git a/README.md b/README.md
index 4baa6def..004a81b3 100644
--- a/README.md
+++ b/README.md
@@ -3,12 +3,12 @@

Proxyshop is a Photoshop automation app that generates high-quality Magic the Gathering card renders.
Inspired by Chilli-Axe's [original Photoshop automation scripts](https://github.com/chilli-axe/mtg-photoshop-automation).
-If you need help with this app or wish to troubleshoot an issue, [please join our discord](https://discord.gg/magicproxies)!
+If you need help with this app or wish to troubleshoot an issue, [please join our discord](https://discord.gg/magic-proxies-889831317066358815)!

-
-[](https://discord.gg/magicproxies)
+
+[](https://discord.gg/magic-proxies-889831317066358815)

[](https://patreon.com/mpcfill)
[](https://github.com/MrTeferi/Proxyshop/blob/main/LICENSE)
@@ -16,9 +16,9 @@ If you need help with this app or wish to troubleshoot an issue, [please join ou
# π οΈ Requirements
-- Photoshop (2017-2024 Supported)
+- Photoshop (23.5 or newer recommended but earlier versions down to 18.0 might work)
- Windows (currently incompatible with Mac/Linux)
-- [The Photoshop templates](https://drive.google.com/drive/u/1/folders/1moEdGmpAJloW4htqhrdWZlleyIop_z1W) (Can be downloaded in the app)
+- [The Photoshop templates](https://drive.google.com/drive/u/1/folders/1moEdGmpAJloW4htqhrdWZlleyIop_z1W) (Can be downloaded in the app, which is recommended over manual download)
- Required fonts (included in `fonts/`):
- **Beleren Proxy Bold** β For Card Name, Typeline, Power/Toughness
- **Proxyglyph** β For mana symbols, a fork of Chilli's NDPMTG font
@@ -35,11 +35,14 @@ If you need help with this app or wish to troubleshoot an issue, [please join ou
# π Setup Guide
1. Download the [latest release](https://github.com/MrTeferi/MTG-Proxyshop/releases), extract it to a folder of your choice.
2. Install the fonts included in the `fonts/` folder, please note that `Proxyglyph` may need to be updated in future releases.
-3. Place card arts for cards you wish to render in the `art/` folder. These arts should be named according to the card (see [Art File Naming](#-art-file-naming) for more info).
-4. Launch `Proxyshop.exe`. Click the **Update** button. Proxyshop will load templates available to download, grab what you want.
-5. Hit **Render All** to render every card art in the `art/` folder. Hit **Render Target** to render one or more specific card arts.
-6. You can also drag art images or folders containing art images onto the Proxyshop app, Proxyshop will automatically start rendering those cards.
-7. During the render process the console at the bottom will display the current progress and prompt you if any failures occur.
+ 1. Select all the fonts
+ 2. right click
+ 3. Install
+3. Card arts should be named according to the card (see [Art File Naming](#-art-file-naming) for more info).
+4. Launch `Proxyshop.exe`. Press the **Updater** button. Proxyshop will load templates available to download, grab what you want.
+5. Press **Render** to render one or more card arts.
+6. You can also drag art images onto the Proxyshop app to start rendering.
+7. The log at the bottom will keep you updated on events happening in the app.
# π¨ Art File Naming
- Art file types currently supported are: `jpg`, `jpeg`, `jpf`, `png`, `tif`, and `webp`. **NOTE**: `webp` requires Photoshop 2022+.
@@ -49,7 +52,7 @@ If you need help with this app or wish to troubleshoot an issue, [please join ou
```
Damnation [TSR].jpg
```
- - **Collector Number** `{num}` β Only works if **Set** tag was also provided, render a version of that card with the exact **set code** and **number** combination. This is particularly useful in cases where a set has multiple versions of the same card, for example Secret Lair (SLD) has 3 different versions of **Brainstorm**.
+ - **Collector Number** `{num}` β Only works if **Set** tag was also provided. Renders a version of that card with the exact **set code** and **number** combination. This is particularly useful in cases where a set has multiple versions of the same card, for example Secret Lair (SLD) has 3 different versions of **Brainstorm**.
```
Brainstorm [SLD] {175}.jpg
```
@@ -61,59 +64,133 @@ If you need help with this app or wish to troubleshoot an issue, [please join ou
```
Brainstorm [SLD] {175}$My Creator Name.jpg
```
+- Proxyshop also has support for additional key-value pairs in the filename via `[key=value]`.
+ - Force Set-Symbol `[sym=SET]`: The `SET` part of this follows the same rules as `[SET]`, but will only affect the set-symbol.
+ - Template `[tmpl=TEMPLATE]`: Forces Proxyshop to render the card with the given template. Note that `TEMPLATE` has to match exactly the name of a template as seen in the UI _and_ the template has to support the card's layout. Otherwise Proxyshop will fall back to the selected template.
+ - Art-File `[art=FILE]`: Force to use a specific art, other than the art-file. `FILE` has to be an absolute path or relative to the art-file.
+ - Nickname: `[nick=NICKNAME]`: Use the specified nickname, only supported by select templates, e.g. Borderless.
# π» Using the Proxyshop GUI
-### Render Cards Tab
-- The main tab for rendering authentic Magic the Gathering cards.
-- **Render All**: Renders a card image using each art image found in the `art/` folder.
-- **Render Target**: Opens file select in Photoshop, renders a card image using each art image you select.
-- **Global Settings**: Opens a settings panel used to change app-wide options for:
- - **Main settings**: Affects template behavior, can be modified for individual templates. When you click the βοΈ icon next to a template, a config file is generated for **that** template which overrides these settings.
- - **System settings**: Affects the entire application and cannot be changed for individual templates.
-- The set of tabs below these buttons represent **template types**, e.g. Normal, MDFC, Transform, etc.
- - **Template types** represent different kinds of templates which require different frame elements or different rendering techniques.
- - If the **Normal** tab is active, and you click on a template button, that template becomes selected for the **Normal** template type. Cards which match the **Normal** type will now render using that template.
- - That template **DOES NOT** become selected for other types. For example, if **Borderless** is selected in the **Normal** tab, but **Normal** is selected in the **MDFC** tab. Cards that match the **MDFC** type will render using **Normal MDFC**.
-- Next to each template in the template list there are two icons:
- - βοΈ Lets you change the **Main Settings** for this template, some templates will also have their own specially designed settings you can change as well.
- - π§Ή Deletes the separate config file generated for this template, effectively returning this template back to default settings. Ensures **Main Settings** for this template are governed by the **Global Settings** panel.
-- The dark grey area below the templates selector is the **Console**, this is where status messages will be displayed tracking render progress and other user actions.
-- To the right of the **Console** are some useful buttons:
- - π Pins the Proxyshop window, so it remains above all other running programs
- - π· Takes a screenshot of the Proxyshop window, saves to: `out/screenshots/`
- - π Opens your default web browser, navigating to Proxyshop's GitHub page
- - β Opens your default web browser, navigating to our community Discord server
- - **Continue**: Becomes active when app is waiting for a user response, either when manual editing is enabled or an error has occurred.
- - **Cancel**: Becomes active when cards are being rendered, can cancel the render operation at any time or if an error occurs.
- - **Update**: Opens the **Updater** panel which allows you to download new templates and update existing ones.
-
-### Custom Creator Tab
-- This tab controls the custom card creator.
-- This feature is currently considered **experimental beta** and may have issues.
-- You can currently render **Normal**, **Planeswalker**, or **Saga** cards, just fill in the appropriate data and hit **Render Custom**.
-- More features and card types will be added in the near future.
-
-### Tools Tab
-- This tab contains a growing list of helpful tools and utilities.
-- **Render All Showcases**: Generates a bordered showcase image for each card image in the `out/` folder, showcases will be placed in `out/showcase/`.
-- **Render Target Showcase**: Opens a file select in Photoshop, generates a bordered showcase image for each card image you select.
-- **Compress Renders**: This tool reduces the size of card images stored in the `out/` folder. The settings are:
- - **Quality**: JPEG save quality of the compressed image, supports a number between 1 and 100. (**Recommended**: 95-99)
- - **Optimize**: Enables Pillow's automatic "optimize" flag. Lowers filesize by a small margin for no discernible downside. (**Recommended**: On)
- - **800 DPI**: Downscales card images above 800 DPI to a maximum of 800 DPI. Most Proxyshop templates are 1200 DPI which is much higher than anyone really needs. Most printing services do not print above 800 DPI. (**Recommended**: On)
-
-# π Setup Guide (Python Environment)
-Setting up the Python environment for Proxyshop is intended for advanced users, contributors, and anyone who wants to
-get their hands dirty making a plugin or custom template for the app! This guide assumes you already have Python installed.
-See the badge above for supported Python versions.
-1. Install Poetry with pipx.
- ```bash
- # Install pipx and poetry
- python -m pip install --user pipx
- python -m pipx ensurepath
- pipx install poetry
- ```
+### Rendering
+
+- **Render**: Opens a file picker for choosing images to render. Template used for rendering is determined based on the tab you are in and what templates you have selected. Completed renders are saved to `out/` under Proxyshop's directory. The chosen files can also be YAML (.yaml, .yml) or JSON (.json), see [Render Specifications](#render_specifications) and [Custom Cards](#custom_cards) for details.
+- **Templates tab**: Allows selecting a specific template to render cards in. If a card has a layout that isn't supporeted by the selected template it won't be rendered.
+- **Batch mode tab**: Allows selecting a different template per card layout (e.g. Normal, Transform, Planeswalker, etc.). If some layout is left without a selection cards of that type are skipped. The batch mode is similar to how the old Proxyshop GUI used to function.
+- **Queue**: Allows viewing what cards have been queued for rendering. Individual entries can be removed from the queue or the whole queue can be cleared.
+- **Cancel/Resume**: Cancel aborts rendering. The currently active operation is removed but the rest of the queue is left intact. Resume can be used to proceed with the rest of the queue after a cancel.
+
+### Render Specifications
+
+You can load a YAML file (.yaml or .yml) which contains a specification for queueing multipe cards at once. In these specifications you can add settings to cards without changing their filenames, as well as use glob-patterns to queue multiple files with the same settings. A schema for render specifications is available in the `schemas` folder.
+
+
+
+Expand for a description and usage-example of Render Specifications
+
+- Reusable configurations can be defined as follows:
+```yml
+- configs:
+ DOM: "[DOM]" # This config specifies the set DOM
+ SLD: "[sym=STAR]" # Force the STAR set-icon
+```
+
+- Cards to render can be defined as follows:
+```yml
+- files:
+ # Just a string, which is a path relative to the Render Specification
+ - "Normal/*.*" # Render all cards in the folder Normal
+ # A string, which is a path relative to the Render Specification
+ # and a string with additional settings
+ - file: "Borderless/*.*" # Render all cards in the folder Borderless ...
+ settings: "[tmpl=Borderless]" # ... using the Borderless template
+ # Settings can also be a list of strings, and can use the configs defined earlier
+ - file: "UB/*.*"
+ settings:
+ - "[tmpl=Universes Beyond]"
+ - "SLD"
+```
+In the above example, if you have a file `UB/Dungeon Descent.png` it will be rendered from that file, but as if its filename was `Dungeon Descent [tmpl=Universes Beyond] [sym=STAR].png`. Since the same is true for all files in the `UB` folder this makes it easy to render batches of files without fiddling with many filenames.
+
+- Lastly, you can also define groups, which contain a set of files (or other groups) and settings:
+```yml
+- groups:
+ - files: # List of files in this group, same syntax as bove
+ - "Normal/*.*"
+ - file: "Borderless/*.*"
+ settings: "[tmpl=Borderless]"
+ - file: "UB/*.*"
+ settings:
+ - "[tmpl=Universes Beyond]"
+ - "SLD"
+ - settings: "(My Favorite Artist)" # Render all files as being by this artist
+```
+
+
+#### Scene/Panorama Renders
+One example use-case of the _Render Specifications_ is the rendering of scenes, internally referred to as panoramas. An example of a scene are the six cards with Collector Number from _301_ to _306_ in the set _TLA_. To render a panorama of these cards from a single seamless artwork is currently not possible without _Render Specifications_. The following would render this scene:
+```yaml
+configs:
+ AVATAR:
+ - "[art=Avatar-Scene.png]"
+ - "[pano_size=3x2]"
+ - "[pano_elem=${GROUP_INDEX}]"
+
+groups:
+ files:
+ - "Combustion Technique"
+ - "Zuko, Conflicted"
+ - "Azula, Cunning Usurper"
+ - "Aang, at the Crossroads"
+ - "Katara, the Fearless"
+ - "Dai Li Agents"
+ settings: "AVATAR"
+```
+
+Note the reusable configuration _AVATAR_, which defines the size of the panorama as well as each card's position in that panorama. `${GROUP_INDEX}` will simply be replaced by the position in the list of files for each card. Lastly the art is replaced with the seamless art in the file `Avatar-Scene.png`. Once this _Render Specification_ is fully evaluated it will contain the following renders.
+```txt
+Combustion Technique [art=Avatar-Scene.png] [pano_size=3x2] [pano_elem=0]
+Zuko, Conflicted [art=Avatar-Scene.png] [pano_size=3x2] [pano_elem=1]
+Azula, Cunning Usurper [art=Avatar-Scene.png] [pano_size=3x2] [pano_elem=2]
+Aang, at the Crossroads [art=Avatar-Scene.png] [pano_size=3x2] [pano_elem=3]
+Katara, the Fearless [art=Avatar-Scene.png] [pano_size=3x2] [pano_elem=4]
+Dai Li Agents [art=Avatar-Scene.png] [pano_size=3x2] [pano_elem=5]
+```
+Proxyshop will use these to automatically position the artwork within the frame such that the resulting cards will seamlessly fit next to each other once bleed edge is trimmed.
+
+### Updater
+
+The updater window allows downloading and updating template files. The files can originate from Proxyshop or installed plugins.
+
+- **Install**: Downloads a tempalte file.
+- **Update**: Downloads an updated version of a template file. Be aware that if you have made local changes to the file that is about to be updated those changes will be lost as the old file is overwritten. Please note that Proxyshop records templates' versions only when downloading them via the updater, so if you source templates manually from elsewhere they will be assumed to have some default version and even if they don't actually have a newer version available the update option is still offered, assuming the templates can be downloaded via the updater as well.
+
+### Settings
+
+The settings window allows modifying Proxyshop's behavior. The settings can be accessed via the *Settings* button or via the template specific settings buttons within the templates list.
+
+- **Application**: App wide settings.
+- **Template defaults**: These settings are used when rendering if the used template doesn't have template specific settings.
+- **Templates**: A tree of template specific settings. If a template is given template specific settings they completely override the **Template defaults**. Template specific settings become active by simply selecting a template entry in the settings tree. Press **Clear** when some template specific settings are open to remove them from use, consequently returning to use **Template defaults** for that template.
+
+### Custom cards
+
+You may supply Proxyshop with image and JSON pairs to render cards with custom specifications. The image's card name part and the JSON file's name, excluding suffix, should be the same, e.g. *my_custom_card (artist).png* and *my_custom_card.json*. The JSON file should contain a valid [Scryfall card specification](https://scryfall.com/docs/api/cards). Look up `class ScryfallCard` from the source code if you want to know the specifics of the data validation. An easy way to make the JSON is to look up a similar card from Scryfall, download the JSON for that card via Scryfall's web GUI (*Copy-pasteable JSON* on the card's page) and modify the downloaded JSON.
+
+### Tools
+
+- **Transform images**: Allows re-encoding and downscaling chosen images. Completed transformations are saved to `compressed/` next to the input image.
+
+# π Setup Guide
+Setting up the Python & Node.js environments for Proxyshop is intended for advanced users, contributors, and anyone who wants to get their hands dirty making a plugin or custom template for the app!
+1. Install requirements:
+ - [uv](https://docs.astral.sh/uv/getting-started/installation/)
+ - Python. See `pyproject.toml` for supported versions. E.g., you may install Python with uv:
+ ```bash
+ uv python install 3.14
+ ```
+ - Node.js
+ - Fonts included in the `fonts/` folder
2. Clone Proxyshop somewhere on your system, we'll call this the ***root directory***.
```bash
git clone https://github.com/MrTeferi/Proxyshop.git
@@ -121,29 +198,71 @@ See the badge above for supported Python versions.
3. Navigate to the **root directory** and install the project environment.
```bash
cd proxyshop
- poetry install
+ uv sync
+ npm install
+ npm run build
```
-4. Install the fonts included in the `fonts/` folder. Do not delete these after install, some are used by the GUI.
-5. Create a folder called `art` in the root directory. This is where you place art images for cards you wish to render.
-6. Run the app.
+4. Run the app.
```bash
- # OPTION 1) Execute via poetry
- poetry run main.py
-
- # OPTION 2) Enter the poetry environment, then execute with cli
- poetry shell
- proxyshop gui
+ # OPTION 1) Activate the virtual environment and run the app's entrypoint with Python
+ ./.venv/Scripts/Activate
+ python main.py
+
+ # OPTION 2) Execute via uv
+ uv run main.py
```
-7. Refer to the [usage guide](#-using-the-proxyshop-gui) for navigating the GUI.
+5. Refer to the [usage guide](#-using-the-proxyshop-gui) for navigating the GUI.
+
+# π₯ Development Environment
+
+If you want to contribute to Proxyshop you should ensure that your code plays well with the strict type checking of [Pyright](https://github.com/microsoft/pyright) or [Mypy](https://github.com/python/mypy). For example, using [VS Code](https://code.visualstudio.com/) with the extensions below will allow you to see type checking results and take advantage of code completions, such as auto-imports, while writing code, though you are free to use any other setup that suits you as well:
+ - [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python)
+ - [Python Debugger](https://marketplace.visualstudio.com/items?itemName=ms-python.debugpy)
+ - [Python Environments](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-python-envs)
+ - [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance)
+
+Additionally if you want to do UI development the extensions below will help with that. These aren't needed for template plugin development.
+- [Qt Core](https://marketplace.visualstudio.com/items?itemName=TheQtCompany.qt-core)
+- [Qt Python](https://marketplace.visualstudio.com/items?itemName=TheQtCompany.qt-python)
+- [Qt Qml](https://marketplace.visualstudio.com/items?itemName=TheQtCompany.qt-qml)
+
+After installing the Qt extensions add your absolute path to `./src/gui` to the `qt-qml.qmlls.additionalImportPaths` setting in VS Code. Without it qmllint won't recognize local Qml imports. At the time of writing the qmllint, provided by the Qt Qml extension, doesn't recognize context defined in Python so warnings within the Qml files are expected.
+
+# π¬ Tests
+
+## Template renders
+
+Predefined template test render cases may be run via the _Tests_ menu in the GUI. The currently chosen template or batch mode configuration determines which templates are tested when choosing to run template specific tests. The _Quick_ tests queue only the first test case for each layout.
+
+## Automatic tests
+
+Automatic tests are defined under the `/tests` directory. All of them can be run with the command:
+```bash
+pytest ./tests
+```
# πΎ Download Templates Manually
If you wish to download the templates manually, visit [this link](https://drive.google.com/drive/u/1/folders/1sgJ3Xu4FabxNgDl0yeI7OjDZ7fqlI4p3). These archives must be extracted to the `/templates`
directory. The archives found within the **Investigamer** and **SilvanMTG** drive folders must be extracted to
`/plugins/Investigamer/templates` and `/plugins/SilvanMTG/templates` respectively.
+# π© Build the App
+
+If you want to make a distributable executable of Proxyshop run the following command:
+```bash
+pyinstaller -n Proxyshop --onefile --icon "./src/img/favicon.ico" --distpath "./dist" --console --add-data "src/gui/qml:src/gui/qml" --add-data "src/img/favicon.ico:src/img/favicon.ico" main.py
+```
+
+Additionally the following directories and their contents should be distributed alongside the executable:
+- /dist_js
+- /fonts
+- /plugins
+- /templates
+- /src/data
+- /src/img
# π How can I support Proxyshop?
-Feel free to [join our discord](http://discord.gg/magicproxies) and participate in the `#Proxyshop` channel where we are constantly brainstorming and
+Feel free to [join our discord](https://discord.gg/magic-proxies-889831317066358815) and participate in the `#Proxyshop` channel where we are constantly brainstorming and
testing new features, dropping beta releases, and sharing new plugins and templates. Also, please consider supporting
[our Patreon](http://patreon.com/mpcfill) which pays for S3 + Cloudfront hosting of Proxyshop templates and allows us the freedom to work on the app,
as well as other applications like MPC Autofill, MTG Art Downloader, and more! If Patreon isn't your thing, you can also buy
@@ -187,8 +306,8 @@ may cause errors on some templates.
Where is a good place to find high quality MTG art?
-Your best resource is going to be [MTG Pics](https://mtgpics.com), to improve art quality even more you can look into upscaling with Topaz/Chainner/ESRGAN.
-On our [discord](https://discord.gg/magicproxies) we provide a lot of resources for learning how to upscale art easily and effectively.
+Your best resource is going to be [MTG Pics](https://mtgpics.com), to improve art quality even more you can look into upscaling with Topaz/Chainner/Upscayl/ComfyUI.
+On our [discord](https://discord.gg/magic-proxies-889831317066358815) we provide a lot of resources for learning how to upscale art easily and effectively.
For mass downloading art, view my other project: [MTG Art Downloader](https://github.com/MrTeferi/MTG-Art-Downloader)
@@ -270,6 +389,7 @@ In your proxyshop directory, look for a folder named `logs`, inside that folder
- iDerp
- TupinambΓ‘ (Pedro Neves)
- Andrew Gioia for his various font projects which have been of use for Proxyshop in the past.
+- AmProsius for additional Proxyglyph adjustments.
- John Prime, Haven King, and members of [CCGHQ](https://www.slightlymagic.net/forum/viewtopic.php?f=15&t=7010) for providing expansion symbol SVG's.
- Hal and the other contributors over at [Photoshop Python API](https://github.com/loonghao/photoshop-python-api).
- Wizards of the Coast and all the talented artists who make Magic the Gathering a reality.
diff --git a/fonts/Proxyglyph.ttf b/fonts/Proxyglyph.ttf
index ef951dcb..31accec4 100644
Binary files a/fonts/Proxyglyph.ttf and b/fonts/Proxyglyph.ttf differ
diff --git a/main.py b/main.py
index 2c7f01fc..20aa72f2 100644
--- a/main.py
+++ b/main.py
@@ -1,68 +1,133 @@
"""
* Proxyshop Application Launcher
"""
-# Standard Library Imports
-import os
-import sys
-"""
-* Launcher Funcs
-"""
+from pathlib import Path
+from PySide6.QtGui import QIcon
-def launch_cli():
- """Launch the app in CLI mode."""
+from src import APP, CFG, CON, ENV
+from src._loader import (
+ AppPlugin,
+ TemplateLibrary,
+ get_all_plugins,
+ get_template_file_versions,
+)
+from src.startup import run_startup_checks
- # Enable headless mode, remove cli marker
- os.environ['PROXYSHOP_HEADLESS'] = '1'
- if 'cli' in sys.argv:
- sys.argv.remove('cli')
- # Local Imports
- from src.commands import ProxyshopCLI
+def launch_gui(template_library: TemplateLibrary, plugins: dict[str, AppPlugin]):
+ """Launch the app in GUI mode."""
- # Run the CLI application
- ProxyshopCLI.main()
+ from PySide6 import QtAsyncio
+ from PySide6.QtGui import QGuiApplication
+ from PySide6.QtQml import QQmlApplicationEngine
+ from PySide6.QtQuickControls2 import QQuickStyle
+ from src.gui.qml.models.batch_rendering_model import BatchRenderingModel
+ from src.gui.qml.models.console_model import ConsoleModel
+ from src.gui.qml.models.file_dialog_model import FileDialogModel
+ from src.gui.qml.models.file_path_model import FilePathModel
+ from src.gui.qml.models.image_transform_model import ImageTransformModel
+ from src.gui.qml.models.message_dialog_content_model import (
+ MessageDialogContentModel,
+ )
+ from src.gui.qml.models.render_operations_model import RenderOperationsModel
+ from src.gui.qml.models.settings_model import SettingsModel
+ from src.gui.qml.models.settings_tree_model import SettingsTreeModel
+ from src.gui.qml.models.template_list_model import TemplateListModel
+ from src.gui.qml.models.template_updater_model import TemplateUpdaterModel
+ from src.gui.qml.models.test_renders_model import TestRendersModel
+ from src.render.render_queue import RenderQueue
-def launch_gui():
- """Launch the app in GUI mode."""
+ app = QGuiApplication(applicationDisplayName="Proxyshop")
+ app.setWindowIcon(QIcon("src/img/favicon.ico"))
+ engine = QQmlApplicationEngine()
- # Local Imports
- os.environ['PROXYSHOP_HEADLESS'] = '0'
- from src import (
- APP, CFG, CON, CONSOLE, ENV,
- PLUGINS, TEMPLATES, TEMPLATE_MAP, TEMPLATE_DEFAULTS)
- from src.gui._state import register_kv_classes, load_kv_config
- from src.gui.app import ProxyshopGUIApp
+ file_path_model = FilePathModel()
+ render_queue = RenderQueue()
+ file_dialog_model = FileDialogModel()
+ console_model = ConsoleModel()
+ render_message_dialog_content_model = MessageDialogContentModel()
+ render_operations_model = RenderOperationsModel(
+ render_queue, render_message_dialog_content_model
+ )
+ template_updater_model = TemplateUpdaterModel(
+ app_env=ENV, template_library=template_library
+ )
+ test_renders_model = TestRendersModel(
+ render_queue,
+ template_library,
+ file_dialog_model,
+ render_message_dialog_content_model,
+ CFG,
+ )
+ template_list_model = TemplateListModel(
+ render_queue,
+ file_dialog_model,
+ render_message_dialog_content_model,
+ template_library,
+ test_renders_model,
+ CFG,
+ )
+ batch_render_model = BatchRenderingModel(
+ file_dialog_model,
+ render_message_dialog_content_model,
+ render_queue,
+ plugins,
+ template_library,
+ test_renders_model,
+ CFG,
+ )
+ settings_tree_model = SettingsTreeModel(
+ app_config=CFG, template_library=template_library
+ )
+ settings_model = SettingsModel(settings_tree_model)
+ image_transform_model = ImageTransformModel(file_dialog_model=file_dialog_model)
- # Kivy Imported Last
- from kivy.resources import resource_add_path
+ root_context = engine.rootContext()
+ root_context.setContextProperty("filePathModel", file_path_model)
+ root_context.setContextProperty("fileDialogModel", file_dialog_model)
+ root_context.setContextProperty("consoleModel", console_model)
+ root_context.setContextProperty(
+ "renderMessageDialogContentModel", render_message_dialog_content_model
+ )
+ root_context.setContextProperty("renderOperationsModel", render_operations_model)
+ root_context.setContextProperty("testRendersModel", test_renders_model)
+ root_context.setContextProperty("templateUpdaterModel", template_updater_model)
+ root_context.setContextProperty("templateListModel", template_list_model)
+ root_context.setContextProperty("batchRenderModel", batch_render_model)
+ root_context.setContextProperty("settingsTreeModel", settings_tree_model)
+ root_context.setContextProperty("settingsModel", settings_model)
+ root_context.setContextProperty("imageTransformModel", image_transform_model)
+ root_context.setContextProperty(
+ "github_url", f"https://github.com/{ENV.APP_UPDATES_REPO}"
+ )
- # Kivy packaging for PyInstaller
- if hasattr(sys, '_MEIPASS'):
- resource_add_path(os.path.join(sys._MEIPASS))
+ # This points to the extracted bundle directory in a distributable build
+ engine.addImportPath(Path(__file__).parent / "src" / "gui")
+ QQuickStyle.setStyle("Fusion")
+ QQuickStyle.setFallbackStyle("Basic")
+ engine.loadFromModule("qml", "App")
- # Load KV files and utilities
- load_kv_config()
- register_kv_classes()
+ # Ensure that the root context is available at QML destruction
+ app.aboutToQuit.connect(engine.deleteLater)
- # Run the GUI application
- ProxyshopGUIApp(
- app=APP,
+ run_startup_checks(APP)
+
+ QtAsyncio.run(handle_sigint=True)
+
+
+if __name__ == "__main__":
+ initial_versions = get_template_file_versions()
+ versions = initial_versions.model_copy(deep=True)
+ plugins = get_all_plugins(con=CON, env=ENV, template_file_versions=versions.root)
+ template_library = TemplateLibrary(
con=CON,
- cfg=CFG,
env=ENV,
- console=CONSOLE,
- plugins=PLUGINS,
- templates=TEMPLATES,
- template_map=TEMPLATE_MAP,
- templates_default=TEMPLATE_DEFAULTS,
- ).run()
-
-
-if __name__ == '__main__':
- """Route to a qualified launcher."""
- if 'cli' in sys.argv:
- sys.exit(launch_cli())
- launch_gui()
+ plugins=plugins,
+ initial_template_file_versions=initial_versions,
+ template_file_versions=versions,
+ )
+
+ launch_gui(template_library, plugins)
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 00000000..dee678ff
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,102 @@
+{
+ "name": "MTG-Proxyshop",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "devDependencies": {
+ "@types/node": "^25.9.1",
+ "@types/photoshop": "^25.0.4",
+ "prettier": "^3.8.3",
+ "pyright": "^1.1.409",
+ "typescript": "^6.0.3"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "25.9.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
+ "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/@types/photoshop": {
+ "version": "25.0.4",
+ "resolved": "https://registry.npmjs.org/@types/photoshop/-/photoshop-25.0.4.tgz",
+ "integrity": "sha512-HfsVvgCrkOhGYlEmtsqT3pvLHFSoa1OXIHr37GsWy1y9FQJw/vb7xcDBGMoYuJemnmWlDMqdKgnnYUZL6kF5HA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.8.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
+ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/pyright": {
+ "version": "1.1.409",
+ "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.409.tgz",
+ "integrity": "sha512-13VFQyw4mJzshZxcxiYbNjo1hG/WHSRDj70Y3lbJEHqCkI2dvBAUTti8VV6Ezsr5gT93pFvC0e/jAQS4JdHarA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "pyright": "index.js",
+ "pyright-langserver": "langserver.index.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..f9d389e6
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+ "type": "module",
+ "scripts": {
+ "build": "tsc && node --experimental-transform-types scripts_js/build.ts",
+ "type-check": "pyright ./plugins/** ./src/** ./tests/** ./scripts/** && tsc --noEmit && npx tsc --project tsconfig.tools.json --noEmit"
+ },
+ "devDependencies": {
+ "@types/node": "^25.9.1",
+ "@types/photoshop": "^25.0.4",
+ "prettier": "^3.8.3",
+ "pyright": "^1.1.409",
+ "typescript": "^6.0.3"
+ }
+}
diff --git a/plugins/Investigamer/manifest.yml b/plugins/Investigamer/manifest.yml
index 8a78b38f..5b97199c 100644
--- a/plugins/Investigamer/manifest.yml
+++ b/plugins/Investigamer/manifest.yml
@@ -3,54 +3,69 @@
###
PLUGIN:
- name: 'Investigamer'
- author: 'Investigamer'
+ name: "Investigamer"
+ author: "Investigamer"
desc: "Investigamer's official Proxyshop plugin."
- license: 'MPL-2.0'
- requires: '^1.13.0'
- version: '1.0.0'
+ license: "MPL-2.0"
+ requires: "^1.13.0"
+ version: "1.0.0"
###
# * Templates
###
sketch.psd:
- name: 'Sketch Showcase'
- id: '1uFfDl5VUYiu77ct6rsDPBRJK0-9ap8Zb'
- desc: 'Based on the Sketch showcase frame introduced in Modern Horizons.'
- templates: { Sketch: SketchTemplate }
+ name: "Sketch Showcase"
+ id: "1uFfDl5VUYiu77ct6rsDPBRJK0-9ap8Zb"
+ desc: "Based on the Sketch showcase frame introduced in Modern Horizons."
+ templates:
+ Sketch:
+ SketchTemplate:
+ - normal
kaldheim.psd:
- name: 'Kaldheim Showcase'
- id: '16XtBeZT_teePRDvythLu8JvHKjSND5TW'
- desc: 'Based on the Legendary Viking showcase frame introduced in Kaldheim.'
- templates: { Kaldheim: KaldheimTemplate }
+ name: "Kaldheim Showcase"
+ id: "16XtBeZT_teePRDvythLu8JvHKjSND5TW"
+ desc: "Based on the Legendary Viking showcase frame introduced in Kaldheim."
+ templates:
+ Kaldheim:
+ KaldheimTemplate:
+ - normal
phyrexian.psd:
- name: 'Phyrexian Showcase'
- id: '1mEb4BXZI1v0dqwGOwgmrHFOaytVoJlkQ'
- desc: 'Based on the Phyrexian showcase Secret Lair.'
- templates: { Phyrexian: PhyrexianTemplate }
+ name: "Phyrexian Showcase"
+ id: "1mEb4BXZI1v0dqwGOwgmrHFOaytVoJlkQ"
+ desc: "Based on the Phyrexian showcase Secret Lair."
+ templates:
+ Phyrexian:
+ PhyrexianTemplate:
+ - normal
colorshifted.psd:
- name: 'Colorshifted'
- id: '14-DkMK2PEoU4aNHWPxsoau_t_xAKhgb7'
- desc: 'Based on the Colorshifted frame introduced in Planar Chaos.'
- templates: { Colorshifted: ColorshiftedTemplate }
+ name: "Colorshifted"
+ id: "14-DkMK2PEoU4aNHWPxsoau_t_xAKhgb7"
+ desc: "Based on the Colorshifted frame introduced in Planar Chaos."
+ templates:
+ Colorshifted:
+ ColorshiftedTemplate:
+ - normal
double-feature.psd:
- name: 'Innistrad: Double Feature Showcase'
- id: '1D88pzyudnGnUglWL7H-K5AlWjLPcaHfE'
+ name: "Innistrad: Double Feature Showcase"
+ id: "1D88pzyudnGnUglWL7H-K5AlWjLPcaHfE"
desc: 'Based on the showcase frame used for the "Double Feature" promo set.'
- templates: { Double Feature: DoubleFeatureTemplate }
+ templates:
+ Double Feature:
+ DoubleFeatureTemplate:
+ - normal
crimson-fang.psd:
- name: 'Crimson Vow Showcase'
- id: '1scPZCVzKezjrjfvGso3IejJgFBYDMvfn'
- desc: 'Based on the Crimson Fang showcase frame introduced in Crimson Vow.'
+ name: "Crimson Vow Showcase"
+ id: "1scPZCVzKezjrjfvGso3IejJgFBYDMvfn"
+ desc: "Based on the Crimson Fang showcase frame introduced in Crimson Vow."
templates:
Crimson Fang:
CrimsonFangTemplate:
- - transform_front
- - transform_back
- - normal
+ - transform_front
+ - transform_back
+ - normal
diff --git a/plugins/Investigamer/py/__init__.py b/plugins/Investigamer/py/__init__.py
index 9aeefdd6..7568aa99 100644
--- a/plugins/Investigamer/py/__init__.py
+++ b/plugins/Investigamer/py/__init__.py
@@ -4,4 +4,5 @@
Notes:
Import * from each py file in this directory.
"""
+
from .templates import *
diff --git a/plugins/Investigamer/py/actions/pencilsketch.py b/plugins/Investigamer/py/actions/pencilsketch.py
index e2ad5e03..e28aa783 100644
--- a/plugins/Investigamer/py/actions/pencilsketch.py
+++ b/plugins/Investigamer/py/actions/pencilsketch.py
@@ -1,19 +1,16 @@
"""
Pencil Sketchify Action Module
"""
-# Standard Library Imports
-from threading import Event
-from typing import Union
-# Third Party Imports
+from collections.abc import Iterable
+
import photoshop.api as ps
+from photoshop.api.enumerations import DialogModes
-# Local Imports
-from src import APP, CONSOLE
+from src import APP
+from src.render.setup import RenderOperation
-# QOL Definitions
-sID, cID = APP.stringIDToTypeID, APP.charIDToTypeID
-dialog_mode = ps.DialogModes.DisplayNoDialogs
+dialog_mode = DialogModes.DisplayNoDialogs
"""
* Action Funcs
@@ -21,139 +18,152 @@
def new_layer(index: int):
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putClass(cID('Lyr '))
- desc1.putReference(cID('null'), ref1)
- desc1.putInteger(cID('LyrI'), index)
- APP.executeAction(cID('Mk '), desc1, dialog_mode)
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putClass(APP.instance.cID("Lyr "))
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc1.putInteger(APP.instance.cID("LyrI"), index)
+ APP.instance.executeAction(APP.instance.cID("Mk "), desc1, dialog_mode)
def select_bg():
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putName(cID('Lyr '), "Background")
- desc1.putReference(cID('null'), ref1)
- desc1.putBoolean(cID('MkVs'), False)
- list1 = ps.ActionList()
- list1.putInteger(1)
- desc1.putList(cID('LyrI'), list1)
- APP.executeAction(cID('slct'), desc1, dialog_mode)
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putName(APP.instance.cID("Lyr "), "Background")
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc1.putBoolean(APP.instance.cID("MkVs"), False)
+ list1 = ps.ActionList()
+ list1.putInteger(1)
+ desc1.putList(APP.instance.cID("LyrI"), list1)
+ APP.instance.executeAction(APP.instance.cID("slct"), desc1, dialog_mode)
def reset_colors():
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putProperty(cID('Clr '), cID('Clrs'))
- desc1.putReference(cID('null'), ref1)
- APP.executeAction(cID('Rset'), desc1, dialog_mode)
-
-
-def move_layer(pos, index: Union[int, list[int]]):
- if isinstance(index, int):
- index = [index]
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- ref2 = ps.ActionReference()
- ref2.putIndex(cID('Lyr '), pos)
- desc1.putReference(cID('T '), ref2)
- desc1.putBoolean(cID('Adjs'), False)
- desc1.putInteger(cID('Vrsn'), 5)
- list1 = ps.ActionList()
- for i in index:
- list1.putInteger(i)
- desc1.putList(cID('LyrI'), list1)
- APP.executeAction(cID('move'), desc1, dialog_mode)
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putProperty(APP.instance.cID("Clr "), APP.instance.cID("Clrs"))
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ APP.instance.executeAction(APP.instance.cID("Rset"), desc1, dialog_mode)
+
+
+def move_layer(pos: int, index: int | list[int]):
+ if isinstance(index, int):
+ index = [index]
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ ref2 = ps.ActionReference()
+ ref2.putIndex(APP.instance.cID("Lyr "), pos)
+ desc1.putReference(APP.instance.cID("T "), ref2)
+ desc1.putBoolean(APP.instance.cID("Adjs"), False)
+ desc1.putInteger(APP.instance.cID("Vrsn"), 5)
+ list1 = ps.ActionList()
+ for i in index:
+ list1.putInteger(i)
+ desc1.putList(APP.instance.cID("LyrI"), list1)
+ APP.instance.executeAction(APP.instance.cID("move"), desc1, dialog_mode)
def set_opacity(opacity: float):
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ps.ActionDescriptor()
- desc2.putUnitDouble(cID('Opct'), cID('#Prc'), opacity)
- desc1.putObject(cID('T '), cID('Lyr '), desc2)
- APP.executeAction(cID('setd'), desc1, dialog_mode)
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ps.ActionDescriptor()
+ desc2.putUnitDouble(APP.instance.cID("Opct"), APP.instance.cID("#Prc"), opacity)
+ desc1.putObject(APP.instance.cID("T "), APP.instance.cID("Lyr "), desc2)
+ APP.instance.executeAction(APP.instance.cID("setd"), desc1, dialog_mode)
def select_layer(name: str, index: int):
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putName(cID('Lyr '), name)
- desc1.putReference(cID('null'), ref1)
- desc1.putBoolean(cID('MkVs'), False)
- list1 = ps.ActionList()
- list1.putInteger(index)
- desc1.putList(cID('LyrI'), list1)
- APP.executeAction(cID('slct'), desc1, dialog_mode)
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putName(APP.instance.cID("Lyr "), name)
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc1.putBoolean(APP.instance.cID("MkVs"), False)
+ list1 = ps.ActionList()
+ list1.putInteger(index)
+ desc1.putList(APP.instance.cID("LyrI"), list1)
+ APP.instance.executeAction(APP.instance.cID("slct"), desc1, dialog_mode)
def select_layers(name: str, layers: list[int]):
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putName(cID('Lyr '), name)
- desc1.putReference(cID('null'), ref1)
- desc1.putEnumerated(
- sID("selectionModifier"), sID("selectionModifierType"), sID("addToSelection"))
- desc1.putBoolean(cID('MkVs'), False)
- list1 = ps.ActionList()
- for layer in layers:
- list1.putInteger(layer)
- desc1.putList(cID('LyrI'), list1)
- APP.executeAction(cID('slct'), desc1, dialog_mode)
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putName(APP.instance.cID("Lyr "), name)
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc1.putEnumerated(
+ APP.instance.sID("selectionModifier"),
+ APP.instance.sID("selectionModifierType"),
+ APP.instance.sID("addToSelection"),
+ )
+ desc1.putBoolean(APP.instance.cID("MkVs"), False)
+ list1 = ps.ActionList()
+ for layer in layers:
+ list1.putInteger(layer)
+ desc1.putList(APP.instance.cID("LyrI"), list1)
+ APP.instance.executeAction(APP.instance.cID("slct"), desc1, dialog_mode)
def auto_tone():
- desc1 = ps.ActionDescriptor()
- desc1.putBoolean(cID('Auto'), True)
- APP.executeAction(cID('Lvls'), desc1, dialog_mode)
+ desc1 = ps.ActionDescriptor()
+ desc1.putBoolean(APP.instance.cID("Auto"), True)
+ APP.instance.executeAction(APP.instance.cID("Lvls"), desc1, dialog_mode)
def auto_contrast():
- desc1 = ps.ActionDescriptor()
- desc1.putBoolean(cID('AuCo'), True)
- APP.executeAction(cID('Lvls'), desc1, dialog_mode)
-
-
-def hide_layer(name=None):
- desc1 = ps.ActionDescriptor()
- list1 = ps.ActionList()
- ref1 = ps.ActionReference()
- if name:
- ref1.putName(cID('Lyr '), name)
- else:
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- list1.putReference(ref1)
- desc1.putList(cID('null'), list1)
- APP.executeAction(cID('Hd '), desc1, dialog_mode)
-
-
-def show_layer(name=None):
- desc1 = ps.ActionDescriptor()
- list1 = ps.ActionList()
- ref1 = ps.ActionReference()
- if name:
- ref1.putName(cID('Lyr '), name)
- else:
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- list1.putReference(ref1)
- desc1.putList(cID('null'), list1)
- APP.executeAction(cID('Shw '), desc1, dialog_mode)
-
-
-def delete_layers(layers):
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- list1 = ps.ActionList()
- for layer in layers:
- list1.putInteger(layer)
- desc1.putList(cID('LyrI'), list1)
- APP.executeAction(cID('Dlt '), desc1, dialog_mode)
+ desc1 = ps.ActionDescriptor()
+ desc1.putBoolean(APP.instance.cID("AuCo"), True)
+ APP.instance.executeAction(APP.instance.cID("Lvls"), desc1, dialog_mode)
+
+
+def hide_layer(name: str | None = None):
+ desc1 = ps.ActionDescriptor()
+ list1 = ps.ActionList()
+ ref1 = ps.ActionReference()
+ if name:
+ ref1.putName(APP.instance.cID("Lyr "), name)
+ else:
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ list1.putReference(ref1)
+ desc1.putList(APP.instance.cID("null"), list1)
+ APP.instance.executeAction(APP.instance.cID("Hd "), desc1, dialog_mode)
+
+
+def show_layer(name: str | None = None):
+ desc1 = ps.ActionDescriptor()
+ list1 = ps.ActionList()
+ ref1 = ps.ActionReference()
+ if name:
+ ref1.putName(APP.instance.cID("Lyr "), name)
+ else:
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ list1.putReference(ref1)
+ desc1.putList(APP.instance.cID("null"), list1)
+ APP.instance.executeAction(APP.instance.cID("Shw "), desc1, dialog_mode)
+
+
+def delete_layers(layers: Iterable[int]):
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ list1 = ps.ActionList()
+ for layer in layers:
+ list1.putInteger(layer)
+ desc1.putList(APP.instance.cID("LyrI"), list1)
+ APP.instance.executeAction(APP.instance.cID("Dlt "), desc1, dialog_mode)
"""
@@ -161,15 +171,19 @@ def delete_layers(layers):
"""
-def blend(key):
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ps.ActionDescriptor()
- desc2.putEnumerated(cID('Md '), cID('BlnM'), sID(key))
- desc1.putObject(cID('T '), cID('Lyr '), desc2)
- APP.executeAction(cID('setd'), desc1, dialog_mode)
+def blend(key: str):
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ps.ActionDescriptor()
+ desc2.putEnumerated(
+ APP.instance.cID("Md "), APP.instance.cID("BlnM"), APP.instance.sID(key)
+ )
+ desc1.putObject(APP.instance.cID("T "), APP.instance.cID("Lyr "), desc2)
+ APP.instance.executeAction(APP.instance.cID("setd"), desc1, dialog_mode)
"""
@@ -178,830 +192,1018 @@ def blend(key):
def filter_photocopy(detail: int = 2, darken: int = 5):
- """
- Apply photocopy filter.
- @param detail: Level of detail.
- @param darken: Darkness amount.
- """
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(cID('GEfk'), cID('GEft'), cID('Phtc'))
- desc1.putInteger(sID('detail '), detail)
- desc1.putInteger(sID('darken'), darken)
- APP.executeAction(1195730531, desc1, dialog_mode)
+ """
+ Apply photocopy filter.
+ @param detail: Level of detail.
+ @param darken: Darkness amount.
+ """
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("Phtc")
+ )
+ desc1.putInteger(APP.instance.sID("detail "), detail)
+ desc1.putInteger(APP.instance.sID("darken"), darken)
+ APP.instance.executeAction(1195730531, desc1, dialog_mode)
# Utility commands
-def blend_multiply(): blend('multiply')
-def blend_color_dodge(): blend('colorDodge')
-def blend_linear_light(): blend("linearLight")
-def blend_linear_burn(): blend("linearBurn")
-def blend_soft_light(): blend('softLight')
-def blend_screen(): blend('screen')
-def blend_overlay(): blend('overlay')
-def blend_color(): blend('color')
-
-
-def run(thr: Event, draft_sketch=False, rough_sketch=False, black_and_white=True, manual_editing=False):
- """
- Pencil Sketchify Steps
- """
+def blend_multiply():
+ blend("multiply")
- # Is the main layer "Layer 1"
- APP.activeDocument.activeLayer.name = "Background"
- # Make - New Layer 1
- new_layer(139)
+def blend_color_dodge():
+ blend("colorDodge")
- # Select
- select_bg()
- # Make - Solid Color Layer
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putClass(sID("contentLayer"))
- desc1.putReference(cID('null'), ref1)
- desc2 = ps.ActionDescriptor()
- desc3 = ps.ActionDescriptor()
- desc4 = ps.ActionDescriptor()
- desc4.putInteger(cID('Rd '), 166)
- desc4.putInteger(cID('Grn '), 166)
- desc4.putInteger(cID('Bl '), 166)
- desc3.putObject(cID('Clr '), sID("RGBColor"), desc4)
- desc2.putObject(cID('Type'), sID("solidColorLayer"), desc3)
- desc1.putObject(cID('Usng'), sID("contentLayer"), desc2)
- APP.executeAction(cID('Mk '), desc1, dialog_mode)
+def blend_linear_light():
+ blend("linearLight")
- # Select
- select_bg()
- # Layer Via Copy - Background copy
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
+def blend_linear_burn():
+ blend("linearBurn")
- # Move
- move_layer(3, 240)
- # Reset Colors
- reset_colors()
+def blend_soft_light():
+ blend("softLight")
- # Filter Gallery - Photocopy
- filter_photocopy(detail=2, darken=5)
- # Set - Blending Multiply
- blend_multiply()
+def blend_screen():
+ blend("screen")
- # Select
- select_bg()
- # Layer Via Copy - Background copy 2
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
+def blend_overlay():
+ blend("overlay")
- # Move
- move_layer(4, 242)
- # Reset Colors
- reset_colors()
+def blend_color():
+ blend("color")
- # Filter Gallery - Photocopy, Accented Edges
- desc1 = ps.ActionDescriptor()
- list1 = ps.ActionList()
- desc2 = ps.ActionDescriptor()
- desc3 = ps.ActionDescriptor()
- desc2.putEnumerated(cID('GEfk'), cID('GEft'), cID('AccE'))
- desc2.putInteger(cID('EdgW'), 3)
- desc2.putInteger(cID('EdgB'), 20)
- desc2.putInteger(cID('Smth'), 15)
- list1.putObject(cID('GEfc'), desc2)
- desc3.putEnumerated(cID('GEfk'), cID('GEft'), cID('Phtc'))
- desc3.putInteger(cID('Dtl '), 1)
- desc3.putInteger(cID('Drkn'), 49)
- list1.putObject(cID('GEfc'), desc3)
- desc1.putList(cID('GEfs'), list1)
- APP.executeAction(1195730531, desc1, dialog_mode)
-
- # Set - Blending Multiply
- blend_multiply()
-
- # Select
- select_bg()
-
- # Layer Via Copy - Background Copy 3
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
- # Move
- move_layer(5, 244)
-
- # Reset Colors
- reset_colors()
+def run(
+ render_operation: RenderOperation,
+ draft_sketch: bool = False,
+ rough_sketch: bool = False,
+ black_and_white: bool = True,
+ manual_editing: bool = False,
+):
+ """
+ Pencil Sketchify Steps
+ """
- # Filter Gallery - Photocopy, Stamp
- desc1 = ps.ActionDescriptor()
- list1 = ps.ActionList()
- desc2 = ps.ActionDescriptor()
- desc2.putEnumerated(cID('GEfk'), cID('GEft'), cID('Stmp'))
- desc2.putInteger(cID('LgDr'), 25)
- desc2.putInteger(cID('Smth'), 40)
- list1.putObject(cID('GEfc'), desc2)
- desc3 = ps.ActionDescriptor()
- desc3.putEnumerated(cID('GEfk'), cID('GEft'), cID('Phtc'))
- desc3.putInteger(cID('Dtl '), 1)
- desc3.putInteger(cID('Drkn'), 49)
- list1.putObject(cID('GEfc'), desc3)
- desc1.putList(cID('GEfs'), list1)
- APP.executeAction(1195730531, desc1, dialog_mode)
+ # Is the main layer "Layer 1"
+ APP.instance.activeDocument.activeLayer.name = "Background"
- # Set - Blending Multiply
- blend_multiply()
-
- # Set
- set_opacity(25)
-
- # Select
- select_bg()
-
- # Layer Via Copy - Background Copy 4
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
-
- # Move
- move_layer(6, 246)
-
- # Reset
- reset_colors()
-
- # Filter Gallery - Glowing Edge
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(cID('GEfk'), cID('GEft'), cID('GlwE'))
- desc1.putInteger(cID('EdgW'), 1)
- desc1.putInteger(cID('EdgB'), 20)
- desc1.putInteger(cID('Smth'), 15)
- APP.executeAction(1195730531, desc1, dialog_mode)
-
- # Desaturate
- APP.executeAction(cID('Dstt'), ps.ActionDescriptor(), dialog_mode)
-
- # Levels - Auto Tone
- auto_tone()
-
- # Levels - Auto Contrast
- auto_contrast()
-
- # Levels Adjustment
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(sID("presetKind"), sID("presetKindType"), sID("presetKindCustom"))
- list1 = ps.ActionList()
- desc2 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('Chnl'), cID('Chnl'), cID('Cmps'))
- desc2.putReference(cID('Chnl'), ref1)
- list2 = ps.ActionList()
- list2.putInteger(25)
- list2.putInteger(230)
- desc2.putList(cID('Inpt'), list2)
- list1.putObject(cID('LvlA'), desc2)
- desc1.putList(cID('Adjs'), list1)
- APP.executeAction(cID('Lvls'), desc1, dialog_mode)
-
- # Invert
- APP.executeAction(cID('Invr'), ps.ActionDescriptor(), dialog_mode)
-
- # Set
- blend_multiply()
+ # Make - New Layer 1
+ new_layer(139)
- # Select
- select_bg()
-
- # Layer Via Copy - Background Copy 5
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
-
- # Move
- move_layer(7, 248)
-
- # Layer Via Copy - Background Copy 6
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
-
- # Invert
- APP.executeAction(cID('Invr'), ps.ActionDescriptor(), dialog_mode)
-
- # Gaussian Blur
- desc1 = ps.ActionDescriptor()
- desc1.putUnitDouble(cID('Rds '), cID('#Pxl'), 50)
- APP.executeAction(sID('gaussianBlur'), desc1, dialog_mode)
-
- # Set - Blending Color Dodge
- blend_color_dodge()
-
- # Select
- select_layers("Background copy 5", [248, 249])
-
- # Merge Layers
- APP.executeAction(sID('mergeLayersNew'), ps.ActionDescriptor(), dialog_mode)
-
- # Desaturate
- APP.executeAction(cID('Dstt'), ps.ActionDescriptor(), dialog_mode)
-
- # Layer Via Copy - Background Copy 7
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
-
- # Reset
- reset_colors()
-
- # Filter Gallery - Glowing Edge
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(cID('GEfk'), cID('GEft'), cID('GlwE'))
- desc1.putInteger(cID('EdgW'), 1)
- desc1.putInteger(cID('EdgB'), 20)
- desc1.putInteger(cID('Smth'), 15)
- APP.executeAction(1195730531, desc1, dialog_mode)
-
- # Invert
- APP.executeAction(cID('Invr'), ps.ActionDescriptor(), dialog_mode)
-
- # Set
- blend_multiply()
-
- # Set
- set_opacity(80)
-
- # Select
- select_layers("Background copy 6", [249, 250])
-
- # Merge Layers
- APP.executeAction(sID('mergeLayersNew'), ps.ActionDescriptor(), dialog_mode)
-
- # Select
- select_bg()
-
- # Layer Via Copy - Background Copy 5
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
-
- # Move
- move_layer(8, 252)
-
- # Desaturate
- APP.executeAction(cID('Dstt'), ps.ActionDescriptor(), dialog_mode)
-
- # High Pass Filter
- desc1 = ps.ActionDescriptor()
- desc1.putUnitDouble(cID('Rds '), cID('#Pxl'), 30)
- APP.executeAction(sID('highPass'), desc1, dialog_mode)
-
- # Set - Blending Linear Light
- blend_linear_light()
-
- # Set - Layer Style, Blending Options: Blend if Gray
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ps.ActionDescriptor()
- list1 = ps.ActionList()
- desc3 = ps.ActionDescriptor()
- ref2 = ps.ActionReference()
- ref2.putEnumerated(cID('Chnl'), cID('Chnl'), cID('Gry '))
- desc3.putReference(cID('Chnl'), ref2)
- desc3.putInteger(cID('SrcB'), 0)
- desc3.putInteger(cID('Srcl'), 0)
- desc3.putInteger(cID('SrcW'), 75)
- desc3.putInteger(cID('Srcm'), 125)
- desc3.putInteger(cID('DstB'), 55)
- desc3.putInteger(cID('Dstl'), 125)
- desc3.putInteger(cID('DstW'), 255)
- desc3.putInteger(cID('Dstt'), 255)
- list1.putObject(cID('Blnd'), desc3)
- desc2.putList(cID('Blnd'), list1)
- desc1.putObject(cID('T '), cID('Lyr '), desc2)
- APP.executeAction(cID('setd'), desc1, dialog_mode)
-
- # Set
- set_opacity(50)
-
- # Select
- select_bg()
-
- # Layer Via Copy - Background Copy 6
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
-
- # Move
- move_layer(9, 254)
-
- # Reset
- reset_colors()
-
- # Filter Gallery - Cutout
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(cID('GEfk'), cID('GEft'), cID('Ct '))
- desc1.putInteger(cID('NmbL'), 8)
- desc1.putInteger(cID('EdgS'), 10)
- desc1.putInteger(cID('EdgF'), 1)
- APP.executeAction(1195730531, desc1, dialog_mode)
-
- # Desaturate
- APP.executeAction(cID('Dstt'), ps.ActionDescriptor(), dialog_mode)
-
- # Levels - Auto Tone
- auto_tone()
-
- # Levels - Auto Contrast
- auto_contrast()
-
- # Color Range
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(cID('Clrs'), cID('Clrs'), cID('Mdtn'))
- desc1.putInteger(sID("midtonesFuzziness"), 40)
- desc1.putInteger(sID("midtonesLowerLimit"), 105)
- desc1.putInteger(sID("midtonesUpperLimit"), 150)
- desc1.putInteger(sID("colorModel"), 0)
- APP.executeAction(sID('colorRange'), desc1, dialog_mode)
-
- # Layer Via Copy - Layer 2
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
-
- # Find Edges
- APP.executeAction(sID('findEdges'), ps.ActionDescriptor(), dialog_mode)
-
- # Hide
- hide_layer("Background copy 6")
-
- # Hide
- hide_layer()
-
- # Select
- select_bg()
-
- # Layer Via Copy - Background copy 8
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
-
- # Move
- move_layer(9, 257)
-
- # Reset
- reset_colors()
-
- # Filter Gallery - Cutout
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(cID('GEfk'), cID('GEft'), cID('Ct '))
- desc1.putInteger(cID('NmbL'), 8)
- desc1.putInteger(cID('EdgS'), 8)
- desc1.putInteger(cID('EdgF'), 1)
- APP.executeAction(1195730531, desc1, dialog_mode)
-
- # Desaturate
- APP.executeAction(cID('Dstt'), ps.ActionDescriptor(), dialog_mode)
-
- # Levels
- auto_tone()
-
- # Levels
- auto_contrast()
-
- # Color Range
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(cID('Clrs'), cID('Clrs'), cID('Mdtn'))
- desc1.putInteger(sID("midtonesFuzziness"), 40)
- desc1.putInteger(sID("midtonesLowerLimit"), 105)
- desc1.putInteger(sID("midtonesUpperLimit"), 150)
- desc1.putInteger(sID("colorModel"), 0)
- APP.executeAction(sID('colorRange'), desc1, dialog_mode)
-
- # Layer Via Copy - Layer 3
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
-
- # Find Edges
- APP.executeAction(sID('findEdges'), ps.ActionDescriptor(), dialog_mode)
-
- # Select
- select_layer("Background copy 8", 257)
-
- # Select
- select_layers("Background copy 6", [257, 254])
-
- # Delete
- delete_layers([257, 254])
-
- # Select
- select_layer("Layer 2", 255)
-
- # Show
- show_layer()
-
- # Select
- select_layers("Layer 3", [258, 255])
-
- # Set
- blend_multiply()
-
- # Set
- set_opacity(30)
-
- # Select
- select_layer("Background copy 3", 244)
-
- # Set
- set_opacity(30)
-
- # Select
- select_layer("Background copy 7", 250)
-
- # Move
- move_layer(2, 250)
-
- # Select
- select_layer("Background copy 5", 252)
-
- # Move
- move_layer(10, 252)
-
- # Select
- select_layer("Background copy 7", 250)
-
- # Set
- set_opacity(50)
-
- # Select
- select_bg()
-
- # Layer Via Copy - Background Copy 6
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
-
- # Move
- move_layer(11, 261)
-
- # Filter - Distort Wave
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(cID('Wvtp'), cID('Wvtp'), cID('WvSn'))
- desc1.putInteger(cID('NmbG'), 5)
- desc1.putInteger(cID('WLMn'), 10)
- desc1.putInteger(cID('WLMx'), 500)
- desc1.putInteger(cID('AmMn'), 5)
- desc1.putInteger(cID('AmMx'), 35)
- desc1.putInteger(cID('SclH'), 100)
- desc1.putInteger(cID('SclV'), 100)
- desc1.putEnumerated(cID('UndA'), cID('UndA'), cID('RptE'))
- desc1.putInteger(cID('RndS'), 1260853)
- APP.executeAction(cID('Wave'), desc1, dialog_mode)
-
- # Reset
- reset_colors()
-
- # Filter Gallery - Photocopy, Accented Edges
- desc1 = ps.ActionDescriptor()
- list1 = ps.ActionList()
- desc2 = ps.ActionDescriptor()
- desc2.putEnumerated(cID('GEfk'), cID('GEft'), cID('AccE'))
- desc2.putInteger(cID('EdgW'), 3)
- desc2.putInteger(cID('EdgB'), 20)
- desc2.putInteger(cID('Smth'), 15)
- list1.putObject(cID('GEfc'), desc2)
- desc3 = ps.ActionDescriptor()
- desc3.putEnumerated(cID('GEfk'), cID('GEft'), cID('Phtc'))
- desc3.putInteger(cID('Dtl '), 1)
- desc3.putInteger(cID('Drkn'), 49)
- list1.putObject(cID('GEfc'), desc3)
- desc1.putList(cID('GEfs'), list1)
- APP.executeAction(1195730531, desc1, dialog_mode)
-
- # Levels - Auto Tone
- auto_tone()
-
- # Levels - Auto Contrast
- auto_contrast()
-
- # Set - Blending Multiply
- blend_multiply()
-
- # Set
- set_opacity(50)
-
- # Move
- move_layer(5, 261)
-
- # Select
- select_bg()
-
- # Layer Via Copy - Background Copy 8
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
-
- # Move
- move_layer(12, 263)
-
- # Reset
- reset_colors()
-
- # Filter Gallery - Photocopy
- filter_photocopy(detail=2, darken=5)
-
- # Set
- blend_multiply()
-
- # Layer Via Copy - Background Copy 9
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
-
- # Transform
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc1.putEnumerated(cID('FTcs'), cID('QCSt'), sID("QCSAverage"))
- desc2 = ps.ActionDescriptor()
- desc2.putUnitDouble(cID('Hrzn'), cID('#Pxl'), 0)
- desc2.putUnitDouble(cID('Vrtc'), cID('#Pxl'), 0)
- desc1.putObject(cID('Ofst'), cID('Ofst'), desc2)
- desc1.putUnitDouble(cID('Wdth'), cID('#Prc'), 110)
- desc1.putUnitDouble(cID('Hght'), cID('#Prc'), 110)
- desc1.putBoolean(cID('Lnkd'), True)
- desc1.putEnumerated(cID('Intr'), cID('Intp'), cID('Bcbc'))
- APP.executeAction(cID('Trnf'), desc1, dialog_mode)
-
- # Select
- select_layer("Background copy 8", 263)
-
- # Transform
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc1.putEnumerated(cID('FTcs'), cID('QCSt'), sID("QCSAverage"))
- desc2 = ps.ActionDescriptor()
- desc2.putUnitDouble(cID('Hrzn'), cID('#Pxl'), -2.27373675443232e-13)
- desc2.putUnitDouble(cID('Vrtc'), cID('#Pxl'), 0)
- desc1.putObject(cID('Ofst'), cID('Ofst'), desc2)
- desc1.putUnitDouble(cID('Wdth'), cID('#Prc'), 90)
- desc1.putUnitDouble(cID('Hght'), cID('#Prc'), 90)
- desc1.putBoolean(cID('Lnkd'), True)
- desc1.putEnumerated(cID('Intr'), cID('Intp'), cID('Bcbc'))
- APP.executeAction(cID('Trnf'), desc1, dialog_mode)
-
- # Select
- select_layers("Background copy 9", [263, 264])
-
- # Set
- set_opacity(10)
-
- # Move
- move_layer(8, [263, 264])
-
- # Select
- select_layer("Background copy 7", 250)
-
- # Select
- select_layers("Layer 2", [
- 250, 240, 242, 261, 244, 246, 263, 264, 258, 255
- ])
-
- # Set
- blend_linear_burn()
-
- # Select
- select_layer("Color Fill 1", 238)
-
- # Make
- new_layer(268)
-
- # Fill - 50% Gray
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(cID('Usng'), cID('FlCn'), cID('Gry '))
- desc1.putUnitDouble(cID('Opct'), cID('#Prc'), 100)
- desc1.putEnumerated(cID('Md '), cID('BlnM'), cID('Nrml'))
- APP.executeAction(cID('Fl '), desc1, dialog_mode)
-
- # Filter Gallery - Texturizer
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(cID('GEfk'), cID('GEft'), cID('Txtz'))
- desc1.putEnumerated(cID('TxtT'), cID('TxtT'), cID('TxSt'))
- desc1.putInteger(cID('Scln'), 200)
- desc1.putInteger(cID('Rlf '), 4)
- desc1.putEnumerated(cID('LghD'), cID('LghD'), cID('LDTp'))
- desc1.putBoolean(cID('InvT'), False)
- APP.executeAction(1195730531, desc1, dialog_mode)
-
- # Set - Blending Soft Light
- blend_soft_light()
-
- # Select
- select_layer("Layer 4", 268)
-
- # Make
- new_layer(269)
-
- # Fill - Foreground Color
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(cID('Usng'), cID('FlCn'), cID('FrgC'))
- desc1.putUnitDouble(cID('Opct'), cID('#Prc'), 100)
- desc1.putEnumerated(cID('Md '), cID('BlnM'), cID('Nrml'))
- APP.executeAction(cID('Fl '), desc1, dialog_mode)
-
- # Add Noise
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(cID('Dstr'), cID('Dstr'), cID('Gsn '))
- desc1.putUnitDouble(cID('Nose'), cID('#Prc'), 25)
- desc1.putBoolean(cID('Mnch'), True)
- desc1.putInteger(cID('FlRs'), 1315132)
- APP.executeAction(sID('addNoise'), desc1, dialog_mode)
- APP.activeDocument.activeLayer.opacity = 40
-
- # Set
- blend_screen()
-
- # Levels Adjustment
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(sID("presetKind"), sID("presetKindType"), sID("presetKindCustom"))
- list1 = ps.ActionList()
- desc2 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('Chnl'), cID('Chnl'), cID('Cmps'))
- desc2.putReference(cID('Chnl'), ref1)
- list2 = ps.ActionList()
- list2.putInteger(0)
- list2.putInteger(90)
- desc2.putList(cID('Inpt'), list2)
- list1.putObject(cID('LvlA'), desc2)
- desc1.putList(cID('Adjs'), list1)
- APP.executeAction(cID('Lvls'), desc1, dialog_mode)
-
- # Select
- select_layer("Background copy 4", 246)
-
- # Move
- move_layer(6, 246)
-
- # Select
- select_layer("Background copy 3", 244)
-
- # Set
- set_opacity(20)
-
- # Select
- select_layer("Layer 3", 258)
-
- # Select
- select_layers("Layer 2", [258, 255])
-
- # Set
- set_opacity(40)
-
- # Select
- select_layer("Background copy 5", 252)
-
- # Reset
- reset_colors()
-
- # Make - Gradient Map
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putClass(cID('AdjL'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ps.ActionDescriptor()
- desc3 = ps.ActionDescriptor()
- desc4 = ps.ActionDescriptor()
- desc4.putString(cID('Nm '), "Foreground to Background")
- desc4.putEnumerated(cID('GrdF'), cID('GrdF'), cID('CstS'))
- desc4.putDouble(cID('Intr'), 4096)
- list1 = ps.ActionList()
- desc5 = ps.ActionDescriptor()
- desc6 = ps.ActionDescriptor()
- desc6.putDouble(cID('Rd '), 0)
- desc6.putDouble(cID('Grn '), 0)
- desc6.putDouble(cID('Bl '), 0)
- desc5.putObject(cID('Clr '), sID("RGBColor"), desc6)
- desc5.putEnumerated(cID('Type'), cID('Clry'), cID('UsrS'))
- desc5.putInteger(cID('Lctn'), 0)
- desc5.putInteger(cID('Mdpn'), 50)
- list1.putObject(cID('Clrt'), desc5)
- desc7 = ps.ActionDescriptor()
- desc8 = ps.ActionDescriptor()
- desc8.putDouble(cID('Rd '), 255)
- desc8.putDouble(cID('Grn '), 255)
- desc8.putDouble(cID('Bl '), 255)
- desc7.putObject(cID('Clr '), sID("RGBColor"), desc8)
- desc7.putEnumerated(cID('Type'), cID('Clry'), cID('UsrS'))
- desc7.putInteger(cID('Lctn'), 4096)
- desc7.putInteger(cID('Mdpn'), 50)
- list1.putObject(cID('Clrt'), desc7)
- desc4.putList(cID('Clrs'), list1)
- list2 = ps.ActionList()
- desc9 = ps.ActionDescriptor()
- desc9.putUnitDouble(cID('Opct'), cID('#Prc'), 100)
- desc9.putInteger(cID('Lctn'), 0)
- desc9.putInteger(cID('Mdpn'), 50)
- list2.putObject(cID('TrnS'), desc9)
- desc10 = ps.ActionDescriptor()
- desc10.putUnitDouble(cID('Opct'), cID('#Prc'), 100)
- desc10.putInteger(cID('Lctn'), 4096)
- desc10.putInteger(cID('Mdpn'), 50)
- list2.putObject(cID('TrnS'), desc10)
- desc4.putList(cID('Trns'), list2)
- desc3.putObject(cID('Grad'), cID('Grdn'), desc4)
- desc2.putObject(cID('Type'), cID('GdMp'), desc3)
- desc1.putObject(cID('Usng'), cID('AdjL'), desc2)
- APP.executeAction(cID('Mk '), desc1, dialog_mode)
-
- # Set
- blend_soft_light()
-
- # Set
- set_opacity(20)
-
- # Make - Levels Adjustment Layer
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putClass(cID('AdjL'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ps.ActionDescriptor()
- desc3 = ps.ActionDescriptor()
- desc3.putEnumerated(sID("presetKind"), sID("presetKindType"), sID("presetKindDefault"))
- desc2.putObject(cID('Type'), cID('Lvls'), desc3)
- desc1.putObject(cID('Usng'), cID('AdjL'), desc2)
- APP.executeAction(cID('Mk '), desc1, dialog_mode)
-
- # Set - Levels Adjustment Layer Settings
- desc1 = ps.ActionDescriptor()
- ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('AdjL'), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ps.ActionDescriptor()
- desc2.putEnumerated(sID("presetKind"), sID("presetKindType"), sID("presetKindCustom"))
- list1 = ps.ActionList()
- desc3 = ps.ActionDescriptor()
- ref2 = ps.ActionReference()
- ref2.putEnumerated(cID('Chnl'), cID('Chnl'), cID('Cmps'))
- desc3.putReference(cID('Chnl'), ref2)
- desc3.putDouble(cID('Gmm '), 0.8)
- list2 = ps.ActionList()
- list2.putInteger(30)
- list2.putInteger(250)
- desc3.putList(cID('Inpt'), list2)
- list1.putObject(cID('LvlA'), desc3)
- desc2.putList(cID('Adjs'), list1)
- desc1.putObject(cID('T '), cID('Lvls'), desc2)
- APP.executeAction(cID('setd'), desc1, dialog_mode)
-
- # Select
- select_layer("Background copy 6", 112)
-
- # Set
- set_opacity(40)
-
- # Select
- select_bg()
-
- # Layer Via Copy - Background Copy 10
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
-
- # Move
- move_layer(18, 121)
-
- # Reset
- reset_colors()
-
- # Filter Gallery - Graphic Pen
- desc1 = ps.ActionDescriptor()
- desc1.putEnumerated(cID('GEfk'), cID('GEft'), cID('GraP'))
- desc1.putInteger(cID('StrL'), 15)
- desc1.putInteger(cID('LgDr'), 50)
- desc1.putEnumerated(cID('SDir'), cID('StrD'), cID('SDRD'))
- APP.executeAction(1195730531, desc1, dialog_mode)
-
- # Set
- blend_overlay()
-
- # Set
- set_opacity(30)
-
- # Move
- move_layer(5, 121)
-
- # Select
- select_bg()
-
- # Layer Via Copy - Background copy 11
- APP.executeAction(sID('copyToLayer'), ps.ActionDescriptor(), dialog_mode)
-
- # Move
- move_layer(16, 127)
-
- # Set
- blend_color()
-
- """
+ # Select
+ select_bg()
+
+ # Make - Solid Color Layer
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putClass(APP.instance.sID("contentLayer"))
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ps.ActionDescriptor()
+ desc3 = ps.ActionDescriptor()
+ desc4 = ps.ActionDescriptor()
+ desc4.putInteger(APP.instance.cID("Rd "), 166)
+ desc4.putInteger(APP.instance.cID("Grn "), 166)
+ desc4.putInteger(APP.instance.cID("Bl "), 166)
+ desc3.putObject(APP.instance.cID("Clr "), APP.instance.sID("RGBColor"), desc4)
+ desc2.putObject(
+ APP.instance.cID("Type"), APP.instance.sID("solidColorLayer"), desc3
+ )
+ desc1.putObject(APP.instance.cID("Usng"), APP.instance.sID("contentLayer"), desc2)
+ APP.instance.executeAction(APP.instance.cID("Mk "), desc1, dialog_mode)
+
+ # Select
+ select_bg()
+
+ # Layer Via Copy - Background copy
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Move
+ move_layer(3, 240)
+
+ # Reset Colors
+ reset_colors()
+
+ # Filter Gallery - Photocopy
+ filter_photocopy(detail=2, darken=5)
+
+ # Set - Blending Multiply
+ blend_multiply()
+
+ # Select
+ select_bg()
+
+ # Layer Via Copy - Background copy 2
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Move
+ move_layer(4, 242)
+
+ # Reset Colors
+ reset_colors()
+
+ # Filter Gallery - Photocopy, Accented Edges
+ desc1 = ps.ActionDescriptor()
+ list1 = ps.ActionList()
+ desc2 = ps.ActionDescriptor()
+ desc3 = ps.ActionDescriptor()
+ desc2.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("AccE")
+ )
+ desc2.putInteger(APP.instance.cID("EdgW"), 3)
+ desc2.putInteger(APP.instance.cID("EdgB"), 20)
+ desc2.putInteger(APP.instance.cID("Smth"), 15)
+ list1.putObject(APP.instance.cID("GEfc"), desc2)
+ desc3.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("Phtc")
+ )
+ desc3.putInteger(APP.instance.cID("Dtl "), 1)
+ desc3.putInteger(APP.instance.cID("Drkn"), 49)
+ list1.putObject(APP.instance.cID("GEfc"), desc3)
+ desc1.putList(APP.instance.cID("GEfs"), list1)
+ APP.instance.executeAction(1195730531, desc1, dialog_mode)
+
+ # Set - Blending Multiply
+ blend_multiply()
+
+ # Select
+ select_bg()
+
+ # Layer Via Copy - Background Copy 3
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Move
+ move_layer(5, 244)
+
+ # Reset Colors
+ reset_colors()
+
+ # Filter Gallery - Photocopy, Stamp
+ desc1 = ps.ActionDescriptor()
+ list1 = ps.ActionList()
+ desc2 = ps.ActionDescriptor()
+ desc2.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("Stmp")
+ )
+ desc2.putInteger(APP.instance.cID("LgDr"), 25)
+ desc2.putInteger(APP.instance.cID("Smth"), 40)
+ list1.putObject(APP.instance.cID("GEfc"), desc2)
+ desc3 = ps.ActionDescriptor()
+ desc3.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("Phtc")
+ )
+ desc3.putInteger(APP.instance.cID("Dtl "), 1)
+ desc3.putInteger(APP.instance.cID("Drkn"), 49)
+ list1.putObject(APP.instance.cID("GEfc"), desc3)
+ desc1.putList(APP.instance.cID("GEfs"), list1)
+ APP.instance.executeAction(1195730531, desc1, dialog_mode)
+
+ # Set - Blending Multiply
+ blend_multiply()
+
+ # Set
+ set_opacity(25)
+
+ # Select
+ select_bg()
+
+ # Layer Via Copy - Background Copy 4
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Move
+ move_layer(6, 246)
+
+ # Reset
+ reset_colors()
+
+ # Filter Gallery - Glowing Edge
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("GlwE")
+ )
+ desc1.putInteger(APP.instance.cID("EdgW"), 1)
+ desc1.putInteger(APP.instance.cID("EdgB"), 20)
+ desc1.putInteger(APP.instance.cID("Smth"), 15)
+ APP.instance.executeAction(1195730531, desc1, dialog_mode)
+
+ # Desaturate
+ APP.instance.executeAction(
+ APP.instance.cID("Dstt"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Levels - Auto Tone
+ auto_tone()
+
+ # Levels - Auto Contrast
+ auto_contrast()
+
+ # Levels Adjustment
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.sID("presetKind"),
+ APP.instance.sID("presetKindType"),
+ APP.instance.sID("presetKindCustom"),
+ )
+ list1 = ps.ActionList()
+ desc2 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Chnl"), APP.instance.cID("Chnl"), APP.instance.cID("Cmps")
+ )
+ desc2.putReference(APP.instance.cID("Chnl"), ref1)
+ list2 = ps.ActionList()
+ list2.putInteger(25)
+ list2.putInteger(230)
+ desc2.putList(APP.instance.cID("Inpt"), list2)
+ list1.putObject(APP.instance.cID("LvlA"), desc2)
+ desc1.putList(APP.instance.cID("Adjs"), list1)
+ APP.instance.executeAction(APP.instance.cID("Lvls"), desc1, dialog_mode)
+
+ # Invert
+ APP.instance.executeAction(
+ APP.instance.cID("Invr"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Set
+ blend_multiply()
+
+ # Select
+ select_bg()
+
+ # Layer Via Copy - Background Copy 5
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Move
+ move_layer(7, 248)
+
+ # Layer Via Copy - Background Copy 6
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Invert
+ APP.instance.executeAction(
+ APP.instance.cID("Invr"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Gaussian Blur
+ desc1 = ps.ActionDescriptor()
+ desc1.putUnitDouble(APP.instance.cID("Rds "), APP.instance.cID("#Pxl"), 50)
+ APP.instance.executeAction(APP.instance.sID("gaussianBlur"), desc1, dialog_mode)
+
+ # Set - Blending Color Dodge
+ blend_color_dodge()
+
+ # Select
+ select_layers("Background copy 5", [248, 249])
+
+ # Merge Layers
+ APP.instance.executeAction(
+ APP.instance.sID("mergeLayersNew"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Desaturate
+ APP.instance.executeAction(
+ APP.instance.cID("Dstt"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Layer Via Copy - Background Copy 7
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Reset
+ reset_colors()
+
+ # Filter Gallery - Glowing Edge
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("GlwE")
+ )
+ desc1.putInteger(APP.instance.cID("EdgW"), 1)
+ desc1.putInteger(APP.instance.cID("EdgB"), 20)
+ desc1.putInteger(APP.instance.cID("Smth"), 15)
+ APP.instance.executeAction(1195730531, desc1, dialog_mode)
+
+ # Invert
+ APP.instance.executeAction(
+ APP.instance.cID("Invr"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Set
+ blend_multiply()
+
+ # Set
+ set_opacity(80)
+
+ # Select
+ select_layers("Background copy 6", [249, 250])
+
+ # Merge Layers
+ APP.instance.executeAction(
+ APP.instance.sID("mergeLayersNew"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Select
+ select_bg()
+
+ # Layer Via Copy - Background Copy 5
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Move
+ move_layer(8, 252)
+
+ # Desaturate
+ APP.instance.executeAction(
+ APP.instance.cID("Dstt"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # High Pass Filter
+ desc1 = ps.ActionDescriptor()
+ desc1.putUnitDouble(APP.instance.cID("Rds "), APP.instance.cID("#Pxl"), 30)
+ APP.instance.executeAction(APP.instance.sID("highPass"), desc1, dialog_mode)
+
+ # Set - Blending Linear Light
+ blend_linear_light()
+
+ # Set - Layer Style, Blending Options: Blend if Gray
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ps.ActionDescriptor()
+ list1 = ps.ActionList()
+ desc3 = ps.ActionDescriptor()
+ ref2 = ps.ActionReference()
+ ref2.putEnumerated(
+ APP.instance.cID("Chnl"), APP.instance.cID("Chnl"), APP.instance.cID("Gry ")
+ )
+ desc3.putReference(APP.instance.cID("Chnl"), ref2)
+ desc3.putInteger(APP.instance.cID("SrcB"), 0)
+ desc3.putInteger(APP.instance.cID("Srcl"), 0)
+ desc3.putInteger(APP.instance.cID("SrcW"), 75)
+ desc3.putInteger(APP.instance.cID("Srcm"), 125)
+ desc3.putInteger(APP.instance.cID("DstB"), 55)
+ desc3.putInteger(APP.instance.cID("Dstl"), 125)
+ desc3.putInteger(APP.instance.cID("DstW"), 255)
+ desc3.putInteger(APP.instance.cID("Dstt"), 255)
+ list1.putObject(APP.instance.cID("Blnd"), desc3)
+ desc2.putList(APP.instance.cID("Blnd"), list1)
+ desc1.putObject(APP.instance.cID("T "), APP.instance.cID("Lyr "), desc2)
+ APP.instance.executeAction(APP.instance.cID("setd"), desc1, dialog_mode)
+
+ # Set
+ set_opacity(50)
+
+ # Select
+ select_bg()
+
+ # Layer Via Copy - Background Copy 6
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Move
+ move_layer(9, 254)
+
+ # Reset
+ reset_colors()
+
+ # Filter Gallery - Cutout
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("Ct ")
+ )
+ desc1.putInteger(APP.instance.cID("NmbL"), 8)
+ desc1.putInteger(APP.instance.cID("EdgS"), 10)
+ desc1.putInteger(APP.instance.cID("EdgF"), 1)
+ APP.instance.executeAction(1195730531, desc1, dialog_mode)
+
+ # Desaturate
+ APP.instance.executeAction(
+ APP.instance.cID("Dstt"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Levels - Auto Tone
+ auto_tone()
+
+ # Levels - Auto Contrast
+ auto_contrast()
+
+ # Color Range
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("Clrs"), APP.instance.cID("Clrs"), APP.instance.cID("Mdtn")
+ )
+ desc1.putInteger(APP.instance.sID("midtonesFuzziness"), 40)
+ desc1.putInteger(APP.instance.sID("midtonesLowerLimit"), 105)
+ desc1.putInteger(APP.instance.sID("midtonesUpperLimit"), 150)
+ desc1.putInteger(APP.instance.sID("colorModel"), 0)
+ APP.instance.executeAction(APP.instance.sID("colorRange"), desc1, dialog_mode)
+
+ # Layer Via Copy - Layer 2
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Find Edges
+ APP.instance.executeAction(
+ APP.instance.sID("findEdges"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Hide
+ hide_layer("Background copy 6")
+
+ # Hide
+ hide_layer()
+
+ # Select
+ select_bg()
+
+ # Layer Via Copy - Background copy 8
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Move
+ move_layer(9, 257)
+
+ # Reset
+ reset_colors()
+
+ # Filter Gallery - Cutout
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("Ct ")
+ )
+ desc1.putInteger(APP.instance.cID("NmbL"), 8)
+ desc1.putInteger(APP.instance.cID("EdgS"), 8)
+ desc1.putInteger(APP.instance.cID("EdgF"), 1)
+ APP.instance.executeAction(1195730531, desc1, dialog_mode)
+
+ # Desaturate
+ APP.instance.executeAction(
+ APP.instance.cID("Dstt"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Levels
+ auto_tone()
+
+ # Levels
+ auto_contrast()
+
+ # Color Range
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("Clrs"), APP.instance.cID("Clrs"), APP.instance.cID("Mdtn")
+ )
+ desc1.putInteger(APP.instance.sID("midtonesFuzziness"), 40)
+ desc1.putInteger(APP.instance.sID("midtonesLowerLimit"), 105)
+ desc1.putInteger(APP.instance.sID("midtonesUpperLimit"), 150)
+ desc1.putInteger(APP.instance.sID("colorModel"), 0)
+ APP.instance.executeAction(APP.instance.sID("colorRange"), desc1, dialog_mode)
+
+ # Layer Via Copy - Layer 3
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Find Edges
+ APP.instance.executeAction(
+ APP.instance.sID("findEdges"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Select
+ select_layer("Background copy 8", 257)
+
+ # Select
+ select_layers("Background copy 6", [257, 254])
+
+ # Delete
+ delete_layers([257, 254])
+
+ # Select
+ select_layer("Layer 2", 255)
+
+ # Show
+ show_layer()
+
+ # Select
+ select_layers("Layer 3", [258, 255])
+
+ # Set
+ blend_multiply()
+
+ # Set
+ set_opacity(30)
+
+ # Select
+ select_layer("Background copy 3", 244)
+
+ # Set
+ set_opacity(30)
+
+ # Select
+ select_layer("Background copy 7", 250)
+
+ # Move
+ move_layer(2, 250)
+
+ # Select
+ select_layer("Background copy 5", 252)
+
+ # Move
+ move_layer(10, 252)
+
+ # Select
+ select_layer("Background copy 7", 250)
+
+ # Set
+ set_opacity(50)
+
+ # Select
+ select_bg()
+
+ # Layer Via Copy - Background Copy 6
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Move
+ move_layer(11, 261)
+
+ # Filter - Distort Wave
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("Wvtp"), APP.instance.cID("Wvtp"), APP.instance.cID("WvSn")
+ )
+ desc1.putInteger(APP.instance.cID("NmbG"), 5)
+ desc1.putInteger(APP.instance.cID("WLMn"), 10)
+ desc1.putInteger(APP.instance.cID("WLMx"), 500)
+ desc1.putInteger(APP.instance.cID("AmMn"), 5)
+ desc1.putInteger(APP.instance.cID("AmMx"), 35)
+ desc1.putInteger(APP.instance.cID("SclH"), 100)
+ desc1.putInteger(APP.instance.cID("SclV"), 100)
+ desc1.putEnumerated(
+ APP.instance.cID("UndA"), APP.instance.cID("UndA"), APP.instance.cID("RptE")
+ )
+ desc1.putInteger(APP.instance.cID("RndS"), 1260853)
+ APP.instance.executeAction(APP.instance.cID("Wave"), desc1, dialog_mode)
+
+ # Reset
+ reset_colors()
+
+ # Filter Gallery - Photocopy, Accented Edges
+ desc1 = ps.ActionDescriptor()
+ list1 = ps.ActionList()
+ desc2 = ps.ActionDescriptor()
+ desc2.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("AccE")
+ )
+ desc2.putInteger(APP.instance.cID("EdgW"), 3)
+ desc2.putInteger(APP.instance.cID("EdgB"), 20)
+ desc2.putInteger(APP.instance.cID("Smth"), 15)
+ list1.putObject(APP.instance.cID("GEfc"), desc2)
+ desc3 = ps.ActionDescriptor()
+ desc3.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("Phtc")
+ )
+ desc3.putInteger(APP.instance.cID("Dtl "), 1)
+ desc3.putInteger(APP.instance.cID("Drkn"), 49)
+ list1.putObject(APP.instance.cID("GEfc"), desc3)
+ desc1.putList(APP.instance.cID("GEfs"), list1)
+ APP.instance.executeAction(1195730531, desc1, dialog_mode)
+
+ # Levels - Auto Tone
+ auto_tone()
+
+ # Levels - Auto Contrast
+ auto_contrast()
+
+ # Set - Blending Multiply
+ blend_multiply()
+
+ # Set
+ set_opacity(50)
+
+ # Move
+ move_layer(5, 261)
+
+ # Select
+ select_bg()
+
+ # Layer Via Copy - Background Copy 8
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Move
+ move_layer(12, 263)
+
+ # Reset
+ reset_colors()
+
+ # Filter Gallery - Photocopy
+ filter_photocopy(detail=2, darken=5)
+
+ # Set
+ blend_multiply()
+
+ # Layer Via Copy - Background Copy 9
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Transform
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc1.putEnumerated(
+ APP.instance.cID("FTcs"),
+ APP.instance.cID("QCSt"),
+ APP.instance.sID("QCSAverage"),
+ )
+ desc2 = ps.ActionDescriptor()
+ desc2.putUnitDouble(APP.instance.cID("Hrzn"), APP.instance.cID("#Pxl"), 0)
+ desc2.putUnitDouble(APP.instance.cID("Vrtc"), APP.instance.cID("#Pxl"), 0)
+ desc1.putObject(APP.instance.cID("Ofst"), APP.instance.cID("Ofst"), desc2)
+ desc1.putUnitDouble(APP.instance.cID("Wdth"), APP.instance.cID("#Prc"), 110)
+ desc1.putUnitDouble(APP.instance.cID("Hght"), APP.instance.cID("#Prc"), 110)
+ desc1.putBoolean(APP.instance.cID("Lnkd"), True)
+ desc1.putEnumerated(
+ APP.instance.cID("Intr"), APP.instance.cID("Intp"), APP.instance.cID("Bcbc")
+ )
+ APP.instance.executeAction(APP.instance.cID("Trnf"), desc1, dialog_mode)
+
+ # Select
+ select_layer("Background copy 8", 263)
+
+ # Transform
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc1.putEnumerated(
+ APP.instance.cID("FTcs"),
+ APP.instance.cID("QCSt"),
+ APP.instance.sID("QCSAverage"),
+ )
+ desc2 = ps.ActionDescriptor()
+ desc2.putUnitDouble(
+ APP.instance.cID("Hrzn"), APP.instance.cID("#Pxl"), -2.27373675443232e-13
+ )
+ desc2.putUnitDouble(APP.instance.cID("Vrtc"), APP.instance.cID("#Pxl"), 0)
+ desc1.putObject(APP.instance.cID("Ofst"), APP.instance.cID("Ofst"), desc2)
+ desc1.putUnitDouble(APP.instance.cID("Wdth"), APP.instance.cID("#Prc"), 90)
+ desc1.putUnitDouble(APP.instance.cID("Hght"), APP.instance.cID("#Prc"), 90)
+ desc1.putBoolean(APP.instance.cID("Lnkd"), True)
+ desc1.putEnumerated(
+ APP.instance.cID("Intr"), APP.instance.cID("Intp"), APP.instance.cID("Bcbc")
+ )
+ APP.instance.executeAction(APP.instance.cID("Trnf"), desc1, dialog_mode)
+
+ # Select
+ select_layers("Background copy 9", [263, 264])
+
+ # Set
+ set_opacity(10)
+
+ # Move
+ move_layer(8, [263, 264])
+
+ # Select
+ select_layer("Background copy 7", 250)
+
+ # Select
+ select_layers("Layer 2", [250, 240, 242, 261, 244, 246, 263, 264, 258, 255])
+
+ # Set
+ blend_linear_burn()
+
+ # Select
+ select_layer("Color Fill 1", 238)
+
+ # Make
+ new_layer(268)
+
+ # Fill - 50% Gray
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("Usng"), APP.instance.cID("FlCn"), APP.instance.cID("Gry ")
+ )
+ desc1.putUnitDouble(APP.instance.cID("Opct"), APP.instance.cID("#Prc"), 100)
+ desc1.putEnumerated(
+ APP.instance.cID("Md "), APP.instance.cID("BlnM"), APP.instance.cID("Nrml")
+ )
+ APP.instance.executeAction(APP.instance.cID("Fl "), desc1, dialog_mode)
+
+ # Filter Gallery - Texturizer
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("Txtz")
+ )
+ desc1.putEnumerated(
+ APP.instance.cID("TxtT"), APP.instance.cID("TxtT"), APP.instance.cID("TxSt")
+ )
+ desc1.putInteger(APP.instance.cID("Scln"), 200)
+ desc1.putInteger(APP.instance.cID("Rlf "), 4)
+ desc1.putEnumerated(
+ APP.instance.cID("LghD"), APP.instance.cID("LghD"), APP.instance.cID("LDTp")
+ )
+ desc1.putBoolean(APP.instance.cID("InvT"), False)
+ APP.instance.executeAction(1195730531, desc1, dialog_mode)
+
+ # Set - Blending Soft Light
+ blend_soft_light()
+
+ # Select
+ select_layer("Layer 4", 268)
+
+ # Make
+ new_layer(269)
+
+ # Fill - Foreground Color
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("Usng"), APP.instance.cID("FlCn"), APP.instance.cID("FrgC")
+ )
+ desc1.putUnitDouble(APP.instance.cID("Opct"), APP.instance.cID("#Prc"), 100)
+ desc1.putEnumerated(
+ APP.instance.cID("Md "), APP.instance.cID("BlnM"), APP.instance.cID("Nrml")
+ )
+ APP.instance.executeAction(APP.instance.cID("Fl "), desc1, dialog_mode)
+
+ # Add Noise
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("Dstr"), APP.instance.cID("Dstr"), APP.instance.cID("Gsn ")
+ )
+ desc1.putUnitDouble(APP.instance.cID("Nose"), APP.instance.cID("#Prc"), 25)
+ desc1.putBoolean(APP.instance.cID("Mnch"), True)
+ desc1.putInteger(APP.instance.cID("FlRs"), 1315132)
+ APP.instance.executeAction(APP.instance.sID("addNoise"), desc1, dialog_mode)
+ APP.instance.activeDocument.activeLayer.opacity = 40
+
+ # Set
+ blend_screen()
+
+ # Levels Adjustment
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.sID("presetKind"),
+ APP.instance.sID("presetKindType"),
+ APP.instance.sID("presetKindCustom"),
+ )
+ list1 = ps.ActionList()
+ desc2 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Chnl"), APP.instance.cID("Chnl"), APP.instance.cID("Cmps")
+ )
+ desc2.putReference(APP.instance.cID("Chnl"), ref1)
+ list2 = ps.ActionList()
+ list2.putInteger(0)
+ list2.putInteger(90)
+ desc2.putList(APP.instance.cID("Inpt"), list2)
+ list1.putObject(APP.instance.cID("LvlA"), desc2)
+ desc1.putList(APP.instance.cID("Adjs"), list1)
+ APP.instance.executeAction(APP.instance.cID("Lvls"), desc1, dialog_mode)
+
+ # Select
+ select_layer("Background copy 4", 246)
+
+ # Move
+ move_layer(6, 246)
+
+ # Select
+ select_layer("Background copy 3", 244)
+
+ # Set
+ set_opacity(20)
+
+ # Select
+ select_layer("Layer 3", 258)
+
+ # Select
+ select_layers("Layer 2", [258, 255])
+
+ # Set
+ set_opacity(40)
+
+ # Select
+ select_layer("Background copy 5", 252)
+
+ # Reset
+ reset_colors()
+
+ # Make - Gradient Map
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putClass(APP.instance.cID("AdjL"))
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ps.ActionDescriptor()
+ desc3 = ps.ActionDescriptor()
+ desc4 = ps.ActionDescriptor()
+ desc4.putString(APP.instance.cID("Nm "), "Foreground to Background")
+ desc4.putEnumerated(
+ APP.instance.cID("GrdF"), APP.instance.cID("GrdF"), APP.instance.cID("CstS")
+ )
+ desc4.putDouble(APP.instance.cID("Intr"), 4096)
+ list1 = ps.ActionList()
+ desc5 = ps.ActionDescriptor()
+ desc6 = ps.ActionDescriptor()
+ desc6.putDouble(APP.instance.cID("Rd "), 0)
+ desc6.putDouble(APP.instance.cID("Grn "), 0)
+ desc6.putDouble(APP.instance.cID("Bl "), 0)
+ desc5.putObject(APP.instance.cID("Clr "), APP.instance.sID("RGBColor"), desc6)
+ desc5.putEnumerated(
+ APP.instance.cID("Type"), APP.instance.cID("Clry"), APP.instance.cID("UsrS")
+ )
+ desc5.putInteger(APP.instance.cID("Lctn"), 0)
+ desc5.putInteger(APP.instance.cID("Mdpn"), 50)
+ list1.putObject(APP.instance.cID("Clrt"), desc5)
+ desc7 = ps.ActionDescriptor()
+ desc8 = ps.ActionDescriptor()
+ desc8.putDouble(APP.instance.cID("Rd "), 255)
+ desc8.putDouble(APP.instance.cID("Grn "), 255)
+ desc8.putDouble(APP.instance.cID("Bl "), 255)
+ desc7.putObject(APP.instance.cID("Clr "), APP.instance.sID("RGBColor"), desc8)
+ desc7.putEnumerated(
+ APP.instance.cID("Type"), APP.instance.cID("Clry"), APP.instance.cID("UsrS")
+ )
+ desc7.putInteger(APP.instance.cID("Lctn"), 4096)
+ desc7.putInteger(APP.instance.cID("Mdpn"), 50)
+ list1.putObject(APP.instance.cID("Clrt"), desc7)
+ desc4.putList(APP.instance.cID("Clrs"), list1)
+ list2 = ps.ActionList()
+ desc9 = ps.ActionDescriptor()
+ desc9.putUnitDouble(APP.instance.cID("Opct"), APP.instance.cID("#Prc"), 100)
+ desc9.putInteger(APP.instance.cID("Lctn"), 0)
+ desc9.putInteger(APP.instance.cID("Mdpn"), 50)
+ list2.putObject(APP.instance.cID("TrnS"), desc9)
+ desc10 = ps.ActionDescriptor()
+ desc10.putUnitDouble(APP.instance.cID("Opct"), APP.instance.cID("#Prc"), 100)
+ desc10.putInteger(APP.instance.cID("Lctn"), 4096)
+ desc10.putInteger(APP.instance.cID("Mdpn"), 50)
+ list2.putObject(APP.instance.cID("TrnS"), desc10)
+ desc4.putList(APP.instance.cID("Trns"), list2)
+ desc3.putObject(APP.instance.cID("Grad"), APP.instance.cID("Grdn"), desc4)
+ desc2.putObject(APP.instance.cID("Type"), APP.instance.cID("GdMp"), desc3)
+ desc1.putObject(APP.instance.cID("Usng"), APP.instance.cID("AdjL"), desc2)
+ APP.instance.executeAction(APP.instance.cID("Mk "), desc1, dialog_mode)
+
+ # Set
+ blend_soft_light()
+
+ # Set
+ set_opacity(20)
+
+ # Make - Levels Adjustment Layer
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putClass(APP.instance.cID("AdjL"))
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ps.ActionDescriptor()
+ desc3 = ps.ActionDescriptor()
+ desc3.putEnumerated(
+ APP.instance.sID("presetKind"),
+ APP.instance.sID("presetKindType"),
+ APP.instance.sID("presetKindDefault"),
+ )
+ desc2.putObject(APP.instance.cID("Type"), APP.instance.cID("Lvls"), desc3)
+ desc1.putObject(APP.instance.cID("Usng"), APP.instance.cID("AdjL"), desc2)
+ APP.instance.executeAction(APP.instance.cID("Mk "), desc1, dialog_mode)
+
+ # Set - Levels Adjustment Layer Settings
+ desc1 = ps.ActionDescriptor()
+ ref1 = ps.ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("AdjL"), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ps.ActionDescriptor()
+ desc2.putEnumerated(
+ APP.instance.sID("presetKind"),
+ APP.instance.sID("presetKindType"),
+ APP.instance.sID("presetKindCustom"),
+ )
+ list1 = ps.ActionList()
+ desc3 = ps.ActionDescriptor()
+ ref2 = ps.ActionReference()
+ ref2.putEnumerated(
+ APP.instance.cID("Chnl"), APP.instance.cID("Chnl"), APP.instance.cID("Cmps")
+ )
+ desc3.putReference(APP.instance.cID("Chnl"), ref2)
+ desc3.putDouble(APP.instance.cID("Gmm "), 0.8)
+ list2 = ps.ActionList()
+ list2.putInteger(30)
+ list2.putInteger(250)
+ desc3.putList(APP.instance.cID("Inpt"), list2)
+ list1.putObject(APP.instance.cID("LvlA"), desc3)
+ desc2.putList(APP.instance.cID("Adjs"), list1)
+ desc1.putObject(APP.instance.cID("T "), APP.instance.cID("Lvls"), desc2)
+ APP.instance.executeAction(APP.instance.cID("setd"), desc1, dialog_mode)
+
+ # Select
+ select_layer("Background copy 6", 112)
+
+ # Set
+ set_opacity(40)
+
+ # Select
+ select_bg()
+
+ # Layer Via Copy - Background Copy 10
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Move
+ move_layer(18, 121)
+
+ # Reset
+ reset_colors()
+
+ # Filter Gallery - Graphic Pen
+ desc1 = ps.ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("GraP")
+ )
+ desc1.putInteger(APP.instance.cID("StrL"), 15)
+ desc1.putInteger(APP.instance.cID("LgDr"), 50)
+ desc1.putEnumerated(
+ APP.instance.cID("SDir"), APP.instance.cID("StrD"), APP.instance.cID("SDRD")
+ )
+ APP.instance.executeAction(1195730531, desc1, dialog_mode)
+
+ # Set
+ blend_overlay()
+
+ # Set
+ set_opacity(30)
+
+ # Move
+ move_layer(5, 121)
+
+ # Select
+ select_bg()
+
+ # Layer Via Copy - Background copy 11
+ APP.instance.executeAction(
+ APP.instance.sID("copyToLayer"), ps.ActionDescriptor(), dialog_mode
+ )
+
+ # Move
+ move_layer(16, 127)
+
+ # Set
+ blend_color()
+
+ """
# Make - Hue/Saturation Adjustment
desc1 = ps.ActionDescriptor()
ref1 = ps.ActionReference()
- ref1.putClass(cID('AdjL'))
- desc1.putReference(cID('null'), ref1)
+ ref1.putClass(APP.instance.cID('AdjL'))
+ desc1.putReference(APP.instance.cID('null'), ref1)
desc2 = ps.ActionDescriptor()
desc3 = ps.ActionDescriptor()
- desc3.putEnumerated(sID("presetKind"), sID("presetKindType"), sID("presetKindDefault"))
- desc3.putBoolean(cID('Clrz'), False)
- desc2.putObject(cID('Type'), cID('HStr'), desc3)
- desc1.putObject(cID('Usng'), cID('AdjL'), desc2)
- APP.executeAction(cID('Mk '), desc1, dialog_mode)
+ desc3.putEnumerated(APP.instance.sID("presetKind"), APP.instance.sID("presetKindType"), APP.instance.sID("presetKindDefault"))
+ desc3.putBoolean(APP.instance.cID('Clrz'), False)
+ desc2.putObject(APP.instance.cID('Type'), APP.instance.cID('HStr'), desc3)
+ desc1.putObject(APP.instance.cID('Usng'), APP.instance.cID('AdjL'), desc2)
+ APP.instance.executeAction(APP.instance.cID('Mk '), desc1, dialog_mode)
# Create Clipping Mask
desc1 = ps.ActionDescriptor()
ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- APP.executeAction(sID('groupEvent'), desc1, dialog_mode)
+ ref1.putEnumerated(APP.instance.cID('Lyr '), APP.instance.cID('Ordn'), APP.instance.cID('Trgt'))
+ desc1.putReference(APP.instance.cID('null'), ref1)
+ APP.instance.executeAction(APP.instance.sID('groupEvent'), desc1, dialog_mode)
# Select
select_layers("Background copy 11", [127, 128])
@@ -1015,67 +1217,67 @@ def run(thr: Event, draft_sketch=False, rough_sketch=False, black_and_white=True
# Make - Hue/Saturation Adjustment Layer
desc1 = ps.ActionDescriptor()
ref1 = ps.ActionReference()
- ref1.putClass(cID('AdjL'))
- desc1.putReference(cID('null'), ref1)
+ ref1.putClass(APP.instance.cID('AdjL'))
+ desc1.putReference(APP.instance.cID('null'), ref1)
desc2 = ps.ActionDescriptor()
desc3 = ps.ActionDescriptor()
- desc3.putEnumerated(sID("presetKind"), sID("presetKindType"), sID("presetKindDefault"))
- desc3.putBoolean(cID('Clrz'), False)
- desc2.putObject(cID('Type'), cID('HStr'), desc3)
- desc1.putObject(cID('Usng'), cID('AdjL'), desc2)
- APP.executeAction(cID('Mk '), desc1, dialog_mode)
+ desc3.putEnumerated(APP.instance.sID("presetKind"), APP.instance.sID("presetKindType"), APP.instance.sID("presetKindDefault"))
+ desc3.putBoolean(APP.instance.cID('Clrz'), False)
+ desc2.putObject(APP.instance.cID('Type'), APP.instance.cID('HStr'), desc3)
+ desc1.putObject(APP.instance.cID('Usng'), APP.instance.cID('AdjL'), desc2)
+ APP.instance.executeAction(APP.instance.cID('Mk '), desc1, dialog_mode)
# Merge Visible
desc1 = ps.ActionDescriptor()
- desc1.putBoolean(cID('Dplc'), True)
- APP.executeAction(sID('mergeVisible'), desc1, dialog_mode)
+ desc1.putBoolean(APP.instance.cID('Dplc'), True)
+ APP.instance.executeAction(APP.instance.sID('mergeVisible'), desc1, dialog_mode)
# Desaturate
def step182():
- APP.executeAction(cID('Dstt'), ps.ActionDescriptor(), dialog_mode)
+ APP.instance.executeAction(APP.instance.cID('Dstt'), ps.ActionDescriptor(), dialog_mode)
# High Pass
def step183():
desc1 = ps.ActionDescriptor()
- desc1.putUnitDouble(cID('Rds '), cID('#Pxl'), 1)
- APP.executeAction(sID('highPass'), desc1, dialog_mode)
+ desc1.putUnitDouble(APP.instance.cID('Rds '), APP.instance.cID('#Pxl'), 1)
+ APP.instance.executeAction(APP.instance.sID('highPass'), desc1, dialog_mode)
# Set
def step184():
desc1 = ps.ActionDescriptor()
ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
+ ref1.putEnumerated(APP.instance.cID('Lyr '), APP.instance.cID('Ordn'), APP.instance.cID('Trgt'))
+ desc1.putReference(APP.instance.cID('null'), ref1)
desc2 = ps.ActionDescriptor()
- desc2.putEnumerated(cID('Md '), cID('BlnM'), sID("vividLight"))
- desc1.putObject(cID('T '), cID('Lyr '), desc2)
- APP.executeAction(cID('setd'), desc1, dialog_mode)
+ desc2.putEnumerated(APP.instance.cID('Md '), APP.instance.cID('BlnM'), APP.instance.sID("vividLight"))
+ desc1.putObject(APP.instance.cID('T '), APP.instance.cID('Lyr '), desc2)
+ APP.instance.executeAction(APP.instance.cID('setd'), desc1, dialog_mode)
# Select
def step185():
desc1 = ps.ActionDescriptor()
ref1 = ps.ActionReference()
- ref1.putName(cID('Lyr '), "Color Fill 1")
- desc1.putReference(cID('null'), ref1)
- desc1.putBoolean(cID('MkVs'), False)
+ ref1.putName(APP.instance.cID('Lyr '), "Color Fill 1")
+ desc1.putReference(APP.instance.cID('null'), ref1)
+ desc1.putBoolean(APP.instance.cID('MkVs'), False)
list1 = ps.ActionList()
list1.putInteger(90)
- desc1.putList(cID('LyrI'), list1)
- APP.executeAction(cID('slct'), desc1, dialog_mode)
+ desc1.putList(APP.instance.cID('LyrI'), list1)
+ APP.instance.executeAction(APP.instance.cID('slct'), desc1, dialog_mode)
# Select
def step186():
desc1 = ps.ActionDescriptor()
ref1 = ps.ActionReference()
- ref1.putName(cID('Lyr '), "Layer 6")
- desc1.putReference(cID('null'), ref1)
- desc1.putEnumerated(sID("selectionModifier"), sID("selectionModifierType"), sID("addToSelectionContinuous"))
- desc1.putBoolean(cID('MkVs'), False)
+ ref1.putName(APP.instance.cID('Lyr '), "Layer 6")
+ desc1.putReference(APP.instance.cID('null'), ref1)
+ desc1.putEnumerated(APP.instance.sID("selectionModifier"), APP.instance.sID("selectionModifierType"), APP.instance.sID("addToSelectionContinuous"))
+ desc1.putBoolean(APP.instance.cID('MkVs'), False)
list1 = ps.ActionList()
list1.putInteger(90)
list1.putInteger(116)
@@ -1098,94 +1300,94 @@ def step186():
list1.putInteger(119)
list1.putInteger(130)
list1.putInteger(131)
- desc1.putList(cID('LyrI'), list1)
- APP.executeAction(cID('slct'), desc1, dialog_mode)
+ desc1.putList(APP.instance.cID('LyrI'), list1)
+ APP.instance.executeAction(APP.instance.cID('slct'), desc1, dialog_mode)
# Make
def step187():
desc1 = ps.ActionDescriptor()
ref1 = ps.ActionReference()
- ref1.putClass(sID("layerSection"))
- desc1.putReference(cID('null'), ref1)
+ ref1.putClass(APP.instance.sID("layerSection"))
+ desc1.putReference(APP.instance.cID('null'), ref1)
ref2 = ps.ActionReference()
- ref2.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('From'), ref2)
+ ref2.putEnumerated(APP.instance.cID('Lyr '), APP.instance.cID('Ordn'), APP.instance.cID('Trgt'))
+ desc1.putReference(APP.instance.cID('From'), ref2)
desc2 = ps.ActionDescriptor()
- desc2.putString(cID('Nm '), "Pencil Sketch")
- desc1.putObject(cID('Usng'), sID("layerSection"), desc2)
- desc1.putInteger(sID("layerSectionStart"), 136)
- desc1.putInteger(sID("layerSectionEnd"), 137)
- desc1.putString(cID('Nm '), "Pencil Sketch")
- APP.executeAction(cID('Mk '), desc1, dialog_mode)
+ desc2.putString(APP.instance.cID('Nm '), "Pencil Sketch")
+ desc1.putObject(APP.instance.cID('Usng'), APP.instance.sID("layerSection"), desc2)
+ desc1.putInteger(APP.instance.sID("layerSectionStart"), 136)
+ desc1.putInteger(APP.instance.sID("layerSectionEnd"), 137)
+ desc1.putString(APP.instance.cID('Nm '), "Pencil Sketch")
+ APP.instance.executeAction(APP.instance.cID('Mk '), desc1, dialog_mode)
# Make
def step188():
desc1 = ps.ActionDescriptor()
- desc1.putClass(cID('Nw '), cID('Chnl'))
+ desc1.putClass(APP.instance.cID('Nw '), APP.instance.cID('Chnl'))
ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('Chnl'), cID('Chnl'), cID('Msk '))
- desc1.putReference(cID('At '), ref1)
- desc1.putEnumerated(cID('Usng'), cID('UsrM'), cID('RvlA'))
- APP.executeAction(cID('Mk '), desc1, dialog_mode)
+ ref1.putEnumerated(APP.instance.cID('Chnl'), APP.instance.cID('Chnl'), APP.instance.cID('Msk '))
+ desc1.putReference(APP.instance.cID('At '), ref1)
+ desc1.putEnumerated(APP.instance.cID('Usng'), APP.instance.cID('UsrM'), APP.instance.cID('RvlA'))
+ APP.instance.executeAction(APP.instance.cID('Mk '), desc1, dialog_mode)
# Set
def step189():
desc1 = ps.ActionDescriptor()
ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
+ ref1.putEnumerated(APP.instance.cID('Lyr '), APP.instance.cID('Ordn'), APP.instance.cID('Trgt'))
+ desc1.putReference(APP.instance.cID('null'), ref1)
desc2 = ps.ActionDescriptor()
- desc2.putBoolean(cID('Usrs'), False)
- desc1.putObject(cID('T '), cID('Lyr '), desc2)
- APP.executeAction(cID('setd'), desc1, dialog_mode)
+ desc2.putBoolean(APP.instance.cID('Usrs'), False)
+ desc1.putObject(APP.instance.cID('T '), APP.instance.cID('Lyr '), desc2)
+ APP.instance.executeAction(APP.instance.cID('setd'), desc1, dialog_mode)
# Select
def step190():
desc1 = ps.ActionDescriptor()
ref1 = ps.ActionReference()
- ref1.putName(cID('Lyr '), "Layer 1")
- desc1.putReference(cID('null'), ref1)
- desc1.putBoolean(cID('MkVs'), False)
+ ref1.putName(APP.instance.cID('Lyr '), "Layer 1")
+ desc1.putReference(APP.instance.cID('null'), ref1)
+ desc1.putBoolean(APP.instance.cID('MkVs'), False)
list1 = ps.ActionList()
list1.putInteger(139)
- desc1.putList(cID('LyrI'), list1)
- APP.executeAction(cID('slct'), desc1, dialog_mode)
+ desc1.putList(APP.instance.cID('LyrI'), list1)
+ APP.instance.executeAction(APP.instance.cID('slct'), desc1, dialog_mode)
# Delete
def step191():
desc1 = ps.ActionDescriptor()
ref1 = ps.ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
+ ref1.putEnumerated(APP.instance.cID('Lyr '), APP.instance.cID('Ordn'), APP.instance.cID('Trgt'))
+ desc1.putReference(APP.instance.cID('null'), ref1)
list1 = ps.ActionList()
list1.putInteger(139)
- desc1.putList(cID('LyrI'), list1)
- APP.executeAction(cID('Dlt '), desc1, dialog_mode)
+ desc1.putList(APP.instance.cID('LyrI'), list1)
+ APP.instance.executeAction(APP.instance.cID('Dlt '), desc1, dialog_mode)
# Select Background
def step343(): select_bg()
"""
- if not draft_sketch:
- hide_layer("Layer 2")
- hide_layer("Layer 3")
+ if not draft_sketch:
+ hide_layer("Layer 2")
+ hide_layer("Layer 3")
- if not rough_sketch:
- hide_layer("Background copy 3")
- hide_layer("Background copy 6")
- hide_layer("Background copy 8")
- hide_layer("Background copy 9")
+ if not rough_sketch:
+ hide_layer("Background copy 3")
+ hide_layer("Background copy 6")
+ hide_layer("Background copy 8")
+ hide_layer("Background copy 9")
- if black_and_white:
- hide_layer("Background copy 11")
+ if black_and_white:
+ hide_layer("Background copy 11")
- # Flatten
- if manual_editing:
- CONSOLE.await_choice(thr, "Sketch Action complete, hit continue when ready!")
- APP.executeAction(cID("FltI"), None, dialog_mode)
+ # Flatten
+ if manual_editing:
+ render_operation.pause_sync("Sketch Action complete.")
+ APP.instance.executeAction(APP.instance.cID("FltI"), None, dialog_mode)
diff --git a/plugins/Investigamer/py/actions/sketch.py b/plugins/Investigamer/py/actions/sketch.py
index 69e64065..ca758175 100644
--- a/plugins/Investigamer/py/actions/sketch.py
+++ b/plugins/Investigamer/py/actions/sketch.py
@@ -1,429 +1,546 @@
"""
* Sketchify Action Module
"""
-# Third Party Imports
-from photoshop.api import ActionDescriptor, ActionList, ActionReference, DialogModes
-# Local Imports
-from src import APP
+from photoshop.api import ActionDescriptor, ActionList, ActionReference
+from photoshop.api.enumerations import DialogModes
-# QOL Definitions
-sID, cID = APP.stringIDToTypeID, APP.charIDToTypeID
+from src import APP
def run():
- """Trix old sketchify Steps."""
-
- # Duplicate
- def step1(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putProperty(cID('Lyr '), cID('Bckg'))
- desc1.putReference(cID('null'), ref1)
- desc1.putString(cID('Nm '), "Layer 1 copy")
- desc1.putInteger(cID('Vrsn'), 5)
- APP.executeAction(cID('Dplc'), desc1, dialog_mode)
-
- # Invert
- def step2(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- APP.executeAction(cID('Invr'), ActionDescriptor(), dialog_mode)
-
- # Gaussian Blur
- def step3(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- desc1.putUnitDouble(cID('Rds '), cID('#Pxl'), 65)
- APP.executeAction(sID('gaussianBlur'), desc1, dialog_mode)
-
- # Set
- def step4(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ActionDescriptor()
- desc2.putEnumerated(cID('Md '), cID('BlnM'), cID('CDdg'))
- desc1.putObject(cID('T '), cID('Lyr '), desc2)
- APP.executeAction(cID('setd'), desc1, dialog_mode)
-
- # Make
- def step5(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putClass(cID('AdjL'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ActionDescriptor()
- desc3 = ActionDescriptor()
- desc3.putEnumerated(sID("presetKind"), sID("presetKindType"), sID("presetKindDefault"))
- desc2.putObject(cID('Type'), cID('Lvls'), desc3)
- desc1.putObject(cID('Usng'), cID('AdjL'), desc2)
- APP.executeAction(cID('Mk '), desc1, dialog_mode)
-
- # Set
- def step6(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putEnumerated(cID('AdjL'), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ActionDescriptor()
- desc2.putEnumerated(sID("presetKind"), sID("presetKindType"), sID("presetKindCustom"))
- list1 = ActionList()
- desc3 = ActionDescriptor()
- ref2 = ActionReference()
- ref2.putEnumerated(cID('Chnl'), cID('Chnl'), cID('Cmps'))
- desc3.putReference(cID('Chnl'), ref2)
- list2 = ActionList()
- list2.putInteger(91)
- list2.putInteger(255)
- desc3.putList(cID('Inpt'), list2)
- desc3.putDouble(cID('Gmm '), 0.66)
- list1.putObject(cID('LvlA'), desc3)
- desc2.putList(cID('Adjs'), list1)
- desc1.putObject(cID('T '), cID('Lvls'), desc2)
- APP.executeAction(cID('setd'), desc1, dialog_mode)
-
- # Make
- def step7(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putClass(cID('AdjL'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ActionDescriptor()
- desc3 = ActionDescriptor()
- desc3.putEnumerated(sID("presetKind"), sID("presetKindType"), sID("presetKindDefault"))
- desc3.putInteger(cID('Rd '), 40)
- desc3.putInteger(cID('Yllw'), 60)
- desc3.putInteger(cID('Grn '), 40)
- desc3.putInteger(cID('Cyn '), 60)
- desc3.putInteger(cID('Bl '), 20)
- desc3.putInteger(cID('Mgnt'), 80)
- desc3.putBoolean(sID("useTint"), False)
- desc4 = ActionDescriptor()
- desc4.putDouble(cID('Rd '), 225)
- desc4.putDouble(cID('Grn '), 211)
- desc4.putDouble(cID('Bl '), 179)
- desc3.putObject(sID("tintColor"), sID("RGBColor"), desc4)
- desc2.putObject(cID('Type'), cID('BanW'), desc3)
- desc1.putObject(cID('Usng'), cID('AdjL'), desc2)
- APP.executeAction(cID('Mk '), desc1, dialog_mode)
-
- # Set
- def step8(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putProperty(cID('Chnl'), sID("selection"))
- desc1.putReference(cID('null'), ref1)
- desc1.putEnumerated(cID('T '), cID('Ordn'), cID('Al '))
- APP.executeAction(cID('setd'), desc1, dialog_mode)
-
- # Copy Merged
- def step9(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- APP.executeAction(sID('copyMerged'), ActionDescriptor(), dialog_mode)
-
- # Paste
- def step10(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- desc1.putEnumerated(cID('AntA'), cID('Annt'), cID('Anno'))
- desc1.putClass(cID('As '), cID('Pxel'))
- APP.executeAction(cID('past'), desc1, dialog_mode)
-
- # Filter Gallery
- def step11(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- desc1.putEnumerated(cID('GEfk'), cID('GEft'), cID('GlwE'))
- desc1.putInteger(cID('EdgW'), 1)
- desc1.putInteger(cID('EdgB'), 20)
- desc1.putInteger(cID('Smth'), 15)
- APP.executeAction(1195730531, desc1, dialog_mode)
-
- # Invert
- def step12(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- APP.executeAction(cID('Invr'), ActionDescriptor(), dialog_mode)
-
- # Blend mode "Multiply"
- # Step 13 and Step 20
- def blend_mode_multiply(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ActionDescriptor()
- desc2.putEnumerated(cID('Md '), cID('BlnM'), cID('Mltp'))
- desc1.putObject(cID('T '), cID('Lyr '), desc2)
- APP.executeAction(cID('setd'), desc1, dialog_mode)
-
- # Set
- def step14(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ActionDescriptor()
- desc2.putUnitDouble(cID('Opct'), cID('#Prc'), 60)
- desc1.putObject(cID('T '), cID('Lyr '), desc2)
- APP.executeAction(cID('setd'), desc1, dialog_mode)
-
- # Select
- def step15(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putName(cID('Lyr '), "Black & White 1")
- desc1.putReference(cID('null'), ref1)
- desc1.putBoolean(cID('MkVs'), False)
- list1 = ActionList()
- list1.putInteger(6)
- desc1.putList(cID('LyrI'), list1)
- APP.executeAction(cID('slct'), desc1, dialog_mode)
-
- # Make
- def step16(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putClass(cID('Lyr '))
- desc1.putReference(cID('null'), ref1)
- desc1.putInteger(cID('LyrI'), 8)
- APP.executeAction(cID('Mk '), desc1, dialog_mode)
-
- # Select
- def step17(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putName(cID('Lyr '), "Layer 2")
- desc1.putReference(cID('null'), ref1)
- desc1.putBoolean(cID('MkVs'), False)
- list1 = ActionList()
- list1.putInteger(7)
- desc1.putList(cID('LyrI'), list1)
- APP.executeAction(cID('slct'), desc1, dialog_mode)
-
- # Fill
- def step18(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- desc1.putEnumerated(cID('Usng'), cID('FlCn'), cID('BckC'))
- APP.executeAction(cID('Fl '), desc1, dialog_mode)
-
- # Filter Gallery
- def step19(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- desc1.putEnumerated(cID('GEfk'), cID('GEft'), cID('Txtz'))
- desc1.putEnumerated(cID('TxtT'), cID('TxtT'), cID('TxSt'))
- desc1.putInteger(cID('Scln'), 100)
- desc1.putInteger(cID('Rlf '), 4)
- desc1.putEnumerated(cID('LghD'), cID('LghD'), cID('LDTp'))
- desc1.putBoolean(cID('InvT'), False)
- APP.executeAction(1195730531, desc1, dialog_mode)
-
- # Set
- def step21(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ActionDescriptor()
- desc2.putUnitDouble(cID('Opct'), cID('#Prc'), 70)
- desc1.putObject(cID('T '), cID('Lyr '), desc2)
- APP.executeAction(cID('setd'), desc1, dialog_mode)
-
- # Select
- def step22(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putName(cID('Lyr '), "Black & White 1")
- desc1.putReference(cID('null'), ref1)
- desc1.putBoolean(cID('MkVs'), False)
- list1 = ActionList()
- list1.putInteger(6)
- desc1.putList(cID('LyrI'), list1)
- APP.executeAction(cID('slct'), desc1, dialog_mode)
-
- # Set
- def step23(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ActionDescriptor()
- desc2.putUnitDouble(cID('Opct'), cID('#Prc'), 50)
- desc1.putObject(cID('T '), cID('Lyr '), desc2)
- APP.executeAction(cID('setd'), desc1, dialog_mode)
-
- # Select
- def step24(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putName(cID('Lyr '), "Background")
- desc1.putReference(cID('null'), ref1)
- desc1.putBoolean(cID('MkVs'), False)
- list1 = ActionList()
- list1.putInteger(1)
- desc1.putList(cID('LyrI'), list1)
- APP.executeAction(cID('slct'), desc1, dialog_mode)
-
- # Duplicate
- def step25(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc1.putString(cID('Nm '), "top adjustment")
- desc1.putInteger(cID('Vrsn'), 5)
- list1 = ActionList()
- list1.putInteger(35)
- desc1.putList(cID('Idnt'), list1)
- APP.executeAction(cID('Dplc'), desc1, dialog_mode)
-
- # Move
- def step26(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- ref2 = ActionReference()
- ref2.putIndex(cID('Lyr '), 7)
- desc1.putReference(cID('T '), ref2)
- desc1.putBoolean(cID('Adjs'), False)
- desc1.putInteger(cID('Vrsn'), 5)
- list1 = ActionList()
- list1.putInteger(35)
- desc1.putList(cID('LyrI'), list1)
- APP.executeAction(cID('move'), desc1, dialog_mode)
-
- # Set
- def step27(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ActionDescriptor()
- desc2.putUnitDouble(cID('Opct'), cID('#Prc'), 40)
- desc1.putObject(cID('T '), cID('Lyr '), desc2)
- APP.executeAction(cID('setd'), desc1, dialog_mode)
-
- # Set
- def step28(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref1.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('null'), ref1)
- desc2 = ActionDescriptor()
- desc2.putEnumerated(cID('Md '), cID('BlnM'), cID('HrdL'))
- desc1.putObject(cID('T '), cID('Lyr '), desc2)
- APP.executeAction(cID('setd'), desc1, dialog_mode)
-
- # Merge Visible
- def step29(enabled: bool = True, dialog: bool = False):
- if not enabled:
- return
- dialog_mode = DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
- APP.executeAction(sID('mergeVisible'), ActionDescriptor(), dialog_mode)
-
- # Run each step
- step1() # Duplicate
- step2() # Invert
- step3() # Gaussian Blur
- step4() # Set
- step5() # Make
- step6() # Set
- step7() # Make
- step8() # Set
- step9() # Copy Merged
- step10() # Paste
- step11() # Filter Gallery
- step12() # Invert
- blend_mode_multiply() # Set
- step14() # Set
- step15() # Select
- step16() # Make
- step17() # Select
- step18() # Fill
- step19() # Filter Gallery
- blend_mode_multiply() # Set
- step21() # Set
- step22() # Select
- step23() # Set
- step24() # Select
- step25() # Duplicate
- step26() # Move
- step27() # Set
- step28() # Set
- step29() # Merge
+ """Trix old sketchify Steps."""
+
+ # Duplicate
+ def step1(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putProperty(APP.instance.cID("Lyr "), APP.instance.cID("Bckg"))
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc1.putString(APP.instance.cID("Nm "), "Layer 1 copy")
+ desc1.putInteger(APP.instance.cID("Vrsn"), 5)
+ APP.instance.executeAction(APP.instance.cID("Dplc"), desc1, dialog_mode)
+
+ # Invert
+ def step2(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ APP.instance.executeAction(
+ APP.instance.cID("Invr"), ActionDescriptor(), dialog_mode
+ )
+
+ # Gaussian Blur
+ def step3(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ desc1.putUnitDouble(APP.instance.cID("Rds "), APP.instance.cID("#Pxl"), 65)
+ APP.instance.executeAction(APP.instance.sID("gaussianBlur"), desc1, dialog_mode)
+
+ # Set
+ def step4(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ActionDescriptor()
+ desc2.putEnumerated(
+ APP.instance.cID("Md "), APP.instance.cID("BlnM"), APP.instance.cID("CDdg")
+ )
+ desc1.putObject(APP.instance.cID("T "), APP.instance.cID("Lyr "), desc2)
+ APP.instance.executeAction(APP.instance.cID("setd"), desc1, dialog_mode)
+
+ # Make
+ def step5(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putClass(APP.instance.cID("AdjL"))
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ActionDescriptor()
+ desc3 = ActionDescriptor()
+ desc3.putEnumerated(
+ APP.instance.sID("presetKind"),
+ APP.instance.sID("presetKindType"),
+ APP.instance.sID("presetKindDefault"),
+ )
+ desc2.putObject(APP.instance.cID("Type"), APP.instance.cID("Lvls"), desc3)
+ desc1.putObject(APP.instance.cID("Usng"), APP.instance.cID("AdjL"), desc2)
+ APP.instance.executeAction(APP.instance.cID("Mk "), desc1, dialog_mode)
+
+ # Set
+ def step6(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("AdjL"), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ActionDescriptor()
+ desc2.putEnumerated(
+ APP.instance.sID("presetKind"),
+ APP.instance.sID("presetKindType"),
+ APP.instance.sID("presetKindCustom"),
+ )
+ list1 = ActionList()
+ desc3 = ActionDescriptor()
+ ref2 = ActionReference()
+ ref2.putEnumerated(
+ APP.instance.cID("Chnl"), APP.instance.cID("Chnl"), APP.instance.cID("Cmps")
+ )
+ desc3.putReference(APP.instance.cID("Chnl"), ref2)
+ list2 = ActionList()
+ list2.putInteger(91)
+ list2.putInteger(255)
+ desc3.putList(APP.instance.cID("Inpt"), list2)
+ desc3.putDouble(APP.instance.cID("Gmm "), 0.66)
+ list1.putObject(APP.instance.cID("LvlA"), desc3)
+ desc2.putList(APP.instance.cID("Adjs"), list1)
+ desc1.putObject(APP.instance.cID("T "), APP.instance.cID("Lvls"), desc2)
+ APP.instance.executeAction(APP.instance.cID("setd"), desc1, dialog_mode)
+
+ # Make
+ def step7(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putClass(APP.instance.cID("AdjL"))
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ActionDescriptor()
+ desc3 = ActionDescriptor()
+ desc3.putEnumerated(
+ APP.instance.sID("presetKind"),
+ APP.instance.sID("presetKindType"),
+ APP.instance.sID("presetKindDefault"),
+ )
+ desc3.putInteger(APP.instance.cID("Rd "), 40)
+ desc3.putInteger(APP.instance.cID("Yllw"), 60)
+ desc3.putInteger(APP.instance.cID("Grn "), 40)
+ desc3.putInteger(APP.instance.cID("Cyn "), 60)
+ desc3.putInteger(APP.instance.cID("Bl "), 20)
+ desc3.putInteger(APP.instance.cID("Mgnt"), 80)
+ desc3.putBoolean(APP.instance.sID("useTint"), False)
+ desc4 = ActionDescriptor()
+ desc4.putDouble(APP.instance.cID("Rd "), 225)
+ desc4.putDouble(APP.instance.cID("Grn "), 211)
+ desc4.putDouble(APP.instance.cID("Bl "), 179)
+ desc3.putObject(
+ APP.instance.sID("tintColor"), APP.instance.sID("RGBColor"), desc4
+ )
+ desc2.putObject(APP.instance.cID("Type"), APP.instance.cID("BanW"), desc3)
+ desc1.putObject(APP.instance.cID("Usng"), APP.instance.cID("AdjL"), desc2)
+ APP.instance.executeAction(APP.instance.cID("Mk "), desc1, dialog_mode)
+
+ # Set
+ def step8(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putProperty(APP.instance.cID("Chnl"), APP.instance.sID("selection"))
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc1.putEnumerated(
+ APP.instance.cID("T "), APP.instance.cID("Ordn"), APP.instance.cID("Al ")
+ )
+ APP.instance.executeAction(APP.instance.cID("setd"), desc1, dialog_mode)
+
+ # Copy Merged
+ def step9(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ APP.instance.executeAction(
+ APP.instance.sID("copyMerged"), ActionDescriptor(), dialog_mode
+ )
+
+ # Paste
+ def step10(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("AntA"), APP.instance.cID("Annt"), APP.instance.cID("Anno")
+ )
+ desc1.putClass(APP.instance.cID("As "), APP.instance.cID("Pxel"))
+ APP.instance.executeAction(APP.instance.cID("past"), desc1, dialog_mode)
+
+ # Filter Gallery
+ def step11(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("GlwE")
+ )
+ desc1.putInteger(APP.instance.cID("EdgW"), 1)
+ desc1.putInteger(APP.instance.cID("EdgB"), 20)
+ desc1.putInteger(APP.instance.cID("Smth"), 15)
+ APP.instance.executeAction(1195730531, desc1, dialog_mode)
+
+ # Invert
+ def step12(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ APP.instance.executeAction(
+ APP.instance.cID("Invr"), ActionDescriptor(), dialog_mode
+ )
+
+ # Blend mode "Multiply"
+ # Step 13 and Step 20
+ def blend_mode_multiply(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ActionDescriptor()
+ desc2.putEnumerated(
+ APP.instance.cID("Md "), APP.instance.cID("BlnM"), APP.instance.cID("Mltp")
+ )
+ desc1.putObject(APP.instance.cID("T "), APP.instance.cID("Lyr "), desc2)
+ APP.instance.executeAction(APP.instance.cID("setd"), desc1, dialog_mode)
+
+ # Set
+ def step14(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ActionDescriptor()
+ desc2.putUnitDouble(APP.instance.cID("Opct"), APP.instance.cID("#Prc"), 60)
+ desc1.putObject(APP.instance.cID("T "), APP.instance.cID("Lyr "), desc2)
+ APP.instance.executeAction(APP.instance.cID("setd"), desc1, dialog_mode)
+
+ # Select
+ def step15(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putName(APP.instance.cID("Lyr "), "Black & White 1")
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc1.putBoolean(APP.instance.cID("MkVs"), False)
+ list1 = ActionList()
+ list1.putInteger(6)
+ desc1.putList(APP.instance.cID("LyrI"), list1)
+ APP.instance.executeAction(APP.instance.cID("slct"), desc1, dialog_mode)
+
+ # Make
+ def step16(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putClass(APP.instance.cID("Lyr "))
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc1.putInteger(APP.instance.cID("LyrI"), 8)
+ APP.instance.executeAction(APP.instance.cID("Mk "), desc1, dialog_mode)
+
+ # Select
+ def step17(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putName(APP.instance.cID("Lyr "), "Layer 2")
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc1.putBoolean(APP.instance.cID("MkVs"), False)
+ list1 = ActionList()
+ list1.putInteger(7)
+ desc1.putList(APP.instance.cID("LyrI"), list1)
+ APP.instance.executeAction(APP.instance.cID("slct"), desc1, dialog_mode)
+
+ # Fill
+ def step18(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("Usng"), APP.instance.cID("FlCn"), APP.instance.cID("BckC")
+ )
+ APP.instance.executeAction(APP.instance.cID("Fl "), desc1, dialog_mode)
+
+ # Filter Gallery
+ def step19(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ desc1.putEnumerated(
+ APP.instance.cID("GEfk"), APP.instance.cID("GEft"), APP.instance.cID("Txtz")
+ )
+ desc1.putEnumerated(
+ APP.instance.cID("TxtT"), APP.instance.cID("TxtT"), APP.instance.cID("TxSt")
+ )
+ desc1.putInteger(APP.instance.cID("Scln"), 100)
+ desc1.putInteger(APP.instance.cID("Rlf "), 4)
+ desc1.putEnumerated(
+ APP.instance.cID("LghD"), APP.instance.cID("LghD"), APP.instance.cID("LDTp")
+ )
+ desc1.putBoolean(APP.instance.cID("InvT"), False)
+ APP.instance.executeAction(1195730531, desc1, dialog_mode)
+
+ # Set
+ def step21(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ActionDescriptor()
+ desc2.putUnitDouble(APP.instance.cID("Opct"), APP.instance.cID("#Prc"), 70)
+ desc1.putObject(APP.instance.cID("T "), APP.instance.cID("Lyr "), desc2)
+ APP.instance.executeAction(APP.instance.cID("setd"), desc1, dialog_mode)
+
+ # Select
+ def step22(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putName(APP.instance.cID("Lyr "), "Black & White 1")
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc1.putBoolean(APP.instance.cID("MkVs"), False)
+ list1 = ActionList()
+ list1.putInteger(6)
+ desc1.putList(APP.instance.cID("LyrI"), list1)
+ APP.instance.executeAction(APP.instance.cID("slct"), desc1, dialog_mode)
+
+ # Set
+ def step23(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ActionDescriptor()
+ desc2.putUnitDouble(APP.instance.cID("Opct"), APP.instance.cID("#Prc"), 50)
+ desc1.putObject(APP.instance.cID("T "), APP.instance.cID("Lyr "), desc2)
+ APP.instance.executeAction(APP.instance.cID("setd"), desc1, dialog_mode)
+
+ # Select
+ def step24(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putName(APP.instance.cID("Lyr "), "Background")
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc1.putBoolean(APP.instance.cID("MkVs"), False)
+ list1 = ActionList()
+ list1.putInteger(1)
+ desc1.putList(APP.instance.cID("LyrI"), list1)
+ APP.instance.executeAction(APP.instance.cID("slct"), desc1, dialog_mode)
+
+ # Duplicate
+ def step25(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc1.putString(APP.instance.cID("Nm "), "top adjustment")
+ desc1.putInteger(APP.instance.cID("Vrsn"), 5)
+ list1 = ActionList()
+ list1.putInteger(35)
+ desc1.putList(APP.instance.cID("Idnt"), list1)
+ APP.instance.executeAction(APP.instance.cID("Dplc"), desc1, dialog_mode)
+
+ # Move
+ def step26(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ ref2 = ActionReference()
+ ref2.putIndex(APP.instance.cID("Lyr "), 7)
+ desc1.putReference(APP.instance.cID("T "), ref2)
+ desc1.putBoolean(APP.instance.cID("Adjs"), False)
+ desc1.putInteger(APP.instance.cID("Vrsn"), 5)
+ list1 = ActionList()
+ list1.putInteger(35)
+ desc1.putList(APP.instance.cID("LyrI"), list1)
+ APP.instance.executeAction(APP.instance.cID("move"), desc1, dialog_mode)
+
+ # Set
+ def step27(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ActionDescriptor()
+ desc2.putUnitDouble(APP.instance.cID("Opct"), APP.instance.cID("#Prc"), 40)
+ desc1.putObject(APP.instance.cID("T "), APP.instance.cID("Lyr "), desc2)
+ APP.instance.executeAction(APP.instance.cID("setd"), desc1, dialog_mode)
+
+ # Set
+ def step28(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref1.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("null"), ref1)
+ desc2 = ActionDescriptor()
+ desc2.putEnumerated(
+ APP.instance.cID("Md "), APP.instance.cID("BlnM"), APP.instance.cID("HrdL")
+ )
+ desc1.putObject(APP.instance.cID("T "), APP.instance.cID("Lyr "), desc2)
+ APP.instance.executeAction(APP.instance.cID("setd"), desc1, dialog_mode)
+
+ # Merge Visible
+ def step29(enabled: bool = True, dialog: bool = False):
+ if not enabled:
+ return
+ dialog_mode = (
+ DialogModes.DisplayAllDialogs if dialog else DialogModes.DisplayNoDialogs
+ )
+ APP.instance.executeAction(
+ APP.instance.sID("mergeVisible"), ActionDescriptor(), dialog_mode
+ )
+
+ # Run each step
+ step1() # Duplicate
+ step2() # Invert
+ step3() # Gaussian Blur
+ step4() # Set
+ step5() # Make
+ step6() # Set
+ step7() # Make
+ step8() # Set
+ step9() # Copy Merged
+ step10() # Paste
+ step11() # Filter Gallery
+ step12() # Invert
+ blend_mode_multiply() # Set
+ step14() # Set
+ step15() # Select
+ step16() # Make
+ step17() # Select
+ step18() # Fill
+ step19() # Filter Gallery
+ blend_mode_multiply() # Set
+ step21() # Set
+ step22() # Select
+ step23() # Set
+ step24() # Select
+ step25() # Duplicate
+ step26() # Move
+ step27() # Set
+ step28() # Set
+ step29() # Merge
diff --git a/plugins/Investigamer/py/templates.py b/plugins/Investigamer/py/templates.py
index 9fb2bd52..cf166d2e 100644
--- a/plugins/Investigamer/py/templates.py
+++ b/plugins/Investigamer/py/templates.py
@@ -1,110 +1,95 @@
"""
* Plugin: Investigamer
"""
-# Standard Library
+
+from collections.abc import Callable
from functools import cached_property
-from typing import Optional, Callable
-# Third Party
from photoshop.api._artlayer import ArtLayer
-# Local Imports
-from src import CFG, ENV
-from src.enums.layers import LAYERS
import src.helpers as psd
-from src.templates import TransformMod, NormalTemplate, ExtendedMod
+from src.enums.layers import LAYERS
+from src.templates import ExtendedMod, NormalTemplate, TransformMod
-# Plugin Imports
-from .actions import sketch, pencilsketch
+from .actions import pencilsketch, sketch
"""
* Template Classes
"""
-class SketchTemplate (NormalTemplate):
+class SketchTemplate(NormalTemplate):
"""
* Sketch showcase from MH2
* Original PSD by Nelynes
"""
+
template_suffix = "Sketch"
"""
* Sketch Action
"""
- @property
- def art_action(self) -> Optional[Callable]:
- # Skip action if in test mode
- if ENV.TEST_MODE:
- return
- action = CFG.get_setting(
- section="ACTION",
- key="Sketch.Action",
- default="Advanced Sketch",
- is_bool=False
+ @cached_property
+ def art_action(self) -> Callable[[], None] | None:
+ action = self.config.get_setting(
+ section="ACTION", key="Sketch.Action", default="Advanced Sketch"
)
if action == "Advanced Sketch":
- return pencilsketch.run
+ return lambda: pencilsketch.run(
+ self.render_operation,
+ draft_sketch=self.config.get_bool_setting(
+ section="ACTION", key="Draft.Sketch.Lines", default=False
+ ),
+ rough_sketch=self.config.get_bool_setting(
+ section="ACTION", key="Rough.Sketch.Lines", default=False
+ ),
+ black_and_white=self.config.get_bool_setting(
+ section="ACTION", key="Black.And.White", default=False
+ ),
+ manual_editing=self.config.get_bool_setting(
+ section="ACTION", key="Sketch.Manual.Editing", default=False
+ ),
+ )
if action == "Quick Sketch":
return sketch.run
return
- @property
- def art_action_args(self) -> Optional[dict]:
- # Skip if in test mode or using quick sketch
- if ENV.TEST_MODE or not self.art_action == pencilsketch.run:
- return
- return {
- 'thr': self.event,
- 'rough_sketch': CFG.get_setting(
- section="ACTION",
- key="Rough.Sketch.Lines",
- default=False
- ),
- 'draft_sketch': CFG.get_setting(
- section="ACTION",
- key="Draft.Sketch.Lines",
- default=False
- ),
- 'black_and_white': CFG.get_setting(
- section="ACTION",
- key="Black.And.White",
- default=False
- ),
- 'manual_editing': CFG.get_setting(
- section="ACTION",
- key="Sketch.Manual.Editing",
- default=False
- )
- }
-
-class KaldheimTemplate (NormalTemplate):
+class KaldheimTemplate(NormalTemplate):
"""
Kaldheim viking legendary showcase.
Original Template by FeuerAmeise
"""
+
template_suffix = "Kaldheim"
# Static Properties
- is_legendary = False
- background_layer = None
- twins_layer = None
+ @cached_property
+ def is_legendary(self) -> bool:
+ return False
+
+ @cached_property
+ def background_layer(self) -> ArtLayer | None:
+ return None
+
+ @cached_property
+ def twins_layer(self) -> ArtLayer | None:
+ return None
"""
* Layer Properties
"""
@cached_property
- def pt_layer(self) -> Optional[ArtLayer]:
+ def pt_layer(self) -> ArtLayer | None:
# Enable vehicle support
if "Vehicle" in self.layout.type_line:
return psd.getLayer("Vehicle", LAYERS.PT_BOX)
return psd.getLayer(self.twins, LAYERS.PT_BOX)
@cached_property
- def pinlines_layer(self) -> Optional[ArtLayer]:
+ def pinlines_layer(self) -> ArtLayer | None:
# Enable vehicle support
if self.is_land:
return psd.getLayer(self.pinlines, LAYERS.LAND_PINLINES_TEXTBOX)
@@ -113,23 +98,26 @@ def pinlines_layer(self) -> Optional[ArtLayer]:
return psd.getLayer(self.pinlines, LAYERS.PINLINES_TEXTBOX)
-class CrimsonFangTemplate (TransformMod, NormalTemplate):
+class CrimsonFangTemplate(TransformMod, NormalTemplate):
"""The crimson vow showcase template. Original template by michayggdrasil.
Notes:
Transform features are kind of unfinished.
"""
+
template_suffix = "Fang"
# Static Properties
- is_flipside_creature = False
+ @cached_property
+ def is_flipside_creature(self) -> bool:
+ return False
"""
* Details
"""
- @property
- def background(self):
+ @cached_property
+ def background(self) -> str:
# Use pinlines colors for background
return self.pinlines
@@ -138,7 +126,7 @@ def background(self):
"""
@cached_property
- def pinlines_layer(self) -> Optional[ArtLayer]:
+ def pinlines_layer(self) -> ArtLayer | None:
# Support backside colors
if self.is_land:
return psd.getLayer(self.pinlines, LAYERS.LAND_PINLINES_TEXTBOX)
@@ -148,7 +136,8 @@ def pinlines_layer(self) -> Optional[ArtLayer]:
def enable_transform_layers(self):
# Enable circle backing
- psd.getLayerSet(LAYERS.TRANSFORM, self.text_group).visible = True
+ if layer := psd.getLayerSet(LAYERS.TRANSFORM, self.text_group):
+ layer.visible = True
super().enable_transform_layers()
def text_layers_transform(self) -> None:
@@ -156,33 +145,45 @@ def text_layers_transform(self) -> None:
pass
-class PhyrexianTemplate (NormalTemplate):
+class PhyrexianTemplate(NormalTemplate):
"""From the Phyrexian secret lair promo."""
+
template_suffix = "Phyrexian"
# Static Properties
- background_layer = None
- twins_layer = None
+ @cached_property
+ def background_layer(self) -> ArtLayer | None:
+ return None
+
+ @cached_property
+ def twins_layer(self) -> ArtLayer | None:
+ return None
-class DoubleFeatureTemplate (NormalTemplate):
+class DoubleFeatureTemplate(NormalTemplate):
"""
Midnight Hunt / Vow Double Feature Showcase
Original assets from Warpdandy's Proximity Template
Doesn't support companion, nyx, or twins layers.
"""
+
template_suffix = "Double Feature"
# Static Properties
- pinlines_layer = None
- twins_layer = None
+ @cached_property
+ def pinlines_layer(self) -> ArtLayer | None:
+ return None
+
+ @cached_property
+ def twins_layer(self) -> ArtLayer | None:
+ return None
"""
* Layer Properties
"""
- @property
- def background_layer(self) -> Optional[ArtLayer]:
+ @cached_property
+ def background_layer(self) -> ArtLayer | None:
return psd.getLayer(self.pinlines, LAYERS.BACKGROUND)
@@ -191,24 +192,30 @@ def background_layer(self) -> Optional[ArtLayer]:
"""
-class ColorshiftedTemplate (NormalTemplate):
+class ColorshiftedTemplate(NormalTemplate):
"""
Planar Chaos era colorshifted template
Rendered from CC and MSE assets. Most title boxes are built into pinlines.
Doesn't support special layers for nyx, companion, land, or colorless.
"""
+
template_suffix = "Colorshifted"
# Static Properties
- is_land = False
- is_colorless = False
+ @cached_property
+ def is_land(self) -> bool:
+ return False
+
+ @cached_property
+ def is_colorless(self) -> bool:
+ return False
"""
* Layer Properties
"""
@cached_property
- def twins_layer(self) -> Optional[ArtLayer]:
+ def twins_layer(self) -> ArtLayer | None:
if "Artifact" in self.layout.type_line and self.pinlines != "Artifact":
if self.is_legendary:
return psd.getLayer("Legendary Artifact", "Twins")
@@ -220,29 +227,31 @@ def twins_layer(self) -> Optional[ArtLayer]:
return
@cached_property
- def pt_layer(self) -> Optional[ArtLayer]:
+ def pt_layer(self) -> ArtLayer | None:
if self.is_creature:
# Check if vehicle
if "Vehicle" in self.layout.type_line:
return psd.getLayer("Vehicle", LAYERS.PT_BOX)
return psd.getLayer(self.twins, LAYERS.PT_BOX)
- return psd.getLayerSet(LAYERS.PT_BOX)
"""
* Methods
"""
def collector_info(self):
-
# Artist and set layer
artist_layer = psd.getLayer(LAYERS.ARTIST, self.legal_group)
- psd.replace_text(artist_layer, "Artist", self.layout.artist)
+ if artist_layer:
+ psd.replace_text(artist_layer, "Artist", self.layout.artist)
# Switch to white brush and artist name
if self.layout.pinlines[0:1] == "B" and len(self.pinlines) < 3:
- artist_layer.textItem.color = psd.rgb_white()
- psd.getLayer("Brush B", self.legal_group).visible = False
- psd.getLayer("Brush W", self.legal_group).visible = True
+ if artist_layer:
+ artist_layer.textItem.color = psd.rgb_white()
+ if layer := psd.getLayer("Brush B", self.legal_group):
+ layer.visible = False
+ if layer := psd.getLayer("Brush W", self.legal_group):
+ layer.visible = True
"""
@@ -250,14 +259,16 @@ def collector_info(self):
"""
-class BasicLandDarkMode (ExtendedMod, NormalTemplate):
+class BasicLandDarkMode(ExtendedMod, NormalTemplate):
"""Basic land Dark Mode. Credit to Vittorio Masia (Sid)
Todo:
Transition to 'Normal' type.
"""
+
template_suffix = "Dark Mode"
def collector_info(self):
# Collector info only has artist
- psd.getLayer(LAYERS.ARTIST, self.legal_group).textItem.contents = self.layout.artist
+ if layer := psd.getLayer(LAYERS.ARTIST, self.legal_group):
+ layer.textItem.contents = self.layout.artist
diff --git a/plugins/SilvanMTG/manifest.yml b/plugins/SilvanMTG/manifest.yml
index b7a6ebcc..45abeee7 100644
--- a/plugins/SilvanMTG/manifest.yml
+++ b/plugins/SilvanMTG/manifest.yml
@@ -3,37 +3,40 @@
###
PLUGIN:
- name: 'SilvanMTG'
- author: 'SilvanMTG'
+ name: "SilvanMTG"
+ author: "SilvanMTG"
desc: "A plugin for SilvanMTG's legendary extended templates."
- license: 'MPL-2.0'
- requires: '^1.13.0'
- version: '1.0.0'
+ license: "MPL-2.0"
+ requires: "^1.13.0"
+ version: "1.0.0"
###
# * Templates
###
extended.psd:
- name: 'Silvan Extended'
- id: '1RloUNFCkOJE1QUwJ0YDtfysCDBYlY9kn'
+ name: "Silvan Extended"
+ id: "1RloUNFCkOJE1QUwJ0YDtfysCDBYlY9kn"
desc: "Silvan's original Extended template."
- templates: { Silvan Extended: SilvanExtendedTemplate }
+ templates:
+ Silvan Extended:
+ SilvanExtendedTemplate:
+ - normal
extended-mdfc-front.psd:
- name: 'Silvan Extended (MDFC Front)'
- id: '17ONVdUMGAv-lj0od_iNjgWT7mmW9725M'
+ name: "Silvan Extended (MDFC Front)"
+ id: "17ONVdUMGAv-lj0od_iNjgWT7mmW9725M"
desc: "Silvan's original Extended template, in MDFC (Front) treatment."
templates:
Silvan Extended:
SilvanMDFCTemplate:
- - mdfc_front
+ - mdfc_front
extended-mdfc-back.psd:
- name: 'Silvan Extended (MDFC Back)'
- id: '10sx3CLO7det1oQl3gfuBoD016bHkPQEa'
+ name: "Silvan Extended (MDFC Back)"
+ id: "10sx3CLO7det1oQl3gfuBoD016bHkPQEa"
desc: "Silvan's original Extended template, in MDFC (Back) treatment."
templates:
Silvan Extended:
SilvanMDFCTemplate:
- - mdfc_back
+ - mdfc_back
diff --git a/plugins/SilvanMTG/py/__init__.py b/plugins/SilvanMTG/py/__init__.py
index 58225800..53baca7d 100644
--- a/plugins/SilvanMTG/py/__init__.py
+++ b/plugins/SilvanMTG/py/__init__.py
@@ -1,4 +1,5 @@
"""
* Import all Template Classes
"""
+
from .templates import *
diff --git a/plugins/SilvanMTG/py/templates.py b/plugins/SilvanMTG/py/templates.py
index c90b6e0b..cb8acc8c 100644
--- a/plugins/SilvanMTG/py/templates.py
+++ b/plugins/SilvanMTG/py/templates.py
@@ -1,29 +1,28 @@
"""
* SilvanMTG Templates
"""
-# Standard Library Imports
+
from functools import cached_property
-from typing import Optional
-# Third Party Imports
from photoshop.api._artlayer import ArtLayer
+from photoshop.api._layerSet import LayerSet
-# Local Imports
-from src.enums.layers import LAYERS
import src.helpers as psd
-from src.templates import MDFCMod, ExtendedMod, M15Template
+from src.enums.layers import LAYERS
+from src.templates import ExtendedMod, M15Template, MDFCMod
"""
* Template Classes
"""
-class SilvanExtendedTemplate (ExtendedMod, M15Template):
+class SilvanExtendedTemplate(ExtendedMod, M15Template):
"""Silvan's legendary extended template used for WillieTanner proxies."""
+
template_suffix = "Extended"
@cached_property
- def background_layer(self) -> Optional[ArtLayer]:
+ def background_layer(self) -> ArtLayer | None:
"""Optional[ArtLayer]: No background for colorless cards."""
if self.background == LAYERS.COLORLESS:
return
@@ -32,16 +31,24 @@ def background_layer(self) -> Optional[ArtLayer]:
def enable_crown(self) -> None:
"""Add a mask to the background layer."""
super().enable_crown()
- if self.background_layer:
- psd.enable_mask(self.background_layer.parent)
+ if self.background_layer and isinstance(
+ (parent := self.background_layer.parent), LayerSet
+ ):
+ psd.enable_mask(parent)
-class SilvanMDFCTemplate (MDFCMod, ExtendedMod, M15Template):
+class SilvanMDFCTemplate(MDFCMod, ExtendedMod, M15Template):
"""Silvan extended template modified for MDFC cards."""
def enable_crown(self) -> None:
super().enable_crown()
# Enable pinlines and background mask
- psd.enable_vector_mask(self.pinlines_layer.parent)
- psd.enable_vector_mask(self.background_layer.parent)
+ if self.pinlines_layer and isinstance(
+ (parent := self.pinlines_layer.parent), LayerSet
+ ):
+ psd.enable_vector_mask(parent)
+ if self.background_layer and isinstance(
+ (parent := self.background_layer.parent), LayerSet
+ ):
+ psd.enable_vector_mask(parent)
diff --git a/poetry.lock b/poetry.lock
deleted file mode 100644
index e54f74e1..00000000
--- a/poetry.lock
+++ /dev/null
@@ -1,4068 +0,0 @@
-# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
-
-[[package]]
-name = "aggdraw"
-version = "1.3.19"
-description = "High quality drawing interface for PIL."
-optional = false
-python-versions = ">=3.9"
-files = [
- {file = "aggdraw-1.3.19-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:cfbb072ee0a6de6cb6a782724df6e8ab9648d9566fb033118dcebbe9dc06326e"},
- {file = "aggdraw-1.3.19-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:789fc905d0bad232fe4d783ee115ed34a1c0904957dc384fc5c45a40df517975"},
- {file = "aggdraw-1.3.19-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30d783a7bfb2ff2ac43ce080ff18fb5df8b96ad4c0988c88324037855310e665"},
- {file = "aggdraw-1.3.19-cp310-cp310-win_amd64.whl", hash = "sha256:4483393bc69392287d1c8159a0c475c51ce2f687b1099d686a3c5b2028e25b0f"},
- {file = "aggdraw-1.3.19-cp310-cp310-win_arm64.whl", hash = "sha256:67f28103a619ebf7068421dfee7bd3d6cf2528887bfb5d16ecf952da10fd6f93"},
- {file = "aggdraw-1.3.19-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a9b6be919955e9ace24a22ca1172786902254db22f6789a0bb8ce08bfb86603f"},
- {file = "aggdraw-1.3.19-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c350f9403749e2fa33525bf9116a154ea5c7fddbb9fe045448b7700b62d882d"},
- {file = "aggdraw-1.3.19-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5c47a5a4c9d1e0902b90b8b0032db55c8dc041dfc5005ff02749d8e9c0aa622"},
- {file = "aggdraw-1.3.19-cp311-cp311-win_amd64.whl", hash = "sha256:b3bace3619f78891dbbeb1273409ea35d104880e5c22b0435808508c4c2f6a0e"},
- {file = "aggdraw-1.3.19-cp311-cp311-win_arm64.whl", hash = "sha256:178784eb812dacfe3069fcbe4f4ef11877130803141b8f41b89535fbec0d0759"},
- {file = "aggdraw-1.3.19-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:e68afe82d0bbd111b2d6d7f41ac8cf13a010cd65ad095117c2e9fd8252710a73"},
- {file = "aggdraw-1.3.19-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f3ff2595490b3760658a28afdca5111ed787bd1fccdab5c81d0ea57e0fe5a8c"},
- {file = "aggdraw-1.3.19-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56bbcc8d4f64e60ed815419837b9d2ba579169d92c4fe19e4caa3679aaedb6aa"},
- {file = "aggdraw-1.3.19-cp312-cp312-win_amd64.whl", hash = "sha256:6a39bdf2f1043ca4148a926e35feaa1d4d592c25c8b225d8d0ad945415bab7cc"},
- {file = "aggdraw-1.3.19-cp312-cp312-win_arm64.whl", hash = "sha256:21f951129b3014b3cf678b250ba5e4345d93243602b4062804e6111319d0eaf3"},
- {file = "aggdraw-1.3.19-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f7cb8141620ec4cb3be55601115126d9a1b084ef5bdea0bb510007efa42e8cfe"},
- {file = "aggdraw-1.3.19-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aecacc4f6d13b8343ac07624576bab4dadd4528f0c47f54b2fae0656fc913670"},
- {file = "aggdraw-1.3.19-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:981210dbcbb649d80763a8ae32b845ff517103718c6bb0a95bae5b9d5d1c8cd2"},
- {file = "aggdraw-1.3.19-cp313-cp313-win_amd64.whl", hash = "sha256:41e09faf469cae11339f5d1b0680dba7ec02502efebc10086f1a142a8a39afb8"},
- {file = "aggdraw-1.3.19-cp313-cp313-win_arm64.whl", hash = "sha256:e5552ac136225692a774d481a9baa0736c68739f64b7a7ec9f4cc5f69c8a789a"},
- {file = "aggdraw-1.3.19-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:e49048918017c62ba7069d781b351d4cbe8b188c76fe9104a923e57ddc95d5d0"},
- {file = "aggdraw-1.3.19-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13a91cb1fdf32f7e5611212987901400297098c6aa403fd59bb3221ce4608663"},
- {file = "aggdraw-1.3.19-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f79e9504e5fead1881d0bc3b763b76fb17f649ef263cfc1d63a10a068e904f3"},
- {file = "aggdraw-1.3.19-cp39-cp39-win_amd64.whl", hash = "sha256:868c10d59daf9c6f625c4a206f384c637fdafe527c56c081bb0e567fcef17b55"},
- {file = "aggdraw-1.3.19-cp39-cp39-win_arm64.whl", hash = "sha256:f0a4fd88d737719071cbfb1eba7520ca6e64c118b71b72525944b8d7bb20327c"},
- {file = "aggdraw-1.3.19.tar.gz", hash = "sha256:e78a23b29fb66a079832bae5604f082bfa4ff9d5d469c77506a67253d7fee7db"},
-]
-
-[[package]]
-name = "altgraph"
-version = "0.17.4"
-description = "Python graph (network) package"
-optional = false
-python-versions = "*"
-files = [
- {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"},
- {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"},
-]
-
-[[package]]
-name = "annotated-types"
-version = "0.7.0"
-description = "Reusable constraint types to use with typing.Annotated"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
- {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
-]
-
-[[package]]
-name = "argcomplete"
-version = "3.5.1"
-description = "Bash tab completion for argparse"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "argcomplete-3.5.1-py3-none-any.whl", hash = "sha256:1a1d148bdaa3e3b93454900163403df41448a248af01b6e849edc5ac08e6c363"},
- {file = "argcomplete-3.5.1.tar.gz", hash = "sha256:eb1ee355aa2557bd3d0145de7b06b2a45b0ce461e1e7813f5d066039ab4177b4"},
-]
-
-[package.extras]
-test = ["coverage", "mypy", "pexpect", "ruff", "wheel"]
-
-[[package]]
-name = "asyncgui"
-version = "0.7.0"
-description = "A thin layer that helps to wrap a callback-style API in an async/await-style API"
-optional = false
-python-versions = "<4.0,>=3.9"
-files = [
- {file = "asyncgui-0.7.0-py3-none-any.whl", hash = "sha256:c1e53b5c0c4df8817b28aa58bda3bc13752ea65519b14c2d7fa1c1721c9bb362"},
- {file = "asyncgui-0.7.0.tar.gz", hash = "sha256:6a5cfdfee34f6478e857a55ff88981995479930c3ddc577c5ff9d85a79f12a4a"},
-]
-
-[package.dependencies]
-exceptiongroup = {version = ">=1.0.4,<2.0.0", markers = "python_version < \"3.11\""}
-
-[[package]]
-name = "asynckivy"
-version = "0.7.0"
-description = "Async library for Kivy"
-optional = false
-python-versions = "<4.0,>=3.9"
-files = [
- {file = "asynckivy-0.7.0-py3-none-any.whl", hash = "sha256:4855d730ff3283203dcb325bc4018301737a307732f1075b6ef61c2db590bd10"},
- {file = "asynckivy-0.7.0.tar.gz", hash = "sha256:83396cecceb513393040b40a3cf6d920ed6190e78660aae5edf48c9d3fd1140b"},
-]
-
-[package.dependencies]
-asyncgui = ">=0.7,<0.8"
-
-[[package]]
-name = "attrs"
-version = "24.2.0"
-description = "Classes Without Boilerplate"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"},
- {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"},
-]
-
-[package.extras]
-benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
-cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
-dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
-docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
-tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
-tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"]
-
-[[package]]
-name = "babel"
-version = "2.16.0"
-description = "Internationalization utilities"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"},
- {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"},
-]
-
-[package.extras]
-dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"]
-
-[[package]]
-name = "backoff"
-version = "2.2.1"
-description = "Function decoration for backoff and retry"
-optional = false
-python-versions = ">=3.7,<4.0"
-files = [
- {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"},
- {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"},
-]
-
-[[package]]
-name = "beautifulsoup4"
-version = "4.12.3"
-description = "Screen-scraping library"
-optional = false
-python-versions = ">=3.6.0"
-files = [
- {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"},
- {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"},
-]
-
-[package.dependencies]
-soupsieve = ">1.2"
-
-[package.extras]
-cchardet = ["cchardet"]
-chardet = ["chardet"]
-charset-normalizer = ["charset-normalizer"]
-html5lib = ["html5lib"]
-lxml = ["lxml"]
-
-[[package]]
-name = "bracex"
-version = "2.5.post1"
-description = "Bash style brace expander."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "bracex-2.5.post1-py3-none-any.whl", hash = "sha256:13e5732fec27828d6af308628285ad358047cec36801598368cb28bc631dbaf6"},
- {file = "bracex-2.5.post1.tar.gz", hash = "sha256:12c50952415bfa773d2d9ccb8e79651b8cdb1f31a42f6091b804f6ba2b4a66b6"},
-]
-
-[[package]]
-name = "brotli"
-version = "1.1.0"
-description = "Python bindings for the Brotli compression library"
-optional = false
-python-versions = "*"
-files = [
- {file = "Brotli-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752"},
- {file = "Brotli-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9"},
- {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3"},
- {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d"},
- {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e"},
- {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da"},
- {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80"},
- {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d"},
- {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0"},
- {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e"},
- {file = "Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2"},
- {file = "Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128"},
- {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc"},
- {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6"},
- {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd"},
- {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf"},
- {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61"},
- {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327"},
- {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd"},
- {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9"},
- {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265"},
- {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8"},
- {file = "Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50"},
- {file = "Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1"},
- {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409"},
- {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2"},
- {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451"},
- {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91"},
- {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408"},
- {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0"},
- {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc"},
- {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180"},
- {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248"},
- {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966"},
- {file = "Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0"},
- {file = "Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951"},
- {file = "Brotli-1.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1"},
- {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d"},
- {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b"},
- {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112"},
- {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064"},
- {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914"},
- {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2"},
- {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354"},
- {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2"},
- {file = "Brotli-1.1.0-cp36-cp36m-win32.whl", hash = "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460"},
- {file = "Brotli-1.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579"},
- {file = "Brotli-1.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c"},
- {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985"},
- {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60"},
- {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a"},
- {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84"},
- {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643"},
- {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74"},
- {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b"},
- {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438"},
- {file = "Brotli-1.1.0-cp37-cp37m-win32.whl", hash = "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95"},
- {file = "Brotli-1.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68"},
- {file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3"},
- {file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208"},
- {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7"},
- {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751"},
- {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48"},
- {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619"},
- {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97"},
- {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a"},
- {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088"},
- {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596"},
- {file = "Brotli-1.1.0-cp38-cp38-win32.whl", hash = "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b"},
- {file = "Brotli-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0"},
- {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a"},
- {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f"},
- {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9"},
- {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf"},
- {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac"},
- {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578"},
- {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474"},
- {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c"},
- {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d"},
- {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59"},
- {file = "Brotli-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64"},
- {file = "Brotli-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467"},
- {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"},
-]
-
-[[package]]
-name = "brotlicffi"
-version = "1.1.0.0"
-description = "Python CFFI bindings to the Brotli library"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "brotlicffi-1.1.0.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9b7ae6bd1a3f0df532b6d67ff674099a96d22bc0948955cb338488c31bfb8851"},
- {file = "brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19ffc919fa4fc6ace69286e0a23b3789b4219058313cf9b45625016bf7ff996b"},
- {file = "brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9feb210d932ffe7798ee62e6145d3a757eb6233aa9a4e7db78dd3690d7755814"},
- {file = "brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84763dbdef5dd5c24b75597a77e1b30c66604725707565188ba54bab4f114820"},
- {file = "brotlicffi-1.1.0.0-cp37-abi3-win32.whl", hash = "sha256:1b12b50e07c3911e1efa3a8971543e7648100713d4e0971b13631cce22c587eb"},
- {file = "brotlicffi-1.1.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:994a4f0681bb6c6c3b0925530a1926b7a189d878e6e5e38fae8efa47c5d9c613"},
- {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2e4aeb0bd2540cb91b069dbdd54d458da8c4334ceaf2d25df2f4af576d6766ca"},
- {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b7b0033b0d37bb33009fb2fef73310e432e76f688af76c156b3594389d81391"},
- {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54a07bb2374a1eba8ebb52b6fafffa2afd3c4df85ddd38fcc0511f2bb387c2a8"},
- {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7901a7dc4b88f1c1475de59ae9be59799db1007b7d059817948d8e4f12e24e35"},
- {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce01c7316aebc7fce59da734286148b1d1b9455f89cf2c8a4dfce7d41db55c2d"},
- {file = "brotlicffi-1.1.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:246f1d1a90279bb6069de3de8d75a8856e073b8ff0b09dcca18ccc14cec85979"},
- {file = "brotlicffi-1.1.0.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc4bc5d82bc56ebd8b514fb8350cfac4627d6b0743382e46d033976a5f80fab6"},
- {file = "brotlicffi-1.1.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37c26ecb14386a44b118ce36e546ce307f4810bc9598a6e6cb4f7fca725ae7e6"},
- {file = "brotlicffi-1.1.0.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca72968ae4eaf6470498d5c2887073f7efe3b1e7d7ec8be11a06a79cc810e990"},
- {file = "brotlicffi-1.1.0.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:add0de5b9ad9e9aa293c3aa4e9deb2b61e99ad6c1634e01d01d98c03e6a354cc"},
- {file = "brotlicffi-1.1.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9b6068e0f3769992d6b622a1cd2e7835eae3cf8d9da123d7f51ca9c1e9c333e5"},
- {file = "brotlicffi-1.1.0.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8557a8559509b61e65083f8782329188a250102372576093c88930c875a69838"},
- {file = "brotlicffi-1.1.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a7ae37e5d79c5bdfb5b4b99f2715a6035e6c5bf538c3746abc8e26694f92f33"},
- {file = "brotlicffi-1.1.0.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391151ec86bb1c683835980f4816272a87eaddc46bb91cbf44f62228b84d8cca"},
- {file = "brotlicffi-1.1.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:2f3711be9290f0453de8eed5275d93d286abe26b08ab4a35d7452caa1fef532f"},
- {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1a807d760763e398bbf2c6394ae9da5815901aa93ee0a37bca5efe78d4ee3171"},
- {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa8ca0623b26c94fccc3a1fdd895be1743b838f3917300506d04aa3346fd2a14"},
- {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3de0cf28a53a3238b252aca9fed1593e9d36c1d116748013339f0949bfc84112"},
- {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6be5ec0e88a4925c91f3dea2bb0013b3a2accda6f77238f76a34a1ea532a1cb0"},
- {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d9eb71bb1085d996244439154387266fd23d6ad37161f6f52f1cd41dd95a3808"},
- {file = "brotlicffi-1.1.0.0.tar.gz", hash = "sha256:b77827a689905143f87915310b93b273ab17888fd43ef350d4832c4a71083c13"},
-]
-
-[package.dependencies]
-cffi = ">=1.0.0"
-
-[[package]]
-name = "bs4"
-version = "0.0.2"
-description = "Dummy package for Beautiful Soup (beautifulsoup4)"
-optional = false
-python-versions = "*"
-files = [
- {file = "bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc"},
- {file = "bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925"},
-]
-
-[package.dependencies]
-beautifulsoup4 = "*"
-
-[[package]]
-name = "certifi"
-version = "2024.8.30"
-description = "Python package for providing Mozilla's CA Bundle."
-optional = false
-python-versions = ">=3.6"
-files = [
- {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
- {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
-]
-
-[[package]]
-name = "cffi"
-version = "1.17.1"
-description = "Foreign Function Interface for Python calling C code."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
- {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
- {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"},
- {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"},
- {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"},
- {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"},
- {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"},
- {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"},
- {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"},
- {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"},
- {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"},
- {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"},
- {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"},
- {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"},
- {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"},
- {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"},
- {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"},
- {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"},
- {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"},
- {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"},
- {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"},
- {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"},
- {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"},
- {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"},
- {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"},
- {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"},
- {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"},
- {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"},
- {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"},
- {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"},
- {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"},
- {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"},
- {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"},
- {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"},
- {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"},
- {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"},
- {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"},
- {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"},
- {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"},
- {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"},
- {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"},
- {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"},
- {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"},
- {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"},
- {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"},
- {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"},
- {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"},
- {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"},
- {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"},
- {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"},
- {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"},
- {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"},
- {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"},
- {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"},
- {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"},
- {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"},
- {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"},
- {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"},
- {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"},
- {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"},
- {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"},
- {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"},
- {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"},
- {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"},
- {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"},
- {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
- {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
-]
-
-[package.dependencies]
-pycparser = "*"
-
-[[package]]
-name = "cfgv"
-version = "3.4.0"
-description = "Validate configuration and produce human readable error messages."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
- {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
-]
-
-[[package]]
-name = "charset-normalizer"
-version = "3.4.0"
-description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
-optional = false
-python-versions = ">=3.7.0"
-files = [
- {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"},
- {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"},
- {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"},
- {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"},
- {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"},
- {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"},
- {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"},
- {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"},
- {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"},
- {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"},
- {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"},
- {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"},
- {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"},
- {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"},
- {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"},
- {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"},
- {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"},
- {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"},
- {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"},
- {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"},
- {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"},
- {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"},
- {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"},
- {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"},
- {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"},
- {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"},
- {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"},
- {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"},
- {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"},
- {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"},
- {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"},
- {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"},
- {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"},
- {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"},
- {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"},
-]
-
-[[package]]
-name = "click"
-version = "8.1.7"
-description = "Composable command line interface toolkit"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
- {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
-]
-
-[package.dependencies]
-colorama = {version = "*", markers = "platform_system == \"Windows\""}
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-description = "Cross-platform colored terminal text."
-optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
-files = [
- {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
- {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
-]
-
-[[package]]
-name = "commitizen"
-version = "3.30.0"
-description = "Python commitizen client tool"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "commitizen-3.30.0-py3-none-any.whl", hash = "sha256:8dc226a136aee61207e396101fcd89e73de67a57c06e066db982310863caaf65"},
- {file = "commitizen-3.30.0.tar.gz", hash = "sha256:ae67a47c1a700b4f35ac12de0c35c7ba96f152b9377d22b6226bb87372c527b0"},
-]
-
-[package.dependencies]
-argcomplete = ">=1.12.1,<3.6"
-charset-normalizer = ">=2.1.0,<4"
-colorama = ">=0.4.1,<0.5.0"
-decli = ">=0.6.0,<0.7.0"
-jinja2 = ">=2.10.3"
-packaging = ">=19"
-pyyaml = ">=3.08"
-questionary = ">=2.0,<3.0"
-termcolor = ">=1.1,<3"
-tomlkit = ">=0.5.3,<1.0.0"
-
-[[package]]
-name = "comtypes"
-version = "1.4.8"
-description = "Pure Python COM package"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "comtypes-1.4.8-py3-none-any.whl", hash = "sha256:773109b12aa0bec630d5b2272dd983cbaa25605a12fc1319f99730c9d0b72f79"},
- {file = "comtypes-1.4.8.zip", hash = "sha256:bb2286cfb3b96f838307200a85b00b98e1bdebf1e58ec3c28b36b1ccfafac01f"},
-]
-
-[[package]]
-name = "contourpy"
-version = "1.3.0"
-description = "Python library for calculating contours of 2D quadrilateral grids"
-optional = false
-python-versions = ">=3.9"
-files = [
- {file = "contourpy-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:880ea32e5c774634f9fcd46504bf9f080a41ad855f4fef54f5380f5133d343c7"},
- {file = "contourpy-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:76c905ef940a4474a6289c71d53122a4f77766eef23c03cd57016ce19d0f7b42"},
- {file = "contourpy-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92f8557cbb07415a4d6fa191f20fd9d2d9eb9c0b61d1b2f52a8926e43c6e9af7"},
- {file = "contourpy-1.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36f965570cff02b874773c49bfe85562b47030805d7d8360748f3eca570f4cab"},
- {file = "contourpy-1.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cacd81e2d4b6f89c9f8a5b69b86490152ff39afc58a95af002a398273e5ce589"},
- {file = "contourpy-1.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69375194457ad0fad3a839b9e29aa0b0ed53bb54db1bfb6c3ae43d111c31ce41"},
- {file = "contourpy-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a52040312b1a858b5e31ef28c2e865376a386c60c0e248370bbea2d3f3b760d"},
- {file = "contourpy-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3faeb2998e4fcb256542e8a926d08da08977f7f5e62cf733f3c211c2a5586223"},
- {file = "contourpy-1.3.0-cp310-cp310-win32.whl", hash = "sha256:36e0cff201bcb17a0a8ecc7f454fe078437fa6bda730e695a92f2d9932bd507f"},
- {file = "contourpy-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:87ddffef1dbe5e669b5c2440b643d3fdd8622a348fe1983fad7a0f0ccb1cd67b"},
- {file = "contourpy-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fa4c02abe6c446ba70d96ece336e621efa4aecae43eaa9b030ae5fb92b309ad"},
- {file = "contourpy-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:834e0cfe17ba12f79963861e0f908556b2cedd52e1f75e6578801febcc6a9f49"},
- {file = "contourpy-1.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dbc4c3217eee163fa3984fd1567632b48d6dfd29216da3ded3d7b844a8014a66"},
- {file = "contourpy-1.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4865cd1d419e0c7a7bf6de1777b185eebdc51470800a9f42b9e9decf17762081"},
- {file = "contourpy-1.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:303c252947ab4b14c08afeb52375b26781ccd6a5ccd81abcdfc1fafd14cf93c1"},
- {file = "contourpy-1.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:637f674226be46f6ba372fd29d9523dd977a291f66ab2a74fbeb5530bb3f445d"},
- {file = "contourpy-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:76a896b2f195b57db25d6b44e7e03f221d32fe318d03ede41f8b4d9ba1bff53c"},
- {file = "contourpy-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e1fd23e9d01591bab45546c089ae89d926917a66dceb3abcf01f6105d927e2cb"},
- {file = "contourpy-1.3.0-cp311-cp311-win32.whl", hash = "sha256:d402880b84df3bec6eab53cd0cf802cae6a2ef9537e70cf75e91618a3801c20c"},
- {file = "contourpy-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:6cb6cc968059db9c62cb35fbf70248f40994dfcd7aa10444bbf8b3faeb7c2d67"},
- {file = "contourpy-1.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:570ef7cf892f0afbe5b2ee410c507ce12e15a5fa91017a0009f79f7d93a1268f"},
- {file = "contourpy-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:da84c537cb8b97d153e9fb208c221c45605f73147bd4cadd23bdae915042aad6"},
- {file = "contourpy-1.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0be4d8425bfa755e0fd76ee1e019636ccc7c29f77a7c86b4328a9eb6a26d0639"},
- {file = "contourpy-1.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c0da700bf58f6e0b65312d0a5e695179a71d0163957fa381bb3c1f72972537c"},
- {file = "contourpy-1.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb8b141bb00fa977d9122636b16aa67d37fd40a3d8b52dd837e536d64b9a4d06"},
- {file = "contourpy-1.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3634b5385c6716c258d0419c46d05c8aa7dc8cb70326c9a4fb66b69ad2b52e09"},
- {file = "contourpy-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0dce35502151b6bd35027ac39ba6e5a44be13a68f55735c3612c568cac3805fd"},
- {file = "contourpy-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea348f053c645100612b333adc5983d87be69acdc6d77d3169c090d3b01dc35"},
- {file = "contourpy-1.3.0-cp312-cp312-win32.whl", hash = "sha256:90f73a5116ad1ba7174341ef3ea5c3150ddf20b024b98fb0c3b29034752c8aeb"},
- {file = "contourpy-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b11b39aea6be6764f84360fce6c82211a9db32a7c7de8fa6dd5397cf1d079c3b"},
- {file = "contourpy-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3e1c7fa44aaae40a2247e2e8e0627f4bea3dd257014764aa644f319a5f8600e3"},
- {file = "contourpy-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:364174c2a76057feef647c802652f00953b575723062560498dc7930fc9b1cb7"},
- {file = "contourpy-1.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32b238b3b3b649e09ce9aaf51f0c261d38644bdfa35cbaf7b263457850957a84"},
- {file = "contourpy-1.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d51fca85f9f7ad0b65b4b9fe800406d0d77017d7270d31ec3fb1cc07358fdea0"},
- {file = "contourpy-1.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:732896af21716b29ab3e988d4ce14bc5133733b85956316fb0c56355f398099b"},
- {file = "contourpy-1.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d73f659398a0904e125280836ae6f88ba9b178b2fed6884f3b1f95b989d2c8da"},
- {file = "contourpy-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c6c7c2408b7048082932cf4e641fa3b8ca848259212f51c8c59c45aa7ac18f14"},
- {file = "contourpy-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f317576606de89da6b7e0861cf6061f6146ead3528acabff9236458a6ba467f8"},
- {file = "contourpy-1.3.0-cp313-cp313-win32.whl", hash = "sha256:31cd3a85dbdf1fc002280c65caa7e2b5f65e4a973fcdf70dd2fdcb9868069294"},
- {file = "contourpy-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:4553c421929ec95fb07b3aaca0fae668b2eb5a5203d1217ca7c34c063c53d087"},
- {file = "contourpy-1.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:345af746d7766821d05d72cb8f3845dfd08dd137101a2cb9b24de277d716def8"},
- {file = "contourpy-1.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3bb3808858a9dc68f6f03d319acd5f1b8a337e6cdda197f02f4b8ff67ad2057b"},
- {file = "contourpy-1.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:420d39daa61aab1221567b42eecb01112908b2cab7f1b4106a52caaec8d36973"},
- {file = "contourpy-1.3.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d63ee447261e963af02642ffcb864e5a2ee4cbfd78080657a9880b8b1868e18"},
- {file = "contourpy-1.3.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:167d6c890815e1dac9536dca00828b445d5d0df4d6a8c6adb4a7ec3166812fa8"},
- {file = "contourpy-1.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:710a26b3dc80c0e4febf04555de66f5fd17e9cf7170a7b08000601a10570bda6"},
- {file = "contourpy-1.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:75ee7cb1a14c617f34a51d11fa7524173e56551646828353c4af859c56b766e2"},
- {file = "contourpy-1.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:33c92cdae89ec5135d036e7218e69b0bb2851206077251f04a6c4e0e21f03927"},
- {file = "contourpy-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a11077e395f67ffc2c44ec2418cfebed032cd6da3022a94fc227b6faf8e2acb8"},
- {file = "contourpy-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e8134301d7e204c88ed7ab50028ba06c683000040ede1d617298611f9dc6240c"},
- {file = "contourpy-1.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e12968fdfd5bb45ffdf6192a590bd8ddd3ba9e58360b29683c6bb71a7b41edca"},
- {file = "contourpy-1.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fd2a0fc506eccaaa7595b7e1418951f213cf8255be2600f1ea1b61e46a60c55f"},
- {file = "contourpy-1.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4cfb5c62ce023dfc410d6059c936dcf96442ba40814aefbfa575425a3a7f19dc"},
- {file = "contourpy-1.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68a32389b06b82c2fdd68276148d7b9275b5f5cf13e5417e4252f6d1a34f72a2"},
- {file = "contourpy-1.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:94e848a6b83da10898cbf1311a815f770acc9b6a3f2d646f330d57eb4e87592e"},
- {file = "contourpy-1.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d78ab28a03c854a873787a0a42254a0ccb3cb133c672f645c9f9c8f3ae9d0800"},
- {file = "contourpy-1.3.0-cp39-cp39-win32.whl", hash = "sha256:81cb5ed4952aae6014bc9d0421dec7c5835c9c8c31cdf51910b708f548cf58e5"},
- {file = "contourpy-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:14e262f67bd7e6eb6880bc564dcda30b15e351a594657e55b7eec94b6ef72843"},
- {file = "contourpy-1.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fe41b41505a5a33aeaed2a613dccaeaa74e0e3ead6dd6fd3a118fb471644fd6c"},
- {file = "contourpy-1.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca7e17a65f72a5133bdbec9ecf22401c62bcf4821361ef7811faee695799779"},
- {file = "contourpy-1.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1ec4dc6bf570f5b22ed0d7efba0dfa9c5b9e0431aeea7581aa217542d9e809a4"},
- {file = "contourpy-1.3.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:00ccd0dbaad6d804ab259820fa7cb0b8036bda0686ef844d24125d8287178ce0"},
- {file = "contourpy-1.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ca947601224119117f7c19c9cdf6b3ab54c5726ef1d906aa4a69dfb6dd58102"},
- {file = "contourpy-1.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6ec93afeb848a0845a18989da3beca3eec2c0f852322efe21af1931147d12cb"},
- {file = "contourpy-1.3.0.tar.gz", hash = "sha256:7ffa0db17717a8ffb127efd0c95a4362d996b892c2904db72428d5b52e1938a4"},
-]
-
-[package.dependencies]
-numpy = ">=1.23"
-
-[package.extras]
-bokeh = ["bokeh", "selenium"]
-docs = ["furo", "sphinx (>=7.2)", "sphinx-copybutton"]
-mypy = ["contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.11.1)", "types-Pillow"]
-test = ["Pillow", "contourpy[test-no-images]", "matplotlib"]
-test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"]
-
-[[package]]
-name = "csscompressor"
-version = "0.9.5"
-description = "A python port of YUI CSS Compressor"
-optional = false
-python-versions = "*"
-files = [
- {file = "csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05"},
-]
-
-[[package]]
-name = "cycler"
-version = "0.12.1"
-description = "Composable style cycles"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"},
- {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"},
-]
-
-[package.extras]
-docs = ["ipython", "matplotlib", "numpydoc", "sphinx"]
-tests = ["pytest", "pytest-cov", "pytest-xdist"]
-
-[[package]]
-name = "decli"
-version = "0.6.2"
-description = "Minimal, easy-to-use, declarative cli tool"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "decli-0.6.2-py3-none-any.whl", hash = "sha256:2fc84106ce9a8f523ed501ca543bdb7e416c064917c12a59ebdc7f311a97b7ed"},
- {file = "decli-0.6.2.tar.gz", hash = "sha256:36f71eb55fd0093895efb4f416ec32b7f6e00147dda448e3365cf73ceab42d6f"},
-]
-
-[[package]]
-name = "distlib"
-version = "0.3.9"
-description = "Distribution utilities"
-optional = false
-python-versions = "*"
-files = [
- {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
- {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
-]
-
-[[package]]
-name = "docopt"
-version = "0.6.2"
-description = "Pythonic argument parser, that will make you smile"
-optional = false
-python-versions = "*"
-files = [
- {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"},
-]
-
-[[package]]
-name = "docutils"
-version = "0.21.2"
-description = "Docutils -- Python Documentation Utilities"
-optional = false
-python-versions = ">=3.9"
-files = [
- {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"},
- {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"},
-]
-
-[[package]]
-name = "dynaconf"
-version = "3.2.6"
-description = "The dynamic configurator for your Python Project"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "dynaconf-3.2.6-py2.py3-none-any.whl", hash = "sha256:3911c740d717df4576ed55f616c7cbad6e06bc8ef23ffca444b6e2a12fb1c34c"},
- {file = "dynaconf-3.2.6.tar.gz", hash = "sha256:74cc1897396380bb957730eb341cc0976ee9c38bbcb53d3307c50caed0aedfb8"},
-]
-
-[package.dependencies]
-"ruamel.yaml" = {version = "*", optional = true, markers = "extra == \"yaml\""}
-
-[package.extras]
-all = ["configobj", "hvac", "redis", "ruamel.yaml"]
-configobj = ["configobj"]
-ini = ["configobj"]
-redis = ["redis"]
-test = ["configobj", "django", "flask (>=0.12)", "hvac (>=1.1.0)", "pytest", "pytest-cov", "pytest-mock", "pytest-xdist", "python-dotenv", "radon", "redis", "toml"]
-toml = ["toml"]
-vault = ["hvac"]
-yaml = ["ruamel.yaml"]
-
-[[package]]
-name = "exceptiongroup"
-version = "1.2.2"
-description = "Backport of PEP 654 (exception groups)"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
- {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
-]
-
-[package.extras]
-test = ["pytest (>=6)"]
-
-[[package]]
-name = "filelock"
-version = "3.16.1"
-description = "A platform independent file lock."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"},
- {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"},
-]
-
-[package.extras]
-docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"]
-testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"]
-typing = ["typing-extensions (>=4.12.2)"]
-
-[[package]]
-name = "fonttools"
-version = "4.54.1"
-description = "Tools to manipulate font files"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "fonttools-4.54.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7ed7ee041ff7b34cc62f07545e55e1468808691dddfd315d51dd82a6b37ddef2"},
- {file = "fonttools-4.54.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41bb0b250c8132b2fcac148e2e9198e62ff06f3cc472065dff839327945c5882"},
- {file = "fonttools-4.54.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7965af9b67dd546e52afcf2e38641b5be956d68c425bef2158e95af11d229f10"},
- {file = "fonttools-4.54.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:278913a168f90d53378c20c23b80f4e599dca62fbffae4cc620c8eed476b723e"},
- {file = "fonttools-4.54.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0e88e3018ac809b9662615072dcd6b84dca4c2d991c6d66e1970a112503bba7e"},
- {file = "fonttools-4.54.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4aa4817f0031206e637d1e685251ac61be64d1adef111060df84fdcbc6ab6c44"},
- {file = "fonttools-4.54.1-cp310-cp310-win32.whl", hash = "sha256:7e3b7d44e18c085fd8c16dcc6f1ad6c61b71ff463636fcb13df7b1b818bd0c02"},
- {file = "fonttools-4.54.1-cp310-cp310-win_amd64.whl", hash = "sha256:dd9cc95b8d6e27d01e1e1f1fae8559ef3c02c76317da650a19047f249acd519d"},
- {file = "fonttools-4.54.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5419771b64248484299fa77689d4f3aeed643ea6630b2ea750eeab219588ba20"},
- {file = "fonttools-4.54.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:301540e89cf4ce89d462eb23a89464fef50915255ece765d10eee8b2bf9d75b2"},
- {file = "fonttools-4.54.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ae5091547e74e7efecc3cbf8e75200bc92daaeb88e5433c5e3e95ea8ce5aa7"},
- {file = "fonttools-4.54.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82834962b3d7c5ca98cb56001c33cf20eb110ecf442725dc5fdf36d16ed1ab07"},
- {file = "fonttools-4.54.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d26732ae002cc3d2ecab04897bb02ae3f11f06dd7575d1df46acd2f7c012a8d8"},
- {file = "fonttools-4.54.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58974b4987b2a71ee08ade1e7f47f410c367cdfc5a94fabd599c88165f56213a"},
- {file = "fonttools-4.54.1-cp311-cp311-win32.whl", hash = "sha256:ab774fa225238986218a463f3fe151e04d8c25d7de09df7f0f5fce27b1243dbc"},
- {file = "fonttools-4.54.1-cp311-cp311-win_amd64.whl", hash = "sha256:07e005dc454eee1cc60105d6a29593459a06321c21897f769a281ff2d08939f6"},
- {file = "fonttools-4.54.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:54471032f7cb5fca694b5f1a0aaeba4af6e10ae989df408e0216f7fd6cdc405d"},
- {file = "fonttools-4.54.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fa92cb248e573daab8d032919623cc309c005086d743afb014c836636166f08"},
- {file = "fonttools-4.54.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a911591200114969befa7f2cb74ac148bce5a91df5645443371aba6d222e263"},
- {file = "fonttools-4.54.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93d458c8a6a354dc8b48fc78d66d2a8a90b941f7fec30e94c7ad9982b1fa6bab"},
- {file = "fonttools-4.54.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5eb2474a7c5be8a5331146758debb2669bf5635c021aee00fd7c353558fc659d"},
- {file = "fonttools-4.54.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9c563351ddc230725c4bdf7d9e1e92cbe6ae8553942bd1fb2b2ff0884e8b714"},
- {file = "fonttools-4.54.1-cp312-cp312-win32.whl", hash = "sha256:fdb062893fd6d47b527d39346e0c5578b7957dcea6d6a3b6794569370013d9ac"},
- {file = "fonttools-4.54.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4564cf40cebcb53f3dc825e85910bf54835e8a8b6880d59e5159f0f325e637e"},
- {file = "fonttools-4.54.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6e37561751b017cf5c40fce0d90fd9e8274716de327ec4ffb0df957160be3bff"},
- {file = "fonttools-4.54.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:357cacb988a18aace66e5e55fe1247f2ee706e01debc4b1a20d77400354cddeb"},
- {file = "fonttools-4.54.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8e953cc0bddc2beaf3a3c3b5dd9ab7554677da72dfaf46951e193c9653e515a"},
- {file = "fonttools-4.54.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58d29b9a294573d8319f16f2f79e42428ba9b6480442fa1836e4eb89c4d9d61c"},
- {file = "fonttools-4.54.1-cp313-cp313-win32.whl", hash = "sha256:9ef1b167e22709b46bf8168368b7b5d3efeaaa746c6d39661c1b4405b6352e58"},
- {file = "fonttools-4.54.1-cp313-cp313-win_amd64.whl", hash = "sha256:262705b1663f18c04250bd1242b0515d3bbae177bee7752be67c979b7d47f43d"},
- {file = "fonttools-4.54.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ed2f80ca07025551636c555dec2b755dd005e2ea8fbeb99fc5cdff319b70b23b"},
- {file = "fonttools-4.54.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9dc080e5a1c3b2656caff2ac2633d009b3a9ff7b5e93d0452f40cd76d3da3b3c"},
- {file = "fonttools-4.54.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d152d1be65652fc65e695e5619e0aa0982295a95a9b29b52b85775243c06556"},
- {file = "fonttools-4.54.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8583e563df41fdecef31b793b4dd3af8a9caa03397be648945ad32717a92885b"},
- {file = "fonttools-4.54.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:0d1d353ef198c422515a3e974a1e8d5b304cd54a4c2eebcae708e37cd9eeffb1"},
- {file = "fonttools-4.54.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fda582236fee135d4daeca056c8c88ec5f6f6d88a004a79b84a02547c8f57386"},
- {file = "fonttools-4.54.1-cp38-cp38-win32.whl", hash = "sha256:e7d82b9e56716ed32574ee106cabca80992e6bbdcf25a88d97d21f73a0aae664"},
- {file = "fonttools-4.54.1-cp38-cp38-win_amd64.whl", hash = "sha256:ada215fd079e23e060157aab12eba0d66704316547f334eee9ff26f8c0d7b8ab"},
- {file = "fonttools-4.54.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f5b8a096e649768c2f4233f947cf9737f8dbf8728b90e2771e2497c6e3d21d13"},
- {file = "fonttools-4.54.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e10d2e0a12e18f4e2dd031e1bf7c3d7017be5c8dbe524d07706179f355c5dac"},
- {file = "fonttools-4.54.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31c32d7d4b0958600eac75eaf524b7b7cb68d3a8c196635252b7a2c30d80e986"},
- {file = "fonttools-4.54.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c39287f5c8f4a0c5a55daf9eaf9ccd223ea59eed3f6d467133cc727d7b943a55"},
- {file = "fonttools-4.54.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a7a310c6e0471602fe3bf8efaf193d396ea561486aeaa7adc1f132e02d30c4b9"},
- {file = "fonttools-4.54.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d3b659d1029946f4ff9b6183984578041b520ce0f8fb7078bb37ec7445806b33"},
- {file = "fonttools-4.54.1-cp39-cp39-win32.whl", hash = "sha256:e96bc94c8cda58f577277d4a71f51c8e2129b8b36fd05adece6320dd3d57de8a"},
- {file = "fonttools-4.54.1-cp39-cp39-win_amd64.whl", hash = "sha256:e8a4b261c1ef91e7188a30571be6ad98d1c6d9fa2427244c545e2fa0a2494dd7"},
- {file = "fonttools-4.54.1-py3-none-any.whl", hash = "sha256:37cddd62d83dc4f72f7c3f3c2bcf2697e89a30efb152079896544a93907733bd"},
- {file = "fonttools-4.54.1.tar.gz", hash = "sha256:957f669d4922f92c171ba01bef7f29410668db09f6c02111e22b2bce446f3285"},
-]
-
-[package.extras]
-all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "fs (>=2.2.0,<3)", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "pycairo", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0)", "xattr", "zopfli (>=0.1.4)"]
-graphite = ["lz4 (>=1.7.4.2)"]
-interpolatable = ["munkres", "pycairo", "scipy"]
-lxml = ["lxml (>=4.0)"]
-pathops = ["skia-pathops (>=0.5.0)"]
-plot = ["matplotlib"]
-repacker = ["uharfbuzz (>=0.23.0)"]
-symfont = ["sympy"]
-type1 = ["xattr"]
-ufo = ["fs (>=2.2.0,<3)"]
-unicode = ["unicodedata2 (>=15.1.0)"]
-woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
-
-[[package]]
-name = "ghp-import"
-version = "2.1.0"
-description = "Copy your docs directly to the gh-pages branch."
-optional = false
-python-versions = "*"
-files = [
- {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"},
- {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"},
-]
-
-[package.dependencies]
-python-dateutil = ">=2.8.1"
-
-[package.extras]
-dev = ["flake8", "markdown", "twine", "wheel"]
-
-[[package]]
-name = "gitdb"
-version = "4.0.11"
-description = "Git Object Database"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"},
- {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"},
-]
-
-[package.dependencies]
-smmap = ">=3.0.1,<6"
-
-[[package]]
-name = "gitpython"
-version = "3.1.43"
-description = "GitPython is a Python library used to interact with Git repositories"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "GitPython-3.1.43-py3-none-any.whl", hash = "sha256:eec7ec56b92aad751f9912a73404bc02ba212a23adb2c7098ee668417051a1ff"},
- {file = "GitPython-3.1.43.tar.gz", hash = "sha256:35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c"},
-]
-
-[package.dependencies]
-gitdb = ">=4.0.1,<5"
-
-[package.extras]
-doc = ["sphinx (==4.3.2)", "sphinx-autodoc-typehints", "sphinx-rtd-theme", "sphinxcontrib-applehelp (>=1.0.2,<=1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (>=2.0.0,<=2.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)"]
-test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions"]
-
-[[package]]
-name = "griffe"
-version = "1.5.1"
-description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
-optional = false
-python-versions = ">=3.9"
-files = [
- {file = "griffe-1.5.1-py3-none-any.whl", hash = "sha256:ad6a7980f8c424c9102160aafa3bcdf799df0e75f7829d75af9ee5aef656f860"},
- {file = "griffe-1.5.1.tar.gz", hash = "sha256:72964f93e08c553257706d6cd2c42d1c172213feb48b2be386f243380b405d4b"},
-]
-
-[package.dependencies]
-colorama = ">=0.4"
-
-[[package]]
-name = "hexproof"
-version = "0.3.7"
-description = "A comprehensive library of Magic the Gathering API utilities."
-optional = false
-python-versions = "<3.13,>=3.10"
-files = [
- {file = "hexproof-0.3.7-py3-none-any.whl", hash = "sha256:bdfbe1d6107091ea5c74421af26f35874e5e36da050da7b3b2791dc78bec14fb"},
- {file = "hexproof-0.3.7.tar.gz", hash = "sha256:71a3db263c81ef2ccbce084a6b7ce2e78f1d35645f6204208773fa7f97c0eccf"},
-]
-
-[package.dependencies]
-bs4 = ">=0.0.2,<0.0.3"
-loguru = ">=0.7.2,<0.8.0"
-omnitils = ">=1.4.3,<2.0.0"
-pydantic = ">=2.9.2"
-PyYAML = ">=6.0.1"
-requests = ">=2.31.0,<3.0.0"
-tomli = ">=2.0.2"
-tomlkit = ">=0.13.2"
-
-[[package]]
-name = "htmlmin2"
-version = "0.1.13"
-description = "An HTML Minifier"
-optional = false
-python-versions = "*"
-files = [
- {file = "htmlmin2-0.1.13-py3-none-any.whl", hash = "sha256:75609f2a42e64f7ce57dbff28a39890363bde9e7e5885db633317efbdf8c79a2"},
-]
-
-[[package]]
-name = "identify"
-version = "2.6.1"
-description = "File identification library for Python"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"},
- {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"},
-]
-
-[package.extras]
-license = ["ukkonen"]
-
-[[package]]
-name = "idna"
-version = "3.10"
-description = "Internationalized Domain Names in Applications (IDNA)"
-optional = false
-python-versions = ">=3.6"
-files = [
- {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
- {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
-]
-
-[package.extras]
-all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
-
-[[package]]
-name = "imageio"
-version = "2.36.0"
-description = "Library for reading and writing a wide range of image, video, scientific, and volumetric data formats."
-optional = false
-python-versions = ">=3.9"
-files = [
- {file = "imageio-2.36.0-py3-none-any.whl", hash = "sha256:471f1eda55618ee44a3c9960911c35e647d9284c68f077e868df633398f137f0"},
- {file = "imageio-2.36.0.tar.gz", hash = "sha256:1c8f294db862c256e9562354d65aa54725b8dafed7f10f02bb3ec20ec1678850"},
-]
-
-[package.dependencies]
-numpy = "*"
-pillow = ">=8.3.2"
-
-[package.extras]
-all-plugins = ["astropy", "av", "imageio-ffmpeg", "numpy (>2)", "pillow-heif", "psutil", "rawpy", "tifffile"]
-all-plugins-pypy = ["av", "imageio-ffmpeg", "pillow-heif", "psutil", "tifffile"]
-build = ["wheel"]
-dev = ["black", "flake8", "fsspec[github]", "pytest", "pytest-cov"]
-docs = ["numpydoc", "pydata-sphinx-theme", "sphinx (<6)"]
-ffmpeg = ["imageio-ffmpeg", "psutil"]
-fits = ["astropy"]
-full = ["astropy", "av", "black", "flake8", "fsspec[github]", "gdal", "imageio-ffmpeg", "itk", "numpy (>2)", "numpydoc", "pillow-heif", "psutil", "pydata-sphinx-theme", "pytest", "pytest-cov", "rawpy", "sphinx (<6)", "tifffile", "wheel"]
-gdal = ["gdal"]
-itk = ["itk"]
-linting = ["black", "flake8"]
-pillow-heif = ["pillow-heif"]
-pyav = ["av"]
-rawpy = ["numpy (>2)", "rawpy"]
-test = ["fsspec[github]", "pytest", "pytest-cov"]
-tifffile = ["tifffile"]
-
-[[package]]
-name = "inflate64"
-version = "1.0.0"
-description = "deflate64 compression/decompression library"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "inflate64-1.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a90c0bdf4a7ecddd8a64cc977181810036e35807f56b0bcacee9abb0fcfd18dc"},
- {file = "inflate64-1.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:57fe7c14aebf1c5a74fc3b70d355be1280a011521a76aa3895486e62454f4242"},
- {file = "inflate64-1.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d90730165f471d61a1a694a5e354f3ffa938227e8dcecb62d5d728e8069cee94"},
- {file = "inflate64-1.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:543f400201f5c101141af3c79c82059e1aa6ef4f1584a7f1fa035fb2e465097f"},
- {file = "inflate64-1.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ceca14f7ec19fb44b047f56c50efb7521b389d222bba2b0a10286a0caeb03fa"},
- {file = "inflate64-1.0.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b559937a42f0c175b4d2dfc7eb53b97bdc87efa9add15ed5549c6abc1e89d02f"},
- {file = "inflate64-1.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5ff8bd2a562343fcbc4eea26fdc368904a3b5f6bb8262344274d3d74a1de15bb"},
- {file = "inflate64-1.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:0fe481f31695d35a433c3044ac8fd5d9f5069aaad03a0c04b570eb258ce655aa"},
- {file = "inflate64-1.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a45f6979ad5874d4d4898c2fc770b136e61b96b850118fdaec5a5af1b9123a"},
- {file = "inflate64-1.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:022ca1cc928e7365a05f7371ff06af143c6c667144965e2cf9a9236a2ae1c291"},
- {file = "inflate64-1.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:46792ecf3565d64fd2c519b0a780c03a57e195613c9954ef94e739a057b3fd06"},
- {file = "inflate64-1.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a70ea2e456c15f7aa7c74b8ab8f20b4f8940ec657604c9f0a9de3342f280fff"},
- {file = "inflate64-1.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e243ea9bd36a035059f2365bd6d156ff59717fbafb0255cb0c75bf151bf6904"},
- {file = "inflate64-1.0.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4dc392dec1cd11cacda3d2637214ca45e38202e8a4f31d4a4e566d6e90625fc4"},
- {file = "inflate64-1.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8b402a50eda7ee75f342fc346d33a41bca58edc222a4b17f9be0db1daed459fa"},
- {file = "inflate64-1.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:f5924499dc8800928c0ee4580fa8eb4ffa880b2cce4431537d0390e503a9c9ee"},
- {file = "inflate64-1.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0c644bf7208e20825ca3bbb5fb1f7f495cfcb49eb01a5f67338796d44a42f2bf"},
- {file = "inflate64-1.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9964a4eaf26a9d36f82a1d9b12c28e35800dd3d99eb340453ed12ac90c2976a8"},
- {file = "inflate64-1.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2cccded63865640d03253897be7232b2bbac295fe43914c61f86a57aa23bb61d"},
- {file = "inflate64-1.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d491f104fb3701926ebd82b8c9250dfba0ddcab584504e26f1e4adb26730378d"},
- {file = "inflate64-1.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ebad4a6cd2a2c1d81be0b09d4006479f3b258803c49a9224ef8ca0b649072fa"},
- {file = "inflate64-1.0.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6823b2c0cff3a8159140f3b17ec64fb8ec0e663b45a6593618ecdde8aeecb5b2"},
- {file = "inflate64-1.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:228d504239d27958e71fc77e3119a6ac4528127df38468a0c95a5bd3927204b8"},
- {file = "inflate64-1.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae2572e06bcfe15e3bbf77d4e4a6d6c55e2a70d6abceaaf60c5c3653ddb96dfd"},
- {file = "inflate64-1.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c10ca61212a753bbce6d341e7cfa779c161b839281f1f9fdc15cf1f324ce7c5b"},
- {file = "inflate64-1.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a982dc93920f9450da4d4f25c5e5c1288ef053b1d618cedc91adb67e035e35f5"},
- {file = "inflate64-1.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ca0310b2c55bc40394c5371db2a22f705fd594226cc09432e1eb04d3aed83930"},
- {file = "inflate64-1.0.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e95044ae55a161144445527a2efad550851fecc699066423d24b2634a6a83710"},
- {file = "inflate64-1.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34de6902c39d9225459583d5034182d371fc694bc3cfd6c0fc89aa62e9809faf"},
- {file = "inflate64-1.0.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ebafbd813213dc470719cd0a2bcb53aab89d9059f4e75386048b4c4dcdb2fd99"},
- {file = "inflate64-1.0.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:75448c7b414dadaeeb11dab9f75e022aa1e0ee19b00f570e9f58e933603d71ac"},
- {file = "inflate64-1.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:2be4e01c1b04761874cb44b35b6103ca5846bc36c18fc3ff5e8cbcd8bfc15e9f"},
- {file = "inflate64-1.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bf2981b95c1f26242bb084d9a07f3feb0cfe3d6d0a8d90f42389803bc1252c4a"},
- {file = "inflate64-1.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9373ccf0661cc72ac84a0ad622634144da5ce7d57c9572ed0723d67a149feed2"},
- {file = "inflate64-1.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e4650c6f65011ec57cf5cd96b92d5b7c6f59e502930c86eb8227c93cf02dc270"},
- {file = "inflate64-1.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a475e8822f1a74c873e60b8f270773757ade024097ca39e43402d47c049c67d4"},
- {file = "inflate64-1.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4367480733ac8daf368f6fc704b7c9db85521ee745eb5bd443f4b97d2051acc"},
- {file = "inflate64-1.0.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6c5775c91f94f5eced9160fb0af12a09f3e030194f91a6a46e706a79350bd056"},
- {file = "inflate64-1.0.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d76d205b844d78ce04768060084ef20e64dcc63a3e9166674f857acaf4d140ed"},
- {file = "inflate64-1.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:92f0dc6af0e8e97324981178dc442956cbff1247a56d1e201af8d865244653f8"},
- {file = "inflate64-1.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f79542478e49e471e8b23556700e6f688a40dc93e9a746f77a546c13251b59b1"},
- {file = "inflate64-1.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a270be6b10cde01258c0097a663a307c62d12c78eb8f62f8e29f205335942c9"},
- {file = "inflate64-1.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1616a87ff04f583e9558cc247ec0b72a30d540ee0c17cc77823be175c0ec92f0"},
- {file = "inflate64-1.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:137ca6b315f0157a786c3a755a09395ca69aed8bcf42ad3437cb349f5ebc86d2"},
- {file = "inflate64-1.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8140942d1614bdeb5a9ddd7559348c5c77f884a42424aef7ccf149ccfb93aa08"},
- {file = "inflate64-1.0.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fe3f9051338bb7d07b5e7d88420d666b5109f33ae39aa55ecd1a053c0f22b1b"},
- {file = "inflate64-1.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36342338e957c790fc630d4afcdcc3926beb2ecaea0b302336079e8fa37e57a0"},
- {file = "inflate64-1.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:9b65cc701ef33ab20dbfd1d64088ffd89a8c265b356d2c21ba0ec565661645ef"},
- {file = "inflate64-1.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:dd6d3e7d47df43210a995fd1f5989602b64de3f2a17cf4cbff553518b3577fd4"},
- {file = "inflate64-1.0.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f033b2879696b855200cde5ca4e293132c7499df790acb2c0dacb336d5e83b1"},
- {file = "inflate64-1.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f816d1c8a0593375c289e285c96deaee9c2d8742cb0edbd26ee05588a9ae657"},
- {file = "inflate64-1.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1facd35319b6a391ee4c3d709c7c650bcada8cd7141d86cd8c2257287f45e6e6"},
- {file = "inflate64-1.0.0.tar.gz", hash = "sha256:3278827b803cf006a1df251f3e13374c7d26db779e5a33329cc11789b804bc2d"},
-]
-
-[package.extras]
-check = ["check-manifest", "flake8", "flake8-black", "flake8-deprecated", "isort (>=5.0.3)", "mypy (>=0.940)", "mypy-extensions (>=0.4.1)", "pygments", "readme-renderer", "twine"]
-docs = ["docutils", "sphinx (>=5.0)"]
-test = ["pyannotate", "pytest"]
-
-[[package]]
-name = "iniconfig"
-version = "2.0.0"
-description = "brain-dead simple config-ini parsing"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
- {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
-]
-
-[[package]]
-name = "jinja2"
-version = "3.1.4"
-description = "A very fast and expressive template engine."
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"},
- {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"},
-]
-
-[package.dependencies]
-MarkupSafe = ">=2.0"
-
-[package.extras]
-i18n = ["Babel (>=2.7)"]
-
-[[package]]
-name = "jsmin"
-version = "3.0.1"
-description = "JavaScript minifier."
-optional = false
-python-versions = "*"
-files = [
- {file = "jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc"},
-]
-
-[[package]]
-name = "kivy"
-version = "2.3.0"
-description = "An open-source Python framework for developing GUI apps that work cross-platform, including desktop, mobile and embedded platforms."
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "Kivy-2.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcd8fdc742ae10d27e578df2052b4c3e99a754e91baad77d1f2e4f4d1238917f"},
- {file = "Kivy-2.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7492593a5d5d916c48b14a06fbe177341b1efb5753c9984be2fb84e3b3313c89"},
- {file = "Kivy-2.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:199c30e8daeace61392329766eeb68daa49631cd9793bec9440dda5cf30d68d5"},
- {file = "Kivy-2.3.0-cp310-cp310-win32.whl", hash = "sha256:03fc4b26c7d6a5ecee2c97ffa8d622e97ac8a8c4e0a00d333c156d64e09e4e19"},
- {file = "Kivy-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:aa5d57494cab405d395d65570d8481ab87869ba6daf4efb6c985bd16b32e7abf"},
- {file = "Kivy-2.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ec36ab3b74a525fa463b61895d3a2d76e9e4d206641233defae0d604e75df7ad"},
- {file = "Kivy-2.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd3e923397779776ac97ad87a1b9dd603b7f1c911a6ae04f1d1658712eaaf7cb"},
- {file = "Kivy-2.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7766baac2509d699df84b284579fa25ee31383d48893660cd8dba62081453a29"},
- {file = "Kivy-2.3.0-cp311-cp311-win32.whl", hash = "sha256:d654aaec6ddf9ca0edf73abd79e6aea423299c825a7ac432df17b031adaa7900"},
- {file = "Kivy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:33dca85a520fe958e7134b96025b0625eb769adfb8829359959c8b314b7bc8d4"},
- {file = "Kivy-2.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7b1307521843d316265481d963344e85870ae5fa0c7d0881129749acfe61da7b"},
- {file = "Kivy-2.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:521105a4ca1db3e1203c3cdba4abe737533874d9c29bbfb1e1ae941238507440"},
- {file = "Kivy-2.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6672959894f652856d1dfcbcdcc09263de5f1cbed768b997dc8dcecab4385a4f"},
- {file = "Kivy-2.3.0-cp312-cp312-win32.whl", hash = "sha256:cf0bccc95b1344b79fbfdf54155d40438490f9801fd77279f068a4f66db72e4e"},
- {file = "Kivy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:710648c987a63e37c723e6622853efe0278767596631a38728a54474b2cb77f2"},
- {file = "Kivy-2.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d2c6a411e2d837684d91b46231dd12db74fb1db6a2628e9f27581ce1583e5c8a"},
- {file = "Kivy-2.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8757f189d8a41a4b164150144037359405906a46b07572e8e1c602a782cacebf"},
- {file = "Kivy-2.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06c9b0a4bff825793e150e2cdbc823b59f635ce51e575d470d0fc3a06159596c"},
- {file = "Kivy-2.3.0-cp37-cp37m-win32.whl", hash = "sha256:d72599b80c8a7c2698769b4129ff52f2c4e28b6a75f9401180052c7d80763f19"},
- {file = "Kivy-2.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:0c42cf3c33e1aa3dee9c8acb6f91f8a4ad6c9de76064dcb8fdb1c60809643788"},
- {file = "Kivy-2.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:da8dd7ade7b7859642f53c3f32e10513877ce650367b68591b3aaacb46dcf012"},
- {file = "Kivy-2.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb6191bb51983f9e8257356aa53a71ccff5b6cf92f0bdcd5756973a6ac4b4446"},
- {file = "Kivy-2.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa3e7ce4fbd22284b303939676c5ae5448bb1e4d405f066dfc76c7cf56595cd"},
- {file = "Kivy-2.3.0-cp38-cp38-win32.whl", hash = "sha256:221f809220e518ae8b88a9b31310f9fef73727569e5cb13436572674fce4507b"},
- {file = "Kivy-2.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:8d2c3e5927fcf021d32124f915d56ae29e3a126c4f53db098436ea3959758a4c"},
- {file = "Kivy-2.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e8b91dfa2ad83739cc12d0f7bbe6410a3af2c2b3afd7b1d08919d9ec92826d61"},
- {file = "Kivy-2.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d79cb4a8649c476db18a079c447e57f8dbd4ad41459dc2162133a45cbb8cae96"},
- {file = "Kivy-2.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3be8db1eecc2d18859a7324b5cea79afb44095ccd73671987840afa26c68b0c9"},
- {file = "Kivy-2.3.0-cp39-cp39-win32.whl", hash = "sha256:5e6c431088584132d685696592e281fac217a5fd662f92cc6c6b48316e30b9c2"},
- {file = "Kivy-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:c332ff319db7648004d486c40fc4e700972f8e79a882d698e18eb238b2009e98"},
- {file = "Kivy-2.3.0.tar.gz", hash = "sha256:e8b8610c7f8ef6db908a139d369b247378f18105c96981e492eab2b4706c79d5"},
-]
-
-[package.dependencies]
-docutils = "*"
-"kivy-deps.angle" = {version = ">=0.4.0,<0.5.0", markers = "sys_platform == \"win32\""}
-"kivy-deps.glew" = {version = ">=0.3.1,<0.4.0", markers = "sys_platform == \"win32\""}
-"kivy-deps.sdl2" = {version = ">=0.7.0,<0.8.0", markers = "sys_platform == \"win32\""}
-Kivy-Garden = ">=0.1.4"
-pygments = "*"
-pypiwin32 = {version = "*", markers = "sys_platform == \"win32\""}
-
-[package.extras]
-angle = ["kivy-deps.angle (>=0.4.0,<0.5.0)"]
-base = ["docutils", "kivy-deps.angle (>=0.4.0,<0.5.0)", "kivy-deps.glew (>=0.3.1,<0.4.0)", "kivy-deps.sdl2 (>=0.7.0,<0.8.0)", "pillow (>=9.5.0,<11)", "pygments", "pypiwin32", "requests"]
-dev = ["flake8", "funcparserlib (==1.0.0a0)", "kivy-deps.glew-dev (>=0.3.1,<0.4.0)", "kivy-deps.gstreamer-dev (>=0.3.3,<0.4.0)", "kivy-deps.sdl2-dev (>=0.7.0,<0.8.0)", "pre-commit", "pyinstaller", "pytest (>=3.6)", "pytest-asyncio (!=0.11.0)", "pytest-benchmark", "pytest-cov", "pytest-timeout", "responses", "sphinx (<=6.2.1)", "sphinxcontrib-actdiag", "sphinxcontrib-blockdiag", "sphinxcontrib-jquery", "sphinxcontrib-nwdiag", "sphinxcontrib-seqdiag"]
-full = ["docutils", "ffpyplayer", "kivy-deps.angle (>=0.4.0,<0.5.0)", "kivy-deps.glew (>=0.3.1,<0.4.0)", "kivy-deps.gstreamer (>=0.3.3,<0.4.0)", "kivy-deps.sdl2 (>=0.7.0,<0.8.0)", "pillow (>=9.5.0,<11)", "pygments", "pypiwin32"]
-glew = ["kivy-deps.glew (>=0.3.1,<0.4.0)"]
-gstreamer = ["kivy-deps.gstreamer (>=0.3.3,<0.4.0)"]
-media = ["ffpyplayer", "kivy-deps.gstreamer (>=0.3.3,<0.4.0)"]
-sdl2 = ["kivy-deps.sdl2 (>=0.7.0,<0.8.0)"]
-tuio = ["oscpy"]
-
-[[package]]
-name = "kivy-deps-angle"
-version = "0.4.0"
-description = "Repackaged binary dependency of Kivy."
-optional = false
-python-versions = "*"
-files = [
- {file = "kivy_deps.angle-0.4.0-cp310-cp310-win32.whl", hash = "sha256:7873a551e488afa5044c4949a4aa42c4a4c4290469f0a6dd861e6b95283c9638"},
- {file = "kivy_deps.angle-0.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:71f2f01a3a7bbe1d4790e2a64e64a0ea8ae154418462ea407799ed66898b2c1f"},
- {file = "kivy_deps.angle-0.4.0-cp311-cp311-win32.whl", hash = "sha256:c3899ff1f3886b80b155955bad07bfa33bbebd97718cdf46dfd788dc467124bc"},
- {file = "kivy_deps.angle-0.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:574381d4e66f3198bc48aa10f238e7a3816ad56b80ec939f5d56fb33a378d0b1"},
- {file = "kivy_deps.angle-0.4.0-cp312-cp312-win32.whl", hash = "sha256:4fa7a6366899fba13f7624baf4645787165f45731db08d14557da29c12ee48f0"},
- {file = "kivy_deps.angle-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:668e670d4afd2551af0af2c627ceb0feac884bd799fb6a3dff78fdbfa2ea0451"},
- {file = "kivy_deps.angle-0.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:9afbf702f8bb9a993c48f39c018ca3b4d2ec381a5d3f82fe65bdaa6af0bba29b"},
- {file = "kivy_deps.angle-0.4.0-cp37-cp37m-win32.whl", hash = "sha256:24cfc0076d558080a00c443c7117311b4a977c1916fe297232eff1fd6f62651e"},
- {file = "kivy_deps.angle-0.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:48592ac6f7c183c5cd10d9ebe43d4148d0b2b9e400a2b0bcb5d21014cc929ce2"},
- {file = "kivy_deps.angle-0.4.0-cp38-cp38-win32.whl", hash = "sha256:1bbacf20bf6bd6ee965388f95d937c8fba2c54916fb44faa166c2ba58276753c"},
- {file = "kivy_deps.angle-0.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:e2ba4e390b02ad5bcb57b43a9227fa27ff55e69cd715a87217b324195eb267c3"},
- {file = "kivy_deps.angle-0.4.0-cp39-cp39-win32.whl", hash = "sha256:6546a62aba2b7e18a800b3df79daa757af3a980c297646c986896522395794e2"},
- {file = "kivy_deps.angle-0.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:bfaf9b37f2ecc3e4e7736657eed507716477af35cdd3118903e999d9d567ae8c"},
-]
-
-[[package]]
-name = "kivy-deps-glew"
-version = "0.3.1"
-description = "Repackaged binary dependency of Kivy."
-optional = false
-python-versions = "*"
-files = [
- {file = "kivy_deps.glew-0.3.1-cp310-cp310-win32.whl", hash = "sha256:8f4b3ed15acb62474909b6d41661ffb4da9eb502bb5684301fb2da668f288a58"},
- {file = "kivy_deps.glew-0.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef2d2a93f129d8425c75234e7f6cc0a34b59a4aee67f6d2cd7a5fdfa9915b53"},
- {file = "kivy_deps.glew-0.3.1-cp311-cp311-win32.whl", hash = "sha256:ee2f80ef7ac70f4b61c50da8101b024308a8c59a57f7f25a6e09762b6c48f942"},
- {file = "kivy_deps.glew-0.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:22e155ec59ce717387f5d8804811206d200a023ba3d0bc9bbf1393ee28d0053e"},
- {file = "kivy_deps.glew-0.3.1-cp312-cp312-win32.whl", hash = "sha256:b64ee4e445a04bc7c848c0261a6045fc2f0944cc05d7f953e3860b49f2703424"},
- {file = "kivy_deps.glew-0.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:3acbbd30da05fc10c185b5d4bb75fbbc882a6ef2192963050c1c94d60a6e795a"},
- {file = "kivy_deps.glew-0.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:f4aa8322078359862ccd9e16e5cea61976d75fb43125d87922e20c916fa31a11"},
- {file = "kivy_deps.glew-0.3.1-cp37-cp37m-win32.whl", hash = "sha256:5bf6a63fe9cc4fe7bbf280ec267ec8c47914020a1175fb22152525ff1837b436"},
- {file = "kivy_deps.glew-0.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:d64a8625799fab7a7efeb3661ef8779a7f9c6d80da53eed87a956320f55530fa"},
- {file = "kivy_deps.glew-0.3.1-cp38-cp38-win32.whl", hash = "sha256:00f4ae0a4682d951266458ddb639451edb24baa54a35215dce889209daf19a06"},
- {file = "kivy_deps.glew-0.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f8b89dcf1846032d7a9c5ef88b0ee9cbd13366e9b4c85ada61e01549a910677"},
- {file = "kivy_deps.glew-0.3.1-cp39-cp39-win32.whl", hash = "sha256:4e377ed97670dfda619a1b63a82345a8589be90e7c616a458fba2810708810b1"},
- {file = "kivy_deps.glew-0.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:081a09b92f7e7817f489f8b6b31c9c9623661378de1dce1d6b097af5e7d42b45"},
-]
-
-[[package]]
-name = "kivy-deps-sdl2"
-version = "0.7.0"
-description = "Repackaged binary dependency of Kivy."
-optional = false
-python-versions = "*"
-files = [
- {file = "kivy_deps.sdl2-0.7.0-cp310-cp310-win32.whl", hash = "sha256:3c4b2bf1e473e6124563e1ff58cf3475c4f19fe9248940872c9e3c248bac3cb4"},
- {file = "kivy_deps.sdl2-0.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:ac0f4a6fe989899a60bbdb39516f45e4d90e2499864ab5d63e3706001cde48e8"},
- {file = "kivy_deps.sdl2-0.7.0-cp311-cp311-win32.whl", hash = "sha256:b727123d059c0c00c7d13cc1db8c8cfd0e48388cf24c11ec71cc6783811063c8"},
- {file = "kivy_deps.sdl2-0.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd946ca4e36a403bcafbe202033948c17f54bd5d28a343d98efd61f976822855"},
- {file = "kivy_deps.sdl2-0.7.0-cp312-cp312-win32.whl", hash = "sha256:2a8f23fe201dea368b47adfecf8fb9133315788d314ad32f33000254aa2388e4"},
- {file = "kivy_deps.sdl2-0.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:e56d5d651f81545c24f920f6f6e5d67b4100802152521022ccde53e822c507a2"},
- {file = "kivy_deps.sdl2-0.7.0-cp37-cp37m-win32.whl", hash = "sha256:c75626f6a3f8979b1c6a59e5070c7a547bb7c379a8e03f249af6b4c399305fc1"},
- {file = "kivy_deps.sdl2-0.7.0-cp37-cp37m-win_amd64.whl", hash = "sha256:95005fb3ae5b9e1d5edd32a6c0cfae9019efa2aeb3d909738dd73c5b9eea9dc1"},
- {file = "kivy_deps.sdl2-0.7.0-cp38-cp38-win32.whl", hash = "sha256:9728eaf70af514e0df163b062944fec008a5ceb73e53897ac89e62fcd2b0bac2"},
- {file = "kivy_deps.sdl2-0.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:a23811df7359e62acf4002fe5240d968a25e7aeaf7989b78b59cd6437f34f7b9"},
- {file = "kivy_deps.sdl2-0.7.0-cp39-cp39-win32.whl", hash = "sha256:ecbbcbd562a14a4a3870c8b6a0b1612eda24e9435df74fbb8e5f670560f0a9d6"},
- {file = "kivy_deps.sdl2-0.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:a5ef494d2f57224b93649df5f7a20c4f4cbc22416167732bf9f62d1cb263fef4"},
-]
-
-[[package]]
-name = "kivy-garden"
-version = "0.1.5"
-description = ""
-optional = false
-python-versions = "*"
-files = [
- {file = "Kivy Garden-0.1.5.tar.gz", hash = "sha256:2b8377378e87501d5d271f33d94f0e44c089884572c64f89c9d609b1f86a2748"},
- {file = "Kivy_Garden-0.1.5-py3-none-any.whl", hash = "sha256:ef50f44b96358cf10ac5665f27a4751bb34ef54051c54b93af891f80afe42929"},
-]
-
-[package.dependencies]
-requests = "*"
-
-[[package]]
-name = "kiwisolver"
-version = "1.4.7"
-description = "A fast implementation of the Cassowary constraint solver"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "kiwisolver-1.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8a9c83f75223d5e48b0bc9cb1bf2776cf01563e00ade8775ffe13b0b6e1af3a6"},
- {file = "kiwisolver-1.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:58370b1ffbd35407444d57057b57da5d6549d2d854fa30249771775c63b5fe17"},
- {file = "kiwisolver-1.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa0abdf853e09aff551db11fce173e2177d00786c688203f52c87ad7fcd91ef9"},
- {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8d53103597a252fb3ab8b5845af04c7a26d5e7ea8122303dd7a021176a87e8b9"},
- {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:88f17c5ffa8e9462fb79f62746428dd57b46eb931698e42e990ad63103f35e6c"},
- {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a9ca9c710d598fd75ee5de59d5bda2684d9db36a9f50b6125eaea3969c2599"},
- {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f4d742cb7af1c28303a51b7a27aaee540e71bb8e24f68c736f6f2ffc82f2bf05"},
- {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e28c7fea2196bf4c2f8d46a0415c77a1c480cc0724722f23d7410ffe9842c407"},
- {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e968b84db54f9d42046cf154e02911e39c0435c9801681e3fc9ce8a3c4130278"},
- {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0c18ec74c0472de033e1bebb2911c3c310eef5649133dd0bedf2a169a1b269e5"},
- {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8f0ea6da6d393d8b2e187e6a5e3fb81f5862010a40c3945e2c6d12ae45cfb2ad"},
- {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:f106407dda69ae456dd1227966bf445b157ccc80ba0dff3802bb63f30b74e895"},
- {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:84ec80df401cfee1457063732d90022f93951944b5b58975d34ab56bb150dfb3"},
- {file = "kiwisolver-1.4.7-cp310-cp310-win32.whl", hash = "sha256:71bb308552200fb2c195e35ef05de12f0c878c07fc91c270eb3d6e41698c3bcc"},
- {file = "kiwisolver-1.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:44756f9fd339de0fb6ee4f8c1696cfd19b2422e0d70b4cefc1cc7f1f64045a8c"},
- {file = "kiwisolver-1.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:78a42513018c41c2ffd262eb676442315cbfe3c44eed82385c2ed043bc63210a"},
- {file = "kiwisolver-1.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d2b0e12a42fb4e72d509fc994713d099cbb15ebf1103545e8a45f14da2dfca54"},
- {file = "kiwisolver-1.4.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2a8781ac3edc42ea4b90bc23e7d37b665d89423818e26eb6df90698aa2287c95"},
- {file = "kiwisolver-1.4.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:46707a10836894b559e04b0fd143e343945c97fd170d69a2d26d640b4e297935"},
- {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef97b8df011141c9b0f6caf23b29379f87dd13183c978a30a3c546d2c47314cb"},
- {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ab58c12a2cd0fc769089e6d38466c46d7f76aced0a1f54c77652446733d2d02"},
- {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:803b8e1459341c1bb56d1c5c010406d5edec8a0713a0945851290a7930679b51"},
- {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9a9e8a507420fe35992ee9ecb302dab68550dedc0da9e2880dd88071c5fb052"},
- {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18077b53dc3bb490e330669a99920c5e6a496889ae8c63b58fbc57c3d7f33a18"},
- {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6af936f79086a89b3680a280c47ea90b4df7047b5bdf3aa5c524bbedddb9e545"},
- {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3abc5b19d24af4b77d1598a585b8a719beb8569a71568b66f4ebe1fb0449460b"},
- {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:933d4de052939d90afbe6e9d5273ae05fb836cc86c15b686edd4b3560cc0ee36"},
- {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:65e720d2ab2b53f1f72fb5da5fb477455905ce2c88aaa671ff0a447c2c80e8e3"},
- {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3bf1ed55088f214ba6427484c59553123fdd9b218a42bbc8c6496d6754b1e523"},
- {file = "kiwisolver-1.4.7-cp311-cp311-win32.whl", hash = "sha256:4c00336b9dd5ad96d0a558fd18a8b6f711b7449acce4c157e7343ba92dd0cf3d"},
- {file = "kiwisolver-1.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:929e294c1ac1e9f615c62a4e4313ca1823ba37326c164ec720a803287c4c499b"},
- {file = "kiwisolver-1.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:e33e8fbd440c917106b237ef1a2f1449dfbb9b6f6e1ce17c94cd6a1e0d438376"},
- {file = "kiwisolver-1.4.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:5360cc32706dab3931f738d3079652d20982511f7c0ac5711483e6eab08efff2"},
- {file = "kiwisolver-1.4.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942216596dc64ddb25adb215c3c783215b23626f8d84e8eff8d6d45c3f29f75a"},
- {file = "kiwisolver-1.4.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:48b571ecd8bae15702e4f22d3ff6a0f13e54d3d00cd25216d5e7f658242065ee"},
- {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad42ba922c67c5f219097b28fae965e10045ddf145d2928bfac2eb2e17673640"},
- {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:612a10bdae23404a72941a0fc8fa2660c6ea1217c4ce0dbcab8a8f6543ea9e7f"},
- {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e838bba3a3bac0fe06d849d29772eb1afb9745a59710762e4ba3f4cb8424483"},
- {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22f499f6157236c19f4bbbd472fa55b063db77a16cd74d49afe28992dff8c258"},
- {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693902d433cf585133699972b6d7c42a8b9f8f826ebcaf0132ff55200afc599e"},
- {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e77f2126c3e0b0d055f44513ed349038ac180371ed9b52fe96a32aa071a5107"},
- {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:657a05857bda581c3656bfc3b20e353c232e9193eb167766ad2dc58b56504948"},
- {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4bfa75a048c056a411f9705856abfc872558e33c055d80af6a380e3658766038"},
- {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:34ea1de54beef1c104422d210c47c7d2a4999bdecf42c7b5718fbe59a4cac383"},
- {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:90da3b5f694b85231cf93586dad5e90e2d71b9428f9aad96952c99055582f520"},
- {file = "kiwisolver-1.4.7-cp312-cp312-win32.whl", hash = "sha256:18e0cca3e008e17fe9b164b55735a325140a5a35faad8de92dd80265cd5eb80b"},
- {file = "kiwisolver-1.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:58cb20602b18f86f83a5c87d3ee1c766a79c0d452f8def86d925e6c60fbf7bfb"},
- {file = "kiwisolver-1.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:f5a8b53bdc0b3961f8b6125e198617c40aeed638b387913bf1ce78afb1b0be2a"},
- {file = "kiwisolver-1.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2e6039dcbe79a8e0f044f1c39db1986a1b8071051efba3ee4d74f5b365f5226e"},
- {file = "kiwisolver-1.4.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a1ecf0ac1c518487d9d23b1cd7139a6a65bc460cd101ab01f1be82ecf09794b6"},
- {file = "kiwisolver-1.4.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ab9ccab2b5bd5702ab0803676a580fffa2aa178c2badc5557a84cc943fcf750"},
- {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f816dd2277f8d63d79f9c8473a79fe54047bc0467754962840782c575522224d"},
- {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf8bcc23ceb5a1b624572a1623b9f79d2c3b337c8c455405ef231933a10da379"},
- {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dea0bf229319828467d7fca8c7c189780aa9ff679c94539eed7532ebe33ed37c"},
- {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c06a4c7cf15ec739ce0e5971b26c93638730090add60e183530d70848ebdd34"},
- {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:913983ad2deb14e66d83c28b632fd35ba2b825031f2fa4ca29675e665dfecbe1"},
- {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5337ec7809bcd0f424c6b705ecf97941c46279cf5ed92311782c7c9c2026f07f"},
- {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4c26ed10c4f6fa6ddb329a5120ba3b6db349ca192ae211e882970bfc9d91420b"},
- {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c619b101e6de2222c1fcb0531e1b17bbffbe54294bfba43ea0d411d428618c27"},
- {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:073a36c8273647592ea332e816e75ef8da5c303236ec0167196793eb1e34657a"},
- {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ce6b2b0231bda412463e152fc18335ba32faf4e8c23a754ad50ffa70e4091ee"},
- {file = "kiwisolver-1.4.7-cp313-cp313-win32.whl", hash = "sha256:f4c9aee212bc89d4e13f58be11a56cc8036cabad119259d12ace14b34476fd07"},
- {file = "kiwisolver-1.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:8a3ec5aa8e38fc4c8af308917ce12c536f1c88452ce554027e55b22cbbfbff76"},
- {file = "kiwisolver-1.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:76c8094ac20ec259471ac53e774623eb62e6e1f56cd8690c67ce6ce4fcb05650"},
- {file = "kiwisolver-1.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5d5abf8f8ec1f4e22882273c423e16cae834c36856cac348cfbfa68e01c40f3a"},
- {file = "kiwisolver-1.4.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:aeb3531b196ef6f11776c21674dba836aeea9d5bd1cf630f869e3d90b16cfade"},
- {file = "kiwisolver-1.4.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b7d755065e4e866a8086c9bdada157133ff466476a2ad7861828e17b6026e22c"},
- {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08471d4d86cbaec61f86b217dd938a83d85e03785f51121e791a6e6689a3be95"},
- {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bbfcb7165ce3d54a3dfbe731e470f65739c4c1f85bb1018ee912bae139e263b"},
- {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d34eb8494bea691a1a450141ebb5385e4b69d38bb8403b5146ad279f4b30fa3"},
- {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9242795d174daa40105c1d86aba618e8eab7bf96ba8c3ee614da8302a9f95503"},
- {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a0f64a48bb81af7450e641e3fe0b0394d7381e342805479178b3d335d60ca7cf"},
- {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8e045731a5416357638d1700927529e2b8ab304811671f665b225f8bf8d8f933"},
- {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4322872d5772cae7369f8351da1edf255a604ea7087fe295411397d0cfd9655e"},
- {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:e1631290ee9271dffe3062d2634c3ecac02c83890ada077d225e081aca8aab89"},
- {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:edcfc407e4eb17e037bca59be0e85a2031a2ac87e4fed26d3e9df88b4165f92d"},
- {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4d05d81ecb47d11e7f8932bd8b61b720bf0b41199358f3f5e36d38e28f0532c5"},
- {file = "kiwisolver-1.4.7-cp38-cp38-win32.whl", hash = "sha256:b38ac83d5f04b15e515fd86f312479d950d05ce2368d5413d46c088dda7de90a"},
- {file = "kiwisolver-1.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:d83db7cde68459fc803052a55ace60bea2bae361fc3b7a6d5da07e11954e4b09"},
- {file = "kiwisolver-1.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3f9362ecfca44c863569d3d3c033dbe8ba452ff8eed6f6b5806382741a1334bd"},
- {file = "kiwisolver-1.4.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e8df2eb9b2bac43ef8b082e06f750350fbbaf2887534a5be97f6cf07b19d9583"},
- {file = "kiwisolver-1.4.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f32d6edbc638cde7652bd690c3e728b25332acbadd7cad670cc4a02558d9c417"},
- {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e2e6c39bd7b9372b0be21456caab138e8e69cc0fc1190a9dfa92bd45a1e6e904"},
- {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dda56c24d869b1193fcc763f1284b9126550eaf84b88bbc7256e15028f19188a"},
- {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79849239c39b5e1fd906556c474d9b0439ea6792b637511f3fe3a41158d89ca8"},
- {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e3bc157fed2a4c02ec468de4ecd12a6e22818d4f09cde2c31ee3226ffbefab2"},
- {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3da53da805b71e41053dc670f9a820d1157aae77b6b944e08024d17bcd51ef88"},
- {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8705f17dfeb43139a692298cb6637ee2e59c0194538153e83e9ee0c75c2eddde"},
- {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:82a5c2f4b87c26bb1a0ef3d16b5c4753434633b83d365cc0ddf2770c93829e3c"},
- {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce8be0466f4c0d585cdb6c1e2ed07232221df101a4c6f28821d2aa754ca2d9e2"},
- {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:409afdfe1e2e90e6ee7fc896f3df9a7fec8e793e58bfa0d052c8a82f99c37abb"},
- {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5b9c3f4ee0b9a439d2415012bd1b1cc2df59e4d6a9939f4d669241d30b414327"},
- {file = "kiwisolver-1.4.7-cp39-cp39-win32.whl", hash = "sha256:a79ae34384df2b615eefca647a2873842ac3b596418032bef9a7283675962644"},
- {file = "kiwisolver-1.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:cf0438b42121a66a3a667de17e779330fc0f20b0d97d59d2f2121e182b0505e4"},
- {file = "kiwisolver-1.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:764202cc7e70f767dab49e8df52c7455e8de0df5d858fa801a11aa0d882ccf3f"},
- {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:94252291e3fe68001b1dd747b4c0b3be12582839b95ad4d1b641924d68fd4643"},
- {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b7dfa3b546da08a9f622bb6becdb14b3e24aaa30adba66749d38f3cc7ea9706"},
- {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd3de6481f4ed8b734da5df134cd5a6a64fe32124fe83dde1e5b5f29fe30b1e6"},
- {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a91b5f9f1205845d488c928e8570dcb62b893372f63b8b6e98b863ebd2368ff2"},
- {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40fa14dbd66b8b8f470d5fc79c089a66185619d31645f9b0773b88b19f7223c4"},
- {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:eb542fe7933aa09d8d8f9d9097ef37532a7df6497819d16efe4359890a2f417a"},
- {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bfa1acfa0c54932d5607e19a2c24646fb4c1ae2694437789129cf099789a3b00"},
- {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:eee3ea935c3d227d49b4eb85660ff631556841f6e567f0f7bda972df6c2c9935"},
- {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f3160309af4396e0ed04db259c3ccbfdc3621b5559b5453075e5de555e1f3a1b"},
- {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a17f6a29cf8935e587cc8a4dbfc8368c55edc645283db0ce9801016f83526c2d"},
- {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10849fb2c1ecbfae45a693c070e0320a91b35dd4bcf58172c023b994283a124d"},
- {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:ac542bf38a8a4be2dc6b15248d36315ccc65f0743f7b1a76688ffb6b5129a5c2"},
- {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8b01aac285f91ca889c800042c35ad3b239e704b150cfd3382adfc9dcc780e39"},
- {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:48be928f59a1f5c8207154f935334d374e79f2b5d212826307d072595ad76a2e"},
- {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f37cfe618a117e50d8c240555331160d73d0411422b59b5ee217843d7b693608"},
- {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:599b5c873c63a1f6ed7eead644a8a380cfbdf5db91dcb6f85707aaab213b1674"},
- {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:801fa7802e5cfabe3ab0c81a34c323a319b097dfb5004be950482d882f3d7225"},
- {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:0c6c43471bc764fad4bc99c5c2d6d16a676b1abf844ca7c8702bdae92df01ee0"},
- {file = "kiwisolver-1.4.7.tar.gz", hash = "sha256:9893ff81bd7107f7b685d3017cc6583daadb4fc26e4a888350df530e41980a60"},
-]
-
-[[package]]
-name = "lazy-loader"
-version = "0.4"
-description = "Makes it easy to load subpackages and functions on demand."
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc"},
- {file = "lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1"},
-]
-
-[package.dependencies]
-packaging = "*"
-
-[package.extras]
-dev = ["changelist (==0.5)"]
-lint = ["pre-commit (==3.7.0)"]
-test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"]
-
-[[package]]
-name = "loguru"
-version = "0.7.2"
-description = "Python logging made (stupidly) simple"
-optional = false
-python-versions = ">=3.5"
-files = [
- {file = "loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb"},
- {file = "loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac"},
-]
-
-[package.dependencies]
-colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
-win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
-
-[package.extras]
-dev = ["Sphinx (==7.2.5)", "colorama (==0.4.5)", "colorama (==0.4.6)", "exceptiongroup (==1.1.3)", "freezegun (==1.1.0)", "freezegun (==1.2.2)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v1.4.1)", "mypy (==v1.5.1)", "pre-commit (==3.4.0)", "pytest (==6.1.2)", "pytest (==7.4.0)", "pytest-cov (==2.12.1)", "pytest-cov (==4.1.0)", "pytest-mypy-plugins (==1.9.3)", "pytest-mypy-plugins (==3.0.0)", "sphinx-autobuild (==2021.3.14)", "sphinx-rtd-theme (==1.3.0)", "tox (==3.27.1)", "tox (==4.11.0)"]
-
-[[package]]
-name = "macholib"
-version = "1.16.3"
-description = "Mach-O header analysis and editing"
-optional = false
-python-versions = "*"
-files = [
- {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"},
- {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"},
-]
-
-[package.dependencies]
-altgraph = ">=0.17"
-
-[[package]]
-name = "markdown"
-version = "3.7"
-description = "Python implementation of John Gruber's Markdown."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"},
- {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"},
-]
-
-[package.extras]
-docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
-testing = ["coverage", "pyyaml"]
-
-[[package]]
-name = "markdown-it-py"
-version = "3.0.0"
-description = "Python port of markdown-it. Markdown parsing, done right!"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
- {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
-]
-
-[package.dependencies]
-mdurl = ">=0.1,<1.0"
-
-[package.extras]
-benchmarking = ["psutil", "pytest", "pytest-benchmark"]
-code-style = ["pre-commit (>=3.0,<4.0)"]
-compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
-linkify = ["linkify-it-py (>=1,<3)"]
-plugins = ["mdit-py-plugins"]
-profiling = ["gprof2dot"]
-rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
-testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
-
-[[package]]
-name = "markupsafe"
-version = "3.0.2"
-description = "Safely add untrusted strings to HTML/XML markup."
-optional = false
-python-versions = ">=3.9"
-files = [
- {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"},
- {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"},
- {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"},
- {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"},
- {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"},
- {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"},
- {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"},
- {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"},
- {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"},
- {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"},
- {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"},
- {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"},
- {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"},
- {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"},
- {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"},
- {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"},
- {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"},
- {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"},
- {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"},
- {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"},
- {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"},
- {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"},
- {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"},
- {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"},
- {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"},
- {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"},
- {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"},
- {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"},
- {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"},
- {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"},
- {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"},
- {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"},
- {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"},
- {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"},
- {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"},
- {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"},
- {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"},
- {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"},
- {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"},
- {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"},
- {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"},
- {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"},
- {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"},
- {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"},
- {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"},
- {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"},
- {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"},
- {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"},
- {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"},
- {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"},
- {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"},
- {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"},
- {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"},
- {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"},
- {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"},
- {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"},
- {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"},
- {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"},
- {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"},
- {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"},
- {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"},
-]
-
-[[package]]
-name = "matplotlib"
-version = "3.9.2"
-description = "Python plotting package"
-optional = false
-python-versions = ">=3.9"
-files = [
- {file = "matplotlib-3.9.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9d78bbc0cbc891ad55b4f39a48c22182e9bdaea7fc0e5dbd364f49f729ca1bbb"},
- {file = "matplotlib-3.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c375cc72229614632c87355366bdf2570c2dac01ac66b8ad048d2dabadf2d0d4"},
- {file = "matplotlib-3.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d94ff717eb2bd0b58fe66380bd8b14ac35f48a98e7c6765117fe67fb7684e64"},
- {file = "matplotlib-3.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab68d50c06938ef28681073327795c5db99bb4666214d2d5f880ed11aeaded66"},
- {file = "matplotlib-3.9.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:65aacf95b62272d568044531e41de26285d54aec8cb859031f511f84bd8b495a"},
- {file = "matplotlib-3.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:3fd595f34aa8a55b7fc8bf9ebea8aa665a84c82d275190a61118d33fbc82ccae"},
- {file = "matplotlib-3.9.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8dd059447824eec055e829258ab092b56bb0579fc3164fa09c64f3acd478772"},
- {file = "matplotlib-3.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c797dac8bb9c7a3fd3382b16fe8f215b4cf0f22adccea36f1545a6d7be310b41"},
- {file = "matplotlib-3.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d719465db13267bcef19ea8954a971db03b9f48b4647e3860e4bc8e6ed86610f"},
- {file = "matplotlib-3.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8912ef7c2362f7193b5819d17dae8629b34a95c58603d781329712ada83f9447"},
- {file = "matplotlib-3.9.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7741f26a58a240f43bee74965c4882b6c93df3e7eb3de160126d8c8f53a6ae6e"},
- {file = "matplotlib-3.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:ae82a14dab96fbfad7965403c643cafe6515e386de723e498cf3eeb1e0b70cc7"},
- {file = "matplotlib-3.9.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ac43031375a65c3196bee99f6001e7fa5bdfb00ddf43379d3c0609bdca042df9"},
- {file = "matplotlib-3.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be0fc24a5e4531ae4d8e858a1a548c1fe33b176bb13eff7f9d0d38ce5112a27d"},
- {file = "matplotlib-3.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf81de2926c2db243c9b2cbc3917619a0fc85796c6ba4e58f541df814bbf83c7"},
- {file = "matplotlib-3.9.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6ee45bc4245533111ced13f1f2cace1e7f89d1c793390392a80c139d6cf0e6c"},
- {file = "matplotlib-3.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:306c8dfc73239f0e72ac50e5a9cf19cc4e8e331dd0c54f5e69ca8758550f1e1e"},
- {file = "matplotlib-3.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:5413401594cfaff0052f9d8b1aafc6d305b4bd7c4331dccd18f561ff7e1d3bd3"},
- {file = "matplotlib-3.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:18128cc08f0d3cfff10b76baa2f296fc28c4607368a8402de61bb3f2eb33c7d9"},
- {file = "matplotlib-3.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4876d7d40219e8ae8bb70f9263bcbe5714415acfdf781086601211335e24f8aa"},
- {file = "matplotlib-3.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d9f07a80deab4bb0b82858a9e9ad53d1382fd122be8cde11080f4e7dfedb38b"},
- {file = "matplotlib-3.9.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7c0410f181a531ec4e93bbc27692f2c71a15c2da16766f5ba9761e7ae518413"},
- {file = "matplotlib-3.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:909645cce2dc28b735674ce0931a4ac94e12f5b13f6bb0b5a5e65e7cea2c192b"},
- {file = "matplotlib-3.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:f32c7410c7f246838a77d6d1eff0c0f87f3cb0e7c4247aebea71a6d5a68cab49"},
- {file = "matplotlib-3.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:37e51dd1c2db16ede9cfd7b5cabdfc818b2c6397c83f8b10e0e797501c963a03"},
- {file = "matplotlib-3.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b82c5045cebcecd8496a4d694d43f9cc84aeeb49fe2133e036b207abe73f4d30"},
- {file = "matplotlib-3.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f053c40f94bc51bc03832a41b4f153d83f2062d88c72b5e79997072594e97e51"},
- {file = "matplotlib-3.9.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbe196377a8248972f5cede786d4c5508ed5f5ca4a1e09b44bda889958b33f8c"},
- {file = "matplotlib-3.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5816b1e1fe8c192cbc013f8f3e3368ac56fbecf02fb41b8f8559303f24c5015e"},
- {file = "matplotlib-3.9.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:cef2a73d06601437be399908cf13aee74e86932a5ccc6ccdf173408ebc5f6bb2"},
- {file = "matplotlib-3.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e0830e188029c14e891fadd99702fd90d317df294c3298aad682739c5533721a"},
- {file = "matplotlib-3.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03ba9c1299c920964e8d3857ba27173b4dbb51ca4bab47ffc2c2ba0eb5e2cbc5"},
- {file = "matplotlib-3.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1cd93b91ab47a3616b4d3c42b52f8363b88ca021e340804c6ab2536344fad9ca"},
- {file = "matplotlib-3.9.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6d1ce5ed2aefcdce11904fc5bbea7d9c21fff3d5f543841edf3dea84451a09ea"},
- {file = "matplotlib-3.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:b2696efdc08648536efd4e1601b5fd491fd47f4db97a5fbfd175549a7365c1b2"},
- {file = "matplotlib-3.9.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d52a3b618cb1cbb769ce2ee1dcdb333c3ab6e823944e9a2d36e37253815f9556"},
- {file = "matplotlib-3.9.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:039082812cacd6c6bec8e17a9c1e6baca230d4116d522e81e1f63a74d01d2e21"},
- {file = "matplotlib-3.9.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6758baae2ed64f2331d4fd19be38b7b4eae3ecec210049a26b6a4f3ae1c85dcc"},
- {file = "matplotlib-3.9.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:050598c2b29e0b9832cde72bcf97627bf00262adbc4a54e2b856426bb2ef0697"},
- {file = "matplotlib-3.9.2.tar.gz", hash = "sha256:96ab43906269ca64a6366934106fa01534454a69e471b7bf3d79083981aaab92"},
-]
-
-[package.dependencies]
-contourpy = ">=1.0.1"
-cycler = ">=0.10"
-fonttools = ">=4.22.0"
-kiwisolver = ">=1.3.1"
-numpy = ">=1.23"
-packaging = ">=20.0"
-pillow = ">=8"
-pyparsing = ">=2.3.1"
-python-dateutil = ">=2.7"
-
-[package.extras]
-dev = ["meson-python (>=0.13.1)", "numpy (>=1.25)", "pybind11 (>=2.6)", "setuptools (>=64)", "setuptools_scm (>=7)"]
-
-[[package]]
-name = "mdurl"
-version = "0.1.2"
-description = "Markdown URL utilities"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
- {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
-]
-
-[[package]]
-name = "memory-profiler"
-version = "0.61.0"
-description = "A module for monitoring memory usage of a python program"
-optional = false
-python-versions = ">=3.5"
-files = [
- {file = "memory_profiler-0.61.0-py3-none-any.whl", hash = "sha256:400348e61031e3942ad4d4109d18753b2fb08c2f6fb8290671c5513a34182d84"},
- {file = "memory_profiler-0.61.0.tar.gz", hash = "sha256:4e5b73d7864a1d1292fb76a03e82a3e78ef934d06828a698d9dada76da2067b0"},
-]
-
-[package.dependencies]
-psutil = "*"
-
-[[package]]
-name = "mergedeep"
-version = "1.3.4"
-description = "A deep merge function for π."
-optional = false
-python-versions = ">=3.6"
-files = [
- {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"},
- {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"},
-]
-
-[[package]]
-name = "mkdocs"
-version = "1.6.1"
-description = "Project documentation with Markdown."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"},
- {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"},
-]
-
-[package.dependencies]
-click = ">=7.0"
-colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""}
-ghp-import = ">=1.0"
-jinja2 = ">=2.11.1"
-markdown = ">=3.3.6"
-markupsafe = ">=2.0.1"
-mergedeep = ">=1.3.4"
-mkdocs-get-deps = ">=0.2.0"
-packaging = ">=20.5"
-pathspec = ">=0.11.1"
-pyyaml = ">=5.1"
-pyyaml-env-tag = ">=0.1"
-watchdog = ">=2.0"
-
-[package.extras]
-i18n = ["babel (>=2.9.0)"]
-min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.4)", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"]
-
-[[package]]
-name = "mkdocs-autolinks-plugin"
-version = "0.7.1"
-description = "An MkDocs plugin"
-optional = false
-python-versions = ">=3.4"
-files = [
- {file = "mkdocs-autolinks-plugin-0.7.1.tar.gz", hash = "sha256:445ddb9b417b7795856c30801bb430773186c1daf210bdeecf8305f55a47d151"},
- {file = "mkdocs_autolinks_plugin-0.7.1-py3-none-any.whl", hash = "sha256:5c6c17f6649b68e79a9ef0b2648d59f3072e18002b90ee1586a64c505f11ab12"},
-]
-
-[package.dependencies]
-mkdocs = ">=1.2.3"
-
-[[package]]
-name = "mkdocs-autorefs"
-version = "1.2.0"
-description = "Automatically link across pages in MkDocs."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "mkdocs_autorefs-1.2.0-py3-none-any.whl", hash = "sha256:d588754ae89bd0ced0c70c06f58566a4ee43471eeeee5202427da7de9ef85a2f"},
- {file = "mkdocs_autorefs-1.2.0.tar.gz", hash = "sha256:a86b93abff653521bda71cf3fc5596342b7a23982093915cb74273f67522190f"},
-]
-
-[package.dependencies]
-Markdown = ">=3.3"
-markupsafe = ">=2.0.1"
-mkdocs = ">=1.1"
-
-[[package]]
-name = "mkdocs-gen-files"
-version = "0.5.0"
-description = "MkDocs plugin to programmatically generate documentation pages during the build"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "mkdocs_gen_files-0.5.0-py3-none-any.whl", hash = "sha256:7ac060096f3f40bd19039e7277dd3050be9a453c8ac578645844d4d91d7978ea"},
- {file = "mkdocs_gen_files-0.5.0.tar.gz", hash = "sha256:4c7cf256b5d67062a788f6b1d035e157fc1a9498c2399be9af5257d4ff4d19bc"},
-]
-
-[package.dependencies]
-mkdocs = ">=1.0.3"
-
-[[package]]
-name = "mkdocs-get-deps"
-version = "0.2.0"
-description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"},
- {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"},
-]
-
-[package.dependencies]
-mergedeep = ">=1.3.4"
-platformdirs = ">=2.2.0"
-pyyaml = ">=5.1"
-
-[[package]]
-name = "mkdocs-git-revision-date-plugin"
-version = "0.3.2"
-description = "MkDocs plugin for setting revision date from git per markdown file."
-optional = false
-python-versions = ">=3.4"
-files = [
- {file = "mkdocs_git_revision_date_plugin-0.3.2-py3-none-any.whl", hash = "sha256:2e67956cb01823dd2418e2833f3623dee8604cdf223bddd005fe36226a56f6ef"},
-]
-
-[package.dependencies]
-GitPython = "*"
-jinja2 = "*"
-mkdocs = ">=0.17"
-
-[[package]]
-name = "mkdocs-include-markdown-plugin"
-version = "6.2.2"
-description = "Mkdocs Markdown includer plugin."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "mkdocs_include_markdown_plugin-6.2.2-py3-none-any.whl", hash = "sha256:d293950f6499d2944291ca7b9bc4a60e652bbfd3e3a42b564f6cceee268694e7"},
- {file = "mkdocs_include_markdown_plugin-6.2.2.tar.gz", hash = "sha256:f2bd5026650492a581d2fd44be6c22f90391910d76582b96a34c264f2d17875d"},
-]
-
-[package.dependencies]
-mkdocs = ">=1.4"
-wcmatch = "*"
-
-[package.extras]
-cache = ["platformdirs"]
-
-[[package]]
-name = "mkdocs-material"
-version = "9.5.42"
-description = "Documentation that simply works"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "mkdocs_material-9.5.42-py3-none-any.whl", hash = "sha256:452a7c5d21284b373f36b981a2cbebfff59263feebeede1bc28652e9c5bbe316"},
- {file = "mkdocs_material-9.5.42.tar.gz", hash = "sha256:92779b5e9b5934540c574c11647131d217dc540dce72b05feeda088c8eb1b8f2"},
-]
-
-[package.dependencies]
-babel = ">=2.10,<3.0"
-colorama = ">=0.4,<1.0"
-jinja2 = ">=3.0,<4.0"
-markdown = ">=3.2,<4.0"
-mkdocs = ">=1.6,<2.0"
-mkdocs-material-extensions = ">=1.3,<2.0"
-paginate = ">=0.5,<1.0"
-pygments = ">=2.16,<3.0"
-pymdown-extensions = ">=10.2,<11.0"
-regex = ">=2022.4"
-requests = ">=2.26,<3.0"
-
-[package.extras]
-git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"]
-imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"]
-recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"]
-
-[[package]]
-name = "mkdocs-material-extensions"
-version = "1.3.1"
-description = "Extension pack for Python Markdown and MkDocs Material."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"},
- {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"},
-]
-
-[[package]]
-name = "mkdocs-minify-plugin"
-version = "0.7.2"
-description = "An MkDocs plugin to minify HTML, JS or CSS files prior to being written to disk"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "mkdocs-minify-plugin-0.7.2.tar.gz", hash = "sha256:6a551e22d6517eaef9e1890afd60021dc1dcd1255de02d266f588d1ace040713"},
- {file = "mkdocs_minify_plugin-0.7.2-py3-none-any.whl", hash = "sha256:ae8bfc4a68806883e990ea025938b3f989da7b9fa08ea8390dba47adf25e0c5b"},
-]
-
-[package.dependencies]
-csscompressor = ">=0.9.5"
-htmlmin2 = ">=0.1.13"
-jsmin = ">=3.0.1"
-mkdocs = ">=1.4.1"
-
-[[package]]
-name = "mkdocs-pymdownx-material-extras"
-version = "2.6"
-description = "Plugin to extend MkDocs Material theme."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "mkdocs_pymdownx_material_extras-2.6-py3-none-any.whl", hash = "sha256:9a005c933c70fdfd2bdb320022b23ddce0b3fc5595aeef7084c8b7613804190b"},
- {file = "mkdocs_pymdownx_material_extras-2.6.tar.gz", hash = "sha256:2aae99cd91a604811ec8b750cc9526f8a404961d90cee30fd43ff112f651d8b1"},
-]
-
-[package.dependencies]
-mkdocs-material = ">=8.3.3"
-
-[[package]]
-name = "mkdocs-same-dir"
-version = "0.1.3"
-description = "MkDocs plugin to allow placing mkdocs.yml in the same directory as documentation"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "mkdocs_same_dir-0.1.3-py3-none-any.whl", hash = "sha256:3d094649e2e47efcf90a8b0051a4c2b837aaf4137a28c8e334ba9465804a317e"},
- {file = "mkdocs_same_dir-0.1.3.tar.gz", hash = "sha256:c849556b1d79ae270947f41bb89d442aa1e858ab6ec6423eb178ae76a7f984fc"},
-]
-
-[package.dependencies]
-mkdocs = ">=1.0.3"
-
-[[package]]
-name = "mkdocstrings"
-version = "0.23.0"
-description = "Automatic documentation from sources, for MkDocs."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "mkdocstrings-0.23.0-py3-none-any.whl", hash = "sha256:051fa4014dfcd9ed90254ae91de2dbb4f24e166347dae7be9a997fe16316c65e"},
- {file = "mkdocstrings-0.23.0.tar.gz", hash = "sha256:d9c6a37ffbe7c14a7a54ef1258c70b8d394e6a33a1c80832bce40b9567138d1c"},
-]
-
-[package.dependencies]
-Jinja2 = ">=2.11.1"
-Markdown = ">=3.3"
-MarkupSafe = ">=1.1"
-mkdocs = ">=1.2"
-mkdocs-autorefs = ">=0.3.1"
-mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""}
-pymdown-extensions = ">=6.3"
-
-[package.extras]
-crystal = ["mkdocstrings-crystal (>=0.3.4)"]
-python = ["mkdocstrings-python (>=0.5.2)"]
-python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
-
-[[package]]
-name = "mkdocstrings-python"
-version = "1.8.0"
-description = "A Python handler for mkdocstrings."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "mkdocstrings_python-1.8.0-py3-none-any.whl", hash = "sha256:4209970cc90bec194568682a535848a8d8489516c6ed4adbe58bbc67b699ca9d"},
- {file = "mkdocstrings_python-1.8.0.tar.gz", hash = "sha256:1488bddf50ee42c07d9a488dddc197f8e8999c2899687043ec5dd1643d057192"},
-]
-
-[package.dependencies]
-griffe = ">=0.37"
-mkdocstrings = ">=0.20"
-
-[[package]]
-name = "multidict"
-version = "6.1.0"
-description = "multidict implementation"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"},
- {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"},
- {file = "multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53"},
- {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5"},
- {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581"},
- {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56"},
- {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429"},
- {file = "multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748"},
- {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db"},
- {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056"},
- {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76"},
- {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160"},
- {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7"},
- {file = "multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0"},
- {file = "multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d"},
- {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6"},
- {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156"},
- {file = "multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb"},
- {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b"},
- {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72"},
- {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304"},
- {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351"},
- {file = "multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb"},
- {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3"},
- {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399"},
- {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423"},
- {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3"},
- {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753"},
- {file = "multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80"},
- {file = "multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926"},
- {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa"},
- {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436"},
- {file = "multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761"},
- {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e"},
- {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef"},
- {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95"},
- {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925"},
- {file = "multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966"},
- {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305"},
- {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2"},
- {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2"},
- {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6"},
- {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3"},
- {file = "multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133"},
- {file = "multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1"},
- {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008"},
- {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f"},
- {file = "multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28"},
- {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b"},
- {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c"},
- {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3"},
- {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44"},
- {file = "multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2"},
- {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3"},
- {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa"},
- {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa"},
- {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4"},
- {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6"},
- {file = "multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81"},
- {file = "multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774"},
- {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392"},
- {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a"},
- {file = "multidict-6.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2"},
- {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc"},
- {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478"},
- {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4"},
- {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d"},
- {file = "multidict-6.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6"},
- {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2"},
- {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd"},
- {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6"},
- {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492"},
- {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd"},
- {file = "multidict-6.1.0-cp38-cp38-win32.whl", hash = "sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167"},
- {file = "multidict-6.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef"},
- {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c"},
- {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1"},
- {file = "multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c"},
- {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c"},
- {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f"},
- {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875"},
- {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255"},
- {file = "multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30"},
- {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057"},
- {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657"},
- {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28"},
- {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972"},
- {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43"},
- {file = "multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada"},
- {file = "multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a"},
- {file = "multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506"},
- {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"},
-]
-
-[package.dependencies]
-typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""}
-
-[[package]]
-name = "multivolumefile"
-version = "0.2.3"
-description = "multi volume file wrapper library"
-optional = false
-python-versions = ">=3.6"
-files = [
- {file = "multivolumefile-0.2.3-py3-none-any.whl", hash = "sha256:237f4353b60af1703087cf7725755a1f6fcaeeea48421e1896940cd1c920d678"},
- {file = "multivolumefile-0.2.3.tar.gz", hash = "sha256:a0648d0aafbc96e59198d5c17e9acad7eb531abea51035d08ce8060dcad709d6"},
-]
-
-[package.extras]
-check = ["check-manifest", "flake8", "flake8-black", "isort (>=5.0.3)", "pygments", "readme-renderer", "twine"]
-test = ["coverage[toml] (>=5.2)", "coveralls (>=2.1.1)", "hypothesis", "pyannotate", "pytest", "pytest-cov"]
-type = ["mypy", "mypy-extensions"]
-
-[[package]]
-name = "mypy"
-version = "1.13.0"
-description = "Optional static typing for Python"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"},
- {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"},
- {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"},
- {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"},
- {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"},
- {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"},
- {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"},
- {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"},
- {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"},
- {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"},
- {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"},
- {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"},
- {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"},
- {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"},
- {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"},
- {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"},
- {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"},
- {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"},
- {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"},
- {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"},
- {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"},
- {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"},
- {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"},
- {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"},
- {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"},
- {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"},
- {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"},
- {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"},
- {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"},
- {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"},
- {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"},
- {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"},
-]
-
-[package.dependencies]
-mypy-extensions = ">=1.0.0"
-tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
-typing-extensions = ">=4.6.0"
-
-[package.extras]
-dmypy = ["psutil (>=4.0)"]
-faster-cache = ["orjson"]
-install-types = ["pip"]
-mypyc = ["setuptools (>=50)"]
-reports = ["lxml"]
-
-[[package]]
-name = "mypy-extensions"
-version = "1.0.0"
-description = "Type system extensions for programs checked with the mypy type checker."
-optional = false
-python-versions = ">=3.5"
-files = [
- {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
- {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
-]
-
-[[package]]
-name = "networkx"
-version = "3.4.2"
-description = "Python package for creating and manipulating graphs and networks"
-optional = false
-python-versions = ">=3.10"
-files = [
- {file = "networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f"},
- {file = "networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1"},
-]
-
-[package.extras]
-default = ["matplotlib (>=3.7)", "numpy (>=1.24)", "pandas (>=2.0)", "scipy (>=1.10,!=1.11.0,!=1.11.1)"]
-developer = ["changelist (==0.5)", "mypy (>=1.1)", "pre-commit (>=3.2)", "rtoml"]
-doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=9.4)", "pydata-sphinx-theme (>=0.15)", "sphinx (>=7.3)", "sphinx-gallery (>=0.16)", "texext (>=0.6.7)"]
-example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "momepy (>=0.7.2)", "osmnx (>=1.9)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"]
-extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"]
-test = ["pytest (>=7.2)", "pytest-cov (>=4.0)"]
-
-[[package]]
-name = "nodeenv"
-version = "1.9.1"
-description = "Node.js virtual environment builder"
-optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
-files = [
- {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
- {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
-]
-
-[[package]]
-name = "numpy"
-version = "2.1.2"
-description = "Fundamental package for array computing in Python"
-optional = false
-python-versions = ">=3.10"
-files = [
- {file = "numpy-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30d53720b726ec36a7f88dc873f0eec8447fbc93d93a8f079dfac2629598d6ee"},
- {file = "numpy-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8d3ca0a72dd8846eb6f7dfe8f19088060fcb76931ed592d29128e0219652884"},
- {file = "numpy-2.1.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:fc44e3c68ff00fd991b59092a54350e6e4911152682b4782f68070985aa9e648"},
- {file = "numpy-2.1.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:7c1c60328bd964b53f8b835df69ae8198659e2b9302ff9ebb7de4e5a5994db3d"},
- {file = "numpy-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdb606a7478f9ad91c6283e238544451e3a95f30fb5467fbf715964341a8a86"},
- {file = "numpy-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d666cb72687559689e9906197e3bec7b736764df6a2e58ee265e360663e9baf7"},
- {file = "numpy-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c6eef7a2dbd0abfb0d9eaf78b73017dbfd0b54051102ff4e6a7b2980d5ac1a03"},
- {file = "numpy-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:12edb90831ff481f7ef5f6bc6431a9d74dc0e5ff401559a71e5e4611d4f2d466"},
- {file = "numpy-2.1.2-cp310-cp310-win32.whl", hash = "sha256:a65acfdb9c6ebb8368490dbafe83c03c7e277b37e6857f0caeadbbc56e12f4fb"},
- {file = "numpy-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:860ec6e63e2c5c2ee5e9121808145c7bf86c96cca9ad396c0bd3e0f2798ccbe2"},
- {file = "numpy-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b42a1a511c81cc78cbc4539675713bbcf9d9c3913386243ceff0e9429ca892fe"},
- {file = "numpy-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:faa88bc527d0f097abdc2c663cddf37c05a1c2f113716601555249805cf573f1"},
- {file = "numpy-2.1.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:c82af4b2ddd2ee72d1fc0c6695048d457e00b3582ccde72d8a1c991b808bb20f"},
- {file = "numpy-2.1.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:13602b3174432a35b16c4cfb5de9a12d229727c3dd47a6ce35111f2ebdf66ff4"},
- {file = "numpy-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ebec5fd716c5a5b3d8dfcc439be82a8407b7b24b230d0ad28a81b61c2f4659a"},
- {file = "numpy-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2b49c3c0804e8ecb05d59af8386ec2f74877f7ca8fd9c1e00be2672e4d399b1"},
- {file = "numpy-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2cbba4b30bf31ddbe97f1c7205ef976909a93a66bb1583e983adbd155ba72ac2"},
- {file = "numpy-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8e00ea6fc82e8a804433d3e9cedaa1051a1422cb6e443011590c14d2dea59146"},
- {file = "numpy-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5006b13a06e0b38d561fab5ccc37581f23c9511879be7693bd33c7cd15ca227c"},
- {file = "numpy-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:f1eb068ead09f4994dec71c24b2844f1e4e4e013b9629f812f292f04bd1510d9"},
- {file = "numpy-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7bf0a4f9f15b32b5ba53147369e94296f5fffb783db5aacc1be15b4bf72f43b"},
- {file = "numpy-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b1d0fcae4f0949f215d4632be684a539859b295e2d0cb14f78ec231915d644db"},
- {file = "numpy-2.1.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f751ed0a2f250541e19dfca9f1eafa31a392c71c832b6bb9e113b10d050cb0f1"},
- {file = "numpy-2.1.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bd33f82e95ba7ad632bc57837ee99dba3d7e006536200c4e9124089e1bf42426"},
- {file = "numpy-2.1.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b8cde4f11f0a975d1fd59373b32e2f5a562ade7cde4f85b7137f3de8fbb29a0"},
- {file = "numpy-2.1.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d95f286b8244b3649b477ac066c6906fbb2905f8ac19b170e2175d3d799f4df"},
- {file = "numpy-2.1.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ab4754d432e3ac42d33a269c8567413bdb541689b02d93788af4131018cbf366"},
- {file = "numpy-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e585c8ae871fd38ac50598f4763d73ec5497b0de9a0ab4ef5b69f01c6a046142"},
- {file = "numpy-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9c6c754df29ce6a89ed23afb25550d1c2d5fdb9901d9c67a16e0b16eaf7e2550"},
- {file = "numpy-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:456e3b11cb79ac9946c822a56346ec80275eaf2950314b249b512896c0d2505e"},
- {file = "numpy-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a84498e0d0a1174f2b3ed769b67b656aa5460c92c9554039e11f20a05650f00d"},
- {file = "numpy-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4d6ec0d4222e8ffdab1744da2560f07856421b367928026fb540e1945f2eeeaf"},
- {file = "numpy-2.1.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:259ec80d54999cc34cd1eb8ded513cb053c3bf4829152a2e00de2371bd406f5e"},
- {file = "numpy-2.1.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:675c741d4739af2dc20cd6c6a5c4b7355c728167845e3c6b0e824e4e5d36a6c3"},
- {file = "numpy-2.1.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b2d4e667895cc55e3ff2b56077e4c8a5604361fc21a042845ea3ad67465aa8"},
- {file = "numpy-2.1.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43cca367bf94a14aca50b89e9bc2061683116cfe864e56740e083392f533ce7a"},
- {file = "numpy-2.1.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:76322dcdb16fccf2ac56f99048af32259dcc488d9b7e25b51e5eca5147a3fb98"},
- {file = "numpy-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:32e16a03138cabe0cb28e1007ee82264296ac0983714094380b408097a418cfe"},
- {file = "numpy-2.1.2-cp313-cp313-win32.whl", hash = "sha256:242b39d00e4944431a3cd2db2f5377e15b5785920421993770cddb89992c3f3a"},
- {file = "numpy-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f2ded8d9b6f68cc26f8425eda5d3877b47343e68ca23d0d0846f4d312ecaa445"},
- {file = "numpy-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ffef621c14ebb0188a8633348504a35c13680d6da93ab5cb86f4e54b7e922b5"},
- {file = "numpy-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ad369ed238b1959dfbade9018a740fb9392c5ac4f9b5173f420bd4f37ba1f7a0"},
- {file = "numpy-2.1.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d82075752f40c0ddf57e6e02673a17f6cb0f8eb3f587f63ca1eaab5594da5b17"},
- {file = "numpy-2.1.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:1600068c262af1ca9580a527d43dc9d959b0b1d8e56f8a05d830eea39b7c8af6"},
- {file = "numpy-2.1.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a26ae94658d3ba3781d5e103ac07a876b3e9b29db53f68ed7df432fd033358a8"},
- {file = "numpy-2.1.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13311c2db4c5f7609b462bc0f43d3c465424d25c626d95040f073e30f7570e35"},
- {file = "numpy-2.1.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:2abbf905a0b568706391ec6fa15161fad0fb5d8b68d73c461b3c1bab6064dd62"},
- {file = "numpy-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ef444c57d664d35cac4e18c298c47d7b504c66b17c2ea91312e979fcfbdfb08a"},
- {file = "numpy-2.1.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:bdd407c40483463898b84490770199d5714dcc9dd9b792f6c6caccc523c00952"},
- {file = "numpy-2.1.2-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:da65fb46d4cbb75cb417cddf6ba5e7582eb7bb0b47db4b99c9fe5787ce5d91f5"},
- {file = "numpy-2.1.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c193d0b0238638e6fc5f10f1b074a6993cb13b0b431f64079a509d63d3aa8b7"},
- {file = "numpy-2.1.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a7d80b2e904faa63068ead63107189164ca443b42dd1930299e0d1cb041cec2e"},
- {file = "numpy-2.1.2.tar.gz", hash = "sha256:13532a088217fa624c99b843eeb54640de23b3414b14aa66d023805eb731066c"},
-]
-
-[[package]]
-name = "omnitils"
-version = "1.4.6"
-description = "Universal reusable Python utils for the modern human."
-optional = false
-python-versions = "<3.13,>=3.10"
-files = [
- {file = "omnitils-1.4.6-py3-none-any.whl", hash = "sha256:47028f3e73c450715ca3b187534617fd7feecab50a6f19f0ff5ee6616c462d20"},
- {file = "omnitils-1.4.6.tar.gz", hash = "sha256:b70b39c89f32dd038d858b4285dabb8c86e9c97b684f2af8be76236c633a58dc"},
-]
-
-[package.dependencies]
-backoff = ">=2.2.1,<3.0.0"
-click = ">=8.1.7,<9.0.0"
-loguru = ">=0.7.2,<0.8.0"
-pathvalidate = ">=3.2.0,<4.0.0"
-pillow = ">=10.4.0,<11.0.0"
-py7zr = ">=0.22.0,<0.23.0"
-pydantic = ">=2.9.2,<3.0.0"
-python-dateutil = ">=2.9.0.post0,<3.0.0"
-PyYAML = ">=6.0.1,<7.0.0"
-ratelimit = ">=2.2.1,<3.0.0"
-requests = ">=2.32.3,<3.0.0"
-tomli = ">=2.0.1,<3.0.0"
-tomlkit = ">=0.13.2,<0.14.0"
-tqdm = ">=4.66.1,<5.0.0"
-yarl = ">=1.9.4,<2.0.0"
-
-[[package]]
-name = "packaging"
-version = "24.1"
-description = "Core utilities for Python packages"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
- {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
-]
-
-[[package]]
-name = "paginate"
-version = "0.5.7"
-description = "Divides large result sets into pages for easier browsing"
-optional = false
-python-versions = "*"
-files = [
- {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"},
- {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"},
-]
-
-[package.extras]
-dev = ["pytest", "tox"]
-lint = ["black"]
-
-[[package]]
-name = "pathspec"
-version = "0.12.1"
-description = "Utility library for gitignore style pattern matching of file paths."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
- {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
-]
-
-[[package]]
-name = "pathvalidate"
-version = "3.2.1"
-description = "pathvalidate is a Python library to sanitize/validate a string such as filenames/file-paths/etc."
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "pathvalidate-3.2.1-py3-none-any.whl", hash = "sha256:9a6255eb8f63c9e2135b9be97a5ce08f10230128c4ae7b3e935378b82b22c4c9"},
- {file = "pathvalidate-3.2.1.tar.gz", hash = "sha256:f5d07b1e2374187040612a1fcd2bcb2919f8db180df254c9581bb90bf903377d"},
-]
-
-[package.extras]
-docs = ["Sphinx (>=2.4)", "sphinx-rtd-theme (>=1.2.2)", "urllib3 (<2)"]
-readme = ["path (>=13,<17)", "readmemaker (>=1.1.0)"]
-test = ["Faker (>=1.0.8)", "allpairspy (>=2)", "click (>=6.2)", "pytest (>=6.0.1)", "pytest-md-report (>=0.6.2)"]
-
-[[package]]
-name = "pefile"
-version = "2024.8.26"
-description = "Python PE parsing module"
-optional = false
-python-versions = ">=3.6.0"
-files = [
- {file = "pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f"},
- {file = "pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632"},
-]
-
-[[package]]
-name = "photoshop-python-api"
-version = "0.22.4"
-description = "Python API for Photoshop."
-optional = false
-python-versions = ">=3.8,<4.0"
-files = [
- {file = "photoshop_python_api-0.22.4-py3-none-any.whl", hash = "sha256:18e206f615a648427c884daa2c0a4b02159cd461a1779a64d69e831730e4dff9"},
- {file = "photoshop_python_api-0.22.4.tar.gz", hash = "sha256:ad080c61d4c850f71165dbf75c6fc23b8b0a1695d3192c0d2439e4101523e5bb"},
-]
-
-[package.dependencies]
-comtypes = ">=1.1.11,<2.0.0"
-wheel = ">=0.42.0,<0.43.0"
-
-[[package]]
-name = "pillow"
-version = "10.4.0"
-description = "Python Imaging Library (Fork)"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"},
- {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"},
- {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"},
- {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"},
- {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"},
- {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"},
- {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"},
- {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"},
- {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"},
- {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"},
- {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"},
- {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"},
- {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"},
- {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"},
- {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"},
- {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"},
- {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"},
- {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"},
- {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"},
- {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"},
- {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"},
- {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"},
- {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"},
- {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"},
- {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"},
- {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"},
- {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"},
- {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"},
- {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"},
- {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"},
- {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"},
- {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"},
- {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"},
- {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"},
- {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"},
- {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"},
- {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"},
- {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"},
- {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"},
- {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"},
- {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"},
- {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"},
- {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"},
- {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"},
- {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"},
- {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"},
- {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"},
- {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"},
- {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"},
- {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"},
- {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"},
- {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"},
- {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"},
- {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"},
- {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"},
- {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"},
- {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"},
- {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"},
- {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"},
- {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"},
- {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"},
- {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"},
- {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"},
- {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"},
- {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"},
- {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"},
- {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"},
- {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"},
- {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"},
- {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"},
- {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"},
- {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"},
- {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"},
- {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"},
- {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"},
- {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"},
- {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"},
- {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"},
- {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"},
- {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"},
-]
-
-[package.extras]
-docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
-fpx = ["olefile"]
-mic = ["olefile"]
-tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
-typing = ["typing-extensions"]
-xmp = ["defusedxml"]
-
-[[package]]
-name = "platformdirs"
-version = "4.3.6"
-description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
- {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
-]
-
-[package.extras]
-docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
-test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
-type = ["mypy (>=1.11.2)"]
-
-[[package]]
-name = "pluggy"
-version = "1.5.0"
-description = "plugin and hook calling mechanisms for python"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
- {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
-]
-
-[package.extras]
-dev = ["pre-commit", "tox"]
-testing = ["pytest", "pytest-benchmark"]
-
-[[package]]
-name = "pre-commit"
-version = "3.8.0"
-description = "A framework for managing and maintaining multi-language pre-commit hooks."
-optional = false
-python-versions = ">=3.9"
-files = [
- {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
- {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
-]
-
-[package.dependencies]
-cfgv = ">=2.0.0"
-identify = ">=1.0.0"
-nodeenv = ">=0.11.1"
-pyyaml = ">=5.1"
-virtualenv = ">=20.10.0"
-
-[[package]]
-name = "prompt-toolkit"
-version = "3.0.36"
-description = "Library for building powerful interactive command lines in Python"
-optional = false
-python-versions = ">=3.6.2"
-files = [
- {file = "prompt_toolkit-3.0.36-py3-none-any.whl", hash = "sha256:aa64ad242a462c5ff0363a7b9cfe696c20d55d9fc60c11fd8e632d064804d305"},
- {file = "prompt_toolkit-3.0.36.tar.gz", hash = "sha256:3e163f254bef5a03b146397d7c1963bd3e2812f0964bb9a24e6ec761fd28db63"},
-]
-
-[package.dependencies]
-wcwidth = "*"
-
-[[package]]
-name = "propcache"
-version = "0.2.0"
-description = "Accelerated property cache"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "propcache-0.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c5869b8fd70b81835a6f187c5fdbe67917a04d7e52b6e7cc4e5fe39d55c39d58"},
- {file = "propcache-0.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:952e0d9d07609d9c5be361f33b0d6d650cd2bae393aabb11d9b719364521984b"},
- {file = "propcache-0.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33ac8f098df0585c0b53009f039dfd913b38c1d2edafed0cedcc0c32a05aa110"},
- {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e48e8875e6c13909c800fa344cd54cc4b2b0db1d5f911f840458a500fde2c2"},
- {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:388f3217649d6d59292b722d940d4d2e1e6a7003259eb835724092a1cca0203a"},
- {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f571aea50ba5623c308aa146eb650eebf7dbe0fd8c5d946e28343cb3b5aad577"},
- {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dfafb44f7bb35c0c06eda6b2ab4bfd58f02729e7c4045e179f9a861b07c9850"},
- {file = "propcache-0.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3ebe9a75be7ab0b7da2464a77bb27febcb4fab46a34f9288f39d74833db7f61"},
- {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2f0d0f976985f85dfb5f3d685697ef769faa6b71993b46b295cdbbd6be8cc37"},
- {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a3dc1a4b165283bd865e8f8cb5f0c64c05001e0718ed06250d8cac9bec115b48"},
- {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9e0f07b42d2a50c7dd2d8675d50f7343d998c64008f1da5fef888396b7f84630"},
- {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e63e3e1e0271f374ed489ff5ee73d4b6e7c60710e1f76af5f0e1a6117cd26394"},
- {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:56bb5c98f058a41bb58eead194b4db8c05b088c93d94d5161728515bd52b052b"},
- {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7665f04d0c7f26ff8bb534e1c65068409bf4687aa2534faf7104d7182debb336"},
- {file = "propcache-0.2.0-cp310-cp310-win32.whl", hash = "sha256:7cf18abf9764746b9c8704774d8b06714bcb0a63641518a3a89c7f85cc02c2ad"},
- {file = "propcache-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:cfac69017ef97db2438efb854edf24f5a29fd09a536ff3a992b75990720cdc99"},
- {file = "propcache-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:63f13bf09cc3336eb04a837490b8f332e0db41da66995c9fd1ba04552e516354"},
- {file = "propcache-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608cce1da6f2672a56b24a015b42db4ac612ee709f3d29f27a00c943d9e851de"},
- {file = "propcache-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:466c219deee4536fbc83c08d09115249db301550625c7fef1c5563a584c9bc87"},
- {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2db02409338bf36590aa985a461b2c96fce91f8e7e0f14c50c5fcc4f229016"},
- {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6ed8db0a556343d566a5c124ee483ae113acc9a557a807d439bcecc44e7dfbb"},
- {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91997d9cb4a325b60d4e3f20967f8eb08dfcb32b22554d5ef78e6fd1dda743a2"},
- {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c7dde9e533c0a49d802b4f3f218fa9ad0a1ce21f2c2eb80d5216565202acab4"},
- {file = "propcache-0.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffcad6c564fe6b9b8916c1aefbb37a362deebf9394bd2974e9d84232e3e08504"},
- {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:97a58a28bcf63284e8b4d7b460cbee1edaab24634e82059c7b8c09e65284f178"},
- {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:945db8ee295d3af9dbdbb698cce9bbc5c59b5c3fe328bbc4387f59a8a35f998d"},
- {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39e104da444a34830751715f45ef9fc537475ba21b7f1f5b0f4d71a3b60d7fe2"},
- {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c5ecca8f9bab618340c8e848d340baf68bcd8ad90a8ecd7a4524a81c1764b3db"},
- {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c436130cc779806bdf5d5fae0d848713105472b8566b75ff70048c47d3961c5b"},
- {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:191db28dc6dcd29d1a3e063c3be0b40688ed76434622c53a284e5427565bbd9b"},
- {file = "propcache-0.2.0-cp311-cp311-win32.whl", hash = "sha256:5f2564ec89058ee7c7989a7b719115bdfe2a2fb8e7a4543b8d1c0cc4cf6478c1"},
- {file = "propcache-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e2e54267980349b723cff366d1e29b138b9a60fa376664a157a342689553f71"},
- {file = "propcache-0.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ee7606193fb267be4b2e3b32714f2d58cad27217638db98a60f9efb5efeccc2"},
- {file = "propcache-0.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:91ee8fc02ca52e24bcb77b234f22afc03288e1dafbb1f88fe24db308910c4ac7"},
- {file = "propcache-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e900bad2a8456d00a113cad8c13343f3b1f327534e3589acc2219729237a2e8"},
- {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f52a68c21363c45297aca15561812d542f8fc683c85201df0bebe209e349f793"},
- {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e41d67757ff4fbc8ef2af99b338bfb955010444b92929e9e55a6d4dcc3c4f09"},
- {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a64e32f8bd94c105cc27f42d3b658902b5bcc947ece3c8fe7bc1b05982f60e89"},
- {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55346705687dbd7ef0d77883ab4f6fabc48232f587925bdaf95219bae072491e"},
- {file = "propcache-0.2.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00181262b17e517df2cd85656fcd6b4e70946fe62cd625b9d74ac9977b64d8d9"},
- {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6994984550eaf25dd7fc7bd1b700ff45c894149341725bb4edc67f0ffa94efa4"},
- {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:56295eb1e5f3aecd516d91b00cfd8bf3a13991de5a479df9e27dd569ea23959c"},
- {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:439e76255daa0f8151d3cb325f6dd4a3e93043e6403e6491813bcaaaa8733887"},
- {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f6475a1b2ecb310c98c28d271a30df74f9dd436ee46d09236a6b750a7599ce57"},
- {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3444cdba6628accf384e349014084b1cacd866fbb88433cd9d279d90a54e0b23"},
- {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a9d9b4d0a9b38d1c391bb4ad24aa65f306c6f01b512e10a8a34a2dc5675d348"},
- {file = "propcache-0.2.0-cp312-cp312-win32.whl", hash = "sha256:69d3a98eebae99a420d4b28756c8ce6ea5a29291baf2dc9ff9414b42676f61d5"},
- {file = "propcache-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad9c9b99b05f163109466638bd30ada1722abb01bbb85c739c50b6dc11f92dc3"},
- {file = "propcache-0.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecddc221a077a8132cf7c747d5352a15ed763b674c0448d811f408bf803d9ad7"},
- {file = "propcache-0.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0e53cb83fdd61cbd67202735e6a6687a7b491c8742dfc39c9e01e80354956763"},
- {file = "propcache-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92fe151145a990c22cbccf9ae15cae8ae9eddabfc949a219c9f667877e40853d"},
- {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a21ef516d36909931a2967621eecb256018aeb11fc48656e3257e73e2e247a"},
- {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f88a4095e913f98988f5b338c1d4d5d07dbb0b6bad19892fd447484e483ba6b"},
- {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a5b3bb545ead161be780ee85a2b54fdf7092815995661947812dde94a40f6fb"},
- {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67aeb72e0f482709991aa91345a831d0b707d16b0257e8ef88a2ad246a7280bf"},
- {file = "propcache-0.2.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c997f8c44ec9b9b0bcbf2d422cc00a1d9b9c681f56efa6ca149a941e5560da2"},
- {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a66df3d4992bc1d725b9aa803e8c5a66c010c65c741ad901e260ece77f58d2f"},
- {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3ebbcf2a07621f29638799828b8d8668c421bfb94c6cb04269130d8de4fb7136"},
- {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1235c01ddaa80da8235741e80815ce381c5267f96cc49b1477fdcf8c047ef325"},
- {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3947483a381259c06921612550867b37d22e1df6d6d7e8361264b6d037595f44"},
- {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d5bed7f9805cc29c780f3aee05de3262ee7ce1f47083cfe9f77471e9d6777e83"},
- {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544"},
- {file = "propcache-0.2.0-cp313-cp313-win32.whl", hash = "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032"},
- {file = "propcache-0.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e"},
- {file = "propcache-0.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:53d1bd3f979ed529f0805dd35ddaca330f80a9a6d90bc0121d2ff398f8ed8861"},
- {file = "propcache-0.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:83928404adf8fb3d26793665633ea79b7361efa0287dfbd372a7e74311d51ee6"},
- {file = "propcache-0.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:77a86c261679ea5f3896ec060be9dc8e365788248cc1e049632a1be682442063"},
- {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:218db2a3c297a3768c11a34812e63b3ac1c3234c3a086def9c0fee50d35add1f"},
- {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7735e82e3498c27bcb2d17cb65d62c14f1100b71723b68362872bca7d0913d90"},
- {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:20a617c776f520c3875cf4511e0d1db847a076d720714ae35ffe0df3e440be68"},
- {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67b69535c870670c9f9b14a75d28baa32221d06f6b6fa6f77a0a13c5a7b0a5b9"},
- {file = "propcache-0.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4569158070180c3855e9c0791c56be3ceeb192defa2cdf6a3f39e54319e56b89"},
- {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:db47514ffdbd91ccdc7e6f8407aac4ee94cc871b15b577c1c324236b013ddd04"},
- {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:2a60ad3e2553a74168d275a0ef35e8c0a965448ffbc3b300ab3a5bb9956c2162"},
- {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:662dd62358bdeaca0aee5761de8727cfd6861432e3bb828dc2a693aa0471a563"},
- {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:25a1f88b471b3bc911d18b935ecb7115dff3a192b6fef46f0bfaf71ff4f12418"},
- {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:f60f0ac7005b9f5a6091009b09a419ace1610e163fa5deaba5ce3484341840e7"},
- {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:74acd6e291f885678631b7ebc85d2d4aec458dd849b8c841b57ef04047833bed"},
- {file = "propcache-0.2.0-cp38-cp38-win32.whl", hash = "sha256:d9b6ddac6408194e934002a69bcaadbc88c10b5f38fb9307779d1c629181815d"},
- {file = "propcache-0.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:676135dcf3262c9c5081cc8f19ad55c8a64e3f7282a21266d05544450bffc3a5"},
- {file = "propcache-0.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:25c8d773a62ce0451b020c7b29a35cfbc05de8b291163a7a0f3b7904f27253e6"},
- {file = "propcache-0.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:375a12d7556d462dc64d70475a9ee5982465fbb3d2b364f16b86ba9135793638"},
- {file = "propcache-0.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1ec43d76b9677637a89d6ab86e1fef70d739217fefa208c65352ecf0282be957"},
- {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f45eec587dafd4b2d41ac189c2156461ebd0c1082d2fe7013571598abb8505d1"},
- {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc092ba439d91df90aea38168e11f75c655880c12782facf5cf9c00f3d42b562"},
- {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa1076244f54bb76e65e22cb6910365779d5c3d71d1f18b275f1dfc7b0d71b4d"},
- {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:682a7c79a2fbf40f5dbb1eb6bfe2cd865376deeac65acf9beb607505dced9e12"},
- {file = "propcache-0.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e40876731f99b6f3c897b66b803c9e1c07a989b366c6b5b475fafd1f7ba3fb8"},
- {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:363ea8cd3c5cb6679f1c2f5f1f9669587361c062e4899fce56758efa928728f8"},
- {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:140fbf08ab3588b3468932974a9331aff43c0ab8a2ec2c608b6d7d1756dbb6cb"},
- {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e70fac33e8b4ac63dfc4c956fd7d85a0b1139adcfc0d964ce288b7c527537fea"},
- {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b33d7a286c0dc1a15f5fc864cc48ae92a846df287ceac2dd499926c3801054a6"},
- {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f6d5749fdd33d90e34c2efb174c7e236829147a2713334d708746e94c4bde40d"},
- {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22aa8f2272d81d9317ff5756bb108021a056805ce63dd3630e27d042c8092798"},
- {file = "propcache-0.2.0-cp39-cp39-win32.whl", hash = "sha256:73e4b40ea0eda421b115248d7e79b59214411109a5bc47d0d48e4c73e3b8fcf9"},
- {file = "propcache-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:9517d5e9e0731957468c29dbfd0f976736a0e55afaea843726e887f36fe017df"},
- {file = "propcache-0.2.0-py3-none-any.whl", hash = "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036"},
- {file = "propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70"},
-]
-
-[[package]]
-name = "psd-tools"
-version = "1.10.2"
-description = "Python package for working with Adobe Photoshop PSD files"
-optional = false
-python-versions = "*"
-files = [
- {file = "psd_tools-1.10.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0d905987d593b0a15647235418cf993ab1d228b7705fe2ebf3b9362af96b51e"},
- {file = "psd_tools-1.10.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0f6c7ffa7685d174232aefbb39448b0b6aaeba9e85b28e0b1c16f0bc92890c"},
- {file = "psd_tools-1.10.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a68f208b5e9c744c7ec3b7ce843698ee1db71cd15695699d83ed88b0aa6c112e"},
- {file = "psd_tools-1.10.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88eda1b4dea2dd3c1e4f3fce1b2b305aa39979a4ec3258ab22a32768a145ab26"},
- {file = "psd_tools-1.10.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:999823284f585fbeb9b415dc4a66d9321a34b49fd20aec1874515d156462f775"},
- {file = "psd_tools-1.10.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d77c11f3f1382f618580adfefd5f39f6a1a90f652f410279461f9f1e76e86534"},
- {file = "psd_tools-1.10.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1391b8749ad439ebfd331fdb02ac50ef59b7a8a9c28c75b63a217dbc18abf61a"},
- {file = "psd_tools-1.10.2-cp310-cp310-win32.whl", hash = "sha256:5bd2ba02a1ee57cd152ae7184eafb432fb96d4fd3cc2e1911aaebf1a78516fe5"},
- {file = "psd_tools-1.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:5cbc043609cdbf31c8c5d69d5e74b9d647cb29bc6bc6e4006330d061ac7d5907"},
- {file = "psd_tools-1.10.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7287bbaeb94d6a660bdeeaa1802224a142923ec088e3d120fc356a4b76c7db2a"},
- {file = "psd_tools-1.10.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:045ef92857d50591b49b788b2f9541db4ffd256056751bebb41e0887d629cf77"},
- {file = "psd_tools-1.10.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a18cd870320f88bf521dcb59be73aed5ecaaf2f51e00f2e5a8de2e2d64e43928"},
- {file = "psd_tools-1.10.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93d634cda00d0e6338da86fb967d8c449b13641061d7043cbb6ec019c93c04bc"},
- {file = "psd_tools-1.10.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b2707edaac31c8f0dd30aa7dd4f1befd23f4f5f23bec63209ced023cb715f21"},
- {file = "psd_tools-1.10.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:477cb09d6c35798177bc7456b0c30fac2d99bf5f78d34592dc331986f8a6a080"},
- {file = "psd_tools-1.10.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7a3aa5e74b4ffd2036b53110a1709284c5649a775e04ecd4d000e9ae956ce634"},
- {file = "psd_tools-1.10.2-cp311-cp311-win32.whl", hash = "sha256:378071e6c11ffdb1f715439a41134bf3c0400518e2ee203d92ca91cebf3d5ce0"},
- {file = "psd_tools-1.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:7c649cc5234f12aa423bb8a7e3f637f8789e212557be2859c581743a17ba1804"},
- {file = "psd_tools-1.10.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2f65fe1eead47a5fae7014381a30c0f72fbb98425165f4e6b9971756a0e9c27e"},
- {file = "psd_tools-1.10.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e00b70418da991b457fa1e177832bc59674bd66e281ddd1acad21daf485d38"},
- {file = "psd_tools-1.10.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c50c142372bb8ec3053aebdf0f28ec2426b7bfd459ce525612e5bd6e20cca41e"},
- {file = "psd_tools-1.10.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ca7f92026c0bcb4e076be77d2bfd6e1f9585129c4399a03ac565f8c02c929c6"},
- {file = "psd_tools-1.10.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7611b5854f2b3de1feb926fc4a1a7760c5de081037642bccff55ce323d56dcf9"},
- {file = "psd_tools-1.10.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:11a2b8ffd4e72dd71f1e7f56c017c082b9f5bd6b400638c98cc9cb353bac48a3"},
- {file = "psd_tools-1.10.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7934c4c76d44e0ec2331cf3ef9605d6058f33cdd243ebee0ee4708c8113a833a"},
- {file = "psd_tools-1.10.2-cp312-cp312-win32.whl", hash = "sha256:1d57f1544eb97d97fff6f1d720417ac65c2005f5eceed9139be787b7c90921df"},
- {file = "psd_tools-1.10.2-cp312-cp312-win_amd64.whl", hash = "sha256:8590940a655ec34821a02684f1f90c0aa27b677e5404a29b744f46ed7cb168ed"},
- {file = "psd_tools-1.10.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:55227eee2789c013ac3b9dd15077010365851f4c441d1cdd58489dfdb6440913"},
- {file = "psd_tools-1.10.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a21c0f767a4557428516e7336ba460fe865e1e1d44bb7a3e2b0ea110786dda8"},
- {file = "psd_tools-1.10.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2535871f25e8d59157f4d851d49b020e172ada7cd89350f67e943f645926b1e"},
- {file = "psd_tools-1.10.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5ed40f2447caa51dba48b4d9319860bcf21178dbcf1216833aa6ad9b30072e8"},
- {file = "psd_tools-1.10.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7f7831b32674e22f7784624d93ecb319b50542c9ccb3f977c855928b2a7d8e53"},
- {file = "psd_tools-1.10.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:872f29979220268091b3e6a68bdda1228daf15d6ae9ca703c2c0a87b2eedb703"},
- {file = "psd_tools-1.10.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5dafd296cf295160ba45f0320af5cc5b90d6f4fec374fe071c819cd80dd7bbc3"},
- {file = "psd_tools-1.10.2-cp313-cp313-win32.whl", hash = "sha256:89129831048e092011c6c3f6046a05fbe066f88f9071827f288f3b6117ac3e13"},
- {file = "psd_tools-1.10.2-cp313-cp313-win_amd64.whl", hash = "sha256:8b32282a9a218cb3b05d708ce10e4331f0d5f1ca06c11044536f0552222bf53f"},
- {file = "psd_tools-1.10.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cc5b317b6fabf954a1d3c1feb04c8f51625182375c65fb0467e6ef9ecb73795d"},
- {file = "psd_tools-1.10.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb3ee2f95adaff7414409ea210e19ccc9a8c6bf2b711916e3a53959b8054c13a"},
- {file = "psd_tools-1.10.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:135221df2705f6e1d5e3d6458e5524f4582bc190499735087aafa07a083103f0"},
- {file = "psd_tools-1.10.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72f4f8c700825fc637b6d4d37420c83efe1fa487eb6a29703e7d629ae216843f"},
- {file = "psd_tools-1.10.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ee58b0242f0ded58a05d638112b8fc75c0d0f97b49caaac6f830d5b6764557e7"},
- {file = "psd_tools-1.10.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f0faf7771b6c9d181a70f04aa8e79fad9d0a447b7afcb6bbab295b701152ee01"},
- {file = "psd_tools-1.10.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a1474712cf226e6d2a911b2d9a9431b1cdcdf1861497573bff1f60d7fe014906"},
- {file = "psd_tools-1.10.2-cp39-cp39-win32.whl", hash = "sha256:61e8c7f7d03036a0cac096be9993a180c00639fbd9a188d23793553e75535ea7"},
- {file = "psd_tools-1.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:c4f6a75aa55f07d20aa4af292084f00861fc0a1f88790cddd9c97df841846e14"},
- {file = "psd_tools-1.10.2.tar.gz", hash = "sha256:5499e97a4d4b072ea0b23c5fccd328c009e053947820ae60dd934612f674d64b"},
-]
-
-[package.dependencies]
-aggdraw = "*"
-attrs = ">=23.0.0"
-docopt = ">=0.6.0"
-numpy = "*"
-Pillow = ">=10.0.0"
-scikit-image = "*"
-scipy = "*"
-
-[package.extras]
-dev = ["ipython", "pytest", "pytest-cov", "ruff", "types-docopt"]
-docs = ["sphinx", "sphinx-rtd-theme"]
-
-[[package]]
-name = "psutil"
-version = "6.1.0"
-description = "Cross-platform lib for process and system monitoring in Python."
-optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
-files = [
- {file = "psutil-6.1.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ff34df86226c0227c52f38b919213157588a678d049688eded74c76c8ba4a5d0"},
- {file = "psutil-6.1.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:c0e0c00aa18ca2d3b2b991643b799a15fc8f0563d2ebb6040f64ce8dc027b942"},
- {file = "psutil-6.1.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:000d1d1ebd634b4efb383f4034437384e44a6d455260aaee2eca1e9c1b55f047"},
- {file = "psutil-6.1.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:5cd2bcdc75b452ba2e10f0e8ecc0b57b827dd5d7aaffbc6821b2a9a242823a76"},
- {file = "psutil-6.1.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:045f00a43c737f960d273a83973b2511430d61f283a44c96bf13a6e829ba8fdc"},
- {file = "psutil-6.1.0-cp27-none-win32.whl", hash = "sha256:9118f27452b70bb1d9ab3198c1f626c2499384935aaf55388211ad982611407e"},
- {file = "psutil-6.1.0-cp27-none-win_amd64.whl", hash = "sha256:a8506f6119cff7015678e2bce904a4da21025cc70ad283a53b099e7620061d85"},
- {file = "psutil-6.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6e2dcd475ce8b80522e51d923d10c7871e45f20918e027ab682f94f1c6351688"},
- {file = "psutil-6.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0895b8414afafc526712c498bd9de2b063deaac4021a3b3c34566283464aff8e"},
- {file = "psutil-6.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9dcbfce5d89f1d1f2546a2090f4fcf87c7f669d1d90aacb7d7582addece9fb38"},
- {file = "psutil-6.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:498c6979f9c6637ebc3a73b3f87f9eb1ec24e1ce53a7c5173b8508981614a90b"},
- {file = "psutil-6.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d905186d647b16755a800e7263d43df08b790d709d575105d419f8b6ef65423a"},
- {file = "psutil-6.1.0-cp36-cp36m-win32.whl", hash = "sha256:6d3fbbc8d23fcdcb500d2c9f94e07b1342df8ed71b948a2649b5cb060a7c94ca"},
- {file = "psutil-6.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:1209036fbd0421afde505a4879dee3b2fd7b1e14fee81c0069807adcbbcca747"},
- {file = "psutil-6.1.0-cp37-abi3-win32.whl", hash = "sha256:1ad45a1f5d0b608253b11508f80940985d1d0c8f6111b5cb637533a0e6ddc13e"},
- {file = "psutil-6.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:a8fb3752b491d246034fa4d279ff076501588ce8cbcdbb62c32fd7a377d996be"},
- {file = "psutil-6.1.0.tar.gz", hash = "sha256:353815f59a7f64cdaca1c0307ee13558a0512f6db064e92fe833784f08539c7a"},
-]
-
-[package.extras]
-dev = ["black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest-cov", "requests", "rstcheck", "ruff", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "wheel"]
-test = ["pytest", "pytest-xdist", "setuptools"]
-
-[[package]]
-name = "py7zr"
-version = "0.22.0"
-description = "Pure python 7-zip library"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "py7zr-0.22.0-py3-none-any.whl", hash = "sha256:993b951b313500697d71113da2681386589b7b74f12e48ba13cc12beca79d078"},
- {file = "py7zr-0.22.0.tar.gz", hash = "sha256:c6c7aea5913535184003b73938490f9a4d8418598e533f9ca991d3b8e45a139e"},
-]
-
-[package.dependencies]
-brotli = {version = ">=1.1.0", markers = "platform_python_implementation == \"CPython\""}
-brotlicffi = {version = ">=1.1.0.0", markers = "platform_python_implementation == \"PyPy\""}
-inflate64 = ">=1.0.0,<1.1.0"
-multivolumefile = ">=0.2.3"
-psutil = {version = "*", markers = "sys_platform != \"cygwin\""}
-pybcj = ">=1.0.0,<1.1.0"
-pycryptodomex = ">=3.16.0"
-pyppmd = ">=1.1.0,<1.2.0"
-pyzstd = ">=0.15.9"
-texttable = "*"
-
-[package.extras]
-check = ["black (>=23.1.0)", "check-manifest", "flake8 (<8)", "flake8-black (>=0.3.6)", "flake8-deprecated", "flake8-isort", "isort (>=5.0.3)", "lxml", "mypy (>=0.940)", "mypy-extensions (>=0.4.1)", "pygments", "readme-renderer", "twine", "types-psutil"]
-debug = ["pytest", "pytest-leaks", "pytest-profiling"]
-docs = ["docutils", "sphinx (>=5.0)", "sphinx-a4doc", "sphinx-py3doc-enhanced-theme"]
-test = ["coverage[toml] (>=5.2)", "coveralls (>=2.1.1)", "py-cpuinfo", "pytest", "pytest-benchmark", "pytest-cov", "pytest-remotedata", "pytest-timeout"]
-test-compat = ["libarchive-c"]
-
-[[package]]
-name = "pybcj"
-version = "1.0.2"
-description = "bcj filter library"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "pybcj-1.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7bff28d97e47047d69a4ac6bf59adda738cf1d00adde8819117fdb65d966bdbc"},
- {file = "pybcj-1.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:198e0b4768b4025eb3309273d7e81dc53834b9a50092be6e0d9b3983cfd35c35"},
- {file = "pybcj-1.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa26415b4a118ea790de9d38f244312f2510a9bb5c65e560184d241a6f391a2d"},
- {file = "pybcj-1.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fabb2be57e4ca28ea36c13146cdf97d73abd27c51741923fc6ba1e8cd33e255c"},
- {file = "pybcj-1.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75d6d613bae6f27678d5e44e89d61018779726aa6aa950c516d33a04b8af8c59"},
- {file = "pybcj-1.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3ffae79ef8a1ea81ea2748ad7b7ad9b882aa88ddf65ce90f9e944df639eccc61"},
- {file = "pybcj-1.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bdb4d8ff5cba3e0bd1adee7d20dbb2b4d80cb31ac04d6ea1cd06cfc02d2ecd0d"},
- {file = "pybcj-1.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a29be917fbc99eca204b08407e0971e0205bfdad4b74ec915930675f352b669d"},
- {file = "pybcj-1.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a2562ebe5a0abec4da0229f8abb5e90ee97b178f19762eb925c1159be36828b3"},
- {file = "pybcj-1.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:af19bc61ded933001cd68f004ae2042bf1a78eb498a3c685ebd655fa1be90dbe"},
- {file = "pybcj-1.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f3f4a447800850aba7724a2274ea0a4800724520c1caf38f7d0dabf2f89a5e15"},
- {file = "pybcj-1.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce1c8af7a4761d2b1b531864d84113948daa0c4245775c63bd9874cb955f4662"},
- {file = "pybcj-1.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8007371f6f2b462f5aa05d5c2135d0a1bcf5b7bdd9bd15d86c730f588d10b7d3"},
- {file = "pybcj-1.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1079ca63ff8da5c936b76863690e0bd2489e8d4e0a3a340e032095dae805dd91"},
- {file = "pybcj-1.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e9a785eb26884429d9b9f6326e68c3638828c83bf6d42d2463c97ad5385caff2"},
- {file = "pybcj-1.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:9ea46e2d45469d13b7f25b08efcdb140220bab1ac5a850db0954591715b8caaa"},
- {file = "pybcj-1.0.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:21b5f2460629167340403d359289a173e0729ce8e84e3ce99462009d5d5e01a4"},
- {file = "pybcj-1.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:2940fb85730b9869254559c491cd83cf777e56c76a8a60df60e4be4f2a4248d7"},
- {file = "pybcj-1.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f40f3243139d675f43793a4e35c410c370f7b91ccae74e70c8b2f4877869f90e"},
- {file = "pybcj-1.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c2b3e60b65c7ac73e44335934e1e122da8d56db87840984601b3c5dc0ae4c19"},
- {file = "pybcj-1.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746550dc7b5af4d04bb5fa4d065f18d39c925bcb5dee30db75747cd9a58bb6e8"},
- {file = "pybcj-1.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8ce9b62b6aaa5b08773be8a919ecc4e865396c969f982b685eeca6e80c82abb7"},
- {file = "pybcj-1.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:493eab2b1f6f546730a6de0c5ceb75ce16f3767154e8ae30e2b70d41b928b7d2"},
- {file = "pybcj-1.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:ef55b96b7f2ed823e0b924de902065ec42ade856366c287dbb073fabd6b90ec1"},
- {file = "pybcj-1.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ed5b3dd9c209fe7b90990dee4ef21870dca39db1cd326553c314ee1b321c1cc"},
- {file = "pybcj-1.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:22a94885723f8362d4cb468e68910eef92d3e2b1293de82b8eacb4198ef6655f"},
- {file = "pybcj-1.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b8f9368036c9e658d8e3b3534086d298a5349c864542b34657cbe57c260daa49"},
- {file = "pybcj-1.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87108181c7a6ac4d3fc1e4551cab5db5eea7f9fdca611175243234cd94bcc59b"},
- {file = "pybcj-1.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db57f26b8c0162cfddb52b869efb1741b8c5e67fc536994f743074985f714c55"},
- {file = "pybcj-1.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bdf5bcac4f1da36ad43567ea6f6ef404347658dbbe417c87cdb1699f327d6337"},
- {file = "pybcj-1.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c3171bb95c9b45cbcad25589e1ae4f4ca4ea99dc1724c4e0671eb6b9055514e"},
- {file = "pybcj-1.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:f9a2585e0da9cf343ea27421995b881736a1eb604a7c1d4ca74126af94c3d4a8"},
- {file = "pybcj-1.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:fdb7cd8271471a5979d84915c1ee57eea7e0a69c893225fc418db66883b0e2a7"},
- {file = "pybcj-1.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e96ae14062bdcddc3197300e6ee4efa6fbc6749be917db934eac66d0daaecb68"},
- {file = "pybcj-1.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a54ebdc8423ba99d75372708a882fcfc3b14d9d52cf195295ad53e5a47dab37f"},
- {file = "pybcj-1.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3602be737c6e9553c45ae89e6b0e556f64f34dabf27d5260317d1824d31b79d3"},
- {file = "pybcj-1.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63dd2ca52a48841f561bfec0fa3f208d375b0a8dcd3d7b236459e683ae29221d"},
- {file = "pybcj-1.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8204a714029784b1a08a3d790430d80b423b68615c5b1e67aabca5bd5419b77d"},
- {file = "pybcj-1.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fde2376b180ae2620c102fbc3ef06638d306feae83964aaa5051ecbdda54845a"},
- {file = "pybcj-1.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:3b8d7810fb587adbffba025330cf212d9bbed8f29559656d05cb6609673f306a"},
- {file = "pybcj-1.0.2.tar.gz", hash = "sha256:c7f5bef7f47723c53420e377bc64d2553843bee8bcac5f0ad076ab1524780018"},
-]
-
-[package.extras]
-check = ["check-manifest", "flake8 (<5)", "flake8-black", "flake8-colors", "flake8-isort", "flake8-pyi", "flake8-typing-imports", "mypy (>=0.812)", "mypy-extensions (>=0.4.3)", "pygments", "readme-renderer"]
-test = ["coverage[toml] (>=5.2)", "hypothesis", "pytest (>=6.0)", "pytest-cov"]
-
-[[package]]
-name = "pyclean"
-version = "2.7.6"
-description = "Pure Python cross-platform pyclean. Clean up your Python bytecode."
-optional = false
-python-versions = "*"
-files = [
- {file = "pyclean-2.7.6-py3-none-any.whl", hash = "sha256:db5f74f6d2fedb79366e3260b260f5ab0053e1df28af14fc2c860800bf00fe96"},
- {file = "pyclean-2.7.6.tar.gz", hash = "sha256:cde95ad4fbc194e970fd7ad009e82718f6c1d1309b195be9018d334d79661cca"},
-]
-
-[[package]]
-name = "pycparser"
-version = "2.22"
-description = "C parser in Python"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
- {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
-]
-
-[[package]]
-name = "pycryptodomex"
-version = "3.21.0"
-description = "Cryptographic library for Python"
-optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
-files = [
- {file = "pycryptodomex-3.21.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:dbeb84a399373df84a69e0919c1d733b89e049752426041deeb30d68e9867822"},
- {file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:a192fb46c95489beba9c3f002ed7d93979423d1b2a53eab8771dbb1339eb3ddd"},
- {file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:1233443f19d278c72c4daae749872a4af3787a813e05c3561c73ab0c153c7b0f"},
- {file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbb07f88e277162b8bfca7134b34f18b400d84eac7375ce73117f865e3c80d4c"},
- {file = "pycryptodomex-3.21.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:e859e53d983b7fe18cb8f1b0e29d991a5c93be2c8dd25db7db1fe3bd3617f6f9"},
- {file = "pycryptodomex-3.21.0-cp27-cp27m-win32.whl", hash = "sha256:ef046b2e6c425647971b51424f0f88d8a2e0a2a63d3531817968c42078895c00"},
- {file = "pycryptodomex-3.21.0-cp27-cp27m-win_amd64.whl", hash = "sha256:da76ebf6650323eae7236b54b1b1f0e57c16483be6e3c1ebf901d4ada47563b6"},
- {file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:c07e64867a54f7e93186a55bec08a18b7302e7bee1b02fd84c6089ec215e723a"},
- {file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:56435c7124dd0ce0c8bdd99c52e5d183a0ca7fdcd06c5d5509423843f487dd0b"},
- {file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65d275e3f866cf6fe891411be9c1454fb58809ccc5de6d3770654c47197acd65"},
- {file = "pycryptodomex-3.21.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:5241bdb53bcf32a9568770a6584774b1b8109342bd033398e4ff2da052123832"},
- {file = "pycryptodomex-3.21.0-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:34325b84c8b380675fd2320d0649cdcbc9cf1e0d1526edbe8fce43ed858cdc7e"},
- {file = "pycryptodomex-3.21.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:103c133d6cd832ae7266feb0a65b69e3a5e4dbbd6f3a3ae3211a557fd653f516"},
- {file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77ac2ea80bcb4b4e1c6a596734c775a1615d23e31794967416afc14852a639d3"},
- {file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9aa0cf13a1a1128b3e964dc667e5fe5c6235f7d7cfb0277213f0e2a783837cc2"},
- {file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46eb1f0c8d309da63a2064c28de54e5e614ad17b7e2f88df0faef58ce192fc7b"},
- {file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:cc7e111e66c274b0df5f4efa679eb31e23c7545d702333dfd2df10ab02c2a2ce"},
- {file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_2_i686.whl", hash = "sha256:770d630a5c46605ec83393feaa73a9635a60e55b112e1fb0c3cea84c2897aa0a"},
- {file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:52e23a0a6e61691134aa8c8beba89de420602541afaae70f66e16060fdcd677e"},
- {file = "pycryptodomex-3.21.0-cp36-abi3-win32.whl", hash = "sha256:a3d77919e6ff56d89aada1bd009b727b874d464cb0e2e3f00a49f7d2e709d76e"},
- {file = "pycryptodomex-3.21.0-cp36-abi3-win_amd64.whl", hash = "sha256:b0e9765f93fe4890f39875e6c90c96cb341767833cfa767f41b490b506fa9ec0"},
- {file = "pycryptodomex-3.21.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:feaecdce4e5c0045e7a287de0c4351284391fe170729aa9182f6bd967631b3a8"},
- {file = "pycryptodomex-3.21.0-pp27-pypy_73-win32.whl", hash = "sha256:365aa5a66d52fd1f9e0530ea97f392c48c409c2f01ff8b9a39c73ed6f527d36c"},
- {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3efddfc50ac0ca143364042324046800c126a1d63816d532f2e19e6f2d8c0c31"},
- {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0df2608682db8279a9ebbaf05a72f62a321433522ed0e499bc486a6889b96bf3"},
- {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5823d03e904ea3e53aebd6799d6b8ec63b7675b5d2f4a4bd5e3adcb512d03b37"},
- {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:27e84eeff24250ffec32722334749ac2a57a5fd60332cd6a0680090e7c42877e"},
- {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ef436cdeea794015263853311f84c1ff0341b98fc7908e8a70595a68cefd971"},
- {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a1058e6dfe827f4209c5cae466e67610bcd0d66f2f037465daa2a29d92d952b"},
- {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ba09a5b407cbb3bcb325221e346a140605714b5e880741dc9a1e9ecf1688d42"},
- {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8a9d8342cf22b74a746e3c6c9453cb0cfbb55943410e3a2619bd9164b48dc9d9"},
- {file = "pycryptodomex-3.21.0.tar.gz", hash = "sha256:222d0bd05381dd25c32dd6065c071ebf084212ab79bab4599ba9e6a3e0009e6c"},
-]
-
-[[package]]
-name = "pydantic"
-version = "2.9.2"
-description = "Data validation using Python type hints"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"},
- {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"},
-]
-
-[package.dependencies]
-annotated-types = ">=0.6.0"
-pydantic-core = "2.23.4"
-typing-extensions = {version = ">=4.6.1", markers = "python_version < \"3.13\""}
-
-[package.extras]
-email = ["email-validator (>=2.0.0)"]
-timezone = ["tzdata"]
-
-[[package]]
-name = "pydantic-core"
-version = "2.23.4"
-description = "Core functionality for Pydantic validation and serialization"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"},
- {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"},
- {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"},
- {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"},
- {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"},
- {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"},
- {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"},
- {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"},
- {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"},
- {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"},
- {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"},
- {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"},
- {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"},
- {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"},
- {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"},
- {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"},
- {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"},
- {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"},
- {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"},
- {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"},
- {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"},
- {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"},
- {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"},
- {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"},
- {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"},
- {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"},
- {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"},
- {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"},
- {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"},
- {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"},
- {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"},
- {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"},
- {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"},
- {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"},
- {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"},
- {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"},
- {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"},
- {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"},
- {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"},
- {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"},
- {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"},
- {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"},
- {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"},
- {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"},
- {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"},
- {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"},
- {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"},
- {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"},
- {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"},
- {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"},
- {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"},
- {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"},
- {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"},
- {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"},
- {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"},
- {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"},
- {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"},
- {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"},
- {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"},
- {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"},
- {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"},
- {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"},
- {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"},
- {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"},
- {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"},
- {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"},
- {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"},
- {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"},
- {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"},
- {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"},
- {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"},
- {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"},
- {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"},
- {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"},
- {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"},
- {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"},
- {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"},
- {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"},
- {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"},
- {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"},
- {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"},
- {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"},
- {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"},
- {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"},
- {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"},
- {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"},
- {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"},
- {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"},
- {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"},
-]
-
-[package.dependencies]
-typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
-
-[[package]]
-name = "pygments"
-version = "2.18.0"
-description = "Pygments is a syntax highlighting package written in Python."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"},
- {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"},
-]
-
-[package.extras]
-windows-terminal = ["colorama (>=0.4.6)"]
-
-[[package]]
-name = "pyinstaller"
-version = "5.13.2"
-description = "PyInstaller bundles a Python application and all its dependencies into a single package."
-optional = false
-python-versions = "<3.13,>=3.7"
-files = [
- {file = "pyinstaller-5.13.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:16cbd66b59a37f4ee59373a003608d15df180a0d9eb1a29ff3bfbfae64b23d0f"},
- {file = "pyinstaller-5.13.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8f6dd0e797ae7efdd79226f78f35eb6a4981db16c13325e962a83395c0ec7420"},
- {file = "pyinstaller-5.13.2-py3-none-manylinux2014_i686.whl", hash = "sha256:65133ed89467edb2862036b35d7c5ebd381670412e1e4361215e289c786dd4e6"},
- {file = "pyinstaller-5.13.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:7d51734423685ab2a4324ab2981d9781b203dcae42839161a9ee98bfeaabdade"},
- {file = "pyinstaller-5.13.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:2c2fe9c52cb4577a3ac39626b84cf16cf30c2792f785502661286184f162ae0d"},
- {file = "pyinstaller-5.13.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c63ef6133eefe36c4b2f4daf4cfea3d6412ece2ca218f77aaf967e52a95ac9b8"},
- {file = "pyinstaller-5.13.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:aadafb6f213549a5906829bb252e586e2cf72a7fbdb5731810695e6516f0ab30"},
- {file = "pyinstaller-5.13.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b2e1c7f5cceb5e9800927ddd51acf9cc78fbaa9e79e822c48b0ee52d9ce3c892"},
- {file = "pyinstaller-5.13.2-py3-none-win32.whl", hash = "sha256:421cd24f26144f19b66d3868b49ed673176765f92fa9f7914cd2158d25b6d17e"},
- {file = "pyinstaller-5.13.2-py3-none-win_amd64.whl", hash = "sha256:ddcc2b36052a70052479a9e5da1af067b4496f43686ca3cdda99f8367d0627e4"},
- {file = "pyinstaller-5.13.2-py3-none-win_arm64.whl", hash = "sha256:27cd64e7cc6b74c5b1066cbf47d75f940b71356166031deb9778a2579bb874c6"},
- {file = "pyinstaller-5.13.2.tar.gz", hash = "sha256:c8e5d3489c3a7cc5f8401c2d1f48a70e588f9967e391c3b06ddac1f685f8d5d2"},
-]
-
-[package.dependencies]
-altgraph = "*"
-macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""}
-pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""}
-pyinstaller-hooks-contrib = ">=2021.4"
-pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""}
-setuptools = ">=42.0.0"
-
-[package.extras]
-encryption = ["tinyaes (>=1.0.0)"]
-hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"]
-
-[[package]]
-name = "pyinstaller-hooks-contrib"
-version = "2024.9"
-description = "Community maintained hooks for PyInstaller"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "pyinstaller_hooks_contrib-2024.9-py3-none-any.whl", hash = "sha256:1ddf9ba21d586afa84e505bb5c65fca10b22500bf3fdb89ee2965b99da53b891"},
- {file = "pyinstaller_hooks_contrib-2024.9.tar.gz", hash = "sha256:4793869f370d1dc4806c101efd2890e3c3e703467d8d27bb5a3db005ebfb008d"},
-]
-
-[package.dependencies]
-packaging = ">=22.0"
-setuptools = ">=42.0.0"
-
-[[package]]
-name = "pymdown-extensions"
-version = "10.11.2"
-description = "Extension pack for Python Markdown."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "pymdown_extensions-10.11.2-py3-none-any.whl", hash = "sha256:41cdde0a77290e480cf53892f5c5e50921a7ee3e5cd60ba91bf19837b33badcf"},
- {file = "pymdown_extensions-10.11.2.tar.gz", hash = "sha256:bc8847ecc9e784a098efd35e20cba772bc5a1b529dfcef9dc1972db9021a1049"},
-]
-
-[package.dependencies]
-markdown = ">=3.6"
-pyyaml = "*"
-
-[package.extras]
-extra = ["pygments (>=2.12)"]
-
-[[package]]
-name = "pyparsing"
-version = "3.2.0"
-description = "pyparsing module - Classes and methods to define and execute parsing grammars"
-optional = false
-python-versions = ">=3.9"
-files = [
- {file = "pyparsing-3.2.0-py3-none-any.whl", hash = "sha256:93d9577b88da0bbea8cc8334ee8b918ed014968fd2ec383e868fb8afb1ccef84"},
- {file = "pyparsing-3.2.0.tar.gz", hash = "sha256:cbf74e27246d595d9a74b186b810f6fbb86726dbf3b9532efb343f6d7294fe9c"},
-]
-
-[package.extras]
-diagrams = ["jinja2", "railroad-diagrams"]
-
-[[package]]
-name = "pypiwin32"
-version = "223"
-description = ""
-optional = false
-python-versions = "*"
-files = [
- {file = "pypiwin32-223-py3-none-any.whl", hash = "sha256:67adf399debc1d5d14dffc1ab5acacb800da569754fafdc576b2a039485aa775"},
- {file = "pypiwin32-223.tar.gz", hash = "sha256:71be40c1fbd28594214ecaecb58e7aa8b708eabfa0125c8a109ebd51edbd776a"},
-]
-
-[package.dependencies]
-pywin32 = ">=223"
-
-[[package]]
-name = "pyppmd"
-version = "1.1.0"
-description = "PPMd compression/decompression library"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "pyppmd-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c5cd428715413fe55abf79dc9fc54924ba7e518053e1fc0cbdf80d0d99cf1442"},
- {file = "pyppmd-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0e96cc43f44b7658be2ea764e7fa99c94cb89164dbb7cdf209178effc2168319"},
- {file = "pyppmd-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dd20142869094bceef5ab0b160f4fff790ad1f612313a1e3393a51fc3ba5d57e"},
- {file = "pyppmd-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4f9b51e45c11e805e74ea6f6355e98a6423b5bbd92f45aceee24761bdc3d3b8"},
- {file = "pyppmd-1.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:459f85e928fb968d0e34fb6191fd8c4e710012d7d884fa2b317b2e11faac7c59"},
- {file = "pyppmd-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f73cf2aaf60477eef17f5497d14b6099d8be9748390ad2b83d1c88214d050c05"},
- {file = "pyppmd-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2ea3ae0e92c0b5345cd3a4e145e01bbd79c2d95355481ea5d833b5c0cb202a2d"},
- {file = "pyppmd-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:775172c740133c0162a01c1a5443d0e312246881cdd6834421b644d89a634b91"},
- {file = "pyppmd-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:14421030f1d46f69829698bdd960698a3b3df0925e3c470e82cfcdd4446b7bc1"},
- {file = "pyppmd-1.1.0-cp310-cp310-win32.whl", hash = "sha256:b691264f9962532aca3bba5be848b6370e596d0a2ca722c86df388be08d0568a"},
- {file = "pyppmd-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:216b0d969a3f06e35fbfef979706d987d105fcb1e37b0b1324f01ee143719c4a"},
- {file = "pyppmd-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1f8c51044ee4df1b004b10bf6b3c92f95ea86cfe1111210d303dca44a56e4282"},
- {file = "pyppmd-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac25b3a13d1ac9b8f0bde46952e10848adc79d932f2b548a6491ef8825ae0045"},
- {file = "pyppmd-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c8d3003eebe6aabe22ba744a38a146ed58a25633420d5da882b049342b7c8036"},
- {file = "pyppmd-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c520656bc12100aa6388df27dd7ac738577f38bf43f4a4bea78e1861e579ea5"},
- {file = "pyppmd-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c2a3e807028159a705951f5cb5d005f94caed11d0984e59cc50506de543e22d"},
- {file = "pyppmd-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec8a2447e69444703e2b273247bfcd4b540ec601780eff07da16344c62d2993d"},
- {file = "pyppmd-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b9e0c8053e69cad6a92a0889b3324f567afc75475b4f54727de553ac4fc85780"},
- {file = "pyppmd-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5938d256e8d2a2853dc3af8bb58ae6b4a775c46fc891dbe1826a0b3ceb624031"},
- {file = "pyppmd-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1ce5822d8bea920856232ccfb3c26b56b28b6846ea1b0eb3d5cb9592a026649e"},
- {file = "pyppmd-1.1.0-cp311-cp311-win32.whl", hash = "sha256:2a9e894750f2a52b03e3bc0d7cf004d96c3475a59b1af7e797d808d7d29c9ffe"},
- {file = "pyppmd-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:969555c72e72fe2b4dd944127521a8f2211caddb5df452bbc2506b5adfac539e"},
- {file = "pyppmd-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d6ef8fd818884e914bc209f7961c9400a4da50d178bba25efcef89f09ec9169"},
- {file = "pyppmd-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95f28e2ecf3a9656bd7e766aaa1162b6872b575627f18715f8b046e8617c124a"},
- {file = "pyppmd-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37f3557ea65ee417abcdf5f49d35df00bb9f6f252639cae57aeefcd0dd596133"},
- {file = "pyppmd-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e84b25d088d7727d50218f57f92127cdb839acd6ec3de670b6680a4cf0b2d2a"},
- {file = "pyppmd-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99ed42891986dac8c2ecf52bddfb777900233d867aa18849dbba6f3335600466"},
- {file = "pyppmd-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6fe69b82634488ada75ba07efb90cd5866fa3d64a2c12932b6e8ae207a14e5f"},
- {file = "pyppmd-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:60981ffde1fe6ade750b690b35318c41a1160a8505597fda2c39a74409671217"},
- {file = "pyppmd-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:46e8240315476f57aac23d71e6de003e122b65feba7c68f4cc46a089a82a7cd4"},
- {file = "pyppmd-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c0308e2e76ecb4c878a18c2d7a7c61dbca89b4ef138f65d5f5ead139154dcdea"},
- {file = "pyppmd-1.1.0-cp312-cp312-win32.whl", hash = "sha256:b4fa4c27dc1314d019d921f2aa19e17f99250557e7569eeb70e180558f46af74"},
- {file = "pyppmd-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c269d21e15f4175df27cf00296476097af76941f948734c642d7fb6e85b9b3b9"},
- {file = "pyppmd-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a04ef5fd59818b035855723af85ce008c8191d31216706ffcbeedc505efca269"},
- {file = "pyppmd-1.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1e3ebcf5f95142268afa5cc46457d9dab2d29a3ccfd020a1129dd9d6bd021be1"},
- {file = "pyppmd-1.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4ad046a9525d1f52e93bc642a4cec0bf344a3ba1a15923e424e7a50f8ca003d8"},
- {file = "pyppmd-1.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:169e5023c86ed1f7587961900f58aa78ad8a3d59de1e488a2228b5ba3de52402"},
- {file = "pyppmd-1.1.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baf798e76edd9da975cc536f943756a1b1755eb8ed87371f86f76d7c16e8d034"},
- {file = "pyppmd-1.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d63be8c068879194c1e7548d0c57f54a4d305ba204cd0c7499b678f0aee893ef"},
- {file = "pyppmd-1.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d5fc178a3c21af78858acbac9782fca6a927267694c452e0882c55fec6e78319"},
- {file = "pyppmd-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:28a1ab1ef0a31adce9b4c837b7b9acb01ce8f1f702ff3ff884f03d21c2f6b9bb"},
- {file = "pyppmd-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5fef43bfe98ada0a608adf03b2d205e071259027ab50523954c42eef7adcef67"},
- {file = "pyppmd-1.1.0-cp38-cp38-win32.whl", hash = "sha256:6b980902797eab821299a1c9f42fa78eff2826a6b0b0f6bde8a621f9765ffd55"},
- {file = "pyppmd-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:80cde69013f357483abe0c3ff30c55dc5e6b4f72b068f91792ce282c51dc0bff"},
- {file = "pyppmd-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2aeea1bf585c6b8771fa43a6abd704da92f8a46a6d0020953af15d7f3c82e48c"},
- {file = "pyppmd-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7759bdb137694d4ab0cfa5ff2c75c212d90714c7da93544694f68001a0c38e12"},
- {file = "pyppmd-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:db64a4fe956a2e700a737a1d019f526e6ccece217c163b28b354a43464cc495b"},
- {file = "pyppmd-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f788ae8f5a9e79cd777b7969d3401b2a2b87f47abe306c2a03baca30595e9bd"},
- {file = "pyppmd-1.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:324a178935c140210fca2043c688b77e79281da8172d2379a06e094f41735851"},
- {file = "pyppmd-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:363030bbcb7902fb9eeb59ffc262581ca5dd7790ba950328242fd2491c54d99b"},
- {file = "pyppmd-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:31b882584f86440b0ff7906385c9f9d9853e5799197abaafdae2245f87d03f01"},
- {file = "pyppmd-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:b991b4501492ec3380b605fe30bee0b61480d305e98519d81c2a658b2de01593"},
- {file = "pyppmd-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b6108044d943b826f97a9e79201242f61392d6c1fadba463b2069c4e6bc961e1"},
- {file = "pyppmd-1.1.0-cp39-cp39-win32.whl", hash = "sha256:c45ce2968b7762d2cacf622b0a8f260295c6444e0883fd21a21017e3eaef16ed"},
- {file = "pyppmd-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:f5289f32ab4ec5f96a95da51309abd1769f928b0bff62047b3bc25c878c16ccb"},
- {file = "pyppmd-1.1.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ad5da9f7592158e6b6b51d7cd15e536d8b23afbb4d22cba4e5744c7e0a3548b1"},
- {file = "pyppmd-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc6543e7d12ef0a1466d291d655e3d6bca59c7336dbb53b62ccdd407822fb52b"},
- {file = "pyppmd-1.1.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5e4008a45910e3c8c227f6f240de67eb14454c015dc3d8060fc41e230f395d3"},
- {file = "pyppmd-1.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9301fa39d1fb0ed09a10b4c5d7f0074113e96a1ead16ba7310bedf95f7ef660c"},
- {file = "pyppmd-1.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:59521a3c6028da0cb5780ba16880047b00163432a6b975da2f6123adfc1b0be8"},
- {file = "pyppmd-1.1.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d7ec02f1778dd68547e497625d66d7858ce10ea199146eb1d80ee23ba42954be"},
- {file = "pyppmd-1.1.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f062ca743f9b99fe88d417b4d351af9b4ff1a7cbd3d765c058bb97de976d57f1"},
- {file = "pyppmd-1.1.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:088e326b180a0469ac936849f5e1e5320118c22c9d9e673e9c8551153b839c84"},
- {file = "pyppmd-1.1.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:897fa9ab5ff588a1000b8682835c5acf219329aa2bbfec478100e57d1204eeab"},
- {file = "pyppmd-1.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3af4338cc48cd59ee213af61d936419774a0f8600b9aa2013cd1917b108424f0"},
- {file = "pyppmd-1.1.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:cce8cd2d4ceebe2dbf41db6dfebe4c2e621314b3af8a2df2cba5eb5fa277f122"},
- {file = "pyppmd-1.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62e57927dbcb91fb6290a41cd83743b91b9d85858efb16a0dd34fac208ee1c6b"},
- {file = "pyppmd-1.1.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:435317949a6f35e54cdf08e0af6916ace427351e7664ac1593980114668f0aaa"},
- {file = "pyppmd-1.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f66b0d0e32b8fb8707f1d2552f13edfc2917e8ed0bdf4d62e2ce190d2c70834"},
- {file = "pyppmd-1.1.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:650a663a591e06fb8096c213f4070b158981c8c3bf9c166ce7e4c360873f2750"},
- {file = "pyppmd-1.1.0.tar.gz", hash = "sha256:1d38ce2e4b7eb84b53bc8a52380b94f66ba6c39328b8800b30c2b5bf31693973"},
-]
-
-[package.extras]
-check = ["check-manifest", "flake8 (<5)", "flake8-black", "flake8-isort", "isort (>=5.0.3)", "mypy (>=0.812)", "mypy-extensions (>=0.4.3)", "pygments", "readme-renderer"]
-docs = ["sphinx (>=2.3)", "sphinx-rtd-theme"]
-fuzzer = ["atheris", "hypothesis"]
-test = ["coverage[toml] (>=5.2)", "hypothesis", "pytest (>=6.0)", "pytest-benchmark", "pytest-cov", "pytest-timeout"]
-
-[[package]]
-name = "pytest"
-version = "7.4.4"
-description = "pytest: simple powerful testing with Python"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
- {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"},
-]
-
-[package.dependencies]
-colorama = {version = "*", markers = "sys_platform == \"win32\""}
-exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
-iniconfig = "*"
-packaging = "*"
-pluggy = ">=0.12,<2.0"
-tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
-
-[package.extras]
-testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
-
-[[package]]
-name = "python-dateutil"
-version = "2.9.0.post0"
-description = "Extensions to the standard Python datetime module"
-optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
-files = [
- {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
- {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
-]
-
-[package.dependencies]
-six = ">=1.5"
-
-[[package]]
-name = "pywin32"
-version = "308"
-description = "Python for Window Extensions"
-optional = false
-python-versions = "*"
-files = [
- {file = "pywin32-308-cp310-cp310-win32.whl", hash = "sha256:796ff4426437896550d2981b9c2ac0ffd75238ad9ea2d3bfa67a1abd546d262e"},
- {file = "pywin32-308-cp310-cp310-win_amd64.whl", hash = "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e"},
- {file = "pywin32-308-cp310-cp310-win_arm64.whl", hash = "sha256:a5ab5381813b40f264fa3495b98af850098f814a25a63589a8e9eb12560f450c"},
- {file = "pywin32-308-cp311-cp311-win32.whl", hash = "sha256:5d8c8015b24a7d6855b1550d8e660d8daa09983c80e5daf89a273e5c6fb5095a"},
- {file = "pywin32-308-cp311-cp311-win_amd64.whl", hash = "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b"},
- {file = "pywin32-308-cp311-cp311-win_arm64.whl", hash = "sha256:100a5442b7332070983c4cd03f2e906a5648a5104b8a7f50175f7906efd16bb6"},
- {file = "pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897"},
- {file = "pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47"},
- {file = "pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091"},
- {file = "pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed"},
- {file = "pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4"},
- {file = "pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd"},
- {file = "pywin32-308-cp37-cp37m-win32.whl", hash = "sha256:1f696ab352a2ddd63bd07430080dd598e6369152ea13a25ebcdd2f503a38f1ff"},
- {file = "pywin32-308-cp37-cp37m-win_amd64.whl", hash = "sha256:13dcb914ed4347019fbec6697a01a0aec61019c1046c2b905410d197856326a6"},
- {file = "pywin32-308-cp38-cp38-win32.whl", hash = "sha256:5794e764ebcabf4ff08c555b31bd348c9025929371763b2183172ff4708152f0"},
- {file = "pywin32-308-cp38-cp38-win_amd64.whl", hash = "sha256:3b92622e29d651c6b783e368ba7d6722b1634b8e70bd376fd7610fe1992e19de"},
- {file = "pywin32-308-cp39-cp39-win32.whl", hash = "sha256:7873ca4dc60ab3287919881a7d4f88baee4a6e639aa6962de25a98ba6b193341"},
- {file = "pywin32-308-cp39-cp39-win_amd64.whl", hash = "sha256:71b3322d949b4cc20776436a9c9ba0eeedcbc9c650daa536df63f0ff111bb920"},
-]
-
-[[package]]
-name = "pywin32-ctypes"
-version = "0.2.3"
-description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
-optional = false
-python-versions = ">=3.6"
-files = [
- {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"},
- {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"},
-]
-
-[[package]]
-name = "pyyaml"
-version = "6.0.2"
-description = "YAML parser and emitter for Python"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
- {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
- {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"},
- {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"},
- {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"},
- {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"},
- {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"},
- {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"},
- {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"},
- {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"},
- {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"},
- {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"},
- {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"},
- {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"},
- {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"},
- {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"},
- {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"},
- {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"},
- {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"},
- {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"},
- {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"},
- {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"},
- {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"},
- {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"},
- {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"},
- {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"},
- {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"},
- {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"},
- {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"},
- {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"},
- {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"},
- {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"},
- {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"},
- {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"},
- {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"},
- {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"},
- {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"},
- {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"},
- {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"},
- {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"},
- {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"},
- {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"},
- {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"},
- {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"},
- {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"},
- {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"},
- {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"},
- {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"},
- {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"},
- {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"},
- {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"},
- {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"},
- {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
-]
-
-[[package]]
-name = "pyyaml-env-tag"
-version = "0.1"
-description = "A custom YAML tag for referencing environment variables in YAML files. "
-optional = false
-python-versions = ">=3.6"
-files = [
- {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"},
- {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"},
-]
-
-[package.dependencies]
-pyyaml = "*"
-
-[[package]]
-name = "pyzstd"
-version = "0.16.2"
-description = "Python bindings to Zstandard (zstd) compression library."
-optional = false
-python-versions = ">=3.5"
-files = [
- {file = "pyzstd-0.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:637376c8f8cbd0afe1cab613f8c75fd502bd1016bf79d10760a2d5a00905fe62"},
- {file = "pyzstd-0.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3e7a7118cbcfa90ca2ddbf9890c7cb582052a9a8cf2b7e2c1bbaf544bee0f16a"},
- {file = "pyzstd-0.16.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a74cb1ba05876179525144511eed3bd5a509b0ab2b10632c1215a85db0834dfd"},
- {file = "pyzstd-0.16.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c084dde218ffbf112e507e72cbf626b8f58ce9eb23eec129809e31037984662"},
- {file = "pyzstd-0.16.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4646459ebd3d7a59ddbe9312f020bcf7cdd1f059a2ea07051258f7af87a0b31"},
- {file = "pyzstd-0.16.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14bfc2833cc16d7657fc93259edeeaa793286e5031b86ca5dc861ba49b435fce"},
- {file = "pyzstd-0.16.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f27d488f19e5bf27d1e8aa1ae72c6c0a910f1e1ffbdf3c763d02ab781295dd27"},
- {file = "pyzstd-0.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:91e134ca968ff7dcfa8b7d433318f01d309b74ee87e0d2bcadc117c08e1c80db"},
- {file = "pyzstd-0.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6b5f64cd3963c58b8f886eb6139bb8d164b42a74f8a1bb95d49b4804f4592d61"},
- {file = "pyzstd-0.16.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0b4a8266871b9e0407f9fd8e8d077c3558cf124d174e6357b523d14f76971009"},
- {file = "pyzstd-0.16.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1bb19f7acac30727354c25125922aa59f44d82e0e6a751df17d0d93ff6a73853"},
- {file = "pyzstd-0.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3008325b7368e794d66d4d98f2ee1d867ef5afd09fd388646ae02b25343c420d"},
- {file = "pyzstd-0.16.2-cp310-cp310-win32.whl", hash = "sha256:66f2d5c0bbf5bf32c577aa006197b3525b80b59804450e2c32fbcc2d16e850fd"},
- {file = "pyzstd-0.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fe5f5459ebe1161095baa7a86d04ab625b35148f6c425df0347ed6c90a2fd58"},
- {file = "pyzstd-0.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1bdbe7f01c7f37d5cd07be70e32a84010d7dfd6677920c0de04cf7d245b60d"},
- {file = "pyzstd-0.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1882a3ceaaf9adc12212d587d150ec5e58cfa9a765463d803d739abbd3ac0f7a"},
- {file = "pyzstd-0.16.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea46a8b9d60f6a6eba29facba54c0f0d70328586f7ef0da6f57edf7e43db0303"},
- {file = "pyzstd-0.16.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7865bc06589cdcecdede0deefe3da07809d5b7ad9044c224d7b2a0867256957"},
- {file = "pyzstd-0.16.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:52f938a65b409c02eb825e8c77fc5ea54508b8fc44b5ce226db03011691ae8cc"},
- {file = "pyzstd-0.16.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e97620d3f53a0282947304189deef7ca7f7d0d6dfe15033469dc1c33e779d5e5"},
- {file = "pyzstd-0.16.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7c40e9983d017108670dc8df68ceef14c7c1cf2d19239213274783041d0e64c"},
- {file = "pyzstd-0.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7cd4b3b2c6161066e4bde6af1cf78ed3acf5d731884dd13fdf31f1db10830080"},
- {file = "pyzstd-0.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:454f31fd84175bb203c8c424f2255a343fa9bd103461a38d1bf50487c3b89508"},
- {file = "pyzstd-0.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5ef754a93743f08fb0386ce3596780bfba829311b49c8f4107af1a4bcc16935d"},
- {file = "pyzstd-0.16.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:be81081db9166e10846934f0e3576a263cbe18d81eca06e6a5c23533f8ce0dc6"},
- {file = "pyzstd-0.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:738bcb2fa1e5f1868986f5030955e64de53157fa1141d01f3a4daf07a1aaf644"},
- {file = "pyzstd-0.16.2-cp311-cp311-win32.whl", hash = "sha256:0ea214c9b97046867d1657d55979021028d583704b30c481a9c165191b08d707"},
- {file = "pyzstd-0.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:c17c0fc02f0e75b0c7cd21f8eaf4c6ce4112333b447d93da1773a5f705b2c178"},
- {file = "pyzstd-0.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4081fd841a9efe9ded7290ee7502dbf042c4158b90edfadea3b8a072c8ec4e1"},
- {file = "pyzstd-0.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fd3fa45d2aeb65367dd702806b2e779d13f1a3fa2d13d5ec777cfd09de6822de"},
- {file = "pyzstd-0.16.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8b5f0d2c07994a5180d8259d51df6227a57098774bb0618423d7eb4a7303467"},
- {file = "pyzstd-0.16.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60c9d25b15c7ae06ed5d516d096a0d8254f9bed4368b370a09cccf191eaab5cb"},
- {file = "pyzstd-0.16.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29acf31ce37254f6cad08deb24b9d9ba954f426fa08f8fae4ab4fdc51a03f4ae"},
- {file = "pyzstd-0.16.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ec77612a17697a9f7cf6634ffcee616eba9b997712fdd896e77fd19ab3a0618"},
- {file = "pyzstd-0.16.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:313ea4974be93be12c9a640ab40f0fc50a023178aae004a8901507b74f190173"},
- {file = "pyzstd-0.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e91acdefc8c2c6c3b8d5b1b5fe837dce4e591ecb7c0a2a50186f552e57d11203"},
- {file = "pyzstd-0.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:929bd91a403539e72b5b5cb97f725ac4acafe692ccf52f075e20cd9bf6e5493d"},
- {file = "pyzstd-0.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:740837a379aa32d110911ebcbbc524f9a9b145355737527543a884bd8777ca4f"},
- {file = "pyzstd-0.16.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:adfc0e80dd157e6d1e0b0112c8ecc4b58a7a23760bd9623d74122ef637cfbdb6"},
- {file = "pyzstd-0.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:79b183beae1c080ad3dca39019e49b7785391947f9aab68893ad85d27828c6e7"},
- {file = "pyzstd-0.16.2-cp312-cp312-win32.whl", hash = "sha256:b8d00631a3c466bc313847fab2a01f6b73b3165de0886fb03210e08567ae3a89"},
- {file = "pyzstd-0.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:c0d43764e9a60607f35d8cb3e60df772a678935ab0e02e2804d4147377f4942c"},
- {file = "pyzstd-0.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3ae9ae7ad730562810912d7ecaf1fff5eaf4c726f4b4dfe04784ed5f06d7b91f"},
- {file = "pyzstd-0.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2ce8d3c213f76a564420f3d0137066ac007ce9fb4e156b989835caef12b367a7"},
- {file = "pyzstd-0.16.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2c14dac23c865e2d78cebd9087e148674b7154f633afd4709b4cd1520b99a61"},
- {file = "pyzstd-0.16.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4527969d66a943e36ef374eda847e918077de032d58b5df84d98ffd717b6fa77"},
- {file = "pyzstd-0.16.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd8256149b88e657e99f31e6d4b114c8ff2935951f1d8bb8e1fe501b224999c0"},
- {file = "pyzstd-0.16.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bd1f1822d65c9054bf36d35307bf8ed4aa2d2d6827431761a813628ff671b1d"},
- {file = "pyzstd-0.16.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6733f4d373ec9ad2c1976cf06f973a3324c1f9abe236d114d6bb91165a397d"},
- {file = "pyzstd-0.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7bec165ab6524663f00b69bfefd13a46a69fed3015754abaf81b103ec73d92c6"},
- {file = "pyzstd-0.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e4460fa6949aac6528a1ad0de8871079600b12b3ef4db49316306786a3598321"},
- {file = "pyzstd-0.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75df79ea0315c97d88337953a17daa44023dbf6389f8151903d371513f503e3c"},
- {file = "pyzstd-0.16.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:93e1d45f4a196afb6f18682c79bdd5399277ead105b67f30b35c04c207966071"},
- {file = "pyzstd-0.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:075e18b871f38a503b5d23e40a661adfc750bd4bd0bb8b208c1e290f3ceb8fa2"},
- {file = "pyzstd-0.16.2-cp313-cp313-win32.whl", hash = "sha256:9e4295eb299f8d87e3487852bca033d30332033272a801ca8130e934475e07a9"},
- {file = "pyzstd-0.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:18deedc70f858f4cf574e59f305d2a0678e54db2751a33dba9f481f91bc71c28"},
- {file = "pyzstd-0.16.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a9892b707ef52f599098b1e9528df0e7849c5ec01d3e8035fb0e67de4b464839"},
- {file = "pyzstd-0.16.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4fbd647864341f3c174c4a6d7f20e6ea6b4be9d840fb900dc0faf0849561badc"},
- {file = "pyzstd-0.16.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20ac2c15656cc6194c4fed1cb0e8159f9394d4ea1d58be755448743d2ec6c9c4"},
- {file = "pyzstd-0.16.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b239fb9a20c1be3374b9a2bd183ba624fd22ad7a3f67738c0d80cda68b4ae1d3"},
- {file = "pyzstd-0.16.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc52400412cdae2635e0978b8d6bcc0028cc638fdab2fd301f6d157675d26896"},
- {file = "pyzstd-0.16.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b766a6aeb8dbb6c46e622e7a1aebfa9ab03838528273796941005a5ce7257b1"},
- {file = "pyzstd-0.16.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd4b8676052f9d59579242bf3cfe5fd02532b6a9a93ab7737c118ae3b8509dc"},
- {file = "pyzstd-0.16.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1c6c0a677aac7c0e3d2d2605d4d68ffa9893fdeeb2e071040eb7c8750969d463"},
- {file = "pyzstd-0.16.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:15f9c2d612e7e2023d68d321d1b479846751f792af89141931d44e82ae391394"},
- {file = "pyzstd-0.16.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:11740bff847aad23beef4085a1bb767d101895881fe891f0a911aa27d43c372c"},
- {file = "pyzstd-0.16.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b9067483ebe860e4130a03ee665b3d7be4ec1608b208e645d5e7eb3492379464"},
- {file = "pyzstd-0.16.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:988f0ba19b14c2fe0afefc444ac1edfb2f497b7d7c3212b2f587504cc2ec804e"},
- {file = "pyzstd-0.16.2-cp39-cp39-win32.whl", hash = "sha256:8855acb1c3e3829030b9e9e9973b19e2d70f33efb14ad5c474b4d086864c959c"},
- {file = "pyzstd-0.16.2-cp39-cp39-win_amd64.whl", hash = "sha256:018e88378df5e76f5e1d8cf4416576603b6bc4a103cbc66bb593eaac54c758de"},
- {file = "pyzstd-0.16.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4b631117b97a42ff6dfd0ffc885a92fff462d7c34766b28383c57b996f863338"},
- {file = "pyzstd-0.16.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:56493a3fbe1b651a02102dd0902b0aa2377a732ff3544fb6fb3f114ca18db52f"},
- {file = "pyzstd-0.16.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1eae9bdba4a1e5d3181331f403114ff5b8ce0f4b569f48eba2b9beb2deef1e4"},
- {file = "pyzstd-0.16.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1be6972391c8aeecc7e61feb96ffc8e77a401bcba6ed994e7171330c45a1948"},
- {file = "pyzstd-0.16.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:761439d687e3a5687c2ff5c6a1190e1601362a4a3e8c6c82ff89719d51d73e19"},
- {file = "pyzstd-0.16.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f5fbdb8cf31b60b2dc586fecb9b73e2f172c21a0b320ed275f7b8d8a866d9003"},
- {file = "pyzstd-0.16.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:183f26e34f9becf0f2db38be9c0bfb136753d228bcb47c06c69175901bea7776"},
- {file = "pyzstd-0.16.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:88318b64b5205a67748148d6d244097fa6cf61fcea02ad3435511b9e7155ae16"},
- {file = "pyzstd-0.16.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73142aa2571b6480136a1865ebda8257e09eabbc8bcd54b222202f6fa4febe1e"},
- {file = "pyzstd-0.16.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d3f8877c29a97f1b1bba16f3d3ab01ad10ad3da7bad317aecf36aaf8848b37c"},
- {file = "pyzstd-0.16.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1f25754562473ac7de856b8331ebd5964f5d85601045627a5f0bb0e4e899990"},
- {file = "pyzstd-0.16.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:6ce17e84310080c55c02827ad9bb17893c00a845c8386a328b346f814aabd2c1"},
- {file = "pyzstd-0.16.2.tar.gz", hash = "sha256:179c1a2ea1565abf09c5f2fd72f9ce7c54b2764cf7369e05c0bfd8f1f67f63d2"},
-]
-
-[[package]]
-name = "questionary"
-version = "2.0.1"
-description = "Python library to build pretty command line user prompts βοΈ"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "questionary-2.0.1-py3-none-any.whl", hash = "sha256:8ab9a01d0b91b68444dff7f6652c1e754105533f083cbe27597c8110ecc230a2"},
- {file = "questionary-2.0.1.tar.gz", hash = "sha256:bcce898bf3dbb446ff62830c86c5c6fb9a22a54146f0f5597d3da43b10d8fc8b"},
-]
-
-[package.dependencies]
-prompt_toolkit = ">=2.0,<=3.0.36"
-
-[[package]]
-name = "ratelimit"
-version = "2.2.1"
-description = "API rate limit decorator"
-optional = false
-python-versions = "*"
-files = [
- {file = "ratelimit-2.2.1.tar.gz", hash = "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42"},
-]
-
-[[package]]
-name = "regex"
-version = "2024.9.11"
-description = "Alternative regular expression module, to replace re."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "regex-2024.9.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1494fa8725c285a81d01dc8c06b55287a1ee5e0e382d8413adc0a9197aac6408"},
- {file = "regex-2024.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0e12c481ad92d129c78f13a2a3662317e46ee7ef96c94fd332e1c29131875b7d"},
- {file = "regex-2024.9.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16e13a7929791ac1216afde26f712802e3df7bf0360b32e4914dca3ab8baeea5"},
- {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46989629904bad940bbec2106528140a218b4a36bb3042d8406980be1941429c"},
- {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a906ed5e47a0ce5f04b2c981af1c9acf9e8696066900bf03b9d7879a6f679fc8"},
- {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a091b0550b3b0207784a7d6d0f1a00d1d1c8a11699c1a4d93db3fbefc3ad35"},
- {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ddcd9a179c0a6fa8add279a4444015acddcd7f232a49071ae57fa6e278f1f71"},
- {file = "regex-2024.9.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b41e1adc61fa347662b09398e31ad446afadff932a24807d3ceb955ed865cc8"},
- {file = "regex-2024.9.11-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ced479f601cd2f8ca1fd7b23925a7e0ad512a56d6e9476f79b8f381d9d37090a"},
- {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:635a1d96665f84b292e401c3d62775851aedc31d4f8784117b3c68c4fcd4118d"},
- {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c0256beda696edcf7d97ef16b2a33a8e5a875affd6fa6567b54f7c577b30a137"},
- {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3ce4f1185db3fbde8ed8aa223fc9620f276c58de8b0d4f8cc86fd1360829edb6"},
- {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:09d77559e80dcc9d24570da3745ab859a9cf91953062e4ab126ba9d5993688ca"},
- {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7a22ccefd4db3f12b526eccb129390942fe874a3a9fdbdd24cf55773a1faab1a"},
- {file = "regex-2024.9.11-cp310-cp310-win32.whl", hash = "sha256:f745ec09bc1b0bd15cfc73df6fa4f726dcc26bb16c23a03f9e3367d357eeedd0"},
- {file = "regex-2024.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:01c2acb51f8a7d6494c8c5eafe3d8e06d76563d8a8a4643b37e9b2dd8a2ff623"},
- {file = "regex-2024.9.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2cce2449e5927a0bf084d346da6cd5eb016b2beca10d0013ab50e3c226ffc0df"},
- {file = "regex-2024.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b37fa423beefa44919e009745ccbf353d8c981516e807995b2bd11c2c77d268"},
- {file = "regex-2024.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:64ce2799bd75039b480cc0360907c4fb2f50022f030bf9e7a8705b636e408fad"},
- {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4cc92bb6db56ab0c1cbd17294e14f5e9224f0cc6521167ef388332604e92679"},
- {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d05ac6fa06959c4172eccd99a222e1fbf17b5670c4d596cb1e5cde99600674c4"},
- {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040562757795eeea356394a7fb13076ad4f99d3c62ab0f8bdfb21f99a1f85664"},
- {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6113c008a7780792efc80f9dfe10ba0cd043cbf8dc9a76ef757850f51b4edc50"},
- {file = "regex-2024.9.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e5fb5f77c8745a60105403a774fe2c1759b71d3e7b4ca237a5e67ad066c7199"},
- {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:54d9ff35d4515debf14bc27f1e3b38bfc453eff3220f5bce159642fa762fe5d4"},
- {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df5cbb1fbc74a8305b6065d4ade43b993be03dbe0f8b30032cced0d7740994bd"},
- {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7fb89ee5d106e4a7a51bce305ac4efb981536301895f7bdcf93ec92ae0d91c7f"},
- {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a738b937d512b30bf75995c0159c0ddf9eec0775c9d72ac0202076c72f24aa96"},
- {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e28f9faeb14b6f23ac55bfbbfd3643f5c7c18ede093977f1df249f73fd22c7b1"},
- {file = "regex-2024.9.11-cp311-cp311-win32.whl", hash = "sha256:18e707ce6c92d7282dfce370cd205098384b8ee21544e7cb29b8aab955b66fa9"},
- {file = "regex-2024.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:313ea15e5ff2a8cbbad96ccef6be638393041b0a7863183c2d31e0c6116688cf"},
- {file = "regex-2024.9.11-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b0d0a6c64fcc4ef9c69bd5b3b3626cc3776520a1637d8abaa62b9edc147a58f7"},
- {file = "regex-2024.9.11-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:49b0e06786ea663f933f3710a51e9385ce0cba0ea56b67107fd841a55d56a231"},
- {file = "regex-2024.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5b513b6997a0b2f10e4fd3a1313568e373926e8c252bd76c960f96fd039cd28d"},
- {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee439691d8c23e76f9802c42a95cfeebf9d47cf4ffd06f18489122dbb0a7ad64"},
- {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8f877c89719d759e52783f7fe6e1c67121076b87b40542966c02de5503ace42"},
- {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23b30c62d0f16827f2ae9f2bb87619bc4fba2044911e2e6c2eb1af0161cdb766"},
- {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85ab7824093d8f10d44330fe1e6493f756f252d145323dd17ab6b48733ff6c0a"},
- {file = "regex-2024.9.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8dee5b4810a89447151999428fe096977346cf2f29f4d5e29609d2e19e0199c9"},
- {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98eeee2f2e63edae2181c886d7911ce502e1292794f4c5ee71e60e23e8d26b5d"},
- {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:57fdd2e0b2694ce6fc2e5ccf189789c3e2962916fb38779d3e3521ff8fe7a822"},
- {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d552c78411f60b1fdaafd117a1fca2f02e562e309223b9d44b7de8be451ec5e0"},
- {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a0b2b80321c2ed3fcf0385ec9e51a12253c50f146fddb2abbb10f033fe3d049a"},
- {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:18406efb2f5a0e57e3a5881cd9354c1512d3bb4f5c45d96d110a66114d84d23a"},
- {file = "regex-2024.9.11-cp312-cp312-win32.whl", hash = "sha256:e464b467f1588e2c42d26814231edecbcfe77f5ac414d92cbf4e7b55b2c2a776"},
- {file = "regex-2024.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:9e8719792ca63c6b8340380352c24dcb8cd7ec49dae36e963742a275dfae6009"},
- {file = "regex-2024.9.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c157bb447303070f256e084668b702073db99bbb61d44f85d811025fcf38f784"},
- {file = "regex-2024.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4db21ece84dfeefc5d8a3863f101995de646c6cb0536952c321a2650aa202c36"},
- {file = "regex-2024.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:220e92a30b426daf23bb67a7962900ed4613589bab80382be09b48896d211e92"},
- {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb1ae19e64c14c7ec1995f40bd932448713d3c73509e82d8cd7744dc00e29e86"},
- {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f47cd43a5bfa48f86925fe26fbdd0a488ff15b62468abb5d2a1e092a4fb10e85"},
- {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9d4a76b96f398697fe01117093613166e6aa8195d63f1b4ec3f21ab637632963"},
- {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ea51dcc0835eea2ea31d66456210a4e01a076d820e9039b04ae8d17ac11dee6"},
- {file = "regex-2024.9.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7aaa315101c6567a9a45d2839322c51c8d6e81f67683d529512f5bcfb99c802"},
- {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c57d08ad67aba97af57a7263c2d9006d5c404d721c5f7542f077f109ec2a4a29"},
- {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f8404bf61298bb6f8224bb9176c1424548ee1181130818fcd2cbffddc768bed8"},
- {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dd4490a33eb909ef5078ab20f5f000087afa2a4daa27b4c072ccb3cb3050ad84"},
- {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:eee9130eaad130649fd73e5cd92f60e55708952260ede70da64de420cdcad554"},
- {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a2644a93da36c784e546de579ec1806bfd2763ef47babc1b03d765fe560c9f8"},
- {file = "regex-2024.9.11-cp313-cp313-win32.whl", hash = "sha256:e997fd30430c57138adc06bba4c7c2968fb13d101e57dd5bb9355bf8ce3fa7e8"},
- {file = "regex-2024.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:042c55879cfeb21a8adacc84ea347721d3d83a159da6acdf1116859e2427c43f"},
- {file = "regex-2024.9.11-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:35f4a6f96aa6cb3f2f7247027b07b15a374f0d5b912c0001418d1d55024d5cb4"},
- {file = "regex-2024.9.11-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:55b96e7ce3a69a8449a66984c268062fbaa0d8ae437b285428e12797baefce7e"},
- {file = "regex-2024.9.11-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb130fccd1a37ed894824b8c046321540263013da72745d755f2d35114b81a60"},
- {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:323c1f04be6b2968944d730e5c2091c8c89767903ecaa135203eec4565ed2b2b"},
- {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be1c8ed48c4c4065ecb19d882a0ce1afe0745dfad8ce48c49586b90a55f02366"},
- {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5b029322e6e7b94fff16cd120ab35a253236a5f99a79fb04fda7ae71ca20ae8"},
- {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6fff13ef6b5f29221d6904aa816c34701462956aa72a77f1f151a8ec4f56aeb"},
- {file = "regex-2024.9.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:587d4af3979376652010e400accc30404e6c16b7df574048ab1f581af82065e4"},
- {file = "regex-2024.9.11-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:079400a8269544b955ffa9e31f186f01d96829110a3bf79dc338e9910f794fca"},
- {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f9268774428ec173654985ce55fc6caf4c6d11ade0f6f914d48ef4719eb05ebb"},
- {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:23f9985c8784e544d53fc2930fc1ac1a7319f5d5332d228437acc9f418f2f168"},
- {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:ae2941333154baff9838e88aa71c1d84f4438189ecc6021a12c7573728b5838e"},
- {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e93f1c331ca8e86fe877a48ad64e77882c0c4da0097f2212873a69bbfea95d0c"},
- {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:846bc79ee753acf93aef4184c040d709940c9d001029ceb7b7a52747b80ed2dd"},
- {file = "regex-2024.9.11-cp38-cp38-win32.whl", hash = "sha256:c94bb0a9f1db10a1d16c00880bdebd5f9faf267273b8f5bd1878126e0fbde771"},
- {file = "regex-2024.9.11-cp38-cp38-win_amd64.whl", hash = "sha256:2b08fce89fbd45664d3df6ad93e554b6c16933ffa9d55cb7e01182baaf971508"},
- {file = "regex-2024.9.11-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:07f45f287469039ffc2c53caf6803cd506eb5f5f637f1d4acb37a738f71dd066"},
- {file = "regex-2024.9.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4838e24ee015101d9f901988001038f7f0d90dc0c3b115541a1365fb439add62"},
- {file = "regex-2024.9.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6edd623bae6a737f10ce853ea076f56f507fd7726bee96a41ee3d68d347e4d16"},
- {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c69ada171c2d0e97a4b5aa78fbb835e0ffbb6b13fc5da968c09811346564f0d3"},
- {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02087ea0a03b4af1ed6ebab2c54d7118127fee8d71b26398e8e4b05b78963199"},
- {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69dee6a020693d12a3cf892aba4808fe168d2a4cef368eb9bf74f5398bfd4ee8"},
- {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297f54910247508e6e5cae669f2bc308985c60540a4edd1c77203ef19bfa63ca"},
- {file = "regex-2024.9.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecea58b43a67b1b79805f1a0255730edaf5191ecef84dbc4cc85eb30bc8b63b9"},
- {file = "regex-2024.9.11-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:eab4bb380f15e189d1313195b062a6aa908f5bd687a0ceccd47c8211e9cf0d4a"},
- {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0cbff728659ce4bbf4c30b2a1be040faafaa9eca6ecde40aaff86f7889f4ab39"},
- {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:54c4a097b8bc5bb0dfc83ae498061d53ad7b5762e00f4adaa23bee22b012e6ba"},
- {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:73d6d2f64f4d894c96626a75578b0bf7d9e56dcda8c3d037a2118fdfe9b1c664"},
- {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:e53b5fbab5d675aec9f0c501274c467c0f9a5d23696cfc94247e1fb56501ed89"},
- {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0ffbcf9221e04502fc35e54d1ce9567541979c3fdfb93d2c554f0ca583a19b35"},
- {file = "regex-2024.9.11-cp39-cp39-win32.whl", hash = "sha256:e4c22e1ac1f1ec1e09f72e6c44d8f2244173db7eb9629cc3a346a8d7ccc31142"},
- {file = "regex-2024.9.11-cp39-cp39-win_amd64.whl", hash = "sha256:faa3c142464efec496967359ca99696c896c591c56c53506bac1ad465f66e919"},
- {file = "regex-2024.9.11.tar.gz", hash = "sha256:6c188c307e8433bcb63dc1915022deb553b4203a70722fc542c363bf120a01fd"},
-]
-
-[[package]]
-name = "requests"
-version = "2.32.3"
-description = "Python HTTP for Humans."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
- {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
-]
-
-[package.dependencies]
-certifi = ">=2017.4.17"
-charset-normalizer = ">=2,<4"
-idna = ">=2.5,<4"
-urllib3 = ">=1.21.1,<3"
-
-[package.extras]
-socks = ["PySocks (>=1.5.6,!=1.5.7)"]
-use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
-
-[[package]]
-name = "rich"
-version = "13.9.3"
-description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
-optional = false
-python-versions = ">=3.8.0"
-files = [
- {file = "rich-13.9.3-py3-none-any.whl", hash = "sha256:9836f5096eb2172c9e77df411c1b009bace4193d6a481d534fea75ebba758283"},
- {file = "rich-13.9.3.tar.gz", hash = "sha256:bc1e01b899537598cf02579d2b9f4a415104d3fc439313a7a2c165d76557a08e"},
-]
-
-[package.dependencies]
-markdown-it-py = ">=2.2.0"
-pygments = ">=2.13.0,<3.0.0"
-typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""}
-
-[package.extras]
-jupyter = ["ipywidgets (>=7.5.1,<9)"]
-
-[[package]]
-name = "ruamel-yaml"
-version = "0.18.6"
-description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "ruamel.yaml-0.18.6-py3-none-any.whl", hash = "sha256:57b53ba33def16c4f3d807c0ccbc00f8a6081827e81ba2491691b76882d0c636"},
- {file = "ruamel.yaml-0.18.6.tar.gz", hash = "sha256:8b27e6a217e786c6fbe5634d8f3f11bc63e0f80f6a5890f28863d9c45aac311b"},
-]
-
-[package.dependencies]
-"ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.13\""}
-
-[package.extras]
-docs = ["mercurial (>5.7)", "ryd"]
-jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"]
-
-[[package]]
-name = "ruamel-yaml-clib"
-version = "0.2.12"
-description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml"
-optional = false
-python-versions = ">=3.9"
-files = [
- {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5"},
- {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969"},
- {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df"},
- {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"},
- {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"},
- {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"},
- {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"},
- {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"},
- {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"},
- {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e"},
- {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e"},
- {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"},
- {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"},
- {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"},
- {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"},
- {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"},
- {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"},
- {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d"},
- {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c"},
- {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"},
- {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"},
- {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"},
- {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"},
- {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"},
- {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"},
- {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475"},
- {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef"},
- {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"},
- {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"},
- {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"},
- {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"},
- {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"},
- {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"},
- {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45"},
- {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519"},
- {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"},
- {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"},
- {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"},
- {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"},
- {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"},
- {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"},
-]
-
-[[package]]
-name = "scikit-image"
-version = "0.24.0"
-description = "Image processing in Python"
-optional = false
-python-versions = ">=3.9"
-files = [
- {file = "scikit_image-0.24.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb3bc0264b6ab30b43c4179ee6156bc18b4861e78bb329dd8d16537b7bbf827a"},
- {file = "scikit_image-0.24.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:9c7a52e20cdd760738da38564ba1fed7942b623c0317489af1a598a8dedf088b"},
- {file = "scikit_image-0.24.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93f46e6ce42e5409f4d09ce1b0c7f80dd7e4373bcec635b6348b63e3c886eac8"},
- {file = "scikit_image-0.24.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39ee0af13435c57351a3397eb379e72164ff85161923eec0c38849fecf1b4764"},
- {file = "scikit_image-0.24.0-cp310-cp310-win_amd64.whl", hash = "sha256:7ac7913b028b8aa780ffae85922894a69e33d1c0bf270ea1774f382fe8bf95e7"},
- {file = "scikit_image-0.24.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:272909e02a59cea3ed4aa03739bb88df2625daa809f633f40b5053cf09241831"},
- {file = "scikit_image-0.24.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:190ebde80b4470fe8838764b9b15f232a964f1a20391663e31008d76f0c696f7"},
- {file = "scikit_image-0.24.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59c98cc695005faf2b79904e4663796c977af22586ddf1b12d6af2fa22842dc2"},
- {file = "scikit_image-0.24.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa27b3a0dbad807b966b8db2d78da734cb812ca4787f7fbb143764800ce2fa9c"},
- {file = "scikit_image-0.24.0-cp311-cp311-win_amd64.whl", hash = "sha256:dacf591ac0c272a111181afad4b788a27fe70d213cfddd631d151cbc34f8ca2c"},
- {file = "scikit_image-0.24.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6fccceb54c9574590abcddc8caf6cefa57c13b5b8b4260ab3ff88ad8f3c252b3"},
- {file = "scikit_image-0.24.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ccc01e4760d655aab7601c1ba7aa4ddd8b46f494ac46ec9c268df6f33ccddf4c"},
- {file = "scikit_image-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18836a18d3a7b6aca5376a2d805f0045826bc6c9fc85331659c33b4813e0b563"},
- {file = "scikit_image-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8579bda9c3f78cb3b3ed8b9425213c53a25fa7e994b7ac01f2440b395babf660"},
- {file = "scikit_image-0.24.0-cp312-cp312-win_amd64.whl", hash = "sha256:82ab903afa60b2da1da2e6f0c8c65e7c8868c60a869464c41971da929b3e82bc"},
- {file = "scikit_image-0.24.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef04360eda372ee5cd60aebe9be91258639c86ae2ea24093fb9182118008d009"},
- {file = "scikit_image-0.24.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:e9aadb442360a7e76f0c5c9d105f79a83d6df0e01e431bd1d5757e2c5871a1f3"},
- {file = "scikit_image-0.24.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e37de6f4c1abcf794e13c258dc9b7d385d5be868441de11c180363824192ff7"},
- {file = "scikit_image-0.24.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4688c18bd7ec33c08d7bf0fd19549be246d90d5f2c1d795a89986629af0a1e83"},
- {file = "scikit_image-0.24.0-cp39-cp39-win_amd64.whl", hash = "sha256:56dab751d20b25d5d3985e95c9b4e975f55573554bd76b0aedf5875217c93e69"},
- {file = "scikit_image-0.24.0.tar.gz", hash = "sha256:5d16efe95da8edbeb363e0c4157b99becbd650a60b77f6e3af5768b66cf007ab"},
-]
-
-[package.dependencies]
-imageio = ">=2.33"
-lazy-loader = ">=0.4"
-networkx = ">=2.8"
-numpy = ">=1.23"
-packaging = ">=21"
-pillow = ">=9.1"
-scipy = ">=1.9"
-tifffile = ">=2022.8.12"
-
-[package.extras]
-build = ["Cython (>=3.0.4)", "build", "meson-python (>=0.15)", "ninja", "numpy (>=2.0.0rc1)", "packaging (>=21)", "pythran", "setuptools (>=67)", "spin (==0.8)", "wheel"]
-data = ["pooch (>=1.6.0)"]
-developer = ["ipython", "pre-commit", "tomli"]
-docs = ["PyWavelets (>=1.1.1)", "dask[array] (>=2022.9.2)", "ipykernel", "ipywidgets", "kaleido", "matplotlib (>=3.6)", "myst-parser", "numpydoc (>=1.7)", "pandas (>=1.5)", "plotly (>=5.10)", "pooch (>=1.6)", "pydata-sphinx-theme (>=0.15.2)", "pytest-doctestplus", "pytest-runner", "scikit-learn (>=1.1)", "seaborn (>=0.11)", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-gallery (>=0.14)", "sphinx_design (>=0.5)", "tifffile (>=2022.8.12)"]
-optional = ["PyWavelets (>=1.1.1)", "SimpleITK", "astropy (>=5.0)", "cloudpickle (>=0.2.1)", "dask[array] (>=2021.1.0)", "matplotlib (>=3.6)", "pooch (>=1.6.0)", "pyamg", "scikit-learn (>=1.1)"]
-test = ["asv", "numpydoc (>=1.7)", "pooch (>=1.6.0)", "pytest (>=7.0)", "pytest-cov (>=2.11.0)", "pytest-doctestplus", "pytest-faulthandler", "pytest-localserver"]
-
-[[package]]
-name = "scipy"
-version = "1.14.1"
-description = "Fundamental algorithms for scientific computing in Python"
-optional = false
-python-versions = ">=3.10"
-files = [
- {file = "scipy-1.14.1-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:b28d2ca4add7ac16ae8bb6632a3c86e4b9e4d52d3e34267f6e1b0c1f8d87e389"},
- {file = "scipy-1.14.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:d0d2821003174de06b69e58cef2316a6622b60ee613121199cb2852a873f8cf3"},
- {file = "scipy-1.14.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8bddf15838ba768bb5f5083c1ea012d64c9a444e16192762bd858f1e126196d0"},
- {file = "scipy-1.14.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:97c5dddd5932bd2a1a31c927ba5e1463a53b87ca96b5c9bdf5dfd6096e27efc3"},
- {file = "scipy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ff0a7e01e422c15739ecd64432743cf7aae2b03f3084288f399affcefe5222d"},
- {file = "scipy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e32dced201274bf96899e6491d9ba3e9a5f6b336708656466ad0522d8528f69"},
- {file = "scipy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8426251ad1e4ad903a4514712d2fa8fdd5382c978010d1c6f5f37ef286a713ad"},
- {file = "scipy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:a49f6ed96f83966f576b33a44257d869756df6cf1ef4934f59dd58b25e0327e5"},
- {file = "scipy-1.14.1-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:2da0469a4ef0ecd3693761acbdc20f2fdeafb69e6819cc081308cc978153c675"},
- {file = "scipy-1.14.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c0ee987efa6737242745f347835da2cc5bb9f1b42996a4d97d5c7ff7928cb6f2"},
- {file = "scipy-1.14.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3a1b111fac6baec1c1d92f27e76511c9e7218f1695d61b59e05e0fe04dc59617"},
- {file = "scipy-1.14.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8475230e55549ab3f207bff11ebfc91c805dc3463ef62eda3ccf593254524ce8"},
- {file = "scipy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:278266012eb69f4a720827bdd2dc54b2271c97d84255b2faaa8f161a158c3b37"},
- {file = "scipy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fef8c87f8abfb884dac04e97824b61299880c43f4ce675dd2cbeadd3c9b466d2"},
- {file = "scipy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b05d43735bb2f07d689f56f7b474788a13ed8adc484a85aa65c0fd931cf9ccd2"},
- {file = "scipy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:716e389b694c4bb564b4fc0c51bc84d381735e0d39d3f26ec1af2556ec6aad94"},
- {file = "scipy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:631f07b3734d34aced009aaf6fedfd0eb3498a97e581c3b1e5f14a04164a456d"},
- {file = "scipy-1.14.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:af29a935803cc707ab2ed7791c44288a682f9c8107bc00f0eccc4f92c08d6e07"},
- {file = "scipy-1.14.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2843f2d527d9eebec9a43e6b406fb7266f3af25a751aa91d62ff416f54170bc5"},
- {file = "scipy-1.14.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:eb58ca0abd96911932f688528977858681a59d61a7ce908ffd355957f7025cfc"},
- {file = "scipy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30ac8812c1d2aab7131a79ba62933a2a76f582d5dbbc695192453dae67ad6310"},
- {file = "scipy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f9ea80f2e65bdaa0b7627fb00cbeb2daf163caa015e59b7516395fe3bd1e066"},
- {file = "scipy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:edaf02b82cd7639db00dbff629995ef185c8df4c3ffa71a5562a595765a06ce1"},
- {file = "scipy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2ff38e22128e6c03ff73b6bb0f85f897d2362f8c052e3b8ad00532198fbdae3f"},
- {file = "scipy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1729560c906963fc8389f6aac023739ff3983e727b1a4d87696b7bf108316a79"},
- {file = "scipy-1.14.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:4079b90df244709e675cdc8b93bfd8a395d59af40b72e339c2287c91860deb8e"},
- {file = "scipy-1.14.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e0cf28db0f24a38b2a0ca33a85a54852586e43cf6fd876365c86e0657cfe7d73"},
- {file = "scipy-1.14.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0c2f95de3b04e26f5f3ad5bb05e74ba7f68b837133a4492414b3afd79dfe540e"},
- {file = "scipy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b99722ea48b7ea25e8e015e8341ae74624f72e5f21fc2abd45f3a93266de4c5d"},
- {file = "scipy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5149e3fd2d686e42144a093b206aef01932a0059c2a33ddfa67f5f035bdfe13e"},
- {file = "scipy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4f5a7c49323533f9103d4dacf4e4f07078f360743dec7f7596949149efeec06"},
- {file = "scipy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:baff393942b550823bfce952bb62270ee17504d02a1801d7fd0719534dfb9c84"},
- {file = "scipy-1.14.1.tar.gz", hash = "sha256:5a275584e726026a5699459aa72f828a610821006228e841b94275c4a7c08417"},
-]
-
-[package.dependencies]
-numpy = ">=1.23.5,<2.3"
-
-[package.extras]
-dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodestyle", "pydevtool", "rich-click", "ruff (>=0.0.292)", "types-psutil", "typing_extensions"]
-doc = ["jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.13.1)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<=7.3.7)", "sphinx-design (>=0.4.0)"]
-test = ["Cython", "array-api-strict (>=2.0)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"]
-
-[[package]]
-name = "setuptools"
-version = "67.8.0"
-description = "Easily download, build, install, upgrade, and uninstall Python packages"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "setuptools-67.8.0-py3-none-any.whl", hash = "sha256:5df61bf30bb10c6f756eb19e7c9f3b473051f48db77fddbe06ff2ca307df9a6f"},
- {file = "setuptools-67.8.0.tar.gz", hash = "sha256:62642358adc77ffa87233bc4d2354c4b2682d214048f500964dbe760ccedf102"},
-]
-
-[package.extras]
-docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
-testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
-testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
-
-[[package]]
-name = "six"
-version = "1.16.0"
-description = "Python 2 and 3 compatibility utilities"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
-files = [
- {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
- {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
-]
-
-[[package]]
-name = "smmap"
-version = "5.0.1"
-description = "A pure Python implementation of a sliding window memory map manager"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"},
- {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"},
-]
-
-[[package]]
-name = "soupsieve"
-version = "2.6"
-description = "A modern CSS selector implementation for Beautiful Soup."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"},
- {file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"},
-]
-
-[[package]]
-name = "termcolor"
-version = "2.5.0"
-description = "ANSI color formatting for output in terminal"
-optional = false
-python-versions = ">=3.9"
-files = [
- {file = "termcolor-2.5.0-py3-none-any.whl", hash = "sha256:37b17b5fc1e604945c2642c872a3764b5d547a48009871aea3edd3afa180afb8"},
- {file = "termcolor-2.5.0.tar.gz", hash = "sha256:998d8d27da6d48442e8e1f016119076b690d962507531df4890fcd2db2ef8a6f"},
-]
-
-[package.extras]
-tests = ["pytest", "pytest-cov"]
-
-[[package]]
-name = "texttable"
-version = "1.7.0"
-description = "module to create simple ASCII tables"
-optional = false
-python-versions = "*"
-files = [
- {file = "texttable-1.7.0-py2.py3-none-any.whl", hash = "sha256:72227d592c82b3d7f672731ae73e4d1f88cd8e2ef5b075a7a7f01a23a3743917"},
- {file = "texttable-1.7.0.tar.gz", hash = "sha256:2d2068fb55115807d3ac77a4ca68fa48803e84ebb0ee2340f858107a36522638"},
-]
-
-[[package]]
-name = "tifffile"
-version = "2024.9.20"
-description = "Read and write TIFF files"
-optional = false
-python-versions = ">=3.10"
-files = [
- {file = "tifffile-2024.9.20-py3-none-any.whl", hash = "sha256:c54dc85bc1065d972cb8a6ffb3181389d597876aa80177933459733e4ed243dd"},
- {file = "tifffile-2024.9.20.tar.gz", hash = "sha256:3fbf3be2f995a7051a8ae05a4be70c96fc0789f22ed6f1c4104c973cf68a640b"},
-]
-
-[package.dependencies]
-numpy = "*"
-
-[package.extras]
-all = ["defusedxml", "fsspec", "imagecodecs (>=2023.8.12)", "lxml", "matplotlib", "zarr"]
-codecs = ["imagecodecs (>=2023.8.12)"]
-plot = ["matplotlib"]
-test = ["cmapfile", "czifile", "dask", "defusedxml", "fsspec", "imagecodecs", "lfdfiles", "lxml", "ndtiff", "oiffile", "psdtags", "pytest", "roifile", "xarray", "zarr"]
-xml = ["defusedxml", "lxml"]
-zarr = ["fsspec", "zarr"]
-
-[[package]]
-name = "tomli"
-version = "2.0.2"
-description = "A lil' TOML parser"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"},
- {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"},
-]
-
-[[package]]
-name = "tomlkit"
-version = "0.13.2"
-description = "Style preserving TOML library"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"},
- {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"},
-]
-
-[[package]]
-name = "tqdm"
-version = "4.66.5"
-description = "Fast, Extensible Progress Meter"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "tqdm-4.66.5-py3-none-any.whl", hash = "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd"},
- {file = "tqdm-4.66.5.tar.gz", hash = "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad"},
-]
-
-[package.dependencies]
-colorama = {version = "*", markers = "platform_system == \"Windows\""}
-
-[package.extras]
-dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"]
-notebook = ["ipywidgets (>=6)"]
-slack = ["slack-sdk"]
-telegram = ["requests"]
-
-[[package]]
-name = "typing-extensions"
-version = "4.12.2"
-description = "Backported and Experimental Type Hints for Python 3.8+"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
- {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
-]
-
-[[package]]
-name = "urllib3"
-version = "2.2.3"
-description = "HTTP library with thread-safe connection pooling, file post, and more."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"},
- {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"},
-]
-
-[package.extras]
-brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
-h2 = ["h2 (>=4,<5)"]
-socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
-zstd = ["zstandard (>=0.18.0)"]
-
-[[package]]
-name = "virtualenv"
-version = "20.27.0"
-description = "Virtual Python Environment builder"
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "virtualenv-20.27.0-py3-none-any.whl", hash = "sha256:44a72c29cceb0ee08f300b314848c86e57bf8d1f13107a5e671fb9274138d655"},
- {file = "virtualenv-20.27.0.tar.gz", hash = "sha256:2ca56a68ed615b8fe4326d11a0dca5dfbe8fd68510fb6c6349163bed3c15f2b2"},
-]
-
-[package.dependencies]
-distlib = ">=0.3.7,<1"
-filelock = ">=3.12.2,<4"
-platformdirs = ">=3.9.1,<5"
-
-[package.extras]
-docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
-test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
-
-[[package]]
-name = "watchdog"
-version = "5.0.3"
-description = "Filesystem events monitoring"
-optional = false
-python-versions = ">=3.9"
-files = [
- {file = "watchdog-5.0.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:85527b882f3facda0579bce9d743ff7f10c3e1e0db0a0d0e28170a7d0e5ce2ea"},
- {file = "watchdog-5.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:53adf73dcdc0ef04f7735066b4a57a4cd3e49ef135daae41d77395f0b5b692cb"},
- {file = "watchdog-5.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e25adddab85f674acac303cf1f5835951345a56c5f7f582987d266679979c75b"},
- {file = "watchdog-5.0.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f01f4a3565a387080dc49bdd1fefe4ecc77f894991b88ef927edbfa45eb10818"},
- {file = "watchdog-5.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91b522adc25614cdeaf91f7897800b82c13b4b8ac68a42ca959f992f6990c490"},
- {file = "watchdog-5.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d52db5beb5e476e6853da2e2d24dbbbed6797b449c8bf7ea118a4ee0d2c9040e"},
- {file = "watchdog-5.0.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d11b07c64f63f49876e0ab8042ae034674c8653bfcdaa8c4b32e71cfff87e8"},
- {file = "watchdog-5.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:349c9488e1d85d0a58e8cb14222d2c51cbc801ce11ac3936ab4c3af986536926"},
- {file = "watchdog-5.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:53a3f10b62c2d569e260f96e8d966463dec1a50fa4f1b22aec69e3f91025060e"},
- {file = "watchdog-5.0.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:950f531ec6e03696a2414b6308f5c6ff9dab7821a768c9d5788b1314e9a46ca7"},
- {file = "watchdog-5.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae6deb336cba5d71476caa029ceb6e88047fc1dc74b62b7c4012639c0b563906"},
- {file = "watchdog-5.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1021223c08ba8d2d38d71ec1704496471ffd7be42cfb26b87cd5059323a389a1"},
- {file = "watchdog-5.0.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:752fb40efc7cc8d88ebc332b8f4bcbe2b5cc7e881bccfeb8e25054c00c994ee3"},
- {file = "watchdog-5.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a2e8f3f955d68471fa37b0e3add18500790d129cc7efe89971b8a4cc6fdeb0b2"},
- {file = "watchdog-5.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b8ca4d854adcf480bdfd80f46fdd6fb49f91dd020ae11c89b3a79e19454ec627"},
- {file = "watchdog-5.0.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:90a67d7857adb1d985aca232cc9905dd5bc4803ed85cfcdcfcf707e52049eda7"},
- {file = "watchdog-5.0.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:720ef9d3a4f9ca575a780af283c8fd3a0674b307651c1976714745090da5a9e8"},
- {file = "watchdog-5.0.3-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:223160bb359281bb8e31c8f1068bf71a6b16a8ad3d9524ca6f523ac666bb6a1e"},
- {file = "watchdog-5.0.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:560135542c91eaa74247a2e8430cf83c4342b29e8ad4f520ae14f0c8a19cfb5b"},
- {file = "watchdog-5.0.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:dd021efa85970bd4824acacbb922066159d0f9e546389a4743d56919b6758b91"},
- {file = "watchdog-5.0.3-py3-none-manylinux2014_armv7l.whl", hash = "sha256:78864cc8f23dbee55be34cc1494632a7ba30263951b5b2e8fc8286b95845f82c"},
- {file = "watchdog-5.0.3-py3-none-manylinux2014_i686.whl", hash = "sha256:1e9679245e3ea6498494b3028b90c7b25dbb2abe65c7d07423ecfc2d6218ff7c"},
- {file = "watchdog-5.0.3-py3-none-manylinux2014_ppc64.whl", hash = "sha256:9413384f26b5d050b6978e6fcd0c1e7f0539be7a4f1a885061473c5deaa57221"},
- {file = "watchdog-5.0.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:294b7a598974b8e2c6123d19ef15de9abcd282b0fbbdbc4d23dfa812959a9e05"},
- {file = "watchdog-5.0.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:26dd201857d702bdf9d78c273cafcab5871dd29343748524695cecffa44a8d97"},
- {file = "watchdog-5.0.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:0f9332243355643d567697c3e3fa07330a1d1abf981611654a1f2bf2175612b7"},
- {file = "watchdog-5.0.3-py3-none-win32.whl", hash = "sha256:c66f80ee5b602a9c7ab66e3c9f36026590a0902db3aea414d59a2f55188c1f49"},
- {file = "watchdog-5.0.3-py3-none-win_amd64.whl", hash = "sha256:f00b4cf737f568be9665563347a910f8bdc76f88c2970121c86243c8cfdf90e9"},
- {file = "watchdog-5.0.3-py3-none-win_ia64.whl", hash = "sha256:49f4d36cb315c25ea0d946e018c01bb028048023b9e103d3d3943f58e109dd45"},
- {file = "watchdog-5.0.3.tar.gz", hash = "sha256:108f42a7f0345042a854d4d0ad0834b741d421330d5f575b81cb27b883500176"},
-]
-
-[package.extras]
-watchmedo = ["PyYAML (>=3.10)"]
-
-[[package]]
-name = "wcmatch"
-version = "10.0"
-description = "Wildcard/glob file name matcher."
-optional = false
-python-versions = ">=3.8"
-files = [
- {file = "wcmatch-10.0-py3-none-any.whl", hash = "sha256:0dd927072d03c0a6527a20d2e6ad5ba8d0380e60870c383bc533b71744df7b7a"},
- {file = "wcmatch-10.0.tar.gz", hash = "sha256:e72f0de09bba6a04e0de70937b0cf06e55f36f37b3deb422dfaf854b867b840a"},
-]
-
-[package.dependencies]
-bracex = ">=2.1.1"
-
-[[package]]
-name = "wcwidth"
-version = "0.2.13"
-description = "Measures the displayed width of unicode strings in a terminal"
-optional = false
-python-versions = "*"
-files = [
- {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"},
- {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"},
-]
-
-[[package]]
-name = "wheel"
-version = "0.42.0"
-description = "A built-package format for Python"
-optional = false
-python-versions = ">=3.7"
-files = [
- {file = "wheel-0.42.0-py3-none-any.whl", hash = "sha256:177f9c9b0d45c47873b619f5b650346d632cdc35fb5e4d25058e09c9e581433d"},
- {file = "wheel-0.42.0.tar.gz", hash = "sha256:c45be39f7882c9d34243236f2d63cbd58039e360f85d0913425fbd7ceea617a8"},
-]
-
-[package.extras]
-test = ["pytest (>=6.0.0)", "setuptools (>=65)"]
-
-[[package]]
-name = "win32-setctime"
-version = "1.1.0"
-description = "A small Python utility to set file creation time on Windows"
-optional = false
-python-versions = ">=3.5"
-files = [
- {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
- {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"},
-]
-
-[package.extras]
-dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
-
-[[package]]
-name = "yarl"
-version = "1.16.0"
-description = "Yet another URL library"
-optional = false
-python-versions = ">=3.9"
-files = [
- {file = "yarl-1.16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32468f41242d72b87ab793a86d92f885355bcf35b3355aa650bfa846a5c60058"},
- {file = "yarl-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:234f3a3032b505b90e65b5bc6652c2329ea7ea8855d8de61e1642b74b4ee65d2"},
- {file = "yarl-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a0296040e5cddf074c7f5af4a60f3fc42c0237440df7bcf5183be5f6c802ed5"},
- {file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de6c14dd7c7c0badba48157474ea1f03ebee991530ba742d381b28d4f314d6f3"},
- {file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b140e532fe0266003c936d017c1ac301e72ee4a3fd51784574c05f53718a55d8"},
- {file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:019f5d58093402aa8f6661e60fd82a28746ad6d156f6c5336a70a39bd7b162b9"},
- {file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c42998fd1cbeb53cd985bff0e4bc25fbe55fd6eb3a545a724c1012d69d5ec84"},
- {file = "yarl-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c7c30fb38c300fe8140df30a046a01769105e4cf4282567a29b5cdb635b66c4"},
- {file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e49e0fd86c295e743fd5be69b8b0712f70a686bc79a16e5268386c2defacaade"},
- {file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b9ca7b9147eb1365c8bab03c003baa1300599575effad765e0b07dd3501ea9af"},
- {file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:27e11db3f1e6a51081a981509f75617b09810529de508a181319193d320bc5c7"},
- {file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8994c42f4ca25df5380ddf59f315c518c81df6a68fed5bb0c159c6cb6b92f120"},
- {file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:542fa8e09a581bcdcbb30607c7224beff3fdfb598c798ccd28a8184ffc18b7eb"},
- {file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2bd6a51010c7284d191b79d3b56e51a87d8e1c03b0902362945f15c3d50ed46b"},
- {file = "yarl-1.16.0-cp310-cp310-win32.whl", hash = "sha256:178ccb856e265174a79f59721031060f885aca428983e75c06f78aa24b91d929"},
- {file = "yarl-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe8bba2545427418efc1929c5c42852bdb4143eb8d0a46b09de88d1fe99258e7"},
- {file = "yarl-1.16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d8643975a0080f361639787415a038bfc32d29208a4bf6b783ab3075a20b1ef3"},
- {file = "yarl-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:676d96bafc8c2d0039cea0cd3fd44cee7aa88b8185551a2bb93354668e8315c2"},
- {file = "yarl-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9525f03269e64310416dbe6c68d3b23e5d34aaa8f47193a1c45ac568cecbc49"},
- {file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b37d5ec034e668b22cf0ce1074d6c21fd2a08b90d11b1b73139b750a8b0dd97"},
- {file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f32c4cb7386b41936894685f6e093c8dfaf0960124d91fe0ec29fe439e201d0"},
- {file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b8e265a0545637492a7e12fd7038370d66c9375a61d88c5567d0e044ded9202"},
- {file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:789a3423f28a5fff46fbd04e339863c169ece97c827b44de16e1a7a42bc915d2"},
- {file = "yarl-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1d1f45e3e8d37c804dca99ab3cf4ab3ed2e7a62cd82542924b14c0a4f46d243"},
- {file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:621280719c4c5dad4c1391160a9b88925bb8b0ff6a7d5af3224643024871675f"},
- {file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ed097b26f18a1f5ff05f661dc36528c5f6735ba4ce8c9645e83b064665131349"},
- {file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2f1fe2b2e3ee418862f5ebc0c0083c97f6f6625781382f828f6d4e9b614eba9b"},
- {file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:87dd10bc0618991c66cee0cc65fa74a45f4ecb13bceec3c62d78ad2e42b27a16"},
- {file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4199db024b58a8abb2cfcedac7b1292c3ad421684571aeb622a02f242280e8d6"},
- {file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:99a9dcd4b71dd5f5f949737ab3f356cfc058c709b4f49833aeffedc2652dac56"},
- {file = "yarl-1.16.0-cp311-cp311-win32.whl", hash = "sha256:a9394c65ae0ed95679717d391c862dece9afacd8fa311683fc8b4362ce8a410c"},
- {file = "yarl-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5b9101f528ae0f8f65ac9d64dda2bb0627de8a50344b2f582779f32fda747c1d"},
- {file = "yarl-1.16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4ffb7c129707dd76ced0a4a4128ff452cecf0b0e929f2668ea05a371d9e5c104"},
- {file = "yarl-1.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1a5e9d8ce1185723419c487758d81ac2bde693711947032cce600ca7c9cda7d6"},
- {file = "yarl-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d743e3118b2640cef7768ea955378c3536482d95550222f908f392167fe62059"},
- {file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26768342f256e6e3c37533bf9433f5f15f3e59e3c14b2409098291b3efaceacb"},
- {file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1b0796168b953bca6600c5f97f5ed407479889a36ad7d17183366260f29a6b9"},
- {file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:858728086914f3a407aa7979cab743bbda1fe2bdf39ffcd991469a370dd7414d"},
- {file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5570e6d47bcb03215baf4c9ad7bf7c013e56285d9d35013541f9ac2b372593e7"},
- {file = "yarl-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66ea8311422a7ba1fc79b4c42c2baa10566469fe5a78500d4e7754d6e6db8724"},
- {file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:649bddcedee692ee8a9b7b6e38582cb4062dc4253de9711568e5620d8707c2a3"},
- {file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3a91654adb7643cb21b46f04244c5a315a440dcad63213033826549fa2435f71"},
- {file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b439cae82034ade094526a8f692b9a2b5ee936452de5e4c5f0f6c48df23f8604"},
- {file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:571f781ae8ac463ce30bacebfaef2c6581543776d5970b2372fbe31d7bf31a07"},
- {file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:aa7943f04f36d6cafc0cf53ea89824ac2c37acbdb4b316a654176ab8ffd0f968"},
- {file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1a5cf32539373ff39d97723e39a9283a7277cbf1224f7aef0c56c9598b6486c3"},
- {file = "yarl-1.16.0-cp312-cp312-win32.whl", hash = "sha256:a5b6c09b9b4253d6a208b0f4a2f9206e511ec68dce9198e0fbec4f160137aa67"},
- {file = "yarl-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1208ca14eed2fda324042adf8d6c0adf4a31522fa95e0929027cd487875f0240"},
- {file = "yarl-1.16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5ace0177520bd4caa99295a9b6fb831d0e9a57d8e0501a22ffaa61b4c024283"},
- {file = "yarl-1.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7118bdb5e3ed81acaa2095cba7ec02a0fe74b52a16ab9f9ac8e28e53ee299732"},
- {file = "yarl-1.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38fec8a2a94c58bd47c9a50a45d321ab2285ad133adefbbadf3012c054b7e656"},
- {file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8791d66d81ee45866a7bb15a517b01a2bcf583a18ebf5d72a84e6064c417e64b"},
- {file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cf936ba67bc6c734f3aa1c01391da74ab7fc046a9f8bbfa230b8393b90cf472"},
- {file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1aab176dd55b59f77a63b27cffaca67d29987d91a5b615cbead41331e6b7428"},
- {file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:995d0759004c08abd5d1b81300a91d18c8577c6389300bed1c7c11675105a44d"},
- {file = "yarl-1.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bc22e00edeb068f71967ab99081e9406cd56dbed864fc3a8259442999d71552"},
- {file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35b4f7842154176523e0a63c9b871168c69b98065d05a4f637fce342a6a2693a"},
- {file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7ace71c4b7a0c41f317ae24be62bb61e9d80838d38acb20e70697c625e71f120"},
- {file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8f639e3f5795a6568aa4f7d2ac6057c757dcd187593679f035adbf12b892bb00"},
- {file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e8be3aff14f0120ad049121322b107f8a759be76a6a62138322d4c8a337a9e2c"},
- {file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:122d8e7986043d0549e9eb23c7fd23be078be4b70c9eb42a20052b3d3149c6f2"},
- {file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0fd9c227990f609c165f56b46107d0bc34553fe0387818c42c02f77974402c36"},
- {file = "yarl-1.16.0-cp313-cp313-win32.whl", hash = "sha256:595ca5e943baed31d56b33b34736461a371c6ea0038d3baec399949dd628560b"},
- {file = "yarl-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:921b81b8d78f0e60242fb3db615ea3f368827a76af095d5a69f1c3366db3f596"},
- {file = "yarl-1.16.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab2b2ac232110a1fdb0d3ffcd087783edd3d4a6ced432a1bf75caf7b7be70916"},
- {file = "yarl-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7f8713717a09acbfee7c47bfc5777e685539fefdd34fa72faf504c8be2f3df4e"},
- {file = "yarl-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdcffe1dbcb4477d2b4202f63cd972d5baa155ff5a3d9e35801c46a415b7f71a"},
- {file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a91217208306d82357c67daeef5162a41a28c8352dab7e16daa82e3718852a7"},
- {file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ab3ed42c78275477ea8e917491365e9a9b69bb615cb46169020bd0aa5e2d6d3"},
- {file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:707ae579ccb3262dfaef093e202b4c3fb23c3810e8df544b1111bd2401fd7b09"},
- {file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7a852d1cd0b8d8b37fc9d7f8581152add917a98cfe2ea6e241878795f917ae"},
- {file = "yarl-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3f1cc3d3d4dc574bebc9b387f6875e228ace5748a7c24f49d8f01ac1bc6c31b"},
- {file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5ff96da263740779b0893d02b718293cc03400c3a208fc8d8cd79d9b0993e532"},
- {file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:3d375a19ba2bfe320b6d873f3fb165313b002cef8b7cc0a368ad8b8a57453837"},
- {file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:62c7da0ad93a07da048b500514ca47b759459ec41924143e2ddb5d7e20fd3db5"},
- {file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:147b0fcd0ee33b4b5f6edfea80452d80e419e51b9a3f7a96ce98eaee145c1581"},
- {file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:504e1fe1cc4f170195320eb033d2b0ccf5c6114ce5bf2f617535c01699479bca"},
- {file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bdcf667a5dec12a48f669e485d70c54189f0639c2157b538a4cffd24a853624f"},
- {file = "yarl-1.16.0-cp39-cp39-win32.whl", hash = "sha256:e9951afe6557c75a71045148890052cb942689ee4c9ec29f5436240e1fcc73b7"},
- {file = "yarl-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:7d7aaa8ff95d0840e289423e7dc35696c2b058d635f945bf05b5cd633146b027"},
- {file = "yarl-1.16.0-py3-none-any.whl", hash = "sha256:e6980a558d8461230c457218bd6c92dfc1d10205548215c2c21d79dc8d0a96f3"},
- {file = "yarl-1.16.0.tar.gz", hash = "sha256:b6f687ced5510a9a2474bbae96a4352e5ace5fa34dc44a217b0537fec1db00b4"},
-]
-
-[package.dependencies]
-idna = ">=2.0"
-multidict = ">=4.0"
-propcache = ">=0.2.0"
-
-[metadata]
-lock-version = "2.0"
-python-versions = ">=3.10,<3.13"
-content-hash = "5744170dc66674bba70eed965a8bae60ecc3f9e9fb4a36a18d755c097667284c"
diff --git a/pyproject.toml b/pyproject.toml
index 1c99008f..df5ae29e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,13 +1,69 @@
-[tool.poetry]
+[project]
name = "proxyshop"
version = "1.13.2"
description = "Photoshop automation tool for generating high quality Magic the Gathering card renders."
-authors = ["Investigamer "]
+authors = [{ name = "Investigamer", email = "freethoughtleft@gmail.com" }]
license = "MPL-2.0"
readme = "README.md"
-keywords = ["proxyshop", "proxy", "mtg", "magic", "gathering", "cards", "photoshop", "magic the gathering", "playtest"]
+keywords = [
+ "proxyshop",
+ "proxy",
+ "mtg",
+ "magic",
+ "gathering",
+ "cards",
+ "photoshop",
+ "magic the gathering",
+ "playtest",
+]
+requires-python = ">=3.14,<3.15"
+dependencies = [
+ "photoshop-python-api @ git+https://github.com/pappnu/photoshop-python-api.git@type-annotation",
+ "requests>=2.33.1,<3.0.0",
+ "Pillow>=12.2.0,<13.0.0",
+ "limits>=5.8.0,<6.0.0",
+ "backoff>=2.2.1,<3.0.0",
+ "pathvalidate>=3.3.1,<4.0.0",
+ "fonttools>=4.62.1,<5.0.0",
+ "pyyaml>=6.0.3,<7.0.0",
+ "yarl>=1.23.0,<2.0.0",
+ "pydantic>=2.13.3,<3.0.0",
+ "pydantic-settings>=2.14.0,<3.0.0",
+ "omnitils @ git+https://github.com/pappnu/omnitils.git@dev",
+ "hexproof @ git+https://github.com/pappnu/hexproof.git@dev",
+ "pywin32>=311.0.0,<312.0.0",
+ "pyside6>=6.11.0,<7.0.0",
+]
-[tool.poetry.urls]
+[dependency-groups]
+dev = [
+ "pytest>=9.0.3,<10.0.0",
+ "commitizen>=4.13.10,<5.0.0",
+ "psd-tools>=1.16.0,<2.0.0",
+ "types-pywin32>=311.0.0.20260402",
+ "types-requests>=2.33.0.20260402",
+]
+build = [
+ "setuptools>=82.0.1,<83.0.0",
+ "pyclean>=3.6.0,<4.0.0",
+ "pyinstaller>=6.20.0,<7.0.0",
+]
+docs = [
+ "mkdocs>=1.6.1,<2.0.0",
+ "mkdocs-material>=9.7.6,<10.0.0",
+ "mkdocs-include-markdown-plugin>=7.2.2,<8.0.0",
+ "mkdocs-pymdownx-material-extras>=2.8.0,<3.0.0",
+ "mkdocs-minify-plugin>=0.8.0,<1.0.0",
+ "mkdocstrings-python>=2.0.3,<3.0.0",
+ "mkdocs-gen-files>=0.6.1,<1.0.0",
+ "mkdocs-autolinks-plugin>=0.7.1,<1.0.0",
+ "mkdocs-same-dir>=0.1.5,<1.0.0",
+ "mkdocs-git-revision-date-plugin>=0.3.2,<1.0.0",
+ "mkdocstrings[python]>=1.0.4,<2.0.0",
+]
+tests-extra = ["matplotlib>=3.10.9,<4.0.0", "memory-profiler>=0.61.0,<1.0.0"]
+
+[project.urls]
Changelog = "https://github.com/Investigamer/Proxyshop/blob/main/CHANGELOG.md"
Discord = "https://discord.gg/magicproxies"
Documentation = "https://investigamer.github.io/Proxyshop"
@@ -15,68 +71,26 @@ Issues = "https://github.com/Investigamer/Proxyshop/issues"
Source = "https://github.com/Investigamer/Proxyshop"
Sponsor = "https://patreon.com/mpcfill"
-[[tool.poetry.packages]]
-include = 'src/../'
-
-[tool.poetry.dependencies]
-python = ">=3.10,<3.13"
-photoshop-python-api = "^0.22.4"
-requests = "^2.28.1"
-asynckivy = "^0.7.0"
-Pillow = "^10.3.0"
-kivy = "^2.3.0"
-typing-extensions = "^4.5.0"
-ratelimit = "^2.2.1"
-backoff = "^2.2.1"
-pathvalidate = "^3.2.0"
-fonttools = "^4.39.3"
-pyyaml = "^6.0"
-tqdm = "^4.65.0"
-click = "^8.1.7"
-tomli = "^2.0.1"
-yarl = "^1.9.3"
-pydantic = "^2.5.2"
-omnitils = "^1.4.4"
-dynaconf = {extras = ["yaml"], version = "^3.2.6"}
-hexproof = "^0.3.7"
-rich = "^13.8.1"
-
-[tool.poetry.group.dev.dependencies]
-pytest = "^7.2.0"
-mypy = "^1.6.0"
-commitizen = "^3.12.0"
-setuptools = "^67.6.0"
-matplotlib = "^3.7.2"
-psd-tools = "^1.9.28"
-pyclean = "^2.2.0"
-pyinstaller = "^5.12.0"
-pre-commit = "^3.5.0"
-mkdocs = "^1.5.3"
-mkdocs-material = "^9.4.8"
-mkdocs-include-markdown-plugin = "^6.0.4"
-mkdocs-pymdownx-material-extras = "^2.5.5"
-mkdocs-minify-plugin = "^0.7.1"
-mkdocstrings-python = "^1.7.3"
-mkdocs-gen-files = "^0.5.0"
-mkdocs-autolinks-plugin = "^0.7.1"
-mkdocs-same-dir = "^0.1.2"
-mkdocs-git-revision-date-plugin = "^0.3.2"
-mkdocstrings = {extras = ["python"], version = "^0.23.0"}
-memory-profiler = "^0.61.0"
-
-[build-system]
-requires = ["poetry-core"]
-build-backend = "poetry.core.masonry.api"
-
[tool.commitizen]
-version = "1.13.2"
+version_provider = "pep621"
encoding = "utf-8"
changelog_start_rev = 'v1.2.0'
tag_format = "v$major.$minor.$patch"
update_changelog_on_bump = true
-version_files = [
- "pyproject.toml:version"
+
+[tool.ruff.lint]
+extend-select = [
+ # isort
+ "I",
+ # pyupgrade
+ "UP",
]
+ignore = ["F403", "UP015"]
+
+[tool.ruff.lint.isort]
+split-on-trailing-comma = false
-[tool.poetry.scripts]
-proxyshop = 'main:launch_cli'
+[tool.pyright]
+typeCheckingMode = "strict"
+reportMissingTypeStubs = "information"
+reportUnnecessaryTypeIgnoreComment = "warning"
diff --git a/scripts/__init__.py b/scripts/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/scripts/build.py b/scripts/build.py
new file mode 100644
index 00000000..37cac113
--- /dev/null
+++ b/scripts/build.py
@@ -0,0 +1,61 @@
+from typing import Annotated
+
+from pydantic import BaseModel, Field
+from pydantic_settings import CliApp, CliSubCommand
+
+from scripts.utils.build import (
+ build_release,
+ generate_mkdocs,
+ generate_nav,
+ update_mkdocs_yml,
+)
+
+
+class BuildApp(BaseModel):
+ """Build executable app release and distributable zip."""
+
+ version: Annotated[
+ str | None,
+ Field(
+ description="Version number to build with, if not provided use latest.",
+ ),
+ ] = None
+ beta: Annotated[
+ bool, Field(alias="B", description="Build app as a Beta release.")
+ ] = False
+ console: Annotated[
+ bool, Field(alias="C", description="Build app with console enabled.")
+ ] = False
+ raw: Annotated[
+ bool,
+ Field(
+ alias="R",
+ description="Build app without creating zip release archive.",
+ ),
+ ] = False
+
+ def cli_cmd(self) -> None:
+ build_release(
+ version=self.version,
+ beta=self.beta,
+ console=self.console,
+ zipped=not self.raw,
+ )
+
+
+class BuildDocs(BaseModel):
+ def cli_cmd(self) -> None:
+ headers = ["Template Classes", "Photoshop Helpers", "App Utilities"]
+ paths = ["templates", "helpers", "utils"]
+ [generate_mkdocs(p) for p in paths]
+ nav = generate_nav(headers, paths)
+ update_mkdocs_yml(nav)
+
+
+class BuildCli(CliApp):
+ app: CliSubCommand[BuildApp]
+ docs: CliSubCommand[BuildDocs]
+
+
+if __name__ == "__main__":
+ CliApp.run(BuildCli)
diff --git a/scripts/compress.py b/scripts/compress.py
new file mode 100644
index 00000000..4cc68466
--- /dev/null
+++ b/scripts/compress.py
@@ -0,0 +1,87 @@
+from pathlib import Path
+from typing import Annotated
+
+from omnitils.files.archive import compress_7z, compress_7z_all
+from pydantic import BaseModel, Field
+from pydantic_settings import CliApp, CliSubCommand
+
+from src._state import PATH
+
+
+class CompressTemplate(BaseModel):
+ """Compress a Photoshop template file (PSD/PSB)."""
+
+ template: Annotated[
+ str,
+ Field(description="Filename of the template, e.g. `normal.psd`"),
+ ]
+ plugin: Annotated[
+ str | None,
+ Field(
+ description="Name of the plugin containing the template if required, e.g. MrTeferi"
+ ),
+ ] = None
+
+ def cli_cmd(self) -> None:
+ path = (
+ Path(PATH.PLUGINS, self.plugin, "templates")
+ if self.plugin
+ else PATH.TEMPLATES
+ )
+ path = path / self.template
+ if not path.is_file():
+ print(
+ f"Couldn't find a template named '{self.template}' at path:\n{str(path)}"
+ )
+ return
+ compress_7z(path)
+
+
+class CompressPlugin(BaseModel):
+ """Compress all Photoshop template files (PSD/PSB) in a given plugin."""
+
+ plugin: Annotated[
+ str,
+ Field(description="Name of the plugin, e.g. MrTeferi"),
+ ]
+
+ def cli_cmd(self) -> None:
+ path = PATH.PLUGINS / self.plugin / "templates"
+ if not path.is_dir():
+ print(f"Couldn't find a plugin named '{self.plugin}'")
+ return
+ compress_7z_all(path)
+
+
+class CompressAll(BaseModel):
+ """Compress all Photoshop template files (PSD/PSB) in the entire app, plugins optional."""
+
+ plugins: Annotated[
+ bool,
+ Field(
+ alias="P",
+ description="Compress built-in plugins as well.",
+ ),
+ ] = False
+
+ def cli_cmd(self) -> None:
+ # Compress main templates folder
+ compress_7z_all(PATH.TEMPLATES)
+
+ # Compress plugins if requested
+ if self.plugins:
+ plugin_paths = [
+ Path(PATH.PLUGINS, p, "templates")
+ for p in ["Investigamer", "SilvanMTG"]
+ ]
+ [compress_7z_all(p) for p in plugin_paths]
+
+
+class CompressCli(BaseModel):
+ template: CliSubCommand[CompressTemplate]
+ plugin: CliSubCommand[CompressPlugin]
+ all: CliSubCommand[CompressAll]
+
+
+if __name__ == "__main__":
+ CliApp.run(CompressCli)
diff --git a/src/data/build/dist.yml b/scripts/data/dist.yml
similarity index 100%
rename from src/data/build/dist.yml
rename to scripts/data/dist.yml
diff --git a/scripts/utils/__init__.py b/scripts/utils/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/utils/build.py b/scripts/utils/build.py
similarity index 58%
rename from src/utils/build.py
rename to scripts/utils/build.py
index f14ae1a4..4677c5b6 100644
--- a/src/utils/build.py
+++ b/scripts/utils/build.py
@@ -1,32 +1,28 @@
"""
* Utils: Building Releases and Docs
"""
-# Standard Library
-import os
+
import ast
+import os
import zipfile
from contextlib import suppress
from glob import glob
from pathlib import Path
-from typing import Optional, Union, TypedDict, NotRequired
-from shutil import (
- copy2,
- copytree,
- rmtree,
- move
-)
+from shutil import copy2, copytree, move, rmtree
+from subprocess import run
+from typing import Any, NotRequired, TypedDict
-# Third party imports
-import PyInstaller.__main__
-from omnitils.files import get_project_version, load_data_file, dump_data_file
+from pydantic import BaseModel, RootModel
+from PyInstaller.__main__ import run as run_pyinstaller
+from yaml import dump, safe_load
-# Local Imports
-from src import PATH
+from src._state import PATH
+from src.utils.data_structures import parse_model
# Directory definitions
SRC: Path = PATH.CWD
-DST: Path = SRC / 'dist'
-DIST_CONFIG: Path = PATH.SRC_DATA / 'build' / 'dist.yml'
+DST: Path = SRC / "dist"
+DIST_CONFIG: Path = PATH.SRC_DATA / "build" / "dist.yml"
"""
@@ -34,24 +30,36 @@
"""
+class ProjectModel(BaseModel):
+ version: str
+
+
+class PyProjectModel(BaseModel):
+ project: ProjectModel
+
+
class DistConfigNames(TypedDict):
"""Maps the recognized names table in 'dist.yml' configuration."""
+
zip: str
class DistConfigSpec(TypedDict):
"""Maps the named spec file paths table in 'dist.yml' configuration."""
+
release: list[str]
console: list[str]
class DistConfigMakeDir(TypedDict):
"""Maps the generated dirs table in 'dist.yml' configuration."""
+
paths: list[list[str]]
class DistConfigCopyDir(TypedDict):
"""Maps the settings for a dir which need to be copied to the release dir."""
+
paths: list[list[str]]
files: NotRequired[list[list[str]]]
exclude_ext: NotRequired[list[str]]
@@ -62,12 +70,15 @@ class DistConfigCopyDir(TypedDict):
class DistConfig(TypedDict):
"""Maps the 'dist.yml' configuration which governs build behavior."""
+
names: DistConfigNames
spec: DistConfigSpec
make: DistConfigMakeDir
copy: dict[str, DistConfigCopyDir]
+DistConfigModel = RootModel[DistConfig]
+
"""
* Handling Build Files
"""
@@ -79,7 +90,7 @@ def generate_version_file(version: str):
Args:
version: Version string to use in the version file.
"""
- with open(Path(SRC, '__VERSION__.py'), 'w') as f:
+ with open(Path(SRC, "__VERSION__.py"), "w") as f:
f.write(f"version='{version}'")
@@ -90,17 +101,17 @@ def make_directories(config: DistConfig) -> None:
config: Config data from 'dist.yml'.
"""
DST.mkdir(mode=777, parents=True, exist_ok=True)
- for path in config['make']['paths']:
+ for path in config["make"]["paths"]:
Path(DST, *path).mkdir(mode=777, parents=True, exist_ok=True)
def copy_directory(
- src: Union[str, os.PathLike],
- dst: Union[str, os.PathLike],
+ src: str | os.PathLike[str],
+ dst: str | os.PathLike[str],
x_files: list[str],
x_dirs: list[str],
- x_ext: Optional[list[str]] = None,
- recursive: bool = True
+ x_ext: list[str] | None = None,
+ recursive: bool = True,
) -> None:
"""Copy a directory from src to dst.
@@ -145,21 +156,20 @@ def copy_app_files(config: DistConfig) -> None:
Args:
config: Config data from 'dist.yml'.
"""
- for _, DIR in config.get('copy', {}).items():
+ for _, DIR in config.get("copy", {}).items():
# Copy directories
- for path in DIR.get('paths', []):
+ for path in DIR.get("paths", []):
copy_directory(
src=Path(SRC, *path),
dst=Path(DST, *path),
- x_files=DIR.get('exclude_files', []),
- x_dirs=DIR.get('exclude_dirs', []),
- x_ext=DIR.get('exclude_ext', []),
- recursive=bool(DIR.get('recursive', True)))
+ x_files=DIR.get("exclude_files", []),
+ x_dirs=DIR.get("exclude_dirs", []),
+ x_ext=DIR.get("exclude_ext", []),
+ recursive=bool(DIR.get("recursive", True)),
+ )
# Copy files
- for file in DIR.get('files', []):
- copy2(
- src=Path(SRC, *file),
- dst=Path(DST, *file))
+ for file in DIR.get("files", []):
+ copy2(src=Path(SRC, *file), dst=Path(DST, *file))
"""
@@ -174,18 +184,18 @@ def clear_build_files(clear_dist: bool = True) -> None:
clear_dist: Remove previous dist directory if True, otherwise skip.
"""
# Run pyclean on main directory and venv
- os.system("pyclean -v .")
- if os.path.exists(os.path.join(SRC, '.venv')):
- os.system("pyclean -v .venv")
+ run(("pyclean", "-v", "."), check=True)
+ if (SRC / ".venv").is_dir():
+ run(("pyclean", "-v", ".venv"))
# Remove build directory
with suppress(Exception):
- rmtree(os.path.join(SRC, 'build'))
+ rmtree(os.path.join(SRC, "build"))
# Optionally remove dist directory
if clear_dist:
with suppress(Exception):
- rmtree(os.path.join(SRC, 'dist'))
+ rmtree(os.path.join(SRC, "dist"))
"""
@@ -202,16 +212,15 @@ def build_zip(filename: str) -> None:
ZIP_SRC = os.path.join(SRC, filename)
with zipfile.ZipFile(ZIP_SRC, "w", zipfile.ZIP_DEFLATED) as zipf:
for fp in glob(os.path.join(DST, "**/*"), recursive=True):
- zipf.write(fp, arcname=fp.replace(
- os.path.commonpath([DST, fp]), ""))
+ zipf.write(fp, arcname=fp.replace(os.path.commonpath([DST, fp]), ""))
move(ZIP_SRC, os.path.join(DST, filename))
def build_release(
- version: Optional[str] = None,
+ version: str | None = None,
console: bool = False,
beta: bool = False,
- zipped: bool = True
+ zipped: bool = True,
) -> None:
"""Build the app to executable release.
@@ -222,19 +231,37 @@ def build_release(
zipped: Whether to create a zip of this release.
"""
# Load dist config
- dist_config: DistConfig = load_data_file(DIST_CONFIG)
+ dist_config = parse_model(DIST_CONFIG, DistConfigModel).root
# Pre-build steps
clear_build_files()
make_directories(dist_config)
# Use provided version or fallback to project defined
- version = version or get_project_version((SRC / 'pyproject').with_suffix('.toml'))
+ version = (
+ version or parse_model(SRC / "pyproject.toml", PyProjectModel).project.version
+ )
generate_version_file(version)
# Run Pyinstaller
- spec_path: list[str] = dist_config['spec']['console'] if console else dist_config['spec']['release']
- PyInstaller.__main__.run([str(Path(SRC, *spec_path)), '--clean'])
+ run_pyinstaller(
+ (
+ "--distpath",
+ "./dist",
+ *(("--console",) if console else []),
+ "-n",
+ "Proxyshop",
+ "--onefile",
+ "--icon",
+ "./src/img/favicon.ico",
+ "--clean",
+ "--add-data",
+ "src/gui/qml:src/gui/qml",
+ "--add-data",
+ "src/img/favicon.ico:src/img/favicon.ico",
+ "main.py",
+ )
+ )
# Copy our essential app files
copy_app_files(dist_config)
@@ -242,15 +269,16 @@ def build_release(
# Build zip release if requested
if zipped:
build_zip(
- filename=dist_config['names']['zip'].format(
+ filename=dist_config["names"]["zip"].format(
version=version,
- console='-console' if console else '',
- beta='-beta' if beta else ''
- ))
+ console="-console" if console else "",
+ beta="-beta" if beta else "",
+ )
+ )
# Clear build files, except dist
clear_build_files(clear_dist=False)
- os.remove(Path(SRC, '__VERSION__.py'))
+ os.remove(Path(SRC, "__VERSION__.py"))
"""
@@ -268,8 +296,7 @@ def get_python_modules(path: Path) -> list[str]:
List of module names.
"""
return [
- f[:-3] for f in os.listdir(path) if
- f.endswith('.py') and f != "__init__.py"
+ f[:-3] for f in os.listdir(path) if f.endswith(".py") and f != "__init__.py"
]
@@ -279,13 +306,14 @@ def generate_mkdocs(path: str) -> None:
Args:
path: Path to a python module directory.
"""
- directory = SRC / 'src' / path
- parent = 'temps' if path == 'templates' else path
+ directory = SRC / "src" / path
+ parent = "temps" if path == "templates" else path
for module in get_python_modules(directory):
- functions, classes = [], []
+ functions: list[str] = []
+ classes: list[str] = []
# Scan for functions and classes to document
- with open(Path(directory, module).with_suffix('.py')) as file:
+ with open(Path(directory, module).with_suffix(".py")) as file:
for node in ast.parse(file.read()).body:
if isinstance(node, ast.FunctionDef):
functions.append(f"src.{path}.{module}.{node.name}")
@@ -294,34 +322,39 @@ def generate_mkdocs(path: str) -> None:
# Write MD file
with open(
- Path(SRC, 'docs', parent, module).with_suffix('.md'),
- "w", encoding='utf-8'
+ Path(SRC, "docs", parent, module).with_suffix(".md"), "w", encoding="utf-8"
) as f:
- if module[0] == '_':
+ if module[0] == "_":
module = module[1:]
- module = module.title().replace('_', ' ')
+ module = module.title().replace("_", " ")
f.write(f"# {module}\n")
if classes:
- [f.write(
- f"\n::: {cls}\n"
- f" options:\n"
- f" show_root_members_full_path: false\n"
- f" show_category_heading: true\n"
- f" show_root_full_path: false\n"
- f" show_root_heading: true\n"
- ) for cls in classes]
+ [
+ f.write(
+ f"\n::: {cls}\n"
+ f" options:\n"
+ f" show_root_members_full_path: false\n"
+ f" show_category_heading: true\n"
+ f" show_root_full_path: false\n"
+ f" show_root_heading: true\n"
+ )
+ for cls in classes
+ ]
if functions:
- [f.write(
- f"\n::: {func}\n"
- f" options:\n"
- f" show_root_members_full_path: false\n"
- f" show_category_heading: true\n"
- f" show_root_full_path: false\n"
- f" show_root_heading: true\n"
- ) for func in functions]
-
-
-def generate_nav(headers: list[str], paths: list[str]) -> list[dict]:
+ [
+ f.write(
+ f"\n::: {func}\n"
+ f" options:\n"
+ f" show_root_members_full_path: false\n"
+ f" show_category_heading: true\n"
+ f" show_root_full_path: false\n"
+ f" show_root_heading: true\n"
+ )
+ for func in functions
+ ]
+
+
+def generate_nav(headers: list[str], paths: list[str]) -> list[dict[str, list[str]]]:
"""Generates the nav menu data for mkdocs.yml containing scanned modules.
Args:
@@ -331,30 +364,37 @@ def generate_nav(headers: list[str], paths: list[str]) -> list[dict]:
Returns:
List of nav item objects.
"""
- nav = []
+ nav: list[dict[str, list[str]]] = []
for i, path in enumerate(paths):
- parent = 'temps' if path == 'templates' else path
- md_files = sorted([f for f in os.listdir(Path(SRC, 'docs', parent)) if f.endswith('.md')])
- nav_items = [f'{parent}/{f}' for f in md_files] # remove .md extension
+ parent = "temps" if path == "templates" else path
+ md_files = sorted(
+ [f for f in os.listdir(Path(SRC, "docs", parent)) if f.endswith(".md")]
+ )
+ nav_items = [f"{parent}/{f}" for f in md_files] # remove .md extension
nav.append({headers[i]: nav_items})
return nav
-def update_mkdocs_yml(nav: list[dict]) -> None:
+def update_mkdocs_yml(nav: list[dict[str, list[str]]]) -> None:
"""Updates the mkdocs.yml file with the new nav list.
Args:
nav: List of nav objects to insert into nav data in mkdocs.yml.
"""
- mkdocs_yml = load_data_file(Path(SRC, 'mkdocs.yml'))
- mkdocs_yml['nav'] = [
- {'Home': 'index.md'},
- {'Changelog': 'changelog.md'},
- {'Reference': [
- *nav,
- {'Text Layer Classes': 'text_layers.md'},
- {'Card Layouts': 'layouts.md'}
- ]},
- {'License': 'license.md'}
+ mkdocs_path = SRC / "mkdocs.yml"
+ with open(mkdocs_path, "rb") as f:
+ mkdocs_yml: dict[str, Any] = safe_load(f)
+ mkdocs_yml["nav"] = [
+ {"Home": "index.md"},
+ {"Changelog": "changelog.md"},
+ {
+ "Reference": [
+ *nav,
+ {"Text Layer Classes": "text_layers.md"},
+ {"Card Layouts": "layouts.md"},
+ ]
+ },
+ {"License": "license.md"},
]
- dump_data_file(mkdocs_yml, Path(SRC, 'mkdocs.yml'), config={'sort_keys': False})
+ with open(mkdocs_path, "w", encoding="UTF-8") as f:
+ dump(mkdocs_yml, stream=f)
diff --git a/scripts_js/build.ts b/scripts_js/build.ts
new file mode 100644
index 00000000..3bfbec86
--- /dev/null
+++ b/scripts_js/build.ts
@@ -0,0 +1,35 @@
+import { readdir, readFile, rename, rm, writeFile } from "fs/promises";
+// import { format, parse } from "path";
+
+const distPath = "dist_js/";
+const awaitInjectionLocator = /\sphotoshop_[0-9]+.core.executeAsModal\(/g;
+
+// TypeScript doesn't allow top level await when transpiling to CommonJS,
+// but that is the combination we need with UXP scripts, so we have to
+// add the awaits after tsc transpilation.
+async function fixUXPScript(path: string) {
+ const content = await readFile(path, "utf-8");
+ const matches = content.match(awaitInjectionLocator);
+ if (matches) {
+ const lastIndex = content.lastIndexOf(matches[matches.length - 1]);
+ await writeFile(
+ path,
+ content.slice(0, lastIndex) + " await" + content.slice(lastIndex),
+ );
+ }
+ // await rename(path, format({ ...parse(path), base: "", ext: ".psjs" }));
+}
+
+async function build() {
+ const operations: Promise[] = [];
+
+ for (let pth of await readdir(distPath)) {
+ pth = distPath + pth;
+ if (pth.endsWith(".psjs")) operations.push(rm(pth));
+ else if (pth.endsWith(".js")) operations.push(fixUXPScript(pth));
+ }
+
+ await Promise.all(operations);
+}
+
+await build();
diff --git a/src/__init__.py b/src/__init__.py
index 8284cc6e..b77c6077 100644
--- a/src/__init__.py
+++ b/src/__init__.py
@@ -1,63 +1,23 @@
"""
* Load Global Application State
"""
-# Standard Library Imports
-import sys
-# Third Party Imports
-from dynaconf import Validator
-from omnitils.files import get_project_version
-
-# Local Imports
-from .console import TerminalConsole
-from ._config import AppConfig
-from ._loader import get_all_plugins, get_all_templates, get_template_map, get_template_map_defaults
-from ._state import AppConstants, AppEnvironment, PATH
+from src._config import AppConfig
+from src._state import AppConstants, AppEnvironment
from src.utils.adobe import PhotoshopHandler
+from src.utils.threading import ThreadInitializedInstance
-"""
-* Globally Loaded Objects
-"""
-
-# Global environment object (dynaconf)
-ENV = AppEnvironment(
- envvar_prefix='PROXYSHOP',
- settings_files=[PATH.SRC_DATA_ENV, PATH.SRC_DATA_ENV_DEFAULT],
- validators=[
- Validator('API_GOOGLE', cast=str, default=''),
- Validator('API_AMAZON', cast=str, default=''),
- Validator('PS_ERROR_DIALOG', cast=bool, default=False),
- Validator('PS_VERSION', cast=AppEnvironment.string_or_none, default=None),
- Validator('HEADLESS', cast=bool, default=False),
- Validator('DEV_MODE', cast=bool, default=bool(not hasattr(sys, '_MEIPASS'))),
- Validator('TEST_MODE', cast=bool, default=False),
- Validator('VERSION', cast=str, default=get_project_version(PATH.PROJECT_FILE)),
- Validator('FORCE_RELOAD', cast=bool, default=False)
- ],
- apply_default_on_none=True
-)
+# Global environment object
+ENV = AppEnvironment()
# Global constants object
CON = AppConstants()
# Global settings object
-CFG = AppConfig(env=ENV)
+CFG = AppConfig()
# Global Photoshop handler
-APP = PhotoshopHandler(env=ENV)
-
-# Conditionally import the GUI console
-if not ENV.HEADLESS:
- from src.gui.console import GUIConsole as Console
-else:
- Console = TerminalConsole
-CONSOLE = Console(cfg=CFG, env=ENV)
-
-# Global plugins and templates
-PLUGINS = get_all_plugins(con=CON, env=ENV)
-TEMPLATES = get_all_templates(con=CON, env=ENV, plugins=PLUGINS)
-TEMPLATE_MAP = get_template_map(templates=TEMPLATES)
-TEMPLATE_DEFAULTS = get_template_map_defaults(TEMPLATE_MAP)
+APP = ThreadInitializedInstance(lambda: PhotoshopHandler(env=ENV))
# Export objects
-__all__ = ['APP', 'CFG', 'CON', 'CONSOLE', 'ENV', 'PATH']
+__all__ = ["APP", "CFG", "CON", "ENV"]
diff --git a/src/_config.py b/src/_config.py
index 7de5bccd..1c366fdf 100644
--- a/src/_config.py
+++ b/src/_config.py
@@ -1,95 +1,269 @@
"""
* Global Settings Module
"""
-# Standard Library Imports
-from typing import Optional
-# Third Party Imports
-from omnitils.metaclass import Singleton
-from omnitils.enums import StrConstant
+from enum import Enum
+from typing import Literal, overload
-# Local Imports
-from src._state import AppEnvironment
+from src._loader import ConfigHandler, CustomConfigParser
+from src._state import PATH
from src.enums.settings import (
- CollectorMode,
BorderColor,
+ CollectorMode,
+ CollectorPromo,
+ FillMode,
+ NicknameShorten,
OutputFileType,
ScryfallSorting,
ScryfallUnique,
- CollectorPromo,
- WatermarkMode
+ WatermarkMode,
)
-from src._loader import ConfigManager
class AppConfig:
"""Stores the current state of app and template settings. Can be changed within a template
class to affect rendering behavior."""
- __metaclass__ = Singleton
- def __init__(self, env: AppEnvironment):
+ def __init__(
+ self,
+ app_config: ConfigHandler | None = None,
+ base_config: ConfigHandler | None = None,
+ user_config: ConfigHandler | None = None,
+ ):
"""Load initial settings values."""
- self.ENV = env
- self.load()
+ self.app_config = app_config or ConfigHandler(
+ base_schema_path=PATH.SRC_DATA_CONFIG_APP,
+ schema_path=None,
+ ini_path=PATH.SRC_DATA_CONFIG_INI_APP,
+ )
+ self.base_config = base_config or ConfigHandler(
+ base_schema_path=PATH.SRC_DATA_CONFIG_BASE,
+ schema_path=None,
+ ini_path=PATH.SRC_DATA_CONFIG_INI_BASE,
+ )
+ self.load(user_config)
+
+ # region Pre-rendering properties
+
+ @property
+ def lang(self) -> str:
+ return self.app_config.get_str_setting(
+ "APP.DATA", "Scryfall.Language", default="en"
+ )
+
+ @property
+ def scry_sorting(self) -> ScryfallSorting:
+ return self.app_config.get_enum_setting(
+ "APP.DATA",
+ "Scryfall.Sorting",
+ ScryfallSorting,
+ default=ScryfallSorting.Released,
+ )
+
+ @property
+ def scry_ascending(self) -> bool:
+ return self.app_config.get_bool_setting(
+ "APP.DATA", "Scryfall.Ascending", default=False
+ )
+
+ @property
+ def scry_extras(self) -> bool:
+ return self.app_config.get_bool_setting(
+ "APP.DATA", "Scryfall.Extras", default=False
+ )
+
+ @property
+ def scry_unique(self) -> ScryfallUnique:
+ return self.app_config.get_enum_setting(
+ "APP.DATA", "Scryfall.Unique", ScryfallUnique, default=ScryfallUnique.Arts
+ )
+
+ @property
+ def manually_edit_card_data(self) -> bool:
+ return self.app_config.get_bool_setting(
+ "APP.DATA", "Manually.Edit.Card.Data", default=False
+ )
+
+ @property
+ def manual_text_editor(self) -> str:
+ return self.app_config.get_str_setting(
+ "APP.DATA", "Manual.Text.Editor", default='notepad "{}"'
+ )
+
+ # endregion Pre-rendering properties
def update_definitions(self):
"""Updates the defined settings values using the currently loaded ConfigParser object."""
# APP - FILES
- self.overwrite_duplicate = self.file.getboolean('APP.FILES', 'Overwrite.Duplicate', fallback=True)
- self.output_file_type = self.get_option('APP.FILES', 'Output.File.Type', OutputFileType)
+ self.overwrite_duplicate = self.file.getboolean(
+ "APP.FILES", "Overwrite.Duplicate", fallback=True
+ )
+ self.output_file_type = self.get_option(
+ "APP.FILES", "Output.File.Type", OutputFileType, default=OutputFileType.JPG
+ )
self.output_file_name = self.file.get(
- section='APP.FILES', option='Output.File.Name',
- fallback='#name (#frame<, #suffix>) [#set] {#num}')
+ section="APP.FILES",
+ option="Output.File.Name",
+ fallback="#name (#frame<, #suffix>) [#set] {#num}",
+ )
+ self.png_compression_level = self.file.getint(
+ "APP.FILES", "PNG.Compression.Level", fallback=3
+ )
# APP - DATA
- self.lang = self.file.get('APP.DATA', 'Scryfall.Language', fallback='en')
- self.scry_sorting = self.get_option('APP.DATA', 'Scryfall.Sorting', ScryfallSorting)
- self.scry_ascending = self.file.getboolean('APP.DATA', 'Scryfall.Ascending', fallback=False)
- self.scry_extras = self.file.getboolean('APP.DATA', 'Scryfall.Extras', fallback=False)
- self.scry_unique = self.get_option('APP.DATA', 'Scryfall.Unique', ScryfallUnique)
+ self.use_printed_texts = self.file.getboolean(
+ "APP.DATA", "Scryfall.Use.Printed.Texts", fallback=False
+ )
# APP - TEXT
- self.force_english_formatting = self.file.getboolean('APP.TEXT', "Force.English.Formatting", fallback=False)
+ self.force_english_formatting = self.file.getboolean(
+ "APP.TEXT", "Force.English.Formatting", fallback=False
+ )
# APP - RENDER
- self.skip_failed = self.file.getboolean('APP.RENDER', 'Skip.Failed', fallback=False)
- self.generative_fill = False if self.ENV.TEST_MODE else self.file.getboolean(
- 'APP.RENDER', 'Generative.Fill', fallback=False)
- self.select_variation = self.file.getboolean('APP.RENDER', 'Select.Variation', fallback=False)
- self.feathered_fill = self.file.getboolean('APP.RENDER', 'Feathered.Fill', fallback=False)
- self.vertical_fullart = self.file.getboolean('APP.RENDER', 'Vertical.Fullart', fallback=False)
+ self.select_variation = self.file.getboolean(
+ "APP.RENDER", "Select.Variation", fallback=False
+ )
+ self.vertical_fullart = self.file.getboolean(
+ "APP.RENDER", "Vertical.Fullart", fallback=False
+ )
# BASE - TEXT
- self.flavor_divider = self.file.getboolean('BASE.TEXT', 'Flavor.Divider', fallback=True)
- self.remove_flavor = self.file.getboolean('BASE.TEXT', 'No.Flavor.Text', fallback=False)
- self.remove_reminder = self.file.getboolean('BASE.TEXT', 'No.Reminder.Text', fallback=False)
- self.collector_mode = self.get_option('BASE.TEXT', 'Collector.Mode', CollectorMode)
- self.collector_promo = self.get_option('BASE.TEXT', "Collector.Promo", CollectorPromo)
+ self.flavor_divider = self.file.getboolean(
+ "BASE.TEXT", "Flavor.Divider", fallback=True
+ )
+ self.remove_flavor = self.file.getboolean(
+ "BASE.TEXT", "No.Flavor.Text", fallback=False
+ )
+ self.remove_reminder = self.file.getboolean(
+ "BASE.TEXT", "No.Reminder.Text", fallback=False
+ )
+ self.collector_mode = self.get_option(
+ "BASE.TEXT", "Collector.Mode", CollectorMode, default=CollectorMode.Normal
+ )
+ self.collector_line_a_format = self.get_setting(
+ "BASE.TEXT", "Collector.Line.A", default=""
+ )
+ self.collector_line_b_format = self.get_setting(
+ "BASE.TEXT", "Collector.Line.B", default=""
+ )
+ self.collector_promo = self.get_option(
+ "BASE.TEXT",
+ "Collector.Promo",
+ CollectorPromo,
+ default=CollectorPromo.Automatic,
+ )
+ self.nickname_allow = self.file.getboolean(
+ "BASE.TEXT", "Nickname", fallback=True
+ )
+ self.nickname_prompt = self.file.getboolean(
+ "BASE.TEXT", "Nickname.Prompt", fallback=False
+ )
+ self.nickname_in_oracle_text = self.file.getboolean(
+ "BASE.TEXT", "Nickname.In.Oracle", fallback=True
+ )
+ self.nickname_shorten_in_oracle_text = self.get_option(
+ "BASE.TEXT",
+ "Nickname.Shorten.In.Oracle",
+ NicknameShorten,
+ default=NicknameShorten.ALL_BUT_FIRST,
+ )
# BASE - SYMBOLS
- self.symbol_enabled = self.file.getboolean('BASE.SYMBOLS', 'Enable.Expansion.Symbol', fallback=True)
- self.symbol_default = self.file.get('BASE.SYMBOLS', 'Default.Symbol', fallback='MTG')
- self.symbol_force_default = self.file.getboolean('BASE.SYMBOLS', 'Force.Default.Symbol', fallback=False)
+ self.symbol_enabled = self.file.getboolean(
+ "BASE.SYMBOLS", "Enable.Expansion.Symbol", fallback=True
+ )
+ self.symbol_default = self.file.get(
+ "BASE.SYMBOLS", "Default.Symbol", fallback="MTG"
+ )
+ self.symbol_force_default = self.file.getboolean(
+ "BASE.SYMBOLS", "Force.Default.Symbol", fallback=False
+ )
+ self.symbol_force_rarity = self.file.get(
+ "BASE.SYMBOLS", "Force.Rarity", fallback=""
+ )
# BASE - WATERMARKS
- self.watermark_mode = self.get_option('BASE.WATERMARKS', 'Watermark.Mode', WatermarkMode)
- self.watermark_default = self.file.get('BASE.WATERMARKS', 'Default.Watermark', fallback='WOTC')
- self.watermark_opacity = self.file.getint('BASE.WATERMARKS', 'Watermark.Opacity', fallback=30)
- self.enable_basic_watermark = self.file.getboolean('BASE.WATERMARKS', 'Enable.Basic.Watermark', fallback=True)
+ self.watermark_mode = self.get_option(
+ "BASE.WATERMARKS",
+ "Watermark.Mode",
+ WatermarkMode,
+ default=WatermarkMode.Disabled,
+ )
+ self.watermark_default = self.file.get(
+ "BASE.WATERMARKS", "Default.Watermark", fallback="WOTC"
+ )
+ self.watermark_opacity = self.file.getfloat(
+ "BASE.WATERMARKS", "Watermark.Opacity", fallback=30
+ )
+ self.enable_basic_watermark = self.file.getboolean(
+ "BASE.WATERMARKS", "Enable.Basic.Watermark", fallback=True
+ )
# BASE - TEMPLATES
- self.exit_early = self.file.getboolean('BASE.TEMPLATES', 'Manual.Edit', fallback=False)
- self.import_scryfall_scan = self.file.getboolean('BASE.TEMPLATES', 'Import.Scryfall.Scan', fallback=False)
- self.border_color = self.get_option('BASE.TEMPLATES', 'Border.Color', BorderColor)
+ self.fill_mode: FillMode = FillMode(
+ self.file.get(
+ "BASE.TEMPLATES",
+ "Border.Fill.Mode",
+ fallback=FillMode.CONTENT_AWARE_FILL.value,
+ )
+ )
+ self.fill_contract = self.file.getint(
+ "BASE.TEMPLATES", "Border.Fill.Contract", fallback=10
+ )
+ self.fill_smooth = self.file.getint(
+ "BASE.TEMPLATES", "Border.Fill.Smooth", fallback=0
+ )
+ self.fill_feather = self.file.getint(
+ "BASE.TEMPLATES", "Border.Fill.Feather", fallback=5
+ )
+ self.exit_early = self.file.getboolean(
+ "BASE.TEMPLATES", "Manual.Edit", fallback=False
+ )
+ self.pause_for_manual_art_alignment = self.file.getboolean(
+ "BASE.TEMPLATES", "Manual.Art.Alignment.Pause", fallback=False
+ )
+ self.minimize_photoshop = self.file.getboolean(
+ "BASE.TEMPLATES", "Minimize.Photoshop", fallback=False
+ )
+ self.import_scryfall_scan = self.file.getboolean(
+ "BASE.TEMPLATES", "Import.Scryfall.Scan", fallback=False
+ )
+ self.border_color = self.get_option(
+ "BASE.TEMPLATES", "Border.Color", BorderColor, default=BorderColor.Black
+ )
"""
* Setting Utils
"""
- def get_option(self, section: str, key: str, enum_class: type[StrConstant], default: str = None) -> str:
+ @overload
+ def get_option[T: Enum](
+ self,
+ section: str,
+ key: str,
+ enum_class: type[T],
+ default: T,
+ ) -> T: ...
+
+ @overload
+ def get_option[T: Enum](
+ self,
+ section: str,
+ key: str,
+ enum_class: type[T],
+ default: T | None = None,
+ ) -> T | None: ...
+
+ def get_option[T: Enum](
+ self,
+ section: str,
+ key: str,
+ enum_class: type[T],
+ default: T | None = None,
+ ) -> T | None:
"""Returns the current value of an "options" setting if that option exists in its StrEnum class.
- Otherwise, returns the default value of that StrEnum class.
Args:
section: Group (section) to access within the config file.
@@ -100,14 +274,45 @@ def get_option(self, section: str, key: str, enum_class: type[StrConstant], defa
Returns:
Validated current value, or default value.
"""
- default = default or enum_class.Default
if self.file.has_section(section):
- option = self.file[section].get(key, fallback=default)
- if option in enum_class:
- return option
+ option = self.file[section].get(key)
+ try:
+ return enum_class(option)
+ except ValueError:
+ pass
return default
- def get_setting(self, section: str, key: str, default: Optional[str] = None, is_bool: bool = True):
+ @overload
+ def get_setting(
+ self, section: str, key: str, default: bool, is_bool: Literal[True]
+ ) -> bool: ...
+
+ @overload
+ def get_setting(
+ self, section: str, key: str, default: bool | None, is_bool: Literal[True]
+ ) -> bool | None: ...
+
+ @overload
+ def get_setting(
+ self, section: str, key: str, default: str, is_bool: Literal[False] = False
+ ) -> str: ...
+
+ @overload
+ def get_setting(
+ self,
+ section: str,
+ key: str,
+ default: str | None = None,
+ is_bool: Literal[False] = False,
+ ) -> str | None: ...
+
+ def get_setting(
+ self,
+ section: str,
+ key: str,
+ default: str | bool | None = None,
+ is_bool: bool = False,
+ ) -> str | bool | None:
"""Check if the setting exists and return it. Default will be returned if missing.
Args:
@@ -126,21 +331,75 @@ def get_setting(self, section: str, key: str, default: Optional[str] = None, is_
return self.file[section].get(key, fallback=default)
return default
+ @overload
+ def get_bool_setting(self, section: str, key: str, default: bool) -> bool: ...
+
+ @overload
+ def get_bool_setting(
+ self, section: str, key: str, default: bool | None = None
+ ) -> bool | None: ...
+
+ def get_bool_setting(
+ self, section: str, key: str, default: bool | None = None
+ ) -> bool | None:
+ return self.get_setting(section, key, default, is_bool=True)
+
+ @overload
+ def get_int_setting(self, section: str, key: str, default: int) -> int: ...
+
+ @overload
+ def get_int_setting(
+ self, section: str, key: str, default: int | None = None
+ ) -> int | None: ...
+
+ def get_int_setting(
+ self, section: str, key: str, default: int | None = None
+ ) -> int | None:
+ setting = self.get_setting(section, key, None)
+ if setting is not None:
+ return int(setting)
+ return default
+
+ @overload
+ def get_float_setting(self, section: str, key: str, default: float) -> float: ...
+
+ @overload
+ def get_float_setting(
+ self, section: str, key: str, default: float | None = None
+ ) -> float | None: ...
+
+ def get_float_setting(
+ self, section: str, key: str, default: float | None = None
+ ) -> float | None:
+ setting = self.get_setting(section, key, None)
+ if setting is not None:
+ return float(setting)
+ return default
+
"""
* Load ConfigParser Object
"""
- def load(self, config: Optional[ConfigManager] = None) -> None:
+ def load(self, config: ConfigHandler | None = None) -> None:
"""Reload the config file and define new values
Args:
- config: ConfigManager to load from if provided, otherwise use app-wide configuration.
+ config: to load from if provided, otherwise use app-wide configuration.
"""
- # Invalidate file cache
- if hasattr(self, 'file'):
- del self.file
-
- # Load provided or load fresh
- config = config or ConfigManager()
- self.file = config.get_config()
+ self.file = CustomConfigParser(default_section="", allow_no_value=True)
+ # Combine app and base/template configs
+ self.file.read_dict(self.app_config.setting_values)
+ self.file.read_dict(
+ (
+ config if config and config.has_config else self.base_config
+ ).setting_values
+ )
self.update_definitions()
+
+ def copy(self, config: ConfigHandler | None = None) -> AppConfig:
+ """Copy the config.
+
+ Args:
+ config: to load from if provided, otherwise use app-wide configuration.
+ """
+ return AppConfig(self.app_config, self.base_config, config)
diff --git a/src/_loader.py b/src/_loader.py
index 919df483..b86fed4e 100644
--- a/src/_loader.py
+++ b/src/_loader.py
@@ -1,44 +1,57 @@
"""
* Plugin and Template Loader
-* Only import enums and utils
"""
-# Standard Library Imports
-import json
-import shutil
-from concurrent.futures import ThreadPoolExecutor, Future
-from configparser import ConfigParser
+
+from collections.abc import Callable
+from configparser import RawConfigParser
from contextlib import suppress
+from enum import Enum
from functools import cached_property
-import os
+from json import load
+from logging import getLogger
from pathlib import Path
-from traceback import print_tb
+from threading import Lock
+from traceback import print_exc
from types import ModuleType
-from typing import Optional, TypedDict, NotRequired, Any, Callable, Union
+from typing import Annotated, Any, Literal, NotRequired, Protocol, TypedDict, overload
-# Third Party Imports
import yarl
-from omnitils.api.gdrive import gdrive_get_metadata, gdrive_download_file
-from omnitils.files import load_data_file, ensure_file, mkdir_full_perms
-from omnitils.modules import get_local_module, import_package, import_module_from_path
+from omnitils.api.gdrive import gdrive_download_file, gdrive_get_metadata
+from omnitils.modules import get_local_module, import_module_from_path, import_package
from omnitils.strings import normalize_ver
-
-# Local Imports
-from src._state import AppConstants, AppEnvironment, PATH
+from py7zr import SevenZipFile
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ Field,
+ RootModel,
+ ValidationError,
+ WrapValidator,
+ create_model,
+ model_validator,
+)
+
+from src._state import PATH, AppConstants, AppEnvironment
from src.enums.mtg import (
- layout_map_types_display,
- layout_map_category,
- layout_map_types,
+ LayoutCategory,
+ LayoutType,
+ layout_map_display_condition,
layout_map_display_condition_dual,
- layout_map_display_condition)
+ layout_map_types,
+ layout_map_types_display,
+)
+from src.utils.data_structures import dump_model, parse_model
from src.utils.download import download_cloudfront
+from src.utils.event import SubscribableEvent
-"""
-* Types
-"""
+_logger = getLogger(__name__)
+
+# region Types
class TemplateUpdate(TypedDict):
"""Details about the latest update for a given AppTemplate."""
+
name: NotRequired[str]
version: NotRequired[str]
size: NotRequired[int]
@@ -46,407 +59,491 @@ class TemplateUpdate(TypedDict):
class TemplateDetails(TypedDict):
"""Details about a specific template within the TemplateCategoryMap."""
+
name: str
class_name: str
- object: 'AppTemplate'
- config: 'ConfigManager'
+ config: ConfigHandler
+ManifestTemplateMap = dict[str, dict[str, list[LayoutType]]]
"""Dictionary which maps a template's displayed names to classes, and classes to template types."""
-ManifestTemplateMap = dict[str, dict[str, list[str]]]
-"""Reversed dictionary which maps a template type, to a template name, to a template class."""
-TemplateTypeMap = dict[str, dict[str, TemplateDetails]]
-"""Map of templates selected for each template type."""
-TemplateSelectedMap = dict[str, Optional[TemplateDetails]]
+class BaseConfig(BaseModel):
+ prefix: str
-class TemplateRequirements(TypedDict):
- """Requirements that must be met for a template to be supported."""
- templates: NotRequired[list[str]]
- version: NotRequired[str]
+class BaseSection(BaseModel):
+ title: str
-class ManifestTemplateDetails(TypedDict):
- """Template details pulled from a plugin's `manifest.yml` file or Proxyshop's `templates.yml` file."""
- file: str
- name: NotRequired[str]
- id: NotRequired[str]
- desc: NotRequired[str]
- requires: NotRequired[TemplateRequirements]
- version: NotRequired[str]
- templates: ManifestTemplateMap
+class SectionTitle(BaseSection):
+ type: Literal["title"] = "title"
-class TemplateMetadata(TypedDict):
- """TemplateDetails, minus the template class map."""
- file: str
- name: NotRequired[str]
- id: NotRequired[str]
- desc: NotRequired[str]
- requires: NotRequired[TemplateRequirements]
- version: NotRequired[str]
+class BaseSetting(BaseSection):
+ desc: str = ""
+ key: str = ""
+ section: str = ""
-class PluginMetadata(TypedDict):
- """Metadata contained in the `PLUGIN` table of a plugin's `manifest.yml` file."""
- name: NotRequired[str]
- author: NotRequired[str]
- desc: NotRequired[str]
- source: NotRequired[str]
- docs: NotRequired[str]
- license: NotRequired[str]
- requires: NotRequired[str]
- version: NotRequired[str]
+class BoolSetting(BaseSetting):
+ type: Literal["bool"]
+ default: bool = False
-class TemplateCategoryMap(TypedDict):
- """Data mapped to a displayed template category, e.g. 'Normal'."""
- names: list[str]
- map: TemplateTypeMap
+class StringSetting(BaseSetting):
+ type: Literal["string"]
+ default: str = ""
-"""
-* Config File Utils
-"""
+class NumericSetting(BaseSetting):
+ type: Literal["numeric"]
+ default: int | float = 0
-def verify_config_fields(ini_file: Path, data_file: Path) -> None:
- """Validate that all settings fields present in a given json data are present in config file. If any are missing,
- add them and return.
+class FloatSetting(BaseSetting):
+ type: Literal["float"]
+ default: float = 0
- Args:
- ini_file: Config file to verify contains the proper fields.
- data_file: Data file containing config fields to check for, JSON or TOML.
- """
- # Track data and changes
- data, changed = {}, False
- # Data file doesn't exist or is unsupported data type
- if not data_file.is_file() or data_file.suffix not in ['.toml', '.json']:
- return
+class IntSetting(BaseSetting):
+ type: Literal["int"]
+ default: int = 0
- # Load data from JSON or TOML file
- raw = load_data_file(data_file)
- raw = parse_kivy_config_toml(raw) if data_file.suffix == '.toml' else raw
- # Ensure INI file exists and load ConfigParser
- ensure_file(ini_file)
- config = get_config_object(ini_file)
+class OptionsSetting(BaseSetting):
+ type: Literal["options"]
+ options: list[str] = []
+ default: str
- # Build a dictionary of the necessary values
- for row in raw:
- # Add row if it's not a title
- if row.get('type', 'title') == 'title':
- continue
- data.setdefault(
- row.get('section', 'BROKEN'), []
- ).append({
- 'key': row.get('key', ''),
- 'value': row.get('default', 0)
- })
-
- # Add the data to ini where missing
- for section, settings in data.items():
- # Check if the section exists
- if not config.has_section(section):
- config.add_section(section)
- changed = True
- # Check if each setting exists
- for setting in settings:
- if not config.has_option(section, setting['key']):
- config.set(section, setting['key'], str(setting['value']))
- changed = True
-
- # If ini has changed, write changes
- if changed:
- with open(ini_file, "w", encoding="utf-8") as f:
- config.write(f) # noqa
-
-
-def parse_kivy_config_json(raw: list[dict]) -> list[dict]:
- """Parse config JSON data for use with Kivy settings panel.
- Args:
- raw: Raw loaded JSON data.
+TypedSetting = (
+ BoolSetting
+ | StringSetting
+ | NumericSetting
+ | FloatSetting
+ | IntSetting
+ | OptionsSetting
+)
- Returns:
- Properly parsed data safe for use with Kivy.
- """
- # Remove unsupported keys
- for row in raw:
- if 'default' in row:
- row.pop('default')
- return raw
+_SomeSetting = RootModel[TypedSetting]
-def parse_kivy_config_toml(raw: dict) -> list[dict]:
- """Parse config TOML data for use with Kivy settings panel.
+class ConfigSection(BaseSection):
+ settings: dict[str, TypedSetting] = Field(default={}, exclude=True)
- Args:
- raw: Raw loaded TOML data.
+ @model_validator(mode="before")
+ @classmethod
+ def extra_validator(cls, data: Any) -> Any:
+ if isinstance(data, dict):
+ for key, value in data.items(): # pyright: ignore[reportUnknownVariableType]
+ if not isinstance(key, str) or key in cls.model_fields:
+ continue
+ data[key] = _SomeSetting.model_validate(value).root
+ return data # pyright: ignore[reportUnknownVariableType]
- Returns:
- Properly parsed data safe for use with Kivy.
- """
+ @model_validator(mode="after")
+ def post_validate(self) -> ConfigSection:
+ if self.model_extra:
+ self.settings = self.model_extra
+ return self
- # Process __CONFIG__ header if present
- cfg_header = raw.pop('__CONFIG__', {})
- prefix = cfg_header.get('prefix', '')
-
- # Process data
- data: list[dict] = []
- for section, settings in raw.items():
-
- # Add section title if it exists
- if title := settings.pop('title', None):
- data.append({
- 'type': 'title',
- 'title': title
- })
-
- # Add each setting within this section
- for key, field in settings.items():
-
- # Establish data type and default value
- data_type = field.get('type', 'bool')
- display_default = default = field.get('default', 0)
- if data_type == 'bool':
- display_default = 'True' if default else 'False'
- elif data_type in ['string', 'options', 'path']:
- display_default = f"'{default}'"
- setting = {
- 'type': data_type,
- 'title': f"[b]{field.get('title', 'Broken Setting')}[/b]",
- 'desc': f"{field.get('desc', '')}\n"
- f"[b](Default: {display_default})[/b]",
- 'section': f'{prefix}.{section}' if prefix else section,
- 'key': key, 'default': default}
- if options := field.get('options'):
- setting['options'] = options
- data.append(setting)
-
- # Return parsed data
- return data
-
-
-def get_kivy_config_from_schema(config: Path) -> str:
- """Return valid JSON data for use with Kivy settings panel.
+ model_config = ConfigDict(extra="allow")
- Args:
- config: Path to config schema file, JSON or TOML.
- Returns:
- Json string dump of validated data.
- """
- # Need to load data as JSON
- raw = load_data_file(config)
+class FormattedSettingsConfig(RootModel[list[SectionTitle | TypedSetting]]):
+ root: list[SectionTitle | TypedSetting] = []
- # Use correct parser
- if config.suffix == '.toml':
- raw = parse_kivy_config_toml(raw)
- return json.dumps(parse_kivy_config_json(raw))
+class SettingsConfig(BaseModel):
+ config: Annotated[BaseConfig | None, Field(alias="__CONFIG__")] = None
+ sections: Annotated[
+ FormattedSettingsConfig,
+ Field(exclude=True, default_factory=FormattedSettingsConfig),
+ ]
-def copy_config_or_verify(path_from: Path, path_to: Path, data_file: Path) -> None:
- """Copy one config to another, or verify it if it exists.
+ @model_validator(mode="before")
+ @classmethod
+ def extra_validator(cls, data: Any) -> Any:
+ if isinstance(data, dict):
+ exclude_keys = [*cls.model_fields.keys(), "__CONFIG__"]
+ for key, value in data.items(): # pyright: ignore[reportUnknownVariableType]
+ if not isinstance(key, str) or key in exclude_keys:
+ continue
+ data[key] = ConfigSection.model_validate(value)
+ return data # pyright: ignore[reportUnknownVariableType]
- Args:
- path_from: Path to the file to be copied.
- path_to: Path to the file to create, if it doesn't exist.
- data_file: Data schema file to use for validating an existing INI file.
- """
- if os.path.isfile(path_to):
- return verify_config_fields(path_to, data_file)
- shutil.copy(path_from, path_to)
+ @model_validator(mode="after")
+ def post_validate(self) -> SettingsConfig:
+ if self.model_extra:
+ sections: dict[str, ConfigSection] = self.model_extra
+ prefix = self.config.prefix if self.config else None
+ for section, data in sections.items():
+ self.sections.root.append(SectionTitle(title=data.title))
+ for key, setting in data.settings.items():
+ setting.key = key
+ setting.section = f"{prefix}.{section}" if prefix else section
+ self.sections.root.append(setting)
+ return self
+ model_config = ConfigDict(extra="allow")
-def get_config_object(path: Union[str, os.PathLike, list[Union[str, os.PathLike]]]) -> ConfigParser:
- """Returns a ConfigParser object using a valid ini path.
- Args:
- path: Path to ini config file.
+SymbolRarity = Literal["80", "B", "C", "H", "M", "R", "S", "T", "U", "WM"]
- Returns:
- ConfigParser object.
- Raises:
- ValueError: If valid ini file wasn't received.
- """
- config = ConfigParser(allow_no_value=True)
- config.optionxform = str
- config.read(path, encoding='utf-8')
- return config
+class SymbolsMeta(BaseModel):
+ date: str
+ version: str
+ uri: str
-"""
-* Classes
-"""
+class SymbolsSet(BaseModel):
+ aliases: dict[str, str]
+ routes: dict[str, str]
+ rarities: dict[SymbolRarity, str]
+ symbols: dict[str, list[SymbolRarity]]
-class ConfigManager:
- """Represents the combined loaded configuration data for the app and template/plugin if provided.
+class SymbolsWatermark(BaseModel):
+ routes: dict[str, str] | None = None
+ symbols: list[str]
- Args:
- template_class: Name of the template class, if provided.
- template: AppTemplate object corresponding to the template class, if provided.
- """
- gui_elements = []
- def __init__(
- self, template_class: Optional[str] = None,
- template: Optional['AppTemplate'] = None
- ) -> None:
+class SymbolsManifest(BaseModel):
+ meta: SymbolsMeta
+ set: SymbolsSet
+ watermark: SymbolsWatermark
- # Establish template and plugin details
- self._template_class: Optional[str] = template_class.replace(
- 'Back', 'Front') if template_class else None
- self._template: Optional[AppTemplate] = template or None
- self._plugin: Optional[AppPlugin] = template.plugin if template else None
- # Core path where config folders exist
- self._path_schema = self._plugin.path_config if self._plugin else PATH.SRC_DATA_CONFIG
- self._path_ini = self._plugin.path_ini if self._plugin else PATH.SRC_DATA_CONFIG_INI
+class PluginInfo(BaseModel):
+ name: str | None = None
+ author: str | None = None
+ desc: str | None = None
+ source: str | None = None
+ docs: str | None = None
+ license: str | None = None
+ version: str | None = None
- """
- * Path: App settings only modified in Global
- """
- @property
- def app_path_schema(self) -> Path:
- """System config schema, in JSON or TOML."""
- return PATH.SRC_DATA_CONFIG_APP
+class TemplateFileInfo(BaseModel):
+ name: str | None = None
+ id: str | None = None
+ desc: str | None = None
+ version: str | None = None
+ templates: ManifestTemplateMap
- @property
- def app_path_ini(self) -> Path:
- """System config INI."""
- return PATH.SRC_DATA_CONFIG_INI_APP
- @property
- def app_json(self) -> str:
- """System config as Kivy readable JSON string dump."""
- return get_kivy_config_from_schema(self.app_path_schema)
+TemplateFileInfoRoot = RootModel[dict[str, TemplateFileInfo]]
- @property
- def app_cfg(self) -> ConfigParser:
- """System ConfigParser instance."""
- return get_config_object(self.app_path_ini)
- """
- * Path: Settings modifiable by Template
- """
+class PluginManifest(BaseModel):
+ plugin: Annotated[PluginInfo, Field(alias="PLUGIN")]
+ files: Annotated[dict[str, TemplateFileInfo], Field(exclude=True)] = {}
- @property
- def base_path_schema(self) -> Path:
- """Main template config schema, in JSON or TOML."""
- return PATH.SRC_DATA_CONFIG_BASE
+ @model_validator(mode="before")
+ @classmethod
+ def extra_validator(cls, data: Any) -> Any:
+ if isinstance(data, dict):
+ exclude_keys = [*cls.model_fields.keys(), "PLUGIN"]
+ for key, value in data.items(): # pyright: ignore[reportUnknownVariableType]
+ if not isinstance(key, str) or key in exclude_keys:
+ continue
+ data[key] = TemplateFileInfo.model_validate(value)
+ return data # pyright: ignore[reportUnknownVariableType]
- @property
- def base_path_ini(self) -> Path:
- """Main template config INI."""
- return PATH.SRC_DATA_CONFIG_INI_BASE
+ @model_validator(mode="after")
+ def post_validate(self) -> PluginManifest:
+ if self.model_extra:
+ self.files = self.model_extra
+ return self
- @property
- def base_json(self) -> str:
- """Main template config as Kivy readable JSON string dump."""
- return get_kivy_config_from_schema(self.base_path_schema)
+ model_config = ConfigDict(extra="allow")
- @property
- def base_cfg(self) -> ConfigParser:
- """Main template ConfigParser instance."""
- return get_config_object(self.base_path_ini)
- """
- * Path: Template specific settings
- """
+# endregion Types
- @property
- def template_path_schema(self) -> Optional[Path]:
- """Template specific config schema, in JSON or TOML."""
- if self._template:
- path = self._template.get_path_config(self._template_class)
- return path if path.is_file() else None
- return
+# region Configs
- @property
- def template_path_ini(self) -> Optional[Path]:
- """Template specific config INI."""
- if self._template:
- return self._template.get_path_ini(self._template_class)
- return
- @property
- def template_json(self) -> Optional[str]:
- """Template specific config as Kivy readable JSON string dump."""
- if self.template_path_schema:
- return get_kivy_config_from_schema(self.template_path_schema)
- return
+class CustomConfigParser(RawConfigParser):
+ def optionxform(self, optionstr: str) -> str:
+ return optionstr
- @property
- def template_cfg(self) -> Optional[ConfigParser]:
- """Template specific ConfigParser instance."""
- if self.template_path_ini:
- return get_config_object(self.template_path_ini)
- return
+def parse_settings_config(data_path: Path) -> FormattedSettingsConfig:
"""
- * Bool Checks
+ Tries to parse the settings config from a file at data_path.
+
+ Raises:
+ OSError: If reading of the file at data_path fails.
+ ValidationError: If the data in file at data_path is invalid.
"""
+ return parse_model(data_path, SettingsConfig).sections
- @property
- def has_template_ini(self) -> bool:
- """Returns True if a template has a separate INI file."""
- return bool(self.template_path_ini and self.template_path_ini.is_file())
- """
- * Utility Methods
- """
+_setting_to_python_type = {
+ "bool": bool,
+ "string": str,
+ "numeric": int | float,
+ "int": int,
+ "float": float,
+ "options": str,
+}
- def get_config(self) -> ConfigParser:
- """Return a ConfigParser instance with all relevant config data loaded."""
- self.validate_configs()
- if self.has_template_ini:
- # Load template INI file instead of base
- self.validate_template_configs()
- return get_config_object([
- self.app_path_ini,
- self.template_path_ini])
- # Load app and base only
- return get_config_object([
- self.app_path_ini,
- self.base_path_ini])
- """
- * Validate Methods
- """
+def configparser_to_dict(parser: RawConfigParser) -> dict[str, dict[str, str]]:
+ return {
+ section: {key: value for key, value in content.items()}
+ for section, content in parser.items()
+ }
+
+
+class ConfigHandler:
+ """Handler for combined config schema and its saved values."""
+
+ def __init__(
+ self, base_schema_path: Path, schema_path: Path | None, ini_path: Path
+ ) -> None:
+ self.base_schema_path = base_schema_path
+ self.schema_path = schema_path
+ self.ini_path = ini_path
+
+ self.ini_path.parent.mkdir(parents=True, exist_ok=True)
+
+ self.config_added: SubscribableEvent[ConfigHandler] = SubscribableEvent()
+ self.config_reset: SubscribableEvent[ConfigHandler] = SubscribableEvent()
+ self.config_deleted: SubscribableEvent[ConfigHandler] = SubscribableEvent()
+
+ @cached_property
+ def id(self) -> str:
+ return str(self.ini_path)
+
+ @cached_property
+ def has_config(self) -> bool:
+ return self.ini_path.is_file()
+
+ @cached_property
+ def base_schema(self) -> FormattedSettingsConfig:
+ return parse_settings_config(self.base_schema_path)
+
+ @cached_property
+ def schema(self) -> FormattedSettingsConfig | None:
+ return parse_settings_config(self.schema_path) if self.schema_path else None
+
+ @cached_property
+ def ini_schema(self) -> type[BaseModel]:
+ sections: dict[str, dict[str, Any]] = {}
+ for schema in (
+ (self.base_schema.root, self.schema.root)
+ if self.schema
+ else (self.base_schema.root,)
+ ):
+ for entry in schema:
+ if not isinstance(entry, SectionTitle):
+ sections.setdefault(entry.section, {})
+
+ # Early bind entry's value to avoid using the last value of
+ # the loop within each function
+ def validator_factory(item: TypedSetting = entry):
+ # Set invalid settings to their default value
+ def validator(v: Any, handler: Callable[[Any], Any]) -> Any:
+ try:
+ return handler(v)
+ except ValidationError:
+ return item.default
+
+ return validator
+
+ sections[entry.section][entry.key] = Annotated[
+ _setting_to_python_type[entry.type] | None,
+ Field(default=entry.default),
+ WrapValidator(validator_factory()),
+ ]
+
+ root_fields: dict[str, Any] = {}
+ for key, item in sections.items():
+ model = create_model(key, **item)
+
+ def factory(modl: type[BaseModel] = model):
+ def validator(v: Any, handler: Callable[[Any], Any]) -> Any:
+ try:
+ return handler(v)
+ except ValidationError:
+ return modl()
+
+ return validator
+
+ root_fields[key] = Annotated[
+ model, Field(default_factory=model), WrapValidator(factory())
+ ]
+
+ return create_model(
+ "ConfigINISchema",
+ **root_fields,
+ )
+
+ @cached_property
+ def _parser(self) -> RawConfigParser:
+ parser = CustomConfigParser(default_section="", allow_no_value=True)
+ if self.ini_path.is_file():
+ parser.read_string(self.ini_path.read_text(encoding="utf-8"))
+ return parser
+
+ @property
+ def parser(self) -> RawConfigParser:
+ vals = self.setting_values
+ self._parser.clear()
+ self._parser.read_dict(vals)
+ return self._parser
- def validate_configs(self) -> None:
- """Validate app and base configs against data schemas."""
- verify_config_fields(
- ini_file=self.app_path_ini,
- data_file=self.app_path_schema)
- verify_config_fields(
- ini_file=self.base_path_ini,
- data_file=self.base_path_schema)
-
- def validate_template_configs(self) -> None:
- """Validate template configs against data schemas."""
- if not self.template_path_ini:
- return
-
- # Validate base template configs
- mkdir_full_perms(self.template_path_ini.parent)
- copy_config_or_verify(
- path_from=self.base_path_ini,
- path_to=self.template_path_ini,
- data_file=self.base_path_schema)
-
- # Validate template specific configs
- if self.template_path_schema:
- verify_config_fields(
- ini_file=self.template_path_ini,
- data_file=self.template_path_schema)
+ @cached_property
+ def _initial_setting_values(self) -> BaseModel | None:
+ return None
+
+ @cached_property
+ def setting_values(self) -> dict[str, dict[str, int | float | str | bool]]:
+ """Use `set_value` to change values. Otherwise the GUI might end up showing incorrect state
+ about the config being set or not."""
+ self._initial_setting_values = self.ini_schema.model_validate(
+ configparser_to_dict(self._parser)
+ )
+ values = self._initial_setting_values.model_dump()
+ return values
+
+ def set_value(
+ self, section: str, key: str, value: int | float | str | bool
+ ) -> bool:
+ if section in self.setting_values and key in self.setting_values[section]:
+ self.setting_values[section][key] = value
+ return True
+ return False
+
+ def save(
+ self,
+ force: bool = False,
+ ) -> None:
+ # Save only if something has changed
+ if force or (
+ self._initial_setting_values
+ and self._initial_setting_values
+ != self.ini_schema.model_validate(self.setting_values)
+ ):
+ parser = self.parser
+ with open(self.ini_path, "w", encoding="utf-8") as f:
+ parser.write(f)
+ self.has_config = True
+ self.config_added.trigger(self)
+
+ def reset(self) -> None:
+ self.setting_values = self.ini_schema().model_dump()
+ self.config_reset.trigger(self)
+
+ def delete(self, notify: bool = True) -> None:
+ self.ini_path.unlink(missing_ok=True)
+ if notify:
+ self.config_deleted.trigger(self)
+
+ # region Config getters
+
+ def get_setting(
+ self,
+ section: str,
+ key: str,
+ default: int | float | str | bool | None = None,
+ ) -> int | float | str | bool | None:
+ if sect := self.setting_values.get(section, None):
+ return sect.get(key, default)
+ return default
+
+ @overload
+ def get_bool_setting(self, section: str, key: str, default: bool) -> bool: ...
+
+ @overload
+ def get_bool_setting(
+ self, section: str, key: str, default: bool | None = None
+ ) -> bool | None: ...
+
+ def get_bool_setting(
+ self, section: str, key: str, default: bool | None = None
+ ) -> bool | None:
+ return bool(self.get_setting(section, key, default))
+
+ @overload
+ def get_int_setting(self, section: str, key: str, default: int) -> int: ...
+
+ @overload
+ def get_int_setting(
+ self, section: str, key: str, default: int | None = None
+ ) -> int | None: ...
+
+ def get_int_setting(
+ self, section: str, key: str, default: int | None = None
+ ) -> int | None:
+ setting = self.get_setting(section, key, None)
+ if setting is not None:
+ return int(setting)
+ return default
+
+ @overload
+ def get_float_setting(self, section: str, key: str, default: float) -> float: ...
+
+ @overload
+ def get_float_setting(
+ self, section: str, key: str, default: float | None = None
+ ) -> float | None: ...
+
+ def get_float_setting(
+ self, section: str, key: str, default: float | None = None
+ ) -> float | None:
+ setting = self.get_setting(section, key, None)
+ if setting is not None:
+ return float(setting)
+ return default
+
+ @overload
+ def get_str_setting(self, section: str, key: str, default: str) -> str: ...
+
+ @overload
+ def get_str_setting(
+ self, section: str, key: str, default: str | None = None
+ ) -> str | None: ...
+
+ def get_str_setting(
+ self, section: str, key: str, default: str | None = None
+ ) -> str | None:
+ setting = self.get_setting(section, key, None)
+ if setting is not None:
+ return str(setting)
+ return default
+
+ @overload
+ def get_enum_setting[T: Enum](
+ self, section: str, key: str, enum_type: type[T], default: T
+ ) -> T: ...
+
+ @overload
+ def get_enum_setting[T: Enum](
+ self, section: str, key: str, enum_type: type[T], default: T | None = None
+ ) -> T | None: ...
+
+ def get_enum_setting[T: Enum](
+ self, section: str, key: str, enum_type: type[T], default: T | None = None
+ ) -> T | None:
+ setting = self.get_setting(section, key, None)
+ if setting is not None:
+ return enum_type(setting)
+ return default
+
+ # endregion Config getters
+
+
+# endregion Configs
+
+# region Plugins
+
+_template_import_lock = Lock()
class AppPlugin:
@@ -459,49 +556,68 @@ class AppPlugin:
Attributes:
_root (Path): Root path where the plugin's files are located.
_manifest (Path): Path to the plugin's manifest file.
- _templates (dict[str, TemplateDetails]): Data loaded from manifest file.
- _info (PluginMetadata): Top level table from the manifest file containing the plugin's metadata.
+ _templates (dict[str, TemplateFileInfo]): Data loaded from manifest file.
+ _info (PluginInfo): Top level table from the manifest file containing the plugin's metadata.
_module (ModuleType): This plugin's loaded Python module.
Raises:
FileNotFoundError: If the plugin path or manifest file couldn't be found.
ModuleNotFoundError: If the plugin's python module couldn't be found.
"""
- def __init__(self, con: AppConstants, env: AppEnvironment, path: Path):
+
+ def __init__(
+ self,
+ con: AppConstants,
+ env: AppEnvironment,
+ path: Path,
+ template_file_versions: dict[str, str],
+ ):
+ self._module: ModuleType | None = None
# Save a reference to the global application constants
self.con: AppConstants = con
self.env: AppEnvironment = env
+ self.versions = template_file_versions
# Ensure path exists
if not path.is_dir():
- raise FileNotFoundError(f"Couldn't locate plugin path: {str(path)}")
+ raise FileNotFoundError(f"Couldn't locate plugin path: {path}")
self._root = path
# Find a valid manifest
- self._manifest = Path(path, 'manifest.yml')
- if not self._manifest.is_file():
- self._manifest = Path(path, 'manifest.yaml')
- if not self._manifest.is_file():
- self._manifest = Path(path, 'manifest.json')
- if not self._manifest.is_file():
- raise FileNotFoundError(f"Couldn't locate manifest file for plugin at path: {str(path)}")
+ plugin_manifest_path = Path(path, "manifest.yml")
+ if not plugin_manifest_path.is_file():
+ plugin_manifest_path = plugin_manifest_path.with_suffix(".yaml")
+ if not plugin_manifest_path.is_file():
+ plugin_manifest_path = plugin_manifest_path.with_suffix(".json")
+ if not plugin_manifest_path.is_file():
+ plugin_manifest_path = plugin_manifest_path.with_suffix(".toml")
+ if not plugin_manifest_path.is_file():
+ raise FileNotFoundError(
+ f"Couldn't locate manifest file for plugin at path: {path}"
+ )
# Load the manifest data
- self.load_manifest() if self._manifest.suffix.lower() != '.json' else self.load_manifest_json()
+ if plugin_manifest_path.suffix == ".json":
+ # Backwards compatibility
+ plugin_manifest = self.load_manifest_json_legacy(plugin_manifest_path)
+ else:
+ plugin_manifest: PluginManifest = parse_model(
+ plugin_manifest_path, PluginManifest
+ )
- # Attempt to load this plugin's module
- self.load_module()
+ self._info: PluginInfo = plugin_manifest.plugin
+ self._templates: dict[str, TemplateFileInfo] = plugin_manifest.files
# Load templates
self.load_templates()
- """
- * Tracked Properties
- """
+ @cached_property
+ def id(self) -> str:
+ return self._root.name
@cached_property
- def template_map(self) -> dict[str, 'AppTemplate']:
+ def template_map(self) -> dict[str, AppTemplate]:
"""dict[str, AppTemplate]: A dictionary mapping of AppTemplate's by file name pulled from this plugin."""
return {}
@@ -509,25 +625,29 @@ def template_map(self) -> dict[str, 'AppTemplate']:
* Pathing
"""
+ @property
+ def root(self) -> Path:
+ return self._root
+
@cached_property
def path_config(self) -> Path:
"""Path: Path to this plugin's config directory."""
- return Path(self._root, 'config')
+ return Path(self._root, "config")
@cached_property
def path_ini(self) -> Path:
"""Path: Path to this plugin's INI config directory."""
- return Path(self._root, 'config_ini')
+ return Path(self._root, "config_ini")
@cached_property
def path_img(self) -> Path:
"""Path: Path to this plugin's preview image directory."""
- return Path(self._root, 'img')
+ return Path(self._root, "img")
@cached_property
def path_templates(self) -> Path:
"""Path: Path to this plugin's templates directory."""
- return self._root / 'templates'
+ return self._root / "templates"
"""
* Plugin Metadata
@@ -536,124 +656,113 @@ def path_templates(self) -> Path:
@cached_property
def name(self) -> str:
"""str: Displayed name of the plugin. Fallback on root directory name."""
- return self._info.get('name', self._root.stem)
+ return self._info.name if self._info.name is not None else self._root.stem
@cached_property
def author(self) -> str:
"""str: Displayed name of the plugin's author. Fallback on name."""
- return self._info.get('author', self.name)
+ return self._info.author if self._info.author is not None else self.name
@cached_property
- def description(self) -> Optional[str]:
+ def description(self) -> str | None:
"""Optional[str]: Displayed description of the plugin. Fallback on None."""
- return self._info.get('desc', None)
+ return self._info.desc
@cached_property
- def source(self) -> Optional[yarl.URL]:
- """Optional[URL]: Link to the hosted files of this plugin."""
- if url := self._info.get('source'):
- with suppress(Exception):
- url = yarl.URL(url)
- return url
- return
-
- @cached_property
- def docs(self) -> Optional[yarl.URL]:
- """Optional[URL]: Link to the hosted documentation for this plugin."""
- if url := self._info.get('docs'):
- with suppress(Exception):
- url = yarl.URL(url)
- return url
- return
+ def source(self) -> str | None:
+ """Link to the hosted files of this plugin."""
+ return self._info.source
@cached_property
- def license(self) -> str:
- """str: Name of the open source license carried by this plugin. Fallback on MPL-2.0."""
- return self._info.get('license', 'MPL-2.0')
+ def docs(self) -> str | None:
+ """Link to the hosted documentation for this plugin."""
+ return self._info.docs
@cached_property
- def required_version(self) -> Optional[str]:
- """Optional[str]: Proxyshop version this plugin requires to function as intended."""
- return self._info.get('requires', None)
+ def license(self) -> str | None:
+ """Name of the open source license carried by this plugin. Fallback on MPL-2.0."""
+ return self._info.license
@cached_property
def version(self) -> str:
- """str: Current version of the plugin."""
- return self._info.get('version', '0.1.0')
+ """Current version of the plugin."""
+ return self._info.version or "0.0.0"
"""
* Module Details
"""
+ @property
+ def module(self) -> ModuleType | None:
+ if not self._module:
+ self.load_module()
+ return self._module
+
@cached_property
def module_path(self) -> Path:
"""Path: The path to this plugin's Python module."""
- return Path(self._root, 'py')
+ return Path(self._root, "py")
@cached_property
def module_name(self) -> str:
"""str: The name of this plugin's Python module, e.g. 'plugins.MyPlugin.py'."""
- return f'{self._root.name}.py'
+ return f"{self._root.name}.py"
"""
* Plugin Methods
"""
- def load_manifest(self) -> None:
- """Load the `manifest.yml` data for this plugin.
-
- Raises:
- ValueError: If the manifest file contains invalid data.
- """
- try:
- # Load plugin metadata and template details
- data = load_data_file(self._manifest)
- self._info: PluginMetadata = data.pop('PLUGIN', {})
- self._templates: dict[str, ManifestTemplateDetails] = data.copy()
- except Exception as e:
- raise ValueError(f'Manifest file contains invalid data: {str(self._manifest)}') from e
-
- def load_manifest_json(self) -> None:
+ def load_manifest_json_legacy(self, path: Path) -> PluginManifest:
"""Load the legacy `manifest.json` data for this plugin.
Raises:
ValueError: If the manifest file contains invalid data.
"""
+
try:
- # Load Plugin metadata
- data = load_data_file(self._manifest)
- self._info: PluginMetadata = data.pop('PLUGIN', {})
- templates: dict[str, dict[str, dict[str, str]]] = data.copy()
+ with open(path, "r", encoding="UTF-8") as f:
+ # Load Plugin metadata
+ templates: dict[str, Any] | None = load(f)
+ if not isinstance(templates, dict):
+ raise ValueError("Plugin manifest isn't a dictionary")
+ manifest = PluginManifest(plugin=templates.pop("PLUGIN", {}), files={})
except Exception as e:
- raise ValueError(f'Manifest file contains invalid data: {str(self._manifest)}') from e
+ raise ValueError(f"Manifest file contains invalid data: {path}") from e
# Re-format templates dict for TemplateDetails
- self._templates: dict[str, ManifestTemplateDetails] = {}
+ manifest.files = {}
for t, temps in templates.items():
if t not in layout_map_types:
continue
for name, details in temps.items():
# Add new file
- file_name = details.get('file', '')
- class_name = details.get('class', '')
- self._templates.setdefault(
- file_name, {
- 'file': file_name,
- 'templates': {}
- })
+ file_name = details.get("file", "")
+ class_name = details.get("class", "")
+ manifest.files.setdefault(file_name, TemplateFileInfo(templates={}))
# Add Google Drive ID
- if details.get('id'):
- self._templates[file_name]['id'] = details['id']
+ if details.get("id"):
+ manifest.files[file_name].id = details["id"]
+
+ template_type = LayoutType(t)
+
# Existing name
- if name in self._templates[file_name]['templates']:
+ if name in manifest.files[file_name].templates:
# Existing class name
- if class_name in self._templates[file_name]['templates'][name]:
- self._templates[file_name]['templates'][name][class_name].append(t)
+ if class_name in manifest.files[file_name].templates[name]:
+ manifest.files[file_name].templates[name][class_name].append(
+ template_type
+ )
continue
# Add new class
- self._templates[file_name]['templates'][name][class_name] = [t]
+ manifest.files[file_name].templates[name][class_name] = [
+ template_type
+ ]
# Add new template
- self._templates[file_name]['templates'][name] = {class_name: [t]}
+ manifest.files[file_name].templates[name] = {
+ class_name: [template_type]
+ }
+
+ return manifest
def load_templates(self) -> None:
"""Load the dictionary of AppTemplate's pulled from this plugin's manifest file.
@@ -661,153 +770,298 @@ def load_templates(self) -> None:
Returns:
A dictionary where keys are PSD/PSB filenames and values are AppTemplate objects.
"""
+ # Use one config manager per python class
+ config_managers: dict[str, ConfigHandler] = {}
for file_name, data in self._templates.items():
- data['file'] = file_name
self.template_map[file_name] = AppTemplate(
con=self.con,
env=self.env,
data=data,
- plugin=self)
+ file_name=file_name,
+ config_managers=config_managers,
+ versions=self.versions,
+ plugin=self,
+ )
def load_module(self, hotswap: bool = False) -> None:
"""Load the plugin's Python module."""
-
# Generate a 'py' module if it doesn't exist
if not self.module_path.is_dir():
self.module_path.mkdir(mode=777, parents=True, exist_ok=True)
# Check if root has init
- root_init = self._root / '__init__.py'
- has_root_init = bool(root_init.is_file())
- if not has_root_init:
- with open(root_init, 'w', encoding='utf-8') as f:
- f.write('')
- import_module_from_path(
- name=self._root.stem,
- path=root_init,
- hotswap=hotswap)
+ root_init = self._root / "__init__.py"
+ has_root_init = root_init.is_file()
if not has_root_init:
- os.remove(root_init)
+ with open(root_init, "w", encoding="utf-8") as f:
+ f.write("")
+ import_module_from_path(name=self._root.stem, path=root_init, hotswap=hotswap)
+ # if not has_root_init:
+ # try:
+ # os.remove(root_init)
+ # except OSError:
+ # print("Failed to remove temporary plugin init file:", str(root_init))
+ # print_exc()
# Ensure init file in py module
- if not Path(self.module_path, '__init__.py').is_file():
- with open(Path(self.module_path, '__init__.py'), 'w', encoding='utf-8') as f:
- f.write('from .templates import *')
+ if not Path(self.module_path, "__init__.py").is_file():
+ with open(
+ Path(self.module_path, "__init__.py"), "w", encoding="utf-8"
+ ) as f:
+ f.write("from .templates import *")
# Attempt to load this module
self._module = import_package(
- name=self.module_name,
- path=self.module_path,
- hotswap=hotswap)
+ name=self.module_name, path=self.module_path, hotswap=hotswap
+ )
- def get_template_list(self) -> list['AppTemplate']:
+ def get_template_list(self) -> list[AppTemplate]:
"""list[AppTemplate]: Returns a list of AppTemplate's pulled from this plugin."""
return list(self.template_map.values())
-class AppTemplate:
- """Represents a template definition from a `manifest.yml` file.
+def get_all_plugins(
+ con: AppConstants, env: AppEnvironment, template_file_versions: dict[str, str]
+) -> dict[str, AppPlugin]:
+ """Gets a dict of 'AppPlugin' objects mapped by their name attribute.
Args:
- con: Global application constants object.
- data: Template data pulled from `manifest.yml` file.
- plugin: Loaded Proxyshop plugin object representing a given plugin directory from `/plugins/`.
+ con: Global constants object.
+ env: Global environment object.
- Attributes:
- _info (TemplateMetadata): This template's metadata info.
- plugin (AppPlugin): The `plugin` this template was loaded from, if provided.
- map (TemplateTypeMap): Template's types, mapped to names, mapped to details.
- manifest_map (TemplateClassMap): Template's display names, mapped to classes, mapped to types.
+ Returns:
+ A mapping of plugin names to their respective 'AppPlugin' object.
"""
+ plugins: dict[str, AppPlugin] = {}
- def __init__(
- self,
- con: AppConstants,
- env: AppEnvironment,
- data: ManifestTemplateDetails,
- plugin: Optional[AppPlugin] = None):
+ # Load all plugins and plugin templates
+ for folder in [p for p in PATH.PLUGINS.iterdir() if p.is_dir()]:
+ if folder.stem.startswith("__") or folder.stem.startswith("!"):
+ continue
+ try:
+ plugin = AppPlugin(
+ con=con,
+ env=env,
+ path=folder,
+ template_file_versions=template_file_versions,
+ )
+ plugins[plugin.name] = plugin
+ except Exception:
+ print_exc()
+ return dict(sorted(plugins.items()))
- # Save a reference to the global application constants
- self.con: AppConstants = con
- self.env: AppEnvironment = env
- # Save the template's plugin and class map
- self.plugin: Optional[AppPlugin] = plugin
- self.manifest_map: ManifestTemplateMap = {
- name: ({mapped: ['normal']} if isinstance(mapped, str) else {
- k: ([v] if isinstance(v, str) else v) for k, v in mapped.items()
- }) for name, mapped in data['templates'].items()}
- self.generate_template_map(self.manifest_map)
+# endregion Plugins
+
+# region Templates
+
+TemplateFileVersionsModel = RootModel[dict[str, str]]
- # Save the template's metadata
- self._info: TemplateMetadata = data.copy()
- self._info.pop('templates')
+def get_template_file_versions() -> TemplateFileVersionsModel:
+ if PATH.SRC_DATA_VERSIONS.exists():
+ try:
+ return parse_model(PATH.SRC_DATA_VERSIONS, TemplateFileVersionsModel)
+ except ValidationError:
+ pass
+ return TemplateFileVersionsModel({})
+
+
+def get_template_class(
+ class_name: str, plugin: AppPlugin | None = None, force_reload: bool = False
+) -> type[object]:
"""
- * Template Mappings
+ Loads the template class for a template of a given class name.
+
+ Raises:
+ Exception: if module loading fails.
"""
+ with _template_import_lock:
+ if plugin:
+ # Load plugin module
+ if force_reload:
+ plugin.load_module(hotswap=True)
+ module = plugin.module
+ else:
+ # Load local module
+ module = get_local_module(module_path="src.templates", hotswap=force_reload)
+ return getattr(module, class_name)
+
+
+def sort_layout_categories(
+ items: set[LayoutCategory], place_first: LayoutCategory | None = None
+) -> list[LayoutCategory]:
+ first_item: LayoutCategory | None = None
+ out = list(items)
+ if place_first and (place_first in items):
+ out.remove(place_first)
+ first_item = place_first
+ out.sort()
+ if first_item:
+ out.insert(0, first_item)
+ return out
+
+
+class RenderableTemplate(Protocol):
+ name: str
+ plugin: AppPlugin | None
- def generate_template_map(self, _map: ManifestTemplateMap) -> None:
- """Generates a TemplateTypeMap, the configuration of types mapped to names mapped to details:
- - 'class_name': python class name
- - 'config': ConfigManager object
- - 'object': AppTemplate object
- """
- mapped: TemplateTypeMap = {}
- configs = {}
- for name, classes in _map.items():
- for class_name, types in classes.items():
- for t in types:
-
- # Reuse ConfigManager object for identical class names
- if class_name not in configs:
- configs[class_name] = ConfigManager(
- template_class=class_name, template=self)
-
- # Add to the map
- mapped.setdefault(t, {})
- mapped[t][name] = TemplateDetails(
- name=name,
- class_name=class_name,
- config=configs[class_name],
- object=self)
-
- # Establish the complete map
- self.map = mapped
+ def get_config(self, class_name: str) -> ConfigHandler | None: ...
+
+ def get_class_name_and_file_for_layout(
+ self, layout_type: LayoutType
+ ) -> tuple[str, Path] | None: ...
+
+
+class TemplateClassDetails(TypedDict):
+ config: ConfigHandler
+ layouts: list[LayoutType]
+
+
+class NamedTemplate(RenderableTemplate):
+ def __init__(
+ self,
+ name: str,
+ template_classes: dict[str, TemplateClassDetails],
+ parent: AppTemplate,
+ ) -> None:
+ self.name = name
+ self.template_classes = template_classes
+ self.parent = parent
+ self.plugin = parent.plugin
+
+ @cached_property
+ def layout_categories(self) -> set[LayoutCategory]:
+ categories: set[LayoutCategory] = set()
+ for template_class_details in self.template_classes.values():
+ for layout in template_class_details["layouts"]:
+ categories.add(layout_map_types[layout])
+ return categories
+
+ def get_config(self, class_name: str) -> ConfigHandler | None:
+ if class_name in self.template_classes:
+ return self.template_classes[class_name]["config"]
+
+ def has_config(self, layout_category: LayoutCategory | None = None) -> bool:
+ for template_class_details in self.template_classes.values():
+ if layout_category:
+ contains_layout: bool = False
+ for layout in template_class_details["layouts"]:
+ if layout_category.matches_layout_type(layout_category, layout):
+ contains_layout = True
+ break
+ if not contains_layout:
+ continue
+
+ return template_class_details["config"].has_config
+ return False
+
+ def get_preview_image_path(self, layout: LayoutCategory) -> Path | None:
+ for class_name, template_class_details in self.template_classes.items():
+ for layout_type in template_class_details["layouts"]:
+ if LayoutCategory.matches_layout_type(layout, layout_type):
+ return self.parent.get_path_preview(class_name, layout_type)
+
+ def get_class_name_for_layout(self, layout_type: LayoutType) -> str | None:
+ for class_name, template_class_details in self.template_classes.items():
+ if layout_type in template_class_details["layouts"]:
+ return class_name
+ return None
+
+ def get_class_name_and_file_for_layout(
+ self, layout_type: LayoutType
+ ) -> tuple[str, Path] | None:
+ if class_name := self.get_class_name_for_layout(layout_type):
+ return (class_name, self.parent.path_psd)
+
+
+class AppTemplate:
+ """Represents a template definition from a `manifest.yml` file."""
+
+ def __init__(
+ self,
+ con: AppConstants,
+ env: AppEnvironment,
+ data: TemplateFileInfo,
+ file_name: str,
+ config_managers: dict[str, ConfigHandler],
+ versions: dict[str, str],
+ plugin: AppPlugin | None = None,
+ ):
+ self.con: AppConstants = con
+ self.env: AppEnvironment = env
+ self._info: TemplateFileInfo = data
+ self.file_name = file_name
+ self.versions = versions
+ self.plugin: AppPlugin | None = plugin
+
+ self.template_installed: SubscribableEvent[None] = SubscribableEvent()
+
+ # Initialize per template class config managers
+ self.config_managers: dict[str, ConfigHandler] = {}
+ for classes in data.templates.values():
+ for class_name in classes:
+ if class_name not in self.config_managers:
+ if not (conf_mgr := config_managers.get(class_name, None)):
+ conf_mgr = ConfigHandler(
+ base_schema_path=PATH.SRC_DATA_CONFIG_BASE,
+ schema_path=conf_path
+ if (conf_path := self.get_path_config(class_name)).is_file()
+ else None,
+ ini_path=self.get_path_ini(class_name),
+ )
+ config_managers[class_name] = conf_mgr
+ self.config_managers[class_name] = conf_mgr
+
+ self.named_templates: dict[str, NamedTemplate] = {
+ name: NamedTemplate(
+ name,
+ {
+ class_name: {
+ "config": self.config_managers[class_name],
+ "layouts": layouts,
+ }
+ for class_name, layouts in classes.items()
+ },
+ self,
+ )
+ for name, classes in data.templates.items()
+ }
+ self.manifest_map: ManifestTemplateMap = data.templates
"""
* Template Metadata
"""
@cached_property
- def name(self) -> str:
- """str: Name of the template displayed in download manager menus."""
- return self._info.get('name', self.generate_template_name())
+ def id(self) -> str:
+ """A unique identifier for the template."""
+ return str(self.path_psd)
@cached_property
- def file_name(self) -> str:
- """str: File name of the template PSD/PSB file."""
- file_name = self._info.get('file')
- if not file_name:
- raise ValueError(f"Template '{self.name}' did not provide a file name!")
- return file_name
+ def name(self) -> str:
+ """str: Name of the template displayed in download manager menus."""
+ return (
+ self._info.name
+ if self._info.name is not None
+ else self.generate_template_name()
+ )
@cached_property
- def google_drive_id(self) -> Optional[str]:
- """Optional[str]: The template's Google Drive file ID, fallback to None."""
- return self._info.get('id', None)
+ def google_drive_id(self) -> str | None:
+ """The template's Google Drive file ID, fallback to None."""
+ return self._info.id
@cached_property
- def description(self) -> Optional[str]:
- """Optional[str]: The template's displayed description, fallback to None."""
- return self._info.get('desc', None)
+ def description(self) -> str | None:
+ """The template's displayed description, fallback to None."""
+ return self._info.desc
@property
- def version(self) -> Optional[str]:
- """Optional[str]: The template's currently logged version."""
- if not self.google_drive_id:
- return
- return self.con.versions.get(self.google_drive_id)
+ def version(self) -> str | None:
+ """The version of the template's installed PSD file."""
+ if self.google_drive_id:
+ return self.versions.get(self.google_drive_id, None)
"""
* Template Update Data
@@ -820,19 +1074,19 @@ def _update(self) -> TemplateUpdate:
return {}
@property
- def update_file(self) -> Optional[str]:
+ def update_file(self) -> str | None:
"""Optional[str]: Returns the filename of the fetched updated version of this template."""
- return self._update.get('name')
+ return self._update.get("name")
@property
- def update_size(self) -> Optional[int]:
+ def update_size(self) -> int | None:
"""Optional[int]: Returns the size in bytes of the fetched updated version of this template."""
- return self._update.get('size')
+ return self._update.get("size")
@property
- def update_version(self) -> Optional[str]:
+ def update_version(self) -> str | None:
"""Optional[str]: Returns the version number of the fetched updated version of this template."""
- return self._update.get('version')
+ return self._update.get("version")
"""
* Boolean Properties
@@ -840,72 +1094,59 @@ def update_version(self) -> Optional[str]:
@property
def is_installed(self) -> bool:
- """bool: Whether PSD/PSB file for this template is installed."""
- # Todo: Add version checking
- if not self.path_psd.is_file():
- return False
- for required_template in self.requirements.get('templates', []):
- if not Path(self.path_psd.parent, required_template).is_file():
- return False
- return True
+ """Whether PSD/PSB file for this template is installed."""
+ return self.path_psd.is_file()
"""
* Template Paths
"""
+ @cached_property
+ def path_templates(self) -> Path:
+ return self.plugin.path_templates if self.plugin else PATH.TEMPLATES
+
@cached_property
def path_psd(self) -> Path:
"""Path: Path to the PSD/PSB file."""
- root = self.plugin.path_templates if self.plugin else PATH.TEMPLATES
- return root / self.file_name
+ return self.path_templates / self.file_name
@cached_property
def path_7z(self) -> Path:
"""Path: Path to the 7z archive downloaded for this plugin."""
- return self.path_psd.with_suffix('.7z')
+ return self.path_psd.with_suffix(".7z")
@property
- def path_download(self) -> Optional[Path]:
+ def path_download(self) -> Path | None:
"""Optional[Path]: Path the file should be saved to when downloaded, if update ready."""
if self.update_file:
- root = self.plugin.path_templates if self.plugin else PATH.TEMPLATES
- return root / self.update_file
+ return self.path_templates / self.update_file
return
- """
- * Template Requirements
- """
-
- @property
- def requirements(self) -> TemplateRequirements:
- """TemplateRequirements: Requirements that must be met for this template to be supported."""
- return self._info.get('requires', {})
-
"""
* Template URLs
"""
@property
- def url_amazon(self) -> Optional[yarl.URL]:
+ def url_amazon(self) -> yarl.URL | None:
"""yarl.URL: Amazon download URL for this template."""
if self.env.API_AMAZON and self.update_file:
with suppress(Exception):
base = yarl.URL(self.env.API_AMAZON)
# Add plugin name to URL
if self.plugin:
- base = base / self.plugin._root.name
+ base = base / self.plugin.root.name
return base / self.update_file
return
@property
- def url_google_drive(self) -> Optional[yarl.URL]:
+ def url_google_drive(self) -> yarl.URL | None:
"""yarl.URL: Google Drive download URL for this template."""
if self.env.API_GOOGLE and self.update_file and self.google_drive_id:
with suppress(Exception):
# Return Google Drive URL with file ID
- return yarl.URL(
- 'https://drive.google.com/uc'
- ).with_query({'id': self.google_drive_id})
+ return yarl.URL("https://drive.google.com/uc").with_query(
+ {"id": self.google_drive_id}
+ )
return
"""
@@ -913,76 +1154,86 @@ def url_google_drive(self) -> Optional[yarl.URL]:
"""
@cached_property
- def types_supported(self) -> list[str]:
- """set[str]: A set of all types supported by this template."""
- return list({
- t for class_map in self.manifest_map.values()
+ def supported_layout_types(self) -> list[LayoutType]:
+ """All the types supported by this template."""
+ types = {
+ t
+ for class_map in self.manifest_map.values()
for types in class_map.values()
for t in types
- })
+ }
+
+ # Start the list with "normal" if it is supported
+ supports_normal = LayoutType.Normal in types
+ if supports_normal:
+ types.remove(LayoutType.Normal)
+
+ sorted_types = list(types)
+ sorted_types.sort()
+
+ if supports_normal:
+ sorted_types.insert(0, LayoutType.Normal)
+
+ return sorted_types
+
+ @cached_property
+ def supported_layout_categories(self) -> list[LayoutCategory]:
+ return sort_layout_categories(
+ {
+ layout_map_types[layout_type]
+ for layout_type in self.supported_layout_types
+ },
+ place_first=LayoutCategory.Normal,
+ )
@cached_property
def all_names(self) -> list[str]:
- """set[str]: A set of all display names used by this template."""
+ """All display names used by this template."""
return list({name for name in self.manifest_map.keys()})
@cached_property
def all_classes(self) -> list[str]:
- """set[str]: A set of all python classes used by this template."""
- return list({cls_name for class_map in self.manifest_map.values() for cls_name in class_map.keys()})
+ """All python classes used by this template."""
+ return list(
+ {
+ cls_name
+ for class_map in self.manifest_map.values()
+ for cls_name in class_map.keys()
+ }
+ )
+
+ @cached_property
+ def all_classes_and_layouts(self) -> dict[str, list[LayoutType]]:
+ classes_and_layouts: dict[str, list[LayoutType]] = {}
+ for class_map in self.manifest_map.values():
+ for cls_name, layouts in class_map.items():
+ classes_and_layouts.setdefault(cls_name, [])
+ classes_and_layouts[cls_name].extend(layouts)
+ return classes_and_layouts
"""
* Template Utils
"""
- def get_template_class(self, class_name: str) -> Any:
- """Loads the template class for a template of a given class name.
-
- Args:
- class_name: Name of the template's python class.
-
- Returns:
- The template's loaded python class.
- """
-
- # Try loading local module
- if not self.plugin:
- try:
- module = get_local_module(
- module_path='src.templates',
- hotswap=self.env.FORCE_RELOAD)
- return getattr(module, class_name)
- except Exception as e:
- # Failed to load module
- print_tb(e.__traceback__)
- return print(e)
-
- # Try loading plugin module
- try:
- if self.env.FORCE_RELOAD:
- self.plugin.load_module(hotswap=True)
- return getattr(self.plugin._module, class_name)
- except Exception as e:
- # Failed to load module
- print_tb(e.__traceback__)
- return print(e)
-
def generate_template_name(self) -> str:
"""Generate an automatic name when name isn't manually defined."""
# Use first name on the list and look for types supported for that name
name = self.all_names[0]
- supported = self.types_supported.copy() if len(self.all_names) == 1 else {
- t for types in self.manifest_map[name].values() for t in types}
- if 'normal' in supported:
- supported.remove('normal')
+ supported = (
+ self.supported_layout_types.copy()
+ if len(self.all_names) == 1
+ else {t for types in self.manifest_map[name].values() for t in types}
+ )
+ if LayoutType.Normal in supported:
+ supported.remove(LayoutType.Normal)
# When 'normal' type is present, just use the name
if not supported:
return self.all_names[0]
# Format types into display names
- cats = set()
+ cats: set[str] = set()
for cat, types in layout_map_display_condition_dual.items():
# Add both face types
if all([n in supported for n in types]):
@@ -1009,46 +1260,50 @@ def check_for_update(self) -> bool:
Returns:
True if Template needs to be updated, otherwise False.
"""
+ if not self.google_drive_id:
+ print(f"{self.name} ({self.file_name}) does not have a Google Drive ID")
+ return False
+
# Get our metadata
- data = gdrive_get_metadata(self.google_drive_id, self.env.API_GOOGLE)
- if not data:
- # File couldn't be located on Google Drive
- print(f"{self.name} ({self.file_name}) not found on Google Drive!")
+ if not (data := gdrive_get_metadata(self.google_drive_id, self.env.API_GOOGLE)):
+ print(f"{self.name} ({self.file_name}) not found on Google Drive")
return False
# Cache update data
- self._update: TemplateUpdate = {
- 'version': data.get('description', 'v1.0.0'),
- 'name': data.get('name', self.file_name),
- 'size': data['size']
+ self._update = {
+ "version": data.get("description", "v1.0.0"),
+ "name": data.get("name", self.file_name),
+ "size": data["size"],
}
# Compare the versions
- self.validate_version()
+ self._validate_version()
if not self.version:
return True
- if normalize_ver(self.version) == normalize_ver(self.update_version):
+ if self.update_version and normalize_ver(self.version) == normalize_ver(
+ self.update_version
+ ):
return False
# Template needs an update
return True
- def validate_version(self) -> None:
+ def _validate_version(self) -> None:
"""Checks the current on-file version of this template and if the template is installed,
updates the version tracker accordingly."""
-
- # If installed but version is not logged, log default version
- if self.is_installed and not self.version:
- self.con.versions[self.google_drive_id] = '1.0.0'
- self.con.update_version_tracker()
- return
-
- # If installed but version mistakenly logged, reset
- if not self.is_installed and self.version:
- del self.con.versions[self.google_drive_id]
- self.con.update_version_tracker()
-
- def update_template(self, callback: Callable) -> bool:
+ if self.google_drive_id:
+ # If installed but version is not logged, log default version
+ if self.is_installed and not self.version:
+ self.versions[self.google_drive_id] = "1.0.0"
+ return
+
+ # If installed but version mistakenly logged, reset
+ if not self.is_installed and self.version:
+ del self.versions[self.google_drive_id]
+
+ def update_template(
+ self, callback: Callable[[int, int], None] | None = None
+ ) -> bool:
"""Update a given template to the latest version.
Args:
@@ -1057,43 +1312,63 @@ def update_template(self, callback: Callable) -> bool:
Returns:
True if succeeded, False if failed.
"""
- try:
- # Download using Google Drive
- result = gdrive_download_file(
- url=self.url_google_drive,
- path=self.path_download,
- path_cookies=PATH.LOGS_COOKIES,
- callback=callback
- ) if self.google_drive_id else False
-
- # Google Drive failed or isn't an option, download from Amazon S3
- if not result:
- result = download_cloudfront(
- url=self.url_amazon,
- path=self.path_download,
- callback=callback)
-
- # Return result status
- return result
-
- # Exception caught while downloading / unpacking
- except Exception as e:
- print(e)
- return False
+ def get_fail_message() -> str:
+ return f"Failed to update template: {self.name}"
- def mark_updated(self) -> None:
- """Update the version tracker with the currently logged update version and clear the update data."""
- if self.google_drive_id and self.update_version:
- self.con.versions[self.google_drive_id] = self.update_version
- self.con.update_version_tracker()
- self._update = {}
+ if self.path_download:
+ try:
+ result: bool = False
+ will_install = not self.is_installed
+
+ if self.google_drive_id and self.url_google_drive:
+ # Download using Google Drive
+ result = bool(
+ gdrive_download_file(
+ url=self.url_google_drive,
+ path=self.path_download,
+ path_cookies=PATH.LOGS_COOKIES,
+ callback=callback,
+ )
+ )
+
+ # If downloaded file is a 7z archive, extract the template from it
+ if result and self.path_download.suffix == ".7z":
+ with SevenZipFile(self.path_7z, "r") as archive:
+ archive.extractall(self.path_templates)
+ self.path_7z.unlink(missing_ok=True)
+
+ if self.url_amazon and not result:
+ # Google Drive failed or isn't an option, download from Amazon S3
+ result = download_cloudfront(
+ url=self.url_amazon,
+ path=self.path_download,
+ callback=callback,
+ )
+
+ if self.google_drive_id and self.update_version:
+ self.versions[self.google_drive_id] = self.update_version
+
+ if will_install:
+ self.template_installed.trigger(None)
+
+ if not result:
+ _logger.error(get_fail_message())
+
+ return result
+
+ # Exception caught while downloading / unpacking
+ except Exception:
+ _logger.exception(get_fail_message())
+ else:
+ _logger.error("Template update failed. Download path isn't specified.")
+ return False
"""
* Pathing Utils
"""
- def get_path_preview(self, class_name: str, class_type: str) -> Path:
+ def get_path_preview(self, class_name: str, class_type: LayoutType) -> Path:
"""Gets the path to a preview image for a given template name and type.
Args:
@@ -1107,8 +1382,8 @@ def get_path_preview(self, class_name: str, class_type: str) -> Path:
root = self.plugin.path_img if self.plugin else PATH.SRC_IMG_PREVIEWS
# Try with type provided, then with just the class name, fallback to "Not Found" image
- path = (root / f'{class_name}[{class_type}]').with_suffix('.jpg')
- path = path if path.is_file() else (root / f'{class_name}').with_suffix('.jpg')
+ path = (root / f"{class_name}[{class_type}]").with_suffix(".jpg")
+ path = path if path.is_file() else (root / f"{class_name}").with_suffix(".jpg")
return path if path.is_file() else PATH.SRC_IMG_NOTFOUND
def get_path_config(self, class_name: str) -> Path:
@@ -1122,13 +1397,13 @@ def get_path_config(self, class_name: str) -> Path:
"""
# Get plugin template config
if self.plugin:
- json_path = (self.plugin.path_config / class_name).with_suffix('.json')
+ json_path = (self.plugin.path_config / class_name).with_suffix(".json")
if json_path.is_file():
return json_path
- return json_path.with_suffix('.toml')
+ return json_path.with_suffix(".toml")
# Get app template config
- return (PATH.SRC_DATA_CONFIG / class_name).with_suffix('.toml')
+ return (PATH.SRC_DATA_CONFIG / class_name).with_suffix(".toml")
def get_path_ini(self, class_name: str) -> Path:
"""Gets the path to an INI config file for a given template class.
@@ -1141,46 +1416,206 @@ def get_path_ini(self, class_name: str) -> Path:
"""
# Is this a plugin template?
if self.plugin:
- return (self.plugin.path_ini / class_name).with_suffix('.ini')
- return (PATH.SRC_DATA_CONFIG_INI / class_name).with_suffix('.ini')
+ return (self.plugin.path_ini / class_name).with_suffix(".ini")
+ return (PATH.SRC_DATA_CONFIG_INI / class_name).with_suffix(".ini")
-"""
-* Plugin Utils
-"""
+class AssembledTemplateInstalledArgs(TypedDict):
+ sender: AssembledTemplate
+ origin: AppTemplate
-def get_all_plugins(con: AppConstants, env: AppEnvironment) -> dict[str, AppPlugin]:
- """Gets a dict of 'AppPlugin' objects mapped by their name attribute.
+class AssembledTemplateConfigChangedArgs(TypedDict):
+ sender: AssembledTemplate
+ class_name: str
+ config: ConfigHandler
+ has_config: bool
- Args:
- con: Global constants object.
- env: Global environment object.
- Returns:
- A mapping of plugin names to their respective 'AppPlugin' object.
- """
- plugins: dict[str, AppPlugin] = {}
+class AssembledTemplate(RenderableTemplate):
+ def __init__(
+ self, name: str, templates: list[NamedTemplate], plugin: AppPlugin | None = None
+ ) -> None:
+ self.name = name
+ self.templates = templates
+ self.plugin = plugin
+
+ self.template_installed: SubscribableEvent[AssembledTemplateInstalledArgs] = (
+ SubscribableEvent()
+ )
+ for parent_template in set([templ.parent for templ in templates]):
+ parent_template.template_installed.add_listener(
+ lambda _: self._on_child_template_installed(parent_template)
+ )
+
+ self.config_state_changed: SubscribableEvent[
+ AssembledTemplateConfigChangedArgs
+ ] = SubscribableEvent()
+ confs_for_classes: dict[ConfigHandler, str] = {}
+ for template in self.templates:
+ for class_name, template_details in template.template_classes.items():
+ confs_for_classes.setdefault(template_details["config"], class_name)
+ for config, class_name in confs_for_classes.items():
+ config.config_added.add_listener(
+ lambda _: self._on_config_state_changed(
+ class_name=class_name, config=config, has_config=True
+ )
+ )
+ config.config_deleted.add_listener(
+ lambda _: self._on_config_state_changed(
+ class_name=class_name, config=config, has_config=False
+ )
+ )
- # Load all plugins and plugin templates
- for folder in [p for p in PATH.PLUGINS.iterdir() if p.is_dir()]:
- if folder.stem.startswith('__') or folder.stem.startswith('!'):
- continue
- try:
- plugin = AppPlugin(con=con, env=env, path=folder)
- plugins[plugin.name] = plugin
- except Exception as e:
- print_tb(e.__traceback__)
- print(e)
- return dict(sorted(plugins.items()))
+ @cached_property
+ def layout_categories(self) -> set[LayoutCategory]:
+ categories: set[LayoutCategory] = set()
+ return categories.union(
+ *[template.layout_categories for template in self.templates]
+ )
+ @property
+ def installed_template_files(self) -> list[str]:
+ return [
+ parent_template.file_name
+ for parent_template in set([templ.parent for templ in self.templates])
+ if parent_template.is_installed
+ ]
-"""
-* Template Utils
-"""
+ @property
+ def missing_template_files(self) -> list[str]:
+ return [
+ parent_template.file_name
+ for parent_template in set([templ.parent for templ in self.templates])
+ if not parent_template.is_installed
+ ]
+
+ def is_installed(self, layout_category: LayoutCategory | None = None) -> bool:
+ for template in self.templates:
+ if layout_category and layout_category not in template.layout_categories:
+ continue
+
+ if template.parent.is_installed:
+ return True
+ return False
+
+ def has_config(self, layout_category: LayoutCategory | None = None) -> bool:
+ for template in self.templates:
+ if layout_category and layout_category not in template.layout_categories:
+ continue
+
+ if template.has_config(layout_category):
+ return True
+ return False
+
+ def get_config(self, class_name: str) -> ConfigHandler | None:
+ for template in self.templates:
+ if conf_mgr := template.get_config(class_name):
+ return conf_mgr
+
+ def get_preview_image_path(self, layout: LayoutCategory) -> Path | None:
+ for template in self.templates:
+ if preview_path := template.get_preview_image_path(layout):
+ return preview_path
+
+ def get_class_name_and_file_for_layout(
+ self, layout_type: LayoutType
+ ) -> tuple[str, Path] | None:
+ for template in self.templates:
+ if class_name := template.get_class_name_for_layout(layout_type):
+ return (class_name, template.parent.path_psd)
+ return None
+
+ def _on_child_template_installed(self, template: AppTemplate) -> None:
+ self.template_installed.trigger(
+ AssembledTemplateInstalledArgs(sender=self, origin=template)
+ )
+
+ def _on_config_state_changed(
+ self, class_name: str, config: ConfigHandler, has_config: bool
+ ) -> None:
+ self.config_state_changed.trigger(
+ AssembledTemplateConfigChangedArgs(
+ sender=self, class_name=class_name, config=config, has_config=has_config
+ )
+ )
-def get_all_templates(con: AppConstants, env: AppEnvironment, plugins: dict[str, AppPlugin]) -> list[AppTemplate]:
+class TemplateLibrary:
+ def __init__(
+ self,
+ con: AppConstants,
+ env: AppEnvironment,
+ initial_template_file_versions: TemplateFileVersionsModel,
+ template_file_versions: TemplateFileVersionsModel,
+ plugins: dict[str, AppPlugin],
+ ) -> None:
+ self.initial_versions = initial_template_file_versions
+ self.versions = template_file_versions
+ self.templates: list[AppTemplate] = get_all_templates(
+ con, env, plugins, template_file_versions.root
+ )
+
+ built_in, grouped_by_plugin = self._group_templates_by_plugin(self.templates)
+
+ self.built_in_templates_by_name = self._group_templates_by_name(built_in)
+ self.plugin_templates_by_name: dict[str, dict[str, AssembledTemplate]] = {}
+
+ for plugin, templates in grouped_by_plugin.items():
+ self.plugin_templates_by_name[plugin.id] = self._group_templates_by_name(
+ templates, plugin
+ )
+
+ def save_template_versions(self) -> None:
+ if self.initial_versions != self.versions:
+ dump_model(PATH.SRC_DATA_VERSIONS, self.versions)
+
+ def get_templates_for_layout_category(
+ self, layout_category: LayoutCategory
+ ) -> list[AssembledTemplate]:
+ templates: list[AssembledTemplate] = []
+ for template in self.built_in_templates_by_name.values():
+ if template.is_installed(layout_category):
+ templates.append(template)
+ for plugin_templates in self.plugin_templates_by_name.values():
+ for template in plugin_templates.values():
+ if template.is_installed(layout_category):
+ templates.append(template)
+ return templates
+
+ def _group_templates_by_plugin(
+ self, templates: list[AppTemplate]
+ ) -> tuple[list[AppTemplate], dict[AppPlugin, list[AppTemplate]]]:
+ built_in: list[AppTemplate] = []
+ grouped_by_plugin: dict[AppPlugin, list[AppTemplate]] = {}
+ for template in templates:
+ if template.plugin:
+ grouped_by_plugin.setdefault(template.plugin, [])
+ grouped_by_plugin[template.plugin].append(template)
+ else:
+ built_in.append(template)
+ return (built_in, grouped_by_plugin)
+
+ def _group_templates_by_name(
+ self, templates: list[AppTemplate], plugin: AppPlugin | None = None
+ ) -> dict[str, AssembledTemplate]:
+ grouped: dict[str, list[NamedTemplate]] = {}
+ for root_template in templates:
+ for named_template in root_template.named_templates.values():
+ grouped.setdefault(named_template.name, [])
+ grouped[named_template.name].append(named_template)
+ return {
+ name: AssembledTemplate(name, templates, plugin)
+ for name, templates in grouped.items()
+ }
+
+
+def get_all_templates(
+ con: AppConstants,
+ env: AppEnvironment,
+ plugins: dict[str, AppPlugin],
+ template_file_versions: dict[str, str],
+) -> list[AppTemplate]:
"""Gets a list of all 'AppTemplate' objects.
Args:
@@ -1195,12 +1630,25 @@ def get_all_templates(con: AppConstants, env: AppEnvironment, plugins: dict[str,
templates: list[AppTemplate] = []
# Load the built-in templates
- manifest: dict[str, ManifestTemplateDetails] = load_data_file(PATH.SRC_DATA_MANIFEST)
+ manifest: TemplateFileInfoRoot = parse_model(
+ PATH.SRC_DATA_MANIFEST, TemplateFileInfoRoot
+ )
+
+ # Use one config manager per python template class
+ conifg_managers: dict[str, ConfigHandler] = {}
# Build a TemplateDetails for each template
- for file_name, data in manifest.items():
- data['file'] = file_name
- templates.append(AppTemplate(con=con, env=env, data=data))
+ for file_name, data in manifest.root.items():
+ templates.append(
+ AppTemplate(
+ con=con,
+ env=env,
+ data=data,
+ file_name=file_name,
+ config_managers=conifg_managers,
+ versions=template_file_versions,
+ )
+ )
# Load all plugins and plugin templates
for p in plugins.values():
@@ -1208,135 +1656,17 @@ def get_all_templates(con: AppConstants, env: AppEnvironment, plugins: dict[str,
return templates
-def get_template_map(templates: list[AppTemplate]) -> dict[str, TemplateCategoryMap]:
- """Gets a 'TemplateCategoryMap' mapping all templates to their respective categories and types.
-
- Args:
- templates: A unordered list of all 'AppTemplate' objects.
-
- Returns:
- A 'TemplateCategoryMap' mapping all templates to their categories and types.
- """
- # Establish our core category map
- d: dict[str, TemplateCategoryMap] = {
- type_named: {'names': [], 'map': {}}
- for type_named in layout_map_category.keys()}
-
- # Track names for uniqueness
- names: dict[str, list[str]] = {}
-
- # Iterate over template list and add to the category map
- for template in templates:
- for t, class_map in template.map.items():
- for name, details in class_map.items():
- if t not in layout_map_types:
- continue
- cat = str(layout_map_types[t])
-
- # Ensure name is unique
- if name in names.get(t, []):
- # Add plugin to name if plugin provided
- if details['object'].plugin:
- # Swap this objects name
- name = f"{name} ({details['object'].plugin.name})"
-
- # Iterate over name until it is unique
- i, n = 1, name
- while name in names.get(t, []):
- name, i = f"{n} ({i})", i + 1
-
- # Add template to map and tracked names
- d[cat]['map'].setdefault(t, {})[name] = details
- if name not in d[cat]['names']:
- d[cat]['names'].append(name)
- names.setdefault(t, []).append(name)
-
- # Sort names
- for t, tcm in d.items():
- sorted_names = []
- if 'Normal' in tcm['names']:
- sorted_names.append('Normal')
- tcm['names'].remove('Normal')
- d[t]['names'] = [*sorted_names, *sorted(tcm['names'])]
- return d
-
-
-def get_template_map_defaults(d: dict[str, TemplateCategoryMap]) -> TemplateSelectedMap:
- """Returns a map of selected defaults for a given 'TemplateCategoryMap'.
-
- Args:
- d: A 'TemplateCategoryMap' object mapping all templates to their respective types.
-
- Returns:
- A 'TemplateSelectedMap' mapping the default selected template for each existing template type.
- """
- sel: TemplateSelectedMap = {t: None for t in layout_map_types.keys()}
- first_items: dict[str, TemplateDetails] = {}
- for cat, tcm in d.items():
- for t, name_map in tcm['map'].items():
- for name, details in name_map.items():
- if t not in first_items:
- first_items[t] = details
- if sel.get(t):
- continue
- if name == 'Normal':
- sel[t] = details.copy()
+# endregion Templates
- # Ensure each type has a value, fallback on 'first' appearing items
- for t, details in sel.items():
- if not details:
- sel[t] = first_items.get(t, {}).copy()
- return sel
-
-
-def get_template_map_selected(
- selected: TemplateSelectedMap,
- defaults: TemplateSelectedMap
-) -> TemplateSelectedMap:
- """Merges the template's selected by the users with a provided mapping of default templates.
-
- Args:
- selected: A mapping of selected templates to template types.
- defaults: A mapping of each default template for every existing template type.
-
- Returns:
- A combined mapping of templates to every existing template type.
- """
- combined = defaults.copy()
- for layout, details in selected.items():
- combined[layout] = details.copy()
- return combined
-
-
-"""
-* Updating Templates
-"""
-
-
-def check_for_updates(templates: list[AppTemplate]) -> list[AppTemplate]:
- """Check our app and plugin manifests for template updates.
-
- Args:
- templates: List of all AppTemplate objects.
-
- Returns:
- List of AppTemplate objects needing an update, sorted by template name.
- """
- # Set up our list of templates needing an update
- updates: list[AppTemplate] = []
+# region Symbols
- # Perform threaded version check requests
- with ThreadPoolExecutor(max_workers=os.cpu_count()) as executor:
- results: list[tuple[Future, AppTemplate]] = []
- # Check each template for updates
- for t in templates:
- results.append((executor.submit(t.check_for_update), t))
+def get_symbols_manifest() -> SymbolsManifest | None:
+ if PATH.SRC_IMG_SYMBOLS_MANIFEST.exists():
+ return SymbolsManifest.model_validate_json(
+ PATH.SRC_IMG_SYMBOLS_MANIFEST.read_bytes()
+ )
+ return None
- # Add templates requiring updates
- for r in results:
- if r[0].result():
- updates.append(r[1])
- # Return templates needing updates
- return sorted(updates, key=lambda x: x.name)
+# endregion Symbols
diff --git a/src/_state.py b/src/_state.py
index d0b9f717..eeac79fd 100644
--- a/src/_state.py
+++ b/src/_state.py
@@ -1,43 +1,60 @@
"""
* Manage Global State (non-GUI)
-* Only local imports should be `enums`, `utils`, or `cards`.
"""
-# Standard Library Imports
+
import os
+import sys
from contextlib import suppress
-from functools import cached_property
-from os import environ
+from logging import INFO
from pathlib import Path
-import sys
from threading import Lock
-from typing import Optional, Union, Any
+from typing import TypedDict
-# Third Party Imports
-from hexproof.hexapi import schema as Hexproof
-from omnitils.exceptions import return_on_exception
-from omnitils.files import load_data_file, dump_data_file
-from omnitils.metaclass import Singleton
+from hexproof.hexapi.schema.meta import Meta
+from omnitils.files import get_project_version
from omnitils.properties import tracked_prop
-from dynaconf import Dynaconf
+from pydantic import BaseModel, RootModel
+from pydantic_settings import (
+ BaseSettings,
+ PydanticBaseSettingsSource,
+ SettingsConfigDict,
+ YamlConfigSettingsSource,
+)
-# Local Imports
from src.enums.layers import LAYERS
-from src.enums.mtg import (
- mana_symbol_map,
- CardFonts)
+from src.enums.mtg import CardFonts, mana_symbol_map
from src.schema.colors import ColorObject, SymbolColorMap
+from src.utils.data_structures import parse_model
from src.utils.mtg import get_symbol_colors
+class HexproofSet(BaseModel):
+ """Cached 'Set' object data from Hexproof.io."""
+
+ code_symbol: str = "default"
+ code_parent: str | None = None
+ count_cards: int
+ count_tokens: int
+ count_printed: int | None = None
+
+
+HexproofSets = RootModel[dict[str, HexproofSet]]
+
+HexproofMetas = RootModel[dict[str, Meta]]
+
"""
* App Paths
"""
# Establish global root, based on frozen executable or Python
__PATH_CWD__: Path = Path(os.getcwd())
-__PATH_ROOT__: Optional[Path] = Path(sys.executable).parent if (
- getattr(sys, 'frozen', False)
-) else (Path(__file__).parent.parent if __file__ else __PATH_CWD__)
+__PATH_ROOT__: Path = (
+ # Path handling for PyInstalller build
+ Path(sys.executable).parent
+ if (getattr(sys, "frozen", False))
+ # Path handling for regular Python
+ else (Path(__file__).parent.parent)
+)
# Switch to root directory if current directory differs
if str(__PATH_CWD__) != str(__PATH_ROOT__):
@@ -48,8 +65,8 @@ class DefinedPaths:
"""Class for defining reusable named Path objects."""
@classmethod
- def __init_subclass__(cls, **kwargs):
- super().__init_subclass__(**kwargs)
+ def __init_subclass__(cls):
+ super().__init_subclass__()
cls.ensure_paths()
@classmethod
@@ -68,178 +85,157 @@ def ensure_paths(cls):
class PATH(DefinedPaths):
"""Define app-wide paths that are always relational to the root project path."""
- __metaclass__ = Singleton
+
CWD = __PATH_ROOT__
# Root Level Directories
- SRC = CWD / 'src'
- OUT = CWD / 'out'
- ART = CWD / 'art'
- LOGS = CWD / 'logs'
- FONTS = CWD / 'fonts'
- PLUGINS = CWD / 'plugins'
- TEMPLATES = CWD / 'templates'
- PROJECT_FILE = CWD / 'pyproject.toml'
+ SRC = CWD / "src"
+ OUT = CWD / "out"
+ TMP = CWD / "tmp"
+ LOGS = CWD / "logs"
+ FONTS = CWD / "fonts"
+ PLUGINS = CWD / "plugins"
+ TEMPLATES = CWD / "templates"
+ JS_SCRIPTS = CWD / "dist_js"
+ PROJECT_FILE = CWD / "pyproject.toml"
# Source Level Directories
- SRC_IMG = SRC / 'img'
- SRC_DATA = SRC / 'data'
+ SRC_IMG = SRC / "img"
+ SRC_DATA = SRC / "data"
# Data Level Directories
- SRC_DATA_KV = SRC_DATA / 'kv'
- SRC_DATA_TESTS = SRC_DATA / 'tests'
- SRC_DATA_CONFIG = SRC_DATA / 'config'
- SRC_DATA_HEXPROOF = SRC_DATA / 'hexproof'
- SRC_DATA_CONFIG_INI = SRC_DATA / 'config_ini'
+ SRC_DATA_TESTS = SRC_DATA / "tests"
+ SRC_DATA_CONFIG = SRC_DATA / "config"
+ SRC_DATA_HEXPROOF = SRC_DATA / "hexproof"
+ SRC_DATA_CONFIG_INI = SRC_DATA / "config_ini"
+ SRC_DATA_PREFERENCES = SRC_DATA / "preferences"
# Data Level Files
- SRC_DATA_ENV = SRC_DATA / 'env.yml'
- SRC_DATA_ENV_DEFAULT = SRC_DATA / 'env.default.yml'
- SRC_DATA_WATERMARKS = SRC_DATA / 'watermarks.yml'
- SRC_DATA_MANIFEST = SRC_DATA / 'manifest.yml'
- SRC_DATA_HEXPROOF_SET = (SRC_DATA_HEXPROOF / 'set').with_suffix('.json')
- SRC_DATA_HEXPROOF_META = (SRC_DATA_HEXPROOF / 'meta').with_suffix('.json')
+ SRC_DATA_ENV = SRC_DATA / "env.yml"
+ SRC_DATA_ENV_DEFAULT = SRC_DATA / "env.default.yml"
+ SRC_DATA_WATERMARKS = SRC_DATA / "watermarks.yml"
+ SRC_DATA_MANIFEST = SRC_DATA / "manifest.yml"
+ SRC_DATA_HEXPROOF_SET = SRC_DATA_HEXPROOF / "set.json"
+ SRC_DATA_HEXPROOF_META = SRC_DATA_HEXPROOF / "meta.json"
+
+ # Test Data Files
+ SRC_DATA_TEMPLATE_RENDER_TEST_CASES = SRC_DATA_TESTS / "template_renders.toml"
# Image Level Directories
- SRC_IMG_SYMBOLS = SRC_IMG / 'symbols'
- SRC_IMG_PREVIEWS = SRC_IMG / 'previews'
+ SRC_IMG_SYMBOLS = SRC_IMG / "symbols"
+ SRC_IMG_PREVIEWS = SRC_IMG / "previews"
# Image Level Files
- SRC_IMG_SYMBOLS_PACKAGE = (SRC_IMG_SYMBOLS / 'package').with_suffix('.zip')
- SRC_IMG_OVERLAY = (SRC_IMG / 'overlay').with_suffix('.jpg')
- SRC_IMG_NOTFOUND = (SRC_IMG / 'notfound').with_suffix('.jpg')
+ SRC_IMG_SYMBOLS_PACKAGE = SRC_IMG_SYMBOLS / "package.zip"
+ SRC_IMG_SYMBOLS_MANIFEST = SRC_IMG_SYMBOLS / "manifest.json"
+ SRC_IMG_OVERLAY = SRC_IMG / "overlay.jpg"
+ SRC_IMG_NOTFOUND = SRC_IMG / "notfound.jpg"
+ SRC_IMG_TEST = SRC_IMG / "test.jpg"
+ SRC_IMG_TEST_FULL_ART = SRC_IMG / "test-fa.jpg"
# Config Level Files
- SRC_DATA_CONFIG_APP = (SRC_DATA_CONFIG / 'app').with_suffix('.toml')
- SRC_DATA_CONFIG_BASE = (SRC_DATA_CONFIG / 'base').with_suffix('.toml')
- SRC_DATA_CONFIG_INI_APP = (SRC_DATA_CONFIG_INI / 'app').with_suffix('.ini')
- SRC_DATA_CONFIG_INI_BASE = (SRC_DATA_CONFIG_INI / 'base').with_suffix('.ini')
+ SRC_DATA_CONFIG_APP = SRC_DATA_CONFIG / "app.toml"
+ SRC_DATA_CONFIG_BASE = SRC_DATA_CONFIG / "base.toml"
+ SRC_DATA_CONFIG_INI_APP = SRC_DATA_CONFIG_INI / "app.ini"
+ SRC_DATA_CONFIG_INI_BASE = SRC_DATA_CONFIG_INI / "base.ini"
# Logs Level Files
- LOGS_SCAN = (LOGS / 'scan').with_suffix('.jpg')
- LOGS_ERROR = (LOGS / 'error').with_suffix('.txt')
- LOGS_FAILED = (LOGS / 'failed').with_suffix('.txt')
- LOGS_COOKIES = (LOGS / 'cookies').with_suffix('.json')
+ LOGS_SCAN = LOGS / "scan.jpg"
+ LOGS_ERROR = LOGS / "error.txt"
+ LOGS_FAILED = LOGS / "failed.txt"
+ LOGS_COOKIES = LOGS / "cookies.json"
# Generated user data files
- SRC_DATA_USER = SRC_DATA / 'user.yml'
- SRC_DATA_VERSIONS = SRC_DATA / 'versions.yml'
+ SRC_DATA_USER = SRC_DATA / "user.yml"
+ SRC_DATA_VERSIONS = SRC_DATA / "versions.yml"
"""
* App Environment
"""
-# KIVY Environment
-environ.setdefault('KIVY_LOG_MODE', 'PYTHON')
-environ.setdefault('KIVY_NO_FILELOG', '1')
+def _get_proj_version(path: Path) -> str:
+ if hasattr(sys, "_MEIPASS"):
+ # Build with Pyinstaller generated module
+ import __VERSION__
-class AppEnvironment(Dynaconf):
- """Tracking and modifying global environment behavior."""
+ return str(__VERSION__.version)
+ try:
+ return get_project_version(path)
+ except Exception:
+ return "0.0.0"
- @staticmethod
- def string_or_none(value: Any) -> Optional[str]:
- """Casts a value as either a string OR None.
- Args:
- value: Value to be cast.
+class AppEnvironment(BaseSettings):
+ API_GOOGLE: str = ""
+ API_AMAZON: str = ""
+ PS_ERROR_DIALOG: bool = False
+ PS_VERSION: str | None = None
+ HEADLESS: bool = False
+ LOG_LEVEL: int = INFO
+ APP_UPDATES_REPO: str = ""
+ SYMBOL_UPDATES_REPO: str = ""
+ FORCE_RELOAD: bool = False
+ VERSION: str = _get_proj_version(PATH.PROJECT_FILE)
- Returns:
- A string or None.
- """
- if value in (None, 'null', 'None', ''):
- return None
- return str(value)
+ model_config = SettingsConfigDict(env_prefix="PROXYSHOP_")
- """
- * File Hosting
- """
-
- @cached_property
- def API_GOOGLE(self) -> str:
- """str: Google Drive API key."""
- return super().API_GOOGLE
-
- @cached_property
- def API_AMAZON(self) -> str:
- """str: Amazon S3 cloudfront URL."""
- return super().API_AMAZON
-
- """
- * Photoshop
- """
-
- @cached_property
- def PS_ERROR_DIALOG(self) -> bool:
- """bool: Whether Photoshop error dialogues are enabled."""
- return super().PS_ERROR_DIALOG
-
- @cached_property
- def PS_VERSION(self) -> Optional[str]:
- """str: Photoshop version to try and load, for use when multiple Photoshop versions are installed."""
- if super().PS_VERSION not in ['', None]:
- return super().PS_VERSION
- return None
-
- """
- * Testing
- """
+ @classmethod
+ def settings_customise_sources(
+ cls,
+ settings_cls: type[BaseSettings],
+ init_settings: PydanticBaseSettingsSource,
+ env_settings: PydanticBaseSettingsSource,
+ dotenv_settings: PydanticBaseSettingsSource,
+ file_secret_settings: PydanticBaseSettingsSource,
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
+ # First entry has highest priority
+ return (
+ env_settings,
+ YamlConfigSettingsSource(settings_cls, PATH.SRC_DATA_ENV),
+ YamlConfigSettingsSource(settings_cls, PATH.SRC_DATA_ENV_DEFAULT),
+ )
- @cached_property
- def HEADLESS(self) -> bool:
- """bool: Whether the app is running in headless mode."""
- return super().HEADLESS
- @cached_property
- def DEV_MODE(self) -> bool:
- """bool: Whether the app is running in developer mode."""
- return super().DEV_MODE
+"""
+* App Constants
+"""
- @cached_property
- def TEST_MODE(self) -> bool:
- """bool: Whether the app is running in testing mode."""
- return super().TEST_MODE
- """
- * Experimental
- """
+class WatermarkFormat(TypedDict):
+ scale: float
- @cached_property
- def FORCE_RELOAD(self) -> bool:
- """bool: Whether to force plugin template modules to be reloaded on each new render sequence."""
- return super().FORCE_RELOAD
- @cached_property
- def VERSION(self) -> str:
- """str: Current app version."""
- if hasattr(sys, '_MEIPASS'):
- # Build with Pyinstaller generated module
- import __VERSION__ # noqa
- return str(__VERSION__.version)
- return super().VERSION
+WatermarkFormats = RootModel[dict[str, WatermarkFormat]]
-"""
-* App Constants
-"""
+class CustomUserDefinitions(BaseModel):
+ # Fonts
+ font_rules_text_italic: str | None = None
+ font_rules_text_bold: str | None = None
+ font_rules_text: str | None = None
+ font_collector: str | None = None
+ font_artist: str | None = None
+ font_title: str | None = None
+ font_mana: str | None = None
+ font_pt: str | None = None
+ # Numeric font values
+ flavor_text_lead_divider: float | None = None
+ flavor_text_lead: float | None = None
+ line_break_lead: float | None = None
+ modal_indent: float | None = None
class AppConstants:
"""Stores global constants that control app behavior."""
- __metaclass__ = Singleton
- _changes = set()
+
+ _changes: set[str] = set()
# Thread locking handlers
lock_decompress = Lock()
lock_file_save = Lock()
- # Standard HTTP request header
- http_header = {
- "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) "
- "AppleWebKit/537.36 (KHTML, like Gecko) "
- "Chrome/39.0.2171.95 Safari/537.36"}
-
def __init__(self):
"""Load initial values."""
self.load_defaults()
@@ -252,20 +248,20 @@ def load_defaults(self):
"""Loads default values. Called at launch and between renders to remove any changes made by templates."""
# Define text layer formatting
- self.modal_indent = 5.7
- self.line_break_lead = 2.4
- self.flavor_text_lead = 4.4
- self.flavor_text_lead_divider = 7
+ self.modal_indent: float = 5.7
+ self.line_break_lead: float = 2.4
+ self.flavor_text_lead: float = 4.4
+ self.flavor_text_lead_divider: float = 7
# Define currently selected fonts as defaults
- self.font_rules_text_italic = CardFonts.RULES_ITALIC
- self.font_rules_text_bold = CardFonts.RULES_BOLD
- self.font_rules_text = CardFonts.RULES
- self.font_collector = CardFonts.COLLECTOR
- self.font_artist = CardFonts.ARTIST
- self.font_title = CardFonts.TITLES
- self.font_pt = CardFonts.TITLES
- self.font_mana = CardFonts.MANA
+ self.font_rules_text_italic: str = CardFonts.RULES_ITALIC
+ self.font_rules_text_bold: str = CardFonts.RULES_BOLD
+ self.font_rules_text: str = CardFonts.RULES
+ self.font_collector: str = CardFonts.COLLECTOR
+ self.font_artist: str = CardFonts.ARTIST
+ self.font_title: str = CardFonts.TITLES
+ self.font_pt: str = CardFonts.TITLES
+ self.font_mana: str = CardFonts.MANA
# Load changed values
if self._changes:
@@ -292,13 +288,12 @@ def mana_symbols(self) -> dict[str, str]:
"""
@tracked_prop
- def watermarks(self) -> dict[str, dict]:
+ def watermarks(self) -> dict[str, WatermarkFormat]:
"""dict[str, dict]: Maps watermark names to defined formatting rules."""
with suppress():
# Ensure file exists
if not PATH.SRC_DATA_WATERMARKS.is_file():
- dump_data_file({}, PATH.SRC_DATA_WATERMARKS)
- return load_data_file(PATH.SRC_DATA_WATERMARKS)
+ return parse_model(PATH.SRC_DATA_WATERMARKS, WatermarkFormats).root
return {}
"""
@@ -306,13 +301,13 @@ def watermarks(self) -> dict[str, dict]:
"""
@tracked_prop
- def colors(self) -> dict[str, list[int]]:
+ def colors(self) -> dict[str, tuple[float, float, float]]:
"""dict[str, list[int]]: Named reusable colors."""
return {
- 'black': [0, 0, 0],
- 'white': [255, 255, 255],
- 'silver': [167, 177, 186],
- 'gold': [166, 135, 75]
+ "black": (0, 0, 0),
+ "white": (255, 255, 255),
+ "silver": (167, 177, 186),
+ "gold": (166, 135, 75),
}
@tracked_prop
@@ -321,19 +316,23 @@ def mana_colors(self) -> SymbolColorMap:
return SymbolColorMap()
@tracked_prop
- def symbol_map(self) -> dict[str, tuple[str, list[ColorObject]]]:
- """dict[str, tuple[str, list[ColorObject]]]: Uses the symbol map and mana_colors to map
- symbol character strings and colors to their Scryfall symbol string."""
- return {sym: (n, get_symbol_colors(sym, n, self.mana_colors)) for sym, n in self.mana_symbols.items()}
+ def symbol_map(self) -> dict[str, tuple[str, list[ColorObject | None]]]:
+ """Uses the symbol map and mana_colors to map
+ symbol character strings and colors to their Scryfall symbol string."""
+ return {
+ sym: (n, get_symbol_colors(sym, n, self.mana_colors))
+ for sym, n in self.mana_symbols.items()
+ }
def build_symbol_map(
- self, colors: Optional[SymbolColorMap] = None,
- symbols: Optional[dict[str, str]] = None
+ self,
+ colors: SymbolColorMap | None = None,
+ symbols: dict[str, str] | None = None,
) -> None:
- """Establishes a new `symbol_color_map` using a provided color map and symbol map.
+ """Sets a new mapping for symbol colors. Affects e.g. mana colors.
Args:
- colors: A `SymbolColorMap`, uses default if not provided.
+ colors: A model mapping color names to colors, uses default if not provided.
symbols: A dictionary mapping font characters to their Scryfall symbol string. Uses default
if not provided.
"""
@@ -343,7 +342,8 @@ def build_symbol_map(
self.mana_symbols = symbols
self.symbol_map = {
sym: (n, get_symbol_colors(sym, n, self.mana_colors))
- for sym, n in self.mana_symbols.items()}
+ for sym, n in self.mana_symbols.items()
+ }
"""
* Tracked Properties: Masks and Gradients
@@ -356,53 +356,37 @@ def masks(self) -> dict[int, list[str]]:
2: [LAYERS.HALF],
3: [LAYERS.THIRD, LAYERS.TWO_THIRDS],
4: [LAYERS.QUARTER, LAYERS.HALF, LAYERS.THREE_QUARTERS],
- 5: [LAYERS.FIFTH, LAYERS.TWO_FIFTHS, LAYERS.THREE_FIFTHS, LAYERS.FOUR_FIFTHS]
+ 5: [
+ LAYERS.FIFTH,
+ LAYERS.TWO_FIFTHS,
+ LAYERS.THREE_FIFTHS,
+ LAYERS.FOUR_FIFTHS,
+ ],
}
@tracked_prop
- def gradient_locations(self) -> dict[int, list[Union[int, float]]]:
+ def gradient_locations(self) -> dict[int, list[int | float]]:
"""dict[int, list[Union[int, float]]: Maps gradient locations to a number representing the number
- of color splits."""
+ of color splits."""
return {
- 2: [.40, .60],
- 3: [.26, .36, .64, .74],
- 4: [.20, .30, .45, .55, .70, .80],
- 5: [.20, .25, .35, .45, .55, .65, .75, .80]
+ 2: [0.40, 0.60],
+ 3: [0.26, 0.36, 0.64, 0.74],
+ 4: [0.20, 0.30, 0.45, 0.55, 0.70, 0.80],
+ 5: [0.20, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.80],
}
- """
- * Tracked Properties: Template Versions
- """
-
- @tracked_prop
- def versions(self) -> dict:
- """dict[str, str]: Maps version numbers to template file identifiers."""
- with suppress():
- # Ensure file exists
- if not PATH.SRC_DATA_VERSIONS.is_file():
- dump_data_file({}, PATH.SRC_DATA_VERSIONS)
- return load_data_file(PATH.SRC_DATA_VERSIONS)
- return {}
-
- def update_version_tracker(self):
- """Updates the version tracker JSON file with current values."""
- dump_data_file(self.versions, PATH.SRC_DATA_VERSIONS)
- if 'versions' in self._changes:
- self._changes.remove('versions')
- delattr(self, 'versions')
-
"""
* Tracked Properties: Hexproof.io Data
"""
@tracked_prop
- def set_data(self) -> dict[str, dict]:
- """dict[str, dict]: Returns set data pulled from Hexproof.io mapped to set codes."""
+ def set_data(self) -> dict[str, HexproofSet]:
+ """Returns set data pulled from Hexproof.io mapped to set codes."""
return self.get_set_data()
@tracked_prop
- def metadata(self) -> dict[str, Hexproof.Meta]:
- """dict[str, dict]: Returns data pulled from Hexproof.io mapped to resources."""
+ def metadata(self) -> dict[str, Meta]:
+ """Returns data pulled from Hexproof.io mapped to resources."""
return self.get_meta_data()
"""
@@ -411,52 +395,38 @@ def metadata(self) -> dict[str, Hexproof.Meta]:
def get_user_data(self):
"""Loads the user data file and replaces any necessary data."""
- # Write a blank data file if not found
- if not PATH.SRC_DATA_USER.is_file():
- dump_data_file({}, PATH.SRC_DATA_USER)
-
- # Pull the version tracker
- f = load_data_file(PATH.SRC_DATA_USER)
-
- # Load user data
- [self.__setattr__(name, f[name]) for name in [
- # Fonts
- 'font_rules_text_italic',
- 'font_rules_text_bold',
- 'font_rules_text',
- 'font_collector',
- 'font_artist',
- 'font_title',
- 'font_mana',
- 'font_pt',
-
- # Numeric font values
- 'flavor_text_lead_divider',
- 'flavor_text_lead',
- 'line_break_lead',
- 'modal_indent'
- ] if f.get(name)]
+ if PATH.SRC_DATA_USER.is_file():
+ data = parse_model(PATH.SRC_DATA_USER, CustomUserDefinitions)
+
+ for field in data.model_fields_set:
+ if (value := getattr(data, field, None)) is not None:
+ setattr(self, field, value)
"""
* Import: Hexproof API Data
"""
- @return_on_exception({})
- def get_set_data(self) -> dict[str, dict]:
- """dict: Loaded data from the 'set' data file."""
- if not PATH.SRC_DATA_HEXPROOF_SET.is_file():
- dump_data_file({}, PATH.SRC_DATA_HEXPROOF_SET)
-
- # Import watermark library
- return load_data_file(PATH.SRC_DATA_HEXPROOF_SET)
-
- @return_on_exception({})
- def get_meta_data(self) -> dict[str, Hexproof.Meta]:
- """dict: Loaded data from the 'meta' data file."""
- if not PATH.SRC_DATA_HEXPROOF_META.is_file():
- dump_data_file({}, PATH.SRC_DATA_HEXPROOF_META)
-
- # Import watermark library
- return {
- k: Hexproof.Meta(**v) for k, v in
- load_data_file(PATH.SRC_DATA_HEXPROOF_META)}
+ def get_set_data(self) -> dict[str, HexproofSet]:
+ """Loaded data from the 'set' data file."""
+ try:
+ if not PATH.SRC_DATA_HEXPROOF_SET.is_file():
+ return {}
+
+ # Read set data
+ return HexproofSets.model_validate_json(
+ PATH.SRC_DATA_HEXPROOF_SET.read_bytes()
+ ).root
+ except Exception:
+ return {}
+
+ def get_meta_data(self) -> dict[str, Meta]:
+ """Loaded data from the 'meta' data file."""
+ try:
+ if not PATH.SRC_DATA_HEXPROOF_META.is_file():
+ return {}
+
+ return HexproofMetas.model_validate_json(
+ PATH.SRC_DATA_HEXPROOF_META.read_bytes()
+ ).root
+ except Exception:
+ return {}
diff --git a/src/cards.py b/src/cards.py
index 8cd9db0a..26060349 100644
--- a/src/cards.py
+++ b/src/cards.py
@@ -2,25 +2,40 @@
* Card Data Module
* Handles raw card data fetching and processing
"""
-# Standard Library Imports
-from contextlib import suppress
+
+from collections.abc import Iterable
+from json import dumps
+from logging import Logger, getLogger
from pathlib import Path
-from typing import Optional, Union, TypedDict, Any
+from typing import TypedDict
-# Third Party Imports
from omnitils.strings import normalize_str
-import yarl
+from pathvalidate import sanitize_filename
-# Local Imports
from src._config import AppConfig
-from src.console import msg_warn
-from src.enums.mtg import TransformIcons, non_italics_abilities, CardTextPatterns
+from src.enums.mtg import (
+ CardTextPatterns,
+ LayoutScryfall,
+ TransformIcons,
+ non_italics_abilities,
+)
from src.schema.colors import ColorObject
-from src.utils import scryfall
-
-"""
-* Types
-"""
+from src.utils.data_structures import find_item
+from src.utils.scryfall import (
+ CardIdentifier,
+ ScryfallCard,
+ ScryfallCardFace,
+ ScryfallException,
+ ScryfallRelatedCard,
+ get_card_search,
+ get_card_unique,
+ get_card_via_url,
+ get_cards_collection,
+)
+
+_logger = getLogger(__name__)
+
+# region Types
# (Start index, end index)
CardItalicString = tuple[int, int]
@@ -31,87 +46,170 @@
class CardDetails(TypedDict):
"""Card details obtained from processing a card's art file name."""
+
name: str
- set: Optional[str]
- number: Optional[str]
- artist: Optional[str]
- creator: Optional[str]
- file: Union[str, Path]
+ set: str
+ number: str
+ artist: str
+ creator: str
+ file: Path
+ kwargs: dict[str, str]
class FrameDetails(TypedDict):
"""Frame details obtained from processing frame logic."""
- background: Optional[str]
- pinlines: Optional[str]
- twins: Optional[str]
- identity: Optional[str]
+
+ background: str
+ pinlines: str
+ twins: str
+ identity: str
is_colorless: bool
is_hybrid: bool
+# endregion Types
+
+# region Constants
+
+_filename_character_replacements: dict[str, str] = {
+ "οΌ": "<",
+ "οΌ": ">",
+ "οΌ": ":",
+ "οΌ": '"',
+ "οΌ": "/",
+ "οΌΌ": "\\",
+ "οΏ¨": "|",
+ "οΌ": "?",
+ "οΌ": "*",
+}
"""
-* Handling Data Requests
+Windows filenames disallow many characters which might be needed in some
+card or artist names, so we can use similar fullwidth unicode characters,
+which otherwise won't likely see much or any use when proxying cards, in the
+filenames and then replace them with the disallowed characters after reading the
+names into Proxyshop.
"""
+_reverse_filename_character_replacements: dict[str, str] = {
+ value: key for key, value in _filename_character_replacements.items()
+}
+
+# endregion Constants
+
+# region Requests
-def get_card_data(card: CardDetails, cfg: AppConfig, logger: Optional[Any] = None) -> Optional[dict]:
+
+def get_card_data(
+ card: CardDetails | CardIdentifier,
+ cfg: AppConfig,
+) -> ScryfallCard | None:
"""Fetch card data from the Scryfall API.
Args:
card: Card details pulled from the art image filename.
cfg: AppConfig object providing search configuration settings.
- logger: Console or other logger object used to relay warning messages.
Returns:
Scryfall 'Card' object data if card was returned, otherwise None.
"""
# Format our query data
- name, code = card.get('name', ''), card.get('set', '')
- number = card.get('number', '').lstrip('0 ') if card.get('number') != '0' else '0'
+ name, code = card.get("name", ""), card.get("set", "")
+ if "collector_number" in card:
+ number = card["collector_number"]
+ else:
+ number = (
+ card.get("number", "").lstrip("0 ") if card.get("number") != "0" else "0"
+ )
# Establish kwarg search terms
- kwargs = {
- 'unique': cfg.scry_unique,
- 'order': cfg.scry_sorting,
- 'dir': 'asc' if cfg.scry_ascending else 'desc',
- 'include_extras': str(cfg.scry_extras),
- } if not number else {}
+ kwargs = (
+ {
+ "unique": cfg.scry_unique,
+ "order": cfg.scry_sorting,
+ "dir": "asc" if cfg.scry_ascending else "desc",
+ "include_extras": str(cfg.scry_extras),
+ }
+ if not number
+ else {}
+ )
# Establish Scryfall fetch action
- action = scryfall.get_card_unique if number else scryfall.get_card_search
+ action = get_card_unique if number else get_card_search
params = [code, number] if number else [name, code]
# Is this an alternate language request?
if cfg.lang != "en":
-
# Pull the alternate language card
- with suppress(Exception):
- data = action(*params, lang=cfg.lang, **kwargs)
- return data
- # Language couldn't be found
- if logger:
- logger.update(msg_warn(f'Reverting to English: [b]{name}[/b]'))
+ try:
+ return action(*params, lang=cfg.lang, **kwargs)
+ except ScryfallException as exc:
+ _logger.warning(
+ f"Couldn't find language {cfg.lang} for {name}. Reverting to English.",
+ exc_info=exc,
+ )
# Query the card in English, retry with extras if failed
- with suppress(Exception):
- data = action(*params, **kwargs)
- return data
- if not number and not cfg.scry_extras:
- # Retry with extras included, case: Planar cards
- with suppress(Exception):
- kwargs['include_extras'] = 'True'
- data = action(*params, **kwargs)
- return data
- return
-
-
-"""
-* Pre-processing Data
-"""
-
-
-def parse_card_info(file_path: Path) -> CardDetails:
+ try:
+ return action(*params, **kwargs)
+ except Exception:
+ if not number and not cfg.scry_extras:
+ # Retry with extras included, case: Planar cards
+ try:
+ kwargs["include_extras"] = "True"
+ data = action(*params, **kwargs)
+ return data
+ except ScryfallException:
+ _logger.exception(
+ "Couldn't retrieve card from Scryfall even with include_extras enabled."
+ )
+ else:
+ _logger.exception("Couldn't retrieve card from Scryfall.")
+
+
+def get_batch_of_cards(
+ batch: Iterable[tuple[CardIdentifier, list[CardDetails]]],
+) -> list[tuple[CardDetails, ScryfallCard]]:
+ if result := get_cards_collection([item[0] for item in batch]):
+ if result.not_found:
+ _logger.warning(
+ f"The following cards were not found from Scryfall:
{
+ '
'.join(
+ [
+ f'{dumps(ide)} derived from {
+ human_readable_card_details(card[1][0])
+ if (
+ card := find_item(
+ batch, lambda item: item[0] == ide
+ )
+ )
+ else None
+ }'
+ for ide in result.not_found
+ ]
+ )
+ }"
+ )
+
+ out: list[tuple[CardDetails, ScryfallCard]] = []
+ idx: int = 0
+ for identifier, cards in batch:
+ if identifier in result.not_found:
+ continue
+ for card in cards:
+ out.append((card, result.data[idx]))
+ idx += 1
+
+ return out
+ return []
+
+
+# endregion Requests
+
+# region Card data processing
+
+
+def parse_card_info(file_path: Path, name_override: str | None = None) -> CardDetails:
"""Retrieve card name from the input file, and optional tags (artist, set, number).
Args:
@@ -121,119 +219,188 @@ def parse_card_info(file_path: Path) -> CardDetails:
Dict of card details.
"""
# Extract just the card name
- file_name = file_path.stem
+ file_name = name_override or file_path.stem
+
+ for substitute, replacement in _filename_character_replacements.items():
+ file_name = file_name.replace(substitute, replacement)
# Match pattern and format data
name_split = CardTextPatterns.PATH_SPLIT.split(file_name)
artist = CardTextPatterns.PATH_ARTIST.search(file_name)
number = CardTextPatterns.PATH_NUM.search(file_name)
code = CardTextPatterns.PATH_SET.search(file_name)
+ kwargs_match: list[tuple[str, str]] = CardTextPatterns.PATH_KWARGS.findall(
+ file_name
+ )
# Return dictionary
return {
- 'file': file_path,
- 'name': name_split[0].strip(),
- 'set': code.group(1) if code else '',
- 'artist': artist.group(1) if artist else '',
- 'number': number.group(1) if number and code else '',
- 'creator': name_split[-1] if '$' in file_name else '',
+ "file": file_path,
+ "name": name_split[0].strip(),
+ "set": code.group(1) if code else "",
+ "artist": artist.group(1) if artist else "",
+ "number": number.group(1) if number and code else "",
+ "creator": name_split[-1] if "$" in file_name else "",
+ "kwargs": {key: value for key, value in kwargs_match},
}
-"""
-* Post-processing Data
-"""
-
-
-def process_card_data(data: dict, card: CardDetails) -> dict:
+def process_card_data(data: ScryfallCard, card: CardDetails) -> ScryfallCard:
"""Process any additional required data before sending it to the layout object.
+ This function is expected to be idempotent.
Args:
- data: Unprocessed scryfall data.
+ data: Scryfall data.
card: Card details processed from art image file name.
Returns:
- Processed scryfall data.
+ Processed Scryfall data.
"""
# Define a normalized name
- name_normalized = normalize_str(card['name'], no_space=True)
+ name_normalized = normalize_str(card["name"], no_space=True)
# Modify meld card data to fit transform layout
- if data['layout'] == 'meld':
+ if data.layout == "meld":
+ front: list[ScryfallRelatedCard] = []
+ back: ScryfallRelatedCard | None = None
+
# Ignore tokens and other objects
- front, back = [], None
- for part in data.get('all_parts', []):
- if part.get('component') == 'meld_part':
+ for part in data.all_parts if data.all_parts else []:
+ if part.component == "meld_part":
front.append(part)
- if part.get('component') == 'meld_result':
+ if part.component == "meld_result":
back = part
# Figure out if card is a front or a back
- faces = [front[0], back] if (
- name_normalized == normalize_str(back.get('name', ''), True) or
- name_normalized == normalize_str(front[0].get('name', ''), True)
- ) else [front[1], back]
-
- # Pull JSON data for each face and set object to card_face
- data['card_faces'] = [{
- **scryfall.get_uri_object(yarl.URL(n['uri'])),
- 'object': 'card_face'
- } for n in faces]
-
- # Add meld transform icon if none provided
- if not any([bool(n in TransformIcons) for n in data.get('frame_effects', [])]):
- data.setdefault('frame_effects', []).append(TransformIcons.MELD)
- data['layout'] = 'transform'
+ is_back = back and name_normalized == normalize_str(back.name, no_space=True)
+
+ faces: list[ScryfallRelatedCard | None] = (
+ [front[0], back]
+ if (
+ is_back
+ or name_normalized == normalize_str(front[0].name, no_space=True)
+ )
+ else [front[1], back]
+ )
+
+ try:
+ if is_back and back:
+ data = get_card_via_url(str(back.uri))
+ data.layout = LayoutScryfall.Normal
+ else:
+ # Pull JSON data for each face and set object to card_face
+ data.card_faces = []
+ for face in faces:
+ if face and not isinstance(face, ScryfallCardFace):
+ face_data = get_card_via_url(str(face.uri))
+ face_data_dict = face_data.model_dump()
+ face_data_dict["object"] = "card_face"
+ data.card_faces.append(ScryfallCardFace(**face_data_dict))
+
+ # Add meld transform icon if none provided
+ if not data.frame_effects or not any(
+ [bool(n in TransformIcons) for n in data.frame_effects]
+ ):
+ data.frame_effects = ["meld"]
+ data.layout = LayoutScryfall.Transform
+ except ScryfallException:
+ _logger.exception(
+ "Couldn't retrieve additional card details for a meld card."
+ )
+ raise
# Check for alternate MDFC / Transform layouts
- if 'card_faces' in data:
+ if data.card_faces:
# Select the corresponding face
- card, i = (data['card_faces'][0], 0) if (
- normalize_str(data['card_faces'][0].get('name', ''), True) == name_normalized
- ) else (data['card_faces'][1], 1)
+ card_faces = data.card_faces
+ # Default to front face
+ i = 0
+ card_face = card_faces[0]
+ for idx, face in enumerate(card_faces):
+ if normalize_str(face.name, True) == name_normalized:
+ i = idx
+ card_face = face
+ break
# Decide if this is a front face
- data['front'] = True if i == 0 else False
+ data.front = i == 0
# Transform / MDFC Planeswalker layout
- if 'Planeswalker' in card.get('type_line', ''):
- data['layout'] = 'planeswalker_tf' if data.get('layout') == 'transform' else 'planeswalker_mdfc'
- # Transform Saga layout
- if 'Saga' in card['type_line']:
- data['layout'] = 'saga'
- # Battle layout
- if 'Battle' in card['type_line']:
- data['layout'] = 'battle'
+ if card_face.type_line:
+ if "Planeswalker" in card_face.type_line:
+ data.layout = (
+ LayoutScryfall.PlaneswalkerTransform
+ if data.layout
+ in (LayoutScryfall.Transform, LayoutScryfall.PlaneswalkerTransform)
+ else LayoutScryfall.PlaneswalkerMDFC
+ )
+ # Transform Saga layout
+ elif "Saga" in card_face.type_line:
+ data.layout = LayoutScryfall.Saga
+ # Battle layout
+ elif "Battle" in card_face.type_line:
+ data.layout = LayoutScryfall.Battle
+
return data
# Add Mutate layout
- if 'Mutate' in data.get('keywords', []):
- data['layout'] = 'mutate'
+ if "Mutate" in data.keywords:
+ data.layout = LayoutScryfall.Mutate
return data
+ type_line = data.type_line
+
# Add Planeswalker layout
- if 'Planeswalker' in data.get('type_line', ''):
- data['layout'] = 'planeswalker'
+ if "Planeswalker" in type_line:
+ data.layout = LayoutScryfall.Planeswalker
+ return data
+
+ # Check for Saga Creature layout
+ if "Saga" in type_line and "Creature" in type_line:
+ data.layout = LayoutScryfall.Saga
+ return data
+
+ # Check for Station layout
+ if data.keywords and "Station" in data.keywords:
+ data.layout = LayoutScryfall.Station
return data
# Return updated data
return data
-"""
-* Card Text Utilities
-"""
+def sanitize_card_filename(name: str) -> str:
+ for illegal, substitute in _reverse_filename_character_replacements.items():
+ name = name.replace(illegal, substitute)
+ return sanitize_filename(name)
+
+
+def card_details_to_scryfall_identifier(card: CardDetails) -> CardIdentifier:
+ if card["set"] and card["number"]:
+ return {
+ "set": card["set"],
+ "collector_number": card["number"],
+ }
+ elif card["set"]:
+ return {"name": card["name"], "set": card["set"]}
+ else:
+ return {"name": card["name"]}
+
+
+# endregion Card data processing
+
+# region Card text processing
def locate_symbols(
text: str,
symbol_map: dict[str, tuple[str, list[ColorObject]]],
- logger: Optional[Any] = None
+ logger: Logger | None = None,
) -> tuple[str, list[CardSymbolString]]:
"""Locate symbols in the input string, replace them with the proper characters from the mana font,
and determine the colors those characters need to be.
Args:
text: String to analyze for symbols.
- symbol_map: Maps a characters and colors to a scryfall symbol string.
+ symbol_map: Maps characters and colors to a scryfall symbol string.
logger: Console or other logger object used to relay warning messages.
Returns:
@@ -241,35 +408,35 @@ def locate_symbols(
of each symbol to format.
"""
# Is there a symbol in this text?
- if '{' not in text:
+ if "{" not in text:
return text, []
# Starting values
symbol_indices: list[CardSymbolString] = []
- start, end = text.find('{'), text.find('}')
+ start, end = text.find("{"), text.find("}")
# Look for symbols in the text
while 0 <= start <= end:
- symbol = text[start:end + 1]
+ symbol = text[start : end + 1]
try:
# Replace the symbol, add its location and color
symbol_string, symbol_color = symbol_map[symbol]
text = text.replace(symbol, symbol_string, 1)
symbol_indices.append((start, symbol_color))
- except (KeyError, IndexError):
+ except KeyError, IndexError:
if logger:
- logger.update(f'Symbol not recognized: {symbol}')
- text = text.replace(symbol, symbol.strip('{}'))
+ logger.warning(f"Symbol not recognized: {symbol}")
+ text = text.replace(symbol, symbol.strip("{}"))
# Move to the next symbols
- start, end = text.find('{'), text.find('}')
+ start, end = text.find("{"), text.find("}")
return text, symbol_indices
def locate_italics(
st: str,
- italics_strings: list,
+ italics_strings: list[str],
symbol_map: dict[str, tuple[str, list[ColorObject]]],
- logger: Optional[Any] = None
+ logger: Logger | None = None,
) -> list[CardItalicString]:
"""Locate all instances of italic strings in the input string and record their start and end indices.
@@ -282,24 +449,23 @@ def locate_italics(
Returns:
List of italic string indices (start and end).
"""
- indexes = []
+ indexes: list[tuple[int, int]] = []
for italic in italics_strings:
-
# Look for symbols present in italicized text
- if '{' in italic:
- start = italic.find('{')
- end = italic.find('}')
+ if "{" in italic:
+ start = italic.find("{")
+ end = italic.find("}")
while 0 <= start < end:
# Replace the symbol
- symbol = italic[start:end + 1]
+ symbol = italic[start : end + 1]
try:
italic = italic.replace(symbol, symbol_map[symbol][0])
- except (KeyError, IndexError):
+ except KeyError, IndexError:
if logger:
- logger.update(f'Symbol not recognized: {symbol}')
- st = st.replace(symbol, symbol.strip('{}'))
+ logger.warning(f"Symbol not recognized: {symbol}")
+ st = st.replace(symbol, symbol.strip("{}"))
# Move to the next symbol
- start, end = italic.find('{'), italic.find('}')
+ start, end = italic.find("{"), italic.find("}")
# Locate Italicized text
end_index = 0
@@ -323,12 +489,11 @@ def generate_italics(card_text: str) -> list[str]:
Returns:
List of italics strings.
"""
- italic_text = []
+ italic_text: list[str] = []
# Find each reminder text block
end_index = 0
while True:
-
# Find parenthesis enclosed string, otherwise break
start_index = card_text.find("(", end_index)
if start_index < 0:
@@ -339,13 +504,13 @@ def generate_italics(card_text: str) -> list[str]:
italic_text.append(card_text[start_index:end_index])
# Determine whether to look for ability words
- if ' β ' not in card_text:
+ if " β " not in card_text:
return italic_text
# Find and add ability words
for match in CardTextPatterns.TEXT_ABILITY.findall(card_text):
# Cover "villainous choice" case
- if 'villainous' in match:
+ if "villainous" in match:
continue
# Cover "Mirrodin Besieged" case
if f"β’ {match}" in card_text and "choose one" not in card_text.lower():
@@ -370,16 +535,20 @@ def strip_reminder_text(text: str) -> str:
Oracle text with no reminder text.
"""
# Skip if there's no reminder text present
- if '(' not in text:
+ if "(" not in text:
return text
# Remove reminder text
text_stripped = CardTextPatterns.TEXT_REMINDER.sub("", text)
# Remove any extra whitespace
- text_stripped = CardTextPatterns.EXTRA_SPACE.sub('', text_stripped).strip()
+ return CardTextPatterns.EXTRA_SPACE.sub("", text_stripped).strip()
+
+
+def human_readable_card_details(details: CardDetails) -> str:
+ return f"{details['name']} ({details['artist']}) [{details['set']}] {{{
+ details['number']
+ }}} |{details['file']}|"
+
- # Return the stripped text if it isn't empty
- if text_stripped:
- return text_stripped
- return text
+# endregion Card text processing
diff --git a/src/commands/__init__.py b/src/commands/__init__.py
deleted file mode 100644
index a9d87be7..00000000
--- a/src/commands/__init__.py
+++ /dev/null
@@ -1,75 +0,0 @@
-"""
-* Headless CLI Application
-"""
-# Standard Library Imports
-import os
-import sys
-from pathlib import Path
-
-# Third Party Imports
-import click
-
-# Local Imports
-from src import PATH
-from src.commands.build import build_cli
-from src.commands.docs import docs_cli
-from src.commands.files import compress_cli
-from src.commands.render import render_cli
-from src.commands.test import test_cli
-
-"""
-* CLI Commands
-"""
-
-
-@click.command(
- name='run_headless',
- help='Launch the Proxyshop CLI application.')
-def run_cli():
- """Launch the Proxyshop CLI application."""
- response = input("What would you like to do?\n")
- if response == '1':
- return print('You gave me 1.')
- return print('You did not give me 1.')
-
-
-@click.command(
- name='run_gui',
- help='Launch the Proxyshop GUI application.'
-)
-def run_gui():
- """Launch the Proxyshop GUI application."""
- exe_path = Path(sys.argv[0])
- if exe_path.suffix not in ['.py', '.exe']:
- exe_path = PATH.CWD / 'main.py'
- os.execv(sys.executable, ['python', exe_path])
-
-
-"""
-* CLI Application
-"""
-
-
-@click.group(
- commands={
- 'build': build_cli,
- 'compress': compress_cli,
- 'docs': docs_cli,
- 'gui': run_gui,
- 'render': render_cli,
- 'test': test_cli,
- },
- context_settings={
- 'ignore_unknown_options': True
- },
- invoke_without_command=True,
- help='Invoke the CLI without a command to launch an ongoing headless Proxyshop application.')
-@click.pass_context
-def ProxyshopCLI(ctx: click.Context):
- if ctx.invoked_subcommand is None:
- ctx.invoke(run_cli)
- pass
-
-
-# Export CLI
-__all__ = ['ProxyshopCLI']
diff --git a/src/commands/build.py b/src/commands/build.py
deleted file mode 100644
index c297e456..00000000
--- a/src/commands/build.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""
-* CLI Commands: Build
-"""
-# Standard Library
-from typing import Optional
-
-# Third party imports
-import click
-
-# Local Imports
-from src.utils.build import build_release
-
-
-"""
-* Commands
-"""
-
-
-@click.command(help='Build executable app release and distributable zip.')
-@click.argument('version', required=False)
-@click.option('-B', '--beta', is_flag=True, default=False, help="Build app as a Beta release.")
-@click.option('-C', '--console', is_flag=True, default=False, help="Build app with console enabled.")
-@click.option('-R', '--raw', is_flag=True, default=False, help="Build app without creating zip release archive.")
-def build_app(version: Optional[str] = None, beta: bool = False, console: bool = False, raw: bool = False) -> None:
- """Build Proxyshop as an executable release.
-
- Args:
- version: Version number to build with, if not provided use latest.
- beta: Build as beta release if True.
- console: Build with console window if True.
- raw: Build app without creating zip if True.
- """
- build_release(version=version, beta=beta, console=console, zipped=not raw)
-
-
-"""
-* Command Groups
-"""
-
-
-@click.group(
- name='build',
- help='Command utilities for building and managing release files.',
- commands=dict(
- app=build_release
- )
-)
-def build_cli() -> None:
- """App build tools CLI."""
- pass
-
-
-# Export CLI
-__all__ = ['build_cli']
diff --git a/src/commands/docs.py b/src/commands/docs.py
deleted file mode 100644
index 608454c1..00000000
--- a/src/commands/docs.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""
-* CLI Commands: Build
-"""
-# Third party imports
-import click
-
-# Local Imports
-from src.utils.build import generate_mkdocs, generate_nav, update_mkdocs_yml
-
-"""
-* Command Groups
-"""
-
-
-@click.group(
- name="docs",
- help="Command utilities for managing the app's documentation."
-)
-def docs_cli() -> None:
- """App docs tools CLI."""
- pass
-
-
-"""
-* Commands
-"""
-
-
-@docs_cli.command(
- name="update",
- help="Updates MKDocs files for the current app version."
-)
-def generate_docs() -> None:
- """Build the docs."""
- headers = ['Template Classes', 'Photoshop Helpers', 'App Utilities']
- paths = ['templates', 'helpers', 'utils']
- [generate_mkdocs(p) for p in paths]
- nav = generate_nav(headers, paths)
- update_mkdocs_yml(nav)
-
-
-# Export CLI
-__all__ = ['docs_cli']
diff --git a/src/commands/files.py b/src/commands/files.py
deleted file mode 100644
index 490ebd62..00000000
--- a/src/commands/files.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""
-* CLI Commands: Files
-"""
-# Standard Library
-from pathlib import Path
-from typing import Optional
-
-# Third Party Imports
-import click
-from omnitils.files.archive import compress_7z, compress_7z_all
-
-# Local Imports
-from src import PATH
-
-"""
-* Commands: Compression
-"""
-
-
-@click.group(
- name='compress',
- help='Command utilities for compressing files.'
-)
-def compress_cli():
- """File utilities CLI."""
- pass
-
-
-@compress_cli.command(
- name='template',
- help='Compress a Photoshop template file (PSD/PSB).'
-)
-@click.argument('template')
-@click.argument('plugin', required=False)
-def compress_template(template: str, plugin: Optional[str] = None) -> None:
- """Compress a template by name and optionally plugin name.
-
- Args:
- template: Filename of the template, e.g. `normal.psd`
- plugin: Name of the plugin containing the template if required, e.g. MrTeferi
- """
- path = Path(PATH.PLUGINS, plugin, 'templates') if plugin else PATH.TEMPLATES
- path = path / template
- if not path.is_file():
- print(f"I couldn't find a template named '{template}' at this path:\n{str(path)}")
- return
- compress_7z(path)
-
-
-@compress_cli.command(
- name='plugin',
- help='Compress all Photoshop template files (PSD/PSB) in a given plugin.'
-)
-@click.argument('plugin')
-def compress_plugin(plugin: str) -> None:
- """Compress all templates in a specific plugin.
-
- Args:
- plugin: Name of the plugin, e.g. MrTeferi
- """
- path = PATH.PLUGINS / plugin / 'templates'
- if not path.is_dir():
- print(f"I couldn't find a plugin named '{plugin}'")
- return
- compress_7z_all(path)
-
-
-@compress_cli.command(
- name='all',
- help='Compress all Photoshop template files (PSD/PSB) in the entire app, plugins optional.'
-)
-@click.option('-P', '--plugins', is_flag=True, default=False, help="Compress built-in plugins as well.")
-def compress_all(plugins: bool = False) -> None:
- """Compress all templates.
-
- Args:
- plugins: Compress built-in plugins as well if True, otherwise skip them.
- """
- # Compress main templates folder
- compress_7z_all(PATH.TEMPLATES)
-
- # Compress plugins if requested
- if plugins:
- plugins = [
- Path(PATH.PLUGINS, p, 'templates')
- for p in ['Investigamer', 'SilvanMTG']]
- [compress_7z_all(p) for p in plugins]
-
-
-# Export CLI
-__all__ = ['compress_cli']
diff --git a/src/commands/render.py b/src/commands/render.py
deleted file mode 100644
index 6a33f0fd..00000000
--- a/src/commands/render.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""
-* CLI Commands: Rendering
-"""
-# Standard Library Imports
-from pathlib import Path
-
-# Third Party Imports
-import click
-from omnitils.files import load_data_file
-
-# Local Imports
-from src import CON, TEMPLATE_DEFAULTS
-from src._loader import TemplateDetails
-from src.cards import CardDetails
-from src.layouts import layout_map
-
-"""
-* Commands: Render
-* Add a render command
-* This is merely an example, will be replaced in the future.
-"""
-
-
-@click.group(
- name='render',
- help='Commands for rendering card images.'
-)
-def render_cli():
- """App render CLI."""
- pass
-
-
-@render_cli.command(
- name='target',
- help='Render one or more target cards.'
-)
-@click.argument('data_file', required=True)
-def render_target(data_file: str = None):
- """Render a single card using JSON data."""
-
- # Find your art image file
- # Todo: Use target selection in Photoshop, support optional filepath argument
- art_path = Path(CON.cwd, 'art', data_file)
- for suf in ['.jpg', '.png', '.webp']:
- art_file = art_path.with_suffix(suf)
- if art_file.is_file():
- break
-
- # Check if proper art file was found
- if not art_file.is_file():
- raise OSError(f"No art file matching name '{data_file}' was found!")
-
- # Load card data from a json file and create fake card "details" dict
- # Todo: Make custom card data an optional filepath argument
- data_file = Path(CON.cwd, 'customs', data_file).with_suffix('.json')
- card = load_data_file(data_file)
- file_details: CardDetails = {
- 'file': art_file, 'name': card.get('name', ''),
- 'set': '', 'artist': '', 'creator': '', 'number': ''
- }
-
- # Get appropriate layout class and initialize it
- # Todo: Use the appropriate layout class provided
- layout = layout_map.get(card.get('layout', 'normal'))
- layout(card, file_details)
-
- # Get appropriate template for this layout
- # Todo: Use default, support optional template name argument or custom defined
- template: TemplateDetails = TEMPLATE_DEFAULTS.get(layout.card_class)
- template_class = template['object'].get_template_class(template['class_name'])
- layout.template_file = template['object'].path_psd
-
- # Load the template class, create an instance, execute it
- template_class(layout).execute()
-
-
-# Export CLI
-__all__ = ['render_cli']
diff --git a/src/commands/test/__init__.py b/src/commands/test/__init__.py
deleted file mode 100644
index aaf1fb6a..00000000
--- a/src/commands/test/__init__.py
+++ /dev/null
@@ -1,73 +0,0 @@
-"""
-* CLI Commands: Testing
-"""
-# Third Party
-import click
-
-# Local Imports
-from src import CONSOLE, PATH
-from src.commands.test import frame_logic, text_logic
-
-"""
-* Commands
-"""
-
-
-@click.command(
- short_help='Test Scryfall data frame logic analysis across a variety of card cases.',
- help='Test Scryfall data frame logic analysis across a variety of card cases. Frame analysis '
- 'is used to determine what colors and textures should be used on a given card.')
-@click.option('-T', '--target', is_flag=True, default=False, help="Evaluate a specific test case.")
-def test_frame_logic(target: bool = False):
- """Run Frame Logic test on all cases."""
- CONSOLE.info(f"Test Utility: Frame Logic ({PATH.CWD})")
- cases = frame_logic.get_frame_logic_cases()
-
- # Was this a targeted case?
- if not target:
- return frame_logic.test_all_cases()
-
- # Choose test case
- options = {str(i): title for i, title in enumerate(cases.keys())}
- while True:
- choice = input("Enter the number for a test case to evaluate:\n" +
- "\n".join([f"[{i}] {n}" for i, n in options.items()]))
- if choice not in options:
- print("Choice provided was invalid.")
- break
-
- # Run the test
- case = options[choice]
- CONSOLE.info(f"CASE: {case}")
- frame_logic.test_target_case(cases[case])
-
-
-@click.command(
- short_help='Test Scryfall data text logic analysis across a variety of card cases.',
- help='Test Scryfall data text logic analysis across a variety of card cases. Text analysis is used to '
- 'decide what text should be italicized when rendering the card.')
-def test_text_logic():
- """Run Text Logic test on all cases."""
- text_logic.test_all_cases()
-
-
-"""
-* Command Groups
-"""
-
-
-@click.group(
- name='test', chain=True,
- help='Commands that test app functionality.',
- commands={
- 'logic.frame': test_frame_logic,
- 'logic.text': test_text_logic
- }
-)
-def test_cli():
- """Cli interface for test funcs."""
- pass
-
-
-# Export CLI
-__all__ = ['test_cli']
diff --git a/src/commands/test/frame_logic.py b/src/commands/test/frame_logic.py
deleted file mode 100644
index 252de7f8..00000000
--- a/src/commands/test/frame_logic.py
+++ /dev/null
@@ -1,193 +0,0 @@
-"""
-* Tests: Frame Logic
-* Credit to Chilli: https://tinyurl.com/chilli-frame-logic-tests
-"""
-# Standard Library Imports
-import os
-from multiprocessing import cpu_count
-from concurrent.futures import (
- ThreadPoolExecutor as Pool,
- as_completed,
- Future)
-from pathlib import Path
-from typing import Optional
-
-# Third Part Imports
-from tqdm import tqdm
-from colorama import Fore, Style
-from omnitils.files import load_data_file
-
-# Local Imports
-from src import CONSOLE as LOGR, PATH, CFG
-from src.enums.mtg import CardTextPatterns
-from src.layouts import layout_map, CardLayout
-from src.cards import get_card_data, process_card_data
-
-"""
-* TYPES
-"""
-
-FrameData = list[str]
-
-"""
-* UTIL FUNCS
-"""
-
-
-def get_frame_logic_cases() -> dict[str, dict[str, FrameData]]:
- """Return frame logic test cases from TOML data file."""
- return load_data_file(Path(PATH.SRC_DATA_TESTS, 'frame_data.toml'))
-
-
-def format_result(layout: CardLayout) -> FrameData:
- """Format frame logic test result for comparison.
-
- Args:
- layout: Test result card layout data.
-
- Returns:
- Formatted frame logic test result data.
- """
- return [
- str(layout.__class__.__name__),
- str(layout.background),
- str(layout.pinlines),
- str(layout.twins),
- str(layout.is_nyx),
- str(layout.is_colorless)
- ]
-
-
-"""
-* TEST FUNCS
-"""
-
-
-def test_case(card_name: str, card_data: FrameData) -> Optional[tuple[str, FrameData, FrameData]]:
- """Test frame logic for a target test case.
-
- Args:
- card_name: Card name for test case.
- card_data: Card data for test case.
-
- Returns:
- Tuple containing (card name, actual data, correct data) if the test failed, otherwise None.
- """
- try:
- # Check if a set code was provided
- set_code = None
- if all([n in card_name for n in ['[', ']']]):
- set_code = CardTextPatterns.PATH_SET.search(card_name).group(1)
- card_name = card_name.replace(f'[{set_code}]', '').strip()
-
- # Create a fake card details object
- details = {
- 'name': card_name,
- 'set': set_code,
- 'number': '',
- 'creator': '',
- 'file': '',
- 'artist': ''
- }
-
- # Pull Scryfall data
- scryfall = get_card_data(
- card=details,
- cfg=CFG,
- logger=LOGR)
- if not scryfall:
- raise OSError('Did not return valid data from Scryfall.')
- # Process the Scryfall data
- scryfall = process_card_data(scryfall, details)
- except Exception as e:
- # Exception occurred during Scryfall lookup
- return LOGR.failed(f"Scryfall error occurred at card: '{card_name}'", exc_info=e)
-
- # Pull layout data for the card
- try:
- result_data: FrameData = format_result(
- layout_map[scryfall['layout']](
- scryfall=scryfall,
- file={
- 'name': card_name,
- 'artist': scryfall['artist'],
- 'set_code': scryfall['set'],
- 'creator': '', 'filename': ''}))
- except Exception as e:
- # Exception occurred during layout generation
- return LOGR.failed(f"Layout error occurred at card: '{card_name}'", exc_info=e)
-
- # Compare the results
- if not result_data == card_data:
- return card_name, result_data, card_data
- return
-
-
-def test_target_case(cards: dict[str, FrameData]) -> None:
- """Test a known Frame Logic test case.
-
- Args:
- cards: Individual card frame cases to test.
-
- Returns:
- True if tests succeeded, otherwise False.
- """
- # Submit tests to a pool
- with Pool(max_workers=cpu_count()) as executor:
- tests_submitted: list[Future] = []
- tests_failed: list[tuple[str, FrameData, FrameData]] = []
-
- # Submit tasks to executor
- for card_name, data in cards.items():
- tests_submitted.append(
- executor.submit(test_case, card_name, data))
-
- # Create a progress bar
- pbar = tqdm(
- total=len(tests_submitted),
- bar_format=f'{LOGR.COLORS.BLUE}'
- '{l_bar}{bar}{r_bar}'
- f'{LOGR.COLORS.RESET}')
-
- # Iterate over completed tasks, update progress bar, add failed tasks
- for task in as_completed(tests_submitted):
- pbar.update(1)
- if result := task.result():
- tests_failed.append(result)
-
- # Set the progress bar result
- if tests_failed:
- pbar.set_postfix({
- "Status": (
- Fore.RED + "FAILED" + Style.RESET_ALL
- ) if tests_failed else (
- Fore.GREEN + "SUCCESS" + Style.RESET_ALL
- )
- })
-
- # Close progress bar and return failures
- pbar.close()
-
- # Log failed results
- if tests_failed:
- LOGR.critical("=" * 40)
- for name, actual, correct in tests_failed:
- LOGR.warning(f'NAME: {name}')
- LOGR.warning(f'RESULT [Actual / Expected]:\n'
- f'{LOGR.COLORS.RESET}{LOGR.COLORS.WHITE}{actual}\n{correct}')
- LOGR.critical("=" * 40)
- LOGR.critical("SOME TESTS FAILED!")
- return
-
- # All tests successful
- LOGR.info("ALL TESTS SUCCESSFUL!")
-
-
-def test_all_cases() -> None:
- """Test all Frame Logic cases."""
- cases = get_frame_logic_cases()
-
- # Submit tests to a pool
- for case, cards in cases.items():
- LOGR.debug(f"CASE: {case}")
- test_target_case(cards)
diff --git a/src/commands/test/scryfall.py b/src/commands/test/scryfall.py
deleted file mode 100644
index 8b9cace3..00000000
--- a/src/commands/test/scryfall.py
+++ /dev/null
@@ -1,55 +0,0 @@
-"""
-* Tests: Scryfall
-"""
-# Local Imports
-from src.api.scryfall import get_card_unique, get_card_search, get_set
-
-
-"""
-* Test Funcs
-"""
-
-
-def test_scryfall_unique():
- """Test the request function for Scryfall's '/cards/set/num' endpoint."""
- try:
- obj = get_card_unique(card_set='TSR', card_number='50', lang='en')
- print("SUCCESS:", obj['name'], obj['set'], obj['collector_number'])
- return obj
- except Exception as e:
- print(e)
- return {}
-
-
-def test_scryfall_search():
- """Test the request function for Scryfall's '/cards/search' endpoint."""
- try:
- obj = get_card_search(card_name='Damnation', card_set='TSR', lang='en')
- print("SUCCESS:", obj['name'], obj['set'], obj['collector_number'])
- return obj
- except Exception as e:
- print(e)
- return {}
-
-
-def test_scryfall_set():
- """Test the request function for Scryfall's '/sets/code' endpoint."""
- try:
- obj = get_set(card_set='TSR')
- print("SUCCESS:", obj['name'], obj['code'])
- return obj
- except Exception as e:
- print(e)
- return {}
-
-
-"""
-* Cli Entrypoints
-"""
-
-
-def test_all_cases() -> None:
- """Test all Scryfall request cases."""
- test_scryfall_unique()
- test_scryfall_search()
- test_scryfall_set()
diff --git a/src/commands/test/text_logic.py b/src/commands/test/text_logic.py
deleted file mode 100644
index 25213ad6..00000000
--- a/src/commands/test/text_logic.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""
-* Tests: Card Text Logic
-"""
-# Standard Library Imports
-from pathlib import Path
-from typing import TypedDict
-
-# Third Party Imports
-from omnitils.files import load_data_file
-
-# Local Imports
-from src import CONSOLE, PATH
-from src.cards import generate_italics
-
-# Use loguru logger
-logr = CONSOLE.logger.opt(colors=True)
-
-"""
-* Types
-"""
-
-
-class TestCaseTextItalic (TypedDict):
- """Test cases for validating the generate italics function."""
- result: list[str]
- scenario: str
- text: str
-
-
-"""
-* Test Funcs
-"""
-
-
-def test_all_cases() -> bool:
- """Test all Text Logic cases."""
-
- # Load our test cases
- SUCCESS = True
- test_file: Path = Path(PATH.SRC_DATA_TESTS, 'text_italic.toml')
- test_cases: dict[str, TestCaseTextItalic] = load_data_file(test_file)
- logr.info(f"Testing > Card Text Logic ({test_file.name})")
-
- # Check each test case for success
- for name, case in test_cases.items():
-
- # Compare actual test results VS expected test results
- result_actual, result_expected = generate_italics(case.get('text', '')), case.get('result', [])
- if not sorted(result_actual) == sorted(result_expected):
- SUCCESS = False
- msg_actual = ''.join(f'\n {i}. {n}' for i, n in enumerate(result_actual, start=1))
- msg_expected = ''.join(f'\n {i}. {n}' for i, n in enumerate(result_expected, start=1))
- logr.error(f"Case: {name} ({case.get('scenario', '')})")
-
- # Log what we expect
- if not result_expected:
- logr.warning(f"This card doesn't have italic text!")
- else:
- logr.warning(f"Italic strings expected: {msg_expected}")
-
- # Log what we found
- if not result_actual:
- logr.warning(f"No italic strings were found!")
- else:
- logr.warning(f"Italic strings found: {msg_actual}")
-
- # Did any tests fail?
- if SUCCESS:
- logr.success('All tests successful!')
- return SUCCESS
diff --git a/src/commands/test/utility.py b/src/commands/test/utility.py
deleted file mode 100644
index dfcafb0c..00000000
--- a/src/commands/test/utility.py
+++ /dev/null
@@ -1,702 +0,0 @@
-"""
-* General Testing Utility
-* For contributors and plugin development.
-"""
-# Standard Library Imports
-from contextlib import suppress
-from typing import Optional, Union
-from _ctypes import COMError
-from xml.dom import minidom
-import warnings
-import logging
-import json
-import csv
-import os
-
-# Third Party Imports
-from photoshop.api._artlayer import ArtLayer
-from photoshop.api._layerSet import LayerSet
-from photoshop.api import (
- ActionDescriptor,
- ActionReference,
- ElementPlacement,
- DialogModes,
- LayerKind)
-from psd_tools.constants import Resource
-from psd_tools import PSDImage
-from psd_tools.psd.image_resources import ImageResource
-import xml.etree.ElementTree as ET
-
-# Local Imports
-from src import APP, TEMPLATES
-import src.helpers as psd
-from src.utils.adobe import LayerContainer
-
-# Photoshop infrastructure
-cID, sID = APP.charIDtoTypeID, APP.stringIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
-
-# Reference Box colors
-ORANGE = [255, 172, 64]
-TANG = [255, 97, 11]
-RED = [192, 55, 38]
-TAN = [245, 235, 210]
-BLACK = [0, 0, 0]
-
-"""
-* Template Design Testing
-"""
-
-
-def test_new_color(new: str, old: Optional[str] = None, ignore: Optional[list[str]] = None) -> None:
- """Enables given color in all necessary groups. Optionally disable a color in those groups.
-
- Args:
- new: Color to enable.
- old: Color to disable.
- ignore: Groups to ignore.
- """
- if ignore is None:
- ignore = ["Pinlines & Textbox"]
- groups = ["Name & Title Boxes", "Legendary Crown", "Pinlines & Textbox", "Background", "PT Box"]
- for r in ignore:
- groups.remove(r)
- for g in groups:
- # Enable new color
- psd.getLayer(new, g).visible = True
- # Disable old color
- if old:
- psd.getLayer(old, g).visible = False
-
-
-def make_duals(
- name: str = "Pinlines & Textbox",
- mask_top: Optional[ArtLayer] = None,
- mask_bottom: Optional[ArtLayer] = None
-):
- """Creates dual color layers for a given group.
-
- Args:
- name: Name of the group.
- mask_top: Mask to place on top color.
- mask_bottom: Mask to place on bottom color.
- """
- duals = ["WU", "WB", "RW", "GW", "UB", "UR", "GU", "BR", "BG", "RG"]
- group = psd.getLayerSet(name)
- ref = psd.getLayer("W", group)
-
- # Loop through each dual
- for dual in duals:
-
- # Change layer visibility
- top = psd.getLayer(dual[0], group).duplicate(ref, ElementPlacement.PlaceBefore)
- bottom = psd.getLayer(dual[1], group).duplicate(top, ElementPlacement.PlaceAfter)
- top.visible = True
- bottom.visible = True
-
- # Enable masks
- if mask_top:
- psd.copy_layer_mask(mask_top, top)
- if mask_bottom:
- psd.copy_layer_mask(mask_bottom, bottom)
-
- # Merge the layers and rename
- new_layer = psd.merge_layers([top, bottom])
- new_layer.name = dual
-
-
-def create_blended_layer(
- colors: Union[str, list[str]],
- group: LayerSet,
- masks: Union[None, ArtLayer, list[ArtLayer]] = None
-):
- """Create a multicolor layer using a gradient mask.
-
- Args:
- colors: Colors to use.
- group: Group to look for the color layers within.
- masks: Layers containing a gradient mask.
- """
- if not masks:
- # No mask provided
- masks = [psd.getLayer("Mask")]
- elif isinstance(masks, ArtLayer):
- # Single layer provided
- masks = [masks]
- layers: list[ArtLayer] = []
-
- # Enable each layer color
- for i, color in enumerate(colors):
- layer = psd.getLayer(color, group)
- layer.visible = True
-
- # Position the new layer and add a mask to previous, if previous layer exists
- if layers:
- layer.move(layers[i - 1], ElementPlacement.PlaceAfter)
- psd.copy_layer_mask(masks[i - 1], layers[i - 1])
-
- # Add to the layer list
- layers.append(layer)
-
-
-"""
-* Action Descriptor Getters
-"""
-
-
-def check_if_needed(key, keys_stored):
- """Check if double key has been locked, skip int keys if it is.
-
- Args:
- key: This key.
- keys_stored: Keys already stored.
-
- Returns:
- True if needed, False if skipped.
- """
- if 'double' in keys_stored:
- if key in ['largeInt', 'int']:
- return False
- return True
-
-
-def try_all_getters(desc: ActionDescriptor, type_id) -> dict:
- """Try all possible getter functions for this Descriptor and Type ID.
-
- Args:
- desc: Current descriptor object.
- type_id: TypeID to send the getter for.
-
- Returns:
- All values returned from our getters.
- """
- values = {}
- getters = {
- 'bool': 'getBoolean',
- 'class': 'getClass',
- 'enumType': 'getEnumerationType',
- 'enumVal': 'getEnumerationValue',
- 'list': 'getList',
- 'objType': 'getObjectType',
- 'objValue': 'getObjectValue',
- 'path': 'getPath',
- 'ref': 'getReference',
- 'str': 'getString',
- 'type': 'getType',
- 'double': 'getUnitDoubleValue',
- 'int': 'getInteger',
- 'largeInt': 'getLargeInteger',
- }
- for k, func in getters.items():
- if not check_if_needed(k, values.keys()):
- # Skip this getter
- continue
- try:
- # Send the getter
- result = getattr(desc, func)(type_id)
- if k == 'list':
- # Getter may have returned an ActionList, grab the first object
- result = get_action_items(result.getObjectValue(0))
- values[k] = result
- except (COMError, NameError, KeyError):
- # Skip this getter
- pass
- return values
-
-
-def get_action_items(desc) -> dict:
- """Try to pull objects and getters from each action key in a descriptor.
-
- Args:
- desc: Current descriptor.
-
- Returns:
- Recursive dict of all objects and getters matched to each key.
- """
- items = {}
- try:
- count = desc.count
- except COMError:
- count = 0
- for i in range(count):
- type_id: int = desc.getKey(i)
- string_id: str = APP.typeIDToStringID(type_id)
- if desc.hasKey(type_id):
- try:
- result = get_action_items(desc.getObjectValue(type_id))
- if result:
- items[string_id] = result
- except COMError:
- # Try every getter till one works
- try:
- result = try_all_getters(desc, type_id)
- items[string_id] = result
- except COMError:
- pass
- return items
-
-
-def dump_layer_action_descriptors(layer: ArtLayer, path: str) -> dict:
- """Combs through all available descriptor keys for a layer, dumps it to JSOn, and returns as a dict.
-
- Args:
- layer: Layer to scan.
- path: Path to save the JSON dump.
-
- Returns:
- Dict containing descriptors by key and value, in a branching tree.
- """
- # Get the layer descriptor
- reference = ActionReference()
- reference.putIdentifier(sID('layer'), layer.id)
- descriptor = APP.executeActionGet(reference)
-
- # Generate a dict of all descriptors
- actions = get_action_items(descriptor)
-
- # Dump the dict to a JSON file and return it
- with open(path, "w", encoding="utf-8-sig") as f:
- json.dump(actions, f)
- return actions
-
-
-"""
-* Dict Utilities
-"""
-
-
-def get_differing_dict(d1: dict, d2: dict):
- """Recursively generates a new dictionary comprised of differing values in two dicts.
-
- Args:
- d1: First dictionary.
- d2: Second dictionary.
-
- Returns:
- A dictionary comprised of differing key value pairs.
- """
- new_dict = {}
- for key, val in d1.items():
- if isinstance(val, dict):
- new_dict[key] = get_differing_dict(d1[key], d2[key])
- elif val != d2[key]:
- new_dict[key] = [val, d2[key]]
- return new_dict
-
-
-"""
-* Text Utilities
-"""
-
-
-def apply_single_line_composer(layer: ArtLayer) -> None:
- """Set the text composer of the layer to 'Single Line Composer'.
-
- Args:
- layer: TextLayer to apply the composer to.
- """
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- desc2 = ActionDescriptor()
- ref1.PutProperty(sID("property"), sID("paragraphStyle"))
- ref1.putIdentifier(sID("textLayer"), layer.id)
- desc1.PutReference(sID("target"), ref1)
- desc2.PutInteger(sID("textOverrideFeatureName"), 808464691)
- desc2.PutBoolean(sID("textEveryLineComposer"), False)
- desc1.PutObject(sID("to"), sID("paragraphStyle"), desc2)
- APP.Executeaction(sID("set"), desc1, NO_DIALOG)
-
-
-def combine_text_items(from_layer: ArtLayer, to_layer: ArtLayer, sep: Optional[str] = " "):
- """Append the "from_layer" contents to the end of "to_layer" contents with optional separator.
- Preserves the complete style range formatting of both text contents.
-
- Args:
- from_layer: TextLayer to pull contents from.
- to_layer: TextLayer to append the contents of from_layer to.
- sep: Optional separator to place between them.
- """
- # Grab the text key of each layer
- from_tk = psd.get_text_key(from_layer)
- to_tk = psd.get_text_key(to_layer)
-
- # Grab the style range of each layer and establish our offset
- from_range = from_tk.getList(sID("textStyleRange"))
- to_range = to_tk.getList(sID("textStyleRange"))
- offset = len(sep) if sep else 0
-
- # For each item in the "from" style range, update the position and apply style to target
- for i in range(from_range.count):
- from_style = from_range.getObjectValue(i)
- id_from = from_style.getInteger(sID("from"))
- id_to = from_style.getInteger(sID("to"))
- from_style.putInteger(sID("from"), id_from + len(to_tk.getString(sID("textKey"))) + (offset if i != 0 else 0))
- from_style.putInteger(sID("to"), id_to + len(to_tk.getString(sID("textKey"))) + offset)
- to_range.putObject(sID("textStyleRange"), from_style)
-
- # Combine the contents and apply the updated style range
- contents = str(to_tk.getString(sID("textKey")) + sep + from_tk.getString(sID("textKey")))
- to_tk.putString(sID("textKey"), contents)
- to_tk.putList(sID("textStyleRange"), to_range)
-
- # Apply the updated text key to the target layer
- psd.apply_text_key(to_layer, to_tk)
-
-
-def reset_transform_factor(layer: ArtLayer) -> None:
- """Reset the transform xx and yy factors of a given layer.
-
- Args:
- layer: TextLayer to reset transform factors for.
- """
- key = psd.get_text_key(layer)
- if key.hasKey(sID('transform')):
-
- # Check the scale factor
- desc = key.getObjectValue(sID("transform"))
- xx = desc.getUnitDoubleValue(sID("xx")) if desc.hasKey(sID("xx")) else 1
- yy = desc.getUnitDoubleValue(sID("yy")) if desc.hasKey(sID("xx")) else 1
-
- # Fix the scale factor
- if xx == 1 and yy == 1:
- return
- if xx != 1:
- desc.putDouble(sID("xx"), 1)
- if yy != 1:
- desc.putDouble(sID("yy"), 1)
-
- # Update the scale factor
- key.putObject(sID("transform"), sID("transform"), desc)
- psd.apply_text_key(layer, key)
-
-
-"""
-* XMP Utilities
-"""
-
-
-def xmp_remove_ancestors() -> None:
- """Remove DocumentAncestors property from XMP data."""
-
- # Check that a document is open
- if not APP.documents:
- print('No documents open!')
- return
-
- # XMP data
- APP.eval_javascript('''
- if (ExternalObject.AdobeXMPScript == undefined) {
- ExternalObject.AdobeXMPScript = new ExternalObject("lib:AdobeXMPScript");
- }
- var xmp = new XMPMeta( activeDocument.xmpMetadata.rawData);
- xmp.deleteProperty(XMPConst.NS_PHOTOSHOP, "DocumentAncestors");
- APP.activeDocument.xmpMetadata.rawData = xmp.serialize();
- ''')
-
-
-"""
-* Vector Shape Utilities
-"""
-
-
-def create_color_shape(layer: ArtLayer, color: list) -> ArtLayer:
- layer_name = layer.name
- color = psd.get_color(color)
- docref = APP.activeDocument
- docsel = docref.selection
- docref.activeLayer = layer
- psd.select_layer_bounds(layer, docsel)
-
- desc1 = ActionDescriptor()
- ref1 = ActionReference()
- ref2 = ActionReference()
- ref1.putClass(sID("path"))
- desc1.putReference(sID("target"), ref1)
- ref2.putProperty(sID("selectionClass"), sID("selection"))
- desc1.putReference(sID("from"), ref2)
- desc1.putUnitDouble(sID("tolerance"), sID("pixelsUnit"), 2.000000)
- APP.executeaction(sID("make"), desc1, NO_DIALOG)
-
- ref1 = ActionReference()
- desc1 = ActionDescriptor()
- desc2 = ActionDescriptor()
- desc3 = ActionDescriptor()
- desc4 = ActionDescriptor()
- ref1.putClass(sID("contentLayer"))
- desc1.putReference(sID("target"), ref1)
- desc4.putDouble(sID("red"), color.rgb.red)
- desc4.putDouble(sID("green"), color.rgb.green)
- desc4.putDouble(sID("blue"), color.rgb.blue)
- desc3.putObject(sID("color"), sID("RGBColor"), desc4)
- desc2.putObject(sID("type"), sID("solidColorLayer"), desc3)
- desc1.putObject(sID("using"), sID("contentLayer"), desc2)
- APP.executeaction(sID("make"), desc1, NO_DIALOG)
- APP.activeDocument.activeLayer.name = layer_name
-
- # Check dims
- dims = psd.get_layer_dimensions(layer)
- dims = (dims['width'], dims['height'])
- new_dims = psd.get_layer_dimensions(docref.activeLayer)
- new_dims = (new_dims['width'], new_dims['height'])
- if not dims == new_dims:
- print("DIMS CHANGED:", layer_name)
- print("Before:", dims)
- print("After:", new_dims)
-
- layer.remove()
- docref.activeLayer.visible = False
- return docref.activeLayer
-
-
-"""
-* Font Utilities
-"""
-
-
-def log_all_template_fonts() -> dict:
- """Create a log of every font found for each PSD template."""
-
- # Ignore warnings from the psd_tools module
- logging.getLogger('psd_tools').setLevel(logging.FATAL)
- warnings.filterwarnings("ignore", module='psd_tools')
-
- def _get_fonts_from_psd(doc_path: str) -> set[str]:
- """
- Get a set of every font found in a given Photoshop document.
- @param doc_path: Path to the Photoshop document.
- @return: Set of font names found in the document.
- """
- file, fonts = PSDImage.open(doc_path), set()
- for layer in [n for n in file.descendants() if n.kind == 'type']:
- for style in layer.engine_dict['StyleRun']['RunArray']:
- font_key = style['StyleSheet']['StyleSheetData']['Font']
- fonts.add(layer.resource_dict['FontSet'][font_key]['Name'])
- return fonts
-
- # PSD documents to test
- docs = {
- t.path_psd: f"{t.name} ({t.plugin.name if t.plugin else 'BASE'})"
- for t in TEMPLATES
- }
-
- # Track progress
- doc_fonts, master, current, total = {}, {}, 1, len(docs)
-
- # Check each document
- logging.basicConfig(level=logging.INFO)
- for f, temp_name in docs.items():
-
- # Alert the user
- logging.info(f"READING FONTS β {temp_name} [{f.name}] [{current}/{total}]")
- current += 1
-
- # Open document, get fonts, close document
- doc_fonts[temp_name] = _get_fonts_from_psd(f)
-
- # Create master list
- for doc, font_list in doc_fonts.items():
- for f in font_list:
- master.setdefault(str(f), []).append(doc)
-
- # Log a sorted master list
- master: dict[str, list] = {k: v for k, v in sorted(master.items(), key=lambda item: len(item[1]))}
- with open(f'logs/FONTS.json', 'w', encoding='utf-8') as f:
- json.dump(master, f, indent=2)
- return master
-
-
-"""
-* Project: Data Sets
-* Exploring the use of data set variables for potential performance gains.
-* Status: Likely not helpful
-"""
-
-
-def insert_data_set_variables(xml_data: str, from_path: str, to_path: Optional[str] = None) -> None:
- """Inserts data set variable XML into a PSD document.
-
- Args:
- xml_data: Data to insert into the document.
- from_path: Path of the PSD file.
- to_path: Path of new PSD file created, overwrites from_path if not provided.
- """
- # Decide the to_path
- to_path = to_path or from_path
-
- # Ignore warnings from the psd_tools module
- logging.getLogger('psd_tools').setLevel(logging.FATAL)
- warnings.filterwarnings("ignore", module='psd_tools')
-
- # Open the PSD file
- f = PSDImage.open(from_path)
-
- # Replace the image variables data
- new_resource = ImageResource(b'MeSa', key=Resource.IMAGE_READY_VARIABLES, name='', data=xml_data.encode(
- encoding='UTF-8', errors='strict'))
- f.image_resources[Resource.IMAGE_READY_VARIABLES] = new_resource
-
- # Save the PSD file
- f.save(to_path)
-
-
-def print_data_set_variables(path: str) -> None:
- """Print data set variable XML data for a given document.
-
- Args:
- path: Path to a PSD document.
- """
- data = PSDImage.open(path).image_resources.get_data(
- Resource.IMAGE_READY_DATA_SETS)
- pretty_xml = minidom.parseString(data).toprettyxml()
-
- # Print without excess newlines
- print('\n'.join([line for line in pretty_xml.split('\n') if line.strip()]))
-
-
-def format_data_set_variable_name(text: str) -> str:
- """Formats string as a data set variable name.
-
- Args:
- text: Text to format as a data set variable name.
-
- Returns:
- Properly formatted data set variable name.
- """
- return text.title().replace(
- ' ', '').replace(
- '-', '').replace(
- '&', '')
-
-
-def get_data_set_variables(
- group: Optional[Union[LayerContainer]] = None,
- tree: Optional[str] = None
-) -> list[dict[str, str]]:
- """Get data set variables for all ArtLayer and LayerSet objects in document or LayerSet.
-
- Args:
- group: LayerSet or Document, use activeDocument if not provided.
- tree: Tree to add to variable names, empty str if not provided.
-
- Returns:
- List of data set variable dicts.
- """
-
- # Establish current tree
- tree = f'{tree}{format_data_set_variable_name(group.name)}.' if tree and group else (
- f'{format_data_set_variable_name(group.name)}.' if group else '')
-
- # Establish group or top level document container
- group = group or APP.activeDocument
- layer_vars: list[dict[str, str]] = []
-
- # Add layer variables
- for n in group.artLayers:
- with suppress(Exception):
- if n.kind == LayerKind.TextLayer:
- layer_vars.append({
- 'varName': f'{tree}{format_data_set_variable_name(n.name)}.Text',
- 'trait': 'textcontent',
- 'docRef': f"id('{n.id}')"
- })
- elif 'Frame' in n.name:
- layer_vars.append({
- 'varName': f'{tree}{format_data_set_variable_name(n.name)}.Image',
- 'trait': 'fileref',
- 'placementMethod': 'fill',
- 'align': 'center',
- 'valign': 'middle',
- 'clip': 'false',
- 'docRef': f"id('{n.id}')"
- })
- layer_vars.append({
- 'varName': f'{tree}{format_data_set_variable_name(n.name)}.Visible',
- 'trait': 'visibility',
- 'docRef': f"id('{n.id}')"
- })
-
- # Add layer group variables
- for n in group.layerSets:
- with suppress(Exception):
- layer_vars.append({
- 'varName': f'{tree}{format_data_set_variable_name(n.name)}.Visible',
- 'trait': 'visibility',
- 'docRef': f"id('{n.id}')"
- })
- layer_vars.extend(get_data_set_variables(n, tree))
- return layer_vars
-
-
-def get_data_set_variable_xml():
- """Generate data set variable XML for all layers in document."""
-
- # Create the root element
- root = ET.Element('variableSets', xmlns="https://ns.adobe.com/Variables/1.0/")
-
- # Create variableSet -> variables
- variableSet = ET.SubElement(root, 'variableSet')
- variableSet.set('locked', 'none')
- variableSet.set('varSetName', 'binding1')
- variables = ET.SubElement(variableSet, 'variables')
-
- # Add your variables, could do this programmatically for each layer
- for var in get_data_set_variables():
- variable = ET.SubElement(variables, 'variable')
- for k, v in var.items():
- variable.set(k, v)
-
- # Convert the XML to a string
- return (ET.tostring(root, encoding='utf8', method='xml', short_empty_elements=False)
- .decode().replace('\n', '', 1).replace('><', '>\n<')) + '\n'
-
-
-def create_data_set_csv(data_set: dict[str, str], path: str) -> None:
- """Create a data set CSV that can be imported for Photoshop's data set variable system.
-
- Args:
- data_set: A dictionary where variable names are the keys and variable values are the values.
- path: Path to save the CSV to.
- """
- with open(path, 'w', newline='') as file:
- writer = csv.writer(file)
- writer.writerow(data_set.keys())
- writer.writerow(data_set.values())
-
-
-def import_data_set(path: str) -> None:
- """Imports a data set into Photoshop's data set variable system.
-
- Args:
- path: Path to the data set CSV file where the first line are the variable names.
- """
- desc = ActionDescriptor()
- ref = ActionReference()
- ref.putClass(sID("dataSetClass"))
- desc.putReference(sID("null"), ref)
- desc.putPath(sID("using"), path)
- desc.putEnumerated(sID("encoding"), sID("dataSetEncoding"), sID("dataSetEncodingAuto"))
- desc.putBoolean(sID("eraseAll"), True)
- desc.putBoolean(sID("useFirstColumn"), True)
- APP.executeAction(sID("importDataSets"), desc, NO_DIALOG)
-
-
-def apply_data_set(data_set_name: str) -> None:
- """Applies a data set from Photoshop's data set variable system.
-
- Args:
- data_set_name: Name of the data set.
- """
- desc = ActionDescriptor()
- setRef = ActionReference()
- setRef.putName(sID("dataSetClass"), data_set_name)
- desc.putReference(sID("null"), setRef)
- APP.executeAction(sID("apply"), desc, NO_DIALOG)
-
-
-"""
-* Testing Stuff
-"""
-
-if __name__ == '__main__':
- """Insert any test actions here."""
- pass
diff --git a/src/console.py b/src/console.py
index a4a7e487..3aed41c3 100644
--- a/src/console.py
+++ b/src/console.py
@@ -1,427 +1,108 @@
"""
* Console Module
"""
-# Standard Library
-import os
-import time
-from threading import Lock, Event, Thread
-from datetime import datetime as dt
-from functools import cached_property
-from typing import Optional
-# Third Party Imports
-from omnitils.enums import StrConstant
-from omnitils.logs import logger, Logger
-from omnitils.metaclass import Singleton
-
-# Local Imports
-from src._config import AppConfig
-from src._state import AppEnvironment, PATH
-
-"""
-* Enums
-"""
-
-
-class LogColors (StrConstant):
+from collections.abc import Callable
+from enum import IntEnum, StrEnum
+from logging import (
+ CRITICAL,
+ DEBUG,
+ ERROR,
+ INFO,
+ WARNING,
+ FileHandler,
+ Formatter,
+ Handler,
+ LogRecord,
+ StreamHandler,
+ getLogger,
+)
+
+from src import ENV
+from src._state import PATH
+
+DEFAULT_LOG_FORMAT = "[%(asctime)s.%(msecs)03d][%(levelname)s] %(message)s"
+DETAILED_LOG_FORMAT = (
+ "[%(asctime)s.%(msecs)03d][%(levelname)s][%(name)s][%(funcName)s] %(message)s"
+)
+DEFAULT_LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
+DEFAULT_LOG_FORMATTER = Formatter(
+ fmt=DEFAULT_LOG_FORMAT,
+ datefmt=DEFAULT_LOG_DATE_FORMAT,
+)
+DETAILED_LOG_FORMATTER = Formatter(
+ fmt=DETAILED_LOG_FORMAT, datefmt=DEFAULT_LOG_DATE_FORMAT
+)
+
+_logger = getLogger()
+_logger.setLevel(ENV.LOG_LEVEL)
+_default_console_handler = StreamHandler()
+_default_console_handler.setLevel(ENV.LOG_LEVEL)
+_default_console_handler.setFormatter(DETAILED_LOG_FORMATTER)
+_logger.addHandler(_default_console_handler)
+_error_log_file_handler = FileHandler(PATH.LOGS_ERROR, "a", encoding="utf-8")
+_error_log_file_handler.setLevel(ERROR)
+_error_log_file_handler.setFormatter(DETAILED_LOG_FORMATTER)
+_logger.addHandler(_error_log_file_handler)
+
+# region Enums
+
+
+class MessageSeverity(IntEnum):
+ DEBUG = DEBUG
+ INFO = INFO
+ WARNING = WARNING
+ ERROR = ERROR
+ CRITICAL = CRITICAL
+
+
+class LogColors(StrEnum):
"""Logging message colors."""
- GRAY = '\x1b[97m'
- BLUE = '\x1b[38;5;39m'
- YELLOW = '\x1b[38;5;226m'
- ORANGE = '\x1b[38;5;202m'
- RED = '\x1b[38;5;196m'
- RED_BOLD = '\x1b[31;1m'
- WHITE = '\x1b[97m'
- RESET = '\x1b[0m'
-
-
-class ConsoleMessages(StrConstant):
- error = "#a84747"
- warning = "#d4c53d"
- success = "#59d461"
- info = "#6bbcfa"
-
-
-"""
-* Console Format Utils
-"""
-
-
-def msg_bold(msg: str) -> str:
- """Wraps a console string with a bold tag.
-
- Args:
- msg: Text to wrap in a bold tag.
-
- Returns:
- Wrapped message.
- """
- return f"[b]{msg}[/b]"
-
-
-def msg_italics(msg: str) -> str:
- """Wraps a console string with an italics tag.
-
- Args:
- msg: Message to wrap in an italics tag.
-
- Returns:
- Wrapped message.
- """
- return f"[i]{msg}[/i]"
-
-
-def msg_error(msg: str, reason: Optional[str] = None) -> str:
- """Adds a defined 'error' color tag to Proxyshop console message.
- Args:
- msg: String wrap in color tag.
- reason: Reason for the error to include in the message, if provided.
+ GRAY = "\x1b[97m"
+ BLUE = "\x1b[38;5;39m"
+ YELLOW = "\x1b[38;5;226m"
+ ORANGE = "\x1b[38;5;202m"
+ RED = "\x1b[38;5;196m"
+ RED_BOLD = "\x1b[31;1m"
+ WHITE = "\x1b[97m"
+ RESET = "\x1b[0m"
- Returns:
- Formatted string.
- """
- msg = f'[color={ConsoleMessages.error}]{msg}[/color]'
- return f"{msg_bold(msg)} - {msg_italics(reason)}" if reason else msg
+class LogMessageColor(StrEnum):
+ DEBUG = "#d8d8d8"
+ SUCCESS = "#59d461"
+ INFO = "#6bbcfa"
+ WARNING = "#d4c53d"
+ ERROR = "#ff3a3a"
+ CRITICAL = "#ba86c0"
-def msg_warn(msg: str, reason: Optional[str] = None) -> str:
- """Adds a defined 'warning' color tag to Proxyshop console message.
- Args:
- msg: String to wrap in color tag.
- reason: Reason for the warning to include in the message, if provided.
+LOG_MESSAGE_COLORS_MAP: dict[int, LogMessageColor] = {
+ MessageSeverity.DEBUG: LogMessageColor.DEBUG,
+ MessageSeverity.INFO: LogMessageColor.INFO,
+ MessageSeverity.WARNING: LogMessageColor.WARNING,
+ MessageSeverity.ERROR: LogMessageColor.ERROR,
+ MessageSeverity.CRITICAL: LogMessageColor.CRITICAL,
+}
- Returns:
- Formatted string.
- """
- msg = f'[color={ConsoleMessages.warning}]{msg}[/color]'
- return f"{msg_bold(msg)} - {msg_italics(reason)}" if reason else msg
+# endregion Enums
+# region Handlers
-def msg_success(msg: str) -> str:
- """Adds a defined 'success' color tag to Proxyshop console message.
-
- Args:
- msg: String to wrap in color tag.
-
- Returns:
- Formatted string.
- """
- return f'[color={ConsoleMessages.success}]{msg}[/color]'
-
-
-def msg_info(msg: str) -> str:
- """Adds defined 'info' color tag to Proxyshop console message.
-
- Args:
- msg: String to wrap in color tag.
-
- Returns:
- Formatted string.
- """
- return f'[color={ConsoleMessages.info}]{msg}[/color]'
-
-
-def get_bullet_points(text: list[str], char: str = 'β’') -> str:
- """Turns a list of strings into a joined bullet point list string.
-
- Args:
- text: List of strings.
- char: Character to use as bullet.
-
- Returns:
- Joined string with bullet points and newlines.
- """
- if not text:
- return ""
- bullet = f"\n{char} "
- return str(bullet + bullet.join(text))
-
-
-"""
-* Base Terminal Class
-"""
-
-
-class TerminalConsole:
- """Wrapper to return the correct global console object."""
- __metaclass__ = Singleton
-
- # Managing threaded operations
- await_lock = Lock()
- running = True
- waiting = False
- continue_next_line = False
+class CustomLogHandler(Handler):
def __init__(
- self,
- cfg: AppConfig,
- env: AppEnvironment
- ):
-
- # Establish global objects
- self.cfg: AppConfig = cfg
- self.env: AppEnvironment = env
-
- """
- Logger Object Properties
- """
-
- @cached_property
- def logger(self) -> Logger:
- """Logger interface handling console output."""
- return logger
-
- """
- Logger Object Methods
- """
-
- def debug(self, *args, **kwargs):
- return self.logger.debug(*args, **kwargs)
-
- def info(self, *args, **kwargs):
- return self.logger.info(*args, **kwargs)
-
- def warning(self, *args, **kwargs):
- return self.logger.warning(*args, **kwargs)
-
- def failed(self, *args, **kwargs):
- return self.logger.error(*args, **kwargs)
-
- def critical(self, *args, **kwargs):
- return self.logger.critical(*args, **kwargs)
-
- """
- Reusable Strings
- """
-
- @property
- def message_cancel(self) -> str:
- """Boilerplate message for canceling the render process."""
- return "Understood! Canceling render operation.\n"
-
- @property
- def message_waiting(self) -> str:
- """Boilerplate message for awaiting a user response."""
- return "Manual editing enabled!\nClick continue to proceed..."
-
- @property
- def message_skipping(self):
- """Boilerplate message for skipping a render process."""
- return "Skipping this card!"
-
- @property
- def time(self) -> str:
- """Current date and time in human-readable format."""
- return dt.now().strftime('%m-%d-%Y %H:%M')
-
- """
- Utility Methods
- """
-
- def log_exception(self, error: Exception, *_) -> None:
- """Log python exception.
-
- Args:
- error: Exception object to log.
- """
- self.logger.exception(error)
-
- @staticmethod
- def clear() -> None:
- """Clear the console output."""
- os.system('cls')
-
- """
- Console Operations
- """
-
- def update(
- self,
- msg: str = "",
- exception: Optional[Exception] = None,
- end: str = "\n"
+ self, on_log: Callable[[str, MessageSeverity], None], level: int | str = 0
) -> None:
- """
- Add text to console output.
- @param msg: Message to add to the console output, blank if not provided.
- @param exception: Exception object to log, if provided.
- @param end: String to append at the end of the message, adds a newline if not provided.
- """
-
- # Check if line is a continuation
- if self.continue_next_line:
- msg = '[>]' + msg
- self.continue_next_line = False
-
- # Check if line has alternate ending
- if end.endswith('\n'):
- msg = msg + end[:-1]
- else:
- msg = msg + end + '[>]'
- self.continue_next_line = True
- self.logger.info(msg)
- if exception:
- self.log_exception(exception)
-
- def log_error(
- self,
- thr: Event,
- card: str,
- template: Optional[str] = None,
- msg: str = "Encountered a general error!",
- e: Optional[Exception] = None
- ) -> bool:
- """
- Log failed card and exception if provided, then prompt user to make a decision.
- @param thr: Event object representing the status of the render thread.
- @param card: Card to log in /logs/failed.txt.
- @param template: Template to log in /logs/failed.txt.
- @param msg: Message to add to the console output.
- @param e: Exception to log in /logs/error.txt.
- """
- with open(PATH.LOGS_FAILED, 'a', encoding='utf-8') as log:
- log.write(f"{card}{f' ({template})' if template else ''} [{self.time}]\n")
- return self.error(thr=thr, msg=msg, exception=e)
-
- def error(
- self,
- thr: Optional[Event] = None,
- msg: str = 'Encountered a general error!',
- exception: Optional[Exception] = None,
- end: str = '\nShould I continue?\n'
- ) -> bool:
- """Display error, wait for user to cancel or continue.
-
- Args:
- thr: Event object representing the status of the render thread.
- msg: Message to add to the console output.
- exception: Exception to log in /logs/error.txt.
- end: String to append to the end of the message.
- """
- # Stop awaiting any signals
- self.end_await()
-
- # Log exception if provided
- if exception:
- self.log_exception(exception)
-
- # Skip the prompts for Dev Mode
- if self.env.TEST_MODE:
- return False
-
- # Notify the user, then wait for a continue signal if needed
- if self.cfg.skip_failed:
- self.update(f"{msg}\n{self.message_skipping}")
- return True
-
- # Previous error already handled
- if thr and thr.is_set():
- return False
-
- # Wait for user response
- if self.await_choice(thr, msg, end):
- return True
-
- # Relay the cancellation
- self.update(self.message_cancel)
- return False
-
- """
- User Prompt Signals
- """
-
- def await_choice(self, thr: Event, msg: Optional[str] = None, end: str = "\n") -> bool:
- """
- Prompt the user to either continue or cancel.
- @param thr: Event object representing the status of the render thread.
- @param msg: Message to prompt the user with, uses boilerplate waiting message if not provided.
- @param end: String to append to the end of a message, adds a newline if not provided.
- @return: True if continued, False if canceled.
- """
- # Clear other await procedures, then begin awaiting a user signal
- self.end_await()
- self.update(msg=msg or self.message_waiting, end=end)
- response = input("[Y / Enter] Continue β [N] Cancel")
-
- # Signal the choice
- choice = True if not response or response == 'Y' else False
- self.signal(choice)
-
- # Cancel the current thread or continue based on user signal
- if thr:
- self.cancel_thread(thr) if not choice else self.start_await_cancel(thr)
- return choice
-
- def signal(self, choice: bool):
- """
- Signal the user decision to any prompts awaiting a response.
- @param choice: True if continuing, False if canceling.
- """
- # Continue if True, otherwise False
- self.running = choice
-
- # End await
- self.end_await()
-
- """
- Await Cancelling Procedures
- """
-
- def start_await_cancel(self, thr: Event) -> None:
- """
- Starts an await_cancel loop in a separate thread.
- @param thr: Event object representing the status of the render thread.
- """
- Thread(target=self.await_cancel, args=(thr,), daemon=True).start()
-
- def await_cancel(self, thr: Event):
- """
- Await a signal from the user to cancel the operation.
- @param thr: Event object representing the status of the render thread.
- """
- # Await a signal from the user to cancel the operation
- self.start_await()
-
- # Cancel the current thread if running flag set to False
- if not self.running:
- self.cancel_thread(thr)
-
- """
- Await Loop Handlers
- """
-
- def end_await(self) -> None:
- """Clears the console of any procedures awaiting a response."""
- # Set the waiting flag
- self.waiting = False
-
- # Ensure await is complete before returning
- with self.await_lock:
- return
-
- def start_await(self) -> None:
- """Starts an await loop that finishes when waiting is flagged as False."""
- # Set initial running and waiting flags
- self.waiting = True
- self.running = True
-
- # Await can only start if others have finished
- with self.await_lock:
- while self.waiting:
- time.sleep(.1)
+ super().__init__(level)
+ self.on_log = on_log
- """
- Thread Management
- """
+ def emit(self, record: LogRecord) -> None:
+ try:
+ self.on_log(self.format(record), MessageSeverity(record.levelno))
+ except Exception:
+ self.handleError(record)
- def cancel_thread(self, thr: Event) -> None:
- """Initiate cancellation of a given thread operation.
- Args:
- thr: Thread event to signal the cancellation.
- """
- # Kill the thread and notify
- thr.set()
- self.update(self.message_cancel)
+# endregion Handlers
diff --git a/src/data/build/Proxyshop-console.spec b/src/data/build/Proxyshop-console.spec
deleted file mode 100644
index 6b14e308..00000000
--- a/src/data/build/Proxyshop-console.spec
+++ /dev/null
@@ -1,58 +0,0 @@
-# -*- mode: python ; coding: utf-8 -*-
-# Standard Library Imports
-import os
-from pathlib import Path
-
-# Third Party Imports
-from kivy_deps import sdl2, glew
-
-# Configure Build
-CWD = Path(os.getcwd())
-IMG = CWD / 'src' / 'img'
-block_cipher = None
-
-
-a = Analysis(
- [os.path.join(CWD, 'main.py')],
- binaries=[],
- datas=[],
- hiddenimports=[
- 'src.templates',
- 'src.gui',
- 'kivy',
- 'svglib.svglib',
- 'reportlab.graphics',
- 'requests'
- ],
- hookspath=[],
- hooksconfig={},
- runtime_hooks=[],
- excludes=[],
- win_no_prefer_redirects=False,
- win_private_assemblies=False,
- cipher=block_cipher,
- noarchive=False)
-
-pyz = PYZ(
- a.pure,
- a.zipped_data,
- cipher=block_cipher)
-
-exe = EXE(
- pyz,
- a.scripts,
- a.binaries,
- a.zipfiles,
- a.datas,
- *[Tree(p) for p in (sdl2.dep_bins + glew.dep_bins)],
- name='Proxyshop',
- debug=True,
- strip=False,
- upx=True,
- upx_exclude=[],
- runtime_tmpdir=None,
- console=True,
- disable_windowed_traceback=False,
- target_arch=None,
- codesign_identity=None,
- icon=os.path.join(IMG, 'favicon.ico'))
diff --git a/src/data/build/Proxyshop.spec b/src/data/build/Proxyshop.spec
deleted file mode 100644
index 3ff409e6..00000000
--- a/src/data/build/Proxyshop.spec
+++ /dev/null
@@ -1,58 +0,0 @@
-# -*- mode: python ; coding: utf-8 -*-
-# Standard Library Imports
-import os
-from pathlib import Path
-
-# Third Party Imports
-from kivy_deps import sdl2, glew
-
-# Configure Build
-CWD = Path(os.getcwd())
-IMG = CWD / 'src' / 'img'
-block_cipher = None
-
-
-a = Analysis(
- [os.path.join(CWD, 'main.py')],
- binaries=[],
- datas=[],
- hiddenimports=[
- 'src.templates',
- 'src.gui',
- 'kivy',
- 'svglib.svglib',
- 'reportlab.graphics',
- 'requests'
- ],
- hookspath=[],
- hooksconfig={},
- runtime_hooks=[],
- excludes=[],
- win_no_prefer_redirects=False,
- win_private_assemblies=False,
- cipher=block_cipher,
- noarchive=False)
-
-pyz = PYZ(
- a.pure,
- a.zipped_data,
- cipher=block_cipher)
-
-exe = EXE(
- pyz,
- a.scripts,
- a.binaries,
- a.zipfiles,
- a.datas,
- *[Tree(p) for p in (sdl2.dep_bins + glew.dep_bins)],
- name='Proxyshop',
- debug=False,
- strip=False,
- upx=True,
- upx_exclude=[],
- runtime_tmpdir=None,
- console=False,
- disable_windowed_traceback=False,
- target_arch=None,
- codesign_identity=None,
- icon=os.path.join(IMG, 'favicon.ico'))
diff --git a/src/data/config/BorderlessVectorTemplate.toml b/src/data/config/BorderlessVectorTemplate.toml
index f5482c98..91ba14c1 100644
--- a/src/data/config/BorderlessVectorTemplate.toml
+++ b/src/data/config/BorderlessVectorTemplate.toml
@@ -23,12 +23,6 @@ default = 1
[TEXT]
title = "Text Options"
-[TEXT.Nickname]
-title = "Use Nickname"
-desc = """When enabled, card will be rendered with both a 'real' name and a 'nickname' you can enter manually."""
-type = "bool"
-default = 0
-
[TEXT."Drop.Shadow"]
title = "Text Drop Shadow"
desc = """When enabled, card text will have a drop shadow."""
diff --git a/src/data/config/app.toml b/src/data/config/app.toml
index d5223e21..91ab7d0e 100644
--- a/src/data/config/app.toml
+++ b/src/data/config/app.toml
@@ -28,6 +28,12 @@ desc = """Overwrite rendered files with identical file names."""
type = "bool"
default = 1
+[FILES."PNG.Compression.Level"]
+title = "PNG Compression Level"
+desc = """What compression level to use when saving PNG files. Accepts values between 1-9. Higher values result in slightly smaller files but can slow down the saving process significantly."""
+type = "int"
+default = 3
+
###
# * Scryfall Settings
###
@@ -43,6 +49,12 @@ type = "options"
default = "en"
options = ["en", "es", "fr", "de", "it", "pt", "jp", "kr", "ru", "cs", "ct"]
+[DATA."Scryfall.Use.Printed.Texts"]
+title = "Use printed texts"
+desc = """Use the printed name, type line and rules text of the card, if they are available in the Scryfall data, instead of the canonical English texts. Non-English cards use printed texts regardless of this setting's value."""
+type = "bool"
+default = 0
+
[DATA."Scryfall.Sorting"]
title = "Scryfall Sorting"
desc = """The method used to sort the Scryfall card lookup results."""
@@ -70,6 +82,18 @@ type = "options"
default = "arts"
options = ["arts", "prints"]
+[DATA."Manually.Edit.Card.Data"]
+title = "Manually Edit Card Data"
+desc = """At the start of the render process, will pass the Scryfall data to a text editor for manual modification. Save the file and close the editor to proceed. This can be used, e.g., to fix incorrect Scryfall data or for creating custom cards."""
+type = "bool"
+default = 0
+
+[DATA."Manual.Text.Editor"]
+title = "Manual Text Editor"
+desc = """Text editor to use for the manual card data editing step. Add curly brackets {} where you wish for the file path to be slotted in the command, e.g. Visual Studio Code can be used with C:\\full\\path\\to\\Code.exe -w "{}"."""
+type = "string"
+default = 'notepad "{}"'
+
###
# * Text Settings
###
@@ -90,30 +114,12 @@ default = 0
[RENDER]
title = "Render Settings"
-[RENDER."Skip.Failed"]
-title = "Skip Failed Cards"
-desc = """Automatically skip failed cards without asking for confirmation."""
-type = "bool"
-default = 0
-
-[RENDER."Generative.Fill"]
-title = "Enable Generative Fill"
-desc = """When enabled, fullart and extended templates will fill empty space using Generative Fill instead of Content Aware Fill. This feature will not work unless running the Photoshop BETA version."""
-type = "bool"
-default = 0
-
[RENDER."Select.Variation"]
title = "Select Generative Fill Variation"
desc = """When enabled, render sequence will pause after 'Generative Fill' is applied and allow you to select a variation."""
type = "bool"
default = 0
-[RENDER."Feathered.Fill"]
-title = 'Feathered Fill Selections'
-desc = """When enabled, Proxyshop will feather (dramatically smooth) selections made for Content Aware Fill and Generative Fill. This usually improves the quality of fill results, especially on Generative Fill, at the cost of potentially replacing more original pixels at the edge of the art image."""
-type = "bool"
-default = 0
-
[RENDER."Vertical.Fullart"]
title = "Force Vertical Framing on Fullart Templates"
desc = """When enabled, Fullart templates will frame all art using the vertical 'fullart' frame, even when horizontal art is provided. As a result, less area will be Content Aware or Generative Filled on horizontal arts, but the art will be 'zoomed in'."""
diff --git a/src/data/config/base.toml b/src/data/config/base.toml
index cf742f28..1c764e30 100644
--- a/src/data/config/base.toml
+++ b/src/data/config/base.toml
@@ -32,10 +32,41 @@ title = "Collector Mode"
desc = """Default: Formats collector info like modern cards pre-2023.
Modern: Formats collector info like March of the Machine and onwards.
Minimal: Only includes artist, set code, and language.
-Artist: Only includes artist."""
+Artist: Only includes artist.
+Custom: Formats collector info lines according to specified custom formats."""
type = "options"
default = "default"
-options = ["default", "modern", "minimal", "artist"]
+options = ["default", "modern", "minimal", "artist", "custom"]
+
+[TEXT."Collector.Line.A"]
+title = "First Collector Info Line Format"
+desc = """Fills the first collector info line with given text. The following variable substitutions may be used in the text:
+
+{pen}: Artist pen icon.
+{artist}: Card's artist.
+{number}: Card's collector number.
+{set}: Card's set.
+{rarity}: Card's rarity code.
+{lang}: Card's language code.
+{date:}: Current datetime formatted with strftime().
+
+If no text is provided, the layer will be hidden.
+
+Example:
+With Together as One [SOS] {4}
+Static Text {lang} - {set} - {number}
+would convert to
+Static Text EN - SOS - 4
+
+This is an experimental setting and the format syntax might change in a later version."""
+type = "string"
+default = ""
+
+[TEXT."Collector.Line.B"]
+title = "Second Collector Info Line Format"
+desc = "Fills the second collector info line with given text. Supports the same variable substitutions as the first line."
+type = "string"
+default = ""
[TEXT."Collector.Promo"]
title = "Collector Promo Star"
@@ -44,6 +75,38 @@ type = "options"
default = "automatic"
options = ["automatic", "always", "never"]
+[TEXT.Nickname]
+title = "Allow Nickname"
+desc = """When enabled, if a nickname is available and the template has support for it, the card will be rendered with both a real name and a nickname. Some Scryfall cards come with a nickname by default, e.g. Gemrazer [IKO] {376}. Nickname can also be specified via the art filename with [nick=...]. E.g.
+Arcane Signet [nick=Manastone]
+Keep Out (Artist) [ECL] [nick=Access Denied] {19}"""
+type = "bool"
+default = 1
+
+[TEXT."Nickname.Prompt"]
+title = "Prompt for Nickname"
+desc = """When enabled if no nickname is available via other means the rendering is paused for
+manual nickname input."""
+type = "bool"
+default = 0
+
+[TEXT."Nickname.In.Oracle"]
+title = "Nickname in Oracle Text"
+desc = """When enabled, cards that are rendered with a nickname will replace the card name
+in their oracle text with their nickname."""
+type = "bool"
+default = 1
+
+[TEXT."Nickname.Shorten.In.Oracle"]
+title = "Shorten Nickname in Oracle Text"
+desc = """Whether to shorten nicknames that contain commas in the oracle text. Shortened name contains only the part before the first comma.
+No: Name is not shortened.
+All but first: The first mention of the name is kept as is but others are shortened.
+All: All mentions of the name are shortened."""
+type = "options"
+default = "All but first"
+options = ["No", "All but first", "All"]
+
###
# * Symbol Settings
###
@@ -69,6 +132,15 @@ desc = """Forces Proxyshop to always used the Default Expansion Symbol chosen ab
type = "bool"
default = 0
+[SYMBOLS."Force.Rarity"]
+title = "Force Use of Specific Rarity"
+desc = """Forces Proxyshop to try to use a symbol of a specified rarity. The card's real rarity will be used as a fallback if the specified rarity can't be found.
+Possible rarities include:
80, B, C, H, M, R, S, T, U, WM
+You may also specify a code for any extra symbol variants that you might have copied to your installation. Extra variants are matched case sensitively by their filename without the suffix (.svg).
+Empty string disables this feature."""
+type = "string"
+default = ""
+
###
# * Watermark Settings
###
@@ -78,9 +150,9 @@ title = "Watermarks"
[WATERMARKS."Watermark.Mode"]
title = "Watermark Mode"
-desc = """[b]Automatic[/b]: Generates a watermark when present in Scryfall data.
-[b]Fallback[/b]: Automatic, but use Default defined below as a fallback for Scryfall data.
-[b]Forced[/b]: Always use Default watermark defined below."""
+desc = """Automatic: Generates a watermark when present in Scryfall data.
+Fallback: Automatic, but use Default defined below as a fallback for Scryfall data.
+Forced: Always use Default watermark defined below."""
type = "options"
default = "Disabled"
options = ["Disabled", "Automatic", "Fallback", "Forced"]
@@ -94,7 +166,7 @@ default = "WOTC"
[WATERMARKS."Watermark.Opacity"]
title = "Watermark Default Opacity"
desc = """Transparency value of the watermark, 0 being invisible 100 being fully visible."""
-type = "numeric"
+type = "float"
default = 30
[WATERMARKS."Enable.Basic.Watermark"]
@@ -110,12 +182,54 @@ default = 1
[TEMPLATES]
title = "Template Options"
+[TEMPLATES."Border.Fill.Mode"]
+title = "Border Fill Mode"
+desc = """Choose the method to use for filling card borders in borderless renders."""
+type = "options"
+default = "Content-Aware Fill"
+options = [
+ "No Fill",
+ "Content-Aware Fill",
+ "Generative Fill",
+ "Remove Content Fill",
+]
+
+[TEMPLATES."Border.Fill.Contract"]
+title = "Border Fill Contract"
+desc = """How much to contract the selection before filling."""
+type = "int"
+default = 10
+
+[TEMPLATES."Border.Fill.Smooth"]
+title = "Border Fill Smooth"
+desc = """How much to smooth the selection before filling."""
+type = "int"
+default = 0
+
+[TEMPLATES."Border.Fill.Feather"]
+title = "Border Fill Feather"
+desc = """How much to feather the selection before filling."""
+type = "int"
+default = 5
+
[TEMPLATES."Manual.Edit"]
title = "Manual Editing Mode"
desc = """Pause the render process before saving to allow for manual edits."""
type = "bool"
default = 0
+[TEMPLATES."Manual.Art.Alignment.Pause"]
+title = "Pause for manual art alignment"
+desc = """Pause the render process after automatic art alignment to allow for manual adjustments."""
+type = "bool"
+default = 0
+
+[TEMPLATES."Minimize.Photoshop"]
+title = "Minimize Photoshop"
+desc = """Minimize Photoshop when rendering starts. The rendering might be faster when Photoshop is minimized."""
+type = "bool"
+default = 0
+
[TEMPLATES."Import.Scryfall.Scan"]
title = "Import Scryfall Scan"
desc = """Will import the scryfall scan of each card into the document for reference. Useful for ensuring card accuracy when making manual changes."""
diff --git a/src/data/env.default.yml b/src/data/env.default.yml
index 98e8a071..9936ac04 100644
--- a/src/data/env.default.yml
+++ b/src/data/env.default.yml
@@ -10,40 +10,46 @@
###
# Google API Key - ONLY FOR ADVANCED USERS
-API_GOOGLE: null
+# API_GOOGLE: ""
# Cloudfront URL - ONLY FOR ADVANCED USERS
-API_AMAZON: null
+# API_AMAZON: ""
###
# * Photoshop Settings
###
# Enable Photoshop error dialogs when executing actions
-PS_ERROR_DIALOG: False
+# PS_ERROR_DIALOG: False
-# Optionally specify Photoshop version to look for (EXPERIMENTAL)
-PS_VERSION: null
+# Optionally specify Photoshop version to look for, e.g. "2025" (EXPERIMENTAL)
+# PS_VERSION: null
###
# * App Testing
###
# Force the app to use headless console (ADVANCED)
-HEADLESS: False
+# HEADLESS: False
-# Force the app into development mode
-DEV_MODE: False
+# Set Python logging level. DEBUG = 10, INFO = 20 and so on.
+# LOG_LEVEL: 20
-# Force the app into testing mode
-TEST_MODE: False
+###
+# * Update Checks
+###
+
+# GitHub repositories to use for updates.
+# Specify using format "/".
+APP_UPDATES_REPO: Investigamer/Proxyshop
+# SYMBOL_UPDATES_REPO: ""
###
# * Experimental
###
# Force plugin template modules to be reloaded on each new render
-FORCE_RELOAD: False
+# FORCE_RELOAD: False
# Give Proxyshop an alternative version string
-VERSION: null
+# VERSION: ""
diff --git a/src/data/kv/app.kv b/src/data/kv/app.kv
deleted file mode 100644
index 6c3f23d6..00000000
--- a/src/data/kv/app.kv
+++ /dev/null
@@ -1,32 +0,0 @@
-#:import get_color_from_hex kivy.utils.get_color_from_hex
-
-:
- id: app_container
- orientation: "vertical"
-
-:
- id: app_tabs
- do_default_tab: False
- tab_width: dp(120)
- tab_height: dp(30)
- tab_spacing: 0
- padding: app.cont_padding
- canvas:
- Color:
- rgba: get_color_from_hex('#2e2e2e')
- Rectangle:
- size: self.size
- pos: self.pos
-
-:
- id: tab_main
- text: "Render Cards"
- spacing: 0
-
-:
- id: tab_creator
- text: "Custom Creator"
-
-:
- id: tab_tools
- text: "Tools"
diff --git a/src/data/kv/console.kv b/src/data/kv/console.kv
deleted file mode 100644
index 169e923e..00000000
--- a/src/data/kv/console.kv
+++ /dev/null
@@ -1,126 +0,0 @@
-#:import HoverButton src.gui.utils.HoverButton
-#:import get_font src.gui.utils.get_font
-#:import get_color_from_hex kivy.utils.get_color_from_hex
-#:import ScrollView kivy.uix.scrollview.ScrollView
-#:import webbrowser webbrowser
-#:import ak asynckivy
-
-:
- id: console
- orientation: "horizontal"
- padding: dp(10)
- canvas:
- Color:
- rgba: get_color_from_hex("#666666")
- Rectangle:
- size: root.size
- pos: root.pos
- BoxLayout:
- id: viewport_container
- padding: dp(10),dp(10),dp(10),dp(10)
- size_hint: 4, 1
- canvas:
- Color:
- rgba: get_color_from_hex("#151515")
- Rectangle:
- size: self.width-10,self.height
- pos: self.pos
- ScrollView:
- id: viewport
- always_overscroll: False
- do_scroll_x: False
- size_hint: 1, 1
- GridLayout:
- id: output_container
- cols: 1
- padding: 0
- spacing: 0
- size_hint: (1,None)
- height: self.minimum_height
- ConsoleControls:
- id: console_controls
- orientation: "vertical"
- spacing: dp(4)
- padding: dp(10)
- canvas:
- Color:
- rgba: get_color_from_hex("#252525")
- Rectangle:
- size: self.width,self.height
- pos: self.pos
- BoxLayout:
- size_hint_y: None
- height: dp(30)
- orientation: "horizontal"
- ToggleButton:
- text: "π"
- font_name: get_font("seguiemj.ttf")
- on_press: app.toggle_window_locked()
- HoverButton:
- text: "π·"
- options: []
- font_name: get_font("seguiemj.ttf")
- on_release: app.screenshot_window()
- HoverButton:
- text: "π"
- options: []
- font_name: get_font("seguiemj.ttf")
- on_release: webbrowser.open('https://github.com/MrTeferi/Proxyshop')
- HoverButton:
- text: "β"
- options: []
- font_name: get_font("seguiemj.ttf")
- on_release: webbrowser.open('https://discord.gg/magicproxies')
- HoverButton:
- id: continue_btn
- size_hint_y: None
- height: dp(40)
- options: ["Go for it!", "Hurry up!", "Do it!", "Let's go!", "Next!"]
- hover_color: "#4a5f8a"
- on_press: console.signal(True)
- disabled: True
- text: "Continue"
- font_size: sp(20)
- background_normal: ""
- background_disabled_normal: ""
- background_color: get_color_from_hex("#283c5e")
- HoverButton:
- id: cancel_btn
- size_hint_y: None
- height: dp(40)
- options: ["STOP!", "End it now!", "No more!", "QUIT!", "Quiddit!", "Enough!"]
- hover_color: "#8a4a50"
- on_press: console.signal(False)
- disabled: True
- text: "Cancel"
- font_size: sp(20)
- background_normal: ""
- background_disabled_normal: ""
- background_color: get_color_from_hex("#63272d")
- HoverButton:
- id: update_btn
- size_hint_y: None
- height: dp(40)
- options: ["UPDATE"]
- hover_color: "#4a5f8a"
- on_press: ak.start(console.main.check_for_updates())
- disabled: False
- text: "Update"
- font_size: sp(20)
- background_normal: ""
- background_disabled_normal: ""
- background_color: get_color_from_hex("#283c5e")
- Label:
- text: ""
-
-:
- id: console_output
- padding: (0,0)
- size_hint: (1,None)
- markup: True
- font_size: sp(20)
- text_size: (self.width, None)
- size: self.texture_size
- text_autoupdate: True
- halign: "left"
- valign: "top"
diff --git a/src/data/kv/creator.kv b/src/data/kv/creator.kv
deleted file mode 100644
index 597eba03..00000000
--- a/src/data/kv/creator.kv
+++ /dev/null
@@ -1,350 +0,0 @@
-#:import get_color_from_hex kivy.utils.get_color_from_hex
-#:import get_font src.gui.utils.get_font
-#:import HoverButton src.gui.utils.HoverButton
-#:import Thread threading.Thread
-#:import InputItem src.gui.utils.InputItem
-#:import NoEnterInputItem src.gui.utils.NoEnterInputItem
-#:import FourNumInput src.gui.utils.FourNumInput
-#:import ThreeNumInput src.gui.utils.ThreeNumInput
-#:import FourCharInput src.gui.utils.FourCharInput
-#:import RaritySpinner src.gui.utils.RaritySpinner
-#:import RenderCustomButton src.gui.utils.RenderCustomButton
-#:import CollectorInput src.gui.utils.CollectorInput
-#:import SetCodeInput src.gui.utils.SetCodeInput
-#:import VerticalCenteredInput src.gui.utils.VerticalCenteredInput
-
-:
- id: panel_creator
- do_default_tab: False
- tab_width: dp(120)
- tab_height: dp(30)
- padding: dp(5)
- tab_spacing: 0
- canvas:
- Color:
- rgba: get_color_from_hex("#141414")
- Rectangle:
- size: self.size
- pos: self.pos
-
-:
- id: creator_norm
- cols: 1
- BoxLayout:
- size_hint_y: .75
- orientation: "horizontal"
- valign: "middle"
- VerticalCenteredInput:
- id: name
- hint_text: "Card Name"
- size_hint_x: 2
- font_size: sp(20)
- font_name: get_font("BelerenProxy-Bold.ttf")
- padding_x: dp(6), dp(6)
- VerticalCenteredInput:
- id: mana_cost
- hint_text: "{1}{W}{U}{B}{R}{G}"
- font_name: get_font("PlantinMTProRg.ttf")
- padding_x: dp(6), dp(6)
- VerticalCenteredInput:
- id: color_identity
- hint_text: "WUBRG"
- size_hint_x: .5
- halign: "center"
- font_name: get_font("PlantinMTProRg.ttf")
- padding_x: dp(6), dp(6)
- BoxLayout:
- orientation: "horizontal"
- size_hint_y: 2
- InputItem:
- id: oracle_text
- font_size: sp(20)
- font_name: get_font("PlantinMTProRg.ttf")
- hint_text: "Rules text"
- multiline: True
- BoxLayout:
- orientation: "horizontal"
- size_hint: (1, 2)
- InputItem:
- id: flavor_text
- font_size: sp(20)
- font_name: get_font("PlantinMTProRgIt.ttf", "Roboto-Italic")
- hint_text: "Flavor text"
- multiline: True
- BoxLayout:
- size_hint_y: .75
- orientation: "horizontal"
- VerticalCenteredInput:
- id: type_line
- text: "Type β Subtype"
- size_hint_x: 5
- font_size: sp(20)
- font_name: get_font("BelerenProxy-Bold.ttf")
- padding_x: dp(6), dp(6)
- BoxLayout:
- size_hint_y: .75
- orientation: "horizontal"
- CollectorInput:
- id: collector_number
- size_hint_x: .12
- CollectorInput:
- id: card_count
- hint_text: "100"
- size_hint_x: .12
- SetCodeInput:
- id: set
- size_hint_x: .12
- VerticalCenteredInput:
- id: keywords
- hint_text: "Keyword,Keyword 2"
- padding_x: dp(6), dp(6)
- PTInput:
- id: power
- size_hint_x: .08
- Label:
- text: "/"
- size_hint_x: .05
- PTInput:
- id: toughness
- size_hint_x: .08
- BoxLayout:
- size_hint_y: .75
- orientation: "horizontal"
- VerticalCenteredInput:
- id: artist
- hint_text: "Artist"
- size_hint_x: 2.5
- font_name: get_font("BelerenProxy-Bold.ttf")
- padding_x: dp(6), dp(6)
- RaritySpinner:
- id: rarity
- Spinner:
- id: template
- text: "Normal"
- values: root._template_names
- on_text: root.select_template(self)
- RenderCustomButton:
- id: render_btn
- press_action: root.render
-
-:
- id: creator_pw
- cols: 1
- BoxLayout:
- size_hint_y: .75
- orientation: "horizontal"
- valign: "middle"
- VerticalCenteredInput:
- id: name
- hint_text: "Card Name"
- size_hint_x: 2
- font_size: sp(20)
- font_name: get_font("BelerenProxy-Bold.ttf")
- padding: dp(6), dp(6)
- VerticalCenteredInput:
- id: mana_cost
- hint_text: "{1}{W}{U}{B}{R}{G}"
- font_name: get_font("PlantinMTProRg.ttf")
- padding: dp(6), dp(6)
- VerticalCenteredInput:
- id: color_identity
- hint_text: "WUBRG"
- size_hint_x: .5
- halign: "center"
- font_name: get_font("PlantinMTProRg.ttf")
- padding_x: dp(6), dp(6)
- BoxLayout:
- orientation: "horizontal"
- size_hint_y: 1
- NoEnterInputItem:
- id: line_1
- font_size: sp(20)
- font_name: get_font("PlantinMTProRg.ttf")
- hint_text: "This is a static ability."
- multiline: True
- BoxLayout:
- orientation: "horizontal"
- size_hint_y: 1
- NoEnterInputItem:
- id: line_2
- font_size: sp(20)
- font_name: get_font("PlantinMTProRg.ttf")
- hint_text: "+1: This ability increases loyalty."
- multiline: True
- BoxLayout:
- orientation: "horizontal"
- size_hint_y: 1
- NoEnterInputItem:
- id: line_3
- font_size: sp(20)
- font_name: get_font("PlantinMTProRg.ttf")
- hint_text: "-1: This ability decreases loyalty."
- multiline: True
- BoxLayout:
- orientation: "horizontal"
- size_hint_y: 1
- NoEnterInputItem:
- id: line_4
- font_size: sp(20)
- font_name: get_font("PlantinMTProRg.ttf")
- hint_text: "0: Planeswalkers need at least 2 ability lines."
- multiline: True
- BoxLayout:
- size_hint_y: .75
- orientation: "horizontal"
- VerticalCenteredInput:
- id: type_line
- text: "Legendary Planeswalker β Jace"
- size_hint_x: 4
- font_size: sp(20)
- font_name: get_font("BelerenProxy-Bold.ttf")
- padding_x: dp(6), dp(6)
- VerticalCenteredInput:
- id: keywords
- hint_text: "Keyword,Keyword 2"
- size_hint_x: 3.15
- halign: "center"
- padding_x: dp(6), dp(6)
- PTInput:
- id: loyalty
- hint_text: "6"
- size_hint_x: .3
- BoxLayout:
- size_hint_y: .75
- orientation: "horizontal"
- CollectorInput:
- id: collector_number
- size_hint_x: .4
- CollectorInput:
- id: card_count
- hint_text: "100"
- size_hint_x: .4
- SetCodeInput:
- id: set
- size_hint_x: .4
- VerticalCenteredInput:
- id: artist
- hint_text: "Artist"
- size_hint_x: 2.5
- font_name: get_font("BelerenProxy-Bold.ttf")
- padding_x: dp(6), dp(6)
- RaritySpinner:
- id: rarity
- Spinner:
- id: template
- text: "Normal"
- values: root._template_names
- on_text: root.select_template(self)
- RenderCustomButton:
- id: render_btn
- press_action: root.render
- size_hint_x: 1.2
-
-:
- id: creator_saga
- cols: 1
- BoxLayout:
- size_hint_y: .75
- orientation: "horizontal"
- valign: "middle"
- VerticalCenteredInput:
- id: name
- hint_text: "Card Name"
- size_hint_x: 2
- font_size: sp(20)
- font_name: get_font("BelerenProxy-Bold.ttf")
- padding_x: dp(6), dp(6)
- VerticalCenteredInput:
- id: mana_cost
- hint_text: "{1}{W}{U}{B}{R}{G}"
- font_name: get_font("PlantinMTProRg.ttf")
- padding_x: dp(6), dp(6)
- VerticalCenteredInput:
- id: color_identity
- hint_text: "WUBRG"
- size_hint_x: .5
- halign: "center"
- font_name: get_font("PlantinMTProRg.ttf")
- padding_x: dp(6), dp(6)
- BoxLayout:
- orientation: "horizontal"
- size_hint_y: 1
- NoEnterInputItem:
- id: line_1
- font_size: sp(20)
- font_name: get_font("PlantinMTProRg.ttf")
- hint_text: "First line"
- multiline: True
- BoxLayout:
- orientation: "horizontal"
- size_hint_y: 1
- NoEnterInputItem:
- id: line_2
- font_size: sp(20)
- font_name: get_font("PlantinMTProRg.ttf")
- hint_text: "Second line"
- multiline: True
- BoxLayout:
- orientation: "horizontal"
- size_hint_y: 1
- NoEnterInputItem:
- id: line_3
- font_size: sp(20)
- font_name: get_font("PlantinMTProRg.ttf")
- hint_text: "Third line [optional]"
- multiline: True
- BoxLayout:
- orientation: "horizontal"
- size_hint_y: 1
- NoEnterInputItem:
- id: line_4
- font_size: sp(20)
- font_name: get_font("PlantinMTProRg.ttf")
- hint_text: "Fourth line [optional]"
- multiline: True
- BoxLayout:
- size_hint_y: .75
- orientation: "horizontal"
- VerticalCenteredInput:
- id: type_line
- text: "Enchantment β Saga"
- size_hint_x: 5
- font_size: sp(20)
- font_name: get_font("BelerenProxy-Bold.ttf")
- padding_x: dp(6), dp(6)
- VerticalCenteredInput:
- id: keywords
- hint_text: "Keyword,Keyword 2"
- size_hint_x: 2.34
- halign: "center"
- padding_x: dp(6), dp(6)
- BoxLayout:
- size_hint_y: .75
- orientation: "horizontal"
- CollectorInput:
- id: collector_number
- size_hint_x: .4
- CollectorInput:
- id: card_count
- hint_text: "100"
- size_hint_x: .4
- SetCodeInput:
- id: set
- size_hint_x: .4
- VerticalCenteredInput:
- id: artist
- hint_text: "Artist"
- size_hint_x: 2.5
- font_name: get_font("BelerenProxy-Bold.ttf")
- padding_x: dp(6), dp(6)
- RaritySpinner:
- id: rarity
- Spinner:
- id: template
- text: "Normal"
- values: root._template_names
- on_text: root.select_template(self)
- RenderCustomButton:
- id: render_btn
- press_action: root.render
- size_hint_x: 1.2
diff --git a/src/data/kv/main.kv b/src/data/kv/main.kv
deleted file mode 100644
index ef7abef4..00000000
--- a/src/data/kv/main.kv
+++ /dev/null
@@ -1,135 +0,0 @@
-#:import ButtonImage src.gui.utils.ButtonImage
-#:import HoverButton src.gui.utils.HoverButton
-#:import get_font src.gui.utils.get_font
-#:import get_color_from_hex kivy.utils.get_color_from_hex
-#:import Thread threading.Thread
-#:import ak asynckivy
-#:import ScrollEffect kivy.effects.scroll.ScrollEffect
-
-
-:
- id: panel_main
- orientation: "vertical"
- padding: dp(5)
- spacing: 0
- canvas:
- Color:
- rgba: get_color_from_hex("#141414")
- Rectangle:
- size: panel_main.size
- pos: panel_main.pos
- BoxLayout:
- orientation: "horizontal"
- size_hint: (1, None)
- height: dp(40)
- HoverButton:
- id: rend_targ_btn
- text: "Render Target"
- options: ["RENDER TARGET"]
- font_size: sp(18)
- on_press: Thread(target=app.render_all, args=(True, []), daemon=True).start()
- background_color: get_color_from_hex("#376aa3")
- HoverButton:
- id: rend_all_btn
- text: "Render All"
- options: ["RENDER ALL"]
- font_size: sp(18)
- on_press: Thread(target=app.render_all, daemon=True).start()
- background_color: get_color_from_hex("#376aa3")
- HoverButton:
- id: app_settings_btn
- text: "Global Settings"
- options: ["GLOBAL SETTINGS"]
- font_size: sp(18)
- on_press: ak.start(app.open_app_settings())
- background_color: get_color_from_hex("#376aa3")
- TemplateModule:
- pos: (0, 0)
- tab_pos: "top_mid"
- tab_width: None
- tab_height: dp(26)
- tab_spacing: 0
- do_default_tab: False
- size_hint: (1, 2)
-
-:
- orientation: "horizontal"
- size_hint: (1, 1)
- pos: 0,0
- spacing: 0
- background_color: get_color_from_hex("#303030")
- BoxLayout:
- id: template_view_container
- size_hint: (1, 1)
- background_color: get_color_from_hex("#303030")
- BoxLayout:
- id: preview_container
- orientation: "vertical"
- size_hint: (None, 1)
- width: 0.735294117*preview_image.height if preview_image.height <=612 else 450
- background_color: get_color_from_hex("#303030")
- ButtonImage:
- source: 'src/img/notfound.jpg'
- id: preview_image
- mipmap: True
- fit_mode: "scale-down"
- nocache: True
- on_press: self.manager.toggle_preview_image()
- canvas:
- Rectangle:
- source: "src/img/overlay.png"
- size: preview_image.size
- pos: preview_image.pos
- BoxLayout:
- size_hint: (1, None)
- height: preview_container.height-612 if preview_container.height-612 >= 0 else 0
-
-:
- padding: 0
- spacing: 0
- size_hint: (1,None)
- height: self.minimum_height
- orientation: 'vertical'
-
-:
- bar_width: dp(4)
- always_overscroll: False
- do_scroll_x: False
- size_hint: (1,1)
- scroll_wheel_distance: 40
- effect_cls: ScrollEffect
- canvas:
- Color:
- rgba: get_color_from_hex("#111111")
- Rectangle:
- size: self.size
- pos: self.pos
-
-:
- orientation: "horizontal"
- size_hint: (1, None)
- height: dp(35)
- ToggleButton:
- id: toggle_button
- on_press: app.select_template(self)
- size_hint: (1, 1)
- halign: "left"
- TemplateSettingsButton:
- id: settings_button
- text: "βοΈ"
- options: []
- font_name: get_font("seguiemj.ttf")
- background_color: get_color_from_hex("#598cc5")
- size_hint: (None, 1)
- width: dp(40)
- on_press: ak.start(self.open_settings())
- TemplateResetDefaultButton:
- id: reset_default_button
- text: "π§ΉοΈ"
- options: []
- font_name: get_font("seguiemj.ttf")
- hover_color: "#ede98a"
- background_color: get_color_from_hex("#c5c059")
- size_hint: (None, 1)
- width: dp(40)
- on_press: ak.start(self.reset_default())
diff --git a/src/data/kv/test.kv b/src/data/kv/test.kv
deleted file mode 100644
index f98d6391..00000000
--- a/src/data/kv/test.kv
+++ /dev/null
@@ -1,46 +0,0 @@
-#:import get_color_from_hex kivy.utils.get_color_from_hex
-#:import ScrollView kivy.uix.scrollview.ScrollView
-#:import Thread threading.Thread
-
-:
- id: test_app
- orientation: "vertical"
- BoxLayout:
- size_hint: 1, None
- height: dp(60)
- orientation: "horizontal"
- spacing: dp(12)
- padding: dp(12)
- HoverButton:
- id: test_all
- font_size: sp(20)
- text: "Test All"
- font_name: "Roboto"
- on_release: Thread(target=app.test_all, daemon=True).start()
- HoverButton:
- id: test_target
- font_size: sp(20)
- text: "Deep Test Target"
- font_name: "Roboto"
- on_release: test_app.select_template()
- HoverButton:
- id: test_all_deep
- font_size: sp(20)
- text: "Deep Test All"
- font_name: "Roboto"
- on_release: Thread(target=app.test_all, args=(True,), daemon=True).start()
-
-:
- title: "Select a template to test!"
- ScrollView:
- id: viewport
- always_overscroll: False
- do_scroll_x: False
- size_hint: (1, 1)
- scroll_wheel_distance: dp(60)
- BoxLayout:
- id: content
- title: "Select test template"
- orientation: "vertical"
- size_hint: (1, None)
- height: self.minimum_height
\ No newline at end of file
diff --git a/src/data/kv/tools.kv b/src/data/kv/tools.kv
deleted file mode 100644
index 0d4b9817..00000000
--- a/src/data/kv/tools.kv
+++ /dev/null
@@ -1,102 +0,0 @@
-#:import get_color_from_hex kivy.utils.get_color_from_hex
-#:import Range100NumInput src.gui.utils.Range100NumInput
-
-:
- id: panel_tools
- orientation: "vertical"
- spacing: dp(8)
- padding: dp(5)
- canvas:
- Color:
- rgba: get_color_from_hex("#141414")
- Rectangle:
- size: self.size
- pos: self.pos
- BoxLayout:
- id: tools_showcase
- orientation: "horizontal"
- size_hint: (1, None)
- height: dp(40)
- HoverButton:
- id: generate_showcases_target
- text: "Render All Showcases"
- options: []
- font_size: sp(18)
- background_color: get_color_from_hex("#376aa3")
- font_name: get_font("Beleren Small Caps.ttf")
- on_press: Thread(target=root.render_showcases, daemon=True).start()
- HoverButton:
- id: generate_showcases
- text: "Render Target Showcase"
- options: []
- font_size: sp(18)
- background_color: get_color_from_hex("#376aa3")
- font_name: get_font("Beleren Small Caps.ttf")
- on_press: Thread(target=root.render_showcases, args=(True,), daemon=True).start()
- BoxLayout:
- id: tools_compress
- size_hint: (1, None)
- height: dp(40)
- orientation: "horizontal"
- spacing: dp(5)
- Label:
- text: "Quality:"
- halign: 'center'
- font_size: sp(18)
- bold: True
- Range100NumInput:
- id: compress_quality
- size_hint_x: None
- width: dp(50)
- halign: 'center'
- padding_y: [self.height / 2.0 - (self.line_height / 2.0) * len(self._lines), 0]
- text: "95"
- input_filter: 'int'
- Label:
- text: "800 DPI:"
- halign: 'center'
- font_size: sp(18)
- bold: True
- Switch:
- id: compress_dpi
- text: "800 DPI"
- active: False
- size_hint_x: None
- width: dp(100)
- Label:
- text: "Compress:"
- halign: 'center'
- font_size: sp(18)
- bold: True
- HoverButton:
- id: compress_arts
- text: "Arts"
- size_hint_x: None
- width: dp(60)
- options: []
- font_size: sp(18)
- background_color: get_color_from_hex("#376aa3")
- font_name: get_font("Beleren Small Caps.ttf")
- on_press: Thread(target=root.compress_arts, daemon=True).start()
- HoverButton:
- id: compress_renders
- text: "Renders"
- size_hint_x: None
- width: dp(90)
- options: []
- font_size: sp(18)
- background_color: get_color_from_hex("#376aa3")
- font_name: get_font("Beleren Small Caps.ttf")
- on_press: Thread(target=root.compress_renders, daemon=True).start()
- HoverButton:
- id: compress_renders_target
- text: "Target"
- size_hint_x: None
- width: dp(90)
- options: []
- font_size: sp(18)
- background_color: get_color_from_hex("#376aa3")
- font_name: get_font("Beleren Small Caps.ttf")
- on_press: Thread(target=root.compress_target, daemon=True).start()
- BoxLayout:
- size_hint: (1, .8)
\ No newline at end of file
diff --git a/src/data/kv/updater.kv b/src/data/kv/updater.kv
deleted file mode 100644
index 1bb5647b..00000000
--- a/src/data/kv/updater.kv
+++ /dev/null
@@ -1,106 +0,0 @@
-#:import HoverButton src.gui.utils.HoverButton
-#:import get_color_from_hex kivy.utils.get_color_from_hex
-#:import ScrollView kivy.uix.scrollview.ScrollView
-#:import ak asynckivy
-
-:
- title: "Proxyshop Updater"
- BoxLayout:
- orientation: "vertical"
- BoxLayout:
- canvas:
- Color:
- rgba: get_color_from_hex("#181818")
- Rectangle:
- size: self.width,self.height
- pos: self.pos
- orientation: "horizontal"
- size_hint: (1,None)
- height: dp(40)
- padding: dp(5)
- spacing: dp(10)
- Label:
- id: loading_text
- text: " [i]Checking for updates...[/i]"
- halign: "left"
- valign: "center"
- text_size: self.size
- font_size: sp(18)
- size_hint: (.85, 1)
- markup: True
- HoverButton:
- options: ["CLOSE"]
- text: "Close"
- size_hint: (.15,1)
- font_size: sp(20)
- on_release: root.dismiss()
- ScrollView:
- id: viewport
- always_overscroll: False
- do_scroll_x: False
- size_hint: (1, 1)
- GridLayout:
- id: container
- cols: 1
- size_hint: (1,None)
- height: self.minimum_height
- padding: [0,dp(20),0,0]
- Image:
- id: loading
- mipmap: True
- source: "src/img/loading.gif"
- allow_stretch: True
- size_hint: 1, None
- height: dp(180)
- width: dp(180)
- fit_mode: "scale-down"
- anim_delay: .06
-
-:
- id: entry
- size_hint: (1,None)
- height: dp(40)
- padding: dp(5)
- spacing: dp(10)
- canvas:
- Color:
- rgba: get_color_from_hex(self.bg_color)
- Rectangle:
- size: self.width,self.height
- pos: self.pos
- Label:
- id: title
- text: self.parent.name
- font_size: sp(20)
- text_size: self.size
- halign: "left"
- valign: "center"
- size_hint: (.60, 1)
- markup: True
- Label:
- id: status
- text: self.parent.status
- font_size: sp(18)
- text_size: self.size
- halign: "right"
- valign: "center"
- size_hint: (.10, 1)
- markup: True
- BoxLayout:
- id: mark_updated
- size_hint: (.15, 1)
- HoverButton:
- options: ["Dismiss"]
- text: "Dismiss"
- font_size: sp(18)
- on_release: ak.start(entry.mark_updated())
- background_color: get_color_from_hex("#821923")
- hover_color: "#b81d2c"
- BoxLayout:
- id: download
- size_hint: (.15, 1)
- HoverButton:
- options: ["Download"]
- text: "Download"
- font_size: sp(20)
- on_release: ak.start(entry.download_update(self.parent))
\ No newline at end of file
diff --git a/src/data/manifest.yml b/src/data/manifest.yml
index 528f3a7a..9def8328 100644
--- a/src/data/manifest.yml
+++ b/src/data/manifest.yml
@@ -1,215 +1,302 @@
normal.psd:
- id: '1t0vFPIKctuGmLMvWd3UG8k_mC-_W8LYe'
- desc: 'Normal M15-style frame, with additional support for Fullart and Stargazing treatments.'
+ id: "1t0vFPIKctuGmLMvWd3UG8k_mC-_W8LYe"
+ desc: "Normal M15-style frame, with additional support for Fullart and Stargazing treatments."
templates:
- Normal: M15Template
- Fullart: FullartTemplate
- Stargazing: StargazingTemplate
+ Normal:
+ M15Template:
+ - normal
+ Fullart:
+ FullartTemplate:
+ - normal
+ Stargazing:
+ StargazingTemplate:
+ - normal
normal-classic.psd:
- id: '1y5_W7Fr-TrRBIVaTdUmpa24Q9p-nPtg0'
+ id: "1y5_W7Fr-TrRBIVaTdUmpa24Q9p-nPtg0"
desc: "Classic frame based on 1997-era MTG cards."
- templates: { Classic: ClassicTemplate }
+ templates:
+ Classic:
+ ClassicTemplate:
+ - normal
classic-remastered.psd:
- id: '1DYbRMtKRZs5EeRYLl6i5lodzMyhTnIdI'
+ id: "1DYbRMtKRZs5EeRYLl6i5lodzMyhTnIdI"
desc: "A remastered take on the classic 1997 frame treatment, assets provided by iDerp."
templates:
Classic Remastered:
ClassicRemasteredTemplate:
- - normal
- - transform_front
- - transform_back
+ - normal
+ - transform_front
+ - transform_back
classic-modern.psd:
- id: '1BgakdZ4pKk48LIReoOBN00DhyKX2PeOd'
+ id: "1BgakdZ4pKk48LIReoOBN00DhyKX2PeOd"
desc: "A rendition of iDerp's 'Classic Remastered' template using modern frame structure."
templates:
Classic Modern:
ClassicModernTemplate:
- - normal
- - transform_front
- - transform_back
- - mdfc_front
- - mdfc_back
+ - normal
+ - transform_front
+ - transform_back
+ - mdfc_front
+ - mdfc_back
normal-extended.psd:
- id: '1iqMrsHlTOeOxCWPFcPaHlXK8x9yjfJ4d'
- desc: 'Normal M15-style frame with an extended art border treatment.'
- templates: { Extended: ExtendedTemplate }
+ id: "1iqMrsHlTOeOxCWPFcPaHlXK8x9yjfJ4d"
+ desc: "Normal M15-style frame with an extended art border treatment."
+ templates:
+ Extended:
+ ExtendedTemplate:
+ - normal
etched.psd:
- id: '13wJYra-Nx7NIvl78h6N8l7lEbxNj0V0y'
+ id: "13wJYra-Nx7NIvl78h6N8l7lEbxNj0V0y"
desc: "Based on the 'Etched' showcase introduced in Commander Legends."
- templates: { Etched: EtchedTemplate }
+ templates:
+ Etched:
+ EtchedTemplate:
+ - normal
masterpiece.psd:
- id: '1h15KM_BVNMxAAvqXkCfXksEwTyQPfgIP'
+ id: "1h15KM_BVNMxAAvqXkCfXksEwTyQPfgIP"
desc: "Based on the 'Invention' showcase introduced in Kaladesh."
- templates: { Invention: InventionTemplate }
+ templates:
+ Invention:
+ InventionTemplate:
+ - normal
znrexp.psd:
- id: '1b5y4wqNvLuvKeqyQb6QrXDJVxjpuVLHX'
+ id: "1b5y4wqNvLuvKeqyQb6QrXDJVxjpuVLHX"
desc: "Based on the newer 'Expedition' showcase frame introduced in Zendikar Rising."
- templates: { Expedition (ZNR): ExpeditionTemplate }
+ templates:
+ Expedition - ZNR:
+ ExpeditionTemplate:
+ - normal
borderless-vector.psd:
- id: '1mJ7JLD-aayTvhcrY9_Q21S3i3qAWeb7v'
+ id: "1mJ7JLD-aayTvhcrY9_Q21S3i3qAWeb7v"
desc: "Based on the 'Borderless' box-topper showcase used in most masters sets."
templates:
Borderless:
BorderlessVectorTemplate:
- - normal
- - transform_front
- - transform_back
- - mdfc_front
- - mdfc_back
+ - normal
+ - transform_front
+ - transform_back
+ - mdfc_front
+ - mdfc_back
miracle.psd:
- id: '1WXfMLkIk4S75210k1l7Z9B6gpol4RqHj'
+ id: "1WXfMLkIk4S75210k1l7Z9B6gpol4RqHj"
desc: "A normal M15-style frame for 'Miracle' cards."
- templates: { Miracle: MiracleTemplate }
+ templates:
+ Miracle:
+ MiracleTemplate:
+ - normal
snow.psd:
- id: '1cqpVFgFnpOq9I43iy2OqnozuvullGCez'
+ id: "1cqpVFgFnpOq9I43iy2OqnozuvullGCez"
desc: "A normal M15-style frame for 'Snow' cards."
- templates: { Snow: SnowTemplate }
+ templates:
+ Snow:
+ SnowTemplate:
+ - normal
universes-beyond.psd:
- id: '1UufcIernCbNX0-xNxajVlTc2dLqUkqr6'
+ id: "1UufcIernCbNX0-xNxajVlTc2dLqUkqr6"
desc: "Based on the 'Universes Beyond' frame treatment used in MTG crossover products."
templates:
Universes Beyond:
UniversesBeyondTemplate:
- - normal
- - transform_front
- - transform_back
+ - normal
+ - transform_front
+ - transform_back
lord-of-the-rings.psd:
- id: '1jFQgCYqev2LpHSFkraaWJIP9q51G8py-'
+ id: "1jFQgCYqev2LpHSFkraaWJIP9q51G8py-"
desc: "Based on the 'Lord of the Rings' showcase introduced in Tales of Middle-earth."
- templates: { Lord of the Rings: LOTRTemplate }
+ templates:
+ Lord of the Rings:
+ LOTRTemplate:
+ - normal
tf-front.psd:
- id: '13TCpRzvOHfxahpXu568AJ-EZzDDjehi7'
+ id: "13TCpRzvOHfxahpXu568AJ-EZzDDjehi7"
templates:
- Normal: { TransformTemplate: transform_front }
+ Normal:
+ TransformTemplate:
+ - transform_front
tf-back.psd:
- id: '1j__OIXyejP-3H7aWm3IyuILgcj3ASq_L'
+ id: "1j__OIXyejP-3H7aWm3IyuILgcj3ASq_L"
templates:
- Normal: { TransformTemplate: transform_back }
+ Normal:
+ TransformTemplate:
+ - transform_back
mdfc-front.psd:
- id: '1_4sinFiSjIN8ayW8gpsxkwgGAa-FPc6H'
- templates: { Normal: { MDFCTemplate: mdfc_front } }
+ id: "1_4sinFiSjIN8ayW8gpsxkwgGAa-FPc6H"
+ templates:
+ Normal:
+ MDFCTemplate:
+ - mdfc_front
mdfc-back.psd:
- id: '189N6G0uPEPAoGQya6ov8g9ZDG4A6lZUA'
- templates: { Normal: { MDFCTemplate: mdfc_back } }
+ id: "189N6G0uPEPAoGQya6ov8g9ZDG4A6lZUA"
+ templates:
+ Normal:
+ MDFCTemplate:
+ - mdfc_back
ixalan.psd:
- id: '1e_VX3nJ5c6bZMObMLt5c1jJGiaaw5-4d'
- requires:
- templates:
- - tf-front.psd
- - tf-back.psd
+ id: "1e_VX3nJ5c6bZMObMLt5c1jJGiaaw5-4d"
templates:
Ixalan:
IxalanTemplate:
- - transform_back
- - transform_front
+ - normal
+ - transform_back
+ - transform_front
mutate.psd:
- id: '1A5YNZA9Z9eE_jdDIPFJCqp_AxcEiAcdL'
- templates: { Normal: { MutateTemplate: mutate } }
+ id: "1A5YNZA9Z9eE_jdDIPFJCqp_AxcEiAcdL"
+ templates:
+ Normal:
+ MutateTemplate:
+ - mutate
normal-vector.psd:
- id: '16Ll3t_u9VwnNRfbhpvV12CL1iwWbyDG0'
- templates: { Normal: { AdventureVectorTemplate: adventure } }
+ id: "16Ll3t_u9VwnNRfbhpvV12CL1iwWbyDG0"
+ templates:
+ Normal:
+ AdventureVectorTemplate:
+ - adventure
leveler.psd:
- id: '1tLrYbTphadzxxyICtaRSqzRUfpekJckf'
- templates: { Normal: { LevelerTemplate: leveler } }
+ id: "1tLrYbTphadzxxyICtaRSqzRUfpekJckf"
+ templates:
+ Normal:
+ LevelerTemplate:
+ - leveler
vertical-mode.psd:
- id: '1ytlBqIywu7gyTrgM9-hKj-apKe6v18Ek'
+ id: "1ytlBqIywu7gyTrgM9-hKj-apKe6v18Ek"
desc: "A powerful Vector template that can render both Saga and Class cards in Normal and Universes Beyond treatment."
templates:
Universes Beyond:
- UniversesBeyondSagaTemplate: saga
- UniversesBeyondClassTemplate: class
+ UniversesBeyondSagaTemplate:
+ - saga
+ UniversesBeyondClassTemplate:
+ - class
Normal:
- SagaVectorTemplate: saga
- ClassVectorTemplate: class
+ SagaVectorTemplate:
+ - saga
+ ClassVectorTemplate:
+ - class
split.psd:
- id: '1J8CFOqxxiopOMBbEmNjayNJvGsH6uyvn'
- templates: { Normal: { SplitTemplate: split } }
+ id: "1J8CFOqxxiopOMBbEmNjayNJvGsH6uyvn"
+ templates:
+ Normal:
+ SplitTemplate:
+ - split
battle.psd:
- id: '1_Je_F91VgancyC6EH9JAKc35cwB9HoqJ'
+ id: "1_Je_F91VgancyC6EH9JAKc35cwB9HoqJ"
templates:
- Normal: { BattleTemplate: battle }
- Universes Beyond: { UniversesBeyondBattleTemplate: battle }
+ Normal:
+ BattleTemplate:
+ - battle
+ Universes Beyond:
+ UniversesBeyondBattleTemplate:
+ - battle
prototype.psd:
- id: '1XwUYcjX18nRUFcWR6OjEyE_H6pHz1uOE'
- templates: { Normal: { PrototypeTemplate: prototype } }
+ id: "1XwUYcjX18nRUFcWR6OjEyE_H6pHz1uOE"
+ templates:
+ Normal:
+ PrototypeTemplate:
+ - prototype
pw.psd:
- id: '1hlXvORM3z7bqURPyDG3VYDUeqq94ek81'
- templates: { Normal: { PlaneswalkerTemplate: planeswalker } }
+ id: "1hlXvORM3z7bqURPyDG3VYDUeqq94ek81"
+ templates:
+ Normal:
+ PlaneswalkerTemplate:
+ - planeswalker
pw-extended.psd:
- id: '1MjIF48qTyAzax7LRdtGAvZLcWTZBIzCB'
+ id: "1MjIF48qTyAzax7LRdtGAvZLcWTZBIzCB"
templates:
- Borderless: { PlaneswalkerBorderlessTemplate: planeswalker }
+ Borderless:
+ PlaneswalkerBorderlessTemplate:
+ - planeswalker
pw-tf-front.psd:
- id: '1fwhWb4BHRRjs2KoGZhLMGtQ3ngmeeQ-o'
+ id: "1fwhWb4BHRRjs2KoGZhLMGtQ3ngmeeQ-o"
templates:
- Normal: { PlaneswalkerTFTemplate: pw_tf_front }
+ Normal:
+ PlaneswalkerTFTemplate:
+ - pw_tf_front
pw-tf-front-extended.psd:
- id: '1TtHwTkDhWl5jJi3b_OiMa-E4izhfcTK7'
+ id: "1TtHwTkDhWl5jJi3b_OiMa-E4izhfcTK7"
templates:
- Borderless: { PlaneswalkerTFBorderlessTemplate: pw_tf_front }
+ Borderless:
+ PlaneswalkerTFBorderlessTemplate:
+ - pw_tf_front
pw-tf-back.psd:
- id: '10_rHVPO7iIbmSlGAxgK0zMz7_lhJeaMf'
+ id: "10_rHVPO7iIbmSlGAxgK0zMz7_lhJeaMf"
templates:
- Normal: { PlaneswalkerTFTemplate: pw_tf_back }
+ Normal:
+ PlaneswalkerTFTemplate:
+ - pw_tf_back
pw-tf-back-extended.psd:
- id: '1H1chKvKFiFYDrnN226zshNo0wQNDIQIX'
+ id: "1H1chKvKFiFYDrnN226zshNo0wQNDIQIX"
templates:
- Borderless: { PlaneswalkerTFBorderlessTemplate: pw_tf_back }
+ Borderless:
+ PlaneswalkerTFBorderlessTemplate:
+ - pw_tf_back
pw-mdfc-front.psd:
- id: '1hQBlgYaGIWIgCf4hkZRjcvMN9VJCkbVe'
+ id: "1hQBlgYaGIWIgCf4hkZRjcvMN9VJCkbVe"
templates:
- Normal: { PlaneswalkerMDFCTemplate: pw_mdfc_front }
+ Normal:
+ PlaneswalkerMDFCTemplate:
+ - pw_mdfc_front
pw-mdfc-front-extended.psd:
- id: '1ddzLx-ookhyrjjJnOGp8pzW29FHVSbYQ'
+ id: "1ddzLx-ookhyrjjJnOGp8pzW29FHVSbYQ"
templates:
- Borderless: { PlaneswalkerMDFCBorderlessTemplate: pw_mdfc_front }
+ Borderless:
+ PlaneswalkerMDFCBorderlessTemplate:
+ - pw_mdfc_front
pw-mdfc-back.psd:
- id: '1J_giOHB_07SK0LPpxH4n9Me6CyJqslvj'
+ id: "1J_giOHB_07SK0LPpxH4n9Me6CyJqslvj"
templates:
- Normal: { PlaneswalkerMDFCTemplate: pw_mdfc_back }
+ Normal:
+ PlaneswalkerMDFCTemplate:
+ - pw_mdfc_back
pw-mdfc-back-extended.psd:
- id: '1BwX6vwoH-HNnAjz8408_PujankJceFox'
+ id: "1BwX6vwoH-HNnAjz8408_PujankJceFox"
templates:
- Borderless: { PlaneswalkerMDFCBorderlessTemplate: pw_mdfc_back }
+ Borderless:
+ PlaneswalkerMDFCBorderlessTemplate:
+ - pw_mdfc_back
token.psd:
- id: '1oWzk6MSv5wQMVJJE3VossKWs1BylBvKP'
- templates: { Chilli Token: { TokenTemplate: normal } }
+ id: "1oWzk6MSv5wQMVJJE3VossKWs1BylBvKP"
+ templates:
+ Chilli Token:
+ TokenTemplate:
+ - normal
planar.psd:
- id: '1MGSgSkvUGe6M8-TEI8cMby_eU3i1uR_E'
- templates: { Normal: { PlanarTemplate: planar } }
+ id: "1MGSgSkvUGe6M8-TEI8cMby_eU3i1uR_E"
+ templates:
+ Normal:
+ PlanarTemplate:
+ - planar
diff --git a/src/data/tests/frame_data.toml b/src/data/tests/frame_data.toml
deleted file mode 100644
index 34faf061..00000000
--- a/src/data/tests/frame_data.toml
+++ /dev/null
@@ -1,295 +0,0 @@
-# TEST CASES: Frame Logic
-
-# * TEST CASE
-["Mono Color, Normal Frame"]
-"Healing Salve" = ["NormalLayout", "W", "W", "W", "False", "False"]
-"Ancestral Recall" = ["NormalLayout", "U", "U", "U", "False", "False"]
-"Dark Ritual" = ["NormalLayout", "B", "B", "B", "False", "False"]
-"Lightning Bolt" = ["NormalLayout", "R", "R", "R", "False", "False"]
-"Giant Growth" = ["NormalLayout", "G", "G", "G", "False", "False"]
-
-# * TEST CASE
-["Mono Color, 2/C in Mana Cost"]
-"Spectral Procession" = ["NormalLayout", "W", "W", "W", "False", "False"]
-"Advice from the Fae" = ["NormalLayout", "U", "U", "U", "False", "False"]
-"Beseech the Queen" = ["NormalLayout", "B", "B", "B", "False", "False"]
-"Flame Javelin" = ["NormalLayout", "R", "R", "R", "False", "False"]
-"Tower Above" = ["NormalLayout", "G", "G", "G", "False", "False"]
-
-# * TEST CASE
-["Mono Color, 0 Cost Pacts"]
-"Intervention Pact" = ["NormalLayout", "W", "W", "W", "False", "False"]
-"Pact of Negation" = ["NormalLayout", "U", "U", "U", "False", "False"]
-"Slaughter Pact" = ["NormalLayout", "B", "B", "B", "False", "False"]
-"Pact of the Titan" = ["NormalLayout", "R", "R", "R", "False", "False"]
-"Summoner's Pact" = ["NormalLayout", "G", "G", "G", "False", "False"]
-
-# * TEST CASE
-["Nyxtouched Enchantment Creatures"]
-"Heliod, God of the Sun [THS]" = ["NormalLayout", "W", "W", "W", "True", "False"]
-"Thassa, God of the Sea" = ["NormalLayout", "U", "U", "U", "True", "False"]
-"Erebos, God of the Dead" = ["NormalLayout", "B", "B", "B", "True", "False"]
-"Purphoros, God of the Forge [THS]" = ["NormalLayout", "R", "R", "R", "True", "False"]
-"Nylea, God of the Hunt" = ["NormalLayout", "G", "G", "G", "True", "False"]
-
-# * TEST CASE
-["No Mana Cost, Suspend"]
-"Restore Balance" = ["NormalLayout", "W", "W", "W", "False", "False"]
-"Ancestral Vision" = ["NormalLayout", "U", "U", "U", "False", "False"]
-"Living End" = ["NormalLayout", "B", "B", "B", "False", "False"]
-"Wheel of Fate" = ["NormalLayout", "R", "R", "R", "False", "False"]
-"Hypergenesis" = ["NormalLayout", "G", "G", "G", "False", "False"]
-"Lotus Bloom" = ["NormalLayout", "Artifact", "Artifact", "Artifact", "False", "False"]
-
-# * TEST CASE
-["Dual Color, Normal Frame"]
-"Azorius Charm" = ["NormalLayout", "Gold", "WU", "Gold", "False", "False"]
-"Dimir Charm" = ["NormalLayout", "Gold", "UB", "Gold", "False", "False"]
-"Rakdos Charm" = ["NormalLayout", "Gold", "BR", "Gold", "False", "False"]
-"Gruul Charm" = ["NormalLayout", "Gold", "RG", "Gold", "False", "False"]
-"Selesnya Charm" = ["NormalLayout", "Gold", "GW", "Gold", "False", "False"]
-"Orzhov Charm" = ["NormalLayout", "Gold", "WB", "Gold", "False", "False"]
-"Golgari Charm" = ["NormalLayout", "Gold", "BG", "Gold", "False", "False"]
-"Simic Charm" = ["NormalLayout", "Gold", "GU", "Gold", "False", "False"]
-"Izzet Charm" = ["NormalLayout", "Gold", "UR", "Gold", "False", "False"]
-"Boros Charm" = ["NormalLayout", "Gold", "RW", "Gold", "False", "False"]
-
-# * TEST CASE
-["Dual Color, Hybrid Frame"]
-"Godhead of Awe" = ["NormalLayout", "WU", "WU", "Land", "False", "False"]
-"Ghastlord of Fugue" = ["NormalLayout", "UB", "UB", "Land", "False", "False"]
-"Demigod of Revenge" = ["NormalLayout", "BR", "BR", "Land", "False", "False"]
-"Deus of Calamity" = ["NormalLayout", "RG", "RG", "Land", "False", "False"]
-"Oversoul of Dusk" = ["NormalLayout", "GW", "GW", "Land", "False", "False"]
-"Divinity of Pride" = ["NormalLayout", "WB", "WB", "Land", "False", "False"]
-"Deity of Scars" = ["NormalLayout", "BG", "BG", "Land", "False", "False"]
-"Overbeing of Myth" = ["NormalLayout", "GU", "GU", "Land", "False", "False"]
-"Dominus of Fealty" = ["NormalLayout", "UR", "UR", "Land", "False", "False"]
-"Nobilis of War" = ["NormalLayout", "RW", "RW", "Land", "False", "False"]
-
-# * TEST CASE
-["No Mana Cost, Hybrid Frame"]
-"Asmoranomardicadaistinaculdacar" = ["NormalLayout", "BR", "BR", "Land", "False", "False"]
-
-# * TEST CASE
-["Dual Color, Gold Frame, Hybrid Mana"]
-"Maelstrom Muse" = ["NormalLayout", "Gold", "UR", "Gold", "False", "False"]
-"Ajani, Sleeper Agent" = ["PlaneswalkerLayout", "Gold", "GW", "Gold", "False", "False"]
-"Tamiyo, Compleated Sage" = ["PlaneswalkerLayout", "Gold", "GU", "Gold", "False", "False"]
-
-# * TEST CASE
-["Tri Color, Gold Frame, Hybrid Mana"]
-"Messenger Falcons" = ["NormalLayout", "Gold", "Gold", "Gold", "False", "False"]
-
-# * TEST CASE
-["Double Faced Cards"]
-"Insectile Aberration" = ["TransformLayout", "U", "U", "U", "False", "False"]
-"Ravager of the Fells" = ["TransformLayout", "Gold", "RG", "Gold", "False", "False"]
-"Brisela, Voice of Nightmares" = ["TransformLayout", "Colorless", "Colorless", "Colorless", "False", "True"]
-"Urza, Planeswalker" = ["PlaneswalkerTransformLayout", "Gold", "WU", "Gold", "False", "False"]
-"Archangel Avacyn" = ["TransformLayout", "W", "W", "W", "False", "False"]
-"Avacyn, the Purifier" = ["TransformLayout", "R", "R", "R", "False", "False"]
-"Curious Homunculus" = ["TransformLayout", "U", "U", "U", "False", "False"]
-"Voracious Reader" = ["TransformLayout", "Colorless", "Colorless", "Colorless", "False", "True"]
-"Barkchannel Pathway" = ["ModalDoubleFacedLayout", "Land", "G", "G", "False", "False"]
-"Tidechannel Pathway" = ["ModalDoubleFacedLayout", "Land", "U", "U", "False", "False"]
-"Blex, Vexing Pest" = ["ModalDoubleFacedLayout", "G", "G", "G", "False", "False"]
-"Search for Blex" = ["ModalDoubleFacedLayout", "B", "B", "B", "False", "False"]
-"Extus, Oriq Overlord" = ["ModalDoubleFacedLayout", "Gold", "WB", "Gold", "False", "False"]
-"Awaken the Blood Avatar" = ["ModalDoubleFacedLayout", "Gold", "BR", "Gold", "False", "False"]
-
-# * TEST CASE
-["Tri Color, Normal Frame"]
-"Esper Charm" = ["NormalLayout", "Gold", "Gold", "Gold", "False", "False"]
-"Grixis Charm" = ["NormalLayout", "Gold", "Gold", "Gold", "False", "False"]
-"Jund Charm" = ["NormalLayout", "Gold", "Gold", "Gold", "False", "False"]
-"Naya Charm" = ["NormalLayout", "Gold", "Gold", "Gold", "False", "False"]
-"Bant Charm" = ["NormalLayout", "Gold", "Gold", "Gold", "False", "False"]
-"Abzan Charm" = ["NormalLayout", "Gold", "Gold", "Gold", "False", "False"]
-"Jeskai Charm" = ["NormalLayout", "Gold", "Gold", "Gold", "False", "False"]
-"Sultai Charm" = ["NormalLayout", "Gold", "Gold", "Gold", "False", "False"]
-"Mardu Charm" = ["NormalLayout", "Gold", "Gold", "Gold", "False", "False"]
-"Temur Charm" = ["NormalLayout", "Gold", "Gold", "Gold", "False", "False"]
-
-# * TEST CASE
-["Colorless, Non-Artifact"]
-"Emrakul, the Aeons Torn" = ["NormalLayout", "Colorless", "Colorless", "Colorless", "False", "True"]
-"Scion of Ugin" = ["NormalLayout", "Colorless", "Colorless", "Colorless", "False", "True"]
-
-# * TEST CASE
-["Colorless, Artifact"]
-"Herald's Horn" = ["NormalLayout", "Artifact", "Artifact", "Artifact", "False", "False"]
-"Black Lotus" = ["NormalLayout", "Artifact", "Artifact", "Artifact", "False", "False"]
-"Mox Pearl" = ["NormalLayout", "Artifact", "Artifact", "Artifact", "False", "False"]
-"Mox Sapphire" = ["NormalLayout", "Artifact", "Artifact", "Artifact", "False", "False"]
-"Mox Jet" = ["NormalLayout", "Artifact", "Artifact", "Artifact", "False", "False"]
-"Mox Ruby" = ["NormalLayout", "Artifact", "Artifact", "Artifact", "False", "False"]
-"Mox Emerald" = ["NormalLayout", "Artifact", "Artifact", "Artifact", "False", "False"]
-
-# * TEST CASE
-["Mono Color, Artifact"]
-"The Circle of Loyalty" = ["NormalLayout", "Artifact", "W", "W", "False", "False"]
-"The Magic Mirror" = ["NormalLayout", "Artifact", "U", "U", "False", "False"]
-"The Cauldron of Eternity" = ["NormalLayout", "Artifact", "B", "B", "False", "False"]
-"Embercleave" = ["NormalLayout", "Artifact", "R", "R", "False", "False"]
-"The Great Henge" = ["NormalLayout", "Artifact", "G", "G", "False", "False"]
-
-# * TEST CASE
-["Dual Color, Artifact"]
-"Filigree Angel" = ["NormalLayout", "Artifact", "WU", "Gold", "False", "False"]
-"Time Sieve" = ["NormalLayout", "Artifact", "UB", "Gold", "False", "False"]
-"Demonspine Whip" = ["NormalLayout", "Artifact", "BR", "Gold", "False", "False"]
-"Mage Slayer" = ["NormalLayout", "Artifact", "RG", "Gold", "False", "False"]
-"Behemoth Sledge" = ["NormalLayout", "Artifact", "GW", "Gold", "False", "False"]
-"Tainted Sigil" = ["NormalLayout", "Artifact", "WB", "Gold", "False", "False"]
-"Shardless Agent" = ["NormalLayout", "Artifact", "GU", "Gold", "False", "False"]
-"Etherium-Horn Sorcerer" = ["NormalLayout", "Artifact", "UR", "Gold", "False", "False"]
-
-# * TEST CASE
-["Tri Color, Artifact"]
-"Sphinx of the Steel Wind" = ["NormalLayout", "Artifact", "Gold", "Gold", "False", "False"]
-"Thopter Foundry" = ["NormalLayout", "Artifact", "Gold", "Gold", "False", "False"]
-
-# * TEST CASE
-["WUBRG, Artifact"]
-"Sphinx of the Guildpact" = ["NormalLayout", "Artifact", "Gold", "Gold", "False", "False"]
-"Reaper King" = ["NormalLayout", "Artifact", "Gold", "Gold", "False", "False"]
-
-# * TEST CASE
-["Non-Colored, Utility Land"]
-"Vesuva" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Evolving Wilds" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Karn's Bastion" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Hall of Heliod's Generosity" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Academy Ruins" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Volrath's Stronghold" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Gemstone Caverns" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Glacial Chasm" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Ash Barrens" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Crumbling Vestige" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Blighted Steppe" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Blighted Cataract" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Blighted Fen" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Blighted Gorge" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Blighted Woodland" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Maze's End" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Inventors' Fair" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Myriad Landscape" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Crystal Quarry" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Demolition Field" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-
-# * TEST CASE
-["Non-Colored, Panorama"]
-"Esper Panorama" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Grixis Panorama" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Jund Panorama" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Naya Panorama" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-"Bant Panorama" = ["NormalLayout", "Land", "Land", "Land", "False", "False"]
-
-# * TEST CASE
-["Mono Color, Land, Add {C}"]
-"Castle Ardenvale" = ["NormalLayout", "Land", "W", "W", "False", "False"]
-"Castle Vantress" = ["NormalLayout", "Land", "U", "U", "False", "False"]
-"Castle Locthwain" = ["NormalLayout", "Land", "B", "B", "False", "False"]
-"Castle Embereth" = ["NormalLayout", "Land", "R", "R", "False", "False"]
-"Castle Garenbrig" = ["NormalLayout", "Land", "G", "G", "False", "False"]
-"Serra's Sanctum" = ["NormalLayout", "Land", "W", "W", "False", "False"]
-"Tolarian Academy" = ["NormalLayout", "Land", "U", "U", "False", "False"]
-"Cabal Coffers" = ["NormalLayout", "Land", "B", "B", "False", "False"]
-"Gaea's Cradle" = ["NormalLayout", "Land", "G", "G", "False", "False"]
-
-# * TEST CASE
-["Mono Color, Land, Basic Land Type"]
-"Idyllic Grange" = ["NormalLayout", "Land", "W", "W", "False", "False"]
-"Mystic Sanctuary" = ["NormalLayout", "Land", "U", "U", "False", "False"]
-"Witch's Cottage" = ["NormalLayout", "Land", "B", "B", "False", "False"]
-"Dwarven Mine" = ["NormalLayout", "Land", "R", "R", "False", "False"]
-"Gingerbread Cabin" = ["NormalLayout", "Land", "G", "G", "False", "False"]
-
-# * TEST CASE
-["Mono Color, Land, Multicolor Activated Ability"]
-"Axgard Armory" = ["NormalLayout", "Land", "W", "W", "False", "False"]
-"Surtland Frostpyre" = ["NormalLayout", "Land", "R", "R", "False", "False"]
-"Skemfar Elderhall" = ["NormalLayout", "Land", "G", "G", "False", "False"]
-
-# * TEST CASE
-["Mono Color, Land, All Lands are X"]
-"Urborg, Tomb of Yawgmoth" = ["NormalLayout", "Land", "B", "B", "False", "False"]
-"Yavimaya, Cradle of Growth" = ["NormalLayout", "Land", "G", "G", "False", "False"]
-
-# * TEST CASE
-["Mono Color, Vivid Land"]
-"Vivid Meadow" = ["NormalLayout", "Land", "W", "W", "False", "False"]
-"Vivid Creek" = ["NormalLayout", "Land", "U", "U", "False", "False"]
-"Vivid Marsh" = ["NormalLayout", "Land", "B", "B", "False", "False"]
-"Vivid Crag" = ["NormalLayout", "Land", "R", "R", "False", "False"]
-"Vivid Grove" = ["NormalLayout", "Land", "G", "G", "False", "False"]
-
-# * TEST CASE
-["Dual Color, Land, Add {C} or {C}"]
-"Celestial Colonnade" = ["NormalLayout", "Land", "WU", "Land", "False", "False"]
-"Creeping Tar Pit" = ["NormalLayout", "Land", "UB", "Land", "False", "False"]
-"Lavaclaw Reaches" = ["NormalLayout", "Land", "BR", "Land", "False", "False"]
-"Raging Ravine" = ["NormalLayout", "Land", "RG", "Land", "False", "False"]
-"Stirring Wildwood" = ["NormalLayout", "Land", "GW", "Land", "False", "False"]
-"Shambling Vent" = ["NormalLayout", "Land", "WB", "Land", "False", "False"]
-"Hissing Quagmire" = ["NormalLayout", "Land", "BG", "Land", "False", "False"]
-"Lumbering Falls" = ["NormalLayout", "Land", "GU", "Land", "False", "False"]
-"Wandering Fumarole" = ["NormalLayout", "Land", "UR", "Land", "False", "False"]
-"Needle Spires" = ["NormalLayout", "Land", "RW", "Land", "False", "False"]
-
-# * TEST CASE
-["Dual Color, Land, Basic Land Types"]
-"Hallowed Fountain" = ["NormalLayout", "Land", "WU", "Land", "False", "False"]
-"Watery Grave" = ["NormalLayout", "Land", "UB", "Land", "False", "False"]
-"Blood Crypt" = ["NormalLayout", "Land", "BR", "Land", "False", "False"]
-"Stomping Ground" = ["NormalLayout", "Land", "RG", "Land", "False", "False"]
-"Temple Garden" = ["NormalLayout", "Land", "GW", "Land", "False", "False"]
-"Godless Shrine" = ["NormalLayout", "Land", "WB", "Land", "False", "False"]
-"Overgrown Tomb" = ["NormalLayout", "Land", "BG", "Land", "False", "False"]
-"Breeding Pool" = ["NormalLayout", "Land", "GU", "Land", "False", "False"]
-"Steam Vents" = ["NormalLayout", "Land", "UR", "Land", "False", "False"]
-"Sacred Foundry" = ["NormalLayout", "Land", "RW", "Land", "False", "False"]
-
-# * TEST CASE
-["Dual Color, Fetch Land"]
-"Flooded Strand" = ["NormalLayout", "Land", "WU", "Land", "False", "False"]
-"Polluted Delta" = ["NormalLayout", "Land", "UB", "Land", "False", "False"]
-"Bloodstained Mire" = ["NormalLayout", "Land", "BR", "Land", "False", "False"]
-"Wooded Foothills" = ["NormalLayout", "Land", "RG", "Land", "False", "False"]
-"Windswept Heath" = ["NormalLayout", "Land", "GW", "Land", "False", "False"]
-"Marsh Flats" = ["NormalLayout", "Land", "WB", "Land", "False", "False"]
-"Verdant Catacombs" = ["NormalLayout", "Land", "BG", "Land", "False", "False"]
-"Misty Rainforest" = ["NormalLayout", "Land", "GU", "Land", "False", "False"]
-"Scalding Tarn" = ["NormalLayout", "Land", "UR", "Land", "False", "False"]
-"Arid Mesa" = ["NormalLayout", "Land", "RW", "Land", "False", "False"]
-
-# * TEST CASE
-["Tri Color, Land"]
-"Arcane Sanctum" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Crumbling Necropolis" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Savage Lands" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Jungle Shrine" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Seaside Citadel" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Sandsteppe Citadel" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Mystic Monastery" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Opulent Palace" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Nomad Outpost" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Frontier Bivouac" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-
-# * TEST CASE
-["Gold Color, Land"]
-"Prismatic Vista" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Fabled Passage" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Aether Hub" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"City of Brass" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Mana Confluence" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Ally Encampment" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Command Tower" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-"Thran Portal" = ["NormalLayout", "Land", "Gold", "Gold", "False", "False"]
-
-# * TEST CASE
-["Land, Edge Cases"]
-"Krosan Verge" = ["NormalLayout", "Land", "GW", "Land", "False", "False"]
-"Murmuring Bosk" = ["NormalLayout", "Land", "Gold", "G", "False", "False"]
-"Dryad Arbor" = ["NormalLayout", "Land", "G", "G", "False", "False"]
diff --git a/src/data/tests/template_renders.toml b/src/data/tests/template_renders.toml
index 6df9e23c..22d032d2 100644
--- a/src/data/tests/template_renders.toml
+++ b/src/data/tests/template_renders.toml
@@ -11,6 +11,10 @@ Mountain = "Basic Land"
"Ghost Ark" = "Vehicle"
"Graven Lore" = "Snow"
Terminus = "Miracle"
+"Elemental [TCMM] {37}" = "Dual Color No Text Token"
+"Tuktuk the Returned [TCM2] {15}" = "Legendary Token"
+"Treasure [TSNC]" = "One Line Token"
+"Rock [TCMR]" = "Multiline Token"
[transform_front]
"Docent of Perfection [EMN]" = "Moon Eldrazi DFC"
@@ -26,6 +30,8 @@ Terminus = "Miracle"
"Havengul Mystery [SLX]" = "Land DFC"
"Dragon-Kami's Egg [NEO]" = "Fan DFC"
"Optimus Prime, Autobot Leader [BOT]" = "Convert DFC"
+"Azcanta, the Sunken Ruin" = "Ixalan Legendary Land"
+"Ragnarok, Divine Deliverance [FIN]" = "Dual Color Meld DFC"
[mdfc_front]
"Plargg, Dean of Chaos" = "Legendary Creature"
@@ -35,9 +41,6 @@ Terminus = "Miracle"
"Journey to the Oracle" = "Noncreature"
"Harnfel, Horn of Bounty" = "Artifact"
-[ixalan]
-"Azcanta, the Sunken Ruin" = "Legendary Land"
-
[mutate]
Gemrazer = "Creature"
@@ -50,6 +53,7 @@ Gemrazer = "Creature"
[leveler]
"Guul Draz Assassin" = "Creature - 2"
+"Lighthouse Chronologist" = "Blank Level"
[saga]
"Time of Ice" = "Mono Color"
@@ -57,16 +61,26 @@ Gemrazer = "Creature"
"The Elder Dragon War" = "Read Ahead"
"The Great Synthesis [MOM]" = "Transform Back"
"The Restoration of Eiganjo" = "Transform Front"
+"Bahamut, Warden of Light" = "Legendary Transform Back Creature"
+"Summon: Fat Chocobo" = "Creature with Flavor text"
[class]
"Wizard Class" = "Mono Color"
"Rogue Class" = "Dual Color"
+[case]
+"Case of the Filched Falcon" = "Mono"
+"Case of the Shattered Pact" = "Colorless"
+
[planeswalker]
"Liliana of the Veil" = "Mono"
"Kasmina, Enigma Sage" = "Dual"
"Karn Liberated" = "Colorless"
+[prepare]
+"Emeritus of Truce [SOS] {13}" = "Mono Color with Instant"
+"Lluwen, Exchange Student [SOS] {199}" = "Legendary Dual Color with Sorcery"
+
[prototype]
"Rust Goliath" = "Flavor text"
"Phyrexian Fleshgorger" = "Normal"
@@ -86,12 +100,6 @@ Gemrazer = "Creature"
[planar]
"Pools of Becoming" = "Planar"
-[token]
-"Zombie [TMBS]" = "No Text"
-"Treasure [TSNC]" = "One Line"
-"Rock [TCMR]" = "Multiline"
-"Human Cleric [TELD]" = "Multicolor"
-
[split]
"Fire [UMA]" = "Two Colors"
"Integrity [GRN]" = "Gold"
@@ -101,3 +109,8 @@ Gemrazer = "Creature"
"Invasion of Kaldheim [MOM]" = "Mono Color"
"Invasion of Tolvada [MOM]" = "Dual Color"
"Invasion of Alara [MOM]" = "Gold"
+
+[station]
+"Adagia, Windswept Bastion" = "Mono Land"
+"Extinguisher Battleship" = "Colorless"
+"Inspirit, Flagship Vessel" = "Legendary Multi Color"
\ No newline at end of file
diff --git a/src/enums/adobe.py b/src/enums/adobe.py
index 361a2036..3831e95f 100644
--- a/src/enums/adobe.py
+++ b/src/enums/adobe.py
@@ -1,14 +1,10 @@
"""
* Enums for Photoshop Actions
"""
-# Standard Library Imports
-from enum import Enum
-from typing import Literal, Union
-# Third Party Imports
-from omnitils.enums import StrConstant
+from enum import Enum, StrEnum
+from typing import Literal
-# Local Imports
from src import APP
"""
@@ -17,68 +13,66 @@
class DescriptorEnum(Enum):
- def __call__(self, *args, **kwargs) -> int:
+ def __call__(self) -> int:
return self.value
@property
def value(self) -> int:
- return int(APP.stringIDToTypeID(self._value_))
+ return int(APP.instance.sID(self._value_))
-class Dimensions(StrConstant):
+class Dimensions(StrEnum):
"""Layer dimension descriptors."""
- Width = 'width'
- Height = 'height'
- CenterX = 'center_x'
- CenterY = 'center_y'
- Left = 'left'
- Right = 'right'
- Top = 'top'
- Bottom = 'bottom'
+
+ Width = "width"
+ Height = "height"
+ CenterX = "center_x"
+ CenterY = "center_y"
+ Left = "left"
+ Right = "right"
+ Top = "top"
+ Bottom = "bottom"
class Alignment(DescriptorEnum):
"""Selection alignment descriptors."""
- Top: str = 'ADSTops'
- Bottom: str = 'ADSBottoms'
- Left: str = 'ADSLefts'
- Right: str = 'ADSRights'
- CenterHorizontal: str = 'ADSCentersH'
- CenterVertical: str = 'ADSCentersV'
+
+ Top = "ADSTops"
+ Bottom = "ADSBottoms"
+ Left = "ADSLefts"
+ Right = "ADSRights"
+ CenterHorizontal = "ADSCentersH"
+ CenterVertical = "ADSCentersV"
class TextAlignment(DescriptorEnum):
"""Selection alignment descriptors."""
- Left: str = 'left'
- Right: str = 'right'
- Center: str = 'center'
+
+ Left = "left"
+ Right = "right"
+ Center = "center"
class Stroke(DescriptorEnum):
"""Stroke effect descriptors."""
- Inside: str = 'insetFrame'
- Outside: str = 'outsetFrame'
- Center: str = 'centeredFrame'
+
+ Inside = "insetFrame"
+ Outside = "outsetFrame"
+ Center = "centeredFrame"
@staticmethod
def position(
pos: Literal[
- 'in', 'insetFrame',
- 'out', 'outsetFrame',
- 'center', 'centeredFrame'
- ]
- ) -> Union[
- 'Stroke.Inside.value',
- 'Stroke.Outside.value',
- 'Stroke.Center.value'
- ]:
+ "in", "insetFrame", "out", "outsetFrame", "center", "centeredFrame"
+ ],
+ ) -> int:
"""
Get the proper stroke action enum from canonical user input.
@param pos: "in", "out", or "center"
@return: Proper action descriptor ID.
"""
- if pos in ['in', 'insetFrame']:
+ if pos in ["in", "insetFrame"]:
return Stroke.Inside.value
- elif pos in ['center', 'centeredFrame']:
+ elif pos in ["center", "centeredFrame"]:
return Stroke.Center.value
return Stroke.Outside.value
diff --git a/src/enums/layers.py b/src/enums/layers.py
index 816e9922..048566dd 100644
--- a/src/enums/layers.py
+++ b/src/enums/layers.py
@@ -1,251 +1,269 @@
"""
* Enums: Layer Names
"""
-# Third Party Imports
-from omnitils.enums import StrConstant
+
+from enum import StrEnum
"""
* Enums
"""
-class LAYERS (StrConstant):
+class LAYERS(StrEnum):
"""Layer name definitions."""
# Default art layer
- DEFAULT = 'Layer 1'
+ DEFAULT = "Layer 1"
# Colors
- WHITE = 'W'
- BLUE = 'U'
- BLACK = 'B'
- RED = 'R'
- GREEN = 'G'
- WU = 'WU'
- UB = 'UB'
- BR = 'BR'
- RG = 'RG'
- GW = 'GW'
- WB = 'WB'
- BG = 'BG'
- GU = 'GU'
- UR = 'UR'
- RW = 'RW'
- GWU = 'GWU'
- WUB = 'WUB'
- UBR = 'UBR'
- BRG = 'BRG'
- RGW = 'RGW'
- WBG = 'WBG'
- URW = 'URW'
- BGU = 'BGU'
- RWB = 'RWB'
- GUR = 'GUR'
- WUBR = 'WUBR'
- UBRG = 'UBRG'
- BRGW = 'BRGW'
- RGWU = 'RGWU'
- GWUB = 'GWUB'
- WUBRG = 'WUBRG'
- ARTIFACT = 'Artifact'
- COLORLESS = 'Colorless'
- NONLAND = 'Nonland'
- LAND = 'Land'
- GOLD = 'Gold'
- VEHICLE = 'Vehicle'
- HYBRID = 'Hybrid'
+ WHITE = "W"
+ BLUE = "U"
+ BLACK = "B"
+ RED = "R"
+ GREEN = "G"
+ WU = "WU"
+ UB = "UB"
+ BR = "BR"
+ RG = "RG"
+ GW = "GW"
+ WB = "WB"
+ BG = "BG"
+ GU = "GU"
+ UR = "UR"
+ RW = "RW"
+ GWU = "GWU"
+ WUB = "WUB"
+ UBR = "UBR"
+ BRG = "BRG"
+ RGW = "RGW"
+ WBG = "WBG"
+ URW = "URW"
+ BGU = "BGU"
+ RWB = "RWB"
+ GUR = "GUR"
+ WUBR = "WUBR"
+ UBRG = "UBRG"
+ BRGW = "BRGW"
+ RGWU = "RGWU"
+ GWUB = "GWUB"
+ WUBRG = "WUBRG"
+ ARTIFACT = "Artifact"
+ COLORLESS = "Colorless"
+ NONLAND = "Nonland"
+ LAND = "Land"
+ GOLD = "Gold"
+ VEHICLE = "Vehicle"
+ HYBRID = "Hybrid"
# Frame layer group names
- PT_BOX = 'PT Box'
- PT_AND_LEVEL_BOXES = 'PT and Level Boxes'
- TWINS = 'Name & Title Boxes'
- LEGENDARY_CROWN = 'Legendary Crown'
- PINLINES_TEXTBOX = 'Pinlines & Textbox'
- PINLINES = 'Pinlines'
- LAND_PINLINES_TEXTBOX = 'Land Pinlines & Textbox'
- COMPANION = 'Companion'
- BACKGROUND = 'Background'
- NYX = 'Nyx'
- FRAME = 'Frame'
- LEGENDARY = 'Legendary'
- NON_LEGENDARY = 'Non-Legendary'
- CREATURE = 'Creature'
- NON_CREATURE = 'Non-Creature'
- NONE = 'None'
- ONE_LINE = 'One Line'
- FULL = 'Full'
- NORMAL = 'Normal'
- SNOW = 'Snow'
+ PT_BOX = "PT Box"
+ PT_AND_LEVEL_BOXES = "PT and Level Boxes"
+ TWINS = "Name & Title Boxes"
+ LEGENDARY_CROWN = "Legendary Crown"
+ PINLINES_TEXTBOX = "Pinlines & Textbox"
+ PINLINES = "Pinlines"
+ LAND_PINLINES_TEXTBOX = "Land Pinlines & Textbox"
+ COMPANION = "Companion"
+ BACKGROUND = "Background"
+ NYX = "Nyx"
+ FRAME = "Frame"
+ LEGENDARY = "Legendary"
+ NON_LEGENDARY = "Non-Legendary"
+ CREATURE = "Creature"
+ NON_CREATURE = "Non-Creature"
+ NONE = "None"
+ ONE_LINE = "One Line"
+ FULL = "Full"
+ NORMAL = "Normal"
+ SNOW = "Snow"
+ LEVEL = "Level"
# Borders
- BORDER = 'Border'
- OUTLINE = 'Outline'
- NORMAL_BORDER = 'Normal Border'
- LEGENDARY_BORDER = 'Legendary Border'
+ BORDER = "Border"
+ OUTLINE = "Outline"
+ NORMAL_BORDER = "Normal Border"
+ LEGENDARY_BORDER = "Legendary Border"
# Shadows
- SHADOWS = 'Shadows'
- HOLLOW_CROWN_SHADOW = 'Hollow Crown Shadow'
+ SHADOWS = "Shadows"
+ HOLLOW_CROWN_SHADOW = "Hollow Crown Shadow"
# Vectors
- SHAPE = 'Shape'
- TEXTLESS = 'Textless'
+ SHAPE = "Shape"
+ TEXTLESS = "Textless"
# Effects
- EFFECTS = 'Effects'
- LIGHTEN = 'Lighten'
- DARKEN = 'Darken'
- CONTRAST = 'Contrast'
+ EFFECTS = "Effects"
+ LIGHTEN = "Lighten"
+ DARKEN = "Darken"
+ CONTRAST = "Contrast"
# Masks
- MASKS = 'Masks'
- HALF = '1/2'
- THIRD = '1/3'
- TWO_THIRDS = '2/3'
- QUARTER = '1/4'
- THREE_QUARTERS = '3/4'
- FIFTH = '1/5'
- TWO_FIFTHS = '2/5'
- THREE_FIFTHS = '3/5'
- FOUR_FIFTHS = '4/5'
+ MASKS = "Masks"
+ HALF = "1/2"
+ THIRD = "1/3"
+ TWO_THIRDS = "2/3"
+ QUARTER = "1/4"
+ THREE_QUARTERS = "3/4"
+ FIFTH = "1/5"
+ TWO_FIFTHS = "2/5"
+ THREE_FIFTHS = "3/5"
+ FOUR_FIFTHS = "4/5"
# Legal / Collector Info
- LEGAL = 'Legal'
- ARTIST = 'Artist'
- SET = 'Set'
- COLLECTOR = 'Collector'
- NFS = 'NFS'
- NFS_CREATURE = 'NFS - Creature'
+ LEGAL = "Legal"
+ ARTIST = "Artist"
+ SET = "Set"
+ COLLECTOR = "Collector"
+ NFS = "NFS"
+ NFS_CREATURE = "NFS - Creature"
# Text and Icons
- TEXT_AND_ICONS = 'Text and Icons'
- NAME = 'Card Name'
- NAME_SHIFT = 'Card Name Shift'
- NICKNAME = 'Nickname'
- TYPE_LINE = 'Typeline'
- TYPE_LINE_SHIFT = 'Typeline Shift'
- MANA_COST = 'Mana Cost'
- EXPANSION_SYMBOL = 'Expansion Symbol'
- COLOR_INDICATOR = 'Color Indicator'
- POWER_TOUGHNESS = 'Power / Toughness'
- FLIPSIDE_POWER_TOUGHNESS = 'Flipside Power / Toughness'
- RULES_TEXT = 'Rules Text'
- RULES_TEXT_FLIP = 'Rules Text - Flip'
- RULES_TEXT_NONCREATURE = 'Rules Text - Noncreature'
- RULES_TEXT_NONCREATURE_FLIP = 'Rules Text - Noncreature Flip'
- RULES_TEXT_CREATURE = 'Rules Text - Creature'
- RULES_TEXT_CREATURE_FLIP = 'Rules Text - Creature Flip'
- MUTATE = 'Mutate'
- DIVIDER = 'Divider'
- CREATOR = 'Creator'
+ TEXT_AND_ICONS = "Text and Icons"
+ NAME = "Card Name"
+ NAME_SHIFT = "Card Name Shift"
+ NICKNAME = "Nickname"
+ TYPE_LINE = "Typeline"
+ TYPE_LINE_SHIFT = "Typeline Shift"
+ MANA_COST = "Mana Cost"
+ EXPANSION_SYMBOL = "Expansion Symbol"
+ COLOR_INDICATOR = "Color Indicator"
+ POWER_TOUGHNESS = "Power / Toughness"
+ FLIPSIDE_POWER_TOUGHNESS = "Flipside Power / Toughness"
+ RULES_TEXT = "Rules Text"
+ RULES_TEXT_FLIP = "Rules Text - Flip"
+ RULES_TEXT_NONCREATURE = "Rules Text - Noncreature"
+ RULES_TEXT_NONCREATURE_FLIP = "Rules Text - Noncreature Flip"
+ RULES_TEXT_CREATURE = "Rules Text - Creature"
+ RULES_TEXT_CREATURE_FLIP = "Rules Text - Creature Flip"
+ REMINDER_TEXT = "Reminder Text"
+ MUTATE = "Mutate"
+ DIVIDER = "Divider"
+ CREATOR = "Creator"
# Prototype
- PROTO_TEXTBOX = 'Prototype Textbox'
- PROTO_MANABOX = 'Prototype Manabox'
- PROTO_PTBOX = 'Prototype PT Box'
- PROTO_MANA_COST = 'Prototype Mana Cost'
- PROTO_RULES = 'Prototype Rules Text'
- PROTO_PT = 'Prototype Power / Toughness'
+ PROTO_TEXTBOX = "Prototype Textbox"
+ PROTO_MANABOX = "Prototype Manabox"
+ PROTO_PTBOX = "Prototype PT Box"
+ PROTO_MANA_COST = "Prototype Mana Cost"
+ PROTO_RULES = "Prototype Rules Text"
+ PROTO_PT = "Prototype Power / Toughness"
# Planar text and icons
- STATIC_ABILITY = 'Static Ability'
- CHAOS_ABILITY = 'Chaos Ability'
- CHAOS_SYMBOL = 'Chaos Symbol'
- PHENOMENON = 'Phenomenon'
- TEXTBOX = 'Textbox'
+ STATIC_ABILITY = "Static Ability"
+ CHAOS_ABILITY = "Chaos Ability"
+ CHAOS_SYMBOL = "Chaos Symbol"
+ PHENOMENON = "Phenomenon"
+ TEXTBOX = "Textbox"
# Text References
- TEXTBOX_REFERENCE = 'Textbox Reference'
- TEXTBOX_REFERENCE_LAND = 'Textbox Reference Land'
- MUTATE_REFERENCE = 'Mutate Reference'
- PT_REFERENCE = 'PT Reference'
- EXPANSION_REFERENCE = 'Expansion Reference'
- NAME_REFERENCE_NON_LEGENDARY = 'Name Reference Non-Legendary'
- NAME_REFERENCE_LEGENDARY = 'Name Reference Legendary'
- NAME_REFERENCE = 'Name Reference'
- TYPE_LINE_REFERENCE = 'Typeline Reference'
- COLLECTOR_REFERENCE = 'Collector Reference'
+ TEXTBOX_REFERENCE = "Textbox Reference"
+ TEXTBOX_REFERENCE_LAND = "Textbox Reference Land"
+ MUTATE_REFERENCE = "Mutate Reference"
+ PT_REFERENCE = "PT Reference"
+ EXPANSION_REFERENCE = "Expansion Reference"
+ NAME_REFERENCE_NON_LEGENDARY = "Name Reference Non-Legendary"
+ NAME_REFERENCE_LEGENDARY = "Name Reference Legendary"
+ NAME_REFERENCE = "Name Reference"
+ TYPE_LINE_REFERENCE = "Typeline Reference"
+ COLLECTOR_REFERENCE = "Collector Reference"
# Saga
- SAGA = 'Saga'
- PINLINES_AND_SAGA_STRIPE = 'Pinlines & Saga Stripe'
- BANNER = 'Banner'
- STRIPE = 'Stripe'
+ SAGA = "Saga"
+ PINLINES_AND_SAGA_STRIPE = "Pinlines & Saga Stripe"
+ BANNER = "Banner"
+ STRIPE = "Stripe"
+
+ # Case
+ CASE = "Case"
# Class
- CLASS = 'Class'
- STAGE = 'Stage'
+ CLASS = "Class"
+ STAGE = "Stage"
# Token
- TOKEN = 'Token'
+ TOKEN = "Token"
# Planeswalker
- PLANESWALKER = 'Planeswalker'
- FIRST_ABILITY = 'First Ability'
- SECOND_ABILITY = 'Second Ability'
- THIRD_ABILITY = 'Third Ability'
- FOURTH_ABILITY = 'Fourth Ability'
- STARTING_LOYALTY = 'Starting Loyalty'
- LOYALTY_GRAPHICS = 'Loyalty Graphics'
- LOYALTY_REFERENCE = 'Loyalty Reference'
- STATIC_TEXT = 'Static Text'
- ABILITY_TEXT = 'Ability Text'
- COLON = 'Colon'
- TEXT = 'Text'
- COST = 'Cost'
+ PLANESWALKER = "Planeswalker"
+ FIRST_ABILITY = "First Ability"
+ SECOND_ABILITY = "Second Ability"
+ THIRD_ABILITY = "Third Ability"
+ FOURTH_ABILITY = "Fourth Ability"
+ STARTING_LOYALTY = "Starting Loyalty"
+ LOYALTY_GRAPHICS = "Loyalty Graphics"
+ LOYALTY_REFERENCE = "Loyalty Reference"
+ STATIC_TEXT = "Static Text"
+ ABILITY_TEXT = "Ability Text"
+ COLON = "Colon"
+ TEXT = "Text"
+ COST = "Cost"
# Art Frames
- ART_FRAME = 'Art Frame'
- FULL_ART_FRAME = 'Full Art Frame'
- BORDERLESS_FRAME = 'Borderless Frame'
- BASIC_ART_FRAME = 'Basic Art Frame'
- PLANESWALKER_ART_FRAME = 'Planeswalker Art Frame'
- SCRYFALL_SCAN_FRAME = 'Scryfall Scan Frame'
+ ART_FRAME = "Art Frame"
+ FULL_ART_FRAME = "Full Art Frame"
+ BORDERLESS_FRAME = "Borderless Frame"
+ BASIC_ART_FRAME = "Basic Art Frame"
+ PLANESWALKER_ART_FRAME = "Planeswalker Art Frame"
+ SCRYFALL_SCAN_FRAME = "Scryfall Scan Frame"
# Double Faced
- MDFC = 'MDFC'
- TRANSFORM = 'Transform'
- TRANSFORM_FRONT = 'TF Front'
- TRANSFORM_BACK = 'TF Back'
- MDFC_FRONT = 'mdfc-front'
- MDFC_BACK = 'mdfc-back'
- MODAL_FRONT = 'MDFC Front'
- MODAL_BACK = 'MDFC Back'
+ MDFC = "MDFC"
+ TRANSFORM = "Transform"
+ TRANSFORM_FRONT = "TF Front"
+ TRANSFORM_BACK = "TF Back"
+ MDFC_FRONT = "mdfc-front"
+ MDFC_BACK = "mdfc-back"
+ MODAL_FRONT = "MDFC Front"
+ MODAL_BACK = "MDFC Back"
# Orientation
- TOP = 'Top'
- BOTTOM = 'Bottom'
- LEFT = 'Left'
- RIGHT = 'Right'
- BACK = 'Back'
- FRONT = 'Front'
+ TOP = "Top"
+ BOTTOM = "Bottom"
+ LEFT = "Left"
+ RIGHT = "Right"
+ BACK = "Back"
+ FRONT = "Front"
# Sizes
- TALL = 'Tall'
- MEDIUM = 'Medium'
- SHORT = 'Short'
- MINI = 'Mini'
+ TALL = "Tall"
+ MEDIUM = "Medium"
+ SHORT = "Short"
+ MINI = "Mini"
# Frame types
- BORDERLESS = 'Borderless'
- EXTENDED = 'Extended'
- CLASSIC = 'Classic'
- FULLART = 'Fullart'
+ BORDERLESS = "Borderless"
+ EXTENDED = "Extended"
+ CLASSIC = "Classic"
+ FULLART = "Fullart"
# Adventure
- ADVENTURE = 'Adventure'
- STORYBOOK = 'Storybook'
- ADVENTURE_NAME = 'Adventure Name'
- ADVENTURE_TYPELINE = 'Adventure Typeline'
- ADVENTURE_TYPELINE_ACCENT = 'Adventure Typeline Accent'
- NAME_ADVENTURE = 'Card Name - Adventure'
- TYPE_LINE_ADVENTURE = 'Typeline - Adventure'
- MANA_COST_ADVENTURE = 'Mana Cost - Adventure'
- RULES_TEXT_ADVENTURE = 'Rules Text - Adventure'
- TEXTBOX_REFERENCE_ADVENTURE = 'Textbox Reference - Adventure'
- DIVIDER_ADVENTURE = 'Divider - Adventure'
- WINGS = 'Wings'
+ ADVENTURE = "Adventure"
+ STORYBOOK = "Storybook"
+ ADVENTURE_NAME = "Adventure Name"
+ ADVENTURE_TYPELINE = "Adventure Typeline"
+ ADVENTURE_TYPELINE_ACCENT = "Adventure Typeline Accent"
+ NAME_ADVENTURE = "Card Name - Adventure"
+ TYPE_LINE_ADVENTURE = "Typeline - Adventure"
+ MANA_COST_ADVENTURE = "Mana Cost - Adventure"
+ RULES_TEXT_ADVENTURE = "Rules Text - Adventure"
+ TEXTBOX_REFERENCE_ADVENTURE = "Textbox Reference - Adventure"
+ DIVIDER_ADVENTURE = "Divider - Adventure"
+ WINGS = "Wings"
+
+ # Prepare
+ PREPARE = "Prepare"
+ NAME_PREPARE = "Card Name - Prepare"
+ TYPE_LINE_PREPARE = "Typeline - Prepare"
+ MANA_COST_PREPARE = "Mana Cost - Prepare"
+ RULES_TEXT_PREPARE = "Rules Text - Prepare"
+ TEXTBOX_REFERENCE_PREPARE = "Textbox Reference - Prepare"
+ DIVIDER_PREPARE = "Divider - Prepare"
# Battles
- DEFENSE = 'Defense'
- DEFENSE_REFERENCE = 'Defense Reference'
+ DEFENSE = "Defense"
+ DEFENSE_REFERENCE = "Defense Reference"
+
+ # Station
+ STATION = "Station"
+ REQUIREMENT = "Requirement"
diff --git a/src/enums/mtg.py b/src/enums/mtg.py
index a65c8955..fe471a5a 100644
--- a/src/enums/mtg.py
+++ b/src/enums/mtg.py
@@ -1,157 +1,216 @@
"""
* Enums: MTG Related Data
"""
-# Standard Library Imports
+
import re
from dataclasses import dataclass
-
-# Third Party Imports
-from omnitils.enums import StrConstant
+from enum import StrEnum
"""
* Card Layouts
"""
-class LayoutCategory(StrConstant):
+class LayoutCategory(StrEnum):
"""Card layout category, broad naming used for displaying on GUI elements."""
- Adventure = 'Adventure'
- Battle = 'Battle'
- Class = 'Class'
- Leveler = 'Leveler'
- MDFC = 'MDFC'
- Mutate = 'Mutate'
- Normal = 'Normal'
- Planar = 'Planar'
- Planeswalker = 'Planeswalker'
- PlaneswalkerMDFC = 'PW MDFC'
- PlaneswalkerTransform = 'PW Transform'
- Prototype = 'Prototype'
- Saga = 'Saga'
- Split = 'Split'
- Token = 'Token'
- Transform = 'Transform'
-
-
-class LayoutType(StrConstant):
+
+ Adventure = "Adventure"
+ Battle = "Battle"
+ Case = "Case"
+ Class = "Class"
+ Leveler = "Leveler"
+ MDFC = "MDFC"
+ Mutate = "Mutate"
+ Normal = "Normal"
+ Planar = "Planar"
+ Planeswalker = "Planeswalker"
+ PlaneswalkerMDFC = "PW MDFC"
+ PlaneswalkerTransform = "PW Transform"
+ Prepare = "Prepare"
+ Prototype = "Prototype"
+ Saga = "Saga"
+ Split = "Split"
+ Station = "Station"
+ Token = "Token"
+ Transform = "Transform"
+
+ @classmethod
+ def matches_layout_type(
+ cls, category: LayoutCategory, layout_type: LayoutType
+ ) -> bool:
+ return layout_type in layout_map_category[category]
+
+
+class LayoutType(StrEnum):
"""Card layout type, fine-grained naming separated by front/back where applicable."""
- Adventure = 'adventure'
- Battle = 'battle'
- Class = 'class'
- Leveler = 'leveler'
- MDFCBack = 'mdfc_back'
- MDFCFront = 'mdfc_front'
- Mutate = 'mutate'
- Normal = 'normal'
- Planar = 'planar'
- Planeswalker = 'planeswalker'
- PlaneswalkerMDFCBack = 'pw_mdfc_back'
- PlaneswalkerMDFCFront = 'pw_mdfc_front'
- PlaneswalkerTransformBack = 'pw_tf_back'
- PlaneswalkerTransformFront = 'pw_tf_front'
- Prototype = 'prototype'
- Saga = 'saga'
- Split = 'split'
- TransformBack = 'transform_back'
- TransformFront = 'transform_front'
-
-
-class LayoutScryfall(StrConstant):
+
+ Adventure = "adventure"
+ Battle = "battle"
+ Case = "case"
+ Class = "class"
+ Leveler = "leveler"
+ MDFCBack = "mdfc_back"
+ MDFCFront = "mdfc_front"
+ Mutate = "mutate"
+ Normal = "normal"
+ Planar = "planar"
+ Planeswalker = "planeswalker"
+ PlaneswalkerMDFCBack = "pw_mdfc_back"
+ PlaneswalkerMDFCFront = "pw_mdfc_front"
+ PlaneswalkerTransformBack = "pw_tf_back"
+ PlaneswalkerTransformFront = "pw_tf_front"
+ Prepare = "prepare"
+ Prototype = "prototype"
+ Saga = "saga"
+ Split = "split"
+ Station = "station"
+ TransformBack = "transform_back"
+ TransformFront = "transform_front"
+
+
+class LayoutScryfall(StrEnum):
"""Card layout type, according to Scryfall data."""
- Normal = 'normal'
- Split = 'split'
- Flip = 'flip'
- Transform = 'transform'
- MDFC = 'modal_dfc'
- Meld = 'meld'
- Leveler = 'leveler'
- Class = 'class'
- Saga = 'saga'
- Adventure = 'adventure'
- Mutate = 'mutate'
- Prototype = 'prototype'
- Battle = 'battle'
- Planar = 'planar'
- Scheme = 'scheme'
- Vanguard = 'vanguard'
- Token = 'token'
- DoubleFacedToken = 'double_faced_token'
- Emblem = 'emblem'
- Augment = 'augment'
- Host = 'host'
- ArtSeries = 'art_series'
- ReversibleCard = 'reversible_card'
+
+ Adventure = "adventure"
+ ArtSeries = "art_series"
+ Augment = "augment"
+ Battle = "battle"
+ Case = "case"
+ Class = "class"
+ DoubleFacedToken = "double_faced_token"
+ Emblem = "emblem"
+ Flip = "flip"
+ Host = "host"
+ Leveler = "leveler"
+ MDFC = "modal_dfc"
+ Meld = "meld"
+ Mutate = "mutate"
+ Normal = "normal"
+ Planar = "planar"
+ Prepare = "prepare"
+ Prototype = "prototype"
+ ReversibleCard = "reversible_card"
+ Saga = "saga"
+ Scheme = "scheme"
+ Split = "split"
+ Token = "token"
+ Transform = "transform"
+ Vanguard = "vanguard"
# Definitions added to Scryfall built-ins
- Planeswalker = 'planeswalker'
- PlaneswalkerMDFC = 'planeswalker_mdfc'
- PlaneswalkerTransform = 'planeswalker_tf'
-
-
-"""Maps Layout categories to a list of equivalent Layout types."""
-layout_map_category: dict[LayoutCategory, list[LayoutType]] = {
- LayoutCategory.Normal: [LayoutType.Normal],
- LayoutCategory.MDFC: [LayoutType.MDFCFront, LayoutType.MDFCBack],
- LayoutCategory.Transform: [LayoutType.TransformFront, LayoutType.TransformBack],
- LayoutCategory.Planeswalker: [LayoutType.Planeswalker],
- LayoutCategory.PlaneswalkerMDFC: [LayoutType.PlaneswalkerMDFCFront, LayoutType.PlaneswalkerMDFCBack],
- LayoutCategory.PlaneswalkerTransform: [LayoutType.PlaneswalkerTransformFront, LayoutType.PlaneswalkerTransformBack],
- LayoutCategory.Saga: [LayoutType.Saga],
- LayoutCategory.Class: [LayoutType.Class],
- LayoutCategory.Mutate: [LayoutType.Mutate],
- LayoutCategory.Prototype: [LayoutType.Prototype],
- LayoutCategory.Adventure: [LayoutType.Adventure],
- LayoutCategory.Leveler: [LayoutType.Leveler],
- LayoutCategory.Split: [LayoutType.Split],
- LayoutCategory.Battle: [LayoutType.Battle],
- LayoutCategory.Planar: [LayoutType.Planar]
+ Planeswalker = "planeswalker"
+ PlaneswalkerMDFC = "planeswalker_mdfc"
+ PlaneswalkerTransform = "planeswalker_tf"
+ Station = "station"
+
+
+"""Maps Layout categories to tuples of equivalent Layout types."""
+layout_map_category: dict[LayoutCategory, tuple[LayoutType, ...]] = {
+ LayoutCategory.Normal: (LayoutType.Normal,),
+ LayoutCategory.MDFC: (LayoutType.MDFCFront, LayoutType.MDFCBack),
+ LayoutCategory.Transform: (LayoutType.TransformFront, LayoutType.TransformBack),
+ LayoutCategory.Planeswalker: (LayoutType.Planeswalker,),
+ LayoutCategory.PlaneswalkerMDFC: (
+ LayoutType.PlaneswalkerMDFCFront,
+ LayoutType.PlaneswalkerMDFCBack,
+ ),
+ LayoutCategory.PlaneswalkerTransform: (
+ LayoutType.PlaneswalkerTransformFront,
+ LayoutType.PlaneswalkerTransformBack,
+ ),
+ LayoutCategory.Saga: (LayoutType.Saga,),
+ LayoutCategory.Case: (LayoutType.Case,),
+ LayoutCategory.Class: (LayoutType.Class,),
+ LayoutCategory.Mutate: (LayoutType.Mutate,),
+ LayoutCategory.Prototype: (LayoutType.Prototype,),
+ LayoutCategory.Adventure: (LayoutType.Adventure,),
+ LayoutCategory.Prepare: (LayoutType.Prepare,),
+ LayoutCategory.Leveler: (LayoutType.Leveler,),
+ LayoutCategory.Split: (LayoutType.Split,),
+ LayoutCategory.Battle: (LayoutType.Battle,),
+ LayoutCategory.Planar: (LayoutType.Planar,),
+ LayoutCategory.Station: (LayoutType.Station,),
}
"""Maps Layout types to their equivalent Layout category."""
-layout_map_types = {
- raw: named for named, raw in sum([
- [(k, n) for n in names] for k, names in layout_map_category.items()
- ], [])
+layout_map_types: dict[LayoutType, LayoutCategory] = {
+ raw: named
+ for named, raw in (
+ (k, n) for k, names in layout_map_category.items() for n in names
+ )
}
"""Maps Layout types to a display formatted Layout category (with Back or Front)."""
-layout_map_types_display = {
+layout_map_types_display: dict[LayoutType, str] = {
raw: (
- f'{named} Front' if 'front' in raw else (
- f'{named} Back' if 'back' in raw else named)
- ) for raw, named in layout_map_types.items()
+ f"{named} Front"
+ if "front" in raw
+ else (f"{named} Back" if "back" in raw else named)
+ )
+ for raw, named in layout_map_types.items()
}
"""Maps multimodal display formatted layout types to a singular layout type."""
-layout_map_display_condition = {
- f'{LayoutCategory.Transform} Front': LayoutType.TransformFront,
- f'{LayoutCategory.Transform} Back': LayoutType.TransformBack,
- f'{LayoutCategory.MDFC} Front': LayoutType.MDFCFront,
- f'{LayoutCategory.MDFC} Back': LayoutType.MDFCBack,
- f'{LayoutCategory.PlaneswalkerTransform} Front': LayoutType.PlaneswalkerTransformFront,
- f'{LayoutCategory.PlaneswalkerTransform} Back': LayoutType.PlaneswalkerTransformBack,
- f'{LayoutCategory.PlaneswalkerMDFC} Front': LayoutType.PlaneswalkerMDFCFront,
- f'{LayoutCategory.PlaneswalkerMDFC} Back': LayoutType.PlaneswalkerMDFCBack
+layout_map_display_condition: dict[str, LayoutType] = {
+ f"{LayoutCategory.Transform} Front": LayoutType.TransformFront,
+ f"{LayoutCategory.Transform} Back": LayoutType.TransformBack,
+ f"{LayoutCategory.MDFC} Front": LayoutType.MDFCFront,
+ f"{LayoutCategory.MDFC} Back": LayoutType.MDFCBack,
+ f"{LayoutCategory.PlaneswalkerTransform} Front": LayoutType.PlaneswalkerTransformFront,
+ f"{LayoutCategory.PlaneswalkerTransform} Back": LayoutType.PlaneswalkerTransformBack,
+ f"{LayoutCategory.PlaneswalkerMDFC} Front": LayoutType.PlaneswalkerMDFCFront,
+ f"{LayoutCategory.PlaneswalkerMDFC} Back": LayoutType.PlaneswalkerMDFCBack,
}
"""Maps display formatted layout types to a combined group of two layout types."""
-layout_map_display_condition_dual = {
- LayoutCategory.Transform: [
- LayoutType.TransformFront,
- LayoutType.TransformBack
- ],
- LayoutCategory.MDFC: [
- LayoutType.MDFCFront,
- LayoutType.MDFCBack
- ],
- LayoutCategory.PlaneswalkerTransform: [
+layout_map_display_condition_dual: dict[
+ LayoutCategory, tuple[LayoutType, LayoutType]
+] = {
+ LayoutCategory.Transform: (LayoutType.TransformFront, LayoutType.TransformBack),
+ LayoutCategory.MDFC: (LayoutType.MDFCFront, LayoutType.MDFCBack),
+ LayoutCategory.PlaneswalkerTransform: (
LayoutType.PlaneswalkerTransformFront,
- LayoutType.PlaneswalkerTransformBack
- ],
- LayoutCategory.PlaneswalkerMDFC: [
+ LayoutType.PlaneswalkerTransformBack,
+ ),
+ LayoutCategory.PlaneswalkerMDFC: (
LayoutType.PlaneswalkerMDFCFront,
- LayoutType.PlaneswalkerMDFCBack
- ],
+ LayoutType.PlaneswalkerMDFCBack,
+ ),
+}
+
+
+scryfall_layout_category_map: dict[LayoutScryfall, LayoutCategory] = {
+ LayoutScryfall.Adventure: LayoutCategory.Adventure,
+ LayoutScryfall.ArtSeries: LayoutCategory.Normal,
+ LayoutScryfall.Augment: LayoutCategory.Normal,
+ LayoutScryfall.Battle: LayoutCategory.Battle,
+ LayoutScryfall.Case: LayoutCategory.Case,
+ LayoutScryfall.Class: LayoutCategory.Class,
+ LayoutScryfall.DoubleFacedToken: LayoutCategory.Transform,
+ LayoutScryfall.Emblem: LayoutCategory.Normal,
+ LayoutScryfall.Flip: LayoutCategory.Transform,
+ LayoutScryfall.Host: LayoutCategory.Normal,
+ LayoutScryfall.Leveler: LayoutCategory.Leveler,
+ LayoutScryfall.MDFC: LayoutCategory.MDFC,
+ # Meld might be Normal or Transform, so this case should not be retrieved from the map
+ # LayoutScryfall.Meld: LayoutCategory.Transform,
+ LayoutScryfall.Mutate: LayoutCategory.Mutate,
+ LayoutScryfall.Normal: LayoutCategory.Normal,
+ LayoutScryfall.Planar: LayoutCategory.Planar,
+ LayoutScryfall.Prepare: LayoutCategory.Prepare,
+ LayoutScryfall.Prototype: LayoutCategory.Prototype,
+ LayoutScryfall.ReversibleCard: LayoutCategory.Transform,
+ LayoutScryfall.Saga: LayoutCategory.Saga,
+ LayoutScryfall.Scheme: LayoutCategory.Normal,
+ LayoutScryfall.Split: LayoutCategory.Split,
+ LayoutScryfall.Token: LayoutCategory.Normal,
+ LayoutScryfall.Transform: LayoutCategory.Transform,
+ LayoutScryfall.Vanguard: LayoutCategory.Normal,
+ # Definitions added to Scryfall built-ins
+ LayoutScryfall.Planeswalker: LayoutCategory.Planeswalker,
+ LayoutScryfall.PlaneswalkerMDFC: LayoutCategory.PlaneswalkerMDFC,
+ LayoutScryfall.PlaneswalkerTransform: LayoutCategory.PlaneswalkerTransform,
+ LayoutScryfall.Station: LayoutCategory.Station,
}
@@ -160,33 +219,35 @@ class LayoutScryfall(StrConstant):
"""
-class CardTypes(StrConstant):
+class CardTypes(StrEnum):
"""Represents main card types."""
- Artifact = 'Artifact'
- Battle = 'Battle'
- Conspiracy = 'Conspiracy'
- Creature = 'Creature'
- Enchantment = 'Enchantment'
- Instant = 'Instant'
- Land = 'Land'
- Phenomenon = 'Phenomenon'
- Plane = 'Plane'
- Planeswalker = 'Planeswalker'
- Scheme = 'Scheme'
- Sorcery = 'Sorcery'
- Tribal = 'Tribal'
- Vanguard = 'Vanguard'
-
-
-class CardTypesSuper(StrConstant):
+
+ Artifact = "Artifact"
+ Battle = "Battle"
+ Conspiracy = "Conspiracy"
+ Creature = "Creature"
+ Enchantment = "Enchantment"
+ Instant = "Instant"
+ Land = "Land"
+ Phenomenon = "Phenomenon"
+ Plane = "Plane"
+ Planeswalker = "Planeswalker"
+ Scheme = "Scheme"
+ Sorcery = "Sorcery"
+ Tribal = "Tribal"
+ Vanguard = "Vanguard"
+
+
+class CardTypesSuper(StrEnum):
"""Represents card supertypes."""
- Basic = 'Basic'
- Elite = 'Elite'
- Host = 'Host'
- Legendary = 'Legendary'
- Ongoing = 'Ongoing'
- Snow = 'Snow'
- World = 'World'
+
+ Basic = "Basic"
+ Elite = "Elite"
+ Host = "Host"
+ Legendary = "Legendary"
+ Ongoing = "Ongoing"
+ Snow = "Snow"
+ World = "World"
"""
@@ -261,7 +322,8 @@ class CardTypesSuper(StrConstant):
"{2/G}": "QqWV",
"{S}": "omn",
"{Q}": "ol",
- "{CHAOS}": "?"
+ "{CHAOS}": "?",
+ "{P}": "`",
}
"""
@@ -269,8 +331,9 @@ class CardTypesSuper(StrConstant):
"""
-class Rarity(StrConstant):
+class Rarity(StrEnum):
"""Card rarities."""
+
C = "common"
U = "uncommon"
R = "rare"
@@ -280,8 +343,9 @@ class Rarity(StrConstant):
T = "timeshifted"
-class TransformIcons(StrConstant):
+class TransformIcons(StrEnum):
"""Transform icon names."""
+
MOONELDRAZI = "mooneldrazidfc"
COMPASSLAND = "compasslanddfc"
UPSIDEDOWN = "upsidedowndfc"
@@ -298,26 +362,26 @@ class TransformIcons(StrConstant):
# Abilities that aren't italicize, despite fitting the pattern
non_italics_abilities = [
- "Boast", # Kaldheim
- "Forecast", # Dissension
- "Gotcha", # Unhinged
- "Visit", # Unfinity
- "Whack", "Doodle", "Buzz" # Unstable
+ "Boast", # Kaldheim
+ "Forecast", # Dissension
+ "Gotcha", # Unhinged
+ "Visit", # Unfinity
+ "Whack",
+ "Doodle",
+ "Buzz", # Unstable
]
# Edge case Planeswalkers with tall ability box
-planeswalkers_tall = [
- "Gideon Blackblade",
- "Comet, Stellar Pup"
-]
+planeswalkers_tall = ["Gideon Blackblade", "Comet, Stellar Pup"]
"""
* Fonts & Characters
"""
-class CardFonts(StrConstant):
+class CardFonts(StrEnum):
"""Fonts used for card text."""
+
RULES = "PlantinMTPro-Regular"
RULES_BOLD = "PlantinMTPro-Bold"
RULES_ITALIC = "PlantinMTPro-Italic"
@@ -332,7 +396,7 @@ class CardFonts(StrConstant):
SYMBOL = "Keyrune"
-class MagicIcons(StrConstant):
+class MagicIcons(StrEnum):
# Proxyglyph font
PAINTBRUSH_MODERN = "a"
PAINTBRUSH_CLASSIC = "Γ½ΓΎ"
@@ -345,35 +409,57 @@ class MagicIcons(StrConstant):
"""
+def _create_word_end_regex(end: str) -> re.Pattern[str]:
+ return re.compile(end + r"(\s|$)")
+
+
@dataclass
class CardTextPatterns:
"""Defined card data regex patterns."""
# Rules Text - Special Card Types
- LEVELER: re.Pattern = re.compile(r"(.*?)\nLEVEL (\d*-\d*)\n(\d*/\d*)\n(.*?)\nLEVEL (\d*\+)\n(\d*/\d*)\n(.*?)$")
- PROTOTYPE: re.Pattern = re.compile(r"Prototype (.+) [β\-] ([0-9]{0,2}/[0-9]{0,2}) \((.+)\)")
- PLANESWALKER: re.Pattern = re.compile(r"(^[^:]*$|^.*:.*$)", re.MULTILINE)
- CLASS: re.Pattern = re.compile(r"(.+?): Level (\d)\n(.+)")
+ LEVELER: re.Pattern[str] = re.compile(
+ r"(.*?)\nLEVEL (\d*-\d*)\n(\d*/\d*)\n(.*?)\nLEVEL (\d*\+)\n(\d*/\d*)\n(.*?)$"
+ )
+ PROTOTYPE: re.Pattern[str] = re.compile(
+ r"Prototype (.+) [β\-] ([0-9]{0,2}/[0-9]{0,2}) \((.+)\)"
+ )
+ PLANESWALKER: re.Pattern[str] = re.compile(r"(^[^:]*$|^.*:.*$)", re.MULTILINE)
+ CLASS: re.Pattern[str] = re.compile(r"(.+?): ([^\d]+ ?)(\d)\n(.+)")
+ CLASS_NON_ENGLISH: re.Pattern[str] = re.compile(r"//Level_\d//")
# Filename - Card Art
- PATH_ARTIST: re.Pattern = re.compile(r"\(+(.*?)\)")
- PATH_SPLIT: re.Pattern = re.compile(r"[\[({$]")
- PATH_SET: re.Pattern = re.compile(r"\[(.*)]")
- PATH_NUM: re.Pattern = re.compile(r"\{(.*)}")
- PATH_CONDITION: re.Pattern = re.compile(r'<([^>]*)>')
+ PATH_ARTIST: re.Pattern[str] = re.compile(r"\(+(.*?)\)")
+ PATH_SPLIT: re.Pattern[str] = re.compile(r"[\[({$]")
+ PATH_KWARGS: re.Pattern[str] = re.compile(r"\[([^\]=]+)=([^\]]*)]")
+ PATH_SET: re.Pattern[str] = re.compile(r"\[([^\]=]+)]")
+ PATH_NUM: re.Pattern[str] = re.compile(r"\{(.*)}")
+ PATH_CONDITION: re.Pattern[str] = re.compile(r"<([^>]*)>")
# Mana - Symbols
- SYMBOL: re.Pattern = re.compile(r"(\{.*?})")
- MANA_NORMAL: re.Pattern = re.compile(r"{([WUBRG])}")
- MANA_PHYREXIAN: re.Pattern = re.compile(r"{([WUBRG])/P}")
- MANA_HYBRID: re.Pattern = re.compile(r"{([2WUBRG])/([WUBRG])}")
- MANA_PHYREXIAN_HYBRID: re.Pattern = re.compile(r"{([WUBRG])/([WUBRG])/P}")
+ SYMBOL: re.Pattern[str] = re.compile(r"(\{.*?})")
+ MANA_NORMAL: re.Pattern[str] = re.compile(r"{([WUBRG])}")
+ MANA_PHYREXIAN: re.Pattern[str] = re.compile(r"{([WUBRG])/P}")
+ MANA_HYBRID: re.Pattern[str] = re.compile(r"{([2WUBRG])/([WUBRG])}")
+ MANA_PHYREXIAN_HYBRID: re.Pattern[str] = re.compile(r"{([WUBRG])/([WUBRG])/P}")
# Text - Extra Spaces
- EXTRA_SPACE: re.Pattern = re.compile(r" +")
+ EXTRA_SPACE: re.Pattern[str] = re.compile(r" +")
# Text - Reminder
- TEXT_REMINDER: re.Pattern = re.compile(r"\([^()]*\)")
+ TEXT_REMINDER: re.Pattern[str] = re.compile(r"\([^()]*\)")
+ TEXT_REMINDER_ENDING: re.Pattern[str] = re.compile(
+ r"[\s\S]*(\([^()]*\))$",
+ )
# Text - Italicised Ability
- TEXT_ABILITY: re.Pattern = re.compile(r"(?:^|\r)+(?:β’ )*([^\r]+) β ", re.MULTILINE)
+ TEXT_ABILITY: re.Pattern[str] = re.compile(
+ r"(?:^|\r)+(?:β’ )*([^\r]+) β ", re.MULTILINE
+ )
+
+ # Text - Specific letters at word end
+ TEXT_WORD_END_F: re.Pattern[str] = _create_word_end_regex("f")
+ TEXT_WORD_END_H: re.Pattern[str] = _create_word_end_regex("h")
+ TEXT_WORD_END_M: re.Pattern[str] = _create_word_end_regex("m")
+ TEXT_WORD_END_N: re.Pattern[str] = _create_word_end_regex("n")
+ TEXT_WORD_END_K: re.Pattern[str] = _create_word_end_regex("k")
diff --git a/src/enums/settings.py b/src/enums/settings.py
index 536974dd..0edfd87a 100644
--- a/src/enums/settings.py
+++ b/src/enums/settings.py
@@ -1,29 +1,21 @@
"""
* Enums: Settings
"""
-# Standard Library Imports
-from functools import cached_property
-
-# Third Party Imports
-from omnitils.enums import StrConstant
+from enum import StrEnum
"""
* App Settings
"""
-class OutputFileType (StrConstant):
+class OutputFileType(StrEnum):
JPG = "jpg"
PNG = "png"
PSD = "psd"
- @cached_property
- def Default(self) -> str:
- return self.JPG
-
-class ScryfallSorting (StrConstant):
+class ScryfallSorting(StrEnum):
Released = "released"
Set = "set"
Rarity = "rarity"
@@ -32,66 +24,56 @@ class ScryfallSorting (StrConstant):
EDHRec = "edhrec"
Artist = "artist"
- @cached_property
- def Default(self) -> str:
- return self.Released
-
-class ScryfallUnique (StrConstant):
+class ScryfallUnique(StrEnum):
Prints = "prints"
Arts = "arts"
- @cached_property
- def Default(self) -> str:
- return self.Arts
-
"""
* Base Settings
"""
-class CollectorMode (StrConstant):
+class CollectorMode(StrEnum):
Normal = "default"
Modern = "modern"
Minimal = "minimal"
ArtistOnly = "artist"
+ Custom = "custom"
- @cached_property
- def Default(self) -> str:
- return self.Normal
-
-class BorderColor (StrConstant):
+class BorderColor(StrEnum):
Black = "black"
White = "white"
Silver = "silver"
Gold = "gold"
- @cached_property
- def Default(self) -> str:
- return self.Black
-
-class CollectorPromo (StrConstant):
+class CollectorPromo(StrEnum):
Automatic = "automatic"
Always = "always"
Never = "never"
- @cached_property
- def Default(self) -> str:
- return self.Automatic
-
-class WatermarkMode (StrConstant):
+class WatermarkMode(StrEnum):
Disabled = "Disabled"
Automatic = "Automatic"
Fallback = "Fallback"
Forced = "Forced"
- @cached_property
- def Default(self) -> str:
- return self.Disabled
+
+class FillMode(StrEnum):
+ NO_FILL = "No Fill"
+ CONTENT_AWARE_FILL = "Content-Aware Fill"
+ GENERATIVE_FILL = "Generative Fill"
+ REMOVE_CONTENT_FILL = "Remove Content Fill"
+
+
+class NicknameShorten(StrEnum):
+ NO = "No"
+ ALL_BUT_FIRST = "All but first"
+ ALL = "All"
"""
@@ -99,7 +81,7 @@ def Default(self) -> str:
"""
-class BorderlessColorMode (StrConstant):
+class BorderlessColorMode(StrEnum):
All = "All"
Twins_And_PT = "Twins and PT"
Textbox = "Textbox"
@@ -107,12 +89,8 @@ class BorderlessColorMode (StrConstant):
PT = "PT Box"
Disabled = "None"
- @cached_property
- def Default(self) -> str:
- return self.Twins_And_PT
-
-class BorderlessTextbox (StrConstant):
+class BorderlessTextbox(StrEnum):
Automatic = "Automatic"
Textless = "Textless"
Normal = "Normal"
@@ -120,21 +98,13 @@ class BorderlessTextbox (StrConstant):
Short = "Short"
Tall = "Tall"
- @cached_property
- def Default(self) -> str:
- return self.Automatic
-
"""
* Template: Modern Classic
"""
-class ModernClassicCrown (StrConstant):
+class ModernClassicCrown(StrEnum):
Pinlines = "Pinlines"
TexturePinlines = "Texture Pinlines"
TextureBackground = "Texture Background"
-
- @cached_property
- def Default(self) -> str:
- return self.Pinlines
diff --git a/src/frame_logic.py b/src/frame_logic.py
index 4eee1566..59937cd5 100644
--- a/src/frame_logic.py
+++ b/src/frame_logic.py
@@ -1,14 +1,14 @@
"""
* Frame Logic Module
"""
-# Standard Library Imports
-from functools import cached_property, cache
-from typing import Union, Iterable
-# Local Imports
+from collections.abc import Iterable, Iterator
+from functools import cache, cached_property
+
from src.cards import FrameDetails
-from src.enums.mtg import Rarity
from src.enums.layers import LAYERS
+from src.enums.mtg import Rarity
+from src.utils.scryfall import MagicColor, ScryfallCard, ScryfallCardFace
"""
* Planned Utility Classes
@@ -17,6 +17,7 @@
class RulesTextLine:
"""Data structure representing one line of the rules text in a Magic the Gathering card."""
+
def __init__(self, line: str):
self._line = line
@@ -33,11 +34,10 @@ class RulesText:
def __init__(self, text: str):
self._text = text
- self._lines = [RulesTextLine(n) for n in text.split('\n')]
+ self._lines = [RulesTextLine(n) for n in text.split("\n")]
- def __iter__(self):
- for line in self._lines:
- yield line
+ def __iter__(self) -> Iterator[RulesTextLine]:
+ yield from self._lines
"""
@@ -46,65 +46,62 @@ def __iter__(self):
# Single Letter Colors
-colors = [
- LAYERS.WHITE,
- LAYERS.BLUE,
- LAYERS.BLACK,
- LAYERS.RED,
- LAYERS.GREEN
-]
+colors = [LAYERS.WHITE, LAYERS.BLUE, LAYERS.BLACK, LAYERS.RED, LAYERS.GREEN]
# Color Lookup Table
color_lookup = {
# Two Colors
- 2: {''.join(sorted(color)): color for color in [
- LAYERS.WU,
- LAYERS.UB,
- LAYERS.BR,
- LAYERS.RG,
- LAYERS.GW,
- LAYERS.WB,
- LAYERS.BG,
- LAYERS.GU,
- LAYERS.UR,
- LAYERS.RW
- ]},
+ 2: {
+ "".join(sorted(color)): color
+ for color in [
+ LAYERS.WU,
+ LAYERS.UB,
+ LAYERS.BR,
+ LAYERS.RG,
+ LAYERS.GW,
+ LAYERS.WB,
+ LAYERS.BG,
+ LAYERS.GU,
+ LAYERS.UR,
+ LAYERS.RW,
+ ]
+ },
# Three Colors
- 3: {''.join(sorted(color)): color for color in [
- LAYERS.GWU,
- LAYERS.WUB,
- LAYERS.UBR,
- LAYERS.BRG,
- LAYERS.RGW,
- LAYERS.WBG,
- LAYERS.URW,
- LAYERS.BGU,
- LAYERS.RWB,
- LAYERS.GUR
- ]},
+ 3: {
+ "".join(sorted(color)): color
+ for color in [
+ LAYERS.GWU,
+ LAYERS.WUB,
+ LAYERS.UBR,
+ LAYERS.BRG,
+ LAYERS.RGW,
+ LAYERS.WBG,
+ LAYERS.URW,
+ LAYERS.BGU,
+ LAYERS.RWB,
+ LAYERS.GUR,
+ ]
+ },
# Four Colors
- 4: {''.join(sorted(color)): color for color in [
- LAYERS.WUBR,
- LAYERS.UBRG,
- LAYERS.BRGW,
- LAYERS.RGWU,
- LAYERS.GWUB
- ]},
- 5: {'BGRUW': LAYERS.WUBRG}
+ 4: {
+ "".join(sorted(color)): color
+ for color in [LAYERS.WUBR, LAYERS.UBRG, LAYERS.BRGW, LAYERS.RGWU, LAYERS.GWUB]
+ },
+ 5: {"BGRUW": LAYERS.WUBRG},
}
# Basic Land Types
land_types = {
- 'Plains': LAYERS.WHITE,
- 'Island': LAYERS.BLUE,
- 'Swamp': LAYERS.BLACK,
- 'Mountain': LAYERS.RED,
- 'Forest': LAYERS.GREEN
+ "Plains": LAYERS.WHITE,
+ "Island": LAYERS.BLUE,
+ "Swamp": LAYERS.BLACK,
+ "Mountain": LAYERS.RED,
+ "Forest": LAYERS.GREEN,
}
# Mana Symbol Matching
-mono_symbols = ['{W}', '{U}', '{B}', '{R}', '{G}']
-hybrid_symbols = ['W/U', 'U/B', 'B/R', 'R/G', 'G/W', 'W/B', 'B/G', 'G/U', 'U/R', 'R/W']
+mono_symbols = ["{W}", "{U}", "{B}", "{R}", "{G}"]
+hybrid_symbols = ["W/U", "U/B", "B/R", "R/G", "G/W", "W/B", "B/G", "G/U", "U/R", "R/W"]
"""
@@ -125,7 +122,7 @@ def is_multicolor_string(text: str) -> bool:
if not text:
return False
if 1 < len(text) < 6:
- return bool(''.join(sorted(text)) in color_lookup.get(len(text), []))
+ return bool("".join(sorted(text)) in color_lookup.get(len(text), []))
return False
@@ -144,11 +141,11 @@ def contains_frame_colors(text: str) -> bool:
if text in colors or text in LAYERS.WUBRG:
return True
if 1 > len(text) < 5:
- return bool(''.join(sorted(text)) in color_lookup.get(len(text), []))
+ return bool("".join(sorted(text)) in color_lookup.get(len(text), []))
return False
-def get_ordered_colors(text: Union[str, Iterable]) -> str:
+def get_ordered_colors(text: str | Iterable[str]) -> str:
"""Takes in a string of letters representing color identity and puts them in the MTG
accurate letter order.
@@ -160,9 +157,9 @@ def get_ordered_colors(text: Union[str, Iterable]) -> str:
"""
# Validate the input
if not text:
- return ''
- if isinstance(text, Iterable):
- text = ''.join(text)
+ return ""
+ if not isinstance(text, str):
+ text = "".join(text)
# Match an ordered color
if len(text) == 1:
@@ -170,7 +167,7 @@ def get_ordered_colors(text: Union[str, Iterable]) -> str:
return text
if 1 < len(text) < 5:
# Use a lookup table
- return color_lookup[len(text)].get(''.join(sorted(text)), '')
+ return color_lookup[len(text)].get("".join(sorted(text)), "")
# All 5 colors
return LAYERS.WUBRG
@@ -186,17 +183,17 @@ def get_mana_cost_colors(mana_cost: str) -> str:
"""
# No valid mana cost
if not mana_cost:
- return ''
+ return ""
color_list = [color for color in colors if color in mana_cost]
- return ''.join(color_list)
+ return "".join(color_list)
def get_color_identity_nonland(
mana_cost: str,
type_line: str,
oracle_text: str,
- color_indicator: list[str],
- color_list: list[str]
+ color_indicator: list[MagicColor],
+ color_list: list[MagicColor],
) -> str:
"""Get the assumed color identity of Non-Land card based on a priority list of factors.
@@ -210,24 +207,26 @@ def get_color_identity_nonland(
Returns:
Our best guess for this card's color identity.
"""
- if ' is all colors.' in oracle_text:
+ if " is all colors." in oracle_text:
# Transguild Courier case
return LAYERS.WUBRG
- if mana_cost == '' or (mana_cost == '{0}' and LAYERS.ARTIFACT not in type_line):
+ if mana_cost == "" or (mana_cost == "{0}" and LAYERS.ARTIFACT not in type_line):
# Card with no mana cost
if color_indicator:
# Use Color Indicator if provided
- return get_ordered_colors(''.join(color_indicator))
+ return get_ordered_colors("".join(color_indicator))
elif color_list:
# Use Color Identity/Colors if provided
- return get_ordered_colors(''.join(color_list))
+ return get_ordered_colors("".join(color_list))
# No Color Identity
return ""
# Use colors from Mana Cost as assumed color identity
return get_ordered_colors(get_mana_cost_colors(mana_cost))
-def check_hybrid_color_card(color_identity: Union[str, list[str]], mana_cost: str, is_dfc: bool) -> bool:
+def check_hybrid_color_card(
+ color_identity: str | list[str], mana_cost: str, is_dfc: bool
+) -> bool:
"""Check a number of inputs to see if this card is:
- A card with only hybrid Mana symbols
- Only 2 colors represented
@@ -244,9 +243,11 @@ def check_hybrid_color_card(color_identity: Union[str, list[str]], mana_cost: st
True if hybrid, otherwise False.
"""
# Identify if the card is a two-color hybrid card with only hybrid mana
- if len(color_identity) == 2 and not any([symbol in mana_cost for symbol in mono_symbols]):
+ if len(color_identity) == 2 and not any(
+ [symbol in mana_cost for symbol in mono_symbols]
+ ):
# Hybrid empty mana case - Asmoranomardi[...]
- if mana_cost == '' and not is_dfc:
+ if mana_cost == "" and not is_dfc:
return True
for hybrid_symbol in hybrid_symbols:
if hybrid_symbol in mana_cost:
@@ -255,7 +256,7 @@ def check_hybrid_color_card(color_identity: Union[str, list[str]], mana_cost: st
return False
-def check_hybrid_mana_cost(color_identity: Union[str, list[str]], mana_cost: str) -> bool:
+def check_hybrid_mana_cost(color_identity: str | list[str], mana_cost: str) -> bool:
"""More simplified hybrid mana test for isolated mana cases e.g. Adventure spells.
Args:
@@ -266,7 +267,9 @@ def check_hybrid_mana_cost(color_identity: Union[str, list[str]], mana_cost: str
True if hybrid mana cost, otherwise False.
"""
# Identify if the card is a two-color hybrid card with only hybrid mana
- if len(color_identity) == 2 and not any([symbol in mana_cost for symbol in mono_symbols]):
+ if len(color_identity) == 2 and not any(
+ [symbol in mana_cost for symbol in mono_symbols]
+ ):
if any([bool(sym in mana_cost) for sym in hybrid_symbols]):
return True
return False
@@ -277,7 +280,7 @@ def check_hybrid_mana_cost(color_identity: Union[str, list[str]], mana_cost: str
"""
-def get_frame_details(card: dict) -> FrameDetails:
+def get_frame_details(card: ScryfallCard | ScryfallCardFace) -> FrameDetails:
"""Figure out which layers to use for pinlines, background, twins and define the color identity.
Pass the card to an appropriate function based on card type.
@@ -287,12 +290,12 @@ def get_frame_details(card: dict) -> FrameDetails:
Returns:
Dict containing FrameDetails representing the card's frame makeup.
"""
- if 'Land' in card.get('type_line', ''):
+ if card.type_line and "Land" in card.type_line:
return get_frame_details_land(card)
return get_frame_details_nonland(card)
-def get_frame_details_land(card: dict) -> FrameDetails:
+def get_frame_details_land(card: ScryfallCard | ScryfallCardFace) -> FrameDetails:
"""Card is a Land card, must check a variety of cases to identify the appropriate color identity.
Args:
@@ -302,15 +305,15 @@ def get_frame_details_land(card: dict) -> FrameDetails:
Dict containing FrameDetails representing the card's frame makeup.
"""
# Grab the attributes we need
- type_line, oracle_text = card.get('type_line', ''), card.get('oracle_text', '')
- twins = colors_tapped = basic_identity = ''
+ type_line, oracle_text = card.type_line or "", card.oracle_text or ""
+ twins = colors_tapped = basic_identity = ""
result: FrameDetails = {
"background": LAYERS.LAND,
"pinlines": LAYERS.LAND,
"twins": LAYERS.LAND,
- "identity": '',
+ "identity": "",
"is_colorless": False,
- "is_hybrid": False
+ "is_hybrid": False,
}
# Check if it has a basic land type
@@ -325,18 +328,15 @@ def get_frame_details_land(card: dict) -> FrameDetails:
elif len(basic_identity) == 2:
# Dual land type identity
identity = get_ordered_colors(basic_identity)
- result.update({
- "pinlines": identity,
- "identity": identity
- })
+ result.update({"pinlines": identity, "identity": identity})
return result
# Iterate over rules text lines
- basic_identity = ''
- for line in oracle_text.split('\n'):
+ basic_identity = ""
+ for line in oracle_text.split("\n"):
# Identify if the card is a fetch land
- if 'search your library' in line.lower():
- if 'cycling' not in line.lower():
+ if "search your library" in line.lower():
+ if "cycling" not in line.lower():
# Fetch land of some kind, find basic land types
for key, basic in land_types.items():
if key in line:
@@ -346,81 +346,93 @@ def get_frame_details_land(card: dict) -> FrameDetails:
# Set the name box & pinlines based on how many basics the ability mentions
if len(basic_identity) == 1:
# One basic mentioned - Single color identity
- result.update({
- 'pinlines': basic_identity,
- 'twins': basic_identity,
- 'identity': basic_identity,
- })
+ result.update(
+ {
+ "pinlines": basic_identity,
+ "twins": basic_identity,
+ "identity": basic_identity,
+ }
+ )
return result
elif len(basic_identity) == 2:
# Two basics mentioned - Dual color identity
identity = get_ordered_colors(basic_identity)
- result.update({
- 'pinlines': identity,
- 'identity': identity
- })
+ result.update({"pinlines": identity, "identity": identity})
return result
elif len(basic_identity) == 3:
# Three basics mentioned - Panorama case
return result
elif LAYERS.LAND.lower() in line:
# Land probably fetches any basic, exclude "Ash Barrens" case
- if (('tapped' not in line or 'untap' in line) and
- # "Ash Barrens" case
- 'into your hand' not in line and
- # "Demolition Field" case
- 'Destroy' not in line):
+ if (
+ ("tapped" not in line or "untap" in line)
+ and
+ # "Ash Barrens" case
+ "into your hand" not in line
+ and
+ # "Demolition Field" case
+ "Destroy" not in line
+ ):
# Gold fetch land
- result.update({
- 'pinlines': LAYERS.GOLD,
- 'twins': LAYERS.GOLD,
- 'identity': LAYERS.WUBRG
- })
+ result.update(
+ {
+ "pinlines": LAYERS.GOLD,
+ "twins": LAYERS.GOLD,
+ "identity": LAYERS.WUBRG,
+ }
+ )
return result
# Colorless fetch land
return result
# Check if the line adds one mana of any color
- if ('add' in line.lower() and 'mana' in line) and any(
- [t in line for t in ['color ', 'colors ', 'color.', 'colors.', 'any type']]
+ if ("add" in line.lower() and "mana" in line) and any(
+ [t in line for t in ["color ", "colors ", "color.", "colors.", "any type"]]
):
# Probably Gold Land if it excludes the following cases
- cases = ['enters the battlefield', 'Remove a charge counter', 'Sacrifice', 'luck counter']
+ cases = [
+ "enters the battlefield",
+ "Remove a charge counter",
+ "Sacrifice",
+ "luck counter",
+ ]
if all(case not in line for case in cases):
# Gold Identity Land
- result.update({
- 'pinlines': LAYERS.GOLD,
- 'twins': LAYERS.GOLD,
- 'identity': LAYERS.WUBRG
- })
+ result.update(
+ {
+ "pinlines": LAYERS.GOLD,
+ "twins": LAYERS.GOLD,
+ "identity": LAYERS.WUBRG,
+ }
+ )
return result
# Check if the line chooses a basic land type, e.g. Thran Portal
- if 'choose a basic land type' in line:
+ if "choose a basic land type" in line:
# Gold Identity Land
- result.update({
- 'pinlines': LAYERS.GOLD,
- 'twins': LAYERS.GOLD,
- 'identity': LAYERS.WUBRG
- })
+ result.update(
+ {
+ "pinlines": LAYERS.GOLD,
+ "twins": LAYERS.GOLD,
+ "identity": LAYERS.WUBRG,
+ }
+ )
return result
# Check if the line makes all lands X type, ex: Urborg, Tomb of Yawgmoth
- if 'Each land is a ' in line:
+ if "Each land is a " in line:
for k, v in land_types.items():
- if f'Each land is a {k}' in line:
- result.update({
- 'pinlines': v,
- 'twins': v,
- 'identity': v
- })
+ if f"Each land is a {k}" in line:
+ result.update({"pinlines": v, "twins": v, "identity": v})
return result
# Count how many colors of mana the card can tap to add
- if line.find('{T}') < line.find(':') and 'add ' in line.lower():
+ if line.find("{T}") < line.find(":") and "add " in line.lower():
# This line taps to add one or more colors, add those colors
- for color in [c for c in colors if f"{{{c}}}" in line and c not in colors_tapped]:
+ for color in [
+ c for c in colors if f"{{{c}}}" in line and c not in colors_tapped
+ ]:
# Add this color to colors_tapped
colors_tapped += color
@@ -428,29 +440,31 @@ def get_frame_details_land(card: dict) -> FrameDetails:
identity = get_ordered_colors(colors_tapped)
if len(identity) == 1:
# Mono Color
- result.update({
- 'pinlines': identity,
- 'identity': identity,
- 'twins': twins or colors_tapped
- })
+ result.update(
+ {
+ "pinlines": identity,
+ "identity": identity,
+ "twins": twins or colors_tapped,
+ }
+ )
elif len(identity) == 2:
# Dual Color
- result.update({
- 'pinlines': identity,
- 'identity': identity,
- 'twins': twins or LAYERS.LAND
- })
+ result.update(
+ {"pinlines": identity, "identity": identity, "twins": twins or LAYERS.LAND}
+ )
elif len(colors_tapped) > 2:
# Three to Five Colors
- result.update({
- 'pinlines': LAYERS.GOLD,
- 'identity': identity,
- 'twins': twins or LAYERS.GOLD
- })
+ result.update(
+ {
+ "pinlines": LAYERS.GOLD,
+ "identity": identity,
+ "twins": twins or LAYERS.GOLD,
+ }
+ )
return result
-def get_frame_details_nonland(card: dict) -> FrameDetails:
+def get_frame_details_nonland(card: ScryfallCard | ScryfallCardFace) -> FrameDetails:
"""Get frame details related to a Non-Land card. Must discern the frame color identity,
for example Noble Hierarch's color identity is [W, U, G] on Scryfall, but the frame is Green.
@@ -458,17 +472,19 @@ def get_frame_details_nonland(card: dict) -> FrameDetails:
card: Dict containing Scryfall data for this card.
"""
# Establish the attributes we need
- mana_cost = card.get('mana_cost', '')
- type_line = card.get('type_line', '')
- oracle_text = card.get('oracle_text', '')
+ mana_cost = card.mana_cost or ""
+ type_line = card.type_line or ""
+ oracle_text = card.oracle_text or ""
# Establish the initial assumed color identity
color_identity = get_color_identity_nonland(
mana_cost=mana_cost,
type_line=type_line,
oracle_text=oracle_text,
- color_indicator=card.get('color_indicator', []),
- color_list=card.get('color_identity', card.get('colors', []))
+ color_indicator=card.color_indicator or [],
+ color_list=card.color_identity
+ if isinstance(card, ScryfallCard)
+ else card.colors or [],
)
# Default results
@@ -478,78 +494,81 @@ def get_frame_details_nonland(card: dict) -> FrameDetails:
"twins": color_identity,
"identity": color_identity,
"is_colorless": False,
- "is_hybrid": False
+ "is_hybrid": False,
}
# Handle full art colorless cards and devoid frame cards
if (
- # Devoid card check
- devoid := bool('Devoid' in oracle_text and len(color_identity) > 0)
- ) or (
- # Zero color non-artifact card check
- len(color_identity) <= 0 and LAYERS.ARTIFACT not in type_line
- ) or (
- # Zero mana cost eldrazi card check
- not mana_cost and 'Eldrazi' in type_line
+ (
+ # Devoid card check
+ devoid := bool("Devoid" in oracle_text and len(color_identity) > 0)
+ )
+ or (
+ # Zero color non-artifact card check
+ len(color_identity) <= 0 and LAYERS.ARTIFACT not in type_line
+ )
+ or (
+ # Zero mana cost eldrazi card check
+ not mana_cost and "Eldrazi" in type_line
+ )
):
# Devoid dual color frame or Colorless frame?
if devoid and len(color_identity) > 1:
# Use gold name plates and devoid-style background
- result.update({
- 'twins': LAYERS.GOLD,
- 'background': LAYERS.GOLD
- })
+ result.update({"twins": LAYERS.GOLD, "background": LAYERS.GOLD})
elif not devoid:
# Completely Colorless card
- result.update({
- 'twins': LAYERS.COLORLESS,
- 'background': LAYERS.COLORLESS,
- 'pinlines': LAYERS.COLORLESS
- })
+ result.update(
+ {
+ "twins": LAYERS.COLORLESS,
+ "background": LAYERS.COLORLESS,
+ "pinlines": LAYERS.COLORLESS,
+ }
+ )
# Return formatted Colorless card
- result['is_colorless'] = True
+ result["is_colorless"] = True
return result
# Identify Hybrid frame cards
hybrid = check_hybrid_color_card(
color_identity=color_identity,
mana_cost=mana_cost,
- is_dfc=bool(card.get('object') == 'card_face')
+ is_dfc=card.object == "card_face",
)
# Is this card hybrid?
if hybrid:
- result['is_hybrid'] = True
+ result["is_hybrid"] = True
# Switch Background
if LAYERS.VEHICLE in type_line:
# Vehicle card
- result['background'] = LAYERS.VEHICLE
+ result["background"] = LAYERS.VEHICLE
elif LAYERS.ARTIFACT in type_line:
# Artifact card
- result['background'] = LAYERS.ARTIFACT
+ result["background"] = LAYERS.ARTIFACT
elif len(color_identity) >= 2 and not hybrid:
# 2+ color card not Hybrid
- result['background'] = LAYERS.GOLD
+ result["background"] = LAYERS.GOLD
# Switch Pinlines
if len(color_identity) == 0:
# No colors
- result['pinlines'] = LAYERS.ARTIFACT
+ result["pinlines"] = LAYERS.ARTIFACT
elif len(color_identity) > 2:
# 1-2 colors
- result['pinlines'] = LAYERS.GOLD
+ result["pinlines"] = LAYERS.GOLD
# Switch Name Plates
if len(color_identity) == 0:
# No colors
- result['twins'] = LAYERS.ARTIFACT
+ result["twins"] = LAYERS.ARTIFACT
elif hybrid:
# Hybrid card
- result['twins'] = LAYERS.LAND
+ result["twins"] = LAYERS.LAND
elif len(color_identity) >= 2:
# 2+ colors
- result['twins'] = LAYERS.GOLD
+ result["twins"] = LAYERS.GOLD
# Return the processed details
return result
@@ -560,7 +579,7 @@ def get_frame_details_nonland(card: dict) -> FrameDetails:
"""
-def get_special_rarity(rarity: str, card: dict) -> str:
+def get_special_rarity(rarity: str, card: ScryfallCard) -> str:
"""Control for special rarities.
Args:
@@ -572,13 +591,13 @@ def get_special_rarity(rarity: str, card: dict) -> str:
"""
if rarity == Rarity.S:
# Timeshifted cards
- if card.get('frame') == '1997':
+ if card.frame == "1997":
return Rarity.T
# Championship cards
- if 'Champion' in card.get('set_name'):
+ if "Champion" in card.set_name:
return Rarity.M
# Masterpiece
- if card.get('set_type') == 'masterpiece':
+ if card.set_type == "masterpiece":
return Rarity.M
# Case like Prismatic Piper
return Rarity.C
diff --git a/src/gui/__init__.py b/src/gui/__init__.py
deleted file mode 100644
index 7815184e..00000000
--- a/src/gui/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""
-* GUI Module
-"""
-# Local Imports
-from ._state import GlobalAccess, GUI
-
-# Export objects
-__all__ = ['GlobalAccess', 'GUI']
diff --git a/src/gui/_state.py b/src/gui/_state.py
deleted file mode 100644
index 2980ee2a..00000000
--- a/src/gui/_state.py
+++ /dev/null
@@ -1,155 +0,0 @@
-"""
-* Managing Global State (GUI)
-"""
-# Standard Library Imports
-from dataclasses import dataclass
-from functools import cache, cached_property
-import os
-from typing import Any
-
-# Third Party Imports
-from kivy.app import App
-from kivy.config import Config
-from kivy.factory import Factory
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.layout import Layout
-from kivy.uix.togglebutton import ToggleButton
-from kivy.uix.button import Button
-from kivy.metrics import dp
-from kivy.core.window import Window
-
-# Local Imports
-from src._config import AppConfig
-from src._state import PATH, AppConstants, AppEnvironment
-from src.enums.mtg import layout_map_category
-from src.gui.utils import HoverBehavior
-from src.utils.adobe import PhotoshopHandler
-
-"""
-* Enums
-"""
-
-
-@dataclass
-class KivyStyles:
- """Define the `kv` stylesheet files to loaded at initialization."""
- App = os.path.join(PATH.SRC_DATA_KV, 'app.kv')
- Main = os.path.join(PATH.SRC_DATA_KV, 'main.kv')
- Console = os.path.join(PATH.SRC_DATA_KV, 'console.kv')
- Creator = os.path.join(PATH.SRC_DATA_KV, 'creator.kv')
- Test = os.path.join(PATH.SRC_DATA_KV, 'test.kv')
- Tools = os.path.join(PATH.SRC_DATA_KV, 'tools.kv')
- Updater = os.path.join(PATH.SRC_DATA_KV, 'updater.kv')
-
-
-"""
-* Cached Utils
-"""
-
-
-@cache
-def get_root_app():
- """Cache and return the running application object."""
- return App.get_running_app()
-
-
-class GlobalAccess(Layout):
- """Utility class giving Kivy GUI elements access to the main app and its
- global objects."""
-
- def __init__(self, *args, **kwargs):
- self.bind(on_kv_post=self.on_load)
- super().__init__(**kwargs)
-
- """
- * Layout Methods
- """
-
- def on_load(self, *args) -> None:
- """Fired when object is loaded into the GUI."""
- self.main.toggle_buttons.extend(self.toggle_buttons)
-
- """
- * Utility Properties
- """
-
- @cached_property
- def toggle_buttons(self) -> list[Button]:
- """Buttons that should be toggled during a locked operation."""
- return []
-
- """
- * Global Object Properties
- """
-
- @cached_property
- def main(self) -> Any:
- """ProxyshopGUIApp: Get the running application."""
- return get_root_app()
-
- @property
- def app(self) -> PhotoshopHandler:
- """PhotoshopHandler: Global Photoshop application object."""
- return self.main.app
-
- @property
- def cfg(self) -> AppConfig:
- """AppConfig: Global settings object."""
- return self.main.cfg
-
- @property
- def con(self) -> AppConstants:
- """AppConstants: Global constants object."""
- return self.main.con
-
- @property
- def env(self) -> AppEnvironment:
- """AppEnvironment: Global environment object."""
- return self.main.env
-
- @property
- def console(self) -> Any:
- """GUIConsole: Console output object."""
- return self.main.console
-
-
-"""
-* GUI Loader Funcs
-"""
-
-
-def load_kv_config():
- """Loads app-wide Kivy configuration."""
-
- # Kivy configuration
- Config.set('input', 'mouse', 'mouse,multitouch_on_demand')
- Config.remove_option('input', 'wm_touch')
- Config.remove_option('input', 'wm_pen')
- Config.set('kivy', 'log_level', 'error')
- Config.write()
-
- # Window configuration
- Window.minimum_width = dp(650)
- Window.minimum_height = dp(540)
- Window.size = (dp(840), dp(720))
-
-
-def register_kv_classes():
- """Loads app-wide GUI classes into Kivy Factory."""
- Factory.register('HoverBehavior', HoverBehavior)
-
-
-"""
-* Tracked Elements
-"""
-
-
-class GUIResources:
- def __init__(self):
- self.template_row: dict[str, dict[str, BoxLayout]] = {k: {} for k in layout_map_category}
- self.template_btn: dict[str, dict[str, ToggleButton]] = {k: {} for k in layout_map_category}
- self.template_btn_cfg: dict[str, dict[str, Button]] = {k: {} for k in layout_map_category}
- self.template_list: dict[str, list[BoxLayout]] = {k: [] for k in layout_map_category}
-
-
-GUI = GUIResources()
diff --git a/src/gui/app.py b/src/gui/app.py
deleted file mode 100644
index d1d6a55e..00000000
--- a/src/gui/app.py
+++ /dev/null
@@ -1,1026 +0,0 @@
-"""
-* Proxyshop GUI Launcher
-"""
-# Standard Library Imports
-import os
-import json
-from io import BytesIO
-from pathlib import Path
-from time import perf_counter
-from concurrent.futures import ThreadPoolExecutor
-from contextlib import suppress
-import win32clipboard as clipboard
-from datetime import datetime as dt
-from functools import cached_property
-from threading import Event, Thread, Lock
-from multiprocessing import cpu_count
-from typing import Union, Optional, Callable
-
-# Third-party Imports
-import requests
-from PIL import Image as PImage
-from photoshop.api._document import Document
-from photoshop.api import SaveOptions, PurgeTarget
-from packaging.version import parse
-
-# Kivy Imports
-from kivy.lang import Builder
-from kivy.app import App
-from kivy.metrics import dp
-from kivy.uix.button import Button
-from kivy.core.window import Window
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.togglebutton import ToggleButton
-from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
-import asynckivy as ak
-from omnitils.files import load_data_file
-
-# Local Imports
-from src._state import AppConstants, AppEnvironment, PATH
-from src._config import AppConfig
-from src.console import get_bullet_points, msg_bold, msg_error, msg_info, msg_success, msg_warn
-from src.enums.mtg import layout_map_types
-from src.gui.console import GUIConsole, ConsoleOutput
-from src.gui.popup.settings import SettingsPopup
-from src._loader import (
- AppPlugin,
- TemplateDetails,
- TemplateSelectedMap,
- TemplateCategoryMap,
- get_template_map_selected,
- AppTemplate)
-from src.gui._state import GUI, GlobalAccess
-from src.gui.popup.updater import UpdatePopup
-from src.gui.tabs.creator import CreatorPanel
-from src.gui.tabs.main import TemplateRow, MainPanel
-from src.gui.tabs.tools import ToolsPanel
-from src.gui.test import TestApp
-from src.layouts import (
- layout_map,
- assign_layout,
- join_dual_card_layouts,
- NormalLayout)
-from src.templates import BaseTemplate
-from src.utils.adobe import get_photoshop_error_message, PhotoshopHandler, PS_EXCEPTIONS
-from src.utils.hexapi import update_hexproof_cache, get_api_key
-from src.utils.fonts import check_app_fonts
-
-
-"""
-* App Tab Containers
-"""
-
-
-class AppContainer(BoxLayout, GlobalAccess):
- """Container for overall app."""
-
- def on_load(self, *args) -> None:
- """Add tab panel."""
- self.add_widget(AppTabs())
-
-
-class AppTabs(TabbedPanel, GlobalAccess):
- """Container for both render and creator tabs."""
-
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
- self._tab_layout.padding = (
- '0dp', '0dp', '0dp', '0dp')
-
- def on_load(self, *args) -> None:
- """Add tabs."""
- self.add_widget(MainTab())
- self.add_widget(CreatorTab())
- self.add_widget(ToolsTab())
-
-
-class MainTab(TabbedPanelItem, GlobalAccess):
- """Main rendering tab."""
-
- def on_load(self, *args) -> None:
- """Add content."""
- self.content = MainPanel()
-
-
-class CreatorTab(TabbedPanelItem, GlobalAccess):
- """Custom card creator tab."""
-
- def on_load(self, *args) -> None:
- """Add content."""
- self.content = CreatorPanel()
-
-
-class ToolsTab(TabbedPanelItem, GlobalAccess):
- """Utility tools tab."""
-
- def on_load(self, *args) -> None:
- """Add content."""
- self.content = ToolsPanel()
-
-
-"""
-* Main GUI Application
-"""
-
-
-class ProxyshopGUIApp(App):
- """Proxyshop's main Kivy App class that initiates render procedures and manages user settings."""
- Builder.load_file(os.path.join(PATH.SRC_DATA_KV, "app.kv"))
-
- def __init__(
- self,
- app: PhotoshopHandler,
- con: AppConstants,
- cfg: AppConfig,
- env: AppEnvironment,
- console: GUIConsole,
- plugins: dict[str, AppPlugin],
- templates: list[AppTemplate],
- template_map: dict[str, TemplateCategoryMap],
- templates_default: dict[str, TemplateDetails],
- **kwargs
- ):
- # Call super
- super().__init__(**kwargs)
-
- # Data
- self._app = app
- self._cfg = cfg
- self._con = con
- self._env = env
- self._console = console
- self._plugins = plugins
- self._templates = templates
- self._template_map = template_map
- self._templates_default = templates_default
- self._templates_selected = {}
- self._current_render = None
- self._result = False
-
- """
- * Kivy Window Properties
- """
-
- @property
- def title(self) -> str:
- """str: App name displayed at the top of the application window."""
- return f"Proxyshop v{self.env.VERSION}"
-
- @property
- def icon(self) -> str:
- """str: Path to icon displayed in the task bar and the corner of the window."""
- return str(PATH.SRC_IMG / 'proxyshop.png')
-
- @property
- def cont_padding(self) -> float:
- """float: Padding for the main app container."""
- return dp(10)
-
- """
- * Global Objects
- """
-
- @property
- def app(self) -> PhotoshopHandler:
- """PhotoshopHandler: Global Photoshop application object."""
- return self._app
-
- @property
- def cfg(self) -> AppConfig:
- """AppConfig: Global settings object."""
- return self._cfg
-
- @property
- def con(self) -> AppConstants:
- """AppConstants: Global constants object."""
- return self._con
-
- @property
- def env(self) -> AppEnvironment:
- """AppEnvironment: Global environment object."""
- return self._env
-
- @property
- def console(self) -> GUIConsole:
- """GUIConsole: Console output object."""
- return self._console
-
- """
- * Rendering Properties
- """
-
- @cached_property
- def _render_lock(self) -> Lock:
- """Lock: Thread locking mechanism for render operations."""
- return Lock()
-
- @cached_property
- def _dropped_files(self) -> list[Path]:
- """list[Path]: Tracks files dragged and dropped onto the app window."""
- return []
-
- @cached_property
- def plugins(self) -> dict[str, AppPlugin]:
- """dict[str, AppPlugin]: Tracks all plugins loaded by the app currently."""
- return self._plugins
-
- @cached_property
- def templates(self) -> list[AppTemplate]:
- """list[AppTemplate]: List of all templates available to the app."""
- return self._templates
-
- @cached_property
- def template_map(self) -> dict[str, TemplateCategoryMap]:
- """dict[str, TemplateCategoryMap]: Dictionary of category mapped templates available to the app."""
- return self._template_map
-
- @cached_property
- def templates_selected(self) -> TemplateSelectedMap:
- """TemplateSelectedMap: Tracks the templates currently selected for each type by the user."""
- return self._templates_selected
-
- @cached_property
- def templates_default(self) -> TemplateSelectedMap:
- """TemplateSelectedMap: Tracks the default template selections for every template type."""
- return self._templates_default
-
- @cached_property
- def current_render(self) -> BaseTemplate:
- """BaseTemplate: Tracks the current template class being used for rendering."""
- return self._current_render
-
- @property
- def docref(self) -> Optional[Document]:
- """Optional[Document]: Tracks the currently open Photoshop document."""
- if self.current_render and hasattr(self.current_render, 'docref'):
- return self.current_render.docref or None
- return None
-
- @property
- def thread(self) -> Optional[Event]:
- """Optional[Event]: Tracks the current render threading Event."""
- if self.current_render and hasattr(self.current_render, 'event'):
- return self.current_render.event or None
- return None
-
- @property
- def timer(self) -> float:
- """float: Returns the current system time as a float to use as a timer comparison."""
- return perf_counter()
-
- @property
- def thread_cancelled(self) -> bool:
- """bool: Whether the current render thread has been cancelled."""
- thr = self.thread
- return bool(not isinstance(thr, Event) or thr.is_set())
-
- """
- * UI Properties
- """
-
- @cached_property
- def toggle_buttons(self) -> list[Button]:
- """list[Button]: UI buttons to toggle when disable_buttons or enable_buttons is called."""
- return [self.console.update_btn]
-
- """
- * Wrappers
- """
-
- @staticmethod
- def render_process_wrapper(func) -> Callable:
- """Decorator to handle state maintenance before and after an initiated render process.
-
- Args:
- func: Function being wrapped.
-
- Returns:
- The result of the wrapped function.
- """
-
- def wrapper(self, *args):
- # Never render on two threads at once
- if self._render_lock.locked():
- return
-
- # Lock the render thread
- with self._render_lock:
- # Disable buttons / clear console on enter
- self.reset(disable_buttons=True, clear_console=True)
-
- # Ensure Photoshop is responsive
- while check := self.app.refresh_app():
- if not self.console.await_choice(
- thr=Event(),
- msg=get_photoshop_error_message(check),
- end="Hit Continue to try again, or Cancel to end the operation.\n"
- ):
- # Cancel this operation
- return
-
- # Call the function
- result = func(self, *args)
-
- # Enable buttons / close document on exit
- self.reset(enable_buttons=True, close_document=True)
- return result
-
- return wrapper
-
- """
- * Art Loading Utilities
- """
-
- def get_art_files(self, folder: Path = PATH.ART) -> list[Path]:
- """Grab all supported image files within a given directory.
-
- Args:
- folder: Path within the working directory containing images.
-
- Returns:
- List of art file paths.
- """
- # Folder, file list, supported extensions
- all_files = os.listdir(folder)
- ext = (".png", ".jpg", ".tif", ".jpeg", ".jpf")
-
- # Select all images in folder not prepended with !
- files = [Path(folder, f) for f in all_files if f.endswith(ext) and not f.startswith('!')]
-
- # Check for webp files
- files_webp = [Path(folder, f) for f in all_files if f.endswith('.webp') and not f.startswith('!')]
-
- # Check if Photoshop version supports webp
- if files_webp and not self.app.supports_webp:
- self.console.update(msg_warn('Skipped WEBP image, WEBP requires Photoshop ^23.2.0'))
- elif files_webp:
- files.extend(files_webp)
- return sorted(files)
-
- def select_art(self) -> list[Path]:
- """Open file select dialog in Photoshop, return the paths of any selected files.
-
- Returns:
- A list of Path objects.
- """
- while True:
- try:
- # Open a file select dialog in Photoshop
- if files := self.app.openDialog():
- return [Path(f) for f in files]
- return []
- except PS_EXCEPTIONS as e:
- # Photoshop is busy or unresponsive, try again?
- if not self.console.await_choice(
- Event(), get_photoshop_error_message(e),
- end="Hit Continue to try again, or Cancel to end the operation.\n"
- ):
- # Cancel the operation
- break
- # Refresh Photoshop, try again
- self.app.refresh_app()
- # No files selected
- return []
-
- """
- * Photoshop Utilities
- """
-
- def close_document(self) -> None:
- """Close Photoshop document if current document reference exists.."""
- if isinstance(self.docref, Document):
- try:
- self.docref.close(SaveOptions.DoNotSaveChanges)
- self.app.purge(PurgeTarget.AllCaches)
- except Exception as e:
- # Document wasn't available
- print("Couldn't close corresponding document!")
- self.console.log_exception(e)
- self.current_render = None
-
- """
- * Render Methods
- """
-
- @render_process_wrapper
- def render_all(self, target: bool = False, files: Optional[list[Path]] = None) -> None:
- """Render cards using all images located in the art folder.
-
- Args:
- target: Whether to do a targeted render operation.
- files: A lit of files to render instead of target or 'art' folder, if provided.
- """
- # Get our templates
- temps = get_template_map_selected(self.templates_selected, self.templates_default)
-
- # Get our art files
- if not files:
- files = self.select_art() if target else self.get_art_files()
-
- # No files provided
- if not files and target:
- return self.console.update(
- "No art images found!" if target else "No art images selected!")
-
- # Run through each file, assigning layout
- with ThreadPoolExecutor(max_workers=cpu_count()) as pool:
- cards = pool.map(assign_layout, files)
-
- # Join dual card layouts
- cards = join_dual_card_layouts(list(cards))
-
- # Remove failed strings
- layouts: dict[str, dict[str, list[NormalLayout]]] = {}
- failed: list[str] = []
- for c in cards:
-
- # Add failed card
- if isinstance(c, str):
- failed.append(c)
- continue
-
- # Assign card as failure if template isn't installed
- if not temps[c.card_class]['object'].is_installed:
- c = msg_error(
- msg=c.display_name,
- reason=f"Template '{temps[c.card_class]['name']}' with type "
- f"'{c.card_class}' is not installed!")
-
- # Map card to its template path and layout type
- layouts.setdefault(
- str(temps[c.card_class]['object'].path_psd), {}
- ).setdefault(c.card_class, []).append(c)
-
- # Did any cards fail to find?
- if failed:
-
- # If all failed alert the user and cancel
- failure_list = '\n'.join(failed)
- if not layouts:
- return self.console.update(
- f"\n{msg_bold(msg_error('Failed to render all cards!'))}"
- f"\n{failure_list}")
-
- # Let the user choose to continue if only some failed
- if not self.console.error(
- msg=f"\n{msg_error('Unable to render these cards:')}"
- f"{failure_list}"
- ):
- return
-
- # Render in batches separated by PSD file
- self.console.update()
- times: list[float] = []
- for (i), (_path, class_map) in enumerate(layouts.items()):
- for layout, cards in class_map.items():
-
- # Initialize the template's python class module
- loaded_class = temps[layout]['object'].get_template_class(
- temps[layout]['class_name'])
- if not loaded_class:
-
- # Failed to load module or python class
- self.console.update(msg_error(
- "Unable to load Python class: "
- f"{msg_bold(temps[layout]['class_name'])}"))
-
- # If more templates in the queue, ask to continue
- if (i + 1) < len(layouts):
-
- # Get a list of cards using this template
- failed_cards = get_bullet_points(
- text=[str(c.display_name) for c in cards],
- char='-')
- if self.console.error(
- msg=f"{msg_error('The following cards have been cancelled:')}"
- f"{failed_cards}"
- ):
- # Continue to next template
- self.console.update()
- continue
-
- # Cancel render process
- return
-
- # Load constants and config for this template
- self.cfg.load(temps[layout]['config'])
- self.con.reload()
-
- # Render each card with this PSD and class
- for c in cards:
- result = self.start_render(c, temps[layout], loaded_class)
- if self.thread_cancelled:
- return
- if result is not None:
- times.append(result)
-
- # Render group complete
- self.close_document()
-
- # Report the average render time
- self.console.update(msg_success('Renders Completed!'))
- if times:
- avg = round(sum(times) / len(times), 1)
- self.console.update(f'Average time: {avg} seconds')
-
- @render_process_wrapper
- def render_custom(self, template: TemplateDetails, scryfall: dict) -> None:
- """Set up custom render job, then execute.
-
- Args:
- template: Dict of template details.
- scryfall: Dict of scryfall data.
- """
- # Open file in PS
- if not (files := self.select_art()):
- return
- art_file = files.pop()
-
- # Instantiate layout object
- try:
- c = layout_map[scryfall['layout']](
- scryfall=scryfall,
- file={
- 'file': art_file,
- 'name': scryfall.get('name', ''),
- 'artist': scryfall.get('artist', ''),
- 'set': scryfall.get('set', ''),
- 'creator': None
- })
- except Exception as e:
- self.console.update(
- msg='Unable to create a layout object, make sure you input valid information.\n'
- 'Custom card failed!',
- exception=e)
- return
-
- # Ensure template is installed
- if not template['object'].is_installed:
- # Template is not installed
- self.console.update(
- f"Template '{template['name']}' for card type '{c.card_class}' is not installed!\n"
- 'Custom card failed!')
- return
-
- # Initialize a python class for the template
- loaded_class = template['object'].get_template_class(template['class_name'])
- if not loaded_class:
- # Failed to load module or python class
- self.console.update(
- f"Unable to load Python class: {msg_bold(template['class_name'])}\n"
- 'Custom card failed!')
- return
-
- # Start render
- self.console.update()
- self.start_render(c, template, loaded_class)
-
- @render_process_wrapper
- def test_all(self, deep: bool = False) -> None:
- """Test all templates in series.
-
- Args:
- deep: Tests every card case for each template if enabled.
- """
- # Load data file and loop through test case sections
- for card_type, cards in load_data_file(
- (PATH.SRC_DATA_TESTS / 'template_renders').with_suffix('.toml')
- ).items():
-
- # If not a deep test, only use first entry
- cards = {k: v for k, v in [next(iter(cards.items()))]} if not deep else cards
- self.console.update(msg_success(f'\nβ {card_type.upper()}'))
-
- # Loop through templates to test
- for name, template in self.template_map[layout_map_types[card_type]]['map'][card_type].items():
- self.console.update(f"{template['class_name']} ... ", end="")
-
- # Is this template installed?
- if not template['object'].is_installed:
- self.console.update(msg_warn('SKIPPED (Template not installed)'))
- continue
-
- # Can this template's class be loaded?
- loaded_class = template['object'].get_template_class(template['class_name'])
- if not loaded_class:
- self.console.update(msg_error('SKIPPED (Template class failed to load)'))
- continue
-
- # Load constants and config for this template
- self.cfg.load(config=template['config'])
- self.con.reload()
-
- # Loop through cards to test
- failures: list[tuple[str, str, str]] = []
- times: list[float] = []
- for card_name, card_case in cards.items():
-
- # Attempt to assign a layout
- layout = assign_layout(Path(card_name))
- if isinstance(layout, str):
- failures.append((card_name, card_case, 'Failed to assign layout'))
- continue
-
- # Grab the template class and start the render thread
- layout.art_file = PATH.SRC_IMG / 'test.jpg'
- result = self.start_render(layout, template, loaded_class)
- if result is None:
- failures.append((card_name, card_case, 'Failed to render'))
- else:
- times.append(result)
-
- # Was thread cancelled?
- if self.thread_cancelled:
- return
-
- # Create a summary message
- if failures:
- fail_log = get_bullet_points([
- f"{name} ({case}) β {reason}"
- for name, case, reason in failures])
- summary = msg_error(f'FAILED{fail_log}')
- else:
- avg = round(sum(times) / len(times), 1)
- summary = msg_success(f'{avg}s Avg.')
-
- # Log summary and continue to next template
- self.console.update(summary)
- self.close_document()
-
- @render_process_wrapper
- def test_target(self, card_type: str, template: TemplateDetails) -> None:
- """Tests a specific template, always tests every case.
-
- Args:
- card_type: Type of card, corresponds to template type.
- template: Specific template to test.
- """
- # Load test case cards
- cases = load_data_file((PATH.SRC_DATA_TESTS / 'template_renders').with_suffix('.toml'))
- title = f"\nβ {template['class_name']}"
- self.console.update(msg_success(title))
-
- # Is this template installed?
- if not template['object'].is_installed:
- self.console.update(msg_warn("SKIPPED (Template not installed)"))
- return
-
- # Can this template's class be loaded?
- loaded_class = template['object'].get_template_class(template['class_name'])
- if not loaded_class:
- self.console.update(msg_error('SKIPPED (Template class failed to load)'))
- return
-
- # Render each test case
- times: list[float] = []
- for card_name in cases[card_type].keys():
- self.console.update(f"{card_name} ... ", end="")
-
- # Attempt to assign a layout
- layout = assign_layout(Path(card_name))
- if isinstance(layout, str):
- self.console.update(msg_error('Failed to assign layout'))
- continue
-
- # Start the render
- layout.art_file = PATH.SRC_IMG / 'test.jpg'
- result = self.start_render(
- card=layout,
- template=template,
- loaded_class=loaded_class,
- reload_config=True,
- reload_constants=True)
-
- # Was thread cancelled?
- if self.thread_cancelled:
- return
- if result is not None:
- times.append(result)
-
- # Was render successful?
- self.console.update(
- msg_error('Failed to render')
- if result is None else
- msg_success(f'{result}s'))
-
- # Report the average render time
- self.console.update(msg_success('\nTests Completed!'))
- if times:
- avg = round(sum(times) / len(times), 1)
- self.console.update(f'Average time: {avg} seconds')
-
- def start_render(
- self, card: NormalLayout,
- template: TemplateDetails,
- loaded_class: type[BaseTemplate],
- reload_config: bool = False,
- reload_constants: bool = False
- ) -> Optional[float]:
- """Execute a render job using a given card layout, template, and template class.
-
- Args:
- card: Layout object representing an MTG card with validated data.
- template: Template details containing relevant data about the template.
- loaded_class: Python class loaded from the template's module which executes the render operation.
- reload_config: Whether to reload the config object for this render, defaults to False.
- reload_constants: Whether to reload the constants object for this render, defaults to False.
-
- Returns:
- True if render queue should continue, False if the remaining renders have been cancelled.
- """
- # Notify the user
- if not self.env.TEST_MODE:
- self.console.update(msg_success(f"---- {card.display_name} ----"))
-
- # Reload config and/or constants
- if reload_config:
- self.cfg.load(config=template['config'])
- if reload_constants:
- self.con.reload()
-
- # Catch any unexpected render exceptions
- try:
-
- # Set the PSD location of the template
- card.template_file = template['object'].path_psd
-
- # Create the template class object
- self.current_render = loaded_class(card)
-
- # Run a cancellation await in a separate thread using executor
- with ThreadPoolExecutor() as executor:
- executor.submit(self.console.start_await_cancel, self.current_render.event)
-
- # Render the card
- start_time = self.timer
- result = self.current_render.execute()
- timed = round(self.timer - start_time, 1)
-
- # Return execution time if successful
- if not self.thread.is_set() and result:
- if not self.env.TEST_MODE:
- self.console.update(f"[i]Time completed: {timed} seconds[/i]\n")
- return timed
-
- # General error outside Template render process
- except Exception as error:
-
- # Reset document
- if self.docref:
- self.current_render.reset()
-
- # Log the error
- self.console.log_error(
- self.thread or Event(),
- card=card.name,
- template=template['name'],
- msg=msg_error(
- 'Encountered a general error!\n'
- 'Check [b]/logs/error.txt[/b] for details.'),
- e=error)
-
- # Card failed or thread cancelled
- return
-
- """
- * UI Utilities
- """
-
- def select_template(self, btn: ToggleButton) -> None:
- """Add selected template to the template dict.
-
- Args:
- btn: Button that was pressed and represents a given template.
- """
- # Set the preview image
- btn.parent.set_preview_image()
-
- # Select the template
- if btn.state == "down":
- obj: TemplateRow = btn.parent
- btn.disabled = True
-
- # Set the preview image
- obj.set_preview_image()
-
- # Select the template for its respective types
- for t in obj.types:
- if _template := obj.template_map.get(t):
- self.templates_selected[t] = _template
-
- # Enable all other buttons in this template list
- for name, button in GUI.template_btn[btn.parent.category].items():
- if name != btn.text:
- button.disabled = False
- button.state = "normal"
-
- def reset(
- self,
- reload_config: bool = True,
- reload_constants: bool = True,
- close_document: bool = False,
- enable_buttons: bool = False,
- disable_buttons: bool = False,
- clear_console: bool = False
- ) -> None:
- """Reset app config state to default.
-
- Args:
- reload_config: Reload the configuration object using system and base settings.
- reload_constants: Reload the global constants object.
- close_document: Close the Photoshop document if still open.
- enable_buttons: Enable UI buttons.
- disable_buttons: Disable UI buttons.
- clear_console: Clear the console of all output.
- """
- # Reset current render thread
- if reload_config:
- self.cfg.load()
- if reload_constants:
- self.con.reload()
- if close_document:
- self.close_document()
- if enable_buttons:
- self.enable_buttons()
- if disable_buttons:
- self.disable_buttons()
- if clear_console:
- self.console.clear()
-
- def disable_buttons(self) -> None:
- """Disable buttons while render process running."""
- for b in self.toggle_buttons:
- b.disabled = True
-
- def enable_buttons(self) -> None:
- """Enable buttons after render process completes."""
- for b in self.toggle_buttons:
- b.disabled = False
-
- """
- GUI METHODS
- """
-
- def toggle_window_locked(self) -> None:
- """Toggle whether to pin the window above all other windows."""
- window: Window = self.root_window
- window.always_on_top = not window.always_on_top
-
- def screenshot_window(self) -> None:
- """Take a screenshot of the Kivy window."""
- path = (PATH.OUT / "screenshots" / dt.now().strftime(
- "%m-%d-%Y_")).with_suffix('.jpg')
- path.parent.mkdir(mode=711, parents=True, exist_ok=True)
- img_path = self.root_window.screenshot(name=str(path))
-
- # Copy image to clipboard
- with suppress(Exception):
- with PImage.open(img_path) as f:
- with BytesIO() as bmp:
- # Load image data
- f.convert("RGB").save(bmp, "BMP")
- data = bmp.getvalue()[14:]
-
- # Apply data to the clipboard
- clipboard.OpenClipboard(), clipboard.EmptyClipboard()
- clipboard.SetClipboardData(clipboard.CF_DIB, data)
- clipboard.CloseClipboard()
-
- @staticmethod
- async def open_app_settings() -> None:
- """Open the settings panel for app system and base configs."""
- cfg_panel = SettingsPopup()
- cfg_panel.open()
-
- def build(self) -> Union[TestApp, AppContainer]:
- """Build the app for display.
-
- Returns:
- The root app layout.
- """
- self._app_layout = TestApp() if self.env.TEST_MODE else AppContainer()
- Window.bind(
- # Bind drop files event
- on_drop_file=self.queue_dropped_file,
- on_drop_end=self.render_dropped_files)
- self.add_console()
- return self._app_layout
-
- def queue_dropped_file(self, _window, path: bytes, _x, _y) -> None:
- """Queue a dropped in file for rendering."""
- if self._render_lock.locked():
- return
- self._dropped_files.append(Path(path.decode()))
-
- def render_dropped_files(self, _window, _x, _y) -> None:
- """Initiate a separate thread to render last dropped in cards."""
- if self._render_lock.locked():
- return
- files = [f for f in self._dropped_files if f.is_file()]
- folders = [f for f in self._dropped_files if f.is_dir()]
- [files.extend(self.get_art_files(f)) for f in folders]
- del self._dropped_files
-
- # Set up render job in separate thread
- Thread(target=self.render_all, args=(False, files), daemon=True).start()
-
- def on_start(self) -> None:
- """Fired after build is fired. Run a diagnostic check to see what works."""
- self.console.update(msg_success("--- STATUS ---"))
-
- # Check if using latest version
- self.console.update(
- f"Proxyshop Version ... {msg_success('Using latest version!')}" if (
- self.check_app_version()
- ) else f"Proxyshop Version ... {msg_info('New release available!')}"
- )
-
- # Update set data if needed
- check, error = update_hexproof_cache()
- if check:
- self.con.reload()
- message = msg_error(error) if error else msg_success(
- 'Update was applied!' if check else 'Using latest data!')
- self.console.update(f"Hexproof API Data ... {message}")
-
- # Check if API keys are valid
- if not self.env.API_GOOGLE:
- self.env.API_GOOGLE = get_api_key('proxyshop.google.drive')
- if not self.env.API_AMAZON:
- self.env.API_AMAZON = get_api_key('proxyshop.amazon.s3')
- keys_missing = [k for k, v in [
- ('Google Drive', self.env.API_GOOGLE),
- ('Amazon S3', self.env.API_AMAZON)
- ] if not v]
- message = msg_warn(
- f"Keys disabled: {', '.join(keys_missing)}"
- ) if keys_missing else msg_success('Keys retrieved!')
- self.console.update(f"Updater API Keys ... {message}")
-
- # Check Photoshop status
- result = self.app.refresh_app()
- if isinstance(result, OSError):
- # Photoshop test failed
- self.console.log_exception(result)
- self.console.update(f"Photoshop ... {msg_error('Cannot make connection with Photoshop!')}\n"
- f"Check [b]logs/error.txt[/b] for more details.")
- self.console.update(f"Fonts ... {msg_warn('Cannot test fonts without Photoshop.')}")
- return
- # Photoshop test passed
- self.console.update(f"Photoshop ... {msg_success('Connection established!')}")
-
- # Check for missing or outdated fonts
- missing, outdated = check_app_fonts(self.app, [PATH.FONTS])
-
- # Font test passed
- if not missing and not outdated:
- self.console.update(f"Fonts ... {msg_success('All essential fonts installed!')}")
- return
-
- # Missing fonts
- self.console.update(f"Fonts ... {msg_warn(f'Missing or outdated fonts:')}", end='')
- if missing:
- self.console.update(
- get_bullet_points([f"{f['name']} β {msg_warn('Not Installed')}" for f in missing.values()]), end="")
- if outdated:
- self.console.update(
- get_bullet_points([f"{f['name']} β {msg_info('New Version')}" for f in outdated.values()]), end="")
- self.console.update()
-
- def add_console(self) -> None:
- """Adds the console to the app window. Label gets frozen if loaded beforehand."""
- output = ConsoleOutput()
- self._app_layout.add_widget(self.console)
- self.console.ids.output_container.add_widget(output)
- self.console.output = output
-
- def on_stop(self) -> None:
- """Called when the app is closed."""
- if self.thread and isinstance(self.thread, Event):
- self.thread.set()
-
- """
- * App Updates
- """
-
- @staticmethod
- async def check_for_updates():
- """Open updater Popup."""
- Updater = UpdatePopup()
- Updater.open()
- await ak.run_in_thread(Updater.check_for_updates, daemon=True)
- ak.start(Updater.populate_updates())
-
- def check_app_version(self) -> bool:
- """Check if app is the latest version.
-
- Returns:
- Return True if up to date, otherwise False.
- """
- with suppress(requests.RequestException, json.JSONDecodeError):
- response = requests.get(
- "https://api.github.com/repos/MrTeferi/Proxyshop/releases/latest",
- timeout=(3, 3))
- latest = response.json().get("tag_name", self.env.VERSION)
- return bool(parse(self.env.VERSION.lstrip('v')) >= parse(latest.lstrip('v')))
- return True
diff --git a/src/gui/console.py b/src/gui/console.py
deleted file mode 100644
index 54136453..00000000
--- a/src/gui/console.py
+++ /dev/null
@@ -1,357 +0,0 @@
-"""
-CONSOLE MODULES
-"""
-# Standard Library Imports
-import os
-import time
-import traceback
-from functools import cached_property
-from threading import Thread, Event, Lock
-from typing import Optional, Any
-from datetime import datetime as dt
-
-# Third Party Imports
-from kivy.lang import Builder
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.label import Label
-from kivy.logger import Logger
-
-# Local Imports
-from src._config import AppConfig
-from src._state import AppEnvironment, PATH
-from src.gui._state import get_root_app
-from src.gui.utils import HoverButton
-
-
-class GUIConsole(BoxLayout):
- """GUI console handler layout."""
- Builder.load_file(os.path.join(PATH.SRC_DATA_KV, "console.kv"))
- max_lines = 250
- running = True
- waiting = False
- lock = Lock()
-
- def __init__(
- self,
- cfg: AppConfig,
- env: AppEnvironment,
- **kwargs
- ):
- # Establish global objects
- super().__init__(**kwargs)
- self.cfg = cfg
- self.env = env
-
- # Test mode uses larger console
- if not self.env.TEST_MODE:
- self.size_hint = (1, .58)
-
- @cached_property
- def main(self) -> Any:
- """ProxyshopGUIApp: Get the running application."""
- return get_root_app()
-
- """
- * Console GUI Objects
- """
-
- @cached_property
- def output(self) -> 'ConsoleOutput':
- """Label where console output is stored."""
- return self.ids.console_output
-
- @cached_property
- def continue_btn(self) -> HoverButton:
- """Button that continues to the next render operation."""
- return self.ids.continue_btn
-
- @cached_property
- def cancel_btn(self) -> HoverButton:
- """Button that cancels the current render operation."""
- return self.ids.cancel_btn
-
- @cached_property
- def update_btn(self) -> HoverButton:
- """Button that launches the updater popup."""
- return self.ids.update_btn
-
- """
- * Reusable Strings
- """
-
- @property
- def message_cancel(self) -> str:
- """Boilerplate message for canceling the render process."""
- return "Understood! Canceling render operation.\n"
-
- @property
- def message_waiting(self) -> str:
- """Boilerplate message for awaiting a user response."""
- return "Manual editing enabled! Click Continue to proceed ..."
-
- @property
- def message_skipping(self):
- """Boilerplate message for skipping a render process."""
- return "Skipping this card!"
-
- @property
- def time(self) -> str:
- """Current date and time in human-readable format."""
- return dt.now().strftime("%m/%d/%Y %H:%M")
-
- @property
- def current_output(self) -> str:
- """str: Text currently contained in the console output label. Remove lines from
- the top when the total linebreaks exceed 250."""
- output = self.output.text
- line_count = output.count('\n') + 1
- if line_count <= self.max_lines:
- return output
- return output.split('\n', line_count-self.max_lines)[-1]
-
- """
- * Utility Methods
- """
-
- def enable_buttons(self) -> None:
- """Enable both user signal buttons (Continue and Cancel)."""
- self.continue_btn.disabled = False
- self.cancel_btn.disabled = False
-
- def disable_buttons(self) -> None:
- """Enable both user signal buttons (Continue and Cancel)."""
- self.continue_btn.disabled = True
- self.cancel_btn.disabled = True
-
- def log_exception(self, error: Exception, log_file: str = "error.txt") -> None:
- """Log python exception.
-
- Args:
- error: Exception object to log.
- log_file: Text file to log the exception to.
- """
- # Is this a proper Exception object?
- if not hasattr(error, '__traceback__'):
- return
-
- # Print the error for dev testing
- Logger.exception(error)
-
- # Choose the path
- path = PATH.LOGS / log_file
-
- # Add to log file
- with open(path, "a", encoding="utf-8") as log:
- log.write("============================================================================\n")
- log.write(f"> {self.time}\n")
- log.write("============================================================================\n")
- traceback.print_exception(error, file=log) # noqa
-
- def clear(self) -> None:
- """Clear the console output."""
- self.output.text = ''
-
- """
- * Console Operations
- """
-
- def update(
- self,
- msg: str = "",
- exception: Optional[Exception] = None,
- end: str = "\n"
- ) -> None:
- """Add text to console output.
-
- Args:
- msg: Message to add to the console output, blank if not provided.
- exception: Exception object to log, if provided.
- end: String to append at the end of the message, adds a newline if not provided.
- """
- # Add message to the output label
- self.output.text = f"{self.current_output}{msg}{end}"
- self.ids.viewport.scroll_y = 0
- if exception:
- self.log_exception(exception)
-
- def log_error(
- self,
- thr: Event,
- card: str,
- template: Optional[str] = None,
- msg: str = 'Encountered a general error!',
- e: Optional[Exception] = None
- ) -> bool:
- """Log failed card and exception if provided, then prompt user to make a decision.
-
- Args:
- thr: Event object representing the status of the render thread.
- card: Card to log in /logs/failed.txt.
- template: Template to log in /logs/failed.txt.
- msg: Message to add to the console output.
- e: Exception to log in /logs/error.txt.
-
- Returns:
- True if user wishes to continue, otherwise False.
- """
- with open(PATH.LOGS_FAILED, "a", encoding="utf-8") as log:
- log.write(f"{card}{f' ({template})' if template else ''} [{self.time}]\n")
- return self.error(thr=thr, msg=msg, exception=e)
-
- def error(
- self,
- thr: Optional[Event] = None,
- msg: str = 'Encountered a general error!',
- exception: Optional[Exception] = None,
- end: str = '\nShould I continue?\n'
- ) -> bool:
- """Display error, wait for user to cancel or continue.
-
- Args:
- thr: Event object representing the status of the render thread.
- msg: Message to add to the console output.
- exception: Exception to log in /logs/error.txt.
- end: String to append to the end of the message.
-
- Returns:
- True if user wishes to continue, otherwise False.
- """
- # Stop awaiting any signals
- self.end_await()
-
- # Log exception if provided
- if exception:
- self.log_exception(exception)
-
- # Skip the prompts for Dev Mode
- if self.env.TEST_MODE:
- return False
-
- # Notify the user, then wait for a continue signal if needed
- if self.cfg.skip_failed:
- self.update(f"{msg}\n{self.message_skipping}")
- return True
-
- # Previous error already handled
- if thr and thr.is_set():
- return False
-
- # Wait for user response
- if self.await_choice(thr, msg, end):
- return True
-
- # Relay the cancellation
- self.update(self.message_cancel)
- return False
-
- """
- * User Prompt Signals
- """
-
- def await_choice(self, thr: Event, msg: Optional[str] = None, end: str = "\n") -> bool:
- """Prompt the user to either continue or cancel.
-
- Args:
- thr: Event object representing the status of the render thread.
- msg: Message to prompt the user with, uses boilerplate waiting message if not provided.
- end: String to append to the end of a message, adds a newline if not provided.
-
- Returns:
- True if user wishes to continue, otherwise False.
- """
- # Clear other await procedures, then begin awaiting a user signal
- self.end_await()
- self.update(msg=msg or self.message_waiting, end=end)
- self.enable_buttons()
- self.start_await()
-
- # Cancel the current thread or continue based on user signal
- if thr:
- self.cancel_thread(thr) if not self.running else self.start_await_cancel(thr)
- return self.running
-
- def signal(self, choice: bool) -> None:
- """Signal the user decision to any prompts awaiting a response.
-
- Args:
- choice: True if continuing, False if canceling.
- """
- # Ensure buttons can't be spammed
- self.disable_buttons()
-
- # Continue if True, otherwise False
- self.running = choice
-
- # End await
- self.end_await()
-
- """
- * Await Cancelling Procedures
- """
-
- def start_await_cancel(self, thr: Event) -> None:
- """Starts an await_cancel loop in a separate thread.
- @param thr: Event object representing the status of the render thread.
- """
- Thread(target=self.await_cancel, args=(thr,), daemon=True).start()
-
- def await_cancel(self, thr: Event):
- """Await a signal from the user to cancel the operation.
-
- Args:
- thr: Event object representing the status of the render thread.
- """
- # Await a signal from the user to cancel the operation
- self.cancel_btn.disabled = False
- self.start_await()
-
- # Cancel the current thread if running flag set to False
- if not self.running:
- self.cancel_thread(thr)
-
- """
- * Await Loop Handlers
- """
-
- def end_await(self) -> None:
- """Clears the console of any procedures awaiting a response."""
- # Set the waiting flag
- self.waiting = False
-
- # Ensure await is complete before returning
- with self.lock:
- return
-
- def start_await(self) -> None:
- """Starts an await loop that finishes when waiting is flagged as False."""
- # Set initial running and waiting flags
- self.waiting = True
- self.running = True
-
- # Await can only start if others have finished
- with self.lock:
- while self.waiting:
- time.sleep(.1)
- self.disable_buttons()
-
- """
- * Thread Management
- """
-
- def cancel_thread(self, thr: Event) -> None:
- """Initiate cancellation of a given thread operation.
-
- Args:
- thr: Thread event to signal the cancellation.
- """
- # Kill the thread and notify
- thr.set()
- self.update(self.message_cancel)
-
-
-class ConsoleOutput(Label):
- """Label displaying console output."""
-
-
-class ConsoleControls(BoxLayout):
- """Layout containing console control."""
diff --git a/src/gui/popup/settings.py b/src/gui/popup/settings.py
deleted file mode 100644
index 9bb9b021..00000000
--- a/src/gui/popup/settings.py
+++ /dev/null
@@ -1,259 +0,0 @@
-"""
-KIVY SETTINGS POPUPS
-"""
-# Standard Library Imports
-from functools import cached_property
-from os import PathLike
-from typing import Union
-
-# Third Party Imports
-import kivy.utils as utils
-from kivy.properties import ConfigParser
-from kivy.uix.colorpicker import ColorPicker
-from kivy.core.window import Window
-from kivy.metrics import dp
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.button import Button
-from kivy.uix.modalview import ModalView
-from kivy.uix.popup import Popup
-from kivy.uix.settings import (
- Settings,
- SettingOptions,
- SettingSpacer,
- SettingNumeric,
- SettingString,
- SettingColor
-)
-from kivy.uix.textinput import TextInput
-from kivy.uix.togglebutton import ToggleButton
-from kivy.uix.widget import Widget
-
-# Local Imports
-from src._loader import TemplateDetails, ConfigManager
-from src.gui._state import GlobalAccess
-
-"""
-AESTHETIC CLASSES
-"""
-
-
-class FormattedSettingColor(SettingColor):
- """Create custom SettingColor class to allow Markup in title."""
- def _create_popup(self, instance):
- # create popup layout
- content = BoxLayout(orientation='vertical', spacing='5dp')
- popup_width = min(0.95 * Window.width, dp(500))
- self.popup = popup = Popup(
- title=self.title, content=content, size_hint=(None, 0.9),
- width=popup_width)
- popup.children[0].children[-1].markup = True
-
- self.colorpicker = colorpicker = ColorPicker(color=utils.get_color_from_hex(self.value))
- colorpicker.bind(on_color=self._validate)
-
- self.colorpicker = colorpicker
- content.add_widget(colorpicker)
- content.add_widget(SettingSpacer())
-
- # 2 buttons are created for accept or cancel the current value
- btnlayout = BoxLayout(size_hint_y=None, height='50dp', spacing='5dp')
- btn = Button(text='Ok')
- btn.bind(on_release=self._validate)
- btnlayout.add_widget(btn)
- btn = Button(text='Cancel')
- btn.bind(on_release=self._dismiss)
- btnlayout.add_widget(btn)
- content.add_widget(btnlayout)
-
- # all done, open the popup !
- popup.open()
-
-
-class FormattedSettingString(SettingString):
- """Create custom SettingString class to allow Markup in title."""
- def _create_popup(self, instance):
- # create popup layout
- content = BoxLayout(orientation='vertical', spacing='5dp')
- popup_width = min(0.95 * Window.width, dp(500))
- self.popup = popup = Popup(
- title=self.title, content=content, size_hint=(None, None),
- size=(popup_width, '250dp'))
- popup.children[0].children[-1].markup = True
-
- # create the textinput used for numeric input
- self.textinput = textinput = TextInput(
- text=self.value, font_size='24sp', multiline=False,
- size_hint_y=None, height='42sp')
- textinput.bind(on_text_validate=self._validate)
- self.textinput = textinput
-
- # construct the content, widget are used as a spacer
- content.add_widget(Widget())
- content.add_widget(textinput)
- content.add_widget(Widget())
- content.add_widget(SettingSpacer())
-
- # 2 buttons are created for accept or cancel the current value
- btnlayout = BoxLayout(size_hint_y=None, height='50dp', spacing='5dp')
- btn = Button(text='Ok')
- btn.bind(on_release=self._validate)
- btnlayout.add_widget(btn)
- btn = Button(text='Cancel')
- btn.bind(on_release=self._dismiss)
- btnlayout.add_widget(btn)
- content.add_widget(btnlayout)
-
- # all done, open the popup !
- popup.open()
-
-
-class FormattedSettingNumeric(SettingNumeric):
- """Create custom SettingNumeric class to allow Markup in title."""
- def _create_popup(self, instance):
- # create popup layout
- content = BoxLayout(orientation='vertical', spacing='5dp')
- popup_width = min(0.95 * Window.width, dp(500))
- self.popup = popup = Popup(
- title=self.title, content=content, size_hint=(None, None),
- size=(popup_width, '250dp'))
- popup.children[0].children[-1].markup = True
-
- # create the textinput used for numeric input
- self.textinput = textinput = TextInput(
- text=self.value, font_size='24sp', multiline=False,
- size_hint_y=None, height='42sp')
- textinput.bind(on_text_validate=self._validate)
- self.textinput = textinput
-
- # construct the content, widget are used as a spacer
- content.add_widget(Widget())
- content.add_widget(textinput)
- content.add_widget(Widget())
- content.add_widget(SettingSpacer())
-
- # 2 buttons are created for accept or cancel the current value
- btnlayout = BoxLayout(size_hint_y=None, height='50dp', spacing='5dp')
- btn = Button(text='Ok')
- btn.bind(on_release=self._validate)
- btnlayout.add_widget(btn)
- btn = Button(text='Cancel')
- btn.bind(on_release=self._dismiss)
- btnlayout.add_widget(btn)
- content.add_widget(btnlayout)
-
- # all done, open the popup !
- popup.open()
-
-
-class FormattedSettingOptions(SettingOptions):
- """Create custom SettingOptions class to allow Markup in title."""
- def _create_popup(self, instance):
- # create the popup
- content = BoxLayout(orientation='vertical', spacing='5dp')
- popup_width = min(0.95 * Window.width, dp(500))
- self.popup = popup = Popup(
- content=content, title=self.title, size_hint=(None, None),
- size=(popup_width, '400dp'))
- popup.height = len(self.options) * dp(55) + dp(150)
- popup.children[0].children[-1].markup = True
-
- # add all the options
- content.add_widget(Widget(size_hint_y=None, height=1))
- uid = str(self.uid)
- for option in self.options:
- state = 'down' if option == self.value else 'normal'
- btn = ToggleButton(text=option, state=state, group=uid)
- btn.bind(on_release=self._set_option)
- content.add_widget(btn)
-
- # finally, add a cancel button to return on the previous panel
- content.add_widget(SettingSpacer())
- btn = Button(text='Cancel', size_hint_y=None, height=dp(50))
- btn.bind(on_release=popup.dismiss)
- content.add_widget(btn)
-
- # and open the popup !
- popup.open()
-
-
-"""
-SETTINGS POPUP
-"""
-
-
-class SettingsPopup(ModalView, GlobalAccess):
- """Popup menu for changing app or template settings."""
-
- """
- * ConfigParser objects
- """
-
- @staticmethod
- def get_config(ini_file: Union[str, PathLike]) -> ConfigParser:
- config = ConfigParser(allow_no_value=True)
- config.optionxform = str
- config.read(str(ini_file))
- return config
-
- """
- * Settings Panel instance
- """
-
- @cached_property
- def cfg_panel(self) -> Settings:
- """Settings panel to load JSON validated config data into."""
- s = Settings()
- s.bind(on_close=self.dismiss)
- s.register_type('options', FormattedSettingOptions)
- s.register_type('string', FormattedSettingString)
- s.register_type('numeric', FormattedSettingNumeric)
- s.register_type('color', FormattedSettingColor)
- return s
-
- """
- * Generate Settings Page
- """
-
- def __init__(self, template: TemplateDetails = None, **kwargs):
- super().__init__(**kwargs)
-
- # Load and validate ConfigManager
- self.template = template
- self.manager = template['config'] if template else ConfigManager()
- self.manager.validate_configs()
- if template:
- self.manager.validate_template_configs()
-
- # Is this global or template specific?
- self.load_template_config() if self.manager.has_template_ini else self.load_global_config()
- self.add_widget(self.cfg_panel)
-
- def load_global_config(self) -> None:
- """Load base and app settings into global config panel."""
-
- # Add main settings
- self.cfg_panel.add_json_panel(
- title='Main Settings',
- config=self.get_config(self.manager.base_path_ini),
- data=self.manager.base_json)
-
- # Add system settings
- self.cfg_panel.add_json_panel(
- title='System Settings',
- config=self.get_config(self.manager.app_path_ini),
- data=self.manager.app_json)
-
- def load_template_config(self) -> None:
- """Load a template-specific settings into config panel."""
-
- # Add custom template settings if provided
- config = self.get_config(self.manager.template_path_ini)
- if self.manager.template_path_schema:
- self.cfg_panel.add_json_panel(
- title=f"{self.template['name']} Template",
- config=config, data=self.manager.template_json)
-
- # Add main settings
- self.cfg_panel.add_json_panel(
- title='Main Settings',
- config=config, data=self.manager.base_json)
diff --git a/src/gui/popup/updater.py b/src/gui/popup/updater.py
deleted file mode 100644
index 6cedfbea..00000000
--- a/src/gui/popup/updater.py
+++ /dev/null
@@ -1,135 +0,0 @@
-"""
-* GUI Popup: Updater
-"""
-# Standard Library Imports
-import os
-
-# Third Party Imports
-import asynckivy as ak
-from kivy.lang import Builder
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.popup import Popup
-from kivy.uix.progressbar import ProgressBar
-from kivy.uix.label import Label
-
-# Local Imports
-from src._state import PATH
-from src._loader import AppTemplate, check_for_updates
-from src.console import msg_success, msg_error, msg_italics
-from src.gui._state import GlobalAccess, GUI
-
-"""
-* GUI Classes
-"""
-
-
-class UpdatePopup(Popup, GlobalAccess):
- """Popup modal for updating templates."""
- Builder.load_file(os.path.join(PATH.SRC_DATA_KV, "updater.kv"))
- updates: list[AppTemplate] = []
- update_downloaded = False
- loading = True
- categories = {}
- entries = {}
-
- """
- * Update Utils
- """
-
- def on_dismiss(self):
- """When this popup is dismissed, reload rows if updates were downloaded."""
- if self.update_downloaded:
- for _, layouts in GUI.template_list.items():
- for TL in layouts:
- TL.reload_template_rows()
-
- def check_for_updates(self):
- """Runs the check_for_updates core function and fills the update dictionary."""
- self.updates: list[AppTemplate] = check_for_updates(self.main.templates)
-
- async def populate_updates(self):
- """Load the list of updates available."""
-
- # Track current background color
- bg_color = "#181818"
-
- # Remove loading screen
- if self.loading:
- self.ids.container.remove_widget(self.ids.loading)
- self.ids.container.padding = [0, 0, 0, 0]
- self.loading = False
-
- # Loop through templates
- for i, t in enumerate(self.updates):
-
- # Alternate table item color
- bg_color = "#101010" if bg_color == "#181818" else "#181818"
- update_entry = UpdateEntry(self, t, bg_color)
- self.ids.container.add_widget(update_entry)
- self.entries[str(t.path_psd)] = update_entry
-
- # Remove loading text
- self.ids.loading_text.text = msg_italics(" No updates found!") if (
- len(self.updates) == 0
- ) else msg_italics(" Updates Available")
-
-
-class UpdateEntry(BoxLayout, GlobalAccess):
- def __init__(self, parent: UpdatePopup, template: AppTemplate, bg_color: str, **kwargs):
- self.bg_color = bg_color
- self.name = template.name
- self.status = msg_success(template.update_version)
- self.template: AppTemplate = template
- self.root = parent
- super().__init__(**kwargs)
-
- async def download_update(self, download: BoxLayout) -> None:
- """Initiates a template update download.
-
- Args:
- download: Layout object containing the download progress bar or status.
- """
- self.progress = UpdateProgress(self.template.update_size)
- download.clear_widgets()
- download.add_widget(self.progress)
- result = await ak.run_in_thread(
- lambda: self.template.update_template(
- self.progress.update_progress
- ), daemon=True)
- await ak.sleep(.5)
-
- # Success
- if result:
- self.root.update_downloaded = True
- return await self.mark_updated()
-
- # Failed
- download.clear_widgets()
- download.add_widget(Label(text=msg_error("FAILED"), markup=True))
-
- async def mark_updated(self):
- """Update template version, remove pending update, and remove the template row."""
-
- # Update version tracker and reset update data
- self.template.mark_updated()
-
- # Remove this widget
- entry = self.root.entries[str(self.template.path_psd)]
- self.root.ids.container.remove_widget(entry)
- del entry
-
-
-class UpdateProgress(ProgressBar, GlobalAccess):
- def __init__(self, size: int, **kwargs):
- super().__init__(**kwargs)
- self.download_size = int(size)
- self.current = 0
-
- def update_progress(self, tran: int, total: int) -> None:
- """Update the download progress bar via callback.
-
- Args:
- tran: Bytes transferred so far.
- total: Total bytes to transfer.
- """
- self.value = int((tran / total) * 100)
diff --git a/src/gui/qml/App.qml b/src/gui/qml/App.qml
new file mode 100644
index 00000000..cbae7241
--- /dev/null
+++ b/src/gui/qml/App.qml
@@ -0,0 +1,686 @@
+pragma ComponentBehavior: Bound
+import QtCore
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+import qml.components
+import qml.dialogs
+import qml.views
+
+ApplicationWindow {
+ id: appWindow
+
+ property QtObject currentRenderingModel: viewTabBar.currentIndex === 1 ? batchRenderModel : templateListModel
+
+ title: "Proxyshop"
+ width: 800
+ height: 640
+ visible: true
+ color: systemPalette.window
+
+ SystemPalette {
+ id: systemPalette
+ }
+
+ FontLoader {
+ id: emojiFontLoader
+ source: "file:///C:/Windows/Fonts/seguiemj.ttf"
+ //source: "file:///C:/Windows/Fonts/seguisym.ttf"
+ }
+
+ FontLoader {
+ id: monospaceFontLoader
+ source: "file:///C:/Windows/Fonts/CascadiaMono.ttf"
+ }
+
+ Settings {
+ id: settings
+
+ category: "App"
+ location: filePathModel.get_preferences_path("App.ini")
+
+ // UI sizing preferences
+ property alias windowWidth: appWindow.width
+ property alias windowHeight: appWindow.height
+ property alias windowX: appWindow.x
+ property alias windowY: appWindow.y
+ property int currentRenderModeTab
+ property var rootSplitState
+ property var templateListSplitState
+
+ // Model preferences
+ property string selectedTemplateName
+ property string batchModeTemplateSelections
+ }
+
+ Component.onCompleted: {
+ if (settings.currentRenderModeTab)
+ viewTabBar.setCurrentIndex(settings.currentRenderModeTab);
+
+ rootSplit.restoreState(settings.rootSplitState);
+ templateListSplit.restoreState(settings.templateListSplitState);
+
+ if (settings.selectedTemplateName)
+ templateListModel.select_template(settings.selectedTemplateName);
+ if (settings.batchModeTemplateSelections)
+ batchRenderModel.restore_template_selections(settings.batchModeTemplateSelections);
+ }
+ Component.onDestruction: {
+ settings.currentRenderModeTab = viewTabBar.currentIndex;
+
+ settings.rootSplitState = rootSplit.saveState();
+ settings.templateListSplitState = templateListSplit.saveState();
+
+ settings.selectedTemplateName = templateListModel.selected_template_name();
+ settings.batchModeTemplateSelections = batchRenderModel.get_template_selections_json();
+
+ settingsTreeModel.save_configs();
+ templateUpdaterModel.save_versions();
+ }
+
+ DelegateModel {
+ id: templateListDelegateModel
+ model: templateListModel
+ }
+
+ function updateRenderQueueSize() {
+ const rCount = renderOperationsModel.rowCount();
+ if (rCount != renderOperationsConnections.renderQueueSize)
+ renderOperationsConnections.renderQueueSize = rCount;
+ }
+
+ Connections {
+ id: renderOperationsConnections
+
+ property int renderQueueSize: 0
+
+ target: renderOperationsModel
+
+ function onRowsInserted() {
+ appWindow.updateRenderQueueSize();
+ }
+ function onRowsRemoved() {
+ appWindow.updateRenderQueueSize();
+ }
+ }
+
+ DropArea {
+ anchors.fill: parent
+ onDropped: drop => {
+ if (drop.hasUrls)
+ appWindow.currentRenderingModel.render_targets(drop.urls);
+ }
+ }
+
+ menuBar: MenuBar {
+ id: menuBar
+
+ background.implicitHeight: 0
+ background.implicitWidth: 0
+
+ delegate: MenuBarItem {
+ id: menuBarItem
+
+ leftPadding: 8
+ rightPadding: 8
+ topPadding: 7
+ bottomPadding: 7
+ background: Rectangle {
+ color: menuBarItem.down || menuBarItem.highlighted ? systemPalette.text : "transparent"
+ opacity: menuBarItem.down ? 0.1 : 0.05
+ }
+ }
+
+ CustomMenu {
+ systemPalette: systemPalette
+ title: "File"
+
+ Action {
+ text: "Render..."
+ onTriggered: appWindow.currentRenderingModel.render_selections()
+ }
+ MenuSeparator {}
+ Action {
+ text: "Open app directory"
+ onTriggered: Qt.openUrlExternally(filePathModel.app_root)
+ }
+ Action {
+ text: "Open output directory"
+ onTriggered: Qt.openUrlExternally(filePathModel.out_directory)
+ }
+ Action {
+ text: "Open templates directory"
+ onTriggered: Qt.openUrlExternally(filePathModel.templates_directory)
+ }
+ Action {
+ text: "Open plugins directory"
+ onTriggered: Qt.openUrlExternally(filePathModel.plugins_directory)
+ }
+ }
+ CustomMenu {
+ systemPalette: systemPalette
+ title: "Tools"
+
+ Action {
+ text: "Transform images"
+ onTriggered: {
+ if (imageTransformLoader.active) {
+ imageTransformLoader.item?.show();
+ } else {
+ imageTransformLoader.active = true;
+ }
+ }
+ }
+ Action {
+ text: "Preview system palette"
+ onTriggered: {
+ if (systemPaletteViewerLoader.active) {
+ systemPaletteViewerLoader.item?.show();
+ } else {
+ systemPaletteViewerLoader.active = true;
+ }
+ }
+ }
+ Action {
+ text: "Preview fonts"
+ onTriggered: {
+ if (fontViewerLoader.active) {
+ fontViewerLoader.item?.show();
+ } else {
+ fontViewerLoader.active = true;
+ }
+ }
+ }
+ }
+ CustomMenu {
+ systemPalette: systemPalette
+ title: "Tests"
+ implicitWidth: 270
+
+ Action {
+ text: "Test all"
+ onTriggered: testRendersModel.test_all("", false)
+ }
+ Action {
+ text: "Quick test all"
+ onTriggered: testRendersModel.test_all("", true)
+ }
+
+ CustomMenu {
+ id: testAllLayoutsSubMenu
+
+ systemPalette: systemPalette
+ title: "Test all with layout"
+
+ Instantiator {
+ model: testRendersModel.layout_categories
+
+ delegate: CustomMenuItem {
+ required property int index
+ required property string modelData
+
+ systemPalette: systemPalette
+ text: modelData
+ onTriggered: testRendersModel.test_all(modelData, false)
+ }
+
+ onObjectAdded: (index, object) => testAllLayoutsSubMenu.insertItem(index, object)
+ onObjectRemoved: (index, object) => testAllLayoutsSubMenu.removeItem(object)
+ }
+ }
+ CustomMenu {
+ id: quickTestAllLayoutsSubMenu
+
+ systemPalette: systemPalette
+ title: "Quick test all with layout"
+
+ Instantiator {
+ model: testRendersModel.layout_categories
+
+ delegate: CustomMenuItem {
+ required property int index
+ required property string modelData
+
+ systemPalette: systemPalette
+ text: modelData
+ onTriggered: testRendersModel.test_all(modelData, true)
+ }
+
+ onObjectAdded: (index, object) => quickTestAllLayoutsSubMenu.insertItem(index, object)
+ onObjectRemoved: (index, object) => quickTestAllLayoutsSubMenu.removeItem(object)
+ }
+ }
+ CustomMenu {
+ id: testAllWithSpecificTestCaseSubMenu
+
+ systemPalette: systemPalette
+ title: "Test all with test case"
+
+ Instantiator {
+ model: testRendersModel.layout_categories
+
+ delegate: CustomMenu {
+ id: testAllWithSpecificLayoutTestCaseSubMenu
+
+ required property int index
+ required property string modelData
+
+ systemPalette: systemPalette
+ title: modelData
+
+ Instantiator {
+ model: testRendersModel.get_test_cases_for_layout(testAllWithSpecificLayoutTestCaseSubMenu.modelData)
+
+ delegate: CustomMenuItem {
+ required property int index
+ required property string modelData
+
+ systemPalette: systemPalette
+ text: modelData
+ onTriggered: testRendersModel.test_all(testAllWithSpecificLayoutTestCaseSubMenu.modelData, false, modelData)
+ }
+
+ onObjectAdded: (index, object) => testAllWithSpecificLayoutTestCaseSubMenu.insertItem(index, object)
+ onObjectRemoved: (index, object) => testAllWithSpecificLayoutTestCaseSubMenu.removeItem(object)
+ }
+ }
+
+ onObjectAdded: (index, object) => testAllWithSpecificTestCaseSubMenu.insertMenu(index, object)
+ onObjectRemoved: (index, object) => testAllWithSpecificTestCaseSubMenu.removeMenu(object)
+ }
+ }
+
+ MenuSeparator {}
+
+ Action {
+ text: "Test selected template(s)"
+ onTriggered: appWindow.currentRenderingModel.test_render("", false)
+ }
+ Action {
+ text: "Quick test selected template(s)"
+ onTriggered: appWindow.currentRenderingModel.test_render("", true)
+ }
+
+ CustomMenu {
+ id: selectedTemplateLayoutsSubMenu
+
+ systemPalette: systemPalette
+ title: "Test selected template(s) with layout"
+
+ Instantiator {
+ model: testRendersModel.layout_categories
+
+ delegate: CustomMenuItem {
+ required property int index
+ required property string modelData
+
+ systemPalette: systemPalette
+ text: modelData
+ onTriggered: appWindow.currentRenderingModel.test_render(modelData, false)
+ }
+
+ onObjectAdded: (index, object) => selectedTemplateLayoutsSubMenu.insertItem(index, object)
+ onObjectRemoved: (index, object) => selectedTemplateLayoutsSubMenu.removeItem(object)
+ }
+ }
+ CustomMenu {
+ id: selectedTemplateQuickLayoutsSubMenu
+
+ systemPalette: systemPalette
+ title: "Quick test selected template(s) with layout"
+
+ Instantiator {
+ model: testRendersModel.layout_categories
+
+ delegate: CustomMenuItem {
+ required property int index
+ required property string modelData
+
+ systemPalette: systemPalette
+ text: modelData
+ onTriggered: appWindow.currentRenderingModel.test_render(modelData, true)
+ }
+
+ onObjectAdded: (index, object) => selectedTemplateQuickLayoutsSubMenu.insertItem(index, object)
+ onObjectRemoved: (index, object) => selectedTemplateQuickLayoutsSubMenu.removeItem(object)
+ }
+ }
+ CustomMenu {
+ id: selectedTemplateSpecificTestCaseSubMenu
+
+ systemPalette: systemPalette
+ title: "Test selected template(s) with test case"
+
+ Instantiator {
+ model: testRendersModel.layout_categories
+
+ delegate: CustomMenu {
+ id: selectedTemplateSpecificLayoutTestCaseSubMenu
+
+ required property int index
+ required property string modelData
+
+ systemPalette: systemPalette
+ title: modelData
+
+ Instantiator {
+ model: testRendersModel.get_test_cases_for_layout(selectedTemplateSpecificLayoutTestCaseSubMenu.modelData)
+
+ delegate: CustomMenuItem {
+ required property int index
+ required property string modelData
+
+ systemPalette: systemPalette
+ text: modelData
+ onTriggered: appWindow.currentRenderingModel.test_render(selectedTemplateSpecificLayoutTestCaseSubMenu.modelData, false, modelData)
+ }
+
+ onObjectAdded: (index, object) => selectedTemplateSpecificLayoutTestCaseSubMenu.insertItem(index, object)
+ onObjectRemoved: (index, object) => selectedTemplateSpecificLayoutTestCaseSubMenu.removeItem(object)
+ }
+ }
+
+ onObjectAdded: (index, object) => selectedTemplateSpecificTestCaseSubMenu.insertMenu(index, object)
+ onObjectRemoved: (index, object) => selectedTemplateSpecificTestCaseSubMenu.removeMenu(object)
+ }
+ }
+ }
+
+ CustomMenu {
+ systemPalette: systemPalette
+ title: "Help"
+
+ Action {
+ text: `π Project GitHub`
+ onTriggered: Qt.openUrlExternally(github_url)
+ }
+ Action {
+ text: `β Frequently asked questions`
+ onTriggered: Qt.openUrlExternally(`${github_url}#-faq`)
+ }
+ Action {
+ text: `πͺ² Report an issue`
+ onTriggered: Qt.openUrlExternally(`${github_url}/issues`)
+ }
+ }
+
+ RowLayout {
+ parent: menuBar
+ anchors.right: parent.right
+
+ CustomButton {
+ systemPalette: systemPalette
+ text: `β¬οΈ Updater`
+ onClicked: {
+ if (templateUpdaterLoader.active) {
+ templateUpdaterLoader.item?.show();
+ } else {
+ templateUpdaterLoader.active = true;
+ }
+ }
+ }
+ CustomButton {
+ id: settingsButton
+
+ systemPalette: systemPalette
+ text: `βοΈ Settings`
+ onClicked: openSettings()
+
+ function openSettings(templateName = "", className = "", pluginName = ""): void {
+ settingsTreeModel.select_settings_section(templateName, className, pluginName);
+ if (settingsWindowLoader.active) {
+ settingsWindowLoader.item?.show();
+ } else {
+ settingsWindowLoader.active = true;
+ }
+ }
+ }
+ }
+ }
+
+ CustomMessageDialog {
+ id: renderMessageDialog
+
+ dialogContentModel: renderMessageDialogContentModel
+ }
+
+ Connections {
+ id: renderMessageDialogConnections
+
+ target: renderMessageDialogContentModel
+ function onDialogRequested(): void {
+ if (renderMessageDialog.visible) {
+ renderMessageDialog.accept();
+ }
+ renderMessageDialog.open();
+ }
+ }
+
+ CustomFileDialog {
+ dialogModel: fileDialogModel
+ settings: settings
+ }
+
+ Loader {
+ id: settingsWindowLoader
+
+ active: false
+ asynchronous: true
+ onLoaded: item.show()
+
+ Component.onCompleted: {
+ setSource("views/SettingsWindow.qml", {
+ systemPalette: systemPalette,
+ pathModel: filePathModel,
+ emojiFontName: emojiFontLoader.name,
+ settingsTreeModel: settingsTreeModel,
+ settingsModel: settingsModel
+ });
+ }
+ }
+
+ Loader {
+ id: templateUpdaterLoader
+
+ active: false
+ asynchronous: true
+ onLoaded: item.show()
+
+ Component.onCompleted: {
+ setSource("views/TemplateUpdater.qml", {
+ systemPalette: systemPalette,
+ updaterModel: templateUpdaterModel,
+ pathModel: filePathModel
+ });
+ }
+ }
+
+ Loader {
+ id: renderQueueLoader
+
+ active: false
+ asynchronous: true
+ onLoaded: item.show()
+
+ Component.onCompleted: {
+ setSource("views/RenderQueue.qml", {
+ systemPalette: systemPalette,
+ emojiFontName: emojiFontLoader.name,
+ queueModel: renderOperationsModel,
+ pathModel: filePathModel
+ });
+ }
+ }
+
+ Loader {
+ id: imageTransformLoader
+
+ active: false
+ asynchronous: true
+ onLoaded: item.show()
+
+ Component.onCompleted: {
+ setSource("tools/ImageTransformWindow.qml", {
+ systemPalette: systemPalette,
+ transformModel: imageTransformModel
+ });
+ }
+ }
+
+ Loader {
+ id: systemPaletteViewerLoader
+
+ active: false
+ asynchronous: true
+ onLoaded: item.show()
+
+ Component.onCompleted: {
+ setSource("tools/SystemPaletteViewer.qml");
+ }
+ }
+
+ Loader {
+ id: fontViewerLoader
+
+ active: false
+ asynchronous: true
+ onLoaded: item.show()
+
+ Component.onCompleted: {
+ setSource("tools/FontViewer.qml");
+ }
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+
+ TabBar {
+ id: viewTabBar
+
+ Layout.fillWidth: true
+
+ width: parent.width
+ implicitHeight: 30
+ spacing: 0
+ palette.window: systemPalette.mid
+ palette.dark: systemPalette.mid
+ palette.mid: systemPalette.midlight
+
+ CustomTabButton {
+ systemPalette: systemPalette
+ text: "Templates"
+ implicitHeight: viewTabBar.implicitHeight
+ background.implicitHeight: viewTabBar.implicitHeight
+ }
+ CustomTabButton {
+ systemPalette: systemPalette
+ text: "Batch mode"
+ implicitHeight: viewTabBar.implicitHeight
+ background.implicitHeight: viewTabBar.implicitHeight
+ }
+
+ RowLayout {
+ parent: viewTabBar
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 0
+
+ CustomButton {
+ systemPalette: systemPalette
+ implicitHeight: viewTabBar.implicitHeight
+ text: `β© Queue (${renderOperationsConnections.renderQueueSize})`
+ onClicked: {
+ if (renderQueueLoader.active) {
+ renderQueueLoader.item?.show();
+ } else {
+ renderQueueLoader.active = true;
+ }
+ }
+ }
+ CustomButton {
+ systemPalette: systemPalette
+ implicitHeight: viewTabBar.implicitHeight
+ text: renderOperationsModel.is_rendering ? `π« Cancel` : `β―οΈ Resume`
+ enabled: renderOperationsConnections.renderQueueSize || renderOperationsModel.is_rendering
+ onClicked: renderOperationsModel.is_rendering ? renderOperationsModel.cancel() : renderOperationsModel.resume()
+ }
+ CustomButton {
+ id: renderButton
+
+ systemPalette: systemPalette
+ implicitHeight: viewTabBar.implicitHeight
+ text: `βΆοΈ Render`
+ onClicked: appWindow.currentRenderingModel.render_selections()
+ }
+ }
+ }
+
+ SplitView {
+ id: rootSplit
+
+ Layout.fillHeight: true
+ Layout.fillWidth: true
+
+ orientation: Qt.Vertical
+
+ StackLayout {
+ SplitView.fillHeight: true
+ SplitView.fillWidth: true
+
+ currentIndex: viewTabBar.currentIndex
+
+ Rectangle {
+ color: systemPalette.window
+ Layout.fillHeight: true
+ Layout.fillWidth: true
+
+ SplitView {
+ id: templateListSplit
+
+ anchors.fill: parent
+ orientation: Qt.Horizontal
+
+ TemplateList {
+ SplitView.fillWidth: true
+ SplitView.fillHeight: true
+
+ systemPalette: systemPalette
+ templateListMdl: templateListModel
+ templateListDelegateMdl: templateListDelegateModel
+ openSettings: settingsButton.openSettings
+ }
+ TemplateDetails {
+ SplitView.minimumWidth: 100
+ SplitView.preferredWidth: 200
+
+ systemPalette: systemPalette
+ templateListMdl: templateListModel
+ templateListDelegateMdl: templateListDelegateModel
+ pathModel: filePathModel
+ }
+ }
+ }
+ BatchRenderView {
+ Layout.fillHeight: true
+ Layout.fillWidth: true
+
+ systemPalette: systemPalette
+ batchModel: batchRenderModel
+ pathModel: filePathModel
+ }
+ }
+ Console {
+ SplitView.preferredHeight: 200
+ SplitView.fillWidth: true
+
+ systemPalette: systemPalette
+ logModel: consoleModel
+ pathModel: filePathModel
+ emojiFontFamily: emojiFontLoader.name
+ monospaceFontFamily: monospaceFontLoader.name
+ }
+ }
+ }
+}
diff --git a/src/gui/qml/components/Badge.qml b/src/gui/qml/components/Badge.qml
new file mode 100644
index 00000000..a9728c2f
--- /dev/null
+++ b/src/gui/qml/components/Badge.qml
@@ -0,0 +1,36 @@
+pragma ComponentBehavior: Bound
+import QtQuick
+import QtQuick.Layouts
+
+Rectangle {
+ id: badge
+
+ required property string text
+ required property color textColor
+ required property color bgColor
+ property double fontSize: 8
+ property bool hovered: hoverHandler.hovered
+
+ implicitWidth: layout.implicitWidth + 15
+ implicitHeight: layout.implicitHeight + 5
+ radius: 10
+ color: bgColor
+
+ RowLayout {
+ id: layout
+
+ anchors.centerIn: parent
+
+ Text {
+ Layout.fillWidth: true
+
+ text: badge.text
+ font.pointSize: badge.fontSize
+ color: badge.textColor
+ }
+ }
+
+ HoverHandler {
+ id: hoverHandler
+ }
+}
diff --git a/src/gui/qml/components/CustomButton.qml b/src/gui/qml/components/CustomButton.qml
new file mode 100644
index 00000000..80da6507
--- /dev/null
+++ b/src/gui/qml/components/CustomButton.qml
@@ -0,0 +1,23 @@
+pragma ComponentBehavior: Bound
+import QtQuick
+import QtQuick.Controls
+
+Button {
+ id: control
+
+ required property SystemPalette systemPalette
+ property color bgColor: "transparent"
+
+ background: Rectangle {
+ implicitHeight: 30
+ implicitWidth: control.contentItem.implicitWidth
+ color: control.bgColor
+
+ Rectangle {
+ anchors.fill: parent
+ visible: control.enabled && (control.down || control.hovered)
+ opacity: control.down ? 0.1 : 0.05
+ color: control.systemPalette.text
+ }
+ }
+}
diff --git a/src/gui/qml/components/CustomCheckBox.qml b/src/gui/qml/components/CustomCheckBox.qml
new file mode 100644
index 00000000..343e3bdf
--- /dev/null
+++ b/src/gui/qml/components/CustomCheckBox.qml
@@ -0,0 +1,32 @@
+pragma ComponentBehavior: Bound
+import QtQuick
+import QtQuick.Controls
+
+CheckBox {
+ id: control
+
+ required property SystemPalette systemPalette
+
+ padding: 0
+ spacing: 0
+ implicitWidth: 25
+ implicitHeight: 25
+ indicator: Rectangle {
+ anchors.fill: parent
+ color: control.systemPalette.button
+
+ Rectangle {
+ anchors.fill: parent
+ visible: control.enabled && (control.down || control.hovered)
+ opacity: control.down ? 0.1 : 0.05
+ color: control.systemPalette.text
+ }
+ Text {
+ anchors.centerIn: parent
+ text: "β"
+ font.pointSize: 11
+ color: control.systemPalette.text
+ visible: control.checked
+ }
+ }
+}
diff --git a/src/gui/qml/components/CustomComboBox.qml b/src/gui/qml/components/CustomComboBox.qml
new file mode 100644
index 00000000..78df602f
--- /dev/null
+++ b/src/gui/qml/components/CustomComboBox.qml
@@ -0,0 +1,41 @@
+pragma ComponentBehavior: Bound
+import QtQuick
+import QtQuick.Controls
+
+ComboBox {
+ id: control
+
+ required property SystemPalette systemPalette
+ property var itemTextColor
+ property color _selectedTextColor: itemTextColor ? itemTextColor(control.currentIndex) : systemPalette.buttonText
+ property var tooltipModel: undefined
+
+ background.implicitHeight: 30
+ palette.buttonText: _selectedTextColor
+ delegate: CustomMenuItem {
+ id: controlDelegate
+
+ required property var model
+ required property int index
+
+ systemPalette: control.systemPalette
+
+ palette.windowText: control.itemTextColor ? control.itemTextColor(index) : systemPalette.windowText
+ width: ListView.view.width
+ text: model[control.textRole]
+ font.weight: control.currentIndex === index ? Font.DemiBold : Font.Normal
+ highlighted: control.highlightedIndex === index
+ hoverEnabled: control.hoverEnabled
+
+ ToolTip {
+ parent: controlDelegate
+ visible: controlDelegate.hovered && Boolean(control.tooltipModel)
+ delay: 50
+ text: control.tooltipModel ? control.tooltipModel[controlDelegate.index] : ""
+ }
+ }
+
+ Component.onCompleted: {
+ indicator.color = systemPalette.text;
+ }
+}
diff --git a/src/gui/qml/components/CustomItemDelegate.qml b/src/gui/qml/components/CustomItemDelegate.qml
new file mode 100644
index 00000000..ac14e943
--- /dev/null
+++ b/src/gui/qml/components/CustomItemDelegate.qml
@@ -0,0 +1,26 @@
+import QtQuick
+import QtQuick.Controls
+
+ItemDelegate {
+ id: control
+
+ required property SystemPalette systemPalette
+ property color bgColor: "transparent"
+
+ implicitHeight: 30
+ padding: 0
+ spacing: 0
+
+ background: Rectangle {
+ implicitHeight: 30
+ implicitWidth: control.contentItem.implicitWidth
+ color: control.highlighted ? control.systemPalette.highlight : control.bgColor
+
+ Rectangle {
+ anchors.fill: parent
+ visible: control.enabled && (control.down || control.hovered)
+ opacity: control.down ? 0.1 : 0.05
+ color: control.systemPalette.text
+ }
+ }
+}
diff --git a/src/gui/qml/components/CustomMenu.qml b/src/gui/qml/components/CustomMenu.qml
new file mode 100644
index 00000000..e9ab2f98
--- /dev/null
+++ b/src/gui/qml/components/CustomMenu.qml
@@ -0,0 +1,20 @@
+pragma ComponentBehavior: Bound
+import QtQuick
+import QtQuick.Controls
+
+Menu {
+ id: control
+
+ required property SystemPalette systemPalette
+
+ delegate: CustomMenuItem {
+ systemPalette: control.systemPalette
+ }
+ background: Rectangle {
+ implicitWidth: 200
+ implicitHeight: 40
+ color: control.systemPalette.button
+ border.color: control.systemPalette.mid
+ border.width: 1
+ }
+}
diff --git a/src/gui/qml/components/CustomMenuItem.qml b/src/gui/qml/components/CustomMenuItem.qml
new file mode 100644
index 00000000..dc57a7ef
--- /dev/null
+++ b/src/gui/qml/components/CustomMenuItem.qml
@@ -0,0 +1,16 @@
+pragma ComponentBehavior: Bound
+import QtQuick
+import QtQuick.Controls
+
+MenuItem {
+ id: control
+
+ required property SystemPalette systemPalette
+
+ background: Rectangle {
+ implicitHeight: 30
+
+ color: control.highlighted ? control.systemPalette.light : "transparent"
+ visible: control.down || control.highlighted
+ }
+}
diff --git a/src/gui/qml/components/CustomSpinBox.qml b/src/gui/qml/components/CustomSpinBox.qml
new file mode 100644
index 00000000..943d5d0d
--- /dev/null
+++ b/src/gui/qml/components/CustomSpinBox.qml
@@ -0,0 +1,6 @@
+import QtQuick.Controls
+
+SpinBox {
+ implicitHeight: 30
+ padding: 0
+}
\ No newline at end of file
diff --git a/src/gui/qml/components/CustomTabButton.qml b/src/gui/qml/components/CustomTabButton.qml
new file mode 100644
index 00000000..bda8aee5
--- /dev/null
+++ b/src/gui/qml/components/CustomTabButton.qml
@@ -0,0 +1,27 @@
+import QtQuick
+import QtQuick.Controls
+
+TabButton {
+ id: control
+
+ required property SystemPalette systemPalette
+ property color bgColor: "transparent"
+
+ leftPadding: 8
+ rightPadding: 8
+ spacing: 0
+ width: implicitWidth
+ palette.brightText: systemPalette.placeholderText
+ background: Rectangle {
+ implicitHeight: 30
+ implicitWidth: control.contentItem.implicitWidth
+ color: control.bgColor
+
+ Rectangle {
+ anchors.fill: parent
+ visible: control.enabled && (control.down || control.hovered)
+ opacity: control.down ? 0.1 : 0.05
+ color: control.systemPalette.text
+ }
+ }
+}
diff --git a/src/gui/qml/components/CustomTextField.qml b/src/gui/qml/components/CustomTextField.qml
new file mode 100644
index 00000000..5efae8fc
--- /dev/null
+++ b/src/gui/qml/components/CustomTextField.qml
@@ -0,0 +1,20 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Controls.impl
+
+TextField {
+ id: control
+
+ implicitHeight: 30
+ leftPadding: 4
+ rightPadding: 4
+ background: Rectangle {
+ implicitWidth: 0
+ implicitHeight: control.implicitHeight
+ color: control.palette.base
+
+ // Custom active highlight
+ border.color: control.activeFocus ? control.palette.highlight : "transparent"
+ border.width: control.activeFocus ? 2 : 0
+ }
+}
\ No newline at end of file
diff --git a/src/gui/qml/components/SelectableText.qml b/src/gui/qml/components/SelectableText.qml
new file mode 100644
index 00000000..708e701f
--- /dev/null
+++ b/src/gui/qml/components/SelectableText.qml
@@ -0,0 +1,15 @@
+import QtQuick
+
+TextEdit {
+ readOnly: true
+ textFormat: TextEdit.AutoText
+ wrapMode: Text.WordWrap
+ selectByMouse: true
+ onLinkActivated: Qt.openUrlExternally(hoveredLink)
+
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.NoButton // we don't want to eat clicks on the Text
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : undefined
+ }
+}
diff --git a/src/gui/qml/components/qmldir b/src/gui/qml/components/qmldir
new file mode 100644
index 00000000..3e55cab6
--- /dev/null
+++ b/src/gui/qml/components/qmldir
@@ -0,0 +1,12 @@
+module qml.components
+Badge Badge.qml
+CustomButton CustomButton.qml
+CustomCheckBox CustomCheckBox.qml
+CustomComboBox CustomComboBox.qml
+CustomItemDelegate CustomItemDelegate.qml
+CustomMenu CustomMenu.qml
+CustomMenuItem CustomMenuItem.qml
+CustomSpinBox CustomSpinBox.qml
+CustomTabButton CustomTabButton.qml
+CustomTextField CustomTextField.qml
+SelectableText SelectableText.qml
diff --git a/src/gui/qml/dialogs/CustomFileDialog.qml b/src/gui/qml/dialogs/CustomFileDialog.qml
new file mode 100644
index 00000000..912b9989
--- /dev/null
+++ b/src/gui/qml/dialogs/CustomFileDialog.qml
@@ -0,0 +1,38 @@
+pragma ComponentBehavior: Bound
+import QtCore
+import QtQuick
+import QtQuick.Dialogs
+
+FileDialog {
+ id: fileDialog
+
+ required property QtObject dialogModel
+ required property Settings settings
+ property string dialogId: "custom_file_dialog_id__" + dialogModel.dialog_id
+
+ modality: dialogModel.modality
+ title: dialogModel.title
+ fileMode: dialogModel.file_mode
+ nameFilters: dialogModel.name_filters
+ onAccepted: {
+ const currFold = fileDialog.currentFolder.toString();
+ if (!currFold.startsWith("data:")) {
+ settings.setValue(dialogId, currFold);
+ }
+ dialogModel.on_accepted(fileDialog.selectedFiles);
+ }
+ onRejected: dialogModel.on_rejected()
+ onCurrentFolderChanged: dialogModel.current_folder = fileDialog.currentFolder
+
+ Connections {
+ target: fileDialog.dialogModel
+
+ function onSelectFiles(): void {
+ const saved = fileDialog.settings.value(fileDialog.dialogId, undefined);
+ if (saved) {
+ fileDialog.currentFolder = saved;
+ }
+ fileDialog.open();
+ }
+ }
+}
diff --git a/src/gui/qml/dialogs/CustomMessageDialog.qml b/src/gui/qml/dialogs/CustomMessageDialog.qml
new file mode 100644
index 00000000..5168d522
--- /dev/null
+++ b/src/gui/qml/dialogs/CustomMessageDialog.qml
@@ -0,0 +1,38 @@
+import QtQml
+import QtQuick.Controls
+import QtQuick.Dialogs
+
+MessageDialog {
+ id: customMessageDialog
+
+ required property QtObject dialogContentModel
+
+ title: dialogContentModel?.title ?? ""
+ text: dialogContentModel?.text ?? ""
+ informativeText: dialogContentModel?.informative_text ?? ""
+ detailedText: dialogContentModel?.detailed_text ?? ""
+ buttons: MessageDialog.Cancel | MessageDialog.Ok
+ modality: Qt.NonModal
+ popupType: Popup.Window
+
+ onButtonClicked: function (button: int, role: int): void {
+ switch (button) {
+ case MessageDialog.Ok:
+ dialogContentModel.ok();
+ break;
+ case MessageDialog.Cancel:
+ dialogContentModel.cancel();
+ break;
+ }
+ }
+
+ Connections {
+ target: customMessageDialog.dialogContentModel
+
+ function onDismissed() {
+ if (customMessageDialog.visible) {
+ customMessageDialog.reject();
+ }
+ }
+ }
+}
diff --git a/src/gui/qml/dialogs/qmldir b/src/gui/qml/dialogs/qmldir
new file mode 100644
index 00000000..dbacb617
--- /dev/null
+++ b/src/gui/qml/dialogs/qmldir
@@ -0,0 +1,3 @@
+module qml.dialogs
+CustomFileDialog CustomFileDialog.qml
+CustomMessageDialog CustomMessageDialog.qml
\ No newline at end of file
diff --git a/src/gui/qml/models/__init__.py b/src/gui/qml/models/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/gui/qml/models/base_dialog_model.py b/src/gui/qml/models/base_dialog_model.py
new file mode 100644
index 00000000..0964bc0e
--- /dev/null
+++ b/src/gui/qml/models/base_dialog_model.py
@@ -0,0 +1,34 @@
+from PySide6.QtCore import Property, QObject, Qt, Signal
+
+
+class BaseDialogModel(QObject):
+ def __init__(
+ self, /, parent: QObject | None = None, *, objectName: str | None = None
+ ) -> None:
+ super().__init__(parent, objectName=objectName)
+ self._modality: Qt.WindowModality = Qt.WindowModality.WindowModal
+ self._title = ""
+
+ _modality_changed = Signal()
+
+ @Property(Qt.WindowModality, notify=_modality_changed)
+ def modality(self) -> Qt.WindowModality: # pyright: ignore[reportRedeclaration]
+ return self._modality
+
+ @modality.setter
+ def modality(self, value: Qt.WindowModality) -> None:
+ if value != self._modality:
+ self._modality = value
+ self._modality_changed.emit()
+
+ _title_changed = Signal()
+
+ @Property(str, notify=_title_changed)
+ def title(self) -> str: # pyright: ignore[reportRedeclaration]
+ return self._title
+
+ @title.setter
+ def title(self, value: str) -> None:
+ if value != self._title:
+ self._title = value
+ self._title_changed.emit()
diff --git a/src/gui/qml/models/batch_rendering_model.py b/src/gui/qml/models/batch_rendering_model.py
new file mode 100644
index 00000000..17123dd8
--- /dev/null
+++ b/src/gui/qml/models/batch_rendering_model.py
@@ -0,0 +1,329 @@
+from asyncio import ensure_future, gather, to_thread
+from collections.abc import Iterable, Sequence
+from functools import cached_property
+from logging import getLogger
+from pathlib import Path
+from typing import override
+from urllib.request import url2pathname
+
+from pydantic import BaseModel, RootModel, ValidationError
+from PySide6.QtCore import QModelIndex, QObject, QPersistentModelIndex, QUrl, Slot
+
+from src._config import AppConfig
+from src._loader import (
+ AppPlugin,
+ AssembledTemplate,
+ AssembledTemplateInstalledArgs,
+ TemplateLibrary,
+)
+from src.cards import CardDetails
+from src.enums.mtg import LayoutCategory
+from src.gui.qml.models.file_dialog_model import FileDialogModel
+from src.gui.qml.models.message_dialog_content_model import MessageDialogContentModel
+from src.gui.qml.models.pydantic_q_list_model import PydanticQListModel
+from src.gui.qml.models.test_renders_model import TestRendersModel
+from src.render.render_queue import RenderQueue, cancel_with_render
+from src.render.setup import prepare_render_operations
+from src.utils.inputs import get_cards_from_inputs
+from src.utils.scryfall import ScryfallCard
+
+_logger = getLogger(__name__)
+
+
+class TemplateOption(BaseModel):
+ name: str
+ plugin: str
+ is_installed: bool
+ preview_img_path: str
+
+ @cached_property
+ def full_name(self) -> str:
+ return self.name + (f" ({self.plugin})" if self.plugin else "")
+
+
+class LayoutCategoryItem(BaseModel):
+ name: str
+ options_details: list[TemplateOption]
+ options: list[str]
+ options_installed: list[bool]
+ options_preview_img_path: list[str]
+ selected: int
+
+
+class _TemplateSelectionPreferences(RootModel[dict[str, str]]):
+ root: dict[str, str] = {}
+
+
+class BatchRenderingModel(PydanticQListModel[LayoutCategoryItem]):
+ item_model = LayoutCategoryItem
+
+ def __init__(
+ self,
+ file_dialog_model: FileDialogModel,
+ message_dialog_model: MessageDialogContentModel,
+ render_queue: RenderQueue,
+ plugins: dict[str, AppPlugin],
+ template_library: TemplateLibrary,
+ test_renders_model: TestRendersModel,
+ app_config: AppConfig,
+ parent: QObject | None = None,
+ selected_index: int = -1,
+ ) -> None:
+ self._file_dialog_model = file_dialog_model
+ self._message_dialog_model = message_dialog_model
+ self._render_queue = render_queue
+ self._test_renders_model = test_renders_model
+ self._template_library = template_library
+ self._app_config = app_config
+
+ self.built_in_templates_by_layout: dict[
+ LayoutCategory, dict[str, AssembledTemplate]
+ ] = {}
+ self.plugin_templates_by_layout: dict[
+ LayoutCategory, dict[str, dict[str, AssembledTemplate]]
+ ] = {}
+ """dict[layout, dict[plugin, dict[template_name, template]]]"""
+
+ for layout_category in LayoutCategory:
+ self.built_in_templates_by_layout.setdefault(layout_category, {})
+ self.plugin_templates_by_layout.setdefault(
+ layout_category, {plugin.id: {} for plugin in plugins.values()}
+ )
+
+ for name, template in template_library.built_in_templates_by_name.items():
+ for layout_category in template.layout_categories:
+ self.built_in_templates_by_layout[layout_category][name] = template
+
+ template.template_installed.add_listener(self._on_template_installed)
+
+ for plugin, templates in template_library.plugin_templates_by_name.items():
+ for name, template in templates.items():
+ for layout_category in template.layout_categories:
+ self.plugin_templates_by_layout[layout_category].setdefault(
+ plugin, {}
+ )
+ self.plugin_templates_by_layout[layout_category][plugin][name] = (
+ template
+ )
+
+ template.template_installed.add_listener(self._on_template_installed)
+
+ layout_cat_items: list[LayoutCategoryItem] = []
+ for (
+ layout_category,
+ template_options,
+ ) in self.built_in_templates_by_layout.items():
+ opts = [
+ TemplateOption(
+ name=opt_name,
+ plugin="",
+ is_installed=opt.is_installed(layout_category),
+ preview_img_path=path.as_uri()
+ if (path := opt.get_preview_image_path(layout_category))
+ else "",
+ )
+ for opt_name, opt in template_options.items()
+ ]
+
+ for plugin, plugin_opts in self.plugin_templates_by_layout[
+ layout_category
+ ].items():
+ for opt_name, opt in plugin_opts.items():
+ opts.append(
+ TemplateOption(
+ name=opt_name,
+ plugin=plugin,
+ is_installed=opt.is_installed(layout_category),
+ preview_img_path=path.as_uri()
+ if (path := opt.get_preview_image_path(layout_category))
+ else "",
+ )
+ )
+
+ layout_cat_item = LayoutCategoryItem(
+ name=layout_category,
+ selected=-1,
+ options_details=opts,
+ options=[opt.full_name for opt in opts],
+ options_installed=[opt.is_installed for opt in opts],
+ options_preview_img_path=[opt.preview_img_path for opt in opts],
+ )
+ layout_cat_items.append(layout_cat_item)
+
+ super().__init__(parent, layout_cat_items, selected_index)
+
+ @override
+ def columnCount(self, parent: QModelIndex | QPersistentModelIndex) -> int:
+ return 2
+
+ @property
+ def template_choices(self) -> dict[LayoutCategory, AssembledTemplate]:
+ template_choices: dict[LayoutCategory, AssembledTemplate] = {}
+ for item in self.items:
+ if item.selected < 0:
+ continue
+
+ selected_opt = item.options_details[item.selected]
+
+ if not selected_opt.is_installed:
+ continue
+
+ layout_category = LayoutCategory(item.name)
+ if selected_opt.plugin:
+ template_choices[layout_category] = self.plugin_templates_by_layout[
+ layout_category
+ ][selected_opt.plugin][selected_opt.name]
+ else:
+ template_choices[layout_category] = self.built_in_templates_by_layout[
+ layout_category
+ ][selected_opt.name]
+ return template_choices
+
+ # layout, selected index
+ @Slot(int, int)
+ def select_template_for_layout(
+ self, layout_category_idx: int, selected_index: int
+ ) -> None:
+ item = self.items[layout_category_idx]
+ item.selected = selected_index
+ q_idx = self.createIndex(layout_category_idx, 0)
+ self.dataChanged.emit(q_idx, q_idx, [self.get_role("selected")])
+
+ async def render_files(self, paths: list[Path]) -> None:
+ if paths:
+ _logger.info(
+ f"Queueing {
+ len(paths)
+ } batch mode inputs for render. Do note that the actual amount of renders might be different if you selected JSON or YAML files or art for split cards."
+ )
+
+ def add_render(
+ input: CardDetails | tuple[CardDetails, ScryfallCard],
+ ) -> None:
+ if render_operations := prepare_render_operations(
+ self.template_choices,
+ self._template_library,
+ (input,),
+ file_dialog=self._file_dialog_model,
+ message_dialog=self._message_dialog_model,
+ ):
+ self._render_queue.enqueue(render_operations[0])
+
+ async def add_renders(cards: Sequence[tuple[CardDetails, ScryfallCard]]):
+ await gather(*[to_thread(add_render, card) for card in cards])
+
+ await get_cards_from_inputs(paths, add_renders, self._app_config)
+
+ @Slot()
+ def render_selections(self) -> None:
+ async def action():
+ # Choose images
+ selections = await self._file_dialog_model.select_images(
+ dialog_id="batch_mode_render",
+ filters=[
+ FileDialogModel.IMAGES_AND_DATA_FILTER,
+ FileDialogModel.ALL_FILTER,
+ ],
+ )
+
+ await self.render_files(selections)
+
+ cancel_with_render(ensure_future(action()), self._render_queue)
+
+ @Slot("QVariantList")
+ def render_targets(self, urls: list[QUrl]) -> None:
+ paths: list[Path] | None = None
+ try:
+ paths = [Path(url2pathname(url.path())) for url in urls]
+ except Exception:
+ _logger.exception("Failed to process drag & dropped files.")
+ if paths:
+ cancel_with_render(
+ ensure_future(self.render_files(paths)), self._render_queue
+ )
+
+ @Slot(str, bool)
+ @Slot(str, bool, str)
+ def test_render(
+ self, layout: str | None = None, quick: bool = False, case: str | None = None
+ ) -> None:
+ async def action() -> None:
+ layout_category = LayoutCategory(layout) if layout else None
+
+ if layout_category and not (
+ (template := self.template_choices.get(layout_category))
+ and template.is_installed(layout_category)
+ ):
+ _logger.info(
+ f"A template hasn't been chosen/installed for {layout_category} so there are no tests to queue."
+ )
+ return
+
+ if layout_category:
+ conf: dict[LayoutCategory, Iterable[AssembledTemplate] | None] = {
+ layout_category: (self.template_choices[layout_category],)
+ }
+ else:
+ conf = {
+ cat: (template,)
+ for cat, template in self.template_choices.items()
+ if template.is_installed(cat)
+ }
+
+ if not conf:
+ _logger.info(
+ "No installed templates have been chosen so there are no tests to queue."
+ )
+ return
+
+ _logger.info(
+ f"Queueing {
+ layout_category.value + ' ' if layout_category else ''
+ }tests for batch mode selections."
+ )
+
+ await self._test_renders_model.test_renders(conf, quick, case=case)
+
+ cancel_with_render(ensure_future(action()), self._render_queue)
+
+ @Slot(result=str)
+ def get_template_selections_json(self) -> str:
+ selections = _TemplateSelectionPreferences()
+ for item in self.items:
+ if item.selected > -1:
+ selections.root[item.name] = item.options[item.selected]
+ return selections.model_dump_json() if selections.root else ""
+
+ @Slot(str)
+ def restore_template_selections(self, data: str) -> None:
+ try:
+ parsed_data = _TemplateSelectionPreferences.model_validate_json(data).root
+ for item_idx, item in enumerate(self.items):
+ if selection := parsed_data.get(item.name):
+ for idx, opt in enumerate(item.options):
+ if opt == selection:
+ item.selected = idx
+ q_idx = self.index(item_idx, 0)
+ self.dataChanged.emit(q_idx, q_idx, ["selected"])
+ except ValidationError:
+ _logger.exception("Failed to restore batch model selection preferences.")
+
+ @Slot(result="QVariantList")
+ def layout_names(self) -> list[str]:
+ return [item.name for item in self.items]
+
+ def _on_template_installed(
+ self, event_args: AssembledTemplateInstalledArgs
+ ) -> None:
+ origin = event_args["origin"]
+ for idx, item in enumerate(self.items):
+ for opt_idx, opt in enumerate(item.options_details):
+ if (
+ opt.plugin if origin.plugin else not opt.plugin
+ ) and opt.name in origin.named_templates:
+ opt.is_installed = True
+ item.options_installed[opt_idx] = True
+ q_idx = self.createIndex(idx, 0)
+ self.dataChanged.emit(
+ q_idx, q_idx, [self.get_role("options_installed")]
+ )
diff --git a/src/gui/qml/models/console_model.py b/src/gui/qml/models/console_model.py
new file mode 100644
index 00000000..53499145
--- /dev/null
+++ b/src/gui/qml/models/console_model.py
@@ -0,0 +1,78 @@
+from logging import DEBUG, Formatter, LogRecord, getLogger
+from re import Match, compile
+
+from pydantic import BaseModel
+from PySide6.QtCore import QModelIndex, QObject, Slot
+from PySide6.QtGui import QGuiApplication
+
+from src.console import (
+ DEFAULT_LOG_DATE_FORMAT,
+ DEFAULT_LOG_FORMAT,
+ LOG_MESSAGE_COLORS_MAP,
+ CustomLogHandler,
+ MessageSeverity,
+)
+from src.gui.qml.models.pydantic_q_list_model import PydanticQListModel
+
+_logger = getLogger()
+
+
+def _colorize_severity(match: Match[str], color: str) -> str:
+ return f'{match.group(1)}{match.group(2)}'
+
+
+class _ColorizingFormatter(Formatter):
+ _severity_regex = compile(r"(\[[^\[\]]+\])(\[[^\s\[\]]+\])")
+
+ def format(self, record: LogRecord) -> str:
+ return "
".join(
+ self._severity_regex.sub(
+ lambda match: _colorize_severity(
+ match, LOG_MESSAGE_COLORS_MAP[record.levelno]
+ ),
+ super().format(record),
+ 1,
+ ).splitlines()
+ )
+
+
+class LogEntry(BaseModel):
+ message: str
+ severity: int
+ color: str
+
+
+class ConsoleModel(PydanticQListModel[LogEntry]):
+ item_model = LogEntry
+
+ def __init__(
+ self,
+ parent: QObject | None = None,
+ items: list[LogEntry] = [],
+ selected_index: int = 0,
+ ) -> None:
+ log_handler = CustomLogHandler(self._add_to_log, level=DEBUG)
+ log_handler.setFormatter(
+ _ColorizingFormatter(
+ fmt=DEFAULT_LOG_FORMAT, datefmt=DEFAULT_LOG_DATE_FORMAT
+ )
+ )
+ _logger.addHandler(log_handler)
+ super().__init__(parent, items, selected_index)
+
+ def _add_to_log(self, message: str, severity: MessageSeverity) -> None:
+ row_count = self.rowCount()
+ self.beginInsertRows(QModelIndex(), row_count, row_count)
+ self.items.append(
+ LogEntry(
+ message=message,
+ severity=severity,
+ color=LOG_MESSAGE_COLORS_MAP[severity],
+ )
+ )
+ self.endInsertRows()
+
+ @Slot()
+ def copy_log(self) -> None:
+ clipboard = QGuiApplication.clipboard()
+ clipboard.setText("\n".join([item.message for item in self.items]))
diff --git a/src/gui/qml/models/file_dialog_model.py b/src/gui/qml/models/file_dialog_model.py
new file mode 100644
index 00000000..7b56f27f
--- /dev/null
+++ b/src/gui/qml/models/file_dialog_model.py
@@ -0,0 +1,147 @@
+from asyncio import Future, get_running_loop
+from enum import IntEnum
+from os import PathLike
+from pathlib import Path
+
+from PySide6.QtCore import Property, QObject, Qt, QUrl, Signal, Slot
+
+from src._state import PATH
+from src.gui.qml.models.base_dialog_model import BaseDialogModel
+from src.utils.asynchronic import try_threadsafe_set_result
+
+
+class FileMode(IntEnum):
+ OpenFile = 0
+ OpenFiles = 1
+ SaveFile = 2
+
+
+class FileDialogModel(BaseDialogModel):
+ ALL_FILTER = "All (*)"
+ PSD_FILTER = "PSD (*.psd)"
+ IMAGES_FILTER = "Images (*.png *.jpg *.jpeg *.jxl *.avif *.webp)"
+ IMAGES_AND_DATA_FILTER = (
+ "Images and data (*.png *.jpg *.jpeg *.jxl *.avif *.webp *.json *.yaml *.yml)"
+ )
+
+ def __init__(
+ self, parent: QObject | None = None, *, objectName: str | None = None
+ ) -> None:
+ super().__init__(parent, objectName=objectName)
+ setattr(type(self), "instance", self)
+ self._response_future: Future[list[QUrl]] | None = None
+ self._current_folder: QUrl = QUrl.fromLocalFile(str(PATH.CWD))
+ self._file_mode = FileMode.OpenFile
+ self._name_filters = []
+ self._dialog_id = "default"
+
+ # region Properties
+
+ _current_folder_changed = Signal()
+
+ @Property(QUrl, notify=_current_folder_changed)
+ def current_folder(self) -> QUrl: # pyright: ignore[reportRedeclaration]
+ return self._current_folder
+
+ @current_folder.setter
+ def current_folder(self, value: QUrl) -> None:
+ if value != self._current_folder:
+ self._current_folder = value
+ self._current_folder_changed.emit()
+
+ _file_mode_changed = Signal()
+
+ @Property(int, notify=_file_mode_changed)
+ def file_mode(self) -> FileMode: # pyright: ignore[reportRedeclaration]
+ return self._file_mode
+
+ @file_mode.setter
+ def file_mode(self, value: FileMode) -> None:
+ if value != self._file_mode:
+ self._file_mode = value
+ self._file_mode_changed.emit()
+
+ _name_filters_changed = Signal()
+
+ @Property(list, notify=_name_filters_changed)
+ def name_filters(self) -> list[str]: # pyright: ignore[reportRedeclaration]
+ return self._name_filters
+
+ @name_filters.setter
+ def name_filters(self, value: list[str]) -> None:
+ if value != self._name_filters:
+ self._name_filters = value
+ self._name_filters_changed.emit()
+
+ _dialog_id_changed = Signal()
+
+ @Property(str, notify=_dialog_id_changed)
+ def dialog_id(self) -> str: # pyright: ignore[reportRedeclaration]
+ return self._dialog_id
+
+ @dialog_id.setter
+ def dialog_id(self, value: str) -> None:
+ if value != self._dialog_id:
+ self._dialog_id = value
+ self._dialog_id_changed.emit()
+
+ # endregion Properties
+
+ # region Events
+
+ @Slot("QVariantList")
+ def on_accepted(self, files: list[QUrl]) -> None:
+ if self._response_future:
+ try_threadsafe_set_result(self._response_future, files)
+
+ @Slot()
+ def on_rejected(self) -> None:
+ if self._response_future:
+ arg: list[QUrl] = []
+ try_threadsafe_set_result(self._response_future, arg)
+
+ # endregion Events
+
+ _select_files_called = Signal(name="selectFiles")
+
+ async def select_files(
+ self,
+ title: str = "",
+ initial_dir: str | PathLike[str] = PATH.CWD,
+ file_mode: FileMode = FileMode.OpenFiles,
+ filters: list[str] = [],
+ modality: Qt.WindowModality = Qt.WindowModality.WindowModal,
+ dialog_id: str = "default",
+ ) -> list[QUrl]:
+ if self._response_future:
+ await self._response_future
+
+ self.title = title # pyright: ignore[reportAttributeAccessIssue]
+ self.current_folder = QUrl.fromLocalFile(initial_dir) # pyright: ignore[reportAttributeAccessIssue]
+ self.file_mode = file_mode # pyright: ignore[reportAttributeAccessIssue]
+ self.name_filters = filters # pyright: ignore[reportAttributeAccessIssue]
+ self.modality = modality # pyright: ignore[reportAttributeAccessIssue]
+ self.dialog_id = dialog_id # pyright: ignore[reportAttributeAccessIssue]
+
+ loop = get_running_loop()
+ self._response_future = loop.create_future()
+ self._select_files_called.emit()
+ return await self._response_future
+
+ async def select_images(
+ self,
+ title: str = "Select images",
+ initial_dir: str | PathLike[str] = PATH.CWD,
+ filters: list[str] = [IMAGES_FILTER, ALL_FILTER],
+ dialog_id: str = "default",
+ ) -> list[Path]:
+ return [
+ Path(path.toLocalFile())
+ for path in await self.select_files(
+ title=title,
+ initial_dir=initial_dir,
+ file_mode=FileMode.OpenFiles,
+ filters=filters,
+ dialog_id=dialog_id,
+ )
+ ]
diff --git a/src/gui/qml/models/file_path_model.py b/src/gui/qml/models/file_path_model.py
new file mode 100644
index 00000000..de571f32
--- /dev/null
+++ b/src/gui/qml/models/file_path_model.py
@@ -0,0 +1,31 @@
+from PySide6.QtCore import Property, QObject, QUrl, Signal, Slot
+
+from src._state import PATH
+
+
+class FilePathModel(QObject):
+ @Property(QUrl)
+ def app_root(self) -> QUrl:
+ return QUrl.fromLocalFile(PATH.CWD)
+
+ @Property(QUrl)
+ def out_directory(self) -> QUrl:
+ return QUrl.fromLocalFile(PATH.OUT)
+
+ @Property(QUrl)
+ def templates_directory(self) -> QUrl:
+ return QUrl.fromLocalFile(PATH.TEMPLATES)
+
+ @Property(QUrl)
+ def plugins_directory(self) -> QUrl:
+ return QUrl.fromLocalFile(PATH.PLUGINS)
+
+ _preview_img_fallback_signal = Signal()
+
+ @Property(str, notify=_preview_img_fallback_signal)
+ def preview_img_fallback(self) -> str:
+ return PATH.SRC_IMG_NOTFOUND.as_uri()
+
+ @Slot(str, result=str)
+ def get_preferences_path(self, file_name: str) -> str:
+ return (PATH.SRC_DATA_PREFERENCES / file_name).as_uri()
diff --git a/src/gui/qml/models/image_transform_model.py b/src/gui/qml/models/image_transform_model.py
new file mode 100644
index 00000000..aab12c4e
--- /dev/null
+++ b/src/gui/qml/models/image_transform_model.py
@@ -0,0 +1,129 @@
+from asyncio import ensure_future, gather, to_thread
+from concurrent.futures import ThreadPoolExecutor
+from logging import getLogger
+from os import process_cpu_count
+from pathlib import Path
+
+from PySide6.QtCore import Property, QObject, Signal, Slot
+
+from src.gui.qml.models.file_dialog_model import FileDialogModel
+from src.utils.images import IMAGE_ENCODING_TO_SUFFIX_MAPPING, save_scaled_card_image
+
+_logger = getLogger(__name__)
+
+
+class ImageTransformModel(QObject):
+ def __init__(
+ self,
+ /,
+ parent: QObject | None = None,
+ *,
+ file_dialog_model: FileDialogModel,
+ objectName: str | None = None,
+ ) -> None:
+ super().__init__(parent, objectName=objectName)
+ self._file_dialog_model = file_dialog_model
+
+ self._thread_pool_executor: ThreadPoolExecutor | None = None
+ self._image_file_formats: list[str] = ["PNG", "JPEG", "WebP"]
+
+ self._downscale = True
+ self._downscale_width = 2176
+ self._image_file_format = self._image_file_formats[1]
+ self._encoding_quality = 95
+
+ @property
+ def _thread_pool(self) -> ThreadPoolExecutor:
+ if not self._thread_pool_executor:
+ self._thread_pool_executor = ThreadPoolExecutor(
+ max_workers=(process_cpu_count() or 5) - 1
+ )
+ return self._thread_pool_executor
+
+ _image_file_formats_changed = Signal()
+
+ @Property(list, notify=_image_file_formats_changed)
+ def image_file_formats(self) -> list[str]:
+ return self._image_file_formats
+
+ _downscale_changed = Signal()
+
+ @Property(bool, notify=_downscale_changed)
+ def downscale(self) -> bool: # pyright: ignore[reportRedeclaration]
+ return self._downscale
+
+ @downscale.setter
+ def downscale(self, value: bool) -> None:
+ if value != self._downscale:
+ self._downscale = value
+ self._downscale_changed.emit()
+
+ _downscale_width_changed = Signal()
+
+ @Property(int, notify=_downscale_width_changed)
+ def downscale_width(self) -> int: # pyright: ignore[reportRedeclaration]
+ return self._downscale_width
+
+ @downscale_width.setter
+ def downscale_width(self, value: int) -> None:
+ if value != self._downscale_width:
+ self._downscale_width = value
+ self._downscale_width_changed.emit()
+
+ _image_file_format_changed = Signal()
+
+ @Property(str, notify=_image_file_format_changed)
+ def image_file_format(self) -> str: # pyright: ignore[reportRedeclaration]
+ return self._image_file_format
+
+ @image_file_format.setter
+ def image_file_format(self, value: str) -> None:
+ if value != self._image_file_format:
+ self._image_file_format = value
+ self._image_file_format_changed.emit()
+
+ _encoding_quality_changed = Signal()
+
+ @Property(int, notify=_encoding_quality_changed)
+ def encoding_quality(self) -> int: # pyright: ignore[reportRedeclaration]
+ return self._encoding_quality
+
+ @encoding_quality.setter
+ def encoding_quality(self, value: int) -> None:
+ if value != self._encoding_quality:
+ self._encoding_quality = value
+ self._encoding_quality_changed.emit()
+
+ @Slot()
+ def transform_images(self) -> None:
+ async def action():
+ images = await self._file_dialog_model.select_images(
+ dialog_id="image_transform"
+ )
+ await gather(*[self.transform_image(image) for image in images])
+
+ ensure_future(action())
+
+ async def transform_image(self, image: Path) -> None:
+ try:
+ out_dir = image.parent / "compressed"
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out_path = (
+ out_dir
+ / image.with_suffix(
+ IMAGE_ENCODING_TO_SUFFIX_MAPPING[self._image_file_format]
+ ).name
+ )
+ await to_thread(
+ save_scaled_card_image,
+ image,
+ out_path,
+ self._image_file_format,
+ self._downscale_width if self._downscale else None,
+ self._encoding_quality,
+ )
+ _logger.info(
+ f"Saved transformed version of {image} to {out_path}"
+ )
+ except Exception:
+ _logger.exception(f"Failed to transform {image}")
diff --git a/src/gui/qml/models/message_dialog_content_model.py b/src/gui/qml/models/message_dialog_content_model.py
new file mode 100644
index 00000000..8bf5969a
--- /dev/null
+++ b/src/gui/qml/models/message_dialog_content_model.py
@@ -0,0 +1,130 @@
+from asyncio import Future, get_running_loop
+from collections.abc import Callable
+from typing import Any
+
+from PySide6.QtCore import Property, QObject, Signal, Slot
+
+from src.utils.asynchronic import try_threadsafe_set_result
+
+
+class MessageDialogContentModel(QObject):
+ def __init__(
+ self,
+ /,
+ title: str = "",
+ text: str = "",
+ informative_text: str = "",
+ detailed_text: str = "",
+ ok_callback: Callable[[], Any] | None = None,
+ cancel_callback: Callable[[], Any] | None = None,
+ parent: QObject | None = None,
+ *,
+ objectName: str | None = None,
+ ) -> None:
+ super().__init__(parent, objectName=objectName)
+ self._title = title
+ self._text = text
+ self._informative_text = informative_text
+ self._detailed_text = detailed_text
+ self.ok_callback = ok_callback
+ self.cancel_callback = cancel_callback
+
+ _title_changed = Signal()
+
+ @Property(str, notify=_title_changed)
+ def title(self) -> str: # pyright: ignore[reportRedeclaration]
+ return self._title
+
+ @title.setter
+ def title(self, value: str) -> None:
+ if value != self._title:
+ self._title = value
+ self._title_changed.emit()
+
+ _text_changed = Signal()
+
+ @Property(str, notify=_text_changed)
+ def text(self) -> str: # pyright: ignore[reportRedeclaration]
+ return self._text
+
+ @text.setter
+ def text(self, value: str) -> None:
+ if value != self._text:
+ self._text = value
+ self._text_changed.emit()
+
+ _informative_text_changed = Signal()
+
+ @Property(str, notify=_informative_text_changed)
+ def informative_text(self) -> str: # pyright: ignore[reportRedeclaration]
+ return self._informative_text
+
+ @informative_text.setter
+ def informative_text(self, value: str) -> None:
+ if value != self._informative_text:
+ self._informative_text = value
+ self._informative_text_changed.emit()
+
+ _detailed_text_changed = Signal()
+
+ @Property(str, notify=_detailed_text_changed)
+ def detailed_text(self) -> str: # pyright: ignore[reportRedeclaration]
+ return self._detailed_text
+
+ @detailed_text.setter
+ def detailed_text(self, value: str) -> None:
+ if value != self._detailed_text:
+ self._detailed_text = value
+ self._detailed_text_changed.emit()
+
+ @Slot()
+ def ok(self) -> None:
+ if self.ok_callback:
+ self.ok_callback()
+
+ @Slot()
+ def cancel(self) -> None:
+ if self.cancel_callback:
+ self.cancel_callback()
+
+ _dismissed = Signal(name="dismissed")
+
+ def dismiss(self) -> None:
+ self._dismissed.emit()
+
+ dialog_requested = Signal(name="dialogRequested")
+
+ def open_message_dialog(
+ self,
+ title: str = "",
+ text: str = "",
+ informative_text: str = "",
+ detailed_text: str = "",
+ ok_callback: Callable[[], Any] | None = None,
+ cancel_callback: Callable[[], Any] | None = None,
+ ) -> None:
+ self.title = title # pyright: ignore[reportAttributeAccessIssue]
+ self.text = text # pyright: ignore[reportAttributeAccessIssue]
+ self.informative_text = informative_text # pyright: ignore[reportAttributeAccessIssue]
+ self.detailed_text = detailed_text # pyright: ignore[reportAttributeAccessIssue]
+ self.ok_callback = ok_callback
+ self.cancel_callback = cancel_callback
+ self.dialog_requested.emit()
+
+ async def open_message_dialog_async(
+ self,
+ title: str = "",
+ text: str = "",
+ informative_text: str = "",
+ detailed_text: str = "",
+ ) -> bool:
+ future: Future[bool] = get_running_loop().create_future()
+ self.open_message_dialog(
+ title=title,
+ text=text,
+ informative_text=informative_text,
+ detailed_text=detailed_text,
+ ok_callback=lambda: try_threadsafe_set_result(future, True),
+ cancel_callback=lambda: try_threadsafe_set_result(future, False),
+ )
+ return await future
diff --git a/src/gui/qml/models/pydantic_q_list_model.py b/src/gui/qml/models/pydantic_q_list_model.py
new file mode 100644
index 00000000..fa9eb4ac
--- /dev/null
+++ b/src/gui/qml/models/pydantic_q_list_model.py
@@ -0,0 +1,240 @@
+from typing import Any, override
+
+from pydantic import BaseModel
+from PySide6.QtCore import (
+ Property,
+ QAbstractItemModel,
+ QAbstractListModel,
+ QByteArray,
+ QModelIndex,
+ QObject,
+ QPersistentModelIndex,
+ Qt,
+ Signal,
+)
+
+
+class TreeItem[T: BaseModel]:
+ def __init__(self, data: T, parent: TreeItem[T] | None = None) -> None:
+ self.children: list[TreeItem[T]] = []
+ self.data = data
+ self.parent: TreeItem[T] | None = None
+ if parent:
+ parent.append_child(self)
+
+ def append_child(self, child: TreeItem[T]) -> None:
+ self.children.append(child)
+ child.parent = self
+
+ def child(self, row: int) -> TreeItem[T] | None:
+ return self.children[row] if -1 < row < self.child_count() else None
+
+ def child_count(self) -> int:
+ return len(self.children)
+
+ def column_count(self) -> int:
+ return 1
+
+ def row(self) -> int:
+ return self.parent.children.index(self) if self.parent else -1
+
+
+class PydanticQItemModelBase[T: BaseModel]:
+ item_model: type[T]
+ _role_names: dict[int, QByteArray]
+ _roles_reverse: dict[str, int]
+
+ def roleNames(self) -> dict[int, QByteArray]:
+ return self._role_names
+
+ def get_role(self, property_name: str) -> int:
+ return self._roles_reverse[property_name]
+
+
+class PydanticQItemModel[T: BaseModel](PydanticQItemModelBase[T], QAbstractItemModel):
+ """
+ Item model that exposes Pydantic model's fields as roles to QML.
+
+ Inheriting models have to set the Pydantic model's type to `item_model` field.
+ """
+
+ _root: TreeItem[T]
+
+ def __init__(self, /, parent: QObject | None = None) -> None:
+ self._roles: dict[int, str] = {
+ Qt.ItemDataRole.UserRole + 1 + idx: field
+ for idx, field in enumerate(self.item_model.model_fields)
+ }
+ self._roles_reverse: dict[str, int] = {
+ field: idx for idx, field in self._roles.items()
+ }
+ self._role_names: dict[int, QByteArray] = {
+ role: QByteArray(field.encode("utf-8"))
+ for role, field in self._roles.items()
+ }
+
+ super().__init__(parent)
+
+ @override
+ def rowCount(
+ self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()
+ ) -> int:
+ if parent.column() > 0:
+ return 0
+
+ parent_item: TreeItem[T] = (
+ parent.internalPointer() if parent.isValid() else self._root
+ )
+
+ return parent_item.child_count()
+
+ @override
+ def columnCount(
+ self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()
+ ) -> int:
+ if parent.isValid():
+ parent_item: TreeItem[T] = parent.internalPointer()
+ return parent_item.column_count()
+ return self._root.column_count()
+
+ @override
+ def data(
+ self,
+ index: QModelIndex | QPersistentModelIndex,
+ role: int = Qt.ItemDataRole.DisplayRole,
+ ) -> Any:
+ if not index.isValid() or role not in self._roles:
+ return None
+ tree_item: TreeItem[T] = index.internalPointer()
+ return getattr(tree_item.data, self._roles[role])
+
+ @override
+ def index(
+ self,
+ row: int,
+ column: int,
+ parent: QModelIndex | QPersistentModelIndex = QModelIndex(),
+ ) -> QModelIndex:
+ if not self.hasIndex(row, column, parent=parent):
+ return QModelIndex()
+
+ parent_item: TreeItem[T] = (
+ parent.internalPointer() if parent.isValid() else self._root
+ )
+
+ if child_item := parent_item.child(row):
+ return self.createIndex(row, column, child_item)
+
+ return QModelIndex()
+
+ @override
+ def parent(self, index: QModelIndex = QModelIndex()) -> QModelIndex: # pyright: ignore[reportIncompatibleMethodOverride]
+ if not index.isValid():
+ return QModelIndex()
+
+ child_item: TreeItem[T] = index.internalPointer()
+ parent_item = child_item.parent
+
+ if parent_item and parent_item != self._root:
+ return self.createIndex(parent_item.row(), 0, parent_item)
+
+ return QModelIndex()
+
+ def index_of_item(self, item: TreeItem[T]) -> QModelIndex:
+ return self.createIndex(item.row(), 0, item)
+
+ # region Properties
+
+ selected_model_index_changed = Signal(QModelIndex, name="selectedModelIndexChanged")
+
+ @Property(QModelIndex, notify=selected_model_index_changed)
+ def selected_model_index(self) -> QModelIndex: # pyright: ignore[reportRedeclaration]
+ return self._selected_model_index
+
+ def _set_selected_model_index(self, value: QModelIndex) -> None:
+ item: TreeItem[T] | None = value.internalPointer()
+ if not value.isValid() or not item:
+ self._selected_model_index = value
+ self.selected_model_index_changed.emit(value)
+ self.selected_title = ""
+ return None
+
+ if value != self._selected_model_index:
+ self._selected_model_index = value
+ self.selected_model_index_changed.emit(value)
+
+ @selected_model_index.setter
+ def selected_model_index(self, value: QModelIndex) -> None:
+ self._set_selected_model_index(value)
+
+ # endregion Properties
+
+
+class PydanticQListModel[T: BaseModel](PydanticQItemModelBase[T], QAbstractListModel):
+ """
+ List model that exposes Pydantic model's fields as roles to QML.
+
+ Inheriting models have to set the Pydantic model's type to `item_model` field.
+ """
+
+ def __init__(
+ self,
+ parent: QObject | None = None,
+ items: list[T] = [],
+ selected_index: int = -1,
+ ) -> None:
+ self._roles: dict[int, str] = {
+ Qt.ItemDataRole.UserRole + 1 + idx: field
+ for idx, field in enumerate(self.item_model.model_fields)
+ }
+ self._roles_reverse: dict[str, int] = {
+ field: idx for idx, field in self._roles.items()
+ }
+ self._role_names: dict[int, QByteArray] = {
+ role: QByteArray(field.encode("utf-8"))
+ for role, field in self._roles.items()
+ }
+
+ self.all_items: list[T] = items
+ self.items: list[T] = items.copy()
+ self._selected_index = selected_index
+
+ super().__init__(parent)
+
+ # region Signals
+
+ _selected_index_changed = Signal()
+
+ # endregion Signals
+
+ # region Properties
+
+ @Property(int, notify=_selected_index_changed, final=True)
+ def selected_index(self) -> int: # pyright: ignore[reportRedeclaration]
+ return self._selected_index
+
+ @selected_index.setter
+ def selected_index(self, value: int) -> None:
+ if value != self._selected_index:
+ self._selected_index = value
+ self._selected_index_changed.emit()
+
+ # endregion Properties
+
+ @override
+ def data(
+ self,
+ index: QModelIndex | QPersistentModelIndex,
+ role: int = Qt.ItemDataRole.DisplayRole,
+ ) -> Any:
+ if not index.isValid() or role not in self._roles:
+ return
+
+ item = self.items[index.row()]
+ return getattr(item, self._roles[role])
+
+ @override
+ def rowCount(
+ self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()
+ ) -> int:
+ return len(self.items)
diff --git a/src/gui/qml/models/render_operations_model.py b/src/gui/qml/models/render_operations_model.py
new file mode 100644
index 00000000..5161416e
--- /dev/null
+++ b/src/gui/qml/models/render_operations_model.py
@@ -0,0 +1,200 @@
+from functools import cached_property
+
+from pydantic import BaseModel
+from PySide6.QtCore import Property, QModelIndex, QObject, Signal, Slot
+
+from src.gui.qml.models.message_dialog_content_model import MessageDialogContentModel
+from src.gui.qml.models.pydantic_q_list_model import PydanticQListModel
+from src.render.render_queue import QueuedOperation, RenderQueue, RenderResult
+from src.render.setup import PausedEventArgs, RenderOperation
+
+
+class RenderOperationDetails(BaseModel):
+ image_name: str
+ image_path: str
+ card_name: str
+ card_artist: str
+ card_set: str
+ card_collector_number: str
+ layout_name: str
+ class_name: str
+
+ @cached_property
+ def render_operation(self) -> RenderOperation:
+ raise ValueError("Render operation not set")
+
+
+class RenderOperationsModel(PydanticQListModel[RenderOperationDetails]):
+ item_model = RenderOperationDetails
+
+ def __init__(
+ self,
+ render_queue: RenderQueue,
+ render_message_dialog_model: MessageDialogContentModel,
+ parent: QObject | None = None,
+ items: list[RenderOperationDetails] = [],
+ selected_index: int = -1,
+ ) -> None:
+ super().__init__(parent, items, selected_index)
+
+ self._render_queue = render_queue
+ self._render_message_dialog_model = render_message_dialog_model
+ self._is_rendering: bool = False
+ self._active_operation: RenderOperationDetails | None = None
+
+ render_queue.queued.add_listener(self._on_queued)
+ render_queue.dequeued.add_listener(self._on_dequeued)
+ render_queue.started.add_listener(self._on_started)
+ render_queue.finished.add_listener(self._on_finished)
+
+ # region Queue
+
+ def _on_queued(self, queued_operation: QueuedOperation) -> None:
+ render_operation = queued_operation["operation"]
+ idx = queued_operation["index"]
+ render_op_details = RenderOperationDetails(
+ image_name=render_operation.layout.art_file.name,
+ image_path=str(render_operation.layout.art_file),
+ card_name=render_operation.layout.name,
+ card_artist=render_operation.layout.artist,
+ card_set=render_operation.layout.set,
+ card_collector_number=render_operation.layout.collector_number_raw or "",
+ layout_name=render_operation.layout.category,
+ class_name=render_operation.template_class.__name__,
+ )
+ render_op_details.render_operation = render_operation
+ self.beginInsertRows(QModelIndex(), idx, idx)
+ self.items.insert(idx, render_op_details)
+ self.endInsertRows()
+
+ def _on_dequeued(self, data: tuple[RenderOperation, int]) -> None:
+ index = data[1]
+ self.beginRemoveRows(QModelIndex(), index, index)
+ self.items.pop(index)
+ self.endRemoveRows()
+
+ def _on_started(self, render_operation: RenderOperation) -> None:
+ render_operation.paused.add_listener(self._on_render_paused)
+ self.beginRemoveRows(QModelIndex(), 0, 0)
+ self.active_operation = self.items.pop(0)
+ self.endRemoveRows()
+ self.is_rendering = True # pyright: ignore[reportAttributeAccessIssue]
+
+ def _on_finished(self, render_result: RenderResult) -> None:
+ render_result["operation"].paused.remove_listener(self._on_render_paused)
+ self.is_rendering = False # pyright: ignore[reportAttributeAccessIssue]
+ self.active_operation = None
+
+ @Slot(int)
+ def dequeue(self, index: int) -> None:
+ self._render_queue.dequeue(index)
+
+ @Slot()
+ def clear(self) -> None:
+ self._render_queue.clear()
+
+ # endregion Queue
+
+ # region Properties
+
+ _is_rendering_changed = Signal()
+
+ @Property(bool, notify=_is_rendering_changed)
+ def is_rendering(self) -> bool: # pyright: ignore[reportRedeclaration]
+ return self._is_rendering
+
+ @is_rendering.setter
+ def is_rendering(self, value: bool) -> None:
+ if value != self._is_rendering:
+ self._is_rendering = value
+ self._is_rendering_changed.emit()
+
+ _active_operation_changed = Signal()
+
+ @property
+ def active_operation(self) -> RenderOperationDetails | None:
+ return self._active_operation
+
+ @active_operation.setter
+ def active_operation(self, value: RenderOperationDetails | None) -> None:
+ if self._active_operation != value:
+ self._active_operation = value
+ self._active_operation_changed.emit()
+
+ @Property(str, notify=_active_operation_changed)
+ def rendering_image_name(self) -> str:
+ if self._active_operation:
+ return self._active_operation.image_name
+ return ""
+
+ @Property(str, notify=_active_operation_changed)
+ def rendering_image_path(self) -> str:
+ if self._active_operation:
+ return self._active_operation.image_path
+ return ""
+
+ @Property(str, notify=_active_operation_changed)
+ def rendering_card_name(self) -> str:
+ if self._active_operation:
+ return self._active_operation.card_name
+ return ""
+
+ @Property(str, notify=_active_operation_changed)
+ def rendering_card_artist(self) -> str:
+ if self._active_operation:
+ return self._active_operation.card_artist
+ return ""
+
+ @Property(str, notify=_active_operation_changed)
+ def rendering_card_set(self) -> str:
+ if self._active_operation:
+ return self._active_operation.card_set
+ return ""
+
+ @Property(str, notify=_active_operation_changed)
+ def rendering_card_collector_number(self) -> str:
+ if self._active_operation:
+ return self._active_operation.card_collector_number
+ return ""
+
+ @Property(str, notify=_active_operation_changed)
+ def rendering_layout_name(self) -> str:
+ if self._active_operation:
+ return self._active_operation.layout_name
+ return ""
+
+ @Property(str, notify=_active_operation_changed)
+ def rendering_class_name(self) -> str:
+ if self._active_operation:
+ return self._active_operation.class_name
+ return ""
+
+ # endregion Properties
+
+ # region Pause dialog
+
+ def _cancel_queue(self) -> None:
+ self._render_queue.cancel()
+
+ @Slot()
+ def cancel(self) -> None:
+ self._render_message_dialog_model.dismiss()
+ self._cancel_queue()
+
+ @Slot()
+ def resume(self) -> None:
+ if op := self._render_queue.active_operation:
+ op.resume()
+ else:
+ self._render_queue.execute_render()
+
+ def _on_render_paused(self, args: PausedEventArgs) -> None:
+ self._render_message_dialog_model.open_message_dialog(
+ "Rendering paused",
+ text=args["message"] or "Rendering paused.",
+ informative_text="Press OK to resume rendering or Cancel to discard the current render.",
+ ok_callback=self.resume,
+ cancel_callback=self._cancel_queue,
+ )
+
+ # endregion Pause dialog
diff --git a/src/gui/qml/models/settings_model.py b/src/gui/qml/models/settings_model.py
new file mode 100644
index 00000000..dae648c0
--- /dev/null
+++ b/src/gui/qml/models/settings_model.py
@@ -0,0 +1,207 @@
+from functools import cached_property
+from typing import Literal, override
+
+from pydantic import BaseModel
+from PySide6.QtCore import (
+ Property,
+ QModelIndex,
+ QObject,
+ QPersistentModelIndex,
+ Qt,
+ Signal,
+ Slot,
+)
+
+from src._loader import (
+ BoolSetting,
+ ConfigHandler,
+ FloatSetting,
+ IntSetting,
+ NumericSetting,
+ SectionTitle,
+ StringSetting,
+)
+from src.gui.qml.models.pydantic_q_list_model import PydanticQListModel
+from src.gui.qml.models.settings_tree_model import SettingsTreeModel
+
+
+class HybridSettingItem(BaseModel):
+ type: Literal["title", "bool", "string", "numeric", "options", "int", "float"]
+ title: str
+ desc: str | None = None
+ value: bool | str | int | float | None = None
+ default_value: bool | str | int | float | None = None
+ options: list[str] | None = None
+
+ @cached_property
+ def section(self) -> str:
+ return ""
+
+ @cached_property
+ def key(self) -> str:
+ return ""
+
+
+class SettingsModel(PydanticQListModel[HybridSettingItem]):
+ item_model = HybridSettingItem
+
+ def __init__(
+ self,
+ settings_tree_model: SettingsTreeModel,
+ parent: QObject | None = None,
+ items: list[HybridSettingItem] = [],
+ selected_index: int = -1,
+ ) -> None:
+ super().__init__(parent, items, selected_index)
+ self._current_config_handler: ConfigHandler | None = None
+ self.settings_tree_model = settings_tree_model
+ settings_tree_model.selected_model_index_changed.connect(
+ self.on_settings_section_change
+ )
+
+ @property
+ def current_config_handler(self) -> ConfigHandler | None:
+ return self._current_config_handler
+
+ @current_config_handler.setter
+ def current_config_handler(self, value: ConfigHandler | None) -> None:
+ if self._current_config_handler:
+ self._current_config_handler.config_deleted.remove_listener(self._on_clear)
+ self._current_config_handler.config_reset.remove_listener(self._on_reset)
+
+ self._current_config_handler = value
+ if value:
+ if not value.has_config:
+ value.save(force=True)
+ value.config_deleted.add_listener(self._on_clear)
+ value.config_reset.add_listener(self._on_reset)
+ self._valid_changed.emit()
+
+ @cached_property
+ def _value_role(self) -> int:
+ for role, name in self._roles.items():
+ if name == "value":
+ return role
+ raise ValueError("Value role doesn't exist")
+
+ @override
+ def setData(
+ self,
+ index: QModelIndex | QPersistentModelIndex,
+ value: int | float | str | bool,
+ /,
+ role: int = Qt.ItemDataRole.EditRole,
+ ) -> bool:
+ if index.isValid() or self._roles.get(role) == "value":
+ item = self.items[index.row()]
+ if self._current_config_handler and self._current_config_handler.set_value(
+ item.section, item.key, value
+ ):
+ item.value = value
+ self.dataChanged.emit(index, index, [role])
+ return True
+ return False
+
+ def populate_settings(self, config_handler: ConfigHandler) -> None:
+ new_items: list[HybridSettingItem] = []
+ schemas = (
+ (config_handler.base_schema.root, config_handler.schema.root)
+ if config_handler.schema
+ else (config_handler.base_schema.root,)
+ )
+ for schema in schemas:
+ for section in schema:
+ if isinstance(section, SectionTitle):
+ new_item = HybridSettingItem(type=section.type, title=section.title)
+ elif isinstance(
+ section,
+ (
+ BoolSetting,
+ StringSetting,
+ NumericSetting,
+ FloatSetting,
+ IntSetting,
+ ),
+ ):
+ new_item = HybridSettingItem(
+ type=section.type,
+ title=section.title,
+ desc=section.desc,
+ value=config_handler.setting_values[section.section][
+ section.key
+ ],
+ default_value=section.default,
+ )
+ new_item.section = section.section
+ new_item.key = section.key
+ else:
+ new_item = HybridSettingItem(
+ type=section.type,
+ title=section.title,
+ desc=section.desc,
+ value=config_handler.setting_values[section.section][
+ section.key
+ ],
+ default_value=section.default,
+ options=section.options,
+ )
+ new_item.section = section.section
+ new_item.key = section.key
+ new_items.append(new_item)
+ self.beginResetModel()
+ self.items = new_items
+ self.current_config_handler = config_handler
+ self.endResetModel()
+
+ @Slot(QModelIndex)
+ def on_settings_section_change(self, value: QModelIndex) -> None:
+ if selected := self.settings_tree_model.selected_section:
+ self.populate_settings(selected)
+ else:
+ self.beginRemoveRows(QModelIndex(), 0, self.rowCount() - 1)
+ self.items = []
+ self.current_config_handler = None
+ self.endRemoveRows()
+
+ def _set_value(self, index: int, value: bool | str | int | float) -> None:
+ self.setData(self.createIndex(index, 0), value)
+
+ @Slot(int, bool)
+ def bool_value_changed(self, index: int, value: bool) -> None:
+ self._set_value(index, value)
+
+ @Slot(int, str)
+ def str_value_changed(self, index: int, value: str) -> None:
+ self._set_value(index, value)
+
+ @Slot(int, int)
+ def int_value_changed(self, index: int, value: int) -> None:
+ self._set_value(index, value)
+
+ @Slot(int, float)
+ def float_value_changed(self, index: int, value: float) -> None:
+ self._set_value(index, value)
+
+ @Slot()
+ def reset(self) -> None:
+ if self._current_config_handler:
+ self._current_config_handler.reset()
+
+ def _on_reset(self, conf_handler: ConfigHandler) -> None:
+ if self._current_config_handler:
+ self.populate_settings(self._current_config_handler)
+
+ @Slot()
+ def clear(self) -> None:
+ if self._current_config_handler:
+ self._current_config_handler.delete()
+
+ def _on_clear(self, conf_handler: ConfigHandler) -> None:
+ if self._current_config_handler:
+ self.settings_tree_model.selected_model_index = QModelIndex() # pyright: ignore[reportAttributeAccessIssue]
+
+ _valid_changed = Signal()
+
+ @Property(bool, notify=_valid_changed)
+ def valid(self) -> bool:
+ return self._current_config_handler is not None
diff --git a/src/gui/qml/models/settings_tree_model.py b/src/gui/qml/models/settings_tree_model.py
new file mode 100644
index 00000000..0ebcad0d
--- /dev/null
+++ b/src/gui/qml/models/settings_tree_model.py
@@ -0,0 +1,217 @@
+from collections.abc import Iterable
+from logging import getLogger
+from typing import override
+
+from pydantic import BaseModel
+from PySide6.QtCore import Property, QModelIndex, QObject, Signal, Slot
+
+from src._config import AppConfig
+from src._loader import AssembledTemplate, ConfigHandler, TemplateLibrary
+from src.gui.qml.models.pydantic_q_list_model import PydanticQItemModel, TreeItem
+
+_logger = getLogger(__name__)
+
+
+class SettingSectionItem(BaseModel):
+ name: str
+ config: ConfigHandler | None = None
+ has_config: bool = True
+
+ model_config = {"arbitrary_types_allowed": True}
+
+
+class SettingsTreeModel(PydanticQItemModel[SettingSectionItem]):
+ item_model = SettingSectionItem
+
+ def __init__(
+ self,
+ /,
+ parent: QObject | None = None,
+ *,
+ app_config: AppConfig,
+ template_library: TemplateLibrary,
+ ) -> None:
+ super().__init__(parent)
+
+ self._app_config = app_config
+ self._template_library = template_library
+ self._root = TreeItem(data=SettingSectionItem(name="root"))
+ self._selected_model_index: QModelIndex = QModelIndex()
+ self._selected_title: str = ""
+
+ self._app_leaf: TreeItem[SettingSectionItem] = TreeItem(
+ data=SettingSectionItem(name="Application", config=app_config.app_config),
+ parent=self._root,
+ )
+ _template_defaults_leaf: TreeItem[SettingSectionItem] = TreeItem(
+ data=SettingSectionItem(
+ name="Template defaults", config=app_config.base_config
+ ),
+ parent=self._root,
+ )
+
+ templates_branch: TreeItem[SettingSectionItem] = TreeItem(
+ data=SettingSectionItem(name="Templates"), parent=self._root
+ )
+ self._built_in_templates_branch: TreeItem[SettingSectionItem] = TreeItem(
+ data=SettingSectionItem(name="Built-in"), parent=templates_branch
+ )
+ self._construct_template_branch(
+ self._built_in_templates_branch, template_library.built_in_templates_by_name
+ )
+ self._plugin_templates_branch: TreeItem[SettingSectionItem] = TreeItem(
+ data=SettingSectionItem(name="Plugins"), parent=templates_branch
+ )
+ for plugin_name, templates in template_library.plugin_templates_by_name.items():
+ plugin_branch = TreeItem(
+ data=SettingSectionItem(name=plugin_name),
+ parent=self._plugin_templates_branch,
+ )
+ self._construct_template_branch(plugin_branch, templates)
+
+ def _construct_template_branch(
+ self,
+ root: TreeItem[SettingSectionItem],
+ templates: dict[str, AssembledTemplate],
+ ) -> None:
+ for name, template in templates.items():
+ assembled_branch: TreeItem[SettingSectionItem] = TreeItem(
+ data=SettingSectionItem(name=name), parent=root
+ )
+ duplicate_check: set[ConfigHandler] = set()
+ for named_template in template.templates:
+ for (
+ template_class_name,
+ details,
+ ) in named_template.template_classes.items():
+ conf = details["config"]
+ if conf not in duplicate_check:
+ tree_item = TreeItem(
+ data=SettingSectionItem(
+ name=template_class_name,
+ config=conf,
+ has_config=conf.has_config,
+ ),
+ parent=assembled_branch,
+ )
+ conf.config_added.add_listener(
+ lambda _, item=tree_item: self._on_config_state_changed(
+ item, True
+ )
+ )
+ conf.config_deleted.add_listener(
+ lambda _, item=tree_item: self._on_config_state_changed(
+ item, False
+ )
+ )
+ duplicate_check.add(conf)
+
+ # region Properties
+
+ @override
+ def _set_selected_model_index(self, value: QModelIndex) -> None:
+ if not value.isValid():
+ super()._set_selected_model_index(value)
+ return
+
+ item: TreeItem[SettingSectionItem] | None = value.internalPointer()
+
+ if not item or not item.data.config:
+ return None
+
+ super()._set_selected_model_index(value)
+
+ self.selected_title = item.data.name # pyright: ignore[reportAttributeAccessIssue]
+
+ _selected_title_changed = Signal()
+
+ @Property(str, notify=_selected_title_changed)
+ def selected_title(self) -> str: # pyright: ignore[reportRedeclaration]
+ return self._selected_title
+
+ @selected_title.setter
+ def selected_title(self, value: str) -> None:
+ if value != self._selected_title:
+ self._selected_title = value
+ self._selected_title_changed.emit()
+
+ @property
+ def selected_section(self) -> ConfigHandler | None:
+ idx = self._selected_model_index
+ if idx.isValid():
+ item: TreeItem[SettingSectionItem] = idx.internalPointer()
+ return item.data.config
+
+ # endregion Properties
+
+ @Slot(str, str, str)
+ def select_settings_section(
+ self,
+ template_name: str | None = None,
+ class_name: str | None = None,
+ plugin: str | None = None,
+ ) -> None:
+ if not template_name:
+ self.selected_model_index = self.index_of_item(self._app_leaf) # pyright: ignore[reportAttributeAccessIssue]
+ return
+
+ templates_root: TreeItem[SettingSectionItem] | None = None
+ if plugin:
+ for tree_item in self._plugin_templates_branch.children:
+ if plugin == tree_item.data.name:
+ templates_root = tree_item
+ break
+ if not templates_root:
+ _logger.error(
+ f"Requested plugin '{plugin}' is not present in the settings tree."
+ )
+ return
+ else:
+ templates_root = self._built_in_templates_branch
+
+ matched_item: TreeItem[SettingSectionItem] | None = None
+ for tree_item in templates_root.children:
+ if template_name == tree_item.data.name:
+ if not class_name:
+ matched_item = tree_item.children[0]
+ else:
+ for class_item in tree_item.children:
+ if class_name == class_item.data.name:
+ matched_item = class_item
+ break
+ break
+
+ if not matched_item:
+ _logger.error(
+ f"Requested template name {template_name} or class name {
+ class_name
+ } is not present in the settings tree."
+ )
+ return
+
+ self.selected_model_index = self.index_of_item(matched_item) # pyright: ignore[reportAttributeAccessIssue]
+
+ @Slot()
+ def save_configs(self) -> None:
+ self._app_config.app_config.save()
+ self._app_config.base_config.save()
+ self._save_template_configs(
+ self._template_library.built_in_templates_by_name.values()
+ )
+ for (
+ plugin_templates
+ ) in self._template_library.plugin_templates_by_name.values():
+ self._save_template_configs(plugin_templates.values())
+
+ def _save_template_configs(self, templates: Iterable[AssembledTemplate]) -> None:
+ for assembled_template in templates:
+ for named_temaplate in assembled_template.templates:
+ for template_details in named_temaplate.template_classes.values():
+ template_details["config"].save()
+
+ def _on_config_state_changed(
+ self, tree_item: TreeItem[SettingSectionItem], has_config: bool
+ ) -> None:
+ tree_item.data.has_config = has_config
+ q_idx = self.index_of_item(tree_item)
+ self.dataChanged.emit(q_idx, q_idx, [self.get_role("has_config")])
diff --git a/src/gui/qml/models/template_list_model.py b/src/gui/qml/models/template_list_model.py
new file mode 100644
index 00000000..81ed7c3f
--- /dev/null
+++ b/src/gui/qml/models/template_list_model.py
@@ -0,0 +1,308 @@
+from asyncio import ensure_future, gather, to_thread
+from collections.abc import Sequence
+from functools import cached_property
+from logging import getLogger
+from pathlib import Path
+from urllib.request import url2pathname
+
+from pydantic import BaseModel
+from PySide6.QtCore import QObject, QUrl, Slot
+
+from src._config import AppConfig
+from src._loader import (
+ AssembledTemplate,
+ AssembledTemplateConfigChangedArgs,
+ AssembledTemplateInstalledArgs,
+ TemplateLibrary,
+ sort_layout_categories,
+)
+from src.cards import CardDetails
+from src.enums.mtg import LayoutCategory
+from src.gui.qml.models.file_dialog_model import FileDialogModel
+from src.gui.qml.models.message_dialog_content_model import MessageDialogContentModel
+from src.gui.qml.models.pydantic_q_list_model import PydanticQListModel
+from src.gui.qml.models.test_renders_model import TestRendersModel
+from src.render.render_queue import RenderQueue, cancel_with_render
+from src.render.setup import prepare_render_operations
+from src.utils.data_structures import first
+from src.utils.inputs import get_cards_from_inputs
+from src.utils.scryfall import ScryfallCard
+
+_logger = getLogger(__name__)
+
+
+class TemplateData(BaseModel):
+ name: str
+ img: str | None
+ card_layouts: list[LayoutCategory]
+ installed_template_files: list[str]
+ missing_template_files: list[str]
+ is_installed: bool
+ has_config: bool
+ plugin: str
+
+ @cached_property
+ def assembled_template(self) -> AssembledTemplate:
+ raise ValueError("Assembled template is not set")
+
+ @cached_property
+ def full_name(self) -> str:
+ return self.name + (f" ({self.plugin})" if self.plugin else "")
+
+
+class TemplateListModel(PydanticQListModel[TemplateData]):
+ item_model = TemplateData
+
+ def __init__(
+ self,
+ render_queue: RenderQueue,
+ file_dialog_model: FileDialogModel,
+ message_dialog_model: MessageDialogContentModel,
+ template_library: TemplateLibrary,
+ test_renders_model: TestRendersModel,
+ app_config: AppConfig,
+ parent: QObject | None = None,
+ selected_index: int = 0,
+ ) -> None:
+ self._render_queue = render_queue
+ self._file_dialog_model = file_dialog_model
+ self._message_dialog_model = message_dialog_model
+ self._template_library = template_library
+ self._test_renders_model = test_renders_model
+ self._template_library = template_library
+ self._app_config = app_config
+
+ self.built_in_templates = template_library.built_in_templates_by_name
+ self.plugin_templates = template_library.plugin_templates_by_name
+
+ template_datas: list[TemplateData] = []
+
+ for name, assembled_template in self.built_in_templates.items():
+ template_data = TemplateData(
+ name=name,
+ card_layouts=(
+ layouts := sort_layout_categories(
+ assembled_template.layout_categories, LayoutCategory.Normal
+ )
+ ),
+ installed_template_files=assembled_template.installed_template_files,
+ missing_template_files=assembled_template.missing_template_files,
+ is_installed=assembled_template.is_installed(),
+ has_config=assembled_template.has_config(),
+ plugin="",
+ img=str(path.as_uri())
+ if (path := assembled_template.get_preview_image_path(first(layouts)))
+ else None,
+ )
+ template_data.assembled_template = assembled_template
+ template_datas.append(template_data)
+
+ for plugin_id, templates_by_name in self.plugin_templates.items():
+ for name, assembled_template in templates_by_name.items():
+ template_data = TemplateData(
+ name=name,
+ card_layouts=(
+ layouts := sort_layout_categories(
+ assembled_template.layout_categories,
+ LayoutCategory.Normal,
+ )
+ ),
+ installed_template_files=assembled_template.installed_template_files,
+ missing_template_files=assembled_template.missing_template_files,
+ is_installed=assembled_template.is_installed(),
+ has_config=assembled_template.has_config(),
+ plugin=plugin_id,
+ img=str(path.as_uri())
+ if (
+ path := assembled_template.get_preview_image_path(
+ first(layouts)
+ )
+ )
+ else None,
+ )
+ template_data.assembled_template = assembled_template
+ template_datas.append(template_data)
+
+ for template_data in template_datas:
+ template_data.assembled_template.template_installed.add_listener(
+ self._on_template_installed
+ )
+ template_data.assembled_template.config_state_changed.add_listener(
+ self._on_template_config_state_changed
+ )
+
+ super().__init__(
+ parent,
+ items=template_datas,
+ selected_index=selected_index,
+ )
+
+ self._sort_items()
+
+ async def render_files(self, paths: list[Path]) -> None:
+ if paths:
+ _logger.info(
+ f"Queueing {
+ len(paths)
+ } inputs for render. Do note that the actual amount of renders might be different if you selected JSON or YAML files or art for split cards."
+ )
+
+ selected_template_entry = self.items[self._selected_index]
+ if selected_template_entry.plugin:
+ template = self.plugin_templates[selected_template_entry.plugin][
+ selected_template_entry.name
+ ]
+ else:
+ template = self.built_in_templates[selected_template_entry.name]
+
+ def add_render(
+ input: CardDetails | tuple[CardDetails, ScryfallCard],
+ ) -> None:
+ if render_operations := prepare_render_operations(
+ template,
+ self._template_library,
+ (input,),
+ file_dialog=self._file_dialog_model,
+ message_dialog=self._message_dialog_model,
+ ):
+ self._render_queue.enqueue(render_operations[0])
+
+ async def add_renders(cards: Sequence[tuple[CardDetails, ScryfallCard]]):
+ await gather(*[to_thread(add_render, card) for card in cards])
+
+ await get_cards_from_inputs(paths, add_renders, self._app_config)
+
+ @Slot()
+ def render_selections(self) -> None:
+ async def action():
+ # Choose images
+ selections = await self._file_dialog_model.select_images(
+ dialog_id="template_list_render",
+ filters=[
+ FileDialogModel.IMAGES_AND_DATA_FILTER,
+ FileDialogModel.ALL_FILTER,
+ ],
+ )
+
+ await self.render_files(selections)
+
+ cancel_with_render(ensure_future(action()), self._render_queue)
+
+ @Slot("QVariantList")
+ def render_targets(self, urls: list[QUrl]) -> None:
+ paths: list[Path] | None = None
+ try:
+ paths = [Path(url2pathname(url.path())) for url in urls]
+ except Exception:
+ _logger.exception("Failed to process drag & dropped files.")
+ if paths:
+ cancel_with_render(
+ ensure_future(self.render_files(paths)), self._render_queue
+ )
+
+ @Slot(str, bool)
+ @Slot(str, bool, str)
+ def test_render(
+ self, layout: str | None = None, quick: bool = False, case: str | None = None
+ ) -> None:
+ async def action() -> None:
+ layout_category = LayoutCategory(layout) if layout else None
+
+ selected_template_entry = self.items[self._selected_index]
+ if selected_template_entry.plugin:
+ template = self.plugin_templates[selected_template_entry.plugin][
+ selected_template_entry.name
+ ]
+ else:
+ template = self.built_in_templates[selected_template_entry.name]
+
+ _logger.info(
+ f"Queueing {
+ layout_category.value + ' ' if layout_category else ''
+ }tests for template {template.name}."
+ )
+
+ await self._test_renders_model.test_renders(
+ {layout_category: (template,)}
+ if layout_category
+ else {category: (template,) for category in template.layout_categories},
+ quick,
+ case=case,
+ )
+
+ cancel_with_render(ensure_future(action()), self._render_queue)
+
+ @Slot(result=str)
+ def selected_template_name(self) -> str:
+ if self._selected_index > -1:
+ return self.items[self._selected_index].full_name
+ return ""
+
+ @Slot(str)
+ def select_template(self, name: str) -> None:
+ for idx, item in enumerate(self.items):
+ if item.full_name == name:
+ self.selected_index = idx # pyright: ignore[reportAttributeAccessIssue]
+
+ @Slot(int)
+ def clear_settings(self, idx: int) -> None:
+ item = self.items[idx]
+ for named_template in item.assembled_template.templates:
+ for template_details in named_template.template_classes.values():
+ template_details["config"].delete()
+
+ def _sort_items(self) -> None:
+ self.beginResetModel()
+ self.items.sort(key=lambda item: item.name)
+ self.items.sort(key=lambda item: item.plugin)
+ self.items.sort(key=lambda item: item.is_installed, reverse=True)
+ self.endResetModel()
+
+ def _on_template_installed(
+ self, event_args: AssembledTemplateInstalledArgs
+ ) -> None:
+ changed_template = event_args["sender"]
+ for idx, item in enumerate(self.items):
+ if item.assembled_template == changed_template:
+ old_value = item.is_installed
+ item.is_installed = changed_template.is_installed()
+ item.installed_template_files = (
+ changed_template.installed_template_files
+ )
+ item.missing_template_files = changed_template.missing_template_files
+ if old_value != item.is_installed:
+ # This is assumed to reset the model, so no need to emit dataChanged
+ self._sort_items()
+ else:
+ q_idx = self.createIndex(idx, 0)
+ self.dataChanged.emit(
+ q_idx,
+ q_idx,
+ [
+ self.get_role("is_installed"),
+ self.get_role("installed_template_files"),
+ self.get_role("missing_template_files"),
+ ],
+ )
+ return
+
+ _logger.warning(
+ f"Installed template was not found from the template list: {changed_template.name}"
+ )
+
+ def _on_template_config_state_changed(
+ self, event_args: AssembledTemplateConfigChangedArgs
+ ) -> None:
+ changed_template = event_args["sender"]
+ for idx, item in enumerate(self.items):
+ if item.assembled_template == changed_template:
+ old_value = item.has_config
+ if old_value != (new_value := event_args["has_config"]):
+ item.has_config = new_value
+ q_idx = self.createIndex(idx, 0)
+ self.dataChanged.emit(q_idx, q_idx, [self.get_role("has_config")])
+ return
+
+ _logger.warning(
+ f"Template with changed config state was not found from the template list: {changed_template.name}"
+ )
diff --git a/src/gui/qml/models/template_updater_model.py b/src/gui/qml/models/template_updater_model.py
new file mode 100644
index 00000000..9ea11697
--- /dev/null
+++ b/src/gui/qml/models/template_updater_model.py
@@ -0,0 +1,133 @@
+from asyncio import ensure_future, gather, to_thread
+from functools import cached_property
+
+from pydantic import BaseModel
+from PySide6.QtCore import Property, QObject, Signal, Slot
+
+from src._loader import AppTemplate, TemplateLibrary
+from src._state import AppEnvironment
+from src.enums.mtg import LayoutCategory
+from src.gui.qml.models.pydantic_q_list_model import PydanticQListModel
+from src.utils.hexapi import check_api_keys
+
+
+class DownloadableTemplateDetails(BaseModel):
+ file_name: str
+ google_drive_id: str | None
+ img: str
+ plugin: str
+ template_names: list[str]
+ template_classes: list[str]
+ layout_categories: list[LayoutCategory]
+ installed_version: str
+ available_version: str
+ download_size: int
+ """bytes"""
+ downloading: bool = False
+
+ @cached_property
+ def handler(self) -> AppTemplate:
+ raise ValueError("Download handler is not specified")
+
+
+class TemplateUpdaterModel(PydanticQListModel[DownloadableTemplateDetails]):
+ item_model = DownloadableTemplateDetails
+
+ def __init__(
+ self,
+ app_env: AppEnvironment,
+ template_library: TemplateLibrary,
+ parent: QObject | None = None,
+ items: list[DownloadableTemplateDetails] = [],
+ selected_index: int = -1,
+ ) -> None:
+ self._app_env = app_env
+ self._template_library = template_library
+
+ self._fetching_data = False
+
+ super().__init__(parent, items, selected_index)
+
+ _fetching_data_changed = Signal(name="fetchingDataChanged")
+
+ @Property(bool, notify=_fetching_data_changed)
+ def fetching_data(self) -> bool: # pyright: ignore[reportRedeclaration]
+ return self._fetching_data
+
+ @fetching_data.setter
+ def fetching_data(self, value: bool) -> None:
+ if value != self._fetching_data:
+ self._fetching_data = value
+ self._fetching_data_changed.emit()
+
+ @Slot()
+ def fetch_data(self) -> None:
+ ensure_future(self._handle_fetch_data())
+
+ async def _handle_fetch_data(self) -> None:
+ self.fetching_data = True # pyright: ignore[reportAttributeAccessIssue]
+
+ await check_api_keys(self._app_env)
+
+ await gather(
+ *[
+ to_thread(template.check_for_update)
+ for template in self._template_library.templates
+ ]
+ )
+
+ self.beginResetModel()
+ self.items = [
+ DownloadableTemplateDetails(
+ file_name=template.file_name,
+ google_drive_id=template.google_drive_id,
+ img=str(
+ template.get_path_preview(
+ class_name=(
+ first_item := next(
+ iter(template.all_classes_and_layouts.items())
+ )
+ )[0],
+ class_type=first_item[1][0],
+ ).as_uri()
+ ),
+ plugin=template.plugin.id if template.plugin else "",
+ template_names=template.all_names,
+ template_classes=template.all_classes,
+ layout_categories=template.supported_layout_categories,
+ installed_version=template.version or "",
+ available_version=template.update_version or "",
+ download_size=template.update_size or 0,
+ )
+ for template in self._template_library.templates
+ ]
+ for item, template in zip(self.items, self._template_library.templates):
+ item.handler = template
+ self.selected_index = 0 # pyright: ignore[reportAttributeAccessIssue]
+ self.endResetModel()
+
+ self.fetching_data = False # pyright: ignore[reportAttributeAccessIssue]
+
+ @Slot(int)
+ def download_template(self, index: int) -> None:
+ if index < 0 or index >= self.rowCount():
+ return
+
+ ensure_future(self._handle_template_download(index))
+
+ async def _handle_template_download(self, index: int) -> None:
+ item = self.items[index]
+ q_index = self.createIndex(index, 0)
+
+ item.downloading = True
+ changed_fields: list[int] = [self.get_role("downloading")]
+ self.dataChanged.emit(q_index, q_index, changed_fields)
+ if await to_thread(item.handler.update_template):
+ item.installed_version = item.handler.version or ""
+ changed_fields.append(self.get_role("installed_version"))
+ item.downloading = False
+ self.dataChanged.emit(q_index, q_index, changed_fields)
+
+ @Slot()
+ def save_versions(self) -> None:
+ self._template_library.save_template_versions()
diff --git a/src/gui/qml/models/test_renders_model.py b/src/gui/qml/models/test_renders_model.py
new file mode 100644
index 00000000..84a3f3a7
--- /dev/null
+++ b/src/gui/qml/models/test_renders_model.py
@@ -0,0 +1,199 @@
+from asyncio import ensure_future, gather, to_thread
+from collections.abc import Awaitable, Callable, Iterable, Sequence
+from functools import cached_property
+from logging import getLogger
+
+from PySide6.QtCore import Property, QObject, Signal, Slot
+
+from src._config import AppConfig
+from src._loader import AssembledTemplate, TemplateLibrary
+from src._state import PATH
+from src.cards import CardDetails, parse_card_info, process_card_data
+from src.enums.mtg import (
+ LayoutCategory,
+ LayoutType,
+ layout_map_category,
+ scryfall_layout_category_map,
+)
+from src.gui.qml.models.file_dialog_model import FileDialogModel
+from src.gui.qml.models.message_dialog_content_model import MessageDialogContentModel
+from src.render.render_queue import RenderQueue, cancel_with_render
+from src.render.setup import prepare_render_operations
+from src.utils.inputs import get_cards_from_details
+from src.utils.scryfall import ScryfallCard
+from src.utils.tests import get_template_render_test_cases, prepare_test_render
+
+_logger = getLogger(__name__)
+
+
+class TestRendersModel(QObject):
+ def __init__(
+ self,
+ render_queue: RenderQueue,
+ template_library: TemplateLibrary,
+ file_dialog_model: FileDialogModel,
+ message_dialog_model: MessageDialogContentModel,
+ app_config: AppConfig,
+ /,
+ parent: QObject | None = None,
+ *,
+ objectName: str | None = None,
+ ) -> None:
+ super().__init__(parent, objectName=objectName)
+ self._render_queue = render_queue
+ self._template_library = template_library
+ self._file_dialog_model = file_dialog_model
+ self._message_dialog_model = message_dialog_model
+ self._app_config = app_config
+ self._layout_categories = list(LayoutCategory)
+
+ @cached_property
+ def template_render_test_cases(self) -> dict[LayoutType, dict[str, str]]:
+ return get_template_render_test_cases()
+
+ _layout_categories_changed = Signal()
+
+ @Property(list, notify=_layout_categories_changed)
+ def layout_categories(self) -> list[LayoutCategory]:
+ return self._layout_categories
+
+ @Slot(str, result=list)
+ def get_test_cases_for_layout(self, layout: str) -> list[str]:
+ layout_category = LayoutCategory(layout)
+ all_cases: list[str] = []
+ for layout_type in layout_map_category.get(layout_category, []):
+ all_cases.extend(
+ self.template_render_test_cases.get(layout_type, {}).keys()
+ )
+ return all_cases
+
+ async def _run_action_per_layout_test_case(
+ self,
+ callback: Callable[
+ [Sequence[tuple[CardDetails, ScryfallCard]]], Awaitable[None]
+ ],
+ layout_categories: Iterable[LayoutCategory],
+ quick: bool,
+ case: str | None = None,
+ ) -> None:
+ cards: list[CardDetails] = []
+ for layout_category in layout_categories:
+ layout_types = layout_map_category[layout_category]
+ for layout_type in layout_types:
+ if test_cases := self.template_render_test_cases.get(layout_type, None):
+ for idx, test_case in enumerate(test_cases):
+ if quick and idx > 0:
+ break
+ if case is not None and test_case != case:
+ continue
+ cards.append(
+ parse_card_info(PATH.SRC_IMG_TEST, name_override=test_case)
+ )
+ if case:
+ break
+ await get_cards_from_details(cards, callback, self._app_config)
+
+ def _collect_categories(
+ self, templates: Iterable[AssembledTemplate]
+ ) -> set[LayoutCategory]:
+ layout_categories: set[LayoutCategory] = set()
+ for template in templates:
+ for category in template.layout_categories:
+ layout_categories.add(category)
+ return layout_categories
+
+ async def test_renders(
+ self,
+ templates: dict[LayoutCategory, Iterable[AssembledTemplate] | None]
+ | None = None,
+ quick: bool = False,
+ case: str | None = None,
+ ) -> None:
+ """Queues test renders.
+
+ Args:
+ templates: The layout categories and templates to queue tests for. Falsy value means that all tests should be queued. A falsy iterable of templates means that all applicable templates should be tested.
+ quick: Queue only the first test for each layout category and template combination.
+ case: Test only a specific case."""
+ if templates:
+ layout_categories_to_test: Iterable[LayoutCategory] = templates.keys()
+ for layout_category, test_templates in templates.items():
+ if not test_templates:
+ templates[layout_category] = (
+ self._template_library.get_templates_for_layout_category(
+ layout_category
+ )
+ )
+ else:
+ templates_to_test: list[AssembledTemplate] = list(
+ self._template_library.built_in_templates_by_name.values()
+ )
+ for (
+ plugin_templates
+ ) in self._template_library.plugin_templates_by_name.values():
+ templates_to_test.extend(plugin_templates.values())
+
+ layout_categories_to_test = self._collect_categories(templates_to_test)
+
+ # Test all templates when a more specific configuration isn't provided
+ templates = {
+ layout_category: [
+ template
+ for template in templates_to_test
+ if template.is_installed(layout_category)
+ ]
+ for layout_category in layout_categories_to_test
+ }
+
+ def process_test_render(
+ card: tuple[CardDetails, ScryfallCard], template: AssembledTemplate
+ ) -> None:
+ render_operations = prepare_render_operations(
+ template,
+ self._template_library,
+ (card,),
+ self._file_dialog_model,
+ self._message_dialog_model,
+ )
+ for op in render_operations:
+ op.before_render_callback = prepare_test_render
+ self._render_queue.enqueue(op)
+
+ async def queue_test_render(
+ cards: Sequence[tuple[CardDetails, ScryfallCard]],
+ ) -> None:
+ operations: list[Awaitable[None]] = []
+ for card in cards:
+ processed_scryfall_data = process_card_data(card[1], card[0])
+ layout_category = scryfall_layout_category_map[
+ processed_scryfall_data.layout
+ ]
+ if test_templates := templates.get(layout_category):
+ for template in test_templates:
+ operations.append(
+ to_thread(process_test_render, card, template)
+ )
+ await gather(*operations)
+
+ await self._run_action_per_layout_test_case(
+ queue_test_render, layout_categories_to_test, quick, case=case
+ )
+
+ @Slot(str, bool)
+ @Slot(str, bool, str)
+ def test_all(
+ self, layout: str | None = None, quick: bool = False, case: str | None = None
+ ) -> None:
+ _logger.info(
+ f"Queueing {'quick' if quick else 'all'} test renders{
+ f' for layout {layout}' if layout else ''
+ }"
+ )
+ cancel_with_render(
+ ensure_future(
+ self.test_renders(
+ {LayoutCategory(layout): None} if layout else None, quick, case=case
+ )
+ ),
+ self._render_queue,
+ )
diff --git a/src/gui/qml/qmldir b/src/gui/qml/qmldir
new file mode 100644
index 00000000..e191c862
--- /dev/null
+++ b/src/gui/qml/qmldir
@@ -0,0 +1,2 @@
+module qml
+App App.qml
\ No newline at end of file
diff --git a/src/gui/qml/tools/FontViewer.qml b/src/gui/qml/tools/FontViewer.qml
new file mode 100644
index 00000000..3c39bbed
--- /dev/null
+++ b/src/gui/qml/tools/FontViewer.qml
@@ -0,0 +1,34 @@
+pragma ComponentBehavior: Bound
+import QtQuick
+import QtQuick.Controls
+
+ApplicationWindow {
+ id: window
+
+ width: 400
+ height: 640
+ color: palet.window
+
+ SystemPalette {
+ id: palet
+ }
+
+ ListView {
+ anchors.fill: parent
+ model: Qt.fontFamilies()
+
+ delegate: Item {
+ id: fontDelegate
+
+ required property string modelData
+
+ height: 30
+ width: ListView.view.width
+ Text {
+ font.family: fontDelegate.modelData
+ text: fontDelegate.modelData
+ color: palet.text
+ }
+ }
+ }
+}
diff --git a/src/gui/qml/tools/ImageTransformWindow.qml b/src/gui/qml/tools/ImageTransformWindow.qml
new file mode 100644
index 00000000..c749fd5d
--- /dev/null
+++ b/src/gui/qml/tools/ImageTransformWindow.qml
@@ -0,0 +1,155 @@
+pragma ComponentBehavior: Bound
+import QtQml
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+import qml.components
+
+ApplicationWindow {
+ id: imageTransformWindow
+
+ required property SystemPalette systemPalette
+ required property QtObject transformModel
+
+ title: "Transform images"
+ width: 300
+ height: 360
+ leftPadding: 10
+ topPadding: 10
+ rightPadding: 10
+ bottomPadding: 10
+ visible: true
+ color: systemPalette.window
+
+ ColumnLayout {
+ anchors.fill: parent
+
+ spacing: 10
+
+ RowLayout {
+ Layout.fillWidth: true
+
+ Text {
+ Layout.alignment: Qt.AlignLeft
+ Layout.fillWidth: true
+
+ text: "Format"
+ color: imageTransformWindow.systemPalette.text
+ }
+ CustomComboBox {
+ Layout.alignment: Qt.AlignRight
+
+ systemPalette: imageTransformWindow.systemPalette
+ model: imageTransformWindow.transformModel.image_file_formats
+ currentValue: imageTransformWindow.transformModel.image_file_format
+
+ onActivated: idx => {
+ imageTransformWindow.transformModel.image_file_format = imageTransformWindow.transformModel.image_file_formats[currentIndex];
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+
+ Text {
+ Layout.alignment: Qt.AlignLeft
+ Layout.fillWidth: true
+
+ text: "Downscale"
+ color: imageTransformWindow.systemPalette.text
+ }
+ CustomCheckBox {
+ Layout.alignment: Qt.AlignRight
+
+ systemPalette: imageTransformWindow.systemPalette
+ checked: imageTransformWindow.transformModel.downscale
+
+ onClicked: {
+ imageTransformWindow.transformModel.downscale = !imageTransformWindow.transformModel.downscale;
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+
+ Text {
+ Layout.alignment: Qt.AlignLeft
+ Layout.fillWidth: true
+
+ text: "Target width (px)"
+ color: imageTransformWindow.systemPalette.text
+ }
+ CustomSpinBox {
+ Layout.alignment: Qt.AlignRight
+
+ editable: true
+ from: 1
+ to: 100000
+ value: imageTransformWindow.transformModel.downscale_width
+
+ onValueModified: {
+ imageTransformWindow.transformModel.downscale_width = value;
+ }
+ }
+ }
+
+ Text {
+ Layout.fillWidth: true
+
+ text: `Typical widths for a proxy image, that has bleed, at different levels of DPI include:
+600: 1632 px
+800: 2176 px
+1200: 3264 px`
+ color: imageTransformWindow.systemPalette.text
+ wrapMode: Text.WordWrap
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+
+ Text {
+ Layout.alignment: Qt.AlignLeft
+ Layout.fillWidth: true
+
+ text: "Quality"
+ color: imageTransformWindow.systemPalette.text
+ }
+ CustomSpinBox {
+ Layout.alignment: Qt.AlignRight
+
+ editable: true
+ from: 1
+ to: 100
+ value: imageTransformWindow.transformModel.encoding_quality
+
+ onValueModified: {
+ imageTransformWindow.transformModel.encoding_quality = value;
+ }
+ }
+ }
+
+ Text {
+ Layout.fillWidth: true
+
+ text: "Quality doesn't affect PNG encoding."
+ color: imageTransformWindow.systemPalette.text
+ wrapMode: Text.WordWrap
+ }
+
+ CustomButton {
+ Layout.alignment: Qt.AlignRight
+
+ systemPalette: imageTransformWindow.systemPalette
+ bgColor: imageTransformWindow.systemPalette.button
+ text: "Transform"
+ onClicked: imageTransformWindow.transformModel.transform_images()
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+ }
+}
diff --git a/src/gui/qml/tools/SystemPaletteViewer.qml b/src/gui/qml/tools/SystemPaletteViewer.qml
new file mode 100644
index 00000000..0f97b861
--- /dev/null
+++ b/src/gui/qml/tools/SystemPaletteViewer.qml
@@ -0,0 +1,197 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+ApplicationWindow {
+ id: window
+
+ width: 800
+ height: 300
+ color: palet.window
+
+ SystemPalette {
+ id: palet
+ }
+
+ Flow {
+ anchors.fill: parent
+ spacing: 10
+
+ ColumnLayout {
+ Text {
+ text: "accent"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.accent
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "alternateBase"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.alternateBase
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "base"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.base
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "button"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.button
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "buttonText"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.buttonText
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "dark"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.dark
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "highlight"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.highlight
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "highlightedText"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.highlightedText
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "light"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.light
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "mid"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.mid
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "midlight"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.midlight
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "placeholderText"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.placeholderText
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "shadow"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.shadow
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "text"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.text
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "window"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.window
+ }
+ }
+ ColumnLayout {
+ Text {
+ text: "windowText"
+ color: palet.text
+ }
+ Rectangle {
+ width: 50
+ height: 50
+ color: palet.windowText
+ }
+ }
+ }
+}
diff --git a/src/gui/qml/tools/qmldir b/src/gui/qml/tools/qmldir
new file mode 100644
index 00000000..c321a1d7
--- /dev/null
+++ b/src/gui/qml/tools/qmldir
@@ -0,0 +1,3 @@
+module qml.tools
+ImageTransformWindow ImageTransformWindow.qml
+SystemPaletteViewer SystemPaletteViewer.qml
\ No newline at end of file
diff --git a/src/gui/qml/views/BatchRenderView.qml b/src/gui/qml/views/BatchRenderView.qml
new file mode 100644
index 00000000..119a86f6
--- /dev/null
+++ b/src/gui/qml/views/BatchRenderView.qml
@@ -0,0 +1,89 @@
+pragma ComponentBehavior: Bound
+import QtQml
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+import qml.components
+
+ListView {
+ id: batchRenderList
+
+ required property SystemPalette systemPalette
+ required property AbstractListModel batchModel
+ required property QtObject pathModel
+ property double requiredLayoutNameWidth: 0
+
+ TextMetrics {
+ id: textMetrics
+ }
+
+ Component.onCompleted: {
+ let requiredW = 0;
+ for (const name of batchModel.layout_names()) {
+ textMetrics.text = name;
+ if (textMetrics.width > requiredW) {
+ requiredW = textMetrics.width;
+ }
+ }
+ textMetrics.destroy();
+ requiredLayoutNameWidth = requiredW + 10;
+ }
+
+ spacing: 1
+ orientation: ListView.Vertical
+ boundsBehavior: Flickable.StopAtBounds
+ boundsMovement: Flickable.StopAtBounds
+ reuseItems: true
+ clip: true
+ focus: true
+ highlightFollowsCurrentItem: false
+ currentIndex: -1
+ model: batchModel
+ delegate: RowLayout {
+ id: batchRenderListDelegate
+
+ width: batchRenderList.width
+ height: 30
+ spacing: 0
+
+ required property int index
+ required property string name
+ required property int selected
+ required property list options
+ required property list options_installed
+ required property list options_preview_img_path
+
+ Text {
+ Layout.alignment: Qt.AlignLeft
+ Layout.minimumWidth: batchRenderList.requiredLayoutNameWidth
+ Layout.leftMargin: 10
+
+ text: batchRenderListDelegate.name
+ color: batchRenderList.systemPalette.text
+ }
+ CustomComboBox {
+ id: templateComboBox
+
+ Layout.alignment: Qt.AlignRight
+ Layout.fillWidth: true
+
+ function getItemTextColor(index: int): color {
+ return batchRenderListDelegate.options_installed[index] ? systemPalette.text : systemPalette.placeholderText;
+ }
+
+ systemPalette: batchRenderList.systemPalette
+ itemTextColor: getItemTextColor
+ implicitHeight: 30
+ model: batchRenderListDelegate.options
+ tooltipModel: batchRenderListDelegate.options_preview_img_path.map(path => `
`)
+ currentIndex: batchRenderListDelegate.selected
+
+ onActivated: idx => {
+ batchRenderList.batchModel.select_template_for_layout(batchRenderListDelegate.index, templateComboBox.currentIndex);
+ }
+ }
+ }
+
+ ScrollBar.vertical: ScrollBar {}
+}
diff --git a/src/gui/qml/views/Console.qml b/src/gui/qml/views/Console.qml
new file mode 100644
index 00000000..abf7351c
--- /dev/null
+++ b/src/gui/qml/views/Console.qml
@@ -0,0 +1,104 @@
+pragma ComponentBehavior: Bound
+import QtCore
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+import qml.components
+
+ColumnLayout {
+ id: root
+
+ required property SystemPalette systemPalette
+ required property AbstractListModel logModel
+ required property QtObject pathModel
+ required property string emojiFontFamily
+ required property string monospaceFontFamily
+
+ Settings {
+ id: settings
+
+ category: "Console"
+ location: root.pathModel.get_preferences_path("Console.ini")
+
+ property bool autoScroll: true
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+
+ Text {
+ Layout.fillWidth: true
+ Layout.leftMargin: 10
+ Layout.topMargin: 5
+ Layout.bottomMargin: 5
+
+ text: "Log"
+ color: root.systemPalette.text
+ }
+ CustomButton {
+ Layout.alignment: Qt.AlignRight
+
+ systemPalette: root.systemPalette
+ text: `β¬ Autoscroll`
+ palette.buttonText: settings.autoScroll ? root.systemPalette.buttonText : root.systemPalette.placeholderText
+ onClicked: settings.autoScroll = !settings.autoScroll
+ }
+ CustomButton {
+ Layout.alignment: Qt.AlignRight
+
+ systemPalette: root.systemPalette
+ text: `π Copy`
+ onClicked: root.logModel.copy_log()
+ }
+ }
+ ListView {
+ id: logList
+
+ Layout.fillHeight: true
+ Layout.fillWidth: true
+
+ leftMargin: 0
+ rightMargin: 10
+ orientation: ListView.Vertical
+ boundsBehavior: Flickable.StopAtBounds
+ boundsMovement: Flickable.StopAtBounds
+ clip: true
+ highlightFollowsCurrentItem: false
+ model: root.logModel
+ delegate: RowLayout {
+ id: logDelegate
+
+ required property int index
+ required property string message
+ required property int severity
+ required property color color
+
+ width: logList.width
+ spacing: 0
+
+ Rectangle {
+ Layout.fillHeight: true
+
+ implicitWidth: 5
+ color: logDelegate.color
+ }
+ SelectableText {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.leftMargin: 5
+
+ color: root.systemPalette.text
+ text: logDelegate.message
+ font.family: root.monospaceFontFamily
+ }
+ }
+
+ onCountChanged: {
+ if (settings.autoScroll)
+ Qt.callLater(logList.positionViewAtEnd);
+ }
+
+ ScrollBar.vertical: ScrollBar {}
+ }
+}
diff --git a/src/gui/qml/views/RenderQueue.qml b/src/gui/qml/views/RenderQueue.qml
new file mode 100644
index 00000000..9ed839a4
--- /dev/null
+++ b/src/gui/qml/views/RenderQueue.qml
@@ -0,0 +1,323 @@
+pragma ComponentBehavior: Bound
+import QtCore
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+import qml.components
+
+ApplicationWindow {
+ id: renderQueueWindow
+
+ required property SystemPalette systemPalette
+ required property string emojiFontName
+ required property AbstractListModel queueModel
+ required property QtObject pathModel
+
+ title: "Render queue"
+ width: 800
+ height: 640
+ visible: true
+ color: systemPalette.window
+
+ Settings {
+ id: settings
+
+ category: "RenderQueue"
+ location: renderQueueWindow.pathModel.get_preferences_path("RenderQueue.ini")
+
+ property alias windowWidth: renderQueueWindow.width
+ property alias windowHeight: renderQueueWindow.height
+ property alias windowX: renderQueueWindow.x
+ property alias windowY: renderQueueWindow.y
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+
+ Rectangle {
+ Layout.fillWidth: true
+
+ implicitWidth: headerContent.implicitWidth
+ implicitHeight: headerContent.implicitHeight
+ color: renderQueueWindow.systemPalette.button
+
+ RowLayout {
+ id: headerContent
+
+ anchors.fill: parent
+ spacing: 10
+
+ Text {
+ Layout.alignment: Qt.AlignLeft
+ Layout.leftMargin: 10
+ Layout.topMargin: 5
+ Layout.bottomMargin: 5
+
+ text: "Currently rendering:"
+ font.pointSize: 12
+ color: renderQueueWindow.systemPalette.text
+ }
+ Loader {
+ Layout.alignment: Qt.AlignLeft
+ Layout.fillWidth: true
+ Layout.topMargin: 5
+ Layout.bottomMargin: 5
+
+ asynchronous: true
+ active: true
+ sourceComponent: renderQueueWindow.queueModel.is_rendering ? renderingActiveComponent : renderingInactiveComponent
+
+ Component {
+ id: renderingActiveComponent
+
+ RowLayout {
+ anchors.fill: parent
+
+ spacing: 5
+
+ ColumnLayout {
+ Layout.fillWidth: true
+
+ spacing: 2
+
+ SelectableText {
+ text: renderQueueWindow.queueModel.rendering_image_name
+ font.pointSize: 11
+ color: renderQueueWindow.systemPalette.text
+
+ ToolTip.delay: 300
+ ToolTip.visible: renderingHoverHandler.hovered
+ ToolTip.text: renderQueueWindow.queueModel.rendering_image_path
+
+ HoverHandler {
+ id: renderingHoverHandler
+ }
+ }
+ Flow {
+ Layout.fillWidth: true
+
+ spacing: 2
+
+ SelectableText {
+ text: `Card: ${renderQueueWindow.queueModel.rendering_card_name} (${renderQueueWindow.queueModel.rendering_card_artist}) [${renderQueueWindow.queueModel.rendering_card_set}] {${renderQueueWindow.queueModel.rendering_card_collector_number}}`
+ font.pointSize: 10
+ color: renderQueueWindow.systemPalette.text
+ }
+ Item {
+ implicitWidth: 13
+ implicitHeight: 1
+ }
+ SelectableText {
+ text: "Layout: " + renderQueueWindow.queueModel.rendering_layout_name
+ font.pointSize: 10
+ color: renderQueueWindow.systemPalette.text
+ }
+ Item {
+ implicitWidth: 13
+ implicitHeight: 1
+ }
+ SelectableText {
+ text: "Class name: " + renderQueueWindow.queueModel.rendering_class_name
+ font.pointSize: 10
+ color: renderQueueWindow.systemPalette.text
+ }
+ }
+ }
+ Item {
+ Layout.fillWidth: true
+ }
+ CustomButton {
+ Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
+ Layout.rightMargin: 10
+
+ systemPalette: renderQueueWindow.systemPalette
+ text: "β"
+ palette.buttonText: !enabled ? renderQueueWindow.systemPalette.placeholderText : hovered || down ? "red" : renderQueueWindow.systemPalette.buttonText
+ enabled: renderQueueWindow.queueModel.is_rendering
+
+ onClicked: renderQueueWindow.queueModel.cancel()
+ }
+ }
+ }
+ Component {
+ id: renderingInactiveComponent
+
+ SelectableText {
+ text: "Nothing"
+ font.pointSize: 11
+ color: renderQueueWindow.systemPalette.text
+ }
+ }
+ }
+ }
+ }
+ Rectangle {
+ Layout.fillWidth: true
+
+ implicitWidth: subheaderContent.implicitWidth
+ implicitHeight: subheaderContent.implicitHeight
+ color: renderQueueWindow.systemPalette.base
+
+ RowLayout {
+ id: subheaderContent
+
+ anchors.fill: parent
+ spacing: 5
+
+ Text {
+ Layout.leftMargin: 10
+
+ text: "Queue"
+ color: renderQueueWindow.systemPalette.text
+ }
+ CustomButton {
+ Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
+ Layout.rightMargin: 10
+
+ systemPalette: renderQueueWindow.systemPalette
+ text: `π§Ή Clear`
+
+ onClicked: renderQueueWindow.queueModel.clear()
+ }
+ }
+ }
+ ListView {
+ id: queueList
+
+ property int indexZeroPadding: 0
+
+ Connections {
+ target: renderQueueWindow.queueModel
+
+ function onRowsInserted() {
+ const requiredPadding = renderQueueWindow.queueModel.rowCount().toString().length;
+ if (requiredPadding != queueList.indexZeroPadding) {
+ queueList.indexZeroPadding = requiredPadding;
+ }
+ }
+ function onRowsRemoved() {
+ const requiredPadding = renderQueueWindow.queueModel.rowCount().toString().length;
+ if (requiredPadding != queueList.indexZeroPadding) {
+ queueList.indexZeroPadding = requiredPadding;
+ }
+ }
+ }
+
+ TextMetrics {
+ id: textMetrics
+ text: "8888."
+ }
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ spacing: 5
+ orientation: ListView.Vertical
+ boundsBehavior: Flickable.StopAtBounds
+ boundsMovement: Flickable.StopAtBounds
+ clip: true
+ focus: true
+ highlightFollowsCurrentItem: false
+ currentIndex: -1
+ model: renderQueueWindow.queueModel
+ delegate: RowLayout {
+ id: queueDelegate
+
+ required property int index
+ required property string image_name
+ required property string image_path
+ required property string card_name
+ required property string card_artist
+ required property string card_set
+ required property string card_collector_number
+ required property string layout_name
+ required property string class_name
+
+ width: queueList.width
+ spacing: 5
+
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.minimumWidth: textMetrics.width + 5
+ Layout.leftMargin: 10
+ Layout.topMargin: 2
+
+ text: queueDelegate.index.toString().padStart(queueList.indexZeroPadding, "0") + "."
+ font.pointSize: 11
+ color: renderQueueWindow.systemPalette.text
+ }
+ ColumnLayout {
+ Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
+ Layout.fillWidth: true
+ Layout.leftMargin: 10
+
+ spacing: 2
+
+ SelectableText {
+ Layout.alignment: Qt.AlignLeft
+
+ text: queueDelegate.image_name
+ font.pointSize: 10
+ color: renderQueueWindow.systemPalette.text
+
+ ToolTip.delay: 300
+ ToolTip.visible: hoverHandler.hovered
+ ToolTip.text: queueDelegate.image_path
+
+ HoverHandler {
+ id: hoverHandler
+ }
+ }
+ Flow {
+ Layout.alignment: Qt.AlignLeft
+ Layout.fillWidth: true
+
+ spacing: 2
+
+ SelectableText {
+ text: `Card: ${queueDelegate.card_name} (${queueDelegate.card_artist}) [${queueDelegate.card_set}] {${queueDelegate.card_collector_number}}`
+ font.pointSize: 9
+ color: renderQueueWindow.systemPalette.text
+ }
+ Item {
+ implicitWidth: 13
+ implicitHeight: 1
+ }
+ SelectableText {
+ text: "Layout: " + queueDelegate.layout_name
+ font.pointSize: 9
+ color: renderQueueWindow.systemPalette.text
+ }
+ Item {
+ implicitWidth: 13
+ implicitHeight: 1
+ }
+ SelectableText {
+ text: "Class name: " + queueDelegate.class_name
+ font.pointSize: 9
+ color: renderQueueWindow.systemPalette.text
+ }
+ }
+ }
+ Item {
+ Layout.fillWidth: true
+ }
+ CustomButton {
+ Layout.alignment: Qt.AlignTop | Qt.AlignRight
+ Layout.topMargin: 2
+ Layout.rightMargin: 10
+
+ systemPalette: renderQueueWindow.systemPalette
+ text: "β"
+ palette.buttonText: hovered || down ? "red" : renderQueueWindow.systemPalette.buttonText
+
+ onClicked: renderQueueWindow.queueModel.dequeue(queueDelegate.index)
+ }
+ }
+
+ ScrollBar.vertical: ScrollBar {}
+ }
+ }
+}
diff --git a/src/gui/qml/views/SettingsWindow.qml b/src/gui/qml/views/SettingsWindow.qml
new file mode 100644
index 00000000..301a0994
--- /dev/null
+++ b/src/gui/qml/views/SettingsWindow.qml
@@ -0,0 +1,507 @@
+pragma ComponentBehavior: Bound
+import QtCore
+import QtQml
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+import qml.components
+
+ApplicationWindow {
+ id: settingsWindow
+
+ required property SystemPalette systemPalette
+ required property QtObject pathModel
+ required property string emojiFontName
+ required property QtObject settingsTreeModel
+ required property QtObject settingsModel
+
+ title: "Settings"
+ width: 800
+ height: 640
+ visible: true
+ color: systemPalette.window
+
+ component RoleData: QtObject {
+ property string type
+ property string title
+ }
+
+ SortFilterProxyModel {
+ id: sfSettingsModel
+
+ model: settingsWindow.settingsModel
+ filters: [
+ FunctionFilter {
+ id: settingsFilter
+
+ property string searchString: ""
+ property string effectiveSearchString: searchString.toLowerCase()
+
+ function filter(data: RoleData): bool {
+ if (!settingsFilter.searchString)
+ return true;
+ return data.type !== "title" && data.title.toLowerCase().includes(settingsFilter.effectiveSearchString);
+ }
+ }
+ ]
+ }
+
+ Timer {
+ id: settingsFilterInvalidationDebounce
+
+ interval: 150
+ repeat: false
+ onTriggered: settingsFilter.invalidate()
+ }
+
+ Settings {
+ id: settings
+
+ category: "Settings"
+ location: settingsWindow.pathModel.get_preferences_path("Settings.ini")
+
+ property alias windowWidth: settingsWindow.width
+ property alias windowHeight: settingsWindow.height
+ property alias windowX: settingsWindow.x
+ property alias windowY: settingsWindow.y
+ property var settingsSectionsSplitState
+ }
+
+ Component.onCompleted: {
+ settingsSectionsSplit.restoreState(settings.settingsSectionsSplitState);
+ }
+ Component.onDestruction: {
+ settings.settingsSectionsSplitState = settingsSectionsSplit.saveState();
+ }
+
+ SplitView {
+ id: settingsSectionsSplit
+
+ anchors.fill: parent
+ orientation: Qt.Horizontal
+
+ TreeView {
+ id: settingsSections
+
+ SplitView.fillHeight: true
+ SplitView.preferredWidth: 200
+ SplitView.minimumWidth: 150
+
+ boundsBehavior: Flickable.StopAtBounds
+ boundsMovement: Flickable.StopAtBounds
+ reuseItems: true
+ clip: true
+ focus: true
+ animate: false
+ alternatingRows: false
+ palette.base: settingsWindow.systemPalette.window
+ model: settingsWindow.settingsTreeModel
+ delegate: TreeViewDelegate {
+ id: settingsSectionDelegate
+
+ required property string name
+ required property bool has_config
+
+ implicitWidth: settingsSections.width
+ selected: settingsSections.rowAtIndex(settingsWindow.settingsTreeModel.selected_model_index) === row
+ onClicked: {
+ settingsWindow.settingsTreeModel.selected_model_index = settingsSections.index(row, 0);
+ }
+ background.implicitHeight: 0
+ background.implicitWidth: 0
+ indicator.implicitHeight: 20
+
+ // We have to override content item because otherwise it tries to get
+ // display role in a way that for some reason doesn't reach the Python data model?
+ contentItem: Label {
+ clip: false
+ text: settingsSectionDelegate.name
+ elide: Text.ElideRight
+ color: settingsSectionDelegate.has_config ? settingsSectionDelegate.highlighted ? settingsSectionDelegate.palette.highlightedText : settingsSectionDelegate.palette.buttonText : settingsWindow.systemPalette.placeholderText
+ visible: !settingsSectionDelegate.editing
+ }
+ }
+
+ Connections {
+ id: treeViewConnections
+
+ target: settingsWindow.settingsTreeModel
+
+ function onSelectedModelIndexChanged(): void {
+ const idx = settingsWindow.settingsTreeModel.selected_model_index;
+ if (idx.valid) {
+ settingsSections.expandToIndex(idx);
+ settingsSections.positionViewAtIndex(idx, TreeView.AlignCenter, Qt.point(0, 0), Qt.rect(0, 0, 0, 0));
+ }
+ }
+ }
+
+ Timer {
+ id: positionTimer
+
+ interval: 50
+ running: false
+ repeat: true
+ onTriggered: {
+ // Somewhat hacky way to wait for the model to load.
+ // This might not guarantee the model to be ready?
+ if (settingsWindow.settingsTreeModel.rowCount()) {
+ treeViewConnections.onSelectedModelIndexChanged();
+ running = false;
+ }
+ }
+ }
+
+ Component.onCompleted: positionTimer.running = true
+ }
+ ColumnLayout {
+ SplitView.fillHeight: true
+ SplitView.fillWidth: true
+
+ spacing: 0
+
+ Rectangle {
+ id: headerBackground
+
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+ Layout.minimumHeight: headerTitle.height + 10
+
+ color: settingsWindow.systemPalette.button
+
+ RowLayout {
+ id: settingsHeader
+
+ anchors.fill: parent
+ spacing: 5
+
+ SelectableText {
+ id: headerTitle
+
+ Layout.alignment: Qt.AlignLeft
+ Layout.leftMargin: 10
+
+ text: settingsWindow.settingsTreeModel.selected_title
+ font.pointSize: 16
+ color: settingsWindow.systemPalette.text
+ }
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.minimumHeight: settingsSearchInput.height
+ Layout.maximumHeight: settingsSearchInput.height
+
+ color: settingsWindow.systemPalette.base
+
+ RowLayout {
+ anchors.fill: parent
+ spacing: 3
+
+ Text {
+ Layout.leftMargin: 3
+
+ text: `π`
+ font.pointSize: 11
+ }
+ CustomTextField {
+ id: settingsSearchInput
+
+ Layout.fillWidth: true
+
+ text: settingsFilter.searchString
+ color: settingsWindow.systemPalette.text
+ placeholderText: "Search settings"
+
+ function onSetValue() {
+ settingsFilter.searchString = settingsSearchInput.text;
+ settingsFilterInvalidationDebounce.restart();
+ }
+
+ //onEditingFinished: onSetValue()
+ onTextEdited: onSetValue()
+ }
+ }
+ }
+ CustomButton {
+ id: resetButton
+
+ systemPalette: settingsWindow.systemPalette
+ text: `π Reset`
+ visible: settingsWindow.settingsModel.valid
+
+ ToolTip.delay: 300
+ ToolTip.visible: resetButton.hovered
+ ToolTip.text: "Restore default settings values"
+
+ onClicked: {
+ settingsWindow.settingsModel.reset();
+ }
+ }
+ CustomButton {
+ id: clearButton
+
+ Layout.rightMargin: 5
+
+ systemPalette: settingsWindow.systemPalette
+ text: `π§Ή Clear`
+ visible: settingsWindow.settingsModel.valid
+
+ ToolTip.delay: 300
+ ToolTip.visible: clearButton.hovered
+ ToolTip.text: "Delete the settings file"
+
+ onClicked: {
+ settingsWindow.settingsModel.clear();
+ }
+ }
+ }
+ }
+ ListView {
+ id: settingsList
+
+ Layout.fillHeight: true
+ Layout.fillWidth: true
+
+ orientation: ListView.Vertical
+ boundsBehavior: Flickable.StopAtBounds
+ boundsMovement: Flickable.StopAtBounds
+ reuseItems: false
+ clip: true
+ spacing: 5
+ highlightFollowsCurrentItem: false
+ model: sfSettingsModel
+ delegate: ItemDelegate {
+ id: settingsListDelegate
+
+ required property int index
+ required property string type
+ required property string title
+ required property string desc
+ required property var value
+ required property var default_value
+ required property var options
+ property bool isTitle: settingsListDelegate.type === "title"
+ readonly property int sourceIndex: sfSettingsModel.mapToSource(sfSettingsModel.index(index, 0)).row
+
+ width: settingsList.width
+ highlighted: false
+ background: Rectangle {
+ color: settingsListDelegate.isTitle ? settingsWindow.systemPalette.base : "transparent"
+ }
+
+ contentItem: RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 10
+ anchors.rightMargin: 10
+ spacing: 10
+
+ ColumnLayout {
+ Layout.alignment: settingsListDelegate.isTitle ? Qt.AlignVCenter : Qt.AlignTop
+ Layout.fillWidth: true
+
+ SelectableText {
+ Layout.alignment: settingsListDelegate.isTitle ? Qt.AlignVCenter : Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: settingsListDelegate.isTitle ? settingsListDelegate.title : `${settingsListDelegate.title}`
+ color: settingsWindow.systemPalette.text
+ font.pointSize: settingsListDelegate.isTitle ? 12 : descText.font.pointSize
+ }
+ SelectableText {
+ id: descText
+
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: settingsListDelegate.desc
+ color: settingsWindow.systemPalette.text
+ visible: Boolean(settingsListDelegate.desc)
+ }
+ SelectableText {
+ id: defaultText
+
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: "Default: " + settingsListDelegate.default_value
+ color: settingsWindow.systemPalette.placeholderText
+ visible: !settingsListDelegate.isTitle
+ }
+ }
+ Loader {
+ id: inputLoader
+
+ property bool isTextInput: sourceComponent === textInputComponent
+
+ Layout.alignment: Qt.AlignRight
+ Layout.minimumWidth: isTextInput ? 50 : 0
+ Layout.maximumWidth: isTextInput ? parent.width / 2 : inputLoader.implicitWidth
+
+ asynchronous: true
+ sourceComponent: {
+ switch (settingsListDelegate.type) {
+ case "bool":
+ return checkboxComponent;
+ case "string":
+ return textInputComponent;
+ case "numeric":
+ if (Number.isInteger(settingsListDelegate.default_value)) {
+ return spinBoxIntComponent;
+ }
+ return spinBoxDouble;
+ case "options":
+ return comboBox;
+ case "int":
+ return spinBoxIntComponent;
+ case "float":
+ return spinBoxDouble;
+ default:
+ return undefined;
+ }
+ }
+
+ Component {
+ id: textInputComponent
+
+ CustomTextField {
+ id: settingTextInput
+
+ text: settingsListDelegate.value
+ color: settingsWindow.systemPalette.text
+
+ function onSetValue() {
+ settingsWindow.settingsModel.str_value_changed(settingsListDelegate.sourceIndex, settingTextInput.text);
+ }
+
+ onEditingFinished: onSetValue()
+
+ Connections {
+ target: settingsWindow
+
+ function onClosing(): void {
+ if (settingTextInput.activeFocus)
+ settingTextInput.onSetValue();
+ }
+ }
+ }
+ }
+ Component {
+ id: checkboxComponent
+
+ CustomCheckBox {
+ id: settingCheckbox
+
+ systemPalette: settingsWindow.systemPalette
+ checked: settingsListDelegate.value
+
+ onClicked: settingsWindow.settingsModel.bool_value_changed(settingsListDelegate.sourceIndex, settingCheckbox.checked)
+ }
+ }
+ Component {
+ id: spinBoxIntComponent
+
+ CustomSpinBox {
+ id: settingSpinBoxInt
+
+ editable: true
+ from: -2147483648
+ to: 2147483647
+ value: settingsListDelegate.value
+
+ function onSetValue(): void {
+ settingsWindow.settingsModel.int_value_changed(settingsListDelegate.sourceIndex, settingSpinBoxInt.value);
+ }
+
+ onValueModified: onSetValue()
+
+ Connections {
+ target: settingsWindow
+
+ function onClosing(): void {
+ if (settingSpinBoxInt.activeFocus) {
+ // Forcibly trigger onValueModified on window close
+ settingSpinBoxInt.focus = false;
+ settingSpinBoxInt.focus = true;
+ }
+ }
+ }
+ }
+ }
+ Component {
+ id: spinBoxDouble
+
+ CustomSpinBox {
+ id: settingSpinBoxDouble
+
+ editable: true
+ from: -2147483648
+ to: 2147483647
+ value: decimalToInt(settingsListDelegate.value)
+ stepSize: decimalFactor
+ anchors.centerIn: parent
+
+ property int decimals: 3
+ property real realValue: value / decimalFactor
+ readonly property int decimalFactor: Math.pow(10, decimals)
+
+ function decimalToInt(decimal: double): int {
+ return decimal * decimalFactor;
+ }
+
+ validator: RegularExpressionValidator {
+ regularExpression: /[0-9]+[.]?[0-9]*/
+ }
+
+ // locale is of type Locale but throws a warning if annotated
+ // citing insufficient annotation of the function
+ textFromValue: function (value: double, locale): string {
+ return (value / decimalFactor).toString();
+ }
+
+ valueFromText: function (text: string, locale): int {
+ return Math.round(parseFloat(text) * decimalFactor);
+ }
+
+ function onSetValue(): void {
+ settingsWindow.settingsModel.float_value_changed(settingsListDelegate.sourceIndex, settingSpinBoxDouble.realValue);
+ }
+
+ onValueModified: onSetValue()
+
+ Connections {
+ target: settingsWindow
+
+ function onClosing(): void {
+ if (settingSpinBoxDouble.activeFocus) {
+ // Forcibly trigger onValueModified on window close
+ settingSpinBoxDouble.focus = false;
+ settingSpinBoxDouble.focus = true;
+ }
+ }
+ }
+ }
+ }
+ Component {
+ id: comboBox
+
+ CustomComboBox {
+ id: settingComboBox
+
+ systemPalette: settingsWindow.systemPalette
+ model: settingsListDelegate.options
+ currentValue: settingsListDelegate.value
+
+ onActivated: idx => {
+ settingsWindow.settingsModel.str_value_changed(settingsListDelegate.sourceIndex, settingComboBox.model[idx]);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ ScrollBar.vertical: ScrollBar {}
+ }
+ }
+ }
+}
diff --git a/src/gui/qml/views/TemplateDetails.qml b/src/gui/qml/views/TemplateDetails.qml
new file mode 100644
index 00000000..fbb04412
--- /dev/null
+++ b/src/gui/qml/views/TemplateDetails.qml
@@ -0,0 +1,147 @@
+pragma ComponentBehavior: Bound
+import QtCore
+import QtQml
+import QtQuick
+import QtQuick.Controls
+
+import qml.components
+
+Rectangle {
+ id: templateDetails
+
+ required property SystemPalette systemPalette
+ required property AbstractListModel templateListMdl
+ required property DelegateModel templateListDelegateMdl
+ required property QtObject pathModel
+ property var model: templateListDelegateMdl.items.count ? templateListDelegateMdl.items.get(templateListMdl.selected_index).model : undefined
+
+ Settings {
+ id: settings
+
+ category: "TemplateDetails"
+ location: templateDetails.pathModel.get_preferences_path("TemplateDetails.ini")
+
+ property var detailsSplitState
+ }
+
+ Component.onCompleted: {
+ detailsSplit.restoreState(settings.detailsSplitState);
+ }
+ Component.onDestruction: {
+ settings.detailsSplitState = detailsSplit.saveState();
+ }
+
+ color: systemPalette.window
+
+ SplitView {
+ id: detailsSplit
+
+ anchors.fill: parent
+ orientation: Qt.Vertical
+
+ Image {
+ SplitView.fillWidth: true
+ SplitView.preferredHeight: 270
+
+ verticalAlignment: Image.AlignTop
+ asynchronous: true
+ source: templateDetails.model?.img ?? pathModel.preview_img_fallback
+ fillMode: Image.PreserveAspectFit
+ }
+ ListView {
+ id: detailsTextFields
+
+ SplitView.fillWidth: true
+ SplitView.fillHeight: true
+
+ spacing: 2
+ orientation: ListView.Vertical
+ boundsBehavior: Flickable.StopAtBounds
+ boundsMovement: Flickable.StopAtBounds
+ clip: true
+ highlightFollowsCurrentItem: false
+ currentIndex: -1
+ model: {
+ const isPlugin = Boolean(templateDetails.model?.plugin);
+
+ return [
+ {
+ name: "Name:",
+ isTitle: true,
+ isVisible: true
+ },
+ {
+ name: templateDetails.model?.name ?? "",
+ isTitle: false,
+ isVisible: true
+ },
+ {
+ name: "Plugin:",
+ isTitle: true,
+ isVisible: isPlugin
+ },
+ {
+ name: templateDetails.model?.plugin ?? "",
+ isTitle: false,
+ isVisible: isPlugin
+ },
+ {
+ name: "Supported layouts:",
+ isTitle: true,
+ isVisible: true
+ },
+ {
+ name: templateDetails.model?.card_layouts.join(", ") ?? "",
+ isTitle: false,
+ isVisible: true
+ },
+ {
+ name: "Installed templates:",
+ isTitle: true,
+ isVisible: true
+ },
+ {
+ name: templateDetails.model?.installed_template_files && templateDetails.model.installed_template_files.length ? templateDetails.model.installed_template_files.join(", ") : "None",
+ isTitle: false,
+ isVisible: true
+ },
+ {
+ name: "Missing templates:",
+ isTitle: true,
+ isVisible: true
+ },
+ {
+ name: templateDetails.model?.missing_template_files && templateDetails.model.missing_template_files.length ? templateDetails.model.missing_template_files.join(", ") : "None",
+ isTitle: false,
+ isVisible: true
+ }
+ ];
+ }
+ delegate: SelectableText {
+ id: textFieldDelegate
+
+ required property int index
+ property var item: detailsTextFields.model[index]
+ property string name: item.name
+ property bool isTitle: item.isTitle
+ property bool isVisible: item.isVisible
+
+ leftPadding: 5
+ rightPadding: 5
+ width: detailsTextFields.width
+ text: name
+ color: templateDetails.systemPalette.text
+ font.bold: isTitle
+ visible: isVisible
+
+ Component.onCompleted: {
+ if (!isVisible) {
+ textFieldDelegate.height = -detailsTextFields.spacing;
+ }
+ }
+ }
+
+ ScrollBar.vertical: ScrollBar {}
+ }
+ }
+}
diff --git a/src/gui/qml/views/TemplateList.qml b/src/gui/qml/views/TemplateList.qml
new file mode 100644
index 00000000..c94377db
--- /dev/null
+++ b/src/gui/qml/views/TemplateList.qml
@@ -0,0 +1,111 @@
+pragma ComponentBehavior: Bound
+import QtQml
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+import qml.components
+
+ListView {
+ id: templateList
+
+ required property SystemPalette systemPalette
+ required property AbstractListModel templateListMdl
+ required property DelegateModel templateListDelegateMdl
+ required property var openSettings
+
+ Timer {
+ id: initTimer
+ interval: 50
+ running: false
+ repeat: false
+ onTriggered: templateList.positionViewAtIndex(templateListMdl.selected_index, ListView.Center)
+ }
+
+ Component.onCompleted: {
+ // Setting the view position in on completed doesn't work,
+ // so as a workaround a small delay is used.
+ // This might be because the delegates haven't been rendered at this point.
+ initTimer.start();
+ }
+
+ orientation: ListView.Vertical
+ boundsBehavior: Flickable.StopAtBounds
+ boundsMovement: Flickable.StopAtBounds
+ reuseItems: false
+ clip: true
+ focus: true
+ highlight: Rectangle {
+ height: templateList.currentItem?.height ?? 0
+ width: templateList.currentItem?.width ?? 0
+ color: templateList.systemPalette.highlight
+ y: templateList.currentItem?.y ?? 0
+ }
+ highlightFollowsCurrentItem: false
+ currentIndex: templateListMdl.selected_index
+ model: templateListDelegateMdl
+ delegate: CustomItemDelegate {
+ id: templateListDelegate
+
+ required property int index
+ required property string name
+ required property string plugin
+ required property bool is_installed
+ required property bool has_config
+ required property list card_layouts
+
+ systemPalette: templateList.systemPalette
+ width: templateList.width
+ implicitHeight: 30
+ highlighted: false
+
+ onClicked: {
+ templateList.templateListMdl.selected_index = index;
+ }
+
+ contentItem: RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 10
+ anchors.rightMargin: 10
+ spacing: 0
+
+ Text {
+ Layout.fillWidth: true
+ Layout.rightMargin: 10
+
+ text: templateListDelegate.name + (templateListDelegate.plugin ? ` (${templateListDelegate.plugin})` : "")
+ color: templateListDelegate.is_installed ? templateList.systemPalette.text : templateList.systemPalette.placeholderText
+ }
+ Badge {
+ id: layoutsBadge
+
+ property int numVisibleLayoutNames: 3
+
+ Layout.rightMargin: 10
+
+ text: templateListDelegate.card_layouts.slice(0, numVisibleLayoutNames).join(", ") + (templateListDelegate.card_layouts.length > numVisibleLayoutNames ? ` +${templateListDelegate.card_layouts.length - numVisibleLayoutNames}` : "")
+ textColor: templateList.systemPalette.text
+ bgColor: templateList.systemPalette.alternateBase
+
+ ToolTip.delay: 300
+ ToolTip.visible: layoutsBadge.hovered
+ ToolTip.text: templateListDelegate.card_layouts.join(", ")
+ }
+ CustomButton {
+ systemPalette: templateList.systemPalette
+ implicitWidth: 32
+ text: "βοΈ"
+ onClicked: templateList.openSettings(templateListDelegate.name, undefined, templateListDelegate.plugin)
+ }
+ CustomButton {
+ systemPalette: templateList.systemPalette
+ implicitWidth: 32
+ text: templateListDelegate.has_config ? "π§Ή" : ""
+ onClicked: templateList.templateListMdl.clear_settings(templateListDelegate.index)
+ enabled: templateListDelegate.has_config
+ }
+ }
+ }
+
+ ScrollBar.vertical: ScrollBar {}
+}
diff --git a/src/gui/qml/views/TemplateUpdater.qml b/src/gui/qml/views/TemplateUpdater.qml
new file mode 100644
index 00000000..d14b1fc0
--- /dev/null
+++ b/src/gui/qml/views/TemplateUpdater.qml
@@ -0,0 +1,316 @@
+pragma ComponentBehavior: Bound
+import QtCore
+import QtQml
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+import qml.components
+
+ApplicationWindow {
+ id: templateUpdaterWindow
+
+ required property SystemPalette systemPalette
+ required property AbstractListModel updaterModel
+ required property QtObject pathModel
+
+ DelegateModel {
+ id: updaterDelegateModel
+ model: templateUpdaterWindow.updaterModel
+ }
+
+ // From https://stackoverflow.com/a/20732091
+ function humanFileSize(bytes: int): string {
+ const i = bytes === 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${["B", "kB", "MB", "GB", "TB"][i]}`;
+ }
+
+ title: "Template updater"
+ width: 800
+ height: 640
+ visible: true
+ color: systemPalette.window
+
+ Settings {
+ id: settings
+
+ category: "TemplateUpdater"
+ location: templateUpdaterWindow.pathModel.get_preferences_path("TemplateUpdater.ini")
+
+ property alias windowWidth: templateUpdaterWindow.width
+ property alias windowHeight: templateUpdaterWindow.height
+ property alias windowX: templateUpdaterWindow.x
+ property alias windowY: templateUpdaterWindow.y
+
+ property var updaterSplitState
+ }
+
+ Component.onCompleted: {
+ updaterSplit.restoreState(settings.updaterSplitState);
+ if (updaterDelegateModel.count < 1)
+ updaterModel.fetch_data();
+ }
+ Component.onDestruction: {
+ settings.updaterSplitState = updaterSplit.saveState();
+ }
+
+ SplitView {
+ id: updaterSplit
+
+ anchors.fill: parent
+ orientation: Qt.Horizontal
+
+ Loader {
+ id: listLoader
+
+ SplitView.fillHeight: true
+ SplitView.fillWidth: true
+
+ asynchronous: true
+ sourceComponent: templateUpdaterWindow.updaterModel.fetching_data ? fetchingIndicatorComponent : availableTemplatesListComponent
+
+ Component {
+ id: fetchingIndicatorComponent
+
+ Item {
+ BusyIndicator {
+ id: indicator
+
+ anchors.centerIn: parent
+ implicitWidth: 50
+ implicitHeight: 50
+ running: templateUpdaterWindow.updaterModel.fetching_data
+
+ palette.dark: templateUpdaterWindow.systemPalette.text
+ }
+ }
+ }
+ Component {
+ id: availableTemplatesListComponent
+
+ ListView {
+ id: availableTemplatesList
+
+ orientation: ListView.Vertical
+ boundsBehavior: Flickable.StopAtBounds
+ boundsMovement: Flickable.StopAtBounds
+ reuseItems: true
+ clip: true
+ focus: true
+ highlight: Rectangle {
+ height: availableTemplatesList.currentItem?.height ?? 0
+ width: availableTemplatesList.currentItem?.width ?? 0
+ color: templateUpdaterWindow.systemPalette.highlight
+ y: availableTemplatesList.currentItem?.y ?? 0
+ }
+ highlightFollowsCurrentItem: false
+ currentIndex: templateUpdaterWindow.updaterModel.selected_index
+ model: updaterDelegateModel
+ delegate: CustomItemDelegate {
+ id: availableTemplatesListDelegate
+
+ required property int index
+ required property string file_name
+ required property string google_drive_id
+ required property string img
+ required property string plugin
+ required property list template_names
+ required property list template_classes
+ required property list layout_categories
+ required property string installed_version
+ required property string available_version
+ required property int download_size
+ required property bool downloading
+
+ property bool canDownload: !installed_version && available_version
+ property bool hasUpdateAvailable: available_version && installed_version && (installed_version !== available_version)
+
+ systemPalette: templateUpdaterWindow.systemPalette
+ width: availableTemplatesList.width
+ height: 30
+ highlighted: false
+
+ onClicked: {
+ templateUpdaterWindow.updaterModel.selected_index = index;
+ }
+
+ contentItem: RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 10
+ spacing: 10
+
+ Text {
+ Layout.alignment: Qt.AlignLeft
+
+ text: availableTemplatesListDelegate.file_name + (availableTemplatesListDelegate.plugin ? ` (${availableTemplatesListDelegate.plugin})` : "")
+ color: availableTemplatesListDelegate.installed_version ? (templateUpdaterWindow.updaterModel.selected_index === availableTemplatesListDelegate.index ? templateUpdaterWindow.systemPalette.highlightedText : templateUpdaterWindow.systemPalette.text) : templateUpdaterWindow.systemPalette.placeholderText
+ }
+ CustomButton {
+ id: downloadButton
+
+ Layout.alignment: Qt.AlignRight
+
+ systemPalette: templateUpdaterWindow.systemPalette
+ text: {
+ if (availableTemplatesListDelegate.downloading) {
+ return "Downloading";
+ }
+ if (availableTemplatesListDelegate.hasUpdateAvailable) {
+ return "Update";
+ }
+ if (availableTemplatesListDelegate.canDownload) {
+ return "Download";
+ }
+ if (availableTemplatesListDelegate.installed_version) {
+ return "Installed";
+ }
+ return "Unavailable";
+ }
+ enabled: !availableTemplatesListDelegate.downloading && (availableTemplatesListDelegate.canDownload || availableTemplatesListDelegate.hasUpdateAvailable)
+ onClicked: {
+ templateUpdaterWindow.updaterModel.download_template(availableTemplatesListDelegate.index);
+ }
+ }
+ }
+ }
+
+ ScrollBar.vertical: ScrollBar {}
+ }
+ }
+ }
+
+ SplitView {
+ id: selectedUpdaterItemDetails
+ orientation: Qt.Vertical
+
+ property var selectedItem: updaterDelegateModel.items.count ? updaterDelegateModel.items.get(templateUpdaterWindow.updaterModel.selected_index).model : undefined
+
+ SplitView.fillHeight: true
+ SplitView.preferredWidth: 200
+
+ Image {
+ SplitView.fillWidth: true
+ SplitView.preferredHeight: 270
+
+ verticalAlignment: Image.AlignTop
+ asynchronous: true
+ source: selectedUpdaterItemDetails.selectedItem?.img ?? templateUpdaterWindow.pathModel.preview_img_fallback
+ fillMode: Image.PreserveAspectFit
+ }
+
+ ColumnLayout {
+ SplitView.fillWidth: true
+
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: "File name:"
+ color: templateUpdaterWindow.systemPalette.text
+ }
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: selectedUpdaterItemDetails.selectedItem?.file_name ?? ""
+ color: templateUpdaterWindow.systemPalette.text
+ }
+
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: "Installed version:"
+ color: templateUpdaterWindow.systemPalette.text
+ }
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: selectedUpdaterItemDetails.selectedItem?.installed_version || "Not installed"
+ color: templateUpdaterWindow.systemPalette.text
+ }
+
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: "Newest version:"
+ color: templateUpdaterWindow.systemPalette.text
+ }
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: selectedUpdaterItemDetails.selectedItem?.available_version ?? "Not available"
+ color: templateUpdaterWindow.systemPalette.text
+ }
+
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: "Download size:"
+ color: templateUpdaterWindow.systemPalette.text
+ }
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: templateUpdaterWindow.humanFileSize(selectedUpdaterItemDetails.selectedItem?.download_size ?? 0)
+ color: templateUpdaterWindow.systemPalette.text
+ }
+
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: "Template names:"
+ wrapMode: Text.WordWrap
+ color: templateUpdaterWindow.systemPalette.text
+ }
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: selectedUpdaterItemDetails.selectedItem?.template_names.join(", ") ?? ""
+ color: templateUpdaterWindow.systemPalette.text
+ }
+
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: "Template layouts:"
+ color: templateUpdaterWindow.systemPalette.text
+ }
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: selectedUpdaterItemDetails.selectedItem?.layout_categories.join(", ") ?? ""
+ color: templateUpdaterWindow.systemPalette.text
+ }
+
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: "Template classes:"
+ color: templateUpdaterWindow.systemPalette.text
+ }
+ SelectableText {
+ Layout.alignment: Qt.AlignTop
+ Layout.fillWidth: true
+
+ text: selectedUpdaterItemDetails.selectedItem?.template_classes.join(", ") ?? ""
+ color: templateUpdaterWindow.systemPalette.text
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+ }
+ }
+ }
+}
diff --git a/src/gui/qml/views/qmldir b/src/gui/qml/views/qmldir
new file mode 100644
index 00000000..d75633cc
--- /dev/null
+++ b/src/gui/qml/views/qmldir
@@ -0,0 +1,8 @@
+module qml.views
+TemplateList TemplateList.qml
+TemplateDetails TemplateDetails.qml
+BatchRenderView BatchRenderView.qml
+Console Console.qml
+RenderQueue RenderQueue.qml
+SettingsWindow SettingsWindow.qml
+TemplateUpdater TemplateUpdater.qml
\ No newline at end of file
diff --git a/src/gui/tabs/creator.py b/src/gui/tabs/creator.py
deleted file mode 100644
index d5e45ab3..00000000
--- a/src/gui/tabs/creator.py
+++ /dev/null
@@ -1,213 +0,0 @@
-"""
-* GUI Tab: Custom Card Creator
-"""
-# Standard Library Imports
-import os
-from functools import cached_property
-
-# Third Party Imports
-from kivy.lang import Builder
-from kivy.uix.button import Button
-from kivy.uix.gridlayout import GridLayout
-from kivy.uix.spinner import Spinner
-from kivy.uix.tabbedpanel import TabbedPanelItem, TabbedPanel
-
-# Local Imports
-from src._state import PATH
-from src._loader import TemplateCategoryMap, TemplateSelectedMap
-from src.console import msg_bold
-from src.gui._state import GlobalAccess
-
-"""
-* Layout Classes
-"""
-
-
-class CreatorPanel(TabbedPanel, GlobalAccess):
- """Panel tab the 'Creator' tab which renders custom cards."""
- Builder.load_file(os.path.join(PATH.SRC_DATA_KV, "creator.kv"))
-
- @cached_property
- def creator_tabs(self) -> list[type['CreatorLayout']]:
- """Defined creator tabs."""
- return [
- CreatorNormalLayout,
- CreatorPlaneswalkerLayout,
- CreatorSagaLayout
- ]
-
- def on_load(self, *args) -> None:
- """Add creator tabs."""
- for tab in self.creator_tabs:
- self.add_widget(CreatorTabItem(tab))
- self._tab_layout.padding = (
- '0dp', '0dp', '0dp', '0dp')
-
-
-class CreatorTabItem(TabbedPanelItem, GlobalAccess):
- """Represents a single tab in the CreatorPanels tabbed panel."""
-
- def __init__(self, widget: type['CreatorLayout'], **kwargs):
- super().__init__(text=widget._category, **kwargs)
- self.add_widget(widget())
-
-
-class CreatorLayout(GridLayout, GlobalAccess):
- """Represents the content of a specific 'CreatorTabItem' tab."""
- _category = ''
-
- def __init__(self, **kwargs):
-
- # Key attributes
- self._templates: TemplateCategoryMap = self.main.template_map[self._category]
- self._template_names: list[str] = self._templates['names']
- self._types: list[str] = list(self._templates['map'].keys())
- self._templates_selected: TemplateSelectedMap = self.main.templates_default.copy()
-
- # Call super
- super().__init__(**kwargs)
-
- @cached_property
- def toggle_buttons(self) -> list[Button]:
- """Add render button."""
- return [self.ids.render_btn]
-
- """
- * Template Utils
- """
-
- def select_template(self, spinner: Spinner) -> None:
- """Choose which template to render with.
-
- Args:
- spinner: Spinner dropdown list element.
- """
- for t in self._types:
- if spinner.text in self._templates['map'][t]:
- self._templates_selected[t] = self._templates['map'][t][spinner.text]
- else:
- # Notify the user one face type isn't supported
- face = 'Front' if 'back' in t else 'Back'
- self.console.update(
- msg_bold(f"NOTE: Template '{spinner.text}' only supports '{face}' face cards."))
-
- def render(self) -> None:
- """Initiate a custom card render operation."""
- scryfall = self.format_card_data(self.get_card_data())
- template = self._templates_selected[scryfall.get('layout', 'normal')]
- self.main.render_custom(template, scryfall)
-
- """
- * Data Utils
- """
-
- def get_card_data(self) -> dict:
- """Extend this method to extract card data from UI form fields."""
- return {}
-
- def format_card_data(self, data: dict) -> dict:
- """Post-process card data for layout validation or other cases.
-
- Args:
- data: Dict containing card data, mirrors Scryfall data in the main app.
-
- Returns:
- Formatted and validated card data dict.
- """
- # Shared data
- data['object'] = 'card'
- data['lang'] = self.cfg.lang
-
- # Is this an alternate language card?
- if data['lang'] != 'en':
- data['printed_name'] = data['name']
- data['printed_text'] = data['oracle_text']
- data['printed_type_line'] = data['type_line']
- return data
-
-
-class CreatorNormalLayout(CreatorLayout):
- _category = 'Normal'
-
- def get_card_data(self) -> dict:
- oracle_text = self.ids.oracle_text.text.replace("~", self.ids.name.text)
- flavor_text = self.ids.flavor_text.text.replace("~", self.ids.name.text)
- return {
- "layout": "normal",
- "set": self.ids.set.text,
- "name": self.ids.name.text,
- "oracle_text": oracle_text,
- "flavor_text": flavor_text,
- "power": self.ids.power.text,
- "artist": self.ids.artist.text,
- "mana_cost": self.ids.mana_cost.text,
- "type_line": self.ids.type_line.text,
- "toughness": self.ids.toughness.text,
- "rarity": self.ids.rarity.text.lower(),
- "printed_size": self.ids.card_count.text,
- "keywords": self.ids.keywords.text.split(","),
- "collector_number": self.ids.collector_number.text,
- "color_identity": self.ids.color_identity.text.split()
- }
-
-
-class CreatorPlaneswalkerLayout(CreatorLayout):
- _category = 'Planeswalker'
-
- def get_card_data(self) -> dict:
- rules_text = "\n".join(n for n in [
- self.ids.line_1.text.replace("~", self.ids.name.text),
- self.ids.line_2.text.replace("~", self.ids.name.text),
- self.ids.line_3.text.replace("~", self.ids.name.text),
- self.ids.line_4.text.replace("~", self.ids.name.text)
- ] if n)
- return {
- "layout": "planeswalker",
- "set": self.ids.set.text,
- "name": self.ids.name.text,
- "oracle_text": rules_text,
- "artist": self.ids.artist.text,
- "loyalty": self.ids.loyalty.text,
- "mana_cost": self.ids.mana_cost.text,
- "type_line": self.ids.type_line.text,
- "rarity": self.ids.rarity.text.lower(),
- "printed_size": self.ids.card_count.text,
- "keywords": self.ids.keywords.text.split(","),
- "collector_number": self.ids.collector_number.text,
- "color_identity": self.ids.color_identity.text.split()
- }
-
-
-class CreatorSagaLayout(CreatorLayout):
- _category = 'Saga'
-
- def get_card_data(self) -> dict:
- text_arr, num_lines = [], 'I'
- if self.ids.line_1.text != "":
- text_arr.append(f"{num_lines} β " + self.ids.line_1.text.replace("~", self.ids.name.text))
- num_lines += "I"
- if self.ids.line_2.text != "":
- text_arr.append(f"{num_lines} β " + self.ids.line_2.text.replace("~", self.ids.name.text))
- num_lines += "I"
- if self.ids.line_3.text != "":
- text_arr.append(f"{num_lines} β " + self.ids.line_3.text.replace("~", self.ids.name.text))
- num_lines = "IV" if len(num_lines) == 3 else num_lines + "I"
- if self.ids.line_4.text != "":
- text_arr.append(f"{num_lines} β " + self.ids.line_4.text.replace("~", self.ids.name.text))
- text_arr.insert(0, f"(As this Saga enters and after your draw step, add a lore counter. "
- f"Sacrifice after {num_lines}.)")
- rules_text = "\n".join(text_arr)
- return {
- "layout": "saga",
- "set": self.ids.set.text,
- "oracle_text": rules_text,
- "name": self.ids.name.text,
- "artist": self.ids.artist.text,
- "mana_cost": self.ids.mana_cost.text,
- "type_line": self.ids.type_line.text,
- "rarity": self.ids.rarity.text.lower(),
- "printed_size": self.ids.card_count.text,
- "keywords": self.ids.keywords.text.split(","),
- "collector_number": self.ids.collector_number.text,
- "color_identity": self.ids.color_identity.text.split()
- }
diff --git a/src/gui/tabs/main.py b/src/gui/tabs/main.py
deleted file mode 100644
index b0ae14cf..00000000
--- a/src/gui/tabs/main.py
+++ /dev/null
@@ -1,287 +0,0 @@
-"""
-* GUI Tab: Main Rendering Tab
-"""
-# Standard Library Imports
-import os
-from pathlib import Path
-from typing import Optional
-from functools import cached_property
-
-# Kivy Imports
-from kivy.lang import Builder
-from kivy.uix.image import Image
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.scrollview import ScrollView
-from kivy.uix.button import Button
-
-# Local Imports
-from src._loader import TemplateDetails, TemplateCategoryMap
-from src._state import PATH
-from src.enums.mtg import layout_map_category
-from src.gui.popup.settings import SettingsPopup
-from src.gui._state import GUI, GlobalAccess
-from src.gui.utils import (
- DynamicTabPanel,
- DynamicTabItem,
- HoverButton)
-
-"""
-* Template Modules
-"""
-
-
-class MainPanel(BoxLayout, GlobalAccess):
- """Main panel to the 'Render Cards' tab."""
- Builder.load_file(os.path.join(PATH.SRC_DATA_KV, "main.kv"))
-
- @cached_property
- def toggle_buttons(self) -> list[Button]:
- """Add render and settings buttons."""
- return [
- self.ids.rend_targ_btn,
- self.ids.rend_all_btn,
- self.ids.app_settings_btn
- ]
-
-
-class TemplateModule(DynamicTabPanel, GlobalAccess):
- """Module that loads template tabs."""
-
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
- self._tab_layout.padding = '0dp', '10dp', '0dp', '0dp'
- self.tabs = []
-
- # Create a scroll box with listed templates for each category
- for cat, _ in layout_map_category.items():
-
- # Get the template map for this category
- templates: TemplateCategoryMap = self.main.template_map[cat]
-
- # If less than 2 templates exist for this category, skip it
- if len(templates['names']) < 2:
- continue
-
- # Add a tab for this category
- tab = DynamicTabItem(text=cat)
- tab.content = self.get_template_container(
- category=cat,
- templates=templates)
- self.tabs.append(tab)
-
- # Only add tabs when all are generated
- [self.add_widget(t) for t in self.tabs]
-
- @staticmethod
- def get_template_container(
- category: str,
- templates: TemplateCategoryMap
- ) -> 'TemplateTabContainer':
- """Return a template container for containing the list view and preview image.
-
- Args:
- category: Template type category, e.g. Transform.
- templates: Mapping of types -> name -> template details.
-
- Returns:
- TemplateTabContainer object to add to the panel.
- """
-
- # Create a scroll box and container
- scroll_box = TemplateView()
- container = TemplateTabContainer()
-
- # Add template list to the scroll box
- TL = TemplateList(
- category=category,
- templates=templates,
- preview=container.ids.preview_image)
- scroll_box.add_widget(TL)
- GUI.template_list.setdefault(category, []).append(TL)
-
- # Add scroll box to the container and return it
- container.ids.template_view_container.add_widget(scroll_box)
- return container
-
-
-class TemplateTabContainer(BoxLayout):
- """Container that holds template list within each tab."""
-
-
-class TemplateView(ScrollView):
- """Scrollable viewport for template list."""
-
-
-class TemplateList(BoxLayout, GlobalAccess):
- """Builds a list of templates from a certain template type."""
-
- def __init__(self, category: str, templates: type[TemplateCategoryMap], preview: Image, **kwargs):
- super().__init__(**kwargs)
- self.category = category
- self.preview = preview
- self.templates = templates
- self.types = list(templates['map'].keys())
- self.add_template_rows()
-
- def add_template_rows(self) -> None:
- """Add a row for each template in this list."""
- missing, found = [], []
-
- # Create a list of buttons
- for name in self.templates['names']:
-
- # Get details for each type
- templates: dict[str, Optional[type[TemplateDetails]]] = {
- t: self.templates['map'][t].get(name) for t in self.types}
-
- # Is template installed?
- if not all([n['object'].is_installed for _, n in templates.items() if n]):
- missing.append(templates)
- continue
-
- # Add installed template
- widget = TemplateRow(
- category=self.category,
- template_map=templates,
- preview=self.preview)
- self.add_widget(widget)
- found.append(widget)
-
- # Add the missing templates
- uninstalled = []
- for m in missing:
- row = TemplateRow(
- category=self.category,
- template_map=m,
- preview=self.preview)
- row.disabled = True
- self.add_widget(row)
- uninstalled.append(row)
-
- # Were no templates found at all?
- if not any([found, uninstalled]):
- return
-
- # Select the first 'found' template in this list, otherwise select the first uninstalled
- btn = (found if found else uninstalled)[0].ids.toggle_button
- btn.state = 'down'
- btn.dispatch('on_press')
-
- def reload_template_rows(self) -> None:
- """Remove existing rows and generate new ones using current template data."""
- self.clear_widgets()
- self.add_template_rows()
-
-
-class TemplateRow(BoxLayout, GlobalAccess):
- """Row containing template selector and governing buttons."""
-
- def __init__(self, category: str, template_map: dict[str, type[TemplateDetails]], preview: Image, **kwargs):
- super().__init__(**kwargs)
-
- # Key attributes
- self.image: Image = preview
- self.template_map: dict[str, type[TemplateDetails]] = template_map
- self.types: list[str] = list(template_map.keys())
- self.category: str = category
-
- # Establish the name and previews
- name, config, previews = '', None, [PATH.SRC_IMG_NOTFOUND] * len(self.types)
- for i, t in enumerate(self.types):
- if obj := template_map.get(t, {}):
- if not obj:
- continue
-
- # Set the name if still empty
- if not name:
- name = obj.get('name', '')
-
- # Add the preview image for this type
- previews[i] = obj['object'].get_path_preview(
- class_name=obj['class_name'], class_type=t
- ) if obj.get('object') else PATH.SRC_IMG_NOTFOUND
-
- # Set the chosen config object
- if not config:
- config = obj['config']
- config.gui_elements.append(self)
-
- # Set name, config, and previews
- self.config = config
- self.name = name or 'Unknown'
- self.preview: list[Path] = previews
- self.ids.toggle_button.text = self.name
- self.preview_face = 0
-
- # Check if config file is present
- self.default_settings_button_check()
-
- # Add to GUI Dict
- GUI.template_row.setdefault(category, {})[self.name] = self
- GUI.template_btn.setdefault(category, {})[self.name] = self.ids.toggle_button
- GUI.template_btn_cfg.setdefault(category, {})[self.name] = self.settings_button
-
- """
- * GUI Elements
- """
-
- @property
- def settings_button(self) -> Button:
- """Button: Click to load template specific settings."""
- return self.ids.settings_button
-
- @property
- def reset_button(self) -> Button:
- """Button: Click to reset template specific settings to default (global)."""
- return self.ids.reset_default_button
-
- """
- * Utility Methods
- """
-
- def default_settings_button_check(self) -> None:
- """Checks for a template's config file and enables/disables the reset settings button."""
- self.ids.reset_default_button.disabled = False if self.config.has_template_ini else True
-
- def toggle_preview_image(self) -> None:
- """Toggles the preview image on multi-side templates like Transform."""
- if len(self.preview) < 2:
- return
- new_face = 0 if self.preview_face == 1 else 1
- self.image.source = str(self.preview[new_face])
- self.preview_face = new_face
-
- def set_preview_image(self) -> None:
- """Sets the preview image in the parent container to the main preview face of this template."""
- self.image.manager = self
- self.image.source = str(self.preview[0])
- self.preview_face = 0
-
-
-class TemplateSettingsButton(HoverButton, GlobalAccess):
- """Opens the settings panel for a given template."""
-
- async def open_settings(self) -> None:
- """Opens a settings panel to customize the settings of a specific template class."""
- obj = self.parent
- cfg_panel = SettingsPopup(obj.template_map[obj.types[0]])
- cfg_panel.open()
- for row in obj.config.gui_elements:
- row.default_settings_button_check()
-
-
-class TemplateResetDefaultButton(HoverButton, GlobalAccess):
- """Deletes the ini config file for a given template (resets its settings to default)."""
-
- async def reset_default(self) -> None:
- """Removes the INI config file containing customized settings for a specific template class."""
- _file, removed = self.parent.config.template_path_ini, False
- if _file and _file.is_file():
- try:
- os.remove(_file)
- self.console.update(f"Reset template '{self.parent.name}' to global settings!")
- except (PermissionError, OSError, FileNotFoundError):
- self.console.update(f"Unable to reset settings: '{self.parent.name}'\n"
- f"Likely a file permission error!")
- for row in self.parent.config.gui_elements:
- row.default_settings_button_check()
diff --git a/src/gui/tabs/tools.py b/src/gui/tabs/tools.py
deleted file mode 100644
index 85d53a30..00000000
--- a/src/gui/tabs/tools.py
+++ /dev/null
@@ -1,172 +0,0 @@
-"""
-* GUI Tab: Tools
-"""
-# Standard Library Imports
-import os
-from functools import cached_property
-from pathlib import Path
-from typing import Callable
-from threading import Event
-from concurrent.futures import ThreadPoolExecutor as Pool, as_completed
-
-# Third Party Imports
-from photoshop.api._document import Document
-from kivy.lang import Builder
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.button import Button
-from omnitils.img import downscale_image_by_width
-
-# Local Imports
-from src._state import PATH
-from src.gui._state import GlobalAccess
-from src.utils.adobe import get_photoshop_error_message
-from src.helpers import import_art, reset_document, save_document_jpeg, close_document
-
-
-class ToolsPanel(BoxLayout, GlobalAccess):
- Builder.load_file(os.path.join(PATH.SRC_DATA_KV, "tools.kv"))
-
- class PSD:
- """PSD tool paths."""
- Showcase: Path = PATH.TEMPLATES / 'tools' / 'showcase.psd'
-
- @cached_property
- def toggle_buttons(self) -> list[Button]:
- """Add tool buttons."""
- return [
- self.ids.generate_showcases_target,
- self.ids.generate_showcases,
- self.ids.compress_renders,
- self.ids.compress_renders_target,
- self.ids.compress_arts]
-
- @staticmethod
- def process_wrapper(func) -> Callable:
- """Decorator to handle state maintenance before and after an initiated render process.
-
- Args:
- func: Function being wrapped.
-
- Returns:
- The result of the wrapped function.
- """
- def wrapper(self: 'ToolsPanel', *args):
- while check := self.app.refresh_app():
- if not self.console.await_choice(
- thr=Event(),
- msg=get_photoshop_error_message(check),
- end="Hit Continue to try again, or Cancel to end the operation.\n"
- ):
- # Cancel this operation
- return
-
- # Reset
- self.main.disable_buttons()
- self.console.clear()
- result = func(self, *args)
- self.main.enable_buttons()
- return result
- return wrapper
-
- @process_wrapper
- def render_showcases(self, target: bool = False) -> None:
- """Render card images as showcases with rounded border.
-
- Args:
- target: If true, select target images with Photoshop file select.
- """
-
- # Ensure showcase folder exists
- path = PATH.OUT / 'showcase'
- path.mkdir(mode=777, parents=True, exist_ok=True)
-
- # Targeted or all images?
- images = self.main.select_art() if target else self.get_images(PATH.OUT)
-
- # No files provided
- if not images and target:
- # Cancelled file select
- return
- if not images:
- # No files in 'out' folder
- self.console.update("No card images found!")
- return
-
- # Open the showcase tool
- self.app.load(str(self.PSD.Showcase))
- docref: Document = self.app.activeDocument
-
- # Open each image and save with border crop
- for img in images:
- img_path = path / img.name
- import_art(
- layer=docref.activeLayer,
- path=img,
- docref=docref)
- save_document_jpeg(
- path=img_path,
- docref=docref)
- reset_document(
- docref=docref)
- close_document(
- docref=docref)
-
- @process_wrapper
- def compress_renders(self) -> None:
- """Utility definition for compressing all rendered card images."""
- if not (images := self.get_images(PATH.OUT)):
- self.console.update('No card images found!')
- return
- self.compress_images(images)
-
- @process_wrapper
- def compress_arts(self):
- """Utility definition for compressing all card arts."""
- if not (images := self.get_images(PATH.ART)):
- self.console.update('No art images found!')
- return
- self.compress_images(images)
-
- @process_wrapper
- def compress_target(self):
- """Utility definition for compressing a target selection of images."""
- if not (images := self.main.select_art()):
- return
- self.compress_images(images)
-
- def compress_images(self, images: list[Path]) -> None:
- """Compress a list of images.
-
- Args:
- images: A list of image paths.
- """
- with Pool(max_workers=(os.cpu_count() - 1) or 1) as executor:
- quality = self.ids.compress_quality.text
- tasks = [executor.submit(
- downscale_image_by_width, img, **{
- 'max_width': 2176 if self.ids.compress_dpi.active else 3264,
- 'optimize': True,
- 'quality': int(quality) if quality.isnumeric() else 95,
- }) for img in images]
- [n.result() for n in as_completed(tasks)]
-
- """
- * File Utils
- """
-
- @staticmethod
- def get_images(path: Path) -> list[Path]:
- """Grab all supported image files within the "out" directory.
-
- Args:
- path: Path to a directory containing images.
-
- Returns:
- List of art files found in the given directory.
- """
- # Folder, file list, supported extensions
- all_files = os.listdir(path)
- ext = (".png", ".jpg", ".jpeg")
-
- # Select all images in folder not prepended with !
- return [Path(path, f) for f in all_files if f.endswith(ext)]
diff --git a/src/gui/test.py b/src/gui/test.py
deleted file mode 100644
index 51c8c884..00000000
--- a/src/gui/test.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""
-* Test Mode App
-"""
-# Standard Library Imports
-import os
-import threading
-
-# Third Party Imports
-from kivy.lang import Builder
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.popup import Popup
-from kivy.uix.label import Label
-from kivy.metrics import dp, sp
-
-# Local Imports
-from src._state import PATH
-from src._loader import TemplateDetails
-from src.gui._state import GlobalAccess
-from src.gui.utils import HoverButton
-
-
-class TestApp(BoxLayout, GlobalAccess):
- """Template Tester."""
- Builder.load_file(os.path.join(PATH.SRC_DATA_KV, "test.kv"))
-
- def on_load(self, *args) -> None:
- """Add selector and toggle buttons."""
- self.selector = TemplateSelector(
- self, size_hint=(.8, .8))
- self.main.toggle_buttons.extend([
- self.ids.test_all,
- self.ids.test_target,
- self.ids.test_all_deep
- ])
-
- def select_template(self):
- """Select a target template to test."""
- self.selector.open()
-
-
-class TemplateSelector(Popup, GlobalAccess):
- """Popup selector for selecting a template to call 'Test Target' on."""
-
- def __init__(self, root: TestApp, **kwargs):
- self.test_app = root
- super().__init__(**kwargs)
-
- def on_load(self, *args) -> None:
- """Add template buttons to template selector."""
- for card_type, templates in {
- t: temps for cat, cat_map in
- self.main.template_map.items() for
- t, temps in cat_map['map'].items()
- }.items():
-
- # Add title label
- self.ids.content.add_widget(
- Label(
- text=card_type.replace("_", " ").title(),
- size_hint=(1, None),
- font_size=sp(25),
- height=dp(45)))
-
- # Add buttons
- [self.ids.content.add_widget(SelectorButton(
- self.test_app,
- template=template,
- card_type=card_type,
- text=template['name']
- )) for name, template in templates.items()]
-
-
-class SelectorButton(HoverButton, GlobalAccess):
- """Button which calls 'Test Target' on a given template."""
-
- def __init__(self, root: TestApp, template: TemplateDetails, card_type: str, **kwargs):
- super().__init__(**kwargs)
- self.test_app = root
- self.card_type = card_type
- self.template = template
- self.size_hint = (1, None)
- self.font_size = sp(20)
- self.height = dp(35)
-
- def on_release(self, **kwargs):
- """Launch app method 'test_target' on release."""
- threading.Thread(
- target=self.main.test_target,
- args=(self.card_type, self.template), daemon=True
- ).start()
- self.test_app.selector.dismiss()
diff --git a/src/gui/utils/__init__.py b/src/gui/utils/__init__.py
deleted file mode 100644
index c7132550..00000000
--- a/src/gui/utils/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""
-* GUI Utility Modules
-"""
-# Local Imports
-from src.gui.utils.behaviors import *
-from src.gui.utils.fonts import *
-from src.gui.utils.forms import *
-from src.gui.utils.layouts import *
diff --git a/src/gui/utils/behaviors.py b/src/gui/utils/behaviors.py
deleted file mode 100644
index 3cad4f12..00000000
--- a/src/gui/utils/behaviors.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""
-* GUI Utility Behaviors
-"""
-# Third Party Imports
-from kivy.app import App
-from kivy.core.window import Window
-from kivy.properties import BooleanProperty, ObjectProperty
-
-"""
-* Utility Classes
-"""
-
-
-class HoverBehavior(object):
- """Utility modifier class which adds hover behavior to layout elements.
-
- Events:
- `on_enter`: Fired when mouse enter the bbox of the widget.
- `on_leave`: Fired when the mouse exit the widget
- """
- hovered = BooleanProperty(False)
- border_point = ObjectProperty(None)
-
- def __init__(self, **kwargs):
- self.register_event_type('on_enter')
- self.register_event_type('on_leave')
- Window.bind(mouse_pos=self.on_mouse_pos)
- super(HoverBehavior, self).__init__(**kwargs)
-
- def on_mouse_pos(self, *args):
- if not self.get_root_window():
- return # do proceed if I'm not displayed <=> If I have no parent
- pos = args[1]
- # Next line to_widget allowed to compensate for relative layout
- inside = self.collide_point(*self.to_widget(*pos))
- if self.hovered == inside:
- # We have already done what was needed
- return
- self.border_point = pos
- self.hovered = inside
- if inside:
- self.dispatch('on_enter')
- else:
- self.dispatch('on_leave')
-
- """
- BLANK METHODS - Overwritten by Extend Class, e.g. Button
- """
-
- def dispatch(self, action):
- return
-
- @staticmethod
- def collide_point(point: float):
- if point:
- return True
- return False
-
- @staticmethod
- def to_widget(point: list):
- return point
-
- @staticmethod
- def get_root_window():
- return App.root_window
-
- @staticmethod
- def register_event_type(event: str) -> None:
- return
diff --git a/src/gui/utils/fonts.py b/src/gui/utils/fonts.py
deleted file mode 100644
index 3144be65..00000000
--- a/src/gui/utils/fonts.py
+++ /dev/null
@@ -1,42 +0,0 @@
-"""
-* GUI Font Utils
-"""
-# Standard Library Imports
-import os
-
-# Third Party Imports
-from kivy.core.text import LabelBase
-
-
-"""
-* Utility Funcs
-"""
-
-
-def get_font(name: str, default: str = "Roboto"):
- """Instantiate font if exists. Otherwise, return default font.
-
- Args:
- name: Font to look for.
- default: Font to default to if 'name' can't be found.
-
- Returns:
- Found font or default font.
- """
- basename = name[0:-4]
- try:
- LabelBase.register(name=basename, fn_regular=name)
- return basename
- except OSError:
- try:
- LabelBase.register(name=basename, fn_regular=f"fonts/{name}")
- return basename
- except OSError:
- try:
- LabelBase.register(
- name=basename,
- fn_regular=f"C:\\Users\\{os.getlogin()}\\AppData\\Local\\Microsoft\\Windows\\Fonts\\{name}"
- )
- return basename
- except OSError:
- return default
diff --git a/src/gui/utils/forms.py b/src/gui/utils/forms.py
deleted file mode 100644
index 89e0467f..00000000
--- a/src/gui/utils/forms.py
+++ /dev/null
@@ -1,194 +0,0 @@
-"""
-* GUI Utility Form Elements
-"""
-from threading import Thread
-
-# Third Party Imports
-from kivy.metrics import sp
-from kivy.properties import get_color_from_hex
-from kivy.uix.textinput import TextInput
-from kivy.uix.spinner import Spinner
-from kivy.properties import StringProperty
-
-# Local Imports
-from src.enums.mtg import Rarity
-from src.gui.utils.layouts import HoverButton, get_font
-
-"""
-* Utility Input classes
-"""
-
-
-class InputItem(TextInput):
- """Track hint text in perpetuity, add QOL key binds."""
- multiline = False
-
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
- self.clicked = False
- self.original = self.text
-
- def _on_focus(self, instance, value, *args):
- """Preserve hint text."""
- if not self.clicked:
- self.clicked = True
- self.original = self.text
- super()._on_focus(instance, value, *args)
-
- def keyboard_on_key_down(self, window, keycode, text, modifiers):
- """Enable tab to next or previous input and F5 to reset."""
- if keycode[1] == 'tab':
- # Deal with tabbing between inputs
- if 'shift' in modifiers:
- nxt = self.get_focus_previous()
- else:
- nxt = self.get_focus_next()
- if nxt:
- self.focus = False
- nxt.focus = True
- return True
- if keycode[0] == 286:
- # F5 to reset text to hint text
- self.clicked = False
- self.text = self.original
- super().keyboard_on_key_down(window, keycode, text, modifiers)
-
-
-class NoEnterInputItem(InputItem):
- def keyboard_on_key_down(self, window, keycode, text, modifiers):
- """Disable next line."""
- if keycode[0] == 13: # deal with cycle
- return False
- super().keyboard_on_key_down(window, keycode, text, modifiers)
-
-
-class ValidatedInput(InputItem):
- """Limit text input based on numeric, length, and whitelisted terms."""
- def __init__(self, **kwargs):
- self._max_len = int(kwargs.pop('max_len', 0))
- self._whitelist = kwargs.pop('whitelist', [])
- self._numeric = bool(kwargs.pop('numeric', False))
- self._numeric_range = kwargs.pop('numeric_range', [0, 0])
- super().__init__(**kwargs)
-
- def insert_text(self, substring, from_undo=False) -> None:
- """
- 3 character max, numeric with a small whitelist
- """
- # Character length requirement
- if self._max_len != 0 and len(self.text) > (self._max_len - 1):
- return
-
- # Numeric value or whitelisted value requirement
- if self._numeric and not (substring.isnumeric() or substring in self._whitelist):
- return
-
- # Numeric value in accepted range requirement
- if self._numeric and not (self._numeric_range[1] == 0 or (
- self._numeric_range[0] <= int(self.text + str(substring)) <= self._numeric_range[1]
- )):
- return
-
- # Value is validated
- return super().insert_text(substring, from_undo=from_undo)
-
-
-class FourNumInput(ValidatedInput):
- """Utility definition, 4 numeric characters with whitelisted operators."""
- multiline = False
-
- def __init__(self, **kwargs):
- super().__init__(max_len=4, numeric=True, whitelist=["*", "X", "Y", "+", "-"], **kwargs)
-
-
-class ThreeNumInput(ValidatedInput):
- """Utility definition, 3 numeric characters with whitelisted operators."""
- multiline = False
-
- def __init__(self, **kwargs):
- super().__init__(max_len=3, numeric=True, whitelist=["*", "X", "Y", "+", "-"], **kwargs)
-
-
-class FourCharInput(ValidatedInput):
- """Utility definition, 4 of any kind of characters."""
- multiline = False
-
- def __init__(self, **kwargs):
- super().__init__(max_len=4, **kwargs)
-
-
-class Range100NumInput(ValidatedInput):
- """Utility definition, number between 1 and 100."""
- multiline = False
-
- def __init__(self, **kwargs):
- super().__init__(numeric=True, numeric_range=[1, 100], **kwargs)
-
-
-class VerticalCenteredInput(ValidatedInput):
- """Utility definition for vertically centering the input field."""
- padding_x = 0, 0
-
- def on_size(self, *args):
- """Adjust padding to achieve vertical centering."""
- left, right = self.padding_x
- self.padding = left, self.height / 2.0 - (self.line_height / 2.0) * len(self._lines), right, 0
- super().on_size(*args)
-
-
-class SetCodeInput(VerticalCenteredInput, FourCharInput):
- """QOL definition for set code string input."""
- hint_text = StringProperty('SET')
- halign = 'center'
-
-
-class CollectorInput(VerticalCenteredInput, FourNumInput):
- """QOL definition for collector number string input."""
- hint_text = StringProperty('001')
- halign = 'center'
-
-
-class PTInput(VerticalCenteredInput, ThreeNumInput):
- """QOL definition for power or toughness text."""
- hint_text = StringProperty('1')
- halign = 'center'
-
-
-"""
-* Spinner Classes
-"""
-
-
-class RaritySpinner(Spinner):
- """Select from a list of card rarities."""
- values = [n.title() for n in Rarity]
- text_autoupdate = True
-
-
-"""
-* Button Classes
-"""
-
-
-class RenderCustomButton(HoverButton):
- font_name = get_font('Beleren Small Caps.ttf')
- font_size = sp(16)
- press_action = None
- options = [
- "Do it!",
- "Game on!",
- "Make it!",
- "Let's go!"
- ]
-
- def __init__(self, **kwargs):
- bg_color = get_color_from_hex('#376aa3')
- super().__init__(
- background_color=bg_color,
- text='Render',
- **kwargs)
-
- def on_press(self):
- """Process action in separate thread if action is defined."""
- if self.press_action is not None:
- Thread(target=self.press_action, daemon=True).start()
diff --git a/src/gui/utils/layouts.py b/src/gui/utils/layouts.py
deleted file mode 100644
index 92390db0..00000000
--- a/src/gui/utils/layouts.py
+++ /dev/null
@@ -1,224 +0,0 @@
-"""
-* GUI Utility Layouts
-"""
-# Standard Library Imports
-import random
-
-# Third Party Imports
-from kivy.metrics import dp, sp
-from kivy.logger import Logger
-from kivy.clock import Clock
-from kivy.compat import string_types
-from kivy.factory import Factory
-from kivy.properties import ObjectProperty
-from kivy.uix.behaviors import ButtonBehavior
-from kivy.uix.button import Button
-from kivy.uix.image import Image
-from kivy.uix.tabbedpanel import (
- TabbedPanelException,
- TabbedPanelContent,
- TabbedPanelHeader,
- TabbedPanelStrip,
- TabbedPanel,
- StripLayout)
-from kivy.utils import get_color_from_hex
-
-# Local Imports
-from src.gui.utils.behaviors import HoverBehavior
-from src.gui.utils.fonts import get_font
-
-"""
-* Button Layouts
-"""
-
-
-class HoverButton(Button, HoverBehavior):
- """Button which adopts the hover behavior modifier."""
- options = [
- "Do it!", "Let's GO!", "Ready?",
- "PROXY", "Hurry up!", "GAME ON",
- "Let's DUEL", "Prox it up!", "Go for it!"
- ]
- hover_color = "#a4c5eb"
- org_text = None
- org_color = None
-
- def __init__(self, **kwargs):
- # Set the default font
- self.font_name = get_font("Beleren Small Caps.ttf")
- super().__init__(**kwargs)
-
- def on_enter(self):
- """Fired when mouse enters the hover area."""
- if not self.disabled:
- self.org_color = self.background_color
- if len(self.options) > 0:
- self.org_text = self.text
- self.text = random.choice(self.options)
- self.background_color = get_color_from_hex(self.hover_color)
-
- def on_leave(self):
- """Fired when mouse leaves the hover area."""
- if self.org_color:
- self.background_color = self.org_color
- if len(self.options) > 0:
- self.text = self.org_text
-
-
-"""
-* Image Layouts
-"""
-
-
-class ButtonImage(ButtonBehavior, Image):
- """Image which adopts the hover behavior modifier."""
- pass
-
-
-"""
-* Tabbed Panel Layouts
-"""
-
-
-class DynamicTabHeader(TabbedPanelHeader):
- """Replacement for 'TabbedPanelHeader'."""
-
-
-class DynamicTabItem(DynamicTabHeader, TabbedPanelHeader):
- """Replacement for 'Tabbed Panel Item'."""
-
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
- self.size_hint = (None, 1)
- self.font_size = sp(16)
- self.bind(texture_size=self.resize_width)
-
- def resize_width(self, _, texture_size):
- self.width = texture_size[0] + dp(20)
-
-
-class DynamicTabPanel(TabbedPanel):
- """Replacement for 'TabbedPanel'."""
-
- # Overwrite the default 'TabbedPanelHeader'
- default_tab_cls = ObjectProperty(DynamicTabHeader)
-
- def set_def_tab(self, new_tab):
- if not issubclass(new_tab.__class__, DynamicTabHeader):
- raise TabbedPanelException('`default_tab_class` should be'
- 'subclassed from `DynamicTabHeader`')
- if self._default_tab == new_tab:
- return
- oltab = self._default_tab
- self._default_tab = new_tab
- self.remove_widget(oltab)
- self._original_tab = None
- self.switch_to(new_tab)
- new_tab.state = 'down'
-
- def __init__(self, **kwargs):
- # these variables need to be initialized before the kv lang is
- # processed set up the base layout for the tabbed panel
- self._childrens = []
- self._tab_layout = StripLayout(rows=1)
- self.rows = 1
- self._tab_strip = TabbedPanelStrip(
- tabbed_panel=self,
- rows=1, size_hint=(None, None),
- height=self.tab_height, width=self.tab_width)
-
- self._partial_update_scrollview = None
- self.content = TabbedPanelContent()
- self._current_tab = self._original_tab = self._default_tab = DynamicTabHeader()
-
- super(TabbedPanel, self).__init__(**kwargs)
-
- self.fbind('size', self._reposition_tabs)
- if not self.do_default_tab:
- Clock.schedule_once(self._switch_to_first_tab)
- return
- self._setup_default_tab()
- self.switch_to(self.default_tab)
-
- def add_widget(self, widget, *args, **kwargs):
- content = self.content
- if content is None:
- return
- parent = widget.parent
- if parent:
- parent.remove_widget(widget)
- if widget in (content, self._tab_layout):
- super(TabbedPanel, self).add_widget(widget, *args, **kwargs)
- elif isinstance(widget, DynamicTabHeader):
- self_tabs = self._tab_strip
- self_tabs.add_widget(widget, *args, **kwargs)
- widget.group = '__tab%r__' % self_tabs.uid
- self.on_tab_width()
- else:
- widget.pos_hint = {'x': 0, 'top': 1}
- self._childrens.append(widget)
- content.disabled = self.current_tab.disabled
- content.add_widget(widget, *args, **kwargs)
-
- def remove_widget(self, widget, *args, **kwargs):
- content = self.content
- if content is None:
- return
- if widget in (content, self._tab_layout):
- super(TabbedPanel, self).remove_widget(widget, *args, **kwargs)
- elif isinstance(widget, DynamicTabHeader):
- if not (self.do_default_tab and widget is self._default_tab):
- self_tabs = self._tab_strip
- self_tabs.width -= widget.width
- self_tabs.remove_widget(widget)
- if widget.state == 'down' and self.do_default_tab:
- self._default_tab.on_release()
- self._reposition_tabs()
- else:
- Logger.info('DynamicTabPanel: default tab! can\'t be removed.\n' +
- 'Change `default_tab` to a different tab.')
- else:
- if widget in self._childrens:
- self._childrens.remove(widget)
- if widget in content.children:
- content.remove_widget(widget, *args, **kwargs)
-
- def _setup_default_tab(self):
- if self._default_tab in self.tab_list:
- return
- content = self._default_tab.content
- _tabs = self._tab_strip
- cls = self.default_tab_cls
-
- if isinstance(cls, string_types):
- cls = Factory.get(cls)
-
- if not issubclass(cls, DynamicTabHeader):
- raise TabbedPanelException('`default_tab_class` should be\
- subclassed from `DynamicTabHeader`')
-
- # no need to instantiate if class is DynamicTabHeader
- if cls != DynamicTabHeader:
- # noinspection PyCallingNonCallable
- self._current_tab = self._original_tab = self._default_tab = cls()
-
- default_tab = self.default_tab
- if self._original_tab == self.default_tab:
- default_tab.text = self.default_tab_text
-
- default_tab.height = self.tab_height
- default_tab.group = '__tab%r__' % _tabs.uid
- default_tab.state = 'down'
- default_tab.width = self.tab_width if self.tab_width else 100
- default_tab.content = content
-
- tl = self.tab_list
- if default_tab not in tl:
- _tabs.add_widget(default_tab, len(tl))
-
- if default_tab.content:
- self.clear_widgets()
- self.add_widget(self.default_tab.content)
- else:
- Clock.schedule_once(self._load_default_tab_content)
- self._current_tab = default_tab
diff --git a/src/helpers/__init__.py b/src/helpers/__init__.py
index 70c27fda..51fc1105 100644
--- a/src/helpers/__init__.py
+++ b/src/helpers/__init__.py
@@ -1,6 +1,7 @@
"""
* Photoshop Helper Modules
"""
+
from src.helpers.actions import *
from src.helpers.adjustments import *
from src.helpers.bounds import *
diff --git a/src/helpers/actions.py b/src/helpers/actions.py
index 4cadc80a..888124b2 100644
--- a/src/helpers/actions.py
+++ b/src/helpers/actions.py
@@ -1,20 +1,11 @@
"""
* Helpers: Actions
"""
-# Third Party Imports
-from photoshop.api import (
- DialogModes,
- ActionDescriptor,
- ActionReference
-)
-# Local Imports
-from src import APP
+from photoshop.api import ActionDescriptor, ActionReference
+from photoshop.api.enumerations import DialogModes
-# QOL Definitions
-sID = APP.stringIDToTypeID
-cID = APP.charIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
+from src import APP
"""
* Working With Actions
@@ -30,9 +21,11 @@ def run_action(action_set: str, action: str) -> None:
"""
desc310 = ActionDescriptor()
ref7 = ActionReference()
- desc310.putBoolean(sID("dontRecord"), False)
- desc310.putBoolean(sID("forceNotify"), True)
- ref7.putName(sID("action"), action)
- ref7.putName(sID("actionSet"), action_set)
- desc310.putReference(sID("target"), ref7)
- APP.ExecuteAction(sID("play"), desc310, NO_DIALOG)
+ desc310.putBoolean(APP.instance.sID("dontRecord"), False)
+ desc310.putBoolean(APP.instance.sID("forceNotify"), True)
+ ref7.putName(APP.instance.sID("action"), action)
+ ref7.putName(APP.instance.sID("actionSet"), action_set)
+ desc310.putReference(APP.instance.sID("target"), ref7)
+ APP.instance.executeAction(
+ APP.instance.sID("play"), desc310, DialogModes.DisplayNoDialogs
+ )
diff --git a/src/helpers/adjustments.py b/src/helpers/adjustments.py
index fa14144f..a2a1e6f5 100644
--- a/src/helpers/adjustments.py
+++ b/src/helpers/adjustments.py
@@ -1,22 +1,18 @@
"""
* Helpers: Adjustment Layers
"""
-# Standard Library
-from typing import Union, Optional
-# Third Party
-from photoshop.api import DialogModes, ActionList, ActionDescriptor, ActionReference, SolidColor
+from typing import NotRequired, TypedDict, Unpack
+
+from photoshop.api import ActionDescriptor, ActionList, ActionReference
from photoshop.api._artlayer import ArtLayer
from photoshop.api._document import Document
from photoshop.api._layerSet import LayerSet
+from photoshop.api.enumerations import BlendMode, DialogModes
-# Local Imports
from src import APP
-from src.helpers.colors import get_color, apply_color, add_color_to_gradient, rgb_black
-
-# QOL Definitions
-sID, cID = APP.stringIDToTypeID, APP.charIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
+from src.helpers.colors import add_color_to_gradient, apply_color, get_color, rgb_black
+from src.schema.colors import ColorObject, GradientConfig
"""
* Creating Adjustment Layers
@@ -32,16 +28,23 @@ def create_vibrant_saturation(vibrancy: int, saturation: int) -> None:
"""
# dialogMode (Have dialog popup?)
desc232 = ActionDescriptor()
- desc232.putInteger(sID("vibrance"), vibrancy)
- desc232.putInteger(sID("saturation"), saturation)
- APP.executeAction(sID("vibrance"), desc232, NO_DIALOG)
+ desc232.putInteger(APP.instance.sID("vibrance"), vibrancy)
+ desc232.putInteger(APP.instance.sID("saturation"), saturation)
+ APP.instance.executeAction(
+ APP.instance.sID("vibrance"), desc232, DialogModes.DisplayNoDialogs
+ )
+
+
+class CreateColorLayerKwargs(TypedDict):
+ clipped: NotRequired[bool]
+ blend_mode: NotRequired[BlendMode]
def create_color_layer(
- color: Union[list[int], SolidColor, str],
- layer: Union[ArtLayer, LayerSet, None],
- docref: Optional[Document] = None,
- **kwargs
+ color: ColorObject,
+ layer: ArtLayer | LayerSet | None,
+ docref: Document | None = None,
+ **kwargs: Unpack[CreateColorLayerKwargs],
) -> ArtLayer:
"""Create a solid color adjustment layer.
@@ -57,32 +60,50 @@ def create_color_layer(
Returns:
The new solid color adjustment layer.
"""
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
if layer:
docref.activeLayer = layer
desc1 = ActionDescriptor()
ref1 = ActionReference()
desc2 = ActionDescriptor()
desc3 = ActionDescriptor()
- ref1.putClass(sID("contentLayer"))
- desc1.putReference(sID("target"), ref1)
- desc2.putBoolean(sID("group"), kwargs.get('clipped', True))
- desc2.putEnumerated(sID("color"), sID("color"), sID("blue"))
+ ref1.putClass(APP.instance.sID("contentLayer"))
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc2.putBoolean(APP.instance.sID("group"), kwargs.get("clipped", True))
+ desc2.putEnumerated(
+ APP.instance.sID("color"), APP.instance.sID("color"), APP.instance.sID("blue")
+ )
apply_color(desc3, color)
- desc2.putObject(sID("type"), sID("solidColorLayer"), desc3)
- desc1.putObject(sID("using"), sID("contentLayer"), desc2)
- APP.executeAction(sID("make"), desc1, NO_DIALOG)
+ desc2.putObject(
+ APP.instance.sID("type"), APP.instance.sID("solidColorLayer"), desc3
+ )
+ desc1.putObject(APP.instance.sID("using"), APP.instance.sID("contentLayer"), desc2)
+ APP.instance.executeAction(
+ APP.instance.sID("make"), desc1, DialogModes.DisplayNoDialogs
+ )
layer = docref.activeLayer
- if 'blend_mode' in kwargs:
- layer.blendMode = kwargs['blend_mode']
+ if not isinstance(layer, ArtLayer):
+ raise ValueError(
+ "Failed to create a color layer. Active layer is unexpectedly not an ArtLayer."
+ )
+ if "blend_mode" in kwargs:
+ layer.blendMode = kwargs["blend_mode"]
return layer
+class CreateGradientLayerKwargs(TypedDict):
+ blend_mode: NotRequired[BlendMode]
+ clipped: NotRequired[bool]
+ dither: NotRequired[bool]
+ rotation: NotRequired[float]
+ scale: NotRequired[float]
+
+
def create_gradient_layer(
- colors: list[dict],
- layer: Union[ArtLayer, LayerSet, None],
- docref: Optional[Document] = None,
- **kwargs
+ colors: list[GradientConfig],
+ layer: ArtLayer | LayerSet | None,
+ docref: Document | None = None,
+ **kwargs: Unpack[CreateGradientLayerKwargs],
) -> ArtLayer:
"""Create a gradient adjustment layer.
@@ -92,15 +113,16 @@ def create_gradient_layer(
docref: Reference Document, use active if not provided.
Keyword Args:
+ blend_mode (BlendMode): Optional blend mode to apply to the new layer.
clipped (bool): Whether to apply as a clipping mask to the nearest layer, defaults to True.
+ dither (bool): Whether to enable dithering for the gradient.
rotation (Union[int, float]): Rotation to apply to the gradient, defaults to 90.
scale (Union[int, float]): Scale to apply to the gradient, defaults to 100.
- blend_mode (BlendMode): Optional blend mode to apply to the new layer.
Returns:
The new gradient adjustment layer.
"""
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
if layer:
docref.activeLayer = layer
desc1 = ActionDescriptor()
@@ -112,40 +134,68 @@ def create_gradient_layer(
list2 = ActionList()
desc9 = ActionDescriptor()
desc10 = ActionDescriptor()
- ref1.putClass(sID("contentLayer"))
- desc1.putReference(sID("target"), ref1)
- desc2.putBoolean(sID("group"), kwargs.get('clipped', True))
+ ref1.putClass(APP.instance.sID("contentLayer"))
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc2.putBoolean(APP.instance.sID("group"), kwargs.get("clipped", True))
+ desc3.putEnumerated(
+ APP.instance.sID("gradientsInterpolationMethod"),
+ APP.instance.sID("gradientInterpolationMethodType"),
+ APP.instance.sID("perceptual"),
+ )
+ desc3.putBoolean(APP.instance.sID("dither"), kwargs.get("dither", True))
+ desc3.putUnitDouble(
+ APP.instance.sID("angle"),
+ APP.instance.sID("angleUnit"),
+ kwargs.get("rotation", 0),
+ )
desc3.putEnumerated(
- sID("gradientsInterpolationMethod"),
- sID("gradientInterpolationMethodType"),
- sID("perceptual"))
- desc3.putUnitDouble(sID("angle"), sID("angleUnit"), kwargs.get('rotation', 0))
- desc3.putEnumerated(sID("type"), sID("gradientType"), sID("linear"))
- desc3.putUnitDouble(sID("scale"), sID("percentUnit"), kwargs.get('scale', 100))
- desc4.putEnumerated(sID("gradientForm"), sID("gradientForm"), sID("customStops"))
- desc4.putDouble(sID("interfaceIconFrameDimmed"), 4096)
+ APP.instance.sID("type"),
+ APP.instance.sID("gradientType"),
+ APP.instance.sID("linear"),
+ )
+ desc3.putUnitDouble(
+ APP.instance.sID("scale"),
+ APP.instance.sID("percentUnit"),
+ kwargs.get("scale", 100),
+ )
+ desc4.putEnumerated(
+ APP.instance.sID("gradientForm"),
+ APP.instance.sID("gradientForm"),
+ APP.instance.sID("customStops"),
+ )
+ desc4.putDouble(APP.instance.sID("interfaceIconFrameDimmed"), 4096)
for c in colors:
add_color_to_gradient(
color_list,
- get_color(c.get('color', rgb_black())),
- int(c.get('location', 0)),
- int(c.get('midpoint', 50))
+ get_color(c.get("color", rgb_black())),
+ int(c.get("location", 0)),
+ int(c.get("midpoint", 50)),
)
- desc4.putList(sID("colors"), color_list)
- desc9.putUnitDouble(sID("opacity"), sID("percentUnit"), 100)
- desc9.putInteger(sID("location"), 0)
- desc9.putInteger(sID("midpoint"), 50)
- list2.putObject(sID("transferSpec"), desc9)
- desc10.putUnitDouble(sID("opacity"), sID("percentUnit"), 100)
- desc10.putInteger(sID("location"), 4096)
- desc10.putInteger(sID("midpoint"), 50)
- list2.putObject(sID("transferSpec"), desc10)
- desc4.putList(sID("transparency"), list2)
- desc3.putObject(sID("gradient"), sID("gradientClassEvent"), desc4)
- desc2.putObject(sID("type"), sID("gradientLayer"), desc3)
- desc1.putObject(sID("using"), sID("contentLayer"), desc2)
- APP.executeAction(sID("make"), desc1, NO_DIALOG)
+ desc4.putList(APP.instance.sID("colors"), color_list)
+ desc9.putUnitDouble(
+ APP.instance.sID("opacity"), APP.instance.sID("percentUnit"), 100
+ )
+ desc9.putInteger(APP.instance.sID("location"), 0)
+ desc9.putInteger(APP.instance.sID("midpoint"), 50)
+ list2.putObject(APP.instance.sID("transferSpec"), desc9)
+ desc10.putUnitDouble(
+ APP.instance.sID("opacity"), APP.instance.sID("percentUnit"), 100
+ )
+ desc10.putInteger(APP.instance.sID("location"), 4096)
+ desc10.putInteger(APP.instance.sID("midpoint"), 50)
+ list2.putObject(APP.instance.sID("transferSpec"), desc10)
+ desc4.putList(APP.instance.sID("transparency"), list2)
+ desc3.putObject(
+ APP.instance.sID("gradient"), APP.instance.sID("gradientClassEvent"), desc4
+ )
+ desc2.putObject(APP.instance.sID("type"), APP.instance.sID("gradientLayer"), desc3)
+ desc1.putObject(APP.instance.sID("using"), APP.instance.sID("contentLayer"), desc2)
+ APP.instance.executeAction(
+ APP.instance.sID("make"), desc1, DialogModes.DisplayNoDialogs
+ )
layer = docref.activeLayer
- if 'blend_mode' in kwargs:
- layer.blendMode = kwargs['blend_mode']
+ if not isinstance(layer, ArtLayer):
+ raise ValueError("Failed to create a gradient color layer")
+ if "blend_mode" in kwargs:
+ layer.blendMode = kwargs["blend_mode"]
return layer
diff --git a/src/helpers/bounds.py b/src/helpers/bounds.py
index a95d2d07..fcd2e07e 100644
--- a/src/helpers/bounds.py
+++ b/src/helpers/bounds.py
@@ -1,47 +1,41 @@
"""
* Helpers: Bounds and Dimensions
"""
-# Standard Library Imports
+
from contextlib import suppress
-from typing import Union, TypedDict
+from typing import TypedDict
-# Third Party Imports
-from photoshop.api import DialogModes
from photoshop.api._artlayer import ArtLayer
+from photoshop.api._document import Document
from photoshop.api._layerSet import LayerSet
-# Local Imports
from src import APP
-from src.helpers.descriptors import get_layer_action_ref
+from src.helpers.descriptors import get_layer_action_descriptor
from src.helpers.document import undo_action
-from src.utils.adobe import PS_EXCEPTIONS
-
-# QOL Definitions
-sID, cID = APP.stringIDToTypeID, APP.charIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
+from src.helpers.layers import duplicate_group
+from src.utils.adobe import LayerBounds
"""
* Types
"""
-# Layer bounds: left, top, right, bottom
-LayerBounds = tuple[int, int, int, int]
-
class LayerDimensions(TypedDict):
"""Calculated layer dimension info for a layer."""
- width: int
- height: int
- center_x: int
- center_y: int
- left: int
- right: int
- top: int
- bottom: int
+
+ width: int | float
+ height: int | float
+ center_x: int | float
+ center_y: int | float
+ left: int | float
+ right: int | float
+ top: int | float
+ bottom: int | float
class TextboxDimensions(TypedDict):
"""Calculated width and height of paragraph text layer bounding box."""
+
width: int
height: int
@@ -51,7 +45,7 @@ class TextboxDimensions(TypedDict):
"""
-def get_dimensions_from_bounds(bounds: LayerBounds) -> type[LayerDimensions]:
+def get_dimensions_from_bounds(bounds: LayerBounds) -> LayerDimensions:
"""Compute width and height based on a set of bounds given.
Args:
@@ -60,18 +54,21 @@ def get_dimensions_from_bounds(bounds: LayerBounds) -> type[LayerDimensions]:
Returns:
Dict containing height, width, and positioning locations.
"""
- width = int(bounds[2]-bounds[0])
- height = int(bounds[3]-bounds[1])
+ width = int(bounds[2] - bounds[0])
+ height = int(bounds[3] - bounds[1])
return LayerDimensions(
width=width,
height=height,
center_x=round((width / 2) + bounds[0]),
center_y=round((height / 2) + bounds[1]),
- left=int(bounds[0]), right=int(bounds[2]),
- top=int(bounds[1]), bottom=int(bounds[3]))
+ left=int(bounds[0]),
+ right=int(bounds[2]),
+ top=int(bounds[1]),
+ bottom=int(bounds[3]),
+ )
-def get_layer_dimensions(layer: Union[ArtLayer, LayerSet]) -> type[LayerDimensions]:
+def get_layer_dimensions(layer: ArtLayer | LayerSet) -> LayerDimensions:
"""Compute the width and height dimensions of a layer.
Args:
@@ -83,7 +80,22 @@ def get_layer_dimensions(layer: Union[ArtLayer, LayerSet]) -> type[LayerDimensio
return get_dimensions_from_bounds(layer.bounds)
-def get_layer_width(layer: Union[ArtLayer, LayerSet]) -> Union[float, int]:
+def get_group_dimensions(group: LayerSet) -> LayerDimensions:
+ """
+ Compute the dimensions of a group.
+
+ Uses a workaround to avoid erroneous dimensions, which might occur
+ when the group contains shapes.
+ """
+ group_copy = duplicate_group(group, group.name)
+ group_copy.visible = True
+ merged = group_copy.merge()
+ dims = get_layer_dimensions(merged)
+ merged.remove()
+ return dims
+
+
+def get_layer_width(layer: ArtLayer | LayerSet) -> float | int:
"""Returns the width of a given layer.
Args:
@@ -93,10 +105,10 @@ def get_layer_width(layer: Union[ArtLayer, LayerSet]) -> Union[float, int]:
int: Width of the layer in pixels.
"""
bounds = layer.bounds
- return int(bounds[2]-bounds[0])
+ return int(bounds[2] - bounds[0])
-def get_layer_height(layer: Union[ArtLayer, LayerSet]) -> Union[float, int]:
+def get_layer_height(layer: ArtLayer | LayerSet) -> float | int:
"""Returns the height of a given layer.
Args:
@@ -106,7 +118,38 @@ def get_layer_height(layer: Union[ArtLayer, LayerSet]) -> Union[float, int]:
int: Height of the layer in pixels.
"""
bounds = layer.bounds
- return int(bounds[3]-bounds[1])
+ return int(bounds[3] - bounds[1])
+
+
+def get_document_dimensions(document: Document) -> LayerDimensions:
+ """Compute the width and height dimensions of a document.
+
+ Args:
+ document: A document object
+
+ Returns:
+ Dict containing height, width, and positioning locations.
+ """
+ doc_width: float = int(document.width)
+ doc_height: float = int(document.height)
+ return get_dimensions_from_bounds((0, 0, doc_width, doc_height))
+
+
+def get_card_dimensions(document: Document) -> LayerDimensions:
+ """Compute the width and height dimensions of a document.
+
+ Args:
+ document: A document object
+
+ Returns:
+ Dict containing height, width, and positioning locations.
+ """
+ bleed = int(document.resolution / 8)
+ doc_width: float = int(document.width)
+ doc_height: float = int(document.height)
+ return get_dimensions_from_bounds(
+ (bleed, bleed, doc_width - bleed, doc_height - bleed)
+ )
"""
@@ -114,7 +157,7 @@ def get_layer_height(layer: Union[ArtLayer, LayerSet]) -> Union[float, int]:
"""
-def get_bounds_no_effects(layer: Union[ArtLayer, LayerSet]) -> LayerBounds:
+def get_bounds_no_effects(layer: ArtLayer | LayerSet) -> LayerBounds:
"""Returns the bounds of a given layer without its effects applied.
Args:
@@ -123,24 +166,18 @@ def get_bounds_no_effects(layer: Union[ArtLayer, LayerSet]) -> LayerBounds:
Returns:
list: Pixel location top left, top right, bottom left, bottom right.
"""
- with suppress(Exception):
- d = get_layer_action_ref(layer)
- try:
- # Try getting bounds no effects
- bounds = d.getObjectValue(sID('boundsNoEffects'))
- except PS_EXCEPTIONS:
- # Try getting bounds
- bounds = d.getObjectValue(sID('bounds'))
- return (
- bounds.getInteger(sID('left')),
- bounds.getInteger(sID('top')),
- bounds.getInteger(sID('right')),
- bounds.getInteger(sID('bottom')))
- # Fallback to layer object bounds property
- return layer.bounds
-
-
-def get_dimensions_no_effects(layer: Union[ArtLayer, LayerSet]) -> type[LayerDimensions]:
+ d = get_layer_action_descriptor(layer)
+ # Try getting bounds no effects
+ bounds = d.getObjectValue(APP.instance.sID("boundsNoEffects"))
+ return (
+ bounds.getInteger(APP.instance.sID("left")),
+ bounds.getInteger(APP.instance.sID("top")),
+ bounds.getInteger(APP.instance.sID("right")),
+ bounds.getInteger(APP.instance.sID("bottom")),
+ )
+
+
+def get_dimensions_no_effects(layer: ArtLayer | LayerSet) -> LayerDimensions:
"""Compute the dimensions of a layer without its effects applied.
Args:
@@ -153,7 +190,7 @@ def get_dimensions_no_effects(layer: Union[ArtLayer, LayerSet]) -> type[LayerDim
return get_dimensions_from_bounds(bounds)
-def get_width_no_effects(layer: Union[ArtLayer, LayerSet]) -> int:
+def get_width_no_effects(layer: ArtLayer | LayerSet) -> float | int:
"""Returns the width of a given layer without its effects applied.
Args:
@@ -164,13 +201,15 @@ def get_width_no_effects(layer: Union[ArtLayer, LayerSet]) -> int:
"""
with suppress(Exception):
# Try getting bounds no effects
- d = get_layer_action_ref(layer)
- bounds = d.getObjectValue(sID('boundsNoEffects'))
- return bounds.getInteger(sID('right')) - bounds.getInteger(sID('left'))
+ d = get_layer_action_descriptor(layer)
+ bounds = d.getObjectValue(APP.instance.sID("boundsNoEffects"))
+ return bounds.getInteger(APP.instance.sID("right")) - bounds.getInteger(
+ APP.instance.sID("left")
+ )
return get_layer_width(layer)
-def get_height_no_effects(layer: Union[ArtLayer, LayerSet]) -> int:
+def get_height_no_effects(layer: ArtLayer | LayerSet) -> float | int:
"""Returns the height of a given layer without its effects applied.
Args:
@@ -181,9 +220,11 @@ def get_height_no_effects(layer: Union[ArtLayer, LayerSet]) -> int:
"""
with suppress(Exception):
# Try getting bounds no effects
- d = get_layer_action_ref(layer)
- bounds = d.getObjectValue(sID('boundsNoEffects'))
- return bounds.getInteger(sID('bottom')) - bounds.getInteger(sID('top'))
+ d = get_layer_action_descriptor(layer)
+ bounds = d.getObjectValue(APP.instance.sID("boundsNoEffects"))
+ return bounds.getInteger(APP.instance.sID("bottom")) - bounds.getInteger(
+ APP.instance.sID("top")
+ )
return get_layer_height(layer)
@@ -202,9 +243,9 @@ def check_textbox_overflow(layer: ArtLayer) -> bool:
True if text overflowing, else False.
"""
# Create a test layer to check the difference
- height = get_layer_dimensions(layer)['height']
+ height = get_layer_dimensions(layer)["height"]
layer.textItem.height = 1000
- dif = get_layer_dimensions(layer)['height'] - height
+ dif = get_layer_dimensions(layer)["height"] - height
undo_action()
if dif > 0:
return True
@@ -220,17 +261,17 @@ def get_textbox_bounds(layer: ArtLayer) -> LayerBounds:
Returns:
List of bounds integers.
"""
- d = get_layer_action_ref(layer)
- bounds = d.getObjectValue(sID('boundingBox'))
+ d = get_layer_action_descriptor(layer)
+ bounds = d.getObjectValue(APP.instance.sID("boundingBox"))
return (
- bounds.getInteger(sID('left')),
- bounds.getInteger(sID('top')),
- bounds.getInteger(sID('right')),
- bounds.getInteger(sID('bottom'))
+ bounds.getInteger(APP.instance.sID("left")),
+ bounds.getInteger(APP.instance.sID("top")),
+ bounds.getInteger(APP.instance.sID("right")),
+ bounds.getInteger(APP.instance.sID("bottom")),
)
-def get_textbox_dimensions(layer: ArtLayer) -> type[TextboxDimensions]:
+def get_textbox_dimensions(layer: ArtLayer) -> TextboxDimensions:
"""Get the dimensions of a TextLayer's bounding box.
Args:
@@ -239,11 +280,11 @@ def get_textbox_dimensions(layer: ArtLayer) -> type[TextboxDimensions]:
Returns:
Dict containing width and height.
"""
- d = get_layer_action_ref(layer)
- bounds = d.getObjectValue(sID('boundingBox'))
+ d = get_layer_action_descriptor(layer)
+ bounds = d.getObjectValue(APP.instance.sID("boundingBox"))
return {
- 'width': bounds.getInteger(sID('width')),
- 'height': bounds.getInteger(sID('height'))
+ "width": bounds.getInteger(APP.instance.sID("width")),
+ "height": bounds.getInteger(APP.instance.sID("height")),
}
@@ -256,9 +297,9 @@ def get_textbox_width(layer: ArtLayer) -> int:
Returns:
Width of the textbox.
"""
- d = get_layer_action_ref(layer)
- bounds = d.getObjectValue(sID('boundingBox'))
- return bounds.getInteger(sID('width'))
+ d = get_layer_action_descriptor(layer)
+ bounds = d.getObjectValue(APP.instance.sID("boundingBox"))
+ return bounds.getInteger(APP.instance.sID("width"))
def get_textbox_height(layer: ArtLayer) -> int:
@@ -270,6 +311,6 @@ def get_textbox_height(layer: ArtLayer) -> int:
Returns:
Height of the textbox.
"""
- d = get_layer_action_ref(layer)
- bounds = d.getObjectValue(sID('boundingBox'))
- return bounds.getInteger(sID('height'))
+ d = get_layer_action_descriptor(layer)
+ bounds = d.getObjectValue(APP.instance.sID("boundingBox"))
+ return bounds.getInteger(APP.instance.sID("height"))
diff --git a/src/helpers/colors.py b/src/helpers/colors.py
index 33cdd13a..fce964d2 100644
--- a/src/helpers/colors.py
+++ b/src/helpers/colors.py
@@ -1,21 +1,16 @@
"""
* Helpers: Colors
"""
-# Standard Library Imports
-from typing import Union, Optional
-# Third Party Imports
-from photoshop.api import SolidColor, DialogModes, ActionList, ActionDescriptor, ColorModel, LayerKind
-from photoshop.api._artlayer import ArtLayer, TextItem
+from collections.abc import Mapping
+
+from photoshop.api import ActionDescriptor, ActionList, SolidColor
+from photoshop.api._artlayer import ArtLayer
+from photoshop.api.enumerations import ColorModel, DialogModes, LayerKind
-# Local Imports
from src import APP, CON
from src.enums.layers import LAYERS
-from src.schema.colors import pinlines_color_map, ColorObject
-
-# QOL Definitions
-sID, cID = APP.stringIDToTypeID, APP.charIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
+from src.schema.colors import ColorObject, GradientConfig, pinlines_color_map
"""
* Color Conversions
@@ -31,9 +26,9 @@ def hex_to_rgb(color: str) -> list[int]:
Returns:
Color in RGB list notation.
"""
- if '#' in color:
+ if "#" in color:
color = color[1:]
- return [int(color[i:i+2], 16) for i in (0, 2, 4)]
+ return [int(color[i : i + 2], 16) for i in (0, 2, 4)]
"""
@@ -68,7 +63,7 @@ def rgb_white() -> SolidColor:
return get_rgb(255, 255, 255)
-def get_rgb(r: int, g: int, b: int) -> SolidColor:
+def get_rgb(r: float, g: float, b: float) -> SolidColor:
"""Creates a SolidColor object with the given RGB values.
Args:
@@ -96,7 +91,7 @@ def get_rgb_from_hex(hex_code: str) -> SolidColor:
SolidColor object.
"""
# Remove hashtag
- hex_code = hex_code.lstrip('#')
+ hex_code = hex_code.lstrip("#")
# Hexadecimal abbreviated
if len(hex_code) == 3:
hex_code = "".join([n * 2 for n in hex_code])
@@ -106,6 +101,14 @@ def get_rgb_from_hex(hex_code: str) -> SolidColor:
return color
+def get_rgb_tuple(color: ColorObject) -> tuple[float, float, float]:
+ if isinstance(color, tuple) and len(color) == 3:
+ return color
+ colr = get_color(color)
+ rgb = colr.rgb
+ return (rgb.red, rgb.green, rgb.blue)
+
+
def get_cmyk(c: float, m: float, y: float, k: float) -> SolidColor:
"""Creates a SolidColor object with the given CMYK values.
@@ -140,7 +143,7 @@ def get_color(color: ColorObject) -> SolidColor:
if isinstance(color, SolidColor):
# Solid color given
return color
- if isinstance(color, list):
+ elif isinstance(color, tuple):
# List notation
if len(color) == 3:
# RGB
@@ -148,15 +151,14 @@ def get_color(color: ColorObject) -> SolidColor:
elif len(color) == 4:
# CMYK
return get_cmyk(*color)
- if isinstance(color, str):
+ else:
# Named color
if color in CON.colors:
return get_color(CON.colors[color])
# Hexadecimal
return get_rgb_from_hex(color)
- except (ValueError, TypeError):
- raise ValueError(f"Invalid color notation given: {color}")
- raise ValueError(f"Unrecognized color notation given: {color}")
+ except (ValueError, TypeError) as exc:
+ raise ValueError(f"Invalid color notation given: {color}") from exc
def get_text_layer_color(layer: ArtLayer) -> SolidColor:
@@ -169,27 +171,16 @@ def get_text_layer_color(layer: ArtLayer) -> SolidColor:
Returns:
SolidColor object representing the color of the text item.
"""
- if isinstance(layer, ArtLayer) and layer.kind == LayerKind.TextLayer:
- if hasattr(layer.textItem, 'color'):
- return layer.textItem.color
- print(f"Couldn't retrieve color of layer: {layer.name}")
- return rgb_black()
-
-
-def get_text_item_color(item: TextItem) -> SolidColor:
- """Utility definition for `get_text_layer_color`, targeting a known TextItem."""
- if isinstance(item, TextItem):
- if hasattr(item, 'color'):
- return item.color
- print(f"Couldn't retrieve color of TextItem!")
+ if layer.kind == LayerKind.TextLayer:
+ return layer.textItem.color
return rgb_black()
def get_pinline_gradient(
- colors: str,
- color_map: Optional[dict] = None,
- location_map: dict = None
-) -> Union[list[int], list[dict]]:
+ colors: str,
+ color_map: Mapping[str, ColorObject] | None = None,
+ location_map: dict[int, list[int | float]] | None = None,
+) -> ColorObject | list[GradientConfig]:
"""Return a gradient color list notation for some given pinline colors.
Args:
@@ -210,67 +201,31 @@ def get_pinline_gradient(
# Return our colors
if not colors:
- return color_map.get('Artifact', [0, 0, 0])
- if len(colors) == 1:
- return color_map.get(colors, [0, 0, 0])
- if len(colors) == 2:
- return [
- {
- 'color': color_map.get(colors[0], [0, 0, 0]),
- 'location': location_map[2][0] * 4096, 'midpoint': 50
- },
- {
- 'color': color_map.get(colors[1], [0, 0, 0]),
- 'location': location_map[2][1] * 4096, 'midpoint': 50,
- }
- ]
- if len(colors) == 3:
- return [
- {
- 'color': color_map.get(colors[0], [0, 0, 0]),
- 'location': location_map[3][0] * 4096, 'midpoint': 50
- },
- {
- 'color': color_map.get(colors[1], [0, 0, 0]),
- 'location': location_map[3][1] * 4096, 'midpoint': 50
- },
- {
- 'color': color_map.get(colors[1], [0, 0, 0]),
- 'location': location_map[3][2] * 4096, 'midpoint': 50
- },
- {
- 'color': color_map.get(colors[2], [0, 0, 0]),
- 'location': location_map[3][3] * 4096, 'midpoint': 50
- }
- ]
- if len(colors) == 4 and colors not in [LAYERS.LAND, LAYERS.GOLD]:
- return [
- {
- 'color': color_map.get(colors[0], [0, 0, 0]),
- 'location': location_map[4][0] * 4096, 'midpoint': 50
- },
- {
- 'color': color_map.get(colors[1], [0, 0, 0]),
- 'location': location_map[4][1] * 4096, 'midpoint': 50
- },
- {
- 'color': color_map.get(colors[1], [0, 0, 0]),
- 'location': location_map[4][2] * 4096, 'midpoint': 50
- },
- {
- 'color': color_map.get(colors[2], [0, 0, 0]),
- 'location': location_map[4][3] * 4096, 'midpoint': 50
- },
- {
- 'color': color_map.get(colors[2], [0, 0, 0]),
- 'location': location_map[4][4] * 4096, 'midpoint': 50
- },
- {
- 'color': color_map.get(colors[3], [0, 0, 0]),
- 'location': location_map[4][5] * 4096, 'midpoint': 50
- }
- ]
- return color_map.get(colors, [0, 0, 0])
+ return color_map.get(LAYERS.ARTIFACT, (0, 0, 0))
+
+ if colors in color_map:
+ return color_map[colors]
+
+ if (len_colors := len(colors)) > 1:
+ color_confs: list[GradientConfig] = []
+ for i in range(len_colors - 1):
+ color_confs.append(
+ {
+ "color": color_map.get(colors[i], (0, 0, 0)),
+ "location": location_map[len_colors][2 * i] * 4096,
+ "midpoint": 50,
+ }
+ )
+ color_confs.append(
+ {
+ "color": color_map.get(colors[i + 1], (0, 0, 0)),
+ "location": location_map[len_colors][2 * i + 1] * 4096,
+ "midpoint": 50,
+ }
+ )
+ return color_confs
+
+ return (0, 0, 0)
"""
@@ -278,7 +233,11 @@ def get_pinline_gradient(
"""
-def apply_rgb_from_list(action: ActionDescriptor, color: list[int], color_type: str = 'color') -> None:
+def apply_rgb_from_list(
+ action: ActionDescriptor,
+ color: tuple[float, float, float],
+ color_type: str = "color",
+) -> None:
"""Applies RGB color to action descriptor from a list of values.
Args:
@@ -287,13 +246,17 @@ def apply_rgb_from_list(action: ActionDescriptor, color: list[int], color_type:
color_type: Color action descriptor type, defaults to 'color'.
"""
ad = ActionDescriptor()
- ad.putDouble(sID("red"), color[0])
- ad.putDouble(sID("green"), color[1])
- ad.putDouble(sID("blue"), color[2])
- action.putObject(sID(color_type), sID("RGBColor"), ad)
+ ad.putDouble(APP.instance.sID("red"), color[0])
+ ad.putDouble(APP.instance.sID("green"), color[1])
+ ad.putDouble(APP.instance.sID("blue"), color[2])
+ action.putObject(APP.instance.sID(color_type), APP.instance.sID("RGBColor"), ad)
-def apply_cmyk_from_list(action: ActionDescriptor, color: list[int], color_type: str = 'color') -> None:
+def apply_cmyk_from_list(
+ action: ActionDescriptor,
+ color: tuple[float, float, float, float],
+ color_type: str = "color",
+) -> None:
"""Applies CMYK color to action descriptor from a list of values.
Args:
@@ -302,14 +265,18 @@ def apply_cmyk_from_list(action: ActionDescriptor, color: list[int], color_type:
color_type: Color action descriptor type, defaults to 'color'.
"""
ad = ActionDescriptor()
- ad.putDouble(sID("cyan"), color[0])
- ad.putDouble(sID("magenta"), color[1])
- ad.putDouble(sID("yellowColor"), color[2])
- ad.putDouble(sID("black"), color[3])
- action.putObject(sID(color_type), sID("CMYKColorClass"), ad)
+ ad.putDouble(APP.instance.sID("cyan"), color[0])
+ ad.putDouble(APP.instance.sID("magenta"), color[1])
+ ad.putDouble(APP.instance.sID("yellowColor"), color[2])
+ ad.putDouble(APP.instance.sID("black"), color[3])
+ action.putObject(
+ APP.instance.sID(color_type), APP.instance.sID("CMYKColorClass"), ad
+ )
-def apply_rgb(action: ActionDescriptor, c: SolidColor, color_type: str = 'color') -> None:
+def apply_rgb(
+ action: ActionDescriptor, c: SolidColor, color_type: str = "color"
+) -> None:
"""Apply RGB SolidColor object to action descriptor.
Args:
@@ -317,10 +284,13 @@ def apply_rgb(action: ActionDescriptor, c: SolidColor, color_type: str = 'color'
c: SolidColor object matching RGB model.
color_type: Color action descriptor type, defaults to 'color'.
"""
- apply_rgb_from_list(action, [c.rgb.red, c.rgb.green, c.rgb.blue], color_type)
+ rgb = c.rgb
+ apply_rgb_from_list(action, (rgb.red, rgb.green, rgb.blue), color_type)
-def apply_cmyk(action: ActionDescriptor, c: SolidColor, color_type: str = 'color') -> None:
+def apply_cmyk(
+ action: ActionDescriptor, c: SolidColor, color_type: str = "color"
+) -> None:
"""Apply CMYK SolidColor object to action descriptor.
Args:
@@ -328,13 +298,14 @@ def apply_cmyk(action: ActionDescriptor, c: SolidColor, color_type: str = 'color
c: SolidColor object matching CMYK model.
color_type: Color action descriptor type, defaults to 'color'.
"""
- apply_cmyk_from_list(action, [c.cmyk.cyan, c.cmyk.magenta, c.cmyk.yellow, c.cmyk.black], color_type)
+ cmyk = c.cmyk
+ apply_cmyk_from_list(
+ action, (cmyk.cyan, cmyk.magenta, cmyk.yellow, cmyk.black), color_type
+ )
def apply_color(
- action: ActionDescriptor,
- color: ColorObject,
- color_type: str = 'color'
+ action: ActionDescriptor, color: ColorObject, color_type: str = "color"
) -> None:
"""Applies color to the specified action descriptor.
@@ -343,7 +314,7 @@ def apply_color(
color: RGB/CMYK SolidColor object, list of RGB/CMYK values, a hex string, or a named color.
color_type: Color action descriptor type, defaults to 'color'.
"""
- if isinstance(color, list):
+ if isinstance(color, tuple):
# RGB / CMYK list notation
if len(color) < 4:
return apply_rgb_from_list(action, color, color_type)
@@ -362,10 +333,7 @@ def apply_color(
def add_color_to_gradient(
- action_list: ActionList,
- color: ColorObject,
- location: int,
- midpoint: int
+ action_list: ActionList, color: ColorObject, location: int, midpoint: int
) -> None:
"""Adds a SolidColor to gradient at a given location, with a given midpoint.
@@ -377,14 +345,24 @@ def add_color_to_gradient(
"""
action = ActionDescriptor()
apply_color(action, color)
- action.putEnumerated(sID("type"), sID("colorStopType"), sID("userStop"))
- action.putInteger(sID("location"), location)
- action.putInteger(sID("midpoint"), midpoint)
- action_list.putObject(sID("colorStop"), action)
+ action.putEnumerated(
+ APP.instance.sID("type"),
+ APP.instance.sID("colorStopType"),
+ APP.instance.sID("userStop"),
+ )
+ action.putInteger(APP.instance.sID("location"), location)
+ action.putInteger(APP.instance.sID("midpoint"), midpoint)
+ action_list.putObject(APP.instance.sID("colorStop"), action)
def fill_layer_primary():
"""Fill active layer using foreground color."""
desc1 = ActionDescriptor()
- desc1.putEnumerated(sID("using"), sID("fillContents"), sID("foregroundColor"))
- APP.executeAction(sID("fill"), desc1, NO_DIALOG)
+ desc1.putEnumerated(
+ APP.instance.sID("using"),
+ APP.instance.sID("fillContents"),
+ APP.instance.sID("foregroundColor"),
+ )
+ APP.instance.executeAction(
+ APP.instance.sID("fill"), desc1, DialogModes.DisplayNoDialogs
+ )
diff --git a/src/helpers/descriptors.py b/src/helpers/descriptors.py
index ce882f8b..17b1c983 100644
--- a/src/helpers/descriptors.py
+++ b/src/helpers/descriptors.py
@@ -1,27 +1,29 @@
"""
* Helpers: PS Object Descriptors
"""
-# Standard Library Imports
-from typing import Union
-# Third Party Imports
-from photoshop.api import DialogModes, ActionReference
+from collections.abc import Callable
+from ctypes import COMError
+from logging import getLogger
+from os import PathLike
+
+from photoshop.api import ActionDescriptor, ActionList, ActionReference
from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
+from photoshop.api.enumerations import DescValueType
-# Local Imports
from src import APP
+from src.helpers.colors import apply_color
+from src.schema.colors import ColorObject
-# QOL Definitions
-sID, cID = APP.stringIDToTypeID, APP.charIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
+_logger = getLogger(__name__)
"""
* Layer Action Descriptors
"""
-def get_layer_action_ref(layer: Union[ArtLayer, LayerSet]) -> ActionReference:
+def get_layer_action_descriptor(layer: ArtLayer | LayerSet) -> ActionDescriptor:
"""Gets action descriptor info object using layer as a reference.
Args:
@@ -31,5 +33,296 @@ def get_layer_action_ref(layer: Union[ArtLayer, LayerSet]) -> ActionReference:
Action descriptor info object about the layer.
"""
ref = ActionReference()
- ref.putIdentifier(sID('layer'), layer.id)
- return APP.executeActionGet(ref)
+ ref.putIdentifier(APP.instance.sID("layer"), layer.id)
+ return APP.instance.executeActionGet(ref)
+
+
+# region Copying
+
+
+def copy_descriptor(
+ source: ActionDescriptor, recursive: bool = False
+) -> ActionDescriptor:
+ """Creates a copy of an ActionDescriptor by copying all properties from source.
+
+ Args:
+ source: Descriptor to copy from.
+ recursive: Whether to create copies of nested descriptors or not.
+
+ Returns:
+ New descriptor with all properties copied from source.
+ """
+ copy = ActionDescriptor()
+
+ for i in range(source.count):
+ copy_descriptor_value(
+ source.getKey(i), source=source, target=copy, recursive=recursive
+ )
+
+ return copy
+
+
+def copy_descriptor_value(
+ key: int,
+ source: ActionDescriptor,
+ target: ActionDescriptor,
+ recursive: bool = False,
+) -> None:
+ """Copies value at key from source to target descriptor."""
+ if source.hasKey(key):
+ value_type = source.getType(key)
+ try:
+ if value_type == DescValueType.AliasType:
+ target.putPath(key, source.getPath(key))
+ elif value_type == DescValueType.BooleanType:
+ target.putBoolean(key, source.getBoolean(key))
+ elif value_type == DescValueType.ClassType:
+ target.putClass(key, source.getClass(key))
+ elif value_type == DescValueType.DoubleType:
+ target.putDouble(key, source.getDouble(key))
+ elif value_type == DescValueType.EnumeratedType:
+ enum_type = source.getEnumerationType(key)
+ enum_value = source.getEnumerationValue(key)
+ target.putEnumerated(key, enum_type, enum_value)
+ elif value_type == DescValueType.IntegerType:
+ target.putInteger(key, source.getInteger(key))
+ elif value_type == DescValueType.LargeIntegerType:
+ target.putLargeInteger(key, source.getLargeInteger(key))
+ elif value_type == DescValueType.ListType:
+ target.putList(key, source.getList(key))
+ elif value_type == DescValueType.ObjectType:
+ obj_type = source.getObjectType(key)
+ obj_desc = source.getObjectValue(key)
+ # Recursive copy for nested objects
+ target.putObject(
+ key, obj_type, copy_descriptor(obj_desc) if recursive else obj_desc
+ )
+ elif value_type == DescValueType.RawType:
+ target.putData(key, source.getData(key))
+ elif value_type == DescValueType.ReferenceType:
+ target.putReference(key, source.getReference(key))
+ elif value_type == DescValueType.StringType:
+ target.putString(key, source.getString(key))
+ elif value_type == DescValueType.UnitDoubleType:
+ unit_type = source.getUnitDoubleType(key)
+ unit_value = source.getUnitDoubleValue(key)
+ target.putUnitDouble(key, unit_type, unit_value)
+ else:
+ _logger.warning(
+ f"ActionDescriptor value at key '{key}' has an unknown type '{value_type}'"
+ )
+ except COMError:
+ # Skip properties that cannot be copied
+ _logger.warning(
+ f"Can't copy key '{key}' of type '{value_type}' from ActionDescriptor"
+ )
+
+
+# endregion Copying
+
+# region Descriptor Value Setters
+
+
+def set_or_copy_alias(
+ key: int,
+ target: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ value: str | PathLike[str] | None = None,
+) -> None:
+ """Set alias (path) value or copy from source if value is None."""
+ if value is not None:
+ target.putPath(key, value)
+ elif source is not None and source.hasKey(key):
+ target.putPath(key, source.getPath(key))
+
+
+def set_or_copy_boolean(
+ key: int,
+ target: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ value: bool | None = None,
+) -> None:
+ """Set boolean value or copy from source if value is None."""
+ if value is not None:
+ target.putBoolean(key, value)
+ elif source is not None and source.hasKey(key):
+ target.putBoolean(key, source.getBoolean(key))
+
+
+def set_or_copy_class(
+ key: int,
+ target: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ value: int | None = None,
+) -> None:
+ """Set class value or copy from source if value is None."""
+ if value is not None:
+ target.putClass(key, value)
+ elif source is not None and source.hasKey(key):
+ target.putClass(key, source.getClass(key))
+
+
+def set_or_copy_double(
+ key: int,
+ target: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ value: float | None = None,
+) -> None:
+ """Set double value or copy from source if value is None."""
+ if value is not None:
+ target.putDouble(key, value)
+ elif source is not None and source.hasKey(key):
+ target.putDouble(key, source.getDouble(key))
+
+
+def set_or_copy_enumerated(
+ key: int,
+ target: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ enum_type: int | None = None,
+ enum_value: int | None = None,
+) -> None:
+ """Set enumerated value or copy from source if both enum_type and enum_value are None."""
+ if enum_type is not None and enum_value is not None:
+ target.putEnumerated(key, enum_type, enum_value)
+ elif source is not None and source.hasKey(key):
+ source_enum_type = source.getEnumerationType(key)
+ source_enum_value = source.getEnumerationValue(key)
+ target.putEnumerated(key, source_enum_type, source_enum_value)
+
+
+def set_or_copy_integer(
+ key: int,
+ target: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ value: int | None = None,
+) -> None:
+ """Set integer value or copy from source if value is None."""
+ if value is not None:
+ target.putInteger(key, value)
+ elif source is not None and source.hasKey(key):
+ target.putInteger(key, source.getInteger(key))
+
+
+def set_or_copy_large_integer(
+ key: int,
+ target: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ value: int | None = None,
+) -> None:
+ """Set large integer value or copy from source if value is None."""
+ if value is not None:
+ target.putLargeInteger(key, value)
+ elif source is not None and source.hasKey(key):
+ target.putLargeInteger(key, source.getLargeInteger(key))
+
+
+def set_or_copy_list(
+ key: int,
+ target: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ value: ActionList | None = None,
+) -> None:
+ """Set list value or copy from source if value is None."""
+ if value is not None:
+ target.putList(key, value)
+ elif source is not None and source.hasKey(key):
+ target.putList(key, source.getList(key))
+
+
+def set_or_copy_object(
+ key: int,
+ target: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ obj_type: int | None = None,
+ obj_desc: ActionDescriptor | None = None,
+) -> None:
+ """Set object value or copy from source if both obj_type and obj_desc are None."""
+ if obj_type is not None and obj_desc is not None:
+ target.putObject(key, obj_type, obj_desc)
+ elif source is not None and source.hasKey(key):
+ source_obj_type = source.getObjectType(key)
+ source_obj_desc = source.getObjectValue(key)
+ target.putObject(key, source_obj_type, source_obj_desc)
+
+
+def set_or_copy_raw(
+ key: int,
+ target: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ value: str | None = None,
+) -> None:
+ """Set raw (data) value or copy from source if value is None."""
+ if value is not None:
+ target.putData(key, value)
+ elif source is not None and source.hasKey(key):
+ target.putData(key, source.getData(key))
+
+
+def set_or_copy_reference(
+ key: int,
+ target: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ value: ActionReference | None = None,
+) -> None:
+ """Set reference value or copy from source if value is None."""
+ if value is not None:
+ target.putReference(key, value)
+ elif source is not None and source.hasKey(key):
+ target.putReference(key, source.getReference(key))
+
+
+def set_or_copy_string(
+ key: int,
+ target: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ value: str | None = None,
+) -> None:
+ """Set string value or copy from source if value is None."""
+ if value is not None:
+ target.putString(key, value)
+ elif source is not None and source.hasKey(key):
+ target.putString(key, source.getString(key))
+
+
+def set_or_copy_unit_double(
+ key: int,
+ target: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ unit_type: int | None = None,
+ unit_value: float | None = None,
+ source_value_hook: Callable[[float], float] | None = None,
+) -> None:
+ """Set unit double value or copy from source if both unit_type and unit_value are None."""
+ if unit_type is not None and unit_value is not None:
+ target.putUnitDouble(key, unit_type, unit_value)
+ elif source is not None and source.hasKey(key):
+ source_unit_type = source.getUnitDoubleType(key)
+ source_unit_value = source.getUnitDoubleValue(key)
+ target.putUnitDouble(
+ key,
+ source_unit_type,
+ source_value_hook(source_unit_value)
+ if source_value_hook
+ else source_unit_value,
+ )
+
+
+def set_or_copy_color(
+ color_str_id: str,
+ target: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ color: ColorObject | None = None,
+) -> None:
+ """Set object value or copy from source if both obj_type and obj_desc are None."""
+ if color is not None:
+ return apply_color(target, color, color_type=color_str_id)
+
+ key = APP.instance.sID(color_str_id)
+ if source is not None and source.hasKey(key):
+ source_obj_type = source.getObjectType(key)
+ source_obj_desc = source.getObjectValue(key)
+ target.putObject(key, source_obj_type, source_obj_desc)
+
+
+# endregion Descriptor Value Setters
diff --git a/src/helpers/design.py b/src/helpers/design.py
index f653e64a..8ac8a789 100644
--- a/src/helpers/design.py
+++ b/src/helpers/design.py
@@ -1,41 +1,37 @@
"""
* Helpers: Design
"""
-# Standard Library Imports
-from typing import Optional
+
+from collections.abc import Callable
from contextlib import suppress
+from logging import getLogger
-# Third Party Imports
-from photoshop.api import (
- DialogModes,
- ActionDescriptor,
- RasterizeType,
- ActionReference,
+from photoshop.api import ActionDescriptor, ActionReference, SolidColor
+from photoshop.api._artlayer import ArtLayer
+from photoshop.api._document import Document
+from photoshop.api._selection import Selection
+from photoshop.api.enumerations import (
BlendMode,
- SolidColor,
+ DialogModes,
ElementPlacement,
- SaveOptions
+ RasterizeType,
+ SaveOptions,
)
-from photoshop.api._artlayer import ArtLayer
-from photoshop.api._document import Document
-# Local Imports
-from src import APP, CONSOLE
-from src.helpers.layers import select_layers, smart_layer, edit_smart_layer
-from src.helpers.colors import rgb_black, fill_layer_primary
+from src import APP
+from src.helpers.colors import fill_layer_primary, rgb_black
+from src.helpers.layers import edit_smart_layer, select_layers, smart_layer
from src.helpers.selection import select_layer_pixels
from src.utils.adobe import PS_EXCEPTIONS
-# QOL Definitions
-sID, cID = APP.stringIDToTypeID, APP.charIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
+_logger = getLogger(__name__)
"""
* Filling Space
"""
-def fill_empty_area(reference: ArtLayer, color: Optional[SolidColor] = None) -> ArtLayer:
+def fill_empty_area(reference: ArtLayer, color: SolidColor | None = None) -> ArtLayer:
"""Fills empty gaps on an art layer, such as a symbol, with a solid color.
Args:
@@ -43,22 +39,22 @@ def fill_empty_area(reference: ArtLayer, color: Optional[SolidColor] = None) ->
color: Color of the background fill
"""
# Magic Wand contiguous outside symbol
- docref = APP.activeDocument
+ docref = APP.instance.activeDocument
docsel = docref.selection
coords = ActionDescriptor()
click1 = ActionDescriptor()
ref1 = ActionReference()
- idPaint = sID('paint')
- idPixel = sID('pixelsUnit')
- idTolerance = sID('tolerance')
- coords.putUnitDouble(sID('horizontal'), idPixel, 5)
- coords.putUnitDouble(sID('vertical'), idPixel, 5)
- ref1.putProperty(sID('channel'), sID('selection'))
- click1.putReference(sID('target'), ref1)
- click1.putObject(sID('to'), idPaint, coords)
+ idPaint = APP.instance.sID("paint")
+ idPixel = APP.instance.sID("pixelsUnit")
+ idTolerance = APP.instance.sID("tolerance")
+ coords.putUnitDouble(APP.instance.sID("horizontal"), idPixel, 5)
+ coords.putUnitDouble(APP.instance.sID("vertical"), idPixel, 5)
+ ref1.putProperty(APP.instance.sID("channel"), APP.instance.sID("selection"))
+ click1.putReference(APP.instance.sID("target"), ref1)
+ click1.putObject(APP.instance.sID("to"), idPaint, coords)
click1.putInteger(idTolerance, 12)
- click1.putBoolean(sID('antiAlias'), True)
- APP.executeAction(sID('set'), click1)
+ click1.putBoolean(APP.instance.sID("antiAlias"), True)
+ APP.instance.executeAction(APP.instance.sID("set"), click1)
# Invert selection
docsel.invert()
@@ -66,25 +62,35 @@ def fill_empty_area(reference: ArtLayer, color: Optional[SolidColor] = None) ->
# Make a new layer
layer = docref.artLayers.add()
- layer.name = 'Expansion Mask'
+ layer.name = "Expansion Mask"
layer.blendMode = BlendMode.NormalBlend
- layer.moveAfter(reference)
+ layer.move(reference, ElementPlacement.PlaceAfter)
# Fill selection with stroke color
- APP.foregroundColor = color or rgb_black()
+ APP.instance.foregroundColor = color or rgb_black()
click3 = ActionDescriptor()
- click3.putObject(sID('from'), idPaint, coords)
+ click3.putObject(APP.instance.sID("from"), idPaint, coords)
click3.putInteger(idTolerance, 0)
- click3.putEnumerated(sID('using'), sID('fillContents'), sID('foregroundColor'))
- click3.putBoolean(sID('contiguous'), False)
- APP.executeAction(sID('fill'), click3)
+ click3.putEnumerated(
+ APP.instance.sID("using"),
+ APP.instance.sID("fillContents"),
+ APP.instance.sID("foregroundColor"),
+ )
+ click3.putBoolean(APP.instance.sID("contiguous"), False)
+ APP.instance.executeAction(APP.instance.sID("fill"), click3)
# Clear Selection
docsel.deselect()
return layer
-def content_aware_fill_edges(layer: Optional[ArtLayer] = None, feather: bool = False) -> None:
+def content_aware_fill_edges(
+ layer: ArtLayer | None = None,
+ contract: int = 10,
+ smooth: int = 0,
+ feather: int = 5,
+ art_selection_hook: Callable[[ArtLayer, Selection], None] | None = None,
+) -> None:
"""Fills pixels outside art layer using content-aware fill.
Args:
@@ -92,31 +98,40 @@ def content_aware_fill_edges(layer: Optional[ArtLayer] = None, feather: bool = F
feather: Whether to feather the selection before performing the fill operation.
"""
# Set active layer if needed, then rasterize
- docref = APP.activeDocument
+ docref = APP.instance.activeDocument
if layer:
docref.activeLayer = layer
- docref.activeLayer.rasterize(RasterizeType.EntireLayer)
+ active_layer = layer
+ elif not isinstance((active_layer := docref.activeLayer), ArtLayer):
+ _logger.warning(
+ "Skipping content aware fill as active layer is not an ArtLayer."
+ )
+ return
+ active_layer.rasterize(RasterizeType.EntireLayer)
# Select pixels of the active layer
- select_layer_pixels(docref.activeLayer)
+ select_layer_pixels(active_layer)
selection = docref.selection
+ if art_selection_hook:
+ art_selection_hook(active_layer, selection)
+
# Guard against no selection made
try:
- # Create a feathered or smoothed selection, then invert
+ # Adjust selection, then invert
+ if contract:
+ selection.contract(contract)
+ if smooth:
+ selection.smooth(smooth)
if feather:
- selection.contract(22)
- selection.feather(8)
- else:
- selection.contract(10)
- selection.smooth(5)
+ selection.feather(feather)
selection.invert()
content_aware_fill()
- except PS_EXCEPTIONS:
+ except PS_EXCEPTIONS as exc:
# Unable to fill due to invalid selection
- CONSOLE.update(
- "Couldn't make a valid selection!\n"
- "Skipping automated fill.")
+ _logger.warning(
+ "Couldn't make a valid selection. Skipping automated fill.", exc_info=exc
+ )
# Clear selection
with suppress(Exception):
@@ -124,11 +139,14 @@ def content_aware_fill_edges(layer: Optional[ArtLayer] = None, feather: bool = F
def generative_fill_edges(
- layer: Optional[ArtLayer] = None,
- feather: bool = False,
+ layer: ArtLayer | None = None,
+ contract: int = 10,
+ smooth: int = 0,
+ feather: int = 5,
close_doc: bool = True,
- docref: Optional[Document] = None
-) -> Optional[Document]:
+ docref: Document | None = None,
+ art_selection_hook: Callable[[ArtLayer, Selection], None] | None = None,
+) -> Document | None:
"""Fills pixels outside an art layer using AI powered generative fill.
Args:
@@ -141,50 +159,57 @@ def generative_fill_edges(
Smart layer document if Generative Fill operation succeeded, otherwise None.
"""
# Set docref and use active layer if not provided
- docref = docref or APP.activeDocument
- if not layer:
- layer = docref.activeLayer
- docref.activeLayer = layer
+ docref = docref or APP.instance.activeDocument
+ if layer:
+ docref.activeLayer = layer
+ active_layer = layer
+ elif not isinstance((active_layer := docref.activeLayer), ArtLayer):
+ print("Skipping content aware fill as active layer is not an ArtLayer.")
+ return
+ active_layer.rasterize(RasterizeType.EntireLayer)
# Create a fill layer the size of the document
fill_layer: ArtLayer = docref.artLayers.add()
- fill_layer.move(layer, ElementPlacement.PlaceAfter)
+ fill_layer.move(active_layer, ElementPlacement.PlaceAfter)
fill_layer_primary()
fill_layer.opacity = 0
# Create a smart layer document and enter it
- select_layers([layer, fill_layer])
+ select_layers([active_layer, fill_layer])
smart_layer()
edit_smart_layer()
# Select pixels of active layer and invert
- docref = APP.activeDocument
- select_layer_pixels(docref.activeLayer)
+ docref = APP.instance.activeDocument
+ select_layer_pixels(active_layer)
selection = docref.selection
+ if art_selection_hook and isinstance((art_layer := docref.activeLayer), ArtLayer):
+ art_selection_hook(art_layer, selection)
+
# Guard against no selection made
try:
- # Create a feathered or smoothed selection, then invert
+ # Adjust selection, then invert
+ if contract:
+ selection.contract(contract)
+ if smooth:
+ selection.smooth(smooth)
if feather:
- selection.contract(22)
- selection.feather(8)
- else:
- selection.contract(10)
- selection.smooth(5)
+ selection.feather(feather)
selection.invert()
try:
generative_fill()
except PS_EXCEPTIONS:
# Generative fill call not responding
- CONSOLE.update("Generative fill failed!\n"
- "Falling back to Content Aware Fill.")
- docref.activeLayer.rasterize(RasterizeType.EntireLayer)
+ _logger.warning(
+ "Generative fill failed. Falling back to Content Aware Fill."
+ )
+ active_layer.rasterize(RasterizeType.EntireLayer)
content_aware_fill()
close_doc = True
except PS_EXCEPTIONS:
# Unable to fill due to invalid selection
- CONSOLE.update("Couldn't make a valid selection!\n"
- "Skipping automated fill.")
+ _logger.warning("Couldn't make a valid selection. Skipping automated fill.")
close_doc = True
# Deselect
@@ -198,13 +223,97 @@ def generative_fill_edges(
return
+def remove_content_fill_edges(
+ layer: ArtLayer | None = None,
+ contract: int = 10,
+ smooth: int = 0,
+ feather: int = 5,
+ art_selection_hook: Callable[[ArtLayer, Selection], None] | None = None,
+) -> None:
+ """Fills pixels outside art layer using remove content fill.
+
+ Args:
+ layer: Layer to use for the fill. Uses active if not provided.
+ feather: Whether to feather the selection before performing the fill operation.
+ """
+ # Set active layer if needed
+ docref = APP.instance.activeDocument
+ if layer:
+ docref.activeLayer = layer
+ active_layer = layer
+ elif not isinstance((active_layer := docref.activeLayer), ArtLayer):
+ _logger.warning(
+ "Skipping remove content fill as active layer is not an ArtLayer."
+ )
+ return
+
+ # Create a fill layer the size of the document in order to
+ # ensure that the smart document retains the document's size
+ fill_layer: ArtLayer = docref.artLayers.add()
+ fill_layer.move(active_layer, ElementPlacement.PlaceAfter)
+ fill_layer_primary()
+ fill_layer.opacity = 0
+
+ # Create a smart layer document and enter it in order to
+ # ensure that content is sampled only from the art layer
+ select_layers([active_layer, fill_layer])
+ smart_layer()
+ edit_smart_layer()
+
+ # Select pixels of the active layer
+ docref = APP.instance.activeDocument
+ if isinstance((layer_in_smart_doc := docref.activeLayer), ArtLayer):
+ select_layer_pixels(layer_in_smart_doc)
+ selection = docref.selection
+
+ if art_selection_hook and isinstance((art_layer := docref.activeLayer), ArtLayer):
+ art_selection_hook(art_layer, selection)
+
+ # Guard against no selection made
+ try:
+ # Adjust selection, then invert
+ if contract:
+ selection.contract(contract)
+ if smooth:
+ selection.smooth(smooth)
+ if feather:
+ selection.feather(feather)
+ selection.invert()
+
+ remove_content_fill()
+ except PS_EXCEPTIONS as exc:
+ # Unable to fill due to invalid selection
+ _logger.warning(
+ "Couldn't complete remove content fill. Skipping automated fill.",
+ exc_info=exc,
+ )
+
+ # Clear selection
+ with suppress(Exception):
+ selection.deselect()
+
+ docref.close(SaveOptions.SaveChanges)
+
+
def content_aware_fill() -> None:
"""Fills the current selection using content aware fill."""
desc = ActionDescriptor()
- desc.putEnumerated(sID("using"), sID("fillContents"), sID("contentAware"))
- desc.putUnitDouble(sID("opacity"), sID("percentUnit"), 100)
- desc.putEnumerated(sID("mode"), sID("blendMode"), sID("normal"))
- APP.executeAction(sID("fill"), desc, NO_DIALOG)
+ desc.putEnumerated(
+ APP.instance.sID("using"),
+ APP.instance.sID("fillContents"),
+ APP.instance.sID("contentAware"),
+ )
+ desc.putUnitDouble(
+ APP.instance.sID("opacity"), APP.instance.sID("percentUnit"), 100
+ )
+ desc.putEnumerated(
+ APP.instance.sID("mode"),
+ APP.instance.sID("blendMode"),
+ APP.instance.sID("normal"),
+ )
+ APP.instance.executeAction(
+ APP.instance.sID("fill"), desc, DialogModes.DisplayNoDialogs
+ )
def generative_fill() -> None:
@@ -213,26 +322,45 @@ def generative_fill() -> None:
ref1 = ActionReference()
desc2 = ActionDescriptor()
desc3 = ActionDescriptor()
- ref1.putEnumerated(sID("document"), sID("ordinal"), sID("targetEnum"))
- desc1.putReference(sID("target"), ref1)
- desc1.putString(sID("prompt"), """""")
- desc1.putString(sID("serviceID"), """clio""")
- desc1.putEnumerated(sID("mode"), sID("syntheticFillMode"), sID("inpaint"))
- desc3.putString(sID("gi_PROMPT"), """""")
- desc3.putString(sID("gi_MODE"), """ginp""")
- desc3.putInteger(sID("gi_SEED"), -1)
- desc3.putInteger(sID("gi_NUM_STEPS"), -1)
- desc3.putInteger(sID("gi_GUIDANCE"), 6)
- desc3.putInteger(sID("gi_SIMILARITY"), 0)
- desc3.putBoolean(sID("gi_CROP"), False)
- desc3.putBoolean(sID("gi_DILATE"), False)
- desc3.putInteger(sID("gi_CONTENT_PRESERVE"), 0)
- desc3.putBoolean(sID("gi_ENABLE_PROMPT_FILTER"), True)
- desc3.putBoolean(sID("dualCrop"), True)
- desc3.putString(sID("gi_ADVANCED"), """{"enable_mts":true}""")
- desc2.putObject(sID("clio"), sID("clio"), desc3)
- desc1.putObject(sID("serviceOptionsList"), sID("target"), desc2)
- APP.executeAction(sID("syntheticFill"), desc1, NO_DIALOG)
+ ref1.putEnumerated(
+ APP.instance.sID("document"),
+ APP.instance.sID("ordinal"),
+ APP.instance.sID("targetEnum"),
+ )
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc1.putString(APP.instance.sID("prompt"), """""")
+ desc1.putString(APP.instance.sID("serviceID"), """clio""")
+ desc1.putEnumerated(
+ APP.instance.sID("mode"),
+ APP.instance.sID("syntheticFillMode"),
+ APP.instance.sID("inpaint"),
+ )
+ desc3.putString(APP.instance.sID("gi_PROMPT"), """""")
+ desc3.putString(APP.instance.sID("gi_MODE"), """ginp""")
+ desc3.putInteger(APP.instance.sID("gi_SEED"), -1)
+ desc3.putInteger(APP.instance.sID("gi_NUM_STEPS"), -1)
+ desc3.putInteger(APP.instance.sID("gi_GUIDANCE"), 6)
+ desc3.putInteger(APP.instance.sID("gi_SIMILARITY"), 0)
+ desc3.putBoolean(APP.instance.sID("gi_CROP"), False)
+ desc3.putBoolean(APP.instance.sID("gi_DILATE"), False)
+ desc3.putInteger(APP.instance.sID("gi_CONTENT_PRESERVE"), 0)
+ desc3.putBoolean(APP.instance.sID("gi_ENABLE_PROMPT_FILTER"), True)
+ desc3.putBoolean(APP.instance.sID("dualCrop"), True)
+ desc3.putString(APP.instance.sID("gi_ADVANCED"), """{"enable_mts":true}""")
+ desc2.putObject(APP.instance.sID("clio"), APP.instance.sID("clio"), desc3)
+ desc1.putObject(
+ APP.instance.sID("serviceOptionsList"), APP.instance.sID("target"), desc2
+ )
+ APP.instance.executeAction(
+ APP.instance.sID("syntheticFill"), desc1, DialogModes.DisplayNoDialogs
+ )
+
+
+def remove_content_fill() -> None:
+ """Call Photoshop's "Remove" action on the current selection."""
+ APP.instance.executeAction(
+ APP.instance.sID("removeButton"), display_dialogs=DialogModes.DisplayNoDialogs
+ )
def repair_edges(edge: int = 6) -> None:
@@ -244,46 +372,56 @@ def repair_edges(edge: int = 6) -> None:
# Select all
desc632724 = ActionDescriptor()
ref489 = ActionReference()
- ref489.putProperty(sID("channel"), sID("selection"))
- desc632724.putReference(sID("target"), ref489)
- desc632724.putEnumerated(sID("to"), sID("ordinal"), sID("allEnum"))
- APP.executeAction(sID("set"), desc632724, NO_DIALOG)
+ ref489.putProperty(APP.instance.sID("channel"), APP.instance.sID("selection"))
+ desc632724.putReference(APP.instance.sID("target"), ref489)
+ desc632724.putEnumerated(
+ APP.instance.sID("to"), APP.instance.sID("ordinal"), APP.instance.sID("allEnum")
+ )
+ APP.instance.executeAction(
+ APP.instance.sID("set"), desc632724, DialogModes.DisplayNoDialogs
+ )
# Contract selection
contract = ActionDescriptor()
- contract.putUnitDouble(sID("by"), sID("pixelsUnit"), edge)
- contract.putBoolean(sID("selectionModifyEffectAtCanvasBounds"), True)
- APP.executeAction(sID("contract"), contract, NO_DIALOG)
+ contract.putUnitDouble(APP.instance.sID("by"), APP.instance.sID("pixelsUnit"), edge)
+ contract.putBoolean(APP.instance.sID("selectionModifyEffectAtCanvasBounds"), True)
+ APP.instance.executeAction(
+ APP.instance.sID("contract"), contract, DialogModes.DisplayNoDialogs
+ )
# Inverse the selection
- APP.executeAction(sID("inverse"), None, NO_DIALOG)
+ APP.instance.executeAction(
+ APP.instance.sID("inverse"), None, DialogModes.DisplayNoDialogs
+ )
# Content aware fill
desc_caf = ActionDescriptor()
desc_caf.putEnumerated(
- sID("cafSamplingRegion"),
- sID("cafSamplingRegion"),
- sID("cafSamplingRegionRectangular")
+ APP.instance.sID("cafSamplingRegion"),
+ APP.instance.sID("cafSamplingRegion"),
+ APP.instance.sID("cafSamplingRegionRectangular"),
)
- desc_caf.putBoolean(sID("cafSampleAllLayers"), False)
+ desc_caf.putBoolean(APP.instance.sID("cafSampleAllLayers"), False)
desc_caf.putEnumerated(
- sID("cafColorAdaptationLevel"),
- sID("cafColorAdaptationLevel"),
- sID("cafColorAdaptationDefault")
+ APP.instance.sID("cafColorAdaptationLevel"),
+ APP.instance.sID("cafColorAdaptationLevel"),
+ APP.instance.sID("cafColorAdaptationDefault"),
)
desc_caf.putEnumerated(
- sID("cafRotationAmount"),
- sID("cafRotationAmount"),
- sID("cafRotationAmountNone")
+ APP.instance.sID("cafRotationAmount"),
+ APP.instance.sID("cafRotationAmount"),
+ APP.instance.sID("cafRotationAmountNone"),
)
- desc_caf.putBoolean(sID("cafScale"), False)
- desc_caf.putBoolean(sID("cafMirror"), False)
+ desc_caf.putBoolean(APP.instance.sID("cafScale"), False)
+ desc_caf.putBoolean(APP.instance.sID("cafMirror"), False)
desc_caf.putEnumerated(
- sID("cafOutput"),
- sID("cafOutput"),
- sID("cafOutputToNewLayer")
+ APP.instance.sID("cafOutput"),
+ APP.instance.sID("cafOutput"),
+ APP.instance.sID("cafOutputToNewLayer"),
+ )
+ APP.instance.executeAction(
+ APP.instance.sID("cafWorkspace"), desc_caf, DialogModes.DisplayNoDialogs
)
- APP.executeAction(sID("cafWorkspace"), desc_caf, NO_DIALOG)
# Deselect
- APP.activeDocument.selection.deselect()
+ APP.instance.activeDocument.selection.deselect()
diff --git a/src/helpers/document.py b/src/helpers/document.py
index 13dcf707..018757b9 100644
--- a/src/helpers/document.py
+++ b/src/helpers/document.py
@@ -1,42 +1,39 @@
"""
* Helpers: Documents
"""
-# Standard Library Imports
+
+from collections.abc import Callable
from pathlib import Path
-from typing import Optional, Union
+from typing import Any
-# Third Party Imports
from photoshop.api import (
- DialogModes,
ActionDescriptor,
ActionReference,
- SaveOptions,
- PurgeTarget,
- PNGSaveOptions,
JPEGSaveOptions,
PhotoshopSaveOptions,
- ElementPlacement,
- FormatOptionsType
+ PNGSaveOptions,
)
from photoshop.api._artlayer import ArtLayer
-from photoshop.api._layerSet import LayerSet
from photoshop.api._document import Document
+from photoshop.api._layerSet import LayerSet
+from photoshop.api.enumerations import (
+ DialogModes,
+ ElementPlacement,
+ FormatOptionsType,
+ PurgeTarget,
+ SaveOptions,
+)
-# Local Imports
from src import APP
from src.helpers.layers import create_new_layer
from src.utils.adobe import PS_EXCEPTIONS
-# QOL Definitions
-sID, cID = APP.stringIDToTypeID, APP.charIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
-
"""
* Document Hierarchy
"""
-def get_leaf_layers(group: Optional[LayerSet] = None) -> list[ArtLayer]:
+def get_leaf_layers(group: Document | LayerSet | None = None) -> list[ArtLayer]:
"""Utility function to generate a list of leaf layers in a LayerSet or document.
Args:
@@ -46,14 +43,17 @@ def get_leaf_layers(group: Optional[LayerSet] = None) -> list[ArtLayer]:
A list of leaf layers in a LayerSet or document.
"""
if not group:
- group = APP.activeDocument
+ group = APP.instance.activeDocument
layers = [node for node in group.artLayers]
for g in group.layerSets:
layers.extend(get_leaf_layers(g))
return layers
-def get_layer_tree(group: Optional[LayerSet] = None) -> dict[str, Union[ArtLayer, dict[str, ArtLayer]]]:
+ArtLayerTree = dict[str, "ArtLayer | ArtLayerTree"]
+
+
+def get_layer_tree(group: Document | LayerSet | None = None) -> ArtLayerTree:
"""Composes a dictionary tree of layers in the active document or a specific LayerSet.
Args:
@@ -63,8 +63,8 @@ def get_layer_tree(group: Optional[LayerSet] = None) -> dict[str, Union[ArtLayer
A dictionary tree comprised of all the layers in a document or group.
"""
if not group:
- group = APP.activeDocument
- layers = {layer.name: layer for layer in group.artLayers}
+ group = APP.instance.activeDocument
+ layers: ArtLayerTree = {layer.name: layer for layer in group.artLayers}
for g in group.layerSets:
layers[g.name] = get_layer_tree(g)
return layers
@@ -77,9 +77,9 @@ def get_layer_tree(group: Optional[LayerSet] = None) -> dict[str, Union[ArtLayer
def import_art(
layer: ArtLayer,
- path: Union[str, Path],
- name: str = 'Layer 1',
- docref: Optional[Document] = None
+ path: str | Path,
+ name: str = "Layer 1",
+ docref: Document | None = None,
) -> ArtLayer:
"""Imports an art file into the active layer.
@@ -93,19 +93,26 @@ def import_art(
Imported art layer.
"""
desc = ActionDescriptor()
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
docref.activeLayer = layer
- desc.putPath(sID('target'), str(path))
- APP.executeAction(sID('placeEvent'), desc)
- docref.activeLayer.name = name
- return docref.activeLayer
+ desc.putPath(APP.instance.sID("target"), str(path))
+ APP.instance.executeAction(APP.instance.sID("placeEvent"), desc)
+
+ active_layer = docref.activeLayer
+ if not isinstance(active_layer, ArtLayer):
+ raise ValueError(
+ "Failed to import art. Active layer is unexpectedly not an ArtLayer."
+ )
+ active_layer.name = name
+
+ return active_layer
def import_svg(
- path: Union[str, Path],
- ref: Union[ArtLayer, LayerSet] = None,
- placement: Optional[ElementPlacement] = None,
- docref: Optional[Document] = None
+ path: str | Path,
+ ref: ArtLayer | LayerSet | None = None,
+ placement: ElementPlacement | None = None,
+ docref: Document | None = None,
) -> ArtLayer:
"""Imports an SVG image, then moves it if needed.
@@ -120,22 +127,27 @@ def import_svg(
"""
# Import the art
desc = ActionDescriptor()
- docref = docref or APP.activeDocument
- desc.putPath(sID('target'), str(path))
- APP.executeAction(sID('placeEvent'), desc)
+ docref = docref or APP.instance.activeDocument
+ desc.putPath(APP.instance.sID("target"), str(path))
+ APP.instance.executeAction(APP.instance.sID("placeEvent"), desc)
+
+ active_layer = docref.activeLayer
+ if not isinstance(active_layer, ArtLayer):
+ raise ValueError(
+ "Failed to import SVG. Active layer is unexpectedly a LayerSet."
+ )
# Position the layer if needed
if ref and placement:
- docref.activeLayer.move(ref, placement)
- return docref.activeLayer
+ active_layer.move(ref, placement)
+ return active_layer
def paste_file(
layer: ArtLayer,
- path: Union[str, Path],
- action: any = None,
- action_args: dict = None,
- docref: Optional[Document] = None
+ path: str | Path,
+ action: Callable[[], Any] | None = None,
+ docref: Document | None = None,
) -> ArtLayer:
"""Pastes the given file into the specified layer.
@@ -150,31 +162,35 @@ def paste_file(
Active layer where art was pasted.
"""
# Select the correct layer, then load the file
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
docref.activeLayer = layer
- APP.load(str(path))
+ APP.instance.load(str(path))
# Optionally run action on art before importing it
if action:
- action(**action_args) if action_args else action()
+ action()
# Select the entire image, copy it, and close the file
- newdoc = APP.activeDocument
+ newdoc = APP.instance.activeDocument
docsel = newdoc.selection
docsel.selectAll()
docsel.copy()
- newdoc.close(
- SaveOptions.DoNotSaveChanges)
+ newdoc.close(SaveOptions.DoNotSaveChanges)
# Paste the image into the specific layer
docref.paste()
- return docref.activeLayer
+
+ active_layer = docref.activeLayer
+ if not isinstance(active_layer, ArtLayer):
+ raise ValueError(
+ "Failed to paste file. Active layer is unexpectedly not an ArtLayer."
+ )
+
+ return active_layer
def import_art_into_new_layer(
- path: Union[str, Path],
- name: str = "New Layer",
- docref: Optional[Document] = None
+ path: str | Path, name: str = "New Layer", docref: Document | None = None
) -> ArtLayer:
"""Creates a new layer and imports a given art into that layer.
@@ -186,11 +202,7 @@ def import_art_into_new_layer(
Returns:
New ArtLayer with imported art.
"""
- return import_art(
- layer=create_new_layer(name),
- path=path,
- name=name,
- docref=docref)
+ return import_art(layer=create_new_layer(name), path=path, name=name, docref=docref)
"""
@@ -207,12 +219,14 @@ def jump_to_history_state(position: int):
"""
desc1 = ActionDescriptor()
ref1 = ActionReference()
- ref1.PutOffset(sID('historyState'), position)
- desc1.PutReference(sID('target'), ref1)
- APP.executeAction(sID('select'), desc1, NO_DIALOG)
+ ref1.putOffset(APP.instance.sID("historyState"), position)
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ APP.instance.executeAction(
+ APP.instance.sID("select"), desc1, DialogModes.DisplayNoDialogs
+ )
-def toggle_history_state(direction: str = 'previous') -> None:
+def toggle_history_state(direction: str = "previous") -> None:
"""Alter the history state.
Args:
@@ -220,32 +234,40 @@ def toggle_history_state(direction: str = 'previous') -> None:
"""
desc1 = ActionDescriptor()
ref1 = ActionReference()
- ref1.PutEnumerated(sID('historyState'), sID('ordinal'), sID(direction))
- desc1.PutReference(sID('target'), ref1)
- APP.executeAction(sID('select'), desc1, NO_DIALOG)
+ ref1.putEnumerated(
+ APP.instance.sID("historyState"),
+ APP.instance.sID("ordinal"),
+ APP.instance.sID(direction),
+ )
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ APP.instance.executeAction(
+ APP.instance.sID("select"), desc1, DialogModes.DisplayNoDialogs
+ )
def undo_action() -> None:
"""Undo the last action in the history state."""
- toggle_history_state('previous')
+ toggle_history_state("previous")
def redo_action() -> None:
"""Redo the last action undone in the history state."""
- toggle_history_state('next')
+ toggle_history_state("next")
-def reset_document(docref: Optional[Document] = None) -> None:
+def reset_document(docref: Document | None = None) -> None:
"""Reset to the history state to when document was first opened.
Args:
docref: Reference document to reset state in, use active if not provided.
"""
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
d1, r1 = ActionDescriptor(), ActionReference()
- r1.putName(sID('snapshotClass'), docref.name)
- d1.putReference(sID('target'), r1)
- APP.executeAction(sID('select'), d1, NO_DIALOG)
+ r1.putName(APP.instance.sID("snapshotClass"), docref.name)
+ d1.putReference(APP.instance.sID("target"), r1)
+ APP.instance.executeAction(
+ APP.instance.sID("select"), d1, DialogModes.DisplayNoDialogs
+ )
"""
@@ -253,7 +275,7 @@ def reset_document(docref: Optional[Document] = None) -> None:
"""
-def points_to_pixels(number: Union[int, float], docref: Optional[Document] = None) -> float:
+def points_to_pixels(number: int | float, docref: Document | None = None) -> float:
"""Converts a given number in point units to pixel units.
Args:
@@ -263,11 +285,11 @@ def points_to_pixels(number: Union[int, float], docref: Optional[Document] = Non
Returns:
Float representing the given value in pixel units.
"""
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
return (docref.resolution / 72) * number
-def pixels_to_points(number: Union[int, float], docref: Optional[Document] = None) -> float:
+def pixels_to_points(number: int | float, docref: Document | None = None) -> float:
"""Converts a given number in pixel units to point units.
Args:
@@ -277,7 +299,7 @@ def pixels_to_points(number: Union[int, float], docref: Optional[Document] = Non
Returns:
Float representing the given value in point units.
"""
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
return number / (docref.resolution / 72)
@@ -293,14 +315,14 @@ def check_active_document() -> bool:
True if exists, otherwise False.
"""
try:
- if APP.documents.length > 0:
+ if APP.instance.documents.length > 0:
return True
except PS_EXCEPTIONS:
pass
return False
-def get_document(name: str) -> Optional[Document]:
+def get_document(name: str) -> Document | None:
"""Check if a Photoshop Document has been loaded.
Args:
@@ -310,11 +332,8 @@ def get_document(name: str) -> Optional[Document]:
The Document if located, None if missing.
"""
try:
- docs = APP.documents
- if docs.length < 1:
- return
- doc = docs.getByName(name)
- APP.activeDocument = doc
+ doc = APP.instance.documents[name]
+ APP.instance.activeDocument = doc
return doc
except PS_EXCEPTIONS:
return
@@ -328,12 +347,18 @@ def get_document(name: str) -> Optional[Document]:
def trim_transparent_pixels() -> None:
"""Trim transparent pixels from Photoshop document."""
desc258 = ActionDescriptor()
- desc258.putEnumerated(sID('trimBasedOn'), sID('trimBasedOn'), sID('transparency'))
- desc258.putBoolean(sID('top'), True)
- desc258.putBoolean(sID('bottom'), True)
- desc258.putBoolean(sID('left'), True)
- desc258.putBoolean(sID('right'), True)
- APP.executeAction(sID('trim'), desc258, NO_DIALOG)
+ desc258.putEnumerated(
+ APP.instance.sID("trimBasedOn"),
+ APP.instance.sID("trimBasedOn"),
+ APP.instance.sID("transparency"),
+ )
+ desc258.putBoolean(APP.instance.sID("top"), True)
+ desc258.putBoolean(APP.instance.sID("bottom"), True)
+ desc258.putBoolean(APP.instance.sID("left"), True)
+ desc258.putBoolean(APP.instance.sID("right"), True)
+ APP.instance.executeAction(
+ APP.instance.sID("trim"), desc258, DialogModes.DisplayNoDialogs
+ )
"""
@@ -341,24 +366,27 @@ def trim_transparent_pixels() -> None:
"""
-def save_document_png(path: Path, docref: Optional[Document] = None) -> None:
+def save_document_png(
+ path: Path, docref: Document | None = None, compression: int = 3
+) -> None:
"""Save the current document as a PNG.
Args:
path: Path to save the PNG file.
docref: Current active document. Use active if not provided.
"""
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
png_options = PNGSaveOptions()
- png_options.compression = 3
+ png_options.compression = compression
png_options.interlaced = False
docref.saveAs(
- file_path=str(path.with_suffix('.png')),
- options=png_options,
- asCopy=True)
+ file_path=str(path.with_suffix(".png")), options=png_options, asCopy=True
+ )
-def save_document_jpeg(path: Path, optimize: bool = True, docref: Optional[Document] = None) -> None:
+def save_document_jpeg(
+ path: Path, docref: Document | None = None, optimize: bool = True
+) -> None:
"""Save the current document as a JPEG.
Args:
@@ -369,45 +397,41 @@ def save_document_jpeg(path: Path, optimize: bool = True, docref: Optional[Docum
"""
# Set up the save options
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
options = JPEGSaveOptions(quality=12)
try:
-
# Reduces filesize, unsupported by older Photoshop versions
if optimize:
options.formatOptions = FormatOptionsType.OptimizedBaseline
# Save the document
docref.saveAs(
- file_path=str(path.with_suffix('.jpg')),
- options=options,
- asCopy=True)
+ file_path=str(path.with_suffix(".jpg")), options=options, asCopy=True
+ )
# Retry without Optimize Baseline
except PS_EXCEPTIONS as e:
if optimize:
- return save_document_jpeg(
- path=path,
- optimize=False,
- docref=docref)
+ return save_document_jpeg(path=path, optimize=False, docref=docref)
raise OSError from e
-def save_document_psd(path: Path, docref: Optional[Document] = None) -> None:
+def save_document_psd(path: Path, docref: Document | None = None) -> None:
"""Save the current document as a PSD.
Args:
path: Path to save the PSD file.
docref: Open Photoshop document. Use active if not provided.
"""
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
docref.saveAs(
- file_path=str(path.with_suffix('.psd')),
+ file_path=str(path.with_suffix(".psd")),
options=PhotoshopSaveOptions(),
- asCopy=True)
+ asCopy=True,
+ )
-def save_document_psb(path: Path, *_args, **_kwargs) -> None:
+def save_document_psb(path: Path) -> None:
"""Save the current document as a PSB.
Args:
@@ -417,14 +441,18 @@ def save_document_psb(path: Path, *_args, **_kwargs) -> None:
"""
d1 = ActionDescriptor()
d2 = ActionDescriptor()
- d2.putBoolean(sID('maximizeCompatibility'), True)
- d1.putObject(sID('as'), sID('largeDocumentFormat'), d2)
- d1.putPath(sID('in'), str(path.with_suffix('.psb')))
- d1.putBoolean(sID('lowerCase'), True)
- APP.executeAction(sID('save'), d1, NO_DIALOG)
-
-
-def close_document(save: bool = False, docref: Optional[Document] = None, purge: bool = True) -> None:
+ d2.putBoolean(APP.instance.sID("maximizeCompatibility"), True)
+ d1.putObject(APP.instance.sID("as"), APP.instance.sID("largeDocumentFormat"), d2)
+ d1.putPath(APP.instance.sID("in"), str(path.with_suffix(".psb")))
+ d1.putBoolean(APP.instance.sID("lowerCase"), True)
+ APP.instance.executeAction(
+ APP.instance.sID("save"), d1, DialogModes.DisplayNoDialogs
+ )
+
+
+def close_document(
+ save: bool = False, docref: Document | None = None, purge: bool = True
+) -> None:
"""Close the active document.
Args:
@@ -432,11 +460,11 @@ def close_document(save: bool = False, docref: Optional[Document] = None, purge:
docref: Open Photoshop document. Use active if not provided.
purge: Whether to purge all caches.
"""
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
save_options = SaveOptions.SaveChanges if save else SaveOptions.DoNotSaveChanges
docref.close(saving=save_options)
if purge:
- APP.purge(PurgeTarget.AllCaches)
+ APP.instance.purge(PurgeTarget.AllCaches)
"""
@@ -452,10 +480,16 @@ def rotate_document(angle: int) -> None:
"""
desc1 = ActionDescriptor()
ref1 = ActionReference()
- ref1.PutEnumerated(sID('document'), sID('ordinal'), sID('first'))
- desc1.PutReference(sID('target'), ref1)
- desc1.PutUnitDouble(sID('angle'), sID('angleUnit'), angle)
- APP.executeaction(sID('rotateEventEnum'), desc1, NO_DIALOG)
+ ref1.putEnumerated(
+ APP.instance.sID("document"),
+ APP.instance.sID("ordinal"),
+ APP.instance.sID("first"),
+ )
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc1.putUnitDouble(APP.instance.sID("angle"), APP.instance.sID("angleUnit"), angle)
+ APP.instance.executeAction(
+ APP.instance.sID("rotateEventEnum"), desc1, DialogModes.DisplayNoDialogs
+ )
def rotate_counter_clockwise() -> None:
@@ -478,15 +512,21 @@ def rotate_full() -> None:
"""
-def paste_to_document(layer: Union[ArtLayer, LayerSet, None] = None):
+def paste_to_document(layer: ArtLayer | LayerSet | None = None):
"""Paste current clipboard to the current layer.
Args:
layer: Layer to make active, if provided.
"""
if layer:
- APP.activeDocument.activeLayer = layer
+ APP.instance.activeDocument.activeLayer = layer
desc1 = ActionDescriptor()
- desc1.PutEnumerated(sID("antiAlias"), sID("antiAliasType"), sID("antiAliasNone"))
- desc1.PutClass(sID("as"), sID("pixel"))
- APP.Executeaction(sID("paste"), desc1, NO_DIALOG)
+ desc1.putEnumerated(
+ APP.instance.sID("antiAlias"),
+ APP.instance.sID("antiAliasType"),
+ APP.instance.sID("antiAliasNone"),
+ )
+ desc1.putClass(APP.instance.sID("as"), APP.instance.sID("pixel"))
+ APP.instance.executeAction(
+ APP.instance.sID("paste"), desc1, DialogModes.DisplayNoDialogs
+ )
diff --git a/src/helpers/effects.py b/src/helpers/effects.py
index c6ef4f16..a314e3ee 100644
--- a/src/helpers/effects.py
+++ b/src/helpers/effects.py
@@ -1,31 +1,35 @@
"""
* Helpers: Layer Effects
"""
-# Standard Library Imports
-from typing import Union, Optional
-# Third Party Imports
-from photoshop.api import DialogModes, ActionDescriptor, ActionReference, ActionList
+from _ctypes import COMError
+from logging import getLogger
+
+from photoshop.api import ActionDescriptor, ActionList, ActionReference
from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
+from photoshop.api.enumerations import DialogModes
-# Local Imports
from src import APP
-from src.helpers.colors import apply_color, get_color, add_color_to_gradient
from src.enums.adobe import Stroke
-from src.schema.adobe import EffectBevel, EffectColorOverlay, EffectDropShadow, EffectGradientOverlay, EffectStroke, \
- LayerEffects
-
-# QOL Definitions
-sID, cID = APP.stringIDToTypeID, APP.charIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
+from src.helpers.colors import add_color_to_gradient, apply_color, get_color
+from src.schema.adobe import (
+ EffectBevel,
+ EffectColorOverlay,
+ EffectDropShadow,
+ EffectGradientOverlay,
+ EffectStroke,
+ LayerEffects,
+)
+
+_logger = getLogger(__name__)
"""
* Blending Utilities
"""
-def set_fill_opacity(opacity: float, layer: Optional[Union[ArtLayer, LayerSet]]) -> None:
+def set_fill_opacity(opacity: float, layer: ArtLayer | LayerSet | None) -> None:
"""Sets the fill opacity of a given layer.
Args:
@@ -34,17 +38,23 @@ def set_fill_opacity(opacity: float, layer: Optional[Union[ArtLayer, LayerSet]])
"""
# Set the active layer
if layer:
- APP.activeDocument.activeLayer = layer
+ APP.instance.activeDocument.activeLayer = layer
# Set the layer's fill opacity
d = ActionDescriptor()
ref = ActionReference()
d1 = ActionDescriptor()
- ref.PutEnumerated(sID("layer"), sID("ordinal"), sID("targetEnum"))
- d.PutReference(sID("target"), ref)
- d1.PutUnitDouble(sID("fillOpacity"), sID("percentUnit"), opacity)
- d.PutObject(sID("to"), sID("layer"), d1)
- APP.executeAction(sID("set"), d, NO_DIALOG)
+ ref.putEnumerated(
+ APP.instance.sID("layer"),
+ APP.instance.sID("ordinal"),
+ APP.instance.sID("targetEnum"),
+ )
+ d.putReference(APP.instance.sID("target"), ref)
+ d1.putUnitDouble(
+ APP.instance.sID("fillOpacity"), APP.instance.sID("percentUnit"), opacity
+ )
+ d.putObject(APP.instance.sID("to"), APP.instance.sID("layer"), d1)
+ APP.instance.executeAction(APP.instance.sID("set"), d, DialogModes.DisplayNoDialogs)
"""
@@ -52,7 +62,9 @@ def set_fill_opacity(opacity: float, layer: Optional[Union[ArtLayer, LayerSet]])
"""
-def set_layer_fx_visibility(layer: Optional[Union[ArtLayer, LayerSet]] = None, visible: bool = True) -> None:
+def set_layer_fx_visibility(
+ layer: ArtLayer | LayerSet | None = None, visible: bool = True
+) -> None:
"""Shows or hides the layer effects on a given layer.
Args:
@@ -61,20 +73,28 @@ def set_layer_fx_visibility(layer: Optional[Union[ArtLayer, LayerSet]] = None, v
"""
# Set the active layer
if layer:
- APP.activeDocument.activeLayer = layer
+ APP.instance.activeDocument.activeLayer = layer
# Set the layer's FX visibility
ref = ActionReference()
desc = ActionDescriptor()
action_list = ActionList()
- ref.putClass(sID("layerEffects"))
- ref.putEnumerated(sID("layer"), sID("ordinal"), sID("targetEnum"))
+ ref.putClass(APP.instance.sID("layerEffects"))
+ ref.putEnumerated(
+ APP.instance.sID("layer"),
+ APP.instance.sID("ordinal"),
+ APP.instance.sID("targetEnum"),
+ )
action_list.putReference(ref)
- desc.putList(sID("target"), action_list)
- APP.executeAction(sID("show" if visible else "hide"), desc, NO_DIALOG)
+ desc.putList(APP.instance.sID("target"), action_list)
+ APP.instance.executeAction(
+ APP.instance.sID("show" if visible else "hide"),
+ desc,
+ DialogModes.DisplayNoDialogs,
+ )
-def enable_layer_fx(layer: Optional[Union[ArtLayer, LayerSet]] = None) -> None:
+def enable_layer_fx(layer: ArtLayer | LayerSet | None = None) -> None:
"""Utility definition for `change_fx_visibility` to enable effects on layer.
Args:
@@ -83,7 +103,7 @@ def enable_layer_fx(layer: Optional[Union[ArtLayer, LayerSet]] = None) -> None:
set_layer_fx_visibility(layer, True)
-def disable_layer_fx(layer: Optional[Union[ArtLayer, LayerSet]] = None) -> None:
+def disable_layer_fx(layer: ArtLayer | LayerSet | None = None) -> None:
"""Utility definition for `change_fx_visibility` to disable effects on layer.
Args:
@@ -92,22 +112,32 @@ def disable_layer_fx(layer: Optional[Union[ArtLayer, LayerSet]] = None) -> None:
set_layer_fx_visibility(layer, False)
-def clear_layer_fx(layer: Union[ArtLayer, LayerSet, None]) -> None:
+def clear_layer_fx(layer: ArtLayer | LayerSet | None) -> None:
"""Removes all layer style effects.
Args:
layer: Layer object
"""
if layer:
- APP.activeDocument.activeLayer = layer
+ APP.instance.activeDocument.activeLayer = layer
try:
desc1600 = ActionDescriptor()
ref126 = ActionReference()
- ref126.putEnumerated(sID("layer"), sID("ordinal"), sID("targetEnum"))
- desc1600.putReference(sID("target"), ref126)
- APP.executeAction(sID("disableLayerStyle"), desc1600, NO_DIALOG)
- except Exception as e:
- print(e, f'\nLayer "{layer.name}" has no effects!')
+ ref126.putEnumerated(
+ APP.instance.sID("layer"),
+ APP.instance.sID("ordinal"),
+ APP.instance.sID("targetEnum"),
+ )
+ desc1600.putReference(APP.instance.sID("target"), ref126)
+ APP.instance.executeAction(
+ APP.instance.sID("disableLayerStyle"),
+ desc1600,
+ DialogModes.DisplayNoDialogs,
+ )
+ except COMError:
+ _logger.exception(
+ f"""\nLayer "{layer.name if layer else ""}" has no effects.""",
+ )
def rasterize_layer_fx(layer: ArtLayer) -> None:
@@ -118,13 +148,21 @@ def rasterize_layer_fx(layer: ArtLayer) -> None:
"""
desc1 = ActionDescriptor()
ref1 = ActionReference()
- ref1.putIdentifier(sID("layer"), layer.id)
- desc1.putReference(sID("target"), ref1)
- desc1.putEnumerated(sID("what"), sID("rasterizeItem"), sID("layerStyle"))
- APP.executeAction(sID("rasterizeLayer"), desc1, NO_DIALOG)
-
-
-def copy_layer_fx(from_layer: Union[ArtLayer, LayerSet], to_layer: Union[ArtLayer, LayerSet]) -> None:
+ ref1.putIdentifier(APP.instance.sID("layer"), layer.id)
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc1.putEnumerated(
+ APP.instance.sID("what"),
+ APP.instance.sID("rasterizeItem"),
+ APP.instance.sID("layerStyle"),
+ )
+ APP.instance.executeAction(
+ APP.instance.sID("rasterizeLayer"), desc1, DialogModes.DisplayNoDialogs
+ )
+
+
+def copy_layer_fx(
+ from_layer: ArtLayer | LayerSet, to_layer: ArtLayer | LayerSet
+) -> None:
"""Copies the layer effects from one layer to another layer.
Args:
@@ -134,18 +172,30 @@ def copy_layer_fx(from_layer: Union[ArtLayer, LayerSet], to_layer: Union[ArtLaye
# Get layer effects from source layer
desc_get = ActionDescriptor()
ref_get = ActionReference()
- ref_get.putIdentifier(sID("layer"), from_layer.id)
- desc_get.putReference(sID("null"), ref_get)
- desc_get.putEnumerated(sID("class"), sID("class"), sID("layerEffects"))
- result_desc = APP.executeAction(sID("get"), desc_get, NO_DIALOG)
+ ref_get.putIdentifier(APP.instance.sID("layer"), from_layer.id)
+ desc_get.putReference(APP.instance.sID("null"), ref_get)
+ desc_get.putEnumerated(
+ APP.instance.sID("class"),
+ APP.instance.sID("class"),
+ APP.instance.sID("layerEffects"),
+ )
+ result_desc = APP.instance.executeAction(
+ APP.instance.sID("get"), desc_get, DialogModes.DisplayNoDialogs
+ )
# Apply layer effects to target layer
desc_set = ActionDescriptor()
ref_set = ActionReference()
- ref_set.putIdentifier(sID("layer"), to_layer.id)
- desc_set.putReference(sID("null"), ref_set)
- desc_set.putObject(sID("to"), sID("layerEffects"), result_desc.getObjectValue(sID("layerEffects")))
- APP.executeAction(sID("set"), desc_set, NO_DIALOG)
+ ref_set.putIdentifier(APP.instance.sID("layer"), to_layer.id)
+ desc_set.putReference(APP.instance.sID("null"), ref_set)
+ desc_set.putObject(
+ APP.instance.sID("to"),
+ APP.instance.sID("layerEffects"),
+ result_desc.getObjectValue(APP.instance.sID("layerEffects")),
+ )
+ APP.instance.executeAction(
+ APP.instance.sID("set"), desc_set, DialogModes.DisplayNoDialogs
+ )
"""
@@ -153,7 +203,7 @@ def copy_layer_fx(from_layer: Union[ArtLayer, LayerSet], to_layer: Union[ArtLaye
"""
-def apply_fx(layer: Union[ArtLayer, LayerSet], effects: list[LayerEffects]) -> None:
+def apply_fx(layer: ArtLayer | LayerSet, effects: list[LayerEffects]) -> None:
"""Apply multiple layer effects to a layer.
Args:
@@ -161,13 +211,17 @@ def apply_fx(layer: Union[ArtLayer, LayerSet], effects: list[LayerEffects]) -> N
effects: List of effects to apply.
"""
# Set up the main action
- APP.activeDocument.activeLayer = layer
+ APP.instance.activeDocument.activeLayer = layer
main_action = ActionDescriptor()
fx_action = ActionDescriptor()
main_ref = ActionReference()
- main_ref.putProperty(sID("property"), sID("layerEffects"))
- main_ref.putEnumerated(sID("layer"), sID("ordinal"), sID("targetEnum"))
- main_action.putReference(sID("target"), main_ref)
+ main_ref.putProperty(APP.instance.sID("property"), APP.instance.sID("layerEffects"))
+ main_ref.putEnumerated(
+ APP.instance.sID("layer"),
+ APP.instance.sID("ordinal"),
+ APP.instance.sID("targetEnum"),
+ )
+ main_action.putReference(APP.instance.sID("target"), main_ref)
# Add each action from fx dictionary
for fx in effects:
@@ -179,12 +233,16 @@ def apply_fx(layer: Union[ArtLayer, LayerSet], effects: list[LayerEffects]) -> N
apply_fx_drop_shadow(fx_action, fx)
elif isinstance(fx, EffectGradientOverlay):
apply_fx_gradient_overlay(fx_action, fx)
- elif isinstance(fx, EffectStroke):
+ else:
apply_fx_stroke(fx_action, fx)
# Apply all fx actions
- main_action.putObject(sID("to"), sID("layerEffects"), fx_action)
- APP.executeAction(sID("set"), main_action, NO_DIALOG)
+ main_action.putObject(
+ APP.instance.sID("to"), APP.instance.sID("layerEffects"), fx_action
+ )
+ APP.instance.executeAction(
+ APP.instance.sID("set"), main_action, DialogModes.DisplayNoDialogs
+ )
def apply_fx_bevel(action: ActionDescriptor, fx: EffectBevel) -> None:
@@ -195,26 +253,70 @@ def apply_fx_bevel(action: ActionDescriptor, fx: EffectBevel) -> None:
fx: Bevel effect properties.
"""
d1, d2 = ActionDescriptor(), ActionDescriptor()
- d1.PutEnumerated(sID("highlightMode"), sID("blendMode"), sID("screen"))
- apply_color(d1, fx.highlight_color, 'highlightColor')
- d1.PutUnitDouble(sID("highlightOpacity"), sID("percentUnit"), fx.highlight_opacity)
- d1.PutEnumerated(sID("shadowMode"), sID("blendMode"), sID("multiply"))
- apply_color(d1, fx.shadow_color, 'shadowColor')
- d1.PutUnitDouble(sID("shadowOpacity"), sID("percentUnit"), fx.shadow_opacity)
- d1.PutEnumerated(sID("bevelTechnique"), sID("bevelTechnique"), sID("softMatte"))
- d1.PutEnumerated(sID("bevelStyle"), sID("bevelEmbossStyle"), sID("outerBevel"))
- d1.PutBoolean(sID("useGlobalAngle"), fx.global_light)
- d1.PutUnitDouble(sID("localLightingAngle"), sID("angleUnit"), fx.rotation)
- d1.PutUnitDouble(sID("localLightingAltitude"), sID("angleUnit"), fx.altitude)
- d1.PutUnitDouble(sID("strengthRatio"), sID("percentUnit"), fx.depth)
- d1.PutUnitDouble(sID("blur"), sID("pixelsUnit"), fx.size)
- d1.PutEnumerated(sID("bevelDirection"), sID("bevelEmbossStampStyle"), sID("in"))
- d1.PutObject(sID("transferSpec"), sID("shapeCurveType"), d2)
- d1.PutBoolean(sID("antialiasGloss"), False)
- d1.PutUnitDouble(sID("softness"), sID("pixelsUnit"), fx.softness)
- d1.PutBoolean(sID("useShape"), False)
- d1.PutBoolean(sID("useTexture"), False)
- action.PutObject(sID("bevelEmboss"), sID("bevelEmboss"), d1)
+ d1.putEnumerated(
+ APP.instance.sID("highlightMode"),
+ APP.instance.sID("blendMode"),
+ APP.instance.sID("screen"),
+ )
+ apply_color(d1, fx.highlight_color, "highlightColor")
+ d1.putUnitDouble(
+ APP.instance.sID("highlightOpacity"),
+ APP.instance.sID("percentUnit"),
+ fx.highlight_opacity,
+ )
+ d1.putEnumerated(
+ APP.instance.sID("shadowMode"),
+ APP.instance.sID("blendMode"),
+ APP.instance.sID("multiply"),
+ )
+ apply_color(d1, fx.shadow_color, "shadowColor")
+ d1.putUnitDouble(
+ APP.instance.sID("shadowOpacity"),
+ APP.instance.sID("percentUnit"),
+ fx.shadow_opacity,
+ )
+ d1.putEnumerated(
+ APP.instance.sID("bevelTechnique"),
+ APP.instance.sID("bevelTechnique"),
+ APP.instance.sID("softMatte"),
+ )
+ d1.putEnumerated(
+ APP.instance.sID("bevelStyle"),
+ APP.instance.sID("bevelEmbossStyle"),
+ APP.instance.sID("outerBevel"),
+ )
+ d1.putBoolean(APP.instance.sID("useGlobalAngle"), fx.global_light)
+ d1.putUnitDouble(
+ APP.instance.sID("localLightingAngle"),
+ APP.instance.sID("angleUnit"),
+ fx.rotation,
+ )
+ d1.putUnitDouble(
+ APP.instance.sID("localLightingAltitude"),
+ APP.instance.sID("angleUnit"),
+ fx.altitude,
+ )
+ d1.putUnitDouble(
+ APP.instance.sID("strengthRatio"), APP.instance.sID("percentUnit"), fx.depth
+ )
+ d1.putUnitDouble(APP.instance.sID("blur"), APP.instance.sID("pixelsUnit"), fx.size)
+ d1.putEnumerated(
+ APP.instance.sID("bevelDirection"),
+ APP.instance.sID("bevelEmbossStampStyle"),
+ APP.instance.sID("in"),
+ )
+ d1.putObject(
+ APP.instance.sID("transferSpec"), APP.instance.sID("shapeCurveType"), d2
+ )
+ d1.putBoolean(APP.instance.sID("antialiasGloss"), False)
+ d1.putUnitDouble(
+ APP.instance.sID("softness"), APP.instance.sID("pixelsUnit"), fx.softness
+ )
+ d1.putBoolean(APP.instance.sID("useShape"), False)
+ d1.putBoolean(APP.instance.sID("useTexture"), False)
+ action.putObject(
+ APP.instance.sID("bevelEmboss"), APP.instance.sID("bevelEmboss"), d1
+ )
def apply_fx_color_overlay(action: ActionDescriptor, fx: EffectColorOverlay) -> None:
@@ -225,10 +327,16 @@ def apply_fx_color_overlay(action: ActionDescriptor, fx: EffectColorOverlay) ->
fx: Color Overlay effect properties.
"""
d = ActionDescriptor()
- d.PutEnumerated(sID("mode"), sID("blendMode"), sID("normal"))
+ d.putEnumerated(
+ APP.instance.sID("mode"),
+ APP.instance.sID("blendMode"),
+ APP.instance.sID("normal"),
+ )
apply_color(d, fx.color)
- d.PutUnitDouble(sID("opacity"), sID("percentUnit"), fx.opacity)
- action.PutObject(sID("solidFill"), sID("solidFill"), d)
+ d.putUnitDouble(
+ APP.instance.sID("opacity"), APP.instance.sID("percentUnit"), fx.opacity
+ )
+ action.putObject(APP.instance.sID("solidFill"), APP.instance.sID("solidFill"), d)
def apply_fx_drop_shadow(action: ActionDescriptor, fx: EffectDropShadow) -> None:
@@ -240,23 +348,43 @@ def apply_fx_drop_shadow(action: ActionDescriptor, fx: EffectDropShadow) -> None
"""
d1 = ActionDescriptor()
d2 = ActionDescriptor()
- d1.putEnumerated(sID("mode"), sID("blendMode"), sID("multiply"))
+ d1.putEnumerated(
+ APP.instance.sID("mode"),
+ APP.instance.sID("blendMode"),
+ APP.instance.sID("multiply"),
+ )
apply_color(d1, fx.color)
- d1.putUnitDouble(sID("opacity"), sID("percentUnit"), fx.opacity)
- d1.putBoolean(sID("useGlobalAngle"), False)
- d1.putUnitDouble(sID("localLightingAngle"), sID("angleUnit"), fx.rotation)
- d1.putUnitDouble(sID("distance"), sID("pixelsUnit"), fx.distance)
- d1.putUnitDouble(sID("chokeMatte"), sID("pixelsUnit"), fx.spread)
- d1.putUnitDouble(sID("blur"), sID("pixelsUnit"), fx.size)
- d1.putUnitDouble(sID("noise"), sID("percentUnit"), fx.noise)
- d1.putBoolean(sID("antiAlias"), False)
- d2.putString(sID("name"), "Linear")
- d1.putObject(sID("transferSpec"), sID("shapeCurveType"), d2)
- d1.putBoolean(sID("layerConceals"), True)
- action.putObject(sID("dropShadow"), sID("dropShadow"), d1)
-
-
-def apply_fx_gradient_overlay(action: ActionDescriptor, fx: EffectGradientOverlay) -> None:
+ d1.putUnitDouble(
+ APP.instance.sID("opacity"), APP.instance.sID("percentUnit"), fx.opacity
+ )
+ d1.putBoolean(APP.instance.sID("useGlobalAngle"), False)
+ d1.putUnitDouble(
+ APP.instance.sID("localLightingAngle"),
+ APP.instance.sID("angleUnit"),
+ fx.rotation,
+ )
+ d1.putUnitDouble(
+ APP.instance.sID("distance"), APP.instance.sID("pixelsUnit"), fx.distance
+ )
+ d1.putUnitDouble(
+ APP.instance.sID("chokeMatte"), APP.instance.sID("pixelsUnit"), fx.spread
+ )
+ d1.putUnitDouble(APP.instance.sID("blur"), APP.instance.sID("pixelsUnit"), fx.size)
+ d1.putUnitDouble(
+ APP.instance.sID("noise"), APP.instance.sID("percentUnit"), fx.noise
+ )
+ d1.putBoolean(APP.instance.sID("antiAlias"), False)
+ d2.putString(APP.instance.sID("name"), "Linear")
+ d1.putObject(
+ APP.instance.sID("transferSpec"), APP.instance.sID("shapeCurveType"), d2
+ )
+ d1.putBoolean(APP.instance.sID("layerConceals"), True)
+ action.putObject(APP.instance.sID("dropShadow"), APP.instance.sID("dropShadow"), d1)
+
+
+def apply_fx_gradient_overlay(
+ action: ActionDescriptor, fx: EffectGradientOverlay
+) -> None:
"""Adds gradient effect to layer effects action.
Args:
@@ -270,39 +398,65 @@ def apply_fx_gradient_overlay(action: ActionDescriptor, fx: EffectGradientOverla
d5 = ActionDescriptor()
color_list = ActionList()
transparency_list = ActionList()
- d1.putEnumerated(sID("mode"), sID("blendMode"), sID("normal"))
- d1.putUnitDouble(sID("opacity"), sID("percentUnit"), fx.opacity)
- d2.putEnumerated(sID("gradientForm"), sID("gradientForm"), sID("customStops"))
- d2.putDouble(sID("interfaceIconFrameDimmed"), fx.size)
+ d1.putEnumerated(
+ APP.instance.sID("mode"),
+ APP.instance.sID("blendMode"),
+ APP.instance.sID(fx.blend_mode),
+ )
+ d1.putUnitDouble(
+ APP.instance.sID("opacity"), APP.instance.sID("percentUnit"), fx.opacity
+ )
+ d2.putEnumerated(
+ APP.instance.sID("gradientForm"),
+ APP.instance.sID("gradientForm"),
+ APP.instance.sID("customStops"),
+ )
+ d2.putDouble(APP.instance.sID("interfaceIconFrameDimmed"), fx.size)
for c in fx.colors:
add_color_to_gradient(
action_list=color_list,
color=get_color(c.color),
location=c.location,
- midpoint=c.midpoint
+ midpoint=c.midpoint,
)
- d2.putList(sID("colors"), color_list)
- d3.putUnitDouble(sID("opacity"), sID("percentUnit"), 100)
- d3.putInteger(sID("location"), 0)
- d3.putInteger(sID("midpoint"), 50)
- transparency_list.putObject(sID("transferSpec"), d3)
- d4.putUnitDouble(sID("opacity"), sID("percentUnit"), 100)
- d4.putInteger(sID("location"), fx.size)
- d4.putInteger(sID("midpoint"), 50)
- transparency_list.putObject(sID("transferSpec"), d4)
- d2.putList(sID("transparency"), transparency_list)
- d1.putObject(sID("gradient"), sID("gradientClassEvent"), d2)
- d1.putUnitDouble(sID("angle"), sID("angleUnit"), fx.rotation)
- d1.putEnumerated(sID("type"), sID("gradientType"), sID("linear"))
- d1.putBoolean(sID("reverse"), False)
- d1.putBoolean(sID("dither"), False)
- d1.putEnumerated(cID("gs99"), sID("gradientInterpolationMethodType"), sID("classic"))
- d1.putBoolean(sID("align"), True)
- d1.putUnitDouble(sID("scale"), sID("percentUnit"), fx.scale)
- d5.putUnitDouble(sID("horizontal"), sID("percentUnit"), 0)
- d5.putUnitDouble(sID("vertical"), sID("percentUnit"), 0)
- d1.putObject(sID("offset"), sID("paint"), d5)
- action.putObject(sID("gradientFill"), sID("gradientFill"), d1)
+ d2.putList(APP.instance.sID("colors"), color_list)
+ d3.putUnitDouble(APP.instance.sID("opacity"), APP.instance.sID("percentUnit"), 100)
+ d3.putInteger(APP.instance.sID("location"), 0)
+ d3.putInteger(APP.instance.sID("midpoint"), 50)
+ transparency_list.putObject(APP.instance.sID("transferSpec"), d3)
+ d4.putUnitDouble(APP.instance.sID("opacity"), APP.instance.sID("percentUnit"), 100)
+ d4.putInteger(APP.instance.sID("location"), round(fx.size))
+ d4.putInteger(APP.instance.sID("midpoint"), 50)
+ transparency_list.putObject(APP.instance.sID("transferSpec"), d4)
+ d2.putList(APP.instance.sID("transparency"), transparency_list)
+ d1.putObject(
+ APP.instance.sID("gradient"), APP.instance.sID("gradientClassEvent"), d2
+ )
+ d1.putUnitDouble(
+ APP.instance.sID("angle"), APP.instance.sID("angleUnit"), fx.rotation
+ )
+ d1.putEnumerated(
+ APP.instance.sID("type"),
+ APP.instance.sID("gradientType"),
+ APP.instance.sID("linear"),
+ )
+ d1.putBoolean(APP.instance.sID("reverse"), False)
+ d1.putBoolean(APP.instance.sID("dither"), fx.dither)
+ d1.putEnumerated(
+ APP.instance.cID("gs99"),
+ APP.instance.sID("gradientInterpolationMethodType"),
+ APP.instance.sID(fx.method),
+ )
+ d1.putBoolean(APP.instance.sID("align"), True)
+ d1.putUnitDouble(
+ APP.instance.sID("scale"), APP.instance.sID("percentUnit"), fx.scale
+ )
+ d5.putUnitDouble(APP.instance.sID("horizontal"), APP.instance.sID("percentUnit"), 0)
+ d5.putUnitDouble(APP.instance.sID("vertical"), APP.instance.sID("percentUnit"), 0)
+ d1.putObject(APP.instance.sID("offset"), APP.instance.sID("paint"), d5)
+ action.putObject(
+ APP.instance.sID("gradientFill"), APP.instance.sID("gradientFill"), d1
+ )
def apply_fx_stroke(action: ActionDescriptor, fx: EffectStroke) -> None:
@@ -313,11 +467,25 @@ def apply_fx_stroke(action: ActionDescriptor, fx: EffectStroke) -> None:
fx: Stroke effect properties.
"""
d = ActionDescriptor()
- d.putEnumerated(sID("style"), sID("frameStyle"), Stroke.position(fx.style))
- d.putEnumerated(sID("paintType"), sID("frameFill"), sID("solidColor"))
- d.putEnumerated(sID("mode"), sID("blendMode"), sID("normal"))
- d.putUnitDouble(sID("opacity"), sID("percentUnit"), fx.opacity)
- d.putUnitDouble(sID("size"), sID("pixelsUnit"), fx.weight)
+ d.putEnumerated(
+ APP.instance.sID("style"),
+ APP.instance.sID("frameStyle"),
+ Stroke.position(fx.style),
+ )
+ d.putEnumerated(
+ APP.instance.sID("paintType"),
+ APP.instance.sID("frameFill"),
+ APP.instance.sID("solidColor"),
+ )
+ d.putEnumerated(
+ APP.instance.sID("mode"),
+ APP.instance.sID("blendMode"),
+ APP.instance.sID("normal"),
+ )
+ d.putUnitDouble(
+ APP.instance.sID("opacity"), APP.instance.sID("percentUnit"), fx.opacity
+ )
+ d.putUnitDouble(APP.instance.sID("size"), APP.instance.sID("pixelsUnit"), fx.weight)
apply_color(d, get_color(fx.color))
- d.putBoolean(sID("overprint"), False)
- action.putObject(sID("frameFX"), sID("frameFX"), d)
+ d.putBoolean(APP.instance.sID("overprint"), False)
+ action.putObject(APP.instance.sID("frameFX"), APP.instance.sID("frameFX"), d)
diff --git a/src/helpers/layers.py b/src/helpers/layers.py
index db8656b9..151cfe5b 100644
--- a/src/helpers/layers.py
+++ b/src/helpers/layers.py
@@ -1,24 +1,28 @@
"""
* Helpers: Layers and Layer Groups
"""
-# Standard Library Imports
-from contextlib import suppress
-from typing import Optional, Union, Iterable
-# Third Party Imports
-from photoshop.api import DialogModes, ActionDescriptor, ActionReference, BlendMode
+from collections.abc import Iterable, Sequence
+from contextlib import AbstractContextManager
+from logging import getLogger
+from types import TracebackType
+from typing import TypedDict
+
+from photoshop.api import ActionDescriptor, ActionReference
from photoshop.api._artlayer import ArtLayer
from photoshop.api._document import Document
from photoshop.api._layerSet import LayerSet
+from photoshop.api.enumerations import BlendMode, DialogModes, ElementPlacement
-# Local Imports
-from src import APP, ENV
-from src.utils.adobe import LayerContainer, LayerContainerTypes, ReferenceLayer, PS_EXCEPTIONS
-
-# QOL Definitions
-sID, cID = APP.stringIDToTypeID, APP.charIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
+from src import APP
+from src.utils.adobe import (
+ PS_EXCEPTIONS,
+ LayerContainer,
+ LayerContainerTypes,
+ ReferenceLayer,
+)
+_logger = getLogger(__name__)
"""
* Searching Layers
@@ -27,8 +31,11 @@
def getLayer(
name: str,
- group: Union[str, None, list[str], LayerContainerTypes, Iterable[LayerContainerTypes]] = None
-) -> Optional[ArtLayer]:
+ group: str
+ | None
+ | LayerContainerTypes
+ | Iterable[LayerContainerTypes | str | None] = None,
+) -> ArtLayer | None:
"""Retrieve ArtLayer object from given name and group/group tree.
Args:
@@ -42,16 +49,16 @@ def getLayer(
# LayerSet provided?
if not group:
# LayerSet not provided
- return APP.activeDocument.artLayers[name]
+ return APP.instance.activeDocument.artLayers[name]
elif isinstance(group, str):
# LayerSet name given
- return APP.activeDocument.layerSets[group].artLayers[name]
+ return APP.instance.activeDocument.layerSets[group].artLayers[name]
elif isinstance(group, LayerContainer):
# LayerSet object given
return group.artLayers[name]
- elif isinstance(group, (tuple, list)):
+ elif isinstance(group, tuple | list):
# Tuple or list of LayerSets
- layer_set = APP.activeDocument
+ layer_set = APP.instance.activeDocument
for g in group:
if isinstance(g, str):
# LayerSet name given
@@ -61,22 +68,28 @@ def getLayer(
layer_set = g
return layer_set.artLayers[name]
# ArtLayer can't be located
- raise OSError(f"ArtLayer invalid")
- except PS_EXCEPTIONS:
+ raise OSError("ArtLayer invalid")
+ except PS_EXCEPTIONS as exc:
# Layer couldn't be found
- if ENV.DEV_MODE:
- print(f'Layer "{name}" could not be found!')
- if group and isinstance(group, LayerSet):
- print(f"LayerSet reference used: {group.name}")
- elif group and isinstance(group, str):
- print(f"LayerSet reference used: {group}")
- return
+ _logger.debug(
+ f'Layer "{name}" could not be found.{
+ f" Used LayerSet reference: {group.name}"
+ if isinstance(group, LayerSet)
+ else f" Used LayerSet reference: {group}"
+ if group and isinstance(group, str)
+ else ""
+ }',
+ exc_info=exc,
+ )
def getLayerSet(
name: str,
- group: Union[str, None, list[str], LayerContainerTypes, Iterable[LayerContainerTypes]] = None
-) -> Optional[LayerSet]:
+ group: str
+ | None
+ | LayerContainerTypes
+ | Iterable[LayerContainerTypes | str | None] = None,
+) -> LayerSet | None:
"""Retrieve layer group object.
Args:
@@ -90,13 +103,13 @@ def getLayerSet(
# Was LayerSet provided?
if not group:
# No LayerSet given
- return APP.activeDocument.layerSets[name]
+ return APP.instance.activeDocument.layerSets[name]
elif isinstance(group, str):
# LayerSet name given
- return APP.activeDocument.layerSets[group].layerSets[name]
- elif isinstance(group, (tuple, list)):
+ return APP.instance.activeDocument.layerSets[group].layerSets[name]
+ elif isinstance(group, tuple | list):
# Tuple or list of groups
- layer_set = APP.activeDocument
+ layer_set = APP.instance.activeDocument
for g in group:
if isinstance(g, str):
# LayerSet name given
@@ -109,19 +122,24 @@ def getLayerSet(
# LayerSet object given
return group.layerSets[name]
# LayerSet can't be located
- raise OSError(f"LayerSet invalid")
- except PS_EXCEPTIONS:
+ raise OSError("LayerSet invalid")
+ except PS_EXCEPTIONS as exc:
# LayerSet couldn't be found
- if ENV.DEV_MODE:
- print(f'LayerSet "{name}" could not be found!')
- if group and isinstance(group, LayerSet):
- print(f"LayerSet reference used: {group.name}")
- elif group and isinstance(group, str):
- print(f"LayerSet reference used: {group}")
- return
-
-
-def get_reference_layer(name: str, group: Union[None, str, LayerSet] = None) -> Optional[ReferenceLayer]:
+ _logger.debug(
+ f'LayerSet "{name}" could not be found.{
+ f" Used LayerSet reference: {group.name}"
+ if isinstance(group, LayerSet)
+ else f" Used LayerSet reference: {group}"
+ if group and isinstance(group, str)
+ else ""
+ }',
+ exc_info=exc,
+ )
+
+
+def get_reference_layer(
+ name: str, group: None | str | LayerSet | Document = None
+) -> ReferenceLayer | None:
"""Get an ArtLayer that is a static reference layer.
Args:
@@ -134,21 +152,26 @@ def get_reference_layer(name: str, group: Union[None, str, LayerSet] = None) ->
time on bounds and dimensions handling.
"""
- # Select the proper group if str or None provided
- if not group:
- group = APP.activeDocument
- if isinstance(group, str):
- try:
- group = APP.activeDocument.layerSets[group]
- except PS_EXCEPTIONS:
- group = APP.activeDocument
-
- # Select the reference layer
- with suppress(Exception):
- return ReferenceLayer(
- parent=group.artLayers.app[name],
- app=APP)
- return None
+ try:
+ # Select the proper group if str or None provided
+ if not group:
+ group = APP.instance.activeDocument
+ elif isinstance(group, str):
+ group = APP.instance.activeDocument.layerSets[group]
+
+ # Select the reference layer
+ return ReferenceLayer(parent=group.artLayers.app[name], app=APP.instance)
+ except PS_EXCEPTIONS as exc:
+ _logger.debug(
+ f'ReferenceLayer "{name}" could not be found.{
+ f" Used LayerSet reference: {group.name}"
+ if isinstance(group, LayerSet)
+ else f" Used LayerSet reference: {group}"
+ if group and isinstance(group, str)
+ else ""
+ }',
+ exc_info=exc,
+ )
"""
@@ -156,7 +179,7 @@ def get_reference_layer(name: str, group: Union[None, str, LayerSet] = None) ->
"""
-def create_new_layer(layer_name: Optional[str] = None) -> ArtLayer:
+def create_new_layer(layer_name: str | None = None) -> ArtLayer:
"""Creates a new layer below the currently active layer. The layer will be visible.
Args:
@@ -166,24 +189,23 @@ def create_new_layer(layer_name: Optional[str] = None) -> ArtLayer:
Newly created layer object
"""
# Create new layer at top of layers
- active_layer = APP.activeDocument.activeLayer
- layer = APP.activeDocument.artLayers.add()
+ active_layer = APP.instance.activeDocument.activeLayer
+ layer = APP.instance.activeDocument.artLayers.add()
layer.name = layer_name or "Layer"
# Name it & set blend mode to normal
layer.blendMode = BlendMode.NormalBlend
# Move the layer below
- layer.moveAfter(active_layer)
+ layer.move(active_layer, ElementPlacement.PlaceAfter)
return layer
-def merge_layers(layers: list[ArtLayer] = None, name: Optional[str] = None) -> ArtLayer:
+def merge_layers(
+ layers: list[ArtLayer | LayerSet], name: str | None = None
+) -> ArtLayer:
"""Merge a set of layers together.
- Todo:
- Check if this can merge layer groups with layers.
-
Args:
layers: Layers to be merged, uses active if not provided.
name: Name of the newly created layer.
@@ -191,20 +213,30 @@ def merge_layers(layers: list[ArtLayer] = None, name: Optional[str] = None) -> A
Returns:
Returns the merged layer.
"""
+ # TODO Check if this can merge layer groups with layers.
# Return layer if only one is present in the list
- if len(layers) == 1:
- return layers[0]
+ if len(layers) == 1 and isinstance((layer := layers[0]), ArtLayer):
+ return layer
# Select none, then select entire list
if layers:
select_layers(layers)
# Merge layers and return result
- APP.executeAction(sID("mergeLayersNew"), None, NO_DIALOG)
+ APP.instance.executeAction(
+ APP.instance.sID("mergeLayersNew"), None, DialogModes.DisplayNoDialogs
+ )
+
+ active_layer = APP.instance.activeDocument.activeLayer
+ if not isinstance(active_layer, ArtLayer):
+ raise ValueError(
+ "Failed to merge layers. Active layer is unexpectedly not an ArtLayer."
+ )
+
if name:
- APP.activeDocument.activeLayer.name = name
- return APP.activeDocument.activeLayer
+ active_layer.name = name
+ return active_layer
"""
@@ -213,8 +245,8 @@ def merge_layers(layers: list[ArtLayer] = None, name: Optional[str] = None) -> A
def group_layers(
- name: Optional[str] = "New Group",
- layers: Optional[list[Union[ArtLayer, LayerSet]]] = None,
+ name: str = "New Group",
+ layers: list[ArtLayer | LayerSet] | None = None,
) -> LayerSet:
"""Groups the selected layers.
@@ -233,21 +265,32 @@ def group_layers(
desc1 = ActionDescriptor()
ref1 = ActionReference()
ref2 = ActionReference()
- ref1.putClass(sID("layerSection"))
- desc1.putReference(sID('null'), ref1)
- ref2.putEnumerated(cID('Lyr '), cID('Ordn'), cID('Trgt'))
- desc1.putReference(cID('From'), ref2)
+ ref1.putClass(APP.instance.sID("layerSection"))
+ desc1.putReference(APP.instance.sID("null"), ref1)
+ ref2.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc1.putReference(APP.instance.cID("From"), ref2)
desc2 = ActionDescriptor()
- desc2.putString(cID('Nm '), name)
- desc1.putObject(cID('Usng'), sID("layerSection"), desc2)
- desc1.putInteger(sID("layerSectionStart"), 0)
- desc1.putInteger(sID("layerSectionEnd"), 1)
- desc1.putString(cID('Nm '), name)
- APP.executeAction(cID('Mk '), desc1, NO_DIALOG)
- return APP.activeDocument.activeLayer
+ desc2.putString(APP.instance.cID("Nm "), name)
+ desc1.putObject(APP.instance.cID("Usng"), APP.instance.sID("layerSection"), desc2)
+ desc1.putInteger(APP.instance.sID("layerSectionStart"), 0)
+ desc1.putInteger(APP.instance.sID("layerSectionEnd"), 1)
+ desc1.putString(APP.instance.cID("Nm "), name)
+ APP.instance.executeAction(
+ APP.instance.cID("Mk "), desc1, DialogModes.DisplayNoDialogs
+ )
+
+ active_layer = APP.instance.activeDocument.activeLayer
+ if not isinstance(active_layer, LayerSet):
+ raise ValueError(
+ "Failed to group layers. Active layer is unexpectedly not a LayerSet."
+ )
+
+ return active_layer
-def duplicate_group(name: str) -> LayerSet:
+def duplicate_group(group: LayerSet, name: str) -> LayerSet:
"""Duplicates current active layer set without renaming contents.
Args:
@@ -256,25 +299,42 @@ def duplicate_group(name: str) -> LayerSet:
Returns:
The newly created layer set object.
"""
+ select_layer(group)
+
desc241 = ActionDescriptor()
ref4 = ActionReference()
- ref4.putEnumerated(sID("layer"), sID("ordinal"), sID("targetEnum"))
- desc241.putReference(sID("target"), ref4)
- desc241.putString(sID("name"), name)
- desc241.putInteger(sID("version"), 5)
- APP.executeAction(sID("duplicate"), desc241, NO_DIALOG)
- return APP.activeDocument.activeLayer
-
-
-def merge_group(group: Optional[LayerSet] = None) -> None:
+ ref4.putEnumerated(
+ APP.instance.sID("layer"),
+ APP.instance.sID("ordinal"),
+ APP.instance.sID("targetEnum"),
+ )
+ desc241.putReference(APP.instance.sID("target"), ref4)
+ desc241.putString(APP.instance.sID("name"), name)
+ desc241.putInteger(APP.instance.sID("version"), 5)
+ APP.instance.executeAction(
+ APP.instance.sID("duplicate"), desc241, DialogModes.DisplayNoDialogs
+ )
+
+ active_layer = APP.instance.activeDocument.activeLayer
+ if not isinstance(active_layer, LayerSet):
+ raise ValueError(
+ "Failed to duplicate group. Active layer is unexpectedly not a LayerSet."
+ )
+
+ return active_layer
+
+
+def merge_group(group: LayerSet | None = None) -> None:
"""Merges a layer set into a single layer.
Args:
group: Layer set to merge. Merges active if not provided.
"""
if group:
- APP.activeDocument.activeLayer = group
- APP.executeAction(sID("mergeLayersNew"), None, NO_DIALOG)
+ APP.instance.activeDocument.activeLayer = group
+ APP.instance.executeAction(
+ APP.instance.sID("mergeLayersNew"), None, DialogModes.DisplayNoDialogs
+ )
"""
@@ -282,21 +342,34 @@ def merge_group(group: Optional[LayerSet] = None) -> None:
"""
-def smart_layer(layer: Union[ArtLayer, LayerSet] = None, docref: Optional[Document] = None) -> ArtLayer:
+def smart_layer(
+ layer: ArtLayer | LayerSet | None = None, docref: Document | None = None
+) -> ArtLayer:
"""Makes a given layer, or the currently selected layer(s) into a smart layer.
Args:
layer: Layer to turn into smart layer, use active layer(s) if not provided.
docref: Document reference, use active if not provided.
"""
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
if layer:
docref.activeLayer = layer
- APP.executeAction(sID("newPlacedLayer"), None, NO_DIALOG)
- return docref.activeLayer
+ APP.instance.executeAction(
+ APP.instance.sID("newPlacedLayer"), None, DialogModes.DisplayNoDialogs
+ )
+
+ active_layer = APP.instance.activeDocument.activeLayer
+ if not isinstance(active_layer, ArtLayer):
+ raise ValueError(
+ "Failed to convert layer to smart layer. Active layer is unexpectedly not an ArtLayer."
+ )
+ return active_layer
-def edit_smart_layer(layer: Optional[ArtLayer] = None, docref: Optional[Document] = None) -> None:
+
+def edit_smart_layer(
+ layer: ArtLayer | None = None, docref: Document | None = None
+) -> None:
"""Opens the contents of a given smart layer (as a separate document) for editing.
Args:
@@ -304,12 +377,16 @@ def edit_smart_layer(layer: Optional[ArtLayer] = None, docref: Optional[Document
docref: Document reference, use active if not provided.
"""
if layer:
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
docref.activeLayer = layer
- APP.executeAction(sID("placedLayerEditContents"), None, NO_DIALOG)
+ APP.instance.executeAction(
+ APP.instance.sID("placedLayerEditContents"), None, DialogModes.DisplayNoDialogs
+ )
-def unpack_smart_layer(layer: Optional[ArtLayer] = None, docref: Optional[Document] = None) -> None:
+def unpack_smart_layer(
+ layer: ArtLayer | None = None, docref: Document | None = None
+) -> None:
"""Converts a smart layer back into its separate components.
Args:
@@ -317,9 +394,13 @@ def unpack_smart_layer(layer: Optional[ArtLayer] = None, docref: Optional[Docume
docref: Document reference, use active if not provided.
"""
if layer:
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
docref.activeLayer = layer
- APP.executeAction(sID("placedLayerConvertToLayers"), None, NO_DIALOG)
+ APP.instance.executeAction(
+ APP.instance.sID("placedLayerConvertToLayers"),
+ None,
+ DialogModes.DisplayNoDialogs,
+ )
"""
@@ -327,7 +408,7 @@ def unpack_smart_layer(layer: Optional[ArtLayer] = None, docref: Optional[Docume
"""
-def lock_layer(layer: Union[ArtLayer, LayerSet], protection: str = "protectAll") -> None:
+def lock_layer(layer: ArtLayer | LayerSet, protection: str = "protectAll") -> None:
"""Locks the given layer.
Args:
@@ -337,15 +418,17 @@ def lock_layer(layer: Union[ArtLayer, LayerSet], protection: str = "protectAll")
d1 = ActionDescriptor()
d2 = ActionDescriptor()
r1 = ActionReference()
- r1.putIdentifier(sID("layer"), layer.id)
- d1.putReference(sID("target"), r1)
- d2.putBoolean(sID(protection), True)
- idlayerLocking = sID("layerLocking")
+ r1.putIdentifier(APP.instance.sID("layer"), layer.id)
+ d1.putReference(APP.instance.sID("target"), r1)
+ d2.putBoolean(APP.instance.sID(protection), True)
+ idlayerLocking = APP.instance.sID("layerLocking")
d1.putObject(idlayerLocking, idlayerLocking, d2)
- APP.executeAction(sID("applyLocking"), d1, NO_DIALOG)
+ APP.instance.executeAction(
+ APP.instance.sID("applyLocking"), d1, DialogModes.DisplayNoDialogs
+ )
-def unlock_layer(layer: Union[ArtLayer, LayerSet]) -> None:
+def unlock_layer(layer: ArtLayer | LayerSet) -> None:
"""Unlocks the given layer.
Args:
@@ -359,7 +442,7 @@ def unlock_layer(layer: Union[ArtLayer, LayerSet]) -> None:
"""
-def select_layer(layer: Union[ArtLayer, LayerSet], make_visible: bool = False) -> None:
+def select_layer(layer: ArtLayer | LayerSet, make_visible: bool = False) -> None:
"""Select a layer and optionally make it visible.
Args:
@@ -368,16 +451,15 @@ def select_layer(layer: Union[ArtLayer, LayerSet], make_visible: bool = False) -
"""
d1 = ActionDescriptor()
r1 = ActionReference()
- r1.putIdentifier(sID('layer'), layer.id)
- d1.putReference(sID('target'), r1)
- d1.putBoolean(sID('makeVisible'), make_visible)
- APP.executeAction(sID('select'), d1, NO_DIALOG)
+ r1.putIdentifier(APP.instance.sID("layer"), layer.id)
+ d1.putReference(APP.instance.sID("target"), r1)
+ d1.putBoolean(APP.instance.sID("makeVisible"), make_visible)
+ APP.instance.executeAction(
+ APP.instance.sID("select"), d1, DialogModes.DisplayNoDialogs
+ )
-def select_layer_add(
- layer: Union[ArtLayer, LayerSet],
- make_visible: bool = False
-) -> None:
+def select_layer_add(layer: ArtLayer | LayerSet, make_visible: bool = False) -> None:
"""Add layer to currently selected and optionally force it to be visible.
Args:
@@ -386,17 +468,20 @@ def select_layer_add(
"""
desc1 = ActionDescriptor()
ref1 = ActionReference()
- ref1.putIdentifier(sID("layer"), layer.id)
- desc1.putReference(sID("target"), ref1)
+ ref1.putIdentifier(APP.instance.sID("layer"), layer.id)
+ desc1.putReference(APP.instance.sID("target"), ref1)
desc1.putEnumerated(
- sID('selectionModifier'),
- sID('selectionModifierType'),
- sID('addToSelection'))
- desc1.putBoolean(sID("makeVisible"), make_visible)
- APP.executeAction(sID('select'), desc1, NO_DIALOG)
+ APP.instance.sID("selectionModifier"),
+ APP.instance.sID("selectionModifierType"),
+ APP.instance.sID("addToSelection"),
+ )
+ desc1.putBoolean(APP.instance.sID("makeVisible"), make_visible)
+ APP.instance.executeAction(
+ APP.instance.sID("select"), desc1, DialogModes.DisplayNoDialogs
+ )
-def select_layers(layers: list[Union[ArtLayer, LayerSet]]) -> None:
+def select_layers(layers: Sequence[ArtLayer | LayerSet]) -> None:
"""Makes a list of layers active (selected) in the layer panel.
Args:
@@ -406,35 +491,88 @@ def select_layers(layers: list[Union[ArtLayer, LayerSet]]) -> None:
if not layers:
return
if len(layers) == 1:
- APP.activeDocument.activeLayer = layers[0]
+ APP.instance.activeDocument.activeLayer = layers[0]
select_no_layers()
# ID's and descriptors
- idLayer = sID('layer')
- idSelect = sID('select')
- idTarget = sID('target')
- idAddToSel = sID('addToSelection')
- idSelMod = sID('selectionModifier')
- idSelModType = sID('selectionModifierType')
+ idLayer = APP.instance.sID("layer")
+ idSelect = APP.instance.sID("select")
+ idTarget = APP.instance.sID("target")
+ idAddToSel = APP.instance.sID("addToSelection")
+ idSelMod = APP.instance.sID("selectionModifier")
+ idSelModType = APP.instance.sID("selectionModifierType")
d1, r1 = ActionDescriptor(), ActionReference()
# Select initial layer
- r1.putIdentifier(idLayer, layers.pop().id)
+ r1.putIdentifier(idLayer, layers[0].id)
d1.putReference(idTarget, r1)
d1.putEnumerated(idSelMod, idSelModType, idAddToSel)
- d1.putBoolean(sID('makeVisible'), False)
- APP.executeAction(idSelect, d1, NO_DIALOG)
+ d1.putBoolean(APP.instance.sID("makeVisible"), False)
+ APP.instance.executeAction(idSelect, d1, DialogModes.DisplayNoDialogs)
# Select each additional layer
- for lay in layers:
+ for lay in layers[1:]:
r1.putIdentifier(idLayer, lay.id)
d1.putReference(idTarget, r1)
- APP.executeAction(idSelect, d1, NO_DIALOG)
+ APP.instance.executeAction(idSelect, d1, DialogModes.DisplayNoDialogs)
def select_no_layers() -> None:
"""Deselect all layers."""
d1, r1 = ActionDescriptor(), ActionReference()
- r1.putEnumerated(sID("layer"), sID("ordinal"), sID("targetEnum"))
- d1.putReference(sID("target"), r1)
- APP.executeAction(sID("selectNoLayers"), d1, NO_DIALOG)
+ r1.putEnumerated(
+ APP.instance.sID("layer"),
+ APP.instance.sID("ordinal"),
+ APP.instance.sID("targetEnum"),
+ )
+ d1.putReference(APP.instance.sID("target"), r1)
+ APP.instance.executeAction(
+ APP.instance.sID("selectNoLayers"), d1, DialogModes.DisplayNoDialogs
+ )
+
+
+class LayerVisibleContext(AbstractContextManager[None]):
+ def __init__(self, layer: ArtLayer | LayerSet) -> None:
+ self._layer = layer
+ self._initial_visibility: bool
+
+ def __enter__(self) -> None:
+ self._initial_visibility = self._layer.visible
+ self._layer.visible = False
+
+ def __exit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_val: BaseException | None,
+ exc_tb: TracebackType | None,
+ ) -> None:
+ self._layer.visible = self._initial_visibility
+
+
+class StrokeDetails(TypedDict):
+ size: int
+
+
+def get_stroke_details(layer: ArtLayer | LayerSet) -> StrokeDetails | None:
+ with LayerVisibleContext(layer):
+ APP.instance.activeDocument.activeLayer = layer
+
+ ref = ActionReference()
+ ref.putEnumerated(
+ APP.instance.cID("Lyr "), APP.instance.cID("Ordn"), APP.instance.cID("Trgt")
+ )
+ desc: ActionDescriptor = APP.instance.executeActionGet(ref)
+
+ layer_effects_id = APP.instance.sID("layerEffects")
+ if not desc.hasKey(layer_effects_id):
+ return
+
+ layer_effects: ActionDescriptor = desc.getObjectValue(layer_effects_id)
+
+ frame_fx_id = APP.instance.sID("frameFX")
+ if not layer_effects.hasKey(frame_fx_id):
+ return
+
+ frame_fx: ActionDescriptor = layer_effects.getObjectValue(frame_fx_id)
+
+ return {"size": frame_fx.getInteger(APP.instance.sID("size"))}
diff --git a/src/helpers/masks.py b/src/helpers/masks.py
index d2676c19..cd1fb7b9 100644
--- a/src/helpers/masks.py
+++ b/src/helpers/masks.py
@@ -1,30 +1,25 @@
"""
* Helpers: Masks
"""
-# Standard Library Imports
-from typing import Union
-# Third Party Imports
-from photoshop.api import DialogModes, ActionDescriptor, ActionReference
+from _ctypes import COMError
+from enum import StrEnum
+
+from photoshop.api import ActionDescriptor, ActionReference
from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
+from photoshop.api.enumerations import DialogModes
-# Local Imports
from src import APP
from src.helpers.selection import select_canvas
-# QOL Definitions
-sID, cID = APP.stringIDToTypeID, APP.charIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
-
"""
* Copying Masks
"""
def copy_layer_mask(
- layer_from: Union[ArtLayer, LayerSet],
- layer_to: Union[ArtLayer, LayerSet]
+ layer_from: ArtLayer | LayerSet, layer_to: ArtLayer | LayerSet
) -> None:
"""Copies mask from one layer to another.
@@ -35,19 +30,28 @@ def copy_layer_mask(
desc1 = ActionDescriptor()
ref17 = ActionReference()
ref18 = ActionReference()
- desc1.putClass(sID("new"), sID("channel"))
- ref17.putEnumerated(sID("channel"), sID("channel"), sID("mask"))
- ref17.putIdentifier(sID("layer"), layer_to.id)
- desc1.putReference(sID("at"), ref17)
- ref18.putEnumerated(sID("channel"), sID("channel"), sID("mask"))
- ref18.putIdentifier(sID("layer"), layer_from.id)
- desc1.putReference(sID("using"), ref18)
- APP.executeAction(sID("make"), desc1, NO_DIALOG)
+ desc1.putClass(APP.instance.sID("new"), APP.instance.sID("channel"))
+ ref17.putEnumerated(
+ APP.instance.sID("channel"),
+ APP.instance.sID("channel"),
+ APP.instance.sID("mask"),
+ )
+ ref17.putIdentifier(APP.instance.sID("layer"), layer_to.id)
+ desc1.putReference(APP.instance.sID("at"), ref17)
+ ref18.putEnumerated(
+ APP.instance.sID("channel"),
+ APP.instance.sID("channel"),
+ APP.instance.sID("mask"),
+ )
+ ref18.putIdentifier(APP.instance.sID("layer"), layer_from.id)
+ desc1.putReference(APP.instance.sID("using"), ref18)
+ APP.instance.executeAction(
+ APP.instance.sID("make"), desc1, DialogModes.DisplayNoDialogs
+ )
def copy_vector_mask(
- layer_from: Union[ArtLayer, LayerSet],
- layer_to: Union[ArtLayer, LayerSet]
+ layer_from: ArtLayer | LayerSet, layer_to: ArtLayer | LayerSet
) -> None:
"""Copies vector mask from one layer to another.
@@ -59,15 +63,25 @@ def copy_vector_mask(
ref1 = ActionReference()
ref2 = ActionReference()
ref3 = ActionReference()
- ref1.putClass(sID("path"))
- desc1.putReference(sID("target"), ref1)
- ref2.putEnumerated(sID("path"), sID("path"), sID("vectorMask"))
- ref2.putIdentifier(sID("layer"), layer_to.id)
- desc1.putReference(sID("at"), ref2)
- ref3.putEnumerated(sID("path"), sID("path"), sID("vectorMask"))
- ref3.putIdentifier(sID("layer"), layer_from.id)
- desc1.putReference(sID("using"), ref3)
- APP.executeAction(sID("make"), desc1, NO_DIALOG)
+ ref1.putClass(APP.instance.sID("path"))
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ ref2.putEnumerated(
+ APP.instance.sID("path"),
+ APP.instance.sID("path"),
+ APP.instance.sID("vectorMask"),
+ )
+ ref2.putIdentifier(APP.instance.sID("layer"), layer_to.id)
+ desc1.putReference(APP.instance.sID("at"), ref2)
+ ref3.putEnumerated(
+ APP.instance.sID("path"),
+ APP.instance.sID("path"),
+ APP.instance.sID("vectorMask"),
+ )
+ ref3.putIdentifier(APP.instance.sID("layer"), layer_from.id)
+ desc1.putReference(APP.instance.sID("using"), ref3)
+ APP.instance.executeAction(
+ APP.instance.sID("make"), desc1, DialogModes.DisplayNoDialogs
+ )
"""
@@ -75,28 +89,40 @@ def copy_vector_mask(
"""
-def apply_mask_to_layer_fx(layer: Union[ArtLayer, LayerSet] = None) -> None:
- """Sets the layer mask to apply only to layer effects in blending options.
+def apply_mask_to_layer_fx(
+ layer: ArtLayer | LayerSet | None = None,
+ apply: bool = True,
+ raster: bool = True,
+ vector: bool = False,
+) -> None:
+ """Sets the layer mask to apply to layer effects in blending options.
Args:
layer: ArtLayer or LayerSet object.
+ apply: Whether to apply or not apply the mask to layer effects.
+ raster: Apply the setting to raster layer mask.
+ vector: Apply the setting to vector layer mask.
"""
if not layer:
- layer = APP.activeDocument.activeLayer
+ layer = APP.instance.activeDocument.activeLayer
ref = ActionReference()
- ref.putIdentifier(sID("layer"), layer.id)
- desc = APP.executeActionGet(ref)
- layer_fx = desc.getObjectValue(sID('layerEffects'))
- layer_fx.putBoolean(sID("layerMaskAsGlobalMask"), True)
+ ref.putIdentifier(APP.instance.sID("layer"), layer.id)
+ desc = APP.instance.executeActionGet(ref)
+ layer_fx = desc.getObjectValue(APP.instance.sID("layerEffects"))
+ if raster:
+ layer_fx.putBoolean(APP.instance.sID("layerMaskAsGlobalMask"), apply)
+ if vector:
+ layer_fx.putBoolean(APP.instance.sID("vectorMaskAsGlobalMask"), apply)
desc = ActionDescriptor()
- desc.putReference(sID("target"), ref)
- desc.putObject(sID("to"), sID("layer"), layer_fx)
- APP.executeAction(sID("set"), desc, NO_DIALOG)
+ desc.putReference(APP.instance.sID("target"), ref)
+ desc.putObject(APP.instance.sID("to"), APP.instance.sID("layer"), layer_fx)
+ APP.instance.executeAction(
+ APP.instance.sID("set"), desc, DialogModes.DisplayNoDialogs
+ )
def set_layer_mask(
- layer: Union[ArtLayer, LayerSet, None] = None,
- visible: bool = True
+ layer: ArtLayer | LayerSet | None = None, visible: bool = True
) -> None:
"""Set the visibility of a layer's mask.
@@ -105,18 +131,20 @@ def set_layer_mask(
visible: Whether to make the layer mask visible.
"""
if not layer:
- layer = APP.activeDocument.activeLayer
+ layer = APP.instance.activeDocument.activeLayer
desc1 = ActionDescriptor()
desc2 = ActionDescriptor()
ref1 = ActionReference()
- ref1.putIdentifier(cID("Lyr "), layer.id)
- desc1.putReference(sID("target"), ref1)
- desc2.putBoolean(cID("UsrM"), visible)
- desc1.putObject(cID("T "), cID("Lyr "), desc2)
- APP.executeAction(cID("setd"), desc1, NO_DIALOG)
+ ref1.putIdentifier(APP.instance.cID("Lyr "), layer.id)
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc2.putBoolean(APP.instance.cID("UsrM"), visible)
+ desc1.putObject(APP.instance.cID("T "), APP.instance.cID("Lyr "), desc2)
+ APP.instance.executeAction(
+ APP.instance.cID("setd"), desc1, DialogModes.DisplayNoDialogs
+ )
-def enable_mask(layer: Union[ArtLayer, LayerSet, None] = None) -> None:
+def enable_mask(layer: ArtLayer | LayerSet | None = None) -> None:
"""Enables a given layer's mask.
Args:
@@ -125,7 +153,7 @@ def enable_mask(layer: Union[ArtLayer, LayerSet, None] = None) -> None:
set_layer_mask(layer, True)
-def disable_mask(layer: Union[ArtLayer, LayerSet, None] = None) -> None:
+def disable_mask(layer: ArtLayer | LayerSet | None = None) -> None:
"""Disables a given layer's mask.
Args:
@@ -134,25 +162,30 @@ def disable_mask(layer: Union[ArtLayer, LayerSet, None] = None) -> None:
set_layer_mask(layer, False)
-def apply_mask(layer: Union[ArtLayer, LayerSet, None] = None) -> None:
+def apply_mask(layer: ArtLayer | LayerSet | None = None) -> None:
"""Applies a given layer's mask.
Args:
layer: ArtLayer or LayerSet object, use active layer if not provided.
"""
if layer:
- APP.activeDocument.activeLayer = layer
+ APP.instance.activeDocument.activeLayer = layer
desc1 = ActionDescriptor()
ref1 = ActionReference()
- ref1.putEnumerated(sID("channel"), sID("channel"), sID("mask"))
- desc1.putReference(sID("target"), ref1)
- desc1.putBoolean(sID("apply"), True)
- APP.executeAction(sID("delete"), desc1, NO_DIALOG)
+ ref1.putEnumerated(
+ APP.instance.sID("channel"),
+ APP.instance.sID("channel"),
+ APP.instance.sID("mask"),
+ )
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc1.putBoolean(APP.instance.sID("apply"), True)
+ APP.instance.executeAction(
+ APP.instance.sID("delete"), desc1, DialogModes.DisplayNoDialogs
+ )
def set_layer_vector_mask(
- layer: Union[ArtLayer, LayerSet, None] = None,
- visible: bool = False
+ layer: ArtLayer | LayerSet | None = None, visible: bool = False
) -> None:
"""Set the visibility of a layer's vector mask.
@@ -161,18 +194,20 @@ def set_layer_vector_mask(
visible: Whether to make the vector mask visible.
"""
if not layer:
- layer = APP.activeDocument.activeLayer
+ layer = APP.instance.activeDocument.activeLayer
desc1 = ActionDescriptor()
desc2 = ActionDescriptor()
ref1 = ActionReference()
- ref1.putIdentifier(sID("layer"), layer.id)
- desc1.putReference(sID("target"), ref1)
- desc2.putBoolean(sID("vectorMaskEnabled"), visible)
- desc1.putObject(sID("to"), sID("layer"), desc2)
- APP.executeAction(sID("set"), desc1, NO_DIALOG)
+ ref1.putIdentifier(APP.instance.sID("layer"), layer.id)
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc2.putBoolean(APP.instance.sID("vectorMaskEnabled"), visible)
+ desc1.putObject(APP.instance.sID("to"), APP.instance.sID("layer"), desc2)
+ APP.instance.executeAction(
+ APP.instance.sID("set"), desc1, DialogModes.DisplayNoDialogs
+ )
-def enable_vector_mask(layer: Union[ArtLayer, LayerSet, None] = None) -> None:
+def enable_vector_mask(layer: ArtLayer | LayerSet | None = None) -> None:
"""Enables a given layer's vector mask.
Args:
@@ -181,7 +216,7 @@ def enable_vector_mask(layer: Union[ArtLayer, LayerSet, None] = None) -> None:
set_layer_vector_mask(layer, True)
-def disable_vector_mask(layer: Union[ArtLayer, LayerSet, None] = None) -> None:
+def disable_vector_mask(layer: ArtLayer | LayerSet | None = None) -> None:
"""Disables a given layer's vector mask.
Args:
@@ -195,58 +230,89 @@ def disable_vector_mask(layer: Union[ArtLayer, LayerSet, None] = None) -> None:
"""
-def enter_mask_channel(layer: Union[ArtLayer, LayerSet, None] = None):
+def enter_mask_channel(layer: ArtLayer | LayerSet | None = None):
"""Enters mask channel to allow working with current layer's mask.
Args:
layer: Layer to make active, if provided.
"""
if layer:
- APP.activeDocument.activeLayer = layer
+ APP.instance.activeDocument.activeLayer = layer
d1 = ActionDescriptor()
r1 = ActionReference()
- r1.PutEnumerated(sID("channel"), sID("channel"), sID("mask"))
- d1.PutReference(sID("target"), r1)
- d1.PutBoolean(sID("makeVisible"), True)
- APP.Executeaction(sID("select"), d1, NO_DIALOG)
-
-
-def enter_rgb_channel(layer: Union[ArtLayer, LayerSet, None] = None):
+ r1.putEnumerated(
+ APP.instance.sID("channel"),
+ APP.instance.sID("channel"),
+ APP.instance.sID("mask"),
+ )
+ d1.putReference(APP.instance.sID("target"), r1)
+ d1.putBoolean(APP.instance.sID("makeVisible"), True)
+ APP.instance.executeAction(
+ APP.instance.sID("select"), d1, DialogModes.DisplayNoDialogs
+ )
+
+
+def enter_rgb_channel(layer: ArtLayer | LayerSet | None = None):
"""Enters the RGB channel (default channel).
Args:
layer: Layer to make active, if provided.
"""
if layer:
- APP.activeDocument.activeLayer = layer
+ APP.instance.activeDocument.activeLayer = layer
d1 = ActionDescriptor()
r1 = ActionReference()
- r1.PutEnumerated(sID("channel"), sID("channel"), sID("RGB"))
- d1.PutReference(sID("target"), r1)
- d1.PutBoolean(sID("makeVisible"), True)
- APP.Executeaction(sID("select"), d1, NO_DIALOG)
-
-
-def create_mask(layer: Union[ArtLayer, LayerSet, None] = None):
+ r1.putEnumerated(
+ APP.instance.sID("channel"),
+ APP.instance.sID("channel"),
+ APP.instance.sID("RGB"),
+ )
+ d1.putReference(APP.instance.sID("target"), r1)
+ d1.putBoolean(APP.instance.sID("makeVisible"), True)
+ APP.instance.executeAction(
+ APP.instance.sID("select"), d1, DialogModes.DisplayNoDialogs
+ )
+
+
+class MaskSelectionBehaviour(StrEnum):
+ REVEAL_ALL = "revealAll"
+ REVEAL_SELECTION = "revealSelection"
+ HIDE_SELECTION = "hideSelection"
+
+
+def create_mask(
+ layer: ArtLayer | LayerSet | None = None,
+ selection_behaviour: MaskSelectionBehaviour = MaskSelectionBehaviour.REVEAL_ALL,
+):
"""Add a mask to provided or active layer.
Args:
layer: Layer to make active, if provided.
"""
if layer:
- APP.activeDocument.activeLayer = layer
+ APP.instance.activeDocument.activeLayer = layer
d1 = ActionDescriptor()
r1 = ActionReference()
- d1.PutClass(sID("new"), sID("channel"))
- r1.PutEnumerated(sID("channel"), sID("channel"), sID("mask"))
- d1.PutReference(sID("at"), r1)
- d1.PutEnumerated(sID("using"), sID("userMaskEnabled"), sID("revealAll"))
- APP.Executeaction(sID("make"), d1, NO_DIALOG)
+ d1.putClass(APP.instance.sID("new"), APP.instance.sID("channel"))
+ r1.putEnumerated(
+ APP.instance.sID("channel"),
+ APP.instance.sID("channel"),
+ APP.instance.sID("mask"),
+ )
+ d1.putReference(APP.instance.sID("at"), r1)
+ d1.putEnumerated(
+ APP.instance.sID("using"),
+ APP.instance.sID("userMaskEnabled"),
+ APP.instance.sID(selection_behaviour),
+ )
+ APP.instance.executeAction(
+ APP.instance.sID("make"), d1, DialogModes.DisplayNoDialogs
+ )
def copy_to_mask(
- target: Union[ArtLayer, LayerSet],
- source: Union[ArtLayer, LayerSet, None] = None,
+ target: ArtLayer | LayerSet,
+ source: ArtLayer | LayerSet | None = None,
):
"""Copies the pixels of the current layer, creates a mask on target layer,
enters that layer's mask, and pastes to the mask before exiting the mask.
@@ -257,7 +323,7 @@ def copy_to_mask(
"""
# Select canvas and copy
- docref = APP.activeDocument
+ docref = APP.instance.activeDocument
if source:
docref.activeLayer = source
docsel = docref.selection
@@ -269,7 +335,11 @@ def copy_to_mask(
docref.activeLayer = target
create_mask()
enter_mask_channel()
- docref.paste()
+ try:
+ docref.paste()
+ except COMError:
+ # The operation likely succeeded, but an error was thrown anyways
+ pass
enter_rgb_channel()
@@ -278,16 +348,43 @@ def copy_to_mask(
"""
-def delete_mask(layer: Union[ArtLayer, LayerSet, None] = None) -> None:
+def delete_mask(layer: ArtLayer | LayerSet | None = None) -> None:
"""Removes a given layer's mask.
Args:
- layer: ArtLayer ore LayerSet object, use active layer if not provided.
+ layer: to delete mask from. Uses active layer if not provided.
"""
if layer:
- APP.activeDocument.activeLayer = layer
+ APP.instance.activeDocument.activeLayer = layer
desc1 = ActionDescriptor()
ref1 = ActionReference()
- ref1.putEnumerated(sID("channel"), sID("ordinal"), sID("targetEnum"))
- desc1.putReference(sID("target"), ref1)
- APP.executeAction(sID("delete"), desc1, NO_DIALOG)
+ ref1.putEnumerated(
+ APP.instance.sID("channel"),
+ APP.instance.sID("ordinal"),
+ APP.instance.sID("targetEnum"),
+ )
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ APP.instance.executeAction(
+ APP.instance.sID("delete"), desc1, DialogModes.DisplayNoDialogs
+ )
+
+
+def delete_mask_from_solid_color_layer(layer: ArtLayer | None = None) -> None:
+ """Removes the layer mask from a given solid color fill layer.
+
+ Args:
+ layer: to delete mask from. Uses active layer if not provided.
+ """
+ if layer:
+ APP.instance.activeDocument.activeLayer = layer
+ desc = ActionDescriptor()
+ ref = ActionReference()
+ ref.putEnumerated(
+ APP.instance.sID("channel"),
+ APP.instance.sID("channel"),
+ APP.instance.sID("mask"),
+ )
+ desc.putReference(APP.instance.sID("target"), ref)
+ APP.instance.executeAction(
+ APP.instance.sID("delete"), desc, DialogModes.DisplayNoDialogs
+ )
diff --git a/src/helpers/miscellaneous.py b/src/helpers/miscellaneous.py
new file mode 100644
index 00000000..fc8e77e7
--- /dev/null
+++ b/src/helpers/miscellaneous.py
@@ -0,0 +1,732 @@
+"""
+* Miscellaneous utils that are awaiting refinement/relocation.
+"""
+
+import csv
+import json
+import logging
+import warnings
+import xml.etree.ElementTree as ET
+from _ctypes import COMError
+from collections.abc import Iterable, Sequence
+from contextlib import suppress
+from os import PathLike
+from typing import Any
+from xml.dom import minidom
+
+from photoshop.api import ActionDescriptor, ActionReference
+from photoshop.api._artlayer import ArtLayer
+from photoshop.api._document import Document
+from photoshop.api._layerSet import LayerSet
+from photoshop.api.enumerations import DialogModes, ElementPlacement, LayerKind
+from psd_tools import PSDImage
+from psd_tools.constants import Resource
+from psd_tools.psd.image_resources import ImageResource
+
+import src.helpers as psd
+from src import APP
+from src._loader import AppTemplate
+from src.schema.colors import ColorObject
+
+# Reference Box colors
+ORANGE = [255, 172, 64]
+TANG = [255, 97, 11]
+RED = [192, 55, 38]
+TAN = [245, 235, 210]
+BLACK = [0, 0, 0]
+
+"""
+* Template Design Testing
+"""
+
+
+def test_new_color(
+ new: str, old: str | None = None, ignore: list[str] | None = None
+) -> None:
+ """Enables given color in all necessary groups. Optionally disable a color in those groups.
+
+ Args:
+ new: Color to enable.
+ old: Color to disable.
+ ignore: Groups to ignore.
+ """
+ if ignore is None:
+ ignore = ["Pinlines & Textbox"]
+ groups = [
+ "Name & Title Boxes",
+ "Legendary Crown",
+ "Pinlines & Textbox",
+ "Background",
+ "PT Box",
+ ]
+ for r in ignore:
+ groups.remove(r)
+ for g in groups:
+ # Enable new color
+ if new_layer := psd.getLayer(new, g):
+ new_layer.visible = True
+ # Disable old color
+ if old and (old_layer := psd.getLayer(old, g)):
+ old_layer.visible = False
+
+
+def make_duals(
+ name: str = "Pinlines & Textbox",
+ mask_top: ArtLayer | None = None,
+ mask_bottom: ArtLayer | None = None,
+):
+ """Creates dual color layers for a given group.
+
+ Args:
+ name: Name of the group.
+ mask_top: Mask to place on top color.
+ mask_bottom: Mask to place on bottom color.
+ """
+ duals = ["WU", "WB", "RW", "GW", "UB", "UR", "GU", "BR", "BG", "RG"]
+ group = psd.getLayerSet(name)
+ ref = psd.getLayer("W", group)
+
+ # Loop through each dual
+ for dual in duals:
+ if (top := psd.getLayer(dual[0], group)) and (
+ bottom := psd.getLayer(dual[1], group)
+ ):
+ top = top.duplicate(ref, ElementPlacement.PlaceBefore)
+ bottom = bottom.duplicate(top, ElementPlacement.PlaceAfter)
+
+ # Change layer visibility
+ top.visible = True
+ bottom.visible = True
+
+ # Enable masks
+ if mask_top:
+ psd.copy_layer_mask(mask_top, top)
+ if mask_bottom:
+ psd.copy_layer_mask(mask_bottom, bottom)
+
+ # Merge the layers and rename
+ new_layer = psd.merge_layers([top, bottom])
+ new_layer.name = dual
+
+
+def create_blended_layer(
+ colors: str | list[str],
+ group: LayerSet,
+ masks: Sequence[ArtLayer] | None = None,
+) -> None:
+ """Create a multicolor layer using a gradient mask.
+
+ Args:
+ colors: Colors to use.
+ group: Group to look for the color layers within.
+ masks: Layers containing a gradient mask.
+ """
+ layers: list[ArtLayer] = []
+
+ # Enable each layer color
+ for i, color in enumerate(colors):
+ if layer := psd.getLayer(color, group):
+ layer.visible = True
+
+ # Position the new layer and add a mask to previous, if previous layer exists
+ if layers:
+ prev_idx = i - 1
+ layer.move(layers[prev_idx], ElementPlacement.PlaceAfter)
+ if masks and prev_idx < len(masks):
+ psd.copy_layer_mask(masks[i - 1], layers[i - 1])
+
+ # Add to the layer list
+ layers.append(layer)
+
+
+"""
+* Action Descriptor Getters
+"""
+
+
+def check_if_needed(key: str, keys_stored: Iterable[str]):
+ """Check if double key has been locked, skip int keys if it is.
+
+ Args:
+ key: This key.
+ keys_stored: Keys already stored.
+
+ Returns:
+ True if needed, False if skipped.
+ """
+ if "double" in keys_stored:
+ if key in ["largeInt", "int"]:
+ return False
+ return True
+
+
+def try_all_getters(desc: ActionDescriptor, type_id: int) -> dict[str, Any]:
+ """Try all possible getter functions for this Descriptor and Type ID.
+
+ Args:
+ desc: Current descriptor object.
+ type_id: TypeID to send the getter for.
+
+ Returns:
+ All values returned from our getters.
+ """
+ values: dict[str, Any] = {}
+ getters = {
+ "bool": "getBoolean",
+ "class": "getClass",
+ "enumType": "getEnumerationType",
+ "enumVal": "getEnumerationValue",
+ "list": "getList",
+ "objType": "getObjectType",
+ "objValue": "getObjectValue",
+ "path": "getPath",
+ "ref": "getReference",
+ "str": "getString",
+ "type": "getType",
+ "double": "getUnitDoubleValue",
+ "int": "getInteger",
+ "largeInt": "getLargeInteger",
+ }
+ for k, func in getters.items():
+ if not check_if_needed(k, values.keys()):
+ # Skip this getter
+ continue
+ try:
+ # Send the getter
+ result = getattr(desc, func)(type_id)
+ if k == "list":
+ # Getter may have returned an ActionList, grab the first object
+ result = get_action_items(result.getObjectValue(0))
+ values[k] = result
+ except COMError, NameError, KeyError:
+ # Skip this getter
+ pass
+ return values
+
+
+def get_action_items(desc: ActionDescriptor) -> dict[str, Any]:
+ """Try to pull objects and getters from each action key in a descriptor.
+
+ Args:
+ desc: Current descriptor.
+
+ Returns:
+ Recursive dict of all objects and getters matched to each key.
+ """
+ items: dict[str, Any] = {}
+ try:
+ count = desc.count
+ except COMError:
+ count = 0
+ for i in range(count):
+ type_id: int = desc.getKey(i)
+ string_id: str = APP.instance.typeIDToStringID(type_id)
+ if desc.hasKey(type_id):
+ try:
+ result = get_action_items(desc.getObjectValue(type_id))
+ if result:
+ items[string_id] = result
+ except COMError:
+ # Try every getter till one works
+ try:
+ result = try_all_getters(desc, type_id)
+ items[string_id] = result
+ except COMError:
+ pass
+ return items
+
+
+def dump_layer_action_descriptors(layer: ArtLayer, path: str) -> dict[str, Any]:
+ """Combs through all available descriptor keys for a layer, dumps it to JSOn, and returns as a dict.
+
+ Args:
+ layer: Layer to scan.
+ path: Path to save the JSON dump.
+
+ Returns:
+ Dict containing descriptors by key and value, in a branching tree.
+ """
+ # Get the layer descriptor
+ reference = ActionReference()
+ reference.putIdentifier(APP.instance.sID("layer"), layer.id)
+ descriptor = APP.instance.executeActionGet(reference)
+
+ # Generate a dict of all descriptors
+ actions = get_action_items(descriptor)
+
+ # Dump the dict to a JSON file and return it
+ with open(path, "w", encoding="utf-8-sig") as f:
+ json.dump(actions, f)
+ return actions
+
+
+"""
+* Text Utilities
+"""
+
+
+def apply_single_line_composer(layer: ArtLayer) -> None:
+ """Set the text composer of the layer to 'Single Line Composer'.
+
+ Args:
+ layer: TextLayer to apply the composer to.
+ """
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ desc2 = ActionDescriptor()
+ ref1.putProperty(APP.instance.sID("property"), APP.instance.sID("paragraphStyle"))
+ ref1.putIdentifier(APP.instance.sID("textLayer"), layer.id)
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc2.putInteger(APP.instance.sID("textOverrideFeatureName"), 808464691)
+ desc2.putBoolean(APP.instance.sID("textEveryLineComposer"), False)
+ desc1.putObject(APP.instance.sID("to"), APP.instance.sID("paragraphStyle"), desc2)
+ APP.instance.executeAction(
+ APP.instance.sID("set"), desc1, DialogModes.DisplayNoDialogs
+ )
+
+
+def combine_text_items(from_layer: ArtLayer, to_layer: ArtLayer, sep: str = " "):
+ """Append the "from_layer" contents to the end of "to_layer" contents with optional separator.
+ Preserves the complete style range formatting of both text contents.
+
+ Args:
+ from_layer: TextLayer to pull contents from.
+ to_layer: TextLayer to append the contents of from_layer to.
+ sep: Optional separator to place between them.
+ """
+ # Grab the text key of each layer
+ from_tk = psd.get_text_key(from_layer)
+ to_tk = psd.get_text_key(to_layer)
+
+ # Grab the style range of each layer and establish our offset
+ from_range = from_tk.getList(APP.instance.sID("textStyleRange"))
+ to_range = to_tk.getList(APP.instance.sID("textStyleRange"))
+ offset = len(sep) if sep else 0
+
+ # For each item in the "from" style range, update the position and apply style to target
+ for i in range(from_range.count):
+ from_style = from_range.getObjectValue(i)
+ id_from = from_style.getInteger(APP.instance.sID("from"))
+ id_to = from_style.getInteger(APP.instance.sID("to"))
+ from_style.putInteger(
+ APP.instance.sID("from"),
+ id_from
+ + len(to_tk.getString(APP.instance.sID("textKey")))
+ + (offset if i != 0 else 0),
+ )
+ from_style.putInteger(
+ APP.instance.sID("to"),
+ id_to + len(to_tk.getString(APP.instance.sID("textKey"))) + offset,
+ )
+ to_range.putObject(APP.instance.sID("textStyleRange"), from_style)
+
+ # Combine the contents and apply the updated style range
+ contents = str(
+ to_tk.getString(APP.instance.sID("textKey"))
+ + sep
+ + from_tk.getString(APP.instance.sID("textKey"))
+ )
+ to_tk.putString(APP.instance.sID("textKey"), contents)
+ to_tk.putList(APP.instance.sID("textStyleRange"), to_range)
+
+ # Apply the updated text key to the target layer
+ psd.apply_text_key(to_layer, to_tk)
+
+
+def reset_transform_factor(layer: ArtLayer) -> None:
+ """Reset the transform xx and yy factors of a given layer.
+
+ Args:
+ layer: TextLayer to reset transform factors for.
+ """
+ key = psd.get_text_key(layer)
+ if key.hasKey(APP.instance.sID("transform")):
+ # Check the scale factor
+ desc = key.getObjectValue(APP.instance.sID("transform"))
+ xx = (
+ desc.getUnitDoubleValue(APP.instance.sID("xx"))
+ if desc.hasKey(APP.instance.sID("xx"))
+ else 1
+ )
+ yy = (
+ desc.getUnitDoubleValue(APP.instance.sID("yy"))
+ if desc.hasKey(APP.instance.sID("xx"))
+ else 1
+ )
+
+ # Fix the scale factor
+ if xx == 1 and yy == 1:
+ return
+ if xx != 1:
+ desc.putDouble(APP.instance.sID("xx"), 1)
+ if yy != 1:
+ desc.putDouble(APP.instance.sID("yy"), 1)
+
+ # Update the scale factor
+ key.putObject(
+ APP.instance.sID("transform"), APP.instance.sID("transform"), desc
+ )
+ psd.apply_text_key(layer, key)
+
+
+"""
+* XMP Utilities
+"""
+
+
+def xmp_remove_ancestors() -> None:
+ """Remove DocumentAncestors property from XMP data."""
+
+ # Check that a document is open
+ if not APP.instance.documents:
+ print("No documents open!")
+ return
+
+ # XMP data
+ APP.instance.eval_javascript("""
+ if (ExternalObject.AdobeXMPScript == undefined) {
+ ExternalObject.AdobeXMPScript = new ExternalObject("lib:AdobeXMPScript");
+ }
+ var xmp = new XMPMeta( activeDocument.xmpMetadata.rawData);
+ xmp.deleteProperty(XMPConst.NS_PHOTOSHOP, "DocumentAncestors");
+ APP.instance.activeDocument.xmpMetadata.rawData = xmp.serialize();
+ """)
+
+
+"""
+* Vector Shape Utilities
+"""
+
+
+def create_color_shape(layer: ArtLayer, color: ColorObject) -> ArtLayer:
+ layer_name = layer.name
+ color = psd.get_color(color)
+ docref = APP.instance.activeDocument
+ docsel = docref.selection
+ docref.activeLayer = layer
+ psd.select_layer_bounds(layer, docsel)
+
+ desc1 = ActionDescriptor()
+ ref1 = ActionReference()
+ ref2 = ActionReference()
+ ref1.putClass(APP.instance.sID("path"))
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ ref2.putProperty(APP.instance.sID("selectionClass"), APP.instance.sID("selection"))
+ desc1.putReference(APP.instance.sID("from"), ref2)
+ desc1.putUnitDouble(
+ APP.instance.sID("tolerance"), APP.instance.sID("pixelsUnit"), 2.000000
+ )
+ APP.instance.executeAction(
+ APP.instance.sID("make"), desc1, DialogModes.DisplayNoDialogs
+ )
+
+ ref1 = ActionReference()
+ desc1 = ActionDescriptor()
+ desc2 = ActionDescriptor()
+ desc3 = ActionDescriptor()
+ desc4 = ActionDescriptor()
+ ref1.putClass(APP.instance.sID("contentLayer"))
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc4.putDouble(APP.instance.sID("red"), color.rgb.red)
+ desc4.putDouble(APP.instance.sID("green"), color.rgb.green)
+ desc4.putDouble(APP.instance.sID("blue"), color.rgb.blue)
+ desc3.putObject(APP.instance.sID("color"), APP.instance.sID("RGBColor"), desc4)
+ desc2.putObject(
+ APP.instance.sID("type"), APP.instance.sID("solidColorLayer"), desc3
+ )
+ desc1.putObject(APP.instance.sID("using"), APP.instance.sID("contentLayer"), desc2)
+ APP.instance.executeAction(
+ APP.instance.sID("make"), desc1, DialogModes.DisplayNoDialogs
+ )
+ APP.instance.activeDocument.activeLayer.name = layer_name
+
+ # Check dims
+ dims = psd.get_layer_dimensions(layer)
+ dims = (dims["width"], dims["height"])
+ new_dims = psd.get_layer_dimensions(docref.activeLayer)
+ new_dims = (new_dims["width"], new_dims["height"])
+ if not dims == new_dims:
+ print("DIMS CHANGED:", layer_name)
+ print("Before:", dims)
+ print("After:", new_dims)
+
+ layer.remove()
+ docref.activeLayer.visible = False
+
+ created_layer = docref.activeLayer
+ if not isinstance(created_layer, ArtLayer):
+ raise ValueError("Created color shape layer isn't the active layer as expected")
+
+ return created_layer
+
+
+"""
+* Font Utilities
+"""
+
+
+def log_all_template_fonts(templates: Iterable[AppTemplate]) -> dict[str, list[str]]:
+ """Create a log of every font found for each PSD template."""
+
+ # Ignore warnings from the psd_tools module
+ logging.getLogger("psd_tools").setLevel(logging.FATAL)
+ warnings.filterwarnings("ignore", module="psd_tools")
+
+ def _get_fonts_from_psd(doc_path: str | PathLike[str]) -> set[str]:
+ """
+ Get a set of every font found in a given Photoshop document.
+ @param doc_path: Path to the Photoshop document.
+ @return: Set of font names found in the document.
+ """
+ file = PSDImage.open(doc_path) # pyright: ignore[reportUnknownMemberType]
+ fonts: set[str] = set()
+ for layer in [n for n in file.descendants() if n.kind == "type"]:
+ for style in layer.engine_dict["StyleRun"]["RunArray"]: # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType, reportAttributeAccessIssue]
+ font_key = style["StyleSheet"]["StyleSheetData"]["Font"] # pyright: ignore[reportUnknownVariableType]
+ fonts.add(layer.resource_dict["FontSet"][font_key]["Name"]) # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType, reportAttributeAccessIssue]
+ return fonts
+
+ # PSD documents to test
+ docs = {
+ t.path_psd: f"{t.name} ({t.plugin.name if t.plugin else 'BASE'})"
+ for t in templates
+ }
+
+ # Track progress
+ doc_fonts: dict[str, set[str]] = {}
+ master: dict[str, list[str]] = {}
+ current = 1
+ total = len(docs)
+
+ # Check each document
+ logging.basicConfig(level=logging.INFO)
+ for f, temp_name in docs.items():
+ # Alert the user
+ logging.info(f"READING FONTS β {temp_name} [{f.name}] [{current}/{total}]")
+ current += 1
+
+ # Open document, get fonts, close document
+ doc_fonts[temp_name] = _get_fonts_from_psd(f)
+
+ # Create master list
+ for doc, font_list in doc_fonts.items():
+ for f in font_list:
+ master.setdefault(str(f), []).append(doc)
+
+ # Log a sorted master list
+ master = {k: v for k, v in sorted(master.items(), key=lambda item: len(item[1]))}
+ with open("logs/FONTS.json", "w", encoding="utf-8") as f:
+ json.dump(master, f, indent=2)
+ return master
+
+
+"""
+* Project: Data Sets
+* Exploring the use of data set variables for potential performance gains.
+* Status: Likely not helpful
+"""
+
+
+def insert_data_set_variables(
+ xml_data: str, from_path: str, to_path: str | None = None
+) -> None:
+ """Inserts data set variable XML into a PSD document.
+
+ Args:
+ xml_data: Data to insert into the document.
+ from_path: Path of the PSD file.
+ to_path: Path of new PSD file created, overwrites from_path if not provided.
+ """
+ # Decide the to_path
+ to_path = to_path or from_path
+
+ # Ignore warnings from the psd_tools module
+ logging.getLogger("psd_tools").setLevel(logging.FATAL)
+ warnings.filterwarnings("ignore", module="psd_tools")
+
+ # Open the PSD file
+ f = PSDImage.open(from_path) # pyright: ignore[reportUnknownMemberType]
+
+ # Replace the image variables data
+ new_resource = ImageResource(
+ b"MeSa",
+ key=Resource.IMAGE_READY_VARIABLES,
+ name="",
+ data=xml_data.encode(encoding="UTF-8", errors="strict"),
+ )
+ f.image_resources[Resource.IMAGE_READY_VARIABLES] = new_resource
+
+ # Save the PSD file
+ f.save(to_path) # pyright: ignore[reportUnknownMemberType]
+
+
+def print_data_set_variables(path: str) -> None:
+ """Print data set variable XML data for a given document.
+
+ Args:
+ path: Path to a PSD document.
+ """
+ data = PSDImage.open(path).image_resources.get_data(Resource.IMAGE_READY_DATA_SETS) # pyright: ignore[reportUnknownMemberType]
+ pretty_xml = minidom.parseString(data).toprettyxml()
+
+ # Print without excess newlines
+ print("\n".join([line for line in pretty_xml.split("\n") if line.strip()]))
+
+
+def format_data_set_variable_name(text: str) -> str:
+ """Formats string as a data set variable name.
+
+ Args:
+ text: Text to format as a data set variable name.
+
+ Returns:
+ Properly formatted data set variable name.
+ """
+ return text.title().replace(" ", "").replace("-", "").replace("&", "")
+
+
+def get_data_set_variables(
+ group: LayerSet | Document | None = None, tree: str | None = None
+) -> list[dict[str, str]]:
+ """Get data set variables for all ArtLayer and LayerSet objects in document or LayerSet.
+
+ Args:
+ group: LayerSet or Document, use activeDocument if not provided.
+ tree: Tree to add to variable names, empty str if not provided.
+
+ Returns:
+ List of data set variable dicts.
+ """
+
+ # Establish current tree
+ tree = (
+ f"{tree}{format_data_set_variable_name(group.name)}."
+ if tree and group
+ else (f"{format_data_set_variable_name(group.name)}." if group else "")
+ )
+
+ # Establish group or top level document container
+ group = group or APP.instance.activeDocument
+ layer_vars: list[dict[str, str]] = []
+
+ # Add layer variables
+ for n in group.artLayers:
+ with suppress(Exception):
+ if n.kind == LayerKind.TextLayer:
+ layer_vars.append(
+ {
+ "varName": f"{tree}{format_data_set_variable_name(n.name)}.Text",
+ "trait": "textcontent",
+ "docRef": f"id('{n.id}')",
+ }
+ )
+ elif "Frame" in n.name:
+ layer_vars.append(
+ {
+ "varName": f"{tree}{format_data_set_variable_name(n.name)}.Image",
+ "trait": "fileref",
+ "placementMethod": "fill",
+ "align": "center",
+ "valign": "middle",
+ "clip": "false",
+ "docRef": f"id('{n.id}')",
+ }
+ )
+ layer_vars.append(
+ {
+ "varName": f"{tree}{format_data_set_variable_name(n.name)}.Visible",
+ "trait": "visibility",
+ "docRef": f"id('{n.id}')",
+ }
+ )
+
+ # Add layer group variables
+ for n in group.layerSets:
+ with suppress(Exception):
+ layer_vars.append(
+ {
+ "varName": f"{tree}{format_data_set_variable_name(n.name)}.Visible",
+ "trait": "visibility",
+ "docRef": f"id('{n.id}')",
+ }
+ )
+ layer_vars.extend(get_data_set_variables(n, tree))
+ return layer_vars
+
+
+def get_data_set_variable_xml():
+ """Generate data set variable XML for all layers in document."""
+
+ # Create the root element
+ root = ET.Element("variableSets", xmlns="https://ns.adobe.com/Variables/1.0/")
+
+ # Create variableSet -> variables
+ variableSet = ET.SubElement(root, "variableSet")
+ variableSet.set("locked", "none")
+ variableSet.set("varSetName", "binding1")
+ variables = ET.SubElement(variableSet, "variables")
+
+ # Add your variables, could do this programmatically for each layer
+ for var in get_data_set_variables():
+ variable = ET.SubElement(variables, "variable")
+ for k, v in var.items():
+ variable.set(k, v)
+
+ # Convert the XML to a string
+ return (
+ ET.tostring(root, encoding="utf8", method="xml", short_empty_elements=False)
+ .decode()
+ .replace("\n", "", 1)
+ .replace("><", ">\n<")
+ ) + "\n"
+
+
+def create_data_set_csv(data_set: dict[str, str], path: str) -> None:
+ """Create a data set CSV that can be imported for Photoshop's data set variable system.
+
+ Args:
+ data_set: A dictionary where variable names are the keys and variable values are the values.
+ path: Path to save the CSV to.
+ """
+ with open(path, "w", newline="") as file:
+ writer = csv.writer(file)
+ writer.writerow(data_set.keys())
+ writer.writerow(data_set.values())
+
+
+def import_data_set(path: str) -> None:
+ """Imports a data set into Photoshop's data set variable system.
+
+ Args:
+ path: Path to the data set CSV file where the first line are the variable names.
+ """
+ desc = ActionDescriptor()
+ ref = ActionReference()
+ ref.putClass(APP.instance.sID("dataSetClass"))
+ desc.putReference(APP.instance.sID("null"), ref)
+ desc.putPath(APP.instance.sID("using"), path)
+ desc.putEnumerated(
+ APP.instance.sID("encoding"),
+ APP.instance.sID("dataSetEncoding"),
+ APP.instance.sID("dataSetEncodingAuto"),
+ )
+ desc.putBoolean(APP.instance.sID("eraseAll"), True)
+ desc.putBoolean(APP.instance.sID("useFirstColumn"), True)
+ APP.instance.executeAction(
+ APP.instance.sID("importDataSets"), desc, DialogModes.DisplayNoDialogs
+ )
+
+
+def apply_data_set(data_set_name: str) -> None:
+ """Applies a data set from Photoshop's data set variable system.
+
+ Args:
+ data_set_name: Name of the data set.
+ """
+ desc = ActionDescriptor()
+ setRef = ActionReference()
+ setRef.putName(APP.instance.sID("dataSetClass"), data_set_name)
+ desc.putReference(APP.instance.sID("null"), setRef)
+ APP.instance.executeAction(
+ APP.instance.sID("apply"), desc, DialogModes.DisplayNoDialogs
+ )
diff --git a/src/helpers/position.py b/src/helpers/position.py
index 782843d1..5f5848e6 100644
--- a/src/helpers/position.py
+++ b/src/helpers/position.py
@@ -1,37 +1,34 @@
"""
* Helpers: Positioning
"""
-# Standard Library Imports
-import math
-from typing import Optional, Union
-# Third Party Imports
-from photoshop.api import DialogModes, AnchorPosition
+from collections.abc import Sequence
+from enum import Enum
+from typing import Literal
+
from photoshop.api._artlayer import ArtLayer
from photoshop.api._document import Document
-from photoshop.api._selection import Selection
from photoshop.api._layerSet import LayerSet
+from photoshop.api._selection import Selection
+from photoshop.api.enumerations import AnchorPosition
-# Local Imports
from src import APP
from src.enums.adobe import Dimensions
from src.helpers.bounds import (
- get_layer_dimensions,
- get_dimensions_from_bounds,
LayerDimensions,
+ get_card_dimensions,
+ get_dimensions_from_bounds,
+ get_layer_dimensions,
+ get_layer_height,
get_layer_width,
- get_layer_height)
+)
from src.helpers.selection import (
- select_overlapping,
check_selection_bounds,
- select_bounds)
-from src.helpers.text import get_font_size, set_text_size_and_leading
+ select_layer_pixels,
+ select_overlapping,
+)
from src.utils.adobe import ReferenceLayer
-# QOL Definitions
-sID, cID = APP.stringIDToTypeID, APP.charIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
-
# Positioning
positions_horizontal = [Dimensions.Left, Dimensions.Right, Dimensions.CenterX]
positions_vertical = [Dimensions.Top, Dimensions.Bottom, Dimensions.CenterY]
@@ -40,11 +37,15 @@
* Alignment Funcs
"""
+DimensionNames = Literal[
+ "width", "height", "center_x", "center_y", "left", "right", "top", "bottom"
+]
+
def align(
- axis: Union[Dimensions, list[Dimensions], None] = None,
- layer: Union[ArtLayer, LayerSet, None] = None,
- ref: Union[ArtLayer, LayerSet, ReferenceLayer, type[LayerDimensions], None] = None
+ axis: DimensionNames | Sequence[DimensionNames] | None = None,
+ layer: ArtLayer | LayerSet | None = None,
+ ref: ArtLayer | LayerSet | ReferenceLayer | LayerDimensions | None = None,
) -> None:
"""Align the currently active layer to current selection, vertically or horizontal.
@@ -54,16 +55,22 @@ def align(
ref: Reference to align the layer within. Uses current selection if not provided.
"""
# Default axis is both
- axis = axis or [Dimensions.CenterX, Dimensions.CenterY]
+ axis = axis or ("center_x", "center_y")
axis = [axis] if isinstance(axis, str) else axis
x, y = 0, 0
# Get the dimensions of layer and reference if not provided
- layer = layer or APP.activeDocument.activeLayer
- item: type[LayerDimensions] = get_layer_dimensions(layer)
- area: type[LayerDimensions] = ref if isinstance(ref, dict) else (
- get_dimensions_from_bounds(APP.activeDocument.selection.bounds)
- if not ref else get_layer_dimensions(ref))
+ layer = layer or APP.instance.activeDocument.activeLayer
+ item = get_layer_dimensions(layer)
+ area = (
+ ref
+ if isinstance(ref, dict)
+ else (
+ get_dimensions_from_bounds(APP.instance.activeDocument.selection.bounds)
+ if not ref
+ else get_layer_dimensions(ref)
+ )
+ )
# Single axis provided
for n in axis:
@@ -77,59 +84,59 @@ def align(
def align_all(
- layer: Union[ArtLayer, LayerSet, None] = None,
- ref: Union[ArtLayer, LayerSet, ReferenceLayer, type[LayerDimensions], None] = None
+ layer: ArtLayer | LayerSet | None = None,
+ ref: ArtLayer | LayerSet | ReferenceLayer | LayerDimensions | None = None,
) -> None:
"""Utility definition for passing CenterX and CenterY to align function."""
- align([Dimensions.CenterX, Dimensions.CenterY], layer, ref)
+ align(["center_x", "center_y"], layer, ref)
def align_vertical(
- layer: Union[ArtLayer, LayerSet, None] = None,
- ref: Union[ArtLayer, LayerSet, ReferenceLayer, type[LayerDimensions], None] = None
+ layer: ArtLayer | LayerSet | None = None,
+ ref: ArtLayer | LayerSet | ReferenceLayer | LayerDimensions | None = None,
) -> None:
"""Utility definition for passing CenterY to align function."""
- align(Dimensions.CenterY, layer, ref)
+ align("center_y", layer, ref)
def align_horizontal(
- layer: Union[ArtLayer, LayerSet, None] = None,
- ref: Union[ArtLayer, LayerSet, ReferenceLayer, type[LayerDimensions], None] = None
+ layer: ArtLayer | LayerSet | None = None,
+ ref: ArtLayer | LayerSet | ReferenceLayer | LayerDimensions | None = None,
) -> None:
"""Utility definition for passing CenterX to align function."""
- align(Dimensions.CenterX, layer, ref)
+ align("center_x", layer, ref)
def align_left(
- layer: Union[ArtLayer, LayerSet, None] = None,
- ref: Union[ArtLayer, LayerSet, ReferenceLayer, type[LayerDimensions], None] = None
+ layer: ArtLayer | LayerSet | None = None,
+ ref: ArtLayer | LayerSet | ReferenceLayer | LayerDimensions | None = None,
) -> None:
"""Utility definition for passing Left to align function."""
- align(Dimensions.Left, layer, ref)
+ align("left", layer, ref)
def align_right(
- layer: Union[ArtLayer, LayerSet, None] = None,
- ref: Union[ArtLayer, LayerSet, ReferenceLayer, type[LayerDimensions], None] = None
+ layer: ArtLayer | LayerSet | None = None,
+ ref: ArtLayer | LayerSet | ReferenceLayer | LayerDimensions | None = None,
) -> None:
"""Utility definition for passing Right to align function."""
- align(Dimensions.Right, layer, ref)
+ align("right", layer, ref)
def align_top(
- layer: Union[ArtLayer, LayerSet, None] = None,
- ref: Union[ArtLayer, LayerSet, ReferenceLayer, type[LayerDimensions], None] = None
+ layer: ArtLayer | LayerSet | None = None,
+ ref: ArtLayer | LayerSet | ReferenceLayer | LayerDimensions | None = None,
) -> None:
"""Utility definition for passing Top to align function."""
- align(Dimensions.Top, layer, ref)
+ align("top", layer, ref)
def align_bottom(
- layer: Union[ArtLayer, LayerSet, None] = None,
- ref: Union[ArtLayer, LayerSet, ReferenceLayer, type[LayerDimensions], None] = None
+ layer: ArtLayer | LayerSet | None = None,
+ ref: ArtLayer | LayerSet | ReferenceLayer | LayerDimensions | None = None,
) -> None:
"""Utility definition for passing Bottom to align function."""
- align(Dimensions.Bottom, layer, ref)
+ align("bottom", layer, ref)
"""
@@ -138,10 +145,10 @@ def align_bottom(
def position_between_layers(
- layer: Union[ArtLayer, LayerSet],
- top_layer: Union[ArtLayer, LayerSet],
- bottom_layer: Union[ArtLayer, LayerSet],
- docref: Optional[Document] = None
+ layer: ArtLayer | LayerSet,
+ top_layer: ArtLayer | LayerSet,
+ bottom_layer: ArtLayer | LayerSet,
+ docref: Document | None = None,
) -> None:
"""Align layer vertically between two reference layers.
@@ -151,15 +158,15 @@ def position_between_layers(
bottom_layer: Reference layer below the layer to be aligned.
docref: Document reference, use active if not provided.
"""
- docref = docref or APP.activeDocument
+ docref = docref or APP.instance.activeDocument
bounds = (0, top_layer.bounds[3], docref.width, bottom_layer.bounds[1])
align_vertical(layer, get_dimensions_from_bounds(bounds))
def position_dividers(
- dividers: list[Union[ArtLayer, LayerSet]],
- layers: list[Union[ArtLayer, LayerSet]],
- docref: Optional[Document] = None
+ dividers: Sequence[ArtLayer | LayerSet],
+ layers: Sequence[ArtLayer | LayerSet],
+ docref: Document | None = None,
) -> None:
"""Positions a list of dividers between a list of layers.
@@ -173,16 +180,17 @@ def position_dividers(
layer=dividers[i],
top_layer=layers[i],
bottom_layer=layers[i + 1],
- docref=docref)
+ docref=docref,
+ )
def spread_layers_over_reference(
layers: list[ArtLayer],
ref: ReferenceLayer,
- gap: Optional[Union[int, float]] = None,
- inside_gap: Union[int, float, None] = None,
- outside_matching: bool = True
-) -> None:
+ gap: float = 0,
+ inside_gap: float = 0,
+ outside_matching: bool = True,
+) -> float:
"""Spread layers apart across a reference layer.
Args:
@@ -191,15 +199,19 @@ def spread_layers_over_reference(
gap: Gap between the top of the reference and the first layer, or between all layers if not provided.
inside_gap: Gap between each layer, calculated using leftover space if not provided.
outside_matching: If enabled, will enforce top and bottom gap to match.
+
+ Returns:
+ Calculated or given inside gap
"""
# Get reference dimensions if not provided
- height = ref.dims['height']
+ height = ref.dims["height"]
# Calculate outside gap if not provided
outside_gap = gap
if not gap:
total_space = height - sum(
- [get_layer_dimensions(layer)['height'] for layer in layers])
+ [get_layer_dimensions(layer)["height"] for layer in layers]
+ )
outside_gap = total_space / (len(layers) + 1)
# Position the top layer relative to the reference
@@ -212,7 +224,8 @@ def spread_layers_over_reference(
ignored = 2 if outside_matching else 1
spaces = len(layers) - 1 if outside_matching else len(layers)
total_space = height - sum(
- [get_layer_dimensions(layer)['height'] for layer in layers])
+ [get_layer_dimensions(layer)["height"] for layer in layers]
+ )
inside_gap = (total_space - (ignored * gap)) / spaces
elif not gap:
# Use the outside gap uniformly
@@ -221,8 +234,10 @@ def spread_layers_over_reference(
# Position the bottom layers relative to the top
space_layers_apart(layers, inside_gap)
+ return inside_gap
+
-def space_layers_apart(layers: list[Union[ArtLayer, LayerSet]], gap: Union[int, float]) -> None:
+def space_layers_apart(layers: Sequence[ArtLayer | LayerSet], gap: int | float) -> None:
"""Position list of layers apart using a given gap.
Args:
@@ -230,7 +245,7 @@ def space_layers_apart(layers: list[Union[ArtLayer, LayerSet]], gap: Union[int,
gap: Gap in pixels.
"""
# Position each layer relative to the one above it
- for i in range((len(layers) - 1)):
+ for i in range(len(layers) - 1):
delta = (layers[i].bounds[3] + gap) - layers[i + 1].bounds[1]
layers[i + 1].translate(0, delta)
@@ -240,13 +255,50 @@ def space_layers_apart(layers: list[Union[ArtLayer, LayerSet]], gap: Union[int,
"""
+def frame_panorama(
+ layer: ArtLayer | LayerSet,
+ document: Document,
+ panorama_position: tuple[int, int],
+ panorama_size: tuple[int, int],
+ anchor: AnchorPosition = AnchorPosition.TopLeft,
+) -> None:
+ """
+ Scale and position a layer within the bounds of a reference layer to make a borderless panorama.
+ @param layer: Layer to scale and position.
+ @param reference: Reference frame to position within.
+ @param anchor: Anchor position for scaling the layer.
+ """
+ # Get layer and full reference dimensions
+ art_dim: LayerDimensions = get_layer_dimensions(layer)
+ ref_dim = get_card_dimensions(document)
+ panorama_dim = (
+ ref_dim["width"] * panorama_size[0],
+ ref_dim["height"] * panorama_size[1],
+ )
+
+ # Scale the layer to fit either the largest dimension
+ scale = 100 * max(
+ (panorama_dim[0] / art_dim["width"]), (panorama_dim[1] / art_dim["height"])
+ )
+ layer.resize(scale, scale, anchor)
+
+ # Align the original layer on the top-left
+ alignments = ("left", "top")
+ align(alignments, layer, ref_dim)
+
+ # Move the layer according to the given index
+ pano_x = -ref_dim["width"] * panorama_position[0]
+ pano_y = -ref_dim["height"] * panorama_position[1]
+ layer.translate(pano_x, pano_y)
+
+
def frame_layer(
- layer: Union[ArtLayer, LayerSet],
- ref: Union[ArtLayer, LayerSet, type[LayerDimensions]],
+ layer: ArtLayer | LayerSet,
+ ref: ArtLayer | LayerSet | LayerDimensions,
smallest: bool = False,
anchor: AnchorPosition = AnchorPosition.MiddleCenter,
- alignments: Union[Dimensions, list[Dimensions], None] = None,
- scale: int = 100,
+ alignments: DimensionNames | Sequence[DimensionNames] | None = None,
+ scale: float = 100,
) -> None:
"""Scale and position a layer within the bounds of a reference.
@@ -265,20 +317,21 @@ def frame_layer(
# Scale the layer to fit either the largest, or the smallest dimension
action = min if smallest else max
scale = scale * action(
- (ref_dim['width'] / layer_dim['width']),
- (ref_dim['height'] / layer_dim['height']))
+ (ref_dim["width"] / layer_dim["width"]),
+ (ref_dim["height"] / layer_dim["height"]),
+ )
layer.resize(scale, scale, anchor)
# Default alignments are center horizontal and vertical
- align(alignments or [Dimensions.CenterX, Dimensions.CenterY], layer, ref_dim)
+ align(alignments or ("center_x", "center_y"), layer, ref_dim)
def frame_layer_by_height(
- layer: Union[ArtLayer, LayerSet],
- ref: Union[ArtLayer, LayerSet, type[LayerDimensions]],
+ layer: ArtLayer | LayerSet,
+ ref: ArtLayer | LayerSet | LayerDimensions,
anchor: AnchorPosition = AnchorPosition.MiddleCenter,
- alignments: Union[Dimensions, list[Dimensions], None] = None,
- scale: int = 100,
+ alignments: DimensionNames | Sequence[DimensionNames] | None = None,
+ scale: float = 100,
) -> None:
"""Scale and position a layer based on the height of a reference layer.
@@ -293,19 +346,19 @@ def frame_layer_by_height(
ref_dim = ref if isinstance(ref, dict) else get_layer_dimensions(ref)
# Scale the layer to fit the height of the reference
- scale = scale * (ref_dim['height'] / get_layer_height(layer))
+ scale = scale * (ref_dim["height"] / get_layer_height(layer))
layer.resize(scale, scale, anchor)
# Default alignments are center horizontal and vertical
- align(alignments or [Dimensions.CenterX, Dimensions.CenterY], layer, ref_dim)
+ align(alignments or ("center_x", "center_y"), layer, ref_dim)
def frame_layer_by_width(
- layer: Union[ArtLayer, LayerSet],
- ref: Union[ArtLayer, LayerSet, type[LayerDimensions]],
+ layer: ArtLayer | LayerSet,
+ ref: ArtLayer | LayerSet | LayerDimensions,
anchor: AnchorPosition = AnchorPosition.MiddleCenter,
- alignments: Union[Dimensions, list[Dimensions], None] = None,
- scale: int = 100,
+ alignments: DimensionNames | Sequence[DimensionNames] | None = None,
+ scale: float = 100,
) -> None:
"""Scale and position a layer based on the width of a reference layer.
@@ -320,11 +373,11 @@ def frame_layer_by_width(
ref_dim = ref if isinstance(ref, dict) else get_layer_dimensions(ref)
# Scale the layer to fit the height of the reference
- scale = scale * (ref_dim['width'] / get_layer_width(layer))
+ scale = scale * (ref_dim["width"] / get_layer_width(layer))
layer.resize(scale, scale, anchor)
# Default alignments are center horizontal and vertical
- align(alignments or [Dimensions.CenterX, Dimensions.CenterY], layer, ref_dim)
+ align(alignments or ("center_x", "center_y"), layer, ref_dim)
"""
@@ -332,11 +385,34 @@ def frame_layer_by_width(
"""
+class RefSide(Enum):
+ LEFT = 1
+ TOP = 2
+ RIGHT = 3
+ BOTTOM = 4
+
+
+def check_bounds_overlap(
+ bounds: tuple[float, float, float, float],
+ ref_bounds: tuple[float, float, float, float],
+ ref_side: RefSide,
+) -> bool:
+ if ref_side == RefSide.LEFT:
+ return bounds[2] > ref_bounds[0]
+ elif ref_side == RefSide.TOP:
+ return bounds[3] > ref_bounds[1]
+ elif ref_side == RefSide.RIGHT:
+ return bounds[0] < ref_bounds[2]
+ else:
+ return bounds[1] < ref_bounds[3]
+
+
def check_reference_overlap(
- layer: Optional[ArtLayer],
- ref_bounds: tuple[int, int, int, int],
- docsel: Optional[Selection] = None
-):
+ layer: ArtLayer,
+ ref: ArtLayer,
+ ref_side: RefSide = RefSide.TOP,
+ docsel: Selection | None = None,
+) -> float:
"""Checks if a layer is overlapping with given set of bounds.
Args:
@@ -345,21 +421,28 @@ def check_reference_overlap(
docsel: Selection object, pull from document if not provided.
Returns:
- Bounds if overlap exists, otherwise None.
+ Amount of overlap between `ref_side` and the opposing side of `layer`.
"""
- select_bounds(ref_bounds, selection=docsel)
+ selection = docsel or APP.instance.activeDocument.selection
+ select_layer_pixels(ref)
select_overlapping(layer)
- if bounds := check_selection_bounds(docsel):
- docsel.deselect()
- return ref_bounds[1] - bounds[3]
+ if bounds := check_selection_bounds(selection):
+ selection.deselect()
+ ref_bounds = ref.bounds
+ if ref_side == RefSide.LEFT:
+ return ref_bounds[0] - bounds[2]
+ if ref_side == RefSide.TOP:
+ return ref_bounds[1] - bounds[3]
+ if ref_side == RefSide.RIGHT:
+ return ref_bounds[2] - bounds[0]
+ else:
+ return ref_bounds[3] - bounds[1]
return 0
def clear_reference_vertical(
- layer: ArtLayer,
- ref: ReferenceLayer,
- docsel: Optional[Selection] = None
-) -> Union[int, float]:
+ layer: ArtLayer, ref: ReferenceLayer, docsel: Selection | None = None
+) -> int | float:
"""Nudges a layer clear vertically of a given reference layer or area.
Args:
@@ -371,105 +454,11 @@ def clear_reference_vertical(
The number of pixels layer was translated by (negative or positive indicating direction).
"""
# Use active layer if not provided
- docsel = docsel or APP.activeDocument.selection
- delta = check_reference_overlap(layer=layer, ref_bounds=ref.bounds, docsel=docsel)
+ docsel = docsel or APP.instance.activeDocument.selection
+ delta = check_reference_overlap(layer=layer, ref=ref, docsel=docsel)
# Check if selection is empty, if not translate our layer to clear the reference
if delta < 0:
layer.translate(0, delta)
return delta
return 0
-
-
-def clear_reference_vertical_multi(
- text_layers: list[ArtLayer],
- ref: ReferenceLayer,
- loyalty_ref: ReferenceLayer,
- space: Union[int, float],
- uniform_gap: bool = False,
- font_size: Optional[float] = None,
- step: float = 0.2,
- docref: Optional[Document] = None,
- docsel: Optional[Selection] = None
-) -> None:
- """Shift or resize multiple text layers to prevent vertical collision with a reference area.
-
- Note:
- Used on Planeswalker cards to allow multiple text abilities to clear the loyalty box.
-
- Args:
- text_layers: Ability text layers to nudge or resize.
- ref: Reference area ability text layers must fit inside.
- loyalty_ref: Reference area that covers the loyalty box.
- space: Minimum space between planeswalker abilities.
- uniform_gap: Whether the gap between abilities should be the same between each ability.
- font_size: The current font size of the text layers, if known. Otherwise, calculate automatically.
- step: The amount of font size and leading to step down each iteration.
- docref: Reference document, use active if not provided (improves performance).
- docsel: Selection object, pull from document if not provided (improves performance).
- """
- # Return if adjustments weren't provided
- if not loyalty_ref:
- return
-
- # Establish fresh data
- if font_size is None:
- font_size = get_font_size(text_layers[0])
- layers = text_layers.copy()
- movable = len(layers)-1
-
- # Calculate inside gap
- total_space = ref.dims['height'] - sum([get_layer_height(layer) for layer in text_layers])
- if not uniform_gap:
- inside_gap = ((total_space - space) - (ref.bounds[3] - layers[-1].bounds[1])) / movable
- else:
- inside_gap = total_space / (len(layers) + 1)
- leftover = (inside_gap - space) * movable
-
- # Does the bottom layer overlap with the loyalty box?
- delta = check_reference_overlap(
- layer=layers[-1],
- ref_bounds=loyalty_ref.bounds,
- docsel=docsel)
- if delta >= 0:
- return
-
- # Calculate the total distance needing to be covered
- total_move = 0
- layers.pop(0)
- for n, lyr in enumerate(layers):
- total_move += math.fabs(delta) * ((len(layers) - n)/len(layers))
-
- # Text layers can just be shifted upwards
- if total_move < leftover:
- layers.reverse()
- for n, lyr in enumerate(layers):
- move_y = delta * ((len(layers) - n)/len(layers))
- lyr.translate(0, move_y)
- return
-
- # Layer gap would be too small, need to resize text then shift upward
- font_size -= step
- for lyr in text_layers:
- set_text_size_and_leading(
- layer=lyr,
- size=font_size,
- leading=font_size)
-
- # Space apart planeswalker text evenly
- spread_layers_over_reference(
- layers=text_layers,
- ref=ref,
- gap=space if not uniform_gap else None,
- outside_matching=False)
-
- # Check for another iteration
- clear_reference_vertical_multi(
- text_layers=text_layers,
- ref=ref,
- loyalty_ref=loyalty_ref,
- space=space,
- uniform_gap=uniform_gap,
- font_size=font_size,
- docref=docref,
- docsel=docsel)
diff --git a/src/helpers/selection.py b/src/helpers/selection.py
index 92410abc..e99ec407 100644
--- a/src/helpers/selection.py
+++ b/src/helpers/selection.py
@@ -1,36 +1,26 @@
"""
* Helpers: Selection
"""
-# Standard Library Imports
+
from contextlib import suppress
-from typing import Optional
-# Third Party Imports
+from photoshop.api import ActionDescriptor, ActionReference
from photoshop.api._artlayer import ArtLayer
from photoshop.api._document import Document
+from photoshop.api._layerSet import LayerSet
from photoshop.api._selection import Selection
-from photoshop.api import (
- ActionDescriptor,
- ActionReference,
- DialogModes,
- LayerKind)
+from photoshop.api.enumerations import DialogModes, LayerKind
-# Local Imports
from src import APP
from src.utils.adobe import PS_EXCEPTIONS
-# Photoshop infrastructure
-cID, sID = APP.charIDtoTypeID, APP.stringIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
-
"""
* Making Selections
"""
def select_bounds(
- bounds: tuple[int, int, int, int],
- selection: Optional[Selection] = None
+ bounds: tuple[float, float, float, float], selection: Selection | None = None
) -> None:
"""Create a selection using a list of bound values.
@@ -38,16 +28,14 @@ def select_bounds(
bounds: List of bound values (left, top, right, bottom).
selection: App selection object, pull from active document if not provided.
"""
- selection = selection or APP.activeDocument.selection
+ selection = selection or APP.instance.activeDocument.selection
left, top, right, bottom = bounds
- selection.select([
- [left, top],
- [right, top],
- [right, bottom],
- [left, bottom]])
+ selection.select(((left, top), (right, top), (right, bottom), (left, bottom)))
-def select_layer_bounds(layer: ArtLayer = None, selection: Optional[Selection] = None) -> None:
+def select_layer_bounds(
+ layer: ArtLayer | LayerSet | None = None, selection: Selection | None = None
+) -> None:
"""Select the bounding box of a given layer.
Args:
@@ -55,7 +43,7 @@ def select_layer_bounds(layer: ArtLayer = None, selection: Optional[Selection] =
selection: App selection object, pull from active document if not provided.
"""
if not layer:
- layer = APP.activeDocument.activeLayer
+ layer = APP.instance.activeDocument.activeLayer
select_bounds(layer.bounds, selection)
@@ -65,31 +53,37 @@ def select_overlapping(layer: ArtLayer) -> None:
Args:
layer: Layer with pixels to select.
"""
- with suppress(PS_EXCEPTIONS):
- idChannel = sID('channel')
+ with suppress(*PS_EXCEPTIONS):
+ idChannel = APP.instance.sID("channel")
desc1, ref1, ref2 = ActionDescriptor(), ActionReference(), ActionReference()
- ref1.putEnumerated(idChannel, idChannel, sID("transparencyEnum"))
- ref1.putIdentifier(sID("layer"), layer.id)
- desc1.putReference(sID("target"), ref1)
- ref2.putProperty(idChannel, sID("selection"))
- desc1.putReference(sID("with"), ref2)
- APP.executeAction(sID("interfaceIconFrameDimmed"), desc1, NO_DIALOG)
-
-
-def select_canvas(docref: Optional[Document] = None, bleed: int = 0):
+ ref1.putEnumerated(idChannel, idChannel, APP.instance.sID("transparencyEnum"))
+ ref1.putIdentifier(APP.instance.sID("layer"), layer.id)
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ ref2.putProperty(idChannel, APP.instance.sID("selection"))
+ desc1.putReference(APP.instance.sID("with"), ref2)
+ APP.instance.executeAction(
+ APP.instance.sID("interfaceIconFrameDimmed"),
+ desc1,
+ DialogModes.DisplayNoDialogs,
+ )
+
+
+def select_canvas(docref: Document | None = None, bleed: int = 0):
"""Select the entire canvas of a provided or active document.
Args:
docref: Document reference, use active if not provided.
bleed: Amount of bleed edge to leave around selection, defaults to 0.
"""
- docref = docref or APP.activeDocument
- docref.selection.select([
- [0 + bleed, 0 + bleed],
- [docref.width - bleed, 0 + bleed],
- [docref.width - bleed, docref.height - bleed],
- [0 + bleed, docref.height - bleed]
- ])
+ docref = docref or APP.instance.activeDocument
+ docref.selection.select(
+ (
+ (0 + bleed, 0 + bleed),
+ (docref.width - bleed, 0 + bleed),
+ (docref.width - bleed, docref.height - bleed),
+ (0 + bleed, docref.height - bleed),
+ )
+ )
"""
@@ -97,27 +91,40 @@ def select_canvas(docref: Optional[Document] = None, bleed: int = 0):
"""
-def select_layer_pixels(layer: Optional[ArtLayer] = None) -> None:
+def select_layer_pixels(
+ layer: ArtLayer | None = None, add_to_selection: bool = False
+) -> Selection:
"""Select pixels of the active layer, or a target layer.
Args:
layer: Layer to select. Uses active layer if not provided.
"""
if layer and layer.kind == LayerKind.SolidFillLayer:
- return select_vector_layer_pixels(layer)
+ return select_vector_layer_pixels(layer, add_to_selection=add_to_selection)
des1 = ActionDescriptor()
ref1 = ActionReference()
ref2 = ActionReference()
- ref1.putProperty(sID("channel"), sID("selection"))
- des1.putReference(sID("target"), ref1)
- ref2.putEnumerated(sID("channel"), sID("channel"), sID("transparencyEnum"))
+ ref1.putProperty(APP.instance.sID("channel"), APP.instance.sID("selection"))
+ des1.putReference(APP.instance.sID("target"), ref1)
+ ref2.putEnumerated(
+ APP.instance.sID("channel"),
+ APP.instance.sID("channel"),
+ APP.instance.sID("transparencyEnum"),
+ )
if layer:
- ref2.putIdentifier(sID("layer"), layer.id)
- des1.putReference(sID("to"), ref2)
- APP.executeAction(sID("set"), des1, NO_DIALOG)
-
-
-def select_vector_layer_pixels(layer: Optional[ArtLayer] = None) -> None:
+ ref2.putIdentifier(APP.instance.sID("layer"), layer.id)
+ des1.putReference(APP.instance.sID("to"), ref2)
+ APP.instance.executeAction(
+ APP.instance.sID("add" if add_to_selection else "set"),
+ des1,
+ DialogModes.DisplayNoDialogs,
+ )
+ return APP.instance.activeDocument.selection
+
+
+def select_vector_layer_pixels(
+ layer: ArtLayer | None = None, add_to_selection: bool = False
+) -> Selection:
"""Select pixels of the active vector layer, or a target layer.
Args:
@@ -126,15 +133,23 @@ def select_vector_layer_pixels(layer: Optional[ArtLayer] = None) -> None:
desc1 = ActionDescriptor()
ref1 = ActionReference()
ref2 = ActionReference()
- ref1.putProperty(sID("channel"), sID("selection"))
- desc1.putReference(sID("target"), ref1)
- ref2.putEnumerated(sID("path"), sID("path"), sID("vectorMask"))
+ ref1.putProperty(APP.instance.sID("channel"), APP.instance.sID("selection"))
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ ref2.putEnumerated(
+ APP.instance.sID("path"),
+ APP.instance.sID("path"),
+ APP.instance.sID("vectorMask"),
+ )
if layer:
- ref2.putIdentifier(sID("layer"), layer.id)
- desc1.putReference(sID("to"), ref2)
- desc1.putInteger(sID("version"), 1)
- desc1.putBoolean(sID("vectorMaskParams"), True)
- APP.executeAction(sID("set"), desc1, NO_DIALOG)
+ ref2.putIdentifier(APP.instance.sID("layer"), layer.id)
+ desc1.putReference(APP.instance.sID("to"), ref2)
+ desc1.putBoolean(APP.instance.sID("vectorMaskParams"), True)
+ APP.instance.executeAction(
+ APP.instance.sID("addTo" if add_to_selection else "set"),
+ desc1,
+ DialogModes.DisplayNoDialogs,
+ )
+ return APP.instance.activeDocument.selection
"""
@@ -142,7 +157,9 @@ def select_vector_layer_pixels(layer: Optional[ArtLayer] = None) -> None:
"""
-def check_selection_bounds(selection: Optional[Selection] = None) -> Optional[tuple[int, int, int, int]]:
+def check_selection_bounds(
+ selection: Selection | None = None,
+) -> tuple[float, float, float, float] | None:
"""Verifies if a selection has valid bounds.
Args:
@@ -151,8 +168,8 @@ def check_selection_bounds(selection: Optional[Selection] = None) -> Optional[tu
Returns:
An empty list if selection is invalid, otherwise return bounds of selection.
"""
- selection = selection or APP.activeDocument.selection
- with suppress(PS_EXCEPTIONS):
+ selection = selection or APP.instance.activeDocument.selection
+ with suppress(*PS_EXCEPTIONS):
if selection.bounds != (0, 0, 0, 0):
return selection.bounds
return
diff --git a/src/helpers/shapes.py b/src/helpers/shapes.py
new file mode 100644
index 00000000..63132d54
--- /dev/null
+++ b/src/helpers/shapes.py
@@ -0,0 +1,59 @@
+from collections.abc import Iterable
+
+from photoshop.api import ActionDescriptor, ActionReference
+from photoshop.api._artlayer import ArtLayer
+from photoshop.api._layerSet import LayerSet
+from photoshop.api.enumerations import DialogModes, ElementPlacement
+
+from src import APP
+from src.helpers.colors import get_color, rgb_black
+from src.schema.colors import ColorObject
+from src.utils.uxp.path import PathPointConf, create_path
+
+
+def create_shape_layer(
+ points: Iterable[PathPointConf],
+ name: str = "",
+ relative_layer: ArtLayer | LayerSet | None = None,
+ placement: ElementPlacement = ElementPlacement.PlaceAfter,
+ hide: bool = False,
+ color: ColorObject | None = None,
+) -> ArtLayer:
+ solid_color = get_color(color) if color else rgb_black()
+ docref = APP.instance.activeDocument
+
+ create_path(points)
+
+ # Convert path to a layer
+ ref1 = ActionReference()
+ desc1 = ActionDescriptor()
+ desc2 = ActionDescriptor()
+ desc3 = ActionDescriptor()
+ desc4 = ActionDescriptor()
+ ref1.putClass(APP.instance.sID("contentLayer"))
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc4.putDouble(APP.instance.sID("red"), solid_color.rgb.red)
+ desc4.putDouble(APP.instance.sID("green"), solid_color.rgb.green)
+ desc4.putDouble(APP.instance.sID("blue"), solid_color.rgb.blue)
+ desc3.putObject(APP.instance.sID("color"), APP.instance.sID("RGBColor"), desc4)
+ desc2.putObject(
+ APP.instance.sID("type"), APP.instance.sID("solidColorLayer"), desc3
+ )
+ desc1.putObject(APP.instance.sID("using"), APP.instance.sID("contentLayer"), desc2)
+ APP.instance.executeAction(
+ APP.instance.sID("make"), desc1, DialogModes.DisplayNoDialogs
+ )
+
+ layer = docref.activeLayer
+ if not isinstance(layer, ArtLayer):
+ raise ValueError(
+ "Failed to create shape layer. Active layer is unexpectedly not an ArtLayer."
+ )
+ if name:
+ layer.name = name
+ if hide:
+ layer.visible = False
+ if relative_layer:
+ layer.move(relative_layer, placement)
+
+ return layer
diff --git a/src/helpers/text.py b/src/helpers/text.py
index ea95e890..065af6ed 100644
--- a/src/helpers/text.py
+++ b/src/helpers/text.py
@@ -1,62 +1,79 @@
"""
* Helpers: Text Items
"""
-# Standard Library Imports
-from typing import Union, Optional, Any
-
-# Third Party Imports
-from photoshop.api import (
- DialogModes,
- ActionDescriptor,
- ActionReference,
- ActionList,
- LayerKind)
+
+from collections.abc import Iterable, Sequence
+from logging import getLogger
+from math import fabs
+from typing import Literal, overload
+
+from photoshop.api import ActionDescriptor, ActionList, ActionReference, SolidColor
from photoshop.api._artlayer import ArtLayer
from photoshop.api._document import Document
from photoshop.api._layerSet import LayerSet
+from photoshop.api._selection import Selection
+from photoshop.api.enumerations import DialogModes, ElementPlacement, LayerKind
from photoshop.api.text_item import TextItem
-# Local Imports
from src import APP
from src.helpers.bounds import (
get_layer_height,
get_layer_width,
get_textbox_width,
- get_width_no_effects)
+ get_width_no_effects,
+)
+from src.helpers.descriptors import (
+ set_or_copy_color,
+ set_or_copy_enumerated,
+ set_or_copy_integer,
+ set_or_copy_string,
+ set_or_copy_unit_double,
+)
from src.helpers.document import pixels_to_points
-from src.utils.adobe import PS_EXCEPTIONS
-
-# QOL Definitions
-sID, cID = APP.stringIDToTypeID, APP.charIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
+from src.helpers.position import (
+ RefSide,
+ check_bounds_overlap,
+ check_reference_overlap,
+ spread_layers_over_reference,
+)
+from src.helpers.shapes import create_shape_layer
+from src.utils.adobe import PS_EXCEPTIONS, ReferenceLayer
+from src.utils.uxp.shape import ShapeOperation, merge_shapes
+from src.utils.uxp.text import create_text_layer_with_path
+
+_logger = getLogger(__name__)
"""
* Text Utils
"""
-def get_font_size(layer: ArtLayer) -> float:
+def get_font_size(layer: ArtLayer, raw_size: float | None = None) -> float:
"""Get scale factor adjusted font size of a given text layer.
Args:
layer: Text layer to get size of.
"""
- return round(layer.textItem.size * get_text_scale_factor(layer), 2)
+ return round(
+ (layer.textItem.size if raw_size is None else raw_size)
+ * get_text_scale_factor(layer),
+ 2,
+ )
-def get_text_key(layer: ArtLayer) -> Any:
+def get_text_key(layer: ArtLayer) -> ActionDescriptor:
"""Get the textKey action reference from a TextLayer.
Args:
layer: ArtLayer which must be a TextLayer kind.
"""
reference = ActionReference()
- reference.putIdentifier(sID('layer'), layer.id)
- descriptor = APP.executeActionGet(reference)
- return descriptor.getObjectValue(sID('textKey'))
+ reference.putIdentifier(APP.instance.sID("layer"), layer.id)
+ descriptor = APP.instance.executeActionGet(reference)
+ return descriptor.getObjectValue(APP.instance.sID("textKey"))
-def apply_text_key(text_layer, text_key) -> None:
+def apply_text_key(text_layer: ArtLayer, text_key: ActionDescriptor) -> None:
"""Applies a TextKey action descriptor to a given TextLayer.
Args:
@@ -64,13 +81,15 @@ def apply_text_key(text_layer, text_key) -> None:
text_key: TextKey extracted from a TextLayer that has been modified.
"""
action, ref = ActionDescriptor(), ActionReference()
- ref.putIdentifier(sID("layer"), text_layer.id)
- action.putReference(sID("target"), ref)
- action.putObject(sID("to"), sID("textLayer"), text_key)
- APP.executeAction(sID("set"), action, NO_DIALOG)
+ ref.putIdentifier(APP.instance.sID("layer"), text_layer.id)
+ action.putReference(APP.instance.sID("target"), ref)
+ action.putObject(APP.instance.sID("to"), APP.instance.sID("textLayer"), text_key)
+ APP.instance.executeAction(
+ APP.instance.sID("set"), action, DialogModes.DisplayNoDialogs
+ )
-def get_line_count(layer: Optional[ArtLayer] = None, docref: Optional[Document] = None) -> int:
+def get_line_count(layer: ArtLayer, docref: Document | None = None) -> int:
"""Get the number of lines in a paragraph text layer.
Args:
@@ -80,11 +99,11 @@ def get_line_count(layer: Optional[ArtLayer] = None, docref: Optional[Document]
Returns:
Number of lines in the TextItem.
"""
- docref = docref or APP.activeDocument
- return round(pixels_to_points(
- number=get_layer_height(layer),
- docref=docref
- ) / layer.textItem.leading)
+ docref = docref or APP.instance.activeDocument
+ return round(
+ pixels_to_points(number=get_layer_height(layer), docref=docref)
+ / layer.textItem.leading
+ )
"""
@@ -101,59 +120,57 @@ def replace_text(layer: ArtLayer, find: str, replace: str) -> None:
replace: Text to replace the found text with.
"""
# Establish our text key and reference text
- idTextKey = sID("textKey")
+ idTextKey = APP.instance.sID("textKey")
text_key: ActionDescriptor = get_text_key(layer)
current_text = text_key.getString(idTextKey)
# Check if our target text exists
if find not in current_text:
- print(f"Text replacement couldn't find the text '{find}' "
- f"in layer with name '{layer.name}'!")
+ _logger.warning(f"Couldn't find the text '{find}' in layer: '{layer.name}'")
return
# Reusable ID's
- idTo = sID('to')
- idFrom = sID('from')
- idTextStyleRange = sID('textStyleRange')
+ idTo = APP.instance.sID("to")
+ idFrom = APP.instance.sID("from")
+ idTextStyleRange = APP.instance.sID("textStyleRange")
# Track length difference and whether replacement was made
offset = len(replace) - len(find)
replaced = False
# Find the range where target text exists
- style_range = text_key.getList(idTextStyleRange)
- for i in range(style_range.count):
- style = style_range.getObjectValue(i)
- idxFrom = style.getInteger(idFrom)
- idxTo = style.getInteger(idTo)
- if not replaced and find in current_text[idxFrom: idxTo]:
+ style_ranges = text_key.getList(idTextStyleRange)
+ for i in range(style_ranges.count):
+ style_range = style_ranges.getObjectValue(i)
+ idxFrom = style_range.getInteger(idFrom)
+ idxTo = style_range.getInteger(idTo)
+ if not replaced and find in current_text[idxFrom:idxTo]:
# Found the text to replace
replaced = True
- style.putInteger(idTo, idxTo + offset)
- style_range.putObject(idTextStyleRange, style)
+ style_range.putInteger(idTo, idxTo + offset)
+ style_ranges.putObject(idTextStyleRange, style_range)
elif replaced:
# Replacement already made, offset the remaining ranges
- style.putInteger(idFrom, idxFrom + offset)
- style.putInteger(idTo, idxTo + offset)
- style_range.putObject(idTextStyleRange, style)
+ style_range.putInteger(idFrom, idxFrom + offset)
+ style_range.putInteger(idTo, idxTo + offset)
+ style_ranges.putObject(idTextStyleRange, style_range)
# Skip applying changes if no replacement could be made
if not replaced:
- print(f"Text replacement couldn't find the text '{find}' "
- f"in layer with name '{layer.name}'!")
+ _logger.warning(f"Couldn't replace the text '{find}' in layer: {layer.name}")
return
# Apply changes
text_key.putString(idTextKey, current_text.replace(find, replace))
- text_key.putList(idTextStyleRange, style_range)
+ text_key.putList(idTextStyleRange, style_ranges)
apply_text_key(layer, text_key)
def replace_text_legacy(
find: str,
replace: str,
- layer: Optional[ArtLayer] = None,
- targeted_replace: bool = True
+ layer: ArtLayer | None = None,
+ targeted_replace: bool = True,
) -> None:
"""Replace all instances of `replace_this` in the specified layer with `replace_with`, using Photoshop's
built-in search and replace feature. Slower than `replace_text`, but can handle strings broken into
@@ -167,35 +184,41 @@ def replace_text_legacy(
"""
# Set the active layer
if layer:
- APP.activeDocument.activeLayer = layer
+ APP.instance.activeDocument.activeLayer = layer
# Find and replace
- idFindReplace = sID("findReplace")
+ idFindReplace = APP.instance.sID("findReplace")
desc31 = ActionDescriptor()
ref3 = ActionReference()
desc32 = ActionDescriptor()
- ref3.putProperty(sID("property"), idFindReplace)
- ref3.putEnumerated(sID("textLayer"), sID("ordinal"), sID("targetEnum"))
- desc31.putReference(sID("target"), ref3)
- desc32.putString(sID("find"), f"""{find}""")
- desc32.putString(sID("replace"), f"""{replace}""")
+ ref3.putProperty(APP.instance.sID("property"), idFindReplace)
+ ref3.putEnumerated(
+ APP.instance.sID("textLayer"),
+ APP.instance.sID("ordinal"),
+ APP.instance.sID("targetEnum"),
+ )
+ desc31.putReference(APP.instance.sID("target"), ref3)
+ desc32.putString(APP.instance.sID("find"), find)
+ desc32.putString(APP.instance.sID("replace"), replace)
desc32.putBoolean(
- sID("checkAll"), # Targeted replace doesn't work on old PS versions
- False if targeted_replace and APP.supports_target_text_replace() else True
+ APP.instance.sID(
+ "checkAll"
+ ), # Targeted replace doesn't work on old PS versions
+ False
+ if targeted_replace and APP.instance.supports_target_text_replace
+ else True,
)
- desc32.putBoolean(sID("forward"), True)
- desc32.putBoolean(sID("caseSensitive"), True)
- desc32.putBoolean(sID("wholeWord"), False)
- desc32.putBoolean(sID("ignoreAccents"), True)
- desc31.putObject(sID("using"), idFindReplace, desc32)
+ desc32.putBoolean(APP.instance.sID("forward"), True)
+ desc32.putBoolean(APP.instance.sID("caseSensitive"), True)
+ desc32.putBoolean(APP.instance.sID("wholeWord"), False)
+ desc32.putBoolean(APP.instance.sID("ignoreAccents"), True)
+ desc31.putObject(APP.instance.sID("using"), idFindReplace, desc32)
try:
- APP.executeAction(idFindReplace, desc31, NO_DIALOG)
+ APP.instance.executeAction(idFindReplace, desc31, DialogModes.DisplayNoDialogs)
except PS_EXCEPTIONS:
replace_text_legacy(
- find=find,
- replace=replace,
- layer=layer,
- targeted_replace=False)
+ find=find, replace=replace, layer=layer, targeted_replace=False
+ )
def remove_trailing_text(layer: ArtLayer, idx: int) -> None:
@@ -206,23 +229,24 @@ def remove_trailing_text(layer: ArtLayer, idx: int) -> None:
idx: Index to remove after.
"""
# Action Descriptor ID's
- idTextKey = sID("textKey")
- idFrom = sID("from")
- idTo = sID("to")
+ idTextKey = APP.instance.sID("textKey")
+ idFrom = APP.instance.sID("from")
+ idTo = APP.instance.sID("to")
# Establish our text key and descriptor ID's
key: ActionDescriptor = get_text_key(layer)
current_text = key.getString(idTextKey)
- new_text = current_text[0:idx - 1]
+ new_text = current_text[0 : idx - 1]
# Find the range where target text exists
- for n in [sID("textStyleRange"), sID("paragraphStyleRange")]:
-
+ for n in [
+ APP.instance.sID("textStyleRange"),
+ APP.instance.sID("paragraphStyleRange"),
+ ]:
# Iterate over list of style ranges
style_ranges: list[ActionDescriptor] = []
text_range = key.getList(n)
for i in range(text_range.count):
-
# Get position of this style range
style = text_range.getObjectValue(i)
i_left = style.getInteger(idFrom)
@@ -253,24 +277,25 @@ def remove_leading_text(layer: ArtLayer, idx: int) -> None:
idx: Index to remove up to.
"""
# Action Descriptor ID's
- idTextKey = sID("textKey")
- idFrom = sID("from")
- idTo = sID("to")
+ idTextKey = APP.instance.sID("textKey")
+ idFrom = APP.instance.sID("from")
+ idTo = APP.instance.sID("to")
# Establish our text key and descriptor ID's
key: ActionDescriptor = get_text_key(layer)
current_text = key.getString(idTextKey)
- new_text = current_text[idx + 1:]
+ new_text = current_text[idx + 1 :]
offset = idx + 1
# Find the range where target text exists
- for n in [sID("textStyleRange"), sID("paragraphStyleRange")]:
-
+ for n in [
+ APP.instance.sID("textStyleRange"),
+ APP.instance.sID("paragraphStyleRange"),
+ ]:
# Iterate over list of style ranges
style_ranges: list[ActionDescriptor] = []
text_range = key.getList(n)
for i in range(text_range.count):
-
# Get position of this style range
style = text_range.getObjectValue(i)
i_left = style.getInteger(idFrom)
@@ -298,17 +323,183 @@ def remove_leading_text(layer: ArtLayer, idx: int) -> None:
apply_text_key(layer, key)
+def set_text_style_values(
+ text_style: ActionDescriptor,
+ source: ActionDescriptor | None = None,
+ font: str | None = None,
+ size: float | None = None,
+ leading: float | None = None,
+ kerning: Literal["metricsKern", "opticalKern", "manual"] | None = None,
+ tracking: int | None = None,
+ color: SolidColor | None = None,
+ language: str | None = None,
+ reference_layer: ArtLayer | None = None,
+) -> None:
+ """Set text style descriptor values."""
+ set_or_copy_string(APP.instance.sID("fontPostScriptName"), text_style, source, font)
+ set_or_copy_unit_double(
+ APP.instance.sID("size"),
+ text_style,
+ source,
+ unit_type=APP.instance.sID("pointsUnit"),
+ unit_value=size,
+ source_value_hook=(lambda value: get_font_size(reference_layer, value))
+ if reference_layer
+ else None,
+ )
+ set_or_copy_unit_double(
+ APP.instance.sID("leading"),
+ text_style,
+ source,
+ unit_type=APP.instance.sID("pointsUnit"),
+ unit_value=leading,
+ )
+ kern_id = APP.instance.sID("autoKern")
+ set_or_copy_enumerated(
+ kern_id,
+ text_style,
+ source,
+ enum_type=kern_id,
+ enum_value=APP.instance.sID(kerning) if kerning else None,
+ )
+ set_or_copy_integer(APP.instance.sID("tracking"), text_style, source, tracking)
+ set_or_copy_color("color", text_style, source, color)
+ lang_id = APP.instance.sID("textLanguage")
+ set_or_copy_enumerated(
+ lang_id,
+ text_style,
+ source,
+ enum_type=lang_id,
+ enum_value=APP.instance.sID(language) if language else None,
+ )
+
+
+def override_text_style_ranges(
+ layer: ArtLayer,
+ ranges: Iterable[tuple[int, int]],
+ font: str | None = None,
+ size: float | None = None,
+ leading: float | None = None,
+ kerning: Literal["metricsKern", "opticalKern", "manual"] | None = None,
+ tracking: int | None = None,
+ color: SolidColor | None = None,
+ language: str | None = None,
+) -> None:
+ """Override style properties in specified character ranges of a text layer.
+
+ Existing style ranges are split and adjusted as necessary. New ranges inherit
+ the existing style from the portions they replace, then apply the overrides.
+
+ Args:
+ layer: Text layer to modify.
+ ranges: List of (start, end) tuples, where end is exclusive (0-based indices).
+ font: Font post script name.
+ size: Text size in points.
+ """
+ # Get IDs
+ text_key_id = APP.instance.sID("textKey")
+ text_style_range_id = APP.instance.sID("textStyleRange")
+ from_id = APP.instance.sID("from")
+ to_id = APP.instance.sID("to")
+ text_style_id = APP.instance.sID("textStyle")
+
+ # Get text key and current text
+ text_key = get_text_key(layer)
+ current_text = text_key.getString(text_key_id)
+ text_len = len(current_text)
+
+ # Get existing style ranges
+ style_ranges = text_key.getList(text_style_range_id)
+
+ # Collect all split positions
+ positions = set([0, text_len])
+ for start, end in ranges:
+ positions.add(max(0, start))
+ positions.add(min(text_len, end))
+ positions = sorted(positions)
+
+ # Create new style ranges
+ new_style_ranges = ActionList()
+ for i in range(len(positions) - 1):
+ start = positions[i]
+ end = positions[i + 1]
+
+ # Find the original range containing this start
+ orig_range = None
+ for j in range(style_ranges.count):
+ r = style_ranges.getObjectValue(j)
+ if r.getInteger(from_id) <= start < r.getInteger(to_id):
+ orig_range = r
+ break
+ if not orig_range:
+ continue
+
+ # Create new range
+ new_range = ActionDescriptor()
+ new_range.putInteger(from_id, start)
+ new_range.putInteger(to_id, end)
+
+ orig_style = orig_range.getObjectValue(text_style_id)
+
+ overridden = any(ov_start <= start < ov_end for ov_start, ov_end in ranges)
+ if overridden:
+ # Create new range if values require overriding,
+ # as editing the original range's text style
+ # doesn't allow changing unit values, such as size,
+ # because the Photoshop API doesn't seem to support it.
+ # The values can be successfully set to the original text
+ # style descriptor but they just won't come into effect when
+ # the action is applied.
+ text_style = ActionDescriptor()
+ set_text_style_values(
+ text_style,
+ orig_style,
+ font=font,
+ size=size,
+ leading=leading,
+ kerning=kerning,
+ tracking=tracking,
+ color=color,
+ language=language,
+ reference_layer=layer,
+ )
+ else:
+ # Use original range's text style if no overriding should be done
+ text_style = orig_style
+
+ new_range.putObject(text_style_id, text_style_id, text_style)
+ new_style_ranges.putObject(text_style_range_id, new_range)
+
+ # Update the text key
+ text_key.putList(text_style_range_id, new_style_ranges)
+ apply_text_key(layer, text_key)
+
+
"""
* Text Item Size
"""
+ScaleAxis = Literal["tx", "ty", "xx", "yy", "xy", "yx"]
+
+@overload
def get_text_scale_factor(
- layer: Optional[ArtLayer] = None,
- axis: Optional[Union[str, list]] = 'yy',
- text_key=None
-) -> Union[int, float, list[Union[int, float]]]:
- """Get the scale factor of the document for changing text size.
+ layer: ArtLayer, axis: list[ScaleAxis], text_key: ActionDescriptor | None = None
+) -> list[float]: ...
+
+
+@overload
+def get_text_scale_factor(
+ layer: ArtLayer, axis: ScaleAxis = "yy", text_key: ActionDescriptor | None = None
+) -> float: ...
+
+
+def get_text_scale_factor(
+ layer: ArtLayer,
+ axis: ScaleAxis | list[ScaleAxis] = "yy",
+ text_key: ActionDescriptor | None = None,
+) -> float | list[float]:
+ """Gets a transform value from text layer.
Args:
layer: The layer to make active and run the check on.
@@ -318,22 +509,44 @@ def get_text_scale_factor(
Returns:
Float scale factor
"""
+
# Get the textKey if not provided
if not text_key:
# Get text key
text_key = get_text_key(layer)
- idTransform = sID('transform')
+ idTransform = APP.instance.sID("transform")
# Check for the "transform" descriptor
if text_key.hasKey(idTransform):
transform = text_key.getObjectValue(idTransform)
# Check list of axis
if isinstance(axis, list):
- return [transform.getUnitDoubleValue(sID(n)) for n in axis]
- # Check string axis
- if isinstance(axis, str):
- return transform.getUnitDoubleValue(sID(axis))
- return 1 if not isinstance(axis, list) else [1] * len(axis)
+ return [transform.getUnitDoubleValue(APP.instance.sID(n)) for n in axis]
+ # String axis
+ return transform.getUnitDoubleValue(APP.instance.sID(axis))
+ if isinstance(axis, list):
+ return [1.0] * len(axis)
+ return 1.0
+
+
+def get_text_click_point(
+ layer: ArtLayer,
+ document_width: float | None = None,
+ document_height: float | None = None,
+ text_key: ActionDescriptor | None = None,
+) -> tuple[float, float]:
+ if not text_key:
+ text_key = get_text_key(layer)
+ if document_width is None or document_height is None:
+ doc = APP.instance.activeDocument
+ document_width = doc.width
+ document_height = doc.height
+ id_text_click_point = APP.instance.sID("textClickPoint")
+ desc = text_key.getObjectValue(id_text_click_point)
+ return (
+ desc.getUnitDoubleValue(APP.instance.sID("horizontal")) / 100 * document_width,
+ desc.getUnitDoubleValue(APP.instance.sID("vertical")) / 100 * document_height,
+ )
"""
@@ -341,7 +554,12 @@ def get_text_scale_factor(
"""
-def align_text(action_list: ActionList, start: int, end: int, alignment: str = "right") -> ActionList:
+def align_text(
+ action_list: ActionList,
+ start: int,
+ end: int,
+ alignment: Literal["left", "center", "right"] = "right",
+) -> ActionList:
"""Align a slice of text in an action using given alignment.
Examples:
@@ -358,13 +576,17 @@ def align_text(action_list: ActionList, start: int, end: int, alignment: str = "
"""
d1 = ActionDescriptor()
d2 = ActionDescriptor()
- paraStyle = sID("paragraphStyle")
- d1.putInteger(sID("from"), start)
- d1.putInteger(sID("to"), end)
- d2.putBoolean(sID("styleSheetHasParent"), True)
- d2.putEnumerated(sID("align"), sID("alignmentType"), sID(alignment))
+ paraStyle = APP.instance.sID("paragraphStyle")
+ d1.putInteger(APP.instance.sID("from"), start)
+ d1.putInteger(APP.instance.sID("to"), end)
+ d2.putBoolean(APP.instance.sID("styleSheetHasParent"), True)
+ d2.putEnumerated(
+ APP.instance.sID("align"),
+ APP.instance.sID("alignmentType"),
+ APP.instance.sID(alignment),
+ )
d1.putObject(paraStyle, paraStyle, d2)
- action_list.putObject(sID("paragraphStyleRange"), d1)
+ action_list.putObject(APP.instance.sID("paragraphStyleRange"), d1)
return action_list
@@ -388,7 +610,7 @@ def align_text_center(action_list: ActionList, start: int, end: int) -> None:
"""
-def set_space_after(space: Union[int, float]) -> None:
+def set_space_after(space: int | float) -> None:
"""Manually assign the 'space after' property for a paragraph text layer.
Args:
@@ -397,16 +619,24 @@ def set_space_after(space: Union[int, float]) -> None:
desc1 = ActionDescriptor()
ref1 = ActionReference()
deesc2 = ActionDescriptor()
- ref1.PutProperty(sID("property"), sID("paragraphStyle"))
- ref1.PutEnumerated(sID("textLayer"), sID("ordinal"), sID("targetEnum"))
- desc1.PutReference(sID("target"), ref1)
- deesc2.PutInteger(sID("textOverrideFeatureName"), 808464438)
- deesc2.PutUnitDouble(sID("spaceAfter"), sID("pointsUnit"), space)
- desc1.PutObject(sID("to"), sID("paragraphStyle"), deesc2)
- APP.executeAction(sID("set"), desc1, NO_DIALOG)
+ ref1.putProperty(APP.instance.sID("property"), APP.instance.sID("paragraphStyle"))
+ ref1.putEnumerated(
+ APP.instance.sID("textLayer"),
+ APP.instance.sID("ordinal"),
+ APP.instance.sID("targetEnum"),
+ )
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ deesc2.putInteger(APP.instance.sID("textOverrideFeatureName"), 808464438)
+ deesc2.putUnitDouble(
+ APP.instance.sID("spaceAfter"), APP.instance.sID("pointsUnit"), space
+ )
+ desc1.putObject(APP.instance.sID("to"), APP.instance.sID("paragraphStyle"), deesc2)
+ APP.instance.executeAction(
+ APP.instance.sID("set"), desc1, DialogModes.DisplayNoDialogs
+ )
-def set_text_leading(layer: ArtLayer, leading: Union[float, int]) -> None:
+def set_text_leading(layer: ArtLayer, leading: float | int) -> None:
"""Manually assign font leading to a text layer using action descriptors.
Args:
@@ -416,15 +646,19 @@ def set_text_leading(layer: ArtLayer, leading: Union[float, int]) -> None:
desc1 = ActionDescriptor()
ref1 = ActionReference()
desc2 = ActionDescriptor()
- ref1.putProperty(sID("property"), sID("textStyle"))
- ref1.putIdentifier(sID("textLayer"), layer.id)
- desc1.putReference(sID("target"), ref1)
- desc2.putUnitDouble(sID("leading"), sID("pointsUnit"), leading)
- desc1.putObject(sID("to"), sID("textStyle"), desc2)
- APP.executeaction(sID("set"), desc1, NO_DIALOG)
+ ref1.putProperty(APP.instance.sID("property"), APP.instance.sID("textStyle"))
+ ref1.putIdentifier(APP.instance.sID("textLayer"), layer.id)
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc2.putUnitDouble(
+ APP.instance.sID("leading"), APP.instance.sID("pointsUnit"), leading
+ )
+ desc1.putObject(APP.instance.sID("to"), APP.instance.sID("textStyle"), desc2)
+ APP.instance.executeAction(
+ APP.instance.sID("set"), desc1, DialogModes.DisplayNoDialogs
+ )
-def set_text_size(layer: ArtLayer, size: Union[float, int]) -> None:
+def set_text_size(layer: ArtLayer, size: float | int) -> None:
"""Manually assign font size to a text layer using action descriptors.
Args:
@@ -434,16 +668,20 @@ def set_text_size(layer: ArtLayer, size: Union[float, int]) -> None:
desc1 = ActionDescriptor()
ref1 = ActionReference()
desc2 = ActionDescriptor()
- textStyle = sID("textStyle")
- ref1.putProperty(sID("property"), textStyle)
- ref1.putIdentifier(sID("textLayer"), layer.id)
- desc1.putReference(sID("target"), ref1)
- desc2.putUnitDouble(sID("size"), sID("pointsUnit"), size)
- desc1.putObject(sID("to"), textStyle, desc2)
- APP.executeAction(sID("set"), desc1, NO_DIALOG)
+ textStyle = APP.instance.sID("textStyle")
+ ref1.putProperty(APP.instance.sID("property"), textStyle)
+ ref1.putIdentifier(APP.instance.sID("textLayer"), layer.id)
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc2.putUnitDouble(APP.instance.sID("size"), APP.instance.sID("pointsUnit"), size)
+ desc1.putObject(APP.instance.sID("to"), textStyle, desc2)
+ APP.instance.executeAction(
+ APP.instance.sID("set"), desc1, DialogModes.DisplayNoDialogs
+ )
-def set_text_size_and_leading(layer: ArtLayer, size: Union[int, float], leading: Union[int, float]) -> None:
+def set_text_size_and_leading(
+ layer: ArtLayer, size: int | float, leading: int | float
+) -> None:
"""Manually assign font size and leading space to a text layer using action descriptors.
Args:
@@ -454,15 +692,17 @@ def set_text_size_and_leading(layer: ArtLayer, size: Union[int, float], leading:
desc1 = ActionDescriptor()
ref1 = ActionReference()
desc2 = ActionDescriptor()
- textStyle = sID("textStyle")
- ptUnit = sID("pointsUnit")
- ref1.putProperty(sID("property"), textStyle)
- ref1.putIdentifier(sID("textLayer"), layer.id)
- desc1.putReference(sID("target"), ref1)
- desc2.putUnitDouble(sID("size"), ptUnit, size)
- desc2.putUnitDouble(sID("leading"), ptUnit, leading)
- desc1.putObject(sID("to"), textStyle, desc2)
- APP.executeAction(sID("set"), desc1, NO_DIALOG)
+ textStyle = APP.instance.sID("textStyle")
+ ptUnit = APP.instance.sID("pointsUnit")
+ ref1.putProperty(APP.instance.sID("property"), textStyle)
+ ref1.putIdentifier(APP.instance.sID("textLayer"), layer.id)
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc2.putUnitDouble(APP.instance.sID("size"), ptUnit, size)
+ desc2.putUnitDouble(APP.instance.sID("leading"), ptUnit, leading)
+ desc1.putObject(APP.instance.sID("to"), textStyle, desc2)
+ APP.instance.executeAction(
+ APP.instance.sID("set"), desc1, DialogModes.DisplayNoDialogs
+ )
def set_composer(layer: ArtLayer, every: bool = False) -> None:
@@ -476,13 +716,15 @@ def set_composer(layer: ArtLayer, every: bool = False) -> None:
desc1 = ActionDescriptor()
ref1 = ActionReference()
desc2 = ActionDescriptor()
- paraStyle = sID("paragraphStyle")
- ref1.putProperty(sID("property"), paraStyle)
- ref1.putIdentifier(sID("textLayer"), layer.id)
- desc1.putReference(sID("target"), ref1)
- desc2.putBoolean(sID("textEveryLineComposer"), every)
- desc1.putObject(sID("to"), paraStyle, desc2)
- APP.executeaction(sID("set"), desc1, NO_DIALOG)
+ paraStyle = APP.instance.sID("paragraphStyle")
+ ref1.putProperty(APP.instance.sID("property"), paraStyle)
+ ref1.putIdentifier(APP.instance.sID("textLayer"), layer.id)
+ desc1.putReference(APP.instance.sID("target"), ref1)
+ desc2.putBoolean(APP.instance.sID("textEveryLineComposer"), every)
+ desc1.putObject(APP.instance.sID("to"), paraStyle, desc2)
+ APP.instance.executeAction(
+ APP.instance.sID("set"), desc1, DialogModes.DisplayNoDialogs
+ )
def set_composer_single_line(layer: ArtLayer) -> None:
@@ -511,7 +753,8 @@ def set_font(layer: ArtLayer, font_name: str) -> None:
layer: ArtLayer containing TextItem.
font_name: Name of the font to set.
"""
- layer.textItem.font = APP.fonts.getByName(font_name).postScriptName
+ if font := APP.instance.fonts.getByName(font_name):
+ layer.textItem.font = font.postScriptName
"""
@@ -519,7 +762,7 @@ def set_font(layer: ArtLayer, font_name: str) -> None:
"""
-def ensure_visible_reference(reference: ArtLayer) -> Optional[TextItem]:
+def ensure_visible_reference(reference: ArtLayer) -> TextItem | None:
"""Ensures that a layer used for reference has bounds if it is a text layer.
Args:
@@ -538,13 +781,20 @@ def ensure_visible_reference(reference: ArtLayer) -> Optional[TextItem]:
return None
-def scale_text_right_overlap(layer: ArtLayer, reference: ArtLayer, gap: int = 30) -> None:
- """Scales a text layer down (in 0.2 pt increments) until its right bound
- has a 30 px~ (based on DPI) clearance from a reference layer's left bound.
+def scale_text_right_overlap(
+ layer: ArtLayer,
+ reference: ArtLayer,
+ reference_side: RefSide,
+ step_sizes: Sequence[float] | None = None,
+ gap: float = 30,
+) -> None:
+ """Scales a text layer down (in 0.2 pt increments) until it
+ doesn't overlap with the reference layer's `reference_side` bound.
Args:
layer: The text item layer to scale.
reference: Reference layer we need to avoid.
+ reference_side: Which side of reference to check overlap with.
gap: Minimum gap to ensure between the layer and reference (DPI adjusted).
"""
# Ensure a valid and visible reference layer
@@ -554,44 +804,65 @@ def scale_text_right_overlap(layer: ArtLayer, reference: ArtLayer, gap: int = 30
# Set starting variables
font_size = old_size = get_font_size(layer)
- ref_left_bound = reference.bounds[0] - APP.scale_by_dpi(gap)
- step, half_step = 0.4, 0.2
+ scaled_gap = APP.instance.scale_by_dpi(gap)
+ ref_bounds = reference.bounds
+ ref_bounds = (
+ ref_bounds[0] - scaled_gap,
+ ref_bounds[1] - scaled_gap,
+ ref_bounds[2] + scaled_gap,
+ ref_bounds[3] + scaled_gap,
+ )
+ layer_bounds = layer.bounds
+ step_sizes = step_sizes or (0.4, 0.2)
# Guard against reference being left of the layer
- if ref_left_bound < layer.bounds[0]:
+ if (
+ ref_bounds[0] < layer_bounds[0]
+ if reference_side == RefSide.LEFT
+ else ref_bounds[1] < layer_bounds[1]
+ if reference_side == RefSide.TOP
+ else ref_bounds[2] > layer_bounds[2]
+ if reference_side == RefSide.RIGHT
+ else ref_bounds[3] > layer_bounds[3]
+ ):
# Reset reference
if ref_TI:
- ref_TI.contents = ''
+ ref_TI.contents = ""
return
- # Make our first check if scaling is necessary
- if continue_scaling := bool(layer.bounds[2] > ref_left_bound):
+ uneven_round = False
+ # Adjust text size down and up in decreasing steps
+ for idx, step_size in enumerate(step_sizes):
+ uneven_round = bool(idx % 2)
+
+ # Check overlap
+ while check_bounds_overlap(layer_bounds, ref_bounds, reference_side):
+ if uneven_round:
+ font_size += step_size
+ else:
+ font_size -= step_size
- # Step down the font till it clears the reference
- while continue_scaling:
- font_size -= step
set_text_size(layer, font_size)
- continue_scaling = bool(layer.bounds[2] > ref_left_bound)
- # Go up a half step
- font_size += half_step
- set_text_size(layer, font_size)
+ layer_bounds = layer.bounds
- # If out of bounds, revert half step
- if layer.bounds[2] > ref_left_bound:
- font_size -= half_step
- set_text_size(layer, font_size)
+ # If the last round was uneven we have to go one step down
+ if uneven_round:
+ font_size -= step_sizes[-1]
+ set_text_size(layer, font_size)
- # Shift baseline up to keep text centered vertically
- layer.textItem.baselineShift = (old_size * 0.3) - (font_size * 0.3)
+ # Shift baseline up to keep text centered vertically
+ layer.textItem.baselineShift = (old_size * 0.3) - (font_size * 0.3)
# Fix corrected reference layer
if ref_TI:
# Reset reference
- ref_TI.contents = ''
+ ref_TI.contents = ""
-def scale_text_left_overlap(layer: ArtLayer, reference: ArtLayer, gap: int = 30) -> None:
+def scale_text_left_overlap(
+ layer: ArtLayer, reference: ArtLayer, gap: int = 30
+) -> None:
"""Scales a text layer down (in 0.2 pt increments) until its left bound
has a 30 px~ (based on DPI) clearance from a reference layer's right bound.
@@ -601,25 +872,24 @@ def scale_text_left_overlap(layer: ArtLayer, reference: ArtLayer, gap: int = 30)
gap: Minimum gap to ensure between the layer and reference (DPI adjusted).
"""
# Ensure a valid and visible reference layer
- if not reference or reference.bounds == [0, 0, 0, 0]:
+ if not reference or reference.bounds == (0, 0, 0, 0):
return
ref_TI = ensure_visible_reference(reference)
# Set starting variables
font_size = old_size = get_font_size(layer)
- ref_right_bound = reference.bounds[2] + APP.scale_by_dpi(gap)
+ ref_right_bound = reference.bounds[2] + APP.instance.scale_by_dpi(gap)
step, half_step = 0.4, 0.2
# Guard against reference being right of the layer
if layer.bounds[0] < reference.bounds[0]:
# Reset reference
if ref_TI:
- ref_TI.contents = ''
+ ref_TI.contents = ""
return
# Make our first check if scaling is necessary
if continue_scaling := bool(ref_right_bound > layer.bounds[0]):
-
# Step down the font till it clears the reference
while continue_scaling:
font_size -= step
@@ -640,16 +910,16 @@ def scale_text_left_overlap(layer: ArtLayer, reference: ArtLayer, gap: int = 30)
# Fix corrected reference layer
if ref_TI:
- ref_TI.contents = ''
+ ref_TI.contents = ""
def scale_text_to_width(
layer: ArtLayer,
- width: int,
+ width: int | float,
spacing: int = 64,
step: float = 0.4,
- font_size: Optional[float] = None
-) -> Optional[float]:
+ font_size: float | None = None,
+) -> float | None:
"""Resize a given text layer's font size/leading until it fits inside a reference width.
Args:
@@ -663,7 +933,7 @@ def scale_text_to_width(
Font size if font size is calculated during operation, otherwise None.
"""
# Cancel if we're already within expected bounds
- width = width - APP.scale_by_dpi(spacing)
+ width = width - APP.instance.scale_by_dpi(spacing)
continue_scaling = bool(width < get_layer_width(layer))
if not continue_scaling:
return
@@ -692,12 +962,12 @@ def scale_text_to_width(
def scale_text_to_height(
layer: ArtLayer,
- height: int,
- spacing: int = 64,
+ height: float,
+ spacing: float = 64,
step: float = 0.4,
- font_size: Optional[float] = None
-) -> Optional[float]:
- """Resize a given text layer's font size/leading until it fits inside a reference width.
+ font_size: float | None = None,
+) -> float | None:
+ """Resize a given text layer's font size/leading until it fits inside a reference height.
Args:
layer: Text layer to scale.
@@ -710,7 +980,7 @@ def scale_text_to_height(
Font size if font size is calculated during operation, otherwise None.
"""
# Cancel if we're already within expected bounds
- height = height - APP.scale_by_dpi(spacing)
+ height = height - APP.instance.scale_by_dpi(spacing)
continue_scaling = bool(height < get_layer_height(layer))
if not continue_scaling:
return
@@ -738,9 +1008,7 @@ def scale_text_to_height(
def scale_text_to_width_textbox(
- layer: ArtLayer,
- font_size: Optional[float] = None,
- step: float = 0.1
+ layer: ArtLayer, font_size: float | None = None, step: float = 0.1
) -> None:
"""Check if the text in a TextLayer exceeds its bounding box.
@@ -762,18 +1030,20 @@ def scale_text_to_width_textbox(
def scale_text_layers_to_height(
text_layers: list[ArtLayer],
- ref_height: Union[int, float],
- font_size: Optional[float] = None,
- step: float = 0.4
-) -> Optional[float]:
+ ref_height: int | float,
+ font_size: float | None = None,
+ step_sizes: Sequence[float] | None = None,
+) -> float | None:
"""Scale multiple text layers until they all can fit within the same given height dimension.
Args:
text_layers: List of TextLayers to check.
ref_height: Height to fit inside.
font_size: Starting font size of the text layers, calculated if not provided.
- step: Points to step down the text layers to fit.
+ step_sizes: Descending sequence, which determines the increments used for text scaling.
"""
+ step_sizes = step_sizes or (0.4, 0.2)
+
# Check initial fit
total_layer_height = sum([get_layer_height(layer) for layer in text_layers])
if total_layer_height <= ref_height:
@@ -782,26 +1052,203 @@ def scale_text_layers_to_height(
# Establish font size
if font_size is None:
font_size = get_font_size(text_layers[0])
- half_step = step / 2
- # Compare height of all 3 elements vs total reference height
- while total_layer_height > ref_height:
- total_layer_height = 0
- font_size -= step
- for i, layer in enumerate(text_layers):
- set_text_size_and_leading(layer, font_size, font_size)
- total_layer_height += get_layer_height(layer)
+ uneven_round = False
+ # Adjust text size down and up in decreasing steps
+ for idx, step_size in enumerate(step_sizes):
+ uneven_round = bool(idx % 2)
+
+ # Compare height of all 3 elements vs total reference height
+ while (
+ total_layer_height < ref_height
+ if uneven_round
+ else total_layer_height > ref_height
+ ):
+ total_layer_height = 0
+
+ if uneven_round:
+ font_size += step_size
+ else:
+ font_size -= step_size
- # Increase by a half step
- font_size += half_step
- total_layer_height = 0
- for i, layer in enumerate(text_layers):
- set_text_size_and_leading(layer, font_size, font_size)
- total_layer_height += get_layer_height(layer)
+ for layer in text_layers:
+ set_text_size_and_leading(layer, font_size, font_size)
+ total_layer_height += get_layer_height(layer)
- # If out of bounds, revert half step
- if total_layer_height > ref_height:
- font_size -= half_step
- for i, layer in enumerate(text_layers):
+ # If the last round was uneven we have to go one step down,
+ # because we are over the reference height
+ if uneven_round:
+ font_size -= step_sizes[-1]
+ for layer in text_layers:
set_text_size_and_leading(layer, font_size, font_size)
+
return font_size
+
+
+def clear_reference_vertical_multi(
+ text_layers: list[ArtLayer],
+ ref: ReferenceLayer,
+ loyalty_ref: ReferenceLayer,
+ space: int | float,
+ uniform_gap: bool = False,
+ font_size: float | None = None,
+ step: float = 0.2,
+ docsel: Selection | None = None,
+ bottom_ref: ReferenceLayer | None = None,
+) -> None:
+ """Shift or resize multiple text layers to prevent vertical collision with a reference area.
+
+ Note:
+ Used on Planeswalker cards to allow multiple text abilities to clear the loyalty box.
+
+ Args:
+ text_layers: Ability text layers to nudge or resize.
+ ref: Reference area ability text layers must fit inside.
+ loyalty_ref: Reference area that covers the loyalty box.
+ space: Minimum space between planeswalker abilities.
+ uniform_gap: Whether the gap between abilities should be the same between each ability.
+ font_size: The current font size of the text layers, if known. Otherwise, calculate automatically.
+ step: The amount of font size and leading to step down each iteration.
+ docsel: Selection object, pull from document if not provided (improves performance).
+ bottom_ref: Reference layer used to check text overflow at bottom.
+ """
+ # Return if adjustments weren't provided
+ if not loyalty_ref:
+ return
+
+ # Establish fresh data
+ if font_size is None:
+ font_size = get_font_size(text_layers[0])
+ layers = text_layers.copy()
+ movable = len(layers) - 1
+
+ # Calculate inside gap
+ total_space = ref.dims["height"] - sum(
+ [get_layer_height(layer) for layer in text_layers]
+ )
+ if not uniform_gap:
+ inside_gap = (
+ (total_space - space) - (ref.bounds[3] - layers[-1].bounds[1])
+ ) / movable
+ else:
+ inside_gap = total_space / (len(layers) + 1)
+ leftover = (inside_gap - space) * movable
+
+ # Does the bottom layer overlap with the loyalty box?
+ delta = check_reference_overlap(layer=layers[-1], ref=loyalty_ref, docsel=docsel)
+ if delta >= 0:
+ return
+
+ outer_gap = space if not uniform_gap else 0
+
+ if APP.instance.supports_uxp_scripts and bottom_ref:
+ keep_adjusting: bool = True
+ ref_right = ref.bounds[2]
+ bottom_ref_bounds = bottom_ref.bounds
+ bottom_layer = text_layers[-1]
+ bottom_layer_top = bottom_layer.bounds[1]
+ bottom_layer_contents = bottom_layer.textItem.contents
+ x, _ = get_text_click_point(bottom_layer)
+ min_inside_gap = outer_gap
+
+ while keep_adjusting:
+ # Avoid overlapping the loyalty box using a shaped text layer
+ bottom_layer = text_layers[-1]
+ # TODO Get the "actual" transform x and y, which are visible in Photoshop UI,
+ # in order to precisely align the shaped text layer with the others. The current
+ # implementation uses text click point which seems to precisely match the x value
+ # but not the y value. Bounds and boundsNoEffects don't give the transform values
+ # and as such can't be used here.
+ base_shape = create_shape_layer(
+ (
+ {"x": x, "y": bottom_layer_top},
+ {"x": ref_right, "y": bottom_layer_top},
+ {"x": ref_right, "y": bottom_ref_bounds[3] + 500},
+ {"x": x, "y": bottom_ref_bounds[3] + 500},
+ ),
+ relative_layer=bottom_layer,
+ placement=ElementPlacement.PlaceBefore,
+ )
+ loyalty_box_cutout = loyalty_ref.duplicate(
+ base_shape, ElementPlacement.PlaceBefore
+ )
+ merged_shape = merge_shapes(
+ loyalty_box_cutout, base_shape, operation=ShapeOperation.SubtractFront
+ )
+ shaped_text = create_text_layer_with_path(
+ reference_path=merged_shape,
+ reference_text=bottom_layer,
+ size=font_size,
+ leading=font_size,
+ )
+ shaped_text.textItem.contents = bottom_layer_contents
+ bottom_layer.visible = False
+ text_layers[-1] = shaped_text
+
+ inside_gap = spread_layers_over_reference(
+ layers=text_layers,
+ ref=ref,
+ gap=outer_gap,
+ outside_matching=True,
+ )
+
+ if keep_adjusting := inside_gap < min_inside_gap or (
+ text_layers[-1].bounds[3] >= bottom_ref_bounds[1]
+ ):
+ font_size -= step
+ for lyr in text_layers:
+ set_text_size_and_leading(
+ layer=lyr, size=font_size, leading=font_size
+ )
+
+ spread_layers_over_reference(
+ layers=text_layers,
+ ref=ref,
+ gap=outer_gap,
+ outside_matching=True,
+ )
+ else:
+ # Ensure uniform gaps between pinlines and all text layers
+ spread_layers_over_reference(
+ layers=text_layers,
+ ref=ref,
+ outside_matching=True,
+ )
+ else:
+ # Calculate the total distance needing to be covered
+ total_move = 0
+ layers.pop(0)
+ for n, lyr in enumerate(layers):
+ total_move += fabs(delta) * ((len(layers) - n) / len(layers))
+
+ # Text layers can just be shifted upwards
+ if total_move < leftover:
+ layers.reverse()
+ for n, lyr in enumerate(layers):
+ move_y = delta * ((len(layers) - n) / len(layers))
+ lyr.translate(0, move_y)
+ return
+
+ # Layer gap would be too small, need to resize text then shift upward
+ font_size -= step
+ for lyr in text_layers:
+ set_text_size_and_leading(layer=lyr, size=font_size, leading=font_size)
+
+ # Space apart planeswalker text evenly
+ spread_layers_over_reference(
+ layers=text_layers,
+ ref=ref,
+ gap=outer_gap,
+ outside_matching=False,
+ )
+
+ # Check for another iteration
+ clear_reference_vertical_multi(
+ text_layers=text_layers,
+ ref=ref,
+ loyalty_ref=loyalty_ref,
+ space=space,
+ uniform_gap=uniform_gap,
+ font_size=font_size,
+ docsel=docsel,
+ )
diff --git a/src/layouts.py b/src/layouts.py
index 19705915..575f9d05 100644
--- a/src/layouts.py
+++ b/src/layouts.py
@@ -1,50 +1,101 @@
"""
* Card Layout Data
"""
-# Standard Library Imports
-from datetime import date, datetime
-from typing import Optional, Match, Union, Type, ForwardRef
-from os import path as osp
-from pathlib import Path
+
+import re
+from collections.abc import Iterable, Sequence
+from datetime import date
from functools import cached_property
+from itertools import batched
+from logging import getLogger
+from pathlib import Path
+from pprint import pprint
+from re import Match
+from typing import Any, NotRequired, TypedDict
-# Third Party Imports
from omnitils.strings import get_line, get_lines, normalize_str, strip_lines
-# Local Imports
-from src import CFG, CON, CONSOLE, ENV, PATH
-from src.cards import CardDetails, FrameDetails, get_card_data, parse_card_info, process_card_data
-from src.console import msg_error, msg_success
-from src.utils.hexapi import get_watermark_svg, get_watermark_svg_from_set
-from src.utils.scryfall import get_cards_oracle
+from src import CFG, CON
+from src._config import AppConfig
+from src._state import PATH, HexproofSet
+from src.cards import CardDetails, FrameDetails, get_card_data, process_card_data
from src.enums.layers import LAYERS
from src.enums.mtg import (
CardTextPatterns,
CardTypes,
CardTypesSuper,
+ LayoutCategory,
LayoutScryfall,
LayoutType,
- planeswalkers_tall,
Rarity,
- TransformIcons)
-from src.enums.settings import CollectorMode, WatermarkMode
+ TransformIcons,
+ layout_map_types,
+ planeswalkers_tall,
+)
+from src.enums.settings import CollectorMode, NicknameShorten, WatermarkMode
from src.frame_logic import (
+ check_hybrid_mana_cost,
get_frame_details,
+ get_mana_cost_colors,
get_ordered_colors,
get_special_rarity,
- check_hybrid_mana_cost,
- get_mana_cost_colors)
+)
+from src.utils.hexapi import get_watermark_svg, get_watermark_svg_from_set
+from src.utils.manual_actions import manually_modify_model
+from src.utils.scryfall import (
+ FrameEffect,
+ MagicColor,
+ ScryfallCard,
+ ScryfallCardFace,
+ get_cards_oracle,
+)
+
+_logger = getLogger(__name__)
+
+
+class PowerToughness(TypedDict):
+ power: str
+ toughness: str
+
+
+class ProtoDetails(TypedDict):
+ oracle_text: str
+ mana_cost: str
+ pt: str
+
+
+class PlaneswalkerAbility(TypedDict):
+ text: str
+ icon: str
+ cost: str
+
+
+class SagaLine(TypedDict):
+ text: str
+ icons: list[str]
+
+
+class ClassLine(TypedDict):
+ cost: str | None
+ level_translation: str
+ level: str
+ text: str
+
"""
* Layout Processing
"""
-def assign_layout(filename: Path) -> str | ForwardRef('CardLayout'):
+def assign_layout(
+ card: CardDetails,
+ scryfall_override: ScryfallCard | None = None,
+) -> NormalLayout | None:
"""Assign layout object to a card.
Args:
- filename (Path): Path to the art file, filename supports optional tags.
+ filename: Path to the art file, filename supports optional tags.
+ name_override: Card name to use instead of the filename without suffix
Filename Tags:
| Tag | Description |
@@ -55,34 +106,43 @@ def assign_layout(filename: Path) -> str | ForwardRef('CardLayout'):
| `$creator` | Must be at the end of filename, indicates the creator. |
Returns:
- str | CardLayout: Layout object for this card.
+ Layout object for this card or None if no layout can be assigned.
"""
- # Get basic card information
- card = parse_card_info(filename)
- name_failed = osp.basename(str(card.get('file', 'None')))
+ name_failed = card["file"].name
# Get scryfall data for the card
- scryfall = get_card_data(card, cfg=CFG, logger=CONSOLE)
+ scryfall = scryfall_override or get_card_data(card, cfg=CFG)
if not scryfall:
- return msg_error(name_failed, reason="Scryfall search failed")
+ _logger.error(f"Scryfall search failed for {name_failed}")
+ return
+
+ if CFG.manually_edit_card_data:
+ try:
+ scryfall = manually_modify_model(scryfall, CFG.manual_text_editor)
+ except Exception:
+ _logger.exception(
+ f"Manual card data modification failed for {name_failed}"
+ )
+ return
+
scryfall = process_card_data(scryfall, card)
# Instantiate layout object
- if scryfall.get('layout', 'None') in layout_map:
+ if scryfall.layout in layout_map:
try:
- layout = layout_map[scryfall['layout']](scryfall, card)
- except Exception as e:
+ layout = layout_map[scryfall.layout](scryfall, card, CFG)
+ except Exception:
# Couldn't instantiate layout object
- CONSOLE.log_exception(e)
- return msg_error(name_failed, reason="Layout generation failed")
- if not ENV.TEST_MODE:
- CONSOLE.update(f"{msg_success('FOUND:')} {str(layout)}")
+ _logger.error(f"Layout generation failed for {name_failed}")
+ return
return layout
# Couldn't find an appropriate layout
- return msg_error(name_failed, reason="Layout incompatible")
+ _logger.error(f"Couldn't find an appropriate layout for {name_failed}")
-def join_dual_card_layouts(layouts: list[Union[str, 'CardLayout']]):
+def join_dual_card_layouts[T: NormalLayout | None](
+ layouts: Iterable[T],
+) -> list[NormalLayout | T]:
"""Join any layout objects that are dual sides of the same card, i.e. Split cards.
Args:
@@ -91,20 +151,18 @@ def join_dual_card_layouts(layouts: list[Union[str, 'CardLayout']]):
Returns:
List of layouts, with split layouts joined.
"""
- # Check if we have any split cards
- normal: list[Union[str, CardLayout]] = [
- n for n in layouts
- if isinstance(n, str)
- or n.card_class != LayoutType.Split]
- split: list[SplitLayout] = [
- n for n in layouts
- if not isinstance(n, str)
- and n.card_class == LayoutType.Split]
+ normal: list[NormalLayout | T] = [
+ n for n in layouts if not isinstance(n, SplitLayout)
+ ]
+ split: list[SplitLayout] = [n for n in layouts if isinstance(n, SplitLayout)]
+
+ # Return early if no split layouts are present
if not split:
return normal
# Join any identical split cards
- skip, add = [], []
+ skip: list[NormalLayout] = []
+ add: list[NormalLayout] = []
for card in split:
if card in skip:
continue
@@ -113,12 +171,24 @@ def join_dual_card_layouts(layouts: list[Union[str, 'CardLayout']]):
continue
if str(c) == str(card):
# Order them according to name position
- card.art_file = [*card.art_file, *c.art_file] if (
- normalize_str(card.name[0]) == normalize_str(card.file['name'])
- ) else [*c.art_file, *card.art_file]
+ card.art_files = (
+ [*card.art_files, *c.art_files]
+ if (normalize_str(card.name[0]) == normalize_str(card.file["name"]))
+ else [*c.art_files, *card.art_files]
+ )
skip.extend([card, c])
add.append(card)
- return [*normal, *add]
+ return normal + add
+
+
+def _get_other_side_color_identity(colors: list[str], mana_cost: str) -> str:
+ if check_hybrid_mana_cost(colors, mana_cost):
+ return LAYERS.LAND
+ if len(colors) > 1:
+ return LAYERS.GOLD
+ if not colors:
+ return LAYERS.COLORLESS
+ return colors[0]
"""
@@ -128,27 +198,75 @@ def join_dual_card_layouts(layouts: list[Union[str, 'CardLayout']]):
class NormalLayout:
"""Defines unified properties for all cards and serves as the layout for any M15 style typical card."""
- card_class: str = LayoutType.Normal
-
- # Static properties
- is_transform: bool = False
- is_mdfc: bool = False
-
- def __init__(self, scryfall: dict, file: dict):
+ def __init__(self, scryfall: ScryfallCard, file: CardDetails, config: AppConfig):
# Establish core properties
self._file = file
self._scryfall = scryfall
-
- # Cache set data and frame data
- _ = self.set_data
- _ = self.frame
+ self.config = config
def __str__(self):
"""String representation of the card layout object."""
- return (f"{self.name}"
- f"{f' [{self.set}]' if self.set else ''}"
- f"{f' {{{self.collector_number_raw}}}' if self.collector_number else ''}")
+ return (
+ f"{self.name}"
+ f"{f' [{self.set}]' if self.set else ''}"
+ f"{f' {{{self.collector_number_raw}}}' if self.collector_number else ''}"
+ )
+
+ def _get_card_name(self, data: ScryfallCard | ScryfallCardFace) -> str:
+ return (
+ data.printed_name if self.is_alt_lang and data.printed_name else data.name
+ )
+
+ @cached_property
+ def type(self) -> LayoutType:
+ return LayoutType.Normal
+
+ @cached_property
+ def category(self) -> LayoutCategory:
+ return layout_map_types[self.type]
+
+ @cached_property
+ def is_transform(self) -> bool:
+ return False
+
+ @cached_property
+ def is_mdfc(self) -> bool:
+ return False
+
+ """
+ * Panorama Data
+ """
+
+ @cached_property
+ def is_panorama(self) -> bool:
+ return self.panorama_size[0] > 1 or self.panorama_size[1] > 1
+
+ @cached_property
+ def is_vertical_panorama(self) -> bool:
+ return self.panorama_size[1] > 1
+
+ @cached_property
+ def panorama_element(self) -> tuple[int, int]:
+ pano_pos_arg = self.file["kwargs"].get("pano_pos", None)
+ if (
+ pano_pos_arg is not None
+ and len(parts := pano_pos_arg.split("x", maxsplit=1)) == 2
+ ):
+ return (int(parts[0]), int(parts[1]))
+ else:
+ pano_elem = int(self.file["kwargs"].get("pano_elem", 0))
+ pano_x = pano_elem % self.panorama_size[0]
+ pano_y = pano_elem // self.panorama_size[0]
+ return (pano_x, pano_y)
+
+ @cached_property
+ def panorama_size(self) -> tuple[int, int]:
+ panorama_size = self.file["kwargs"].get("pano_size", "1x1")
+ panorama_size = [int(c) for c in panorama_size.split("x", maxsplit=1)]
+ if len(panorama_size) < 2:
+ panorama_size = panorama_size + [1] * (2 - len(panorama_size))
+ return (panorama_size[0], panorama_size[1])
"""
* Core Data
@@ -160,24 +278,35 @@ def file(self) -> CardDetails:
return self._file
@cached_property
- def scryfall(self) -> dict:
+ def scryfall(self) -> ScryfallCard:
"""Card data fetched from Scryfall."""
return self._scryfall
@cached_property
def template_file(self) -> Path:
"""Template PSD file path, replaced before render process."""
- return PATH.TEMPLATES / 'normal.psd'
+ return PATH.TEMPLATES / "normal.psd"
@cached_property
def art_file(self) -> Path:
"""Path: Art image file path."""
- return self.file['file']
+ art_file = self.file["kwargs"].get("art", None)
+ if art_file is not None:
+ art_file = Path(art_file)
+ if art_file.is_absolute():
+ return art_file
+ else:
+ file = Path(self.file["file"])
+ return file.parent / art_file
+ else:
+ return Path(self.file["file"])
@cached_property
def scryfall_scan(self) -> str:
"""Scryfall large image scan, if available."""
- return self.card.get('image_uris', {}).get('large', '')
+ if self.card.image_uris:
+ return str(self.card.image_uris.large)
+ return ""
"""
* Set Data
@@ -186,65 +315,80 @@ def scryfall_scan(self) -> str:
@cached_property
def set(self) -> str:
"""Card set code, uppercase enforced, falls back to 'MTG' if missing."""
- return self.scryfall.get('set', 'MTG').upper()
+ return self.scryfall.set.upper()
@cached_property
- def set_data(self) -> dict:
+ def set_data(self) -> HexproofSet | None:
"""Set data from the current hexproof.io data file."""
- return CON.set_data.get(self.scryfall.get('set', 'mtg').lower(), {})
+ return CON.set_data.get(self.scryfall.set.lower(), None)
@cached_property
def set_type(self) -> str:
"""str: Type of set the card was printed in, e.g. promo, draft_innovation, etc."""
- return self.scryfall.get('set_type', '')
+ return self.scryfall.set_type
@cached_property
def date(self) -> date:
"""date: The date this card was released."""
- _released = self.scryfall.get('released_at')
- if not _released:
- return date.today()
- return datetime.strptime(_released, '%Y-%m-%d').date()
+ return self.scryfall.released_at
"""
* Gameplay Info
"""
@cached_property
- def card(self) -> dict:
+ def card(self) -> ScryfallCard | ScryfallCardFace:
"""Main card data object to pull most relevant data from."""
- for i, face in enumerate(self.scryfall.get('card_faces', [])):
+ if faces := self.scryfall.card_faces:
# Card with multiple faces, first index is always front side
- if normalize_str(face['name']) == normalize_str(self.input_name):
- return face
+ matching_face: ScryfallCardFace | None = None
+
+ for face in faces:
+ if normalize_str(face.name) == normalize_str(self.input_name):
+ matching_face = face
+ break
+
+ if not matching_face:
+ face_listing = "\n".join(f" - {face.name}" for face in faces)
+ _logger.warning(
+ f"None of the card faces
{
+ face_listing
+ }
matches the input file's name {
+ self.input_name
+ }.
Defaulting to first face."
+ )
+ return faces[0]
+
+ return matching_face
# Treat single face cards as front
return self.scryfall
@cached_property
- def first_print(self) -> dict:
+ def first_print(self) -> ScryfallCard | None:
"""Card data fetched from Scryfall representing the first print of this card."""
- first = get_cards_oracle(self.scryfall.get('oracle_id', ''))
- return first[0] if first else {}
+ if self.scryfall.oracle_id:
+ cards = get_cards_oracle(str(self.scryfall.oracle_id))
+ return cards[0] if cards else None
"""
* Card Collections
"""
@cached_property
- def frame_effects(self) -> list[str]:
+ def frame_effects(self) -> list[FrameEffect]:
"""Array of frame effects, e.g. nyxtouched, snow, etc."""
- return self.scryfall.get('frame_effects', [])
+ return self.scryfall.frame_effects or []
@cached_property
def keywords(self) -> list[str]:
"""Array of keyword abilities, e.g. Flying, Haste, etc."""
- return self.scryfall.get('keywords', [])
+ return self.scryfall.keywords
@cached_property
def promo_types(self) -> list[str]:
"""list[str]: Promo types this card matches, e.g. stamped, datestamped, etc."""
- return self.scryfall.get('promo_types', [])
+ return self.scryfall.promo_types or []
"""
* Text Info
@@ -253,18 +397,19 @@ def promo_types(self) -> list[str]:
@cached_property
def name(self) -> str:
"""Card name, supports alternate language source."""
- return self.card.get('printed_name', self.name_raw) if self.is_alt_lang else self.name_raw
+ return self._get_card_name(self.card)
@cached_property
def name_raw(self) -> str:
"""Card name, enforced English representation."""
- return self.card.get('name', '')
+ return self.card.name
@cached_property
def nickname(self) -> str:
"""Nickname, typically set inside template logic but can be passed in filename."""
- # Todo: Add user-provided text
- return self.card.get('flavor_name', '')
+ if isinstance(self.card, ScryfallCard):
+ return self.file["kwargs"].get("nick") or self.card.flavor_name or ""
+ return ""
@cached_property
def display_name(self) -> str:
@@ -274,42 +419,73 @@ def display_name(self) -> str:
@cached_property
def input_name(self) -> str:
"""Card name, version provided in art file name."""
- return self.file['name']
+ return self.file["name"]
@cached_property
- def mana_cost(self) -> Optional[str]:
+ def mana_cost(self) -> str:
"""Scryfall formatted card mana cost."""
- return self.card.get('mana_cost', '')
+ return self.card.mana_cost or ""
@cached_property
def oracle_text(self) -> str:
"""Card rules text, supports alternate language source."""
- return self.card.get('printed_text', self.oracle_text_raw) if self.is_alt_lang else self.oracle_text_raw
+ return (
+ self.card.printed_text
+ if self.is_alt_lang and self.card.printed_text
+ else self.oracle_text_raw
+ )
@cached_property
def oracle_text_raw(self) -> str:
"""Card rules text, enforced English representation."""
- return self.card.get('oracle_text', '')
+ txt = self.card.oracle_text or ""
+
+ if (
+ self.config.nickname_allow
+ and self.config.nickname_in_oracle_text
+ and self.nickname
+ ):
+ if "," in self.name:
+ # Some cards use a shorter name and some don't,
+ # so we have to ensure that replacements work as expected.
+ name_shortener = re.compile(r"\,.*")
+ original_short_name = name_shortener.sub("", self.name).strip()
+ txt = txt.replace(self.name, original_short_name)
+
+ if self.config.nickname_shorten_in_oracle_text == NicknameShorten.NO:
+ txt = txt.replace(original_short_name, self.nickname)
+ else:
+ nick_short_name = name_shortener.sub("", self.nickname).strip()
+ if (
+ self.config.nickname_shorten_in_oracle_text
+ == NicknameShorten.ALL_BUT_FIRST
+ ):
+ txt = txt.replace(original_short_name, self.nickname, 1)
+ txt = txt.replace(original_short_name, nick_short_name)
+ else:
+ txt = txt.replace(self.name, self.nickname)
+
+ return txt
@cached_property
def flavor_text(self) -> str:
"""Card flavor text, alternate language version shares the same key."""
- return self.card.get('flavor_text', '')
+ return self.card.flavor_text or ""
@cached_property
def rules_text(self) -> str:
"""Utility definition comprised of rules and flavor text as available."""
- return (self.oracle_text or '') + (self.flavor_text or '')
+ return (self.oracle_text or "") + (self.flavor_text or "")
@cached_property
def power(self) -> str:
"""Creature power, if provided."""
- return self.card.get('power', '')
+ return self.card.power or ""
@cached_property
def toughness(self) -> str:
"""Creature toughness, if provided."""
- return self.card.get('toughness', '')
+ return self.card.toughness or ""
"""
* Card Types
@@ -318,17 +494,21 @@ def toughness(self) -> str:
@cached_property
def type_line(self) -> str:
"""Card type line, supports alternate language source."""
- return self.card.get('printed_type_line', self.type_line_raw) if self.is_alt_lang else self.type_line_raw
+ return (
+ self.card.printed_type_line
+ if self.is_alt_lang and self.card.printed_type_line
+ else self.type_line_raw
+ )
@cached_property
def type_line_raw(self) -> str:
"""Card type line, enforced English representation."""
- return self.card.get('type_line', '')
+ return self.card.type_line or ""
@cached_property
def types_raw(self) -> list[str]:
"""List of types extracted from the raw typeline."""
- return self.type_line_raw.replace(' β', '').split(' ')
+ return self.type_line_raw.replace(" β", "").split(" ")
@cached_property
def types(self) -> list[str]:
@@ -344,9 +524,9 @@ def supertypes(self) -> list[str]:
def subtypes(self) -> list[str]:
"""Subtypes represented, e.g. Elf, Human, Goblin, etc."""
return [
- n for n in self.types_raw
- if n not in self.supertypes
- and n not in self.types
+ n
+ for n in self.types_raw
+ if n not in self.supertypes and n not in self.types
]
"""
@@ -354,14 +534,16 @@ def subtypes(self) -> list[str]:
"""
@cached_property
- def color_identity(self) -> list[str]:
+ def color_identity(self) -> list[MagicColor]:
"""Commander relevant color identity array, e.g. [W, U]."""
- return self.card.get('color_identity', [])
+ if isinstance(self.card, ScryfallCard):
+ return self.card.color_identity
+ return []
@cached_property
def color_indicator(self) -> str:
- """Color indicator identity array, e.g. [W, U]."""
- return get_ordered_colors(self.card.get('color_indicator', []))
+ """Color indicator identity , e.g. "WU"."""
+ return get_ordered_colors(self.card.color_indicator or [])
"""
* Collector Info
@@ -370,26 +552,33 @@ def color_indicator(self) -> str:
@cached_property
def symbol_code(self) -> str:
"""Code used to match a symbol to this card's set. Provided by hexproof.io."""
- if CFG.symbol_force_default:
- return CFG.symbol_default.upper()
- return self.set_data.get('code_symbol', 'DEFAULT').upper()
+ forced_symbol = self.file["kwargs"].get("sym", None)
+ if forced_symbol:
+ return forced_symbol.upper()
+ if self.config.symbol_force_default:
+ return self.config.symbol_default.upper()
+ if self.set_data:
+ return self.set_data.code_symbol.upper()
+ return "DEFAULT"
@cached_property
def lang(self) -> str:
"""Card print language, uppercase enforced, falls back to settings defined value."""
- return self.scryfall.get('lang', CFG.lang).upper()
+ return self.scryfall.lang.upper()
@cached_property
def rarity(self) -> str:
"""Card rarity, interprets 'special' rarities based on card data."""
- return self.rarity_raw if self.rarity_raw in [
- Rarity.C, Rarity.U, Rarity.R, Rarity.M, Rarity.T
- ] else get_special_rarity(self.rarity_raw, self.scryfall)
+ return (
+ self.rarity_raw
+ if self.rarity_raw in [Rarity.C, Rarity.U, Rarity.R, Rarity.M, Rarity.T]
+ else get_special_rarity(self.rarity_raw, self.scryfall)
+ )
@cached_property
def rarity_raw(self) -> str:
"""Card rarity, doesn't interpret 'special' rarities."""
- return self.scryfall.get('rarity', Rarity.C)
+ return self.scryfall.rarity
@cached_property
def rarity_letter(self) -> str:
@@ -399,112 +588,133 @@ def rarity_letter(self) -> str:
@cached_property
def artist(self) -> str:
"""Card artist name, prioritizes user provided artist name. Controls for duplicate last names."""
- if self.file.get('artist'):
- return self.file['artist']
+ if self.file.get("artist"):
+ return self.file["artist"]
+
+ artist = self.card.artist
+ count: set[str] = set()
# Check for duplicate last names
- artist, count = self.card.get('artist', 'Unknown'), []
- if '&' in artist:
- for w in artist.split(' '):
- if w in count:
- count.remove(w)
- count.append(w)
- return ' '.join(count)
- return artist
+ if isinstance(artist, str) and "&" in artist:
+ for w in artist.split(" "):
+ count.add(w)
+ return " ".join(count)
+
+ return artist or "Unknown"
@cached_property
def collector_number(self) -> int:
"""int: Card number assigned within release set. Non-digit characters are ignored, falls back to 0."""
if self.collector_number_raw:
- return int(''.join(char for char in self.collector_number_raw if char.isdigit()))
+ return int(
+ "".join(char for char in self.collector_number_raw if char.isdigit())
+ )
return 0
@cached_property
- def collector_number_raw(self) -> Optional[str]:
+ def collector_number_raw(self) -> str | None:
"""str | None: Card number assigned within release set. Raw string representation, allows non-digits."""
- return self.scryfall.get('collector_number')
+ return self.scryfall.collector_number
@cached_property
- def card_count(self) -> Optional[int]:
- """int | None: Number of cards within the card's release set. Only required in 'Normal' Collector Mode."""
+ def card_count(self) -> int | None:
+ """int | None: Number of cards within the card's release set."""
- # Skip if collector mode doesn't require it or if collector number is bad
- if CFG.collector_mode != CollectorMode.Normal or not self.collector_number_raw:
+ # Skip if collector number is bad
+ if not self.collector_number_raw:
return
# Prefer printed count, fallback to card count, skip if count isn't found
- count = self.set_data.get('count_printed', self.set_data.get('count_cards'))
- if count is None:
+ if not self.set_data:
return
+ count = self.set_data.count_printed or self.set_data.count_cards
# Skip if count is smaller than collector number
- return count if int(count) >= self.collector_number else None
+ return count if count >= self.collector_number else None
@cached_property
def collector_data(self) -> str:
- """str: Formatted collector info line, e.g. 050/230 M."""
- if self.card_count:
+ """str: Formatted collector info line, e.g. '050/230 M' or 'M 0050'."""
+ if self.card_count and self.config.collector_mode == CollectorMode.Normal:
return f"{str(self.collector_number).zfill(3)}/{str(self.card_count).zfill(3)} {self.rarity_letter}"
if self.collector_number_raw:
return f"{self.rarity_letter} {str(self.collector_number).zfill(4)}"
- return ''
+ return ""
@cached_property
def creator(self) -> str:
"""str: Optional creator string provided by user in art file name."""
- return self.file.get('creator', '')
+ return self.file.get("creator", "")
"""
* Symbols
"""
@cached_property
- def symbol_svg(self) -> Optional[Path]:
+ def symbol_svg(self) -> Path | None:
"""SVG path definition for card's expansion symbol."""
+ if self.config.symbol_force_rarity:
+ path = (
+ PATH.SRC_IMG_SYMBOLS
+ / "set"
+ / self.set
+ / self.config.symbol_force_rarity
+ ).with_suffix(".svg")
+ if path.is_file():
+ return path
# If code is default, perform replacement and check if we have a local asset first
- if self.symbol_code == 'DEFAULT':
- if not CFG.symbol_force_default:
- path = (PATH.SRC_IMG_SYMBOLS / 'set' / self.set / self.rarity_letter).with_suffix('.svg')
+ if self.symbol_code == "DEFAULT":
+ if not self.config.symbol_force_default:
+ path = (
+ PATH.SRC_IMG_SYMBOLS / "set" / self.set / self.rarity_letter
+ ).with_suffix(".svg")
if path.is_file():
return path
- self.symbol_code = CFG.symbol_default.upper()
+ self.symbol_code = self.config.symbol_default.upper()
# Does SVG exist?
- path = (PATH.SRC_IMG_SYMBOLS / 'set' / self.symbol_code / self.rarity_letter).with_suffix('.svg')
+ path = (
+ PATH.SRC_IMG_SYMBOLS / "set" / self.symbol_code / self.rarity_letter
+ ).with_suffix(".svg")
if path.is_file():
return path
# Revert to mythic for special rarities
if self.rarity not in [Rarity.C, Rarity.U, Rarity.R, Rarity.M]:
- path = (PATH.SRC_IMG_SYMBOLS / 'set' / self.symbol_code / 'M').with_suffix('.svg')
+ path = (PATH.SRC_IMG_SYMBOLS / "set" / self.symbol_code / "M").with_suffix(
+ ".svg"
+ )
if path.is_file():
return path
# Revert to default symbol or None
- path = (PATH.SRC_IMG_SYMBOLS / 'set' / 'DEFAULT' / self.rarity_letter).with_suffix('.svg')
+ path = (
+ PATH.SRC_IMG_SYMBOLS / "set" / "DEFAULT" / self.rarity_letter
+ ).with_suffix(".svg")
if path.is_file():
return path
return
@cached_property
- def watermark(self) -> Optional[str]:
+ def watermark(self) -> str | None:
"""Name of the card's watermark file that is actually used, if provided."""
if not self.watermark_svg:
return
- if self.watermark_svg.stem.upper() == 'WM':
+ if self.watermark_svg.stem.upper() == "WM":
return self.watermark_svg.parent.stem.lower()
return self.watermark_svg.stem.lower()
@cached_property
- def watermark_raw(self) -> Optional[str]:
+ def watermark_raw(self) -> str | None:
"""Name of the card's watermark from raw Scryfall data, if provided."""
- return self.card.get('watermark')
+ return self.card.watermark
@cached_property
- def watermark_svg(self) -> Optional[Path]:
+ def watermark_svg(self) -> Path | None:
"""Path to the watermark SVG file, if provided."""
- def _find_watermark_svg(wm: str) -> Optional[Path]:
+
+ def _find_watermark_svg(wm: str) -> Path | None:
"""Try to find a watermark SVG asset, allowing for special cases and set code fallbacks.
Args:
@@ -522,43 +732,45 @@ def _find_watermark_svg(wm: str) -> Optional[Path]:
wm = wm.lower()
# Special case watermarks
- if wm in ['set', 'symbol']:
+ if self.first_print and wm in ["set", "symbol"]:
return get_watermark_svg_from_set(
- self.first_print.get('set', self.set) if wm == 'set' else self.set)
+ self.first_print.set if wm == "set" else self.set
+ )
# Look for normal watermark
return get_watermark_svg(wm)
# WatermarkMode: Disabled or Forced
- if CFG.watermark_mode == WatermarkMode.Disabled:
+ if self.config.watermark_mode == WatermarkMode.Disabled:
return
- elif CFG.watermark_mode == WatermarkMode.Forced:
- return _find_watermark_svg(CFG.watermark_default)
+ elif self.config.watermark_mode == WatermarkMode.Forced:
+ return _find_watermark_svg(self.config.watermark_default)
# WatermarkMode: Automatic
- path = _find_watermark_svg(self.watermark_raw)
- if path or CFG.watermark_mode == WatermarkMode.Automatic:
+ path = _find_watermark_svg(self.watermark_raw) if self.watermark_raw else None
+ if path or self.config.watermark_mode == WatermarkMode.Automatic:
return path
# WatermarkMode: Fallback
- return _find_watermark_svg(CFG.watermark_default)
+ return _find_watermark_svg(self.config.watermark_default)
@cached_property
- def watermark_basic(self) -> Optional[Path]:
+ def watermark_basic(self) -> Path | None:
"""Optional[Path]: Path to basic land watermark, if card is a Basic Land."""
if not self.is_basic_land:
return
# Map pinlines to basic land type
_map = {
- 'W': 'plains',
- 'U': 'island',
- 'B': 'swamp',
- 'R': 'mountain',
- 'G': 'forest',
- 'Land': 'wastes'}
+ "W": "plains",
+ "U": "island",
+ "B": "swamp",
+ "R": "mountain",
+ "G": "forest",
+ "Land": "wastes",
+ }
if basic_type := _map.get(self.pinlines):
- return (PATH.SRC_IMG_SYMBOLS / 'watermark' / basic_type).with_suffix('.svg')
+ return (PATH.SRC_IMG_SYMBOLS / "watermark" / basic_type).with_suffix(".svg")
return
"""
@@ -573,44 +785,44 @@ def is_creature(self) -> bool:
@cached_property
def is_land(self) -> bool:
"""True if card is a Land."""
- return 'Land' in self.type_line_raw
+ return "Land" in self.type_line_raw
@cached_property
def is_basic_land(self) -> bool:
"""True if card is a Basic Land."""
- return self.type_line_raw.startswith('Basic')
+ return self.type_line_raw.startswith("Basic")
@cached_property
def is_legendary(self) -> bool:
"""True if card is Legendary."""
- return 'Legendary' in self.type_line_raw
+ return "Legendary" in self.type_line_raw
@cached_property
def is_colorless(self) -> bool:
"""True if card is colorless or devoid."""
- return self.frame['is_colorless']
+ return self.frame["is_colorless"]
@cached_property
def is_hybrid(self) -> bool:
"""True if card is a hybrid frame."""
- return self.frame['is_hybrid']
+ return self.frame["is_hybrid"]
@cached_property
def is_artifact(self) -> bool:
"""True if card is an Artifact."""
- return 'Artifact' in self.type_line_raw
+ return "Artifact" in self.type_line_raw
@cached_property
def is_vehicle(self) -> bool:
"""True if card is a Vehicle."""
- return 'Vehicle' in self.type_line_raw
+ return "Vehicle" in self.type_line_raw
@cached_property
def is_promo(self) -> bool:
"""True if card is a promotional print."""
- if self.scryfall.get('promo', False):
+ if self.scryfall.promo:
return True
- if self.set_type == 'promo':
+ if self.set_type == "promo":
return True
if self.promo_types:
return True
@@ -619,12 +831,12 @@ def is_promo(self) -> bool:
@cached_property
def is_front(self) -> bool:
"""True if card is front face."""
- return bool(self.scryfall.get('front', True))
+ return self.scryfall.front
@cached_property
def is_alt_lang(self) -> bool:
"""True if language selected isn't English."""
- return bool(self.lang != 'EN')
+ return self.config.use_printed_texts or bool(self.lang != "EN")
"""
* Cosmetic Bool
@@ -633,20 +845,18 @@ def is_alt_lang(self) -> bool:
@cached_property
def is_token(self) -> bool:
"""bool: True if card is a Token or Emblem."""
- return bool('Token' in self.type_line_raw or self.is_emblem)
+ return bool("Token" in self.type_line_raw or self.is_emblem)
@cached_property
def is_emblem(self) -> bool:
"""bool: True on card is an Emblem."""
- return bool('Emblem' in self.type_line_raw)
+ return bool("Emblem" in self.type_line_raw)
@cached_property
def is_nyx(self) -> bool:
"""True if card has Nyx enchantment background texture."""
- if 'nyxtouched' in self.frame_effects:
- return True
- # Nyxtouched often not provided, check for 'Enchantment Creature'
- return bool(self.is_creature and 'Enchantment' in self.type_line_raw)
+ # Check for 'Enchantment Creature'
+ return bool(self.is_creature and "Enchantment" in self.type_line_raw)
@cached_property
def is_companion(self) -> bool:
@@ -656,12 +866,12 @@ def is_companion(self) -> bool:
@cached_property
def is_miracle(self) -> bool:
"""True if card is a 'Miracle' card."""
- return bool("Miracle" in self.frame_effects)
+ return bool("miracle" in self.frame_effects)
@cached_property
def is_snow(self) -> bool:
"""True if card is a 'Snow' card."""
- return bool('Snow' in self.type_line_raw)
+ return bool("Snow" in self.type_line_raw)
"""
* Frame Details
@@ -675,22 +885,22 @@ def frame(self) -> FrameDetails:
@cached_property
def twins(self) -> str:
"""Identity of the name and title boxes."""
- return self.frame['twins']
+ return self.frame["twins"]
@cached_property
def pinlines(self) -> str:
"""Identity of the pinlines."""
- return self.frame['pinlines']
+ return self.frame["pinlines"]
@cached_property
def background(self) -> str:
"""Identity of the background."""
- return self.frame['background']
+ return self.frame["background"]
@cached_property
def identity(self) -> str:
"""Frame appropriate color identity of the card."""
- return self.frame['identity']
+ return self.frame["identity"]
"""
* Opposing Face Properties
@@ -703,93 +913,105 @@ def transform_icon(self) -> str:
if effect in TransformIcons:
return effect
# Cover special Havengul Lab case
- if self.scryfall.get('oracle_id') == 'e71ac446-02a4-4468-8d29-f28b21617665':
+ if self.scryfall.oracle_id == "e71ac446-02a4-4468-8d29-f28b21617665":
return TransformIcons.UPSIDEDOWN
# All other cards use the triangle (originally: `convertdfc`)
return TransformIcons.CONVERT
@cached_property
- def other_face(self) -> dict:
+ def other_face(self) -> ScryfallCardFace | None:
"""Card data from opposing face if provided."""
- for face in self.scryfall.get('card_faces', []):
- if face.get('name') != self.name_raw:
+ for face in self.scryfall.card_faces or []:
+ if face.name != self.name_raw:
return face
- return {}
@cached_property
- def other_face_frame(self) -> Union[FrameDetails, dict]:
+ def other_face_frame(self) -> FrameDetails | dict[str, Any]:
"""Calculated frame information of opposing face, if provided."""
return get_frame_details(self.other_face) if self.other_face else {}
@cached_property
def other_face_twins(self) -> str:
"""Name and title box identity of opposing face."""
- return self.other_face_frame.get('twins', '')
+ return self.other_face_frame.get("twins", "")
@cached_property
def other_face_mana_cost(self) -> str:
"""Mana cost of opposing face."""
- return self.other_face.get('mana_cost', '')
+ if self.other_face:
+ return self.other_face.mana_cost or ""
+ return ""
@cached_property
def other_face_type_line(self) -> str:
"""Type line of opposing face."""
- return self.other_face.get('type_line', '')
+ if self.other_face:
+ return self.other_face.type_line or ""
+ return ""
@cached_property
def other_face_type_line_raw(self) -> str:
"""Type line of opposing face, English language enforced."""
- if self.is_alt_lang:
- return self.other_face.get('printed_type_line', self.other_face_type_line)
+ if self.other_face and self.is_alt_lang:
+ return self.other_face.printed_type_line or self.other_face_type_line
return self.other_face_type_line
@cached_property
def other_face_oracle_text(self) -> str:
"""Rules text of opposing face."""
- if self.is_alt_lang:
- return self.other_face.get('printed_text', self.other_face_oracle_text_raw)
+ if self.other_face and self.is_alt_lang:
+ return self.other_face.printed_text or self.other_face_oracle_text_raw
return self.other_face_oracle_text_raw
@cached_property
def other_face_oracle_text_raw(self) -> str:
"""Rules text of opposing face."""
- return self.other_face.get('oracle_text', '')
+ if self.other_face:
+ return self.other_face.oracle_text or ""
+ return ""
@cached_property
def other_face_power(self) -> str:
"""Creature power of opposing face, if provided."""
- return self.other_face.get('power', '')
+ if self.other_face:
+ return self.other_face.power or ""
+ return ""
@cached_property
def other_face_toughness(self) -> str:
"""Creature toughness of opposing face, if provided."""
- return self.other_face.get('toughness', '')
+ if self.other_face:
+ return self.other_face.toughness or ""
+ return ""
@cached_property
- def other_face_left(self) -> Optional[str]:
+ def other_face_left(self) -> str:
"""Abridged type of the opposing side to display on bottom MDFC bar."""
- return self.other_face_type_line_raw.split(' ')[-1] if self.other_face else ''
+ return self.other_face_type_line_raw.split(" ")[-1] if self.other_face else ""
@cached_property
def other_face_right(self) -> str:
"""Mana cost or mana ability of opposing side, depending on land or nonland."""
if not self.other_face:
- return ''
+ return ""
# Other face is not a land
- if 'Land' not in self.other_face_type_line_raw:
+ if "Land" not in self.other_face_type_line_raw:
return self.other_face_mana_cost
# Other face is a land, find the mana tap ability
- for line in self.other_face_oracle_text.split('\n'):
- if line.startswith('{T}'):
+ for line in self.other_face_oracle_text.split("\n"):
+ if line.startswith("{T}"):
return f"{line.split('.')[0]}."
return self.other_face_oracle_text
class MutateLayout(NormalLayout):
"""Mutate card layout introduced in Ikoria: Lair of Behemoths."""
- card_class: str = LayoutType.Mutate
+
+ @cached_property
+ def type(self) -> LayoutType:
+ return LayoutType.Mutate
"""
* Text Info
@@ -798,7 +1020,11 @@ class MutateLayout(NormalLayout):
@cached_property
def oracle_text_unprocessed(self) -> str:
"""str: Unaltered text to split between oracle and mutate."""
- return self.card.get('printed_text', self.oracle_text_raw) if self.is_alt_lang else self.oracle_text_raw
+ return (
+ self.card.printed_text
+ if self.is_alt_lang and self.card.printed_text
+ else self.oracle_text_raw
+ )
@cached_property
def oracle_text(self) -> str:
@@ -817,7 +1043,10 @@ def mutate_text(self) -> str:
class PrototypeLayout(NormalLayout):
"""Prototype card layout, introduced in The Brothers' War."""
- card_class: str = LayoutType.Prototype
+
+ @cached_property
+ def type(self) -> LayoutType:
+ return LayoutType.Prototype
"""
* Text Info
@@ -825,32 +1054,34 @@ class PrototypeLayout(NormalLayout):
@cached_property
def oracle_text(self) -> str:
- return self.proto_details['oracle_text']
+ return self.proto_details["oracle_text"]
"""
* Prototype Properties
"""
@cached_property
- def proto_details(self) -> dict:
+ def proto_details(self) -> ProtoDetails:
"""Returns dictionary containing prototype data and separated oracle text."""
- proto_text, rules_text = self.card.get('oracle_text').split("\n", 1)
+ proto_text, rules_text = (
+ self.card.oracle_text.split("\n", 1) if self.card.oracle_text else ("", "")
+ )
match = CardTextPatterns.PROTOTYPE.match(proto_text)
return {
- 'oracle_text': rules_text,
- 'mana_cost': match[1],
- 'pt': match[2]
+ "oracle_text": rules_text,
+ "mana_cost": match[1] if match else "",
+ "pt": match[2] if match else "",
}
@cached_property
def proto_mana_cost(self) -> str:
"""Mana cost of card when case with Prototype Ability."""
- return self.proto_details['mana_cost']
+ return self.proto_details["mana_cost"]
@cached_property
def proto_pt(self) -> str:
"""Power/Toughness of card when cast with Prototype ability."""
- return self.proto_details['pt']
+ return self.proto_details["pt"]
"""
* Prototype Colors
@@ -859,12 +1090,17 @@ def proto_pt(self) -> str:
@cached_property
def proto_color(self) -> str:
"""Color to use for colored prototype frame elements."""
- return self.color_identity[0] if len(self.color_identity) > 0 else LAYERS.ARTIFACT
+ return (
+ self.color_identity[0] if len(self.color_identity) > 0 else LAYERS.ARTIFACT
+ )
class PlaneswalkerLayout(NormalLayout):
"""Planeswalker card layout introduced in Lorwyn block."""
- card_class: str = LayoutType.Planeswalker
+
+ @cached_property
+ def type(self) -> LayoutType:
+ return LayoutType.Planeswalker
"""
* Text Info
@@ -874,7 +1110,9 @@ class PlaneswalkerLayout(NormalLayout):
def oracle_text(self) -> str:
"""Fix Scryfall's minus character."""
if self.is_alt_lang:
- return self.card.get('printed_text', self.card.get('oracle_text', '')).replace("\u2212", "-")
+ return (self.card.printed_text or self.card.oracle_text or "").replace(
+ "\u2212", "-"
+ )
return self.oracle_text_raw
@cached_property
@@ -889,7 +1127,7 @@ def oracle_text_raw(self) -> str:
@cached_property
def loyalty(self) -> str:
"""Planeswalker starting loyalty."""
- return self.card.get('loyalty', '')
+ return self.card.loyalty or ""
@cached_property
def pw_size(self) -> int:
@@ -901,47 +1139,49 @@ def pw_size(self) -> int:
return 3 if len(self.pw_abilities) <= 3 else 4
@cached_property
- def pw_abilities(self) -> list[dict]:
+ def pw_abilities(self) -> list[PlaneswalkerAbility]:
"""Processes Planeswalker text into listed abilities."""
- lines = CardTextPatterns.PLANESWALKER.findall(self.oracle_text_raw)
+ lines = CardTextPatterns.PLANESWALKER.findall(self.oracle_text)
en_lines = lines.copy()
# Process alternate language lines if needed
- if self.is_alt_lang and 'printed_text' in self.card:
-
+ if self.is_alt_lang and self.card.printed_text:
# Separate alternate language lines
- alt_lines = self.oracle_text.split('\n')
+ alt_lines = self.oracle_text.split("\n")
new_lines: list[str] = []
for line in lines:
-
# Ensure number of breaks matches alternate text
- breaks = line.count('\n') + 1
+ breaks = line.count("\n") + 1
if not alt_lines or not (len(alt_lines) >= breaks):
new_lines = lines
break
# Slice and add lines
- new_lines.append('\n'.join(alt_lines[:breaks]))
+ new_lines.append("\n".join(alt_lines[:breaks]))
alt_lines = alt_lines[breaks:]
# Replace with alternate language lines
lines = new_lines
# Create list of ability dictionaries
- abilities: list[dict] = []
+ abilities: list[PlaneswalkerAbility] = []
for i, line in enumerate(lines):
index = en_lines[i].find(": ")
- abilities.append({
- # Activated ability
- 'text': line[index + 1:].lstrip(),
- 'icon': en_lines[i][0],
- 'cost': en_lines[i][0:index]
- } if 5 > index > 0 else {
- # Static ability
- 'text': line,
- 'icon': None,
- 'cost': None
- })
+ abilities.append(
+ {
+ # Activated ability
+ "text": line[index + 1 :].lstrip(),
+ "icon": en_lines[i][0],
+ "cost": en_lines[i][0:index],
+ }
+ if 5 > index > 0
+ else {
+ # Static ability
+ "text": line,
+ "icon": "",
+ "cost": "",
+ }
+ )
return abilities
@@ -949,34 +1189,48 @@ class PlaneswalkerTransformLayout(PlaneswalkerLayout):
"""Transform version of the Planeswalker card layout introduced in Innistrad block."""
# Static properties
- is_transform: bool = True
+ @cached_property
+ def is_transform(self) -> bool:
+ return True
@cached_property
- def card_class(self) -> str:
+ def type(self) -> LayoutType:
"""Card class separated by card face."""
- return LayoutType.PlaneswalkerTransformFront if self.is_front else LayoutType.PlaneswalkerTransformBack
+ return (
+ LayoutType.PlaneswalkerTransformFront
+ if self.is_front
+ else LayoutType.PlaneswalkerTransformBack
+ )
class PlaneswalkerMDFCLayout(PlaneswalkerLayout):
"""MDFC version of the Planeswalker card layout introduced in Kaldheim."""
# Static properties
- is_mdfc: bool = True
+ @cached_property
+ def is_mdfc(self) -> bool:
+ return True
@cached_property
- def card_class(self) -> str:
+ def type(self) -> LayoutType:
"""Card class separated by card face."""
- return LayoutType.PlaneswalkerMDFCFront if self.is_front else LayoutType.PlaneswalkerMDFCBack
+ return (
+ LayoutType.PlaneswalkerMDFCFront
+ if self.is_front
+ else LayoutType.PlaneswalkerMDFCBack
+ )
class TransformLayout(NormalLayout):
"""Transform card layout, introduced in Innistrad block."""
# Static properties
- is_transform: bool = True
+ @cached_property
+ def is_transform(self) -> bool:
+ return True
@cached_property
- def card_class(self) -> str:
+ def type(self) -> LayoutType:
"""Card class separated by card face, also supports special Ixalan lands."""
return LayoutType.TransformFront if self.is_front else LayoutType.TransformBack
@@ -985,10 +1239,12 @@ class ModalDoubleFacedLayout(NormalLayout):
"""Modal Double Faced card layout, introduced in Zendikar Rising."""
# Static properties
- is_mdfc: bool = True
+ @cached_property
+ def is_mdfc(self) -> bool:
+ return True
@cached_property
- def card_class(self) -> str:
+ def type(self) -> LayoutType:
"""Card class separated by card face."""
return LayoutType.MDFCFront if self.is_front else LayoutType.MDFCBack
@@ -999,25 +1255,32 @@ def card_class(self) -> str:
@cached_property
def oracle_text(self) -> str:
"""On MDFC alternate language data contains text from both sides, separate them."""
- if self.is_alt_lang and 'printed_text' in self.card:
+ if self.is_alt_lang and self.card.printed_text:
return get_lines(
- self.card.get('printed_text', ''),
- self.oracle_text_raw.count('\n') + 1)
+ self.card.printed_text, self.oracle_text_raw.count("\n") + 1
+ )
return self.oracle_text_raw
class AdventureLayout(NormalLayout):
"""Adventure card layout, introduced in Throne of Eldraine."""
- card_class: str = LayoutType.Adventure
+
+ @cached_property
+ def type(self) -> LayoutType:
+ return LayoutType.Adventure
"""
* Core Data
"""
@cached_property
- def adventure(self) -> dict:
+ def adventure(self) -> ScryfallCardFace:
"""Card object for adventure side."""
- return self.scryfall['card_faces'][1]
+ if self.scryfall.card_faces:
+ return self.scryfall.card_faces[1]
+ raise ValueError(
+ f"Scryfall data doesn't have a card face for Adventure side: {pprint(self.scryfall)}"
+ )
"""
* Adventure Text
@@ -1026,33 +1289,31 @@ def adventure(self) -> dict:
@cached_property
def mana_adventure(self) -> str:
"""Mana cost of the adventure side."""
- return self.adventure['mana_cost']
+ return self.adventure.mana_cost or ""
@cached_property
def name_adventure(self) -> str:
"""Name of the Adventure side."""
- if self.is_alt_lang and 'printed_name' in self.adventure:
- return self.adventure.get('printed_name', '')
- return self.adventure.get('name', '')
+ return self._get_card_name(self.adventure)
@cached_property
def type_line_adventure(self) -> str:
"""Type line of the Adventure side."""
- if self.is_alt_lang and 'printed_type_line' in self.adventure:
- return self.adventure.get('printed_type_line', '')
- return self.adventure.get('type_line', '')
+ if self.is_alt_lang and self.adventure.printed_type_line:
+ return self.adventure.printed_type_line
+ return self.adventure.type_line or ""
@cached_property
def oracle_text_adventure(self) -> str:
"""Oracle text of the Adventure side."""
- if self.is_alt_lang and 'printed_text' in self.adventure:
- return self.adventure.get('printed_text', '')
- return self.adventure.get('oracle_text', '')
+ if self.is_alt_lang and self.adventure.printed_text:
+ return self.adventure.printed_text
+ return self.adventure.oracle_text or ""
@cached_property
def flavor_text_adventure(self) -> str:
"""Flavor text of the Adventure side."""
- return self.adventure.get('flavor_text', '')
+ return self.adventure.flavor_text or ""
"""
* Adventure Colors
@@ -1061,72 +1322,78 @@ def flavor_text_adventure(self) -> str:
@cached_property
def color_identity_adventure(self) -> list[str]:
"""Colors present in the adventure side mana cost."""
- return [n for n in get_mana_cost_colors(self.mana_adventure)]
+ return [
+ n for n in get_ordered_colors(get_mana_cost_colors(self.mana_adventure))
+ ]
@cached_property
def adventure_colors(self) -> str:
"""Color identity of adventure side frame elements."""
- if check_hybrid_mana_cost(self.color_identity_adventure, self.mana_adventure):
- return LAYERS.LAND
- if len(self.color_identity_adventure) > 1:
- return LAYERS.GOLD
- if not self.color_identity_adventure:
- return LAYERS.COLORLESS
- return self.color_identity_adventure[0]
+ return _get_other_side_color_identity(
+ self.color_identity_adventure, self.mana_adventure
+ )
class LevelerLayout(NormalLayout):
"""Leveler card layout, introduced in Rise of the Eldrazi."""
- card_class: str = LayoutType.Leveler
+
+ @cached_property
+ def type(self) -> LayoutType:
+ return LayoutType.Leveler
"""
* Leveler Text
"""
@cached_property
- def leveler_match(self) -> Optional[Match[str]]:
+ def leveler_match(self) -> Match[str] | None:
"""Unpack leveler text fields from oracle text string."""
return CardTextPatterns.LEVELER.match(self.oracle_text)
@cached_property
def level_up_text(self) -> str:
"""Main text that defines the 'Level up' cost."""
- return self.leveler_match[1] if self.leveler_match else ''
+ return self.leveler_match[1] if self.leveler_match else ""
@cached_property
def middle_level(self) -> str:
"""Level number(s) requirement of middle stage, e.g. 1-2."""
- return self.leveler_match[2] if self.leveler_match else ''
+ return self.leveler_match[2] if self.leveler_match else ""
@cached_property
def middle_power_toughness(self) -> str:
"""Creature Power/Toughness applied at middle stage."""
- return self.leveler_match[3] if self.leveler_match else ''
+ return self.leveler_match[3] if self.leveler_match else ""
@cached_property
def middle_text(self) -> str:
"""Rules text applied at middle stage."""
- return self.leveler_match[4] if self.leveler_match else ''
+ return self.leveler_match[4] if self.leveler_match else ""
@cached_property
def bottom_level(self) -> str:
"""Level number(s) requirement of bottom stage, e.g. 5+."""
- return self.leveler_match[5] if self.leveler_match else ''
+ return self.leveler_match[5] if self.leveler_match else ""
@cached_property
def bottom_power_toughness(self) -> str:
"""Creature Power/Toughness applied at bottom stage."""
- return self.leveler_match[6] if self.leveler_match else ''
+ return self.leveler_match[6] if self.leveler_match else ""
@cached_property
def bottom_text(self) -> str:
"""Rules text applied at bottom stage."""
- return self.leveler_match[7] if self.leveler_match else ''
+ return self.leveler_match[7] if self.leveler_match else ""
class SagaLayout(NormalLayout):
"""Saga card layout, introduced in Dominaria."""
- card_class: str = LayoutType.Saga
+
+ @cached_property
+ def type(self) -> LayoutType:
+ return LayoutType.Saga
+
+ _chapter_regex = re.compile(r"^[ ,IVXLCDM]+ β ")
"""
* Bool Properties
@@ -1134,8 +1401,8 @@ class SagaLayout(NormalLayout):
@cached_property
def is_transform(self) -> bool:
- """Sage supports both single and double faced cards."""
- return bool('card_faces' in self.scryfall)
+ """Saga supports both single and double faced cards."""
+ return bool(self.scryfall.card_faces)
"""
* Saga Properties
@@ -1143,42 +1410,64 @@ def is_transform(self) -> bool:
@cached_property
def saga_text(self) -> str:
- """Text comprised of saga ability lines."""
- return strip_lines(self.oracle_text, 1)
+ """Text comprised of Saga ability lines."""
+ return "\n".join(
+ text
+ for text in self.oracle_text.splitlines()
+ if self._chapter_regex.match(text)
+ )
+
+ @cached_property
+ def ability_text(self) -> str:
+ """
+ Rules text that's separate from the Saga chapters.
+ Seen e.g. in Saga creatures.
+ """
+ # Drop reminder text or first chapter text. Which was dropped
+ # doesn't matter as long as there's at least one chapter.
+ stripped = strip_lines(self.oracle_text, 1)
+ # Return all text that comes after chapter lines.
+ lines = stripped.splitlines()
+ for idx, line in enumerate(lines):
+ if not self._chapter_regex.match(line):
+ return "\n".join(lines[idx:])
+ return ""
@cached_property
def saga_description(self) -> str:
"""Description at the top of the Saga card"""
- return get_line(self.oracle_text, 0)
+ if self.config.remove_reminder:
+ return ""
+ line = get_line(self.oracle_text, 0)
+ return "" if self._chapter_regex.match(line) else line
@cached_property
- def saga_lines(self) -> list[dict]:
+ def saga_lines(self) -> list[SagaLine]:
"""Unpack saga text into list of dictionaries containing ability text and icons."""
- abilities: list[dict] = []
+ abilities: list[SagaLine] = []
for i, line in enumerate(self.saga_text.split("\n")):
# Full ability line
if "β" in line:
icons, text = line.split("β", 1)
- abilities.append({
- "text": text.strip(),
- "icons": icons.strip().split(", ")
- })
+ abilities.append(
+ {"text": text.strip(), "icons": icons.strip().split(", ")}
+ )
continue
# Static text line, "Greatest Show in the Multiverse"
if i == 0:
- abilities.append({
- 'text': line,
- 'icons': []
- })
+ abilities.append({"text": line, "icons": []})
continue
# Part of a previous ability line
- abilities[-1]['text'] += f"\n{line}"
+ abilities[-1]["text"] += f"\n{line}"
return abilities
class ClassLayout(NormalLayout):
"""Class card layout, introduced in Adventures in the Forgotten Realms."""
- card_class: str = LayoutType.Class
+
+ @cached_property
+ def type(self) -> LayoutType:
+ return LayoutType.Class
"""
* Class Properties
@@ -1187,40 +1476,69 @@ class ClassLayout(NormalLayout):
@cached_property
def class_text(self) -> str:
"""Text comprised of class ability lines."""
- return strip_lines(self.oracle_text, 1)
+ return (
+ self.oracle_text
+ if self.config.remove_reminder
+ else strip_lines(self.oracle_text, 1)
+ )
@cached_property
def class_description(self) -> str:
"""Description at the top of the Class card."""
- return get_line(self.oracle_text, 0)
+ return "" if self.config.remove_reminder else get_line(self.oracle_text, 0)
@cached_property
- def class_lines(self) -> list[dict]:
+ def class_lines(self) -> list[ClassLine]:
"""Unpack class text into list of dictionaries containing ability levels, cost, and text."""
# Initial class ability
- initial, *lines = self.class_text.split('\n')
- abilities: list[dict] = [{'text': initial, 'cost': None, 'level': 1}]
+ initial, *lines = self.class_text.split("\n")
+ # Non-English Class cards have extra //Level_X// after first line of each level
+ lines = [
+ line for line in lines if not CardTextPatterns.CLASS_NON_ENGLISH.match(line)
+ ]
+ abilities: list[ClassLine] = [
+ {"text": initial, "cost": None, "level": "1", "level_translation": ""}
+ ]
# Add level-up abilities
- for line in ["\n".join(lines[i:i + 2]) for i in range(0, len(lines), 2)]:
+ for line in ["\n".join(lines[i : i + 2]) for i in range(0, len(lines), 2)]:
# Try to match this line to a class ability
details = CardTextPatterns.CLASS.match(line)
if details and len(details.groups()) >= 3:
- abilities.append({
- 'cost': details[1],
- 'level': details[2],
- 'text': details[3]
- })
+ abilities.append(
+ {
+ "cost": details[1],
+ "level_translation": details[2],
+ "level": details[3],
+ "text": details[4],
+ }
+ )
continue
# Otherwise add line to the previous ability
- abilities[-1]['text'] += f'\n{line}'
+ abilities[-1]["text"] += f"\n{line}"
return abilities
+class CaseLayout(NormalLayout):
+ """Case card layout, introduced in Murders at Karlov Manor."""
+
+ @cached_property
+ def type(self) -> LayoutType:
+ return LayoutType.Case
+
+ @cached_property
+ def case_lines(self) -> list[str]:
+ """Split Case text into sections."""
+ return self.oracle_text.split("\n")
+
+
class BattleLayout(TransformLayout):
"""Battle card layout, introduced in March of the Machine."""
- card_class: str = LayoutType.Battle
+
+ @cached_property
+ def type(self) -> LayoutType:
+ return LayoutType.Battle
"""
* Text Info
@@ -1229,123 +1547,148 @@ class BattleLayout(TransformLayout):
@cached_property
def defense(self) -> str:
"""Battle card defense."""
- return self.card.get('defense', '')
+ return self.card.defense or ""
class PlanarLayout(NormalLayout):
"""Planar card layout, introduced in Planechase block."""
- card_class: str = LayoutType.Planar
+
+ @cached_property
+ def type(self) -> LayoutType:
+ return LayoutType.Planar
class SplitLayout(NormalLayout):
"""Split card layout, introduced in Invasion."""
- card_class: str = LayoutType.Split
+
+ @cached_property
+ def type(self) -> LayoutType:
+ return LayoutType.Split
# Static properties
- is_nyx: bool = False
- is_land: bool = False
- is_basic_land: bool = False
- is_artifact: bool = False
- is_creature: bool = False
- is_legendary: bool = False
- is_companion: bool = False
- is_colorless: bool = False
- toughness: str = ''
- power: str = ''
+ @cached_property
+ def is_nyx(self) -> bool:
+ return False
+
+ @cached_property
+ def is_land(self) -> bool:
+ return False
+
+ @cached_property
+ def is_basic_land(self) -> bool:
+ return False
+
+ @cached_property
+ def is_artifact(self) -> bool:
+ return False
+
+ @cached_property
+ def is_creature(self) -> bool:
+ return False
+
+ @cached_property
+ def is_legendary(self) -> bool:
+ return False
+
+ @cached_property
+ def is_companion(self) -> bool:
+ return False
+
+ @cached_property
+ def toughness(self) -> str:
+ return ""
+
+ @cached_property
+ def power(self) -> str:
+ return ""
def __str__(self):
- return (f"{self.display_name}"
- f"{f' [{self.set}]' if self.set else ''}"
- f"{f' {{{self.collector_number}}}' if self.collector_number else ''}")
+ return (
+ f"{self.display_name}"
+ f"{f' [{self.set}]' if self.set else ''}"
+ f"{f' {{{self.collector_number}}}' if self.collector_number else ''}"
+ )
"""
* Core Data
"""
@cached_property
- def art_file(self) -> list[Path]:
+ def art_file(self) -> Path:
+ return self.art_files[0]
+
+ @cached_property
+ def art_files(self) -> list[Path]:
"""list[Path]: Two image files, second is appended during render process."""
- return [self.file['file']]
+ return [super().art_file]
@cached_property
def display_name(self) -> str:
"""Both side names."""
- return f"{self.name[0]} // {self.name[1]}"
+ return f"{self.names[0]} // {self.names[1]}"
+
+ @cached_property
+ def card(self) -> ScryfallCardFace:
+ return self.cards[0]
@cached_property
- def card(self) -> list[dict]:
+ def cards(self) -> Sequence[ScryfallCardFace]:
"""Both side objects."""
- return [c for c in self.scryfall.get('card_faces', [])]
+ return self.scryfall.card_faces or []
"""
* Colors
"""
@cached_property
- def color_identity(self) -> list:
+ def color_identity(self) -> list[MagicColor]:
"""Color identity is shared by both halves, use raw Scryfall instead of 'card' data."""
- return self.scryfall.get('color_identity', [])
+ return self.scryfall.color_identity
@cached_property
def color_indicator(self) -> str:
"""Color indicator is shared by both halves, use raw Scryfall instead of 'card' data."""
- return get_ordered_colors(self.scryfall.get('color_indicator', []))
+ return get_ordered_colors(self.scryfall.color_indicator or [])
"""
- * Images
- """
-
- @cached_property
- def scryfall_scan(self) -> str:
- """Scryfall large image scan, if available."""
- return self.scryfall.get('image_uris', {}).get('large', '')
-
- """
- * Collector Info
+ * Symbols
"""
@cached_property
- def artist(self) -> str:
- """Card artist name, use Scryfall raw data instead of 'card' data."""
- if self.file.get('artist'):
- return self.file['artist']
-
- # Check for duplicate last names
- artist, count = self.scryfall.get('artist', 'Unknown'), []
- if '&' in artist:
- for w in artist.split(' '):
- if w in count:
- count.remove(w)
- count.append(w)
- return ' '.join(count)
- return artist
-
- """
- * Symbols
- """
+ def watermark(self) -> str | None:
+ return self.watermarks[0]
@cached_property
- def watermark(self) -> list[Optional[str]]:
+ def watermarks(self) -> list[str | None]:
"""Name of the card's watermark file that is actually used, if provided."""
- watermarks: list[Optional[str]] = []
- for wm in self.watermark_svg:
+ watermarks: list[str | None] = []
+ for wm in self.watermark_svgs:
if not wm:
watermarks.append(None)
- elif wm.stem.upper() == 'WM':
+ elif wm.stem.upper() == "WM":
watermarks.append(wm.parent.stem.lower())
else:
watermarks.append(wm.stem.lower())
return watermarks
@cached_property
- def watermark_raw(self) -> list[Optional[str]]:
+ def watermark_raw(self) -> str | None:
+ return self.watermarks_raw[0]
+
+ @cached_property
+ def watermarks_raw(self) -> list[str | None]:
"""Name of the card's watermark from raw Scryfall data, if provided."""
- return [c.get('watermark', '') for c in self.card]
+ return [c.watermark or "" for c in self.cards]
+
+ @cached_property
+ def watermark_svg(self) -> Path | None:
+ return self.watermark_svgs[0]
@cached_property
- def watermark_svg(self) -> list[Optional[Path]]:
+ def watermark_svgs(self) -> list[Path | None]:
"""Path to the watermark SVG file, if provided."""
- def _find_watermark_svg(wm: str) -> Optional[Path]:
+
+ def _find_watermark_svg(wm: str) -> Path | None:
"""Try to find a watermark SVG asset, allowing for special cases and set code fallbacks.
Args:
@@ -1363,36 +1706,35 @@ def _find_watermark_svg(wm: str) -> Optional[Path]:
wm = wm.lower()
# Special case watermarks
- if wm in ['set', 'symbol']:
+ if wm in ["set", "symbol"]:
return get_watermark_svg_from_set(
- self.first_print.get('set', self.set) if wm == 'set' else self.set)
+ self.first_print.set
+ if wm == "set" and self.first_print
+ else self.set
+ )
# Look for normal watermark
return get_watermark_svg(wm)
# Find a watermark SVG for each face
- watermarks = []
- for w in self.watermark_raw:
- if CFG.watermark_mode == WatermarkMode.Disabled:
+ watermarks: list[Path | None] = []
+ for w in self.watermarks_raw:
+ if self.config.watermark_mode == WatermarkMode.Disabled:
# Disabled Mode
watermarks.append(None)
continue
- elif CFG.watermark_mode == WatermarkMode.Forced:
+ elif self.config.watermark_mode == WatermarkMode.Forced:
# Forced Mode
- watermarks.append(
- _find_watermark_svg(
- CFG.watermark_default))
+ watermarks.append(_find_watermark_svg(self.config.watermark_default))
continue
else:
# Automatic Mode
- path = _find_watermark_svg(w)
- if path or CFG.watermark_mode == WatermarkMode.Automatic:
+ path = _find_watermark_svg(w) if w else None
+ if path or self.config.watermark_mode == WatermarkMode.Automatic:
watermarks.append(path)
continue
# Fallback Mode
- watermarks.append(
- _find_watermark_svg(
- CFG.watermark_default))
+ watermarks.append(_find_watermark_svg(self.config.watermark_default))
return watermarks
"""
@@ -1400,11 +1742,13 @@ def _find_watermark_svg(wm: str) -> Optional[Path]:
"""
@cached_property
- def name(self) -> list[str]:
+ def name(self) -> str:
+ return self.names[0]
+
+ @cached_property
+ def names(self) -> list[str]:
"""Both side names."""
- if self.is_alt_lang:
- return [c.get('printed_name', c.get('name', '')) for c in self.card]
- return [c.get('name', '') for c in self.card]
+ return [self._get_card_name(c) for c in self.cards]
@cached_property
def name_raw(self) -> str:
@@ -1412,84 +1756,150 @@ def name_raw(self) -> str:
return f"{self.name[0]} _ {self.name[1]}"
@cached_property
- def type_line(self) -> list[str]:
+ def type_line(self) -> str:
+ return self.type_lines[0]
+
+ @cached_property
+ def type_lines(self) -> list[str]:
"""Both side type lines."""
if self.is_alt_lang:
- return [c.get('printed_type_line', c.get('type_line', '')) for c in self.card]
- return [c.get('type_line', '') for c in self.card]
+ return [c.printed_type_line or c.type_line or "" for c in self.cards]
+ return [c.type_line or "" for c in self.cards]
@cached_property
- def mana_cost(self) -> list[str]:
+ def mana_cost(self) -> str:
+ return self.mana_costs[0]
+
+ @cached_property
+ def mana_costs(self) -> list[str]:
"""Both side mana costs."""
- return [c.get('mana_cost', '') for c in self.card]
+ return [c.mana_cost or "" for c in self.cards]
+
+ @cached_property
+ def oracle_text(self) -> str:
+ return self.oracle_texts[0]
@cached_property
- def oracle_text(self) -> list[str]:
+ def oracle_texts(self) -> list[str]:
"""Both side oracle texts."""
- text = []
+ text: list[str] = []
for t in [
- c.get('printed_text', c.get('oracle_text', ''))
- if self.is_alt_lang else c.get('oracle_text', '')
- for c in self.card
+ (
+ c.printed_text or c.oracle_text or ""
+ if self.is_alt_lang
+ else c.oracle_text or ""
+ )
+ for c in self.cards
]:
- # Remove Fuse if present in oracle text data
- t = ''.join(t.split('\n')[:-1]) if 'Fuse' in self.keywords else t
+ if "Fuse" in self.keywords:
+ # Remove Fuse if present in oracle text data
+ t = "".join(t.split("\n")[:-1])
+ elif self.shared_reminder:
+ # Remove shared reminder if present
+ t = t[0 : -len(self.shared_reminder)].rstrip()
+
text.append(t)
return text
@cached_property
- def flavor_text(self) -> list[str]:
+ def flavor_text(self) -> str:
+ return self.flavor_texts[0]
+
+ @cached_property
+ def flavor_texts(self) -> list[str]:
"""Both sides flavor text."""
- return [c.get('flavor_text', '') for c in self.card]
+ return [c.flavor_text or "" for c in self.cards]
+
+ @cached_property
+ def shared_reminder(self) -> str:
+ prev_match: str = ""
+ for oracle_text in [
+ (
+ c.printed_text or c.oracle_text or ""
+ if self.is_alt_lang
+ else c.oracle_text or ""
+ )
+ for c in self.cards
+ ]:
+ match = CardTextPatterns.TEXT_REMINDER_ENDING.match(oracle_text)
+ curr_match = match.group(1) if match else ""
+ if not match or (prev_match and prev_match != curr_match):
+ return ""
+ prev_match = curr_match
+ return prev_match
"""
* Bool Data
"""
@cached_property
- def is_hybrid(self) -> list[bool]:
+ def is_hybrid(self) -> bool:
+ return self.hybrid_checks[0]
+
+ @cached_property
+ def hybrid_checks(self) -> list[bool]:
"""Both sides hybrid check."""
- return [f['is_hybrid'] for f in self.frame]
+ return [f["is_hybrid"] for f in self.frames]
+
+ @cached_property
+ def is_colorless(self) -> bool:
+ return self.colorless_checks[0]
@cached_property
- def is_colorless(self) -> list[bool]:
+ def colorless_checks(self) -> list[bool]:
"""Both sides colorless check."""
- return [f['is_colorless'] for f in self.frame]
+ return [f["is_colorless"] for f in self.frames]
"""
* Frame Details
"""
@cached_property
- def frame(self) -> list[FrameDetails]:
+ def frames(self) -> list[FrameDetails]:
"""Both sides frame data."""
- return [get_frame_details(c) for c in self.card]
+ return [get_frame_details(c) for c in self.cards]
@cached_property
- def pinlines(self) -> list[str]:
+ def pinlines(self) -> str:
+ return get_ordered_colors(self.color_identity)
+
+ @cached_property
+ def pinline_identities(self) -> list[str]:
"""Both sides pinlines identity."""
- return [f['pinlines'] for f in self.frame]
+ return [f["pinlines"] for f in self.frames]
+
+ @cached_property
+ def twins(self) -> str:
+ return self.pinlines
@cached_property
- def twins(self) -> list[str]:
+ def twins_identities(self) -> list[str]:
"""Both sides twins identity."""
- return [f['twins'] for f in self.frame]
+ return [f["twins"] for f in self.frames]
+
+ @cached_property
+ def background(self) -> str:
+ return self.pinlines
@cached_property
- def background(self) -> list[str]:
+ def backgrounds(self) -> list[str]:
"""Both sides background identity."""
- return [f['background'] for f in self.frame]
+ return [f["background"] for f in self.frames]
+
+ @cached_property
+ def identity(self) -> str:
+ return self.pinlines
@cached_property
- def identity(self) -> list[str]:
+ def identities(self) -> list[str]:
"""Both sides frame accurate color identity."""
- return [f['identity'] for f in self.frame]
+ return [f["identity"] for f in self.frames]
class TokenLayout(NormalLayout):
"""Token card layout for token game pieces."""
- @property
+ @cached_property
def display_name(self) -> str:
"""str: Add Token for display on GUI."""
return f"{self.name} Token"
@@ -1502,58 +1912,149 @@ def display_name(self) -> str:
def collector_data(self) -> str:
"""str: Formatted collector info line, rarity letter is always T, e.g. 050/230 T."""
if self.card_count:
- return f'{str(self.collector_number).zfill(3)}/{str(self.card_count).zfill(3)} T'
+ return f"{str(self.collector_number).zfill(3)}/{str(self.card_count).zfill(3)} T"
if self.collector_number_raw:
- return f'T {str(self.collector_number).zfill(4)}'
- return ''
+ return f"T {str(self.collector_number).zfill(4)}"
+ return ""
@cached_property
def set(self) -> str:
"""str: Use parent set code if provided."""
- return self.set_data.get('code_parent', super().set).upper()
+ if self.set_data and self.set_data.code_parent:
+ return self.set_data.code_parent.upper()
+ return super().set
@cached_property
- def card_count(self) -> Optional[int]:
+ def card_count(self) -> int | None:
"""Optional[int]: Use token count for token cards."""
- # Skip if collector mode doesn't require it or if collector number is bad
- if CFG.collector_mode != CollectorMode.Normal or not self.collector_number_raw:
+ # Skip if collector number is bad
+ if not self.collector_number_raw:
return
- # Prefer printed count, fallback to card count, skip if count isn't found
- return self.set_data.get('count_tokens', None)
+ if self.set_data:
+ return self.set_data.count_tokens
+ return
+
+
+class StationDetails(TypedDict):
+ requirement: str
+ ability: str
+ pt: NotRequired[PowerToughness]
+
+
+class StationLayout(NormalLayout):
+ """Station card layout, introduced in Edge of Eternities."""
+
+ _pt_pattern = re.compile(r"([0-9]+)/([0-9]+)")
+ _station_level_pattern = re.compile(r"\n([0-9]+\+) \| ")
+
+ @cached_property
+ def _station_levels_start_idx(self) -> int:
+ if match := self._station_level_pattern.search(self.oracle_text_unprocessed):
+ return match.start()
+ return -1
+
+ @cached_property
+ def type(self) -> LayoutType:
+ return LayoutType.Station
+
+ @cached_property
+ def oracle_text_unprocessed(self) -> str:
+ """Unaltered oracle text."""
+ return (
+ self.card.printed_text or self.oracle_text_raw
+ if self.is_alt_lang
+ else self.oracle_text_raw
+ )
+
+ @cached_property
+ def oracle_text(self) -> str:
+ """Oracle text with station levels stripped."""
+ if self._station_levels_start_idx > -1:
+ return self.oracle_text_unprocessed[0 : self._station_levels_start_idx]
+ return self.oracle_text_unprocessed
+
+ @cached_property
+ def stations(self) -> list[StationDetails]:
+ out: list[StationDetails] = []
+
+ if self._station_levels_start_idx > -1:
+ station_splits = self._station_level_pattern.split(
+ self.oracle_text_unprocessed[self._station_levels_start_idx :]
+ )
+ if not station_splits[0]:
+ station_splits.pop(0)
+ for requirement, ability in batched(station_splits, n=2):
+ if isinstance(requirement, str) and isinstance(ability, str):
+ out.append(
+ {
+ "requirement": requirement.strip(),
+ "ability": ability.strip(),
+ }
+ )
+
+ if self.power and self.toughness:
+ last = out[-1]
+ last["pt"] = {"power": self.power, "toughness": self.toughness}
+
+ return out
+
+
+class PrepareLayout(NormalLayout):
+ """Prepare card layout, introduced in Secrets of Strixhaven."""
+
+ @cached_property
+ def type(self) -> LayoutType:
+ return LayoutType.Prepare
+
+ @cached_property
+ def spell_side(self) -> ScryfallCardFace:
+ if self.scryfall.card_faces:
+ return self.scryfall.card_faces[1]
+ raise ValueError(
+ f"Scryfall data doesn't have a card face for spell side: {pprint(self.scryfall)}"
+ )
+
+ @cached_property
+ def mana_prepare(self) -> str:
+ return self.spell_side.mana_cost or ""
+
+ @cached_property
+ def name_prepare(self) -> str:
+ return self._get_card_name(self.spell_side)
+
+ @cached_property
+ def type_line_prepare(self) -> str:
+ return self.spell_side.type_line or ""
+
+ @cached_property
+ def oracle_text_prepare(self) -> str:
+ if self.is_alt_lang and self.spell_side.printed_text:
+ return self.spell_side.printed_text
+ return self.spell_side.oracle_text or ""
+
+ @cached_property
+ def flavor_text_prepare(self) -> str:
+ return self.spell_side.flavor_text or ""
+
+ @cached_property
+ def colors_prepare(self) -> list[str]:
+ """Colors present in the spell side mana cost."""
+ return [n for n in get_ordered_colors(get_mana_cost_colors(self.mana_prepare))]
+
+ @cached_property
+ def color_identity_prepare(self) -> str:
+ """Color identity of spell side frame elements."""
+ return _get_other_side_color_identity(self.colors_prepare, self.mana_prepare)
"""
* Types & Enums
"""
-"""All card layout classes."""
-CardLayout = Union[
- NormalLayout,
- TransformLayout,
- ModalDoubleFacedLayout,
- AdventureLayout,
- LevelerLayout,
- SagaLayout,
- MutateLayout,
- PrototypeLayout,
- ClassLayout,
- SplitLayout,
- PlanarLayout,
- TokenLayout
-]
-
-"""Planeswalker card layout classes."""
-PlaneswalkerLayouts = Union[
- PlaneswalkerLayout,
- PlaneswalkerTransformLayout,
- PlaneswalkerMDFCLayout
-]
-
"""Maps Scryfall layout names to their respective layout class."""
-layout_map: dict[str, Type[CardLayout]] = {
-
+layout_map: dict[str, type[NormalLayout]] = {
# Definitions supported by Scryfall natively
LayoutScryfall.Normal: NormalLayout,
LayoutScryfall.Split: SplitLayout,
@@ -1561,21 +2062,22 @@ def card_count(self) -> Optional[int]:
LayoutScryfall.MDFC: ModalDoubleFacedLayout,
LayoutScryfall.Meld: TransformLayout,
LayoutScryfall.Leveler: LevelerLayout,
+ LayoutScryfall.Case: CaseLayout,
LayoutScryfall.Class: ClassLayout,
LayoutScryfall.Saga: SagaLayout,
LayoutScryfall.Adventure: AdventureLayout,
+ LayoutScryfall.Prepare: PrepareLayout,
LayoutScryfall.Mutate: MutateLayout,
LayoutScryfall.Prototype: PrototypeLayout,
LayoutScryfall.Battle: BattleLayout,
LayoutScryfall.Planar: PlanarLayout,
LayoutScryfall.Token: TokenLayout,
LayoutScryfall.Emblem: TokenLayout,
-
# Definitions added to Scryfall data in postprocessing
LayoutScryfall.Planeswalker: PlaneswalkerLayout,
LayoutScryfall.PlaneswalkerMDFC: PlaneswalkerMDFCLayout,
LayoutScryfall.PlaneswalkerTransform: PlaneswalkerTransformLayout,
-
+ LayoutScryfall.Station: StationLayout,
# TODO: Supported by Scryfall, not implemented
LayoutScryfall.Flip: TransformLayout,
LayoutScryfall.Scheme: NormalLayout,
@@ -1585,5 +2087,4 @@ def card_count(self) -> Optional[int]:
LayoutScryfall.Host: NormalLayout,
LayoutScryfall.ArtSeries: TokenLayout,
LayoutScryfall.ReversibleCard: TransformLayout,
-
}
diff --git a/src/render/__init__.py b/src/render/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/render/render_queue.py b/src/render/render_queue.py
new file mode 100644
index 00000000..000bbcf2
--- /dev/null
+++ b/src/render/render_queue.py
@@ -0,0 +1,172 @@
+from _ctypes import COMError
+from asyncio import EventLoop, Future, Task, run
+from logging import getLogger
+from threading import Lock, Thread
+from typing import TypedDict
+
+from src.render.setup import RenderOperation
+from src.utils.data_structures import find_last_index
+from src.utils.event import SubscribableEvent
+
+_logger = getLogger(__name__)
+
+
+class RenderResult(TypedDict):
+ operation: RenderOperation
+ result: bool
+
+
+class QueuedOperation(TypedDict):
+ operation: RenderOperation
+ index: int
+
+
+class RenderQueue:
+ """Queue for render operations. Runs the render loop in a separate thread."""
+
+ _render_lock = Lock()
+
+ def __init__(self) -> None:
+ self._queue: list[RenderOperation] = []
+ self._queue_lock = Lock()
+
+ self._active_operation: RenderOperation | None = None
+ self._cancel: bool = False
+
+ self.queued: SubscribableEvent[QueuedOperation] = SubscribableEvent()
+ self.dequeued: SubscribableEvent[tuple[RenderOperation, int]] = (
+ SubscribableEvent()
+ )
+ self.started: SubscribableEvent[RenderOperation] = SubscribableEvent()
+ self.finished: SubscribableEvent[RenderResult] = SubscribableEvent()
+ self.cancelled: SubscribableEvent[None] = SubscribableEvent()
+
+ @property
+ def active_operation(self) -> RenderOperation | None:
+ return self._active_operation
+
+ def enqueue(self, operation: RenderOperation) -> None:
+ with self._queue_lock:
+ # Order renders so that that the template document has to be switched
+ # as few times as possible
+ idx = find_last_index(
+ self._queue,
+ lambda item: (
+ item.layout.template_file == operation.layout.template_file
+ ),
+ )
+
+ # Active operation has to be checked separately if no match was found otherwise
+ if (
+ idx == -1
+ and self.active_operation
+ and operation.layout.template_file
+ == self.active_operation.layout.template_file
+ ):
+ idx = 0
+
+ if idx > -1:
+ idx += 1
+ self._queue.insert(idx, operation)
+ else:
+ idx = len(self._queue)
+ self._queue.append(operation)
+ self.queued.trigger({"operation": operation, "index": idx})
+ self.execute_render()
+
+ def dequeue(self, index: int) -> RenderOperation | None:
+ try:
+ with self._queue_lock:
+ self.dequeued.trigger((self._queue.pop(index), index))
+ except IndexError:
+ return
+
+ def clear(self) -> None:
+ with self._queue_lock:
+ for idx in reversed(range(len(self._queue))):
+ self.dequeued.trigger((self._queue.pop(), idx))
+
+ def cancel(self) -> None:
+ if self._active_operation:
+ self._cancel = True
+ self._active_operation.cancel()
+ self.cancelled.trigger(None)
+
+ def execute_render(self) -> None:
+ if self._render_lock.locked():
+ return
+
+ async def loop_render():
+ locked = self._render_lock.acquire(blocking=False)
+ if locked:
+ try:
+ try:
+ while not self._cancel and (
+ render_operation := self._queue.pop(0)
+ ):
+ self._active_operation = render_operation
+ self.started.trigger(render_operation)
+ result = False
+ try:
+ result = await render_operation.render()
+ finally:
+ try:
+ # As an optimization the document isn't closed if
+ # the next render also uses the same document
+ if (
+ render_operation.template_instance
+ and result
+ and (
+ len(self._queue) == 0
+ or self._queue[0].layout.template_file
+ != render_operation.layout.template_file
+ )
+ ):
+ render_operation.template_instance.docref.close()
+ except COMError as exc:
+ _logger.debug(
+ f"Couldn't close the document for {
+ f'{
+ render_operation.layout.display_name
+ } ({render_operation.layout.artist}) [{
+ render_operation.layout.set
+ }] {{{
+ render_operation.layout.collector_number_raw
+ }}} |{
+ render_operation.layout.category.value
+ }| \\{
+ render_operation.template_class.__name__
+ }/'
+ }. It might have already been closed.",
+ exc_info=exc,
+ )
+ self.finished.trigger(
+ {"operation": render_operation, "result": result}
+ )
+ except IndexError:
+ return
+ finally:
+ self._active_operation = None
+ self._cancel = False
+ finally:
+ self._render_lock.release()
+
+ def target():
+ # Using asyncio.run without a factory starts another Qt loop, since
+ # Qt sets the global loop policy to it's own implementation.
+ # Starting another Qt loop fails, because the loop also tries to
+ # exec another Qt application. As a workaround, we have to specifically
+ # create an instance of the default non-Qt event loop.
+ run(loop_render(), loop_factory=EventLoop)
+
+ Thread(target=target).start()
+
+
+def cancel_with_render[T](
+ task: Future[T] | Task[T], queue: RenderQueue, msg: str | None = None
+) -> None:
+ def on_cancel(_: None = None) -> None:
+ task.cancel()
+
+ queue.cancelled.listen_once(on_cancel)
+ task.add_done_callback(lambda _: queue.cancelled.remove_listener(on_cancel))
diff --git a/src/render/setup.py b/src/render/setup.py
new file mode 100644
index 00000000..8e1427d7
--- /dev/null
+++ b/src/render/setup.py
@@ -0,0 +1,277 @@
+from asyncio import CancelledError, Future, Task, create_task, get_running_loop
+from collections.abc import Callable, Iterable, Mapping
+from logging import getLogger
+from time import perf_counter
+from typing import TYPE_CHECKING, TypedDict
+
+from src import CON, ENV
+from src._config import AppConfig
+from src._loader import (
+ ConfigHandler,
+ RenderableTemplate,
+ TemplateLibrary,
+ get_template_class,
+)
+from src.cards import CardDetails
+from src.enums.mtg import LayoutCategory
+from src.layouts import NormalLayout, assign_layout, join_dual_card_layouts
+from src.templates._core import BaseTemplate
+from src.utils.asynchronic import async_to_sync, try_threadsafe_set_result
+from src.utils.event import SubscribableEvent
+from src.utils.scryfall import ScryfallCard
+
+if TYPE_CHECKING:
+ from src.gui.qml.models.file_dialog_model import FileDialogModel
+ from src.gui.qml.models.message_dialog_content_model import (
+ MessageDialogContentModel,
+ )
+
+_logger = getLogger(__name__)
+
+
+class PausedEventArgs(TypedDict):
+ message: str | None
+
+
+class RenderOperation:
+ def __init__(
+ self,
+ template_class: type[BaseTemplate],
+ layout: NormalLayout,
+ config: ConfigHandler,
+ file_dialog: FileDialogModel,
+ message_dialog: MessageDialogContentModel,
+ ) -> None:
+ self._file_dialog = file_dialog
+ self._message_dialog = message_dialog
+
+ self.template_class = template_class
+ self.layout = layout
+ self.config = config
+ self.template_instance: BaseTemplate | None = None
+ self.before_render_callback: (
+ Callable[[RenderOperation, AppConfig], None] | None
+ ) = None
+ self.do_not_pause: bool = False
+ self.render_task: Task[bool] | None = None
+ self._waiting: Future[bool] | None = None
+ self._paused: SubscribableEvent[PausedEventArgs] = SubscribableEvent()
+
+ @property
+ def paused(self):
+ return self._paused
+
+ async def render(self) -> bool:
+ card_display_name = f"{self.layout.display_name} ({self.layout.artist}) [{self.layout.set}] {{{self.layout.collector_number_raw}}} |{self.layout.category.value}| \\{self.template_class.__name__}/"
+ _logger.info(f"Starting rendering of {card_display_name}")
+
+ try:
+ self.layout.config = self.layout.config.copy(self.config)
+ CON.reload()
+
+ self.template_instance = self.template_class(self.layout)
+ self.template_instance.render_operation = self
+ self.template_instance.file_dialog = self._file_dialog
+ self.template_instance.message_dialog = self._message_dialog
+
+ if self.before_render_callback:
+ self.before_render_callback(self, self.layout.config)
+
+ start_time = perf_counter()
+
+ self.render_task = create_task(self.template_instance.execute())
+ try:
+ result = await self.render_task
+ except CancelledError:
+ result = False
+
+ if not result:
+ self.template_instance.docref.close()
+
+ _logger.info(
+ f"{'Finished' if result else 'Cancelled'} rendering {
+ card_display_name
+ }
Rendering took {
+ round(perf_counter() - start_time, 1)
+ } seconds"
+ )
+ return result
+ except Exception:
+ _logger.exception(f"Failed rendering {card_display_name}")
+ return False
+
+ def cancel(self) -> None:
+ if self.template_instance:
+ self.template_instance.cancel()
+ if (task := self.render_task) and not task.done():
+ task.get_loop().call_soon_threadsafe(task.cancel)
+ if (waiting := self._waiting) and not waiting.done():
+ try_threadsafe_set_result(waiting, False)
+
+ async def pause(
+ self, message: str | None = None, show_photoshop: bool = True
+ ) -> bool:
+ if self.do_not_pause:
+ return True
+
+ if waiting := self._waiting:
+ await waiting
+
+ self._waiting = get_running_loop().create_future()
+ if show_photoshop and self.template_instance:
+ self.template_instance.app.bringToFront()
+ self._paused.trigger({"message": message})
+
+ return await self._waiting
+
+ def pause_sync(
+ self, message: str | None = None, show_photoshop: bool = True
+ ) -> bool:
+ return async_to_sync(self.pause(message, show_photoshop))
+
+ def resume(self) -> None:
+ if (waiting := self._waiting) and not waiting.done():
+ try_threadsafe_set_result(waiting, True)
+
+
+def prepare_render_operations(
+ template_choices: RenderableTemplate | Mapping[LayoutCategory, RenderableTemplate],
+ template_library: TemplateLibrary,
+ assets: Iterable[CardDetails | tuple[CardDetails, ScryfallCard]],
+ file_dialog: FileDialogModel,
+ message_dialog: MessageDialogContentModel,
+) -> list[RenderOperation]:
+ if not template_choices:
+ _logger.warning(
+ "No template choices were provided, so no cards can be rendered. Please select some installed templates in the Batch mode in order to render cards with that feature."
+ )
+ return []
+
+ layouts: list[NormalLayout] = []
+ layout_template_class_mapping: dict[
+ NormalLayout, tuple[str, type[BaseTemplate], RenderableTemplate]
+ ] = {}
+
+ for asset in assets:
+ if isinstance(asset, tuple):
+ card, scryfall_override = asset
+ else:
+ card = asset
+ scryfall_override = None
+
+ if not (
+ layout := assign_layout(
+ card,
+ scryfall_override=scryfall_override,
+ )
+ ):
+ # assign_layout reports its own errors
+ continue
+
+ template_to_use: RenderableTemplate | None = None
+
+ if template_name := card["kwargs"].get("tmpl"):
+ if builtin_template := template_library.built_in_templates_by_name.get(
+ template_name, None
+ ):
+ if layout.category in builtin_template.layout_categories:
+ template_to_use = builtin_template
+ if template_to_use is None:
+ for templates in template_library.plugin_templates_by_name.values():
+ if plugin_template := templates.get(template_name, None):
+ if layout.category in plugin_template.layout_categories:
+ template_to_use = plugin_template
+ break
+
+ if template_to_use is None:
+ if isinstance(template_choices, Mapping):
+ if not (template_to_use := template_choices.get(layout.category, None)):
+ _logger.error(
+ f"Skipping image {
+ card['file'].name
+ } because no template was specified for layout {
+ layout.category
+ }."
+ )
+ continue
+ else:
+ template_to_use = template_choices
+
+ if not (
+ class_name_and_file := template_to_use.get_class_name_and_file_for_layout(
+ layout.type
+ )
+ ):
+ _logger.error(
+ f"Failed to find a template class name from template {
+ template_to_use.name
+ } for layout {layout.type}."
+ )
+ continue
+
+ class_name, file_path = class_name_and_file
+
+ if not file_path.is_file():
+ _logger.error(
+ f"The PSD file {file_path} for template {
+ template_to_use.name
+ } is not available. This might be becuase you haven't downloaded it using the Template updater."
+ )
+ continue
+
+ # Store PSD path to layout
+ layout.template_file = file_path
+
+ try:
+ template_class = get_template_class(
+ class_name, template_to_use.plugin, ENV.FORCE_RELOAD
+ )
+ except Exception:
+ _logger.exception(
+ f"Failed to load class {class_name} for template {template_to_use.name}"
+ )
+ continue
+
+ if not issubclass(template_class, BaseTemplate):
+ _logger.error(
+ f"Class {class_name} of template {
+ template_to_use.name
+ } is not a subclass of BaseTemplate."
+ )
+ continue
+
+ layouts.append(layout)
+ layout_template_class_mapping[layout] = (
+ class_name,
+ template_class,
+ template_to_use,
+ )
+
+ layouts = join_dual_card_layouts(layouts)
+
+ render_operations: list[RenderOperation] = []
+
+ for layout in layouts:
+ class_name, template_class, template_to_use = layout_template_class_mapping[
+ layout
+ ]
+
+ if not (conf := template_to_use.get_config(class_name)):
+ _logger.error(
+ f"Failed to get config for class {class_name} of template {
+ template_to_use.name
+ }."
+ )
+ continue
+
+ render_operations.append(
+ RenderOperation(
+ template_class,
+ layout,
+ conf,
+ file_dialog=file_dialog,
+ message_dialog=message_dialog,
+ )
+ )
+
+ return render_operations
diff --git a/src/render_spec.py b/src/render_spec.py
new file mode 100644
index 00000000..a970363b
--- /dev/null
+++ b/src/render_spec.py
@@ -0,0 +1,218 @@
+"""
+* Render Spec Module
+* Handles parsing render spec file, aka yaml files that contain a set of configurations and cards to render
+"""
+
+from __future__ import annotations
+
+import glob
+import os
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Annotated, TypeVar
+
+from pydantic import BaseModel, BeforeValidator, RootModel
+
+from src.cards import CardDetails, parse_card_info
+from src.utils.data_structures import parse_model
+
+# region Model-Types
+
+
+def _ensure_list[T](values: list[T] | T) -> list[T]:
+ if isinstance(values, list):
+ return values # type: ignore
+ return [values]
+
+
+def _ensure_str(values: list[str] | str) -> str:
+ if isinstance(values, list):
+ return " ".join(values)
+ return values
+
+
+T = TypeVar("T")
+EnsuredList = Annotated[list[T], BeforeValidator(_ensure_list)]
+EnsuredStr = Annotated[str, BeforeValidator(_ensure_str)]
+
+
+class ConfigModel(BaseModel):
+ name: str
+ settings: EnsuredList[str]
+
+
+class FileModel(BaseModel):
+ file: str
+ settings: EnsuredList[str] = []
+
+
+class GroupModel(BaseModel):
+ files: EnsuredList[FileModel | str]
+ groups: EnsuredList[GroupModel] = []
+ settings: EnsuredList[str] = []
+
+
+class RenderSpecModel(BaseModel):
+ files: EnsuredList[FileModel | str] = []
+ groups: EnsuredList[GroupModel] = []
+ configs: dict[str, EnsuredStr] = {}
+
+
+# endregion Model-Types
+# region Types
+
+
+@dataclass
+class CardSpec:
+ spec: str
+ actual_path: Path | None
+
+
+@dataclass
+class RenderSpec:
+ """Render spec obtained from parsing the file."""
+
+ name: str
+ file: Path
+ configs: dict[str, str]
+ cards: list[CardDetails]
+
+
+# endregion Types
+
+# region Parsing
+
+
+def parse_render_spec_file(render_spec_path: Path) -> RenderSpecModel:
+ return parse_model(render_spec_path, RootModel[RenderSpecModel]).root
+
+
+def parse_render_spec(render_spec_path: Path) -> RenderSpec:
+ """Retrieve render spec from the input file.
+
+ Args:
+ render_spec_path: Path to the yaml file.
+
+ Returns:
+ Dict containing the configurations and cards in this spec.
+ """
+
+ render_spec_data = parse_render_spec_file(render_spec_path)
+
+ render_spec_name = render_spec_path.stem
+ parent_dir = render_spec_path.parent
+
+ cards: list[CardDetails] = []
+
+ def resolve_file(file: str) -> list[CardSpec]:
+ if "*" in file:
+ specs = glob.glob(file, root_dir=parent_dir, recursive=True)
+ return [
+ CardSpec(s.split(".")[0], parent_dir / s)
+ for s in specs
+ if not s.endswith(".yaml") and not s.endswith(".yml")
+ ]
+ else:
+ abs_file = render_spec_path.parent / Path(file)
+ if os.path.exists(abs_file):
+ return [CardSpec(file.split(".")[0], abs_file)]
+ else:
+ return [CardSpec(file, None)]
+
+ def append_card(card_spec: CardSpec):
+ spec = card_spec.spec
+
+ # Make sure the extension doesn't contain a ']' as that implies
+ # we have something without extension using a config that contains
+ # something with extension, e.g. [art=file.png]
+ if not re.match(r"\.[^\]]+$", spec):
+ spec += ".png"
+
+ # Pretend this is a file right next to the spec and parse that
+ full_card_path = parent_dir / Path(spec).name
+ card_info = parse_card_info(full_card_path)
+
+ path = card_spec.actual_path
+ if path is not None:
+ if "art" not in card_info["kwargs"]:
+ card_info["kwargs"]["art"] = str(path)
+ if "dir" not in card_info["kwargs"]:
+ if parent_dir in path.parents:
+ rel_dir = path.relative_to(parent_dir).parent
+ card_info["kwargs"]["dir"] = str(rel_dir)
+
+ cards.append(card_info)
+
+ def append_configs(card: CardSpec, card_configs: list[str]) -> CardSpec:
+ resolved_configs = [
+ render_spec_data.configs[c] if c in render_spec_data.configs else c
+ for c in card_configs
+ ]
+ config = " ".join(resolved_configs)
+ return CardSpec(
+ f"{card.spec} {config}",
+ card.actual_path,
+ )
+
+ def parse_group(group: GroupModel, root_group: bool = True) -> list[CardSpec]:
+ group_cards: list[CardSpec] = []
+ group_settings = group.settings
+
+ for file_model in group.files:
+ if isinstance(file_model, FileModel):
+ file = file_model.file
+ settings = file_model.settings + group_settings
+ else:
+ file = file_model
+ settings = group_settings
+
+ for card_spec in resolve_file(file):
+ card_spec = append_configs(card_spec, settings)
+ group_cards.append(card_spec)
+
+ for nested_group in group.groups:
+ for card_spec in parse_group(nested_group):
+ card_spec = append_configs(card_spec, group_settings)
+ group_cards.append(card_spec)
+
+ def expand_variable(card: CardSpec, variable: str, value: str | int):
+ return CardSpec(
+ card.spec.replace(variable, str(value)),
+ card.actual_path,
+ )
+
+ group_cards = [
+ expand_variable(c, "${GROUP_INDEX}", i) for (i, c) in enumerate(group_cards)
+ ]
+ if root_group:
+ group_cards = [
+ expand_variable(c, "${ROOT_GROUP_INDEX}", i)
+ for (i, c) in enumerate(group_cards)
+ ]
+
+ return group_cards
+
+ for card_model in render_spec_data.files:
+ if isinstance(card_model, str):
+ card = FileModel(file=card_model, settings=[])
+ else:
+ card = card_model
+
+ for card_spec in resolve_file(card.file):
+ card_spec = append_configs(card_spec, card.settings)
+ append_card(card_spec)
+
+ for group in render_spec_data.groups:
+ for card_spec in parse_group(group):
+ append_card(card_spec)
+
+ return RenderSpec(
+ name=render_spec_name,
+ file=render_spec_path,
+ configs=render_spec_data.configs,
+ cards=cards,
+ )
+
+
+# endregion Parsing
diff --git a/src/schema/adobe.py b/src/schema/adobe.py
index 196db2f8..e70f0508 100644
--- a/src/schema/adobe.py
+++ b/src/schema/adobe.py
@@ -1,13 +1,11 @@
"""
* Schema: Photoshop
"""
-# Standard Library Imports
-from typing import Union, Literal
-# Third Party Imports
-from omnitils.schema import ArbitrarySchema, Schema
+from typing import Literal
+
+from pydantic import BaseModel
-# Local Imports
from src.schema.colors import ColorObject, GradientColor
"""
@@ -15,8 +13,9 @@
"""
-class LayerDimensions(Schema):
+class LayerDimensions(BaseModel):
"""Calculated layer dimension info for a layer."""
+
width: int
height: int
center_x: int
@@ -31,12 +30,44 @@ class LayerDimensions(Schema):
* Layer Effects
"""
+BlendMode = Literal[
+ "normal",
+ "dissolve",
+ "darken",
+ "multiply",
+ "colorBurn",
+ "linearBurn",
+ "darkerColor",
+ "lighten",
+ "screen",
+ "colorDodge",
+ "linearDodge",
+ "lighterColor",
+ "overlay",
+ "softLight",
+ "hardLight",
+ "vividLight",
+ "linearLight",
+ "pinLight",
+ "hardMix",
+ "difference",
+ "exclusion",
+ "subtract",
+ "divide",
+ "hue",
+ "saturation",
+ "color",
+ "luminosity",
+]
+GradientMethod = Literal["perceptual", "linear", "classic", "smooth", "stripes"]
-class EffectBevel(ArbitrarySchema):
+
+class EffectBevel(BaseModel):
"""Layer Effect: Bevel"""
- highlight_color: ColorObject = [255, 255, 255]
+
+ highlight_color: ColorObject = (255, 255, 255)
highlight_opacity: float | int = 70
- shadow_color: ColorObject = [0, 0, 0]
+ shadow_color: ColorObject = (0, 0, 0)
shadow_opacity: float | int = 72
global_light: bool = False
rotation: float | int = 45
@@ -46,15 +77,17 @@ class EffectBevel(ArbitrarySchema):
softness: float | int = 14
-class EffectColorOverlay(ArbitrarySchema):
+class EffectColorOverlay(BaseModel):
"""Layer Effect: Color Overlay"""
- color: ColorObject = [0, 0, 0]
+
+ color: ColorObject = (0, 0, 0)
opacity: float | int = 100
-class EffectDropShadow(ArbitrarySchema):
+class EffectDropShadow(BaseModel):
"""Layer Effect: Drop Shadow"""
- color: ColorObject = [0, 0, 0]
+
+ color: ColorObject = (0, 0, 0)
opacity: float | int = 100
rotation: float | int = 45
distance: float | int = 10
@@ -63,28 +96,35 @@ class EffectDropShadow(ArbitrarySchema):
noise: float | int = 0
-class EffectGradientOverlay(ArbitrarySchema):
+class EffectGradientOverlay(BaseModel):
"""Layer Effect: Drop Shadow"""
+
colors: list[GradientColor] = []
+ blend_mode: BlendMode = "normal"
+ dither: bool = False
opacity: int | float = 100
rotation: int | float = 45
scale: int | float = 70
size: int | float = 4096
+ method: GradientMethod = "classic"
-class EffectStroke(ArbitrarySchema):
+class EffectStroke(BaseModel):
"""Layer Effect: Stroke"""
- color: ColorObject = [0, 0, 0]
+
+ color: ColorObject = (0, 0, 0)
weight: int | float = 6
opacity: int | float = 100
- style: Literal['in', 'insetFrame', 'out', 'outsetFrame', 'center', 'centeredFrame'] = 'out'
+ style: Literal[
+ "in", "insetFrame", "out", "outsetFrame", "center", "centeredFrame"
+ ] = "out"
# Type: Any layer effect
-LayerEffects = Union[
- EffectBevel,
- EffectColorOverlay,
- EffectDropShadow,
- EffectGradientOverlay,
- EffectStroke
-]
+LayerEffects = (
+ EffectBevel
+ | EffectColorOverlay
+ | EffectDropShadow
+ | EffectGradientOverlay
+ | EffectStroke
+)
diff --git a/src/schema/colors.py b/src/schema/colors.py
index 8d2f0a4b..5e9c89cc 100644
--- a/src/schema/colors.py
+++ b/src/schema/colors.py
@@ -1,21 +1,15 @@
"""
* Schema: Colors and Gradients
"""
-# Standard Library Imports
+
+from collections.abc import Sequence
from functools import cache
-from typing import Any
+from typing import Annotated, Any, TypedDict, TypeGuard
-# Third Party Imports
-from omnitils.schema import Schema, DictSchema, ArbitrarySchema
-from photoshop.api import SolidColor, CMYKColor, RGBColor, LabColor, HSBColor
-from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
+from photoshop.api import CMYKColor, HSBColor, LabColor, RGBColor, SolidColor
+from pydantic import BaseModel, GetCoreSchemaHandler, GetJsonSchemaHandler
from pydantic.json_schema import JsonSchemaValue
from pydantic_core import core_schema
-from typing_extensions import Annotated
-
-# Color not evaluated as a SolidColor object
-LazyColor = list[int] | str
-
"""
* General Color Objects
@@ -23,37 +17,45 @@
class _SolidColorAnnotation:
-
@classmethod
@cache
def get_validation_func(cls):
"""Lazy loads outside function that validates value."""
from src.helpers.colors import get_color
+
return get_color
@classmethod
def __get_pydantic_core_schema__(
- cls,
- _source_type: Any,
- _handler: GetCoreSchemaHandler
+ cls, _source_type: Any, _handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
"""Provides a Pydantic core schema for a SolidColor object."""
- def validate_from_lazy(value: list[int] | str | SolidColor) -> SolidColor:
+ def validate_from_lazy(value: ColorObject) -> SolidColor:
"""Generates a SolidColor object from a provided list or str value."""
_func = cls.get_validation_func()
return _func(value)
- def serialize_value(obj: SolidColor) -> list[int]:
+ def serialize_value(
+ obj: SolidColor,
+ ) -> tuple[float, float, float] | tuple[float, float, float, float]:
"""Serializes the SolidColor object to a list."""
if obj.model == RGBColor:
- return [obj.rgb.red, obj.rgb.green, obj.rgb.blue]
+ return (obj.rgb.red, obj.rgb.green, obj.rgb.blue)
elif obj.model == CMYKColor:
- return [obj.cmyk.cyan, obj.cmyk.magenta, obj.cmyk.yellow, obj.cmyk.black]
+ return (
+ obj.cmyk.cyan,
+ obj.cmyk.magenta,
+ obj.cmyk.yellow,
+ obj.cmyk.black,
+ )
elif obj.model == LabColor:
- return [obj.lab.L, obj.lab.A, obj.lab.B]
+ return (obj.lab.L, obj.lab.A, obj.lab.B)
elif obj.model == HSBColor:
- return [obj.hsb.hue, obj.hsb.saturation, obj.hsb.brightness]
+ return (obj.hsb.hue, obj.hsb.saturation, obj.hsb.brightness)
+ raise ValueError(
+ f"Got SolidColor which has an unsupported color model '{obj.model}'"
+ )
from_list_schema = core_schema.chain_schema(
[
@@ -66,8 +68,11 @@ def serialize_value(obj: SolidColor) -> list[int]:
return core_schema.json_or_python_schema(
json_schema=from_list_schema,
python_schema=core_schema.union_schema(
- [core_schema.is_instance_schema(SolidColor), from_list_schema]),
- serialization=core_schema.plain_serializer_function_ser_schema(serialize_value),
+ [core_schema.is_instance_schema(SolidColor), from_list_schema]
+ ),
+ serialization=core_schema.plain_serializer_function_ser_schema(
+ serialize_value
+ ),
)
@classmethod
@@ -79,160 +84,198 @@ def __get_pydantic_json_schema__(
# Generic color object types
SolidColorType = Annotated[SolidColor, _SolidColorAnnotation]
-ColorObject = list[int] | str | SolidColorType
+ColorObject = (
+ tuple[float, float, float]
+ | tuple[float, float, float, float]
+ | str
+ | SolidColorType
+)
-class GradientColor(ArbitrarySchema):
+class GradientColor(BaseModel):
"""Defines a color within a gradient."""
- color: ColorObject = [0, 0, 0]
+
+ color: ColorObject = (0, 0, 0)
location: int = 0
midpoint: int = 50
+class GradientConfig(TypedDict):
+ color: ColorObject
+ location: int | float
+ midpoint: int | float
+
+
+def _is_num_tuple_of_length(
+ val: ColorObject | Sequence[ColorObject] | Sequence[GradientConfig], length: int
+) -> bool:
+ return (
+ isinstance(val, tuple)
+ and len(val) == length
+ and all(isinstance(item, float | int) for item in val)
+ )
+
+
+def is_rgb_tuple(
+ val: ColorObject | Sequence[ColorObject] | Sequence[GradientConfig],
+) -> TypeGuard[tuple[float, float, float]]:
+ return _is_num_tuple_of_length(val, 3)
+
+
+def is_cmyk_tuple(
+ val: ColorObject | Sequence[ColorObject] | Sequence[GradientConfig],
+) -> TypeGuard[tuple[float, float, float, float]]:
+ return _is_num_tuple_of_length(val, 4)
+
+
+def is_rgb_or_cmyk_tuple(
+ val: ColorObject | Sequence[ColorObject] | Sequence[GradientConfig],
+) -> TypeGuard[tuple[float, float, float] | tuple[float, float, float, float]]:
+ return is_rgb_tuple(val) or is_cmyk_tuple(val)
+
+
"""
* General Color Maps
"""
-class ManaColors(DictSchema):
- """Defines the mana colors for a specific symbol map (inner, outer, hybrid)."""
- C: ColorObject = [204, 194, 193]
- W: ColorObject = [255, 251, 214]
- U: ColorObject = [170, 224, 250]
- B: ColorObject = [204, 194, 193]
- R: ColorObject = [249, 169, 143]
- G: ColorObject = [154, 211, 175]
-
- def __new__(cls, **data):
- d = super().__new__(cls, **data)
- d['2'] = d.pop('C', [0, 0, 0])
- return d
+mana_colors: dict[str, ColorObject] = {
+ "2": (204, 194, 193), # same as C
+ "C": (204, 194, 193),
+ "W": (255, 251, 214),
+ "U": (170, 224, 250),
+ "B": (204, 194, 193),
+ "R": (249, 169, 143),
+ "G": (154, 211, 175),
+}
-class ManaColorsInner(ManaColors):
- """Default mana colors for the inside character (black)."""
- C: ColorObject = [0, 0, 0]
- W: ColorObject = [0, 0, 0]
- U: ColorObject = [0, 0, 0]
- B: ColorObject = [0, 0, 0]
- R: ColorObject = [0, 0, 0]
- G: ColorObject = [0, 0, 0]
+mana_colors_inner: dict[str, ColorObject] = {
+ "2": (0, 0, 0),
+ "C": (0, 0, 0),
+ "W": (0, 0, 0),
+ "U": (0, 0, 0),
+ "B": (0, 0, 0),
+ "R": (0, 0, 0),
+ "G": (0, 0, 0),
+}
-class SymbolColorMap(Schema):
+class SymbolColorMap(BaseModel):
"""Color map schema."""
- primary: ColorObject = [0, 0, 0]
- secondary: ColorObject = [255, 255, 255]
- colorless: ColorObject = [204, 194, 193]
- colors: dict[str, ColorObject] = ManaColors()
- hybrid: dict[str, ColorObject] = ManaColors(B=[159, 146, 143])
- colors_inner: dict[str, ColorObject] = ManaColorsInner()
- hybrid_inner: dict[str, ColorObject] = ManaColorsInner()
+
+ primary: ColorObject = (0, 0, 0)
+ secondary: ColorObject = (255, 255, 255)
+ colorless: ColorObject = (204, 194, 193)
+ colors: dict[str, ColorObject] = mana_colors.copy()
+ hybrid: dict[str, ColorObject] = {**mana_colors, "B": (159, 146, 143)}
+ colors_inner: dict[str, ColorObject] = mana_colors_inner.copy()
+ hybrid_inner: dict[str, ColorObject] = mana_colors_inner.copy()
"""
* Layer Color Maps
"""
-
-class ColorMap(DictSchema):
- """Defines RGB or CMYK color values mapped to string keys."""
- W: ColorObject = [246, 246, 239]
- U: ColorObject = [0, 117, 190]
- B: ColorObject = [39, 38, 36]
- R: ColorObject = [239, 56, 39]
- G: ColorObject = [0, 123, 67]
- Gold: ColorObject = [246, 210, 98]
- Land: ColorObject = [174, 151, 135]
- Artifact: ColorObject = [230, 236, 242]
- Colorless: ColorObject = [230, 236, 242]
- Vehicle: ColorObject = [77, 45, 5]
+color_map: dict[str, ColorObject] = {
+ "W": (246, 246, 239),
+ "U": (0, 117, 190),
+ "B": (39, 38, 36),
+ "R": (239, 56, 39),
+ "G": (0, 123, 67),
+ "Gold": (246, 210, 98),
+ "Land": (174, 151, 135),
+ "Artifact": (230, 236, 242),
+ "Colorless": (230, 236, 242),
+ "Vehicle": (77, 45, 5),
+}
+"""Defines RGB or CMYK color values mapped to string keys."""
watermark_color_map = {
# Default watermark colors
- 'W': [183, 157, 88],
- 'U': [140, 172, 197],
- 'B': [94, 94, 94],
- 'R': [198, 109, 57],
- 'G': [89, 140, 82],
- 'Gold': [202, 179, 77],
- 'Land': [94, 84, 72],
- 'Artifact': [100, 125, 134],
- 'Colorless': [100, 125, 134]
+ "W": (183, 157, 88),
+ "U": (140, 172, 197),
+ "B": (94, 94, 94),
+ "R": (198, 109, 57),
+ "G": (89, 140, 82),
+ "Gold": (202, 179, 77),
+ "Land": (94, 84, 72),
+ "Artifact": (100, 125, 134),
+ "Colorless": (100, 125, 134),
}
basic_watermark_color_map = {
# Basic land watermark colors
- 'W': [248, 249, 243],
- 'U': [0, 115, 178],
- 'B': [6, 0, 0],
- 'R': [212, 39, 44],
- 'G': [1, 131, 69],
- 'Land': [165, 150, 132],
- 'Snow': [255, 255, 255]
+ "W": (248, 249, 243),
+ "U": (0, 115, 178),
+ "B": (6, 0, 0),
+ "R": (212, 39, 44),
+ "G": (1, 131, 69),
+ "Land": (165, 150, 132),
+ "Snow": (255, 255, 255),
}
pinlines_color_map = {
# Default pinline colors
- 'W': [246, 246, 239],
- 'U': [0, 117, 190],
- 'B': [39, 38, 36],
- 'R': [239, 56, 39],
- 'G': [0, 123, 67],
- 'Gold': [246, 210, 98],
- 'Land': [174, 151, 135],
- 'Artifact': [230, 236, 242],
- 'Colorless': [230, 236, 242],
- 'Vehicle': [77, 45, 5]
+ "W": (246, 246, 239),
+ "U": (0, 117, 190),
+ "B": (39, 38, 36),
+ "R": (239, 56, 39),
+ "G": (0, 123, 67),
+ "Gold": (246, 210, 98),
+ "Land": (174, 151, 135),
+ "Artifact": (230, 236, 242),
+ "Colorless": (230, 236, 242),
+ "Vehicle": (77, 45, 5),
}
indicator_color_map = {
# Default color indicator colors
- 'W': [248, 244, 240],
- 'U': [0, 109, 174],
- 'B': [57, 52, 49],
- 'R': [222, 60, 35],
- 'G': [0, 109, 66],
- 'Artifact': [181, 197, 205],
- 'Colorless': [214, 214, 220],
- 'Land': [165, 150, 132]
+ "W": (248, 244, 240),
+ "U": (0, 109, 174),
+ "B": (57, 52, 49),
+ "R": (222, 60, 35),
+ "G": (0, 109, 66),
+ "Artifact": (181, 197, 205),
+ "Colorless": (214, 214, 220),
+ "Land": (165, 150, 132),
}
crown_color_map = {
# Legendary crown colors
- 'W': [248, 244, 240],
- 'U': [0, 109, 174],
- 'B': [57, 52, 49],
- 'R': [222, 60, 35],
- 'G': [0, 109, 66],
- 'Gold': [239, 209, 107],
- 'Land': [165, 150, 132],
- 'Artifact': [181, 197, 205],
- 'Colorless': [214, 214, 220]
+ "W": (248, 244, 240),
+ "U": (0, 109, 174),
+ "B": (57, 52, 49),
+ "R": (222, 60, 35),
+ "G": (0, 109, 66),
+ "Gold": (239, 209, 107),
+ "Land": (165, 150, 132),
+ "Artifact": (181, 197, 205),
+ "Colorless": (214, 214, 220),
}
saga_banner_color_map = {
# Saga banner colors
- 'W': [241, 225, 193],
- 'U': [37, 89, 177],
- 'B': [59, 51, 48],
- 'R': [218, 56, 44],
- 'G': [1, 99, 58],
- 'Gold': [204, 173, 116],
- 'Artifact': [200, 205, 234],
- 'Land': [106, 89, 81]
+ "W": (241, 225, 193),
+ "U": (37, 89, 177),
+ "B": (59, 51, 48),
+ "R": (218, 56, 44),
+ "G": (1, 99, 58),
+ "Gold": (204, 173, 116),
+ "Artifact": (200, 205, 234),
+ "Land": (106, 89, 81),
}
saga_stripe_color_map = {
# Saga stripe colors
- 'W': [235, 209, 156],
- 'U': [34, 72, 153],
- 'B': [30, 24, 18],
- 'R': [197, 41, 30],
- 'G': [2, 84, 46],
- 'Gold': [135, 107, 45],
- 'Artifact': [163, 171, 202],
- 'Land': [55, 47, 41],
- 'Dual': [42, 42, 42]
+ "W": (235, 209, 156),
+ "U": (34, 72, 153),
+ "B": (30, 24, 18),
+ "R": (197, 41, 30),
+ "G": (2, 84, 46),
+ "Gold": (135, 107, 45),
+ "Artifact": (163, 171, 202),
+ "Land": (55, 47, 41),
+ "Dual": (42, 42, 42),
}
diff --git a/src/schmas/render_spec_schema.json b/src/schmas/render_spec_schema.json
new file mode 100644
index 00000000..de687de2
--- /dev/null
+++ b/src/schmas/render_spec_schema.json
@@ -0,0 +1,189 @@
+{
+ "$defs": {
+ "ConfigModel": {
+ "properties": {
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "settings": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "title": "Settings"
+ }
+ },
+ "required": [
+ "name",
+ "settings"
+ ],
+ "title": "ConfigModel",
+ "type": "object"
+ },
+ "FileModel": {
+ "properties": {
+ "file": {
+ "title": "File",
+ "type": "string"
+ },
+ "settings": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "default": [],
+ "title": "Settings"
+ }
+ },
+ "required": [
+ "file"
+ ],
+ "title": "FileModel",
+ "type": "object"
+ },
+ "GroupModel": {
+ "properties": {
+ "files": {
+ "anyOf": [
+ {
+ "items": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/FileModel"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ },
+ "type": "array"
+ },
+ {
+ "$ref": "#/$defs/FileModel"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "title": "Files"
+ },
+ "groups": {
+ "anyOf": [
+ {
+ "items": {
+ "$ref": "#/$defs/GroupModel"
+ },
+ "type": "array"
+ },
+ {
+ "$ref": "#/$defs/GroupModel"
+ }
+ ],
+ "default": [],
+ "title": "Groups"
+ },
+ "settings": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "default": [],
+ "title": "Settings"
+ }
+ },
+ "required": [
+ "files"
+ ],
+ "title": "GroupModel",
+ "type": "object"
+ },
+ "RenderSpecModel": {
+ "properties": {
+ "files": {
+ "anyOf": [
+ {
+ "items": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/FileModel"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ },
+ "type": "array"
+ },
+ {
+ "$ref": "#/$defs/FileModel"
+ },
+ {
+ "type": "string"
+ }
+ ],
+ "default": [],
+ "title": "Files"
+ },
+ "groups": {
+ "anyOf": [
+ {
+ "items": {
+ "$ref": "#/$defs/GroupModel"
+ },
+ "type": "array"
+ },
+ {
+ "$ref": "#/$defs/GroupModel"
+ }
+ ],
+ "default": [],
+ "title": "Groups"
+ },
+ "configs": {
+ "additionalProperties": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ },
+ "default": {},
+ "title": "Configs",
+ "type": "object"
+ }
+ },
+ "title": "RenderSpecModel",
+ "type": "object"
+ }
+ },
+ "$ref": "#/$defs/RenderSpecModel",
+ "title": "RootModel[RenderSpecModel]"
+}
\ No newline at end of file
diff --git a/src/startup.py b/src/startup.py
new file mode 100644
index 00000000..906c12fa
--- /dev/null
+++ b/src/startup.py
@@ -0,0 +1,129 @@
+from logging import getLogger
+from threading import Thread
+
+from packaging.version import InvalidVersion, parse
+from pydantic import ValidationError
+from requests import RequestException
+
+from src import CON, ENV
+from src._state import PATH
+from src.utils.adobe import PhotoshopHandler
+from src.utils.fonts import check_app_fonts
+from src.utils.github import get_github_releases
+from src.utils.hexapi import get_api_key, update_hexproof_cache
+from src.utils.threading import ThreadInitializedInstance
+
+_logger = getLogger(__name__)
+
+
+def photoshop_checks(app: PhotoshopHandler) -> None:
+ # Check Photoshop connection
+ result = app.refresh_app()
+ if isinstance(result, OSError):
+ # Photoshop test failed
+ _logger.exception(
+ "Photoshop connection failed. Can't test fonts without Photoshop.",
+ exc_info=result,
+ )
+ return
+
+ # Photoshop test passed
+ _logger.info("Connected to Photoshop.")
+
+ # Check for missing or outdated fonts
+ missing, outdated = check_app_fonts(app, [PATH.FONTS])
+
+ # Font test passed
+ if not missing and not outdated:
+ _logger.info("All essential fonts are installed.")
+ return
+
+ # Missing fonts
+ if missing:
+ _logger.warning(
+ f"The following fonts aren't installed:
{
+ '
'.join(
+ [details['name'] for details in missing.values() if details['name']]
+ )
+ }"
+ )
+ if outdated:
+ _logger.warning(
+ f"The following fonts are outdated and have to be reinstalled:
{
+ '
'.join(
+ [
+ details['name']
+ for details in outdated.values()
+ if details['name']
+ ]
+ )
+ }"
+ )
+
+
+def check_app_version() -> None:
+ """Check if app is the latest version.
+
+ Returns:
+ Return True if up to date, otherwise False.
+ """
+ if ENV.APP_UPDATES_REPO:
+ try:
+ releases = get_github_releases(ENV.APP_UPDATES_REPO, per_page=1)
+ if len(releases) > 0:
+ latest = releases[0].tag_name
+ try:
+ update_available = parse(ENV.VERSION.lstrip("v")) < parse(
+ latest.lstrip("v")
+ )
+ except InvalidVersion:
+ update_available = ENV.VERSION < latest
+ if update_available:
+ _logger.info(
+ f'A newer version of Proxyshop is available: {
+ latest
+ }. Download'
+ )
+ except RequestException, ValidationError:
+ _logger.exception("Failed to check app version.")
+
+
+def update_set_data() -> None:
+ # Update set data if needed
+ updated, error = update_hexproof_cache()
+ if updated:
+ CON.reload()
+ _logger.info("Hexproof API data update was applied.")
+ if error:
+ _logger.error(f"Failed to update Hexproof API data: {error}")
+
+
+def check_api_keys() -> None:
+ # Check if API keys are valid
+ if not ENV.API_GOOGLE:
+ ENV.API_GOOGLE = get_api_key("proxyshop.google.drive")
+ if not ENV.API_AMAZON:
+ ENV.API_AMAZON = get_api_key("proxyshop.amazon.s3")
+ keys = {
+ "Google Drive": ENV.API_GOOGLE,
+ "Amazon S3": ENV.API_AMAZON,
+ }
+ if keys_missing := [k for k, v in keys.items() if not v]:
+ _logger.warning(f"Failed to retrieve API keys for: {', '.join(keys_missing)}")
+ else:
+ _logger.info(f"Retrieved keys for: {', '.join(keys)}")
+
+
+def run_startup_checks(
+ photoshop_initializer: ThreadInitializedInstance[PhotoshopHandler],
+) -> None:
+ photoshop_initializer.initialize()
+ if photoshop_initializer.ready:
+ photoshop_checks(photoshop_initializer.instance)
+ else:
+ photoshop_initializer.add_listener(photoshop_checks)
+
+ for check in (check_app_version, update_set_data, check_api_keys):
+ Thread(target=check).start()
diff --git a/src/templates/__init__.py b/src/templates/__init__.py
index 42fc6b49..e71dc511 100644
--- a/src/templates/__init__.py
+++ b/src/templates/__init__.py
@@ -1,20 +1,24 @@
"""
* Templates Modules
"""
+
from src.templates._core import *
from src.templates._cosmetic import *
from src.templates._vector import *
-from src.templates.normal import *
-from src.templates.planeswalker import *
-from src.templates.transform import *
-from src.templates.mdfc import *
from src.templates.adventure import *
+from src.templates.battle import *
+from src.templates.case import *
+from src.templates.classes import *
from src.templates.leveler import *
-from src.templates.saga import *
-from src.templates.token import *
+from src.templates.mdfc import *
from src.templates.mutate import *
-from src.templates.classes import *
-from src.templates.battle import *
-from src.templates.prototype import *
+from src.templates.normal import *
from src.templates.planar import *
+from src.templates.planeswalker import *
+from src.templates.prepare import *
+from src.templates.prototype import *
+from src.templates.saga import *
from src.templates.split import *
+from src.templates.station import *
+from src.templates.token import *
+from src.templates.transform import *
diff --git a/src/templates/_core.py b/src/templates/_core.py
index 7720fd44..32e3d3f5 100644
--- a/src/templates/_core.py
+++ b/src/templates/_core.py
@@ -1,61 +1,82 @@
"""
* CORE PROXYSHOP TEMPLATES
"""
-# Standard Library Imports
-import os.path as osp
+
+from collections.abc import Callable, Sequence
from contextlib import suppress
+from datetime import UTC, datetime
from functools import cached_property
+from logging import getLogger
from pathlib import Path
+from re import Match, sub
from threading import Event
-from typing import Optional, Callable, Any, Union, Iterable
+from traceback import format_stack
+from typing import TYPE_CHECKING, Any, Unpack
-# Third Party Imports
-from pathvalidate import sanitize_filename
+from omnitils.files import get_unique_filename
+from photoshop.api import SolidColor
from photoshop.api._artlayer import ArtLayer
from photoshop.api._document import Document
from photoshop.api._layerSet import LayerSet
from photoshop.api._selection import Selection
-from photoshop.api import (
- ElementPlacement,
- SaveOptions,
- SolidColor,
- BlendMode)
+from photoshop.api.enumerations import BlendMode, ElementPlacement, SaveOptions
from PIL import Image
-from omnitils.files import get_unique_filename
-# Local Imports
-from src import APP, CON, CONSOLE, CFG, ENV, PATH
-from src.utils.scryfall import get_card_scan
-from src.cards import strip_reminder_text
-from src.console import msg_error, msg_warn
-from src.enums.mtg import MagicIcons
-from src.enums.adobe import Dimensions
+import src.helpers as psd
+from src import APP, CON
+from src._state import PATH, WatermarkFormat
+from src.cards import sanitize_card_filename, strip_reminder_text
from src.enums.layers import LAYERS
+from src.enums.mtg import CardFonts, CardTextPatterns, MagicIcons
from src.enums.settings import (
+ BorderColor,
CollectorMode,
- OutputFileType,
CollectorPromo,
+ FillMode,
+ OutputFileType,
WatermarkMode,
- BorderColor)
-from src.enums.mtg import CardTextPatterns
+)
from src.frame_logic import is_multicolor_string
-import src.helpers as psd
+from src.helpers.adjustments import CreateColorLayerKwargs
from src.helpers.effects import LayerEffects
-from src.schema.adobe import EffectColorOverlay, EffectGradientOverlay, EffectBevel
-from src.schema.colors import watermark_color_map, basic_watermark_color_map, ColorObject, GradientColor
+from src.helpers.position import DimensionNames
+from src.helpers.text import get_font_size, override_text_style_ranges
+from src.layouts import NormalLayout, SplitLayout
+from src.schema.adobe import EffectBevel, EffectColorOverlay, EffectGradientOverlay
+from src.schema.colors import (
+ ColorObject,
+ GradientColor,
+ GradientConfig,
+ basic_watermark_color_map,
+ is_rgb_or_cmyk_tuple,
+ watermark_color_map,
+)
from src.text_layers import (
- TextField,
- ScaledTextField,
FormattedTextArea,
FormattedTextField,
- FormattedTextLayer)
+ FormattedTextLayer,
+ ScaledTextField,
+ TextField,
+)
from src.utils.adobe import (
- get_photoshop_error_message,
- LayerContainer,
- PhotoshopHandler,
PS_EXCEPTIONS,
+ PhotoshopHandler,
ReferenceLayer,
- try_photoshop)
+ get_photoshop_error_message,
+ try_photoshop,
+)
+from src.utils.scryfall import get_card_scan
+from src.utils.text import format_string_with_indices
+from src.utils.windows import WindowState
+
+if TYPE_CHECKING:
+ from src.gui.qml.models.file_dialog_model import FileDialogModel
+ from src.gui.qml.models.message_dialog_content_model import (
+ MessageDialogContentModel,
+ )
+ from src.render.setup import RenderOperation
+
+_logger = getLogger(__name__)
"""
* Template Classes
@@ -69,65 +90,21 @@ class BaseTemplate:
- Contains all the core architecture that is required for any template to function in Proxyshop,
as well as a ton of optional built-in utility properties and methods for building templates.
"""
- frame_suffix = 'Normal'
- template_suffix = ''
- def __init__(self, layout: Any, **kwargs):
+ frame_suffix = "Normal"
+ template_suffix = ""
+ def __init__(self, layout: NormalLayout):
# Setup manual properties
self.layout = layout
- self._text = []
-
- """
- * Template Class Routing
- """
-
- def __new__(cls, layout, **kwargs) -> 'BaseTemplate':
- """Governs which class is called to instantiate a new object."""
- return cls.get_template_route(layout, **kwargs)
-
- @staticmethod
- def redirect_template(
- template_class: type['BaseTemplate'],
- template_file: Union[str, Path],
- layout,
- **kwargs
- ) -> 'BaseTemplate':
- """Reroutes template initialization to another template class and PSD file.
-
- Args:
- template_class: Template class to reroute to.
- template_file: Filename of the PSD to load with this template class.
- layout: The card layout object.
-
- Returns:
- Initialized template class object.
- """
- if isinstance(template_file, Path):
- layout.template_file = template_file
- elif isinstance(template_file, str):
- layout.template_file = layout.template_file.with_name(template_file)
- return template_class(layout, **kwargs)
-
- @classmethod
- def get_template_route(cls, layout, **kwargs) -> 'BaseTemplate':
- """Overwrite this method to reroute a template class to another class under a set of
- conditions. See the 'IxalanTemplate' class for an example.
-
- Args:
- layout: The card layout object.
-
- Returns:
- Initialized template class object.
- """
- return super().__new__(cls)
+ self.config = layout.config
"""
* Enabled Method Lists
"""
- @property
- def pre_render_methods(self) -> list[Callable]:
+ @cached_property
+ def pre_render_methods(self) -> list[Callable[[], None]]:
"""list[Callable]: Methods called before rendering begins.
Methods:
@@ -135,8 +112,8 @@ def pre_render_methods(self) -> list[Callable]:
"""
return [self.process_layout_data]
- @property
- def frame_layer_methods(self) -> list[Callable]:
+ @cached_property
+ def frame_layer_methods(self) -> list[Callable[[], None]]:
"""list[Callable]: Methods called to insert and enable frame layers.
Methods:
@@ -145,22 +122,22 @@ def frame_layer_methods(self) -> list[Callable]:
"""
return [self.color_border, self.enable_frame_layers]
- @property
- def text_layer_methods(self) -> list[Callable]:
+ @cached_property
+ def text_layer_methods(self) -> list[Callable[[], None]]:
"""list[Callable]: Methods called to insert and format text layers."""
return [
self.collector_info,
self.basic_text_layers,
- self.rules_text_and_pt_layers
+ self.rules_text_and_pt_layers,
]
- @property
- def post_text_methods(self) -> list[Callable]:
+ @cached_property
+ def post_text_methods(self) -> list[Callable[[], None]]:
"""list[Callable]: Methods called after text is inserted and formatted."""
return []
- @property
- def post_save_methods(self) -> list[Callable]:
+ @cached_property
+ def post_save_methods(self) -> list[Callable[[], None]]:
"""list[Callable]: Methods called after the rendered image is saved."""
return []
@@ -168,14 +145,14 @@ def post_save_methods(self) -> list[Callable]:
* Hook Method List
"""
- @property
- def hooks(self) -> list[Callable]:
+ @cached_property
+ def hooks(self) -> list[Callable[[], None]]:
"""list[Callable]: List of methods that will be called during the hooks execution step"""
- hooks = []
+ hooks: list[Callable[[], None]] = []
if self.is_creature:
# Creature hook
hooks.append(self.hook_creature)
- if 'P' in self.layout.mana_cost or '/' in self.layout.mana_cost:
+ if "P" in self.layout.mana_cost or "/" in self.layout.mana_cost:
# Large mana symbol hook
hooks.append(self.hook_large_mana)
return hooks
@@ -192,27 +169,34 @@ def hook_large_mana(self) -> None:
* App Properties
"""
+ @cached_property
+ def render_operation(self) -> RenderOperation:
+ raise ValueError("Render operation not assigned")
+
+ @cached_property
+ def file_dialog(self) -> FileDialogModel | None:
+ return None
+
+ @cached_property
+ def message_dialog(self) -> MessageDialogContentModel | None:
+ return None
+
@cached_property
def event(self) -> Event:
"""Event: Threading Event used to signal thread cancellation."""
return Event()
- @cached_property
- def console(self) -> type[CONSOLE]:
- """type[CONSOLE]: Console output object used to communicate with the user."""
- return CONSOLE
-
@property
def app(self) -> PhotoshopHandler:
"""PhotoshopHandler: Photoshop Application object used to communicate with Photoshop."""
- return APP
+ return APP.instance
@cached_property
- def docref(self) -> Optional[Document]:
- """Optional[Document]: This template's document open in Photoshop."""
- if doc := psd.get_document(osp.basename(self.layout.template_file)):
+ def docref(self) -> Document:
+ """This template's document open in Photoshop."""
+ if doc := psd.get_document(self.layout.template_file.name):
return doc
- return
+ raise ValueError("Failed to get document reference for the template.")
@cached_property
def doc_selection(self) -> Selection:
@@ -220,12 +204,12 @@ def doc_selection(self) -> Selection:
return self.docref.selection
@property
- def active_layer(self) -> Union[ArtLayer, LayerSet]:
+ def active_layer(self) -> ArtLayer | LayerSet:
"""Union[ArtLayer, LayerSet]: Get the currently active layer in the Photoshop document."""
return self.docref.activeLayer
@active_layer.setter
- def active_layer(self, value: Union[ArtLayer, LayerSet]):
+ def active_layer(self, value: ArtLayer | LayerSet):
"""Set the currently active layer in the Photoshop document.
Args:
@@ -252,43 +236,46 @@ def RGB_WHITE(self) -> SolidColor:
"""
@cached_property
- def save_mode(self) -> Callable:
+ def save_mode(self) -> Callable[[Path, Document | None], None]:
"""Callable: Function called to save the rendered image."""
- if CFG.output_file_type == OutputFileType.PNG:
- return psd.save_document_png
- if CFG.output_file_type == OutputFileType.PSD:
+ if self.config.output_file_type == OutputFileType.PNG:
+ return lambda pth, doc: psd.save_document_png(
+ pth, doc, self.config.png_compression_level
+ )
+ if self.config.output_file_type == OutputFileType.PSD:
return psd.save_document_psd
return psd.save_document_jpeg
@cached_property
def output_directory(self) -> Path:
- """PathL Directory to save the rendered image."""
- if ENV.TEST_MODE:
- path = PATH.OUT / self.__class__.__name__
- path.mkdir(mode=777, parents=True, exist_ok=True)
- return path
+ """Directory where the rendered image will be saved to."""
+ if output_directory := self.layout.file["kwargs"].get("dir", None):
+ return PATH.OUT / output_directory
return PATH.OUT
@cached_property
def output_file_name(self) -> Path:
- """Path: The formatted filename for the rendered image."""
- name, tag_map = CFG.output_file_name, {
- '#name': self.layout.name_raw,
- '#artist': self.layout.artist,
- '#set': self.layout.set,
- '#num': str(self.layout.collector_number),
- '#frame': self.frame_suffix,
- '#suffix': self.template_suffix,
- '#creator': self.layout.creator,
- '#lang': self.layout.lang
- }
+ """The formatted path for the rendered image."""
+ name, tag_map = (
+ self.config.output_file_name,
+ {
+ "#name": self.layout.name_raw,
+ "#artist": self.layout.artist,
+ "#set": self.layout.set,
+ "#num": str(self.layout.collector_number),
+ "#frame": self.frame_suffix,
+ "#suffix": self.template_suffix,
+ "#creator": self.layout.creator,
+ "#lang": self.layout.lang,
+ },
+ )
# Replace conditional tags
for n in CardTextPatterns.PATH_CONDITION.findall(name):
- case_new, case = n, f'<{n}>'
+ case_new, case = n, f"<{n}>"
for tag, val in tag_map.items():
if tag in case and not val:
- name, case_new = name.replace(case, ''), ''
+ name, case_new = name.replace(case, ""), ""
break
if tag in case:
case_new = case_new.replace(tag, val)
@@ -299,13 +286,13 @@ def output_file_name(self) -> Path:
for tag, value in tag_map.items():
if value:
name = name.replace(tag, value)
- path = Path(
- self.output_directory,
- sanitize_filename(name)
- ).with_suffix(f'.{CFG.output_file_type}')
+
+ path = Path(self.output_directory, sanitize_card_filename(name)).with_suffix(
+ f".{self.config.output_file_type}"
+ )
# Are we overwriting duplicate names?
- if not CFG.overwrite_duplicate:
+ if not self.config.overwrite_duplicate:
path = get_unique_filename(path)
return path
@@ -313,12 +300,12 @@ def output_file_name(self) -> Path:
* Cosmetic Extendable Checks
"""
- @property
+ @cached_property
def is_hollow_crown(self) -> bool:
"""bool: Governs whether a hollow crown should be rendered."""
return False
- @property
+ @cached_property
def is_fullart(self) -> bool:
"""bool: Returns True if art must be treated as Fullart."""
return False
@@ -327,82 +314,82 @@ def is_fullart(self) -> bool:
* Boolean Checks
"""
- @property
+ @cached_property
def is_creature(self) -> bool:
"""bool: Governs whether to add PT box and use Creature rules text."""
return self.layout.is_creature
- @property
+ @cached_property
def is_legendary(self) -> bool:
"""bool: Enables the legendary crown step."""
return self.layout.is_legendary
- @property
+ @cached_property
def is_land(self) -> bool:
"""bool: Governs whether to use normal or land pinlines group."""
return self.layout.is_land
- @property
+ @cached_property
def is_artifact(self) -> bool:
"""bool: Utility definition for custom templates. Returns True if card is an Artifact."""
return self.layout.is_artifact
- @property
+ @cached_property
def is_vehicle(self) -> bool:
"""bool: Utility definition for custom templates. Returns True if card is a Vehicle."""
return self.layout.is_vehicle
- @property
+ @cached_property
def is_hybrid(self) -> bool:
"""bool: Utility definition for custom templates. Returns True if card is hybrid color."""
return self.layout.is_hybrid
- @property
+ @cached_property
def is_colorless(self) -> bool:
"""bool: Enforces fullart framing for card art on many templates."""
return self.layout.is_colorless
- @property
+ @cached_property
def is_front(self) -> bool:
"""bool: Governs render behavior on MDFC and Transform cards."""
return self.layout.is_front
- @property
+ @cached_property
def is_transform(self) -> bool:
"""bool: Governs behavior on double faced card varieties."""
return self.layout.is_transform
- @property
+ @cached_property
def is_mdfc(self) -> bool:
"""bool: Governs behavior on double faced card varieties."""
return self.layout.is_mdfc
- @property
+ @cached_property
def is_companion(self) -> bool:
"""bool: Enables companion cosmetic elements."""
return self.layout.is_companion
- @property
+ @cached_property
def is_nyx(self) -> bool:
"""bool: Enables nyxtouched cosmetic elements."""
return self.layout.is_nyx
- @property
+ @cached_property
def is_snow(self) -> bool:
"""bool: Enables snow cosmetic elements."""
return self.layout.is_snow
- @property
+ @cached_property
def is_miracle(self) -> bool:
"""bool: Enables miracle cosmetic elements."""
return self.layout.is_miracle
- @property
+ @cached_property
def is_token(self) -> bool:
"""bool: Enables token cosmetic elements."""
return self.layout.is_token
- @property
+ @cached_property
def is_emblem(self) -> bool:
"""bool: Enables emblem cosmetic elements."""
return self.layout.is_emblem
@@ -423,7 +410,8 @@ def is_centered(self) -> bool:
return bool(
len(self.layout.flavor_text) <= 1
and len(self.layout.oracle_text) <= 70
- and "\n" not in self.layout.oracle_text)
+ and "\n" not in self.layout.oracle_text
+ )
@cached_property
def is_name_shifted(self) -> bool:
@@ -454,17 +442,23 @@ def is_art_vertical(self) -> bool:
@cached_property
def is_content_aware_enabled(self) -> bool:
"""bool: Governs whether content aware fill should be performed during the art loading step."""
- if self.is_fullart and all([n not in self.art_reference.name for n in ['Full', 'Borderless']]):
+ if self.is_fullart and (
+ not self.art_reference
+ or all([n not in self.art_reference.name for n in ["Full", "Borderless"]])
+ ):
# By default, fill when we want a fullart image but didn't receive one
- return True
+ return self.config.fill_mode != FillMode.NO_FILL
return False
@cached_property
def is_collector_promo(self) -> bool:
"""bool: Governs whether to use promo star in collector info."""
- if CFG.collector_promo == CollectorPromo.Always:
+ if self.config.collector_promo == CollectorPromo.Always:
return True
- if self.layout.is_promo and CFG.collector_promo == CollectorPromo.Automatic:
+ if (
+ self.layout.is_promo
+ and self.config.collector_promo == CollectorPromo.Automatic
+ ):
return True
return False
@@ -472,12 +466,12 @@ def is_collector_promo(self) -> bool:
* Frame Details
"""
- @property
+ @cached_property
def art_frame(self) -> str:
"""str: Normal frame to use for positioning the art."""
return LAYERS.ART_FRAME
- @property
+ @cached_property
def art_frame_vertical(self) -> str:
"""str: Vertical orientation frame to use for positioning the art."""
return LAYERS.FULL_ART_FRAME
@@ -519,33 +513,33 @@ def legal_group(self) -> LayerSet:
return self.docref.layerSets[LAYERS.LEGAL]
@cached_property
- def border_group(self) -> Optional[Union[LayerSet, ArtLayer]]:
+ def collector_group(self) -> LayerSet | None:
+ return psd.getLayerSet(LAYERS.COLLECTOR, self.legal_group)
+
+ @cached_property
+ def border_group(self) -> LayerSet | None:
"""Optional[Union[LayerSet, ArtLayer]]: Group, or sometimes a layer, containing the card border."""
if group := psd.getLayerSet(LAYERS.BORDER, self.docref):
return group
- if layer := psd.getLayer(LAYERS.BORDER, self.docref):
- return layer
return
@cached_property
- def text_group(self) -> Optional[LayerSet]:
+ def text_group(self) -> LayerSet | None:
"""Optional[LayerSet]: Text and icon group, contains rules text and necessary symbols."""
- with suppress(Exception):
- return self.docref.layerSets[LAYERS.TEXT_AND_ICONS]
- return self.docref
+ return psd.getLayerSet(LAYERS.TEXT_AND_ICONS)
@cached_property
- def dfc_group(self) -> Optional[LayerSet]:
+ def dfc_group(self) -> LayerSet | None:
"""Optional[LayerSet]: Group containing double face elements."""
face = LAYERS.FRONT if self.is_front else LAYERS.BACK
if self.is_transform:
return psd.getLayerSet(face, [self.text_group, LAYERS.TRANSFORM])
if self.is_mdfc:
- return psd.getLayerSet(f'{LAYERS.MDFC} {face}', self.text_group)
+ return psd.getLayerSet(f"{LAYERS.MDFC} {face}", self.text_group)
return
@cached_property
- def mask_group(self) -> Optional[LayerSet]:
+ def mask_group(self) -> LayerSet | None:
"""LayerSet: Group containing masks used to blend and adjust various layers."""
with suppress(Exception):
return self.docref.layerSets[LAYERS.MASKS]
@@ -556,52 +550,74 @@ def mask_group(self) -> Optional[LayerSet]:
"""
@cached_property
- def text_layer_creator(self) -> Optional[ArtLayer]:
+ def text_layer_artist(self) -> ArtLayer | None:
+ """Card artist text layer."""
+ return psd.getLayer(LAYERS.ARTIST, self.legal_group)
+
+ @cached_property
+ def text_layer_set(self) -> ArtLayer | None:
+ """Card set text layer."""
+ return psd.getLayer(LAYERS.SET, self.legal_group)
+
+ @cached_property
+ def text_layer_collector_first(self) -> ArtLayer | None:
+ return psd.getLayer(LAYERS.TOP, self.collector_group)
+
+ @cached_property
+ def text_layer_collector_second(self) -> ArtLayer | None:
+ return psd.getLayer(LAYERS.BOTTOM, self.collector_group)
+
+ @cached_property
+ def text_layer_creator(self) -> ArtLayer | None:
"""Optional[ArtLayer]: Proxy creator name text layer."""
return psd.getLayer(LAYERS.CREATOR, self.legal_group)
@cached_property
- def text_layer_name(self) -> Optional[ArtLayer]:
+ def text_layer_name(self) -> ArtLayer | None:
"""Optional[ArtLayer]: Card name text layer."""
- if self.is_name_shifted:
- psd.getLayer(LAYERS.NAME, self.text_group).visible = False
- name = psd.getLayer(LAYERS.NAME_SHIFT, self.text_group)
- name.visible = True
- return name
- return psd.getLayer(LAYERS.NAME, self.text_group)
+ layer = psd.getLayer(LAYERS.NAME, self.text_group)
+ if self.is_name_shifted and (
+ shift_layer := psd.getLayer(LAYERS.NAME_SHIFT, self.text_group)
+ ):
+ if layer:
+ layer.visible = False
+ shift_layer.visible = True
+ return shift_layer
+ return layer
@cached_property
- def text_layer_mana(self) -> Optional[ArtLayer]:
+ def text_layer_mana(self) -> ArtLayer | None:
"""Optional[ArtLayer]: Card mana cost text layer."""
return psd.getLayer(LAYERS.MANA_COST, self.text_group)
@cached_property
- def text_layer_type(self) -> Optional[ArtLayer]:
+ def text_layer_type(self) -> ArtLayer | None:
"""Optional[ArtLayer]: Card typeline text layer."""
- if self.is_type_shifted:
- psd.getLayer(LAYERS.TYPE_LINE, self.text_group).visible = False
- typeline = psd.getLayer(LAYERS.TYPE_LINE_SHIFT, self.text_group)
- typeline.visible = True
+ layer = psd.getLayer(LAYERS.TYPE_LINE, self.text_group)
+ if layer and self.is_type_shifted:
+ layer.visible = False
+ if typeline := psd.getLayer(LAYERS.TYPE_LINE_SHIFT, self.text_group):
+ typeline.visible = True
return typeline
- return psd.getLayer(LAYERS.TYPE_LINE, self.text_group)
+ return layer
@cached_property
- def text_layer_rules(self) -> Optional[ArtLayer]:
+ def text_layer_rules(self) -> ArtLayer | None:
"""Optional[ArtLayer]: Card rules text layer."""
if self.is_creature:
return psd.getLayer(LAYERS.RULES_TEXT_CREATURE, self.text_group)
return psd.getLayer(LAYERS.RULES_TEXT_NONCREATURE, self.text_group)
@cached_property
- def text_layer_pt(self) -> Optional[ArtLayer]:
+ def text_layer_pt(self) -> ArtLayer | None:
"""Optional[ArtLayer]: Card power and toughness text layer."""
return psd.getLayer(LAYERS.POWER_TOUGHNESS, self.text_group)
@cached_property
- def divider_layer(self) -> Optional[ArtLayer]:
+ def divider_layer(self) -> ArtLayer | LayerSet | None:
"""Optional[ArtLayer]: Divider layer between rules text and flavor text."""
if self.is_transform and self.is_front and self.is_flipside_creature:
- if TF_DIVIDER := psd.getLayer('Divider TF', self.text_group):
+ if TF_DIVIDER := psd.getLayer("Divider TF", self.text_group):
return TF_DIVIDER
return psd.getLayer(LAYERS.DIVIDER, self.text_group)
@@ -610,64 +626,64 @@ def divider_layer(self) -> Optional[ArtLayer]:
"""
@property
- def art_layer(self) -> ArtLayer:
+ def art_layer(self) -> ArtLayer | None:
"""ArtLayer: Layer the art image is imported into."""
return psd.getLayer(LAYERS.DEFAULT, self.docref)
@cached_property
- def twins_layer(self) -> Optional[ArtLayer]:
+ def twins_layer(self) -> ArtLayer | None:
"""Name and title boxes layer."""
return psd.getLayer(self.twins, LAYERS.TWINS)
@cached_property
- def pinlines_layer(self) -> Optional[ArtLayer]:
+ def pinlines_layer(self) -> ArtLayer | None:
"""Pinlines (and textbox) layer."""
if self.is_land:
return psd.getLayer(self.pinlines, LAYERS.LAND_PINLINES_TEXTBOX)
return psd.getLayer(self.pinlines, LAYERS.PINLINES_TEXTBOX)
@cached_property
- def background_layer(self) -> Optional[ArtLayer]:
+ def background_layer(self) -> ArtLayer | None:
"""Background texture layer."""
# Try finding Vehicle background
if self.is_vehicle and self.background == LAYERS.VEHICLE:
- return psd.getLayer(
- LAYERS.VEHICLE, LAYERS.BACKGROUND
- ) or psd.getLayer(
- LAYERS.ARTIFACT, LAYERS.BACKGROUND)
+ return psd.getLayer(LAYERS.VEHICLE, LAYERS.BACKGROUND) or psd.getLayer(
+ LAYERS.ARTIFACT, LAYERS.BACKGROUND
+ )
# All other backgrounds
return psd.getLayer(self.background, LAYERS.BACKGROUND)
@cached_property
- def pt_layer(self) -> Optional[ArtLayer]:
+ def pt_layer(self) -> ArtLayer | None:
"""Power and toughness box layer."""
# Test for Vehicle PT support
if self.is_vehicle and self.background == LAYERS.VEHICLE:
if layer := psd.getLayer(LAYERS.VEHICLE, LAYERS.PT_BOX):
# Change font to white for Vehicle PT box
- self.text_layer_pt.textItem.color = self.RGB_WHITE
+ if self.text_layer_pt:
+ self.text_layer_pt.textItem.color = self.RGB_WHITE
return layer
return psd.getLayer(self.twins, LAYERS.PT_BOX)
@cached_property
- def crown_layer(self) -> Optional[ArtLayer]:
+ def crown_layer(self) -> ArtLayer | None:
"""Legendary crown layer."""
return psd.getLayer(self.pinlines, LAYERS.LEGENDARY_CROWN)
@cached_property
- def crown_shadow_layer(self) -> Union[ArtLayer, LayerSet, None]:
+ def crown_shadow_layer(self) -> ArtLayer | LayerSet | None:
"""Legendary crown hollow shadow layer."""
return psd.getLayer(LAYERS.HOLLOW_CROWN_SHADOW, self.docref)
@cached_property
- def color_indicator_layer(self) -> Optional[ArtLayer]:
+ def color_indicator_layer(self) -> ArtLayer | None:
"""Color indicator icon layer."""
if self.layout.color_indicator:
return psd.getLayer(self.layout.color_indicator, LAYERS.COLOR_INDICATOR)
return
@cached_property
- def transform_icon_layer(self) -> Optional[ArtLayer]:
+ def transform_icon_layer(self) -> ArtLayer | None:
"""Optional[ArtLayer]: Transform icon layer."""
return psd.getLayer(self.layout.transform_icon, self.dfc_group)
@@ -676,24 +692,26 @@ def transform_icon_layer(self) -> Optional[ArtLayer]:
"""
@cached_property
- def art_reference(self) -> ReferenceLayer:
+ def art_reference(self) -> ReferenceLayer | None:
"""ReferenceLayer: Reference frame used to scale and position the art layer."""
# Check if art is vertically oriented, or forced vertical, and for valid vertical frame
- if self.is_art_vertical or (self.is_fullart and CFG.vertical_fullart):
+ if self.is_art_vertical or (self.is_fullart and self.config.vertical_fullart):
if layer := psd.get_reference_layer(self.art_frame_vertical):
return layer
# Check for normal art frame
- return psd.get_reference_layer(self.art_frame) or psd.get_reference_layer(LAYERS.ART_FRAME)
+ return psd.get_reference_layer(self.art_frame) or psd.get_reference_layer(
+ LAYERS.ART_FRAME
+ )
@cached_property
- def name_reference(self) -> Optional[ArtLayer]:
+ def name_reference(self) -> ArtLayer | None:
"""ArtLayer: By default, name uses Mana Cost as a reference to check collision against."""
if self.is_basic_land:
return
return self.text_layer_mana
@cached_property
- def type_reference(self) -> Optional[ArtLayer]:
+ def type_reference(self) -> ArtLayer | None:
"""ArtLayer: By default, typeline uses the expansion symbol to check collision against,
otherwise fallback to the expansion symbols reference layer."""
if self.is_basic_land:
@@ -701,12 +719,12 @@ def type_reference(self) -> Optional[ArtLayer]:
return self.expansion_symbol_layer or self.expansion_reference
@cached_property
- def textbox_reference(self) -> ReferenceLayer:
+ def textbox_reference(self) -> ReferenceLayer | None:
"""ReferenceLayer: Reference frame used to scale and position the rules text layer."""
return psd.get_reference_layer(LAYERS.TEXTBOX_REFERENCE, self.text_group)
@cached_property
- def pt_reference(self) -> Optional[ReferenceLayer]:
+ def pt_reference(self) -> ReferenceLayer | None:
"""ArtLayer: Reference used to check rules text overlap with the PT Box."""
if not self.is_creature:
return
@@ -733,38 +751,42 @@ def process_layout_data(self) -> None:
"""Performs any required pre-processing on the provided layout data."""
# Strip flavor text, string or list
- if CFG.remove_flavor:
- self.layout.flavor_text = "" if isinstance(
- self.layout.flavor_text, str
- ) else ['' for _ in self.layout.flavor_text]
+ if self.config.remove_flavor:
+ if isinstance(self.layout, SplitLayout):
+ self.layout.flavor_texts = ["" for _ in self.layout.flavor_texts]
+ else:
+ self.layout.flavor_text = ""
# Strip reminder text, string or list
- if CFG.remove_reminder:
- self.layout.oracle_text = strip_reminder_text(
- self.layout.oracle_text
- ) if isinstance(
- self.layout.oracle_text, str
- ) else [strip_reminder_text(n) for n in self.layout.oracle_text]
+ if self.config.remove_reminder:
+ if isinstance(self.layout, SplitLayout):
+ self.layout.oracle_texts = [
+ strip_reminder_text(txt) for txt in self.layout.oracle_texts
+ ]
+ else:
+ self.layout.oracle_text = strip_reminder_text(self.layout.oracle_text)
"""
* Loading Artwork
"""
- @property
- def art_action(self) -> Optional[Callable]:
+ @cached_property
+ def art_action(self) -> Callable[[], None] | None:
"""Function that is called to perform an action on the imported art."""
return
- @property
- def art_action_args(self) -> Optional[dict]:
- """Args to pass to art_action."""
- return
+ @cached_property
+ def art_fill_selection_hook(self) -> Callable[[ArtLayer, Selection], None] | None:
+ """A hook for adjusting the selected art area.
+ Area outside the selected area will be filled.
+ The hook runs before contract, feather, etc."""
+ return None
def load_artwork(
self,
- art_file: Optional[str | Path] = None,
- art_layer: Optional[ArtLayer] = None,
- art_reference: Optional[ReferenceLayer] = None
+ art_file: str | Path | None = None,
+ art_layer: ArtLayer | None = None,
+ art_reference: ReferenceLayer | None = None,
) -> None:
"""Loads the specified art file into the specified layer.
@@ -782,9 +804,8 @@ def load_artwork(
art_layer = art_layer or self.art_layer
art_reference = art_reference or self.art_reference
- # Check for full-art test image
- if ENV.TEST_MODE and self.is_fullart:
- art_file = PATH.SRC_IMG / "test-fa.jpg"
+ if not art_layer:
+ raise ValueError("Failed to get art layer.")
# Import art file
if self.art_action:
@@ -793,44 +814,66 @@ def load_artwork(
layer=art_layer,
path=art_file,
action=self.art_action,
- action_args=self.art_action_args,
- docref=self.docref)
+ docref=self.docref,
+ )
else:
# Use traditional pipeline
art_layer = psd.import_art(
- layer=art_layer,
- path=art_file,
- docref=self.docref)
- self.active_layer = art_layer
+ layer=art_layer, path=art_file, docref=self.docref
+ )
# Frame the artwork
- psd.frame_layer(
- layer=art_layer,
- ref=art_reference)
+ if self.layout.is_panorama:
+ psd.frame_panorama(
+ art_layer,
+ self.docref,
+ self.layout.panorama_element,
+ self.layout.panorama_size,
+ )
+ elif art_reference:
+ psd.frame_layer(layer=art_layer, ref=art_reference)
+
+ if self.config.pause_for_manual_art_alignment:
+ if not self.pause("Adjust the art alignment manually."):
+ return
# Perform content aware fill if needed
if self.is_content_aware_enabled:
-
- # Perform a generative fill
- if CFG.generative_fill:
+ if self.config.fill_mode == FillMode.CONTENT_AWARE_FILL:
+ psd.content_aware_fill_edges(
+ layer=art_layer,
+ contract=self.config.fill_contract,
+ smooth=self.config.fill_smooth,
+ feather=self.config.fill_feather,
+ art_selection_hook=self.art_fill_selection_hook,
+ )
+ elif self.config.fill_mode == FillMode.GENERATIVE_FILL:
if _doc_generated := psd.generative_fill_edges(
layer=art_layer,
- feather=CFG.feathered_fill,
- close_doc=bool(not CFG.select_variation),
- docref=self.docref
+ contract=self.config.fill_contract,
+ smooth=self.config.fill_smooth,
+ feather=self.config.fill_feather,
+ close_doc=bool(not self.config.select_variation),
+ docref=self.docref,
+ art_selection_hook=self.art_fill_selection_hook,
):
# Document reference was returned, await user intervention
- self.console.await_choice(
- self.event, msg="Select a Generative Fill variation, then click Continue ...")
+ if not self.pause("Select a Generative Fill variation."):
+ return
_doc_generated.close(SaveOptions.SaveChanges)
return
-
- # Perform a content aware fill
- psd.content_aware_fill_edges(
- layer=art_layer,
- feather=CFG.feathered_fill)
-
- def paste_scryfall_scan(self, rotate: bool = False, visible: bool = True) -> Optional[ArtLayer]:
+ elif self.config.fill_mode == FillMode.REMOVE_CONTENT_FILL:
+ psd.remove_content_fill_edges(
+ layer=art_layer,
+ contract=self.config.fill_contract,
+ smooth=self.config.fill_smooth,
+ feather=self.config.fill_feather,
+ art_selection_hook=self.art_fill_selection_hook,
+ )
+
+ def paste_scryfall_scan(
+ self, rotate: bool = False, visible: bool = True
+ ) -> ArtLayer | None:
"""Downloads the card's scryfall scan, pastes it into the document next to the active layer,
and frames it to fill the given reference layer.
@@ -850,20 +893,17 @@ def paste_scryfall_scan(self, rotate: bool = False, visible: bool = True) -> Opt
# Paste the scan into a new layer
if layer := psd.import_art_into_new_layer(
- path=scryfall_scan,
- name="Scryfall Reference",
- docref=self.docref
+ path=scryfall_scan, name="Scryfall Reference", docref=self.docref
):
# Rotate the layer if necessary
if rotate:
layer.rotate(90)
# Frame the layer and position it above the art layer
- bleed = int(self.docref.resolution / 8)
- dims = psd.get_dimensions_from_bounds(
- (bleed, bleed, self.docref.width - bleed, self.docref.height - bleed))
+ dims = psd.get_card_dimensions(self.docref)
psd.frame_layer(layer, dims)
- layer.move(self.art_layer, ElementPlacement.PlaceBefore)
+ if self.art_layer:
+ layer.move(self.art_layer, ElementPlacement.PlaceBefore)
layer.visible = visible
return layer
@@ -874,107 +914,192 @@ def paste_scryfall_scan(self, rotate: bool = False, visible: bool = True) -> Opt
def collector_info(self) -> None:
"""Format and add the collector info at the bottom."""
- # Ignore this step if legal layer not present
- if not self.legal_group:
- return
-
# If creator is specified add the text
if self.layout.creator and self.text_layer_creator:
self.text_layer_creator.textItem.contents = self.layout.creator
# Which collector info mode?
- if CFG.collector_mode in [
- CollectorMode.Default, CollectorMode.Modern
- ] and self.layout.collector_data:
+ if (
+ self.config.collector_mode in [CollectorMode.Normal, CollectorMode.Modern]
+ and self.layout.collector_data
+ ):
return self.collector_info_authentic()
- elif CFG.collector_mode == CollectorMode.ArtistOnly:
+ elif self.config.collector_mode == CollectorMode.ArtistOnly:
return self.collector_info_artist_only()
+ elif self.config.collector_mode == CollectorMode.Custom:
+ return self.collector_info_custom()
return self.collector_info_basic()
def collector_info_basic(self) -> None:
"""Called to generate basic collector info."""
# Collector layers
- artist_layer = psd.getLayer(LAYERS.ARTIST, self.legal_group)
- set_layer = psd.getLayer(LAYERS.SET, self.legal_group)
- set_TI = set_layer.textItem
+ set_TI = self.text_layer_set.textItem if self.text_layer_set else None
# Correct color for non-black border
if self.border_color != BorderColor.Black:
- set_TI.color = self.RGB_BLACK
- artist_layer.textItem.color = self.RGB_BLACK
+ if set_TI:
+ set_TI.color = self.RGB_BLACK
+ if self.text_layer_artist:
+ self.text_layer_artist.textItem.color = self.RGB_BLACK
# Fill optional collector star
- if self.is_collector_promo:
- psd.replace_text(set_layer, "β’", MagicIcons.COLLECTOR_STAR)
+ if self.text_layer_set and self.is_collector_promo:
+ psd.replace_text(self.text_layer_set, "β’", MagicIcons.COLLECTOR_STAR)
# Fill language, artist, and set
- if self.layout.lang != "en":
- psd.replace_text(set_layer, "EN", self.layout.lang.upper())
- psd.replace_text(artist_layer, "Artist", self.layout.artist)
- set_TI.contents = self.layout.set + set_TI.contents
+ if self.text_layer_set and self.layout.lang != "en":
+ psd.replace_text(self.text_layer_set, "EN", self.layout.lang.upper())
+
+ if self.text_layer_artist:
+ psd.replace_text(self.text_layer_artist, "Artist", self.layout.artist)
+
+ if set_TI:
+ set_TI.contents = self.layout.set + set_TI.contents
def collector_info_authentic(self) -> None:
"""Called to generate realistic collector info."""
# Hide basic layers
- psd.getLayer(LAYERS.ARTIST, self.legal_group).visible = False
- psd.getLayer(LAYERS.SET, self.legal_group).visible = False
-
- # Get the collector layers
- group = psd.getLayerSet(LAYERS.COLLECTOR, self.legal_group)
- top = psd.getLayer(LAYERS.TOP, group).textItem
- bottom = psd.getLayer(LAYERS.BOTTOM, group)
- group.visible = True
+ if self.text_layer_artist:
+ self.text_layer_artist.visible = False
+ if self.text_layer_set:
+ self.text_layer_set.visible = False
- # Correct color for non-black border
- if self.border_color != 'black':
- top.color = self.RGB_BLACK
- bottom.textItem.color = self.RGB_BLACK
+ if self.collector_group:
+ self.collector_group.visible = True
- # Fill in language if needed
- if self.layout.lang != "en":
- psd.replace_text(bottom, "EN", self.layout.lang.upper())
-
- # Fill optional collector star
- if self.is_collector_promo:
- psd.replace_text(bottom, "β’", MagicIcons.COLLECTOR_STAR)
-
- # Apply the collector info
- top.contents = self.layout.collector_data
- psd.replace_text(bottom, "SET", self.layout.set)
- psd.replace_text(bottom, "Artist", self.layout.artist)
+ # Get the collector layers
+ if self.text_layer_collector_first:
+ first = self.text_layer_collector_first.textItem
+
+ # Correct color for non-black border
+ if self.border_color != "black":
+ first.color = self.RGB_BLACK
+
+ # Apply the collector info
+ first.contents = self.layout.collector_data
+
+ if self.text_layer_collector_second and self.text_layer_collector_second:
+ # Fill in language if needed
+ if self.layout.lang != "en":
+ psd.replace_text(
+ self.text_layer_collector_second, "EN", self.layout.lang.upper()
+ )
+
+ # Fill optional collector star
+ if self.is_collector_promo:
+ psd.replace_text(
+ self.text_layer_collector_second, "β’", MagicIcons.COLLECTOR_STAR
+ )
+
+ # Correct color for non-black border
+ if self.border_color != "black":
+ self.text_layer_collector_second.textItem.color = self.RGB_BLACK
+
+ psd.replace_text(self.text_layer_collector_second, "SET", self.layout.set)
+ psd.replace_text(
+ self.text_layer_collector_second, "Artist", self.layout.artist
+ )
def collector_info_artist_only(self) -> None:
"""Called to generate 'Artist Only' collector info."""
# Collector layers
- artist_layer = psd.getLayer(LAYERS.ARTIST, self.legal_group)
- psd.getLayer(LAYERS.SET, self.legal_group).visible = False
+ if self.text_layer_set:
+ self.text_layer_set.visible = False
# Correct color for non-black border
- if self.border_color != BorderColor.Black:
- artist_layer.textItem.color = self.RGB_BLACK
+ if self.text_layer_artist and self.border_color != BorderColor.Black:
+ self.text_layer_artist.textItem.color = self.RGB_BLACK
# Insert artist name
- psd.replace_text(artist_layer, "Artist", self.layout.artist)
+ if self.text_layer_artist:
+ psd.replace_text(self.text_layer_artist, "Artist", self.layout.artist)
+
+ def collector_info_custom(self) -> None:
+ """Formats collector layers according to configured custom format."""
+ if layer_a := self.text_layer_set:
+ layer_a_ti = layer_a.textItem
+ font = layer_a_ti.font
+ font_size = get_font_size(layer_a)
+
+ if self.config.collector_line_a_format:
+ self.format_collector_info_line_custom(
+ layer_a, self.config.collector_line_a_format
+ )
+ else:
+ layer_a.visible = False
+
+ if layer_b := self.text_layer_artist:
+ layer_b_ti = layer_b.textItem
+ layer_b_ti.font = font
+ layer_b_ti.size = font_size
+
+ if self.config.collector_line_b_format:
+ self.format_collector_info_line_custom(
+ layer_b, self.config.collector_line_b_format
+ )
+ else:
+ layer_b.visible = False
+
+ def format_collector_info_line_custom(self, layer: ArtLayer, format: str) -> None:
+ """Format collector info line according to given format, e.g.
+ `{artist} {date:%Y-%m-%d %H:%M}Z`"""
+
+ # Build values dictionary with all available placeholders
+ values = {
+ "pen": MagicIcons.PAINTBRUSH_MODERN,
+ "artist": self.layout.artist,
+ "number": self.layout.collector_number,
+ "set": self.layout.set,
+ "rarity": self.layout.rarity_letter,
+ "lang": self.layout.lang,
+ }
+
+ now = datetime.now(tz=UTC)
+ id_counter: list[int] = [0]
+ prefix = "_TkJqxrzSveisbe_"
+
+ def replace_date(match: Match[str]) -> str:
+ new_id = f"{prefix}{id_counter[0]}"
+ id_counter[0] += 1
+ values[new_id] = now.strftime(match.group(1))
+ return f"{{{new_id}}}"
+
+ # Handle {date:} pattern
+ format_str = sub(r"\{date:([^}]+)\}", replace_date, format)
+
+ # Format string with values
+ formatted_text, indices = format_string_with_indices(format_str, **values)
+ layer.textItem.contents = formatted_text
+
+ # Apply specific text styles to substitutions that need them
+ if ranges := indices.get("pen"):
+ override_text_style_ranges(
+ layer, ranges, font=CardFonts.MANA, size=5, tracking=200
+ )
+ if ranges := indices.get("artist"):
+ override_text_style_ranges(
+ layer, ranges, font=CardFonts.ARTIST, size=4.6, tracking=0
+ )
"""
* Expansion Symbol
"""
- @property
- def expansion_symbol_alignments(self) -> list[Dimensions]:
+ @cached_property
+ def expansion_symbol_alignments(self) -> list[DimensionNames]:
"""Alignments used for positioning the expansion symbol"""
- return [Dimensions.Right, Dimensions.CenterY]
+ return ["right", "center_y"]
@cached_property
- def expansion_symbol_layer(self) -> Optional[ArtLayer]:
+ def expansion_symbol_layer(self) -> ArtLayer | None:
"""Expansion symbol layer, value set during the `load_expansion_symbol` method."""
return
@cached_property
- def expansion_reference(self) -> Optional[ArtLayer]:
+ def expansion_reference(self) -> ArtLayer | None:
"""Expansion symbol reference layer"""
return psd.getLayer(LAYERS.EXPANSION_REFERENCE, self.text_group)
@@ -982,33 +1107,34 @@ def load_expansion_symbol(self) -> None:
"""Imports and positions the expansion symbol SVG image."""
# Check for expansion symbol disabled
- if not CFG.symbol_enabled or not self.expansion_reference:
+ if not self.config.symbol_enabled or not self.expansion_reference:
return
if not self.layout.symbol_svg:
- return self.log("Expansion symbol disabled, SVG file not found.")
+ return _logger.error("Expansion symbol disabled. SVG file not found.")
# Try to import the expansion symbol
try:
-
# Import and place the symbol
svg = psd.import_svg(
path=str(self.layout.symbol_svg),
ref=self.expansion_reference,
placement=ElementPlacement.PlaceBefore,
- docref=self.docref)
+ docref=self.docref,
+ )
# Frame the symbol
psd.frame_layer_by_height(
layer=svg,
ref=self.expansion_reference,
- alignments=self.expansion_symbol_alignments)
+ alignments=self.expansion_symbol_alignments,
+ )
# Rename and reset property
- svg.name = 'Expansion Symbol'
+ svg.name = "Expansion Symbol"
self.expansion_symbol_layer = svg
- except Exception as e:
- return self.log('Expansion symbol disabled due to an error.', e)
+ except Exception:
+ _logger.exception("Expansion symbol disabled due to an error")
"""
* Watermark
@@ -1020,72 +1146,77 @@ def watermark_blend_mode(self) -> BlendMode:
return BlendMode.ColorBurn
@cached_property
- def watermark_color_map(self) -> dict:
+ def watermark_color_map(
+ self,
+ ) -> dict[str, tuple[float | int, float | int, float | int]]:
"""Maps color values for Watermark."""
- return watermark_color_map.copy()
+ return {**watermark_color_map}
@cached_property
- def watermark_colors(self) -> list[SolidColor]:
+ def watermark_colors(self) -> list[ColorObject]:
"""Colors to use for the Watermark."""
if self.pinlines in self.watermark_color_map:
return [self.watermark_color_map.get(self.pinlines, self.RGB_WHITE)]
elif len(self.identity) < 3:
- return [self.watermark_color_map.get(c, self.RGB_WHITE) for c in self.identity]
+ return [
+ self.watermark_color_map.get(c, self.RGB_WHITE) for c in self.identity
+ ]
return []
@cached_property
def watermark_fx(self) -> list[LayerEffects]:
"""Defines the layer effects to use for the Watermark."""
if len(self.watermark_colors) == 1:
- return [EffectColorOverlay(
- opacity=100,
- color=self.watermark_colors[0]
- )]
+ return [EffectColorOverlay(opacity=100, color=self.watermark_colors[0])]
if len(self.watermark_colors) == 2:
- return [EffectGradientOverlay(
- rotation=0,
- colors=[
- GradientColor(
- color=self.watermark_colors[0],
- location=0,
- midpoint=50),
- GradientColor(
- color=self.watermark_colors[1],
- location=4096,
- midpoint=50)
- ]
- )]
+ return [
+ EffectGradientOverlay(
+ rotation=0,
+ colors=[
+ GradientColor(
+ color=self.watermark_colors[0], location=0, midpoint=50
+ ),
+ GradientColor(
+ color=self.watermark_colors[1], location=4096, midpoint=50
+ ),
+ ],
+ )
+ ]
return []
def create_watermark(self) -> None:
"""Builds the watermark."""
# Required values to generate a Watermark
- if not all([
- self.layout.watermark_svg,
- self.layout.watermark,
- self.textbox_reference,
- self.watermark_colors,
- self.text_group
- ]):
+ if not (
+ self.layout.watermark_svg
+ and self.layout.watermark
+ and self.textbox_reference
+ and self.watermark_colors
+ and self.text_group
+ ):
return
# Get watermark custom settings if available
- wm_details = CON.watermarks.get(self.layout.watermark, {})
+ wm_details: WatermarkFormat | dict[str, float] = CON.watermarks.get(
+ self.layout.watermark, {}
+ )
# Import and frame the watermark
wm = psd.import_svg(
path=self.layout.watermark_svg,
ref=self.text_group,
placement=ElementPlacement.PlaceAfter,
- docref=self.docref)
+ docref=self.docref,
+ )
psd.frame_layer(
layer=wm,
ref=self.textbox_reference.dims,
smallest=True,
- scale=wm_details.get('scale', 80))
+ scale=wm_details.get("scale", 80),
+ )
# Apply opacity, blending, and effects
- wm.opacity = wm_details.get('opacity', CFG.watermark_opacity)
+ wm.opacity = wm_details.get("opacity", self.config.watermark_opacity)
wm.blendMode = self.watermark_blend_mode
psd.apply_fx(wm, self.watermark_fx)
@@ -1094,9 +1225,11 @@ def create_watermark(self) -> None:
"""
@cached_property
- def basic_watermark_color_map(self) -> dict:
+ def basic_watermark_color_map(
+ self,
+ ) -> dict[str, tuple[float | int, float | int, float | int]]:
"""Maps color values for Basic Land Watermark."""
- return basic_watermark_color_map.copy()
+ return {**basic_watermark_color_map}
@cached_property
def basic_watermark_color(self) -> SolidColor:
@@ -1107,9 +1240,7 @@ def basic_watermark_color(self) -> SolidColor:
def basic_watermark_fx(self) -> list[LayerEffects]:
"""Defines the layer effects used on the Basic Land Watermark."""
return [
- EffectColorOverlay(
- opacity=100,
- color=self.basic_watermark_color),
+ EffectColorOverlay(opacity=100, color=self.basic_watermark_color),
EffectBevel(
highlight_opacity=70,
shadow_opacity=72,
@@ -1117,34 +1248,36 @@ def basic_watermark_fx(self) -> list[LayerEffects]:
rotation=45,
altitude=22,
depth=100,
- size=28)
+ size=28,
+ ),
]
def create_basic_watermark(self) -> None:
"""Builds a basic land watermark."""
+ if self.layout.watermark_basic:
+ # Generate the watermark
+ wm = psd.import_svg(
+ path=self.layout.watermark_basic,
+ ref=self.text_group,
+ placement=ElementPlacement.PlaceAfter,
+ docref=self.docref,
+ )
+ if self.textbox_reference:
+ psd.frame_layer_by_height(
+ layer=wm, ref=self.textbox_reference.dims, scale=75
+ )
- # Generate the watermark
- wm = psd.import_svg(
- path=self.layout.watermark_basic,
- ref=self.text_group,
- placement=ElementPlacement.PlaceAfter,
- docref=self.docref)
- psd.frame_layer_by_height(
- layer=wm,
- ref=self.textbox_reference.dims,
- scale=75)
-
- # Add effects
- psd.apply_fx(wm, self.basic_watermark_fx)
+ # Add effects
+ psd.apply_fx(wm, self.basic_watermark_fx)
- # Add snow effects
- if self.is_snow:
- self.add_basic_watermark_snow_effects(wm)
+ # Add snow effects
+ if self.is_snow:
+ self.add_basic_watermark_snow_effects(wm)
- # Remove rules text step
- self.rules_text_and_pt_layers = lambda: None
- self.layout.oracle_text = ''
- self.layout.flavor_text = ''
+ # Remove rules text step
+ self.rules_text_and_pt_layers = lambda: None
+ self.layout.oracle_text = ""
+ self.layout.flavor_text = ""
def add_basic_watermark_snow_effects(self, wm: ArtLayer):
"""Adds optional snow effects for 'Snow' Basic Land watermarks.
@@ -1161,29 +1294,30 @@ def add_basic_watermark_snow_effects(self, wm: ArtLayer):
@cached_property
def border_color(self) -> str:
"""Use 'black' unless an alternate color and a valid border group is provided."""
- if CFG.border_color != BorderColor.Black and self.border_group:
- return CFG.border_color
- return 'black'
+ if self.config.border_color != BorderColor.Black and self.border_group:
+ return self.config.border_color
+ return "black"
@try_photoshop
def color_border(self) -> None:
"""Color this card's border based on given setting."""
- if self.border_color != BorderColor.Black:
- psd.apply_fx(self.border_group, [EffectColorOverlay(color=psd.get_color(self.border_color))])
+ if self.border_group is not None:
+ if self.layout.is_vertical_panorama:
+ self.border_group.visible = False
+ elif self.border_color != BorderColor.Black:
+ psd.apply_fx(
+ self.border_group,
+ [EffectColorOverlay(color=psd.get_color(self.border_color))],
+ )
"""
* Formatted Text Layers
"""
- @property
+ @cached_property
def text(self) -> list[FormattedTextLayer]:
"""List of text layer objects to execute."""
- return self._text
-
- @text.setter
- def text(self, value):
- """Add text layer to execute."""
- self._text = value
+ return []
def format_text_layers(self) -> None:
"""Validate and execute each formatted text layer."""
@@ -1207,9 +1341,8 @@ def check_photoshop(self) -> None:
return
# Connection with Photoshop couldn't be established, try again?
- if not self.console.await_choice(
- self.event, get_photoshop_error_message(check),
- end="Hit Continue to try again, or Cancel to end the operation.\n\n"
+ if not self.pause(
+ f"{get_photoshop_error_message(check)}\nPress OK to try again, or Cancel to end the render.",
):
# Cancel the operation
raise OSError(check)
@@ -1222,31 +1355,16 @@ def reset(self) -> None:
psd.reset_document(self.docref)
except PS_EXCEPTIONS:
pass
- self.console.end_await()
"""
* Tasks and Logging
"""
- def log(self, text: str, e: Optional[Exception] = None) -> None:
- """Writes a message to console if test mode isn't enabled, logs an exception if provided.
-
- Args:
- text: Message to write to console.
- e: Exception to log if provided.
- """
- if e:
- self.console.log_exception(e)
- if not ENV.TEST_MODE:
- self.console.update(text)
-
def run_tasks(
- self,
- funcs: list[Callable],
- message: str,
- warning: bool = False,
- args: Union[Iterable[Any], None] = None,
- kwargs: Optional[dict] = None,
+ self,
+ funcs: list[Callable[[], Any]],
+ message: str,
+ ignore_exception: bool = False,
) -> bool:
"""Run a list of functions, checking for thread cancellation and exceptions on each.
@@ -1254,17 +1372,11 @@ def run_tasks(
funcs: List of functions to perform.
message: Error message to raise if exception occurs.
warning: Warn the user if True, otherwise raise error.
- args: Optional arguments to pass to the func. Empty tuple if not provided.
- kwargs: Optional keyword arguments to pass to the func. Empty dict if not provided.
Returns:
- True if tasks completed, False if exception occurs or thread is cancelled.
+ True if tasks completed, False if exception occurred or thread was cancelled.
"""
- # Default args and kwargs
- args = args or ()
- kwargs = kwargs or {}
-
# Execute each function
for func in funcs:
# Check if thread was cancelled
@@ -1272,45 +1384,18 @@ def run_tasks(
return False
try:
# Run the task
- func(*args, **kwargs)
- except Exception as e:
- # Raise error or warning
- if not warning:
- self.raise_error(message=message, error=e)
+ func()
+ except Exception:
+ _logger.exception(self.format_exception_message(message))
+ if not ignore_exception:
return False
- self.raise_warning(message=message, error=e)
# Once again, check if thread was cancelled
if self.event.is_set():
return False
return True
- def raise_error(self, message: str, error: Optional[Exception] = None) -> None:
- """Raise an error on the console display.
-
- Args:
- message: Message to be displayed
- error: Exception object
- """
- self.console.log_error(
- thr=self.event,
- card=self.layout.name,
- template=self.layout.template_file,
- msg=f'{msg_error(message)}\n'
- f'Check [b]/logs/error.txt[/b] for details.',
- e=error)
- self.reset()
-
- def raise_warning(self, message: str, error: Exception = None) -> None:
- """Raise a warning on the console display.
-
- Args:
- message: Message to be displayed.
- error: Exception object.
- """
- if error:
- self.console.log_exception(error)
- message += "\nCheck [b]/logs/error.txt[/b] for details."
- self.console.update(msg_warn(message), exception=error)
+ def format_exception_message(self, message: str) -> str:
+ return f"An exception occurred while rendering {self.layout.display_name}: {message}"
"""
* Layer Generator Utilities
@@ -1319,11 +1404,11 @@ def raise_warning(self, message: str, error: Exception = None) -> None:
"""
def create_blended_layer(
- self,
- group: LayerSet,
- colors: Union[None, str, list[str]] = None,
- masks: Optional[list[ArtLayer]] = None,
- **kwargs
+ self,
+ group: LayerSet,
+ colors: None | str | list[str] = None,
+ masks: list[ArtLayer] | None = None,
+ blend_mode: BlendMode | None = None,
):
"""Either enable a single frame layer or create a multicolor layer using a gradient mask.
@@ -1350,33 +1435,34 @@ def create_blended_layer(
# Single layer
if len(colors) == 1:
layer = psd.getLayer(colors[0], group)
- layer.visible = True
+ if layer:
+ layer.visible = True
return
# Enable each layer color
layers: list[ArtLayer] = []
for i, color in enumerate(colors):
-
# Make layer visible
layer = psd.getLayer(color, group)
- if 'blend_mode' in kwargs:
- layer.blendMode = kwargs['blend_mode']
- layer.visible = True
+ if layer:
+ if blend_mode is not None:
+ layer.blendMode = blend_mode
+ layer.visible = True
- # Position the new layer and add a mask to previous, if previous layer exists
- if layers and len(masks) >= i:
- layer.move(layers[i - 1], ElementPlacement.PlaceAfter)
- psd.copy_layer_mask(masks[i - 1], layers[i - 1])
+ # Position the new layer and add a mask to previous, if previous layer exists
+ if layers and len(masks) >= i:
+ layer.move(layers[i - 1], ElementPlacement.PlaceAfter)
+ psd.copy_layer_mask(masks[i - 1], layers[i - 1])
- # Add to the layer list
- layers.append(layer)
+ # Add to the layer list
+ layers.append(layer)
@staticmethod
def create_blended_solid_color(
- group: LayerSet,
- colors: list[ColorObject],
- masks: Optional[list[Union[ArtLayer, LayerSet]]] = None,
- **kwargs
+ group: LayerSet,
+ colors: Sequence[ColorObject],
+ masks: Sequence[ArtLayer | LayerSet] | None = None,
+ blend_mode: BlendMode | None = None,
) -> None:
"""Either enable a single frame layer or create a multicolor layer using a gradient mask.
@@ -1395,8 +1481,8 @@ def create_blended_solid_color(
layers: list[ArtLayer] = []
for i, color in enumerate(colors):
layer = psd.smart_layer(psd.create_color_layer(color, group))
- if 'blend_mode' in kwargs:
- layer.blendMode = kwargs['blend_mode']
+ if blend_mode is not None:
+ layer.blendMode = blend_mode
# Position the new layer and add a mask to previous, if previous layer exists
if layers and len(masks) >= i:
@@ -1405,11 +1491,12 @@ def create_blended_solid_color(
layers.append(layer)
def generate_layer(
- self, group: Union[ArtLayer, LayerSet],
- colors: Union[str, ColorObject, list[ColorObject], list[dict]],
- masks: Optional[list[ArtLayer]] = None,
- **kwargs
- ) -> Optional[ArtLayer]:
+ self,
+ group: ArtLayer | LayerSet,
+ colors: ColorObject | Sequence[ColorObject] | Sequence[GradientConfig],
+ masks: list[ArtLayer] | None = None,
+ **kwargs: Unpack[CreateColorLayerKwargs],
+ ) -> ArtLayer | None:
"""Takes information about a frame layer group and routes it to the correct
generation function which blends rasterized layers, blends solid color layers, or
generates a solid color/gradient adjustment layer.
@@ -1418,11 +1505,11 @@ def generate_layer(
The result for a given 'colors' schema:
- str: Enable and/or blend one or more texture layers, unless string is a hex color, in which
case create a solid color adjustment layer.
- - list[str]: Blend multiple texture layers.
- - list[int]: Create a solid color adjustment layer.
- - list[dict]: Create a gradient adjustment layer.
- - list[list[int]]: Blend multiple solid color adjustment layers.
- - list[SolidColor]: Blend multiple solid color adjustment layers.
+ - Sequence[str]: Blend multiple texture layers.
+ - tuple[float, ...]: Create a solid color adjustment layer.
+ - Sequence[GradientConfig]: Create a gradient adjustment layer.
+ - Sequence[tuple[float, ...]]: Blend multiple solid color adjustment layers.
+ - Sequence[SolidColor]: Blend multiple solid color adjustment layers.
Args:
group: Layer or group containing layers.
@@ -1433,74 +1520,82 @@ def generate_layer(
if isinstance(colors, str):
# Example: '#FFFFFF'
# Single adjustment layer
- if colors.startswith('#'):
+ if colors.startswith("#"):
return psd.create_color_layer(
- color=colors,
- layer=group,
- docref=self.docref,
- **kwargs)
+ color=colors, layer=group, docref=self.docref, **kwargs
+ )
# Example: 'Land'
# Single or blended texture layers
- return self.create_blended_layer(
- group=group,
- colors=colors,
- masks=masks,
- **kwargs)
+ if isinstance(group, LayerSet):
+ return self.create_blended_layer(
+ group=group,
+ colors=colors,
+ masks=masks,
+ blend_mode=kwargs.get("blend_mode", None),
+ )
elif isinstance(colors, SolidColor):
# Example: SolidColor
# Single adjustment layer
return psd.create_color_layer(
- color=colors,
- layer=group,
- docref=self.docref,
- **kwargs
+ color=colors, layer=group, docref=self.docref, **kwargs
+ )
+ elif is_rgb_or_cmyk_tuple(colors):
+ # Example: [r, g, b]
+ # RGB/CMYK adjustment layer
+ return psd.create_color_layer(
+ color=colors, layer=group, docref=self.docref, **kwargs
)
- elif isinstance(colors, list):
- if all(isinstance(c, str) for c in colors):
+ elif len(colors) > 0:
+ if all(isinstance(c, str) for c in colors) and isinstance(group, LayerSet):
+ str_colors = [color for color in colors if isinstance(color, str)]
# Example: ['#000000', '#FFFFFF', ...]
# Blended RGB/CMYK adjustment layers
- if colors[0].startswith('#'): # noqa
+ if isinstance(colors[0], str) and colors[0].startswith("#"):
return self.create_blended_solid_color(
group=group,
- colors=colors,
+ colors=str_colors,
masks=masks,
- **kwargs)
+ blend_mode=kwargs.get("blend_mode", None),
+ )
# Example: ['W', 'U']
# Blended texture layers
return self.create_blended_layer(
group=group,
- colors=colors,
+ colors=str_colors,
masks=masks,
- **kwargs)
- elif all(isinstance(c, int) for c in colors):
- # Example: [r, g, b]
- # RGB/CMYK adjustment layer
- return psd.create_color_layer(
- color=colors,
- layer=group,
- docref=self.docref,
- **kwargs)
+ blend_mode=kwargs.get("blend_mode", None),
+ )
elif all(isinstance(c, dict) for c in colors):
# Example: [GradientColor, GradientColor, ...]
# Gradient adjustment layer
return psd.create_gradient_layer(
- colors=colors,
+ colors=[color for color in colors if isinstance(color, dict)],
layer=group,
docref=self.docref,
- **kwargs)
- elif all(isinstance(c, (list, SolidColor)) for c in colors):
+ **kwargs,
+ )
+ elif all(isinstance(c, tuple | SolidColor) for c in colors) and isinstance(
+ group, LayerSet
+ ):
# Example 1: [[r, g, b], [r, g, b], ...]
# Example 2: [SolidColor, SolidColor, ...]
# Blended RGB/CMYK adjustment layers
return self.create_blended_solid_color(
group=group,
- colors=colors,
+ colors=[
+ color
+ for color in colors
+ if isinstance(color, SolidColor | tuple | str)
+ ],
masks=masks,
- **kwargs)
+ blend_mode=kwargs.get("blend_mode", None),
+ )
# Failed to match a recognized color notation
if group:
- self.log(f"Couldn't generate frame element: '{group.name}'")
+ _logger.warning(
+ f"Couldn't generate layer: {group.name}\n{''.join(format_stack())}"
+ )
"""
* Extendable Methods
@@ -1531,7 +1626,24 @@ def rules_text_and_pt_layers(self) -> None:
* Execution Sequence
"""
- def execute(self) -> bool:
+ def cancel(self) -> None:
+ self.event.set()
+
+ def pause(self, message: str | None, show_photoshop: bool = True) -> bool:
+ response = self.render_operation.pause_sync(message, show_photoshop)
+ if self.config.minimize_photoshop:
+ self.app.set_window_state(WindowState.MINIMIZE)
+ return response
+
+ async def pause_async(
+ self, message: str | None, show_photoshop: bool = True
+ ) -> bool:
+ response = await self.render_operation.pause(message, show_photoshop)
+ if self.config.minimize_photoshop:
+ self.app.set_window_state(WindowState.MINIMIZE)
+ return response
+
+ async def execute(self) -> bool:
"""Perform actions to render the card using this template.
Notes:
@@ -1541,66 +1653,65 @@ def execute(self) -> bool:
"""
# Preliminary Photoshop check
if not self.run_tasks(
- funcs=[self.check_photoshop],
- message="Unable to reach Photoshop!"
+ funcs=[self.check_photoshop], message="Unable to reach Photoshop!"
):
return False
+ if self.config.minimize_photoshop:
+ self.app.set_window_state(WindowState.MINIMIZE)
+
# Pre-process layout data
if not self.run_tasks(
- funcs=self.pre_render_methods,
- message="Pre-processing layout data failed!"
+ funcs=self.pre_render_methods, message="Pre-processing layout data failed!"
):
return False
# Load in the PSD template
if not self.run_tasks(
- funcs=[self.app.load],
+ funcs=[lambda: self.app.load(self.layout.template_file)],
message="PSD template failed to load!",
- args=[str(self.layout.template_file)]
):
return False
# Load in artwork and frame it
if not self.run_tasks(
- funcs=[self.load_artwork],
- message="Unable to load artwork!"
+ funcs=[self.load_artwork], message="Unable to load artwork!"
):
return False
# Load in Scryfall scan and frame it
- if CFG.import_scryfall_scan:
+ if self.config.import_scryfall_scan:
self.run_tasks(
funcs=[self.paste_scryfall_scan],
message="Couldn't import Scryfall scan, continuing without it!",
- warning=True)
+ ignore_exception=True,
+ )
# Add expansion symbol
self.run_tasks(
funcs=[self.load_expansion_symbol],
message="Unable to generate expansion symbol!",
- warning=True)
+ ignore_exception=True,
+ )
# Add watermark
- if CFG.enable_basic_watermark and self.is_basic_land:
+ if self.config.enable_basic_watermark and self.is_basic_land:
# Basic land watermark
if not self.run_tasks(
funcs=[self.create_basic_watermark],
- message="Unable to generate basic land watermark!"
+ message="Unable to generate basic land watermark!",
):
return False
- elif CFG.watermark_mode is not WatermarkMode.Disabled:
+ elif self.config.watermark_mode is not WatermarkMode.Disabled:
# Normal watermark
if not self.run_tasks(
- funcs=[self.create_watermark],
- message="Unable to generate watermark!"
+ funcs=[self.create_watermark], message="Unable to generate watermark!"
):
return False
# Enable layers to build our frame
if not self.run_tasks(
- funcs=self.frame_layer_methods,
- message="Enabling layers failed!"
+ funcs=self.frame_layer_methods, message="Enabling layers failed!"
):
return False
@@ -1609,41 +1720,41 @@ def execute(self) -> bool:
funcs=[
*self.text_layer_methods,
self.format_text_layers,
- *self.post_text_methods
+ *self.post_text_methods,
],
- message="Formatting text layers failed!"
+ message="Formatting text layers failed!",
):
return False
# Specific hooks
if not self.run_tasks(
funcs=self.hooks,
- message="Encountered an error during triggered hooks step!"
+ message="Encountered an error during triggered hooks step!",
):
return False
# Manual edit step?
- if CFG.exit_early and not ENV.TEST_MODE:
- self.console.await_choice(self.event)
+ if self.config.exit_early:
+ await self.pause_async("Rendering paused for manual editing.")
+
+ # Make sure output folder exists
+ self.output_directory.mkdir(parents=True, exist_ok=True)
# Save the document
if not self.run_tasks(
- funcs=[self.save_mode],
+ funcs=[lambda: self.save_mode(self.output_file_name, self.docref)],
message="Error during file save process!",
- kwargs={'path': self.output_file_name, 'docref': self.docref}
):
return False
# Post save methods
if not self.run_tasks(
funcs=self.post_save_methods,
- message="Image saved, but an error was encountered during the post-save step!"
+ message="Image saved, but an error was encountered during the post-save step!",
):
return False
- # Reset document, return success
- if not ENV.TEST_MODE:
- self.console.update(f"[b]{self.output_file_name.stem}[/b] rendered successfully!")
+ # Reset document
self.reset()
return True
@@ -1659,21 +1770,28 @@ class StarterTemplate(BaseTemplate):
def basic_text_layers(self) -> None:
"""Add essential text layers: Mana cost, Card name, Typeline."""
- self.text.extend([
- FormattedTextField(
- layer=self.text_layer_mana,
- contents=self.layout.mana_cost
- ),
- ScaledTextField(
- layer=self.text_layer_name,
- contents=self.layout.name,
- reference=self.name_reference
- ),
- ScaledTextField(
- layer=self.text_layer_type,
- contents=self.layout.type_line,
- reference=self.type_reference
- )])
+ if self.text_layer_mana:
+ self.text.append(
+ FormattedTextField(
+ layer=self.text_layer_mana, contents=self.layout.mana_cost
+ )
+ )
+ if self.text_layer_name:
+ self.text.append(
+ ScaledTextField(
+ layer=self.text_layer_name,
+ contents=self.layout.name,
+ reference=self.name_reference,
+ )
+ )
+ if self.text_layer_type:
+ self.text.append(
+ ScaledTextField(
+ layer=self.text_layer_type,
+ contents=self.layout.type_line,
+ reference=self.type_reference,
+ )
+ )
class NormalTemplate(StarterTemplate):
@@ -1698,21 +1816,25 @@ def is_fullart(self) -> bool:
def rules_text_and_pt_layers(self) -> None:
"""Add rules and power/toughness text."""
- self.text.extend([
- FormattedTextArea(
- layer=self.text_layer_rules,
- contents=self.layout.oracle_text,
- flavor=self.layout.flavor_text,
- reference=self.textbox_reference,
- divider=self.divider_layer,
- pt_reference=self.pt_reference,
- centered=self.is_centered
- ),
- TextField(
- layer=self.text_layer_pt,
- contents=f"{self.layout.power}/{self.layout.toughness}"
- ) if self.is_creature else None
- ])
+ if self.text_layer_rules:
+ self.text.append(
+ FormattedTextArea(
+ layer=self.text_layer_rules,
+ contents=self.layout.oracle_text,
+ flavor=self.layout.flavor_text,
+ reference=self.textbox_reference,
+ divider=self.divider_layer,
+ pt_reference=self.pt_reference,
+ centered=self.is_centered,
+ )
+ )
+ if self.text_layer_pt and self.is_creature:
+ self.text.append(
+ TextField(
+ layer=self.text_layer_pt,
+ contents=f"{self.layout.power}/{self.layout.toughness}",
+ )
+ )
"""
* Frame Layer Methods
@@ -1749,27 +1871,29 @@ def enable_crown(self) -> None:
"""Enable layers which make-up the Legendary crown."""
# Enable crown and legendary border
- self.crown_layer.visible = True
- if self.border_group and isinstance(self.border_group, LayerContainer):
- psd.getLayer(LAYERS.NORMAL_BORDER, self.border_group).visible = False
- psd.getLayer(LAYERS.LEGENDARY_BORDER, self.border_group).visible = True
+ if self.crown_layer:
+ self.crown_layer.visible = True
+ if self.border_group:
+ if layer := psd.getLayer(LAYERS.NORMAL_BORDER, self.border_group):
+ layer.visible = False
+ if layer := psd.getLayer(LAYERS.LEGENDARY_BORDER, self.border_group):
+ layer.visible = True
# Call hollow crown step
if self.is_hollow_crown:
- self.enable_hollow_crown(
- psd.getLayer(LAYERS.SHADOWS))
+ self.enable_hollow_crown()
- def enable_hollow_crown(self, shadows: Optional[ArtLayer] = None) -> None:
+ def enable_hollow_crown(self) -> None:
"""Enable the hollow legendary crown."""
- if shadows:
+ if shadows := psd.getLayer(LAYERS.SHADOWS):
psd.enable_mask(shadows)
- psd.enable_mask(self.crown_layer.parent)
- psd.enable_mask(self.pinlines_layer.parent)
- self.crown_shadow_layer.visible = True
-
-
-class NormalEssentialsTemplate(NormalTemplate):
- """
- * Original extendable class for creating an M15 Style template without Nyx or Companion layers.
- * DEPRECATED, left here for backwards compatibility.
- """
+ if self.crown_layer and isinstance(
+ (parent := self.crown_layer.parent), LayerSet
+ ):
+ psd.enable_mask(parent)
+ if self.pinlines_layer and isinstance(
+ (parent := self.pinlines_layer.parent), LayerSet
+ ):
+ psd.enable_mask(parent)
+ if self.crown_shadow_layer:
+ self.crown_shadow_layer.visible = True
diff --git a/src/templates/_cosmetic.py b/src/templates/_cosmetic.py
index b87fbfab..a8ee7352 100644
--- a/src/templates/_cosmetic.py
+++ b/src/templates/_cosmetic.py
@@ -1,17 +1,16 @@
"""
* Cosmetic Template Class Modifiers
"""
-# Standard Library Imports
+
+from collections.abc import Callable
from functools import cached_property
-from typing import Optional, Callable, Union, cast
-# Third Party Imports
from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
-# Local Imports
-from src.enums.layers import LAYERS
import src.helpers as psd
+from src.enums.layers import LAYERS
+from src.helpers.layers import select_layer
from src.templates._core import BaseTemplate
from src.templates._vector import VectorTemplate
from src.text_layers import ScaledWidthTextField
@@ -22,29 +21,37 @@
"""
-class ExtendedMod (BaseTemplate):
+class ExtendedMod(BaseTemplate):
"""
Modifier for Extended templates.
Modifies:
- 'is_content_aware_enabled': Enabled
"""
- frame_suffix = 'Extended'
- is_content_aware_enabled = True
+
+ frame_suffix = "Extended"
+
+ @cached_property
+ def is_content_aware_enabled(self) -> bool:
+ return True
-class FullartMod (BaseTemplate):
+class FullartMod(BaseTemplate):
"""
Modifier for Fullart templates.
Modifies:
- 'is_fullart': Enabled
"""
- frame_suffix = 'Fullart'
- is_fullart = True
+
+ frame_suffix = "Fullart"
+
+ @cached_property
+ def is_fullart(self) -> bool:
+ return True
-class BorderlessMod (BaseTemplate):
+class BorderlessMod(BaseTemplate):
"""
Modifier for Borderless templates.
@@ -53,21 +60,28 @@ class BorderlessMod (BaseTemplate):
- 'is_content_aware_enabled': Enabled
- 'background_layer': None
"""
- frame_suffix = 'Borderless'
- is_fullart = True
- is_content_aware_enabled = True
+
+ frame_suffix = "Borderless"
+
+ @cached_property
+ def is_fullart(self) -> bool:
+ return True
+
+ @cached_property
+ def is_content_aware_enabled(self) -> bool:
+ return True
"""
* Layers
"""
- @property
- def background_layer(self) -> Optional[ArtLayer]:
+ @cached_property
+ def background_layer(self) -> ArtLayer | None:
"""Borderless cards have no 'Background' layer."""
return
-class VectorBorderlessMod (BorderlessMod):
+class VectorBorderlessMod(BorderlessMod):
"""
Modifier for vectorized Borderless templates.
@@ -81,8 +95,8 @@ class VectorBorderlessMod (BorderlessMod):
* Groups
"""
- @property
- def background_group(self) -> Optional[LayerSet]:
+ @cached_property
+ def background_group(self) -> LayerSet | None:
"""Borderless cards have no 'Background' group."""
return
@@ -92,7 +106,7 @@ def background_group(self) -> Optional[LayerSet]:
"""
-class NyxMod (BaseTemplate):
+class NyxMod(BaseTemplate):
"""
Modifier for 'Nyxtouched' supported templates.
@@ -117,7 +131,7 @@ def is_hollow_crown(self) -> bool:
"""
@cached_property
- def background_layer(self) -> Optional[ArtLayer]:
+ def background_layer(self) -> ArtLayer | None:
"""Try finding a Nyx background layer if the card is a 'Nyxtouched' frame."""
if self.is_nyx:
if layer := psd.getLayer(self.background, LAYERS.NYX):
@@ -125,7 +139,7 @@ def background_layer(self) -> Optional[ArtLayer]:
return super().background_layer
-class VectorNyxMod (NyxMod, VectorTemplate):
+class VectorNyxMod(NyxMod, VectorTemplate):
"""
Modifier for vectorized 'Nyxtouched' supported templates.
@@ -137,15 +151,15 @@ class VectorNyxMod (NyxMod, VectorTemplate):
"""
@cached_property
- def background_group(self) -> Optional[LayerSet]:
+ def background_group(self) -> LayerSet | None:
"""Optional[LayerSet]: Try finding a Nyx background group if the card is a 'Nyxtouched' frame."""
if self.is_nyx:
if layer := psd.getLayerSet(LAYERS.NYX):
return layer
- return cast(super().background_group, Optional[LayerSet])
+ return super().background_group
-class CompanionMod (BaseTemplate):
+class CompanionMod(BaseTemplate):
"""
Modifier for 'Companion' supported templates.
@@ -158,7 +172,7 @@ class CompanionMod (BaseTemplate):
"""
@cached_property
- def frame_layer_methods(self) -> list[Callable]:
+ def frame_layer_methods(self) -> list[Callable[[], None]]:
"""Add companion layers step."""
funcs = [self.enable_companion_layers] if self.is_companion else []
return [*super().frame_layer_methods, *funcs]
@@ -179,7 +193,7 @@ def is_hollow_crown(self) -> bool:
"""
@cached_property
- def companion_layer(self) -> Optional[ArtLayer]:
+ def companion_layer(self) -> ArtLayer | None:
"""Companion inner crown layer."""
return psd.getLayer(self.pinlines, LAYERS.COMPANION)
@@ -193,7 +207,7 @@ def enable_companion_layers(self) -> None:
self.companion_layer.visible = True
-class VectorCompanionMod (CompanionMod, VectorTemplate):
+class VectorCompanionMod(CompanionMod, VectorTemplate):
"""
Modifier for vectorized 'Companion' supported templates.
@@ -208,7 +222,7 @@ class VectorCompanionMod (CompanionMod, VectorTemplate):
"""
@cached_property
- def companion_group(self) -> Optional[LayerSet]:
+ def companion_group(self) -> LayerSet | None:
"""Group containing Companion inner crown textures."""
return psd.getLayerSet(LAYERS.COMPANION)
@@ -221,8 +235,13 @@ def enable_companion_layers(self) -> None:
if self.is_legendary and self.companion_group:
self.create_blended_layer(
group=self.companion_group,
- colors=self.crown_colors,
- masks=self.crown_masks)
+ colors=self.crown_colors
+ if isinstance(self.crown_colors, str)
+ else [color for color in self.crown_colors if isinstance(color, str)]
+ if isinstance(self.crown_colors, list)
+ else "",
+ masks=self.crown_masks,
+ )
"""
@@ -230,14 +249,12 @@ def enable_companion_layers(self) -> None:
"""
-class NicknameMod(VectorTemplate):
- """Modifier for adding Nickname support to a template. Nickname features were introduced after
- VectorTemplate architecture reached maturity, so there are no plans to add Nickname support
- to Raster-based templates, and this Mod is entirely based around VectorTemplate design practices.
+class NicknameMod(BaseTemplate):
+ """Modifier for adding Nickname support to a template.
Adds:
- - `is_nickname`: Boolean property which toggles Nickname behavior (default: True).
- - 'nickname_group': Defines the group containing some OR all Nickname frame elements.
+ - `is_nickname`: Boolean property which toggles Nickname behavior.
+ - 'nickname_group': Defines the group containing Nickname frame elements.
- `text_layer_nickname`: Defines the text layer the original card name gets moved to, so
Nickname text may be added to the Card Name layer.
- `nickname_shape`: Defines the shape layer used for the Nickname plat backing, also used
@@ -245,15 +262,17 @@ class NicknameMod(VectorTemplate):
Modifies:
- Hooks the `basic_text_layers` method to swap the Card Name text with the Nickname text, and
- allow the user to manually enter the Nickname text before text formatting occurs, if
+ allows the user to manually enter the Nickname text before text formatting occurs, if
nickname text wasn't provided automatically.
"""
@cached_property
- def post_text_methods(self) -> list[Callable]:
+ def post_text_methods(self) -> list[Callable[[], None]]:
"""Add nickname text layers."""
- funcs = [self.format_nickname_text] if self.is_nickname else []
- return [*super().post_text_methods, *funcs]
+ funcs = super().post_text_methods
+ if self.is_nickname:
+ funcs.append(self.format_nickname_text)
+ return funcs
"""
* Flags
@@ -261,27 +280,28 @@ def post_text_methods(self) -> list[Callable]:
@cached_property
def is_nickname(self) -> bool:
- """Toggles nickname behavior. Can be overwritten to implement
- conditional logic."""
- return True
+ """Toggles nickname behavior. Can be overwritten to implement conditional logic."""
+ return self.config.nickname_allow and (
+ bool(self.layout.nickname) or self.config.nickname_prompt
+ )
"""
* Text Layers
"""
@cached_property
- def text_layer_nickname(self) -> Optional[ArtLayer]:
+ def text_layer_nickname(self) -> ArtLayer | None:
"""Alternate text layer to use for original card name when a nickname is used."""
- _layer = psd.getLayer(LAYERS.NICKNAME, self.text_group)
- _layer.visible = True
- return _layer
+ if layer := psd.getLayer(LAYERS.NICKNAME, self.text_group):
+ layer.visible = True
+ return layer
"""
* Layer Groups
"""
@cached_property
- def nickname_group(self) -> Optional[LayerSet]:
+ def nickname_group(self) -> LayerSet | None:
"""Nickname frame element group."""
return psd.getLayerSet(LAYERS.NICKNAME)
@@ -290,9 +310,9 @@ def nickname_group(self) -> Optional[LayerSet]:
"""
@cached_property
- def nickname_shape(self) -> Optional[ReferenceLayer]:
+ def nickname_shape(self) -> ReferenceLayer | None:
"""Shape layer behind the original card name on the nickname frame element. Also used
- to position the original card name as a reference."""
+ to position the original card name as a reference."""
_shape_group = psd.getLayerSet(LAYERS.SHAPE, self.nickname_group)
# Check for a legendary-specific nickname shape
@@ -301,14 +321,6 @@ def nickname_shape(self) -> Optional[ReferenceLayer]:
return _layer
return psd.get_reference_layer(LAYERS.NORMAL, _shape_group)
- @cached_property
- def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
- """Add Nickname shape if needed."""
- _shapes = super().enabled_shapes
- if self.is_nickname:
- _shapes.append(self.nickname_shape)
- return _shapes
-
"""
* Overwrite Methods
"""
@@ -321,13 +333,16 @@ def basic_text_layers(self) -> None:
# Ask user to input the nickname if not provided
self.prompt_nickname_text()
- # Add original name
- self.text.append(
- ScaledWidthTextField(
- layer=self.text_layer_nickname,
- contents=str(self.layout.name),
- reference=self.nickname_shape))
- self.layout.name = self.layout.nickname_text
+ if self.text_layer_nickname:
+ # Add original name
+ self.text.append(
+ ScaledWidthTextField(
+ layer=self.text_layer_nickname,
+ contents=str(self.layout.name),
+ reference=self.nickname_shape,
+ )
+ )
+ self.layout.name = self.layout.nickname
return super().basic_text_layers()
"""
@@ -335,7 +350,6 @@ def basic_text_layers(self) -> None:
"""
def format_nickname_text(self) -> None:
-
# Center the card name on the nickname plate
psd.align_all(self.text_layer_nickname, self.nickname_shape)
@@ -345,9 +359,21 @@ def format_nickname_text(self) -> None:
def prompt_nickname_text(self) -> None:
"""Check if nickname text is already defined. If not, prompt the user."""
- if not self.layout.nickname:
+ if not self.layout.nickname and self.text_layer_name:
_textItem = self.text_layer_name.textItem
- _textItem.contents = 'ENTER NICKNAME'
- self.console.await_choice(
- self.event, msg='Enter nickname text, then hit continue ...')
+ _textItem.contents = "ENTER NICKNAME"
+ select_layer(self.text_layer_name)
+ self.pause("Enter nickname text.")
self.layout.nickname = _textItem.contents
+
+
+class NicknameVectorMod(NicknameMod, VectorTemplate):
+ """Modifier for adding Nickname support to a vector template."""
+
+ @cached_property
+ def enabled_shapes(self) -> list[ArtLayer | LayerSet | None]:
+ """Add Nickname shape if needed."""
+ _shapes = super().enabled_shapes
+ if self.is_nickname:
+ _shapes.append(self.nickname_shape)
+ return _shapes
diff --git a/src/templates/_vector.py b/src/templates/_vector.py
index 7600a664..aca43a12 100644
--- a/src/templates/_vector.py
+++ b/src/templates/_vector.py
@@ -7,27 +7,40 @@
- Architecture for mask and shape enabling based on card archetype
* Vector templates can be challenging for beginners, but have huge benefits.
"""
-# Standard Library Imports
+
+from collections.abc import Callable, Sequence
from functools import cached_property
-from typing import Optional, Union
+from typing import NotRequired, TypedDict
-# Third Party Imports
-from photoshop.api.application import ArtLayer
+from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
-# Local Imports
-from src.enums.layers import LAYERS
import src.helpers as psd
-from src.schema.colors import crown_color_map, indicator_color_map, pinlines_color_map
+from src.enums.layers import LAYERS
+from src.schema.colors import (
+ ColorObject,
+ GradientConfig,
+ crown_color_map,
+ indicator_color_map,
+ pinlines_color_map,
+)
from src.templates import NormalTemplate
-from src.utils.adobe import LayerObject, LayerObjectTypes
+
+
+class MaskAction(TypedDict):
+ layer: NotRequired[ArtLayer | LayerSet]
+ mask: ArtLayer | LayerSet
+ vector: NotRequired[bool]
+ funcs: NotRequired[Sequence[Callable[[ArtLayer | LayerSet], None]]]
+ """The layer is passed to the callback functions."""
+
"""
* Template Classes
"""
-class VectorTemplate (NormalTemplate):
+class VectorTemplate(NormalTemplate):
"""Next generation template using vector shape layers, automatic pinlines, and blended multicolor textures."""
"""
@@ -53,45 +66,51 @@ def is_within_color_limit(self) -> bool:
"""
@cached_property
- def pinlines_group(self) -> Optional[LayerSet]:
+ def pinlines_group(self) -> LayerSet | None:
"""Group containing pinlines colors, textures, or other groups."""
return psd.getLayerSet(LAYERS.PINLINES, self.docref)
@cached_property
def pinlines_groups(self) -> list[LayerSet]:
"""Groups where pinline colors will be generated."""
- return [self.pinlines_group]
+ if self.pinlines_group:
+ return [self.pinlines_group]
+ return []
@cached_property
- def twins_group(self) -> Optional[LayerSet]:
+ def twins_group(self) -> LayerSet | None:
"""Group containing twins texture layers."""
return psd.getLayerSet(LAYERS.TWINS, self.docref)
@cached_property
- def textbox_group(self) -> Optional[LayerSet]:
+ def textbox_group(self) -> LayerSet | None:
"""Group containing textbox texture layers."""
return psd.getLayerSet(LAYERS.TEXTBOX, self.docref)
@cached_property
- def background_group(self) -> Optional[LayerSet]:
+ def background_group(self) -> LayerSet | None:
"""Optional[LayerSet]: Group containing background texture layers."""
return psd.getLayerSet(LAYERS.BACKGROUND, self.docref)
@cached_property
- def crown_group(self) -> Optional[LayerSet]:
+ def crown_group(self) -> LayerSet | None:
"""Group containing Legendary Crown texture layers."""
return psd.getLayerSet(LAYERS.LEGENDARY_CROWN, self.docref)
@cached_property
- def pt_group(self) -> Optional[LayerSet]:
+ def pt_group(self) -> LayerSet | None:
"""Group containing PT Box texture layers."""
return psd.getLayerSet(LAYERS.PT_BOX, self.docref)
@cached_property
- def indicator_group(self) -> Optional[LayerSet]:
+ def indicator_group(self) -> LayerSet | None:
"""Group where Color Indicator colors will be generated."""
- if group := psd.getLayerSet(LAYERS.SHAPE, [self.docref, LAYERS.COLOR_INDICATOR]):
- group.parent.visible = True
+ if (
+ group := psd.getLayerSet(
+ LAYERS.SHAPE, [self.docref, LAYERS.COLOR_INDICATOR]
+ )
+ ) and isinstance((parent := group.parent), LayerSet):
+ parent.visible = True
return group
"""
@@ -99,64 +118,94 @@ def indicator_group(self) -> Optional[LayerSet]:
"""
@cached_property
- def pinlines_color_map(self) -> dict:
+ def pinlines_color_map(self) -> dict[str, ColorObject]:
"""Maps color values for the Pinlines."""
- return pinlines_color_map.copy()
+ return {**pinlines_color_map}
@cached_property
- def crown_color_map(self) -> dict:
+ def crown_color_map(self) -> dict[str, ColorObject]:
"""Maps color values for the Legendary Crown."""
- return crown_color_map.copy()
+ return {**crown_color_map}
@cached_property
- def indicator_color_map(self) -> dict:
+ def indicator_color_map(
+ self,
+ ) -> dict[str, tuple[float, float, float] | tuple[float, float, float, float]]:
"""Maps color values for the Color Indicator."""
- return indicator_color_map.copy()
+ return {**indicator_color_map}
"""
* Colors
"""
@cached_property
- def pinlines_colors(self) -> Union[list[int], list[dict]]:
+ def pinlines_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""Must be returned as SolidColor or gradient notation."""
return psd.get_pinline_gradient(
- self.identity if 1 < len(self.identity) < self.color_limit else self.pinlines,
- color_map=self.pinlines_color_map
+ self.identity
+ if 1 < len(self.identity) < self.color_limit
+ else self.pinlines,
+ color_map=self.pinlines_color_map,
)
@cached_property
- def indicator_colors(self) -> list[list[int]]:
- """list[list[int]]: Must be returned as list of RGB/CMYK color notations."""
- return [
- self.indicator_color_map.get(c, [0, 0, 0])
- for c in self.layout.color_indicator[::-1]
- ] if self.layout.color_indicator else []
+ def indicator_colors(
+ self,
+ ) -> list[tuple[float, float, float] | tuple[float, float, float, float]]:
+ """Must be returned as list of RGB/CMYK color notations."""
+ return (
+ [
+ self.indicator_color_map.get(c, (0, 0, 0))
+ for c in self.layout.color_indicator[::-1]
+ ]
+ if self.layout.color_indicator
+ else []
+ )
@cached_property
- def textbox_colors(self) -> str:
+ def textbox_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""Must be returned as color combination or layer name, e.g. WU or Artifact."""
- return self.identity if 1 < len(self.identity) < self.color_limit else self.pinlines
+ return (
+ self.identity
+ if 1 < len(self.identity) < self.color_limit
+ else self.pinlines
+ )
@cached_property
- def crown_colors(self) -> str:
+ def crown_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""Must be returned as color combination or layer name, e.g. WU or Artifact."""
- return self.identity if 1 < len(self.identity) < self.color_limit else self.pinlines
+ return (
+ self.identity
+ if 1 < len(self.identity) < self.color_limit
+ else self.pinlines
+ )
@cached_property
- def twins_colors(self) -> str:
+ def twins_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""Must be returned as color combination or layer name, e.g. WU or Artifact."""
return self.twins
@cached_property
- def background_colors(self) -> str:
+ def background_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""Must be returned as color combination or layer name, e.g. WU or Artifact."""
return self.background
@cached_property
- def pt_colors(self) -> str:
- """Optional[str]: returned as a color combination or layer name, e.g. WU or Artifact."""
- if self.is_vehicle and self.background == LAYERS.VEHICLE:
+ def pt_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
+ """Must be returned as a color combination or layer name, e.g. WU or Artifact."""
+ if self.is_vehicle and self.background == LAYERS.VEHICLE and self.text_layer_pt:
# Typically use white text for Vehicle PT
self.text_layer_pt.textItem.color = self.RGB_WHITE
return LAYERS.VEHICLE
@@ -171,48 +220,55 @@ def pt_colors(self) -> str:
"""
@cached_property
- def border_shape(self) -> Union[LayerObjectTypes, list[LayerObjectTypes], None]:
+ def border_shape(self) -> ArtLayer | None:
"""Vector shape representing the card border."""
- if self.is_legendary:
- return psd.getLayer(LAYERS.LEGENDARY, self.border_group)
- return psd.getLayer(LAYERS.NORMAL, self.border_group)
+ if isinstance(self.border_group, LayerSet):
+ if self.is_legendary:
+ return psd.getLayer(LAYERS.LEGENDARY, self.border_group)
+ return psd.getLayer(LAYERS.NORMAL, self.border_group)
@cached_property
- def crown_shape(self) -> Union[LayerObjectTypes, list[LayerObjectTypes], None]:
+ def crown_shape(self) -> ArtLayer | None:
"""Vector shape representing the legendary crown. This isn't typically used, so by default
- it just returns empty."""
+ it just returns empty."""
return None
@cached_property
- def pinlines_shape(self) -> Union[LayerObjectTypes, list[LayerObjectTypes], None]:
+ def pinlines_shape(self) -> ArtLayer | None:
"""Vector shape representing the card pinlines."""
return psd.getLayer(
(LAYERS.TRANSFORM_FRONT if self.is_front else LAYERS.TRANSFORM_BACK)
- if self.is_transform else LAYERS.NORMAL,
- [self.pinlines_group, LAYERS.SHAPE])
+ if self.is_transform
+ else LAYERS.NORMAL,
+ [self.pinlines_group, LAYERS.SHAPE],
+ )
@cached_property
- def textbox_shape(self) -> Union[LayerObjectTypes, list[LayerObjectTypes], None]:
+ def textbox_shape(self) -> ArtLayer | None:
"""Vector shape representing the card textbox."""
- name = LAYERS.TRANSFORM_FRONT if self.is_transform and self.is_front else LAYERS.NORMAL
+ name = (
+ LAYERS.TRANSFORM_FRONT
+ if self.is_transform and self.is_front
+ else LAYERS.NORMAL
+ )
return psd.getLayer(name, [self.textbox_group, LAYERS.SHAPE])
@cached_property
- def twins_shape(self) -> Union[LayerObjectTypes, list[LayerObjectTypes], None]:
+ def twins_shape(self) -> ArtLayer | None:
"""Vector shape representing the card name and title boxes."""
name = LAYERS.TRANSFORM if self.is_transform or self.is_mdfc else LAYERS.NORMAL
return psd.getLayer(name, [self.twins_group, LAYERS.SHAPE])
@cached_property
- def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
+ def enabled_shapes(self) -> list[ArtLayer | LayerSet | None]:
"""Vector shapes that should be enabled during the enable_shape_layers step. Should be
- a list of layer, layer group, or None objects."""
+ a list of layer, layer group, or None objects."""
return [
self.border_shape,
self.crown_shape,
self.pinlines_shape,
self.twins_shape,
- self.textbox_shape
+ self.textbox_shape,
]
"""
@@ -222,16 +278,30 @@ def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
@cached_property
def indicator_masks(self) -> list[ArtLayer]:
"""List of layers containing masks used to build the Color Indicator."""
- if len(self.layout.color_indicator) == 2:
+ if len(self.layout.color_indicator) == 2 and self.indicator_group:
# 2 colors -> Enable 2 outline
- psd.getLayer('2', self.indicator_group.parent).visible = True
- return [psd.getLayer(LAYERS.HALF, [self.mask_group, LAYERS.COLOR_INDICATOR])]
- if len(self.layout.color_indicator) == 3:
+ if layer := psd.getLayer("2", self.indicator_group.parent):
+ layer.visible = True
+ if layer := psd.getLayer(
+ LAYERS.HALF, [self.mask_group, LAYERS.COLOR_INDICATOR]
+ ):
+ return [layer]
+ return []
+ if len(self.layout.color_indicator) == 3 and self.indicator_group:
# 3 colors -> Enable 3 outline
- psd.getLayer('3', self.indicator_group.parent).visible = True
+ if layer := psd.getLayer("3", self.indicator_group.parent):
+ layer.visible = True
return [
- psd.getLayer(LAYERS.THIRD, [self.mask_group, LAYERS.COLOR_INDICATOR]),
- psd.getLayer(LAYERS.TWO_THIRDS, [self.mask_group, LAYERS.COLOR_INDICATOR])
+ layer
+ for layer in (
+ psd.getLayer(
+ LAYERS.THIRD, [self.mask_group, LAYERS.COLOR_INDICATOR]
+ ),
+ psd.getLayer(
+ LAYERS.TWO_THIRDS, [self.mask_group, LAYERS.COLOR_INDICATOR]
+ ),
+ )
+ if layer
]
return []
@@ -270,13 +340,21 @@ def pt_masks(self) -> list[ArtLayer]:
"""
@cached_property
- def enabled_masks(self) -> list[Union[dict, list, ArtLayer, LayerSet, None]]:
+ def enabled_masks(
+ self,
+ ) -> list[
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ]:
"""
Masks that should be copied or enabled during the `enable_layer_masks` step. Not utilized by default.
Returns:
- dict: Advanced mask notation, contains "from" and "to" layers and other optional parameters.
- - list: Contains layer to copy from, layer to copy to.
+ - tuple: Contains layer to copy from, layer to copy to.
- ArtLayer | LayerSet: Layer object to enable a mask on.
- None: Skip this mask.
"""
@@ -299,45 +377,45 @@ def enable_frame_layers(self) -> None:
if self.is_creature and self.pt_group:
self.pt_group.visible = True
self.generate_layer(
- group=self.pt_group,
- colors=self.pt_colors,
- masks=self.pt_masks)
+ group=self.pt_group, colors=self.pt_colors, masks=self.pt_masks
+ )
# Color Indicator -> Blended solid color layers
if self.is_type_shifted and self.indicator_group:
self.generate_layer(
group=self.indicator_group,
colors=self.indicator_colors,
- masks=self.indicator_masks)
+ masks=self.indicator_masks,
+ )
# Pinlines -> Solid color or gradient layers
for group in [g for g in self.pinlines_groups if g]:
group.visible = True
self.generate_layer(
- group=group,
- colors=self.pinlines_colors,
- masks=self.pinlines_masks)
+ group=group, colors=self.pinlines_colors, masks=self.pinlines_masks
+ )
# Twins -> Blended texture layers
if self.twins_group:
self.generate_layer(
- group=self.twins_group,
- colors=self.twins_colors,
- masks=self.twins_masks)
+ group=self.twins_group, colors=self.twins_colors, masks=self.twins_masks
+ )
# Textbox -> Blended texture layers
if self.textbox_group:
self.generate_layer(
group=self.textbox_group,
colors=self.textbox_colors,
- masks=self.textbox_masks)
+ masks=self.textbox_masks,
+ )
# Background layer -> Blended texture layers
if self.background_group:
self.generate_layer(
group=self.background_group,
colors=self.background_colors,
- masks=self.background_masks)
+ masks=self.background_masks,
+ )
# Legendary crown
if self.is_legendary:
@@ -345,14 +423,16 @@ def enable_frame_layers(self) -> None:
def enable_shape_layers(self) -> None:
"""Enable required vector shape layers provided by `enabled_shapes`."""
- def _enable_shape(_shapes: Union[LayerObjectTypes, list[LayerObjectTypes], None]) -> None:
- for x in _shapes:
+
+ def _enable_shape(
+ shapes: list[ArtLayer | LayerSet | None],
+ ) -> None:
+ for x in shapes:
if not x:
continue
- if isinstance(x, list):
- _enable_shape(x)
else:
x.visible = True
+
_enable_shape(self.enabled_shapes)
def enable_layer_masks(self) -> None:
@@ -360,49 +440,53 @@ def enable_layer_masks(self) -> None:
# For each mask enabled, apply it based on given notation
for mask in [m for m in self.enabled_masks if m]:
-
# Dict notation, complex mask behavior
if isinstance(mask, dict):
-
# Copy to a layer?
- if layer := mask.get('layer'):
+ if layer := mask.get("layer"):
# Copy normal or vector mask to layer
- func = psd.copy_vector_mask if mask.get('vector') else psd.copy_layer_mask
- func(mask.get('mask'), layer)
+ func = (
+ psd.copy_vector_mask
+ if mask.get("vector")
+ else psd.copy_layer_mask
+ )
+ func(mask.get("mask"), layer)
else:
# Enable normal or vector mask
- layer = mask.get('mask')
- func = psd.enable_vector_mask if mask.get('vector') else psd.enable_mask
+ layer = mask.get("mask")
+ func = (
+ psd.enable_vector_mask
+ if mask.get("vector")
+ else psd.enable_mask
+ )
func(layer)
# Apply extra functions
- [f(layer) for f in mask.get('funcs', [])]
+ [f(layer) for f in mask.get("funcs", [])]
- # List notation, copy from one layer to another
- elif isinstance(mask, list):
+ # Tuple notation, copy from one layer to another
+ elif isinstance(mask, tuple):
psd.copy_layer_mask(*mask)
# Single layer to enable mask on
- elif isinstance(mask, LayerObject):
+ else:
psd.enable_mask(mask)
def enable_crown(self) -> None:
"""Enable the Legendary crown, only called if card is Legendary."""
# Enable Legendary Crown group and layers
- self.crown_group.visible = True
- self.generate_layer(
- group=self.crown_group,
- colors=self.crown_colors,
- masks=self.crown_masks)
-
- # Enable Hollow Crown
- if self.is_hollow_crown:
- self.enable_hollow_crown(
- masks=[self.crown_group],
- vector_masks=[self.pinlines_group])
-
- def enable_hollow_crown(self, **kwargs) -> None:
+ if self.crown_group:
+ self.crown_group.visible = True
+ self.generate_layer(
+ group=self.crown_group, colors=self.crown_colors, masks=self.crown_masks
+ )
+
+ # Enable Hollow Crown
+ if self.is_hollow_crown:
+ self.enable_hollow_crown()
+
+ def enable_hollow_crown(self) -> None:
"""Enable the Hollow Crown within the Legendary Crown, only called if card is Legendary Nyx or Companion.
Keyword Args:
@@ -411,12 +495,12 @@ def enable_hollow_crown(self, **kwargs) -> None:
"""
# Layer masks to enable
- for m in kwargs.get('masks', []):
- psd.enable_mask(m)
+ if self.crown_group:
+ psd.enable_mask(self.crown_group)
# Vector masks to enable
- for m in kwargs.get('vector_masks', []):
- psd.enable_vector_mask(m)
+ if self.pinlines_group:
+ psd.enable_vector_mask(self.pinlines_group)
# Enable shadow
if self.crown_shadow_layer:
diff --git a/src/templates/adventure.py b/src/templates/adventure.py
index f43d8c18..7984bb00 100644
--- a/src/templates/adventure.py
+++ b/src/templates/adventure.py
@@ -1,21 +1,20 @@
"""
* Adventure Templates
"""
-# Standard Library
+
+from collections.abc import Callable
from functools import cached_property
-from typing import Callable, Optional, Union
-# Third Party Imports
from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
-# Local Imports
-from src.enums.layers import LAYERS
import src.helpers as psd
+import src.text_layers as text_classes
+from src.enums.layers import LAYERS
from src.layouts import AdventureLayout
-from src.templates._vector import VectorTemplate
+from src.schema.colors import ColorObject, GradientConfig
from src.templates._core import NormalTemplate
-import src.text_layers as text_classes
+from src.templates._vector import VectorTemplate
"""
* Modifier Classes
@@ -29,17 +28,18 @@ class AdventureMod(NormalTemplate):
* Adventure side text layers (Mana cost, name, typeline, and oracle text) and textbox reference.
"""
- def __init__(self, layout: AdventureLayout, **kwargs):
- super().__init__(layout, **kwargs)
-
"""
* Mixin Methods
"""
@cached_property
- def text_layer_methods(self) -> list[Callable]:
+ def text_layer_methods(self) -> list[Callable[[], None]]:
"""Add Adventure text layers step."""
- funcs = [self.text_layers_adventure] if isinstance(self.layout, AdventureLayout) else []
+ funcs = (
+ [self.text_layers_adventure]
+ if isinstance(self.layout, AdventureLayout)
+ else []
+ )
return [*super().text_layer_methods, *funcs]
"""
@@ -47,27 +47,27 @@ def text_layer_methods(self) -> list[Callable]:
"""
@cached_property
- def text_layer_name_adventure(self) -> Optional[ArtLayer]:
+ def text_layer_name_adventure(self) -> ArtLayer | None:
"""Name for the adventure side."""
return psd.getLayer(LAYERS.NAME_ADVENTURE, self.text_group)
@cached_property
- def text_layer_mana_adventure(self) -> Optional[ArtLayer]:
+ def text_layer_mana_adventure(self) -> ArtLayer | None:
"""Mana cost for the adventure side."""
return psd.getLayer(LAYERS.MANA_COST_ADVENTURE, self.text_group)
@cached_property
- def text_layer_type_adventure(self) -> Optional[ArtLayer]:
+ def text_layer_type_adventure(self) -> ArtLayer | None:
"""Type line for the adventure side."""
return psd.getLayer(LAYERS.TYPE_LINE_ADVENTURE, self.text_group)
@cached_property
- def text_layer_rules_adventure(self) -> Optional[ArtLayer]:
+ def text_layer_rules_adventure(self) -> ArtLayer | None:
"""Rules text for the adventure side."""
return psd.getLayer(LAYERS.RULES_TEXT_ADVENTURE, self.text_group)
@cached_property
- def divider_layer_adventure(self) -> Optional[ArtLayer]:
+ def divider_layer_adventure(self) -> ArtLayer | None:
"""Flavor divider for the adventure side."""
return psd.getLayer(LAYERS.DIVIDER_ADVENTURE, self.text_group)
@@ -76,37 +76,51 @@ def divider_layer_adventure(self) -> Optional[ArtLayer]:
"""
@cached_property
- def textbox_reference_adventure(self) -> Optional[ArtLayer]:
- return psd.get_reference_layer(LAYERS.TEXTBOX_REFERENCE_ADVENTURE, self.text_group)
+ def textbox_reference_adventure(self) -> ArtLayer | None:
+ return psd.get_reference_layer(
+ LAYERS.TEXTBOX_REFERENCE_ADVENTURE, self.text_group
+ )
"""
* Adventure Methods
"""
def text_layers_adventure(self):
- # Add adventure text layers
- self.text.extend([
- text_classes.FormattedTextField(
- layer=self.text_layer_mana_adventure,
- contents=self.layout.mana_adventure
- ),
- text_classes.ScaledTextField(
- layer=self.text_layer_name_adventure,
- contents=self.layout.name_adventure,
- reference=self.text_layer_mana_adventure,
- ),
- text_classes.FormattedTextArea(
- layer=self.text_layer_rules_adventure,
- contents=self.layout.oracle_text_adventure,
- reference=self.textbox_reference_adventure,
- flavor=self.layout.flavor_text_adventure,
- centered=False
- ),
- text_classes.TextField(
- layer=self.text_layer_type_adventure,
- contents=self.layout.type_line_adventure
- )
- ])
+ if isinstance(self.layout, AdventureLayout):
+ # Add adventure text layers
+
+ if self.text_layer_mana_adventure:
+ self.text.append(
+ text_classes.FormattedTextField(
+ layer=self.text_layer_mana_adventure,
+ contents=self.layout.mana_adventure,
+ )
+ )
+ if self.text_layer_name_adventure:
+ self.text.append(
+ text_classes.ScaledTextField(
+ layer=self.text_layer_name_adventure,
+ contents=self.layout.name_adventure,
+ reference=self.text_layer_mana_adventure,
+ )
+ )
+ if self.text_layer_rules_adventure:
+ self.text.append(
+ text_classes.FormattedTextArea(
+ layer=self.text_layer_rules_adventure,
+ contents=self.layout.oracle_text_adventure,
+ reference=self.textbox_reference_adventure,
+ flavor=self.layout.flavor_text_adventure,
+ centered=False,
+ )
+ )
+ if self.text_layer_type_adventure:
+ self.text.append(
+ text_classes.TextField(
+ layer=self.text_layer_type_adventure,
+ contents=self.layout.type_line_adventure,
+ )
+ )
class AdventureVectorMod(AdventureMod, VectorTemplate):
@@ -121,47 +135,48 @@ class AdventureVectorMod(AdventureMod, VectorTemplate):
# Color Maps
"""Maps color values to adventure name box."""
adventure_name_color_map = {
- 'W': [179, 172, 156],
- 'U': [43, 126, 167],
- 'B': [104, 103, 102],
- 'R': [159, 83, 59],
- 'G': [68, 96, 63],
- 'Colorless': [],
- 'Gold': [166, 145, 80],
- 'Land': [177, 166, 169]
+ "W": (179, 172, 156),
+ "U": (43, 126, 167),
+ "B": (104, 103, 102),
+ "R": (159, 83, 59),
+ "G": (68, 96, 63),
+ # There are no colorless adventure cards as of now
+ "Colorless": (-1, -1, -1),
+ "Gold": (166, 145, 80),
+ "Land": (177, 166, 169),
}
"""Maps color values to adventure typeline box."""
adventure_typeline_color_map = {
- 'W': [129, 120, 103],
- 'U': [3, 94, 127],
- 'B': [44, 41, 40],
- 'R': [124, 51, 33],
- 'G': [11, 53, 30],
- 'Colorless': [],
- 'Gold': [117, 90, 40],
- 'Land': [154, 137, 130]
+ "W": (129, 120, 103),
+ "U": (3, 94, 127),
+ "B": (44, 41, 40),
+ "R": (124, 51, 33),
+ "G": (11, 53, 30),
+ "Colorless": (-1, -1, -1),
+ "Gold": (117, 90, 40),
+ "Land": (154, 137, 130),
}
"""Maps color values to adventure typeline accent box."""
adventure_typeline_accent_color_map = {
- 'W': [90, 82, 71],
- 'U': [2, 67, 96],
- 'B': [20, 17, 19],
- 'R': [81, 34, 22],
- 'G': [2, 34, 16],
- 'Colorless': [],
- 'Gold': [75, 62, 37],
- 'Land': [115, 98, 89]
+ "W": (90, 82, 71),
+ "U": (2, 67, 96),
+ "B": (20, 17, 19),
+ "R": (81, 34, 22),
+ "G": (2, 34, 16),
+ "Colorless": (-1, -1, -1),
+ "Gold": (75, 62, 37),
+ "Land": (115, 98, 89),
}
"""Maps color values to adventure wings."""
adventure_wings_color_map = {
- 'W': [213, 203, 181],
- 'U': [181, 198, 213],
- 'B': [162, 155, 152],
- 'R': [192, 142, 115],
- 'G': [174, 174, 155],
- 'Colorless': [],
- 'Gold': [196, 172, 131],
- 'Land': [194, 178, 177]
+ "W": (213, 203, 181),
+ "U": (181, 198, 213),
+ "B": (162, 155, 152),
+ "R": (192, 142, 115),
+ "G": (174, 174, 155),
+ "Colorless": (-1, -1, -1),
+ "Gold": (196, 172, 131),
+ "Land": (194, 178, 177),
}
"""
@@ -169,9 +184,13 @@ class AdventureVectorMod(AdventureMod, VectorTemplate):
"""
@cached_property
- def frame_layer_methods(self) -> list[Callable]:
+ def frame_layer_methods(self) -> list[Callable[[], None]]:
"""Add Adventure frame layers step."""
- funcs = [self.enable_adventure_layers] if isinstance(self.layout, AdventureLayout) else []
+ funcs = (
+ [self.enable_adventure_layers]
+ if isinstance(self.layout, AdventureLayout)
+ else []
+ )
return [*super().frame_layer_methods, *funcs]
"""
@@ -179,7 +198,7 @@ def frame_layer_methods(self) -> list[Callable]:
"""
@cached_property
- def textbox_group(self) -> LayerSet:
+ def textbox_group(self) -> LayerSet | None:
"""LayerSet: Use right page of storybook as main textbox group."""
return psd.getLayerSet(LAYERS.RIGHT, self.adventure_group)
@@ -188,37 +207,37 @@ def textbox_group(self) -> LayerSet:
"""
@cached_property
- def adventure_group(self) -> LayerSet:
+ def adventure_group(self) -> LayerSet | None:
"""Adventure storybook group."""
return psd.getLayerSet(LAYERS.STORYBOOK)
@cached_property
- def adventure_pinlines_group(self) -> LayerSet:
+ def adventure_pinlines_group(self) -> LayerSet | None:
"""Pinline at the bottom of adventure storybook."""
return psd.getLayerSet(LAYERS.PINLINES, self.adventure_group)
@cached_property
- def adventure_textbox_group(self) -> LayerSet:
+ def adventure_textbox_group(self) -> LayerSet | None:
"""Left side storybook page, contains adventure text."""
return psd.getLayerSet(LAYERS.LEFT, self.adventure_group)
@cached_property
- def adventure_name_group(self) -> LayerSet:
+ def adventure_name_group(self) -> LayerSet | None:
"""Plate to color for adventure name."""
return psd.getLayerSet(LAYERS.ADVENTURE_NAME, self.adventure_group)
@cached_property
- def adventure_typeline_group(self) -> LayerSet:
+ def adventure_typeline_group(self) -> LayerSet | None:
"""Plate to color for adventure typeline."""
return psd.getLayerSet(LAYERS.ADVENTURE_TYPELINE, self.adventure_group)
@cached_property
- def adventure_typeline_accent_group(self) -> LayerSet:
+ def adventure_typeline_accent_group(self) -> LayerSet | None:
"""Plate to color for adventure typeline accent."""
return psd.getLayerSet(LAYERS.ADVENTURE_TYPELINE_ACCENT, self.adventure_group)
@cached_property
- def adventure_wings_group(self) -> LayerSet:
+ def adventure_wings_group(self) -> LayerSet | None:
"""Group containing wings on each side of adventure storybook."""
return psd.getLayerSet(LAYERS.WINGS, self.adventure_group)
@@ -229,29 +248,46 @@ def adventure_wings_group(self) -> LayerSet:
@cached_property
def adventure_textbox_colors(self) -> str:
"""Colors to use for adventure textbox textures."""
- return self.layout.adventure_colors
+ if isinstance(self.layout, AdventureLayout):
+ return self.layout.adventure_colors
+ return ""
@cached_property
- def adventure_name_colors(self) -> list[int]:
+ def adventure_name_colors(self) -> tuple[float, float, float]:
"""Colors to use for adventure name box."""
- return self.adventure_name_color_map.get(self.layout.adventure_colors)
+ if isinstance(self.layout, AdventureLayout):
+ return self.adventure_name_color_map.get(
+ self.layout.adventure_colors, (0, 0, 0)
+ )
+ return (0, 0, 0)
@cached_property
- def adventure_typeline_colors(self) -> list[int]:
+ def adventure_typeline_colors(self) -> tuple[float, float, float]:
"""Colors to use for adventure typeline box."""
- return self.adventure_typeline_color_map.get(self.layout.adventure_colors)
+ if isinstance(self.layout, AdventureLayout):
+ return self.adventure_typeline_color_map.get(
+ self.layout.adventure_colors, (0, 0, 0)
+ )
+ return (0, 0, 0)
@cached_property
- def adventure_typeline_accent_colors(self) -> list[int]:
+ def adventure_typeline_accent_colors(self) -> tuple[float, float, float]:
"""Colors to use for adventure typeline accent box."""
- return self.adventure_typeline_accent_color_map.get(self.layout.adventure_colors)
+ if isinstance(self.layout, AdventureLayout):
+ return self.adventure_typeline_accent_color_map.get(
+ self.layout.adventure_colors, (0, 0, 0)
+ )
+ return (0, 0, 0)
@cached_property
- def adventure_wings_colors(self) -> Union[list[int], list[dict]]:
+ def adventure_wings_colors(self) -> ColorObject | list[GradientConfig]:
"""Colors to use for adventure wings."""
return psd.get_pinline_gradient(
- self.identity if 1 < len(self.identity) < self.color_limit else self.pinlines,
- color_map=self.adventure_wings_color_map)
+ self.identity
+ if 1 < len(self.identity) < self.color_limit
+ else self.pinlines,
+ color_map=self.adventure_wings_color_map,
+ )
"""
* Adventure Blending Masks
@@ -264,7 +300,9 @@ def adventure_textbox_masks(self) -> list[ArtLayer]:
@cached_property
def textbox_masks(self) -> list[ArtLayer]:
- return [psd.getLayer(LAYERS.HALF, [LAYERS.MASKS, LAYERS.RIGHT])]
+ if layer := psd.getLayer(LAYERS.HALF, [LAYERS.MASKS, LAYERS.RIGHT]):
+ return [layer]
+ return []
"""
* Adventure Frame Methods
@@ -273,36 +311,45 @@ def textbox_masks(self) -> list[ArtLayer]:
def enable_adventure_layers(self) -> None:
"""Add and modify layers required for Adventure cards."""
- # Pinlines
- self.generate_layer(
- group=self.adventure_pinlines_group,
- colors=self.pinlines_colors)
+ if self.adventure_pinlines_group:
+ # Pinlines
+ self.generate_layer(
+ group=self.adventure_pinlines_group, colors=self.pinlines_colors
+ )
- # Wings
- self.generate_layer(
- group=self.adventure_wings_group,
- colors=self.adventure_wings_colors)
+ if self.adventure_wings_group:
+ # Wings
+ self.generate_layer(
+ group=self.adventure_wings_group, colors=self.adventure_wings_colors
+ )
- # textbox
- self.generate_layer(
- group=self.adventure_textbox_group,
- colors=self.adventure_textbox_colors,
- masks=self.adventure_textbox_masks)
+ if self.adventure_textbox_group:
+ # textbox
+ self.generate_layer(
+ group=self.adventure_textbox_group,
+ colors=self.adventure_textbox_colors,
+ masks=self.adventure_textbox_masks,
+ )
- # Adventure Name
- self.generate_layer(
- group=self.adventure_name_group,
- colors=self.adventure_name_colors)
+ if self.adventure_name_group:
+ # Adventure Name
+ self.generate_layer(
+ group=self.adventure_name_group, colors=self.adventure_name_colors
+ )
- # Adventure Typeline
- self.generate_layer(
- group=self.adventure_typeline_group,
- colors=self.adventure_typeline_colors)
+ if self.adventure_typeline_group:
+ # Adventure Typeline
+ self.generate_layer(
+ group=self.adventure_typeline_group,
+ colors=self.adventure_typeline_colors,
+ )
- # Adventure Accent
- self.generate_layer(
- group=self.adventure_typeline_accent_group,
- colors=self.adventure_typeline_accent_colors)
+ if self.adventure_typeline_accent_group:
+ # Adventure Accent
+ self.generate_layer(
+ group=self.adventure_typeline_accent_group,
+ colors=self.adventure_typeline_accent_colors,
+ )
"""
@@ -322,11 +369,11 @@ class AdventureVectorTemplate(AdventureVectorMod, VectorTemplate):
"""
@cached_property
- def crown_group(self) -> LayerSet:
+ def crown_group(self) -> LayerSet | None:
return psd.getLayerSet(LAYERS.LEGENDARY_CROWN, LAYERS.LEGENDARY_CROWN)
@cached_property
- def pinlines_group(self) -> LayerSet:
+ def pinlines_group(self) -> LayerSet | None:
return psd.getLayerSet(LAYERS.PINLINES, LAYERS.PINLINES)
"""
@@ -334,19 +381,19 @@ def pinlines_group(self) -> LayerSet:
"""
@cached_property
- def pinlines_legendary_shape(self) -> Optional[ArtLayer]:
+ def pinlines_legendary_shape(self) -> ArtLayer | None:
if self.is_legendary:
return psd.getLayer(LAYERS.LEGENDARY, [self.pinlines_group, LAYERS.SHAPE])
return
@cached_property
- def pt_shape(self) -> Optional[ArtLayer]:
+ def pt_shape(self) -> ArtLayer | None:
if self.is_creature:
return psd.getLayer(LAYERS.SHAPE, LAYERS.PT_BOX)
return
@cached_property
- def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
+ def enabled_shapes(self) -> list[ArtLayer | LayerSet | None]:
return [self.border_shape, self.pinlines_legendary_shape, self.pt_shape]
"""
@@ -354,5 +401,8 @@ def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
"""
def enable_crown(self) -> None:
- self.crown_group.parent.visible = True
+ if self.crown_group and isinstance(
+ (parent := self.crown_group.parent), LayerSet
+ ):
+ parent.visible = True
super().enable_crown()
diff --git a/src/templates/basic_land.py b/src/templates/basic_land.py
deleted file mode 100644
index 0c6e4663..00000000
--- a/src/templates/basic_land.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""
-* Basic Land Templates
-* Deprecated in v1.13.0
-"""
-# Standard Library Imports
-from functools import cached_property
-from typing import Optional
-
-# Third Party Imports
-from photoshop.api._layerSet import LayerSet
-
-# Local Imports
-from src.templates._cosmetic import BorderlessMod, FullartMod
-from src.templates._core import BaseTemplate
-import src.helpers as psd
-
-
-class BasicLandUnstableTemplate (BorderlessMod, BaseTemplate):
- """Basic land template for the borderless basics from Unstable. Doesn't support expansion symbol.
-
- Todo:
- Transition to 'Normal' type.
- """
- template_suffix = 'Unstable'
-
- """
- * Layer Groups
- """
-
- @cached_property
- def text_group(self) -> Optional[LayerSet]:
- return self.docref
-
- """
- * Expansion Symbol
- """
-
- def load_expansion_symbol(self):
- """Does not support expansion symbol."""
- pass
-
- """
- * Frame Layer Methods
- """
-
- def enable_frame_layers(self):
- """Only one layer, named according to basic land name."""
- psd.getLayer(self.layout.name_raw).visible = True
-
-
-class BasicLandTherosTemplate (FullartMod, BaseTemplate):
- """Fullart basic land template introduced in Theros: Beyond Death.
-
- Todo:
- Transition to 'Normal' type.
- """
- template_suffix = 'Theros'
-
- @cached_property
- def text_group(self) -> Optional[LayerSet]:
- """Text layers are in the document root."""
- return self.docref
-
- """
- * Frame Layer Methods
- """
-
- def enable_frame_layers(self):
- """Only one layer, named according to basic land name."""
- psd.getLayer(self.layout.name_raw).visible = True
diff --git a/src/templates/battle.py b/src/templates/battle.py
index fa3a8e29..9fb4c4b1 100644
--- a/src/templates/battle.py
+++ b/src/templates/battle.py
@@ -1,23 +1,22 @@
"""
* BATTLE TEMPLATES
"""
-# Standard Library
+
+from collections.abc import Callable, Sequence
from functools import cached_property
-from typing import Callable, Optional, Union
-# Third Party Imports
-from photoshop.api import SolidColor
from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
-# Local Imports
-from src.enums.layers import LAYERS
import src.helpers as psd
-from src.layouts import BattleLayout
-from src.schema.colors import pinlines_color_map
+from src.enums.layers import LAYERS
+from src.helpers.layers import get_reference_layer
+from src.layouts import BattleLayout, NormalLayout
+from src.schema.colors import ColorObject, GradientConfig, pinlines_color_map
from src.templates._core import BaseTemplate
from src.templates._vector import VectorTemplate
from src.text_layers import FormattedTextArea, TextField
+from src.utils.adobe import ReferenceLayer
"""
* Modifier Classes
@@ -34,8 +33,8 @@ class BattleMod(BaseTemplate):
* Might add support for Transform icon in the future, if other symbols are used.
"""
- def __init__(self, layout: BattleLayout, **kwargs):
- super().__init__(layout, **kwargs)
+ def __init__(self, layout: NormalLayout):
+ super().__init__(layout)
"""
* Layout Check
@@ -51,13 +50,13 @@ def is_layout_battle(self) -> bool:
"""
@cached_property
- def text_layer_methods(self) -> list[Callable]:
+ def text_layer_methods(self) -> list[Callable[[], None]]:
"""Add Class text layers."""
funcs = [self.text_layers_battle] if self.is_layout_battle else []
return [*super().text_layer_methods, *funcs]
@cached_property
- def post_text_methods(self) -> list[Callable]:
+ def post_text_methods(self) -> list[Callable[[], None]]:
"""Rotate card sideways."""
funcs = [psd.rotate_counter_clockwise] if self.is_layout_battle else []
return [*super().post_text_methods, *funcs]
@@ -67,24 +66,24 @@ def post_text_methods(self) -> list[Callable]:
"""
@cached_property
- def text_layer_name(self) -> Optional[ArtLayer]:
+ def text_layer_name(self) -> ArtLayer | None:
"""Doesn't need to be shifted."""
return psd.getLayer(LAYERS.NAME, self.text_group)
@cached_property
- def text_layer_rules(self) -> Optional[ArtLayer]:
+ def text_layer_rules(self) -> ArtLayer | None:
"""Supports noncreature and creature, with or without flipside PT."""
if self.is_transform and self.is_front and self.is_flipside_creature:
return psd.getLayer(LAYERS.RULES_TEXT_FLIP, self.text_group)
return psd.getLayer(LAYERS.RULES_TEXT, self.text_group)
@cached_property
- def text_layer_flipside_pt(self) -> Optional[ArtLayer]:
+ def text_layer_flipside_pt(self) -> ArtLayer | None:
"""Flipside power/toughness layer for front face Transform cards."""
return psd.getLayer(LAYERS.FLIPSIDE_POWER_TOUGHNESS, self.text_group)
@cached_property
- def text_layer_defense(self) -> Optional[ArtLayer]:
+ def text_layer_defense(self) -> ArtLayer | None:
"""Battle defense number in bottom right corner."""
return psd.getLayer(LAYERS.DEFENSE, self.text_group)
@@ -93,11 +92,14 @@ def text_layer_defense(self) -> Optional[ArtLayer]:
"""
@cached_property
- def defense_reference(self) -> Optional[ArtLayer]:
+ def defense_reference(self) -> ReferenceLayer | None:
"""Optional[ArtLayer]: Reference used to detect collision with the PT box."""
- return psd.getLayer(
- f"{LAYERS.DEFENSE_REFERENCE} Flip" if self.is_flipside_creature else LAYERS.DEFENSE_REFERENCE,
- self.text_group)
+ return get_reference_layer(
+ f"{LAYERS.DEFENSE_REFERENCE} Flip"
+ if self.is_flipside_creature
+ else LAYERS.DEFENSE_REFERENCE,
+ self.text_group,
+ )
"""
* Methods
@@ -110,17 +112,19 @@ def rules_text_and_pt_layers(self) -> None:
if not self.is_layout_battle:
return super().rules_text_and_pt_layers()
- # Rules Text and Power / Toughness
- self.text.extend([
- FormattedTextArea(
- layer=self.text_layer_rules,
- contents=self.layout.oracle_text,
- flavor=self.layout.flavor_text,
- reference=self.textbox_reference,
- divider=self.divider_layer,
- pt_reference=self.defense_reference,
- centered=self.is_centered
- )])
+ if self.text_layer_rules:
+ # Rules Text and Power / Toughness
+ self.text.append(
+ FormattedTextArea(
+ layer=self.text_layer_rules,
+ contents=self.layout.oracle_text,
+ flavor=self.layout.flavor_text,
+ reference=self.textbox_reference,
+ divider=self.divider_layer,
+ pt_reference=self.defense_reference,
+ centered=self.is_centered,
+ )
+ )
"""
* Battle Methods
@@ -129,18 +133,20 @@ def rules_text_and_pt_layers(self) -> None:
def text_layers_battle(self) -> None:
"""Add and modify text layers required by Battle cards."""
- # Add defense text
- self.text.append(
- TextField(
- layer=self.text_layer_defense,
- contents=self.layout.defense))
+ if isinstance(self.layout, BattleLayout) and self.text_layer_defense:
+ # Add defense text
+ self.text.append(
+ TextField(layer=self.text_layer_defense, contents=self.layout.defense)
+ )
# Add flipside Power/Toughness
- if self.is_flipside_creature:
+ if self.is_flipside_creature and self.text_layer_flipside_pt:
self.text.append(
TextField(
layer=self.text_layer_flipside_pt,
- contents=str(self.layout.other_face_power) + "/" + str(self.layout.other_face_toughness)
+ contents=str(self.layout.other_face_power)
+ + "/"
+ + str(self.layout.other_face_toughness),
)
)
@@ -157,7 +163,7 @@ class BattleTemplate(BattleMod, VectorTemplate):
* Bool Properties
"""
- @property
+ @cached_property
def is_legendary(self) -> bool:
return False
@@ -165,8 +171,8 @@ def is_legendary(self) -> bool:
* Groups
"""
- @property
- def background_group(self) -> Optional[LayerSet]:
+ @cached_property
+ def background_group(self) -> LayerSet | None:
return
"""
@@ -174,12 +180,16 @@ def background_group(self) -> Optional[LayerSet]:
"""
@cached_property
- def pinlines_colors(self) -> Union[SolidColor, list[dict]]:
+ def pinlines_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""Must be returned as SolidColor or gradient notation."""
return psd.get_pinline_gradient(
- self.identity if 1 < len(self.identity) < self.color_limit else self.pinlines,
+ self.identity
+ if 1 < len(self.identity) < self.color_limit
+ else self.pinlines,
color_map=self.pinlines_color_map,
- location_map={2: [.4543, .5886]}
+ location_map={2: [0.4543, 0.5886]},
)
"""
@@ -187,11 +197,11 @@ def pinlines_colors(self) -> Union[SolidColor, list[dict]]:
"""
@cached_property
- def textbox_shape(self) -> Optional[ArtLayer]:
+ def textbox_shape(self) -> ArtLayer | None:
return psd.getLayer(LAYERS.NORMAL, [self.textbox_group, LAYERS.SHAPE])
@cached_property
- def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
+ def enabled_shapes(self) -> list[ArtLayer | LayerSet | None]:
return [self.textbox_shape]
@@ -203,23 +213,22 @@ class UniversesBeyondBattleTemplate(BattleTemplate):
"""
@cached_property
- def pinlines_color_map(self) -> dict:
- colors = pinlines_color_map.copy()
- colors.update({
- 'W': [246, 247, 241],
- 'U': [0, 131, 193],
- 'B': [44, 40, 33],
- 'R': [237, 66, 31],
- 'G': [5, 129, 64],
- 'Gold': [239, 209, 107],
- 'Land': [165, 150, 132],
- 'Artifact': [227, 228, 230],
- 'Colorless': [227, 228, 230]
- })
- return colors
+ def pinlines_color_map(self) -> dict[str, ColorObject]:
+ return {
+ **pinlines_color_map,
+ "W": (246, 247, 241),
+ "U": (0, 131, 193),
+ "B": (44, 40, 33),
+ "R": (237, 66, 31),
+ "G": (5, 129, 64),
+ "Gold": (239, 209, 107),
+ "Land": (165, 150, 132),
+ "Artifact": (227, 228, 230),
+ "Colorless": (227, 228, 230),
+ }
@cached_property
- def twins_colors(self) -> Optional[str]:
+ def twins_colors(self) -> str:
return f"{self.twins} Beyond"
"""
@@ -227,7 +236,7 @@ def twins_colors(self) -> Optional[str]:
"""
@cached_property
- def textbox_group(self) -> LayerSet:
+ def textbox_group(self) -> LayerSet | None:
"""Textbox Beyond group."""
return psd.getLayerSet(f"{LAYERS.TEXTBOX} Beyond")
@@ -236,5 +245,5 @@ def textbox_group(self) -> LayerSet:
"""
@cached_property
- def textbox_shape(self) -> Optional[ArtLayer]:
+ def textbox_shape(self) -> ArtLayer | None:
return psd.getLayer(LAYERS.NORMAL, [self.textbox_group, LAYERS.SHAPE])
diff --git a/src/templates/case.py b/src/templates/case.py
new file mode 100644
index 00000000..17a4dd67
--- /dev/null
+++ b/src/templates/case.py
@@ -0,0 +1,181 @@
+from collections.abc import Callable
+from functools import cached_property
+
+from photoshop.api._artlayer import ArtLayer
+from photoshop.api._layerSet import LayerSet
+
+from src.enums.layers import LAYERS
+from src.helpers.bounds import get_layer_height
+from src.helpers.layers import getLayer, getLayerSet
+from src.helpers.position import position_dividers, spread_layers_over_reference
+from src.helpers.text import scale_text_layers_to_height
+from src.layouts import CaseLayout, NormalLayout
+from src.templates._core import NormalTemplate
+from src.text_layers import FormattedTextField
+
+
+class CaseMod(NormalTemplate):
+ """
+ * A template modifier for Case cards introduced in Murders at Karlov Manor.
+
+ Adds:
+ * Evenly spaced ability sections and dividers.
+ """
+
+ def __init__(self, layout: NormalLayout):
+ self._case_line_layers: list[ArtLayer] = []
+ self.case_divider_layers: list[ArtLayer | LayerSet] = []
+ super().__init__(layout)
+
+ @property
+ def case_line_layers(self) -> list[ArtLayer]:
+ return self._case_line_layers
+
+ @case_line_layers.setter
+ def case_line_layers(self, value: list[ArtLayer]) -> None:
+ self._case_line_layers = value
+
+ """
+ * Checks
+ """
+
+ @cached_property
+ def is_case_layout(self) -> bool:
+ """bool: Checks if this card uses Case layout."""
+ return isinstance(self.layout, CaseLayout)
+
+ """
+ * Mixin Methods
+ """
+
+ @cached_property
+ def text_layer_methods(self) -> list[Callable[[], None]]:
+ """Add Case text layers."""
+ funcs = [self.text_layers_case] if self.is_case_layout else []
+ return [*super().text_layer_methods, *funcs]
+
+ @cached_property
+ def frame_layer_methods(self) -> list[Callable[[], None]]:
+ """Add Case frame layers."""
+ funcs = [self.frame_layers_case] if self.is_case_layout else []
+ return [*super().frame_layer_methods, *funcs]
+
+ @cached_property
+ def post_text_methods(self) -> list[Callable[[], None]]:
+ """Position Case abilities and dividers."""
+ funcs = [self.layer_positioning_case] if self.is_case_layout else []
+ return [*super().post_text_methods, *funcs]
+
+ """
+ * Groups
+ """
+
+ @cached_property
+ def case_group(self) -> LayerSet | None:
+ return getLayerSet(LAYERS.CASE)
+
+ """
+ * Text Layers
+ """
+
+ @cached_property
+ def text_layer_ability(self) -> ArtLayer | None:
+ return getLayer(LAYERS.TEXT, self.case_group)
+
+ @cached_property
+ def case_ability_divider(self) -> ArtLayer | None:
+ return getLayer(LAYERS.DIVIDER, self.case_group)
+
+ """
+ * Layer Methods
+ """
+
+ def rules_text_and_pt_layers(self) -> None:
+ if self.is_case_layout and not self.is_creature:
+ return
+ return super().rules_text_and_pt_layers()
+
+ """
+ * Text Layer Methods
+ """
+
+ def text_layers_case(self) -> None:
+ """Add and modify text layers relating to Case type cards."""
+ if isinstance(self.layout, CaseLayout):
+ skip_divider_for = len(self.layout.case_lines) - 1
+
+ # Add text fields for each line
+ for i, line in enumerate(self.layout.case_lines):
+ # Create a new ability line
+ if layer := self.text_layer_ability:
+ line_layer: ArtLayer = layer if i == 0 else layer.duplicate()
+ self.case_line_layers.append(line_layer)
+ self.text.append(
+ FormattedTextField(layer=line_layer, contents=line)
+ )
+
+ # Use existing ability divider or create a new one
+ if i != skip_divider_for and (layer := self.case_ability_divider):
+ divider: ArtLayer = (
+ self.case_ability_divider
+ if i == 0
+ else self.case_ability_divider.duplicate()
+ )
+ self.case_divider_layers.append(divider)
+
+ """
+ * Frame Layer Methods
+ """
+
+ def frame_layers_case(self) -> None:
+ """Enable frame layers required by Case cards. None by default."""
+ pass
+
+ """
+ * Positioning Methods
+ """
+
+ def layer_positioning_case(self) -> None:
+ """Positions and sizes Case ability layers and dividers."""
+ if self.textbox_reference:
+ # Core vars
+ spacing = self.app.scale_by_dpi(80)
+ spaces = len(self.case_line_layers)
+ divider_height = (
+ get_layer_height(self.case_divider_layers[0])
+ if len(self.case_divider_layers) > 0
+ else 0
+ )
+ ref_height: float | int = self.textbox_reference.dims["height"]
+ spacing_total = (spaces * (spacing + divider_height)) + (spacing * 2)
+ total_height = ref_height - spacing_total
+
+ # Resize text items till they fit in the available space
+ scale_text_layers_to_height(
+ text_layers=self.case_line_layers, ref_height=total_height
+ )
+
+ # Get the exact gap between each layer left over
+ layer_heights = sum(
+ [get_layer_height(lyr) for lyr in self.case_line_layers]
+ )
+ gap = (ref_height - layer_heights) * (spacing / spacing_total)
+ inside_gap = (ref_height - layer_heights) * (
+ (spacing + divider_height) / spacing_total
+ )
+
+ # Space lines evenly apart
+ spread_layers_over_reference(
+ layers=self.case_line_layers,
+ ref=self.textbox_reference,
+ gap=gap,
+ inside_gap=inside_gap,
+ )
+
+ # Position a divider between each ability line
+ if len(self.case_divider_layers) == len(self.case_line_layers) - 1:
+ position_dividers(
+ dividers=self.case_divider_layers,
+ layers=self.case_line_layers,
+ docref=self.docref,
+ )
diff --git a/src/templates/classes.py b/src/templates/classes.py
index 129a5647..de294a08 100644
--- a/src/templates/classes.py
+++ b/src/templates/classes.py
@@ -1,30 +1,31 @@
"""
* CLASS TEMPLATES
"""
-# Standard Library Imports
+
+from collections.abc import Callable, Sequence
from functools import cached_property
-from typing import Union, Optional, Callable
-# Third Party Imports
-from photoshop.api.application import ArtLayer
+from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
-# Local Imports
-from src.enums.layers import LAYERS
import src.helpers as psd
-from src.layouts import ClassLayout
-from src.schema.colors import pinlines_color_map
+from src.enums.layers import LAYERS
+from src.helpers.bounds import get_dimensions_from_bounds
+from src.helpers.layers import get_reference_layer
+from src.layouts import ClassLayout, NormalLayout
+from src.schema.colors import ColorObject, GradientConfig, pinlines_color_map
from src.templates._core import NormalTemplate
from src.templates._cosmetic import VectorNyxMod
-from src.templates._vector import VectorTemplate
+from src.templates._vector import MaskAction, VectorTemplate
from src.text_layers import FormattedTextField, TextField
+from src.utils.adobe import ReferenceLayer
"""
* Modifier Classes
"""
-class ClassMod (NormalTemplate):
+class ClassMod(NormalTemplate):
"""
* A template modifier for Class cards introduced in Adventures in the Forgotten Realms.
* Utilizes similar automated positioning techniques as Planeswalker templates.
@@ -35,10 +36,10 @@ class ClassMod (NormalTemplate):
* A positioning step to evenly space the abilities and stage dividers.
"""
- def __init__(self, layout: ClassLayout, **kwargs):
- self._line_layers: list[ArtLayer] = []
- self._stage_layers: list[LayerSet] = []
- super().__init__(layout, **kwargs)
+ def __init__(self, layout: NormalLayout):
+ self._class_line_layers: list[ArtLayer] = []
+ self._class_stage_layers: list[LayerSet] = []
+ super().__init__(layout)
"""
* Checks
@@ -54,19 +55,19 @@ def is_class_layout(self) -> bool:
"""
@cached_property
- def text_layer_methods(self) -> list[Callable]:
+ def text_layer_methods(self) -> list[Callable[[], None]]:
"""Add Class text layers."""
funcs = [self.text_layers_classes] if self.is_class_layout else []
return [*super().text_layer_methods, *funcs]
@cached_property
- def frame_layer_methods(self) -> list[Callable]:
+ def frame_layer_methods(self) -> list[Callable[[], None]]:
"""Add Class text layers."""
funcs = [self.frame_layers_classes] if self.is_class_layout else []
return [*super().frame_layer_methods, *funcs]
@cached_property
- def post_text_methods(self) -> list[Callable]:
+ def post_text_methods(self) -> list[Callable[[], None]]:
"""Position Class abilities and stage dividers."""
funcs = [self.layer_positioning_classes] if self.is_class_layout else []
return [*super().post_text_methods, *funcs]
@@ -76,11 +77,11 @@ def post_text_methods(self) -> list[Callable]:
"""
@cached_property
- def class_group(self) -> LayerSet:
+ def class_group(self) -> LayerSet | None:
return psd.getLayerSet(LAYERS.CLASS)
@cached_property
- def stage_group(self) -> LayerSet:
+ def stage_group(self) -> LayerSet | None:
return psd.getLayerSet(LAYERS.STAGE, self.class_group)
"""
@@ -88,32 +89,40 @@ def stage_group(self) -> LayerSet:
"""
@cached_property
- def text_layer_ability(self) -> ArtLayer:
+ def text_layer_ability(self) -> ArtLayer | None:
return psd.getLayer(LAYERS.TEXT, self.class_group)
+ @cached_property
+ def class_text_layer_reminder(self) -> ArtLayer | None:
+ return psd.getLayer(LAYERS.REMINDER_TEXT, self.class_group)
+
"""
* Class Abilities
"""
@property
- def line_layers(self) -> list[ArtLayer]:
- return self._line_layers
+ def class_line_layers(self) -> list[ArtLayer]:
+ return self._class_line_layers
- @line_layers.setter
- def line_layers(self, value):
- self._line_layers = value
+ @class_line_layers.setter
+ def class_line_layers(self, value: list[ArtLayer]):
+ self._class_line_layers = value
"""
- * Class Stage Dividers
+ * Class Dividers
"""
+ @cached_property
+ def class_reminder_divider(self) -> ArtLayer | LayerSet | None:
+ return psd.getLayer(LAYERS.DIVIDER, self.class_group)
+
@property
- def stage_layers(self) -> list[LayerSet]:
- return self._stage_layers
+ def class_stage_layers(self) -> list[LayerSet]:
+ return self._class_stage_layers
- @stage_layers.setter
- def stage_layers(self, value):
- self._stage_layers = value
+ @class_stage_layers.setter
+ def class_stage_layers(self, value: list[LayerSet]):
+ self._class_stage_layers = value
"""
* Text Layer Methods
@@ -121,7 +130,9 @@ def stage_layers(self, value):
def rules_text_and_pt_layers(self) -> None:
"""Skip this step for Class cards."""
- pass
+ if self.is_class_layout and not self.is_creature:
+ return
+ return super().rules_text_and_pt_layers()
"""
* Class Text Layer Methods
@@ -129,33 +140,51 @@ def rules_text_and_pt_layers(self) -> None:
def text_layers_classes(self) -> None:
"""Add and modify text layers relating to Class type cards."""
-
- # Add first static line
- self.line_layers.append(self.text_layer_ability)
- self.text.append(
- FormattedTextField(
- layer=self.text_layer_ability,
- contents=self.layout.class_lines[0]['text']
- ))
-
- # Add text fields for each line and class stage
- for i, line in enumerate(self.layout.class_lines[1:]):
-
- # Create a new ability line
- line_layer = self.text_layer_ability.duplicate()
- self.line_layers.append(line_layer)
-
- # Use existing stage divider or create new one
- stage = self.stage_group if i == 0 else self.stage_group.duplicate()
- cost, level = [*stage.artLayers][:2]
- self.stage_layers.append(stage)
-
- # Add text layers to be formatted
- self.text.extend([
- FormattedTextField(layer=line_layer, contents=line['text']),
- FormattedTextField(layer=cost, contents=f"{line['cost']}:"),
- TextField(layer=level, contents=f"Level {line['level']}")
- ])
+ if isinstance(self.layout, ClassLayout) and self.text_layer_ability:
+ # Add reminder text
+ if self.class_text_layer_reminder:
+ self.text.append(
+ FormattedTextField(
+ layer=self.class_text_layer_reminder,
+ contents=self.layout.class_description,
+ )
+ )
+
+ # Add first static line
+ self.class_line_layers.append(self.text_layer_ability)
+ self.text.append(
+ FormattedTextField(
+ layer=self.text_layer_ability,
+ contents=self.layout.class_lines[0]["text"],
+ )
+ )
+
+ # Add text fields for each line and class stage
+ for i, line in enumerate(self.layout.class_lines[1:]):
+ # Create a new ability line
+ line_layer = self.text_layer_ability.duplicate()
+ self.class_line_layers.append(line_layer)
+
+ self.text.append(
+ FormattedTextField(layer=line_layer, contents=line["text"])
+ )
+
+ if self.stage_group:
+ # Use existing stage divider or create new one
+ stage = self.stage_group if i == 0 else self.stage_group.duplicate()
+ cost, level = [*stage.artLayers][:2]
+ self.class_stage_layers.append(stage)
+
+ # Add text layers to be formatted
+ self.text.extend(
+ [
+ FormattedTextField(layer=cost, contents=f"{line['cost']}:"),
+ TextField(
+ layer=level,
+ contents=f"{line['level_translation']}{line['level']}",
+ ),
+ ]
+ )
"""
* Class Frame Layer Methods
@@ -171,37 +200,79 @@ def frame_layers_classes(self) -> None:
def layer_positioning_classes(self) -> None:
"""Positions and sizes class ability layers and stage dividers."""
-
- # Core vars
- spacing = self.app.scale_by_dpi(80)
- spaces = len(self.line_layers) - 1
- divider_height = psd.get_layer_height(self.stage_layers[0])
- ref_height = self.textbox_reference.dims['height']
- spacing_total = (spaces * (spacing + divider_height)) + (spacing * 2)
- total_height = ref_height - spacing_total
-
- # Resize text items till they fit in the available space
- psd.scale_text_layers_to_height(
- text_layers=self.line_layers,
- ref_height=total_height)
-
- # Get the exact gap between each layer left over
- layer_heights = sum([psd.get_layer_height(lyr) for lyr in self.line_layers])
- gap = (ref_height - layer_heights) * (spacing / spacing_total)
- inside_gap = (ref_height - layer_heights) * ((spacing + divider_height) / spacing_total)
-
- # Space Class lines evenly apart
- psd.spread_layers_over_reference(
- layers=self.line_layers,
- ref=self.textbox_reference,
- gap=gap,
- inside_gap=inside_gap)
-
- # Position a class stage between each ability line
- psd.position_dividers(
- dividers=self.stage_layers,
- layers=self.line_layers,
- docref=self.docref)
+ if self.textbox_reference:
+ # Ensure that textbox reference bounds don't overlap with reminder
+ reminder_end: float = 0
+ if self.class_text_layer_reminder:
+ reminder_dims = psd.get_layer_dimensions(self.class_text_layer_reminder)
+ reminder_end = reminder_dims["bottom"]
+
+ if self.class_reminder_divider:
+ divider_dims = psd.get_layer_dimensions(self.class_reminder_divider)
+ reminder_end += divider_dims["height"]
+
+ textbox_ref_bounds = self.textbox_reference.bounds
+ new_bounds = (
+ textbox_ref_bounds[0],
+ max(textbox_ref_bounds[1], reminder_end),
+ textbox_ref_bounds[2],
+ textbox_ref_bounds[3],
+ )
+ # Override the cached bounds with modified bounds.
+ # It is assumed that bounds without effects aren't needed,
+ # so they aren't overridden.
+ self.textbox_reference.bounds = new_bounds
+ self.textbox_reference.dims = get_dimensions_from_bounds(new_bounds)
+
+ # Core vars
+ spacing = self.app.scale_by_dpi(80)
+ spaces = len(self.class_line_layers) - 1
+ divider_height = psd.get_layer_height(self.class_stage_layers[0])
+ ref_height = self.textbox_reference.dims["height"]
+ spacing_total = (spaces * (spacing + divider_height)) + (spacing * 2)
+ total_height = ref_height - spacing_total
+
+ # Resize text items till they fit in the available space
+ psd.scale_text_layers_to_height(
+ text_layers=self.class_line_layers, ref_height=total_height
+ )
+
+ # Get the exact gap between each layer left over
+ layer_heights = sum(
+ [psd.get_layer_height(lyr) for lyr in self.class_line_layers]
+ )
+ gap = (ref_height - layer_heights) * (spacing / spacing_total)
+ inside_gap = (ref_height - layer_heights) * (
+ (spacing + divider_height) / spacing_total
+ )
+
+ # Space Class lines evenly apart
+ psd.spread_layers_over_reference(
+ layers=self.class_line_layers,
+ ref=self.textbox_reference,
+ gap=gap,
+ inside_gap=inside_gap,
+ )
+
+ # Position a class stage between each ability line
+ psd.position_dividers(
+ dividers=self.class_stage_layers,
+ layers=self.class_line_layers,
+ docref=self.docref,
+ )
+
+ # Position reminder divider
+ if self.class_reminder_divider:
+ first_line_layer_dims = psd.get_layer_dimensions(
+ self.class_line_layers[0]
+ )
+ divider_dims = psd.get_layer_dimensions(self.class_reminder_divider)
+ delta = (
+ (reminder_text_end := reminder_end - divider_dims["height"])
+ + (first_line_layer_dims["top"] - reminder_text_end) / 2
+ - divider_dims["center_y"]
+ )
+ self.class_reminder_divider.translate(0, delta)
"""
@@ -209,7 +280,7 @@ def layer_positioning_classes(self) -> None:
"""
-class ClassVectorTemplate (VectorNyxMod, ClassMod, VectorTemplate):
+class ClassVectorTemplate(VectorNyxMod, ClassMod, VectorTemplate):
"""Class template using vector shape layers and automatic pinlines / multicolor generation."""
"""
@@ -226,31 +297,36 @@ def is_name_shifted(self) -> bool:
"""
@cached_property
- def textbox_colors(self) -> list[str]:
+ def textbox_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""list[str]: Support back side texture names."""
colors = list(self.identity) if self.is_within_color_limit else [self.pinlines]
# Is this card a back face transform?
if self.is_transform and not self.is_front:
- return [f'{n} {LAYERS.BACK}' for n in colors]
+ return [f"{n} {LAYERS.BACK}" for n in colors]
return colors
@cached_property
- def crown_colors(self) -> Union[list[int], list[dict]]:
+ def crown_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""Return RGB notation or Gradient dict notation for adjustment layers."""
return psd.get_pinline_gradient(
- colors=self.pinlines, color_map=self.crown_color_map)
+ colors=self.pinlines, color_map=self.crown_color_map
+ )
"""
* Groups
"""
@cached_property
- def crown_group(self) -> LayerSet:
+ def crown_group(self) -> LayerSet | None:
"""Use inner shape group for Legendary Crown."""
return psd.getLayerSet(LAYERS.SHAPE, [self.docref, LAYERS.LEGENDARY_CROWN])
@cached_property
- def textbox_group(self) -> LayerSet:
+ def textbox_group(self) -> LayerSet | None:
"""Must enable textbox group."""
if group := psd.getLayerSet(LAYERS.TEXTBOX, self.docref):
group.visible = True
@@ -261,30 +337,33 @@ def textbox_group(self) -> LayerSet:
"""
@cached_property
- def twins_layer(self) -> Optional[ArtLayer]:
+ def twins_layer(self) -> ArtLayer | None:
# Use Back face versions for back side Transform
return psd.getLayer(
- f"{self.twins} {LAYERS.BACK}" if self.is_transform and not self.is_front else self.twins,
- self.twins_group)
+ f"{self.twins} {LAYERS.BACK}"
+ if self.is_transform and not self.is_front
+ else self.twins,
+ self.twins_group,
+ )
"""
* References
"""
@cached_property
- def art_reference(self) -> ArtLayer:
- return psd.getLayer(LAYERS.ART_FRAME + " Left")
+ def art_reference(self) -> ReferenceLayer | None:
+ return get_reference_layer(LAYERS.ART_FRAME + " Left")
@cached_property
- def textbox_reference(self) -> Optional[ArtLayer]:
+ def textbox_reference(self) -> ReferenceLayer | None:
if self.is_front and self.is_flipside_creature:
return psd.get_reference_layer(
- f'{LAYERS.TEXTBOX_REFERENCE} {LAYERS.TRANSFORM_FRONT}',
- self.class_group)
+ f"{LAYERS.TEXTBOX_REFERENCE} {LAYERS.TRANSFORM_FRONT}", self.class_group
+ )
return psd.get_reference_layer(LAYERS.TEXTBOX_REFERENCE, self.class_group)
@cached_property
- def textbox_position_reference(self) -> Optional[ArtLayer]:
+ def textbox_position_reference(self) -> ArtLayer | None:
return psd.getLayer(LAYERS.ART_FRAME + " Right")
"""
@@ -294,62 +373,78 @@ def textbox_position_reference(self) -> Optional[ArtLayer]:
@cached_property
def textbox_masks(self) -> list[ArtLayer]:
"""Blends the textbox colors."""
- return [psd.getLayer(LAYERS.HALF, [self.mask_group, LAYERS.TEXTBOX])]
+ if layer := psd.getLayer(LAYERS.HALF, [self.mask_group, LAYERS.TEXTBOX]):
+ return [layer]
+ return []
@cached_property
def background_masks(self) -> list[ArtLayer]:
"""Blends the background colors."""
- return [psd.getLayer(LAYERS.HALF, [self.mask_group, LAYERS.BACKGROUND])]
+ if layer := psd.getLayer(LAYERS.HALF, [self.mask_group, LAYERS.BACKGROUND]):
+ return [layer]
+ return []
"""
* Shapes
"""
@cached_property
- def border_shape(self) -> Optional[ArtLayer]:
+ def border_shape(self) -> ArtLayer | None:
"""Support a Normal and Legendary border for front-face Transform."""
if self.is_transform and self.is_front:
return psd.getLayer(
f"{LAYERS.LEGENDARY if self.is_legendary else LAYERS.NORMAL} {LAYERS.TRANSFORM_FRONT}",
- self.border_group)
+ self.border_group,
+ )
return super().border_shape
@cached_property
def pinlines_shapes(self) -> list[LayerSet]:
"""Support front and back face Transform pinlines, and optional Legendary pinline shape."""
- shapes = [psd.getLayerSet(LAYERS.LEGENDARY, [self.pinlines_group, LAYERS.SHAPE])] if self.is_legendary else []
- return [
- # Normal or Transform pinline
- psd.getLayerSet(
- (LAYERS.TRANSFORM_FRONT if self.is_front else LAYERS.TRANSFORM_BACK)
- if self.is_transform else LAYERS.NORMAL,
- [self.pinlines_group, LAYERS.SHAPE]
- ), *shapes
- ]
+ shapes: list[LayerSet] = []
+ if self.is_legendary and (
+ group := psd.getLayerSet(
+ LAYERS.LEGENDARY, [self.pinlines_group, LAYERS.SHAPE]
+ )
+ ):
+ shapes.append(group)
+ if group := psd.getLayerSet(
+ (LAYERS.TRANSFORM_FRONT if self.is_front else LAYERS.TRANSFORM_BACK)
+ if self.is_transform
+ else LAYERS.NORMAL,
+ [self.pinlines_group, LAYERS.SHAPE],
+ ):
+ shapes.append(group)
+ return shapes
@cached_property
- def twins_shape(self) -> ArtLayer:
+ def twins_shape(self) -> ArtLayer | None:
"""Support both front and back face Transform shapes."""
return psd.getLayer(
(LAYERS.TRANSFORM_FRONT if self.is_front else LAYERS.TRANSFORM_BACK)
- if self.is_transform else LAYERS.NORMAL,
- [self.twins_group, LAYERS.SHAPE])
+ if self.is_transform
+ else LAYERS.NORMAL,
+ [self.twins_group, LAYERS.SHAPE],
+ )
@cached_property
- def outline_shape(self):
+ def outline_shape(self) -> ArtLayer | None:
"""Outline for the textbox and art."""
return psd.getLayer(
- LAYERS.TRANSFORM_FRONT if self.is_transform and self.is_front else LAYERS.NORMAL,
- LAYERS.OUTLINE)
+ LAYERS.TRANSFORM_FRONT
+ if self.is_transform and self.is_front
+ else LAYERS.NORMAL,
+ LAYERS.OUTLINE,
+ )
@cached_property
- def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
+ def enabled_shapes(self) -> list[ArtLayer | LayerSet | None]:
"""Add support for outline shape and multiple pinlines shapes."""
return [
*self.pinlines_shapes,
self.outline_shape,
self.border_shape,
- self.twins_shape
+ self.twins_shape,
]
"""
@@ -357,17 +452,30 @@ def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
"""
@cached_property
- def pinlines_mask(self) -> list[Union[ArtLayer, LayerSet]]:
+ def pinlines_mask(
+ self,
+ ) -> tuple[ArtLayer | LayerSet, ArtLayer | LayerSet] | None:
"""Mask hiding pinlines effects inside textbox and art frame."""
- return [
- psd.getLayer(
- LAYERS.TRANSFORM_FRONT if self.is_transform and self.is_front else LAYERS.NORMAL,
- [self.mask_group, LAYERS.PINLINES]),
- self.pinlines_group
- ]
+ if (
+ layer := psd.getLayer(
+ LAYERS.TRANSFORM_FRONT
+ if self.is_transform and self.is_front
+ else LAYERS.NORMAL,
+ [self.mask_group, LAYERS.PINLINES],
+ )
+ ) and self.pinlines_group:
+ return (layer, self.pinlines_group)
@cached_property
- def enabled_masks(self) -> list[Union[dict, list, ArtLayer, LayerSet, None]]:
+ def enabled_masks(
+ self,
+ ) -> list[
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ]:
"""Support a pinlines mask."""
return [self.pinlines_mask]
@@ -381,8 +489,8 @@ def enable_frame_layers(self) -> None:
# Merge the textbox and shift it to right half
psd.merge_group(self.textbox_group)
psd.align_horizontal(
- layer=self.active_layer,
- ref=self.textbox_position_reference)
+ layer=self.active_layer, ref=self.textbox_position_reference
+ )
"""
* Class Frame Layer Methods
@@ -391,28 +499,35 @@ def enable_frame_layers(self) -> None:
def frame_layers_classes(self):
"""Enable layers relating to Class type cards."""
- # Enable class group, disable saga banner
- self.class_group.visible = True
- psd.getLayerSet("Banner Top").visible = False
+ # Enable class group
+ if self.class_group:
+ self.class_group.visible = True
+
+ # Disable Saga banner
+ if layer := psd.getLayerSet("Banner Top"):
+ layer.visible = False
class UniversesBeyondClassTemplate(ClassVectorTemplate):
"""Saga Vector template with Universes Beyond frame treatment."""
- template_suffix = 'Universes Beyond'
+
+ template_suffix = "Universes Beyond"
# Color Maps
- pinlines_color_map = {
- **pinlines_color_map.copy(),
- 'W': [246, 247, 241],
- 'U': [0, 131, 193],
- 'B': [44, 40, 33],
- 'R': [237, 66, 31],
- 'G': [5, 129, 64],
- 'Gold': [239, 209, 107],
- 'Land': [165, 150, 132],
- 'Artifact': [227, 228, 230],
- 'Colorless': [227, 228, 230]
- }
+ @cached_property
+ def pinlines_color_map(self) -> dict[str, ColorObject]:
+ return {
+ **pinlines_color_map,
+ "W": (246, 247, 241),
+ "U": (0, 131, 193),
+ "B": (44, 40, 33),
+ "R": (237, 66, 31),
+ "G": (5, 129, 64),
+ "Gold": (239, 209, 107),
+ "Land": (165, 150, 132),
+ "Artifact": (227, 228, 230),
+ "Colorless": (227, 228, 230),
+ }
"""
* Colors
@@ -428,20 +543,20 @@ def textbox_colors(self) -> list[str]:
@cached_property
def twins_colors(self) -> str:
"""str: Universes Beyond variant texture name."""
- return f'{self.twins} Beyond'
+ return f"{self.twins} Beyond"
"""
* Groups
"""
@cached_property
- def background_group(self) -> LayerSet:
+ def background_group(self) -> LayerSet | None:
"""LayerSet: Universes Beyond variant group."""
- return psd.getLayerSet(f'{LAYERS.BACKGROUND} Beyond')
+ return psd.getLayerSet(f"{LAYERS.BACKGROUND} Beyond")
@cached_property
- def textbox_group(self) -> LayerSet:
+ def textbox_group(self) -> LayerSet | None:
"""LayerSet: Universes Beyond variant group. Must be enabled."""
- group = psd.getLayerSet(f"{LAYERS.TEXTBOX} Beyond")
- group.visible = True
- return group
+ if group := psd.getLayerSet(f"{LAYERS.TEXTBOX} Beyond"):
+ group.visible = True
+ return group
diff --git a/src/templates/leveler.py b/src/templates/leveler.py
index ebb3b1e4..e09b38f1 100644
--- a/src/templates/leveler.py
+++ b/src/templates/leveler.py
@@ -1,20 +1,19 @@
"""
* LEVELER TEMPLATES
"""
-# Standard Library
+
+from collections.abc import Callable
from functools import cached_property
-from typing import Optional, Callable
-# Third Party Imports
from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
-# Local Imports
-from src.enums.layers import LAYERS
import src.helpers as psd
+import src.text_layers as text_classes
+from src.enums.layers import LAYERS
from src.layouts import LevelerLayout
from src.templates._core import NormalTemplate
-import src.text_layers as text_classes
+from src.utils.adobe import ReferenceLayer
"""
* Modifier Classes
@@ -31,14 +30,22 @@ class LevelerMod(NormalTemplate):
* Level requirements for second and third stage.
"""
+ """
+ * Checks
+ """
+
+ @cached_property
+ def is_leveler(self) -> bool:
+ return isinstance(self.layout, LevelerLayout)
+
"""
* Mixin Methods
"""
@cached_property
- def text_layer_methods(self) -> list[Callable]:
+ def text_layer_methods(self) -> list[Callable[[], None]]:
"""Add Adventure text layers."""
- funcs = [self.text_layers_leveler] if isinstance(self.layout, LevelerLayout) else []
+ funcs = [self.text_layers_leveler] if self.is_leveler else []
return [*super().text_layer_methods, *funcs]
"""
@@ -46,7 +53,7 @@ def text_layer_methods(self) -> list[Callable]:
"""
@cached_property
- def leveler_group(self) -> Optional[LayerSet]:
+ def leveler_group(self) -> LayerSet | None:
"""Group containing Leveler text layers."""
return psd.getLayerSet("Leveler Text", self.text_group)
@@ -55,47 +62,53 @@ def leveler_group(self) -> Optional[LayerSet]:
"""
@cached_property
- def pt_layer(self) -> Optional[ArtLayer]:
- return psd.getLayer(self.twins, LAYERS.PT_AND_LEVEL_BOXES)
+ def pt_layer(self) -> ArtLayer | None:
+ if self.is_leveler:
+ return psd.getLayer(self.twins, LAYERS.PT_AND_LEVEL_BOXES)
+ return super().pt_layer
"""
* Text Layers
"""
@cached_property
- def text_layer_rules(self) -> Optional[ArtLayer]:
- return psd.getLayer("Rules Text - Level Up", self.leveler_group)
+ def text_layer_rules(self) -> ArtLayer | None:
+ if self.is_leveler:
+ return psd.getLayer("Rules Text - Level Up", self.leveler_group)
+ return super().text_layer_rules
@cached_property
- def text_layer_pt(self) -> Optional[ArtLayer]:
- return psd.getLayer("Top Power / Toughness", self.leveler_group)
+ def text_layer_pt(self) -> ArtLayer | None:
+ if self.is_leveler:
+ return psd.getLayer("Top Power / Toughness", self.leveler_group)
+ return super().text_layer_pt
"""
* Leveler Text Layers
"""
@cached_property
- def text_layer_rules_x_y(self) -> Optional[ArtLayer]:
+ def text_layer_rules_x_y(self) -> ArtLayer | None:
return psd.getLayer("Rules Text - Levels X-Y", self.leveler_group)
@cached_property
- def text_layer_rules_z(self) -> Optional[ArtLayer]:
+ def text_layer_rules_z(self) -> ArtLayer | None:
return psd.getLayer("Rules Text - Levels Z+", self.leveler_group)
@cached_property
- def text_layer_level_middle(self) -> Optional[ArtLayer]:
+ def text_layer_level_middle(self) -> ArtLayer | None:
return psd.getLayer("Middle Level", self.leveler_group)
@cached_property
- def text_layer_level_bottom(self) -> Optional[ArtLayer]:
+ def text_layer_level_bottom(self) -> ArtLayer | None:
return psd.getLayer("Bottom Level", self.leveler_group)
@cached_property
- def text_layer_pt_middle(self) -> Optional[ArtLayer]:
+ def text_layer_pt_middle(self) -> ArtLayer | None:
return psd.getLayer("Middle Power / Toughness", self.leveler_group)
@cached_property
- def text_layer_pt_bottom(self) -> Optional[ArtLayer]:
+ def text_layer_pt_bottom(self) -> ArtLayer | None:
return psd.getLayer("Bottom Power / Toughness", self.leveler_group)
"""
@@ -103,20 +116,28 @@ def text_layer_pt_bottom(self) -> Optional[ArtLayer]:
"""
@cached_property
- def textbox_reference(self) -> Optional[ArtLayer]:
- return psd.get_reference_layer(f'{LAYERS.TEXTBOX_REFERENCE} - Level Text', self.leveler_group)
+ def textbox_reference(self) -> ReferenceLayer | None:
+ if self.is_leveler:
+ return psd.get_reference_layer(
+ f"{LAYERS.TEXTBOX_REFERENCE} - Level Text", self.leveler_group
+ )
+ return super().textbox_reference
"""
* Leveler References
"""
@cached_property
- def textbox_reference_x_y(self) -> Optional[ArtLayer]:
- return psd.get_reference_layer(f'{LAYERS.TEXTBOX_REFERENCE} - Level X-Y', self.leveler_group)
+ def textbox_reference_x_y(self) -> ArtLayer | None:
+ return psd.get_reference_layer(
+ f"{LAYERS.TEXTBOX_REFERENCE} - Level X-Y", self.leveler_group
+ )
@cached_property
- def textbox_reference_z(self) -> Optional[ArtLayer]:
- return psd.get_reference_layer(f'{LAYERS.TEXTBOX_REFERENCE} - Levels Z+', self.leveler_group)
+ def textbox_reference_z(self) -> ArtLayer | None:
+ return psd.get_reference_layer(
+ f"{LAYERS.TEXTBOX_REFERENCE} - Levels Z+", self.leveler_group
+ )
"""
* Leveler Text Methods
@@ -125,53 +146,80 @@ def textbox_reference_z(self) -> Optional[ArtLayer]:
def rules_text_and_pt_layers(self) -> None:
"""Add rules and power/toughness text."""
- # Level-Up text and starting P/T
- self.text.extend([
- text_classes.FormattedTextArea(
- layer=self.text_layer_rules,
- contents=self.layout.level_up_text,
- reference=self.textbox_reference
- ),
- text_classes.TextField(
- layer=self.text_layer_pt,
- contents=str(self.layout.power) + "/" + str(self.layout.toughness)
- )
- ])
+ if isinstance(self.layout, LevelerLayout):
+ # Level-Up text and starting P/T
+ if self.text_layer_rules:
+ self.text.append(
+ text_classes.FormattedTextArea(
+ layer=self.text_layer_rules,
+ contents=self.layout.level_up_text,
+ reference=self.textbox_reference,
+ )
+ )
+ if self.text_layer_pt:
+ self.text.append(
+ text_classes.TextField(
+ layer=self.text_layer_pt,
+ contents=str(self.layout.power)
+ + "/"
+ + str(self.layout.toughness),
+ )
+ )
+ else:
+ return super().rules_text_and_pt_layers()
def text_layers_leveler(self):
"""Add and modify text layers required by Leveler cards."""
+ if isinstance(self.layout, LevelerLayout):
+ # Add Leveler sections
- # Add Leveler sections
- self.text.extend([
# Level 2
- text_classes.TextField(
- layer=self.text_layer_level_middle,
- contents=self.layout.middle_level
- ),
- text_classes.TextField(
- layer=self.text_layer_pt_middle,
- contents=self.layout.middle_power_toughness
- ),
- text_classes.FormattedTextArea(
- layer=self.text_layer_rules_x_y,
- contents=self.layout.middle_text,
- reference=self.textbox_reference_x_y
- ),
+ if self.text_layer_level_middle:
+ self.text.append(
+ text_classes.TextField(
+ layer=self.text_layer_level_middle,
+ contents=self.layout.middle_level,
+ )
+ )
+ if self.text_layer_pt_middle:
+ self.text.append(
+ text_classes.TextField(
+ layer=self.text_layer_pt_middle,
+ contents=self.layout.middle_power_toughness,
+ )
+ )
+ if self.text_layer_rules_x_y:
+ self.text.append(
+ text_classes.FormattedTextArea(
+ layer=self.text_layer_rules_x_y,
+ contents=self.layout.middle_text,
+ reference=self.textbox_reference_x_y,
+ )
+ )
+
# Level 3
- text_classes.TextField(
- layer=self.text_layer_level_bottom,
- contents=self.layout.bottom_level
- ),
- text_classes.TextField(
- layer=self.text_layer_pt_bottom,
- contents=self.layout.bottom_power_toughness
- ),
- text_classes.FormattedTextArea(
- layer=self.text_layer_rules_z,
- contents=self.layout.bottom_text,
- reference=self.textbox_reference_z
- )
- ])
+ if self.text_layer_level_bottom:
+ self.text.append(
+ text_classes.TextField(
+ layer=self.text_layer_level_bottom,
+ contents=self.layout.bottom_level,
+ )
+ )
+ if self.text_layer_pt_bottom:
+ self.text.append(
+ text_classes.TextField(
+ layer=self.text_layer_pt_bottom,
+ contents=self.layout.bottom_power_toughness,
+ )
+ )
+ if self.text_layer_rules_z:
+ self.text.append(
+ text_classes.FormattedTextArea(
+ layer=self.text_layer_rules_z,
+ contents=self.layout.bottom_text,
+ reference=self.textbox_reference_z,
+ )
+ )
"""
diff --git a/src/templates/mdfc.py b/src/templates/mdfc.py
index 4b9a9960..80cc9a5e 100644
--- a/src/templates/mdfc.py
+++ b/src/templates/mdfc.py
@@ -1,19 +1,17 @@
"""
* MDFC TEMPLATES
"""
-# Standard Library
+
+from collections.abc import Callable
from functools import cached_property
-from typing import Optional, Callable
-# Third Party Imports
from photoshop.api._artlayer import ArtLayer
-# Local Imports
-from src.enums.layers import LAYERS
import src.helpers as psd
+from src.enums.layers import LAYERS
from src.templates._core import BaseTemplate, NormalTemplate
from src.templates._vector import VectorTemplate
-from src.text_layers import ScaledTextField, FormattedTextField
+from src.text_layers import FormattedTextField, ScaledTextField
"""
* Modifier Classes
@@ -35,13 +33,15 @@ class MDFCMod(BaseTemplate):
"""
@cached_property
- def frame_layer_methods(self) -> list[Callable]:
+ def frame_layer_methods(self) -> list[Callable[[], None]]:
"""Add MDFC frame layers step."""
parent_funcs = super().frame_layer_methods
- return [*parent_funcs, self.enable_mdfc_layers] if self.is_mdfc else parent_funcs
+ return (
+ [*parent_funcs, self.enable_mdfc_layers] if self.is_mdfc else parent_funcs
+ )
@cached_property
- def text_layer_methods(self) -> list[Callable]:
+ def text_layer_methods(self) -> list[Callable[[], None]]:
"""Add MDFC text layers step."""
parent_funcs = super().text_layer_methods
return [*parent_funcs, self.text_layers_mdfc] if self.is_mdfc else parent_funcs
@@ -69,12 +69,12 @@ def mdfc_bar_color(self) -> str:
"""
@cached_property
- def text_layer_mdfc_left(self) -> Optional[ArtLayer]:
+ def text_layer_mdfc_left(self) -> ArtLayer | None:
"""The back face card type."""
return psd.getLayer(LAYERS.LEFT, self.dfc_group)
@cached_property
- def text_layer_mdfc_right(self) -> Optional[ArtLayer]:
+ def text_layer_mdfc_right(self) -> ArtLayer | None:
"""The back face mana cost or land tap ability."""
return psd.getLayer(LAYERS.RIGHT, self.dfc_group)
@@ -86,8 +86,10 @@ def enable_mdfc_layers(self) -> None:
"""Enable layers that are required by modal double faced cards."""
# MDFC elements at the top and bottom of the card
- psd.getLayer(self.mdfc_icon_color, [self.dfc_group, LAYERS.TOP]).visible = True
- psd.getLayer(self.mdfc_bar_color, [self.dfc_group, LAYERS.BOTTOM]).visible = True
+ if layer := psd.getLayer(self.mdfc_icon_color, [self.dfc_group, LAYERS.TOP]):
+ layer.visible = True
+ if layer := psd.getLayer(self.mdfc_bar_color, [self.dfc_group, LAYERS.BOTTOM]):
+ layer.visible = True
# Front and back side layers
if self.is_front:
@@ -110,14 +112,21 @@ def text_layers_mdfc(self) -> None:
"""Adds and modifies text layers required by modal double faced cards."""
# Add mdfc text layers
- self.text.extend([
- FormattedTextField(
- layer=self.text_layer_mdfc_right,
- contents=self.layout.other_face_right),
- ScaledTextField(
- layer=self.text_layer_mdfc_left,
- contents=self.layout.other_face_left,
- reference=self.text_layer_mdfc_right)])
+ if self.text_layer_mdfc_right:
+ self.text.append(
+ FormattedTextField(
+ layer=self.text_layer_mdfc_right,
+ contents=self.layout.other_face_right,
+ )
+ )
+ if self.text_layer_mdfc_left:
+ self.text.append(
+ ScaledTextField(
+ layer=self.text_layer_mdfc_left,
+ contents=self.layout.other_face_left,
+ reference=self.text_layer_mdfc_right,
+ )
+ )
# Front and back side layers
if self.is_front:
@@ -142,7 +151,8 @@ class VectorMDFCMod(MDFCMod, VectorTemplate):
def enable_mdfc_layers(self) -> None:
"""Enable group containing MDFC layers."""
- self.dfc_group.visible = True
+ if self.dfc_group:
+ self.dfc_group.visible = True
super().enable_mdfc_layers()
diff --git a/src/templates/mutate.py b/src/templates/mutate.py
index da3dc9b5..113faa5f 100644
--- a/src/templates/mutate.py
+++ b/src/templates/mutate.py
@@ -1,21 +1,18 @@
"""
* Mutate Templates
"""
-# Standard Library
+
+from collections.abc import Callable
from functools import cached_property
-from typing import Optional, Callable
-# Third Party Imports
-from photoshop.api.application import ArtLayer
+from photoshop.api._artlayer import ArtLayer
-# Local Imports
-from src import CFG
+import src.helpers as psd
+import src.text_layers as text_classes
from src.cards import strip_reminder_text
from src.enums.layers import LAYERS
-import src.helpers as psd
from src.layouts import MutateLayout
from src.templates._core import NormalTemplate
-import src.text_layers as text_classes
from src.utils.adobe import ReferenceLayer
"""
@@ -36,9 +33,11 @@ class MutateMod(NormalTemplate):
"""
@cached_property
- def text_layer_methods(self) -> list[Callable]:
+ def text_layer_methods(self) -> list[Callable[[], None]]:
"""Add Mutate text layers."""
- funcs = [self.text_layers_mutate] if isinstance(self.layout, MutateLayout) else []
+ funcs = (
+ [self.text_layers_mutate] if isinstance(self.layout, MutateLayout) else []
+ )
return [*super().text_layer_methods, *funcs]
"""
@@ -46,7 +45,7 @@ def text_layer_methods(self) -> list[Callable]:
"""
@cached_property
- def text_layer_mutate(self) -> Optional[ArtLayer]:
+ def text_layer_mutate(self) -> ArtLayer | None:
"""Text layer containing the mutate text."""
return psd.getLayer(LAYERS.MUTATE, self.text_group)
@@ -55,7 +54,7 @@ def text_layer_mutate(self) -> Optional[ArtLayer]:
"""
@cached_property
- def mutate_reference(self) -> Optional[ReferenceLayer]:
+ def mutate_reference(self) -> ReferenceLayer | None:
"""Mutate textbox reference."""
return psd.get_reference_layer(LAYERS.MUTATE_REFERENCE, self.text_group)
@@ -65,9 +64,8 @@ def mutate_reference(self) -> Optional[ReferenceLayer]:
def process_layout_data(self) -> None:
"""Remove reminder text for mutate text if required."""
- if CFG.remove_reminder:
- self.layout.mutate_text = strip_reminder_text(
- self.layout.mutate_text)
+ if self.config.remove_reminder and isinstance(self.layout, MutateLayout):
+ self.layout.mutate_text = strip_reminder_text(self.layout.mutate_text)
super().process_layout_data()
"""
@@ -78,11 +76,14 @@ def text_layers_mutate(self):
"""Add text layers required by Mutate cards."""
# Add mutate text
- self.text.append(
- text_classes.FormattedTextArea(
- layer=self.text_layer_mutate,
- contents=self.layout.mutate_text,
- reference=self.mutate_reference))
+ if isinstance(self.layout, MutateLayout) and self.text_layer_mutate:
+ self.text.append(
+ text_classes.FormattedTextArea(
+ layer=self.text_layer_mutate,
+ contents=self.layout.mutate_text,
+ reference=self.mutate_reference,
+ )
+ )
"""
diff --git a/src/templates/normal.py b/src/templates/normal.py
index 1e76b5af..b7ae6905 100644
--- a/src/templates/normal.py
+++ b/src/templates/normal.py
@@ -1,43 +1,49 @@
"""
* Normal Templates
"""
-# Standard Library Imports
+
+from collections.abc import Callable, Iterable, Sequence
from functools import cached_property
-from typing import Optional, Union, cast
-# Third Party Imports
-from photoshop.api import AnchorPosition, SolidColor
+from photoshop.api import SolidColor
from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
+from photoshop.api.enumerations import AnchorPosition
-# Local Imports
-from src import CFG, CON
-from src.schema.adobe import EffectColorOverlay, EffectBevel
-from src.templates import NicknameMod
-from src.utils.adobe import ReferenceLayer, LayerObjectTypes
-from src.enums.mtg import MagicIcons
-from src.enums.adobe import Dimensions
+import src.helpers as psd
+from src import CON
from src.enums.layers import LAYERS
+from src.enums.mtg import MagicIcons
from src.enums.settings import (
BorderlessColorMode,
BorderlessTextbox,
ModernClassicCrown,
- CollectorMode)
+)
from src.frame_logic import is_multicolor_string
-from src.helpers import get_line_count, LayerEffects
-import src.helpers as psd
-from src.schema.colors import pinlines_color_map
+from src.helpers import LayerEffects, get_line_count
+from src.helpers.layers import get_reference_layer
+from src.schema.adobe import EffectBevel, EffectColorOverlay
+from src.schema.colors import ColorObject, GradientConfig, pinlines_color_map
+from src.templates import NicknameVectorMod
from src.templates._core import NormalTemplate
-from src.templates._cosmetic import ExtendedMod, FullartMod, NyxMod, VectorBorderlessMod, CompanionMod
-from src.templates._vector import VectorTemplate
-from src.templates.transform import VectorTransformMod
+from src.templates._cosmetic import (
+ CompanionMod,
+ ExtendedMod,
+ FullartMod,
+ NyxMod,
+ VectorBorderlessMod,
+)
+from src.templates._vector import MaskAction, VectorTemplate
from src.templates.mdfc import VectorMDFCMod
+from src.templates.transform import VectorTransformMod
from src.text_layers import (
- TextField,
- ScaledTextField,
- FormattedTextField,
FormattedTextArea,
- ScaledWidthTextField)
+ FormattedTextField,
+ ScaledTextField,
+ ScaledWidthTextField,
+ TextField,
+)
+from src.utils.adobe import ReferenceLayer
"""
* Extendable Templates
@@ -71,7 +77,7 @@ class FullartTemplate(FullartMod, M15Template):
"""
@cached_property
- def overlay_group(self) -> LayerSet:
+ def overlay_group(self) -> LayerSet | None:
"""Glass overlay that replaces textbox and twins."""
return psd.getLayerSet("Overlay")
@@ -82,46 +88,65 @@ def overlay_group(self) -> LayerSet:
def enable_frame_layers(self) -> None:
"""Mask textbox and type bar, add glass overlay."""
super().enable_frame_layers()
- psd.enable_vector_mask(self.pinlines_layer.parent)
- psd.enable_mask(self.twins_layer.parent)
- self.overlay_group.visible = True
+ if self.pinlines_layer and isinstance(
+ (parent := self.pinlines_layer.parent), LayerSet
+ ):
+ psd.enable_vector_mask(parent)
+ if self.twins_layer and isinstance(
+ (parent := self.twins_layer.parent), LayerSet
+ ):
+ psd.enable_mask(parent)
+ if self.overlay_group:
+ self.overlay_group.visible = True
def basic_text_layers(self) -> None:
"""White typeline."""
super().basic_text_layers()
psd.enable_layer_fx(self.text_layer_type)
- self.text_layer_type.textItem.color = psd.rgb_white()
+ if self.text_layer_type:
+ self.text_layer_type.textItem.color = psd.rgb_white()
def rules_text_and_pt_layers(self) -> None:
"""White rules text and divider."""
super().rules_text_and_pt_layers()
psd.enable_layer_fx(self.text_layer_rules)
- self.text_layer_rules.textItem.color = psd.rgb_white()
+ if self.text_layer_rules:
+ self.text_layer_rules.textItem.color = psd.rgb_white()
# Make the divider white
- if self.layout.flavor_text and self.layout.oracle_text and CFG.flavor_divider:
+ if (
+ self.layout.flavor_text
+ and self.layout.oracle_text
+ and self.config.flavor_divider
+ ):
psd.enable_layer_fx(self.divider_layer)
class StargazingTemplate(FullartTemplate):
"""Stargazing template from 'Theros: Beyond Death' showcase cards. Always uses nyx backgrounds."""
- template_suffix = 'Stargazing'
+
+ template_suffix = "Stargazing"
# Static Properties
- is_nyx = True
- twins_layer = None
+ @cached_property
+ def is_nyx(self) -> bool:
+ return True
+
+ @cached_property
+ def twins_layer(self) -> ArtLayer | None:
+ return None
"""
* Layers
"""
- @property
- def pt_layer(self) -> Optional[ArtLayer]:
+ @cached_property
+ def pt_layer(self) -> ArtLayer | None:
# Use darker PT boxes except for Vehicle
if self.is_vehicle:
if layer := psd.getLayer(LAYERS.VEHICLE, LAYERS.PT_BOX):
return layer
- return psd.getLayer(self.twins, LAYERS.PT_BOX + ' Dark')
+ return psd.getLayer(self.twins, LAYERS.PT_BOX + " Dark")
"""
* Methods
@@ -132,25 +157,38 @@ def enable_frame_layers(self) -> None:
super(FullartTemplate, self).enable_frame_layers()
# Glass cutouts
- psd.enable_vector_mask(self.background_layer.parent)
- psd.enable_vector_mask(self.pinlines_layer.parent)
+ if self.background_layer and isinstance(
+ (parent := self.background_layer.parent), LayerSet
+ ):
+ psd.enable_vector_mask(parent)
+ if self.pinlines_layer and isinstance(
+ (parent := self.pinlines_layer.parent), LayerSet
+ ):
+ psd.enable_vector_mask(parent)
# Glass overlays
- self.overlay_group.visible = True
- psd.getLayer("Name", self.overlay_group).visible = True
+ if self.overlay_group:
+ self.overlay_group.visible = True
+ if layer := psd.getLayer("Name", self.overlay_group):
+ layer.visible = True
def enable_crown(self) -> None:
super().enable_crown()
- psd.enable_vector_mask(self.crown_layer.parent)
+ if self.crown_layer and isinstance(
+ (parent := self.crown_layer.parent), LayerSet
+ ):
+ psd.enable_vector_mask(parent)
def basic_text_layers(self) -> None:
super().basic_text_layers()
- psd.enable_layer_fx(self.text_layer_name)
- self.text_layer_name.textItem.color = psd.rgb_white()
+
+ if self.text_layer_name:
+ psd.enable_layer_fx(self.text_layer_name)
+ self.text_layer_name.textItem.color = psd.rgb_white()
def rules_text_and_pt_layers(self) -> None:
super().rules_text_and_pt_layers()
- if self.is_creature:
+ if self.is_creature and self.text_layer_pt:
self.text_layer_pt.textItem.color = psd.rgb_white()
@@ -168,7 +206,7 @@ class ExtendedTemplate(ExtendedMod, M15Template):
@cached_property
def template_suffix(self) -> str:
- return 'Dark Mode' if self.is_dark_mode else ''
+ return "Dark Mode" if self.is_dark_mode else ""
"""
* Bool
@@ -177,7 +215,7 @@ def template_suffix(self) -> str:
@cached_property
def is_dark_mode(self) -> bool:
"""Governs whether Dark Mode is enabled."""
- return bool(CFG.get_setting('FRAME', 'Dark.Mode', default=False))
+ return bool(self.config.get_bool_setting("FRAME", "Dark.Mode", default=False))
"""
* Methods
@@ -188,7 +226,7 @@ def rules_text_and_pt_layers(self) -> None:
super().rules_text_and_pt_layers()
# White rules text for dark mode
- if self.is_dark_mode:
+ if self.is_dark_mode and self.text_layer_rules:
self.text_layer_rules.textItem.color = psd.rgb_white()
def enable_frame_layers(self) -> None:
@@ -197,19 +235,27 @@ def enable_frame_layers(self) -> None:
# Dark mode changes
if self.is_dark_mode:
- psd.getLayer("Dark", "Overlays").visible = True
+ if layer := psd.getLayer("Dark", "Overlays"):
+ layer.visible = True
# White divider
- if self.layout.flavor_text and self.layout.oracle_text and CFG.flavor_divider:
+ if (
+ self.layout.flavor_text
+ and self.layout.oracle_text
+ and self.config.flavor_divider
+ ):
psd.enable_layer_fx(self.divider_layer)
class InventionTemplate(FullartMod, NormalTemplate):
"""Kaladesh Invention template. Uses either Bronze or Silver frame layers depending on setting."""
- template_suffix = 'Masterpiece'
+
+ template_suffix = "Masterpiece"
# Static Properties
- is_land = False
+ @cached_property
+ def is_land(self) -> bool:
+ return False
"""
* Frame Details
@@ -218,12 +264,11 @@ class InventionTemplate(FullartMod, NormalTemplate):
@cached_property
def twins(self) -> str:
"""str: Pull twins color from settings. Silver or Bronze."""
- return str(CFG.get_setting(
- section="FRAME",
- key="Accent",
- default="Silver",
- is_bool=False
- ))
+ return str(
+ self.config.get_setting(
+ section="FRAME", key="Accent", default="Silver", is_bool=False
+ )
+ )
@cached_property
def background(self) -> str:
@@ -235,18 +280,24 @@ class ExpeditionTemplate(FullartMod, NormalTemplate):
"""Zendikar Rising Expedition template. Masks pinlines for legendary cards, has a single static background layer,
doesn't support color indicator, companion, or nyx layers.
"""
- template_suffix = 'Expedition'
+
+ template_suffix = "Expedition"
# Static Properties
- is_land = False
- background_layer = None
+ @cached_property
+ def is_land(self) -> bool:
+ return False
+
+ @cached_property
+ def background_layer(self) -> ArtLayer | None:
+ return None
"""
* Layers
"""
@cached_property
- def text_layer_rules(self) -> Optional[ArtLayer]:
+ def text_layer_rules(self) -> ArtLayer | None:
"""No separate creature text layer."""
return psd.getLayer(LAYERS.RULES_TEXT, self.text_group)
@@ -258,22 +309,26 @@ def rules_text_and_pt_layers(self) -> None:
"""Add rules and power/toughness text."""
# No Creature-specific rules text
- self.text.append(
- FormattedTextArea(
- layer=self.text_layer_rules,
- contents=self.layout.oracle_text,
- flavor=self.layout.flavor_text,
- reference=self.textbox_reference,
- divider=self.divider_layer,
- centered=self.is_centered))
+ if self.text_layer_rules:
+ self.text.append(
+ FormattedTextArea(
+ layer=self.text_layer_rules,
+ contents=self.layout.oracle_text,
+ flavor=self.layout.flavor_text,
+ reference=self.textbox_reference,
+ divider=self.divider_layer,
+ centered=self.is_centered,
+ )
+ )
# Add PT for Creature
- if self.is_creature:
+ if self.is_creature and self.text_layer_pt:
self.text.append(
TextField(
layer=self.text_layer_pt,
- contents=f'{self.layout.power}/'
- f'{self.layout.toughness}'))
+ contents=f"{self.layout.power}/{self.layout.toughness}",
+ )
+ )
"""
* Frame Layer Methods
@@ -281,31 +336,44 @@ def rules_text_and_pt_layers(self) -> None:
def enable_crown(self):
# No hollow crown
- self.crown_layer.visible = True
- psd.getLayer(LAYERS.NORMAL_BORDER, self.border_group).visible = False
- psd.getLayer(LAYERS.LEGENDARY_BORDER, self.border_group).visible = True
+ if self.crown_layer:
+ self.crown_layer.visible = True
+ if layer := psd.getLayer(LAYERS.NORMAL_BORDER, self.border_group):
+ layer.visible = False
+ if layer := psd.getLayer(LAYERS.LEGENDARY_BORDER, self.border_group):
+ layer.visible = True
# Enable legendary cutout on background and pinlines
- psd.enable_mask(psd.getLayer('Background'))
- psd.enable_mask(self.pinlines_layer.parent)
+ psd.enable_mask(psd.getLayer("Background"))
+ if self.pinlines_layer and isinstance(
+ (parent := self.pinlines_layer.parent), LayerSet
+ ):
+ psd.enable_mask(parent)
class SnowTemplate(NormalTemplate):
"""A snow template with textures from Kaldheim's snow cards."""
- template_suffix = 'Snow'
+
+ template_suffix = "Snow"
class MiracleTemplate(NyxMod, NormalTemplate):
"""A template for miracle cards introduced in Avacyn Restored."""
# Static Properties
- is_legendary = False
- is_vehicle = False
+ @cached_property
+ def is_legendary(self) -> bool:
+ return False
+
+ @cached_property
+ def is_vehicle(self) -> bool:
+ return False
class ClassicTemplate(NormalTemplate):
"""A template for 7th Edition frame. Lacks some of the Normal Template features."""
- frame_suffix = 'Classic'
+
+ frame_suffix = "Classic"
"""
* Frame Details
@@ -314,7 +382,7 @@ class ClassicTemplate(NormalTemplate):
@cached_property
def template_suffix(self) -> str:
"""Add Promo if promo star enabled."""
- return 'Promo' if self.is_promo_star else ''
+ return "Promo" if self.is_promo_star else ""
"""
* Settings
@@ -323,26 +391,23 @@ def template_suffix(self) -> str:
@cached_property
def is_promo_star(self) -> bool:
"""bool: Whether to enable the Promo Star overlay."""
- return CFG.get_setting(
- section='FRAME',
- key='Promo.Star',
- default=False)
+ return self.config.get_bool_setting(
+ section="FRAME", key="Promo.Star", default=False
+ )
@cached_property
def is_extended(self) -> bool:
"""bool: Whether to render using Extended Art framing."""
- return CFG.get_setting(
- section='FRAME',
- key='Extended.Art',
- default=False)
+ return self.config.get_bool_setting(
+ section="FRAME", key="Extended.Art", default=False
+ )
@cached_property
def is_align_collector_left(self) -> bool:
"""CollectorAlign: Which collector alignment to use."""
- return CFG.get_setting(
- section='TEXT',
- key='Collector.Align.Left',
- default=False)
+ return self.config.get_bool_setting(
+ section="TEXT", key="Collector.Align.Left", default=False
+ )
"""
* Bool Properties
@@ -358,23 +423,26 @@ def is_content_aware_enabled(self) -> bool:
"""
@cached_property
- def pinlines_layer(self) -> ArtLayer:
+ def pinlines_layer(self) -> ArtLayer | None:
"""Only use split colors for land and hybrid cards, otherwise gold."""
return psd.getLayer(
- self.background if len(self.pinlines) == 2 and not self.is_hybrid and not self.is_land else self.pinlines,
- LAYERS.LAND if self.is_land else LAYERS.NONLAND)
+ self.background
+ if len(self.pinlines) == 2 and not self.is_hybrid and not self.is_land
+ else self.pinlines,
+ LAYERS.LAND if self.is_land else LAYERS.NONLAND,
+ )
"""
* Text Layers
"""
@cached_property
- def text_layer_rules(self) -> ArtLayer:
+ def text_layer_rules(self) -> ArtLayer | None:
"""ArtLayer: Does not change depending on Creature/Noncreature."""
return psd.getLayer(LAYERS.RULES_TEXT, self.text_group)
@cached_property
- def text_layer_type(self) -> ArtLayer:
+ def text_layer_type(self) -> ArtLayer | None:
"""ArtLayer: Card typeline text layer, used shifted version when color indicator is present."""
if self.is_type_shifted:
return psd.getLayer(LAYERS.TYPE_LINE_SHIFT, self.text_group)
@@ -385,32 +453,34 @@ def text_layer_type(self) -> ArtLayer:
"""
@cached_property
- def collector_reference(self) -> ReferenceLayer:
+ def collector_reference(self) -> ReferenceLayer | None:
"""ReferenceLayer: Reference used to position the collector info."""
return psd.get_reference_layer(LAYERS.COLLECTOR_REFERENCE, self.legal_group)
@cached_property
- def textbox_reference(self) -> ArtLayer:
+ def textbox_reference(self) -> ReferenceLayer | None:
"""ArtLayer: Separately positioned box for 'Land' cards."""
return psd.get_reference_layer(
- LAYERS.TEXTBOX_REFERENCE_LAND if self.is_land
- else LAYERS.TEXTBOX_REFERENCE,
- self.text_group)
+ LAYERS.TEXTBOX_REFERENCE_LAND if self.is_land else LAYERS.TEXTBOX_REFERENCE,
+ self.text_group,
+ )
@cached_property
- def expansion_reference(self) -> ArtLayer:
+ def expansion_reference(self) -> ArtLayer | None:
"""ArtLayer: Separately positioned box for 'Land' cards."""
return psd.getLayer(
- f"{LAYERS.EXPANSION_REFERENCE} {LAYERS.LAND}" if self.is_land
+ f"{LAYERS.EXPANSION_REFERENCE} {LAYERS.LAND}"
+ if self.is_land
else LAYERS.EXPANSION_REFERENCE,
- self.text_group)
+ self.text_group,
+ )
"""
* Masks
"""
@cached_property
- def border_mask(self) -> ArtLayer:
+ def border_mask(self) -> ArtLayer | None:
"""ArtLayer: Contains a mask used to change the border to an Extended Art border."""
return psd.getLayer(LAYERS.EXTENDED, LAYERS.MASKS)
@@ -421,32 +491,22 @@ def border_mask(self) -> ArtLayer:
def process_layout_data(self) -> None:
"""Remove rarity letter from collector data."""
super().process_layout_data()
- self.layout.collector_data = self.layout.collector_data[:-2] if (
- '/' in self.layout.collector_data
- ) else self.layout.collector_data[2:]
+ self.layout.collector_data = (
+ self.layout.collector_data[:-2]
+ if ("/" in self.layout.collector_data)
+ else self.layout.collector_data[2:]
+ )
"""
* Collector Info Methods
"""
- def collector_info(self) -> None:
- """Format and add the collector info at the bottom."""
-
- # Which collector info mode?
- if CFG.collector_mode in [
- CollectorMode.Default, CollectorMode.Modern
- ] and self.layout.collector_data:
- layers = self.collector_info_authentic()
- elif CFG.collector_mode == CollectorMode.ArtistOnly:
- layers = self.collector_info_artist_only()
- else:
- layers = self.collector_info_basic()
-
+ def align_collector_layers(self, layers: Iterable[ArtLayer | None]) -> None:
# Shift collector text
- if self.is_align_collector_left:
+ if self.is_align_collector_left and self.collector_reference:
[psd.align_left(n, ref=self.collector_reference.dims) for n in layers]
- def collector_info_basic(self) -> list[ArtLayer]:
+ def collector_info_basic(self) -> None:
"""Called to generate basic collector info."""
# Get artist and info layers
@@ -454,47 +514,58 @@ def collector_info_basic(self) -> list[ArtLayer]:
info = psd.getLayer(LAYERS.SET, self.legal_group)
# Fill optional promo star
- if self.is_collector_promo:
+ if self.is_collector_promo and info:
psd.replace_text(info, "β’", MagicIcons.COLLECTOR_STAR)
# Apply the collector info
- if self.layout.lang != 'en':
- psd.replace_text(info, 'EN', self.layout.lang.upper())
- psd.replace_text(artist, "Artist", self.layout.artist)
- psd.replace_text(info, 'SET', self.layout.set)
- return [artist, info]
+ if self.layout.lang != "en" and info:
+ psd.replace_text(info, "EN", self.layout.lang.upper())
+ if artist:
+ psd.replace_text(artist, "Artist", self.layout.artist)
+ if info:
+ psd.replace_text(info, "SET", self.layout.set)
- def collector_info_authentic(self) -> list[ArtLayer]:
+ self.align_collector_layers((artist, info))
+
+ def collector_info_authentic(self) -> None:
"""Classic presents authentic collector info differently."""
# Hide basic 'Set' layer
- psd.getLayer(LAYERS.SET, self.legal_group).visible = False
+ if layer := psd.getLayer(LAYERS.SET, self.legal_group):
+ layer.visible = False
# Get artist and info layers, reveal info layer
artist = psd.getLayer(LAYERS.ARTIST, self.legal_group)
info = psd.getLayer(LAYERS.COLLECTOR, self.legal_group)
- info.visible = True
+
+ if info:
+ info.visible = True
# Fill optional promo star
- if self.is_collector_promo:
+ if self.is_collector_promo and info:
psd.replace_text(info, "β’", MagicIcons.COLLECTOR_STAR)
# Apply the collector info
- psd.replace_text(artist, 'Artist', self.layout.artist)
- psd.replace_text(info, 'SET', self.layout.set)
- psd.replace_text(info, 'NUM', self.layout.collector_data)
- return [artist, info]
+ if artist:
+ psd.replace_text(artist, "Artist", self.layout.artist)
+ if info:
+ psd.replace_text(info, "SET", self.layout.set)
+ psd.replace_text(info, "NUM", self.layout.collector_data)
- def collector_info_artist_only(self) -> list[ArtLayer]:
+ self.align_collector_layers((artist, info))
+
+ def collector_info_artist_only(self) -> None:
"""Called to generate 'Artist Only' collector info."""
# Collector layers
artist = psd.getLayer(LAYERS.ARTIST, self.legal_group)
- psd.getLayer(LAYERS.SET, self.legal_group).visible = False
+ if layer := psd.getLayer(LAYERS.SET, self.legal_group):
+ layer.visible = False
# Apply the collector info
- psd.replace_text(artist, "Artist", self.layout.artist)
- return [artist]
+ if artist:
+ psd.replace_text(artist, "Artist", self.layout.artist)
+ self.align_collector_layers((artist,))
"""
* Frame Layer Methods
@@ -510,20 +581,27 @@ def enable_frame_layers(self):
self.expansion_symbol_layer.translate(0, 8)
# Simple one image background
- self.pinlines_layer.visible = True
+ if self.pinlines_layer:
+ self.pinlines_layer.visible = True
# Add the promo star
- if self.is_promo_star:
- psd.getLayerSet("Promo Star", LAYERS.TEXT_AND_ICONS).visible = True
+ if self.is_promo_star and (
+ layer := psd.getLayerSet("Promo Star", LAYERS.TEXT_AND_ICONS)
+ ):
+ layer.visible = True
# Make Extended Art modifications
if self.is_extended:
# Copy extended mask to Border
- psd.copy_layer_mask(self.border_mask, self.border_group)
+ if self.border_mask and self.border_group:
+ psd.copy_layer_mask(self.border_mask, self.border_group)
# Enable extended mask on Pinlines
- psd.enable_mask(self.pinlines_layer.parent)
- psd.enable_vector_mask(self.pinlines_layer.parent)
+ if self.pinlines_layer and isinstance(
+ (parent := self.pinlines_layer.parent), LayerSet
+ ):
+ psd.enable_mask(parent)
+ psd.enable_vector_mask(parent)
"""
* Text Layer Methods
@@ -533,22 +611,26 @@ def rules_text_and_pt_layers(self):
"""Does not require a separate text area definition for Creature cards."""
# Add rules text
- self.text.append(
- FormattedTextArea(
- layer=self.text_layer_rules,
- contents=self.layout.oracle_text,
- flavor=self.layout.flavor_text,
- centered=self.is_centered,
- reference=self.textbox_reference,
- divider=self.divider_layer))
+ if self.text_layer_rules:
+ self.text.append(
+ FormattedTextArea(
+ layer=self.text_layer_rules,
+ contents=self.layout.oracle_text,
+ flavor=self.layout.flavor_text,
+ centered=self.is_centered,
+ reference=self.textbox_reference,
+ divider=self.divider_layer,
+ )
+ )
# Add Power / Toughness
- if self.is_creature:
+ if self.is_creature and self.text_layer_pt:
self.text.append(
TextField(
layer=self.text_layer_pt,
- contents=f'{self.layout.power}/'
- f'{self.layout.toughness}'))
+ contents=f"{self.layout.power}/{self.layout.toughness}",
+ )
+ )
"""
* Hook Methods
@@ -556,7 +638,8 @@ def rules_text_and_pt_layers(self):
def hook_large_mana(self) -> None:
"""Adjust mana cost position for large symbols."""
- self.text_layer_mana.translate(0, -5)
+ if self.text_layer_mana:
+ self.text_layer_mana.translate(0, -5)
"""
@@ -571,39 +654,55 @@ class EtchedTemplate(VectorTemplate):
except for Artifact cards. Uses pinline colors for the textbox always. No hollow crown, no companion or
nyx layers.
"""
- template_suffix = 'Etched'
+
+ template_suffix = "Etched"
# Static properties
- background_group = None
+ @cached_property
+ def background_group(self) -> LayerSet | None:
+ return None
# Color Maps
- pinlines_color_map = {
- **pinlines_color_map.copy(),
- 'W': [252, 254, 255],
- 'Land': [136, 120, 98],
- 'Artifact': [194, 210, 221],
- 'Colorless': [194, 210, 221]}
+ @cached_property
+ def pinlines_color_map(self) -> dict[str, ColorObject]:
+ return {
+ **pinlines_color_map,
+ "W": (252, 254, 255),
+ "Land": (136, 120, 98),
+ "Artifact": (194, 210, 221),
+ "Colorless": (194, 210, 221),
+ }
"""
* Colors
"""
@cached_property
- def pinlines_colors(self) -> Union[SolidColor, list[dict]]:
+ def pinlines_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
# Use Artifact color even for colored artifacts
if self.is_artifact and not self.is_land:
- return psd.get_pinline_gradient(LAYERS.ARTIFACT, color_map=self.pinlines_color_map)
- return psd.get_pinline_gradient(self.pinlines, color_map=self.pinlines_color_map)
+ return psd.get_pinline_gradient(
+ LAYERS.ARTIFACT, color_map=self.pinlines_color_map
+ )
+ return psd.get_pinline_gradient(
+ self.pinlines, color_map=self.pinlines_color_map
+ )
@cached_property
- def crown_colors(self) -> Optional[str]:
+ def crown_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
# Use Artifact color even for colored artifacts
if self.is_artifact and not self.is_land:
return LAYERS.ARTIFACT
return self.pinlines
@cached_property
- def textbox_colors(self) -> Optional[str]:
+ def textbox_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
# Normal pinline coloring rules
return self.pinlines
@@ -612,7 +711,7 @@ def textbox_colors(self) -> Optional[str]:
"""
@cached_property
- def divider_layer(self) -> Optional[ArtLayer]:
+ def divider_layer(self) -> ArtLayer | LayerSet | None:
# Divider is grouped
return psd.getLayerSet(LAYERS.DIVIDER, self.text_group)
@@ -621,7 +720,7 @@ def divider_layer(self) -> Optional[ArtLayer]:
"""
@cached_property
- def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
+ def enabled_shapes(self) -> list[ArtLayer | LayerSet | None]:
"""Enable Legendary pinlines shape if card is Legendary."""
if self.is_legendary:
return [psd.getLayer(LAYERS.LEGENDARY, [self.pinlines_group, LAYERS.SHAPE])]
@@ -632,9 +731,21 @@ def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
"""
@cached_property
- def enabled_masks(self) -> list[Union[dict, list, ArtLayer, LayerSet, None]]:
+ def enabled_masks(
+ self,
+ ) -> list[
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ]:
"""Enable pinlines mask if card is Legendary."""
- return [psd.getLayer(LAYERS.NORMAL, [self.pinlines_group, LAYERS.SHAPE])] if self.is_legendary else []
+ return (
+ [psd.getLayer(LAYERS.NORMAL, [self.pinlines_group, LAYERS.SHAPE])]
+ if self.is_legendary
+ else []
+ )
"""
* Methods
@@ -642,7 +753,8 @@ def enabled_masks(self) -> list[Union[dict, list, ArtLayer, LayerSet, None]]:
def enable_crown(self) -> None:
"""Enable Legendary Crown shape."""
- psd.getLayer(f"{LAYERS.LEGENDARY} {LAYERS.SHADOWS}").visible = True
+ if layer := psd.getLayer(f"{LAYERS.LEGENDARY} {LAYERS.SHADOWS}"):
+ layer.visible = True
class ClassicRemasteredTemplate(VectorTransformMod, VectorTemplate):
@@ -650,13 +762,22 @@ class ClassicRemasteredTemplate(VectorTransformMod, VectorTemplate):
Based on iDerp's Classic Remastered template, modified to work with Proxyshop, colored pinlines added for
land generation. PT box added for creatures. Does not support Nyx or Companion layers.
"""
- frame_suffix = 'Classic'
- template_suffix = 'Remastered'
+
+ frame_suffix = "Classic"
+ template_suffix = "Remastered"
# Static properties
- is_vehicle = False
- is_name_shifted = False
- twins_group = None
+ @cached_property
+ def is_vehicle(self) -> bool:
+ return False
+
+ @cached_property
+ def is_name_shifted(self) -> bool:
+ return False
+
+ @cached_property
+ def twins_group(self) -> LayerSet | None:
+ return None
"""
* Frame Details
@@ -665,19 +786,19 @@ class ClassicRemasteredTemplate(VectorTransformMod, VectorTemplate):
@cached_property
def color_limit(self) -> int:
"""Supports 2 and 3 color limit setting."""
- return int(CFG.get_setting("FRAME", "Max.Colors", "3", is_bool=False)) + 1
+ return int(self.config.get_setting("FRAME", "Max.Colors", "3")) + 1
@cached_property
def gold_pt(self) -> bool:
"""Returns True if PT for multicolored cards should be gold."""
- return bool(CFG.get_setting("FRAME", "Gold.PT", False))
+ return self.config.get_bool_setting("FRAME", "Gold.PT", False)
"""
* Bool
"""
@cached_property
- def has_pinlines(self):
+ def has_pinlines(self) -> bool:
"""bool: Allow pinlines for Land and Artifact cards."""
return bool(self.is_land or self.is_artifact)
@@ -686,8 +807,10 @@ def has_pinlines(self):
"""
@cached_property
- def pinlines_colors(self) -> Union[list[int], list[dict]]:
- """Union[list[int], list[dict]]: Allow pinlines on Land and Artifact cards."""
+ def pinlines_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
+ """Allow pinlines on Land and Artifact cards."""
if self.has_pinlines:
if len(self.identity) >= self.color_limit:
if len(self.identity) == 2:
@@ -712,10 +835,10 @@ def textbox_colors(self) -> list[str]:
if self.is_land:
# Within color limit
if 1 < len(self.identity) < self.color_limit:
- return [f'{n} {LAYERS.LAND}' for n in self.identity]
+ return [f"{n} {LAYERS.LAND}" for n in self.identity]
# 1 Color limit
if len(self.identity) == 2:
- return [f'{LAYERS.GOLD} {LAYERS.LAND}']
+ return [f"{LAYERS.GOLD} {LAYERS.LAND}"]
# Plain land background
if self.pinlines == LAYERS.LAND:
return [LAYERS.LAND]
@@ -752,7 +875,7 @@ def crown_colors(self) -> list[str]:
"""
@cached_property
- def pinlines_group(self) -> LayerSet:
+ def pinlines_group(self) -> LayerSet | None:
"""LayerSet: Pinlines and textbox combined."""
return psd.getLayerSet(LAYERS.PINLINES_TEXTBOX)
@@ -764,22 +887,31 @@ def pinlines_groups(self) -> list[LayerSet]:
# Two main pinlines groups
groups: list[LayerSet] = [
- psd.getLayerSet("Pinlines Top", self.pinlines_group),
- psd.getLayerSet("Pinlines Bottom", self.pinlines_group)
+ group
+ for group in (
+ psd.getLayerSet("Pinlines Top", self.pinlines_group),
+ psd.getLayerSet("Pinlines Bottom", self.pinlines_group),
+ )
+ if group
]
# Add the crown pinlines if needed
- if self.is_legendary:
- groups.append(psd.getLayerSet(LAYERS.PINLINES, LAYERS.LEGENDARY_CROWN))
+ if self.is_legendary and (
+ group := psd.getLayerSet(LAYERS.PINLINES, LAYERS.LEGENDARY_CROWN)
+ ):
+ groups.append(group)
return groups
@cached_property
- def textbox_group(self) -> LayerSet:
+ def textbox_group(self) -> LayerSet | None:
"""LayerSet: Must apply correct layer effects."""
group = psd.getLayerSet(LAYERS.TEXTBOX, self.pinlines_group)
- psd.copy_layer_fx(
- psd.getLayer("EFFECTS LAND" if self.pinlines_colors else "EFFECTS", group),
- group)
+ if (
+ layer := psd.getLayer(
+ "EFFECTS LAND" if self.pinlines_colors else "EFFECTS", group
+ )
+ ) and group:
+ psd.copy_layer_fx(layer, group)
return group
"""
@@ -790,7 +922,14 @@ def textbox_group(self) -> LayerSet:
def mask_layers(self) -> list[ArtLayer]:
"""list[ArtLayer]: A list of layers containing masks based on how many color splits."""
if 1 < len(self.identity) < self.color_limit:
- return [psd.getLayer(n, self.mask_group) for n in CON.masks[len(self.identity)]]
+ return [
+ layer
+ for layer in (
+ psd.getLayer(n, self.mask_group)
+ for n in CON.masks[len(self.identity)]
+ )
+ if layer
+ ]
return []
"""
@@ -798,17 +937,19 @@ def mask_layers(self) -> list[ArtLayer]:
"""
@cached_property
- def pinlines_shape(self) -> Optional[LayerSet]:
+ def pinlines_shape(self) -> ArtLayer | None:
"""Pinlines are only provided for Land and Artifact cards."""
if not self.pinlines_groups:
return
return psd.getLayer(
- LAYERS.TRANSFORM_FRONT if self.is_transform and self.is_front else LAYERS.NORMAL,
- [self.pinlines_groups[0], LAYERS.SHAPE]
+ LAYERS.TRANSFORM_FRONT
+ if self.is_transform and self.is_front
+ else LAYERS.NORMAL,
+ [self.pinlines_groups[0], LAYERS.SHAPE],
)
@cached_property
- def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
+ def enabled_shapes(self) -> list[ArtLayer | LayerSet | None]:
"""No need for Twins or Border shape."""
return [self.pinlines_shape, self.textbox_shape]
@@ -817,20 +958,24 @@ def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
"""
@cached_property
- def pt_reference(self) -> Optional[ArtLayer]:
+ def pt_reference(self) -> ReferenceLayer | None:
"""Support alternate reference for flipside PT."""
- return psd.getLayer(
- f'{LAYERS.PT_REFERENCE} Flip' if (
- self.is_transform and self.is_front and self.is_flipside_creature
- ) else LAYERS.PT_REFERENCE, self.text_group)
+ return get_reference_layer(
+ f"{LAYERS.PT_REFERENCE} Flip"
+ if (self.is_transform and self.is_front and self.is_flipside_creature)
+ else LAYERS.PT_REFERENCE,
+ self.text_group,
+ )
@cached_property
- def textbox_reference(self) -> Optional[ArtLayer]:
+ def textbox_reference(self) -> ReferenceLayer | None:
"""Use smaller Textbox Reference if Pinlines are added."""
return psd.get_reference_layer(
f"{LAYERS.TEXTBOX_REFERENCE} {LAYERS.PINLINES}"
- if self.pinlines_colors else LAYERS.TEXTBOX_REFERENCE,
- self.text_group)
+ if self.pinlines_colors
+ else LAYERS.TEXTBOX_REFERENCE,
+ self.text_group,
+ )
"""
* Hooks
@@ -838,14 +983,14 @@ def textbox_reference(self) -> Optional[ArtLayer]:
def hook_large_mana(self) -> None:
"""Adjust mana cost position for large symbols."""
- if not self.is_legendary:
+ if not self.is_legendary and self.text_layer_mana:
self.text_layer_mana.translate(0, -10)
"""
* Methods
"""
- def enable_hollow_crown(self, **kwargs) -> None:
+ def enable_hollow_crown(self) -> None:
"""No hollow crown."""
pass
@@ -870,20 +1015,24 @@ class UniversesBeyondTemplate(VectorTransformMod, VectorTemplate):
an alternative way to build a highly complex template which can work for multiple card types.
Credit to Kyle of Card Conjurer, WarpDandy, SilvanMTG, Chilli and MrTeferi.
"""
- template_suffix = 'Universes Beyond'
+
+ template_suffix = "Universes Beyond"
# Color Maps
- pinlines_color_map = {
- **pinlines_color_map.copy(),
- 'W': [246, 247, 241],
- 'U': [0, 131, 193],
- 'B': [44, 40, 33],
- 'R': [237, 66, 31],
- 'G': [5, 129, 64],
- 'Gold': [239, 209, 107],
- 'Land': [165, 150, 132],
- 'Artifact': [227, 228, 230],
- 'Colorless': [227, 228, 230]}
+ @cached_property
+ def pinlines_color_map(self) -> dict[str, ColorObject]:
+ return {
+ **pinlines_color_map,
+ "W": (246, 247, 241),
+ "U": (0, 131, 193),
+ "B": (44, 40, 33),
+ "R": (237, 66, 31),
+ "G": (5, 129, 64),
+ "Gold": (239, 209, 107),
+ "Land": (165, 150, 132),
+ "Artifact": (227, 228, 230),
+ "Colorless": (227, 228, 230),
+ }
"""
* Colors
@@ -892,10 +1041,12 @@ class UniversesBeyondTemplate(VectorTransformMod, VectorTemplate):
@cached_property
def pt_colors(self) -> str:
"""str: Layer texture name, supports Vehicle PT but doesn't need white text."""
- return LAYERS.VEHICLE if self.is_vehicle == LAYERS.VEHICLE else self.twins
+ return LAYERS.VEHICLE if self.is_vehicle else self.twins
@cached_property
- def crown_colors(self) -> Union[list[int], list[dict]]:
+ def crown_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""list[int] | list[dict]: Solid color or gradient adjustment layer notation."""
return psd.get_pinline_gradient(self.pinlines, color_map=self.crown_color_map)
@@ -904,37 +1055,56 @@ def crown_colors(self) -> Union[list[int], list[dict]]:
"""
@cached_property
- def background_group(self) -> Optional[LayerSet]:
+ def background_group(self) -> LayerSet | None:
"""Optional[LayerSet]: No background for 'Colorless' cards."""
if self.is_colorless:
return
- return cast(super().background_group, Optional[LayerSet])
+ return super().background_group
"""
* Masks
"""
@cached_property
- def enabled_masks(self) -> list[Union[dict, list, ArtLayer, LayerSet, None]]:
+ def enabled_masks(
+ self,
+ ) -> list[
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ]:
"""Masks enabled or copied."""
- return [
- # Pinlines mask, supports Transform Front
- [psd.getLayer(
- LAYERS.TRANSFORM_FRONT if self.is_transform and self.is_front else LAYERS.NORMAL,
- [self.mask_group, LAYERS.PINLINES]
- ), self.pinlines_group]
- ]
+ if self.pinlines_group and (
+ layer := psd.getLayer(
+ LAYERS.TRANSFORM_FRONT
+ if self.is_transform and self.is_front
+ else LAYERS.NORMAL,
+ [self.mask_group, LAYERS.PINLINES],
+ )
+ ):
+ return [
+ (
+ # Pinlines mask, supports Transform Front
+ layer,
+ self.pinlines_group,
+ )
+ ]
+ return []
"""
* Shapes
"""
@cached_property
- def pinlines_shape(self) -> Optional[LayerSet]:
+ def pinlines_shape_group(self) -> LayerSet | None:
# Support Normal and Transform Front
name = (
- LAYERS.TRANSFORM_FRONT if self.is_front else LAYERS.TRANSFORM_BACK
- ) if self.is_transform else LAYERS.NORMAL
+ (LAYERS.TRANSFORM_FRONT if self.is_front else LAYERS.TRANSFORM_BACK)
+ if self.is_transform
+ else LAYERS.NORMAL
+ )
return psd.getLayerSet(name, [self.pinlines_group, LAYERS.SHAPE])
"""
@@ -944,24 +1114,25 @@ def pinlines_shape(self) -> Optional[LayerSet]:
def enable_frame_layers(self) -> None:
super().enable_frame_layers()
+ if self.pinlines_shape_group:
+ self.pinlines_shape_group.visible = True
+
# Translucent colorless shapes
if self.is_colorless and self.twins_shape and self.textbox_shape:
psd.set_fill_opacity(60, self.twins_group)
psd.set_fill_opacity(60, self.textbox_group)
def enable_crown(self) -> None:
-
# Generate a solid color or gradient layer for the crown group
- self.crown_group.visible = True
- self.generate_layer(
- group=self.crown_group,
- colors=self.crown_colors)
+ if self.crown_group:
+ self.crown_group.visible = True
+ self.generate_layer(group=self.crown_group, colors=self.crown_colors)
# Enable Legendary pinline connector
- psd.getLayerSet(
- LAYERS.LEGENDARY,
- self.pinlines_shape.parent
- ).visible = True
+ if self.pinlines_shape_group and (
+ layer := psd.getLayerSet(LAYERS.LEGENDARY, self.pinlines_shape_group.parent)
+ ):
+ layer.visible = True
"""
* Transform Methods
@@ -971,9 +1142,13 @@ def enable_transform_layers_back(self) -> None:
super().enable_transform_layers_back()
# Enable brightness shift on back face layers
- psd.getLayerSet(LAYERS.BACK, self.pt_group).visible = True
- psd.getLayerSet(LAYERS.BACK, self.twins_group).visible = True
- psd.getLayer(LAYERS.BACK, self.textbox_group).visible = True
+ for layer in (
+ psd.getLayerSet(LAYERS.BACK, self.pt_group),
+ psd.getLayerSet(LAYERS.BACK, self.twins_group),
+ psd.getLayer(LAYERS.BACK, self.textbox_group),
+ ):
+ if layer:
+ layer.visible = True
class LOTRTemplate(VectorTemplate):
@@ -982,60 +1157,80 @@ class LOTRTemplate(VectorTemplate):
* Credit to TupinambΓ‘ (Pedro Neves) for the master template
* With additional support from CompC and MrTeferi
"""
- template_suffix = 'Lord of the Rings'
+
+ template_suffix = "Lord of the Rings"
# Static Properties
- enabled_shapes = []
+ @cached_property
+ def enabled_shapes(self) -> list[ArtLayer | LayerSet | None]:
+ return []
# Color Maps
- pinlines_color_map = {
- **pinlines_color_map.copy(),
- 'W': [230, 220, 185],
- 'U': [72, 142, 191],
- 'B': [126, 128, 127],
- 'R': [217, 94, 76],
- 'G': [97, 143, 102],
- 'Gold': [246, 213, 125],
- 'Land': [181, 162, 149],
- 'Artifact': [210, 219, 227],
- 'Colorless': [210, 219, 227]}
- dark_color_map = {
- **pinlines_color_map.copy(),
- 'W': [134, 123, 105],
- 'U': [44, 51, 103],
- 'B': [44, 43, 39],
- 'R': [118, 34, 34],
- 'G': [13, 68, 45],
- 'Gold': [158, 124, 78],
- 'Land': [103, 90, 74],
- 'Artifact': [61, 88, 109],
- 'Colorless': [78, 91, 101],
- 'Vehicle': [76, 51, 20]}
+ @cached_property
+ def pinlines_color_map(self) -> dict[str, ColorObject]:
+ return {
+ **pinlines_color_map,
+ "W": (230, 220, 185),
+ "U": (72, 142, 191),
+ "B": (126, 128, 127),
+ "R": (217, 94, 76),
+ "G": (97, 143, 102),
+ "Gold": (246, 213, 125),
+ "Land": (181, 162, 149),
+ "Artifact": (210, 219, 227),
+ "Colorless": (210, 219, 227),
+ }
+
+ @cached_property
+ def dark_color_map(self) -> dict[str, ColorObject]:
+ return {
+ **pinlines_color_map,
+ "W": (134, 123, 105),
+ "U": (44, 51, 103),
+ "B": (44, 43, 39),
+ "R": (118, 34, 34),
+ "G": (13, 68, 45),
+ "Gold": (158, 124, 78),
+ "Land": (103, 90, 74),
+ "Artifact": (61, 88, 109),
+ "Colorless": (78, 91, 101),
+ "Vehicle": (76, 51, 20),
+ }
"""
* Colors
"""
@cached_property
- def crown_colors(self) -> Union[SolidColor, list[dict]]:
+ def crown_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""Must be returned as SolidColor or gradient notation."""
return psd.get_pinline_gradient(
- self.identity if 1 < len(self.identity) < self.color_limit else self.pinlines,
- color_map=self.pinlines_color_map)
+ self.identity
+ if 1 < len(self.identity) < self.color_limit
+ else self.pinlines,
+ color_map=self.pinlines_color_map,
+ )
@cached_property
- def twins_colors(self) -> Union[SolidColor, list[dict]]:
+ def twins_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""Must be returned as SolidColor or gradient notation."""
return psd.get_pinline_gradient(
- colors=self.twins,
- color_map=self.dark_color_map)
+ colors=self.twins, color_map=self.dark_color_map
+ )
@cached_property
- def pt_colors(self) -> Union[SolidColor, list[dict]]:
+ def pt_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""Must be returned as SolidColor or gradient notation."""
return psd.get_pinline_gradient(
colors=LAYERS.VEHICLE if self.is_vehicle else self.twins,
- color_map=self.dark_color_map)
+ color_map=self.dark_color_map,
+ )
@cached_property
def textbox_colors(self) -> str:
@@ -1054,8 +1249,10 @@ def background_colors(self) -> str:
@cached_property
def pinlines_groups(self) -> list[LayerSet]:
"""Pinlines and crown pinlines addon."""
- groups = [self.pinlines_group]
- if self.is_legendary:
+ groups: list[LayerSet] = []
+ if self.pinlines_group:
+ groups.append(self.pinlines_group)
+ if self.is_legendary and self.crown_group:
groups.append(self.crown_group)
return groups
@@ -1064,7 +1261,15 @@ def pinlines_groups(self) -> list[LayerSet]:
"""
@cached_property
- def enabled_masks(self) -> list[Union[dict, list, ArtLayer, LayerSet, None]]:
+ def enabled_masks(
+ self,
+ ) -> list[
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ]:
"""Mask Pinlines if Legendary."""
if self.is_legendary:
return [self.pinlines_group]
@@ -1076,59 +1281,76 @@ def enabled_masks(self) -> list[Union[dict, list, ArtLayer, LayerSet, None]]:
def enable_crown(self) -> None:
"""Enable the Legendary crown group only."""
- self.crown_group.visible = True
+ if self.crown_group:
+ self.crown_group.visible = True
class BorderlessVectorTemplate(
- NicknameMod,
+ NicknameVectorMod,
VectorBorderlessMod,
VectorMDFCMod,
VectorTransformMod,
- VectorTemplate
+ VectorTemplate,
):
"""Borderless template first used in the Womens Day Secret Lair, redone with vector shapes."""
# Color Maps
- light_color_map = {
- 'W': "#faf8f2",
- 'U': "#d2edfa",
- 'B': "#c9c2be",
- 'R': "#f8c7b0",
- 'G': "#dbfadc",
- 'Gold': "#f5e5a4",
- 'Land': "#f0ddce",
- 'Hybrid': "f#0ddce",
- 'Artifact': "#cde0e9",
- 'Colorless': "#e2d8d4",
- 'Vehicle': "#4c3314"}
- crown_color_map = {
- 'W': "#f8f4f0",
- 'U': "#006dae",
- 'B': "#393431",
- 'R': "#de3c23",
- 'G': "#006d42",
- 'Gold': "#efd16b",
- 'Land': "#a59684",
- 'Artifact': "#b5c5cd",
- 'Colorless': "#d6d6dc"}
- gradient_location_map = {
- 2: [.40, .60],
- 3: [.29, .40, .60, .71],
- 4: [.20, .30, .45, .55, .70, .80],
- 5: [.20, .25, .35, .45, .55, .65, .75, .80]}
+ @cached_property
+ def light_color_map(self) -> dict[str, ColorObject]:
+ return {
+ "W": "#faf8f2",
+ "U": "#d2edfa",
+ "B": "#c9c2be",
+ "R": "#f8c7b0",
+ "G": "#dbfadc",
+ "Gold": "#f5e5a4",
+ "Land": "#f0ddce",
+ "Hybrid": "f#0ddce",
+ "Artifact": "#cde0e9",
+ "Colorless": "#e2d8d4",
+ "Vehicle": "#4c3314",
+ }
+
+ @cached_property
+ def crown_color_map(self) -> dict[str, ColorObject]:
+ return {
+ "W": "#f8f4f0",
+ "U": "#006dae",
+ "B": "#393431",
+ "R": "#de3c23",
+ "G": "#006d42",
+ "Gold": "#efd16b",
+ "Land": "#a59684",
+ "Artifact": "#b5c5cd",
+ "Colorless": "#d6d6dc",
+ }
+
+ @cached_property
+ def gradient_location_map(self) -> dict[int, list[float]]:
+ return {
+ 2: [0.40, 0.60],
+ 3: [0.29, 0.40, 0.60, 0.71],
+ 4: [0.20, 0.30, 0.45, 0.55, 0.70, 0.80],
+ 5: [0.20, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.80],
+ }
# Static Properties
- mask_layers = []
- background_group = None
+ @cached_property
+ def mask_layers(self) -> list[ArtLayer]:
+ return []
+
+ @cached_property
+ def background_group(self) -> LayerSet | None:
+ return None
"""
* Mixin Methods
"""
- @property
- def post_text_methods(self):
+ @cached_property
+ def post_text_methods(self) -> list[Callable[[], None]]:
"""Add post-text adjustments method."""
- funcs = []
+ funcs: list[Callable[[], None]] = []
if self.size != BorderlessTextbox.Tall:
funcs.append(self.textbox_positioning)
if self.is_token:
@@ -1150,128 +1372,144 @@ def size(self) -> str:
return BorderlessTextbox.Textless
# Get the user's preferred setting
- size = str(CFG.get_option(
- section="FRAME",
- key="Textbox.Size",
- enum_class=BorderlessTextbox,
- default=BorderlessTextbox.Automatic
- ))
+ size = str(
+ self.config.get_option(
+ section="FRAME",
+ key="Textbox.Size",
+ enum_class=BorderlessTextbox,
+ default=BorderlessTextbox.Automatic,
+ )
+ )
# Determine the automatic size
if size == BorderlessTextbox.Automatic:
-
# Check for basic land
if self.is_basic_land:
return BorderlessTextbox.Short
# Set up our test text layer
- test_layer = psd.getLayer(self.text_layer_rules_name, [self.text_group, "Tall"])
+ test_layer = psd.getLayer(
+ self.text_layer_rules_name, [self.text_group, "Tall"]
+ )
test_text = self.layout.oracle_text
- if self.layout.flavor_text:
- test_text += f'\r{self.layout.flavor_text}'
- test_layer.textItem.contents = test_text.replace('\n', '\r')
-
- # Get the number of lines in our test text and decide what size
- num = get_line_count(test_layer, self.docref)
- if self.layout.flavor_text:
- num += 1
- if num < 5:
- return BorderlessTextbox.Short
- if num < 6:
- return BorderlessTextbox.Medium
- if num < 7:
- return BorderlessTextbox.Normal
+ if test_layer:
+ if self.layout.flavor_text:
+ test_text += f"\r{self.layout.flavor_text}"
+ test_layer.textItem.contents = test_text.replace("\n", "\r")
+
+ # Get the number of lines in our test text and decide what size
+ num = get_line_count(test_layer, self.docref)
+ if self.layout.flavor_text:
+ num += 1
+ if num < 5:
+ return BorderlessTextbox.Short
+ if num < 6:
+ return BorderlessTextbox.Medium
+ if num < 7:
+ return BorderlessTextbox.Normal
return BorderlessTextbox.Tall
return size
@cached_property
def color_limit(self) -> int:
# Built in setting, dual and triple color split
- return int(CFG.get_setting(
- section="COLORS",
- key="Max.Colors",
- default="2",
- is_bool=False)) + 1
+ return (
+ int(
+ self.config.get_setting(section="COLORS", key="Max.Colors", default="2")
+ )
+ + 1
+ )
@cached_property
def drop_shadow_enabled(self) -> bool:
"""Returns True if Drop Shadow text setting is enabled."""
- return bool(CFG.get_setting(
- section="TEXT",
- key="Drop.Shadow",
- default=True))
+ return bool(
+ self.config.get_bool_setting(
+ section="TEXT", key="Drop.Shadow", default=True
+ )
+ )
@cached_property
def crown_texture_enabled(self) -> bool:
"""Returns True if Legendary crown clipping texture should be enabled."""
- return bool(CFG.get_setting(
- section="FRAME",
- key="Crown.Texture",
- default=True))
+ return bool(
+ self.config.get_bool_setting(
+ section="FRAME", key="Crown.Texture", default=True
+ )
+ )
@cached_property
def multicolor_textbox(self) -> bool:
"""Returns True if Textbox for multicolored cards should use blended colors."""
- return bool(CFG.get_setting(
- section="COLORS",
- key="Multicolor.Textbox",
- default=True))
+ return bool(
+ self.config.get_bool_setting(
+ section="COLORS", key="Multicolor.Textbox", default=True
+ )
+ )
@cached_property
def multicolor_pinlines(self) -> bool:
"""Returns True if Pinlines and Crown for multicolored cards should use blended colors."""
- return bool(CFG.get_setting(
- section="COLORS",
- key="Multicolor.Pinlines",
- default=True))
+ return bool(
+ self.config.get_bool_setting(
+ section="COLORS", key="Multicolor.Pinlines", default=True
+ )
+ )
@cached_property
def multicolor_twins(self) -> bool:
"""Returns True if Twins for multicolored cards should use blended colors."""
- return bool(CFG.get_setting(
- section="COLORS",
- key="Multicolor.Twins",
- default=False))
+ return bool(
+ self.config.get_bool_setting(
+ section="COLORS", key="Multicolor.Twins", default=False
+ )
+ )
@cached_property
def multicolor_pt(self) -> bool:
"""Returns True if PT Box for multicolored cards should use the last color."""
- return bool(CFG.get_setting(
- section="COLORS",
- key="Multicolor.PT",
- default=False))
+ return bool(
+ self.config.get_bool_setting(
+ section="COLORS", key="Multicolor.PT", default=False
+ )
+ )
@cached_property
def hybrid_colored(self) -> bool:
"""Returns True if Twins and PT should be colored on Hybrid cards."""
- return bool(CFG.get_setting(
- section="COLORS",
- key="Hybrid.Colored",
- default=True))
+ return bool(
+ self.config.get_bool_setting(
+ section="COLORS", key="Hybrid.Colored", default=True
+ )
+ )
@cached_property
def front_face_colors(self) -> bool:
"""Returns True if lighter color map should be used on front face DFC cards."""
- return bool(CFG.get_setting(
- section="COLORS",
- key="Front.Face.Colors",
- default=True))
+ return bool(
+ self.config.get_bool_setting(
+ section="COLORS", key="Front.Face.Colors", default=True
+ )
+ )
@cached_property
def land_colorshift(self) -> bool:
"""Returns True if Land cards should use the darker brown color."""
- return bool(CFG.get_setting(
- section="COLORS",
- key="Land.Colorshift",
- default=False))
+ return bool(
+ self.config.get_bool_setting(
+ section="COLORS", key="Land.Colorshift", default=False
+ )
+ )
@cached_property
- def artifact_color_mode(self) -> str:
+ def artifact_color_mode(self) -> BorderlessColorMode:
"""Setting determining what elements to color for colored artifacts.."""
- return CFG.get_option(
+ return self.config.get_option(
section="COLORS",
key="Artifact.Color.Mode",
- enum_class=BorderlessColorMode)
+ enum_class=BorderlessColorMode,
+ default=BorderlessColorMode.Twins_And_PT,
+ )
"""
* Frame Details
@@ -1282,9 +1520,11 @@ def frame_type(self) -> str:
"""Layer name associated with the holistic frame type."""
if self.is_textless:
# Textless / Textless Transform
- return f"{self.size} {LAYERS.TRANSFORM}" if (
- self.is_transform or self.is_mdfc
- ) else self.size
+ return (
+ f"{self.size} {LAYERS.TRANSFORM}"
+ if (self.is_transform or self.is_mdfc)
+ else self.size
+ )
if self.is_transform and self.is_front:
# Size TF Front
return f"{self.size} {LAYERS.TRANSFORM_FRONT}"
@@ -1302,31 +1542,32 @@ def art_frame(self) -> str:
* Bool
"""
- @property
- def is_basic_land(self):
+ @cached_property
+ def is_basic_land(self) -> bool:
"""Disable basic land watermark if Textless is enabled."""
- if bool(CFG.get_setting(section="FRAME", key="Textless", default=False)):
+ if bool(
+ self.config.get_bool_setting(section="FRAME", key="Textless", default=False)
+ ):
return False
return super().is_basic_land
@cached_property
def is_textless(self) -> bool:
"""Return True if this a textless render."""
- if not any([self.layout.oracle_text, self.layout.flavor_text, self.is_basic_land]):
- return True
- return bool(CFG.get_setting(section="FRAME", key="Textless", default=False))
-
- @cached_property
- def is_nickname(self) -> bool:
- """Return True if this a nickname render."""
- if self.layout.nickname:
+ if not any(
+ [self.layout.oracle_text, self.layout.flavor_text, self.is_basic_land]
+ ):
return True
- return CFG.get_setting(section="TEXT", key="Nickname", default=False)
+ return self.config.get_bool_setting(
+ section="FRAME", key="Textless", default=False
+ )
@cached_property
def is_colored_nickname(self) -> bool:
"""Return True if nickname plate should be colored."""
- return CFG.get_setting(section="COLORS", key="Nickname", default=False)
+ return self.config.get_bool_setting(
+ section="COLORS", key="Nickname", default=False
+ )
@cached_property
def is_multicolor(self) -> bool:
@@ -1342,47 +1583,63 @@ def is_centered(self) -> bool:
and "\n" not in self.layout.oracle_text
and not (
# Not centered if using a small textbox with Flipside PT
- self.is_flipside_creature and self.is_front and self.size in [
+ self.is_flipside_creature
+ and self.is_front
+ and self.size
+ in [
BorderlessTextbox.Automatic,
BorderlessTextbox.Medium,
- BorderlessTextbox.Short
- ]))
+ BorderlessTextbox.Short,
+ ]
+ )
+ )
@cached_property
def is_pt_enabled(self) -> bool:
"""Return True if a separate Power/Toughness text layer is used for this render."""
- return self.is_creature and (not self.is_textless or not CFG.symbol_enabled)
+ return self.is_creature and (
+ not self.is_textless or not self.config.symbol_enabled
+ )
@cached_property
def is_drop_shadow(self) -> bool:
"""Return True if drop shadow setting is enabled."""
return bool(
- self.drop_shadow_enabled and not
- ((self.is_mdfc or self.is_transform) and self.is_front and self.front_face_colors))
+ self.drop_shadow_enabled
+ and not (
+ (self.is_mdfc or self.is_transform)
+ and self.is_front
+ and self.front_face_colors
+ )
+ )
@cached_property
def is_authentic_front(self) -> bool:
"""Return True if rendering a front face DFC card with authentic lighter colors."""
- return bool((self.is_mdfc or self.is_transform) and self.is_front and self.front_face_colors)
+ return bool(
+ (self.is_mdfc or self.is_transform)
+ and self.is_front
+ and self.front_face_colors
+ )
"""
* Color Maps
"""
@cached_property
- def dark_color_map(self) -> dict:
+ def dark_color_map(self) -> dict[str, ColorObject]:
return {
- 'W': "#958676",
- 'U': "#045482",
- 'B': "#282523",
- 'R': "#93362a",
- 'G': "#134f23",
- 'Gold': "#9a883f",
- 'Land': "#684e30" if self.land_colorshift else "#a79c8e",
- 'Hybrid': "#a79c8e",
- 'Artifact': "#48555c",
- 'Colorless': "#74726b",
- 'Vehicle': "#4c3314"
+ "W": "#958676",
+ "U": "#045482",
+ "B": "#282523",
+ "R": "#93362a",
+ "G": "#134f23",
+ "Gold": "#9a883f",
+ "Land": "#684e30" if self.land_colorshift else "#a79c8e",
+ "Hybrid": "#a79c8e",
+ "Artifact": "#48555c",
+ "Colorless": "#74726b",
+ "Vehicle": "#4c3314",
}
"""
@@ -1390,51 +1647,70 @@ def dark_color_map(self) -> dict:
"""
@cached_property
- def twins_colors(self) -> Union[list[int], list[dict]]:
-
+ def twins_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
# Default to twins
colors = self.twins
# Color enabled hybrid OR color enabled multicolor
- if (self.is_hybrid and self.hybrid_colored) or (self.is_multicolor and self.multicolor_twins):
+ if (self.is_hybrid and self.hybrid_colored) or (
+ self.is_multicolor and self.multicolor_twins
+ ):
colors = self.identity
# Color disabled hybrid cards
elif self.is_hybrid:
colors = LAYERS.HYBRID
# Use artifact twins if artifact mode isn't colored
- if self.is_artifact and not self.is_land and self.artifact_color_mode not in [
- BorderlessColorMode.Twins_And_PT,
- BorderlessColorMode.Twins,
- BorderlessColorMode.All
- ]:
+ if (
+ self.is_artifact
+ and not self.is_land
+ and self.artifact_color_mode
+ not in [
+ BorderlessColorMode.Twins_And_PT,
+ BorderlessColorMode.Twins,
+ BorderlessColorMode.All,
+ ]
+ ):
colors = LAYERS.ARTIFACT
# Return Solid Color or Gradient notation
return psd.get_pinline_gradient(
colors=colors,
- color_map=self.light_color_map if self.is_authentic_front else self.dark_color_map,
- location_map=self.gradient_location_map)
+ color_map=self.light_color_map
+ if self.is_authentic_front
+ else self.dark_color_map,
+ location_map=self.gradient_location_map,
+ )
@cached_property
- def pt_colors(self) -> Union[list[int], list[dict]]:
-
+ def pt_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
# Default to twins, or Vehicle for non-colored vehicle artifacts
colors = self.twins
# Color enabled hybrid OR color enabled multicolor
- if (self.is_hybrid and self.hybrid_colored) or (self.is_multicolor and self.multicolor_pt):
+ if (self.is_hybrid and self.hybrid_colored) or (
+ self.is_multicolor and self.multicolor_pt
+ ):
colors = self.identity[-1]
# Use Hybrid color for color-disabled hybrid cards
elif self.is_hybrid:
colors = LAYERS.HYBRID
# Use artifact twins color if artifact mode isn't colored
- if self.is_artifact and not self.is_land and self.artifact_color_mode not in [
- BorderlessColorMode.Twins_And_PT,
- BorderlessColorMode.All,
- BorderlessColorMode.PT
- ]:
+ if (
+ self.is_artifact
+ and not self.is_land
+ and self.artifact_color_mode
+ not in [
+ BorderlessColorMode.Twins_And_PT,
+ BorderlessColorMode.All,
+ BorderlessColorMode.PT,
+ ]
+ ):
colors = LAYERS.ARTIFACT
# Use Vehicle for non-colored artifacts
@@ -1444,11 +1720,15 @@ def pt_colors(self) -> Union[list[int], list[dict]]:
# Return Solid Color or Gradient notation
return psd.get_pinline_gradient(
colors=colors,
- color_map=self.light_color_map if self.is_authentic_front else self.dark_color_map)
+ color_map=self.light_color_map
+ if self.is_authentic_front
+ else self.dark_color_map,
+ )
@cached_property
- def textbox_colors(self) -> Union[list[int], list[dict]]:
-
+ def textbox_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
# Default to twins
colors = self.twins
@@ -1459,62 +1739,81 @@ def textbox_colors(self) -> Union[list[int], list[dict]]:
# Use artifact textbox color if artifact mod isn't colored
if self.is_artifact and self.artifact_color_mode not in [
BorderlessColorMode.Textbox,
- BorderlessColorMode.All
+ BorderlessColorMode.All,
]:
colors = LAYERS.ARTIFACT
# Return Solid Color or Gradient notation
return psd.get_pinline_gradient(
colors=colors,
- color_map=self.light_color_map if self.is_authentic_front else self.dark_color_map,
- location_map=self.gradient_location_map)
+ color_map=self.light_color_map
+ if self.is_authentic_front
+ else self.dark_color_map,
+ location_map=self.gradient_location_map,
+ )
@cached_property
- def crown_colors(self) -> Union[list[int], list[dict]]:
+ def crown_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
# Use Solid Color or Gradient adjustment layer for Crown colors
return psd.get_pinline_gradient(
# Use identity for hybrid OR color enabled multicolor
- colors=self.identity if self.is_hybrid or (self.is_multicolor and self.multicolor_pinlines) else (
- # Use pinlines if not a color code
- self.pinlines if not is_multicolor_string(self.pinlines) else LAYERS.GOLD),
+ colors=self.identity
+ if self.is_hybrid or (self.is_multicolor and self.multicolor_pinlines)
+ # Use pinlines if not a color code
+ else (
+ self.pinlines
+ if not is_multicolor_string(self.pinlines)
+ else LAYERS.GOLD
+ ),
color_map=self.crown_color_map,
- location_map=self.gradient_location_map)
+ location_map=self.gradient_location_map,
+ )
@cached_property
- def pinlines_colors(self) -> Union[list[int], list[dict]]:
+ def pinlines_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
# Use alternate gradient location map
return psd.get_pinline_gradient(
# Use identity for hybrid OR color enabled multicolor
- colors=self.identity if self.is_hybrid or (self.is_multicolor and self.multicolor_pinlines) else (
- # Use pinlines if not a color code
- self.pinlines if not is_multicolor_string(self.pinlines) else LAYERS.GOLD),
+ colors=self.identity
+ if self.is_hybrid or (self.is_multicolor and self.multicolor_pinlines)
+ # Use pinlines if not a color code
+ else (
+ self.pinlines
+ if not is_multicolor_string(self.pinlines)
+ else LAYERS.GOLD
+ ),
color_map=self.pinlines_color_map,
- location_map=self.gradient_location_map)
+ location_map=self.gradient_location_map,
+ )
"""
* Groups
"""
@cached_property
- def pt_group(self) -> Optional[LayerSet]:
+ def pt_group(self) -> LayerSet | None:
"""PT Box group, alternative Textless option used when Expansion Symbol is disabled."""
- if self.is_textless and CFG.symbol_enabled:
+ if self.is_textless and self.config.symbol_enabled:
return
if self.is_textless and self.is_pt_enabled:
- return psd.getLayerSet(f'{LAYERS.PT_BOX} {LAYERS.TEXTLESS}')
- return cast(super().pt_group, Optional[LayerSet])
+ return psd.getLayerSet(f"{LAYERS.PT_BOX} {LAYERS.TEXTLESS}")
+ return super().pt_group
@cached_property
- def crown_group(self) -> LayerSet:
+ def crown_group(self) -> LayerSet | None:
"""Legendary Crown group, use inner group to allow textured overlays above."""
return psd.getLayerSet(LAYERS.LEGENDARY_CROWN, LAYERS.LEGENDARY_CROWN)
@cached_property
- def textbox_group(self) -> Optional[LayerSet]:
+ def textbox_group(self) -> LayerSet | None:
"""Optional[LayerSet]: Textbox group if not a 'Textless' render."""
if self.is_textless:
return
- return cast(super().textbox_group, Optional[LayerSet])
+ return super().textbox_group
"""
* Text Layers
@@ -1525,17 +1824,21 @@ def text_layer_rules_name(self) -> str:
"""Compute the name of this layer separately, so we can use it for automatic textbox sizing."""
if self.is_creature:
# Is a creature, Flipside P/T?
- return LAYERS.RULES_TEXT_CREATURE_FLIP if (
- self.is_transform and self.is_flipside_creature
- ) else LAYERS.RULES_TEXT_CREATURE
+ return (
+ LAYERS.RULES_TEXT_CREATURE_FLIP
+ if (self.is_transform and self.is_flipside_creature)
+ else LAYERS.RULES_TEXT_CREATURE
+ )
# Not a creature, Flipside P/T?
- return LAYERS.RULES_TEXT_NONCREATURE_FLIP if (
- self.is_transform and self.is_flipside_creature
- ) else LAYERS.RULES_TEXT_NONCREATURE
+ return (
+ LAYERS.RULES_TEXT_NONCREATURE_FLIP
+ if (self.is_transform and self.is_flipside_creature)
+ else LAYERS.RULES_TEXT_NONCREATURE
+ )
@cached_property
- def text_layer_rules(self) -> Optional[ArtLayer]:
+ def text_layer_rules(self) -> ArtLayer | None:
"""Card rules text layer, use pre-computed layer name."""
return psd.getLayer(self.text_layer_rules_name, [self.text_group, self.size])
@@ -1544,13 +1847,21 @@ def text_layer_rules(self) -> Optional[ArtLayer]:
"""
@cached_property
- def textbox_reference(self) -> Optional[ReferenceLayer]:
+ def textbox_reference(self) -> ReferenceLayer | None:
"""Use size appropriate textbox reference."""
- ref = psd.get_reference_layer(self.size, psd.getLayerSet(LAYERS.TEXTBOX_REFERENCE, self.text_group))
- if self.is_mdfc:
- psd.copy_layer_mask(
- layer_from=psd.getLayer(LAYERS.MDFC, [self.mask_group, LAYERS.TEXTBOX_REFERENCE]),
- layer_to=ref)
+ ref = psd.get_reference_layer(
+ self.size, psd.getLayerSet(LAYERS.TEXTBOX_REFERENCE, self.text_group)
+ )
+ if (
+ self.is_mdfc
+ and ref
+ and (
+ layer := psd.getLayer(
+ LAYERS.MDFC, [self.mask_group, LAYERS.TEXTBOX_REFERENCE]
+ )
+ )
+ ):
+ psd.copy_layer_mask(layer_from=layer, layer_to=ref)
psd.apply_mask(ref)
ref.visible = False
return ref
@@ -1560,33 +1871,43 @@ def textbox_reference(self) -> Optional[ReferenceLayer]:
"""
@cached_property
- def textbox_shape(self) -> Union[LayerObjectTypes, list[LayerObjectTypes], None]:
+ def textbox_shape(self) -> ArtLayer | None:
"""Support a size appropriate textbox shape and Transform Front addition."""
# Return None if textless
if self.is_textless:
- return []
+ return None
_shape_group = psd.getLayerSet(LAYERS.SHAPE, self.textbox_group)
+ return psd.getLayer(self.size, _shape_group)
+ @cached_property
+ def textbox_transform_front_addition_shape(self) -> ArtLayer | None:
# Enable Transform Front addition if required
- layers = [psd.getLayer(self.size, _shape_group)]
- if self.is_transform and self.is_front:
- layers.append(psd.getLayer(LAYERS.TRANSFORM_FRONT, _shape_group))
- return layers
+ if not self.is_textless and self.is_transform and self.is_front:
+ _shape_group = psd.getLayerSet(LAYERS.SHAPE, self.textbox_group)
+ return psd.getLayer(LAYERS.TRANSFORM_FRONT, _shape_group)
@cached_property
- def pinlines_shape(self) -> Union[LayerObjectTypes, list[LayerObjectTypes], None]:
- """Support a variety of pinlines shapes including Transform, MDFC, Textless, Nickname, etc."""
+ def pinlines_shape(self) -> ArtLayer | None:
+ return None
+
+ @cached_property
+ def pinlines_shapes(self) -> list[ArtLayer | LayerSet | None]:
_shape_group = psd.getLayerSet(LAYERS.SHAPE, self.pinlines_group)
# Name and typeline always included
- layers = [
+ layers: list[ArtLayer | LayerSet | None] = [
psd.getLayerSet(
- LAYERS.TRANSFORM if self.is_transform else (LAYERS.MDFC if self.is_mdfc else LAYERS.NORMAL),
- [_shape_group, LAYERS.NAME]),
+ LAYERS.TRANSFORM
+ if self.is_transform
+ else (LAYERS.MDFC if self.is_mdfc else LAYERS.NORMAL),
+ [_shape_group, LAYERS.NAME],
+ ),
psd.getLayer(
LAYERS.TEXTLESS if self.is_textless else self.size,
- [_shape_group, LAYERS.TYPE_LINE])]
+ [_shape_group, LAYERS.TYPE_LINE],
+ ),
+ ]
# Add nickname pinlines if required
if self.is_nickname and not self.is_legendary:
@@ -1602,89 +1923,166 @@ def pinlines_shape(self) -> Union[LayerObjectTypes, list[LayerObjectTypes], None
# Add Transform Front cutout if required
if self.is_transform and self.is_front:
layers.append(
- psd.getLayer(LAYERS.TRANSFORM_FRONT, [_shape_group, LAYERS.TEXTBOX]))
+ psd.getLayer(LAYERS.TRANSFORM_FRONT, [_shape_group, LAYERS.TEXTBOX])
+ )
return layers
@cached_property
- def twins_shape(self) -> Union[LayerObjectTypes, list[LayerObjectTypes], None]:
+ def twins_shape(self) -> ArtLayer | None:
+ return None
+
+ @cached_property
+ def twins_shapes(self) -> list[ArtLayer | LayerSet | None]:
"""Separate shapes for Name and Typeline box."""
_shape_group = psd.getLayerSet(LAYERS.SHAPE, self.twins_group)
return [
psd.getLayer(
- LAYERS.TRANSFORM if self.is_transform or self.is_mdfc else LAYERS.NORMAL,
- [_shape_group, LAYERS.NAME]),
+ LAYERS.TRANSFORM
+ if self.is_transform or self.is_mdfc
+ else LAYERS.NORMAL,
+ [_shape_group, LAYERS.NAME],
+ ),
psd.getLayer(
LAYERS.TEXTLESS if self.is_textless else self.size,
- [_shape_group, LAYERS.TYPE_LINE])
+ [_shape_group, LAYERS.TYPE_LINE],
+ ),
]
@cached_property
- def crown_shape(self) -> Union[LayerObjectTypes, list[LayerObjectTypes], None]:
+ def crown_shape(self) -> ArtLayer | None:
"""Vector shape for Legendary Crown."""
if not self.is_legendary:
- return []
- return [psd.getLayer(
+ return None
+ return psd.getLayer(
LAYERS.NICKNAME if self.is_nickname else LAYERS.NORMAL,
- [self.crown_group, LAYERS.SHAPE]
- )]
+ [self.crown_group, LAYERS.SHAPE],
+ )
+
+ @cached_property
+ def enabled_shapes(self) -> list[ArtLayer | LayerSet | None]:
+ return [
+ *super().enabled_shapes,
+ *self.pinlines_shapes,
+ *self.twins_shapes,
+ self.textbox_transform_front_addition_shape,
+ ]
"""
* Masks
"""
@cached_property
- def border_mask(self) -> Optional[list]:
+ def border_mask(
+ self,
+ ) -> (
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ):
"""Support border mask for Textless and front face Transform modifications."""
- if self.is_textless:
- return [psd.getLayer(LAYERS.TEXTLESS, [self.mask_group, LAYERS.BORDER]), self.border_group]
- if self.is_transform and self.is_front:
- return [psd.getLayer(LAYERS.TRANSFORM_FRONT, [self.mask_group, LAYERS.BORDER]), self.border_group]
- return
-
- @cached_property
- def pinlines_mask(self) -> dict:
+ if self.border_group:
+ if self.is_textless:
+ if layer := psd.getLayer(
+ LAYERS.TEXTLESS, [self.mask_group, LAYERS.BORDER]
+ ):
+ return (layer, self.border_group)
+ elif (
+ self.is_transform
+ and self.is_front
+ and (
+ layer := psd.getLayer(
+ LAYERS.TRANSFORM_FRONT, [self.mask_group, LAYERS.BORDER]
+ )
+ )
+ ):
+ return (layer, self.border_group)
+
+ @cached_property
+ def pinlines_mask(
+ self,
+ ) -> (
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ):
"""Use pre-calculated frame type to find pinlines mask. This mask hides overlapping layer effects."""
- return {
- 'mask': psd.getLayer(self.frame_type, [self.mask_group, LAYERS.PINLINES]),
- 'layer': self.pinlines_group,
- 'funcs': [psd.apply_mask_to_layer_fx]
- }
-
- @cached_property
- def pinlines_vector_mask(self) -> Optional[dict]:
- """Enable the pinlines vector mask if card is Legendary. """
+ if self.pinlines_group and (
+ layer := psd.getLayer(self.frame_type, [self.mask_group, LAYERS.PINLINES])
+ ):
+ return {
+ "mask": layer,
+ "layer": self.pinlines_group,
+ "funcs": [psd.apply_mask_to_layer_fx],
+ }
+
+ @cached_property
+ def pinlines_vector_mask(
+ self,
+ ) -> (
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ):
+ """Enable the pinlines vector mask if card is Legendary."""
if not self.is_legendary:
return
- return {
- 'mask': self.pinlines_group,
- 'vector': True
- }
-
- @cached_property
- def crown_mask(self) -> Optional[dict]:
+ if self.pinlines_group:
+ return {"mask": self.pinlines_group, "vector": True}
+
+ @cached_property
+ def crown_mask(
+ self,
+ ) -> (
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ):
"""Copy the pinlines mask to Legendary crown if card is Legendary."""
if not self.is_legendary:
return
- return {
- 'mask': self.pinlines_mask['mask'],
- 'layer': self.crown_group.parent,
- 'funcs': [psd.apply_mask_to_layer_fx]}
-
- @cached_property
- def enabled_masks(self) -> list[Union[dict, list, ArtLayer, LayerSet, None]]:
+ if (
+ (layer := psd.getLayer(self.frame_type, [self.mask_group, LAYERS.PINLINES]))
+ and self.crown_group
+ and isinstance((parent := self.crown_group.parent), LayerSet)
+ ):
+ return {
+ "mask": layer,
+ "layer": parent,
+ "funcs": [psd.apply_mask_to_layer_fx],
+ }
+
+ @cached_property
+ def enabled_masks(
+ self,
+ ) -> list[
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ]:
"""Masks that should be copied or enabled."""
return [
self.crown_mask,
self.border_mask,
self.pinlines_mask,
- self.pinlines_vector_mask]
+ self.pinlines_vector_mask,
+ ]
"""
* Effects
"""
@cached_property
- def nickname_fx(self) -> ArtLayer:
+ def nickname_fx(self) -> ArtLayer | None:
"""ArtLayer: Layer containing nickname effects."""
return psd.getLayer(LAYERS.NICKNAME, LAYERS.EFFECTS)
@@ -1696,17 +2094,16 @@ def nickname_fx(self) -> ArtLayer:
def basic_watermark_fx(self) -> list[LayerEffects]:
"""Defines the layer effects used on the Basic Land Watermark."""
return [
- EffectColorOverlay(
- color=self.basic_watermark_color,
- opacity=100),
+ EffectColorOverlay(color=self.basic_watermark_color, opacity=100),
EffectBevel(
- size=self.watermark_bevel_map.get(self.size),
+ size=self.watermark_bevel_map.get(self.size, 0),
highlight_opacity=70,
shadow_opacity=72,
softness=14,
rotation=45,
altitude=22,
- depth=100)
+ depth=100,
+ ),
]
"""Maps watermark bevel size to textbox size."""
@@ -1714,7 +2111,7 @@ def basic_watermark_fx(self) -> list[LayerEffects]:
BorderlessTextbox.Short: 20,
BorderlessTextbox.Medium: 22,
BorderlessTextbox.Normal: 25,
- BorderlessTextbox.Tall: 28
+ BorderlessTextbox.Tall: 28,
}
"""
@@ -1726,82 +2123,89 @@ def enable_frame_layers(self) -> None:
super().enable_frame_layers()
# Color the nickname plate if enabled in settings
- if self.is_nickname and self.is_colored_nickname:
- self.generate_layer(
- group=self.nickname_group,
- colors=self.twins_colors)
+ if self.is_nickname and self.is_colored_nickname and self.nickname_group:
+ self.generate_layer(group=self.nickname_group, colors=self.twins_colors)
def enable_crown(self) -> None:
"""Allow modifying crown texture based on setting."""
# Enable Legendary Crown group and layers
- self.generate_layer(
- group=self.crown_group,
- colors=self.crown_colors,
- masks=self.crown_masks)
+ if self.crown_group:
+ self.generate_layer(
+ group=self.crown_group, colors=self.crown_colors, masks=self.crown_masks
+ )
# Enable Hollow Crown
if self.is_hollow_crown:
- self.enable_hollow_crown(
- masks=[self.crown_group],
- vector_masks=[self.pinlines_group])
+ self.enable_hollow_crown()
# Remove crown textures if disabled
- if not self.crown_texture_enabled:
+ if not self.crown_texture_enabled and self.crown_group:
+ parent = self.crown_group.parent
for n in ["Shading", "Highlight", "Overlay"]:
- psd.getLayer(n, self.crown_group.parent).visible = False
+ if layer := psd.getLayer(n, parent):
+ layer.visible = False
"""
* Text Layer Methods
"""
def basic_text_layers(self) -> None:
-
# Establish whether this is a textless creature render with no symbol
- self.text.extend([
- FormattedTextField(
- layer=self.text_layer_mana,
- contents=self.layout.mana_cost
- ),
- ScaledTextField(
- layer=self.text_layer_type,
- # Add Power/Toughness if rendering textless creature WITH symbol
- contents=f"{self.layout.type_line} β {self.layout.power}/{self.layout.toughness}" if
- self.is_creature and not self.is_pt_enabled else self.layout.type_line,
- # Use PT box as right reference if rendering textless creature WITHOUT symbol
- reference=psd.getLayer(LAYERS.PT_BOX, [self.pt_group, LAYERS.SHAPE])
- if self.is_textless and self.is_pt_enabled else self.type_reference
+ if self.text_layer_mana:
+ self.text.append(
+ FormattedTextField(
+ layer=self.text_layer_mana, contents=self.layout.mana_cost
+ )
+ )
+ if self.text_layer_type:
+ self.text.append(
+ ScaledTextField(
+ layer=self.text_layer_type,
+ # Add Power/Toughness if rendering textless creature WITH symbol
+ contents=f"{self.layout.type_line} β {self.layout.power}/{self.layout.toughness}"
+ if self.is_creature and not self.is_pt_enabled
+ else self.layout.type_line,
+ # Use PT box as right reference if rendering textless creature WITHOUT symbol
+ reference=psd.getLayer(LAYERS.PT_BOX, [self.pt_group, LAYERS.SHAPE])
+ if self.is_textless and self.is_pt_enabled
+ else self.type_reference,
+ )
)
- ])
# Prompt for and add nickname if required
_card_name = str(self.layout.name)
- if self.is_nickname:
+ if self.is_nickname and self.text_layer_nickname:
self.prompt_nickname_text()
self.text.append(
ScaledWidthTextField(
layer=self.text_layer_nickname,
contents=self.layout.name,
- reference=self.nickname_shape))
+ reference=self.nickname_shape,
+ )
+ )
_card_name = self.layout.nickname
# Add regular card name
- self.text.append(
- ScaledTextField(
- layer=self.text_layer_name,
- contents=_card_name,
- reference=self.name_reference
- ))
+ if self.text_layer_name:
+ self.text.append(
+ ScaledTextField(
+ layer=self.text_layer_name,
+ contents=_card_name,
+ reference=self.name_reference,
+ )
+ )
def rules_text_and_pt_layers(self) -> None:
"""Skip this step for 'Textless' renders."""
if self.is_textless:
- if self.is_pt_enabled:
+ if self.is_pt_enabled and self.text_layer_pt:
self.text.append(
TextField(
layer=self.text_layer_pt,
- contents=f'{self.layout.power}/'
- f'{self.layout.toughness}'))
+ contents=f"{self.layout.power}/{self.layout.toughness}",
+ )
+ )
return
super().rules_text_and_pt_layers()
@@ -1813,16 +2217,17 @@ def textless_adjustments(self) -> None:
"""Actions taken if this is a 'Textless' render."""
# Make positioning adjustments
- if self.is_type_shifted:
+ if self.is_type_shifted and self.indicator_group:
self.indicator_group.translate(-10, 0)
- self.text_layer_type.translate(-10, 0)
+ if self.text_layer_type:
+ self.text_layer_type.translate(-10, 0)
# Align and add PT text for creatures with no expansion symbol
if self.is_pt_enabled:
psd.align_all(
layer=self.text_layer_pt,
- ref=psd.getLayer(LAYERS.PT_BOX, [
- self.pt_group, LAYERS.SHAPE]))
+ ref=psd.getLayer(LAYERS.PT_BOX, [self.pt_group, LAYERS.SHAPE]),
+ )
return
# Otherwise just shift the symbol over
@@ -1833,39 +2238,47 @@ def token_adjustments(self) -> None:
"""Actions taken if this is a 'Token' card."""
# Change name font and center it
- self.text_layer_name.textItem.font = CON.font_artist
- psd.align_horizontal(self.text_layer_name, self.twins_shape[0])
+ if self.text_layer_name:
+ self.text_layer_name.textItem.font = CON.font_artist
+ psd.align_horizontal(self.text_layer_name, self.twins_shapes[0])
# Add name plate shadow
- psd.getLayer(LAYERS.TOKEN, self.text_group).visible = True
+ if layer := psd.getLayer(LAYERS.TOKEN, self.text_group):
+ layer.visible = True
def textbox_positioning(self) -> None:
"""Reposition various elements when textbox size isn't 'Tall' (the default)."""
# Get the delta between the highest box and the target box
- shape = psd.getLayer(LAYERS.TALL, [
- self.pinlines_group,
- LAYERS.SHAPE,
- LAYERS.TYPE_LINE])
- dims_ref = psd.get_layer_dimensions(shape)
- dims_obj = psd.get_layer_dimensions(self.pinlines_shape[1])
- delta = dims_obj[Dimensions.CenterY] - dims_ref[Dimensions.CenterY]
- self.text_layer_type.translate(0, delta)
-
- # Shift expansion symbol
- if CFG.symbol_enabled and self.expansion_symbol_layer:
- self.expansion_symbol_layer.translate(0, delta)
-
- # Shift indicator
- if self.is_type_shifted:
- self.indicator_group.parent.translate(0, delta)
+ shape = psd.getLayer(
+ LAYERS.TALL, [self.pinlines_group, LAYERS.SHAPE, LAYERS.TYPE_LINE]
+ )
+ if shape:
+ dims_ref = psd.get_layer_dimensions(shape)
+ if layer := self.pinlines_shapes[1]:
+ dims_obj = psd.get_layer_dimensions(layer)
+ delta = dims_obj["center_y"] - dims_ref["center_y"]
+
+ if self.text_layer_type:
+ self.text_layer_type.translate(0, delta)
+
+ # Shift expansion symbol
+ if self.config.symbol_enabled and self.expansion_symbol_layer:
+ self.expansion_symbol_layer.translate(0, delta)
+
+ # Shift indicator
+ if (
+ self.is_type_shifted
+ and self.indicator_group
+ and isinstance((parent := self.indicator_group.parent), LayerSet)
+ ):
+ parent.translate(0, delta)
def text_layer_fx(self) -> None:
"""Handles all specialized text adjustments for a variety of render settings."""
# Add drop shadow if enabled and allowed
if self.is_drop_shadow:
-
# Name and Typeline
psd.enable_layer_fx(self.text_layer_name)
psd.enable_layer_fx(self.text_layer_type)
@@ -1879,7 +2292,9 @@ def text_layer_fx(self) -> None:
psd.enable_layer_fx(self.text_layer_flipside_pt)
# Allow exception for PT drop shadow on front face Vehicle cards
- if (self.is_drop_shadow or (self.drop_shadow_enabled and self.is_vehicle)) and self.is_pt_enabled:
+ if (
+ self.is_drop_shadow or (self.drop_shadow_enabled and self.is_vehicle)
+ ) and self.is_pt_enabled:
psd.enable_layer_fx(self.text_layer_pt)
"""
@@ -1891,8 +2306,13 @@ def format_nickname_text(self) -> None:
super().format_nickname_text()
# Copy effects to legendary crown
- if self.is_legendary:
- psd.copy_layer_fx(self.nickname_fx, self.crown_group.parent)
+ if (
+ self.is_legendary
+ and self.nickname_fx
+ and self.crown_group
+ and isinstance((parent := self.crown_group.parent), LayerSet)
+ ):
+ psd.copy_layer_fx(self.nickname_fx, parent)
if self.is_drop_shadow:
psd.enable_layer_fx(self.text_layer_nickname)
@@ -1909,7 +2329,11 @@ def text_layers_transform_front(self) -> None:
self.swap_font_color()
# Switch flipside PT to light gray
- if not self.is_authentic_front and self.is_flipside_creature:
+ if (
+ not self.is_authentic_front
+ and self.is_flipside_creature
+ and self.text_layer_flipside_pt
+ ):
self.text_layer_flipside_pt.textItem.color = psd.get_rgb(*[186, 186, 186])
def text_layers_transform_back(self):
@@ -1929,7 +2353,7 @@ def text_layers_mdfc_front(self) -> None:
* Util Methods
"""
- def swap_font_color(self, color: SolidColor = None) -> None:
+ def swap_font_color(self, color: SolidColor | None = None) -> None:
"""Switch the font color of each key text layer.
Args:
@@ -1940,15 +2364,17 @@ def swap_font_color(self, color: SolidColor = None) -> None:
color = color or self.RGB_BLACK
# Name and Typeline
- self.text_layer_name.textItem.color = color
- self.text_layer_type.textItem.color = color
+ if self.text_layer_name:
+ self.text_layer_name.textItem.color = color
+ if self.text_layer_type:
+ self.text_layer_type.textItem.color = color
# Rules text if not textless
- if not self.is_textless:
+ if not self.is_textless and self.text_layer_rules:
self.text_layer_rules.textItem.color = color
# PT if card is a non-vehicle creature
- if self.is_pt_enabled and not self.is_vehicle:
+ if self.is_pt_enabled and not self.is_vehicle and self.text_layer_pt:
self.text_layer_pt.textItem.color = color
@@ -1956,23 +2382,28 @@ class ClassicModernTemplate(VectorTransformMod, VectorMDFCMod, VectorTemplate):
"""A modern frame version of iDerp's 'Classic Remastered' template."""
# Static properties
- is_vehicle: bool = False
- frame_suffix: str = 'Extended'
- template_suffix: str = 'Classic Modern'
+ frame_suffix: str = "Extended"
+ template_suffix: str = "Classic Modern"
+
+ @cached_property
+ def is_vehicle(self) -> bool:
+ return False
# Color Maps
- pinlines_color_map = {**pinlines_color_map.copy(), "Land": "#604a33"}
+ @cached_property
+ def pinlines_color_map(self) -> dict[str, ColorObject]:
+ return {**pinlines_color_map.copy(), "Land": "#604a33"}
"""
* Bool
"""
- @property
+ @cached_property
def is_extended(self) -> bool:
"""TODO: Based on whether the extended setting is enabled."""
return True
- @property
+ @cached_property
def is_content_aware_enabled(self) -> bool:
"""Force enabled if 'is_extended' setting is enabled."""
return True if self.is_extended else super().is_content_aware_enabled
@@ -1982,22 +2413,32 @@ def is_content_aware_enabled(self) -> bool:
"""
@cached_property
- def crown_mode(self) -> str:
+ def crown_mode(self) -> ModernClassicCrown:
"""Whether to use pinlines when generating the Legendary Crown."""
- return CFG.get_option("FRAME", "Crown.Mode", ModernClassicCrown)
+ return self.config.get_option(
+ "FRAME",
+ "Crown.Mode",
+ ModernClassicCrown,
+ default=ModernClassicCrown.Pinlines,
+ )
"""
* References
"""
@cached_property
- def textbox_reference(self) -> Optional[ArtLayer]:
+ def textbox_reference(self) -> ReferenceLayer | None:
"""Use a mask to reduce reference size for MDFC cards."""
- ref = getattr(super(), 'textbox_reference')
- if self.is_mdfc:
+ ref = getattr(super(), "textbox_reference")
+ if self.is_mdfc and (
+ layer := psd.getLayer(
+ LAYERS.MDFC, [self.mask_group, LAYERS.TEXTBOX_REFERENCE]
+ )
+ ):
psd.copy_layer_mask(
- layer_from=psd.getLayer(LAYERS.MDFC, [self.mask_group, LAYERS.TEXTBOX_REFERENCE]),
- layer_to=ref)
+ layer_from=layer,
+ layer_to=ref,
+ )
psd.apply_mask(ref)
ref.visible = False
return ref
@@ -2013,12 +2454,12 @@ def textbox_colors(self) -> list[str]:
# 2-3 color lands
if 1 < len(self.identity) < self.color_limit and self.is_land:
# Dual or tri colors
- return [f'{n} {LAYERS.LAND}' for n in self.identity]
+ return [f"{n} {LAYERS.LAND}" for n in self.identity]
# Plain land background
if self.pinlines == LAYERS.LAND:
return [LAYERS.LAND]
# All other land backgrounds
- return [f'{self.pinlines} {LAYERS.LAND}']
+ return [f"{self.pinlines} {LAYERS.LAND}"]
# Hybrid cards
if self.is_hybrid:
return list(self.pinlines)
@@ -2030,102 +2471,189 @@ def crown_colors(self) -> str:
"""Support multicolor based on color limit."""
if self.crown_mode == ModernClassicCrown.TextureBackground:
return self.background
- return self.identity if 1 < len(self.identity) < self.color_limit else self.pinlines
+ return (
+ self.identity
+ if 1 < len(self.identity) < self.color_limit
+ else self.pinlines
+ )
"""
* Shapes
"""
@cached_property
- def crown_shape(self) -> Union[LayerObjectTypes, list[LayerObjectTypes], None]:
+ def crown_shape(self) -> ArtLayer | None:
# Support Normal and Extended
- return psd.getLayer(
- LAYERS.EXTENDED if self.is_extended else LAYERS.NORMAL,
- [LAYERS.LEGENDARY_CROWN, LAYERS.SHAPE])
+ if self.is_legendary:
+ return psd.getLayer(
+ LAYERS.EXTENDED if self.is_extended else LAYERS.NORMAL,
+ [LAYERS.LEGENDARY_CROWN, LAYERS.SHAPE],
+ )
@cached_property
- def border_shape(self) -> Union[LayerObjectTypes, list[LayerObjectTypes], None]:
+ def border_shape(self) -> ArtLayer | None:
# Support Normal and Legendary
return psd.getLayer(
- LAYERS.LEGENDARY if self.is_legendary else LAYERS.NORMAL,
- LAYERS.BORDER)
+ LAYERS.LEGENDARY if self.is_legendary else LAYERS.NORMAL, LAYERS.BORDER
+ )
@cached_property
- def pinlines_shape(self) -> Union[LayerObjectTypes, list[LayerObjectTypes], None]:
+ def pinlines_shapes(self) -> list[ArtLayer | LayerSet | None]:
# Add transform cutout textbox
- masks = [
- psd.getLayer(
- LAYERS.TRANSFORM_FRONT,
- [self.pinlines_group, LAYERS.SHAPE, LAYERS.TEXTBOX]
- )
- ] if self.is_transform and self.is_front else []
+ masks = (
+ [
+ psd.getLayer(
+ LAYERS.TRANSFORM_FRONT,
+ [self.pinlines_group, LAYERS.SHAPE, LAYERS.TEXTBOX],
+ )
+ ]
+ if self.is_transform and self.is_front
+ else []
+ )
# Add Twins shape, supports Normal and Transform
return [
*masks,
psd.getLayerSet(
- LAYERS.TRANSFORM if self.is_transform else (
- LAYERS.MDFC if self.is_mdfc else LAYERS.NORMAL),
- [self.pinlines_group, LAYERS.SHAPE, LAYERS.NAME]),
+ LAYERS.TRANSFORM
+ if self.is_transform
+ else (LAYERS.MDFC if self.is_mdfc else LAYERS.NORMAL),
+ [self.pinlines_group, LAYERS.SHAPE, LAYERS.NAME],
+ ),
]
@cached_property
- def textbox_shape(self) -> Union[LayerObjectTypes, list[LayerObjectTypes], None]:
- return [
- psd.getLayer(LAYERS.TRANSFORM_FRONT, [self.textbox_group, LAYERS.SHAPE])
- ] if self.is_transform and self.is_front else []
+ def textbox_shape(self) -> ArtLayer | None:
+ if self.is_transform and self.is_front:
+ return psd.getLayer(
+ LAYERS.TRANSFORM_FRONT, [self.textbox_group, LAYERS.SHAPE]
+ )
+
+ @cached_property
+ def enabled_shapes(self) -> list[ArtLayer | LayerSet | None]:
+ return [*super().enabled_shapes, *self.pinlines_shapes]
"""
* Masks
"""
@cached_property
- def twins_mask(self) -> list[dict]:
- return [{
- 'mask': self.twins_group,
- 'vector': True
- }] if self.is_extended else []
-
- @cached_property
- def border_mask(self) -> list[Union[ArtLayer, LayerSet]]:
- return [
- psd.getLayer(
- LAYERS.MDFC if self.is_mdfc and not self.is_legendary else LAYERS.NORMAL,
- [self.mask_group, LAYERS.BORDER, LAYERS.EXTENDED if self.is_extended else LAYERS.NORMAL]
- ), self.border_group
- ]
-
- @cached_property
- def background_mask(self) -> list[Union[ArtLayer, LayerSet]]:
- return [
- psd.getLayer(
+ def twins_mask(
+ self,
+ ) -> (
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ):
+ if self.is_extended and self.twins_group:
+ return {"mask": self.twins_group, "vector": True}
+
+ @cached_property
+ def border_mask(
+ self,
+ ) -> (
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ):
+ if self.border_group and (
+ layer := psd.getLayer(
+ LAYERS.MDFC
+ if self.is_mdfc and not self.is_legendary
+ else LAYERS.NORMAL,
+ [
+ self.mask_group,
+ LAYERS.BORDER,
+ LAYERS.EXTENDED if self.is_extended else LAYERS.NORMAL,
+ ],
+ )
+ ):
+ return (layer, self.border_group)
+
+ @cached_property
+ def background_mask(
+ self,
+ ) -> (
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ):
+ if self.background_group and (
+ layer := psd.getLayer(
LAYERS.EXTENDED if self.is_extended else LAYERS.NORMAL,
- [self.mask_group, LAYERS.BACKGROUND]
- ), self.background_group
- ]
+ [self.mask_group, LAYERS.BACKGROUND],
+ )
+ ):
+ return (
+ layer,
+ self.background_group,
+ )
@cached_property
- def pinlines_mask(self) -> list[Union[list, dict]]:
+ def pinlines_mask(
+ self,
+ ) -> list[
+ (
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ )
+ ]:
# Mask covering top on Legendary cards
- masks: list[Union[list, dict]] = [{
- 'mask': self.pinlines_group,
- 'vector': True
- }] if self.is_legendary else []
- # Fade mask and covering layer effects
- return [*masks, {
- 'mask': psd.getLayer(
- LAYERS.TRANSFORM_FRONT if self.is_transform and self.is_front else LAYERS.NORMAL,
- [self.mask_group, LAYERS.PINLINES, LAYERS.EXTENDED if self.is_extended else LAYERS.NORMAL]),
- 'layer': self.pinlines_group,
- 'funcs': [psd.apply_mask_to_layer_fx]
- }]
-
- @cached_property
- def enabled_masks(self) -> list[Union[list, dict]]:
+ masks: list[
+ (
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ )
+ ] = []
+ if self.pinlines_group:
+ if self.is_legendary:
+ masks.append({"mask": self.pinlines_group, "vector": True})
+ # Fade mask and covering layer effects
+ if layer := psd.getLayer(
+ LAYERS.TRANSFORM_FRONT
+ if self.is_transform and self.is_front
+ else LAYERS.NORMAL,
+ [
+ self.mask_group,
+ LAYERS.PINLINES,
+ LAYERS.EXTENDED if self.is_extended else LAYERS.NORMAL,
+ ],
+ ):
+ masks.append(
+ {
+ "mask": layer,
+ "layer": self.pinlines_group,
+ "funcs": [psd.apply_mask_to_layer_fx],
+ }
+ )
+ return masks
+
+ @cached_property
+ def enabled_masks(
+ self,
+ ) -> list[
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ]:
return [
self.border_mask,
self.background_mask,
*self.pinlines_mask,
- *self.twins_mask
+ self.twins_mask,
]
"""
@@ -2134,30 +2662,27 @@ def enabled_masks(self) -> list[Union[list, dict]]:
def enable_crown(self) -> None:
"""Enable the Legendary crown, only called if card is Legendary."""
+ if self.crown_group:
+ # Generate Legendary Crown using pinline colors
+ if self.crown_mode == ModernClassicCrown.Pinlines:
+ self.generate_layer(group=self.crown_group, colors=self.pinlines_colors)
+ return
- # Generate Legendary Crown using pinline colors
- if self.crown_mode == ModernClassicCrown.Pinlines:
+ # Generate Legendary Crown using textures
self.generate_layer(
- group=self.crown_group,
- colors=self.pinlines_colors)
- return
-
- # Generate Legendary Crown using textures
- self.generate_layer(
- group=self.crown_group,
- colors=self.crown_colors,
- masks=self.crown_masks)
+ group=self.crown_group, colors=self.crown_colors, masks=self.crown_masks
+ )
"""
* Transform Methods
"""
def enable_transform_layers_back(self) -> None:
-
# Use darker Twins and PT texture
- if self.is_creature:
- psd.getLayer(LAYERS.LIGHTEN, self.pt_group).visible = False
- psd.getLayer(LAYERS.LIGHTEN, self.twins_group).visible = False
+ if self.is_creature and (layer := psd.getLayer(LAYERS.LIGHTEN, self.pt_group)):
+ layer.visible = False
+ if layer := psd.getLayer(LAYERS.LIGHTEN, self.twins_group):
+ layer.visible = False
super().enable_transform_layers_back()
"""
@@ -2165,13 +2690,18 @@ def enable_transform_layers_back(self) -> None:
"""
def enable_mdfc_layers_back(self) -> None:
-
# Use darker Twins and PT texture and white font color
if self.is_creature:
- psd.getLayer(LAYERS.LIGHTEN, self.pt_group).opacity = 10
- self.text_layer_pt.textItem.color = self.RGB_WHITE
- psd.enable_layer_fx(self.text_layer_pt)
- psd.getLayer(LAYERS.LIGHTEN, self.twins_group).opacity = 10
- self.text_layer_name.textItem.color = self.RGB_WHITE
- psd.enable_layer_fx(self.text_layer_name)
+ if layer := psd.getLayer(LAYERS.LIGHTEN, self.pt_group):
+ layer.opacity = 10
+ if self.text_layer_pt:
+ self.text_layer_pt.textItem.color = self.RGB_WHITE
+ psd.enable_layer_fx(self.text_layer_pt)
+
+ if layer := psd.getLayer(LAYERS.LIGHTEN, self.twins_group):
+ layer.opacity = 10
+ if self.text_layer_name:
+ self.text_layer_name.textItem.color = self.RGB_WHITE
+ psd.enable_layer_fx(self.text_layer_name)
+
super().enable_transform_layers_back()
diff --git a/src/templates/planar.py b/src/templates/planar.py
index 122ff9d6..fc44338b 100644
--- a/src/templates/planar.py
+++ b/src/templates/planar.py
@@ -1,20 +1,16 @@
"""
* PLANAR TEMPLATES
"""
-# Standard Library Imports
+
from functools import cached_property
-from typing import Optional
-# Third Party Imports
-from photoshop.api.application import ArtLayer
+from photoshop.api._artlayer import ArtLayer
-# Local Imports
-from src import CFG
-from src.templates._core import StarterTemplate
+import src.helpers as psd
import src.text_layers as text_classes
from src.enums.layers import LAYERS
-from src.layouts import CardLayout
-import src.helpers as psd
+from src.layouts import NormalLayout
+from src.templates._core import StarterTemplate
"""
* Template Classes
@@ -28,63 +24,72 @@ class PlanarTemplate(StarterTemplate):
Needs a complete rework and a 'Modifier' class.
"""
- def __init__(self, layout: CardLayout, **kwargs):
- CFG.exit_early = True
- super().__init__(layout, **kwargs)
+ def __init__(self, layout: NormalLayout):
+ super().__init__(layout)
+ self.config.exit_early = True
@cached_property
- def text_layer_static_ability(self) -> ArtLayer:
+ def text_layer_static_ability(self) -> ArtLayer | None:
return psd.getLayer(LAYERS.STATIC_ABILITY, self.text_group)
@cached_property
- def text_layer_chaos_ability(self) -> ArtLayer:
+ def text_layer_chaos_ability(self) -> ArtLayer | None:
return psd.getLayer(LAYERS.CHAOS_ABILITY, self.text_group)
def basic_text_layers(self):
"""No mana cost, don't scale name layer."""
- self.text.extend([
- text_classes.TextField(
- layer=self.text_layer_name,
- contents=self.layout.name
- ),
- text_classes.ScaledTextField(
- layer=self.text_layer_type,
- contents=self.layout.type_line,
- reference=self.type_reference
+ if self.text_layer_name:
+ self.text.append(
+ text_classes.TextField(
+ layer=self.text_layer_name, contents=self.layout.name
+ )
+ )
+ if self.text_layer_type:
+ self.text.append(
+ text_classes.ScaledTextField(
+ layer=self.text_layer_type,
+ contents=self.layout.type_line,
+ reference=self.type_reference,
+ )
)
- ])
def rules_text_and_pt_layers(self):
-
# Phenomenon card?
if self.layout.type_line == LAYERS.PHENOMENON:
-
# Insert oracle text into static ability layer and disable chaos ability & layer mask on textbox
- self.text.append(
- text_classes.FormattedTextField(
- layer=self.text_layer_static_ability,
- contents=self.layout.oracle_text
+ if self.text_layer_static_ability:
+ self.text.append(
+ text_classes.FormattedTextField(
+ layer=self.text_layer_static_ability,
+ contents=self.layout.oracle_text,
+ )
)
- )
psd.enable_mask(psd.getLayerSet(LAYERS.TEXTBOX))
- psd.getLayer(LAYERS.CHAOS_SYMBOL, self.text_group).visible = False
- self.text_layer_chaos_ability.visible = False
+ if layer := psd.getLayer(LAYERS.CHAOS_SYMBOL, self.text_group):
+ layer.visible = False
+ if self.text_layer_chaos_ability:
+ self.text_layer_chaos_ability.visible = False
else:
-
# Split oracle text on last line break, insert everything before into static, the rest into chaos
linebreak_index = self.layout.oracle_text.rindex("\n")
- self.text.extend([
- text_classes.FormattedTextField(
- layer=self.text_layer_static_ability,
- contents=self.layout.oracle_text[0:linebreak_index]
- ),
- text_classes.FormattedTextField(
- layer=self.text_layer_chaos_ability,
- contents=self.layout.oracle_text[linebreak_index + 1:]
- ),
- ])
+ if self.text_layer_static_ability:
+ self.text.append(
+ text_classes.FormattedTextField(
+ layer=self.text_layer_static_ability,
+ contents=self.layout.oracle_text[0:linebreak_index],
+ )
+ )
+ if self.text_layer_chaos_ability:
+ self.text.append(
+ text_classes.FormattedTextField(
+ layer=self.text_layer_chaos_ability,
+ contents=self.layout.oracle_text[linebreak_index + 1 :],
+ )
+ )
- def paste_scryfall_scan(self, rotate: bool = False, visible: bool = False) -> Optional[ArtLayer]:
+ def paste_scryfall_scan(
+ self, rotate: bool = False, visible: bool = False
+ ) -> ArtLayer | None:
"""Ensure we rotate the scan for Planar cards."""
return super().paste_scryfall_scan(rotate=True, visible=visible)
diff --git a/src/templates/planeswalker.py b/src/templates/planeswalker.py
index a5c6c204..97d8107a 100644
--- a/src/templates/planeswalker.py
+++ b/src/templates/planeswalker.py
@@ -1,33 +1,35 @@
"""
* PLANESWALKER TEMPLATES
"""
-# Standard Library Imports
+
+from collections.abc import Callable, Sequence
from functools import cached_property
-from typing import Optional, Callable
+from logging import getLogger
-# Third Party Imports
-from photoshop.api import ElementPlacement, ColorBlendMode
-from photoshop.api.application import ArtLayer
+from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
+from photoshop.api.enumerations import ColorBlendMode, ElementPlacement
-# Local Imports
-from src.enums.layers import LAYERS
import src.helpers as psd
+import src.text_layers as text_classes
+from src.enums.layers import LAYERS
from src.helpers import scale_text_layers_to_height
-from src.layouts import PlaneswalkerLayouts
+from src.helpers.text import clear_reference_vertical_multi
+from src.layouts import NormalLayout, PlaneswalkerAbility, PlaneswalkerLayout
from src.templates._core import StarterTemplate
from src.templates._cosmetic import BorderlessMod, FullartMod
from src.templates.mdfc import MDFCMod
from src.templates.transform import TransformMod
-import src.text_layers as text_classes
from src.utils.adobe import ReferenceLayer
+_logger = getLogger(__name__)
+
"""
* Template Classes
"""
-class PlaneswalkerMod (FullartMod, StarterTemplate):
+class PlaneswalkerMod(FullartMod, StarterTemplate):
"""A modifier class which adds methods required for Planeswalker type cards introduced in Lorwyn block.
Adds:
@@ -35,28 +37,29 @@ class PlaneswalkerMod (FullartMod, StarterTemplate):
* Planeswalker text layer positioning.
* Planeswalker ability mask generation.
"""
- frame_suffix = 'Normal'
- def __init__(self, layout: PlaneswalkerLayouts, **kwargs):
- super().__init__(layout, **kwargs)
+ frame_suffix = "Normal"
+
+ def __init__(self, layout: NormalLayout):
+ super().__init__(layout)
# Settable Properties
- self._ability_layers = []
- self._icons = []
- self._colons = []
+ self._ability_layers: list[ArtLayer] = []
+ self._icons: list[ArtLayer | LayerSet | None] = []
+ self._colons: list[ArtLayer | LayerSet | None] = []
@cached_property
- def text_layer_methods(self) -> list[Callable]:
+ def text_layer_methods(self) -> list[Callable[[], None]]:
"""Add and position Planeswalker text layers, add ability mask."""
return [*super().text_layer_methods, self.pw_text_layers]
@cached_property
- def post_text_methods(self) -> list[Callable]:
+ def post_text_methods(self) -> list[Callable[[], None]]:
"""Add Planeswalker layer positioning and ability mask step."""
return [
*super().post_text_methods,
self.pw_layer_positioning,
- self.pw_ability_mask
+ self.pw_ability_mask,
]
"""
@@ -64,20 +67,30 @@ def post_text_methods(self) -> list[Callable]:
"""
@cached_property
- def art_frame_vertical(self):
+ def art_frame_vertical(self) -> str:
"""Use special Borderless frame for 'Colorless' cards."""
if self.is_colorless:
return LAYERS.BORDERLESS_FRAME
return LAYERS.FULL_ART_FRAME
+ @cached_property
+ def ability_text_spacing(self) -> float | int:
+ return 64
+
+ @cached_property
+ def ability_text_scaling_step_sizes(self) -> Sequence[float] | None:
+ return None
+
"""
* Planeswalker Details
"""
@cached_property
- def abilities(self) -> list[dict]:
+ def abilities(self) -> list[PlaneswalkerAbility]:
"""List of Planeswalker abilities data."""
- return self.layout.pw_abilities
+ if isinstance(self.layout, PlaneswalkerLayout):
+ return self.layout.pw_abilities
+ return []
@cached_property
def fill_color(self):
@@ -93,23 +106,23 @@ def ability_layers(self) -> list[ArtLayer]:
return self._ability_layers
@ability_layers.setter
- def ability_layers(self, value):
+ def ability_layers(self, value: list[ArtLayer]):
self._ability_layers = value
@property
- def colons(self) -> list:
+ def colons(self) -> list[ArtLayer | LayerSet | None]:
return self._colons
@colons.setter
- def colons(self, value):
+ def colons(self, value: list[ArtLayer | LayerSet | None]):
self._colons = value
@property
- def icons(self) -> list:
+ def icons(self) -> list[ArtLayer | LayerSet | None]:
return self._icons
@icons.setter
- def icons(self, value):
+ def icons(self, value: list[ArtLayer | LayerSet | None]):
self._icons = value
"""
@@ -117,75 +130,88 @@ def icons(self, value):
"""
@cached_property
- def group(self) -> LayerSet:
+ def planeswalker_group(self) -> LayerSet | None:
"""The main Planeswalker layer group, sized according to number of abilities."""
- if group := psd.getLayerSet(f"pw-{str(self.layout.pw_size)}"):
+ if isinstance(self.layout, PlaneswalkerLayout) and (
+ group := psd.getLayerSet(f"pw-{str(self.layout.pw_size)}")
+ ):
group.visible = True
return group
@cached_property
- def loyalty_group(self) -> LayerSet:
+ def loyalty_group(self) -> LayerSet | None:
"""Group containing Planeswalker loyalty graphics."""
return psd.getLayerSet(LAYERS.LOYALTY_GRAPHICS)
@cached_property
- def border_group(self) -> Optional[LayerSet]:
+ def border_group(self) -> LayerSet | None:
"""Border group, nested in the appropriate Planeswalker group."""
- return psd.getLayerSet(LAYERS.BORDER, self.group)
+ return psd.getLayerSet(LAYERS.BORDER, self.planeswalker_group)
@cached_property
- def mask_group(self) -> Optional[LayerSet]:
+ def mask_group(self) -> LayerSet | None:
"""Group containing the vector shapes used to create the ragged lines divider mask."""
return psd.getLayerSet(LAYERS.MASKS)
@cached_property
- def textbox_group(self) -> Optional[LayerSet]:
+ def textbox_group(self) -> LayerSet | None:
"""Group to populate with ragged lines divider mask."""
- return psd.getLayerSet("Ragged Lines", [self.group, LAYERS.TEXTBOX, "Ability Dividers"])
+ return psd.getLayerSet(
+ "Ragged Lines",
+ [self.planeswalker_group, LAYERS.TEXTBOX, "Ability Dividers"],
+ )
@cached_property
- def text_group(self) -> LayerSet:
+ def text_group(self) -> LayerSet | None:
"""Text Layer group, nexted in the appropriate Planeswalker group."""
- return psd.getLayerSet(LAYERS.TEXT_AND_ICONS, self.group)
+ return psd.getLayerSet(LAYERS.TEXT_AND_ICONS, self.planeswalker_group)
"""
* Frame Layers
"""
@cached_property
- def twins_layer(self) -> Optional[ArtLayer]:
- return psd.getLayer(self.twins, psd.getLayerSet(LAYERS.TWINS, self.group))
+ def twins_layer(self) -> ArtLayer | None:
+ return psd.getLayer(
+ self.twins, psd.getLayerSet(LAYERS.TWINS, self.planeswalker_group)
+ )
@cached_property
- def pinlines_layer(self) -> Optional[ArtLayer]:
- return psd.getLayer(self.pinlines, psd.getLayerSet(LAYERS.PINLINES, self.group))
+ def pinlines_layer(self) -> ArtLayer | None:
+ return psd.getLayer(
+ self.pinlines, psd.getLayerSet(LAYERS.PINLINES, self.planeswalker_group)
+ )
@cached_property
- def background_layer(self) -> Optional[ArtLayer]:
- return psd.getLayer(self.background, psd.getLayerSet(LAYERS.BACKGROUND, self.group))
+ def background_layer(self) -> ArtLayer | None:
+ return psd.getLayer(
+ self.background, psd.getLayerSet(LAYERS.BACKGROUND, self.planeswalker_group)
+ )
@cached_property
- def color_indicator_layer(self) -> Optional[ArtLayer]:
- return psd.getLayer(self.pinlines, [self.group, LAYERS.COLOR_INDICATOR])
+ def color_indicator_layer(self) -> ArtLayer | None:
+ return psd.getLayer(
+ self.pinlines, [self.planeswalker_group, LAYERS.COLOR_INDICATOR]
+ )
"""
* Text Layers
"""
@cached_property
- def text_layer_loyalty(self) -> ArtLayer:
+ def text_layer_loyalty(self) -> ArtLayer | None:
return psd.getLayer(LAYERS.TEXT, [self.loyalty_group, LAYERS.STARTING_LOYALTY])
@cached_property
- def text_layer_ability(self) -> ArtLayer:
+ def text_layer_ability(self) -> ArtLayer | None:
return psd.getLayer(LAYERS.ABILITY_TEXT, self.loyalty_group)
@cached_property
- def text_layer_static(self) -> ArtLayer:
+ def text_layer_static(self) -> ArtLayer | None:
return psd.getLayer(LAYERS.STATIC_TEXT, self.loyalty_group)
@cached_property
- def text_layer_colon(self) -> ArtLayer:
+ def text_layer_colon(self) -> ArtLayer | None:
return psd.getLayer(LAYERS.COLON, self.loyalty_group)
"""
@@ -193,16 +219,20 @@ def text_layer_colon(self) -> ArtLayer:
"""
@cached_property
- def loyalty_reference(self) -> ReferenceLayer:
+ def loyalty_reference(self) -> ReferenceLayer | None:
"""ArtLayer: Reference used to check ability layer collision with the loyalty box."""
return psd.get_reference_layer(LAYERS.LOYALTY_REFERENCE, self.loyalty_group)
+ @cached_property
+ def textbox_overflow_reference(self) -> ReferenceLayer | None:
+ """Rules text is not allowed to go below the top edge of this reference."""
+ return None
+
"""
* Methods
"""
def enable_frame_layers(self):
-
# Twins
if self.twins_layer:
self.twins_layer.visible = True
@@ -231,55 +261,75 @@ def pw_text_layers(self) -> None:
self.pw_add_ability(ability)
# Starting loyalty
- if self.layout.loyalty:
+ if (
+ isinstance(self.layout, PlaneswalkerLayout)
+ and self.layout.loyalty
+ and self.text_layer_loyalty
+ ):
self.text_layer_loyalty.textItem.contents = self.layout.loyalty
- else:
- self.text_layer_loyalty.parent.visible = False
+ elif self.text_layer_loyalty and isinstance(
+ (parent := self.text_layer_loyalty.parent), LayerSet
+ ):
+ parent.visible = False
def pw_layer_positioning(self) -> None:
"""Position Planeswalker ability layers and icons."""
-
- # Auto-position the ability text, colons, and shields.
- spacing = self.app.scale_by_dpi(64)
- spaces = len(self.ability_layers) + 1
- total_height = self.textbox_reference.dims['height'] - (spacing * spaces)
-
- # Resize text items till they fit in the available space
- font_size = scale_text_layers_to_height(
- text_layers=self.ability_layers,
- ref_height=total_height)
-
- # Space abilities evenly apart
- uniform_gap = True if len(self.ability_layers) < 3 or not self.layout.loyalty else False
- psd.spread_layers_over_reference(
- layers=self.ability_layers,
- ref=self.textbox_reference,
- gap=spacing if not uniform_gap else None)
-
- # Adjust text to avoid loyalty badge
- if self.layout.loyalty and self.loyalty_reference:
- psd.clear_reference_vertical_multi(
+ if isinstance(self.layout, PlaneswalkerLayout) and self.textbox_reference:
+ # Auto-position the ability text, colons, and shields.
+ spacing = self.app.scale_by_dpi(self.ability_text_spacing)
+ spaces = len(self.ability_layers) + 1
+ total_height = self.textbox_reference.dims["height"] - (spacing * spaces)
+
+ # Resize text items till they fit in the available space
+ font_size = scale_text_layers_to_height(
text_layers=self.ability_layers,
+ ref_height=total_height,
+ step_sizes=self.ability_text_scaling_step_sizes,
+ )
+
+ # Space abilities evenly apart
+ uniform_gap = (
+ True
+ if len(self.ability_layers) < 3 or not self.layout.loyalty
+ else False
+ )
+ psd.spread_layers_over_reference(
+ layers=self.ability_layers,
ref=self.textbox_reference,
- loyalty_ref=self.loyalty_reference,
- space=spacing,
- uniform_gap=uniform_gap,
- font_size=font_size,
- docref=self.docref,
- docsel=self.doc_selection)
-
- # Align colons and shields to respective text layers
- for i, ref_layer in enumerate(self.ability_layers):
- # Break if we encounter a length mismatch
- if len(self.icons) < (i + 1) or len(self.colons) < (i + 1):
- self.raise_warning("Encountered bizarre Planeswalker data!")
- break
- # Skip if this is a static ability
- if self.icons[i] and self.colons[i]:
- before = self.colons[i].bounds[1]
- psd.align_vertical(self.colons[i], ref_layer)
- difference = self.colons[i].bounds[1] - before
- self.icons[i].translate(0, difference)
+ gap=spacing if not uniform_gap else 0,
+ )
+
+ # Adjust text to avoid loyalty badge
+ if self.layout.loyalty and self.loyalty_reference:
+ clear_reference_vertical_multi(
+ text_layers=self.ability_layers,
+ ref=self.textbox_reference,
+ loyalty_ref=self.loyalty_reference,
+ space=spacing,
+ uniform_gap=uniform_gap,
+ font_size=font_size,
+ docsel=self.doc_selection,
+ bottom_ref=self.textbox_overflow_reference,
+ )
+
+ # Align colons and shields to respective text layers
+ for i, ref_layer in enumerate(self.ability_layers):
+ # Break if we encounter a length mismatch
+ if len(self.icons) < (i + 1) or len(self.colons) < (i + 1):
+ _logger.warning(
+ f"Planeswalker ability, icon and colon layers don't match. There's {
+ len(self.ability_layers)
+ } ability layers, {len(self.icons)} icon layers and {
+ len(self.colons)
+ } colon layers."
+ )
+ break
+ # Skip if this is a static ability
+ if (icon := self.icons[i]) and (colon := self.colons[i]):
+ before = colon.bounds[1]
+ psd.align_vertical(colon, ref_layer)
+ difference = colon.bounds[1] - before
+ icon.translate(0, difference)
def pw_ability_mask(self) -> None:
"""Position the ragged edge ability mask."""
@@ -288,21 +338,36 @@ def pw_ability_mask(self) -> None:
line_top = psd.getLayer(LAYERS.TOP, self.mask_group)
line_bottom = psd.getLayer(LAYERS.BOTTOM, self.mask_group)
- # Create our line mask pairs
lines: list[list[ArtLayer]] = []
- for i in range(len(self.ability_layers)-1):
- if lines and len(lines[-1]) == 1:
- lines[-1].append(line_bottom.duplicate(self.textbox_group, ElementPlacement.PlaceInside))
- else:
- lines.append([line_top.duplicate(self.textbox_group, ElementPlacement.PlaceInside)])
+ if line_top and line_bottom:
+ # Create our line mask pairs
+ for _i in range(len(self.ability_layers) - 1):
+ if lines and len(lines[-1]) == 1:
+ lines[-1].append(
+ line_bottom.duplicate(
+ self.textbox_group, ElementPlacement.PlaceInside
+ )
+ )
+ else:
+ lines.append(
+ [
+ line_top.duplicate(
+ self.textbox_group, ElementPlacement.PlaceInside
+ )
+ ]
+ )
# Position and fill each pair
n = 0
- for i, group in enumerate(lines):
+ for group in lines:
# Position the top line, bottom if provided, then fill the area between
- self.position_divider([self.ability_layers[n], self.ability_layers[n+1]], group[0])
+ self.position_divider(
+ [self.ability_layers[n], self.ability_layers[n + 1]], group[0]
+ )
if len(group) == 2:
- self.position_divider([self.ability_layers[n+1], self.ability_layers[n+2]], group[1])
+ self.position_divider(
+ [self.ability_layers[n + 1], self.ability_layers[n + 2]], group[1]
+ )
self.fill_between_dividers(group)
# Skip every other ability
n += 2
@@ -311,35 +376,51 @@ def pw_ability_mask(self) -> None:
* Utility Methods
"""
- def pw_add_ability(self, ability: dict) -> None:
+ def pw_add_ability(self, ability: PlaneswalkerAbility) -> None:
"""Add a Planeswalker ability.
Args:
ability: Planeswalker ability data.
"""
# Create an icon and colon if this isn't a static ability
- static = False if ability.get('icon') and ability.get('cost') else True
- icon = None if static else psd.getLayerSet(ability.get('icon', '0'), self.loyalty_group)
- colon = None if static else self.text_layer_colon.duplicate()
+ static = False if ability.get("icon") and ability.get("cost") else True
+ icon = (
+ None
+ if static
+ else psd.getLayerSet(ability.get("icon", "0"), self.loyalty_group)
+ )
+ colon = (
+ None
+ if static
+ else self.text_layer_colon.duplicate()
+ if self.text_layer_colon
+ else None
+ )
# Update ability cost if needed
if not static:
- psd.getLayer(LAYERS.COST, icon).textItem.contents = ability.get('cost', '0')
- icon = icon.duplicate(*[self.icons[-1], ElementPlacement.PlaceBefore]) if (
- self.icons and self.icons[-1]
- ) else icon.duplicate()
+ if layer := psd.getLayer(LAYERS.COST, icon):
+ layer.textItem.contents = ability.get("cost", "0")
+ if icon:
+ icon = (
+ icon.duplicate(self.icons[-1], ElementPlacement.PlaceBefore)
+ if (self.icons and self.icons[-1])
+ else icon.duplicate()
+ )
# Add ability, icons, and colons
self.icons.append(icon)
self.colons.append(colon)
- self.ability_layers.append(
- self.text_layer_static.duplicate() if static
- else self.text_layer_ability.duplicate())
+ if static:
+ if self.text_layer_static:
+ self.ability_layers.append(self.text_layer_static.duplicate())
+ elif self.text_layer_ability:
+ self.ability_layers.append(self.text_layer_ability.duplicate())
self.text.append(
text_classes.FormattedTextField(
- layer=self.ability_layers[-1],
- contents=ability.get('text', '')
- ))
+ layer=self.ability_layers[-1], contents=ability.get("text", "")
+ )
+ )
def fill_between_dividers(self, group: list[ArtLayer]) -> None:
"""Fill area between two ragged lines, or a top line and the bottom of the document.
@@ -348,7 +429,9 @@ def fill_between_dividers(self, group: list[ArtLayer]) -> None:
group: List containing 1 or 2 ragged lines to fill between.
"""
# If no second line is provided use the bottom of the document
- bottom_bound: int = (group[1].bounds[1] if len(group) == 2 else self.docref.height) + 1
+ bottom_bound = (
+ group[1].bounds[1] if len(group) == 2 else self.docref.height
+ ) + 1
top_bound = group[0].bounds
# Create a new layer to fill the selection
@@ -356,12 +439,14 @@ def fill_between_dividers(self, group: list[ArtLayer]) -> None:
self.active_layer.move(group[0], ElementPlacement.PlaceAfter)
# Select between the two points and fill
- self.doc_selection.select([
- [top_bound[0] - 200, top_bound[3] - 1],
- [top_bound[2] + 200, top_bound[3] - 1],
- [top_bound[2] + 200, bottom_bound],
- [top_bound[0] - 200, bottom_bound]
- ])
+ self.doc_selection.select(
+ (
+ (top_bound[0] - 200, top_bound[3] - 1),
+ (top_bound[2] + 200, top_bound[3] - 1),
+ (top_bound[2] + 200, bottom_bound),
+ (top_bound[0] - 200, bottom_bound),
+ )
+ )
self.doc_selection.fill(self.fill_color, ColorBlendMode.NormalBlendColor, 100)
self.doc_selection.deselect()
@@ -384,18 +469,18 @@ def position_divider(layers: list[ArtLayer], line: ArtLayer) -> None:
"""
-class PlaneswalkerTemplate (PlaneswalkerMod, StarterTemplate):
+class PlaneswalkerTemplate(PlaneswalkerMod, StarterTemplate):
"""Core Planeswalker 'Normal' M15-style template."""
-class PlaneswalkerBorderlessTemplate (BorderlessMod, PlaneswalkerTemplate):
+class PlaneswalkerBorderlessTemplate(BorderlessMod, PlaneswalkerTemplate):
"""A Borderless version of PlaneswalkerTemplate."""
"""
* Details
"""
- @property
+ @cached_property
def art_frame_vertical(self) -> str:
"""No separation for 'Colorless' cards."""
return LAYERS.FULL_ART_FRAME
@@ -406,7 +491,7 @@ def art_frame_vertical(self) -> str:
"""
-class PlaneswalkerMDFCTemplate (MDFCMod, PlaneswalkerTemplate):
+class PlaneswalkerMDFCTemplate(MDFCMod, PlaneswalkerTemplate):
"""Adds MDFC functionality to the existing PlaneswalkerTemplate."""
"""
@@ -414,22 +499,22 @@ class PlaneswalkerMDFCTemplate (MDFCMod, PlaneswalkerTemplate):
"""
@cached_property
- def dfc_group(self) -> LayerSet:
+ def dfc_group(self) -> LayerSet | None:
"""LayerSet: DFC group at top level."""
face = LAYERS.FRONT if self.is_front else LAYERS.BACK
- return psd.getLayerSet(f'{LAYERS.MDFC} {face}')
+ return psd.getLayerSet(f"{LAYERS.MDFC} {face}")
"""
* Text Layers
"""
@cached_property
- def text_layer_name(self) -> ArtLayer:
+ def text_layer_name(self) -> ArtLayer | None:
"""ArtLayer: Name is always shifted."""
return psd.getLayer(LAYERS.NAME, self.text_group)
-class PlaneswalkerMDFCBorderlessTemplate (MDFCMod, PlaneswalkerBorderlessTemplate):
+class PlaneswalkerMDFCBorderlessTemplate(MDFCMod, PlaneswalkerBorderlessTemplate):
"""Adds MDFC functionality to the existing PlaneswalkerExtendedTemplate."""
"""
@@ -437,17 +522,17 @@ class PlaneswalkerMDFCBorderlessTemplate (MDFCMod, PlaneswalkerBorderlessTemplat
"""
@cached_property
- def dfc_group(self) -> LayerSet:
+ def dfc_group(self) -> LayerSet | None:
"""LayerSet: DFC group at top level."""
face = LAYERS.FRONT if self.is_front else LAYERS.BACK
- return psd.getLayerSet(f'{LAYERS.MDFC} {face}')
+ return psd.getLayerSet(f"{LAYERS.MDFC} {face}")
"""
* Text Layers
"""
@cached_property
- def text_layer_name(self) -> ArtLayer:
+ def text_layer_name(self) -> ArtLayer | None:
"""ArtLayer: Name is always shifted."""
return psd.getLayer(LAYERS.NAME, self.text_group)
@@ -457,7 +542,7 @@ def text_layer_name(self) -> ArtLayer:
"""
-class PlaneswalkerTFTemplate (TransformMod, PlaneswalkerTemplate):
+class PlaneswalkerTFTemplate(TransformMod, PlaneswalkerTemplate):
"""Adds Transform functionality to the existing PlaneswalkerTemplate."""
"""
@@ -465,23 +550,23 @@ class PlaneswalkerTFTemplate (TransformMod, PlaneswalkerTemplate):
"""
@cached_property
- def dfc_group(self) -> LayerSet:
+ def dfc_group(self) -> LayerSet | None:
"""LayerSet: DFC group at top level."""
return psd.getLayerSet(
- LAYERS.FRONT if self.is_front else LAYERS.BACK,
- LAYERS.TRANSFORM)
+ LAYERS.FRONT if self.is_front else LAYERS.BACK, LAYERS.TRANSFORM
+ )
"""
* Text Layers
"""
@cached_property
- def text_layer_name(self) -> ArtLayer:
+ def text_layer_name(self) -> ArtLayer | None:
"""ArtLayer: Name is always shifted."""
return psd.getLayer(LAYERS.NAME, self.text_group)
@cached_property
- def text_layer_type(self) -> Optional[ArtLayer]:
+ def text_layer_type(self) -> ArtLayer | None:
"""ArtLayer: Typeline is always shifted."""
return psd.getLayer(LAYERS.TYPE_LINE, self.text_group)
@@ -494,7 +579,7 @@ def text_layers_transform(self):
pass
-class PlaneswalkerTFBorderlessTemplate (TransformMod, PlaneswalkerBorderlessTemplate):
+class PlaneswalkerTFBorderlessTemplate(TransformMod, PlaneswalkerBorderlessTemplate):
"""Adds Transform functionality to the existing PlaneswalkerBorderlessTemplate."""
"""
@@ -502,23 +587,23 @@ class PlaneswalkerTFBorderlessTemplate (TransformMod, PlaneswalkerBorderlessTemp
"""
@cached_property
- def dfc_group(self) -> LayerSet:
+ def dfc_group(self) -> LayerSet | None:
"""LayerSet: DFC group at top level."""
return psd.getLayerSet(
- LAYERS.FRONT if self.is_front else LAYERS.BACK,
- LAYERS.TRANSFORM)
+ LAYERS.FRONT if self.is_front else LAYERS.BACK, LAYERS.TRANSFORM
+ )
"""
* Text Layers
"""
@cached_property
- def text_layer_name(self) -> ArtLayer:
+ def text_layer_name(self) -> ArtLayer | None:
"""ArtLayer: Name is always shifted."""
return psd.getLayer(LAYERS.NAME, self.text_group)
@cached_property
- def text_layer_type(self) -> Optional[ArtLayer]:
+ def text_layer_type(self) -> ArtLayer | None:
"""ArtLayer: Typeline is always shifted."""
return psd.getLayer(LAYERS.TYPE_LINE, self.text_group)
diff --git a/src/templates/prepare.py b/src/templates/prepare.py
new file mode 100644
index 00000000..27121483
--- /dev/null
+++ b/src/templates/prepare.py
@@ -0,0 +1,114 @@
+"""
+* Prepare Templates
+"""
+
+from collections.abc import Callable
+from functools import cached_property
+
+from photoshop.api._artlayer import ArtLayer
+
+import src.helpers as psd
+import src.text_layers as text_classes
+from src.enums.layers import LAYERS
+from src.layouts import PrepareLayout
+from src.templates._core import NormalTemplate
+
+
+class PrepareMod(NormalTemplate):
+ """A modifier class which adds functionality required by Prepare cards,
+ introduced in Secrets of Strixhaven.
+
+ Adds:
+ * Spell side text layers (Mana cost, name, typeline, and oracle text) and textbox reference.
+ """
+
+ @cached_property
+ def is_prepare(self) -> bool:
+ return isinstance(self.layout, PrepareLayout)
+
+ # region Text Layers
+
+ @cached_property
+ def text_layer_methods(self) -> list[Callable[[], None]]:
+ """Add Prepare text layers step."""
+ funcs = super().text_layer_methods
+ if isinstance(self.layout, PrepareLayout):
+ funcs.append(self.text_layers_prepare)
+ return funcs
+
+ @cached_property
+ def text_layer_name_prepare(self) -> ArtLayer | None:
+ """Name for the prepare side."""
+ return psd.getLayer(LAYERS.NAME_PREPARE, self.text_group)
+
+ @cached_property
+ def text_layer_mana_prepare(self) -> ArtLayer | None:
+ """Mana cost for the prepare side."""
+ return psd.getLayer(LAYERS.MANA_COST_PREPARE, self.text_group)
+
+ @cached_property
+ def text_layer_type_prepare(self) -> ArtLayer | None:
+ """Type line for the prepare side."""
+ return psd.getLayer(LAYERS.TYPE_LINE_PREPARE, self.text_group)
+
+ @cached_property
+ def text_layer_rules_prepare(self) -> ArtLayer | None:
+ """Rules text for the prepare side."""
+ return psd.getLayer(LAYERS.RULES_TEXT_PREPARE, self.text_group)
+
+ @cached_property
+ def divider_layer_prepare(self) -> ArtLayer | None:
+ """Flavor divider for the prepare side."""
+ return psd.getLayer(LAYERS.DIVIDER_PREPARE, self.text_group)
+
+ # region Text Layers
+
+ # region References
+
+ @cached_property
+ def textbox_reference_prepare(self) -> ArtLayer | None:
+ return psd.get_reference_layer(
+ LAYERS.TEXTBOX_REFERENCE_PREPARE, self.text_group
+ )
+
+ # endregion References
+
+ # region Methods
+
+ def text_layers_prepare(self) -> None:
+ if isinstance(self.layout, PrepareLayout):
+ # Add prepare text layers
+ if self.text_layer_mana_prepare:
+ self.text.append(
+ text_classes.FormattedTextField(
+ layer=self.text_layer_mana_prepare,
+ contents=self.layout.mana_prepare,
+ )
+ )
+ if self.text_layer_name_prepare:
+ self.text.append(
+ text_classes.ScaledTextField(
+ layer=self.text_layer_name_prepare,
+ contents=self.layout.name_prepare,
+ reference=self.text_layer_mana_prepare,
+ )
+ )
+ if self.text_layer_rules_prepare:
+ self.text.append(
+ text_classes.FormattedTextArea(
+ layer=self.text_layer_rules_prepare,
+ contents=self.layout.oracle_text_prepare,
+ reference=self.textbox_reference_prepare,
+ flavor=self.layout.flavor_text_prepare,
+ centered=False,
+ )
+ )
+ if self.text_layer_type_prepare:
+ self.text.append(
+ text_classes.TextField(
+ layer=self.text_layer_type_prepare,
+ contents=self.layout.type_line_prepare,
+ )
+ )
+
+ # endregion Methods
diff --git a/src/templates/prototype.py b/src/templates/prototype.py
index d128b42c..f9f5df56 100644
--- a/src/templates/prototype.py
+++ b/src/templates/prototype.py
@@ -1,21 +1,18 @@
"""
* Templates: Prototype
"""
-# Standard Library
+
+from collections.abc import Callable
from functools import cached_property
-from typing import Callable
-# Third Party Imports
from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
-# Local Imports
-from src import CFG
-from src.enums.layers import LAYERS
import src.helpers as psd
+import src.text_layers as text_classes
+from src.enums.layers import LAYERS
from src.layouts import PrototypeLayout
from src.templates._core import NormalTemplate
-import src.text_layers as text_classes
from src.utils.adobe import ReferenceLayer
"""
@@ -37,15 +34,23 @@ class PrototypeMod(NormalTemplate):
"""
@cached_property
- def text_layer_methods(self) -> list[Callable]:
+ def text_layer_methods(self) -> list[Callable[[], None]]:
"""Add Prototype text layers."""
- funcs = [self.text_layers_prototype] if isinstance(self.layout, PrototypeLayout) else []
+ funcs = (
+ [self.text_layers_prototype]
+ if isinstance(self.layout, PrototypeLayout)
+ else []
+ )
return [*super().text_layer_methods, *funcs]
@cached_property
- def frame_layer_methods(self) -> list[Callable]:
+ def frame_layer_methods(self) -> list[Callable[[], None]]:
"""Enable Prototype frame layers."""
- funcs = [self.frame_layers_prototype] if isinstance(self.layout, PrototypeLayout) else []
+ funcs = (
+ [self.frame_layers_prototype]
+ if isinstance(self.layout, PrototypeLayout)
+ else []
+ )
return [*super().frame_layer_methods, *funcs]
"""
@@ -53,7 +58,7 @@ def frame_layer_methods(self) -> list[Callable]:
"""
@cached_property
- def prototype_reference(self) -> ReferenceLayer:
+ def prototype_reference(self) -> ReferenceLayer | None:
"""ReferenceLayer: Reference used to size and position the prototype text."""
return self.proto_textbox_layer
@@ -62,17 +67,17 @@ def prototype_reference(self) -> ReferenceLayer:
"""
@cached_property
- def proto_manabox_group(self) -> LayerSet:
+ def proto_manabox_group(self) -> LayerSet | None:
"""LayerSet: Layer group containing the colors and shape for the Prototype mana box."""
return psd.getLayerSet(LAYERS.PROTO_MANABOX, self.docref)
@cached_property
- def proto_textbox_group(self) -> LayerSet:
+ def proto_textbox_group(self) -> LayerSet | None:
"""LayerSet: Layer group containing textures for the Prototype textbox."""
return psd.getLayerSet(LAYERS.PROTO_TEXTBOX, self.docref)
@cached_property
- def proto_pt_group(self) -> LayerSet:
+ def proto_pt_group(self) -> LayerSet | None:
"""LayerSet: Layer group containing textures for the Prototype PT box."""
return psd.getLayerSet(LAYERS.PROTO_PTBOX, self.docref)
@@ -81,52 +86,52 @@ def proto_pt_group(self) -> LayerSet:
"""
@cached_property
- def proto_manabox_shape(self) -> ArtLayer:
+ def proto_manabox_shape(self) -> ArtLayer | None:
"""ArtLayer: Vector shape containing the Prototype mana text."""
- size = '2' if self.layout.proto_mana_cost.count('{') <= 2 else '3'
- return psd.getLayer(size, [self.proto_manabox_group, LAYERS.SHAPE])
+ if isinstance(self.layout, PrototypeLayout):
+ size = "2" if self.layout.proto_mana_cost.count("{") <= 2 else "3"
+ return psd.getLayer(size, [self.proto_manabox_group, LAYERS.SHAPE])
"""
* Prototype Layers
"""
@cached_property
- def proto_textbox_layer(self) -> ReferenceLayer:
+ def proto_textbox_layer(self) -> ReferenceLayer | None:
"""ReferenceLayer: Colored and outlined box containing the Prototype ability text."""
- return psd.get_reference_layer(
- self.layout.proto_color,
- self.proto_textbox_group)
+ if isinstance(self.layout, PrototypeLayout):
+ return psd.get_reference_layer(
+ self.layout.proto_color, self.proto_textbox_group
+ )
@cached_property
- def proto_manabox_layer(self) -> ArtLayer:
+ def proto_manabox_layer(self) -> ArtLayer | None:
"""ArtLayer: Solid color adjustment layer used to color the Prototype manabox."""
- return psd.getLayer(
- self.layout.proto_color,
- self.proto_manabox_group)
+ if isinstance(self.layout, PrototypeLayout):
+ return psd.getLayer(self.layout.proto_color, self.proto_manabox_group)
@cached_property
- def proto_pt_layer(self) -> ArtLayer:
+ def proto_pt_layer(self) -> ArtLayer | None:
"""ArtLayer: Box for the P/T of the Prototype version of this card."""
- return psd.getLayer(
- self.layout.proto_color,
- self.proto_pt_group)
+ if isinstance(self.layout, PrototypeLayout):
+ return psd.getLayer(self.layout.proto_color, self.proto_pt_group)
"""
* Prototype Text Layers
"""
@cached_property
- def text_layer_proto(self) -> ArtLayer:
+ def text_layer_proto(self) -> ArtLayer | None:
"""ArtLayer: Text layer containing the Prototype rules text."""
return psd.getLayer(LAYERS.PROTO_RULES, self.text_group)
@cached_property
- def text_layer_proto_mana(self) -> ArtLayer:
+ def text_layer_proto_mana(self) -> ArtLayer | None:
"""ArtLayer: Text layer containing the Prototype mana cost."""
return psd.getLayer(LAYERS.PROTO_MANA_COST, self.text_group)
@cached_property
- def text_layer_proto_pt(self) -> ArtLayer:
+ def text_layer_proto_pt(self) -> ArtLayer | None:
"""ArtLayer: Text layer containing the Prototype power/toughness."""
return psd.getLayer(LAYERS.PROTO_PT, self.text_group)
@@ -143,8 +148,9 @@ def frame_layers_prototype(self):
# Prototype Mana Box
if self.proto_manabox_layer:
- self.proto_manabox_shape.visible = True
self.proto_manabox_layer.visible = True
+ if self.proto_manabox_shape:
+ self.proto_manabox_shape.visible = True
# Prototype PT
if self.proto_pt_layer:
@@ -156,24 +162,34 @@ def frame_layers_prototype(self):
def text_layers_prototype(self):
"""Add and modify text layers required by Prototype cards."""
-
- # Add prototype PT and Mana Cost
- self.text.extend([
- text_classes.FormattedTextField(
- layer=self.text_layer_proto_mana,
- contents=self.layout.proto_mana_cost),
- text_classes.TextField(
- layer=self.text_layer_proto_pt,
- contents=self.layout.proto_pt)])
-
- # Remove reminder text if necessary
- if CFG.remove_reminder:
- self.text_layer_proto.textItem.size = psd.get_text_scale_factor(self.text_layer_proto) * 9
- self.text.append(
- text_classes.FormattedTextArea(
- layer=self.text_layer_proto,
- contents='Prototype',
- reference=self.prototype_reference))
+ if isinstance(self.layout, PrototypeLayout):
+ # Add prototype PT and Mana Cost
+ if self.text_layer_proto_mana:
+ self.text.append(
+ text_classes.FormattedTextField(
+ layer=self.text_layer_proto_mana,
+ contents=self.layout.proto_mana_cost,
+ )
+ )
+ if self.text_layer_proto_pt:
+ self.text.append(
+ text_classes.TextField(
+ layer=self.text_layer_proto_pt, contents=self.layout.proto_pt
+ )
+ )
+
+ # Remove reminder text if necessary
+ if self.config.remove_reminder and self.text_layer_proto:
+ self.text_layer_proto.textItem.size = (
+ psd.get_text_scale_factor(self.text_layer_proto) * 9
+ )
+ self.text.append(
+ text_classes.FormattedTextArea(
+ layer=self.text_layer_proto,
+ contents="Prototype",
+ reference=self.prototype_reference,
+ )
+ )
"""
diff --git a/src/templates/saga.py b/src/templates/saga.py
index 769ee1a2..f2a6f9a3 100644
--- a/src/templates/saga.py
+++ b/src/templates/saga.py
@@ -1,24 +1,29 @@
"""
* Templates: Saga
"""
-# Standard Library Imports
+
+from collections.abc import Callable, Sequence
from functools import cached_property
-from typing import Optional, Union, Callable
-# Third Party Imports
from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
-# Local Imports
-from src.enums.layers import LAYERS
import src.helpers as psd
-from src.layouts import SagaLayout
-from src.schema.colors import pinlines_color_map, saga_banner_color_map, saga_stripe_color_map
+import src.text_layers as text_classes
+from src.enums.layers import LAYERS
+from src.helpers.layers import get_reference_layer
+from src.layouts import NormalLayout, SagaLayout
+from src.schema.colors import (
+ ColorObject,
+ GradientConfig,
+ pinlines_color_map,
+ saga_banner_color_map,
+ saga_stripe_color_map,
+)
from src.templates import VectorNyxMod
from src.templates._core import NormalTemplate
-from src.templates._vector import VectorTemplate
+from src.templates._vector import MaskAction, VectorTemplate
from src.templates.transform import VectorTransformMod
-import src.text_layers as text_classes
from src.utils.adobe import ReferenceLayer
"""
@@ -26,7 +31,7 @@
"""
-class SagaMod (NormalTemplate):
+class SagaMod(NormalTemplate):
"""
* A template modifier for Saga cards introduced in Dominaria.
* Utilizes some of the same automated positioning techniques as Planeswalker templates.
@@ -37,15 +42,16 @@ class SagaMod (NormalTemplate):
* Also has some layer support for double faced sagas.
"""
- def __init__(self, layout: SagaLayout, **kwargs):
- self._abilities: list[ArtLayer] = []
- self._icons: list[list[ArtLayer]] = []
- super().__init__(layout, **kwargs)
+ def __init__(self, layout: NormalLayout):
+ self._saga_abilities: list[ArtLayer] = []
+ self._saga_icons: list[list[ArtLayer | LayerSet]] = []
+ super().__init__(layout)
"""
* Layout Checks
"""
+ @cached_property
def is_layout_saga(self) -> bool:
"""Checks whether the card matches SagaLayout."""
return isinstance(self.layout, SagaLayout)
@@ -55,19 +61,19 @@ def is_layout_saga(self) -> bool:
"""
@cached_property
- def text_layer_methods(self) -> list[Callable]:
+ def text_layer_methods(self) -> list[Callable[[], None]]:
"""Add Saga text layers."""
funcs = [self.text_layers_saga] if self.is_layout_saga else []
return [*super().text_layer_methods, *funcs]
@cached_property
- def frame_layer_methods(self) -> list[Callable]:
+ def frame_layer_methods(self) -> list[Callable[[], None]]:
"""Add Saga text layers."""
funcs = [self.frame_layers_saga] if self.is_layout_saga else []
return [*super().frame_layer_methods, *funcs]
@cached_property
- def post_text_methods(self) -> list[Callable]:
+ def post_text_methods(self) -> list[Callable[[], None]]:
"""Position Saga abilities, dividers, and icons."""
funcs = [self.layer_positioning_saga] if self.is_layout_saga else []
return [*super().post_text_methods, *funcs]
@@ -85,47 +91,52 @@ def saga_group(self):
"""
@property
- def ability_layers(self) -> list[ArtLayer]:
- return self._abilities
+ def saga_ability_layers(self) -> list[ArtLayer]:
+ return self._saga_abilities
- @ability_layers.setter
- def ability_layers(self, value):
- self._abilities = value
+ @saga_ability_layers.setter
+ def saga_ability_layers(self, value: list[ArtLayer]):
+ self._saga_abilities = value
@property
- def icon_layers(self) -> list[list[ArtLayer]]:
- return self._icons
+ def saga_icon_layers(self) -> list[list[ArtLayer | LayerSet]]:
+ return self._saga_icons
- @icon_layers.setter
- def icon_layers(self, value):
- self._icons = value
+ @saga_icon_layers.setter
+ def saga_icon_layers(self, value: list[list[ArtLayer | LayerSet]]):
+ self._saga_icons = value
@cached_property
- def ability_divider_layer(self) -> ArtLayer:
+ def ability_divider_layer(self) -> ArtLayer | None:
return psd.getLayer(LAYERS.DIVIDER, self.saga_group)
+ def get_saga_stage_icon(self, stage: str) -> ArtLayer | LayerSet:
+ if layer := psd.getLayer(stage, self.saga_group):
+ return layer.duplicate()
+ raise ValueError(f"Couldn't get icon for Saga stage '{stage}'.")
+
"""
* Text Layers
"""
@cached_property
- def text_layer_ability(self) -> ArtLayer:
+ def text_layer_ability(self) -> ArtLayer | None:
return psd.getLayer(LAYERS.TEXT, self.saga_group)
@cached_property
- def text_layer_reminder(self) -> ArtLayer:
- return psd.getLayer("Reminder Text", self.saga_group)
+ def text_layer_reminder(self) -> ArtLayer | None:
+ return psd.getLayer(LAYERS.REMINDER_TEXT, self.saga_group)
"""
* References
"""
@cached_property
- def art_reference(self) -> ArtLayer:
- return psd.getLayer(LAYERS.ART_FRAME)
+ def art_reference(self) -> ReferenceLayer | None:
+ return get_reference_layer(LAYERS.ART_FRAME)
@cached_property
- def reminder_reference(self) -> ReferenceLayer:
+ def reminder_reference(self) -> ReferenceLayer | None:
return psd.get_reference_layer("Description Reference", self.saga_group)
"""
@@ -134,7 +145,9 @@ def reminder_reference(self) -> ReferenceLayer:
def rules_text_and_pt_layers(self) -> None:
"""Skip this step for Saga cards."""
- pass
+ if self.is_layout_saga and not self.is_creature:
+ return
+ return super().rules_text_and_pt_layers()
"""
* Saga Frame Layer Methods
@@ -144,7 +157,8 @@ def frame_layers_saga(self):
"""Enable frame layers required by Saga cards."""
# Saga stripe
- psd.getLayer(self.pinlines, LAYERS.PINLINES_AND_SAGA_STRIPE).visible = True
+ if layer := psd.getLayer(self.pinlines, LAYERS.PINLINES_AND_SAGA_STRIPE):
+ layer.visible = True
"""
* Saga Text Layer Methods
@@ -152,26 +166,37 @@ def frame_layers_saga(self):
def text_layers_saga(self):
"""Add and modify text layers required by Saga cards."""
-
- # Add description text with reminder
- self.text.append(
- text_classes.FormattedTextArea(
- layer=self.text_layer_reminder,
- contents=self.layout.saga_description,
- reference=self.reminder_reference))
-
- # Iterate through each saga stage and add line to text layers
- for i, line in enumerate(self.layout.saga_lines):
-
- # Add icon layers for this ability
- self.icon_layers.append([psd.getLayer(n, self.saga_group).duplicate() for n in line['icons']])
-
- # Add ability text for this ability
- layer = self.text_layer_ability if i == 0 else self.text_layer_ability.duplicate()
- self.ability_layers.append(layer)
- self.text.append(
- text_classes.FormattedTextField(
- layer=layer, contents=line['text']))
+ if isinstance(self.layout, SagaLayout):
+ # Add description text with reminder
+ if self.text_layer_reminder:
+ self.text.append(
+ text_classes.FormattedTextArea(
+ layer=self.text_layer_reminder,
+ contents=self.layout.saga_description,
+ reference=self.reminder_reference,
+ )
+ )
+
+ if self.text_layer_ability:
+ # Iterate through each saga stage and add line to text layers
+ for i, line in enumerate(self.layout.saga_lines):
+ # Add icon layers for this ability
+ self.saga_icon_layers.append(
+ [self.get_saga_stage_icon(n) for n in line["icons"]]
+ )
+
+ # Add ability text for this ability
+ layer = (
+ self.text_layer_ability
+ if i == 0
+ else self.text_layer_ability.duplicate()
+ )
+ self.saga_ability_layers.append(layer)
+ self.text.append(
+ text_classes.FormattedTextField(
+ layer=layer, contents=line["text"]
+ )
+ )
"""
* Saga Positioning Methods
@@ -179,56 +204,61 @@ def text_layers_saga(self):
def layer_positioning_saga(self) -> None:
"""Position Saga ability, icon, and divider layers."""
-
- # Core vars
- spacing = self.app.scale_by_dpi(80)
- spaces = len(self.ability_layers) - 1
- spacing_total = (spaces * 1.5) + 2
- ref_height = self.textbox_reference.dims['height']
- total_height = ref_height - (((spacing * 1.5) * spaces) + (spacing * 2))
-
- # Resize text items till they fit in the available space
- psd.scale_text_layers_to_height(
- text_layers=self.ability_layers,
- ref_height=total_height)
-
- # Get the exact gap between each layer left over
- layer_heights = sum([psd.get_layer_height(lyr) for lyr in self.ability_layers])
- gap = (ref_height - layer_heights) * (1 / spacing_total)
- inside_gap = (ref_height - layer_heights) * (1.5 / spacing_total)
-
- # Space Saga lines evenly apart
- psd.spread_layers_over_reference(
- layers=self.ability_layers,
- ref=self.textbox_reference,
- gap=gap,
- inside_gap=inside_gap)
-
- # Align icons to respective text layers
- for i, ref_layer in enumerate(self.ability_layers):
-
- # Skip if no icons present or icons are invalid
- if not (icons := self.icon_layers[i]):
- continue
- if not all(icons):
- continue
-
- # Space multiple icons apart
- if len(icons) > 1:
- psd.space_layers_apart(
- layers=icons,
- gap=spacing / 3)
-
- # Combine icons and align them
- layer = icons[0] if len(icons) == 1 else psd.merge_layers(icons)
- psd.align_vertical(layer, ref_layer)
-
- # Position divider lines
- dividers = [self.ability_divider_layer.duplicate() for _ in range(len(self.ability_layers) - 1)]
- psd.position_dividers(
- dividers=dividers,
- layers=self.ability_layers,
- docref=self.docref)
+ if self.textbox_reference:
+ # Core vars
+ spacing = self.app.scale_by_dpi(80)
+ spaces = len(self.saga_ability_layers) - 1
+ spacing_total = (spaces * 1.5) + 2
+ ref_height = self.textbox_reference.dims["height"]
+ total_height = ref_height - (((spacing * 1.5) * spaces) + (spacing * 2))
+
+ # Resize text items till they fit in the available space
+ psd.scale_text_layers_to_height(
+ text_layers=self.saga_ability_layers, ref_height=total_height
+ )
+
+ # Get the exact gap between each layer left over
+ layer_heights = sum(
+ [psd.get_layer_height(lyr) for lyr in self.saga_ability_layers]
+ )
+ gap = (ref_height - layer_heights) * (1 / spacing_total)
+ inside_gap = (ref_height - layer_heights) * (1.5 / spacing_total)
+
+ # Space Saga lines evenly apart
+ psd.spread_layers_over_reference(
+ layers=self.saga_ability_layers,
+ ref=self.textbox_reference,
+ gap=gap,
+ inside_gap=inside_gap,
+ )
+
+ # Align icons to respective text layers
+ for i, ref_layer in enumerate(self.saga_ability_layers):
+ # Skip if no icons present or icons are invalid
+ if not (icons := self.saga_icon_layers[i]):
+ continue
+ if not all(icons):
+ continue
+
+ # Space multiple icons apart
+ if len(icons) > 1:
+ psd.space_layers_apart(layers=icons, gap=spacing / 3)
+
+ # Combine icons and align them
+ layer = icons[0] if len(icons) == 1 else psd.merge_layers(icons)
+ psd.align_vertical(layer, ref_layer)
+
+ # Position divider lines
+ if self.ability_divider_layer:
+ dividers = [
+ self.ability_divider_layer.duplicate()
+ for _ in range(len(self.saga_ability_layers) - 1)
+ ]
+ psd.position_dividers(
+ dividers=dividers,
+ layers=self.saga_ability_layers,
+ docref=self.docref,
+ )
class VectorSagaMod(SagaMod, VectorTemplate):
@@ -243,18 +273,22 @@ class VectorSagaMod(SagaMod, VectorTemplate):
"""
@cached_property
- def saga_banner_colors(self) -> list[list[int]]:
- """Must be returned as list of RGB/CMYK integer lists."""
+ def saga_banner_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
+ """Must be returned as list of RGB/CMYK color notations."""
if len(self.pinlines) == 2:
- return [self.saga_banner_color_map.get(c, [0, 0, 0]) for c in self.pinlines]
- return [self.saga_banner_color_map.get(self.pinlines, [0, 0, 0])]
+ return [self.saga_banner_color_map.get(c, (0, 0, 0)) for c in self.pinlines]
+ return [self.saga_banner_color_map.get(self.pinlines, (0, 0, 0))]
@cached_property
- def saga_stripe_colors(self) -> list[int]:
- """Must be returned as an RGB/CMYK integer list."""
+ def saga_stripe_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
+ """Must be returned as an RGB/CMYK color notation."""
if len(self.pinlines) == 2:
- return self.saga_stripe_color_map.get('Dual', [0, 0, 0])
- return self.saga_stripe_color_map.get(self.pinlines, [0, 0, 0])
+ return self.saga_stripe_color_map.get("Dual", (0, 0, 0))
+ return self.saga_stripe_color_map.get(self.pinlines, (0, 0, 0))
"""
* Blending Masks
@@ -262,18 +296,20 @@ def saga_stripe_colors(self) -> list[int]:
@cached_property
def saga_banner_masks(self) -> list[ArtLayer]:
- return [psd.getLayer(LAYERS.HALF, [self.mask_group, LAYERS.BANNER])]
+ if layer := psd.getLayer(LAYERS.HALF, [self.mask_group, LAYERS.BANNER]):
+ return [layer]
+ return []
"""
* Groups
"""
@cached_property
- def saga_banner_group(self) -> LayerSet:
+ def saga_banner_group(self) -> LayerSet | None:
return psd.getLayerSet(LAYERS.BANNER, self.saga_group)
@cached_property
- def saga_stripe_group(self) -> LayerSet:
+ def saga_stripe_group(self) -> LayerSet | None:
return psd.getLayerSet(LAYERS.STRIPE, self.saga_group)
"""
@@ -281,25 +317,34 @@ def saga_stripe_group(self) -> LayerSet:
"""
@cached_property
- def saga_stripe_shape(self) -> ArtLayer:
+ def saga_stripe_shape(self) -> ArtLayer | None:
"""The stripe shape in the middle of the Saga banner."""
return psd.getLayer(
- LAYERS.TRANSFORM_FRONT if self.is_transform and self.is_front else LAYERS.NORMAL,
- [self.saga_stripe_group, LAYERS.SHAPE])
+ LAYERS.TRANSFORM_FRONT
+ if self.is_transform and self.is_front
+ else LAYERS.NORMAL,
+ [self.saga_stripe_group, LAYERS.SHAPE],
+ )
@cached_property
- def saga_banner_shape(self) -> ArtLayer:
+ def saga_banner_shape(self) -> ArtLayer | None:
"""The overall Saga banner shape."""
return psd.getLayer(
- LAYERS.TRANSFORM_FRONT if self.is_transform and self.is_front else LAYERS.NORMAL,
- [self.saga_banner_group, LAYERS.SHAPE])
+ LAYERS.TRANSFORM_FRONT
+ if self.is_transform and self.is_front
+ else LAYERS.NORMAL,
+ [self.saga_banner_group, LAYERS.SHAPE],
+ )
@cached_property
- def saga_trim_shape(self) -> ArtLayer:
+ def saga_trim_shape(self) -> ArtLayer | None:
"""The gold trim on the Saga Banner."""
return psd.getLayer(
- LAYERS.TRANSFORM_FRONT if self.is_transform and self.is_front else LAYERS.NORMAL,
- self.saga_group)
+ LAYERS.TRANSFORM_FRONT
+ if self.is_transform and self.is_front
+ else LAYERS.NORMAL,
+ self.saga_group,
+ )
"""
* Saga Frame Layer Methods
@@ -309,16 +354,20 @@ def frame_layers_saga(self):
"""Enable layers required by Saga cards."""
# Enable Saga group
- self.saga_group.visible = True
+ if self.saga_group:
+ self.saga_group.visible = True
# Add colors
- self.generate_layer(
- group=self.saga_banner_group,
- colors=self.saga_banner_colors,
- masks=self.saga_banner_masks)
- self.generate_layer(
- group=self.saga_stripe_group,
- colors=self.saga_stripe_colors)
+ if self.saga_banner_group:
+ self.generate_layer(
+ group=self.saga_banner_group,
+ colors=self.saga_banner_colors,
+ masks=self.saga_banner_masks,
+ )
+ if self.saga_stripe_group:
+ self.generate_layer(
+ group=self.saga_stripe_group, colors=self.saga_stripe_colors
+ )
"""
@@ -326,7 +375,9 @@ def frame_layers_saga(self):
"""
-class SagaVectorTemplate(VectorNyxMod, VectorSagaMod, VectorTransformMod, VectorTemplate):
+class SagaVectorTemplate(
+ VectorNyxMod, VectorSagaMod, VectorTransformMod, VectorTemplate
+):
"""Saga template using vector shape layers and automatic pinlines / multicolor generation."""
"""
@@ -343,9 +394,15 @@ def is_name_shifted(self) -> bool:
"""
@cached_property
- def twins_colors(self) -> list[str]:
+ def twins_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""list[str]: Use Back face versions for back side Transform."""
- return [f'{self.twins} {LAYERS.BACK}'] if self.is_transform and not self.is_front else [self.twins]
+ return (
+ [f"{self.twins} {LAYERS.BACK}"]
+ if self.is_transform and not self.is_front
+ else [self.twins]
+ )
@cached_property
def textbox_colors(self) -> list[str]:
@@ -363,23 +420,25 @@ def textbox_colors(self) -> list[str]:
return [self.pinlines]
@cached_property
- def crown_colors(self) -> Union[list[int], list[dict]]:
+ def crown_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""Return RGB/CMYK integer list or Gradient dict notation for color adjustment layers."""
return psd.get_pinline_gradient(
- colors=self.pinlines,
- color_map=self.crown_color_map)
+ colors=self.pinlines, color_map=self.crown_color_map
+ )
"""
* Groups
"""
@cached_property
- def crown_group(self) -> LayerSet:
+ def crown_group(self) -> LayerSet | None:
"""Legendary crown group."""
return psd.getLayerSet(LAYERS.SHAPE, LAYERS.LEGENDARY_CROWN)
@cached_property
- def textbox_group(self) -> LayerSet:
+ def textbox_group(self) -> LayerSet | None:
"""Must enable textbox group."""
if group := psd.getLayerSet(LAYERS.TEXTBOX):
group.visible = True
@@ -390,7 +449,7 @@ def textbox_group(self) -> LayerSet:
"""
@cached_property
- def border_layer(self) -> Optional[ArtLayer]:
+ def border_layer(self) -> ArtLayer | None:
"""Check for Legendary and/or front face Transform."""
name = LAYERS.LEGENDARY if self.is_legendary else LAYERS.NORMAL
if self.is_transform and self.is_front:
@@ -402,13 +461,15 @@ def border_layer(self) -> Optional[ArtLayer]:
"""
@cached_property
- def art_reference(self) -> ArtLayer:
- return psd.getLayer(LAYERS.ART_FRAME + " Right")
+ def art_reference(self) -> ReferenceLayer | None:
+ return get_reference_layer(LAYERS.ART_FRAME + " Right")
@cached_property
- def textbox_reference(self) -> Optional[ReferenceLayer]:
+ def textbox_reference(self) -> ReferenceLayer | None:
if self.is_front and self.is_flipside_creature:
- return psd.get_reference_layer(f"{LAYERS.TEXTBOX_REFERENCE} {LAYERS.TRANSFORM_FRONT}", self.saga_group)
+ return psd.get_reference_layer(
+ f"{LAYERS.TEXTBOX_REFERENCE} {LAYERS.TRANSFORM_FRONT}", self.saga_group
+ )
return psd.get_reference_layer(LAYERS.TEXTBOX_REFERENCE, self.saga_group)
"""
@@ -417,55 +478,71 @@ def textbox_reference(self) -> Optional[ReferenceLayer]:
@cached_property
def textbox_masks(self) -> list[ArtLayer]:
- return [psd.getLayer(LAYERS.HALF, [self.mask_group, LAYERS.TEXTBOX])]
+ if layer := psd.getLayer(LAYERS.HALF, [self.mask_group, LAYERS.TEXTBOX]):
+ return [layer]
+ return []
@cached_property
def background_masks(self) -> list[ArtLayer]:
- return [psd.getLayer(LAYERS.HALF, [self.mask_group, LAYERS.BACKGROUND])]
+ if layer := psd.getLayer(LAYERS.HALF, [self.mask_group, LAYERS.BACKGROUND]):
+ return [layer]
+ return []
"""
* Shape Layers
"""
@cached_property
- def pinlines_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
+ def pinlines_shapes(self) -> list[ArtLayer | LayerSet | None]:
"""Add Legendary shape to pinlines shapes."""
- shapes = [psd.getLayerSet(LAYERS.LEGENDARY, [self.pinlines_group, LAYERS.SHAPE])] if self.is_legendary else []
+ shapes = (
+ [psd.getLayerSet(LAYERS.LEGENDARY, [self.pinlines_group, LAYERS.SHAPE])]
+ if self.is_legendary
+ else []
+ )
return [
# Normal or Transform pinline
psd.getLayerSet(
(LAYERS.TRANSFORM_FRONT if self.is_front else LAYERS.TRANSFORM_BACK)
- if self.is_transform else LAYERS.NORMAL,
- [self.pinlines_group, LAYERS.SHAPE]
- ), *shapes
+ if self.is_transform
+ else LAYERS.NORMAL,
+ [self.pinlines_group, LAYERS.SHAPE],
+ ),
+ *shapes,
]
@cached_property
- def textbox_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
+ def textbox_shapes(self) -> list[ArtLayer | LayerSet | None]:
"""Optional Textbox cutout shapes."""
if self.is_transform and self.is_front:
- return [psd.getLayer(LAYERS.TRANSFORM_FRONT, [self.textbox_group, LAYERS.SHAPE])]
+ return [
+ psd.getLayer(LAYERS.TRANSFORM_FRONT, [self.textbox_group, LAYERS.SHAPE])
+ ]
return []
@cached_property
- def twins_shape(self) -> ArtLayer:
+ def twins_shape(self) -> ArtLayer | None:
"""Allow for both front and back Transform twins."""
if self.is_transform:
return psd.getLayer(
LAYERS.TRANSFORM_FRONT if self.is_front else LAYERS.TRANSFORM_BACK,
- [self.twins_group, LAYERS.SHAPE])
+ [self.twins_group, LAYERS.SHAPE],
+ )
# Normal twins
return psd.getLayer(LAYERS.NORMAL, [self.twins_group, LAYERS.SHAPE])
@cached_property
- def outline_shape(self) -> ArtLayer:
+ def outline_shape(self) -> ArtLayer | None:
"""Outline for textbox and art area."""
return psd.getLayer(
- LAYERS.TRANSFORM_FRONT if self.is_transform and self.is_front else LAYERS.NORMAL,
- LAYERS.OUTLINE)
+ LAYERS.TRANSFORM_FRONT
+ if self.is_transform and self.is_front
+ else LAYERS.NORMAL,
+ LAYERS.OUTLINE,
+ )
@cached_property
- def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
+ def enabled_shapes(self) -> list[ArtLayer | LayerSet | None]:
"""Add support for outline shape, multiple pinlines shapes, and saga shapes."""
return [
*self.pinlines_shapes,
@@ -475,7 +552,7 @@ def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
self.saga_trim_shape,
self.outline_shape,
self.border_shape,
- self.twins_shape
+ self.twins_shape,
]
"""
@@ -483,17 +560,36 @@ def enabled_shapes(self) -> list[Union[ArtLayer, LayerSet, None]]:
"""
@cached_property
- def pinlines_mask(self) -> list[ArtLayer]:
+ def pinlines_mask(
+ self,
+ ) -> (
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ):
"""Mask hiding pinlines effects inside textbox and art frame."""
- return [
- psd.getLayer(
- LAYERS.TRANSFORM_FRONT if self.is_transform and self.is_front else LAYERS.NORMAL,
- [self.mask_group, LAYERS.PINLINES]),
- self.pinlines_group
- ]
-
- @cached_property
- def enabled_masks(self) -> list[Union[dict, list, ArtLayer, LayerSet, None]]:
+ if self.pinlines_group and (
+ layer := psd.getLayer(
+ LAYERS.TRANSFORM_FRONT
+ if self.is_transform and self.is_front
+ else LAYERS.NORMAL,
+ [self.mask_group, LAYERS.PINLINES],
+ )
+ ):
+ return (layer, self.pinlines_group)
+
+ @cached_property
+ def enabled_masks(
+ self,
+ ) -> list[
+ MaskAction
+ | tuple[ArtLayer | LayerSet, ArtLayer | LayerSet]
+ | ArtLayer
+ | LayerSet
+ | None
+ ]:
"""Support a pinlines mask."""
return [self.pinlines_mask]
@@ -502,40 +598,46 @@ def enabled_masks(self) -> list[Union[dict, list, ArtLayer, LayerSet, None]]:
"""
def enable_transform_layers(self):
+ super().enable_transform_layers()
# Must enable Transform Icon group
- if self.transform_icon_layer:
- self.transform_icon_layer.parent.visible = True
- self.transform_icon_layer.visible = True
+ if self.transform_icon_layer and isinstance(
+ (parent := self.transform_icon_layer.parent), LayerSet
+ ):
+ parent.visible = True
"""
* Transform Text Layer Methods
"""
def text_layers_transform_back(self):
-
# Change back face name and typeline to white
- self.text_layer_name.textItem.color = psd.rgb_white()
- self.text_layer_type.textItem.color = psd.rgb_white()
+ if self.text_layer_name:
+ self.text_layer_name.textItem.color = psd.rgb_white()
+ if self.text_layer_type:
+ self.text_layer_type.textItem.color = psd.rgb_white()
class UniversesBeyondSagaTemplate(SagaVectorTemplate):
"""Saga Vector template with Universes Beyond frame treatment."""
- template_suffix = 'Universes Beyond'
+
+ template_suffix = "Universes Beyond"
# Color Maps
- pinlines_color_map = {
- **pinlines_color_map.copy(),
- 'W': [246, 247, 241],
- 'U': [0, 131, 193],
- 'B': [44, 40, 33],
- 'R': [237, 66, 31],
- 'G': [5, 129, 64],
- 'Gold': [239, 209, 107],
- 'Land': [165, 150, 132],
- 'Artifact': [227, 228, 230],
- 'Colorless': [227, 228, 230]
- }
+ @cached_property
+ def pinlines_color_map(self) -> dict[str, ColorObject]:
+ return {
+ **pinlines_color_map,
+ "W": (246, 247, 241),
+ "U": (0, 131, 193),
+ "B": (44, 40, 33),
+ "R": (237, 66, 31),
+ "G": (5, 129, 64),
+ "Gold": (239, 209, 107),
+ "Land": (165, 150, 132),
+ "Artifact": (227, 228, 230),
+ "Colorless": (227, 228, 230),
+ }
"""
* Colors
@@ -549,21 +651,23 @@ def textbox_colors(self) -> list[str]:
return [self.pinlines]
@cached_property
- def twins_colors(self) -> Optional[str]:
+ def twins_colors(
+ self,
+ ) -> ColorObject | Sequence[ColorObject] | Sequence[GradientConfig]:
"""Look for 'Beyond' variant texture."""
- return f'{self.twins} Beyond'
+ return f"{self.twins} Beyond"
"""
* Groups
"""
@cached_property
- def background_group(self) -> LayerSet:
+ def background_group(self) -> LayerSet | None:
"""Look for 'Beyond' variant group."""
- return psd.getLayerSet(f'{LAYERS.BACKGROUND} Beyond')
+ return psd.getLayerSet(f"{LAYERS.BACKGROUND} Beyond")
@cached_property
- def textbox_group(self) -> LayerSet:
+ def textbox_group(self) -> LayerSet | None:
"""Look for 'Beyond' variant group. Must be enabled."""
if group := psd.getLayerSet(f"{LAYERS.TEXTBOX} Beyond"):
group.visible = True
@@ -578,5 +682,7 @@ def enable_transform_layers(self):
# Switch to darker colors for back side
if not self.is_front:
- psd.getLayer(LAYERS.BACK, self.textbox_group).visible = True
- psd.getLayer(LAYERS.BACK, self.twins_group).visible = True
+ if layer := psd.getLayer(LAYERS.BACK, self.textbox_group):
+ layer.visible = True
+ if layer := psd.getLayer(LAYERS.BACK, self.twins_group):
+ layer.visible = True
diff --git a/src/templates/split.py b/src/templates/split.py
index 4f806ae9..3d302d3e 100644
--- a/src/templates/split.py
+++ b/src/templates/split.py
@@ -1,368 +1,456 @@
"""
* Templates: Split
"""
-# Standard Library Imports
+
+from _ctypes import COMError
+from collections.abc import Callable, Sequence
from functools import cached_property
+from logging import getLogger
from pathlib import Path
-from typing import Union, Optional
-# Third Party Imports
from omnitils.strings import normalize_str
-from photoshop.api import ElementPlacement, SolidColor, BlendMode
from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
+from photoshop.api.enumerations import BlendMode, ElementPlacement
-# Local Imports
-from src import CFG, CON, ENV
-from src.enums.layers import LAYERS
import src.helpers as psd
+from src import CON
+from src._state import WatermarkFormat
+from src.enums.layers import LAYERS
from src.helpers import LayerEffects
+from src.helpers.effects import copy_layer_fx
+from src.helpers.layers import get_reference_layer
from src.layouts import SplitLayout
from src.schema.adobe import EffectColorOverlay, EffectGradientOverlay
-from src.schema.colors import GradientColor
+from src.schema.colors import ColorObject, GradientColor, GradientConfig
from src.templates import BaseTemplate
-from src.text_layers import FormattedTextField, FormattedTextArea, ScaledTextField
+from src.text_layers import FormattedTextArea, FormattedTextField, ScaledTextField
from src.utils.adobe import ReferenceLayer
-"""
-* Template Classes
-"""
+_logger = getLogger(__name__)
-class SplitTemplate(BaseTemplate):
+class SplitMod(BaseTemplate):
"""
- * A template for split cards introduced in Invasion.
+ * A modifier class for split cards introduced in Invasion.
- Adds:
- * Must return all properties shared by both halves as a list of two items (left, right).
- * Must overwrite a lot of core functionality to navigate rendering 2 cards in one template.
-
- Todo:
- * Formalize as a vector template, implement 'Modifier' class.
+ Adds for each side:
+ * Art
+ * Text fields
+ * Color definitions
"""
- is_vehicle = False
- is_artifact = False
-
- # Color and gradient maps
- fuse_gradient_locations = {
- **CON.gradient_locations.copy(),
- 2: [.50, .54],
- 3: [.28, .33, .71, .76],
- 4: [.28, .33, .50, .54, .71, .76]
- }
- pinline_gradient_locations = [
- {**CON.gradient_locations.copy(), 2: [.28, .33]},
- {**CON.gradient_locations.copy(), 2: [.71, .76]}
- ]
-
- def __init__(self, layout: SplitLayout, **kwargs):
- super().__init__(layout, **kwargs)
- """
- * Mixin Methods
- """
+ sides: list[str] = [LAYERS.LEFT, LAYERS.RIGHT]
- @property
- def post_text_methods(self):
- """Rotate card sideways."""
- return [*super().post_text_methods, psd.rotate_counter_clockwise]
+ # refion Color maps
- """
- * Bool Properties
- """
+ @cached_property
+ def fuse_gradient_locations(self) -> dict[int, list[int | float]]:
+ return {
+ **CON.gradient_locations,
+ 2: [0.50, 0.54],
+ 3: [0.28, 0.33, 0.71, 0.76],
+ 4: [0.28, 0.33, 0.50, 0.54, 0.71, 0.76],
+ }
@cached_property
- def is_centered(self) -> list[bool]:
- """Allow centered text for each side independently."""
+ def pinline_gradient_locations(self) -> list[dict[int, list[int | float]]]:
return [
- bool(
- len(self.layout.flavor_text[i]) <= 1
- and len(self.layout.oracle_text[i]) <= 70
- and "\n" not in self.layout.oracle_text[i]
- ) for i in range(2)
+ {**CON.gradient_locations, 2: [0.28, 0.33]},
+ {**CON.gradient_locations, 2: [0.71, 0.76]},
]
+ # endregion Color maps
+
+ # region Settings
+
+ @cached_property
+ def color_limit(self) -> int:
+ """One more than the max number of colors this card can split by."""
+ return (
+ self.config.get_int_setting(section="COLORS", key="Max.Colors", default=2)
+ + 1
+ )
+
+ # endregion Settings
+
+ # region Checks
+
+ @cached_property
+ def is_split(self) -> bool:
+ return isinstance(self.layout, SplitLayout)
+
+ @cached_property
+ def text_centerings(self) -> list[bool]:
+ """Allow centered text for each side independently."""
+ if isinstance(self.layout, SplitLayout):
+ return [
+ bool(
+ len(self.layout.flavor_texts[i]) <= 1
+ and len(self.layout.oracle_texts[i]) <= 70
+ and "\n" not in self.layout.oracle_texts[i]
+ )
+ for i in range(len(self.sides))
+ ]
+ return []
+
@cached_property
def is_fuse(self) -> bool:
"""Determine if this is a 'Fuse' split card."""
- return bool('Fuse' in self.layout.keywords)
+ return bool("Fuse" in self.layout.keywords)
- """
- * Colors
- """
+ @cached_property
+ def has_unified_typeline(self) -> bool:
+ """Determine if the card has only one shared typeline."""
+ if isinstance(self.layout, SplitLayout):
+ return " Room" in self.layout.type_line
+ return False
+
+ # endregion Checks
+
+ # region Mixin methods
@cached_property
- def color_limit(self) -> int:
- """One more than the max number of colors this card can split by."""
- return 3
+ def frame_layer_methods(self) -> list[Callable[[], None]]:
+ methods = super().frame_layer_methods
+ if self.is_split:
+ methods.append(self.frame_layers_split)
+ return methods
+
+ @cached_property
+ def hooks(self) -> list[Callable[[], None]]:
+ """Rotate card sideways."""
+ methods = super().hooks
+ if self.is_split:
+ methods.append(psd.rotate_counter_clockwise)
+ return methods
+
+ # endregion Mixin methods
+
+ # region Colors
@cached_property
def fuse_pinlines(self) -> str:
"""Merged pinline colors of each side."""
- if self.pinlines[0] != self.pinlines[1]:
- return self.pinlines[0] + self.pinlines[1]
- return self.pinlines[0]
+ if isinstance(self.layout, SplitLayout):
+ if self.layout.pinline_identities[0] != self.layout.pinline_identities[1]:
+ return (
+ self.layout.pinline_identities[0]
+ + self.layout.pinline_identities[1]
+ )
+ return self.layout.pinline_identities[0]
+ raise NotImplementedError("Fuse colors doesn't support this layout.")
@cached_property
def fuse_textbox_colors(self) -> str:
- """Gold if Fuse colors are more than 3, otherwise use Fuse colors."""
- if len(self.fuse_pinlines) > 3:
+ """Gold if Fuse colors are more than color_limit, otherwise use Fuse colors."""
+ if len(self.fuse_pinlines) > self.color_limit:
return LAYERS.GOLD
return self.fuse_pinlines
@cached_property
- def fuse_pinline_colors(self) -> Union[list[int], list[dict]]:
+ def fuse_pinline_colors(
+ self,
+ ) -> ColorObject | list[ColorObject] | list[GradientConfig]:
"""Color definition for Fuse pinlines."""
return psd.get_pinline_gradient(
- self.fuse_pinlines, location_map=self.fuse_gradient_locations)
+ self.fuse_pinlines, location_map=self.fuse_gradient_locations
+ )
@cached_property
- def fuse_pinlines_action(self) -> Union[psd.create_color_layer, psd.create_gradient_layer]:
- """Action used to render Fuse pinlines."""
- return psd.create_color_layer if isinstance(
- self.fuse_pinline_colors, SolidColor
- ) else psd.create_gradient_layer
-
- @cached_property
- def pinlines_colors(self) -> list[Union[list[int], list[dict]]]:
+ def pinlines_colors_split(
+ self,
+ ) -> list[(ColorObject | Sequence[ColorObject] | Sequence[GradientConfig])]:
"""Color definitions used for pinlines of each side."""
- return [
- psd.get_pinline_gradient(p, location_map=self.pinline_gradient_locations[i])
- for i, p in enumerate(self.pinlines)]
+ if isinstance(self.layout, SplitLayout):
+ return [
+ psd.get_pinline_gradient(
+ p, location_map=self.pinline_gradient_locations[i]
+ )
+ for i, p in enumerate(self.layout.pinline_identities)
+ ]
+ return []
- @cached_property
- def pinlines_action(self) -> list[Union[psd.create_color_layer, psd.create_gradient_layer]]:
- """Action used to render the pinlines of each side."""
- return [
- psd.create_color_layer if isinstance(
- colors, SolidColor
- ) else psd.create_gradient_layer
- for colors in self.pinlines_colors]
+ # endregion Colors
- """
- * Layer Groups
- """
+ # region Groups
@cached_property
- def fuse_group(self) -> Optional[LayerSet]:
+ def fuse_group(self) -> LayerSet | None:
"""Fuse elements parent group."""
- return psd.getLayerSet('Fuse')
+ return psd.getLayerSet("Fuse")
@cached_property
- def fuse_textbox_group(self) -> Optional[LayerSet]:
+ def fuse_textbox_group(self) -> LayerSet | None:
"""Fuse textbox group."""
return psd.getLayerSet(LAYERS.TEXTBOX, self.fuse_group)
@cached_property
- def fuse_pinlines_group(self) -> Optional[LayerSet]:
+ def fuse_pinlines_group(self) -> LayerSet | None:
"""Fuse pinlines group."""
return psd.getLayerSet(LAYERS.PINLINES, self.fuse_group)
@cached_property
- def text_group(self) -> Optional[LayerSet]:
- """One text and icons group located in the 'Left' side group."""
- return psd.getLayerSet(LAYERS.TEXT_AND_ICONS, LAYERS.LEFT)
+ def text_groups(self) -> list[LayerSet | None]:
+ """Text and icons group for each side."""
+ return [psd.getLayerSet(LAYERS.TEXT_AND_ICONS, side) for side in self.sides]
@cached_property
- def card_groups(self):
+ def card_groups(self) -> list[LayerSet | None]:
"""Left and Right side parent groups."""
- return [
- psd.getLayerSet(LAYERS.LEFT),
- psd.getLayerSet(LAYERS.RIGHT)
- ]
+ return [psd.getLayerSet(side) for side in self.sides]
@cached_property
- def pinlines_groups(self) -> list[LayerSet]:
+ def pinlines_groups_split(self) -> list[LayerSet | None]:
"""Pinlines group for each side."""
return [psd.getLayerSet(LAYERS.PINLINES, group) for group in self.card_groups]
@cached_property
- def twins_groups(self) -> list[LayerSet]:
+ def twins_groups(self) -> list[LayerSet | None]:
"""Twins group for each side."""
return [psd.getLayerSet(LAYERS.TWINS, group) for group in self.card_groups]
@cached_property
- def textbox_groups(self) -> list[LayerSet]:
+ def textbox_groups(self) -> list[LayerSet | None]:
"""Textbox group for each side."""
return [psd.getLayerSet(LAYERS.TEXTBOX, group) for group in self.card_groups]
@cached_property
- def background_groups(self) -> list[LayerSet]:
+ def background_groups(self) -> list[LayerSet | None]:
"""Background group for each side."""
return [psd.getLayerSet(LAYERS.BACKGROUND, group) for group in self.card_groups]
- """
- * References
- """
-
- @cached_property
- def textbox_reference(self) -> list[ArtLayer]:
- """Textbox positioning reference for each side."""
- return [
- psd.get_reference_layer(
- LAYERS.TEXTBOX_REFERENCE + ' Fuse' if self.is_fuse else LAYERS.TEXTBOX_REFERENCE,
- psd.getLayerSet(LAYERS.TEXT_AND_ICONS, group)
- ) for group in self.card_groups]
+ # endregion Groups
- @cached_property
- def name_reference(self) -> list[ArtLayer]:
- """list[ArtLayer]: Name reference for each side."""
- return self.text_layer_name
+ # region Reference layers
@cached_property
- def type_reference(self) -> list[ArtLayer]:
- """list[ArtLayer]: Typeline reference for each side."""
- return [
- n or self.expansion_reference[i]
- for i, n in enumerate(self.expansion_symbols)]
+ def name_references(self) -> list[ReferenceLayer | None]:
+ """Name reference for each side."""
+ return [ReferenceLayer(layer) for layer in self.text_layers_mana]
@cached_property
- def twins_reference(self) -> list[ArtLayer]:
- """Twins positioning reference for each side."""
- return [psd.getLayer('Reference', [group, LAYERS.TWINS]) for group in self.card_groups]
+ def type_references(self) -> list[ReferenceLayer | None]:
+ """Typeline reference for each side."""
+ return self.expansion_references
@cached_property
- def background_reference(self) -> list[ArtLayer]:
- """Background positioning reference for each side."""
- return [psd.getLayer('Reference', [group, LAYERS.BACKGROUND]) for group in self.card_groups]
+ def expansion_reference(self) -> ArtLayer | None:
+ if self.is_split:
+ return self.expansion_references[-1 if self.has_unified_typeline else 0]
+ return super().expansion_reference
@cached_property
- def art_reference(self) -> list[ArtLayer]:
- """Art layer positioning reference for each side."""
- return [psd.getLayer(LAYERS.ART_FRAME, group) for group in self.card_groups]
-
- """
- * Text Layers
- """
-
- @cached_property
- def text_layer_name(self) -> list[ArtLayer]:
- """Name text layer for each side."""
- return [psd.getLayer(LAYERS.NAME, [self.card_groups[i], LAYERS.TEXT_AND_ICONS]) for i in range(2)]
-
- @cached_property
- def text_layer_rules(self) -> list[ArtLayer]:
- """Rules text layer for each side."""
- return [psd.getLayer(LAYERS.RULES_TEXT, [self.card_groups[i], LAYERS.TEXT_AND_ICONS]) for i in range(2)]
-
- @cached_property
- def text_layer_type(self) -> list[ArtLayer]:
- """Typeline text layer for each side."""
- return [psd.getLayer(LAYERS.TYPE_LINE, [self.card_groups[i], LAYERS.TEXT_AND_ICONS]) for i in range(2)]
+ def expansion_references(self) -> list[ReferenceLayer | None]:
+ """Expansion symbol reference for each side."""
+ return [
+ get_reference_layer(LAYERS.EXPANSION_REFERENCE, group)
+ for group in self.text_groups
+ ]
@cached_property
- def text_layer_mana(self) -> list[ArtLayer]:
- """Mana cost text layer for each side."""
- return [psd.getLayer(LAYERS.MANA_COST, [self.card_groups[i], LAYERS.TEXT_AND_ICONS]) for i in range(2)]
-
- """
- * Expansion Symbol
- """
+ def textbox_references(self) -> list[ReferenceLayer | None]:
+ """Textbox positioning reference for each side."""
+ return [
+ psd.get_reference_layer(
+ LAYERS.TEXTBOX_REFERENCE + " Fuse"
+ if self.is_fuse
+ else LAYERS.TEXTBOX_REFERENCE,
+ psd.getLayerSet(LAYERS.TEXT_AND_ICONS, group),
+ )
+ for group in self.card_groups
+ ]
@cached_property
- def expansion_references(self) -> list[ArtLayer]:
- """Expansion reference for each side."""
- return [self.expansion_reference, self.expansion_reference_right]
+ def background_reference(self) -> list[ArtLayer | None]:
+ """Background positioning reference for each side."""
+ return [
+ psd.getLayer("Reference", [group, LAYERS.BACKGROUND])
+ for group in self.card_groups
+ ]
@cached_property
- def expansion_reference_right(self) -> None:
- """Right side expansion symbol reference."""
- return psd.getLayer(LAYERS.EXPANSION_REFERENCE, [LAYERS.RIGHT, LAYERS.TEXT_AND_ICONS])
+ def art_references(self) -> list[ReferenceLayer | None]:
+ """Art layer positioning reference for each side."""
+ return [
+ get_reference_layer(LAYERS.ART_FRAME, group) for group in self.card_groups
+ ]
- @cached_property
- def expansion_symbols(self) -> list[Optional[ArtLayer]]:
- """Expansion symbol layers for each side. Right side is generated duplicating the left side."""
- if self.expansion_symbol_layer:
- layer = self.expansion_symbol_layer.duplicate(self.expansion_reference_right, ElementPlacement.PlaceAfter)
- psd.align_right(layer, self.expansion_reference_right)
- return [self.expansion_symbol_layer, layer]
- return [None, None]
+ # endregion Reference layers
- """
- * Layers
- """
+ # region Shape layers
@cached_property
- def art_layer(self) -> list[ArtLayer]:
+ def art_layers(self) -> list[ArtLayer | None]:
"""Art layer for each side."""
return [psd.getLayer(LAYERS.DEFAULT, group) for group in self.card_groups]
@cached_property
- def background_layer(self) -> list[ArtLayer]:
- """Background layer for each side."""
- return [psd.getLayer(b, LAYERS.BACKGROUND) for b in self.background]
+ def rules_text_dividers(self) -> list[ArtLayer | None]:
+ """Divider layer for each side."""
+ return [None] * len(self.sides)
- @cached_property
- def twins_layer(self) -> list[ArtLayer]:
- """Twins layer for each side."""
- return [psd.getLayer(t, LAYERS.TWINS) for t in self.twins]
+ # endregion Shape layers
+
+ # region Text layers
@cached_property
- def textbox_layer(self) -> list[ArtLayer]:
- """Textbox layer for each side."""
- return [psd.getLayer(t, LAYERS.TEXTBOX) for t in self.pinlines]
+ def text_layers_name(self) -> list[ArtLayer | None]:
+ """Name text layer for each side."""
+ return [psd.getLayer(LAYERS.NAME, [group]) for group in self.text_groups]
@cached_property
- def divider_layer(self) -> list[Optional[ArtLayer]]:
- """Divider layer for each side. List updated if either side has flavor text."""
- return [None, None]
+ def text_layers_rules(self) -> list[ArtLayer | None]:
+ """Rules text layer for each side."""
+ return [psd.getLayer(LAYERS.RULES_TEXT, [group]) for group in self.text_groups]
- """
- * Blending Masks
- """
+ @cached_property
+ def text_layers_type(self) -> list[ArtLayer | None]:
+ """Typeline text layer for each side."""
+ return [psd.getLayer(LAYERS.TYPE_LINE, [group]) for group in self.text_groups]
@cached_property
- def mask_layers(self) -> list[ArtLayer]:
- """Blending masks supported by this template."""
- return [psd.getLayer(LAYERS.HALF, LAYERS.MASKS)]
+ def text_layers_mana(self) -> list[ArtLayer | None]:
+ """Mana cost text layer for each side."""
+ return [psd.getLayer(LAYERS.MANA_COST, [group]) for group in self.text_groups]
+
+ # endregion Text layers
+
+ # region Expansion Symbol
+
+ def load_expansion_symbol(self) -> None:
+ super().load_expansion_symbol()
+ if self.is_split:
+ self.expansion_symbols
+
+ @cached_property
+ def expansion_symbols(self) -> list[ArtLayer | None]:
+ """Expansion symbol layer for each side."""
+ if not self.has_unified_typeline and self.expansion_symbol_layer:
+ symbols: list[ArtLayer | None] = [self.expansion_symbol_layer]
+ for layer_ref in self.expansion_references[1:]:
+ if layer_ref:
+ symbol_duplicate = self.expansion_symbol_layer.duplicate(
+ layer_ref, ElementPlacement.PlaceAfter
+ )
+ psd.align_right(symbol_duplicate, layer_ref)
+ try:
+ copy_layer_fx(self.expansion_symbol_layer, symbol_duplicate)
+ except COMError:
+ pass
+ symbols.append(symbol_duplicate)
+ else:
+ symbols.append(None)
+ return symbols
+ return [self.expansion_symbol_layer]
+
+ # endregion Expansion Symbol
+
+ # region Artwork
- """
- * Watermarks
- """
+ def load_artwork(
+ self,
+ art_file: str | Path | None = None,
+ art_layer: ArtLayer | None = None,
+ art_reference: ReferenceLayer | None = None,
+ ) -> None:
+ if not self.is_fullart and self.is_split:
+ return self.load_artworks([art_file] if art_file else None)
+ else:
+ return super().load_artwork(art_file, art_layer, art_reference)
- @cached_property
- def watermark_colors(self) -> list[list[SolidColor]]:
+ def load_artworks(
+ self,
+ art_files: list[str | Path] | None = None,
+ art_layers: list[ArtLayer | None] | None = None,
+ art_references: list[ReferenceLayer | None] | None = None,
+ ) -> None:
+ """Loads art for each side."""
+
+ if isinstance(self.layout, SplitLayout):
+ # Set default values
+ art_files = art_files or [*self.layout.art_files]
+ art_layers = art_layers or self.art_layers
+ art_references = art_references or self.art_references
+
+ # Second art not provided
+ if len(art_files) == 1:
+ # Manually select a second art
+ _logger.info("Please select the second split art!")
+ file: list[Path] = self.app.openDialog()
+ if not file:
+ _logger.info("No art selected, cancelling render.")
+ return
+
+ # Place new art in the correct order
+ if normalize_str(self.layout.names[0]) == normalize_str(
+ self.layout.file["name"]
+ ):
+ art_files.append(file[0])
+ else:
+ art_files.insert(0, file[0])
+
+ # Load art for each side
+ for i, (art_file, layer, ref) in enumerate(
+ zip(art_files, art_layers, art_references)
+ ):
+ if art_file and layer and ref:
+ super().load_artwork(
+ art_file=art_files[i],
+ art_layer=art_layers[i],
+ art_reference=ref,
+ )
+
+ # endregion Artwork
+
+ # region Watermarks
+
+ @cached_property
+ def watermarks_colors(self) -> list[list[ColorObject]]:
"""A list of 'SolidColor' objects for each face."""
- colors = []
- for i, pinline in enumerate(self.pinlines):
- if pinline in self.watermark_color_map:
- # Named pinline colors
- colors.append([self.watermark_color_map.get(pinline, self.RGB_WHITE)])
- elif len(self.identity[i]) < 3:
- # Dual color based on identity
- colors.append([
- self.watermark_color_map.get(c, self.RGB_WHITE)
- for c in self.identity[i]])
- else:
- colors.append([])
- return colors
-
- @cached_property
- def watermark_fx(self) -> list[list[LayerEffects]]:
+ if isinstance(self.layout, SplitLayout):
+ colors: list[list[ColorObject]] = []
+ for i, pinline in enumerate(self.layout.pinline_identities):
+ if pinline in self.watermark_color_map:
+ # Named pinline colors
+ colors.append(
+ [self.watermark_color_map.get(pinline, self.RGB_WHITE)]
+ )
+ elif len(self.identity[i]) < self.color_limit:
+ # Dual color based on identity
+ colors.append(
+ [
+ self.watermark_color_map.get(c, self.RGB_WHITE)
+ for c in self.identity[i]
+ ]
+ )
+ else:
+ colors.append([])
+ raise NotImplementedError("Watermarks colors doesn't support this layout.")
+
+ @cached_property
+ def watermark_fxs(self) -> list[list[LayerEffects]]:
"""A list of LayerEffects' objects for each face."""
fx: list[list[LayerEffects]] = []
- for color in self.watermark_colors:
+ for color in self.watermarks_colors:
if len(color) == 1:
# Single color watermark
- fx.append([EffectColorOverlay(
- opacity=100,
- color=color[0]
- )])
+ fx.append([EffectColorOverlay(opacity=100, color=color[0])])
elif len(color) == 2:
# Dual color watermark
- fx.append([EffectGradientOverlay(
- rotation=0,
- colors=[
- GradientColor(
- color=color[0],
- location=0,
- midpoint=50),
- GradientColor(
- color=color[1],
- location=4096,
- midpoint=50)
+ fx.append(
+ [
+ EffectGradientOverlay(
+ rotation=0,
+ colors=[
+ GradientColor(color=color[0], location=0, midpoint=50),
+ GradientColor(
+ color=color[1], location=4096, midpoint=50
+ ),
+ ],
+ )
]
- )])
+ )
else:
fx.append([])
return fx
@@ -370,174 +458,236 @@ def watermark_fx(self) -> list[list[LayerEffects]]:
def create_watermark(self) -> None:
"""Render a watermark for each side that has one."""
- # Add watermark to each side if needed
- for i, watermark in enumerate(self.layout.watermark):
-
- # Required values to generate a Watermark
- if not all([
- self.layout.watermark_svg[i],
- self.textbox_reference[i],
- self.watermark_colors[i],
- watermark
- ]):
- return
-
- # Get watermark custom settings if available
- wm_details = CON.watermarks.get(watermark, {})
-
- # Import and frame the watermark
- wm = psd.import_svg(
- path=self.layout.watermark_svg[i],
- ref=self.textbox_reference[i],
- placement=ElementPlacement.PlaceAfter,
- docref=self.docref)
- psd.frame_layer(
- layer=wm,
- ref=self.textbox_reference[i],
- smallest=True,
- scale=wm_details.get('scale', 80))
-
- # Apply opacity, blending, and effects
- wm.opacity = wm_details.get('opacity', CFG.watermark_opacity)
- wm.blendMode = BlendMode.ColorBurn
- psd.apply_fx(wm, self.watermark_fx[i])
+ if isinstance(self.layout, SplitLayout):
+ # Add watermark to each side if needed
+ for i, watermark in enumerate(self.layout.watermarks):
+ # Required values to generate a Watermark
+ if (
+ watermark
+ and (mark := self.layout.watermark_svgs[i])
+ and (textbox_ref := self.textbox_references[i])
+ ):
+ # Get watermark custom settings if available
+ wm_details: WatermarkFormat | dict[str, int] = CON.watermarks.get(
+ watermark, {}
+ )
+
+ # Import and frame the watermark
+ wm = psd.import_svg(
+ path=mark,
+ ref=textbox_ref,
+ placement=ElementPlacement.PlaceAfter,
+ docref=self.docref,
+ )
+ psd.frame_layer(
+ layer=wm,
+ ref=textbox_ref,
+ smallest=True,
+ scale=wm_details.get("scale", 80),
+ )
+
+ # Apply opacity, blending, and effects
+ wm.opacity = wm_details.get(
+ "opacity", self.config.watermark_opacity
+ )
+ wm.blendMode = BlendMode.ColorBurn
+ psd.apply_fx(wm, self.watermark_fxs[i])
+ else:
+ return super().create_watermark()
+
+ # endregion Watermarks
+
+ # region Frame layer methods
+
+ def frame_layers_split(self) -> None:
+ """Enable frame layers required by Split cards. None by default."""
+ pass
+
+ # endregion Frame layer methods
+
+ # region Text layer methods
+ def basic_text_layers(self) -> None:
+ """Add basic text layers for each side."""
+ if isinstance(self.layout, SplitLayout):
+ for i in range(len(self.sides)):
+ if (name := self.text_layers_name[i]) and (
+ mana := self.text_layers_mana[i]
+ ):
+ self.text.extend(
+ [
+ FormattedTextField(
+ layer=mana,
+ contents=self.layout.mana_costs[i],
+ ),
+ ScaledTextField(
+ layer=name,
+ contents=self.layout.names[i],
+ reference=self.name_references[i],
+ ),
+ ]
+ )
+
+ if (not self.has_unified_typeline or i == 0) and (
+ layer := self.text_layers_type[i]
+ ):
+ self.text.append(
+ ScaledTextField(
+ layer=layer,
+ contents=self.layout.type_lines[i],
+ reference=self.type_references[
+ -1 if self.has_unified_typeline else i
+ ],
+ )
+ )
+ else:
+ return super().basic_text_layers()
+
+ def rules_text_and_pt_layers(self) -> None:
+ """Add rules and P/T text for each side."""
+ if isinstance(self.layout, SplitLayout):
+ for i in range(len(self.sides)):
+ if layer := self.text_layers_rules[i]:
+ self.text.append(
+ FormattedTextArea(
+ layer=layer,
+ contents=self.layout.oracle_texts[i],
+ flavor=self.layout.flavor_texts[i],
+ reference=self.textbox_references[i],
+ divider=self.rules_text_dividers[i],
+ centered=self.text_centerings[i],
+ )
+ )
+ else:
+ return super().rules_text_and_pt_layers()
+
+ # endregion Text layer methods
+
+
+class SplitTemplate(SplitMod):
"""
- * Loading Files
+ * A template for split cards.
+
+ In addition to SplitMod, adds for each side:
+ * Frame layers
"""
- def load_artwork(
- self,
- art_file: Optional[str | Path | list[str | Path]] = None,
- art_layer: Optional[list[ArtLayer]] = None,
- art_reference: Optional[list[ReferenceLayer]] = None
- ) -> None:
- """Loads the specified art file into the specified layer.
-
- Args:
- art_file: Optional path (as str or Path) to art file. Will use `self.layout.art_file`
- if not provided.
- art_layer: Optional `ArtLayer` where art image should be placed when imported. Will use `self.art_layer`
- property if not provided.
- art_reference: Optional `ReferenceLayer` that should be used to position and scale the imported
- image. Will use `self.art_reference` property if not provided.`
- """
-
- # Set default values
- art_file = art_file or self.layout.art_file
- art_layer = art_layer or self.art_layer
- art_reference = art_reference or self.art_reference
-
- # Double up art for test mode
- if ENV.TEST_MODE and not isinstance(art_file, list):
- art_file = [art_file] * 2
-
- # Second art not provided
- if len(art_file) == 1:
-
- # Manually select a second art
- self.console.update("Please select the second split art!")
- file = self.app.openDialog()
- if not file:
- self.console.update('No art selected, cancelling render.')
- self.console.cancel_thread(thr=self.event)
- return
-
- # Place new art in the correct order
- if normalize_str(self.layout.name[0]) == normalize_str(self.layout.file['name']):
- art_file.append(file[0])
- else:
- art_file.insert(0, file[0])
+ # region Reference layers
- # Load art for each side
- for i, ref in enumerate(art_reference):
- super().load_artwork(
- art_file=art_file[i],
- art_layer=art_layer[i],
- art_reference=ref)
+ @cached_property
+ def twins_references(self) -> list[ArtLayer | None]:
+ """Twins positioning reference for each side."""
+ return [
+ psd.getLayer("Reference", [group, LAYERS.TWINS])
+ for group in self.card_groups
+ ]
- """
- * Frame Layer Methods
- """
+ # endregion Reference layers
- def enable_frame_layers(self) -> None:
- """Enable frame layers for each side. Add Fuse layers if required."""
+ # region Shape layers
- # Frame layers
- for i in range(2):
- # Copy twins and position
- self.twins_layer[i].visible = True
- twins = self.twins_layer[i].parent.duplicate(self.twins_groups[i], ElementPlacement.PlaceBefore)
- self.twins_layer[i].visible = False
- twins.visible = True
- psd.align_horizontal(twins, self.twins_reference[i])
-
- # Copy background and position
- background = self.background_layer[i].duplicate(
- self.background_groups[i], ElementPlacement.PlaceInside)
- background.visible = True
- psd.align_horizontal(background, self.background_reference[i])
-
- # Copy textbox and position
- textbox = self.textbox_layer[i].duplicate(
- self.textbox_groups[i], ElementPlacement.PlaceInside)
- textbox.visible = True
- self.active_layer = textbox
- psd.align_horizontal(textbox, self.textbox_reference[i].dims)
- if self.is_fuse:
- psd.select_bounds(self.textbox_reference[i].bounds, self.doc_selection)
- self.doc_selection.invert()
- self.doc_selection.clear()
- self.doc_selection.deselect()
-
- # Apply pinlines
- self.generate_layer(
- group=self.pinlines_groups[i],
- colors=self.pinlines_colors[i])
-
- # Fuse addone
- if self.is_fuse:
- psd.getLayer(f'{LAYERS.BORDER} Fuse').visible = True
- self.fuse_group.visible = True
- self.generate_layer(
- group=self.fuse_pinlines_group,
- colors=self.fuse_pinline_colors)
- self.generate_layer(
- group=self.fuse_textbox_group,
- colors=self.fuse_textbox_colors,
- masks=self.mask_layers)
+ @cached_property
+ def background_layers(self) -> list[ArtLayer | None]:
+ """Background layer for each side."""
+ if isinstance(self.layout, SplitLayout):
+ return [psd.getLayer(b, LAYERS.BACKGROUND) for b in self.layout.backgrounds]
+ return [None] * len(self.sides)
- """
- * Text Layer Methods
- """
+ @cached_property
+ def twins_layers(self) -> list[ArtLayer | None]:
+ """Twins layer for each side."""
+ if isinstance(self.layout, SplitLayout):
+ return [psd.getLayer(t, LAYERS.TWINS) for t in self.layout.twins_identities]
+ return [None] * len(self.sides)
- def basic_text_layers(self) -> None:
- """Add basic text layers for each side."""
- for i in range(2):
- self.text.extend([
- FormattedTextField(
- layer=self.text_layer_mana[i],
- contents=self.layout.mana_cost[i]
- ),
- ScaledTextField(
- layer=self.text_layer_name[i],
- contents=self.layout.name[i],
- reference=self.name_reference[i]
- ),
- ScaledTextField(
- layer=self.text_layer_type[i],
- contents=self.layout.type_line[i],
- reference=self.type_reference[i]
- )])
+ @cached_property
+ def textbox_layers(self) -> list[ArtLayer | None]:
+ """Textbox layer for each side."""
+ if isinstance(self.layout, SplitLayout):
+ return [
+ psd.getLayer(t, LAYERS.TEXTBOX) for t in self.layout.pinline_identities
+ ]
+ return [None] * len(self.sides)
- def rules_text_and_pt_layers(self) -> None:
- """Add rules and P/T text for each face."""
- for i in range(2):
- self.text.append(
- FormattedTextArea(
- layer=self.text_layer_rules[i],
- contents=self.layout.oracle_text[i],
- flavor=self.layout.flavor_text[i],
- reference=self.textbox_reference[i],
- divider=self.divider_layer[i],
- centered=self.is_centered[i]))
+ # endregion Shape layers
+
+ # region Blending masks
+
+ @cached_property
+ def mask_layers(self) -> list[ArtLayer]:
+ """Blending masks supported by this template."""
+ if self.is_split:
+ if layer := psd.getLayer(LAYERS.HALF, LAYERS.MASKS):
+ return [layer]
+ return []
+ return super().mask_layers
+
+ # endregion Blending masks
+
+ # region Frame layer methods
+
+ def frame_layers_split(self) -> None:
+ """Enable frame layers for each side. Add Fuse layers if required."""
+
+ # Frame layers
+ for i in range(len(self.sides)):
+ if (group := self.twins_groups[i]) and (layer := self.twins_layers[i]):
+ parent = layer.parent
+ if isinstance(parent, LayerSet):
+ # Copy twins and position
+ layer.visible = True
+ twins = parent.duplicate(group, ElementPlacement.PlaceBefore)
+ layer.visible = False
+ twins.visible = True
+
+ if ref := self.twins_references[i]:
+ psd.align_horizontal(twins, ref)
+
+ if layer := self.background_layers[i]:
+ # Copy background and position
+ background = layer.duplicate(
+ self.background_groups[i], ElementPlacement.PlaceInside
+ )
+ background.visible = True
+
+ if ref := self.background_reference[i]:
+ psd.align_horizontal(background, ref)
+
+ if layer := self.textbox_layers[i]:
+ # Copy textbox and position
+ textbox = layer.duplicate(
+ self.textbox_groups[i], ElementPlacement.PlaceInside
+ )
+ textbox.visible = True
+
+ if ref := self.textbox_references[i]:
+ self.active_layer = textbox
+ psd.align_horizontal(textbox, ref.dims)
+ if self.is_fuse:
+ psd.select_bounds(ref.bounds, self.doc_selection)
+ self.doc_selection.invert()
+ self.doc_selection.clear()
+ self.doc_selection.deselect()
+
+ if group := self.pinlines_groups_split[i]:
+ # Apply pinlines
+ self.generate_layer(group=group, colors=self.pinlines_colors_split[i])
+
+ # Fuse addon
+ if self.is_fuse:
+ if layer := psd.getLayer(f"{LAYERS.BORDER} Fuse"):
+ layer.visible = True
+ if self.fuse_group:
+ self.fuse_group.visible = True
+ if self.fuse_pinlines_group:
+ self.generate_layer(
+ group=self.fuse_pinlines_group, colors=self.fuse_pinline_colors
+ )
+ if self.fuse_textbox_group:
+ self.generate_layer(
+ group=self.fuse_textbox_group,
+ colors=self.fuse_textbox_colors,
+ masks=self.mask_layers,
+ )
+
+ # endregion Frame layer methods
diff --git a/src/templates/station.py b/src/templates/station.py
new file mode 100644
index 00000000..82609995
--- /dev/null
+++ b/src/templates/station.py
@@ -0,0 +1,247 @@
+from collections.abc import Callable
+from functools import cached_property
+
+from photoshop.api._artlayer import ArtLayer
+from photoshop.api._layerSet import LayerSet
+
+from src.enums.layers import LAYERS
+from src.helpers.bounds import (
+ get_group_dimensions,
+ get_layer_dimensions,
+ get_layer_height,
+)
+from src.helpers.layers import duplicate_group, getLayer, getLayerSet
+from src.helpers.position import spread_layers_over_reference
+from src.helpers.text import scale_text_layers_to_height
+from src.layouts import StationLayout
+from src.templates._core import NormalTemplate
+from src.text_layers import FormattedTextField
+
+
+class StationMod(NormalTemplate):
+ """
+ A template modifier for Station cards introduced in Edge of Eternities.
+
+ Adds:
+ * Station requirement, ability and P/T texts and shapes.
+ """
+
+ # region Checks
+
+ @cached_property
+ def is_station(self) -> bool:
+ """Checks if this card uses Station layout"""
+ return isinstance(self.layout, StationLayout)
+
+ @cached_property
+ def is_centered(self) -> bool:
+ if self.is_station:
+ return False
+ return super().is_centered
+
+ # endregion Checks
+
+ # region Options
+
+ @cached_property
+ def rules_text_gap(self) -> int | float:
+ return 64
+
+ # endregion Options
+
+ # region Groups
+
+ @cached_property
+ def station_group(self) -> LayerSet | None:
+ return getLayerSet(LAYERS.STATION)
+
+ @cached_property
+ def station_level_base_group(self) -> LayerSet | None:
+ return getLayerSet(LAYERS.LEVEL, self.station_group)
+
+ @cached_property
+ def station_level_groups(self) -> list[LayerSet]:
+ groups: list[LayerSet] = []
+ if self.station_level_base_group:
+ if isinstance(self.layout, StationLayout):
+ for i in range(len(self.layout.stations) - 1):
+ groups.append(
+ duplicate_group(
+ self.station_level_base_group,
+ f"{self.station_level_base_group.name} {i}",
+ )
+ )
+ groups.append(self.station_level_base_group)
+ return groups
+
+ @cached_property
+ def station_requirement_groups(self) -> list[LayerSet]:
+ groups: list[LayerSet] = []
+ for level_group in self.station_level_groups:
+ if group := getLayerSet(LAYERS.REQUIREMENT, level_group):
+ groups.append(group)
+ return groups
+
+ @cached_property
+ def station_pt_groups(self) -> list[LayerSet]:
+ groups: list[LayerSet] = []
+ for level_group in self.station_level_groups:
+ if group := getLayerSet(LAYERS.PT_BOX, level_group):
+ groups.append(group)
+ return groups
+
+ # endregion Groups
+
+ # region Text layers
+
+ @cached_property
+ def station_requirement_text_layers(self) -> list[ArtLayer]:
+ layers: list[ArtLayer] = []
+ for requirement_group in self.station_requirement_groups:
+ if layer := getLayer(LAYERS.TEXT, requirement_group):
+ layers.append(layer)
+ return layers
+
+ @cached_property
+ def station_level_text_layers(self) -> list[ArtLayer]:
+ layers: list[ArtLayer] = []
+ if isinstance(self.layout, StationLayout):
+ for details, level_group in zip(
+ self.layout.stations, self.station_level_groups
+ ):
+ if layer := getLayer(
+ LAYERS.RULES_TEXT_CREATURE
+ if "pt" in details
+ else LAYERS.RULES_TEXT,
+ level_group,
+ ):
+ layers.append(layer)
+ return layers
+
+ @cached_property
+ def station_pt_text_layers(self) -> list[ArtLayer]:
+ layers: list[ArtLayer] = []
+ for pt_group in self.station_pt_groups:
+ if layer := getLayer(LAYERS.TEXT, pt_group):
+ layers.append(layer)
+ return layers
+
+ # endregion Text layers
+
+ # region Mixin methods
+
+ @cached_property
+ def text_layer_methods(self) -> list[Callable[[], None]]:
+ """Add Station text layers."""
+ funcs = super().text_layer_methods
+ if self.is_station:
+ funcs.append(self.text_layers_station)
+ return funcs
+
+ @cached_property
+ def frame_layer_methods(self) -> list[Callable[[], None]]:
+ """Add Station frame layers."""
+ funcs = super().frame_layer_methods
+ if self.is_station:
+ funcs.append(self.frame_layers_station)
+ return funcs
+
+ @cached_property
+ def post_text_methods(self) -> list[Callable[[], None]]:
+ """Position Station abilities."""
+ funcs = super().post_text_methods
+ if self.is_station:
+ funcs.append(self.layer_positioning_station)
+ return funcs
+
+ # endregion Mixin methods
+
+ # region Text layer methods
+
+ def text_layers_station(self) -> None:
+ """Add and modify text layers relating to Station cards."""
+ if isinstance(self.layout, StationLayout):
+ for details, ability, requirement, pt in zip(
+ self.layout.stations,
+ self.station_level_text_layers,
+ self.station_requirement_text_layers,
+ self.station_pt_text_layers,
+ ):
+ self.text.append(
+ FormattedTextField(layer=ability, contents=details["ability"])
+ )
+ requirement.textItem.contents = details["requirement"]
+ if "pt" in details:
+ pt.textItem.contents = (
+ f"{details['pt']['power']}/{details['pt']['toughness']}"
+ )
+
+ # endregion Text layer methods
+
+ # region Frame layer methods
+
+ def frame_layers_station(self) -> None:
+ """Enable frame layers required by Station cards."""
+ if self.station_group:
+ self.station_group.visible = True
+
+ if isinstance(self.layout, StationLayout):
+ for details, group in zip(self.layout.stations, self.station_pt_groups):
+ if "pt" in details:
+ group.visible = True
+
+ # endregion Frame layer methods
+
+ # region Positioning methods
+
+ def align_center_ys(self, group: LayerSet, ref: ArtLayer) -> None:
+ dims = get_group_dimensions(group)
+ dims_ref = get_layer_dimensions(ref)
+ group.translate(0, dims_ref["center_y"] - dims["center_y"])
+
+ def layer_positioning_station(self) -> None:
+ """Positions and sizes Station ability layers."""
+ if (
+ isinstance(self.layout, StationLayout)
+ and self.textbox_reference
+ and self.text_layer_rules
+ ):
+ spacing = self.app.scale_by_dpi(self.rules_text_gap)
+ spaces = len(self.layout.stations) + 1
+ ref_height = self.textbox_reference.dims["height"]
+ spacing_total = spaces * spacing + spacing
+ total_height = ref_height - spacing_total
+
+ ability_layers = [self.text_layer_rules, *self.station_level_text_layers]
+
+ # Resize text items till they fit in the available space
+ scale_text_layers_to_height(
+ text_layers=ability_layers,
+ ref_height=total_height,
+ )
+
+ # Get the exact gap between each layer left over
+ layer_heights = sum([get_layer_height(lyr) for lyr in ability_layers])
+ gap = (ref_height - layer_heights) * (spacing / spacing_total)
+ inside_gap = (ref_height - layer_heights) * (spacing / spacing_total)
+
+ # Space lines evenly apart
+ spread_layers_over_reference(
+ layers=ability_layers,
+ ref=self.textbox_reference,
+ gap=gap,
+ inside_gap=inside_gap,
+ )
+
+ # Shift requirement and pt elements
+ for details, level_text, requirement, pt in zip(
+ self.layout.stations,
+ self.station_level_text_layers,
+ self.station_requirement_groups,
+ self.station_pt_groups,
+ ):
+ self.align_center_ys(requirement, level_text)
+ if "pt" in details:
+ self.align_center_ys(pt, level_text)
+
+ # endregion Positioning methods
diff --git a/src/templates/token.py b/src/templates/token.py
index 547d5ac6..dc4c0b7d 100644
--- a/src/templates/token.py
+++ b/src/templates/token.py
@@ -2,22 +2,19 @@
* Templates: Token
* Treated as 'Normal' templates, separated for better organization.
"""
-# Standard Library Imports
+
+from collections.abc import Callable
from functools import cached_property
-from typing import Optional
-# Third Party Imports
-from omnitils.strings import is_multiline
-from photoshop.api.application import ArtLayer
+from photoshop.api._artlayer import ArtLayer
from photoshop.api._layerSet import LayerSet
-# Local Imports
+import src.helpers as psd
+import src.text_layers as text_classes
from src import CON
from src.enums.layers import LAYERS
-import src.helpers as psd
from src.templates._core import StarterTemplate
from src.templates._cosmetic import FullartMod
-import src.text_layers as text_classes
from src.utils.adobe import ReferenceLayer
"""
@@ -36,14 +33,15 @@ class TokenTemplate(FullartMod, StarterTemplate):
Modifies:
* Only supports a singular frame layer which is the Background layer.
"""
- frame_suffix = 'Token'
+
+ frame_suffix = "Token"
"""
* Mixin Methods
"""
- @property
- def post_text_methods(self):
+ @cached_property
+ def post_text_methods(self) -> list[Callable[[], None]]:
"""Make some text positioning adjustments after text is formatted."""
return [*super().post_text_methods, self.text_adjustments]
@@ -52,20 +50,23 @@ def post_text_methods(self):
"""
@cached_property
- def background_layer(self) -> ArtLayer:
+ def background_layer(self) -> ArtLayer | None:
"""ArtLayer: Background governed by Legendary and creature checks."""
- return psd.getLayer(self.layout.pinlines, [
- LAYERS.FRAME,
- LAYERS.LEGENDARY if self.is_legendary else LAYERS.NON_LEGENDARY,
- LAYERS.CREATURE if self.is_creature else LAYERS.NON_CREATURE
- ])
+ return psd.getLayer(
+ self.layout.pinlines,
+ [
+ LAYERS.FRAME,
+ LAYERS.LEGENDARY if self.is_legendary else LAYERS.NON_LEGENDARY,
+ LAYERS.CREATURE if self.is_creature else LAYERS.NON_CREATURE,
+ ],
+ )
"""
* Groups
"""
@cached_property
- def textbox_group(self) -> LayerSet:
+ def textbox_group(self) -> LayerSet | None:
"""A group containing background, text, and reference based upon oracle text requirements."""
# Decide the textbox size
@@ -76,7 +77,9 @@ def textbox_group(self) -> LayerSet:
if all([self.layout.oracle_text, self.layout.flavor_text]):
# Both rules and flavor text
group = LAYERS.FULL
- if any(is_multiline([self.layout.oracle_text, self.layout.flavor_text])):
+ if any(
+ "\n" in text for text in (self.layout.oracle_text, self.layout.flavor_text)
+ ):
# Multi-line text
group = LAYERS.FULL
if len(self.layout.oracle_text) > 50:
@@ -84,49 +87,53 @@ def textbox_group(self) -> LayerSet:
group = LAYERS.FULL
# Enable and return group
- group = psd.getLayerSet(group, LAYERS.TEXTBOX)
- group.visible = True
- return group
+ if group := psd.getLayerSet(group, LAYERS.TEXTBOX):
+ group.visible = True
+ return group
"""
* References
"""
@cached_property
- def textbox_reference(self) -> ReferenceLayer:
+ def textbox_reference(self) -> ReferenceLayer | None:
"""Pull from the textbox group."""
return psd.get_reference_layer(LAYERS.TEXTBOX_REFERENCE, self.textbox_group)
@cached_property
- def type_line_reference(self) -> ReferenceLayer:
+ def type_line_reference(self) -> ReferenceLayer | None:
"""Reference to scale the TypeLine."""
return psd.get_reference_layer(LAYERS.TYPE_LINE_REFERENCE, self.textbox_group)
@cached_property
- def name_reference(self) -> ReferenceLayer:
+ def name_reference(self) -> ReferenceLayer | None:
"""Reference to scale the Card Name"""
if self.is_legendary:
- return psd.get_reference_layer(LAYERS.NAME_REFERENCE_LEGENDARY, self.text_group)
- return psd.get_reference_layer(LAYERS.NAME_REFERENCE_NON_LEGENDARY, self.text_group)
+ return psd.get_reference_layer(
+ LAYERS.NAME_REFERENCE_LEGENDARY, self.text_group
+ )
+ return psd.get_reference_layer(
+ LAYERS.NAME_REFERENCE_NON_LEGENDARY, self.text_group
+ )
"""
* Text Layers
"""
@cached_property
- def text_layer_type(self) -> ArtLayer:
+ def text_layer_type(self) -> ArtLayer | None:
"""Pull from the textbox group."""
return psd.getLayer(LAYERS.TYPE_LINE, self.textbox_group)
@cached_property
- def text_layer_rules(self) -> Optional[ArtLayer]:
+ def text_layer_rules(self) -> ArtLayer | None:
"""Full textbox group has both creature and non-creature option."""
- if self.textbox_group.name == LAYERS.FULL:
+ if self.textbox_group and self.textbox_group.name == LAYERS.FULL:
return psd.getLayer(
- LAYERS.RULES_TEXT_CREATURE if (
- self.is_creature
- ) else LAYERS.RULES_TEXT_NONCREATURE,
- self.textbox_group
+ LAYERS.RULES_TEXT_CREATURE
+ if (self.is_creature)
+ else LAYERS.RULES_TEXT_NONCREATURE,
+ self.textbox_group,
)
return psd.getLayer(LAYERS.RULES_TEXT, self.textbox_group)
@@ -145,18 +152,22 @@ def enable_frame_layers(self) -> None:
def basic_text_layers(self) -> None:
"""Don't include the mana cost in basic text layers."""
- self.text.extend([
- text_classes.ScaledWidthTextField(
- layer=self.text_layer_name,
- contents=self.layout.name,
- reference=self.name_reference
- ),
- text_classes.ScaledWidthTextField(
- layer=self.text_layer_type,
- contents=self.layout.type_line,
- reference=self.type_reference
+ if self.text_layer_name:
+ self.text.append(
+ text_classes.ScaledWidthTextField(
+ layer=self.text_layer_name,
+ contents=self.layout.name,
+ reference=self.name_reference,
+ )
+ )
+ if self.text_layer_type:
+ self.text.append(
+ text_classes.ScaledWidthTextField(
+ layer=self.text_layer_type,
+ contents=self.layout.type_line,
+ reference=self.type_reference,
+ )
)
- ])
def rules_text_and_pt_layers(self) -> None:
"""3 rules text modes: None, One-Line, and Full"""
@@ -165,44 +176,48 @@ def rules_text_and_pt_layers(self) -> None:
CON.line_break_lead = 3
# Rules Text
- if self.textbox_group.name == LAYERS.ONE_LINE:
- self.text.append(
- text_classes.FormattedTextArea(
- layer=self.text_layer_rules,
- contents=self.layout.oracle_text,
- color=psd.rgb_white(),
- flavor=self.layout.flavor_text,
- reference=self.textbox_reference,
- scale_height=False,
- scale_width=True,
- centered=True
+ if self.textbox_group and self.text_layer_rules:
+ if self.textbox_group.name == LAYERS.ONE_LINE:
+ self.text.append(
+ text_classes.FormattedTextArea(
+ layer=self.text_layer_rules,
+ contents=self.layout.oracle_text,
+ color=psd.rgb_white(),
+ flavor=self.layout.flavor_text,
+ reference=self.textbox_reference,
+ scale_height=False,
+ scale_width=True,
+ centered=True,
+ )
)
- )
- elif self.textbox_group.name == LAYERS.FULL:
- self.text.append(
- text_classes.FormattedTextArea(
- layer=self.text_layer_rules,
- contents=self.layout.oracle_text,
- color=psd.rgb_white(),
- flavor=self.layout.flavor_text,
- reference=self.textbox_reference,
- centered=True
+ elif self.textbox_group.name == LAYERS.FULL:
+ self.text.append(
+ text_classes.FormattedTextArea(
+ layer=self.text_layer_rules,
+ contents=self.layout.oracle_text,
+ color=psd.rgb_white(),
+ flavor=self.layout.flavor_text,
+ reference=self.textbox_reference,
+ centered=True,
+ )
)
- )
# PT Layer
if self.is_creature:
# Enable cutout
- psd.enable_vector_mask(self.textbox_group.parent)
- self.text.append(
- text_classes.TextField(
- layer=self.text_layer_pt,
- contents=f"{self.layout.power}/{self.layout.toughness}"
+ if self.textbox_group and isinstance(
+ (parent := self.textbox_group.parent), LayerSet
+ ):
+ psd.enable_vector_mask(parent)
+ if self.text_layer_pt:
+ self.text.append(
+ text_classes.TextField(
+ layer=self.text_layer_pt,
+ contents=f"{self.layout.power}/{self.layout.toughness}",
+ )
)
- )
def text_adjustments(self) -> None:
-
# Vertically center the name after it's been scaled
psd.align_all(self.text_layer_name, self.name_reference)
diff --git a/src/templates/transform.py b/src/templates/transform.py
index fc7d57d5..a61b40ce 100644
--- a/src/templates/transform.py
+++ b/src/templates/transform.py
@@ -1,18 +1,17 @@
"""
* Templates: Transform / Ixalan
"""
-# Standard Library Imports
+
+from collections.abc import Callable
from functools import cached_property
-from typing import Optional, Callable
-# Third Party Imports
from photoshop.api._artlayer import ArtLayer
+from photoshop.api._layerSet import LayerSet
-# Local Imports
-from src.enums.adobe import Dimensions
+import src.helpers as psd
from src.enums.layers import LAYERS
from src.enums.mtg import TransformIcons
-import src.helpers as psd
+from src.helpers.position import DimensionNames
from src.templates._core import BaseTemplate, NormalTemplate
from src.templates._vector import VectorTemplate
from src.text_layers import TextField
@@ -40,13 +39,13 @@ class TransformMod(BaseTemplate):
"""
@cached_property
- def frame_layer_methods(self) -> list[Callable]:
+ def frame_layer_methods(self) -> list[Callable[[], None]]:
"""Add Transform frame layers step."""
funcs = [self.enable_transform_layers] if self.is_transform else []
return super().frame_layer_methods + funcs
@cached_property
- def text_layer_methods(self) -> list[Callable]:
+ def text_layer_methods(self) -> list[Callable[[], None]]:
"""Add Transform text layers step."""
funcs = [self.text_layers_transform] if self.is_transform else []
return super().text_layer_methods + funcs
@@ -56,7 +55,7 @@ def text_layer_methods(self) -> list[Callable]:
"""
@cached_property
- def text_layer_rules(self) -> Optional[ArtLayer]:
+ def text_layer_rules(self) -> ArtLayer | None:
"""Supports noncreature and creature, with or without flipside PT."""
if self.is_transform and self.is_front and self.is_flipside_creature:
if self.is_creature:
@@ -69,7 +68,7 @@ def text_layer_rules(self) -> Optional[ArtLayer]:
"""
@cached_property
- def text_layer_flipside_pt(self) -> Optional[ArtLayer]:
+ def text_layer_flipside_pt(self) -> ArtLayer | None:
"""Flipside power/toughness layer for front face Transform cards."""
return psd.getLayer(LAYERS.FLIPSIDE_POWER_TOUGHNESS, self.text_group)
@@ -113,21 +112,25 @@ def text_layers_transform_front(self) -> None:
"""Adds and modifies text layers for front face transform cards."""
# Add flipside Power/Toughness
- if self.is_flipside_creature:
+ if self.is_flipside_creature and self.text_layer_flipside_pt:
self.text.append(
TextField(
layer=self.text_layer_flipside_pt,
- contents=f'{self.layout.other_face_power}/'
- f'{self.layout.other_face_toughness}'))
+ contents=f"{self.layout.other_face_power}/"
+ f"{self.layout.other_face_toughness}",
+ )
+ )
def text_layers_transform_back(self) -> None:
"""Adds and modifies text layers for back face transform cards."""
# Rear face Eldrazi cards: Black rules, typeline, and PT text
if self.layout.transform_icon == TransformIcons.MOONELDRAZI:
- self.text_layer_name.textItem.color = self.RGB_BLACK
- self.text_layer_type.textItem.color = self.RGB_BLACK
- if self.is_creature:
+ if self.text_layer_name:
+ self.text_layer_name.textItem.color = self.RGB_BLACK
+ if self.text_layer_type:
+ self.text_layer_type.textItem.color = self.RGB_BLACK
+ if self.is_creature and self.text_layer_pt:
self.text_layer_pt.textItem.color = self.RGB_BLACK
@@ -140,7 +143,8 @@ class VectorTransformMod(TransformMod, VectorTemplate):
def enable_transform_layers(self) -> None:
"""Enable group containing Transform layers."""
- self.dfc_group.parent.visible = True
+ if self.dfc_group and isinstance((parent := self.dfc_group.parent), LayerSet):
+ parent.visible = True
super().enable_transform_layers()
"""
@@ -154,9 +158,11 @@ def text_layers_transform_back(self) -> None:
if self.layout.transform_icon != TransformIcons.MOONELDRAZI:
psd.enable_layer_fx(self.text_layer_name)
psd.enable_layer_fx(self.text_layer_type)
- self.text_layer_name.textItem.color = psd.rgb_white()
- self.text_layer_type.textItem.color = psd.rgb_white()
- if self.is_creature:
+ if self.text_layer_name:
+ self.text_layer_name.textItem.color = psd.rgb_white()
+ if self.text_layer_type:
+ self.text_layer_type.textItem.color = psd.rgb_white()
+ if self.is_creature and self.text_layer_pt:
psd.enable_layer_fx(self.text_layer_pt)
self.text_layer_pt.textItem.color = psd.rgb_white()
@@ -172,24 +178,30 @@ class IxalanMod(NormalTemplate):
* No mana cost or scaled typeline
* Centered Expansion Symbol
"""
- is_creature = False
- is_name_shifted = False
+
+ @cached_property
+ def is_creature(self) -> bool:
+ return False
+
+ @cached_property
+ def is_name_shifted(self) -> bool:
+ return False
"""
* Expansion Symbol
"""
@cached_property
- def expansion_symbol_alignments(self) -> list[Dimensions]:
+ def expansion_symbol_alignments(self) -> list[DimensionNames]:
"""Expansion symbol is entirely centered."""
- return [Dimensions.CenterX, Dimensions.CenterY]
+ return ["center_x", "center_y"]
"""
* Layer Properties
"""
@cached_property
- def background_layer(self) -> Optional[ArtLayer]:
+ def background_layer(self) -> ArtLayer | None:
"""Uses pinline color for background choice layer."""
return psd.getLayer(self.pinlines, LAYERS.BACKGROUND)
@@ -199,7 +211,8 @@ def background_layer(self) -> Optional[ArtLayer]:
def enable_frame_layers(self):
"""Only background frame layer."""
- self.background_layer.visible = True
+ if self.background_layer:
+ self.background_layer.visible = True
"""
* Text Layer Methods
@@ -207,14 +220,14 @@ def enable_frame_layers(self):
def basic_text_layers(self):
"""No mana cost layer, no scaled typeline."""
- self.text.extend([
- TextField(
- layer=self.text_layer_name,
- contents=self.layout.name),
- TextField(
- layer=self.text_layer_type,
- contents=self.layout.type_line)
- ])
+ if self.text_layer_name:
+ self.text.append(
+ TextField(layer=self.text_layer_name, contents=self.layout.name),
+ )
+ if self.text_layer_type:
+ self.text.append(
+ TextField(layer=self.text_layer_type, contents=self.layout.type_line)
+ )
"""
@@ -226,7 +239,7 @@ class TransformTemplate(TransformMod, NormalTemplate):
"""Template for double faced Transform cards introduced in Innistrad block."""
@cached_property
- def pinlines_layer(self) -> Optional[ArtLayer]:
+ def pinlines_layer(self) -> ArtLayer | None:
"""Does not support colored land layers."""
if self.is_land and self.pinlines != LAYERS.LAND:
return psd.getLayer(self.pinlines, LAYERS.PINLINES_TEXTBOX)
@@ -236,27 +249,4 @@ def pinlines_layer(self) -> Optional[ArtLayer]:
class IxalanTemplate(IxalanMod, NormalTemplate):
"""Template for the back face lands for transforming cards from Ixalan block."""
- @classmethod
- def get_template_route(cls, layout, **kwargs) -> BaseTemplate:
- """Reroute for multicolor cards, front cards, and non-land and/or creature cards.
-
- Args:
- layout: The card layout object.
-
- Returns:
- Initialized template class object.
- """
- if any([
- len(layout.identity) > 1,
- not layout.is_land,
- layout.is_front,
- layout.is_creature
- ]):
- # Redirect to regular Transform template
- return cls.redirect_template(
- template_class=TransformTemplate,
- template_file='tf-front.psd' if layout.is_front else 'tf-back.psd',
- layout=layout,
- **kwargs)
- # Route normally
- return super().get_template_route(layout, **kwargs)
+ pass
diff --git a/src/text_layers.py b/src/text_layers.py
index f8a13e9d..35f4528f 100644
--- a/src/text_layers.py
+++ b/src/text_layers.py
@@ -1,64 +1,97 @@
"""
* Text Layer Classes
"""
-# Standard Library Imports
-from contextlib import suppress
+
+from _ctypes import COMError
+from collections.abc import Sequence
from functools import cached_property
-from typing import Optional, Union
+from logging import getLogger
+from re import Match
+from typing import NotRequired, TypedDict, Unpack
-# Third Party Imports
-from photoshop.api import (
- ActionDescriptor,
- ActionReference,
- ActionList,
- DialogModes,
- LayerKind,
- SolidColor,
- Language,
- Justification,
- RasterizeType)
-from photoshop.api._document import Document
+from photoshop.api import ActionDescriptor, ActionList, ActionReference, SolidColor
from photoshop.api._artlayer import ArtLayer
+from photoshop.api._document import Document
from photoshop.api._layerSet import LayerSet
from photoshop.api._selection import Selection
+from photoshop.api.enumerations import (
+ DialogModes,
+ Justification,
+ Language,
+ LayerKind,
+ RasterizeType,
+)
from photoshop.api.text_item import TextItem
-# Local Imports
-from src import APP, CFG, CON, CONSOLE
-from src.cards import generate_italics, locate_symbols, locate_italics, CardItalicString, CardSymbolString
-from src.enums.mtg import CardFonts
+from src import APP, CFG, CON
+from src.cards import (
+ CardItalicString,
+ CardSymbolString,
+ generate_italics,
+ locate_italics,
+ locate_symbols,
+)
+from src.enums.mtg import CardFonts, CardTextPatterns
from src.helpers import select_layer
-from src.helpers.bounds import get_layer_dimensions, LayerDimensions, get_layer_width
-from src.helpers.colors import apply_color, get_text_item_color
-from src.helpers.position import position_between_layers, clear_reference_vertical
-from src.helpers.selection import select_layer_bounds
+from src.helpers.bounds import LayerDimensions, get_layer_dimensions, get_layer_width
+from src.helpers.colors import apply_color, rgb_black
+from src.helpers.position import (
+ RefSide,
+ clear_reference_vertical,
+ position_between_layers,
+)
+from src.helpers.selection import select_layer_bounds, select_layer_pixels
from src.helpers.text import (
get_text_scale_factor,
remove_trailing_text,
- scale_text_to_width_textbox,
- scale_text_to_width,
- scale_text_to_height,
scale_text_left_overlap,
- scale_text_right_overlap)
+ scale_text_right_overlap,
+ scale_text_to_height,
+ scale_text_to_width,
+ scale_text_to_width_textbox,
+)
from src.schema.colors import ColorObject
from src.utils.adobe import ReferenceLayer
+from src.utils.lazy import LazyLoader
-# QOL Definitions
-sID = APP.stringIDToTypeID
-cID = APP.charIDToTypeID
-NO_DIALOG = DialogModes.DisplayNoDialogs
+_logger = getLogger(__name__)
"""
* Text Layer Classes
"""
-class TextField:
- FONT = CardFonts.TITLES
- FONT_ITALIC = CardFonts.RULES_ITALIC
- FONT_BOLD = CardFonts.RULES_BOLD
+class TextFieldKwargs(TypedDict):
+ color: NotRequired[SolidColor | None]
+ font: NotRequired[str | None]
+ font_mana: NotRequired[str | None]
+ font_italic: NotRequired[str | None]
+ font_bold: NotRequired[str | None]
+ reference: NotRequired[ArtLayer | None]
+ symbol_map: NotRequired[dict[str, tuple[str, list[ColorObject]]]]
+ flavor: NotRequired[str | None]
+ flavor_color: NotRequired[SolidColor]
+ line_break_lead: NotRequired[int | float]
+ flavor_text_lead: NotRequired[int | float]
+ flavor_text_lead_divider: NotRequired[int | float]
+ centered: NotRequired[bool]
+ flavor_centered: NotRequired[bool]
+ vertically_centered: NotRequired[bool]
+ bold_rules_text: NotRequired[bool]
+ right_align_quote: NotRequired[bool]
+ pt_reference: NotRequired[ReferenceLayer | None]
+ divider: NotRequired[ArtLayer | LayerSet | None]
+ scale_height: NotRequired[bool]
+ scale_width: NotRequired[bool]
+ fix_overflow_height: NotRequired[bool]
+ fix_overflow_width: NotRequired[bool]
+ replace_characters_in_title_font: NotRequired[bool]
- def __init__(self, layer: ArtLayer, contents: str = "", **kwargs):
+
+class TextField:
+ def __init__(
+ self, layer: ArtLayer, contents: str = "", **kwargs: Unpack[TextFieldKwargs]
+ ):
"""A generic TextField, which allows you to set a text layer's contents and text color.
Args:
@@ -76,47 +109,67 @@ def __init__(self, layer: ArtLayer, contents: str = "", **kwargs):
"""
self._kwargs = kwargs
self._layer = layer
- self.contents = contents.replace(
- "\n", "\r")
+ self.contents = contents.replace("\n", "\r")
"""
* Keyword Arguments
"""
@cached_property
- def kwargs(self) -> dict:
+ def kwargs(self) -> TextFieldKwargs:
"""Contains optional parameters to modify text formatting behavior."""
return self._kwargs
@cached_property
- def kw_color(self) -> Optional[SolidColor]:
+ def kw_color(self) -> SolidColor | None:
"""Color to apply to the TextItem."""
- return self.kwargs.get('color')
+ return self.kwargs.get("color")
@cached_property
- def kw_font(self) -> Optional[str]:
+ def kw_font(self) -> str | None:
"""Font to apply to the root TextItem."""
- return self.kwargs.get('font')
+ return self.kwargs.get("font")
@cached_property
- def kw_font_mana(self) -> Optional[str]:
+ def kw_font_mana(self) -> str | None:
"""Font to apply to any mana symbols in the TextItem."""
- return self.kwargs.get('font_mana')
+ return self.kwargs.get("font_mana")
@cached_property
- def kw_font_italic(self) -> Optional[str]:
+ def kw_font_italic(self) -> str | None:
"""Font to apply to any italicized text in the TextItem."""
- return self.kwargs.get('font_italic')
+ return self.kwargs.get("font_italic")
@cached_property
- def kw_font_bold(self) -> Optional[str]:
+ def kw_font_bold(self) -> str | None:
"""Font to apply to any bold text in the TextItem."""
- return self.kwargs.get('font_bold')
+ return self.kwargs.get("font_bold")
@cached_property
def kw_symbol_map(self) -> dict[str, tuple[str, list[ColorObject]]]:
"""Symbol map to use for formatting mana symbols."""
- return self.kwargs.get('symbol_map', CON.symbol_map)
+ if not (symbol_map := self.kwargs.get("symbol_map")):
+ symbol_map = {}
+
+ def get_default_color() -> SolidColor:
+ # For some reason fetching color from a seemingly valid TextItem can fail.
+ # The error goes away if the color is changed via Photoshop's GUI.
+ try:
+ return self.TI.color
+ except COMError:
+ return rgb_black()
+
+ # Use text layer's color for all None colors
+ default_color = LazyLoader(get_default_color)
+ for key, value in CON.symbol_map.items():
+ symbol_map[key] = (
+ value[0],
+ [
+ item if item is not None else default_color()
+ for item in value[1]
+ ],
+ )
+ return symbol_map
"""
* Checks
@@ -134,7 +187,7 @@ def is_text_layer(self) -> bool:
@cached_property
def docref(self) -> Document:
"""The currently active Photoshop document."""
- return APP.activeDocument
+ return APP.instance.activeDocument
@cached_property
def doc_selection(self) -> Selection:
@@ -152,20 +205,20 @@ def TI(self) -> TextItem:
return self.layer.textItem
@cached_property
- def reference(self) -> Optional[ArtLayer]:
+ def reference(self) -> ArtLayer | None:
"""A reference layer, typically used for scaling the TextItem."""
- return self.kwargs.get('reference', None)
+ return self.kwargs.get("reference", None)
@cached_property
- def reference_dims(self) -> Optional[type[LayerDimensions]]:
- """Optional[type[LayerDimensions]]: Dimensions of the scaling reference layer."""
+ def reference_dims(self) -> LayerDimensions | None:
+ """Dimensions of the scaling reference layer."""
if isinstance(self.reference, ReferenceLayer):
return self.reference.dims
if self.reference:
return get_layer_dimensions(self.reference)
return None
- @property
+ @cached_property
def input(self) -> str:
"""Raw contents provided to fill the TextItem."""
return self.contents
@@ -173,12 +226,18 @@ def input(self) -> str:
@cached_property
def color(self) -> SolidColor:
"""A SolidColor object provided, or fallback on current TextItem color."""
- return self.kw_color or get_text_item_color(self.TI)
+ return self.kw_color or self.TI.color
@cached_property
def font(self) -> str:
"""Font provided, or fallback on global constant."""
- return self.kw_font or CON.font_title
+ return self.kw_font or self.TI.font
+
+ @cached_property
+ def replace_characters_in_title_font(self) -> bool:
+ """Whether to replace specific characters when using title font
+ in order to more closely match real cards."""
+ return self.kwargs.get("replace_characters_in_title_font", True)
"""
* Methods
@@ -190,17 +249,30 @@ def validate(self):
# Layer is valid, select and show it
select_layer(self.layer, True)
return True
- with suppress(Exception):
- # Layer provided doesn't exist or isn't a text layer
- name = self.layer.name if self.layer else '[Non-Layer]'
- print(f'Text Field class: {self.__class__.__name__}\n'
- f'Invalid layer provided: {name}')
- self.layer.visible = False
return False
def execute(self):
"""Executes all text actions."""
+ # Apply custom word end characters if using Beleren font,
+ # which is usually used in card name and type.
+ if self.replace_characters_in_title_font and self.font == CardFonts.TITLES:
+ for pattern, replacement in (
+ (CardTextPatterns.TEXT_WORD_END_F, "\ue006"),
+ (CardTextPatterns.TEXT_WORD_END_H, "\ue007"),
+ (CardTextPatterns.TEXT_WORD_END_M, "\ue008"),
+ (CardTextPatterns.TEXT_WORD_END_N, "\ue009"),
+ (CardTextPatterns.TEXT_WORD_END_K, "\ue00a"),
+ ):
+
+ def replacer(match: Match[str]) -> str:
+ after_char = match.group(1)
+ return (
+ replacement + after_char if isinstance(after_char, str) else ""
+ )
+
+ self.input = pattern.sub(replacer, self.input)
+
# Update TextItem contents
self.TI.contents = self.input
@@ -209,7 +281,7 @@ def execute(self):
self.TI.color = self.color
# Update font manually if mismatch detected
- if self.font != self.FONT:
+ if self.font != self.TI.font:
self.TI.font = self.font
# Change to English formatting if needed
@@ -217,21 +289,39 @@ def execute(self):
self.TI.language = Language.EnglishUSA
-class ScaledTextField (TextField):
- """A TextField which automatically scales down its font size until the right bound
- no longer overlaps with the `reference` layer's left bound."""
+class ScaledTextField(TextField):
+ """A TextField which automatically scales down its font size until it
+ no longer overlaps with the `reference` layer's `reference_side` bound."""
+
+ @cached_property
+ def step_sizes(self) -> Sequence[float]:
+ return (0.4, 0.2)
+
+ @cached_property
+ def gap(self) -> float:
+ return 30
+
+ @cached_property
+ def reference_side(self) -> RefSide:
+ return RefSide.LEFT
def execute(self):
super().execute()
# Scale down the text layer until it doesn't overlap with a reference layer
if self.reference:
- scale_text_right_overlap(self.layer, self.reference)
+ scale_text_right_overlap(
+ self.layer,
+ self.reference,
+ reference_side=self.reference_side,
+ step_sizes=self.step_sizes,
+ gap=self.gap,
+ )
-class ScaledTextFieldLeft (TextField):
+class ScaledTextFieldLeft(TextField):
"""A TextField which automatically scales down its font size until the left bound
- no longer overlaps with the `reference` layer's right bound."""
+ no longer overlaps with the `reference` layer's right bound."""
def execute(self):
super().execute()
@@ -241,39 +331,42 @@ def execute(self):
scale_text_left_overlap(self.layer, self.reference)
-class ScaledWidthTextField (TextField):
+class ScaledWidthTextField(TextField):
"""A TextField which automatically scales down its font size until the width of the
- layer is within the horizontal bound of a reference layer."""
- FONT = CardFonts.RULES
+ layer is within the horizontal bound of a reference layer."""
@cached_property
- def font(self) -> str:
- """str: Font provided, or fallback on global constant."""
- return self.kw_font or CON.font_rules_text
-
- @cached_property
- def reference_width(self) -> Union[float, int]:
+ def reference_width(self) -> float | int | None:
"""Union[float, int]: Width of the reference layer provided."""
- return get_layer_width(self.reference)
+ if self.reference:
+ return get_layer_width(self.reference)
def execute(self):
super().execute()
# Scale down the text layer until it doesn't overlap with a reference layer
- if self.reference:
+ if self.reference_width:
scale_text_to_width(self.layer, width=self.reference_width)
-class FormattedTextField (TextField):
+class TextDetails(TypedDict):
+ rules_text: str
+ input_string: str
+ symbol_indices: list[CardSymbolString]
+ italics_indices: list[CardItalicString]
+
+
+class FormattedTextField(TextField):
"""A utility class containing the required infrastructure to format text with action descriptors.
* Formats any recognized mana symbols contained in the text.
* Formats any modal/bullet point sections in the text.
* Formats any italicized or bolded text, as well as line breaks.
"""
- FONT = CardFonts.RULES
- def __init__(self, layer: ArtLayer, contents: str = "", **kwargs):
+ def __init__(
+ self, layer: ArtLayer, contents: str = "", **kwargs: Unpack[TextFieldKwargs]
+ ):
super().__init__(layer, contents, **kwargs)
# Pre-cache text details
@@ -284,8 +377,7 @@ def __init__(self, layer: ArtLayer, contents: str = "", **kwargs):
"""
@cached_property
- def text_details(self) -> dict:
-
+ def text_details(self) -> TextDetails:
# Generate italic text arrays from things in (parentheses), ability words, and the given flavor text
italic_text = generate_italics(self.contents) if self.contents else []
@@ -293,51 +385,60 @@ def text_details(self) -> dict:
if self.flavor_text.count("*") >= 2:
# Don't italicize text between asterisk
flavor_text_split = self.flavor_text.split("*")
- italic_text.extend([v for i, v in enumerate(flavor_text_split) if not i % 2 and not v == ''])
- self.flavor_text = ''.join(flavor_text_split)
+ italic_text.extend(
+ [
+ v
+ for i, v in enumerate(flavor_text_split)
+ if not i % 2 and not v == ""
+ ]
+ )
+ self.flavor_text = "".join(flavor_text_split)
elif self.flavor_text:
# Regular flavor text
italic_text.append(self.flavor_text)
# Locate symbols and update the rules string
rules, symbols = locate_symbols(
- text=self.contents,
- symbol_map=self.kw_symbol_map,
- logger=CONSOLE)
+ text=self.contents, symbol_map=self.kw_symbol_map, logger=_logger
+ )
# Create the new input string
- input_str = f'{rules}\r{self.flavor_text}' if rules and self.flavor_text else (
- rules if rules else self.flavor_text)
+ input_str = (
+ f"{rules}\r{self.flavor_text}"
+ if rules and self.flavor_text
+ else (rules if rules else self.flavor_text)
+ )
# Locate italics text indices
italicized = locate_italics(
st=input_str,
italics_strings=italic_text,
symbol_map=self.kw_symbol_map,
- logger=CONSOLE)
+ logger=_logger,
+ )
# Return text details
return {
- 'rules_text': rules,
- 'input_string': input_str,
- 'symbol_indices': symbols,
- 'italics_indices': italicized,
+ "rules_text": rules,
+ "input_string": input_str,
+ "symbol_indices": symbols,
+ "italics_indices": italicized,
}
@cached_property
def italics_indices(self) -> list[CardItalicString]:
- return self.text_details['italics_indices']
+ return self.text_details["italics_indices"]
@cached_property
def symbol_indices(self) -> list[CardSymbolString]:
- return self.text_details['symbol_indices']
+ return self.text_details["symbol_indices"]
@cached_property
def input(self) -> str:
- return self.text_details['input_string']
+ return self.text_details["input_string"]
- @property
- def divider(self) -> Optional[ArtLayer]:
+ @cached_property
+ def divider(self) -> ArtLayer | LayerSet | None:
# Default to None unless overridden
return
@@ -347,10 +448,10 @@ def divider(self) -> Optional[ArtLayer]:
@cached_property
def rules_text(self) -> str:
- return self.text_details['rules_text']
+ return self.text_details["rules_text"]
@cached_property
- def rules_range(self) -> Optional[tuple[int, int]]:
+ def rules_range(self) -> tuple[int, int] | None:
if not self.rules_text:
return
return 0, len(self.rules_text)
@@ -373,10 +474,12 @@ def rules_end(self) -> int:
@cached_property
def flavor_text(self) -> str:
- return self.kwargs.get('flavor', '').replace('\n', '\r')
+ if flavor := self.kwargs.get("flavor", ""):
+ return flavor.replace("\n", "\r")
+ return ""
@cached_property
- def flavor_text_range(self) -> Optional[tuple[int, int]]:
+ def flavor_text_range(self) -> tuple[int, int] | None:
if not self.flavor_text:
return
total = len(self.input)
@@ -405,19 +508,14 @@ def quote_index(self) -> int:
"""
@cached_property
- def flavor_color(self) -> Optional[SolidColor]:
+ def flavor_color(self) -> SolidColor | None:
"""If defined separately, `color` is effectively the `rules_color`."""
- return self.kwargs.get('flavor_color')
+ return self.kwargs.get("flavor_color", None)
"""
* Fonts
"""
- @cached_property
- def font(self) -> str:
- """Font provided, or fallback on global constant."""
- return self.kw_font or CON.font_rules_text
-
@cached_property
def font_mana(self) -> str:
"""Mana font provided, or fallback on global constant."""
@@ -438,19 +536,20 @@ def font_bold(self) -> str:
"""
@cached_property
- def line_break_lead(self) -> Union[int, float]:
+ def line_break_lead(self) -> int | float:
"""Leading space before linebreaks."""
return self.kwargs.get(
- 'line_break_lead',
- 0 if self.contents_centered else CON.line_break_lead
+ "line_break_lead", 0 if self.contents_centered else CON.line_break_lead
)
@cached_property
- def flavor_text_lead(self) -> Union[int, float]:
+ def flavor_text_lead(self) -> int | float:
"""Leading space before linebreak separating rules and flavor text. Increased if divider is present."""
if self.divider:
- return self.kwargs.get('flavor_text_lead_divider', CON.flavor_text_lead_divider)
- return self.kwargs.get('flavor_text_lead', CON.flavor_text_lead)
+ return self.kwargs.get(
+ "flavor_text_lead_divider", CON.flavor_text_lead_divider
+ )
+ return self.kwargs.get("flavor_text_lead", CON.flavor_text_lead)
"""
* Optional Properties
@@ -458,23 +557,27 @@ def flavor_text_lead(self) -> Union[int, float]:
@cached_property
def contents_centered(self) -> bool:
- return self.kwargs.get('centered', False)
+ return self.kwargs.get("centered", False)
@cached_property
def flavor_centered(self) -> bool:
- return self.kwargs.get('flavor_centered', self.contents_centered)
+ return self.kwargs.get("flavor_centered", self.contents_centered)
+
+ @cached_property
+ def vertically_centered(self) -> bool:
+ return self.kwargs.get("vertically_centered", True)
@cached_property
def bold_rules_text(self) -> bool:
- return self.kwargs.get('bold_rules_text', False)
+ return self.kwargs.get("bold_rules_text", False)
@cached_property
def right_align_quote(self) -> bool:
- return self.kwargs.get('right_align_quote', False)
+ return self.kwargs.get("right_align_quote", False)
@cached_property
def font_size(self) -> float:
- if font_size := self.kwargs.get('font_size'):
+ if font_size := self.kwargs.get("font_size"):
return font_size * get_text_scale_factor(self.layer)
return self.TI.size * get_text_scale_factor(self.layer)
@@ -492,7 +595,7 @@ def is_quote_text(self) -> bool:
@cached_property
def is_modal(self) -> bool:
- return bool('\u2022' in self.input)
+ return bool("\u2022" in self.input)
"""
* Methods
@@ -518,31 +621,31 @@ def format_text(self):
main_list = ActionList()
# Descriptor ID's
- idTo = sID('to')
- size = sID('size')
- idFrom = sID('from')
- textLayer = sID('textLayer')
- textStyle = sID('textStyle')
- ptUnit = sID('pointsUnit')
- spaceAfter = sID('spaceAfter')
- autoLeading = sID('autoLeading')
- startIndent = sID('startIndent')
- spaceBefore = sID('spaceBefore')
- leadingType = sID('leadingType')
- styleRange = sID('textStyleRange')
- paragraphStyle = sID('paragraphStyle')
- firstLineIndent = sID('firstLineIndent')
- fontPostScriptName = sID('fontPostScriptName')
- paragraphStyleRange = sID('paragraphStyleRange')
+ idTo = APP.instance.sID("to")
+ size = APP.instance.sID("size")
+ idFrom = APP.instance.sID("from")
+ textLayer = APP.instance.sID("textLayer")
+ textStyle = APP.instance.sID("textStyle")
+ ptUnit = APP.instance.sID("pointsUnit")
+ spaceAfter = APP.instance.sID("spaceAfter")
+ autoLeading = APP.instance.sID("autoLeading")
+ startIndent = APP.instance.sID("startIndent")
+ spaceBefore = APP.instance.sID("spaceBefore")
+ leadingType = APP.instance.sID("leadingType")
+ styleRange = APP.instance.sID("textStyleRange")
+ paragraphStyle = APP.instance.sID("paragraphStyle")
+ firstLineIndent = APP.instance.sID("firstLineIndent")
+ fontPostScriptName = APP.instance.sID("fontPostScriptName")
+ paragraphStyleRange = APP.instance.sID("paragraphStyleRange")
# Spin up the text insertion action
- main_desc.putString(sID('textKey'), self.input)
+ main_desc.putString(APP.instance.sID("textKey"), self.input)
main_range.putInteger(idFrom, 0)
main_range.putInteger(idTo, len(self.input))
apply_color(main_style, self.color)
main_style.putBoolean(autoLeading, False)
main_style.putUnitDouble(size, ptUnit, self.font_size)
- main_style.putUnitDouble(sID('leading'), ptUnit, self.font_size)
+ main_style.putUnitDouble(APP.instance.sID("leading"), ptUnit, self.font_size)
main_style.putString(fontPostScriptName, self.font)
main_range.putObject(textStyle, textStyle, main_style)
main_list.putObject(styleRange, main_range)
@@ -581,11 +684,13 @@ def format_text(self):
para_range.putInteger(idTo, len(self.input))
para_style.putUnitDouble(firstLineIndent, ptUnit, 0)
para_style.putUnitDouble(startIndent, ptUnit, 0)
- para_style.putUnitDouble(sID("endIndent"), ptUnit, 0)
+ para_style.putUnitDouble(APP.instance.sID("endIndent"), ptUnit, 0)
para_style.putUnitDouble(spaceBefore, ptUnit, self.line_break_lead)
para_style.putUnitDouble(spaceAfter, ptUnit, 0)
- para_style.putInteger(sID("dropCapMultiplier"), 1)
- para_style.putEnumerated(leadingType, leadingType, sID("leadingBelow"))
+ para_style.putInteger(APP.instance.sID("dropCapMultiplier"), 1)
+ para_style.putEnumerated(
+ leadingType, leadingType, APP.instance.sID("leadingBelow")
+ )
# Adjust paragraph formatting for modal card with bullet points
if self.is_modal:
@@ -598,36 +703,38 @@ def format_text(self):
default_style.putString(fontPostScriptName, self.font_mana)
default_style.putUnitDouble(size, ptUnit, 12)
default_style.putBoolean(autoLeading, False)
- para_style.putObject(sID("defaultStyle"), textStyle, default_style)
+ para_style.putObject(
+ APP.instance.sID("defaultStyle"), textStyle, default_style
+ )
para_range.putObject(paragraphStyle, paragraphStyle, para_style)
style_list.putObject(paragraphStyleRange, para_range)
# Flavor text actions
if self.is_flavor_text:
-
# Add linebreak spacing between rules and flavor text
para_range.putInteger(idFrom, self.flavor_start + 3)
para_range.putInteger(idTo, self.flavor_start + 4)
para_style.putUnitDouble(startIndent, ptUnit, 0)
para_style.putUnitDouble(firstLineIndent, ptUnit, 0)
- para_style.putUnitDouble(sID("impliedStartIndent"), ptUnit, 0)
- para_style.putUnitDouble(sID("impliedFirstLineIndent"), ptUnit, 0)
+ para_style.putUnitDouble(APP.instance.sID("impliedStartIndent"), ptUnit, 0)
+ para_style.putUnitDouble(
+ APP.instance.sID("impliedFirstLineIndent"), ptUnit, 0
+ )
para_style.putUnitDouble(spaceBefore, ptUnit, self.flavor_text_lead)
para_range.putObject(paragraphStyle, paragraphStyle, para_style)
style_list.putObject(paragraphStyleRange, para_range)
# Adjust flavor text color
if self.flavor_color:
- main_range.PutInteger(idFrom, self.flavor_start)
- main_range.PutInteger(idTo, self.flavor_end)
+ main_range.putInteger(idFrom, self.flavor_start)
+ main_range.putInteger(idTo, self.flavor_end)
apply_color(main_style, self.flavor_color)
main_style.putString(fontPostScriptName, self.font_italic)
- main_range.PutObject(textStyle, textStyle, main_style)
+ main_range.putObject(textStyle, textStyle, main_style)
main_list.putObject(styleRange, main_range)
# Quote actions flavor text
if self.is_quote_text:
-
# Adjust line break spacing if there's a line break in the flavor text
para_range.putInteger(idFrom, self.quote_index + 3)
para_range.putInteger(idTo, len(self.input))
@@ -639,8 +746,12 @@ def format_text(self):
if self.right_align_quote and '"\rβ' in self.flavor_text:
para_range.putInteger(idFrom, self.input.find('"\rβ') + 2)
para_range.putInteger(idTo, self.flavor_end)
- para_style.putBoolean(sID('styleSheetHasParent'), True)
- para_range.putEnumerated(sID('align'), sID('alignmentType'), sID('right'))
+ para_style.putBoolean(APP.instance.sID("styleSheetHasParent"), True)
+ para_style.putEnumerated(
+ APP.instance.sID("align"),
+ APP.instance.sID("alignmentType"),
+ APP.instance.sID("right"),
+ )
para_range.putObject(paragraphStyle, paragraphStyle, para_style)
style_list.putObject(paragraphStyleRange, para_range)
@@ -649,26 +760,48 @@ def format_text(self):
main_desc.putList(styleRange, main_list)
# Push changes to text layer
- main_ref.putEnumerated(textLayer, sID("ordinal"), sID("targetEnum"))
- main_target.putReference(sID("target"), main_ref)
+ main_ref.putEnumerated(
+ textLayer, APP.instance.sID("ordinal"), APP.instance.sID("targetEnum")
+ )
+ main_target.putReference(APP.instance.sID("target"), main_ref)
main_target.putObject(idTo, textLayer, main_desc)
- APP.executeAction(sID("set"), main_target, NO_DIALOG)
+ APP.instance.executeAction(
+ APP.instance.sID("set"), main_target, DialogModes.DisplayNoDialogs
+ )
def execute(self):
super().execute()
+ # No need to format if there's no content
+ if not self.input:
+ return
+
# Format text
self.format_text()
# Justify center if required
if self.contents_centered:
+ initial_justification = self.TI.justification
self.TI.justification = Justification.Center
+ # Revert justification change if it caused overflow
+ if initial_justification != Justification.Center and self.reference_dims:
+ selection = select_layer_pixels(self.layer)
+ selection_bounds = selection.bounds
+ selection.deselect()
+ if (
+ initial_justification == Justification.Left
+ and self.reference_dims["right"] < selection_bounds[2]
+ ) or (
+ initial_justification == Justification.Right
+ and self.reference_dims["left"] > selection_bounds[0]
+ ):
+ self.TI.justification = initial_justification
# Ensure hyphenation disabled
self.TI.hyphenation = False
-class FormattedTextArea (FormattedTextField):
+class FormattedTextArea(FormattedTextField):
"""A FormattedTextField where the text is required to fit within a given area.
* Reduces font size until the text fits within the reference layer's bounds.
@@ -681,13 +814,15 @@ class FormattedTextArea (FormattedTextField):
"""
@cached_property
- def pt_reference(self) -> Optional[ReferenceLayer]:
- return self.kwargs.get('pt_reference', None)
+ def pt_reference(self) -> ReferenceLayer | None:
+ return self.kwargs.get("pt_reference", None)
@cached_property
- def divider(self) -> Optional[Union[ArtLayer, LayerSet]]:
+ def divider(self) -> ArtLayer | LayerSet | None:
"""Divider layer, if provided and flavor text exists."""
- if (divider := self.kwargs.get('divider')) and all([self.flavor_text, self.contents, CFG.flavor_divider]):
+ if (divider := self.kwargs.get("divider")) and all(
+ [self.flavor_text, self.contents, CFG.flavor_divider]
+ ):
divider.visible = True
return divider
return
@@ -695,32 +830,22 @@ def divider(self) -> Optional[Union[ArtLayer, LayerSet]]:
@cached_property
def scale_height(self) -> bool:
"""Scale text to fit reference height (Default: True)."""
- if scale_height := self.kwargs.get('scale_height'):
- return scale_height
- return True
+ return self.kwargs.get("scale_height", True)
@cached_property
def scale_width(self) -> bool:
"""Scale text to fit reference width (Default: False)."""
- if scale_width := self.kwargs.get('scale_width'):
- return scale_width
- return False
+ return self.kwargs.get("scale_width", False)
@cached_property
def fix_overflow_width(self) -> bool:
"""Scale text to fit bounding box width (Default: False)."""
- if fix_overflow_width := self.kwargs.get('fix_overflow_width'):
- return fix_overflow_width
- return True
+ return self.kwargs.get("fix_overflow_width", False)
@cached_property
def fix_overflow_height(self) -> bool:
- """Scale text to fit bounding box height (Default: If it overflows the bounds)."""
- if len(self.contents + self.flavor_text) > 280:
- return True
- if fix_overflow_height := self.kwargs.get('fix_overflow_height'):
- return fix_overflow_height
- return False
+ """Scale text to fit bounding box height (Default: True)."""
+ return self.kwargs.get("fix_overflow_height", True)
"""
* Methods
@@ -728,77 +853,83 @@ def fix_overflow_height(self) -> bool:
def insert_divider(self):
"""Inserts and correctly positions flavor text divider."""
-
- # Create a reference layer with no effects
- flavor = self.layer.duplicate()
- rules = flavor.duplicate()
- flavor.rasterize(RasterizeType.EntireLayer)
- remove_trailing_text(rules, self.flavor_start)
- select_layer_bounds(rules, self.doc_selection)
- self.docref.activeLayer = flavor
- self.doc_selection.expand(2)
- self.doc_selection.clear()
-
- # Move flavor text to bottom, then position divider
- flavor.translate(0, self.layer.bounds[3] - flavor.bounds[3])
- position_between_layers(self.divider, rules, flavor)
- self.doc_selection.deselect()
- flavor.remove()
- rules.remove()
-
- def pre_scale_to_fit(self) -> Optional[float]:
+ if self.divider:
+ # Create a reference layer with no effects
+ flavor = self.layer.duplicate()
+ rules = flavor.duplicate()
+ flavor.rasterize(RasterizeType.EntireLayer)
+ remove_trailing_text(rules, self.flavor_start)
+ select_layer_bounds(rules, self.doc_selection)
+ self.docref.activeLayer = flavor
+ self.doc_selection.expand(2)
+ self.doc_selection.clear()
+
+ # Move flavor text to bottom, then position divider
+ flavor.translate(0, self.layer.bounds[3] - flavor.bounds[3])
+ position_between_layers(self.divider, rules, flavor)
+ self.doc_selection.deselect()
+ flavor.remove()
+ rules.remove()
+
+ def pre_scale_to_fit(self) -> float | None:
"""Fix height overflow before formatting text."""
- contents = self.contents if not self.flavor_text else str(
- self.contents + "\r" + self.flavor_text)
- self.TI.contents = contents
- return scale_text_to_height(
- layer=self.layer,
- height=int(self.reference_dims['height']*1.1))
-
- def scale_to_fit(self, font_size: Optional[float] = None) -> None:
+ if self.reference_dims:
+ contents = (
+ self.contents
+ if not self.flavor_text
+ else str(self.contents + "\r" + self.flavor_text)
+ )
+ self.TI.contents = contents
+ return scale_text_to_height(
+ layer=self.layer, height=int(self.reference_dims["height"] * 1.1)
+ )
+
+ def scale_to_fit(self, font_size: float | None = None) -> None:
"""Scale font size to fit within any references."""
# Scale layer to reference
if self.reference_dims:
-
# Resize the text until it fits the reference vertically
if self.scale_height:
font_size = scale_text_to_height(
- layer=self.layer,
- height=self.reference_dims['height'])
+ layer=self.layer, height=self.reference_dims["height"]
+ )
# Resize the text until it fits the reference horizontally
if self.scale_width:
font_size = scale_text_to_width(
layer=self.layer,
- width=self.reference_dims['width'],
- step=0.2, font_size=font_size)
+ width=self.reference_dims["width"],
+ step=0.2,
+ font_size=font_size,
+ )
# Resize the text until it fits the TextLayer bounding box
if self.fix_overflow_width:
- scale_text_to_width_textbox(
- layer=self.layer, font_size=font_size)
+ scale_text_to_width_textbox(layer=self.layer, font_size=font_size)
def position_within_reference(self):
"""Positions the layer with respect to the reference, if required."""
+ if self.reference_dims:
+ # Ensure the layer is centered vertically
+ dims = get_layer_dimensions(self.layer)
- # Ensure the layer is centered vertically
- dims = get_layer_dimensions(self.layer)
- self.layer.translate(0, self.reference_dims['center_y'] - dims['center_y'])
+ if self.vertically_centered:
+ self.layer.translate(
+ 0, self.reference_dims["center_y"] - dims["center_y"]
+ )
- # Ensure the layer is centered horizontally if needed
- if self.contents_centered and self.flavor_centered:
- self.layer.translate(0, self.reference_dims['center_x'] - dims['center_x'])
+ # Ensure the layer is centered horizontally if needed
+ if self.contents_centered and self.flavor_centered:
+ self.layer.translate(
+ 0, self.reference_dims["center_x"] - dims["center_x"]
+ )
def execute(self):
-
- # Skip if both are empty
- if not self.input:
- return
font_size = None
# Pre-scaling to prevent text overflow errors
- if self.fix_overflow_height and self.reference_dims:
+ if self.input and self.fix_overflow_height and self.reference_dims:
font_size = self.pre_scale_to_fit()
# Execute text formatting
@@ -817,12 +948,10 @@ def execute(self):
# Shift vertically if the text overlaps the PT box
if self.pt_reference:
-
# Use newer methodology if top reference not provided
delta = clear_reference_vertical(
- layer=self.layer,
- ref=self.pt_reference,
- docsel=self.doc_selection)
+ layer=self.layer, ref=self.pt_reference, docsel=self.doc_selection
+ )
# Shift the divider if layer was moved
if delta < 0 and self.divider:
@@ -833,10 +962,10 @@ def execute(self):
* Text Item Type
"""
-FormattedTextLayer = Union[
- TextField,
- ScaledTextField,
- FormattedTextArea,
- FormattedTextField,
- ScaledWidthTextField,
-]
+FormattedTextLayer = (
+ TextField
+ | ScaledTextField
+ | FormattedTextArea
+ | FormattedTextField
+ | ScaledWidthTextField
+)
diff --git a/src/utils/adobe.py b/src/utils/adobe.py
index ba16340c..61e10b24 100644
--- a/src/utils/adobe.py
+++ b/src/utils/adobe.py
@@ -1,43 +1,47 @@
"""
* Utils: Adobe Photoshop
"""
-# Standard Library
-from _ctypes import COMError, ArgumentError
+
+from _ctypes import ArgumentError, COMError
+from collections.abc import Callable
from contextlib import suppress
from ctypes import c_uint32
from functools import cache, cached_property
-from typing import Union, Any, Optional, TypedDict, Callable
+from typing import TypedDict
-# Third Party
-from comtypes.client.lazybind import Dispatch
from packaging.version import parse
from photoshop.api import (
ActionDescriptor,
ActionReference,
Application,
- DialogModes,
PhotoshopPythonAPIError,
- Units)
+)
from photoshop.api._artlayer import ArtLayer
from photoshop.api._core import Photoshop
from photoshop.api._document import Document
+from photoshop.api._layer import Layer
from photoshop.api._layerSet import LayerSet
+from photoshop.api.enumerations import DialogModes, ElementPlacement, TypeUnits, Units
from win32api import FormatMessage
-# Local Imports
from src._state import AppEnvironment
+from src.utils.windows import (
+ WindowState,
+ get_window_handle_by_process_file_path_suffix,
+ set_window_state,
+)
"""
* Types & Definitions
"""
# Common Layer Objects
-LayerContainer = LayerSet, Document, Dispatch
-LayerObject = LayerSet, ArtLayer, Dispatch
+LayerContainer = LayerSet, Document
+LayerObject = LayerSet, ArtLayer
# Common Layer Types
-LayerContainerTypes = Union[LayerSet, Document, Dispatch]
-LayerObjectTypes = Union[ArtLayer, LayerSet, Dispatch]
+LayerContainerTypes = LayerSet | Document
+LayerObjectTypes = ArtLayer | LayerSet
# Common Photoshop Exceptions
PS_EXCEPTIONS = (
@@ -49,10 +53,10 @@
KeyError,
ValueError,
TypeError,
- OSError
+ OSError,
)
-PS_ERROR_CODES: dict[int: str] = {
+PS_ERROR_CODES: dict[int, str] = {
# --> COMError Messages that contain a message string
# Response: "The message filter indicated that the application is busy."
-2147417846: "Photoshop is currently busy, close any dialog boxes and stop any pending actions.",
@@ -60,10 +64,9 @@
-2147023170: "Unable to make connection with Photoshop, please check the FAQ for solutions.",
# Response: "Invalid index."
-2147352565: "Failed to load a PSD template or other file, ensure template file isn't corrupted "
- "and that you have allocated enough scratch disk space and RAM to Photoshop.",
+ "and that you have allocated enough scratch disk space and RAM to Photoshop.",
# Response: "Exception occurred."
-2147352567: "Photoshop does not appear to be installed. If Photoshop is installed, check the FAQ for solutions.",
-
# --> COMError Messages that don't contain a message string, but have been investigated
# Reference: https://docs.google.com/document/d/1j5xkWCWeHEFUZUaVtF59ccvAm9zTFsZ1qJcmFsXvkCM
-2147220261: "Invalid data type passed to action descriptor function.",
@@ -73,7 +76,6 @@
-2147212704: "Action descriptor or layer object key/property is missing.",
# Reference: https://docs.google.com/document/d/1Oz69nNO0jR9qBbhjv3SVlMmk8iRnZY1LG7VaX7pqB-U
-2147220262: "Photoshop tried to load a PSD template or file that doesn't exist.",
-
# --> COMError Messages that don't contain a message string, but have been identified with testing
# Test case: Pass a value to layer.textItem.color that isn't a SolidColor object
-2147220279: "Wrong type of value passed to a Photoshop object property.",
@@ -81,24 +83,25 @@
# Also: Observed when accessing the textItem property of a layer that contains an uninstalled font
-2147213327: "Tried to interact with a text layer that is rasterized or has an uninstalled font.",
# Test case: Delete a layer object, then try to delete it again.
- -2147213404: "Tried to delete a layer that doesn't exist."
+ -2147213404: "Tried to delete a layer that doesn't exist.",
}
# Layer bounds: left, top, right, bottom
-LayerBounds = tuple[int, int, int, int]
+LayerBounds = tuple[float, float, float, float]
class LayerDimensions(TypedDict):
"""Calculated layer dimension info for a layer."""
- width: int
- height: int
- center_x: int
- center_y: int
- left: int
- right: int
- top: int
- bottom: int
+
+ width: float
+ height: float
+ center_x: float
+ center_y: float
+ left: float
+ right: float
+ top: float
+ bottom: float
"""
@@ -109,23 +112,25 @@ class LayerDimensions(TypedDict):
class ApplicationHandler(Application):
"""Wrapper for the Photoshop Application class."""
- def __init__(self, env: Optional[AppEnvironment] = None):
+ def __init__(self, env: AppEnvironment | None = None):
version = env.PS_VERSION if env else None
super().__init__(version=version)
self._env = env
# Set error dialog state
with suppress(Exception):
- self.displayDialogs = DialogModes.DisplayErrorDialogs if (
- env.PS_ERROR_DIALOG
- ) else DialogModes.DisplayNoDialogs
+ self.displayDialogs = (
+ DialogModes.DisplayErrorDialogs
+ if (env and env.PS_ERROR_DIALOG)
+ else DialogModes.DisplayNoDialogs
+ )
"""
* Handler Properties
"""
@cached_property
- def _env(self) -> Optional[AppEnvironment]:
+ def _env(self) -> AppEnvironment | None:
"""AppEnvironment: Global app environment object."""
return
@@ -139,12 +144,19 @@ def is_error_dialog_enabled(self) -> bool:
class PhotoshopHandler(ApplicationHandler):
"""Wrapper for a single global Photoshop Application object equipped with soft loading,
caching mechanisms, environment settings, and more."""
- DIMS_1200 = (3264, 4440)
- DIMS_800 = (2176, 2960)
- DIMS_600 = (1632, 2220)
+
_instance = None
+ _window_handle: int | None = None
+
+ @property
+ def window_handle(self) -> int | None:
+ if self._window_handle is None:
+ self._window_handle = get_window_handle_by_process_file_path_suffix(
+ "Photoshop.exe"
+ )
+ return self._window_handle
- def __new__(cls, env: Optional[Any] = None) -> 'PhotoshopHandler':
+ def __new__(cls, env: AppEnvironment | None = None) -> PhotoshopHandler:
"""Always return the same Photoshop Application instance on successive calls.
Args:
@@ -172,14 +184,21 @@ def refresh_app(self):
if not self.is_running():
try:
# Load Photoshop and default preferences
- super(PhotoshopHandler, self).__init__(env=self._env)
+ super().__init__(env=self._env)
self.preferences.rulerUnits = Units.Pixels
- self.preferences.typeUnits = Units.Points
+ self.preferences.typeUnits = TypeUnits.TypePoints
except Exception as e:
# Photoshop is either busy or unresponsive
return OSError(get_photoshop_error_message(e))
+
+ # Clear window handle as it might have changed
+ self._window_handle = None
return
+ def set_window_state(self, state: WindowState) -> None:
+ if (handle := self.window_handle) is not None:
+ set_window_state(handle, state)
+
"""
* Class Methods
"""
@@ -188,8 +207,8 @@ def refresh_app(self):
def is_running(cls) -> bool:
"""Check if the current Photoshop Application instance is still valid."""
with suppress(Exception):
- _ = cls._instance.version
- return True
+ if cls._instance and cls._instance.version:
+ return True
return False
"""
@@ -197,7 +216,7 @@ def is_running(cls) -> bool:
"""
@cache
- def charIDToTypeID(self, index: str) -> int:
+ def charIDToTypeID(self, index: str) -> int: # pyright: ignore[reportIncompatibleMethodOverride]
"""Caching handler for charIDToTypeID.
Args:
@@ -208,18 +227,12 @@ def charIDToTypeID(self, index: str) -> int:
"""
return super().charIDToTypeID(index)
- @cache
- def CharIDToTypeID(self, index: str) -> int:
- """Uppercase redirect for charIDToTypeID."""
- return self.charIDToTypeID(index)
-
- @cache
def cID(self, index: str) -> int:
"""Shorthand redirect for charIDToTypeID."""
return self.charIDToTypeID(index)
@cache
- def typeIDToCharID(self, index: int) -> str:
+ def typeIDToCharID(self, index: int) -> str: # pyright: ignore[reportIncompatibleMethodOverride]
"""Caching handler for typeIDToCharID.
Args:
@@ -230,7 +243,6 @@ def typeIDToCharID(self, index: int) -> str:
"""
return super().typeIDToCharID(index)
- @cache
def t2c(self, index: int) -> str:
"""Shorthand redirect for typeIDToCharID."""
return self.typeIDToCharID(index)
@@ -240,7 +252,7 @@ def t2c(self, index: int) -> str:
"""
@cache
- def stringIDToTypeID(self, index: str) -> int:
+ def stringIDToTypeID(self, index: str) -> int: # pyright: ignore[reportIncompatibleMethodOverride]
"""Caching handler for stringIDToTypeID.
Args:
@@ -251,18 +263,12 @@ def stringIDToTypeID(self, index: str) -> int:
"""
return super().stringIDToTypeID(index)
- @cache
- def StringIDToTypeID(self, index: str) -> int:
- """Uppercase redirect for stringIDTotypeID."""
- return self.stringIDToTypeID(index)
-
- @cache
def sID(self, index: str) -> int:
"""Shorthand redirect for stringIDToTypeID."""
return self.stringIDToTypeID(index)
@cache
- def typeIDToStringID(self, index: int) -> str:
+ def typeIDToStringID(self, index: int) -> str: # pyright: ignore[reportIncompatibleMethodOverride]
"""Caching handler for typeIDToStringID.
Args:
@@ -273,7 +279,6 @@ def typeIDToStringID(self, index: int) -> str:
"""
return super().typeIDToStringID(index)
- @cache
def t2s(self, index: int) -> str:
"""Shorthand redirect for typeIDToStringID."""
return self.typeIDToStringID(index)
@@ -292,8 +297,7 @@ def charIDToStringID(self, index: int) -> str:
Returns:
str: String representation of Char ID.
"""
- return self.typeIDToStringID(
- self.charIDToTypeID(index))
+ return self.typeIDToStringID(self.charIDToTypeID(index))
@cache
def stringIDToCharID(self, index: int) -> str:
@@ -305,18 +309,18 @@ def stringIDToCharID(self, index: int) -> str:
Returns:
str: Character representation of String ID.
"""
- return self.typeIDToCharID(
- self.stringIDToTypeID(index))
+ return self.typeIDToCharID(self.stringIDToTypeID(index))
"""
* Executing Action Descriptors
"""
def executeAction(
- self, event_id: int,
- descriptor: ActionDescriptor,
- dialogs: DialogModes = DialogModes.DisplayNoDialogs
- ) -> Any:
+ self,
+ event_id: int,
+ descriptor: ActionDescriptor | None = None,
+ display_dialogs: DialogModes = DialogModes.DisplayNoDialogs,
+ ) -> ActionDescriptor:
"""Middleware to allow all dialogs when an error occurs upon calling executeAction in development mode.
Args:
@@ -329,35 +333,33 @@ def executeAction(
"""
if self.is_error_dialog_enabled():
# Allow error dialogs if enabled in the app environment
- return super().executeAction(event_id, descriptor, DialogModes.DisplayErrorDialogs)
- return super().executeAction(event_id, descriptor, dialogs)
-
- def ExecuteAction(
- self, event_id: int,
- descriptor: ActionDescriptor,
- dialogs: DialogModes = DialogModes.DisplayNoDialogs
- ) -> Any:
- """Utility definition rerouting to original `executeAction`."""
- self.executeAction(event_id, descriptor, dialogs)
+ return super().executeAction(
+ event_id, descriptor, DialogModes.DisplayErrorDialogs
+ )
+ return super().executeAction(event_id, descriptor, display_dialogs)
"""
* Version Checks
"""
- @cache
+ @cached_property
def supports_target_text_replace(self) -> bool:
"""bool: Checks if Photoshop version supports targeted text replacement."""
- return self.version_meets_requirement('22.0.0')
+ return self.version_meets_requirement("22.0.0")
- @cache
+ @cached_property
def supports_webp(self) -> bool:
"""bool: Checks if Photoshop version supports WEBP files."""
- return self.version_meets_requirement('23.2.0')
+ return self.version_meets_requirement("23.2.0")
- @cache
+ @cached_property
def supports_generative_fill(self) -> bool:
"""Checks if Photoshop version supports Generative Fill."""
- return self.version_meets_requirement('24.6.0')
+ return self.version_meets_requirement("24.6.0")
+
+ @cached_property
+ def supports_uxp_scripts(self) -> bool:
+ return self.version_meets_requirement("23.5.0")
def version_meets_requirement(self, value: str) -> bool:
"""Checks if Photoshop version meets or exceeds required value.
@@ -374,7 +376,7 @@ def version_meets_requirement(self, value: str) -> bool:
"""
@cache
- def scale_by_dpi(self, value: Union[int, float]) -> int:
+ def scale_by_dpi(self, value: int | float) -> int:
"""Scales a value by comparing document DPI to ideal DPI.
Args:
@@ -390,7 +392,9 @@ class ReferenceLayer(ArtLayer):
"""A static ArtLayer whose properties such as width or height are not going to change. Most often
used as a reference to position or size other layers."""
- def __init__(self, parent: Any = None, app: PhotoshopHandler = None):
+ def __init__(
+ self, parent: Photoshop | None = None, app: PhotoshopHandler | None = None
+ ):
self._global_app = app if app else PhotoshopHandler()
super().__init__(parent=parent)
@@ -398,9 +402,13 @@ def __init__(self, parent: Any = None, app: PhotoshopHandler = None):
* API Methods
"""
- def duplicate(self, relativeObject=None, insertionLocation=None):
+ def duplicate(
+ self,
+ relativeObject: Layer | None = None,
+ insertionLocation: ElementPlacement | None = None,
+ ) -> ArtLayer:
"""Duplicates the layer and returns it as a `ReferenceLayer` object."""
- return ReferenceLayer(self.app.duplicate(relativeObject, insertionLocation))
+ return ReferenceLayer(super().duplicate(relativeObject, insertionLocation))
"""
* Cached Conversions
@@ -423,19 +431,19 @@ def sID(self, index: str) -> int:
"""
@cached_property
- def id(self) -> int:
+ def id(self) -> int: # pyright: ignore[reportIncompatibleMethodOverride]
"""int: This layer's ID (cached)."""
- return self.app.id
+ return super().id
@cached_property
- def action_getter(self) -> ActionReference:
+ def action_getter(self) -> ActionDescriptor:
"""Gets action descriptor info object for this layer.
Returns:
Action descriptor info object about the layer.
"""
ref = ActionReference()
- ref.putIdentifier(self.sID('layer'), self.id)
+ ref.putIdentifier(self.sID("layer"), self.id)
return self._global_app.executeActionGet(ref)
"""
@@ -443,9 +451,9 @@ def action_getter(self) -> ActionReference:
"""
@cached_property
- def bounds(self) -> LayerBounds:
- """LayerBounds: Bounds of the layer (left, top, right, bottom)."""
- return self.app.bounds
+ def bounds(self) -> LayerBounds: # pyright: ignore[reportIncompatibleMethodOverride]
+ """LayerBounds: Bounds of the layer (left, top, right, bottom) (cached)."""
+ return super().bounds
@cached_property
def bounds_no_effects(self) -> LayerBounds:
@@ -454,15 +462,16 @@ def bounds_no_effects(self) -> LayerBounds:
d = self.action_getter
try:
# Try getting bounds no effects
- bounds = d.getObjectValue(self.sID('boundsNoEffects'))
+ bounds = d.getObjectValue(self.sID("boundsNoEffects"))
except PS_EXCEPTIONS:
# Try getting bounds
- bounds = d.getObjectValue(self.sID('bounds'))
+ bounds = d.getObjectValue(self.sID("bounds"))
return (
- bounds.getInteger(self.sID('left')),
- bounds.getInteger(self.sID('top')),
- bounds.getInteger(self.sID('right')),
- bounds.getInteger(self.sID('bottom')))
+ bounds.getInteger(self.sID("left")),
+ bounds.getInteger(self.sID("top")),
+ bounds.getInteger(self.sID("right")),
+ bounds.getInteger(self.sID("bottom")),
+ )
# Fallback to layer object bounds property
return self.bounds
@@ -471,24 +480,24 @@ def bounds_no_effects(self) -> LayerBounds:
"""
@cached_property
- def dims(self) -> type[LayerDimensions]:
+ def dims(self) -> LayerDimensions:
"""LayerDimensions: Returns dimensions of the layer (cached), including:
- - bounds (left, right, top, bottom)
- - height
- - width
- - center_x
- - center_y
+ - bounds (left, right, top, bottom)
+ - height
+ - width
+ - center_x
+ - center_y
"""
return self.get_dimensions_from_bounds(self.bounds)
@cached_property
- def dims_no_effects(self) -> type[LayerDimensions]:
+ def dims_no_effects(self) -> LayerDimensions:
"""LayerDimensions: Returns dimensions of the layer (cached) without layer effects applied, including:
- - bounds (left, right, top, bottom)
- - height
- - width
- - center_x
- - center_y
+ - bounds (left, right, top, bottom)
+ - height
+ - width
+ - center_x
+ - center_y
"""
return self.get_dimensions_from_bounds(self.bounds_no_effects)
@@ -497,7 +506,9 @@ def dims_no_effects(self) -> type[LayerDimensions]:
"""
@staticmethod
- def get_dimensions_from_bounds(bounds) -> type[LayerDimensions]:
+ def get_dimensions_from_bounds(
+ bounds: tuple[float, float, float, float],
+ ) -> LayerDimensions:
"""Compute width and height based on a set of bounds given.
Args:
@@ -513,8 +524,11 @@ def get_dimensions_from_bounds(bounds) -> type[LayerDimensions]:
height=height,
center_x=round((width / 2) + bounds[0]),
center_y=round((height / 2) + bounds[1]),
- left=int(bounds[0]), right=int(bounds[2]),
- top=int(bounds[1]), bottom=int(bounds[3]))
+ left=int(bounds[0]),
+ right=int(bounds[2]),
+ top=int(bounds[1]),
+ bottom=int(bounds[3]),
+ )
"""
@@ -522,7 +536,7 @@ def get_dimensions_from_bounds(bounds) -> type[LayerDimensions]:
"""
-def try_photoshop(func) -> Callable:
+def try_photoshop[**P, T](func: Callable[P, T]) -> Callable[P, T | None]:
"""Decorator to handle trying to run a Photoshop action but allowing exceptions to fail silently.
Args:
@@ -531,12 +545,13 @@ def try_photoshop(func) -> Callable:
Returns:
The wrapped function.
"""
- def wrapper(self, *args, **kwargs):
+
+ def wrapper(*args: P.args, **kwargs: P.kwargs):
try:
- result = func(self, *args, **kwargs)
- return result
+ return func(*args, **kwargs)
except PS_EXCEPTIONS:
return
+
return wrapper
@@ -555,11 +570,13 @@ def get_photoshop_error_message(err: Exception) -> str:
Proper user response for this exception.
"""
return (
- "Photoshop is currently busy, close any dialogs and stop any actions.\n"
- ) if 'busy' in str(err).lower() else (
- "Photoshop does not appear to be installed on your system.\n"
- "Please close Proxyshop and install a fresh copy of Photoshop,\n"
- "if Photoshop is installed, view the FAQ for troubleshooting.\n"
+ ("Photoshop is currently busy, close any dialogs and stop any actions.\n")
+ if "busy" in str(err).lower()
+ else (
+ "Photoshop does not appear to be installed on your system.\n"
+ "Please close Proxyshop and install a fresh copy of Photoshop,\n"
+ "if Photoshop is installed, view the FAQ for troubleshooting.\n"
+ )
)
diff --git a/src/utils/asynchronic.py b/src/utils/asynchronic.py
new file mode 100644
index 00000000..7ae9be94
--- /dev/null
+++ b/src/utils/asynchronic.py
@@ -0,0 +1,36 @@
+from asyncio import EventLoop, Future, InvalidStateError, get_running_loop, run
+from collections.abc import Coroutine
+from concurrent.futures import ThreadPoolExecutor
+from logging import getLogger
+from typing import Any
+
+_logger = getLogger(__name__)
+
+_thread_pool = ThreadPoolExecutor(max_workers=4)
+
+
+def async_to_sync[T](awaitable: Coroutine[Any, Any, T]) -> T:
+ return _thread_pool.submit(run, awaitable, loop_factory=EventLoop).result()
+
+
+async def run_in_thread[T](coro: Coroutine[Any, Any, T]) -> None:
+ def target():
+ # Using asyncio.run without a factory starts another Qt loop, since
+ # Qt sets the global loop policy to it's own implementation.
+ # Starting another Qt loop fails, because the loop also tries to
+ # exec another Qt application. As a workaround, we have to specifically
+ # create an instance of the default non-Qt event loop.
+ run(coro, loop_factory=EventLoop)
+
+ get_running_loop().run_in_executor(_thread_pool, target)
+
+
+def try_threadsafe_set_result[T](task: Future[T], result: T) -> None:
+ def set_result_if_not_done():
+ if not task.done():
+ try:
+ task.set_result(result)
+ except InvalidStateError as err:
+ _logger.warning("Failed to set future result", exc_info=err)
+
+ task.get_loop().call_soon_threadsafe(set_result_if_not_done)
diff --git a/src/utils/data_structures.py b/src/utils/data_structures.py
new file mode 100644
index 00000000..02759224
--- /dev/null
+++ b/src/utils/data_structures.py
@@ -0,0 +1,56 @@
+import tomllib
+from collections.abc import Callable, Iterable, Sequence
+from pathlib import Path
+
+import yaml
+from pydantic import BaseModel
+
+
+def first[T](iterable: Iterable[T]) -> T:
+ return next(iter(iterable))
+
+
+def find_item[T](iterable: Iterable[T], condition: Callable[[T], bool]) -> T | None:
+ for item in iterable:
+ if condition(item):
+ return item
+ return None
+
+
+def find_index[T](iterable: Iterable[T], condition: Callable[[T], bool]) -> int:
+ for idx, item in enumerate(iterable):
+ if condition(item):
+ return idx
+ return -1
+
+
+def find_last_index[T](sequence: Sequence[T], condition: Callable[[T], bool]) -> int:
+ for idx, item in enumerate(reversed(sequence)):
+ if condition(item):
+ return len(sequence) - 1 - idx
+ return -1
+
+
+def parse_model[T: BaseModel](path: Path, model: type[T]) -> T:
+ if path.suffix == ".json":
+ return model.model_validate_json(path.read_bytes())
+ else:
+ if path.suffix == ".toml":
+ with open(path, "rb") as f:
+ data = tomllib.load(f)
+ return model.model_validate(data)
+ if path.suffix in (".yaml", ".yml"):
+ with open(path, "rb") as f:
+ data = yaml.safe_load(f)
+ return model.model_validate(data)
+ raise NotImplementedError(
+ f"{
+ model
+ } can be parsed only from .json, .toml, .yaml and .yml files. Got file: {path}"
+ )
+
+
+def dump_model(path: Path, model: BaseModel) -> None:
+ with open(path, "w", encoding="utf-8") as f:
+ if path.suffix in (".yaml", ".yml"):
+ yaml.dump(model.model_dump(), stream=f)
diff --git a/src/utils/download.py b/src/utils/download.py
index f31d1139..129ae8c3 100644
--- a/src/utils/download.py
+++ b/src/utils/download.py
@@ -1,13 +1,12 @@
"""
* Utils: Downloads and Updates
"""
-# Standard Library Imports
+
import shutil
+from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
-from typing import Callable, Optional
-# Third Party Imports
import requests
import yarl
from omnitils.fetch import download_file
@@ -21,8 +20,9 @@ class HEADERS:
Default = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) "
- "AppleWebKit/537.36 (KHTML, like Gecko) "
- "Chrome/39.0.2171.95 Safari/537.36"}
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/39.0.2171.95 Safari/537.36"
+ }
"""
@@ -30,7 +30,9 @@ class HEADERS:
"""
-def download_cloudfront(url: yarl.URL, path: Path, callback: Optional[Callable] = None) -> bool:
+def download_cloudfront(
+ url: yarl.URL, path: Path, callback: Callable[[int, int], None] | None = None
+) -> bool:
"""Download a template from cloudfront cached Amazon S3 bucket.
Args:
@@ -42,16 +44,13 @@ def download_cloudfront(url: yarl.URL, path: Path, callback: Optional[Callable]
True if download is successful, otherwise False.
"""
# Get a temp file
- temp_path = get_temporary_file(path=path, ext='.amzn')
+ temp_path = get_temporary_file(path=path, ext=".amzn")
# Start the download
try:
- download_file(
- url=url,
- path=temp_path,
- callback=callback)
+ download_file(url=url, path=temp_path, callback=callback)
shutil.move(temp_path, path)
unpack_archive(path)
- except (requests.RequestException, FileExistsError, FileNotFoundError):
+ except requests.RequestException, FileExistsError, FileNotFoundError:
return False
return True
diff --git a/src/utils/event.py b/src/utils/event.py
new file mode 100644
index 00000000..bba61ecd
--- /dev/null
+++ b/src/utils/event.py
@@ -0,0 +1,40 @@
+from collections.abc import Callable
+
+
+class SubscribableEvent[T]:
+ def __init__(self) -> None:
+ self._listeners: set[Callable[[T], None]] = set()
+
+ def add_listener(self, listener: Callable[[T], None]) -> None:
+ self._listeners.add(listener)
+
+ def listen_once(self, listener: Callable[[T], None]) -> None:
+ def one_off(value: T) -> None:
+ try:
+ self._listeners.remove(one_off)
+ except KeyError:
+ pass
+ listener(value)
+
+ self.add_listener(one_off)
+
+ def remove_listener(self, listener: Callable[[T], None]) -> bool:
+ try:
+ self._listeners.remove(listener)
+ return True
+ except KeyError:
+ return False
+
+ def trigger(self, value: T) -> bool:
+ """Send the event to listeners.
+
+ Returns:
+ True if listeners were present, otherwise False"""
+ if len(self._listeners) == 0:
+ return False
+
+ # Copy the set in order to allow listeners to remove themselves during iteration.
+ for listener in self._listeners.copy():
+ listener(value)
+
+ return True
diff --git a/src/utils/fonts.py b/src/utils/fonts.py
index e45559ab..ed957671 100644
--- a/src/utils/fonts.py
+++ b/src/utils/fonts.py
@@ -1,25 +1,45 @@
"""
* Font Utils
"""
-# Standard Library Imports
-import ctypes
+
import os
-from contextlib import suppress
-from ctypes import wintypes
import os.path as osp
import re
-from typing import Optional, TypedDict
-
-# Third Party Imports
-from photoshop.api.enumerations import LayerKind
-from fontTools.ttLib import TTFont, TTLibError
+from contextlib import suppress
+from ctypes import COMError, windll, wintypes
+from functools import cached_property
+from logging import getLogger
+from pathlib import Path
+from typing import TypedDict
+
+from fontTools.ttLib import ( # pyright: ignore[reportMissingTypeStubs]
+ TTFont,
+ TTLibError,
+)
from packaging.version import parse
+from photoshop.api._document import Document
+from photoshop.api._layerSet import LayerSet
+from photoshop.api.enumerations import LayerKind
-# Local Imports
-from src.utils.adobe import LayerContainer, PhotoshopHandler, PS_EXCEPTIONS
+from src.utils.adobe import PS_EXCEPTIONS, PhotoshopHandler
+
+_logger = getLogger(__name__)
# Precompile font version pattern
-REG_FONT_VER: re.Pattern = re.compile(r"\b(\d+\.\d+)\b")
+REG_FONT_VER: re.Pattern[str] = re.compile(r"\b(\d+\.\d+)\b")
+
+
+class FontCache:
+ @cached_property
+ def user_fonts_dir(self) -> Path:
+ return Path.home() / "AppData/Local/Microsoft/Windows/Fonts"
+
+ @cached_property
+ def user_font_files(self) -> list[Path]:
+ return [path for path in self.user_fonts_dir.iterdir() if path.is_file()]
+
+
+FONT_CACHE = FontCache()
"""
* Types
@@ -28,8 +48,9 @@
class FontDetails(TypedDict):
"""Font name and current version."""
- name: Optional[str]
- version: Optional[str]
+
+ name: str | None
+ version: str | None
"""
@@ -47,19 +68,22 @@ def register_font(ps_app: PhotoshopHandler, font_path: str) -> bool:
Returns:
True if succeeded, False if failed.
"""
- result = ctypes.windll.gdi32.AddFontResourceW(osp.abspath(font_path))
+ result = windll.gdi32.AddFontResourceW(osp.abspath(font_path))
if result != 0:
# Font Resource added successfully
try:
# Notify all programs
- print(f"{osp.basename(font_path)} added to font cache!")
+ _logger.debug(f"Font '{osp.basename(font_path)}' added to font cache.")
hwnd_broadcast = wintypes.HWND(-1)
- ctypes.windll.user32.SendMessageW(
- hwnd_broadcast, wintypes.UINT(0x001D), wintypes.WPARAM(0), wintypes.LPARAM(0)
+ windll.user32.SendMessageW(
+ hwnd_broadcast,
+ wintypes.UINT(0x001D),
+ wintypes.WPARAM(0),
+ wintypes.LPARAM(0),
)
ps_app.refreshFonts()
- except Exception as e:
- print(e)
+ except Exception:
+ _logger.exception(f"Couldn't register font: {font_path}")
return True
return False
@@ -74,19 +98,22 @@ def unregister_font(ps_app: PhotoshopHandler, font_path: str) -> bool:
Returns:
True if succeeded, False if failed.
"""
- result = ctypes.windll.gdi32.RemoveFontResourceW(osp.abspath(font_path))
+ result = windll.gdi32.RemoveFontResourceW(osp.abspath(font_path))
if result != 0:
# Font Resource removed successfully
try:
# Notify all programs
- print(f"{osp.basename(font_path)} removed from font cache!")
+ _logger.debug(f"Font {osp.basename(font_path)} removed from font cache!")
hwnd_broadcast = wintypes.HWND(-1)
- ctypes.windll.user32.SendMessageW(
- hwnd_broadcast, wintypes.UINT(0x001D), wintypes.WPARAM(0), wintypes.LPARAM(0)
+ windll.user32.SendMessageW(
+ hwnd_broadcast,
+ wintypes.UINT(0x001D),
+ wintypes.WPARAM(0),
+ wintypes.LPARAM(0),
)
ps_app.refreshFonts()
- except Exception as e:
- print(e)
+ except Exception:
+ _logger.exception(f"Couldn't unregister font: {font_path}")
return True
return False
@@ -96,6 +123,15 @@ def unregister_font(ps_app: PhotoshopHandler, font_path: str) -> bool:
"""
+def is_font_available_in_ps(
+ ps_app: PhotoshopHandler, font_post_script_name: str
+) -> bool:
+ try:
+ return bool(ps_app.fonts[font_post_script_name])
+ except COMError, KeyError:
+ return False
+
+
def get_ps_font_dict(ps_app: PhotoshopHandler) -> dict[str, str]:
"""Gets a dictionary of every font accessible in Photoshop.
@@ -105,19 +141,24 @@ def get_ps_font_dict(ps_app: PhotoshopHandler) -> dict[str, str]:
Returns:
Dictionary with postScriptName as key, display name as value.
"""
- fonts = {}
+ fonts: dict[str, str] = {}
for f in ps_app.fonts:
- with suppress(PS_EXCEPTIONS):
+ with suppress(*PS_EXCEPTIONS):
fonts[f.name] = f.postScriptName
return fonts
+class FontInfo(TypedDict):
+ name: str | None
+ count: int
+
+
def get_document_fonts(
ps_app: PhotoshopHandler,
- container: Optional[type[LayerContainer]] = None,
- fonts: Optional[dict] = None,
- ps_fonts: Optional[dict] = None
-) -> dict:
+ container: LayerSet | Document | None = None,
+ fonts: dict[str, FontInfo] | None = None,
+ ps_fonts: dict[str, str] | None = None,
+) -> dict[str, FontInfo]:
"""Get a list of all fonts used in a given Photoshop Document or LayerSet.
Args:
@@ -140,15 +181,13 @@ def get_document_fonts(
# Log a new font or update an existing one
font = str(layer.textItem.font)
if font in fonts:
- fonts[font]['count'] += 1
+ fonts[font]["count"] += 1
else:
- fonts[font] = {
- 'name': ps_fonts.get(font, None),
- 'count': 1
- }
- except PS_EXCEPTIONS:
- # Font property couldn't be accessed
- print(f"Font unreadable for layer: {layer.name}")
+ fonts[font] = {"name": ps_fonts.get(font, None), "count": 1}
+ except PS_EXCEPTIONS as exc:
+ _logger.warning(
+ f"Couldn't read font from layer: {layer.name}", exc_info=exc
+ )
# Make additional calls for nested groups
for group in container.layerSets:
@@ -161,7 +200,7 @@ def get_document_fonts(
"""
-def get_font_details(path: str) -> Optional[tuple[str, FontDetails]]:
+def get_font_details(path: str) -> tuple[str, FontDetails] | None:
"""Gets the font name and postscript name for a given font file.
Args:
@@ -170,17 +209,23 @@ def get_font_details(path: str) -> Optional[tuple[str, FontDetails]]:
Returns:
Tuple containing name and postscript name.
"""
- with suppress(PS_EXCEPTIONS, TTLibError):
+ with suppress(*PS_EXCEPTIONS, TTLibError):
with TTFont(path) as font:
- font_name = font['name'].getName(4, 3, 1, 1033).toUnicode()
- font_postscript = font['name'].getDebugName(6)
- version_match = REG_FONT_VER.search(font['name'].getDebugName(5))
- font_version = version_match.group(1).lstrip('0') if version_match else None
- return font_postscript, {'name': font_name, 'version': font_version}
- return
+ if (
+ (name_record := font["name"].getName(4, 3, 1, 1033))
+ and (font_postscript := font["name"].getDebugName(6)) is not None
+ and (font_version := font["name"].getDebugName(5)) is not None
+ ):
+ font_name = name_record.toUnicode()
+
+ version_match = REG_FONT_VER.search(font_version)
+ font_version = (
+ version_match.group(1).lstrip("0") if version_match else None
+ )
+ return font_postscript, {"name": font_name, "version": font_version}
-def get_fonts_from_folder(folder: str) -> dict[str, FontDetails]:
+def get_fonts_from_folder(folder: str | os.PathLike[str]) -> dict[str, FontDetails]:
"""Return a dictionary of font details for the fonts contained in a target directory.
Args:
@@ -192,7 +237,9 @@ def get_fonts_from_folder(folder: str) -> dict[str, FontDetails]:
# Get a list of the font names in your `fonts` folder
with suppress(Exception):
ext = (".otf", ".ttf", ".OTF", ".TTF")
- local_fonts = [osp.join(folder, f) for f in os.listdir(folder) if f.endswith(ext)]
+ local_fonts = [
+ osp.join(folder, f) for f in os.listdir(folder) if f.endswith(ext)
+ ]
return {n[0]: n[1] for n in [get_font_details(f) for f in local_fonts] if n}
return {}
@@ -204,11 +251,10 @@ def get_installed_fonts_dict() -> dict[str, FontDetails]:
Dictionary with postScriptName as key, and tuple of display name and version as value.
"""
with suppress(Exception):
- installed_fonts_dir = os.path.expandvars(r'%userprofile%\AppData\Local\Microsoft\Windows\Fonts')
- system_fonts_dir = os.path.join(os.path.join(os.environ['WINDIR']), 'Fonts')
+ system_fonts_dir = os.path.join(os.path.join(os.environ["WINDIR"]), "Fonts")
return {
- **get_fonts_from_folder(installed_fonts_dir),
- **get_fonts_from_folder(system_fonts_dir)
+ **get_fonts_from_folder(FONT_CACHE.user_fonts_dir),
+ **get_fonts_from_folder(system_fonts_dir),
}
return {}
@@ -219,8 +265,7 @@ def get_installed_fonts_dict() -> dict[str, FontDetails]:
def get_outdated_fonts(
- fonts: dict[str, FontDetails],
- missing: Optional[dict[str, FontDetails]] = None
+ fonts: dict[str, FontDetails], missing: dict[str, FontDetails] | None = None
) -> dict[str, FontDetails]:
"""Compares the version of each font given against installed fonts.
@@ -239,14 +284,26 @@ def get_outdated_fonts(
# Check fonts for any outdated
for name, data in fonts.items():
- if name in installed and installed[name].get('version'):
- if parse(installed[name]['version']) < parse(data['version']):
+ if name in installed and installed[name].get("version"):
+ version = data["version"]
+ installed_version = installed[name]["version"]
+ if (
+ version
+ and installed_version
+ and parse(installed_version) < parse(version)
+ ):
outdated[name] = data
# Check missing fonts to see if found in installed dict, if so check for version change
for k in list(missing.keys()):
- if k in installed and installed[k].get('version'):
- if parse(installed[k]['version']) < parse(missing[k]['version']):
+ if k in installed and installed[k].get("version"):
+ installed_version = installed[k]["version"]
+ missing_version = missing[k]["version"]
+ if (
+ installed_version
+ and missing_version
+ and parse(installed_version) < parse(missing_version)
+ ):
outdated[k] = missing[k]
del missing[k]
@@ -254,8 +311,7 @@ def get_outdated_fonts(
def get_missing_fonts(
- ps_app: PhotoshopHandler,
- fonts: dict[str, FontDetails]
+ ps_app: PhotoshopHandler, fonts: dict[str, FontDetails]
) -> tuple[dict[str, FontDetails], dict[str, FontDetails]]:
"""Checks each font to see if it's present in the Photoshop font list.
@@ -281,8 +337,7 @@ def get_missing_fonts(
def check_app_fonts(
- ps_app: PhotoshopHandler,
- folders: list[str]
+ ps_app: PhotoshopHandler, folders: list[str | os.PathLike[str]]
) -> tuple[dict[str, FontDetails], dict[str, FontDetails]]:
"""Checks each font in a folder to see if it is installed or outdated.
diff --git a/src/utils/github.py b/src/utils/github.py
new file mode 100644
index 00000000..5e262c6b
--- /dev/null
+++ b/src/utils/github.py
@@ -0,0 +1,89 @@
+from collections.abc import Callable
+from logging import getLogger
+from typing import Literal
+
+from backoff import expo, on_exception
+from limits import RateLimitItemPerHour
+from limits.storage import MemoryStorage
+from limits.strategies import MovingWindowRateLimiter
+from omnitils.exceptions import return_on_exception
+from omnitils.rate_limit import rate_limit
+from pydantic import BaseModel, RootModel
+from requests import RequestException, get
+
+from src.utils.logging import log_on_exception
+
+_logger = getLogger(__name__)
+
+# Rate limiter to safely limit GitHub requests
+_rate_limit_storage = MemoryStorage()
+_rate_limiter = MovingWindowRateLimiter(_rate_limit_storage)
+_rate_limit = RateLimitItemPerHour(60)
+
+_headers = {"accept": "application/vnd.github+json"}
+
+GITHUB_API_BASE_URL = "https://api.github.com/"
+GITHUB_API_RELEASES_URL = (
+ GITHUB_API_BASE_URL + "repos/{repo}/releases?per_page={per_page}&page={page}"
+)
+
+
+# These definitions are incomplete since we don't need most of the fields.
+class GitHubReleaseAsset(BaseModel):
+ id: int
+ name: str
+ content_type: str
+ state: Literal["uploaded", "open"]
+ size: int
+ """Bytes"""
+ created_at: str
+ updated_at: str
+ browser_download_url: str
+
+
+class GitHubRelease(BaseModel):
+ id: int
+ tag_name: str
+ name: str
+ draft: bool
+ prerelease: bool
+ created_at: str
+ updated_at: str
+ published_at: str
+ assets: list[GitHubReleaseAsset]
+
+
+GitHubReleases = RootModel[list[GitHubRelease]]
+
+
+def github_request_wrapper[T, **P](
+ fallback: T,
+) -> Callable[[Callable[P, T]], Callable[P, T]]:
+ def decorator(func: Callable[P, T]):
+ @return_on_exception(fallback)
+ @log_on_exception(logger=_logger)
+ @rate_limit(limiter=_rate_limiter, limit=_rate_limit)
+ @on_exception(expo, RequestException, logger=None, max_tries=1, max_time=1)
+ def wrapper(*args: P.args, **kwargs: P.kwargs):
+ return func(*args, **kwargs)
+
+ return wrapper
+
+ return decorator
+
+
+_default_releases: list[GitHubRelease] = []
+
+
+@github_request_wrapper(_default_releases)
+def get_github_releases(
+ repository: str, per_page: int = 5, page: int = 1
+) -> list[GitHubRelease]:
+ response = get(
+ GITHUB_API_RELEASES_URL.format(repo=repository, per_page=per_page, page=page),
+ headers=_headers,
+ timeout=(10, 10),
+ )
+ if response.status_code == 200:
+ return GitHubReleases.model_validate_json(response.content).root
+ raise RequestException(response=response)
diff --git a/src/utils/hexapi.py b/src/utils/hexapi.py
index 171ab963..9dfda399 100644
--- a/src/utils/hexapi.py
+++ b/src/utils/hexapi.py
@@ -1,78 +1,78 @@
"""
* Handles Requests to the hexproof.io API
"""
-# Standard Library Imports
+
+from asyncio import gather, to_thread
+from collections.abc import Awaitable, Callable
from contextlib import suppress
+from datetime import UTC, datetime
from functools import cache
+from io import BytesIO
+from logging import getLogger
from pathlib import Path
-from typing import Any, Callable, Optional
+from zipfile import ZipFile
-# Third Party Imports
import requests
-from requests import RequestException
-from ratelimit import RateLimitDecorator, sleep_and_retry
-from backoff import on_exception, expo
-from omnitils.exceptions import log_on_exception, return_on_exception
-from omnitils.fetch import download_file
-from omnitils.files.archive import unpack_zip
+from backoff import expo, on_exception
+from hexproof.hexapi.enums import HexURL
+from hexproof.hexapi.schema.meta import Meta
+from limits import RateLimitItemPerSecond
+from limits.storage import MemoryStorage
+from limits.strategies import MovingWindowRateLimiter
+from omnitils.exceptions import return_on_exception
from omnitils.files import dump_data_file
-from omnitils.schema import Schema
-from hexproof.hexapi import schema as Hexproof
+from omnitils.rate_limit import rate_limit
+from requests import RequestException, get
-# Local Imports
-from src import CON, CONSOLE, PATH
-from hexproof.hexapi.enums import HexURL
+from src import CON, ENV
+from src._loader import SymbolsManifest, get_symbols_manifest
+from src._state import PATH, AppEnvironment, HexproofSet, HexproofSets
from src.utils.download import HEADERS
+from src.utils.github import GitHubReleaseAsset, get_github_releases
-"""
-* Types
-"""
-
-
-class HexproofSet(Schema):
- """Cached 'Set' object data from Hexproof.io."""
- code_symbol: str
- code_parent: Optional[str] = None
- count_cards: int
- count_tokens: int
- count_printed: Optional[int] = None
-
+_logger = getLogger(__name__)
"""
* Hexproof.io Objects
"""
# Rate limiter to safely limit Hexproof.io requests
-hexproof_rate_limit = RateLimitDecorator(calls=20, period=1)
+_rate_limit_storage = MemoryStorage()
+_hexproof_rate_limit = MovingWindowRateLimiter(_rate_limit_storage)
+_rate_limit = RateLimitItemPerSecond(20)
# Hexproof.io HTTP header
hexproof_http_header = HEADERS.Default.copy()
"""
-* Scryfall Error Handling
+* Error Handling
"""
-def hexproof_request_wrapper(logr: Any = None) -> Callable:
- """Wrapper for a Hexproof.io request function to handle retries, rate limits, and a final exception catch.
+def hexproof_request_wrapper[T, **P](
+ fallback: T,
+) -> Callable[[Callable[P, T]], Callable[P, T]]:
+ """Wrapper for a Hexproof.io request function to handle retries, rate limits, and a final exception catch."""
+
+ def decorator(func: Callable[P, T]):
+ @return_on_exception(fallback)
+ @rate_limit(limiter=_hexproof_rate_limit, limit=_rate_limit)
+ @on_exception(
+ expo,
+ requests.exceptions.RequestException,
+ logger=None,
+ max_tries=1,
+ max_time=1,
+ )
+ def wrapper(*args: P.args, **kwargs: P.kwargs):
+ try:
+ return func(*args, **kwargs)
+ except Exception:
+ _logger.exception("Hexproof request failed")
+ raise
- Args:
- logr: Logger object to output any exception messages.
-
- Returns:
- Wrapped function.
- """
- logr = logr or CONSOLE
-
- def decorator(func):
- @return_on_exception({})
- @log_on_exception(logr)
- @sleep_and_retry
- @hexproof_rate_limit
- @on_exception(expo, requests.exceptions.RequestException, max_tries=2, max_time=1)
- def wrapper(*args, **kwargs):
- return func(*args, **kwargs)
return wrapper
+
return decorator
@@ -81,7 +81,7 @@ def wrapper(*args, **kwargs):
"""
-@hexproof_request_wrapper('')
+@hexproof_request_wrapper("")
def get_api_key(key: str) -> str:
"""Get an API key from https://api.hexproof.io.
@@ -95,16 +95,45 @@ def get_api_key(key: str) -> str:
RequestException if request was unsuccessful.
"""
url = HexURL.API.Keys.All / key
- res = requests.get(url, headers=hexproof_http_header, timeout=(3, 3))
+ res = requests.get(str(url), headers=hexproof_http_header, timeout=(3, 3))
if res.status_code == 200:
- return res.json().get('key', '')
+ return res.json().get("key", "")
raise RequestException(
- res.json().get('details', f"Failed to get key: '{key}'"),
- response=res)
+ res.json().get("details", f"Failed to get key: '{key}'"), response=res
+ )
+
+
+async def check_api_keys(env: AppEnvironment) -> bool:
+ get_operations: list[Awaitable[None]] = []
+
+ # Check if API keys are valid
+ if not env.API_GOOGLE:
+
+ async def set_google_api_key():
+ env.API_GOOGLE = await to_thread(get_api_key, "proxyshop.google.drive")
+ if not env.API_GOOGLE:
+ _logger.warning("Couldn't get Google Drive API key")
+
+ get_operations.append(set_google_api_key())
+ if not env.API_AMAZON:
+
+ async def set_amazon_api_key():
+ env.API_AMAZON = await to_thread(get_api_key, "proxyshop.amazon.s3")
+ if not env.API_AMAZON:
+ _logger.warning("Couldn't get Amazon API key")
+
+ get_operations.append(set_amazon_api_key())
+
+ await gather(*get_operations)
+ return bool(env.API_GOOGLE or env.API_AMAZON)
-@hexproof_request_wrapper()
-def get_metadata() -> dict[str, Hexproof.Meta]:
+
+_get_metadata_fallback: dict[str, Meta] = {}
+
+
+@hexproof_request_wrapper(_get_metadata_fallback)
+def get_metadata() -> dict[str, Meta]:
"""Return a manifest of all resource metadata.
Returns:
@@ -113,16 +142,21 @@ def get_metadata() -> dict[str, Hexproof.Meta]:
Raises:
RequestException if request was unsuccessful.
"""
- res = requests.get(HexURL.API.Meta.All, headers=hexproof_http_header, timeout=(3, 3))
+ res = requests.get(
+ str(HexURL.API.Meta.All), headers=hexproof_http_header, timeout=(3, 3)
+ )
if res.status_code == 200:
- return {k: Hexproof.Meta(**v) for k, v in res.json().items()}
+ return {k: Meta(**v) for k, v in res.json().items()}
raise RequestException(
- res.json().get('details', f"Failed to get metadata!"),
- response=res)
+ res.json().get("details", "Failed to get metadata!"), response=res
+ )
+
+
+_get_sets_fallback: dict[str, HexproofSet] = {}
-@hexproof_request_wrapper()
-def get_sets() -> dict:
+@hexproof_request_wrapper(_get_sets_fallback)
+def get_sets() -> dict[str, HexproofSet]:
"""Retrieve the current 'Set' data manifest from https://api.hexproof.io.
Returns:
@@ -131,92 +165,138 @@ def get_sets() -> dict:
Raises:
RequestException if request was unsuccessful.
"""
- res = requests.get(HexURL.API.Sets.All, headers=hexproof_http_header, timeout=(10, 30))
+ res = requests.get(
+ str(HexURL.API.Sets.All), headers=hexproof_http_header, timeout=(5, 5)
+ )
if res.status_code == 200:
- return res.json()
+ return HexproofSets.model_validate_json(res.content).root
raise RequestException(
- res.json().get('details', f'Failed to get set data!'),
- response=res)
+ res.json().get("details", "Failed to get set data!"), response=res
+ )
"""
-* Data Post-Processing
+* Data Caching
"""
-def process_data_sets(data: dict) -> dict[str, HexproofSet]:
- """Process bulk 'Set' data retrieved from the Hexproof API into a smaller dataset.
-
- Args:
- data: Raw data pulled from the `/sets/` Hexproof API endpoint.
-
- Returns:
- Dictionary of smaller 'HexproofSet' data entries.
- """
- return {
- code: HexproofSet(
- code_symbol=d.get('code_symbol', 'DEFAULT'),
- count_cards=d.get('count_cards', 0),
- count_tokens=d.get('count_tokens', 0),
- code_parent=d.get('code_parent'),
- count_printed=d.get('count_printed'),
- ) for code, d in data.items()
- }
-
-
-"""
-* Data Caching
-"""
+def _download_symbols(url: str) -> SymbolsManifest | None:
+ response = get(url)
+ if response.status_code == 200:
+ with ZipFile(BytesIO(response.content), "r") as zf:
+ zf.extractall(PATH.SRC_IMG_SYMBOLS)
+ return get_symbols_manifest()
+ return None
-def update_hexproof_cache() -> tuple[bool, Optional[str]]:
+def update_hexproof_cache() -> tuple[bool, str | None]:
"""Check for a hexproof.io data update.
Returns:
tuple: A tuple containing the boolean success state of the update, and a string message
explaining the error if one occurred.
"""
- meta, set_data, updated = {}, {}, False
+ meta, updated = {}, False
with suppress(Exception):
- meta: dict[str, Hexproof.Meta] = get_metadata()
+ meta: dict[str, Meta] = get_metadata()
# Check against current metadata
- _current, _next = CON.metadata.get('sets'), meta.get('sets')
+ new_set_data: dict[str, HexproofSet] | None = None
+ _current, _next = CON.metadata.get("sets"), meta.get("sets")
if not _current or not _next or _current.version != _next.version:
try:
# Download updated 'Set' data
- data = get_sets()
- data = process_data_sets(data)
- dump_data_file(
- obj={k: v.model_dump(exclude_none=True) for k, v in data.items()},
- path=PATH.SRC_DATA_HEXPROOF_SET)
+ new_set_data = get_sets()
updated = True
- except (RequestException, ValueError, OSError):
+ except RequestException, ValueError, OSError:
return False, "Unable to update 'Set' data from hexproof.io!"
# Check against current symbol data
- _current, _next = CON.metadata.get('symbols'), meta.get('symbols')
- if not _current or not _next or _current.version != _next.version:
+ new_symbols: SymbolsManifest | None = None
+ symbols_dl_url: str | None = None
+ if ENV.SYMBOL_UPDATES_REPO:
+ symbols_releases = get_github_releases(ENV.SYMBOL_UPDATES_REPO, per_page=1)
+ if len(symbols_releases) > 0:
+ latest_release = symbols_releases[0]
+ if len(latest_release.assets) > 0:
+ chosen_asset: GitHubReleaseAsset | None = None
+ for asset in latest_release.assets:
+ if "optimized" in asset.name:
+ chosen_asset = asset
+ break
+ if not chosen_asset:
+ chosen_asset = latest_release.assets[0]
+ asset_timestamp: datetime
+ try:
+ asset_timestamp = datetime.fromisoformat(latest_release.tag_name)
+ except ValueError:
+ asset_timestamp = datetime.fromisoformat(chosen_asset.updated_at)
+ if not (
+ current_symbols_manifest := get_symbols_manifest()
+ ) or asset_timestamp > datetime.fromtimestamp(
+ datetime.fromisoformat(
+ current_symbols_manifest.meta.date
+ ).timestamp(),
+ tz=UTC,
+ ):
+ symbols_dl_url = chosen_asset.browser_download_url
+ else:
+ _current, _next = CON.metadata.get("symbols"), meta.get("symbols")
+ if not _current or not _next or _current.version != _next.version:
+ symbols_dl_url = str(HexURL.API.Symbols.All / "package")
+
+ if symbols_dl_url:
try:
# Download and unpack updated 'Symbols' assets
- download_file(
- url=HexURL.API.Symbols.All / 'package',
- path=PATH.SRC_IMG_SYMBOLS_PACKAGE)
- unpack_zip(PATH.SRC_IMG_SYMBOLS_PACKAGE)
- updated = True
- except (RequestException, FileNotFoundError):
- return False, 'Unable to download symbols package!'
+ new_symbols = _download_symbols(symbols_dl_url)
+ updated = updated or bool(new_symbols)
+ except RequestException, FileNotFoundError:
+ return False, "Unable to download symbols package!"
# Update metadata
try:
if not updated:
return updated, None
+
+ # Ensure that all symbols are present in set data
+ if new_symbols:
+ symbs = new_symbols.set.symbols
+ set_data = new_set_data or CON.set_data
+ for card_set, data in set_data.items():
+ if data.code_symbol == "default":
+ if card_set.upper() in symbs:
+ data.code_symbol = card_set
+ # elif card_set in symbs:
+ # data.code_symbol = card_set
+ else:
+ curr_key = card_set
+ curr_data = data
+ while curr_data.code_parent:
+ curr_key = curr_data.code_parent
+ curr_data = set_data.get(curr_key, None)
+ if not curr_data:
+ break
+ if curr_key.upper() in symbs:
+ data.code_symbol = curr_key
+ break
+ # elif curr_key in symbs:
+ # data.code_symbol = curr_key
+ # break
+ for card_set in symbs:
+ if (card_set_low := card_set.lower()) not in set_data:
+ set_data[card_set_low] = HexproofSet(
+ code_symbol=card_set_low, count_cards=0, count_tokens=0
+ )
+ with open(PATH.SRC_DATA_HEXPROOF_SET, "w") as f:
+ f.write(HexproofSets(set_data).model_dump_json(indent=2))
+
dump_data_file(
obj={k: v.model_dump() for k, v in meta.items()},
- path=PATH.SRC_DATA_HEXPROOF_META)
+ path=PATH.SRC_DATA_HEXPROOF_META,
+ )
return updated, None
- except (FileNotFoundError, OSError, ValueError):
- return False, 'Unable to update metadata from hexproof.io!'
+ except FileNotFoundError, OSError, ValueError:
+ return False, "Unable to update metadata from hexproof.io!"
"""
@@ -225,7 +305,7 @@ def update_hexproof_cache() -> tuple[bool, Optional[str]]:
@cache
-def get_set_data(code: str) -> dict:
+def get_set_data(code: str) -> HexproofSet | None:
"""Returns a specific 'Set' object by set code.
Args:
@@ -237,7 +317,7 @@ def get_set_data(code: str) -> dict:
return CON.set_data.get(code.lower(), None)
-def get_watermark_svg_from_set(code: str) -> Optional[Path]:
+def get_watermark_svg_from_set(code: str) -> Path | None:
"""Look for a watermark SVG in the 'Set' symbol catalog.
Args:
@@ -253,16 +333,15 @@ def get_watermark_svg_from_set(code: str) -> Optional[Path]:
return
# Check if this set has a provided symbol code
- symbol = set_obj.get('code_symbol')
- if not symbol:
+ if not set_obj.code_symbol:
return
# Check if this symbol code matches a supported watermark
- p = PATH.SRC_IMG_SYMBOLS / 'set' / symbol.upper() / 'WM.svg'
+ p = PATH.SRC_IMG_SYMBOLS / "set" / set_obj.code_symbol.upper() / "WM.svg"
return p if p.is_file() else None
-def get_watermark_svg(wm: str) -> Optional[Path]:
+def get_watermark_svg(wm: str) -> Path | None:
"""Look for a watermark SVG in the watermark symbol catalog. If not found, look for a 'set' watermark.
Args:
@@ -271,5 +350,5 @@ def get_watermark_svg(wm: str) -> Optional[Path]:
Returns:
Path to a watermark SVG file if found, otherwise None.
"""
- p = (PATH.SRC_IMG_SYMBOLS / 'watermark' / wm.lower()).with_suffix('.svg')
+ p = (PATH.SRC_IMG_SYMBOLS / "watermark" / wm.lower()).with_suffix(".svg")
return p if p.is_file() else get_watermark_svg_from_set(wm)
diff --git a/src/utils/images.py b/src/utils/images.py
new file mode 100644
index 00000000..74239c4d
--- /dev/null
+++ b/src/utils/images.py
@@ -0,0 +1,41 @@
+from os import PathLike
+from pathlib import Path
+from typing import IO
+
+from PIL import Image
+
+IMAGE_ENCODING_TO_SUFFIX_MAPPING: dict[str, str] = {
+ "PNG": ".png",
+ "JPEG": ".jpg",
+ "WebP": ".webp",
+}
+
+
+def save_scaled_card_image(
+ input_path: str | PathLike[str] | IO[bytes],
+ output_path: Path,
+ image_format: str,
+ downscale_width: int | None = None,
+ quality: int = 95,
+):
+ with Image.open(input_path) as f:
+ if downscale_width is not None:
+ img_width, img_height = f.size
+ ratio = downscale_width / img_width
+ if ratio < 1:
+ f.thumbnail(
+ (downscale_width, round(ratio * img_height)),
+ resample=Image.Resampling.LANCZOS,
+ )
+ image_format = image_format.lower()
+ if image_format == "png":
+ save_kwargs = {"optimize": True}
+ elif image_format == "jpeg":
+ if f.mode == "RGBA":
+ f = f.convert("RGB")
+ save_kwargs = {"optimize": True, "quality": quality}
+ elif image_format == "webp":
+ save_kwargs = {"quality": quality, "method": 6}
+ else:
+ save_kwargs = {}
+ f.save(output_path, format=image_format, **save_kwargs)
diff --git a/src/utils/inputs.py b/src/utils/inputs.py
new file mode 100644
index 00000000..45e2967e
--- /dev/null
+++ b/src/utils/inputs.py
@@ -0,0 +1,152 @@
+from asyncio import gather, to_thread
+from collections.abc import Awaitable, Callable, Iterable, Sequence
+from itertools import batched
+from logging import getLogger
+from pathlib import Path
+
+from pydantic import ValidationError
+
+from src._config import AppConfig
+from src.cards import (
+ CardDetails,
+ card_details_to_scryfall_identifier,
+ get_batch_of_cards,
+ get_card_data,
+ parse_card_info,
+)
+from src.render_spec import parse_render_spec
+from src.utils.data_structures import find_index, find_item
+from src.utils.scryfall import CardIdentifier, ScryfallCard
+
+_logger = getLogger(__name__)
+
+
+def match_images_with_data_files(
+ paths: Iterable[Path],
+) -> list[CardDetails | tuple[CardDetails, ScryfallCard]]:
+ """
+ Pairs data files (.json) with image files that share the same name.
+
+ Raises:
+ Pydantic.ValidationError: if some of the data files don't conform to the data model
+ """
+ data_files = [parse_card_info(pth) for pth in paths if pth.suffix == ".json"]
+ render_specs = [pth for pth in paths if pth.suffix in (".yaml", ".yml")]
+ image_files = [pth for pth in paths if pth.suffix not in (".json", ".yaml", ".yml")]
+
+ results: list[CardDetails | tuple[CardDetails, ScryfallCard]] = []
+
+ def log_data_exception(file: Path) -> None:
+ _logger.exception(
+ f"Data file {file} failed to validate. Please correct the reported errors in the data and then try again. Since the file selection was invalid nothing will be added to the render queue."
+ )
+
+ def add_card(card: CardDetails) -> None:
+ card_name = card["name"]
+
+ idx = find_index(data_files, lambda item: item["name"] == card_name)
+ if idx > -1:
+ data_file = data_files.pop(idx)
+ try:
+ results.append(
+ (
+ card,
+ ScryfallCard.model_validate_json(
+ data_file["file"].read_bytes()
+ ),
+ )
+ )
+ except ValidationError:
+ log_data_exception(data_file["file"])
+ raise
+ else:
+ results.append(card)
+
+ try:
+ for path in render_specs:
+ try:
+ cards = parse_render_spec(path).cards
+ except ValidationError:
+ log_data_exception(path)
+ raise
+
+ for card in cards:
+ add_card(card)
+
+ for path in image_files:
+ card = parse_card_info(path)
+ add_card(card)
+ except ValidationError:
+ return []
+
+ if data_files:
+ _logger.warning(
+ f"Couldn't find a matching image file for files:
{
+ '
'.join([str(pth) for pth in data_files])
+ }When selecting JSON files for rendering make sure to also select an image whose card name part matches the JSON file's name, e.g. my_custom_card (artist).png and my_custom_card.json. Since the file selection was invalid nothing will be added to the render queue."
+ )
+ return []
+
+ return results
+
+
+async def get_cards_from_details(
+ inputs: Iterable[CardDetails | tuple[CardDetails, ScryfallCard]],
+ callback: Callable[[Sequence[tuple[CardDetails, ScryfallCard]]], Awaitable[None]],
+ config: AppConfig,
+) -> None:
+ """Calls the callback on fetched Scryfall cards as the API requests finish.
+
+ The collection endpoint doesn't allow looking up cards by language, so when using
+ a language other than english we have to fall back to individual requests per card."""
+
+ async def batch_fetch_and_queue(
+ batch: Iterable[tuple[CardIdentifier, list[CardDetails]]],
+ ) -> None:
+ cards = await to_thread(get_batch_of_cards, batch)
+ await callback(cards)
+
+ async def single_fetch_and_queue(
+ card: tuple[CardIdentifier, list[CardDetails]],
+ ) -> None:
+ if scryfall_card := await to_thread(get_card_data, card[0], config):
+ for crd in card[1]:
+ await callback(((crd, scryfall_card),))
+
+ cards_to_batch_fetch: list[tuple[CardIdentifier, list[CardDetails]]] = []
+ cards_to_fetch_individually: list[tuple[CardIdentifier, list[CardDetails]]] = []
+ ready_cards: list[tuple[CardDetails, ScryfallCard]] = []
+
+ active_list = (
+ cards_to_batch_fetch if config.lang == "en" else cards_to_fetch_individually
+ )
+
+ for input in inputs:
+ if isinstance(input, dict):
+ identifier = card_details_to_scryfall_identifier(input)
+ if existing := find_item(active_list, lambda item: item[0] == identifier):
+ existing[1].append(input)
+ else:
+ active_list.append((identifier, [input]))
+ else:
+ ready_cards.append(input)
+
+ await gather(
+ callback(ready_cards),
+ *[batch_fetch_and_queue(batch) for batch in batched(cards_to_batch_fetch, 75)],
+ *[single_fetch_and_queue(card) for card in cards_to_fetch_individually],
+ )
+
+
+async def get_cards_from_inputs(
+ inputs: Iterable[Path],
+ callback: Callable[[Sequence[tuple[CardDetails, ScryfallCard]]], Awaitable[None]],
+ config: AppConfig,
+) -> None:
+ """Calls the callback on fetched Scryfall cards as the API requests finish.
+
+ The collection endpoint doesn't allow looking up cards by language, so when using
+ a language other than english we have to fall back to individual requests per card."""
+ return await get_cards_from_details(
+ match_images_with_data_files(inputs), callback, config
+ )
diff --git a/src/utils/lazy.py b/src/utils/lazy.py
new file mode 100644
index 00000000..d631ad8f
--- /dev/null
+++ b/src/utils/lazy.py
@@ -0,0 +1,14 @@
+from collections.abc import Callable
+from functools import cached_property
+
+
+class LazyLoader[T]:
+ def __init__(self, factory: Callable[[], T]) -> None:
+ self._factory = factory
+
+ @cached_property
+ def _cached_value(self) -> T:
+ return self._factory()
+
+ def __call__(self) -> T:
+ return self._cached_value
diff --git a/src/utils/logging.py b/src/utils/logging.py
new file mode 100644
index 00000000..5af75892
--- /dev/null
+++ b/src/utils/logging.py
@@ -0,0 +1,24 @@
+from collections.abc import Callable
+from functools import wraps
+from logging import Logger, getLogger
+
+_logger = getLogger(__name__)
+
+
+def log_on_exception[**P, T, E: Exception](
+ msg: str | Callable[[E], str] = "",
+ exception_type: type[E] = Exception,
+ logger: Logger = _logger,
+) -> Callable[[Callable[P, T]], Callable[P, T]]:
+ def decorator(func: Callable[P, T]):
+ @wraps(func)
+ def wrapper(*args: P.args, **kwargs: P.kwargs):
+ try:
+ return func(*args, **kwargs)
+ except exception_type as e:
+ logger.exception(msg if isinstance(msg, str) else msg(e))
+ raise e
+
+ return wrapper
+
+ return decorator
diff --git a/src/utils/manual_actions.py b/src/utils/manual_actions.py
new file mode 100644
index 00000000..e3d5a237
--- /dev/null
+++ b/src/utils/manual_actions.py
@@ -0,0 +1,29 @@
+import subprocess
+from os import unlink
+from tempfile import NamedTemporaryFile
+
+from pydantic import BaseModel
+
+
+def manually_modify_model[T: BaseModel](model: T, text_editing_program: str) -> T:
+ """Opens model as JSON in a text editing program, e.g. notepad, for manual editing,
+ waits for editing to be done and then converts the edited JSON back to model form.
+
+ Args:
+ model: Model to edit.
+ text_editing_program: The command to open the text editor. Place curly brackets to where the text file path should go in the call, e.g. `notepad "{}"`.
+ """
+ tmp = NamedTemporaryFile(
+ "w",
+ prefix="Proxyshop_manual_dict_modification_",
+ encoding="UTF-8",
+ delete=False,
+ )
+ try:
+ tmp.write(model.model_dump_json(indent=2))
+ tmp.close()
+ subprocess.run((text_editing_program.format(tmp.name)), check=True)
+ with open(tmp.name, "rb") as f:
+ return model.model_validate_json(f.read())
+ finally:
+ unlink(tmp.name)
diff --git a/src/utils/mtg.py b/src/utils/mtg.py
index a77194f5..0a2d8546 100644
--- a/src/utils/mtg.py
+++ b/src/utils/mtg.py
@@ -1,16 +1,19 @@
"""
* MTG Related Utiltiies
"""
-from src.schema.colors import SymbolColorMap, ColorObject
+
from src.enums.mtg import CardTextPatterns as _P
+from src.schema.colors import ColorObject, SymbolColorMap
"""
* MTG Color Utilities
"""
-def get_symbol_colors(symbol: str, chars: str, color_map: SymbolColorMap) -> list[ColorObject]:
- """Determines the colors of a symbol (represented as Scryfall string) and returns an array Symbol colors.
+def get_symbol_colors(
+ symbol: str, chars: str, color_map: SymbolColorMap
+) -> list[ColorObject | None]:
+ """Determines the colors of a symbol (represented as Scryfall string) and returns a list of Symbol colors.
Args:
symbol: Symbol to determine the colors of.
@@ -31,12 +34,15 @@ def get_symbol_colors(symbol: str, chars: str, color_map: SymbolColorMap) -> lis
elif symbol == "{Q}":
# Untap symbol
return [color_map.primary, color_map.secondary]
+ elif symbol == "{P}":
+ # Pawprint symbol
+ return [None]
# Normal mana symbol
if normal_symbol_match := _P.MANA_NORMAL.match(symbol):
return [
color_map.colors[normal_symbol_match[1]],
- color_map.colors_inner[normal_symbol_match[1]]
+ color_map.colors_inner[normal_symbol_match[1]],
]
# Hybrid
@@ -47,14 +53,14 @@ def get_symbol_colors(symbol: str, chars: str, color_map: SymbolColorMap) -> lis
colors[hybrid_match[2]],
colors[hybrid_match[1]],
color_map.colors_inner[hybrid_match[1]],
- color_map.colors_inner[hybrid_match[2]]
+ color_map.colors_inner[hybrid_match[2]],
]
# Phyrexian
if phyrexian_match := _P.MANA_PHYREXIAN.match(symbol):
return [
color_map.hybrid[phyrexian_match[1]],
- color_map.hybrid_inner[phyrexian_match[1]]
+ color_map.hybrid_inner[phyrexian_match[1]],
]
# Phyrexian hybrid
@@ -63,7 +69,7 @@ def get_symbol_colors(symbol: str, chars: str, color_map: SymbolColorMap) -> lis
color_map.colors[phyrexian_hybrid_match[2]],
color_map.colors[phyrexian_hybrid_match[1]],
color_map.colors_inner[phyrexian_hybrid_match[1]],
- color_map.colors_inner[phyrexian_hybrid_match[2]]
+ color_map.colors_inner[phyrexian_hybrid_match[2]],
]
# Weird situation?
diff --git a/src/utils/scryfall.py b/src/utils/scryfall.py
index a0d0d8c2..45758705 100644
--- a/src/utils/scryfall.py
+++ b/src/utils/scryfall.py
@@ -1,42 +1,364 @@
"""
* Scryfall API Module
"""
-# Standard Library Imports
+
+from collections.abc import Callable, Iterable, Sequence
+from datetime import date
+from json import dumps
+from logging import getLogger
from pathlib import Path
from shutil import copyfileobj
-from typing import Optional, Union, Callable, Any, TypedDict, Literal, NotRequired
+from typing import TYPE_CHECKING, Literal, NotRequired, SupportsInt, TypedDict
+from urllib.parse import quote_plus, urlencode
+from uuid import UUID
-# Third Party Imports
-from backoff import on_exception, expo
-from hexproof.scryfall.enums import ScryURL
-from omnitils.exceptions import log_on_exception, return_on_exception
-from ratelimit import sleep_and_retry, RateLimitDecorator
import requests
+from limits import RateLimitItemPerSecond
+from limits.storage import MemoryStorage
+from limits.strategies import MovingWindowRateLimiter
+from omnitils.exceptions import return_on_exception
+from omnitils.rate_limit import rate_limit
+from pydantic import BaseModel, Field, HttpUrl, ValidationError
from requests.exceptions import RequestException
-import yarl
-# Local Imports
-from src import CONSOLE, PATH
-from src.console import get_bullet_points
+from src._state import PATH
+from src.enums.mtg import LayoutScryfall
from src.utils.download import HEADERS
+if TYPE_CHECKING:
+ from urllib.parse import (
+ _QueryType, # pyright: ignore[reportPrivateUsage]
+ _QuoteVia, # pyright: ignore[reportPrivateUsage]
+ )
+
+_logger = getLogger()
+
"""
* Types
"""
-
-class ScryfallError(TypedDict):
+QueryVar = object | Sequence[object]
+
+MagicColor = Literal["W", "U", "B", "R", "G"]
+
+Legality = Literal["legal", "not_legal", "restricted", "banned"]
+
+BorderColor = Literal["black", "white", "borderless", "yellow", "silver", "gold"]
+
+Finish = Literal["foil", "nonfoil", "etched"]
+
+FrameEffect = Literal[
+ "legendary",
+ "miracle",
+ "enchantment",
+ "draft",
+ "devoid",
+ "tombstone",
+ "colorshifted",
+ "inverted",
+ "sunmoondfc",
+ "compasslanddfc",
+ "originpwdfc",
+ "mooneldrazidfc",
+ "waxingandwaningmoondfc",
+ "showcase",
+ "extendedart",
+ "fullart",
+ "companion",
+ "etched",
+ "snow",
+ "lesson",
+ "shatteredglass",
+ "convertdfc",
+ "fandfc",
+ "upsidedowndfc",
+ "spree",
+ # Non-official
+ "meld",
+]
+
+ScryfallGame = Literal["paper", "arena", "mtgo"]
+
+ScryfallImageStatus = Literal["missing", "placeholder", "lowres", "highres_scan"]
+
+Rarity = Literal["common", "uncommon", "rare", "special", "mythic", "bonus"]
+
+SetType = Literal[
+ "core",
+ "expansion",
+ "masters",
+ "eternal",
+ "alchemy",
+ "masterpiece",
+ "arsenal",
+ "from_the_vault",
+ "spellbook",
+ "premium_deck",
+ "duel_deck",
+ "draft_innovation",
+ "treasure_chest",
+ "commander",
+ "planechase",
+ "archenemy",
+ "vanguard",
+ "funny",
+ "starter",
+ "box",
+ "promo",
+ "token",
+ "memorabilia",
+ "minigame",
+]
+
+
+class ScryfallImageUris(BaseModel):
+ png: HttpUrl
+ border_crop: HttpUrl
+ art_crop: HttpUrl
+ large: HttpUrl
+ normal: HttpUrl
+ small: HttpUrl
+
+
+class ScryfallPrices(BaseModel):
+ usd: str | None = None
+ usd_foil: str | None = None
+ usd_etched: str | None = None
+ eur: str | None = None
+ eur_foil: str | None = None
+ eur_etched: str | None = None
+ tix: str | None = None
+
+
+class ScryfallPurchaseUris(BaseModel):
+ tcgplayer: HttpUrl
+ cardmarket: HttpUrl
+ cardhoarder: HttpUrl
+
+
+class ScryfallRelatedCard(BaseModel):
+ id: UUID
+ object: Literal["related_card"]
+ component: Literal["token", "meld_part", "meld_result", "combo_piece"]
+ name: str
+ type_line: str
+ uri: HttpUrl
+
+
+class ScryfallPreview(BaseModel):
+ previewed_at: date | None = None
+ source_uri: HttpUrl | Literal[""] | None = None
+ source: str | None = None
+
+
+class ScryfallCardFace(BaseModel):
+ artist: str | None = None
+ artist_id: UUID | None = None
+ cmc: float | None = None
+ color_indicator: list[MagicColor] | None = None
+ colors: list[MagicColor] | None = None
+ defense: str | None = None
+ flavor_text: str | None = None
+ illustration_id: UUID | None = None
+ image_uris: ScryfallImageUris | None = None
+ layout: LayoutScryfall | None = None
+ loyalty: str | None = None
+ mana_cost: str | None = None
+ name: str
+ object: Literal["card_face"]
+ oracle_id: UUID | None = None
+ oracle_text: str | None = None
+ power: str | None = None
+ printed_name: str | None = None
+ printed_text: str | None = None
+ printed_type_line: str | None = None
+ toughness: str | None = None
+ type_line: str | None = None
+ watermark: str | None = None
+
+
+class ScryfallCard(BaseModel):
+ # Core Card Fields
+ arena_id: int | None = None
+ id: UUID
+ lang: str
+ mtgo_id: int | None = None
+ mtgo_foil_id: int | None = None
+ multiverse_ids: list[int] | None = None
+ tcgplayer_id: int | None = None
+ tcgplayer_etched_id: int | None = None
+ cardmarket_id: int | None = None
+ object: Literal["card"]
+ layout: LayoutScryfall
+ oracle_id: UUID | None = None
+ prints_search_uri: HttpUrl
+ rulings_uri: HttpUrl
+ scryfall_uri: HttpUrl
+ uri: HttpUrl
+
+ # Gameplay Fields
+ all_parts: list[ScryfallRelatedCard] | None = None
+ card_faces: list[ScryfallCardFace] | None = None
+ cmc: float
+ color_identity: list[MagicColor]
+ color_indicator: list[MagicColor] | None = None
+ colors: list[MagicColor] | None = None
+ defense: str | None = None
+ edhrec_rank: int | None = None
+ game_changer: bool | None = None
+ hand_modifier: str | None = None
+ keywords: list[str]
+ legalities: dict[str, Legality]
+ life_modifier: str | None = None
+ loyalty: str | None = None
+ mana_cost: str | None = None
+ name: str
+ oracle_text: str | None = None
+ penny_rank: int | None = None
+ power: str | None = None
+ produced_mana: list[str] | None = None
+ reserved: bool
+ toughness: str | None = None
+ type_line: str
+
+ # Print Fields
+ artist: str | None = None
+ artist_ids: list[UUID] | None = None
+ attraction_lights: list[int] | None = None
+ booster: bool
+ border_color: BorderColor
+ card_back_id: UUID | None = None
+ collector_number: str
+ content_warning: bool | None = None
+ digital: bool
+ finishes: list[Finish]
+ flavor_name: str | None = None
+ flavor_text: str | None = None
+ frame_effects: list[FrameEffect] | None = None
+ frame: str
+ full_art: bool
+ games: list[ScryfallGame]
+ highres_image: bool
+ illustration_id: UUID | None = None
+ image_status: ScryfallImageStatus
+ image_uris: ScryfallImageUris | None = None
+ oversized: bool
+ prices: ScryfallPrices
+ printed_name: str | None = None
+ printed_text: str | None = None
+ printed_type_line: str | None = None
+ promo: bool
+ promo_types: list[str] | None = None
+ purchase_uris: ScryfallPurchaseUris | None = None
+ rarity: Rarity
+ related_uris: dict[str, HttpUrl]
+ released_at: date
+ reprint: bool
+ scryfall_set_uri: HttpUrl
+ set_name: str
+ set_search_uri: HttpUrl
+ set_type: SetType
+ set_uri: HttpUrl
+ set: str
+ set_id: UUID
+ story_spotlight: bool
+ textless: bool
+ variation: bool
+ variation_of: UUID | None = None
+ security_stamp: str | None = None
+ watermark: str | None = None
+ preview: ScryfallPreview | None = None
+
+ # Non-official extensions
+ front: bool = Field(default=True, exclude=True)
+
+
+class ScryfallList[T](BaseModel):
+ object: Literal["list"]
+ data: list[T]
+ has_more: bool = False
+ next_page: HttpUrl | None = None
+ total_cards: int | None = None
+ warnings: list[str] | None = None
+
+
+class ScryfallCardList(ScryfallList[ScryfallCard]):
+ pass
+
+
+class ScryfallSet(BaseModel):
+ object: Literal["set"]
+ id: UUID
+ code: str
+ mtgo_code: str | None = None
+ arena_code: str | None = None
+ tcgplayer_id: int | None = None
+ name: str
+ set_type: SetType
+ released_at: date | None = None
+ block_code: str | None = None
+ block: str | None = None
+ parent_set_code: str | None = None
+ card_count: int
+ printed_size: int | None = None
+ digital: bool
+ foil_only: bool
+ nonfoil_only: bool
+ scryfall_uri: HttpUrl
+ uri: HttpUrl
+ icon_svg_uri: HttpUrl
+ search_uri: HttpUrl
+
+
+class ScryfallError(BaseModel):
"""Error object outlined in Scryfall's API docs.
Notes:
https://scryfall.com/docs/api/errors
"""
- object: Literal['error']
+
+ object: Literal["error"]
code: str
status: int
details: str
- type: NotRequired[str]
- warnings: NotRequired[list[str]]
+ type: str | None = None
+ warnings: list[str] | None = None
+
+
+# region URLs
+
+_base_scryfall_url = "https://api.scryfall.com"
+_cards_url = f"{_base_scryfall_url}/cards"
+_cards_collection_url = f"{_cards_url}/collection"
+
+
+def _cards_single_url(code: str, number: str | int, lang: str | None = None) -> str:
+ """/cards/:code/:number(/:lang)"""
+ return f"{_cards_url}/{code}/{number}{f'/{lang}' if lang else ''}"
+
+
+def _cards_search_url(
+ query: _QueryType | None = None,
+ doseq: bool = False,
+ safe: str | bytes = "",
+ encoding: str | None = None,
+ errors: str | None = None,
+ quote_via: _QuoteVia = quote_plus,
+) -> str:
+ """/cards/search(?:query)
+ Query is encoded with `urllib.parse.urlencode`"""
+ return f"{_cards_url}/search{
+ f'?{urlencode(query, doseq, safe, encoding, errors, quote_via)}'
+ if query
+ else ''
+ }"
+
+
+def _sets_url(code: str | UUID | None = None) -> str:
+ return f"{_base_scryfall_url}/sets{f'/{code}' if code else ''}"
+
+
+# endregion URLs
"""
@@ -44,7 +366,10 @@ class ScryfallError(TypedDict):
"""
# Rate limiter to safely limit Scryfall requests
-scryfall_rate_limit = RateLimitDecorator(calls=20, period=1)
+_rate_limit_storage = MemoryStorage()
+_scryfall_rate_limit = MovingWindowRateLimiter(_rate_limit_storage)
+# https://scryfall.com/docs/api/rate-limits
+_rate_limit = RateLimitItemPerSecond(2)
# Scryfall HTTP header
scryfall_http_header = HEADERS.Default.copy()
@@ -54,71 +379,105 @@ class ScryfallError(TypedDict):
"""
+class ScryfallExceptionKwargs(TypedDict):
+ exception: NotRequired[Exception | None]
+ card_name: NotRequired[str | None]
+ card_set: NotRequired[str | None]
+ card_number: NotRequired[str | None]
+ lang: NotRequired[str | None]
+
+
class ScryfallException(RequestException):
"""Exception representing a failure to retrieve Scryfall data."""
- def __init__(self, **kwargs):
+ def __init__(
+ self,
+ *args: object,
+ request: requests.Request | requests.PreparedRequest | None = None,
+ response: requests.Response | None = None,
+ exception: Exception | None = None,
+ card_name: str | None = None,
+ card_set: str | None = None,
+ card_number: str | None = None,
+ lang: str | None = None,
+ ) -> None:
"""Allow details relating to the exception to be passed.
- Keyword Args:
+ Args:
exception (Exception): Caught exception to pull potential request details from.
card_name (str): Name of a card.
card_set (str): Set code of a card.
card_number (str): Collector number of a card.
lang (str): Language of a card.
"""
-
- # Check for our kwargs
- e = kwargs.pop('exception', None)
- params = {
- 'Name': kwargs.pop('card_name', None),
- 'Set': kwargs.pop('card_set', None),
- 'Num': kwargs.pop('card_number', None),
- 'Lang': kwargs.pop('lang', None)
- }
-
# Compile error message
- msg = 'Scryfall request failed!'
- if any(params.values()):
- # List the params provided
- msg += f'\nParams: '
- p = [f"{k}: '{v}'" for k, v in params.items() if v]
- msg += ', '.join(p)
- if e and isinstance(e, RequestException) and e.request:
+ msg = "Scryfall request failed."
+ if details := [
+ f"{key}: '{value}'"
+ for key, value in (
+ ("Name", card_name),
+ ("Set", card_set),
+ ("Num", card_number),
+ ("Lang", lang),
+ )
+ if value
+ ]:
+ msg += f"\nParams: {', '.join(details)}"
+
+ if exception and isinstance(exception, RequestException) and exception.request:
# Provide the URL which failed
- msg += f'\nAPI URL: {e.request.url}'
- if e and isinstance(e, Exception):
- # Provide the exception cause
- msg += f'\nReason: {str(e)}'
- super().__init__(msg)
-
-
-def scryfall_request_wrapper(logr: Any = None) -> Callable:
+ msg += f"\nAPI URL: {exception.request.url}"
+ request = exception.request
+ response = exception.response
+ super().__init__(msg, request=request, response=response)
+
+ def __str__(self) -> str:
+ """Return a detailed error message with response debug information."""
+ msg = super().__str__()
+
+ if self.response is not None and not self.response.ok:
+ # Add HTTP status information
+ msg += f"\nHTTP Status: {self.response.status_code}"
+ if self.response.reason:
+ msg += f" ({self.response.reason})"
+
+ # Add response content if available
+ if self.response.text:
+ # Show a concise version of the response
+ response_text = self.response.text.strip()
+ if len(response_text) > 200:
+ msg += f"\nResponse: {response_text[:200]}..."
+ else:
+ msg += f"\nResponse: {response_text}"
+
+ return msg
+
+
+def scryfall_request_wrapper[**P, T]() -> Callable[[Callable[P, T]], Callable[P, T]]:
"""Wrapper for a Scryfall request function to handle retries, rate limits, and a final exception catch.
- Args:
- logr: Logger object to output any exception messages.
-
Returns:
Wrapped function.
"""
- logr = logr or CONSOLE
-
- def decorator(func):
- @log_on_exception(logr)
- @on_exception(expo, requests.exceptions.RequestException, max_tries=2, max_time=1)
- @sleep_and_retry
- @scryfall_rate_limit
- def wrapper(*args, **kwargs):
+
+ def decorator(func: Callable[P, T]):
+ @rate_limit(limiter=_scryfall_rate_limit, limit=_rate_limit)
+ def wrapper(*args: P.args, **kwargs: P.kwargs):
return func(*args, **kwargs)
+
return wrapper
+
return decorator
-def get_error(
+def _get_scryfall_exception(
error: ScryfallError,
- response: Optional[requests.Response] = None,
- **kwargs
+ request: requests.Request | requests.PreparedRequest | None = None,
+ response: requests.Response | None = None,
+ card_name: str | None = None,
+ card_set: str | None = None,
+ card_number: str | None = None,
+ lang: str | None = None,
) -> ScryfallException:
"""Returns a ScryfallException object created using data from a ScryfallError object.
@@ -130,12 +489,18 @@ def get_error(
Returns:
A ScryfallException object.
"""
- msg = error['details']
- if error.get('warnings'):
- msg += get_bullet_points(error['warnings'], ' -')
+ msg = error.details
+ if error.warnings:
+ msg += "
".join(error.warnings)
return ScryfallException(
- exception=RequestException(msg, response=response),
- **kwargs)
+ request=request,
+ response=response,
+ exception=RequestException(msg, request=request, response=response),
+ card_name=card_name,
+ card_set=card_set,
+ card_number=card_number,
+ lang=lang,
+ )
"""
@@ -144,11 +509,33 @@ def get_error(
@scryfall_request_wrapper()
-def get_card_unique(
- card_set: str,
- card_number: str,
- lang: str = 'en'
-) -> Union[dict, ScryfallException]:
+def get_card_via_url(url: str) -> ScryfallCard:
+ res = requests.get(url=url, headers=scryfall_http_header)
+
+ try:
+ card = ScryfallCard.model_validate_json(res.content)
+ except ValidationError as exc:
+ # Handle Scryfall error
+ try:
+ err = ScryfallError.model_validate_json(res.content)
+ raise _get_scryfall_exception(error=err, response=res)
+ except ValidationError:
+ pass
+ raise ScryfallException(response=res) from exc
+
+ if is_playable_card(card):
+ return card
+
+ # No playable result
+ raise ScryfallException(
+ exception=RequestException(
+ "No playable card found with the provided set and number.", response=res
+ )
+ )
+
+
+@scryfall_request_wrapper()
+def get_card_unique(card_set: str, card_number: str, lang: str = "en") -> ScryfallCard:
"""Get card using /cards/:code/:number(/:lang) Scryfall API endpoint.
Notes:
@@ -159,42 +546,60 @@ def get_card_unique(
card_number: Collector number of the card
lang: Lang code to look for, ex: en
- Returns:
- Card dict or ScryfallException
+ Raises:
+ ScryfallException: if the request fails
"""
# Establish API pathing
- url = ScryURL.API.Cards.Main / card_set.lower() / card_number
- url = url / lang if lang != 'en' else url
-
- # Track the params
- params = {
- 'card_set': card_set,
- 'card_number': card_number,
- 'lang': lang}
+ url = _cards_single_url(
+ card_set.lower(), card_number, lang if lang != "en" else None
+ )
# Request the data
res = requests.get(url=url, headers=scryfall_http_header)
- card = res.json()
- # Ensure playable card was returned
- if card.get('object' == 'error'):
- raise get_error(error=card, response=res, **params)
- if card.get('object') == 'card' and is_playable_card(card):
+ try:
+ card = ScryfallCard.model_validate_json(res.content)
+ except ValidationError as exc:
+ # Handle Scryfall error
+ try:
+ err = ScryfallError.model_validate_json(res.content)
+ raise _get_scryfall_exception(
+ error=err,
+ response=res,
+ card_set=card_set,
+ card_number=card_number,
+ lang=lang,
+ )
+ except ValidationError:
+ pass
+ raise ScryfallException(
+ response=res,
+ card_set=card_set,
+ card_number=card_number,
+ lang=lang,
+ ) from exc
+
+ if is_playable_card(card):
return card
+
+ # No playable result
raise ScryfallException(
exception=RequestException(
- 'No card found with the provided set and number.',
- response=res),
- **params)
+ "No playable card found with the provided set and number.", response=res
+ ),
+ card_set=card_set,
+ card_number=card_number,
+ lang=lang,
+ )
@scryfall_request_wrapper()
def get_card_search(
card_name: str,
- card_set: Optional[str] = None,
- lang: str = 'en',
- **kwargs
-) -> Union[dict, ScryfallException]:
+ card_set: str | None = None,
+ lang: str = "en",
+ **kwargs: str | SupportsInt | float | Sequence[str | SupportsInt | float],
+) -> ScryfallCard:
"""Get card using /cards/search Scryfall API endpoint.
Notes:
@@ -215,46 +620,64 @@ def get_card_search(
name, set, released, rarity, color, usd, tix, eur, cmc, power, toughness,
edhrec, penny, artist, review
- Returns:
- Card dict or ScryfallException
+ Raises:
+ ScryfallException: if the request fails
"""
# Query Scryfall
res = requests.get(
- url=ScryURL.API.Cards.Search.with_query({
- 'q': f'!"{card_name}"'
- f' lang:{lang}'
- f"{f' set:{card_set.lower()}' if card_set else ''}",
- **kwargs
- }), headers=scryfall_http_header)
- data = res.json()
-
- # Check for a playable card
- if data.get('object') == 'error':
- raise get_error(
- error=data, response=res, **{
- 'card_name': card_name, 'card_set': card_set, 'lang': lang
- })
- for c in data.get('data', []):
- if is_playable_card(c):
- return c
+ url=_cards_search_url(
+ {
+ "q": f'!"{card_name}" lang:{lang} {
+ f" set:{card_set.lower()}" if card_set else ""
+ }',
+ **kwargs,
+ }
+ ),
+ headers=scryfall_http_header,
+ )
+
+ try:
+ list_data = ScryfallCardList.model_validate_json(res.content)
+ except ValidationError as exc:
+ # Handle Scryfall error
+ try:
+ err = ScryfallError.model_validate_json(res.content)
+ raise _get_scryfall_exception(
+ error=err,
+ response=res,
+ card_name=card_name,
+ card_set=card_set,
+ lang=lang,
+ )
+ except ValidationError:
+ pass
+ raise ScryfallException(
+ card_name=card_name, card_set=card_set, lang=lang
+ ) from exc
+
+ for card in list_data.data:
+ if is_playable_card(card):
+ return card
# No playable results
- return ScryfallException(
+ raise ScryfallException(
+ response=res,
exception=RequestException(
- 'No card found with the provided search terms.',
- response=res),
- name=card_name,
- code=card_set,
- lang=lang)
+ "No playable card found with the provided search terms.", response=res
+ ),
+ card_name=card_name,
+ card_set=card_set,
+ lang=lang,
+ )
@scryfall_request_wrapper()
@return_on_exception([])
def get_cards_paged(
- url: Union[yarl.URL, ScryURL, None] = None,
+ url: str | None = None,
all_pages: bool = True,
- **kwargs
-) -> list[dict]:
+ **kwargs: QueryVar,
+) -> list[ScryfallCard]:
"""Grab paginated card list from a Scryfall API endpoint.
Args:
@@ -263,30 +686,43 @@ def get_cards_paged(
**kwargs: Optional parameters to pass to API endpoint.
"""
# Configure URL object
- url = url or ScryURL.API.Cards.Search
+ url = (
+ f"{url}{f'?{urlencode(kwargs)}' if kwargs else None}"
+ if url
+ else _cards_search_url(kwargs)
+ )
# Query Scryfall
- req = requests.get(url=url.with_query(kwargs), headers=scryfall_http_header)
- res = req.json()
- cards = res.get('data', [])
-
- # Check for an error object
- if res.get('object') == 'error':
- raise get_error(error=res, response=req)
-
- # Add additional pages if any exist
- if all_pages and res.get("has_more") and res.get("next_page"):
- cards.extend(
- get_cards_paged(
- url=res.get['next_page'],
- all_pages=all_pages
- ))
- return cards
+ res = requests.get(url=str(_cards_search_url(kwargs)), headers=scryfall_http_header)
+
+ try:
+ list_data = ScryfallCardList.model_validate_json(res.content)
+ cards = list_data.data
+ if all_pages and list_data.has_more and list_data.next_page:
+ cards.extend(
+ get_cards_paged(url=str(list_data.next_page), all_pages=all_pages)
+ )
+ return cards
+ except ValidationError as exc:
+ # Handle Scryfall error
+ try:
+ err = ScryfallError.model_validate_json(res.content)
+ scry_exc = _get_scryfall_exception(error=err, response=res)
+ except ValidationError:
+ scry_exc = ScryfallException(exception=exc)
+ _logger.error(
+ "Couldn't retrieve a paginated card list from Scryfall.", exc_info=scry_exc
+ )
+ return []
@scryfall_request_wrapper()
-@return_on_exception([])
-def get_cards_oracle(oracle_id: str, all_pages: bool = False, **kwargs) -> list[dict]:
+@return_on_exception(None)
+def get_cards_oracle(
+ oracle_id: str | UUID,
+ all_pages: bool = False,
+ **kwargs: QueryVar,
+) -> list[ScryfallCard]:
"""Grab paginated card list from a Scryfall API endpoint using the Oracle ID of the card.
Args:
@@ -298,15 +734,85 @@ def get_cards_oracle(oracle_id: str, all_pages: bool = False, **kwargs) -> list[
A list of card objects.
"""
return get_cards_paged(
- url=ScryURL.API.Cards.Search,
+ url=None,
all_pages=all_pages,
- **{
- 'q': f'oracleid:{oracle_id}',
- 'dir': kwargs.pop('dir', 'asc'),
- 'order': kwargs.pop('order', 'released'),
- 'unique': kwargs.pop('unique', 'prints'),
- **kwargs
- })
+ q=f"oracleid:{oracle_id}",
+ dir=kwargs.pop("dir", "asc"),
+ order=kwargs.pop("order", "released"),
+ unique=kwargs.pop("unique", "prints"),
+ **kwargs,
+ )
+
+
+class CardIdentifierBase(TypedDict):
+ id: NotRequired[str | UUID]
+ mtgo_id: NotRequired[int]
+ multiverse_id: NotRequired[int]
+ oracle_id: NotRequired[str | UUID]
+ illustration_id: NotRequired[str | UUID]
+
+
+class CardIdentifierWithName(CardIdentifierBase):
+ name: NotRequired[str]
+
+
+class CardIdentifierWithNameAndSet(CardIdentifierBase):
+ name: str
+ set: str
+
+
+class CardIdentifierWithCollectorNumber(CardIdentifierBase):
+ collector_number: str
+ set: str
+
+
+CardIdentifier = (
+ CardIdentifierWithCollectorNumber
+ | CardIdentifierWithNameAndSet
+ | CardIdentifierWithName
+)
+
+
+class ScryfallCollectionList(ScryfallList[ScryfallCard]):
+ not_found: list[CardIdentifier] = []
+
+
+@scryfall_request_wrapper()
+@return_on_exception(None)
+def get_cards_collection(
+ identifiers: Iterable[CardIdentifier],
+) -> ScryfallCollectionList:
+ """Get cards using POST /cards/collection Scryfall API endpoint.
+ A maximum of 75 identifiers may be submitted per request.
+
+ Notes:
+ https://scryfall.com/docs/api/cards/collection
+
+ Raises:
+ ScryfallException: if the request fails
+ """
+ res = requests.post(
+ url=_cards_collection_url,
+ json={"identifiers": identifiers},
+ headers=scryfall_http_header,
+ )
+
+ try:
+ return ScryfallCollectionList.model_validate_json(res.content)
+ except ValidationError as exc:
+ _logger.exception("Validation exc")
+ try:
+ err = ScryfallError.model_validate_json(res.content)
+ scry_exc = _get_scryfall_exception(error=err, response=res)
+ except ValidationError:
+ scry_exc = ScryfallException(exception=exc)
+ _logger.error(
+ f"Couldn't retrieve the following cards from Scryfall collection endpoint:
{
+ dumps(identifiers, indent=2)
+ }",
+ exc_info=scry_exc,
+ )
+ raise scry_exc from exc
"""
@@ -315,8 +821,8 @@ def get_cards_oracle(oracle_id: str, all_pages: bool = False, **kwargs) -> list[
@scryfall_request_wrapper()
-@return_on_exception({})
-def get_set(card_set: str) -> dict:
+@return_on_exception(None)
+def get_set(card_set: str) -> ScryfallSet | None:
"""Grab Set data from Scryfall.
Args:
@@ -326,15 +832,25 @@ def get_set(card_set: str) -> dict:
Scryfall set dict or empty dict.
"""
# Make the request
- res = requests.get(
- ScryURL.API.Cards.Search.SCRY_SETS / card_set.upper(),
- headers=scryfall_http_header)
- data = res.json()
-
- # Check for an error object
- if data.get('object') == 'error':
- raise get_error(error=data, response=res, **{'card_set': card_set})
- return data or {}
+ res = requests.get(_sets_url(card_set.upper()), headers=scryfall_http_header)
+
+ try:
+ return ScryfallSet.model_validate_json(res.content)
+ except ValidationError as exc:
+ # Handle Scryfall error
+ try:
+ err = ScryfallError.model_validate_json(res.content)
+ scry_exc = _get_scryfall_exception(
+ error=err,
+ response=res,
+ card_set=card_set,
+ )
+ except ValidationError:
+ scry_exc = ScryfallException(exception=exc, card_set=card_set)
+ _logger.error(
+ "Couldn't retrieve set data from Scryfall.",
+ exc_info=scry_exc,
+ )
"""
@@ -343,28 +859,8 @@ def get_set(card_set: str) -> dict:
@scryfall_request_wrapper()
-@return_on_exception({})
-def get_uri_object(url: yarl.URL, **kwargs) -> dict:
- """Pull a single object from Scryfall using a URI from a previous Scryfall data set.
-
- Args:
- url: Formatted URL to pull object from.
-
- Returns:
- A Scryfall object, e.g. Card, Set, etc.
- """
- res = requests.get(url.with_query(kwargs), headers=scryfall_http_header)
- data = res.json()
-
- # Check for error object
- if data.get('object') == 'error':
- raise get_error(error=data, response=res)
- return data
-
-
-@scryfall_request_wrapper()
-@return_on_exception()
-def get_card_scan(img_url: str) -> Path:
+@return_on_exception(None)
+def get_card_scan(img_url: str) -> Path | None:
"""Downloads scryfall art from URL
Args:
@@ -378,10 +874,11 @@ def get_card_scan(img_url: str) -> Path:
"""
res = requests.get(img_url, stream=True)
if res.status_code != 200:
- raise RequestException(
- "Couldn't retrieve image from scryfall.",
- response=res)
- with open(PATH.LOGS_SCAN, 'wb') as f:
+ _logger.warning(
+ f"Couldn't retrieve image from scryfall. {res.status_code}
{res.text}"
+ )
+ return None
+ with open(PATH.LOGS_SCAN, "wb") as f:
copyfileobj(res.raw, f)
return PATH.LOGS_SCAN
@@ -391,7 +888,7 @@ def get_card_scan(img_url: str) -> Path:
"""
-def is_playable_card(card_json: dict) -> bool:
+def is_playable_card(card: ScryfallCard) -> bool:
"""Checks if this card object is a playable game piece.
Args:
@@ -400,14 +897,14 @@ def is_playable_card(card_json: dict) -> bool:
Returns:
Valid scryfall data if check passed, else None.
"""
- if card_json.get('set_type') in ["minigame"]:
+ if card.set_type == "minigame":
# Ignore minigame insert cards
return False
- if card_json.get('layout') in ['art_series', 'reversible_card']:
+ if card.layout in ["art_series", "reversible_card"]:
# Ignore art series and reversible cards
# TODO: Implement support for reversible
return False
- if card_json.get('set_type') in ['memorabilia'] and '(Theme)' in card_json.get('name', ''):
+ if card.set_type == "memorabilia" and "(Theme)" in card.name:
# Ignore theme insert cards (Jumpstart)
return False
return True
diff --git a/src/utils/tests.py b/src/utils/tests.py
new file mode 100644
index 00000000..555fc810
--- /dev/null
+++ b/src/utils/tests.py
@@ -0,0 +1,36 @@
+from pydantic import RootModel
+
+from src._config import AppConfig
+from src._state import PATH
+from src.enums.mtg import LayoutType
+from src.enums.settings import FillMode
+from src.layouts import SplitLayout
+from src.render.setup import RenderOperation
+from src.utils.data_structures import parse_model
+
+TestCards = RootModel[dict[LayoutType, dict[str, str]]]
+"""dict[ layout, dict[ image_file_name, test_case_name ]]"""
+
+
+def get_template_render_test_cases() -> dict[LayoutType, dict[str, str]]:
+ return parse_model(PATH.SRC_DATA_TEMPLATE_RENDER_TEST_CASES, TestCards).root
+
+
+def prepare_test_render(render_operation: RenderOperation, config: AppConfig):
+ """Modifies render operation so that the render process won't pause for manual editing.
+ Call this after loading the config for a test render."""
+ config.fill_mode = FillMode.NO_FILL
+ config.import_scryfall_scan = False
+
+ render_operation.do_not_pause = True
+
+ if template := render_operation.template_instance:
+ path = PATH.OUT / "test_renders" / template.__class__.__name__
+ path.mkdir(mode=777, parents=True, exist_ok=True)
+ template.output_directory = path
+ art_image_override = (
+ PATH.SRC_IMG_TEST_FULL_ART if template.is_fullart else PATH.SRC_IMG_TEST
+ )
+ template.layout.art_file = art_image_override
+ if isinstance(template.layout, SplitLayout):
+ template.layout.art_files = [art_image_override, art_image_override]
diff --git a/src/utils/text.py b/src/utils/text.py
new file mode 100644
index 00000000..706f6451
--- /dev/null
+++ b/src/utils/text.py
@@ -0,0 +1,75 @@
+from re import finditer
+
+
+def format_string_with_indices(
+ format_string: str, **variables: object
+) -> tuple[str, dict[str, list[tuple[int, int]]]]:
+ """
+ Format a string with named variables and track the replaced indices.
+
+ Args:
+ format_string: String with {variable_name} placeholders
+ **variables: Named variables to substitute
+
+ Returns:
+ A tuple containing:
+ - The formatted string
+ - A dict mapping variable names to lists of (start, end) index pairs
+ representing where each variable appears in the formatted string.
+ The start is inclusive and the end exclusive.
+
+ Example:
+ ```
+ result, indices = format_string_with_indices(
+ "The {adjective} brown {animal} jumps over the lazy dog",
+ adjective="quick",
+ animal="fox",
+ )
+
+ # result
+ "The quick brown fox jumps over the lazy dog"
+ # indices
+ {"adjective": [(4, 9)], "animal": [(16, 19)]}
+ ```
+ """
+ indices_map: dict[str, list[tuple[int, int]]] = {}
+ result = ""
+ current_pos = 0
+ last_end = 0
+
+ # Find all named placeholders {variable_name}
+ pattern = r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}"
+
+ for match in finditer(pattern, format_string):
+ var_name = match.group(1)
+ placeholder_start = match.start()
+ placeholder_end = match.end()
+
+ # Add the part of the string before this placeholder
+ before_text = format_string[last_end:placeholder_start]
+ result += before_text
+ current_pos += len(before_text)
+
+ # Get the variable value and add it to result
+ if var_name in variables:
+ value_str = str(variables[var_name])
+ start_idx = current_pos
+ result += value_str
+ current_pos += len(value_str)
+ end_idx = current_pos
+
+ # Track the indices for this variable
+ if var_name not in indices_map:
+ indices_map[var_name] = []
+ indices_map[var_name].append((start_idx, end_idx))
+ else:
+ full_match = match.group(0)
+ result += full_match
+ current_pos += len(full_match)
+
+ last_end = placeholder_end
+
+ # Add any remaining part of the format string
+ result += format_string[last_end:]
+
+ return result, indices_map
diff --git a/src/utils/threading.py b/src/utils/threading.py
new file mode 100644
index 00000000..3b9b7ed7
--- /dev/null
+++ b/src/utils/threading.py
@@ -0,0 +1,52 @@
+from collections.abc import Callable
+from functools import cached_property
+from threading import Thread
+
+from src.utils.event import SubscribableEvent
+
+
+class ThreadInitializedInstance[T]:
+ def __init__(self, factory: Callable[[], T]) -> None:
+ self._on_ready: SubscribableEvent[T] = SubscribableEvent()
+ self._factory = factory
+ self.ready: bool = False
+ self._initialization: Thread | None = None
+
+ @cached_property
+ def instance(self) -> T:
+ # Instance access is synchronous
+ if thread := self.initialize():
+ thread.join()
+ return self.instance
+
+ def initialize(self) -> Thread | None:
+ """
+ Start initialization of instance.
+
+ Initialize should be called well ahead of the moment
+ when the instance is actually needed, because instance
+ access is synchronous.
+
+ Subscribe to `on_ready` to access the instance in a callback once it's ready.
+ """
+ if self.ready:
+ return None
+
+ if not self._initialization:
+
+ def init_in_thread() -> None:
+ self.instance = self._factory()
+ self.ready = True
+ self._initialization = None
+ self._on_ready.trigger(self.instance)
+
+ self._initialization = Thread(target=init_in_thread)
+ self._initialization.start()
+
+ return self._initialization
+
+ def add_listener(self, listener: Callable[[T], None]) -> None:
+ self._on_ready.listen_once(listener)
+
+ def remove_listener(self, listener: Callable[[T], None]) -> None:
+ self._on_ready.remove_listener(listener)
diff --git a/src/utils/uxp/__init__.py b/src/utils/uxp/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/utils/uxp/base.py b/src/utils/uxp/base.py
new file mode 100644
index 00000000..aac79b0d
--- /dev/null
+++ b/src/utils/uxp/base.py
@@ -0,0 +1,61 @@
+from functools import cached_property
+from json import dumps
+from pathlib import Path
+from typing import Any
+
+from photoshop.api import ActionDescriptor
+from photoshop.api.enumerations import DialogModes
+
+from src import APP
+from src._state import PATH
+
+
+def replace_last(string: str, old: str, new: str) -> str:
+ old_idx = string.rfind(old)
+ if old_idx > -1:
+ return string[:old_idx] + new + string[old_idx + len(old) :]
+ return string
+
+
+def open_in_photoshop(path: Path | str):
+ desc = ActionDescriptor()
+ desc.putPath(APP.instance.cID("null"), str(path))
+ APP.instance.executeAction(
+ APP.instance.sID("open"), desc, DialogModes.DisplayNoDialogs
+ )
+
+
+class _UXPAccess:
+ @cached_property
+ def path_temp_script(self) -> Path:
+ return PATH.TMP / "_temp.psjs"
+
+ @cached_property
+ def path_temp_script_absolute(self) -> str:
+ return str(self.path_temp_script.resolve()).replace("\\", "/")
+
+ def read_script(self, name: str) -> str:
+ with open(PATH.JS_SCRIPTS / name, "r", encoding="utf-8") as f:
+ return f.read()
+
+ def construct_script(self, script: str, data: Any) -> None:
+ script_str = script.replace(
+ "data = []", f"data = {dumps(data, ensure_ascii=False)}"
+ )
+ with open(self.path_temp_script, "w", encoding="utf-8") as f:
+ f.write(script_str)
+
+ def run_script(self, script: str, data: Any) -> None:
+ """Runs an UXP script in Photoshop."""
+ self.construct_script(script, data)
+ open_in_photoshop(self.path_temp_script_absolute)
+ # try:
+ # APP.instance.open(self.path_temp_script_absolute)
+ # except COMError as err:
+ # # The open script operation errors even if the script executes successfully
+ # if "-2147213504," not in str(err):
+ # print("Batch play failed for script:", script)
+ # raise err
+
+
+uxp = _UXPAccess()
diff --git a/src/utils/uxp/batch_play.py b/src/utils/uxp/batch_play.py
new file mode 100644
index 00000000..dd5d2136
--- /dev/null
+++ b/src/utils/uxp/batch_play.py
@@ -0,0 +1,62 @@
+from functools import cached_property
+from typing import Literal, NotRequired, TypedDict
+
+from .base import uxp
+
+
+class ActionTarget(TypedDict):
+ _ref: str
+
+
+class ActionTargetID(ActionTarget):
+ _id: int
+
+
+class ActionTargetName(ActionTarget):
+ _name: str
+
+
+class ActionTargetIndex(ActionTarget):
+ _index: int
+
+
+class ActionTargetEnumeration(ActionTarget):
+ _enum: NotRequired[str]
+ _value: NotRequired[str]
+
+
+class ActionTargetProperty(ActionTarget):
+ _property: str
+
+
+class OptionsDescriptor(TypedDict):
+ dialogOptions: NotRequired[Literal["silent", "dontDisplay", "display"]]
+ suppressProgressBar: NotRequired[bool]
+
+
+class ActionDescriptor(TypedDict):
+ _target: NotRequired[
+ list[
+ ActionTarget
+ | ActionTargetID
+ | ActionTargetName
+ | ActionTargetIndex
+ | ActionTargetEnumeration
+ | ActionTargetProperty
+ ]
+ ]
+ _options: NotRequired[OptionsDescriptor]
+
+
+class _Cache:
+ @cached_property
+ def batch_play_template_script(self) -> str:
+ return uxp.read_script("batchPlay.js")
+
+
+_cache = _Cache()
+
+
+def batch_play(*descriptors: ActionDescriptor) -> None:
+ """Runs a batch play script in Photoshop."""
+ uxp.run_script(_cache.batch_play_template_script, descriptors)
diff --git a/src/utils/uxp/path.py b/src/utils/uxp/path.py
new file mode 100644
index 00000000..c1ee157b
--- /dev/null
+++ b/src/utils/uxp/path.py
@@ -0,0 +1,29 @@
+from collections.abc import Iterable
+from functools import cached_property
+from typing import NotRequired, TypedDict
+
+from .base import uxp
+
+
+class PointConf(TypedDict):
+ x: float | int
+ y: float | int
+
+
+class PathPointConf(PointConf):
+ left: NotRequired[PointConf]
+ right: NotRequired[PointConf]
+
+
+class _PathCache:
+ @cached_property
+ def create_path_template_script(self) -> str:
+ return uxp.read_script("createPath.js")
+
+
+_cache = _PathCache()
+
+
+def create_path(points: Iterable[PathPointConf]) -> None:
+ """Creates a path layer according to the given points."""
+ uxp.run_script(_cache.create_path_template_script, points)
diff --git a/src/utils/uxp/shape.py b/src/utils/uxp/shape.py
new file mode 100644
index 00000000..4973e020
--- /dev/null
+++ b/src/utils/uxp/shape.py
@@ -0,0 +1,52 @@
+from enum import StrEnum
+from typing import Literal, TypedDict
+
+from photoshop.api._artlayer import ArtLayer
+
+from src import APP
+from src.helpers.layers import select_layers
+
+from .batch_play import ActionDescriptor, batch_play
+
+
+class ShapeOperation(StrEnum):
+ Unite = "add"
+ SubtractFront = "subtract"
+ UniteAtOverlap = "interfaceIconFrameDimmed"
+ SubtractAtOverlap = "xor"
+
+
+class ShapeOperationDescriptor(TypedDict):
+ _enum: Literal["shapeOperation"]
+ _value: ShapeOperation
+
+
+class MergeShapesDescriptor(ActionDescriptor):
+ _obj: Literal["mergeLayersNew"]
+ shapeOperation: ShapeOperationDescriptor
+
+
+class CombineShapeComponentsDescriptor(ActionDescriptor):
+ _obj: Literal["combine"]
+
+
+def merge_shapes(*args: ArtLayer, operation: ShapeOperation) -> ArtLayer:
+ """Merges shapes, consuming the shapes that are earlier in the document order."""
+ for layer in args:
+ layer.visible = True
+ select_layers([*args])
+ desc: MergeShapesDescriptor = {
+ "_obj": "mergeLayersNew",
+ "shapeOperation": {"_enum": "shapeOperation", "_value": operation},
+ }
+ comb_desc: CombineShapeComponentsDescriptor = {
+ "_obj": "combine",
+ "_target": [{"_ref": "path", "_enum": "ordinal"}],
+ }
+ batch_play(desc, comb_desc)
+ active_layer = APP.instance.activeDocument.activeLayer
+ if not isinstance(active_layer, ArtLayer):
+ raise ValueError(
+ "Failed to merge shapes. Active layer is unexpectedly not an ArtLayer."
+ )
+ return active_layer
diff --git a/src/utils/uxp/text.py b/src/utils/uxp/text.py
new file mode 100644
index 00000000..8cc3209b
--- /dev/null
+++ b/src/utils/uxp/text.py
@@ -0,0 +1,335 @@
+from _ctypes import COMError
+from typing import Literal, NotRequired, TypedDict, Unpack
+
+from photoshop.api import SolidColor
+from photoshop.api._artlayer import ArtLayer
+from photoshop.api.enumerations import AutoKernType, PointKind
+from photoshop.api.path_item import PathItem
+
+from src import APP
+from src.helpers.effects import copy_layer_fx
+from src.helpers.layers import select_layer
+
+from .batch_play import ActionDescriptor, batch_play
+
+FromToDescriptor = TypedDict(
+ "FromToDescriptor",
+ {
+ "from": int,
+ "to": int,
+ },
+)
+
+
+class UnitDescriptor(TypedDict):
+ _unit: Literal["pixelsUnit", "pointsUnit"]
+ _value: float | int
+
+
+class OrientationDescriptor(TypedDict):
+ _enum: Literal["orientation"]
+ _value: Literal["horizontal", "vertical"]
+
+
+class AlignmentTypeDescriptor(TypedDict):
+ _enum: Literal["alignmentType"]
+ _value: Literal["left", "right"]
+
+
+class DirectionTypeDescriptor(TypedDict):
+ _enum: Literal["directionType"]
+ _value: Literal["dirLeftToRight", "dirRightToLeft"]
+
+
+class ParagraphStyleDescriptor(TypedDict):
+ _obj: Literal["paragraphStyle"]
+ align: NotRequired[AlignmentTypeDescriptor]
+ directionType: NotRequired[DirectionTypeDescriptor]
+ spaceBefore: NotRequired[UnitDescriptor]
+ impliedSpaceBefore: NotRequired[UnitDescriptor]
+ spaceAfter: NotRequired[UnitDescriptor]
+ impliedSpaceAfter: NotRequired[UnitDescriptor]
+
+
+class ParagraphStyleRangeDescriptor(FromToDescriptor):
+ _obj: Literal["paragraphStyleRange"]
+ paragraphStyle: ParagraphStyleDescriptor
+
+
+class AnchorDescriptor(TypedDict):
+ _obj: Literal["paint"]
+ horizontal: UnitDescriptor
+ vertical: UnitDescriptor
+
+
+class PathPointDescriptor(TypedDict):
+ _obj: Literal["pathPoint"]
+ anchor: AnchorDescriptor
+ backward: NotRequired[AnchorDescriptor]
+ forward: NotRequired[AnchorDescriptor]
+ smooth: NotRequired[bool]
+
+
+class SubpathsListDescriptor(TypedDict):
+ _obj: Literal["subpathsList"]
+ closedSubpath: bool
+ points: list[PathPointDescriptor]
+
+
+class ShapeOperationDescriptor(TypedDict):
+ _enum: Literal["shapeOperation"]
+ _value: Literal["xor"]
+
+
+class PathComponentDescriptor(TypedDict):
+ _obj: Literal["pathComponent"]
+ shapeOperation: ShapeOperationDescriptor
+ subpathListKey: list[SubpathsListDescriptor]
+
+
+class CharDescriptor(TypedDict):
+ _enum: Literal["char"]
+ _value: Literal["box"]
+
+
+class PathClassDescriptor(TypedDict):
+ _obj: Literal["pathClass"]
+ pathComponents: list[PathComponentDescriptor]
+
+
+class TextShapeDescriptor(TypedDict):
+ _obj: Literal["textShape"]
+ char: CharDescriptor
+ path: PathClassDescriptor
+
+
+class ColorDescriptor(TypedDict):
+ _obj: Literal["RGBColor"]
+ blue: float | int
+ grain: float | int
+ red: float | int
+
+
+class KerningDescriptor(TypedDict):
+ _enum: Literal["autoKern"]
+ _value: Literal["metricsKern", "opticalKern"]
+
+
+class TextStyleDescriptor(TypedDict):
+ _obj: Literal["textStyle"]
+ color: NotRequired[ColorDescriptor]
+ fontName: NotRequired[str]
+ fontPostScriptName: NotRequired[str]
+ fontScript: NotRequired[int]
+ fontStyleName: NotRequired[str]
+ size: NotRequired[UnitDescriptor]
+ impliedFontSize: NotRequired[UnitDescriptor]
+ autoLeading: NotRequired[bool]
+ leading: NotRequired[UnitDescriptor]
+ impliedLeading: NotRequired[UnitDescriptor]
+ autoKern: NotRequired[KerningDescriptor]
+
+
+class TextStyleRangeDescriptor(FromToDescriptor):
+ _obj: Literal["textStyleRange"]
+ textStyle: TextStyleDescriptor
+
+
+class TextLayerDescriptor(TypedDict):
+ _obj: Literal["textLayer"]
+ kerningRange: NotRequired[list[None]]
+ orientation: NotRequired[OrientationDescriptor]
+ paragraphStyleRange: NotRequired[list[ParagraphStyleRangeDescriptor]]
+ textKey: NotRequired[str]
+ textShape: NotRequired[list[TextShapeDescriptor]]
+ textStyleRange: NotRequired[list[TextStyleRangeDescriptor]]
+
+
+class MakeTextLayerActionDescriptor(ActionDescriptor):
+ _obj: Literal["make"]
+ using: TextLayerDescriptor
+
+
+class CreateTextLayerWithPathOptions(TypedDict):
+ color: NotRequired[SolidColor]
+ size: NotRequired[float]
+ leading: NotRequired[float]
+
+
+# leftDirection -> forward
+# rightDirection -> backward
+def create_text_layer_with_path(
+ reference_path: ArtLayer,
+ reference_text: ArtLayer,
+ **kwargs: Unpack[CreateTextLayerWithPathOptions],
+) -> ArtLayer:
+ """Creates a shaped text layer, which aims to mimic the properties of reference_text layer."""
+ select_layer(reference_path, make_visible=True)
+
+ layer_path: PathItem | None = None
+ for path in APP.instance.activeDocument.pathItems:
+ if path.name == f"{reference_path.name} Shape Path":
+ layer_path = path
+
+ if not layer_path:
+ layer_path = APP.instance.activeDocument.pathItems[-1]
+
+ points: list[PathPointDescriptor] = []
+ for sub_path in layer_path.subPathItems:
+ for point in sub_path.pathPoints:
+ points.append(
+ {
+ "_obj": "pathPoint",
+ "smooth": point.kind is PointKind.SmoothPoint,
+ "anchor": {
+ "_obj": "paint",
+ "horizontal": {
+ "_unit": "pixelsUnit",
+ "_value": point.anchor[0],
+ },
+ "vertical": {"_unit": "pixelsUnit", "_value": point.anchor[1]},
+ },
+ "forward": {
+ "_obj": "paint",
+ "horizontal": {
+ "_unit": "pixelsUnit",
+ "_value": point.leftDirection[0],
+ },
+ "vertical": {
+ "_unit": "pixelsUnit",
+ "_value": point.leftDirection[1],
+ },
+ },
+ "backward": {
+ "_obj": "paint",
+ "horizontal": {
+ "_unit": "pixelsUnit",
+ "_value": point.rightDirection[0],
+ },
+ "vertical": {
+ "_unit": "pixelsUnit",
+ "_value": point.rightDirection[1],
+ },
+ },
+ }
+ )
+
+ reference_path.visible = False
+
+ ref_text = reference_text.textItem
+ color = kwargs.get("color", ref_text.color)
+ size = kwargs.get("size", ref_text.size)
+ leading = kwargs.get("leading", ref_text.leading)
+ try:
+ space_after = ref_text.spaceAfter
+ space_before = ref_text.spaceBefore
+ except COMError:
+ # Hacky workaround to a value retrieval issue with an unknown cause.
+ # This was observed on a duplicated text layer when accessing spaceAfter.
+ # https://community.adobe.com/t5/photoshop-ecosystem-discussions/textitem-color-returns-quot-general-photoshop-error-quot/m-p/10900372#M303432
+ ref_text.contents = ref_text.contents
+ space_after = ref_text.spaceAfter
+ space_before = ref_text.spaceBefore
+ desc: MakeTextLayerActionDescriptor = {
+ "_obj": "make",
+ "_target": [{"_ref": "textLayer"}],
+ "using": {
+ "_obj": "textLayer",
+ # If initially empty text is replaced later on that seems
+ # to mess up the text size and leading values. At least in my case
+ # when rendering a Showcase Adventure card they became very small (< 1 pt).
+ # We don't want to use placeholder text either as sometimes no text might
+ # be assigned to the layer. Whitespace seems to be an acceptable workaround.
+ "textKey": " ", # text contents
+ "textStyleRange": [
+ {
+ "_obj": "textStyleRange",
+ "from": 0,
+ "to": 227,
+ "textStyle": {
+ "_obj": "textStyle",
+ "color": {
+ "_obj": "RGBColor",
+ "blue": color.rgb.blue,
+ "grain": color.rgb.green,
+ "red": color.rgb.red,
+ },
+ "fontPostScriptName": ref_text.font,
+ "size": {
+ "_unit": "pointsUnit",
+ "_value": size,
+ },
+ "autoLeading": bool(ref_text.useAutoLeading),
+ "leading": {
+ "_unit": "pointsUnit",
+ "_value": leading,
+ },
+ "autoKern": {
+ "_enum": "autoKern",
+ "_value": "metricsKern"
+ if ref_text.autoKerning is AutoKernType.Metrics
+ else "opticalKern",
+ },
+ },
+ }
+ ],
+ "paragraphStyleRange": [
+ {
+ "_obj": "paragraphStyleRange",
+ "from": 0,
+ "to": 227,
+ "paragraphStyle": {
+ "_obj": "paragraphStyle",
+ "spaceBefore": {
+ "_unit": "pointsUnit",
+ "_value": float(space_before),
+ },
+ "spaceAfter": {
+ "_unit": "pointsUnit",
+ "_value": float(space_after),
+ },
+ },
+ }
+ ],
+ "textShape": [
+ {
+ "_obj": "textShape",
+ "char": {"_enum": "char", "_value": "box"},
+ "path": {
+ "_obj": "pathClass",
+ "pathComponents": [
+ {
+ "_obj": "pathComponent",
+ "shapeOperation": {
+ "_enum": "shapeOperation",
+ "_value": "xor",
+ },
+ "subpathListKey": [
+ {
+ "_obj": "subpathsList",
+ "closedSubpath": True,
+ "points": points,
+ }
+ ],
+ }
+ ],
+ },
+ }
+ ],
+ },
+ }
+ batch_play(desc)
+
+ created_layer = APP.instance.activeDocument.activeLayer
+ if not isinstance(created_layer, ArtLayer):
+ raise ValueError(
+ "Failed to create shaped text layer. Active layer is unexpectedly not an ArtLayer."
+ )
+ created_layer.name = f"{reference_text.name} - Path"
+
+ try:
+ copy_layer_fx(reference_text, created_layer)
+ except COMError:
+ pass
+
+ return created_layer
diff --git a/src/utils/windows.py b/src/utils/windows.py
new file mode 100644
index 00000000..0ddc74a9
--- /dev/null
+++ b/src/utils/windows.py
@@ -0,0 +1,79 @@
+from enum import IntEnum
+from logging import getLogger
+
+import pywintypes
+import win32api
+import win32con
+import win32gui
+import win32process
+
+_logger = getLogger()
+
+
+# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-showwindow
+class WindowState(IntEnum):
+ HIDE = win32con.SW_HIDE
+ NORMAL = win32con.SW_NORMAL
+ SHOWMINIMIZED = win32con.SW_SHOWMINIMIZED
+ MAXIMIZE = win32con.SW_MAXIMIZE
+ SHOWNOACTIVATE = win32con.SW_SHOWNOACTIVATE
+ SHOW = win32con.SW_SHOW
+ MINIMIZE = win32con.SW_MINIMIZE
+ SHOWMINNOACTIVE = win32con.SW_SHOWMINNOACTIVE
+ SHOWNA = win32con.SW_SHOWNA
+ RESTORE = win32con.SW_RESTORE
+ SHOWDEFAULT = win32con.SW_SHOWDEFAULT
+ FORCEMINIMIZE = win32con.SW_FORCEMINIMIZE
+
+
+def set_window_state(window_handle: int, state: WindowState) -> None:
+ win32gui.ShowWindow(window_handle, state)
+
+
+def get_window_handle_by_process_file_path_suffix(suffix: str) -> int | None:
+ """
+ Tries to look up a window handle that belongs to a prcess that has a module
+ whose file path ends with suffix.
+
+ Returns:
+ Window handle of a matching window or None if no matching window was found.
+ """
+ extra: list[int] = []
+ window_handles: list[int] = []
+
+ def enum_callback(handle: int, extra: list[int]):
+ window_handles.append(handle)
+
+ win32gui.EnumWindows(enum_callback, extra)
+ for handle in window_handles:
+ if (
+ # Try to skip windows that aren't visible in taskbar
+ not (
+ win32gui.GetWindowLong(handle, win32con.GWL_STYLE) # pyright: ignore[reportUnknownMemberType]
+ & win32con.WS_EX_APPWINDOW
+ )
+ # win32gui.GetParent(handle)
+ # or not win32gui.GetWindowText(handle)
+ ):
+ continue
+
+ try:
+ _, process_id = win32process.GetWindowThreadProcessId(handle)
+ process_handle = win32api.OpenProcess(
+ win32con.PROCESS_QUERY_INFORMATION + win32con.PROCESS_VM_READ,
+ False,
+ process_id,
+ )
+ try:
+ for module_handle in win32process.EnumProcessModules(process_handle):
+ process_file_path: str = win32process.GetModuleFileNameEx( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
+ process_handle, module_handle
+ )
+ if process_file_path.endswith(suffix): # pyright: ignore[reportUnknownMemberType]
+ return handle
+ finally:
+ win32api.CloseHandle(process_handle)
+ except pywintypes.error:
+ pass
+ except Exception as exc:
+ _logger.warning("Couldn't get Photoshop's window handle:", exc_info=exc)
diff --git a/src_js/batchPlay.ts b/src_js/batchPlay.ts
new file mode 100644
index 00000000..1b8ad364
--- /dev/null
+++ b/src_js/batchPlay.ts
@@ -0,0 +1,11 @@
+import { action, core } from "photoshop";
+import { ActionDescriptor } from "photoshop/dom/CoreModules";
+
+const data: ActionDescriptor[] = [];
+
+async function doBatchPlay() {
+ return await action.batchPlay(data, {});
+}
+core.executeAsModal(doBatchPlay, {
+ commandName: "Programmatic Batch Play",
+});
diff --git a/src_js/createPath.ts b/src_js/createPath.ts
new file mode 100644
index 00000000..2d521cdd
--- /dev/null
+++ b/src_js/createPath.ts
@@ -0,0 +1,43 @@
+import { app, core, constants } from "photoshop";
+import { type PathPointInfo } from "photoshop/dom/objects/PathPointInfo";
+
+interface PointConf {
+ x: number;
+ y: number;
+}
+
+interface PathPointConf extends PointConf {
+ left?: PointConf;
+ right?: PointConf;
+}
+
+const data: PathPointConf[] = [];
+
+function createPath(points: PathPointConf[]) {
+ const infoPoints: PathPointInfo[] = [];
+ for (const point of points) {
+ const info = new app.PathPointInfo();
+ info.anchor = [point.x, point.y];
+ info.kind =
+ point.left || point.right
+ ? constants.PointKind.SMOOTHPOINT
+ : constants.PointKind.CORNERPOINT;
+ info.leftDirection = point.left
+ ? [point.left.x, point.left.y]
+ : info.anchor;
+ info.rightDirection = point.right
+ ? [point.right.x, point.right.y]
+ : info.anchor;
+ infoPoints.push(info);
+ }
+ const subPath = new app.SubPathInfo();
+ subPath.closed = true;
+ subPath.operation = constants.ShapeOperation.SHAPEADD;
+ subPath.entireSubPath = infoPoints;
+ const newPath = app.activeDocument.pathItems.add("New Path", [subPath]);
+ newPath.select();
+}
+
+core.executeAsModal(async () => createPath(data), {
+ commandName: "Programmatic Create Path",
+});
diff --git a/src_js/types.d.ts b/src_js/types.d.ts
new file mode 100644
index 00000000..b15c831e
--- /dev/null
+++ b/src_js/types.d.ts
@@ -0,0 +1,11 @@
+import "photoshop/dom/Document";
+import type { Layer } from "photoshop/dom/Layer";
+
+declare module "photoshop/dom/Document" {
+ export interface Document {
+ /**
+ * @minVersion set 26.9
+ */
+ set activeLayers(layers: Layer[]);
+ }
+}
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/api/__init__.py b/tests/api/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/api/test_scryfall.py b/tests/api/test_scryfall.py
new file mode 100644
index 00000000..0ec0abbc
--- /dev/null
+++ b/tests/api/test_scryfall.py
@@ -0,0 +1,65 @@
+from itertools import batched
+
+from pytest import fixture
+
+from src._state import PATH
+from src.cards import card_details_to_scryfall_identifier, parse_card_info
+from src.utils.scryfall import (
+ get_card_search,
+ get_card_unique,
+ get_cards_collection,
+ get_set,
+)
+from src.utils.tests import get_template_render_test_cases
+
+
+class TestScryfall:
+ @fixture(autouse=True)
+ def setup(self) -> None:
+ self.test_cards_by_layout = get_template_render_test_cases()
+ self.test_card_filenames: list[str] = []
+ for test_cases in self.test_cards_by_layout.values():
+ self.test_card_filenames += test_cases.keys()
+ self.test_cards = [
+ parse_card_info(PATH.SRC_IMG_TEST, name)
+ for name in self.test_card_filenames
+ ]
+
+ def test_scryfall_unique(self) -> None:
+ """Test the request function for Scryfall's '/cards/set/num' endpoint."""
+ card_set = "tsr"
+ card_number = "50"
+ card_lang = "en"
+ card = get_card_unique(
+ card_set=card_set, card_number=card_number, lang=card_lang
+ )
+ assert card.set == card_set
+ assert card.collector_number == card_number
+ assert card.lang == card_lang
+
+ def test_scryfall_search(self) -> None:
+ """Test the request function for Scryfall's '/cards/search' endpoint."""
+ card_name = "Damnation"
+ card_set = "tsr"
+ card_lang = "en"
+ card = get_card_search(card_name=card_name, card_set=card_set, lang=card_lang)
+ assert card.name == card_name
+ assert card.set == card_set
+ assert card.lang == card_lang
+
+ def test_cards_collection(self) -> None:
+ """Test the request function for Scryfall's '/cards/collection' endpoint."""
+ identifiers = [
+ card_details_to_scryfall_identifier(card) for card in self.test_cards
+ ]
+ for batch in batched(identifiers, 75):
+ result = get_cards_collection(batch)
+ assert result
+ assert not result.not_found
+
+ def test_scryfall_set(self):
+ """Test the request function for Scryfall's '/sets/code' endpoint."""
+ card_set = "tsr"
+ scry_set = get_set(card_set=card_set)
+ assert scry_set
+ assert scry_set.code == card_set
diff --git a/tests/cards/__init__.py b/tests/cards/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/cards/test_layouts.py b/tests/cards/test_layouts.py
new file mode 100644
index 00000000..3f9080a8
--- /dev/null
+++ b/tests/cards/test_layouts.py
@@ -0,0 +1,98 @@
+from collections.abc import Iterable
+from concurrent.futures import Future, ThreadPoolExecutor, as_completed
+from itertools import batched
+from multiprocessing import cpu_count
+from pathlib import Path
+
+from pydantic import RootModel
+from pytest import fixture
+
+from src import CFG
+from src.cards import (
+ CardDetails,
+ card_details_to_scryfall_identifier,
+ parse_card_info,
+ process_card_data,
+)
+from src.layouts import NormalLayout, layout_map
+from src.utils.data_structures import parse_model
+from src.utils.scryfall import CardIdentifier, ScryfallCard, get_cards_collection
+from tests.testing_utils import TestDataPaths
+
+LayoutTestCases = RootModel[dict[str, dict[str, tuple[str, str, str, str, bool, bool]]]]
+
+
+class TestLayouts:
+ @fixture(autouse=True)
+ def setup(self) -> None:
+ test_data = parse_model(TestDataPaths.LAYOUT_TEST_DATA, LayoutTestCases).root
+ self.layout_test_cases: list[
+ tuple[str, tuple[str, str, str, str, bool, bool]]
+ ] = []
+ for cases in test_data.values():
+ for card_name, data in cases.items():
+ self.layout_test_cases.append((card_name, data))
+
+ def test_layout_assignments(self) -> None:
+ with ThreadPoolExecutor(max_workers=min(4, cpu_count())) as executor:
+ tasks: list[Future[None]] = []
+
+ for batch in batched(self.layout_test_cases, 75):
+ tasks.append(executor.submit(_test_batch_of_layout_test_cards, batch))
+
+ for _ in as_completed(tasks):
+ pass
+
+
+def _test_batch_of_layout_test_cards(
+ cases: Iterable[tuple[str, tuple[str, str, str, str, bool, bool]]],
+) -> None:
+ card_details = [parse_card_info(Path(name)) for name, _ in cases]
+ identifiers: list[CardIdentifier] = [
+ card_details_to_scryfall_identifier(card) for card in card_details
+ ]
+ scryfall_cards = get_cards_collection(identifiers)
+ assert scryfall_cards
+ assert not scryfall_cards.not_found
+ assert len(identifiers) == len(scryfall_cards.data)
+ for card, scryfall_card, (_, expected_result) in zip(
+ card_details, scryfall_cards.data, cases, strict=True
+ ):
+ _test_case(card, scryfall_card, expected_result)
+
+
+def _test_case(
+ card_details: CardDetails,
+ scryfall_card: ScryfallCard,
+ expected_result: tuple[str, str, str, str, bool, bool],
+) -> None:
+ scryfall_card = process_card_data(scryfall_card, card_details)
+
+ result = _format_result(
+ layout_map[scryfall_card.layout](
+ scryfall=scryfall_card,
+ file=card_details,
+ config=CFG,
+ )
+ )
+
+ assert expected_result == result
+
+
+def _format_result(layout: NormalLayout) -> tuple[str, str, str, str, bool, bool]:
+ """Format layout test result for comparison.
+
+ Args:
+ layout: Test result card layout data.
+
+ Returns:
+ Formatted frame logic test result data.
+ """
+ return (
+ layout.__class__.__name__,
+ layout.background,
+ layout.pinlines,
+ layout.twins,
+ layout.is_nyx,
+ layout.is_colorless,
+ )
diff --git a/tests/cards/test_text.py b/tests/cards/test_text.py
new file mode 100644
index 00000000..da43d7b7
--- /dev/null
+++ b/tests/cards/test_text.py
@@ -0,0 +1,30 @@
+from typing import TypedDict
+
+from pydantic import RootModel
+
+from src.cards import generate_italics
+from src.utils.data_structures import parse_model
+from tests.testing_utils import TestDataPaths
+
+
+class TestCaseTextItalic(TypedDict):
+ result: list[str]
+ scenario: str
+ text: str
+
+
+ItalicTestCasesModel = RootModel[dict[str, TestCaseTextItalic]]
+
+
+class TestText:
+ def test_italicization(self) -> None:
+ test_cases = parse_model(
+ TestDataPaths.ITALIC_TEST_DATA, ItalicTestCasesModel
+ ).root
+
+ for case in test_cases.values():
+ result_actual, result_expected = (
+ generate_italics(case["text"]),
+ case["result"],
+ )
+ assert sorted(result_actual) == sorted(result_expected)
diff --git a/src/commands/test/compression.py b/tests/compression.py
similarity index 61%
rename from src/commands/test/compression.py
rename to tests/compression.py
index 28e6addf..fdf8a18b 100644
--- a/src/commands/test/compression.py
+++ b/tests/compression.py
@@ -1,26 +1,22 @@
-"""
-* Tests: Compression
-"""
-# Standard Library Imports
+from logging import getLogger
from pathlib import Path
-from typing import Optional
from time import perf_counter
+from typing import TypedDict
-# Third Party Imports
import matplotlib.pyplot as plt
-from PIL.Image import Resampling
-from omnitils.files.archive import WordSize, DictionarySize, compress_7z
+from omnitils.files.archive import DictionarySize, WordSize, compress_7z
from omnitils.img import downscale_image_by_width
+from PIL.Image import Resampling
+
+_logger = getLogger(__name__)
-# Local Imports
-from src import CONSOLE
-"""
-* Test Funcs
-"""
+class CompressionTestResult(TypedDict):
+ time: float
+ size: float
-def test_7z_compression(path: Path) -> dict:
+def test_7z_compression(path: Path) -> dict[str, CompressionTestResult]:
"""Test all compression settings for a given file and generates a plot of time and compression
efficiency.
@@ -38,7 +34,7 @@ def test_7z_compression(path: Path) -> dict:
WordSize.WS48,
WordSize.WS64,
WordSize.WS96,
- WordSize.WS128
+ WordSize.WS128,
]
dict_sizes = [
DictionarySize.DS32,
@@ -57,22 +53,25 @@ def test_7z_compression(path: Path) -> dict:
# For each Word and Dictionary size run a test
total, current = len(word_sizes) * len(dict_sizes), 0
- x, y, z = [], [], []
+ x: list[float] = []
+ y: list[float] = []
+ z: list[str] = []
for ws in word_sizes:
for ds in dict_sizes:
current += 1
s = perf_counter()
path_out = compress_7z(path, word_size=ws, dict_size=ds)
+ assert path_out
size = path_out.stat().st_size
- time = round(perf_counter()-s, 3)
+ time = round(perf_counter() - s, 3)
x.append(time)
y.append(size)
z.append(f"{ws}/{ds}")
print(f"Word: {ws}, Dict: {ds}, T({time})βMB({size}) [{current}/{total}]")
# Format a 600 DPI plot at 12" x 8"
- plt.rcParams['lines.markersize'] = 5
- fig, ax = plt.subplots()
+ plt.rcParams["lines.markersize"] = 5
+ fig, ax = plt.subplots() # pyright: ignore[reportUnknownMemberType]
fig.set_dpi(600)
fig.set_size_inches(12, 8)
@@ -81,25 +80,25 @@ def test_7z_compression(path: Path) -> dict:
ax.invert_yaxis()
# Set axis labels and plot our points
- ax.set_xlabel("Seconds")
- ax.set_ylabel("Megabytes")
- ax.scatter(x, y, s=1)
+ ax.set_xlabel("Seconds") # pyright: ignore[reportUnknownMemberType]
+ ax.set_ylabel("Megabytes") # pyright: ignore[reportUnknownMemberType]
+ ax.scatter(x, y, s=1) # pyright: ignore[reportUnknownMemberType]
# Annotate each point with its label (z)
for i, txt in enumerate(z):
- ax.annotate(txt, (x[i], y[i]), rotation=45, fontsize=5)
+ ax.annotate(txt, (x[i], y[i]), rotation=45, fontsize=5) # pyright: ignore[reportUnknownMemberType]
# Show the plot and return raw data
- plt.show()
- return {name: {'time': x[i], 'size': y[i]} for i, name in enumerate(z)}
+ plt.show() # pyright: ignore[reportUnknownMemberType]
+ return {name: {"time": x[i], "size": y[i]} for i, name in enumerate(z)}
def test_jpeg_compression(
- path: Path,
- test_dpi=True,
- test_resample=True,
- test_optimize=True,
- test_quality: Optional[list[int]] = None
+ path: Path,
+ test_dpi: bool = True,
+ test_resample: bool = True,
+ test_optimize: bool = True,
+ test_quality: list[int] | None = None,
) -> None:
"""
Test a battery of JPEG compression settings.
@@ -111,38 +110,43 @@ def test_jpeg_compression(
"""
# Settings to test
- RESAMPLE = [Resampling.LANCZOS, Resampling.BICUBIC] if test_resample else [Resampling.LANCZOS]
+ RESAMPLE = (
+ [Resampling.LANCZOS, Resampling.BICUBIC]
+ if test_resample
+ else [Resampling.LANCZOS]
+ )
OPTIMIZE = [True, False] if test_optimize else [True]
WIDTH = [3264, 2176] if test_dpi else [3264]
QUALITY, i = test_quality or [95, 90, 85, 80], 0
# Loop through each required test
- CONSOLE.info("=" * 50)
+ _logger.info("=" * 50)
for _W in WIDTH:
for _R in RESAMPLE:
for _Q in QUALITY:
for _O in OPTIMIZE:
-
# Downscale the image
s = perf_counter()
- CURRENT = (f"{i}. {'800' if _W < 3264 else '1200'} "
- f"{'lanczos' if _R == Resampling.LANCZOS else 'bicubic'} "
- f"{_Q} {'optimize_YES' if _O else 'optimize_NO'}")
- CONSOLE.info(f"TESTING: {CURRENT}")
- SAVE_TO = path.parent / 'compressed' / f'{CURRENT}.jpg'
+ CURRENT = (
+ f"{i}. {'800' if _W < 3264 else '1200'} "
+ f"{'lanczos' if _R == Resampling.LANCZOS else 'bicubic'} "
+ f"{_Q} {'optimize_YES' if _O else 'optimize_NO'}"
+ )
+ _logger.info(f"TESTING: {CURRENT}")
+ SAVE_TO = path.parent / "compressed" / f"{CURRENT}.jpg"
downscale_image_by_width(
path_img=path,
path_save=SAVE_TO,
max_width=_W,
optimize=_O,
quality=_Q,
- resample=_R
+ resample=_R,
)
# Print the time of execution
- CONSOLE.info(f"TIME COMPLETED: {perf_counter() - s} SECONDS")
- CONSOLE.info("=" * 50)
+ _logger.info(f"TIME COMPLETED: {perf_counter() - s} SECONDS")
+ _logger.info("=" * 50)
i += 1
# Test completed
- CONSOLE.info("ALL TESTS COMPLETED!")
+ _logger.info("ALL TESTS COMPLETED!")
diff --git a/tests/data/layout_data.toml b/tests/data/layout_data.toml
new file mode 100644
index 00000000..e0e8db28
--- /dev/null
+++ b/tests/data/layout_data.toml
@@ -0,0 +1,295 @@
+# TEST CASES: Layout assignment
+
+# * TEST CASE
+["Mono Color, Normal Frame"]
+"Healing Salve" = ["NormalLayout", "W", "W", "W", false, false]
+"Ancestral Recall" = ["NormalLayout", "U", "U", "U", false, false]
+"Dark Ritual" = ["NormalLayout", "B", "B", "B", false, false]
+"Lightning Bolt" = ["NormalLayout", "R", "R", "R", false, false]
+"Giant Growth" = ["NormalLayout", "G", "G", "G", false, false]
+
+# * TEST CASE
+["Mono Color, 2/C in Mana Cost"]
+"Spectral Procession" = ["NormalLayout", "W", "W", "W", false, false]
+"Advice from the Fae" = ["NormalLayout", "U", "U", "U", false, false]
+"Beseech the Queen" = ["NormalLayout", "B", "B", "B", false, false]
+"Flame Javelin" = ["NormalLayout", "R", "R", "R", false, false]
+"Tower Above" = ["NormalLayout", "G", "G", "G", false, false]
+
+# * TEST CASE
+["Mono Color, 0 Cost Pacts"]
+"Intervention Pact" = ["NormalLayout", "W", "W", "W", false, false]
+"Pact of Negation" = ["NormalLayout", "U", "U", "U", false, false]
+"Slaughter Pact" = ["NormalLayout", "B", "B", "B", false, false]
+"Pact of the Titan" = ["NormalLayout", "R", "R", "R", false, false]
+"Summoner's Pact" = ["NormalLayout", "G", "G", "G", false, false]
+
+# * TEST CASE
+["Nyxtouched Enchantment Creatures"]
+"Heliod, God of the Sun [THS]" = ["NormalLayout", "W", "W", "W", true, false]
+"Thassa, God of the Sea" = ["NormalLayout", "U", "U", "U", true, false]
+"Erebos, God of the Dead" = ["NormalLayout", "B", "B", "B", true, false]
+"Purphoros, God of the Forge [THS]" = ["NormalLayout", "R", "R", "R", true, false]
+"Nylea, God of the Hunt" = ["NormalLayout", "G", "G", "G", true, false]
+
+# * TEST CASE
+["No Mana Cost, Suspend"]
+"Restore Balance" = ["NormalLayout", "W", "W", "W", false, false]
+"Ancestral Vision" = ["NormalLayout", "U", "U", "U", false, false]
+"Living End" = ["NormalLayout", "B", "B", "B", false, false]
+"Wheel of Fate" = ["NormalLayout", "R", "R", "R", false, false]
+"Hypergenesis" = ["NormalLayout", "G", "G", "G", false, false]
+"Lotus Bloom" = ["NormalLayout", "Artifact", "Artifact", "Artifact", false, false]
+
+# * TEST CASE
+["Dual Color, Normal Frame"]
+"Azorius Charm" = ["NormalLayout", "Gold", "WU", "Gold", false, false]
+"Dimir Charm" = ["NormalLayout", "Gold", "UB", "Gold", false, false]
+"Rakdos Charm" = ["NormalLayout", "Gold", "BR", "Gold", false, false]
+"Gruul Charm" = ["NormalLayout", "Gold", "RG", "Gold", false, false]
+"Selesnya Charm" = ["NormalLayout", "Gold", "GW", "Gold", false, false]
+"Orzhov Charm" = ["NormalLayout", "Gold", "WB", "Gold", false, false]
+"Golgari Charm" = ["NormalLayout", "Gold", "BG", "Gold", false, false]
+"Simic Charm" = ["NormalLayout", "Gold", "GU", "Gold", false, false]
+"Izzet Charm" = ["NormalLayout", "Gold", "UR", "Gold", false, false]
+"Boros Charm" = ["NormalLayout", "Gold", "RW", "Gold", false, false]
+
+# * TEST CASE
+["Dual Color, Hybrid Frame"]
+"Godhead of Awe" = ["NormalLayout", "WU", "WU", "Land", false, false]
+"Ghastlord of Fugue" = ["NormalLayout", "UB", "UB", "Land", false, false]
+"Demigod of Revenge" = ["NormalLayout", "BR", "BR", "Land", false, false]
+"Deus of Calamity" = ["NormalLayout", "RG", "RG", "Land", false, false]
+"Oversoul of Dusk" = ["NormalLayout", "GW", "GW", "Land", false, false]
+"Divinity of Pride" = ["NormalLayout", "WB", "WB", "Land", false, false]
+"Deity of Scars" = ["NormalLayout", "BG", "BG", "Land", false, false]
+"Overbeing of Myth" = ["NormalLayout", "GU", "GU", "Land", false, false]
+"Dominus of Fealty" = ["NormalLayout", "UR", "UR", "Land", false, false]
+"Nobilis of War" = ["NormalLayout", "RW", "RW", "Land", false, false]
+
+# * TEST CASE
+["No Mana Cost, Hybrid Frame"]
+"Asmoranomardicadaistinaculdacar" = ["NormalLayout", "BR", "BR", "Land", false, false]
+
+# * TEST CASE
+["Dual Color, Gold Frame, Hybrid Mana"]
+"Maelstrom Muse" = ["NormalLayout", "Gold", "UR", "Gold", false, false]
+"Ajani, Sleeper Agent" = ["PlaneswalkerLayout", "Gold", "GW", "Gold", false, false]
+"Tamiyo, Compleated Sage" = ["PlaneswalkerLayout", "Gold", "GU", "Gold", false, false]
+
+# * TEST CASE
+["Tri Color, Gold Frame, Hybrid Mana"]
+"Messenger Falcons" = ["NormalLayout", "Gold", "Gold", "Gold", false, false]
+
+# * TEST CASE
+["Double Faced Cards"]
+"Insectile Aberration" = ["TransformLayout", "U", "U", "U", false, false]
+"Ravager of the Fells" = ["TransformLayout", "Gold", "RG", "Gold", false, false]
+"Brisela, Voice of Nightmares" = ["TransformLayout", "Colorless", "Colorless", "Colorless", false, true]
+"Urza, Planeswalker" = ["PlaneswalkerTransformLayout", "Gold", "WU", "Gold", false, false]
+"Archangel Avacyn" = ["TransformLayout", "W", "W", "W", false, false]
+"Avacyn, the Purifier" = ["TransformLayout", "R", "R", "R", false, false]
+"Curious Homunculus" = ["TransformLayout", "U", "U", "U", false, false]
+"Voracious Reader" = ["TransformLayout", "Colorless", "Colorless", "Colorless", false, true]
+"Barkchannel Pathway" = ["ModalDoubleFacedLayout", "Land", "G", "G", false, false]
+"Tidechannel Pathway" = ["ModalDoubleFacedLayout", "Land", "U", "U", false, false]
+"Blex, Vexing Pest" = ["ModalDoubleFacedLayout", "G", "G", "G", false, false]
+"Search for Blex" = ["ModalDoubleFacedLayout", "B", "B", "B", false, false]
+"Extus, Oriq Overlord" = ["ModalDoubleFacedLayout", "Gold", "WB", "Gold", false, false]
+"Awaken the Blood Avatar" = ["ModalDoubleFacedLayout", "Gold", "BR", "Gold", false, false]
+
+# * TEST CASE
+["Tri Color, Normal Frame"]
+"Esper Charm" = ["NormalLayout", "Gold", "Gold", "Gold", false, false]
+"Grixis Charm" = ["NormalLayout", "Gold", "Gold", "Gold", false, false]
+"Jund Charm" = ["NormalLayout", "Gold", "Gold", "Gold", false, false]
+"Naya Charm" = ["NormalLayout", "Gold", "Gold", "Gold", false, false]
+"Bant Charm" = ["NormalLayout", "Gold", "Gold", "Gold", false, false]
+"Abzan Charm" = ["NormalLayout", "Gold", "Gold", "Gold", false, false]
+"Jeskai Charm" = ["NormalLayout", "Gold", "Gold", "Gold", false, false]
+"Sultai Charm" = ["NormalLayout", "Gold", "Gold", "Gold", false, false]
+"Mardu Charm" = ["NormalLayout", "Gold", "Gold", "Gold", false, false]
+"Temur Charm" = ["NormalLayout", "Gold", "Gold", "Gold", false, false]
+
+# * TEST CASE
+["Colorless, Non-Artifact"]
+"Emrakul, the Aeons Torn" = ["NormalLayout", "Colorless", "Colorless", "Colorless", false, true]
+"Scion of Ugin" = ["NormalLayout", "Colorless", "Colorless", "Colorless", false, true]
+
+# * TEST CASE
+["Colorless, Artifact"]
+"Herald's Horn" = ["NormalLayout", "Artifact", "Artifact", "Artifact", false, false]
+"Black Lotus" = ["NormalLayout", "Artifact", "Artifact", "Artifact", false, false]
+"Mox Pearl" = ["NormalLayout", "Artifact", "Artifact", "Artifact", false, false]
+"Mox Sapphire" = ["NormalLayout", "Artifact", "Artifact", "Artifact", false, false]
+"Mox Jet" = ["NormalLayout", "Artifact", "Artifact", "Artifact", false, false]
+"Mox Ruby" = ["NormalLayout", "Artifact", "Artifact", "Artifact", false, false]
+"Mox Emerald" = ["NormalLayout", "Artifact", "Artifact", "Artifact", false, false]
+
+# * TEST CASE
+["Mono Color, Artifact"]
+"The Circle of Loyalty" = ["NormalLayout", "Artifact", "W", "W", false, false]
+"The Magic Mirror" = ["NormalLayout", "Artifact", "U", "U", false, false]
+"The Cauldron of Eternity" = ["NormalLayout", "Artifact", "B", "B", false, false]
+"Embercleave" = ["NormalLayout", "Artifact", "R", "R", false, false]
+"The Great Henge" = ["NormalLayout", "Artifact", "G", "G", false, false]
+
+# * TEST CASE
+["Dual Color, Artifact"]
+"Filigree Angel" = ["NormalLayout", "Artifact", "WU", "Gold", false, false]
+"Time Sieve" = ["NormalLayout", "Artifact", "UB", "Gold", false, false]
+"Demonspine Whip" = ["NormalLayout", "Artifact", "BR", "Gold", false, false]
+"Mage Slayer" = ["NormalLayout", "Artifact", "RG", "Gold", false, false]
+"Behemoth Sledge" = ["NormalLayout", "Artifact", "GW", "Gold", false, false]
+"Tainted Sigil" = ["NormalLayout", "Artifact", "WB", "Gold", false, false]
+"Shardless Agent" = ["NormalLayout", "Artifact", "GU", "Gold", false, false]
+"Etherium-Horn Sorcerer" = ["NormalLayout", "Artifact", "UR", "Gold", false, false]
+
+# * TEST CASE
+["Tri Color, Artifact"]
+"Sphinx of the Steel Wind" = ["NormalLayout", "Artifact", "Gold", "Gold", false, false]
+"Thopter Foundry" = ["NormalLayout", "Artifact", "Gold", "Gold", false, false]
+
+# * TEST CASE
+["WUBRG, Artifact"]
+"Sphinx of the Guildpact" = ["NormalLayout", "Artifact", "Gold", "Gold", false, false]
+"Reaper King" = ["NormalLayout", "Artifact", "Gold", "Gold", false, false]
+
+# * TEST CASE
+["Non-Colored, Utility Land"]
+"Vesuva" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Evolving Wilds" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Karn's Bastion" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Hall of Heliod's Generosity" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Academy Ruins" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Volrath's Stronghold" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Gemstone Caverns" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Glacial Chasm" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Ash Barrens" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Crumbling Vestige" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Blighted Steppe" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Blighted Cataract" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Blighted Fen" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Blighted Gorge" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Blighted Woodland" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Maze's End" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Inventors' Fair" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Myriad Landscape" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Crystal Quarry" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Demolition Field" = ["NormalLayout", "Land", "Land", "Land", false, false]
+
+# * TEST CASE
+["Non-Colored, Panorama"]
+"Esper Panorama" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Grixis Panorama" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Jund Panorama" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Naya Panorama" = ["NormalLayout", "Land", "Land", "Land", false, false]
+"Bant Panorama" = ["NormalLayout", "Land", "Land", "Land", false, false]
+
+# * TEST CASE
+["Mono Color, Land, Add {C}"]
+"Castle Ardenvale" = ["NormalLayout", "Land", "W", "W", false, false]
+"Castle Vantress" = ["NormalLayout", "Land", "U", "U", false, false]
+"Castle Locthwain" = ["NormalLayout", "Land", "B", "B", false, false]
+"Castle Embereth" = ["NormalLayout", "Land", "R", "R", false, false]
+"Castle Garenbrig" = ["NormalLayout", "Land", "G", "G", false, false]
+"Serra's Sanctum" = ["NormalLayout", "Land", "W", "W", false, false]
+"Tolarian Academy" = ["NormalLayout", "Land", "U", "U", false, false]
+"Cabal Coffers" = ["NormalLayout", "Land", "B", "B", false, false]
+"Gaea's Cradle" = ["NormalLayout", "Land", "G", "G", false, false]
+
+# * TEST CASE
+["Mono Color, Land, Basic Land Type"]
+"Idyllic Grange" = ["NormalLayout", "Land", "W", "W", false, false]
+"Mystic Sanctuary" = ["NormalLayout", "Land", "U", "U", false, false]
+"Witch's Cottage" = ["NormalLayout", "Land", "B", "B", false, false]
+"Dwarven Mine" = ["NormalLayout", "Land", "R", "R", false, false]
+"Gingerbread Cabin" = ["NormalLayout", "Land", "G", "G", false, false]
+
+# * TEST CASE
+["Mono Color, Land, Multicolor Activated Ability"]
+"Axgard Armory" = ["NormalLayout", "Land", "W", "W", false, false]
+"Surtland Frostpyre" = ["NormalLayout", "Land", "R", "R", false, false]
+"Skemfar Elderhall" = ["NormalLayout", "Land", "G", "G", false, false]
+
+# * TEST CASE
+["Mono Color, Land, All Lands are X"]
+"Urborg, Tomb of Yawgmoth" = ["NormalLayout", "Land", "B", "B", false, false]
+"Yavimaya, Cradle of Growth" = ["NormalLayout", "Land", "G", "G", false, false]
+
+# * TEST CASE
+["Mono Color, Vivid Land"]
+"Vivid Meadow" = ["NormalLayout", "Land", "W", "W", false, false]
+"Vivid Creek" = ["NormalLayout", "Land", "U", "U", false, false]
+"Vivid Marsh" = ["NormalLayout", "Land", "B", "B", false, false]
+"Vivid Crag" = ["NormalLayout", "Land", "R", "R", false, false]
+"Vivid Grove" = ["NormalLayout", "Land", "G", "G", false, false]
+
+# * TEST CASE
+["Dual Color, Land, Add {C} or {C}"]
+"Celestial Colonnade" = ["NormalLayout", "Land", "WU", "Land", false, false]
+"Creeping Tar Pit" = ["NormalLayout", "Land", "UB", "Land", false, false]
+"Lavaclaw Reaches" = ["NormalLayout", "Land", "BR", "Land", false, false]
+"Raging Ravine" = ["NormalLayout", "Land", "RG", "Land", false, false]
+"Stirring Wildwood" = ["NormalLayout", "Land", "GW", "Land", false, false]
+"Shambling Vent" = ["NormalLayout", "Land", "WB", "Land", false, false]
+"Hissing Quagmire" = ["NormalLayout", "Land", "BG", "Land", false, false]
+"Lumbering Falls" = ["NormalLayout", "Land", "GU", "Land", false, false]
+"Wandering Fumarole" = ["NormalLayout", "Land", "UR", "Land", false, false]
+"Needle Spires" = ["NormalLayout", "Land", "RW", "Land", false, false]
+
+# * TEST CASE
+["Dual Color, Land, Basic Land Types"]
+"Hallowed Fountain" = ["NormalLayout", "Land", "WU", "Land", false, false]
+"Watery Grave" = ["NormalLayout", "Land", "UB", "Land", false, false]
+"Blood Crypt" = ["NormalLayout", "Land", "BR", "Land", false, false]
+"Stomping Ground" = ["NormalLayout", "Land", "RG", "Land", false, false]
+"Temple Garden" = ["NormalLayout", "Land", "GW", "Land", false, false]
+"Godless Shrine" = ["NormalLayout", "Land", "WB", "Land", false, false]
+"Overgrown Tomb" = ["NormalLayout", "Land", "BG", "Land", false, false]
+"Breeding Pool" = ["NormalLayout", "Land", "GU", "Land", false, false]
+"Steam Vents" = ["NormalLayout", "Land", "UR", "Land", false, false]
+"Sacred Foundry" = ["NormalLayout", "Land", "RW", "Land", false, false]
+
+# * TEST CASE
+["Dual Color, Fetch Land"]
+"Flooded Strand" = ["NormalLayout", "Land", "WU", "Land", false, false]
+"Polluted Delta" = ["NormalLayout", "Land", "UB", "Land", false, false]
+"Bloodstained Mire" = ["NormalLayout", "Land", "BR", "Land", false, false]
+"Wooded Foothills" = ["NormalLayout", "Land", "RG", "Land", false, false]
+"Windswept Heath" = ["NormalLayout", "Land", "GW", "Land", false, false]
+"Marsh Flats" = ["NormalLayout", "Land", "WB", "Land", false, false]
+"Verdant Catacombs" = ["NormalLayout", "Land", "BG", "Land", false, false]
+"Misty Rainforest" = ["NormalLayout", "Land", "GU", "Land", false, false]
+"Scalding Tarn" = ["NormalLayout", "Land", "UR", "Land", false, false]
+"Arid Mesa" = ["NormalLayout", "Land", "RW", "Land", false, false]
+
+# * TEST CASE
+["Tri Color, Land"]
+"Arcane Sanctum" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Crumbling Necropolis" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Savage Lands" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Jungle Shrine" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Seaside Citadel" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Sandsteppe Citadel" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Mystic Monastery" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Opulent Palace" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Nomad Outpost" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Frontier Bivouac" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+
+# * TEST CASE
+["Gold Color, Land"]
+"Prismatic Vista" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Fabled Passage" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Aether Hub" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"City of Brass" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Mana Confluence" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Ally Encampment" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Command Tower" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+"Thran Portal" = ["NormalLayout", "Land", "Gold", "Gold", false, false]
+
+# * TEST CASE
+["Land, Edge Cases"]
+"Krosan Verge" = ["NormalLayout", "Land", "GW", "Land", false, false]
+"Murmuring Bosk" = ["NormalLayout", "Land", "Gold", "G", false, false]
+"Dryad Arbor" = ["NormalLayout", "Land", "G", "G", false, false]
diff --git a/src/data/tests/text_file_name.toml b/tests/data/text_file_name.toml
similarity index 100%
rename from src/data/tests/text_file_name.toml
rename to tests/data/text_file_name.toml
diff --git a/src/data/tests/text_italic.toml b/tests/data/text_italic.toml
similarity index 100%
rename from src/data/tests/text_italic.toml
rename to tests/data/text_italic.toml
diff --git a/tests/testing_utils.py b/tests/testing_utils.py
new file mode 100644
index 00000000..0002ec42
--- /dev/null
+++ b/tests/testing_utils.py
@@ -0,0 +1,8 @@
+from pathlib import Path
+
+_data_dir_path = Path(__file__).parent / "data"
+
+
+class TestDataPaths:
+ LAYOUT_TEST_DATA = _data_dir_path / "layout_data.toml"
+ ITALIC_TEST_DATA = _data_dir_path / "text_italic.toml"
diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/utils/test_format_string_with_indices.py b/tests/utils/test_format_string_with_indices.py
new file mode 100644
index 00000000..13af7f3e
--- /dev/null
+++ b/tests/utils/test_format_string_with_indices.py
@@ -0,0 +1,69 @@
+from src.utils.text import format_string_with_indices
+
+
+class TestFormatStringWithIndices:
+ def test_format_string_with_indices(self) -> None:
+ """Test basic string formatting with single variables."""
+ result, indices = format_string_with_indices(
+ "The {adjective} brown {animal}", adjective="quick", animal="fox"
+ )
+ assert result == "The quick brown fox"
+ assert indices == {"adjective": [(4, 9)], "animal": [(16, 19)]}
+
+ def test_format_string_with_indices_multiple_occurrences(self) -> None:
+ """Test that repeated variables are tracked correctly."""
+ result, indices = format_string_with_indices(
+ "{name} likes {name}", name="Alice"
+ )
+ assert result == "Alice likes Alice"
+ assert indices == {"name": [(0, 5), (12, 17)]}
+
+ def test_format_string_with_indices_no_placeholders(self) -> None:
+ """Test string with no placeholders."""
+ result, indices = format_string_with_indices("Hello world")
+ assert result == "Hello world"
+ assert indices == {}
+
+ def test_format_string_with_indices_missing_variable(self) -> None:
+ """Test that missing variables are not replaced."""
+ result, indices = format_string_with_indices(
+ "Hello {name}, you are {age}. {name}? {age}", name="Bob"
+ )
+ assert result == "Hello Bob, you are {age}. Bob? {age}"
+ assert indices == {"name": [(6, 9), (26, 29)]}
+
+ def test_format_string_with_indices_empty_string(self) -> None:
+ """Test empty format string."""
+ result, indices = format_string_with_indices("")
+ assert result == ""
+ assert indices == {}
+
+ def test_format_string_with_indices_only_placeholder(self) -> None:
+ """Test string that is only a placeholder."""
+ result, indices = format_string_with_indices("{value}", value="test")
+ assert result == "test"
+ assert indices == {"value": [(0, 4)]}
+
+ def test_format_string_with_indices_adjacent_placeholders(self) -> None:
+ """Test adjacent placeholders with no space between them."""
+ result, indices = format_string_with_indices(
+ "{first}{second}", first="Hello", second="World"
+ )
+ assert result == "HelloWorld"
+ assert indices == {"first": [(0, 5)], "second": [(5, 10)]}
+
+ def test_format_string_with_indices_numeric_values(self) -> None:
+ """Test that numeric values are converted to strings."""
+ result, indices = format_string_with_indices("I have {count} apples", count=42)
+ assert result == "I have 42 apples"
+ assert indices == {"count": [(7, 9)]}
+
+ def test_format_string_with_indices_variable_names(self) -> None:
+ """Test that variable names can contain underscores and numbers."""
+ result, indices = format_string_with_indices(
+ "Value: {var_1}, another: {value_2_name}",
+ var_1="first",
+ value_2_name="second",
+ )
+ assert result == "Value: first, another: second"
+ assert indices == {"var_1": [(7, 12)], "value_2_name": [(23, 29)]}
diff --git a/tests/utils/test_match_images_with_data_files.py b/tests/utils/test_match_images_with_data_files.py
new file mode 100644
index 00000000..9415f352
--- /dev/null
+++ b/tests/utils/test_match_images_with_data_files.py
@@ -0,0 +1,55 @@
+from pathlib import Path
+from typing import cast
+
+from pytest import MonkeyPatch
+
+from src.utils.inputs import match_images_with_data_files
+from src.utils.scryfall import ScryfallCard
+
+
+def _fake_model_validate_json(cls: type[ScryfallCard], data: bytes) -> ScryfallCard:
+ return cast(ScryfallCard, object())
+
+
+def test_match_images_with_data_files_json_stem_has_tags(
+ tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+ """A .json file whose name still carries the same artist/set/number tags as
+ its paired image should be matched, not just a .json named after the bare
+ card name."""
+ monkeypatch.setattr(
+ ScryfallCard, "model_validate_json", classmethod(_fake_model_validate_json)
+ )
+
+ image = tmp_path / "Braids, Cabal Minion (Eric Peterson) [ODY] {117}.jpg"
+ data_file = tmp_path / "Braids, Cabal Minion (Eric Peterson) [ODY] {117}.json"
+ data_file.write_text("{}")
+
+ results = match_images_with_data_files([image, data_file])
+
+ assert len(results) == 1
+ result = results[0]
+ assert isinstance(result, tuple)
+ card, _ = result
+ assert card["name"] == "Braids, Cabal Minion"
+
+
+def test_match_images_with_data_files_json_stem_is_bare_name(
+ tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+ """A .json file named after just the card name should still match, as before."""
+ monkeypatch.setattr(
+ ScryfallCard, "model_validate_json", classmethod(_fake_model_validate_json)
+ )
+
+ image = tmp_path / "Braids, Cabal Minion (Eric Peterson) [ODY] {117}.jpg"
+ data_file = tmp_path / "Braids, Cabal Minion.json"
+ data_file.write_text("{}")
+
+ results = match_images_with_data_files([image, data_file])
+
+ assert len(results) == 1
+ result = results[0]
+ assert isinstance(result, tuple)
+ card, _ = result
+ assert card["name"] == "Braids, Cabal Minion"
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..b59e2082
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "rootDir": "./src_js",
+ "outDir": "./dist_js",
+ "module": "commonjs",
+ "target": "ES2017",
+ "allowJs": false,
+ "typeRoots": ["./node_modules/@types"]
+ },
+ "include": ["src_js/**/*"]
+}
diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json
new file mode 100644
index 00000000..74577cc5
--- /dev/null
+++ b/tsconfig.scripts.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "types": ["node"],
+ "noEmit": true,
+ "target": "esnext",
+ "module": "nodenext",
+ "rewriteRelativeImportExtensions": true,
+ "verbatimModuleSyntax": true
+ },
+ "include": ["scripts_js/**/*"]
+}
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 00000000..c8db8374
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,2052 @@
+version = 1
+revision = 3
+requires-python = "==3.14.*"
+
+[[package]]
+name = "altgraph"
+version = "0.17.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "argcomplete"
+version = "3.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "26.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
+]
+
+[[package]]
+name = "babel"
+version = "2.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" },
+]
+
+[[package]]
+name = "backoff"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
+]
+
+[[package]]
+name = "backrefs"
+version = "7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" },
+ { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" },
+ { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" },
+]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.14.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
+]
+
+[[package]]
+name = "bracex"
+version = "2.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" },
+]
+
+[[package]]
+name = "brotli"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
+ { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
+ { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
+]
+
+[[package]]
+name = "brotlicffi"
+version = "1.2.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" },
+ { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" },
+ { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" },
+]
+
+[[package]]
+name = "bs4"
+version = "0.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beautifulsoup4" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/aa/4acaf814ff901145da37332e05bb510452ebed97bc9602695059dd46ef39/bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925", size = 698, upload-time = "2024-01-17T18:15:47.371Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/bb/bf7aab772a159614954d84aa832c129624ba6c32faa559dfb200a534e50b/bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc", size = 1189, upload-time = "2024-01-17T18:15:48.613Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.4.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+ { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+ { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+ { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+ { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+ { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+ { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+ { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+ { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+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" }
+wheels = [
+ { 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" },
+]
+
+[[package]]
+name = "commitizen"
+version = "4.15.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "argcomplete" },
+ { name = "charset-normalizer" },
+ { name = "colorama" },
+ { name = "decli" },
+ { name = "deprecated" },
+ { name = "jinja2" },
+ { name = "packaging" },
+ { name = "prompt-toolkit" },
+ { name = "pyyaml" },
+ { name = "questionary" },
+ { name = "termcolor" },
+ { name = "tomlkit" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/2a/ac219a23e89210aa3ee9244dc076e39bc532bc3f352024f3d478f3e79b85/commitizen-4.15.1.tar.gz", hash = "sha256:cca192e07b2f9d77734044c631da294b3007ef9aa10cc8f0600290aa662e9fa3", size = 65718, upload-time = "2026-05-06T04:14:45.159Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/5d/12e74d633a622474cf3af6b27656c7da8b96caf87e1c6672436fcff85138/commitizen-4.15.1-py3-none-any.whl", hash = "sha256:8207ee3e0344b483c5e59103d59ce19b96eb7204ccf28c72b65596fa524b06e2", size = 87876, upload-time = "2026-05-06T04:14:43.754Z" },
+]
+
+[[package]]
+name = "comtypes"
+version = "1.4.16"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c6/2a/65274c13327f637ec13af8d39f2cf579d9ebe7a0e683696b5f05236d2805/comtypes-1.4.16.tar.gz", hash = "sha256:cd66d1add01265cface4df51ba1e31cd1657e04463c281c802e737e79e1ba93c", size = 260252, upload-time = "2026-03-02T23:11:42.413Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5f/7c/0eb685107290b6221c03c46d39214a4e42a124189691cb83ae3228257f46/comtypes-1.4.16-py3-none-any.whl", hash = "sha256:e18d85179ff12955524c5a8c3bc09cb3c0d890f1da4d7123d14244c7b78f84c8", size = 296230, upload-time = "2026-03-02T23:11:41.049Z" },
+]
+
+[[package]]
+name = "contourpy"
+version = "1.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" },
+ { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" },
+ { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" },
+ { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" },
+ { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" },
+ { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" },
+ { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" },
+ { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" },
+ { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" },
+ { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
+]
+
+[[package]]
+name = "csscompressor"
+version = "0.9.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f1/2a/8c3ac3d8bc94e6de8d7ae270bb5bc437b210bb9d6d9e46630c98f4abd20c/csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05", size = 237808, upload-time = "2017-11-26T21:13:08.238Z" }
+
+[[package]]
+name = "cycler"
+version = "0.12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
+]
+
+[[package]]
+name = "decli"
+version = "0.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0c/59/d4ffff1dee2c8f6f2dd8f87010962e60f7b7847504d765c91ede5a466730/decli-0.6.3.tar.gz", hash = "sha256:87f9d39361adf7f16b9ca6e3b614badf7519da13092f2db3c80ca223c53c7656", size = 7564, upload-time = "2025-06-01T15:23:41.25Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d8/fa/ec878c28bc7f65b77e7e17af3522c9948a9711b9fa7fc4c5e3140a7e3578/decli-0.6.3-py3-none-any.whl", hash = "sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3", size = 7989, upload-time = "2025-06-01T15:23:40.228Z" },
+]
+
+[[package]]
+name = "deprecated"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wrapt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
+]
+
+[[package]]
+name = "fonttools"
+version = "4.62.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" },
+ { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" },
+ { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" },
+ { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" },
+ { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" },
+ { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" },
+ { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" },
+ { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" },
+]
+
+[[package]]
+name = "ghp-import"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
+]
+
+[[package]]
+name = "gitdb"
+version = "4.0.12"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "smmap" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
+]
+
+[[package]]
+name = "gitpython"
+version = "3.1.50"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "gitdb" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" },
+]
+
+[[package]]
+name = "griffelib"
+version = "2.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" },
+]
+
+[[package]]
+name = "hexproof"
+version = "0.3.7"
+source = { git = "https://github.com/pappnu/hexproof.git?rev=dev#fc281f1d767651322ff271d2ccb72251bd70e19c" }
+dependencies = [
+ { name = "bs4" },
+ { name = "limits" },
+ { name = "loguru" },
+ { name = "omnitils" },
+ { name = "pydantic" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "tomli" },
+ { name = "tomlkit" },
+]
+
+[[package]]
+name = "htmlmin2"
+version = "0.1.13"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/31/a76f4bfa885f93b8167cb4c85cf32b54d1f64384d0b897d45bc6d19b7b45/htmlmin2-0.1.13-py3-none-any.whl", hash = "sha256:75609f2a42e64f7ce57dbff28a39890363bde9e7e5885db633317efbdf8c79a2", size = 34486, upload-time = "2023-03-14T21:28:30.388Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.13"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
+]
+
+[[package]]
+name = "inflate64"
+version = "1.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3e/f3/41bb2901543abe7aad0b0b0284ae5854bb75f848cf406bf8a046bf525f67/inflate64-1.0.4.tar.gz", hash = "sha256:b398c686960c029777afc0ed281a86f66adb956cfc3fbf6667cc6453f7b407ce", size = 902542, upload-time = "2025-11-28T10:55:52.641Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/fb/ec9d10f44f2fc7666ad5d70acae2b8a1941e8e08ccae1fad0820f7796be3/inflate64-1.0.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4f61925b2d4248eac2ebb15350a80aaa0d1f7f1dc770bd5ebbbb3b0db4a6a416", size = 58727, upload-time = "2025-11-28T10:55:13.834Z" },
+ { url = "https://files.pythonhosted.org/packages/81/80/24ba0d2ee14e07e275e9c5b058e59a8a58f8ef42dd51a78ebbfd7c857ac4/inflate64-1.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c1acf18b08b32981a4a11ec5a112b8ad5d7c7a5b15cb5bdbdb5b19201e9aa180", size = 35945, upload-time = "2025-11-28T10:55:15.225Z" },
+ { url = "https://files.pythonhosted.org/packages/70/b8/073a79716e093db973b8823bdfb02e10fbdf65642dbe1fa3cda24832aeb2/inflate64-1.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abddae8920b2eaef824254e14b8d4ff54afbe6194a1bbe9816584859f0c1244d", size = 36060, upload-time = "2025-11-28T10:55:16.261Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/f0/87d3c317ed0acd94f5e9b3b1b9e9000228ea2af0cb4618c62cbfc816da34/inflate64-1.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b303132cc562a906543a56f35c4e164e3880da6ff041cb4a7b1df9f9d2b4bb69", size = 98995, upload-time = "2025-11-28T10:55:17.405Z" },
+ { url = "https://files.pythonhosted.org/packages/90/72/0b6035302e9c33f004240a50cb6e2e1fc7bb1f2b415b02d939c551bdd06b/inflate64-1.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f0993214dea0738c557fa56c13cd9083aef0097a201d726c21984ad7f577514", size = 100781, upload-time = "2025-11-28T10:55:18.597Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/05/5f383c615ec0f01bcbbc699a71da167623e494083ab7ed0df86b4bddf125/inflate64-1.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a6baedc3288d7a4ff588951d3a9a97a5391dceed6255ff5b16e42cae7274bfa9", size = 97038, upload-time = "2025-11-28T10:55:20.156Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/9a/c1482718c717c49c67490c42b4fdced9476e894eaebd52193e488c12e188/inflate64-1.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a846ce1f38845b20bef2625af1b512be83416d97824539524c5a34e7a729aec7", size = 100016, upload-time = "2025-11-28T10:55:21.337Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/7f/700ede7474e72a1c0e2e8fe36624cd0225ca8b2875eca33d64aa2de75f4d/inflate64-1.0.4-cp314-cp314-win32.whl", hash = "sha256:eef87908c780439393d577a155868317f0a275b47b417db9f47d8633ec791745", size = 33691, upload-time = "2025-11-28T10:55:22.515Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/51/f2972df8cceecc9bf3afa3353d517ffc7125285198c844588e9aaf98f5d0/inflate64-1.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:fb2fdd63ef3933b67af98b3f2ee2f57e7787278041d7ba4821382fedd729b68a", size = 36304, upload-time = "2025-11-28T10:55:24.005Z" },
+ { url = "https://files.pythonhosted.org/packages/29/77/16200aced67215119fb30ec9a5889b48289ee2fa5ce5137623a9ad41b2c4/inflate64-1.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:2e129669a0243ac7816fd526946ee01c25688fe81623a6d6bc95b3156d80f4fb", size = 34491, upload-time = "2025-11-28T10:55:25.068Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/79/b466ec7666c40912ea81a305a8a2b75f5998e6cec1d3d75e067d76203731/inflate64-1.0.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:b17bf665d948dc4edeea0cd17752415d0cd7240c882b9c7e136ad4cc4321e9d4", size = 59300, upload-time = "2025-11-28T10:55:26.185Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ad/16e1168ac80f39894e6cb18b439eec63fec42cbced239aebfe12081b6ec7/inflate64-1.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6751758301936fbb38fa38eb5312e14e27b6a1abf568f83c17557fab2694373d", size = 36258, upload-time = "2025-11-28T10:55:27.295Z" },
+ { url = "https://files.pythonhosted.org/packages/25/42/c463b42fd8a7947b4445fbaf57c265bb7f3114362fb7aee6884ffd8b5341/inflate64-1.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d6a4136752aa2a544301059d8f13780aeb88c34d60770258436a87dacd3fc304", size = 36296, upload-time = "2025-11-28T10:55:28.632Z" },
+ { url = "https://files.pythonhosted.org/packages/39/f1/cf6121926e405020e9e7bccb78ec7781fbc87500ec67368e7d9e866758be/inflate64-1.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:938ebc6b28578bfd365d1a9fdb18b7faab08321babeb2198e8025d07d8dc7fb5", size = 106788, upload-time = "2025-11-28T10:55:29.797Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/dd/b653f9962497cf4d3520d69272894c37fd76f86a0e04bb3bd9f32827dc2f/inflate64-1.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61f51f80fa6f367288343c1a2cd20a42af454883087064e9274fd2a8c3a5a200", size = 107959, upload-time = "2025-11-28T10:55:31.306Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ae/65d88109b63611c9b6c29008201107127cf2603d186ab99beec39d85f38e/inflate64-1.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:172b51da7bbfa66b33f0a5405e944807b9949e92cf4cd9f983c07af8152766df", size = 104213, upload-time = "2025-11-28T10:55:32.506Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/94/c17de2f55b9fb1269bca4657f9089efe4ba0f3d4b652f07b34dfc69f69a2/inflate64-1.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ca9a2985afd5a14fb48cd126a67e5944ccb7a0a6bdec58c4f796c8c88a84539", size = 106629, upload-time = "2025-11-28T10:55:33.735Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/30/8bbc587bb2ad09ab7edf1f9215b2c5faf4fa7ee7c071daefe9ed55e28814/inflate64-1.0.4-cp314-cp314t-win32.whl", hash = "sha256:f8964ceaabea294bc20abc9ef408c6aae978a75c25c83168a76cd87a37c38938", size = 33865, upload-time = "2025-11-28T10:55:35.335Z" },
+ { url = "https://files.pythonhosted.org/packages/91/87/8bf6f412f93c8b6dc14866a021b83321331fbdd17f6ab902a24dbf88773d/inflate64-1.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:7d13b04cba65c12d21e65eaa77da9484e265e8e821b26e0761d1455ad3a878d9", size = 36943, upload-time = "2025-11-28T10:55:36.983Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/85/33447bb3c4e3c0ae7b1fde3aadc52a18b2b0193cfcf4f585977e924c6463/inflate64-1.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:9ae3ee727235a06dc3cd353ee5761fdd8e3b56ad119c711f61680528972a6ced", size = 34846, upload-time = "2025-11-28T10:55:38.433Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+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" }
+wheels = [
+ { 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" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "jsmin"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/73/e01e4c5e11ad0494f4407a3f623ad4d87714909f50b17a06ed121034ff6e/jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc", size = 13925, upload-time = "2022-01-16T20:35:59.13Z" }
+
+[[package]]
+name = "kiwisolver"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" },
+ { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" },
+ { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" },
+ { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" },
+ { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" },
+ { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" },
+ { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" },
+ { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" },
+ { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" },
+]
+
+[[package]]
+name = "limits"
+version = "5.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "deprecated" },
+ { name = "packaging" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" },
+]
+
+[[package]]
+name = "loguru"
+version = "0.7.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "win32-setctime", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
+]
+
+[[package]]
+name = "macholib"
+version = "1.16.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "altgraph" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
+]
+
+[[package]]
+name = "markdown"
+version = "3.10.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "matplotlib"
+version = "3.10.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "contourpy" },
+ { name = "cycler" },
+ { name = "fonttools" },
+ { name = "kiwisolver" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pillow" },
+ { name = "pyparsing" },
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" },
+ { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" },
+ { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" },
+ { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" },
+ { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" },
+ { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" },
+ { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" },
+ { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" },
+ { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" },
+ { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" },
+ { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" },
+]
+
+[[package]]
+name = "memory-profiler"
+version = "0.61.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "psutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b2/88/e1907e1ca3488f2d9507ca8b0ae1add7b1cd5d3ca2bc8e5b329382ea2c7b/memory_profiler-0.61.0.tar.gz", hash = "sha256:4e5b73d7864a1d1292fb76a03e82a3e78ef934d06828a698d9dada76da2067b0", size = 35935, upload-time = "2022-11-15T17:57:28.994Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/26/aaca612a0634ceede20682e692a6c55e35a94c21ba36b807cc40fe910ae1/memory_profiler-0.61.0-py3-none-any.whl", hash = "sha256:400348e61031e3942ad4d4109d18753b2fb08c2f6fb8290671c5513a34182d84", size = 31803, upload-time = "2022-11-15T17:57:27.031Z" },
+]
+
+[[package]]
+name = "mergedeep"
+version = "1.3.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
+]
+
+[[package]]
+name = "mkdocs"
+version = "1.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "ghp-import" },
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "markupsafe" },
+ { name = "mergedeep" },
+ { name = "mkdocs-get-deps" },
+ { name = "packaging" },
+ { name = "pathspec" },
+ { name = "pyyaml" },
+ { name = "pyyaml-env-tag" },
+ { name = "watchdog" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" },
+]
+
+[[package]]
+name = "mkdocs-autolinks-plugin"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mkdocs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/63/b7/efc75b7870a4fecbc9a05ba94ce622462a40b5a13670d00036c5b47bf82f/mkdocs-autolinks-plugin-0.7.1.tar.gz", hash = "sha256:445ddb9b417b7795856c30801bb430773186c1daf210bdeecf8305f55a47d151", size = 4375, upload-time = "2023-08-04T14:42:25.67Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/9c/ee3d81a799b8b0b73a89625bcbf3c58cd369c8a26a1b415413edea9bf259/mkdocs_autolinks_plugin-0.7.1-py3-none-any.whl", hash = "sha256:5c6c17f6649b68e79a9ef0b2648d59f3072e18002b90ee1586a64c505f11ab12", size = 4235, upload-time = "2023-08-04T14:42:23.955Z" },
+]
+
+[[package]]
+name = "mkdocs-autorefs"
+version = "1.4.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown" },
+ { name = "markupsafe" },
+ { name = "mkdocs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" },
+]
+
+[[package]]
+name = "mkdocs-gen-files"
+version = "0.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mkdocs" },
+ { name = "properdocs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/43/428f312149c161cae557eecd35f3c4a82b867998b1d47fb29fdfe927be26/mkdocs_gen_files-0.6.1.tar.gz", hash = "sha256:57d7ff2229e23d077e46d14a33db6d37c8823f6ce1a503c874c1764a71679763", size = 8746, upload-time = "2026-03-16T23:26:09.31Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ee/1b/3075eb67fe66e19db059f0a25744c4e56978a309603a20e1d3353d545b5e/mkdocs_gen_files-0.6.1-py3-none-any.whl", hash = "sha256:b3182bfc6219e35b8d26658cb988368659d5d023aac30c2a819247558fc12189", size = 8282, upload-time = "2026-03-16T23:26:08.292Z" },
+]
+
+[[package]]
+name = "mkdocs-get-deps"
+version = "0.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mergedeep" },
+ { name = "platformdirs" },
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" },
+]
+
+[[package]]
+name = "mkdocs-git-revision-date-plugin"
+version = "0.3.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "gitpython" },
+ { name = "jinja2" },
+ { name = "mkdocs" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/c7/73990d826985bbfcb87e3c46887ff025db336ffb12fe505f2d0990d51178/mkdocs_git_revision_date_plugin-0.3.2-py3-none-any.whl", hash = "sha256:2e67956cb01823dd2418e2833f3623dee8604cdf223bddd005fe36226a56f6ef", size = 4160, upload-time = "2022-03-08T14:42:31.97Z" },
+]
+
+[[package]]
+name = "mkdocs-include-markdown-plugin"
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mkdocs" },
+ { name = "wcmatch" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ad/2d/bdf1aee3f4f7b34148b0f62298b62f03415160cb2707f09503c99a0a7cd5/mkdocs_include_markdown_plugin-7.2.2.tar.gz", hash = "sha256:f052ccb741eccf498116b826c1d78a2d761c56747372594709441cee0963fbc9", size = 25415, upload-time = "2026-03-29T15:15:14.2Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/a5/f6b2f0aa805dbda52f6265e9aff1450c8643195442facf29d475bdeba15d/mkdocs_include_markdown_plugin-7.2.2-py3-none-any.whl", hash = "sha256:f2ec4487cf32d3e33ca528f9366f20fb9280ded9c8d1630eb2bbda244962dcd1", size = 29528, upload-time = "2026-03-29T15:15:13.079Z" },
+]
+
+[[package]]
+name = "mkdocs-material"
+version = "9.7.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "babel" },
+ { name = "backrefs" },
+ { name = "colorama" },
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "mkdocs" },
+ { name = "mkdocs-material-extensions" },
+ { name = "paginate" },
+ { name = "pygments" },
+ { name = "pymdown-extensions" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" },
+]
+
+[[package]]
+name = "mkdocs-material-extensions"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
+]
+
+[[package]]
+name = "mkdocs-minify-plugin"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "csscompressor" },
+ { name = "htmlmin2" },
+ { name = "jsmin" },
+ { name = "mkdocs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/67/fe4b77e7a8ae7628392e28b14122588beaf6078b53eb91c7ed000fd158ac/mkdocs-minify-plugin-0.8.0.tar.gz", hash = "sha256:bc11b78b8120d79e817308e2b11539d790d21445eb63df831e393f76e52e753d", size = 8366, upload-time = "2024-01-29T16:11:32.982Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1b/cd/2e8d0d92421916e2ea4ff97f10a544a9bd5588eb747556701c983581df13/mkdocs_minify_plugin-0.8.0-py3-none-any.whl", hash = "sha256:5fba1a3f7bd9a2142c9954a6559a57e946587b21f133165ece30ea145c66aee6", size = 6723, upload-time = "2024-01-29T16:11:31.851Z" },
+]
+
+[[package]]
+name = "mkdocs-pymdownx-material-extras"
+version = "2.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mkdocs-material" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/f6/5199d1e251e15b3c554f46b5796d02870e129f524dc2f117e828c5444674/mkdocs_pymdownx_material_extras-2.8.tar.gz", hash = "sha256:7b22bb119cd9592f98d6c6d4d269506d9a68d7038355c71525aadc88169ee9fe", size = 26512, upload-time = "2025-03-16T14:24:50.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/78/0059cb24b62a3dd68293daea38acb2cb75415d5735a62d6d60f752cd00ed/mkdocs_pymdownx_material_extras-2.8-py3-none-any.whl", hash = "sha256:81b68789420c51b9b15514180d0f3ab7136d56ee512c830c998d2edb77ca3d77", size = 28846, upload-time = "2025-03-16T14:24:49.235Z" },
+]
+
+[[package]]
+name = "mkdocs-same-dir"
+version = "0.1.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mkdocs" },
+ { name = "properdocs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2e/58/f49fdf5fd82cb82ab1ee4fb9a5729d741d8dd6bb9623e0471ad704a6f0c0/mkdocs_same_dir-0.1.5.tar.gz", hash = "sha256:677cf8d682d0ae6226620871c01906a1a631083f2b00fac58ffe9019b2cb2322", size = 5082, upload-time = "2026-04-17T16:22:24.866Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ca/f0/bc3618e170db0f47baf02f15ca465ee342a28400c5630e561fad9d1ff191/mkdocs_same_dir-0.1.5-py3-none-any.whl", hash = "sha256:a9cc599caf438da288ba14b21d24db4bb7975106eef1cd56d0d92e6835c366aa", size = 4379, upload-time = "2026-04-17T16:22:23.679Z" },
+]
+
+[[package]]
+name = "mkdocstrings"
+version = "1.0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "markupsafe" },
+ { name = "mkdocs" },
+ { name = "mkdocs-autorefs" },
+ { name = "pymdown-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" },
+]
+
+[package.optional-dependencies]
+python = [
+ { name = "mkdocstrings-python" },
+]
+
+[[package]]
+name = "mkdocstrings-python"
+version = "2.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "griffelib" },
+ { name = "mkdocs-autorefs" },
+ { name = "mkdocstrings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" },
+]
+
+[[package]]
+name = "multidict"
+version = "6.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
+ { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
+ { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
+ { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
+ { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
+ { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
+ { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
+ { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
+ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
+]
+
+[[package]]
+name = "multivolumefile"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/50/f0/a7786212b5a4cb9ba05ae84a2bbd11d1d0279523aea0424b6d981d652a14/multivolumefile-0.2.3.tar.gz", hash = "sha256:a0648d0aafbc96e59198d5c17e9acad7eb531abea51035d08ce8060dcad709d6", size = 77984, upload-time = "2021-04-29T12:18:39.882Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/31/ec5f46fd4c83185b806aa9c736e228cb780f13990a9cf4da0beb70025fcc/multivolumefile-0.2.3-py3-none-any.whl", hash = "sha256:237f4353b60af1703087cf7725755a1f6fcaeeea48421e1896940cd1c920d678", size = 17037, upload-time = "2021-04-29T12:18:38.886Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" },
+ { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" },
+ { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" },
+ { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" },
+ { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" },
+ { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" },
+ { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" },
+ { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" },
+ { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" },
+ { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
+]
+
+[[package]]
+name = "omnitils"
+version = "1.4.6"
+source = { git = "https://github.com/pappnu/omnitils.git?rev=dev#dab468c228d746a1832f421a092d902b4a3f5fb8" }
+dependencies = [
+ { name = "backoff" },
+ { name = "click" },
+ { name = "limits" },
+ { name = "loguru" },
+ { name = "pathvalidate" },
+ { name = "pillow" },
+ { name = "py7zr" },
+ { name = "pydantic" },
+ { name = "python-dateutil" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "tomli" },
+ { name = "tomlkit" },
+ { name = "tqdm" },
+ { name = "yarl" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+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" }
+wheels = [
+ { 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" },
+]
+
+[[package]]
+name = "paginate"
+version = "0.5.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
+]
+
+[[package]]
+name = "pathvalidate"
+version = "3.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" },
+]
+
+[[package]]
+name = "pefile"
+version = "2024.8.26"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
+]
+
+[[package]]
+name = "photoshop-python-api"
+version = "0.24.1"
+source = { git = "https://github.com/pappnu/photoshop-python-api.git?rev=type-annotation#4cd7f34986a17da959295dc698650d5e5b58e3f0" }
+dependencies = [
+ { name = "comtypes" },
+ { name = "wheel" },
+]
+
+[[package]]
+name = "pillow"
+version = "12.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
+ { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
+ { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
+ { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
+ { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
+ { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.9.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+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" }
+wheels = [
+ { 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" },
+]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.51"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" },
+]
+
+[[package]]
+name = "propcache"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" },
+ { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" },
+ { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" },
+ { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" },
+ { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" },
+ { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" },
+ { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" },
+ { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" },
+ { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
+]
+
+[[package]]
+name = "properdocs"
+version = "1.6.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "ghp-import" },
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "markupsafe" },
+ { name = "packaging" },
+ { name = "pathspec" },
+ { name = "platformdirs" },
+ { name = "pyyaml" },
+ { name = "pyyaml-env-tag" },
+ { name = "watchdog" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ec/29/f27a4e1eddf72ed3db6e47818fbafe6debbf09fd7051f9c1a007239b46ef/properdocs-1.6.7.tar.gz", hash = "sha256:adc7b16e562890af0e098a7e5b02e3a81c20894a87d6a28d345c9300de73c26e", size = 276141, upload-time = "2026-03-20T20:07:48.167Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/4d/fc923f5c85318ee8cc903566dc4e0ebe41b2dfc1d2ecf5546db232397ed6/properdocs-1.6.7-py3-none-any.whl", hash = "sha256:6fa0cfa2e01bf338f684892c8a506cf70ea88ae7f3479c933b6fa20168101cbd", size = 225406, upload-time = "2026-03-20T20:07:46.875Z" },
+]
+
+[[package]]
+name = "proxyshop"
+version = "1.13.2"
+source = { virtual = "." }
+dependencies = [
+ { name = "backoff" },
+ { name = "fonttools" },
+ { name = "hexproof" },
+ { name = "limits" },
+ { name = "omnitils" },
+ { name = "pathvalidate" },
+ { name = "photoshop-python-api" },
+ { name = "pillow" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "pyside6" },
+ { name = "pywin32" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "yarl" },
+]
+
+[package.dev-dependencies]
+build = [
+ { name = "pyclean" },
+ { name = "pyinstaller" },
+ { name = "setuptools" },
+]
+dev = [
+ { name = "commitizen" },
+ { name = "psd-tools" },
+ { name = "pytest" },
+ { name = "types-pywin32" },
+ { name = "types-requests" },
+]
+docs = [
+ { name = "mkdocs" },
+ { name = "mkdocs-autolinks-plugin" },
+ { name = "mkdocs-gen-files" },
+ { name = "mkdocs-git-revision-date-plugin" },
+ { name = "mkdocs-include-markdown-plugin" },
+ { name = "mkdocs-material" },
+ { name = "mkdocs-minify-plugin" },
+ { name = "mkdocs-pymdownx-material-extras" },
+ { name = "mkdocs-same-dir" },
+ { name = "mkdocstrings", extra = ["python"] },
+ { name = "mkdocstrings-python" },
+]
+tests-extra = [
+ { name = "matplotlib" },
+ { name = "memory-profiler" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "backoff", specifier = ">=2.2.1,<3.0.0" },
+ { name = "fonttools", specifier = ">=4.62.1,<5.0.0" },
+ { name = "hexproof", git = "https://github.com/pappnu/hexproof.git?rev=dev" },
+ { name = "limits", specifier = ">=5.8.0,<6.0.0" },
+ { name = "omnitils", git = "https://github.com/pappnu/omnitils.git?rev=dev" },
+ { name = "pathvalidate", specifier = ">=3.3.1,<4.0.0" },
+ { name = "photoshop-python-api", git = "https://github.com/pappnu/photoshop-python-api.git?rev=type-annotation" },
+ { name = "pillow", specifier = ">=12.2.0,<13.0.0" },
+ { name = "pydantic", specifier = ">=2.13.3,<3.0.0" },
+ { name = "pydantic-settings", specifier = ">=2.14.0,<3.0.0" },
+ { name = "pyside6", specifier = ">=6.11.0,<7.0.0" },
+ { name = "pywin32", specifier = ">=311.0.0,<312.0.0" },
+ { name = "pyyaml", specifier = ">=6.0.3,<7.0.0" },
+ { name = "requests", specifier = ">=2.33.1,<3.0.0" },
+ { name = "yarl", specifier = ">=1.23.0,<2.0.0" },
+]
+
+[package.metadata.requires-dev]
+build = [
+ { name = "pyclean", specifier = ">=3.6.0,<4.0.0" },
+ { name = "pyinstaller", specifier = ">=6.20.0,<7.0.0" },
+ { name = "setuptools", specifier = ">=82.0.1,<83.0.0" },
+]
+dev = [
+ { name = "commitizen", specifier = ">=4.13.10,<5.0.0" },
+ { name = "psd-tools", specifier = ">=1.16.0,<2.0.0" },
+ { name = "pytest", specifier = ">=9.0.3,<10.0.0" },
+ { name = "types-pywin32", specifier = ">=311.0.0.20260402" },
+ { name = "types-requests", specifier = ">=2.33.0.20260402" },
+]
+docs = [
+ { name = "mkdocs", specifier = ">=1.6.1,<2.0.0" },
+ { name = "mkdocs-autolinks-plugin", specifier = ">=0.7.1,<1.0.0" },
+ { name = "mkdocs-gen-files", specifier = ">=0.6.1,<1.0.0" },
+ { name = "mkdocs-git-revision-date-plugin", specifier = ">=0.3.2,<1.0.0" },
+ { name = "mkdocs-include-markdown-plugin", specifier = ">=7.2.2,<8.0.0" },
+ { name = "mkdocs-material", specifier = ">=9.7.6,<10.0.0" },
+ { name = "mkdocs-minify-plugin", specifier = ">=0.8.0,<1.0.0" },
+ { name = "mkdocs-pymdownx-material-extras", specifier = ">=2.8.0,<3.0.0" },
+ { name = "mkdocs-same-dir", specifier = ">=0.1.5,<1.0.0" },
+ { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.4,<2.0.0" },
+ { name = "mkdocstrings-python", specifier = ">=2.0.3,<3.0.0" },
+]
+tests-extra = [
+ { name = "matplotlib", specifier = ">=3.10.9,<4.0.0" },
+ { name = "memory-profiler", specifier = ">=0.61.0,<1.0.0" },
+]
+
+[[package]]
+name = "psd-tools"
+version = "1.16.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "numpy" },
+ { name = "pillow" },
+ { name = "typing-extensions" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7a/9b/452cb58795a11b33eecc1dfbf84771fbaa50857dc3f2bd3f4793b0e9068d/psd_tools-1.16.0-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:2496628209a01eae3e5657c941ad0e27acec4c8ae0b72fe57fee0e3b039a3f49", size = 375521, upload-time = "2026-04-24T10:36:18.471Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c4/85fc97ea2549807f13cde18d1727865171e0b9ca87d9681a72b70a23541c/psd_tools-1.16.0-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:ae361357ec228cc5f76f8814d0c668375ace6c883c97b85c868b591a658292df", size = 374679, upload-time = "2026-04-24T10:36:19.434Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/33/873f58292768cfcfbc4619dcbe04e357b62e37546810d910f05111ebfe22/psd_tools-1.16.0-cp313-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40a5650ed17032027d18747601afdfb740e46f6ba918d43a08a55f900361ae8a", size = 714006, upload-time = "2026-04-24T10:36:20.738Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/83/4d69d4d69213e5cc703d14219dcefedb9b6768ae4fcb83b8cbe9ce2ebb4a/psd_tools-1.16.0-cp313-abi3-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6940eada4882f64db5a2bd4097f21c8c7eed9c391083302e46b8dc07b2d0fb7c", size = 718879, upload-time = "2026-04-24T10:36:21.783Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/2f/0bb0e9f67755761f19f4e7600ed93566a08f4a4d7f65e9c22300c8557d90/psd_tools-1.16.0-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b8e71330f18ec165497e4c1fb6841781a4faa20125f7229a2d1293ccd543c22", size = 1687647, upload-time = "2026-04-24T10:36:22.997Z" },
+ { url = "https://files.pythonhosted.org/packages/15/fd/1d162c3ffa1af3e84f66f62782759c68ba2e5ef3028cdc121a15501966d2/psd_tools-1.16.0-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3388ffbfd0213e5ad4c2b0d923cd931403b0b422c8954e22cabc32404a461a9d", size = 1750475, upload-time = "2026-04-24T10:36:24.385Z" },
+ { url = "https://files.pythonhosted.org/packages/74/2f/5b6e622a50e8a303121a8c14c891dab1d07d1595ef73d4a6072a1771792a/psd_tools-1.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:522781ade64766ed5c4f3428d3b2fcc79070297f2a290902e2ae8f3fc2ae1738", size = 564078, upload-time = "2026-04-24T10:36:28.13Z" },
+ { url = "https://files.pythonhosted.org/packages/76/9a/5b5baf290f8853e43a96a5c1860d0fc3ea9affdf936f02e88a9b7d493bf1/psd_tools-1.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:6ec8a50f5cae6801c5c797a924f08081088c336be5eecd8761f6325ee2a38c87", size = 728901, upload-time = "2026-04-24T10:36:29.366Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/f0/9efc1b726daff600aaf3d7aa38476ca8da87364b249ae66f8ad6b09e7700/psd_tools-1.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fe2ddef38f54d917bfc0e0d4a928eec0809f75a1b6f8b27b275f56ba302cc78b", size = 387418, upload-time = "2026-04-24T10:36:30.906Z" },
+ { url = "https://files.pythonhosted.org/packages/07/b0/9af743ceeeaf0aad79851af3feb30d3bddd8310110caf4326f5c894255b9/psd_tools-1.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd96d069915d7a0a1450d3518dd00c7200cbd1aa012dd828e700f183cc28e2bf", size = 387156, upload-time = "2026-04-24T10:36:32.067Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/e4/f9121b776b13049f5184dac342ba0e39591fe07596711fba51e9ba70279c/psd_tools-1.16.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fe559baa68ea01f460073a144efab23d5f6f62743bb7da3436f148212314b3c", size = 773533, upload-time = "2026-04-24T10:36:33.089Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/31/733e75a4fa9fe2f3de206e68a349f17a59772bc1fc58980faf3eb8b51ff5/psd_tools-1.16.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:441e5942692a25464f2248d536235b5addf1cf9c0d8c0c3625d022134d2fd4e3", size = 772161, upload-time = "2026-04-24T10:36:34.461Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/cf/b9bd9e7b86175323f5c045a73d8d8a2fd5bc86d33df1f5b4e0ea51265c1f/psd_tools-1.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:09d675eaf47d1ce3f5841a6d8ab00f3ed34e53a18830c81f8fd2e04f1f52de00", size = 1737878, upload-time = "2026-04-24T10:36:35.777Z" },
+ { url = "https://files.pythonhosted.org/packages/11/12/3a2620a73c80121a7af09ed8c08df59968b6612614ce9b1c20d54fcb4c1b/psd_tools-1.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:114d8a648e1a6c3fe67dadfc1af41fb21962c6276893c4774ccc8c0b5bad7f62", size = 1796510, upload-time = "2026-04-24T10:36:37.021Z" },
+ { url = "https://files.pythonhosted.org/packages/34/43/60c2a09061cd4d943027ceefd4ae474dffa7a505421a08d983e31c369781/psd_tools-1.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a3265cbac76097e8a6372964a3af1014c6f61d93fdcd76547c628f3b57f1f12", size = 575104, upload-time = "2026-04-24T10:36:38.14Z" },
+ { url = "https://files.pythonhosted.org/packages/21/a4/07d1b509030b9ab4000582304c700c88071fcf5bd7bc01d2f17a3dea5f68/psd_tools-1.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:38b6c205490ceb81cde8e41b201e7c05a8dbef3a9406ba068c980e739a7f0d7d", size = 734757, upload-time = "2026-04-24T10:36:39.581Z" },
+]
+
+[[package]]
+name = "psutil"
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
+]
+
+[[package]]
+name = "py7zr"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "brotli", marker = "platform_python_implementation == 'CPython'" },
+ { name = "brotlicffi", marker = "platform_python_implementation == 'PyPy'" },
+ { name = "inflate64" },
+ { name = "multivolumefile" },
+ { name = "psutil", marker = "sys_platform != 'cygwin'" },
+ { name = "pybcj" },
+ { name = "pycryptodomex" },
+ { name = "pyppmd" },
+ { name = "texttable" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0c/e6/01fb15361ca75ee5d01df6361825a49816a836c99980c5481da0e40c6877/py7zr-1.1.0.tar.gz", hash = "sha256:087b1a94861ad9eb4d21604f6aaa0a8986a7e00580abd79fedd6f82fecf0592c", size = 70855, upload-time = "2025-12-21T03:27:44.653Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f0/9c/762284710ead9076eeecd55fb60509c19cd1f4bea811df5f3603725b44cb/py7zr-1.1.0-py3-none-any.whl", hash = "sha256:5921bc30fb72b5453aafe3b2183664c08ef508cde2655988d5e9bd6078353ef7", size = 71257, upload-time = "2025-12-21T03:27:42.881Z" },
+]
+
+[[package]]
+name = "pybcj"
+version = "1.0.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/12/0c/2670b672655b18454841b8e88f024b9159d637a4c07f6ce6db85accf8467/pybcj-1.0.7.tar.gz", hash = "sha256:72d64574069ffb0a800020668376b7ebd7adea159adbf4d35f8effc62f0daa67", size = 31282, upload-time = "2025-11-29T00:53:29.738Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/9e/eb50f11ea7fb6342167bb8353d48966b490afa8ae47c98917e9acd045b71/pybcj-1.0.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:293f951eb3877840acab79f0c4dcfc06eab03e087cb9e4c004ec058e093acb1d", size = 32409, upload-time = "2025-11-29T00:53:08.152Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/f6/b55f4a5faf9bd162426f49de84873fbf813698d1b018234c3c99816a9662/pybcj-1.0.7-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3ae64960904f362d33ffca10715803afd9f9a6a2a592f871dcb335acf82edf29", size = 23818, upload-time = "2025-11-29T00:53:09.236Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/24/78cf0973b3deded1e072479ac35d387083a626b3dc0b29e2e099cf73e082/pybcj-1.0.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:70aa4476910f982025f878e598c136559a6d78b59fc20ba8b4b592306cde6051", size = 24115, upload-time = "2025-11-29T00:53:10.241Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/69/e044d7127018158c67b119e6ed26d1a4b34b1e1defd9b0d3de029a782b9f/pybcj-1.0.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79ce3ce9b380b1b75c5e490abc3888ee3b5b2d28c22b59618674bf410b9cee16", size = 52484, upload-time = "2025-11-29T00:53:11.71Z" },
+ { url = "https://files.pythonhosted.org/packages/98/05/55337b0a9807887967b2cf49dd6f3bf47c7745786b0bda51a5cbfda66c78/pybcj-1.0.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc73ee1bc064d6f97dfd66051d3859b32e1b6a4cf89b077f5c8ef6c2dccb71af", size = 51471, upload-time = "2025-11-29T00:53:12.86Z" },
+ { url = "https://files.pythonhosted.org/packages/59/09/6c57cbbf4931ac304f822ef78f478dd6b34b00b08e9530d308737959f064/pybcj-1.0.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5b0d13f41a9f85b3f95dd5dc7bfaa9539e80f8ae60a96db7f34c07ed732e4a82", size = 50435, upload-time = "2025-11-29T00:53:14.264Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/93/26415ca1b96456e27550dae67092023af3e8454621b1efa701079d8acb64/pybcj-1.0.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:597d7e9a8cbb30a6ed54d552fd3436edb32bbb821a7ac2fa8e5c7ebd1f7e0e93", size = 50010, upload-time = "2025-11-29T00:53:15.396Z" },
+ { url = "https://files.pythonhosted.org/packages/91/f7/d4e55aede85143adff3ded89a1ae87bbf29060ddc2249fb861d78b103d4a/pybcj-1.0.7-cp314-cp314-win_amd64.whl", hash = "sha256:4603cc41ceb1236abe9169e2ead344140be5d2c3ac01bbc5e44cb1b13078a009", size = 25283, upload-time = "2025-11-29T00:53:16.472Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/19/9939ba437140d7375aecf8063d8695dfeea56ca40f57ddaa6ca3b828c131/pybcj-1.0.7-cp314-cp314-win_arm64.whl", hash = "sha256:adf985e816ddd59f3bf6d1066b7fa89de7424a4f19f3725f9976284cabe54e28", size = 23622, upload-time = "2025-11-29T00:53:17.994Z" },
+ { url = "https://files.pythonhosted.org/packages/62/6e/5e14c70f3ddc268f28e7ac912510c8bc2b5430f18bb7f326bbb9d1878955/pybcj-1.0.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bbd835873de147481d62c11ba91a75d26a72df1142de3516b384b04e5a1db6d", size = 33016, upload-time = "2025-11-29T00:53:19.434Z" },
+ { url = "https://files.pythonhosted.org/packages/49/c1/25a1a8d5811c5d2a2ca3439c548abbba882fa72eba9a6eb41040206fdc26/pybcj-1.0.7-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b7576b25d7b01a953e2f987e77cef93c001db7b95924a5541d5a55f9195a7e89", size = 24114, upload-time = "2025-11-29T00:53:20.476Z" },
+ { url = "https://files.pythonhosted.org/packages/35/66/09567564cdc6cc2713d729bd29794367ebb67fa9ea5c10313c49608c08fa/pybcj-1.0.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57b36920498f82ca6a325a98b13e0fbff8fc29bade7aaaddc7d284640bffd87d", size = 24428, upload-time = "2025-11-29T00:53:21.522Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/49/4f6793624eb418cc2536ef39e67c8783d7ca542f6139a051e9d2d76fdc80/pybcj-1.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aac2a46faf41e373939f6d3e6a5aa2121bf09e2446972c14a8e5d1ca3b0f8130", size = 58806, upload-time = "2025-11-29T00:53:22.568Z" },
+ { url = "https://files.pythonhosted.org/packages/be/1c/8604f7fe360a9340bbf798b826a88f8e9d186fc031eb531bcd11ac9e684b/pybcj-1.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c7d6156ef2b4e8ecd450b62dc4cc3a89e8dda307cb26288b670952ef0df3a37", size = 56975, upload-time = "2025-11-29T00:53:23.691Z" },
+ { url = "https://files.pythonhosted.org/packages/83/71/d706b4220f60fa4e6a5756dc0051fb76c32a5615958fc12f0dcacef3f86b/pybcj-1.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0fe306213de1e764abae63c06ae5a4e9a83632f62612805f1f883b8d74431901", size = 56023, upload-time = "2025-11-29T00:53:25.135Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/28/ad306f5acb1226241960bb86e9021b4e32bb7da426ccdc824d9d36d61261/pybcj-1.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:00448182d535cca37e8f24d892d480fa86f80ff20c79385f6eca75f118efcbb4", size = 55194, upload-time = "2025-11-29T00:53:26.209Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/d8/70c5e5701926fc67dae02101f0298d21c0b89ff83fa705712c3b4da252e5/pybcj-1.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:7e94aa712d0fa5fda9875828441755ece7121fc3f8c5cc3bc8ee92d05b853590", size = 25826, upload-time = "2025-11-29T00:53:27.655Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/5d/7a87ba32c0c0756f36000fafe642fa4609be2c26a50a7913a057a47eabdf/pybcj-1.0.7-cp314-cp314t-win_arm64.whl", hash = "sha256:16fd4e51a5556d1f38d7ba5d1fab588bfb60ae23d2299b5179779bf9900adf71", size = 24049, upload-time = "2025-11-29T00:53:28.679Z" },
+]
+
+[[package]]
+name = "pyclean"
+version = "3.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0d/7690e519b66101c41c114e2ddbf144f9b4ff6ea938fb199c09ed958f3970/pyclean-3.6.0.tar.gz", hash = "sha256:a46c179be187999d50d2d3362b2c94856e60f77de32ca7ec2db4eeb16bc39199", size = 27000, upload-time = "2026-03-30T17:34:30.509Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/35/2a/1797004bb000db555bd45e38550b3ca10699f87e22a78e6753882e0ac2cf/pyclean-3.6.0-py3-none-any.whl", hash = "sha256:3e5f9c83f2472df5bdafa78ed533bd38690756e0a7b3b6f11c8b29c9affc728b", size = 27750, upload-time = "2026-03-30T17:34:29.034Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pycryptodomex"
+version = "3.23.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" },
+ { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" },
+ { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" },
+ { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
+ { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
+ { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
+ { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
+ { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
+ { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
+ { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
+ { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
+ { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
+ { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
+ { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+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" }
+wheels = [
+ { 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" },
+]
+
+[[package]]
+name = "pyinstaller"
+version = "6.20.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "altgraph" },
+ { name = "macholib", marker = "sys_platform == 'darwin'" },
+ { name = "packaging" },
+ { name = "pefile", marker = "sys_platform == 'win32'" },
+ { name = "pyinstaller-hooks-contrib" },
+ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
+ { name = "setuptools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/60/d03d52e6690d4e9caf333dcd14550cde634ce6c118b3bc8fa3112c3186fd/pyinstaller-6.20.0.tar.gz", hash = "sha256:95c5c7e03d5d61e9dfb8ef259c699cf492bb1041beb6dbe83696608cec07347a", size = 4048728, upload-time = "2026-04-22T20:59:36.96Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/e4/e228d6d1bbb7fd62dc660a8fb202a583b023d3a3624ca95d1a9290ee4d6a/pyinstaller-6.20.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:bf3be4e1284ee78ddccba5e29f99443a12a7b4673168288ffc4c9d38c6f7b90e", size = 1047642, upload-time = "2026-04-22T20:58:32.006Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/bd/afb631bcb3f9040efebd4f6d067f0828b51710818f69fb41a2d4b7787f52/pyinstaller-6.20.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72ae9c1fdea134afa791f58bdc9a1934d5c7609753c111e0026bfc272b32b712", size = 742494, upload-time = "2026-04-22T20:58:36.285Z" },
+ { url = "https://files.pythonhosted.org/packages/76/08/0729a5bac14754150e5d83b39d87d842eb42b0bffcaa03dbad6252e23a39/pyinstaller-6.20.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1031bcc307f3fbeffd4e162723e64d46dbf591c82dd0997413afb2a07328b941", size = 754191, upload-time = "2026-04-22T20:58:40.603Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/82/bc0ee4c7b97db1958eb651e0da9fb1e672e5ae53ca8867fd97701de52906/pyinstaller-6.20.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:8df3b3f347659fa2562d8d193a98ad4600133b8b8d07c268df89e4154376750e", size = 751902, upload-time = "2026-04-22T20:58:44.7Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e7/770002d6aaa54173881cb2c49bb195ba67b97bf39bac1cdf320f28401629/pyinstaller-6.20.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b0d3cc9dd8120d448459bd3880a12e2f9774c51443af49047801446377999a59", size = 748634, upload-time = "2026-04-22T20:58:48.579Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/db/68ba1fccb71278b2124fb90b37b7c8c0bc4c1173fba45b94466df3d9cb7f/pyinstaller-6.20.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:03696bb6350177c6bc23bcaf78e71a33c4a89b6754dd90d1be2f318e978c918b", size = 748490, upload-time = "2026-04-22T20:58:52.749Z" },
+ { url = "https://files.pythonhosted.org/packages/03/0f/ac77ffa996a56be3d5c8f85734a007f8347240691657f9704e7de2527fa3/pyinstaller-6.20.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:6357f1699f6af84f37e7367f031d4f68abdba65543b83990c9e8f5a4cebed0b7", size = 747650, upload-time = "2026-04-22T20:58:57.093Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/56/1ee91c3a2bc10ca1f36da10a6fd55ff7efc4dec367171eb25992a827874f/pyinstaller-6.20.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0ab39c690abad26ba148e8f664f0478acc82a733997f4f22e757774832802da9", size = 747413, upload-time = "2026-04-22T20:59:01.174Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/55/ae264339996953c4cdf9d89d916a0a8fa26a83cf917a742fff8b9d5f3fe8/pyinstaller-6.20.0-py3-none-win32.whl", hash = "sha256:9a7637e8e44b4387b13667fdcaac86ab6b29c446c16d34d8401539b81838759c", size = 1331584, upload-time = "2026-04-22T20:59:07.201Z" },
+ { url = "https://files.pythonhosted.org/packages/76/8c/300f57578882cce259bfb5ae56fda3b69caa3fe9df40a176c719920ea6e2/pyinstaller-6.20.0-py3-none-win_amd64.whl", hash = "sha256:d588844e890ee80c4365867f98146636e1849bbca8e4284bbf0c809aff0f161a", size = 1391851, upload-time = "2026-04-22T20:59:14.024Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/ea/b2f8e1642aecda78c0b75c7321f708e49e10bb3c00dd4f148c40761a1527/pyinstaller-6.20.0-py3-none-win_arm64.whl", hash = "sha256:bd53282c0a73e5c95573e1ddc8e5d564d4932bec91efbaed4dc5fdff9c2ae7f2", size = 1332259, upload-time = "2026-04-22T20:59:20.509Z" },
+]
+
+[[package]]
+name = "pyinstaller-hooks-contrib"
+version = "2026.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "setuptools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1a/67/f4452d68793fb15beba4f19ef39a38a8822f0da7452b503c400d5a21f5c1/pyinstaller_hooks_contrib-2026.5.tar.gz", hash = "sha256:f066dfca8f7c45ff6336c9cf9fe25b4e48bfeb322a1aa24faaedfb8a8d1b0b08", size = 173689, upload-time = "2026-05-04T22:36:55.124Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f6/5c/fd465d11da4d12b50d7eb5d2ee2ceb780d8d049dbb489f3828d131e387af/pyinstaller_hooks_contrib-2026.5-py3-none-any.whl", hash = "sha256:ea1535783fbdac4626351709e83f3ea80b681d3a4745763ebb407b5e27342eb9", size = 457314, upload-time = "2026-05-04T22:36:53.598Z" },
+]
+
+[[package]]
+name = "pymdown-extensions"
+version = "10.21.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown" },
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" },
+]
+
+[[package]]
+name = "pyparsing"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
+]
+
+[[package]]
+name = "pyppmd"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/81/d7/803232913cab9163a1a97ecf2236cd7135903c46ac8d49613448d88e8759/pyppmd-1.3.1.tar.gz", hash = "sha256:ced527f08ade4408c1bfc5264e9f97ffac8d221c9d13eca4f35ec1ec0c7b6b2e", size = 1351815, upload-time = "2025-11-27T22:08:44.175Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/65/10/ac2a011af1c7c40b6482e60d85d267ac5923fb8794b51bfddbb56b04d1ec/pyppmd-1.3.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ea53f71ac16e113599b8441a9d8b6dcd71cfdf15cdb33ba5151810b8e656c5ec", size = 77897, upload-time = "2025-11-27T22:08:17.104Z" },
+ { url = "https://files.pythonhosted.org/packages/20/13/737f4dac06685865d23f51b7061dbe9373c7bcc60b5b6456cd08301bc807/pyppmd-1.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:985c8703b53e5f68fe17f653e96748d60b1f855676c852a6e67cd472eb853671", size = 48268, upload-time = "2025-11-27T22:08:18.263Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/d3/c50ebaee8b8a064fe9a534e98df5051e309efb705728aadff4e6893cd87f/pyppmd-1.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:33daa996ad5203c665c0b55aff6329817b2cb7fa95f2c33a2e83ed0121b400cb", size = 48521, upload-time = "2025-11-27T22:08:19.742Z" },
+ { url = "https://files.pythonhosted.org/packages/80/6c/4a97c0a0ab7640aeddde4843cb6381b80ecb71fe2c6c48b9032d60586b11/pyppmd-1.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b49870c6d7194f6eb80f30335ca03596d153e02fcde2c222e4f1202ac25f7fcf", size = 142892, upload-time = "2025-11-27T22:08:20.972Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/22/744c32c7da3de171d9e62a1b2cb4104544ac778358565a3dbc06a2253324/pyppmd-1.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:385e92c97c42e8a6f0bfc0e4acfc6c074cb1ba3a2f650f292696dd9f19e2e603", size = 144550, upload-time = "2025-11-27T22:08:22.594Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/18/84af6808e0754923062122d3ee9f0532050f1612069d558bb5d53978e67a/pyppmd-1.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:017a1e2903f1c3147a1046db486990d401e8a25eb52c320b1fc2fb3e7b83cbeb", size = 139850, upload-time = "2025-11-27T22:08:23.863Z" },
+ { url = "https://files.pythonhosted.org/packages/20/5e/dc6166ea7929d442625301289d99313a5b358e3cb2e927a6bd913047e8ee/pyppmd-1.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f2770a4b777c0c5236b3d9294b7bf4bc15538c95d45b2079eb8ebc1298e62e37", size = 142589, upload-time = "2025-11-27T22:08:25.228Z" },
+ { url = "https://files.pythonhosted.org/packages/77/92/adc6b12ddf11173a02fd631010f88ad4fe4c532363959638d5d53583a3d9/pyppmd-1.3.1-cp314-cp314-win32.whl", hash = "sha256:b9d54cd59ce97f2ba57be1da91b3d874d129faca21c9565d7afec111f942e6a1", size = 42761, upload-time = "2025-11-27T22:08:26.917Z" },
+ { url = "https://files.pythonhosted.org/packages/12/4c/5003a413d9f2743298a070df6713a75dedccc470e7adeeced5b43cad7418/pyppmd-1.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0e64247618cb150d2909beb0137da3084fef1d3479b4cc73b5b47fda7611abf9", size = 47806, upload-time = "2025-11-27T22:08:28.059Z" },
+ { url = "https://files.pythonhosted.org/packages/07/b7/db927e1df7fc3132cb57960f4ea23bf174c7e86ccec22857e6a35cccdae7/pyppmd-1.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:d354d6e551d2630b0ac98f27e3ad63e86cdcac9ce2115b5dfe46e2c9d3f4e82c", size = 46122, upload-time = "2025-11-27T22:08:29.191Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/5f/3150227624f374b06e331a7f67ae3383e796dd90973ee17ec3e35d30524c/pyppmd-1.3.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2d77ed79662c32e2551748d59763cfe3dcd10855bf3495937e3d5e5917507818", size = 78876, upload-time = "2025-11-27T22:08:30.635Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/81/11a3bba62d1d92050f4dd86db810062b506ec76d20b6e00b4ab567ff15e4/pyppmd-1.3.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6a1f92b94635c23d85270bb26db25cc0db544e436af86efc1cf58302d71d5af1", size = 48830, upload-time = "2025-11-27T22:08:32.087Z" },
+ { url = "https://files.pythonhosted.org/packages/24/47/e8f50cf89c689543d5a16c7be3df3c4afd43cbeb1a019b84ff9e82f13415/pyppmd-1.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:610f214f2405e27eb5a3dad7fa15f385cfc42141a01cda71995d9c1e0b09fab9", size = 48956, upload-time = "2025-11-27T22:08:33.567Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/0f/944b30679a8dea3ea95a66531ee30cb6feb5d011ec7fd6832069d9130bf3/pyppmd-1.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae81a14895498d9a23429d92114c98da478b74b8e33251527d7cff3e01c09de0", size = 152872, upload-time = "2025-11-27T22:08:35.098Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/87/1e48ea92994f4c72dc9b5520a6a386b21c524d3d2969c2c2a5c325929ad7/pyppmd-1.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1683e6d1ba09e377e0ae02de3a518191a3d63ccdb0b6037c74e6ddf577b5644", size = 154210, upload-time = "2025-11-27T22:08:36.368Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/21/650911f98c4cb360442724bbdade27cb3679b0587ea77e73512693d2918d/pyppmd-1.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e4d74fa0f3531e9dadc56e0ace41bce82d3c0babed47b3f224101dc0dbde7287", size = 147636, upload-time = "2025-11-27T22:08:37.657Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/29/4f8cfc1dd67b04ced8c10669fa14c4e35a42cf4a84e1101793735a82d5f0/pyppmd-1.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f8d6375b18b9c79127fee0885cfd52e2e983edb67041464309426571d38dcd4", size = 150998, upload-time = "2025-11-27T22:08:38.951Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/da/dc9e5e10bc56056bd8c33e533517cb327752cbaf172688bba3c23f6b2318/pyppmd-1.3.1-cp314-cp314t-win32.whl", hash = "sha256:de87f7acd575fb07a4ff42d41bcc071570fe759a36f345f1f54f574ecfccfc5b", size = 43016, upload-time = "2025-11-27T22:08:40.234Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/a0/46dc15549ad2c0427a9825730e6bdf342045ad410543368afd4389a16f36/pyppmd-1.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:76e4800aa67292b4cc80058fd29b39e02a5dded721af9fe5654f356ef24307f4", size = 48636, upload-time = "2025-11-27T22:08:41.45Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/7b/7e9a83848de928c26f0ab3ee105c0da63a932a0804a94807205e6313e73a/pyppmd-1.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:e066cbf1d335fe20480cd8ecc10848ba78d99fe6d1e44ea00def48feaf46afdf", size = 46402, upload-time = "2025-11-27T22:08:42.804Z" },
+]
+
+[[package]]
+name = "pyside6"
+version = "6.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyside6-addons" },
+ { name = "pyside6-essentials" },
+ { name = "shiboken6" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/95/f3f5a2799163b6658126d78a85bc1dec9eda88c75c26780556b26071a1d8/pyside6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1f2735dc4f2bd4ec452ae50502c8a22128bba0aced35358a2bbc58384b820c6f", size = 571544, upload-time = "2026-03-23T12:47:20.263Z" },
+ { url = "https://files.pythonhosted.org/packages/da/89/9a1f521051714e6694ebbe2b979ded279845ec8e25cb309ca3960158d74f/pyside6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c642e2d25704ca746fd37f56feacf25c5aecc4cd40bef23d18eec81f87d9dc00", size = 571725, upload-time = "2026-03-23T12:47:21.727Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/3d/f779d8bba00fcde31a7d7fb6b59347a70773c9cc8135592dea9972579877/pyside6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:267b344c73580ac938ca63c611881fb42a3922ebfe043e271005f4f06c372c4e", size = 571722, upload-time = "2026-03-23T12:47:22.761Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/98/150e01a026df3e9697310236821fa825319bb4b9d6137539cb25a3032968/pyside6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:9092cb002ca43c64006afb2e0d0f6f51aef17aa737c33a45e502326a081ddcbc", size = 577988, upload-time = "2026-03-23T12:47:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e7/55960f7c6b41d058e95cb4af02652c46c48702c506c8bbf12e99550e1fb3/pyside6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:b15f39acc2b8f46251a630acad0d97f9a0a0461f2baffcd66d7adfada8eb641e", size = 561372, upload-time = "2026-03-23T12:47:25.073Z" },
+]
+
+[[package]]
+name = "pyside6-addons"
+version = "6.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyside6-essentials" },
+ { name = "shiboken6" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/df/241f311c61a46b7b1195927da77b2537692ee3442aa9ccd87981164ff78d/pyside6_addons-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d5eaa4643302e3a0fa94c5766234bee4073d7d5ab9c2b7fd222692a176faf182", size = 331554157, upload-time = "2026-03-23T12:40:40.497Z" },
+ { url = "https://files.pythonhosted.org/packages/31/b9/e81172835ccc9d8b9792cc6bf7524a252a0db9a76ddd693de230402697f9/pyside6_addons-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ac6fe3d4ef4497dde3efc5e896b0acd53ff6c93be4bf485f045690f919419f35", size = 174948482, upload-time = "2026-03-23T12:41:05.379Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/a4/426d9333782bf65ab2a20257d6b4b3af9b8d5d7a710da719865fab49d492/pyside6_addons-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8ffb40222456078930816ebcac2f2511716d2acbc11716dd5acc5c365179a753", size = 170430798, upload-time = "2026-03-23T12:41:38.134Z" },
+ { url = "https://files.pythonhosted.org/packages/35/9a/46d271fedfabad8c6dce2ebb69bb593745487ed33753a56a47c3ba4fdb1c/pyside6_addons-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:413e6121c24f5ffdce376298059eddecff74aa6d638e94e0f6015b33d29b889e", size = 168723088, upload-time = "2026-03-23T12:42:00.668Z" },
+ { url = "https://files.pythonhosted.org/packages/16/cd/1b28264f7dc9a642da2e4e7c02f67418d0949eb7ce329ae20869703c2630/pyside6_addons-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:aaaee83385977a0fe134b2f4fbfb92b45a880d5b656e4d90a708eef10b1b6de8", size = 35698324, upload-time = "2026-03-23T12:42:13.748Z" },
+]
+
+[[package]]
+name = "pyside6-essentials"
+version = "6.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "shiboken6" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/00/8a8583d3429c737cc20e61a43eba8ab1ec13ddb101e99802c2ffeedf3b41/pyside6_essentials-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:85d6ca87ef35fa6565d385ede72ae48420dd3f63113929d10fc800f6b0360e01", size = 108085251, upload-time = "2026-03-23T12:42:52.872Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/a9/07c9e5c014b871c1b19caf8f994bcd50b345559b81f81671217b49559b67/pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc20e7afd5fc6fe51297db91cef997ce60844be578f7a49fc61b7ab9657a8849", size = 78316055, upload-time = "2026-03-23T12:43:04.19Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/35/f06b1b641d7600ec46374c16cd37c66fa4a22870326b4eb073a95471035f/pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4854cb0a1b061e7a576d8fb7bb7cf9f49540d558b1acb7df0742a7afefe61e4e", size = 77380821, upload-time = "2026-03-23T12:43:24.649Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/37/ba95c6262836d2b286b4e05a9d16a5e870995d5d2503ac6adc6312208049/pyside6_essentials-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:3b3362882ad9389357a80504e600180006a957731fec05786fced7b038461fdf", size = 75793322, upload-time = "2026-03-23T12:43:35.575Z" },
+ { url = "https://files.pythonhosted.org/packages/53/27/d17f25e45820e633a70e6109b35991eda09a5e8000c2a306f0ab7538d48c/pyside6_essentials-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:81ca603dbf21bc39f89bb42db215c25ebe0c879a1a4c387625c321d2730ec187", size = 56337457, upload-time = "2026-03-23T12:43:43.573Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+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" }
+wheels = [
+ { 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" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+]
+
+[[package]]
+name = "pywin32"
+version = "311"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
+ { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
+]
+
+[[package]]
+name = "pywin32-ctypes"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "pyyaml-env-tag"
+version = "1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" },
+]
+
+[[package]]
+name = "questionary"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "prompt-toolkit" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.33.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
+]
+
+[[package]]
+name = "setuptools"
+version = "82.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
+]
+
+[[package]]
+name = "shiboken6"
+version = "6.11.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/1d/b56b7b694fbc871496435488d1f41c5068de546334850d722756511cef65/shiboken6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d88e8a1eb705f2b9ad21db08a61ae1dc0c773e5cd86a069de0754c4cf1f9b43b", size = 476085, upload-time = "2026-03-23T12:47:05.724Z" },
+ { url = "https://files.pythonhosted.org/packages/65/cb/4bb0c76011166230daa7c0074aeb3fdb3935c83ac1fef3789b85fcd1a8fc/shiboken6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad54e64f8192ddbdff0c54ac82b89edcd62ed623f502ea21c960541d19514053", size = 271055, upload-time = "2026-03-23T12:47:07.349Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/96/771a6e2b530f725303d16d78a321fa4876b98b4f3615c9851880df8c1a43/shiboken6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a10dc7718104ea2dc15d5b0b96909b77162ce1c76fcc6968e6df692b947a00e9", size = 267456, upload-time = "2026-03-23T12:47:08.689Z" },
+ { url = "https://files.pythonhosted.org/packages/72/f7/44c0c42c3f5f29dec457fd46ea0552174bcb8aa75becf03bbd90308ba07b/shiboken6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:483ff78a73c7b3189ca924abc694318084f078bcfeaffa68e32024ff2d025ee1", size = 1222132, upload-time = "2026-03-23T12:47:10.143Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/99/6e5ee21db2d6af84bbbd7d871d441dafeb069c6de5667b1aa49891a77c66/shiboken6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:3bd76cf56105ab2d62ecaff630366f11264f69b88d488f10f048da9a065781f4", size = 1783186, upload-time = "2026-03-23T12:47:11.832Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "smmap"
+version = "5.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" },
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.8.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
+]
+
+[[package]]
+name = "termcolor"
+version = "3.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" },
+]
+
+[[package]]
+name = "texttable"
+version = "1.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/dc/0aff23d6036a4d3bf4f1d8c8204c5c79c4437e25e0ae94ffe4bbb55ee3c2/texttable-1.7.0.tar.gz", hash = "sha256:2d2068fb55115807d3ac77a4ca68fa48803e84ebb0ee2340f858107a36522638", size = 12831, upload-time = "2023-10-03T09:48:12.272Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/99/4772b8e00a136f3e01236de33b0efda31ee7077203ba5967fcc76da94d65/texttable-1.7.0-py2.py3-none-any.whl", hash = "sha256:72227d592c82b3d7f672731ae73e4d1f88cd8e2ef5b075a7a7f01a23a3743917", size = 10768, upload-time = "2023-10-03T09:48:10.434Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
+]
+
+[[package]]
+name = "tomlkit"
+version = "0.13.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.67.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
+]
+
+[[package]]
+name = "types-pywin32"
+version = "311.0.0.20260408"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/40/0d182fbf578f30f7ff2b07b8fe494cc42178992d0087a739c70990adca8c/types_pywin32-311.0.0.20260408.tar.gz", hash = "sha256:cb86c6beae20195165e770a65c3ee707746dc777ca8e03e4f06a66d4013a4bd0", size = 332341, upload-time = "2026-04-08T04:33:29.824Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/b5/3cc67baf622805270d84e2252dfa130daf7ccd49795f80b51350abb91bd9/types_pywin32-311.0.0.20260408-py3-none-any.whl", hash = "sha256:0b691da60aaed0ee7169a69268bad1e2051eb52f4acc10248c103aadcd1f2451", size = 395413, upload-time = "2026-04-08T04:33:28.476Z" },
+]
+
+[[package]]
+name = "types-requests"
+version = "2.33.0.20260503"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a1/b8/57e94268c0d82ac3eaa2fc35aa8ca7bbc2542f726b67dcf90b0b00a3b14d/types_requests-2.33.0.20260503.tar.gz", hash = "sha256:9721b2d9dbee7131f2fb39f20f0ebb1999c18cef4b512c9a7932f3722de7c5f4", size = 23931, upload-time = "2026-05-03T05:20:08.882Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c3/82/959113a6351f3ca046cd0a8cd2cee071d7ea47473560557a01eeae9a6fe2/types_requests-2.33.0.20260503-py3-none-any.whl", hash = "sha256:02aaa7e3577a13471715bb1bddb693cc985ea514f754b503bf033e6a09a3e528", size = 20736, upload-time = "2026-05-03T05:20:07.858Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+]
+
+[[package]]
+name = "watchdog"
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
+ { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
+ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
+]
+
+[[package]]
+name = "wcmatch"
+version = "10.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "bracex" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" },
+]
+
+[[package]]
+name = "wcwidth"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" },
+]
+
+[[package]]
+name = "wheel"
+version = "0.46.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/89/24/a2eb353a6edac9a0303977c4cb048134959dd2a51b48a269dfc9dde00c8a/wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803", size = 60605, upload-time = "2026-01-22T12:39:49.136Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", size = 30557, upload-time = "2026-01-22T12:39:48.099Z" },
+]
+
+[[package]]
+name = "win32-setctime"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
+]
+
+[[package]]
+name = "wrapt"
+version = "2.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" },
+ { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" },
+ { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" },
+ { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" },
+ { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" },
+ { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" },
+ { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" },
+ { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
+]
+
+[[package]]
+name = "yarl"
+version = "1.23.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" },
+ { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" },
+ { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" },
+ { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" },
+ { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" },
+ { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" },
+ { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" },
+ { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" },
+ { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" },
+ { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" },
+ { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" },
+ { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" },
+ { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" },
+ { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
+]