decksmith 0.9.1__tar.gz → 0.9.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {decksmith-0.9.1 → decksmith-0.9.3}/PKG-INFO +3 -1
  2. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/card_builder.py +66 -35
  3. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/deck_builder.py +95 -85
  4. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/export.py +4 -1
  5. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/gui/app.py +25 -20
  6. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/gui/static/css/style.css +105 -33
  7. decksmith-0.9.3/decksmith/gui/static/img/decksmith.ico +0 -0
  8. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/gui/static/js/main.js +169 -115
  9. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/gui/templates/index.html +184 -182
  10. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/image_ops.py +11 -0
  11. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/main.py +7 -6
  12. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/project.py +35 -39
  13. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/renderers/image.py +4 -6
  14. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/renderers/text.py +153 -127
  15. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/validate.py +14 -2
  16. {decksmith-0.9.1 → decksmith-0.9.3}/docs/README.md +2 -0
  17. {decksmith-0.9.1 → decksmith-0.9.3}/pyproject.toml +51 -51
  18. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/__init__.py +0 -0
  19. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/gui/__init__.py +0 -0
  20. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/logger.py +0 -0
  21. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/macro.py +0 -0
  22. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/renderers/__init__.py +0 -0
  23. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/renderers/shapes.py +0 -0
  24. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/templates/deck.csv +0 -0
  25. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/templates/deck.yaml +0 -0
  26. {decksmith-0.9.1 → decksmith-0.9.3}/decksmith/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: decksmith
3
- Version: 0.9.1
3
+ Version: 0.9.3
4
4
  Summary: A command-line application to dynamically generate decks of cards from a YAML specification and a CSV data file, inspired by nandeck.
5
5
  License-Expression: GPL-2.0-only
6
6
  Author: Julio Cabria
@@ -28,6 +28,8 @@ Description-Content-Type: text/markdown
28
28
 
29
29
  # DeckSmith
30
30
 
31
+ [julynx.github.io/decksmith](https://julynx.github.io/decksmith/)
32
+
31
33
  *A powerful application to dynamically generate decks of cards from a YAML specification and a CSV data file.*
32
34
 
33
35
  <br>
@@ -4,6 +4,7 @@ which is used to create card images based on a YAML specification.
4
4
  """
5
5
 
6
6
  import operator
7
+ import traceback
7
8
  from pathlib import Path
8
9
  from typing import Any, Dict, Optional, Tuple
9
10
 
@@ -52,14 +53,17 @@ class CardBuilder:
52
53
 
53
54
  def _calculate_absolute_position(self, element: Dict[str, Any]) -> Tuple[int, int]:
54
55
  """
55
- Calculates the absolute position of an element,
56
- resolving relative positioning.
56
+ Calculates the absolute position of an element, resolving relative positioning.
57
+
57
58
  Args:
58
59
  element (dict): The element dictionary.
60
+
59
61
  Returns:
60
62
  tuple: The absolute (x, y) position of the element.
63
+
64
+ Raises:
65
+ ValueError: If the referenced element for relative positioning is not found.
61
66
  """
62
- # If the element has no 'relative_to', return its position directly
63
67
  if "relative_to" not in element:
64
68
  return tuple(element.get("position", [0, 0]))
65
69
 
@@ -85,53 +89,80 @@ class CardBuilder:
85
89
  def render(self) -> Image.Image:
86
90
  """
87
91
  Renders the card image by drawing all elements specified in the YAML.
92
+
88
93
  Returns:
89
94
  Image.Image: The rendered card image.
95
+
96
+ Raises:
97
+ Exception: If an error occurs during rendering.
90
98
  """
91
99
  self.spec = transform_card(self.spec)
92
100
  validate_card(self.spec)
93
101
 
102
+ renderers = {
103
+ "text": self._render_text,
104
+ "image": self._render_image,
105
+ "circle": self._render_shape,
106
+ "ellipse": self._render_shape,
107
+ "polygon": self._render_shape,
108
+ "regular-polygon": self._render_shape,
109
+ "rectangle": self._render_shape,
110
+ }
111
+
94
112
  for element in self.spec.get("elements", []):
95
113
  element_type = element.get("type")
96
- try:
97
- if element_type == "text":
98
- self.text_renderer.render(
99
- self.draw,
100
- element,
101
- self._calculate_absolute_position,
102
- self._store_element_position,
103
- )
104
- elif element_type == "image":
105
- self.image_renderer.render(
106
- self.card,
107
- element,
108
- self._calculate_absolute_position,
109
- self._store_element_position,
110
- )
111
- elif element_type in [
112
- "circle",
113
- "ellipse",
114
- "polygon",
115
- "regular-polygon",
116
- "rectangle",
117
- ]:
118
- self.card = self.shape_renderer.render(
119
- self.card,
120
- element,
121
- self._calculate_absolute_position,
122
- self._store_element_position,
114
+ handler = renderers.get(element_type)
115
+
116
+ if handler:
117
+ try:
118
+ handler(element)
119
+ except Exception as exc:
120
+ logger.error(
121
+ "Error drawing element %s: %s\n%s",
122
+ element_type,
123
+ exc,
124
+ traceback.format_exc(),
123
125
  )
124
- # Re-create draw object because shape renderer might have composited a new image
125
- self.draw = ImageDraw.Draw(self.card, "RGBA")
126
- except Exception as e:
127
- logger.error("Error drawing element %s: %s", element_type, e)
128
- # Continue drawing other elements
126
+ raise
127
+ else:
128
+ logger.warning("Unknown element type: %s", element_type)
129
129
 
130
130
  return self.card
131
131
 
132
+ def _render_text(self, element: Dict[str, Any]):
133
+ """Renders a text element."""
134
+ self.card = self.text_renderer.render(
135
+ self.card,
136
+ element,
137
+ self._calculate_absolute_position,
138
+ self._store_element_position,
139
+ )
140
+ self.draw = ImageDraw.Draw(self.card, "RGBA")
141
+
142
+ def _render_image(self, element: Dict[str, Any]):
143
+ """Renders an image element."""
144
+ self.image_renderer.render(
145
+ self.card,
146
+ element,
147
+ self._calculate_absolute_position,
148
+ self._store_element_position,
149
+ )
150
+
151
+ def _render_shape(self, element: Dict[str, Any]):
152
+ """Renders a shape element."""
153
+ self.card = self.shape_renderer.render(
154
+ self.card,
155
+ element,
156
+ self._calculate_absolute_position,
157
+ self._store_element_position,
158
+ )
159
+ # Re-create draw object because shape renderer might have composited a new image
160
+ self.draw = ImageDraw.Draw(self.card, "RGBA")
161
+
132
162
  def build(self, output_path: Path):
133
163
  """
134
164
  Builds the card image and saves it to the specified path.
165
+
135
166
  Args:
136
167
  output_path (Path): The path where the card image will be saved.
137
168
  """
@@ -1,85 +1,95 @@
1
- """
2
- This module contains the DeckBuilder class,
3
- which is used to create a deck of cards based on a YAML specification
4
- and a CSV file.
5
- """
6
-
7
- import concurrent.futures
8
- from pathlib import Path
9
- from typing import Any, Dict, List, Optional
10
-
11
- import pandas as pd
12
- from pandas import Series
13
- from ruamel.yaml import YAML
14
-
15
- from decksmith.card_builder import CardBuilder
16
- from decksmith.logger import logger
17
- from decksmith.macro import MacroResolver
18
-
19
-
20
- class DeckBuilder:
21
- """
22
- A class to build a deck of cards based on a YAML specification and a CSV file.
23
- Attributes:
24
- spec_path (Path): Path to the YAML specification file.
25
- csv_path (Union[Path, None]): Path to the CSV file containing card data.
26
- cards (list): List of CardBuilder instances for each card in the deck.
27
- """
28
-
29
- def __init__(self, spec_path: Path, csv_path: Optional[Path] = None):
30
- """
31
- Initializes the DeckBuilder with a YAML specification file and a CSV file.
32
- Args:
33
- spec_path (Path): Path to the YAML specification file.
34
- csv_path (Union[Path, None]): Path to the CSV file containing card data.
35
- """
36
- self.spec_path = spec_path
37
- self.csv_path = csv_path
38
- self.cards: List[CardBuilder] = []
39
- self._spec_cache: Optional[Dict[str, Any]] = None
40
-
41
- @property
42
- def spec(self) -> Dict[str, Any]:
43
- """Loads and caches the spec file."""
44
- if self._spec_cache is None:
45
- yaml = YAML()
46
- with open(self.spec_path, "r", encoding="utf-8") as spec_file:
47
- self._spec_cache = yaml.load(spec_file)
48
- return self._spec_cache
49
-
50
- def build_deck(self, output_path: Path):
51
- """
52
- Builds the deck of cards by reading the CSV file and creating CardBuilder instances.
53
- """
54
- base_path = self.spec_path.parent if self.spec_path else None
55
-
56
- if not self.csv_path or not self.csv_path.exists():
57
- logger.info("No CSV file found. Building single card from spec.")
58
- card_builder = CardBuilder(self.spec, base_path=base_path)
59
- card_builder.build(output_path / "card_1.png")
60
- return
61
-
62
- try:
63
- dataframe = pd.read_csv(self.csv_path, encoding="utf-8", sep=";", header=0)
64
- except Exception as e:
65
- logger.error("Error reading CSV file: %s", e)
66
- return
67
-
68
- def build_card(row_tuple: tuple[int, Series]):
69
- """
70
- Builds a single card from a row of the CSV file.
71
- Args:
72
- row_tuple (tuple[int, Series]): A tuple containing the row index and the row data.
73
- """
74
- idx, row = row_tuple
75
- try:
76
- # We need a deep copy of the spec for each card to avoid side effects
77
- # But resolve_macros creates a new structure, so it should be fine
78
- spec = MacroResolver.resolve(self.spec, row.to_dict())
79
- card_builder = CardBuilder(spec, base_path=base_path)
80
- card_builder.build(output_path / f"card_{idx + 1}.png")
81
- except Exception as e:
82
- logger.error("Error building card %s: %s", idx + 1, e)
83
-
84
- with concurrent.futures.ThreadPoolExecutor() as executor:
85
- list(executor.map(build_card, dataframe.iterrows()))
1
+ """
2
+ This module contains the DeckBuilder class,
3
+ which is used to create a deck of cards based on a YAML specification
4
+ and a CSV file.
5
+ """
6
+
7
+ import concurrent.futures
8
+ import traceback
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ import pandas as pd
13
+ from pandas import Series
14
+ from ruamel.yaml import YAML
15
+
16
+ from decksmith.card_builder import CardBuilder
17
+ from decksmith.logger import logger
18
+ from decksmith.macro import MacroResolver
19
+
20
+
21
+ class DeckBuilder:
22
+ """
23
+ A class to build a deck of cards based on a YAML specification and a CSV file.
24
+ Attributes:
25
+ spec_path (Path): Path to the YAML specification file.
26
+ csv_path (Union[Path, None]): Path to the CSV file containing card data.
27
+ cards (list): List of CardBuilder instances for each card in the deck.
28
+ """
29
+
30
+ def __init__(self, spec_path: Path, csv_path: Optional[Path] = None):
31
+ """
32
+ Initializes the DeckBuilder with a YAML specification file and a CSV file.
33
+ Args:
34
+ spec_path (Path): Path to the YAML specification file.
35
+ csv_path (Union[Path, None]): Path to the CSV file containing card data.
36
+ """
37
+ self.spec_path = spec_path
38
+ self.csv_path = csv_path
39
+ self.cards: List[CardBuilder] = []
40
+ self._spec_cache: Optional[Dict[str, Any]] = None
41
+
42
+ @property
43
+ def spec(self) -> Dict[str, Any]:
44
+ """Loads and caches the spec file."""
45
+ if self._spec_cache is None:
46
+ yaml = YAML()
47
+ with open(self.spec_path, "r", encoding="utf-8") as spec_file:
48
+ self._spec_cache = yaml.load(spec_file)
49
+ return self._spec_cache
50
+
51
+ def build_deck(self, output_path: Path):
52
+ """
53
+ Builds the deck of cards by reading the CSV file and creating CardBuilder instances.
54
+
55
+ Args:
56
+ output_path (Path): The directory path to save the built cards.
57
+ """
58
+ base_path = self.spec_path.parent if self.spec_path else None
59
+
60
+ if not self.csv_path or not self.csv_path.exists():
61
+ logger.info("No CSV file found. Building single card from spec.")
62
+ card_builder = CardBuilder(self.spec, base_path=base_path)
63
+ card_builder.build(output_path / "card_1.png")
64
+ return
65
+
66
+ try:
67
+ dataframe = pd.read_csv(self.csv_path, encoding="utf-8", sep=";", header=0)
68
+ except Exception as e:
69
+ logger.error("Error reading CSV file: %s\n%s", e, traceback.format_exc())
70
+ return
71
+
72
+ def build_card(row_tuple: tuple[int, Series]):
73
+ """
74
+ Builds a single card from a row of the CSV file.
75
+ Args:
76
+ row_tuple (tuple[int, Series]): A tuple containing the row index and the row data.
77
+ """
78
+ idx, row = row_tuple
79
+ try:
80
+ # We need a deep copy of the spec for each card to avoid side effects
81
+ # But resolve_macros creates a new structure, so it should be fine
82
+ spec = MacroResolver.resolve(self.spec, row.to_dict())
83
+ card_builder = CardBuilder(spec, base_path=base_path)
84
+ card_builder.build(output_path / f"card_{idx + 1}.png")
85
+ except Exception as exc:
86
+ logger.error(
87
+ "Error building card %s: %s\n%s",
88
+ idx + 1,
89
+ exc,
90
+ traceback.format_exc(),
91
+ )
92
+ raise
93
+
94
+ with concurrent.futures.ThreadPoolExecutor() as executor:
95
+ list(executor.map(build_card, dataframe.iterrows()))
@@ -2,6 +2,7 @@
2
2
  This module provides the functionality to export images from a folder to a PDF file.
3
3
  """
4
4
 
5
+ import traceback
5
6
  from pathlib import Path
6
7
  from typing import List, Tuple
7
8
 
@@ -166,5 +167,7 @@ class PdfExporter:
166
167
  self.pdf.save()
167
168
  logger.info("Successfully exported PDF to %s", self.output_path)
168
169
  except Exception as e:
169
- logger.error("An error occurred during PDF export: %s", e)
170
+ logger.error(
171
+ "An error occurred during PDF export: %s\n%s", e, traceback.format_exc()
172
+ )
170
173
  raise
@@ -8,6 +8,7 @@ import os
8
8
  import signal
9
9
  import threading
10
10
  import time
11
+ import traceback
11
12
  import webbrowser
12
13
  from io import StringIO
13
14
  from pathlib import Path
@@ -96,8 +97,8 @@ def get_default_path():
96
97
  default_path.mkdir(parents=True, exist_ok=True)
97
98
  return jsonify({"path": str(default_path)})
98
99
  except Exception as e:
99
- logger.error("Error creating default path: %s", e)
100
- return jsonify({"error": str(e)}), 500
100
+ logger.error("Error creating default path: %s\n%s", e, traceback.format_exc())
101
+ return jsonify({"status": "error", "message": str(e)}), 500
101
102
 
102
103
 
103
104
  @app.route("/api/system/browse", methods=["POST"])
@@ -115,23 +116,26 @@ def browse_folder():
115
116
  return jsonify({"path": folder_path})
116
117
  return jsonify({"path": None})
117
118
  except ImportError as e:
118
- logger.error("crossfiledialog import failed: %s", e)
119
- return jsonify({"error": f"Browse feature unavailable: {e}"}), 501
119
+ logger.error("crossfiledialog import failed: %s\n%s", e, traceback.format_exc())
120
+ return jsonify(
121
+ {"status": "error", "message": f"Browse feature unavailable: {e}"}
122
+ ), 501
120
123
  except Exception as e:
121
- logger.error("Error browsing folder: %s", e)
122
- return jsonify({"error": str(e)}), 500
124
+ logger.error("Error browsing folder: %s\n%s", e, traceback.format_exc())
125
+ return jsonify({"status": "error", "message": str(e)}), 500
123
126
 
124
127
 
125
128
  @app.route("/api/project/select", methods=["POST"])
126
129
  def select_project():
127
130
  """Selects a project directory."""
128
- path_str = request.json.get("path")
131
+ data = request.json or {}
132
+ path_str = data.get("path")
129
133
  if not path_str:
130
- return jsonify({"error": "Path is required"}), 400
134
+ return jsonify({"status": "error", "message": "Path is required"}), 400
131
135
 
132
136
  path = Path(path_str)
133
137
  if not path.exists() or not path.is_dir():
134
- return jsonify({"error": "Directory does not exist"}), 400
138
+ return jsonify({"status": "error", "message": "Directory does not exist"}), 400
135
139
 
136
140
  project_manager.set_working_dir(path)
137
141
  return jsonify({"status": "success", "path": str(path)})
@@ -147,9 +151,10 @@ def close_project():
147
151
  @app.route("/api/project/create", methods=["POST"])
148
152
  def create_project():
149
153
  """Creates a new project."""
150
- path_str = request.json.get("path")
154
+ data = request.json or {}
155
+ path_str = data.get("path")
151
156
  if not path_str:
152
- return jsonify({"error": "Path is required"}), 400
157
+ return jsonify({"status": "error", "message": "Path is required"}), 400
153
158
 
154
159
  path = Path(path_str)
155
160
 
@@ -157,8 +162,8 @@ def create_project():
157
162
  project_manager.create_project(path)
158
163
  return jsonify({"status": "success", "path": str(path)})
159
164
  except Exception as e:
160
- logger.error("Error creating project: %s", e)
161
- return jsonify({"error": str(e)}), 500
165
+ logger.error("Error creating project: %s\n%s", e, traceback.format_exc())
166
+ return jsonify({"status": "error", "message": str(e)}), 500
162
167
 
163
168
 
164
169
  @app.route("/api/load", methods=["GET"])
@@ -172,7 +177,7 @@ def load_files():
172
177
  def save_files():
173
178
  """Saves project files."""
174
179
  if project_manager.get_working_dir() is None:
175
- return jsonify({"error": "No project selected"}), 400
180
+ return jsonify({"status": "error", "message": "No project selected"}), 400
176
181
 
177
182
  data = request.json
178
183
  yaml_content = data.get("yaml")
@@ -182,8 +187,8 @@ def save_files():
182
187
  project_manager.save_files(yaml_content, csv_content)
183
188
  return jsonify({"status": "success"})
184
189
  except Exception as e:
185
- logger.error("Error saving files: %s", e)
186
- return jsonify({"error": str(e)}), 500
190
+ logger.error("Error saving files: %s\n%s", e, traceback.format_exc())
191
+ return jsonify({"status": "error", "message": str(e)}), 500
187
192
 
188
193
 
189
194
  @app.route("/api/cards", methods=["GET"])
@@ -201,7 +206,7 @@ def list_cards():
201
206
  csv_table = pd.read_csv(csv_path, sep=";")
202
207
  return jsonify(csv_table.to_dict(orient="records"))
203
208
  except Exception as e:
204
- logger.error("Error listing cards: %s", e)
209
+ logger.error("Error listing cards: %s\n%s", e, traceback.format_exc())
205
210
  return jsonify({"error": str(e)}), 400
206
211
 
207
212
 
@@ -242,7 +247,7 @@ def preview_card(card_index):
242
247
  return send_file(image_buffer, mimetype="image/png")
243
248
 
244
249
  except Exception as e:
245
- logger.error("Error previewing card: %s", e)
250
+ logger.error("Error previewing card: %s\n%s", e, traceback.format_exc())
246
251
  return jsonify({"error": str(e)}), 500
247
252
 
248
253
 
@@ -271,7 +276,7 @@ def build_deck():
271
276
 
272
277
  return jsonify({"status": "success", "message": f"Deck built in {output_path}"})
273
278
  except Exception as e:
274
- logger.error("Error building deck: %s", e)
279
+ logger.error("Error building deck: %s\n%s", e, traceback.format_exc())
275
280
  return jsonify({"error": str(e)}), 500
276
281
 
277
282
 
@@ -313,7 +318,7 @@ def export_pdf():
313
318
  {"status": "success", "message": f"PDF exported to {output_pdf}"}
314
319
  )
315
320
  except Exception as e:
316
- logger.error("Error exporting PDF: %s", e)
321
+ logger.error("Error exporting PDF: %s\n%s", e, traceback.format_exc())
317
322
  return jsonify({"error": str(e)}), 500
318
323
 
319
324