contribution-city 1.3.0__py3-none-any.whl
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.
- contribution_city/__init__.py +15 -0
- contribution_city/_version.py +3 -0
- contribution_city/cli.py +475 -0
- contribution_city/config.py +345 -0
- contribution_city/github_api.py +267 -0
- contribution_city/models.py +322 -0
- contribution_city/previews.py +556 -0
- contribution_city/renderer/__init__.py +14 -0
- contribution_city/renderer/geometry.py +98 -0
- contribution_city/renderer/layouts.py +102 -0
- contribution_city/renderer/palettes.py +106 -0
- contribution_city/renderer/presets.py +113 -0
- contribution_city/renderer/svg.py +849 -0
- contribution_city/renderer/themes.py +489 -0
- contribution_city/svgcheck.py +126 -0
- contribution_city/visual/__init__.py +17 -0
- contribution_city/visual/baseline.py +167 -0
- contribution_city/visual/cases.py +449 -0
- contribution_city/visual/cli.py +234 -0
- contribution_city/visual/compare.py +179 -0
- contribution_city/visual/fixtures.py +143 -0
- contribution_city/visual/gallery.py +178 -0
- contribution_city/visual/render.py +62 -0
- contribution_city-1.3.0.dist-info/METADATA +382 -0
- contribution_city-1.3.0.dist-info/RECORD +28 -0
- contribution_city-1.3.0.dist-info/WHEEL +4 -0
- contribution_city-1.3.0.dist-info/entry_points.txt +2 -0
- contribution_city-1.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Contribution City - Isometric GitHub contribution visualization toolkit."""
|
|
2
|
+
|
|
3
|
+
from contribution_city._version import __version__
|
|
4
|
+
from contribution_city.models import ContributionDataSet, ContributionDay
|
|
5
|
+
from contribution_city.renderer.svg import CityRenderer
|
|
6
|
+
from contribution_city.renderer.themes import Theme, get_theme
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"__version__",
|
|
10
|
+
"ContributionDay",
|
|
11
|
+
"ContributionDataSet",
|
|
12
|
+
"CityRenderer",
|
|
13
|
+
"Theme",
|
|
14
|
+
"get_theme",
|
|
15
|
+
]
|
contribution_city/cli.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Command-line interface for Contribution City."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from contribution_city._version import __version__
|
|
11
|
+
from contribution_city.config import AppConfig, ConfigError
|
|
12
|
+
from contribution_city.github_api import (
|
|
13
|
+
GitHubAPIError,
|
|
14
|
+
GitHubAuthError,
|
|
15
|
+
GitHubClient,
|
|
16
|
+
GitHubRateLimitError,
|
|
17
|
+
GitHubUserNotFoundError,
|
|
18
|
+
)
|
|
19
|
+
from contribution_city.models import ContributionDataSet
|
|
20
|
+
from contribution_city.renderer import CityRenderer
|
|
21
|
+
from contribution_city.renderer.layouts import LAYOUT_SPECS
|
|
22
|
+
from contribution_city.renderer.presets import (
|
|
23
|
+
BUILDING_STYLES,
|
|
24
|
+
HEIGHT_PRESET_NAMES,
|
|
25
|
+
VIEW_ANGLE_NAMES,
|
|
26
|
+
)
|
|
27
|
+
from contribution_city.renderer.svg import APPEARANCES, BACKGROUND_MODES, HEIGHT_MODES, LAYOUTS
|
|
28
|
+
from contribution_city.renderer.themes import LIGHT_PAIRS, THEMES
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _theme_lines() -> str:
|
|
32
|
+
width = max(len(name) for name in THEMES)
|
|
33
|
+
return "\n".join(f" {name:<{width}} {theme.description}" for name, theme in THEMES.items())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _layout_lines() -> str:
|
|
37
|
+
width = max(len(name) for name in LAYOUT_SPECS)
|
|
38
|
+
return "\n".join(
|
|
39
|
+
f" {name:<{width}} {spec.description}" for name, spec in LAYOUT_SPECS.items()
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
44
|
+
"""Create command-line argument parser."""
|
|
45
|
+
parser = argparse.ArgumentParser(
|
|
46
|
+
prog="contribution-city",
|
|
47
|
+
description="Transform GitHub contribution activity into beautiful isometric pixel-art cities.",
|
|
48
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
49
|
+
epilog="""
|
|
50
|
+
Examples:
|
|
51
|
+
# Generate city from sample or local JSON file:
|
|
52
|
+
contribution-city generate --input examples/sample-contributions.json --theme navy-cyan --output dist/city.svg
|
|
53
|
+
|
|
54
|
+
# Generate live city from GitHub username (requires GITHUB_TOKEN environment variable or --token):
|
|
55
|
+
contribution-city generate --username octocat --theme matrix-green --output dist/octocat.svg
|
|
56
|
+
|
|
57
|
+
# Generate using a YAML configuration file:
|
|
58
|
+
contribution-city generate --config examples/config.example.yaml
|
|
59
|
+
|
|
60
|
+
# New in 1.2: layouts, backgrounds and themes
|
|
61
|
+
contribution-city generate --input examples/sample-contributions.json --theme cyberpunk --layout downtown
|
|
62
|
+
contribution-city generate --input examples/sample-contributions.json --layout floating-island --background transparent-sky
|
|
63
|
+
contribution-city generate --input examples/sample-contributions.json --layout wireframe --theme arctic-glass
|
|
64
|
+
|
|
65
|
+
# Offline data can be limited to a year or a date range
|
|
66
|
+
contribution-city generate --input examples/sample-contributions.json --from 2025-03-01 --to 2025-06-30
|
|
67
|
+
|
|
68
|
+
# Compact skyline layout with custom dimensions:
|
|
69
|
+
contribution-city generate --input examples/sample-contributions.json --layout compact --width 1000 --height 600
|
|
70
|
+
|
|
71
|
+
Available Themes:
|
|
72
|
+
{themes}
|
|
73
|
+
|
|
74
|
+
Layouts:
|
|
75
|
+
{layouts}
|
|
76
|
+
|
|
77
|
+
Backgrounds: solid | transparent | transparent-sky | auto (--background overrides --transparent)
|
|
78
|
+
Light/dark: use --appearance auto with a theme that has a light variant ({pairs}) to embed both
|
|
79
|
+
palettes in one SVG, or generate a dark and a light file and switch them with an HTML <picture>.
|
|
80
|
+
Precedence: built-in defaults < configuration file < command-line options.
|
|
81
|
+
""".replace("{themes}", _theme_lines())
|
|
82
|
+
.replace("{layouts}", _layout_lines())
|
|
83
|
+
.replace("{pairs}", ", ".join(LIGHT_PAIRS)),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
87
|
+
|
|
88
|
+
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
|
|
89
|
+
from contribution_city.visual.cli import add_subcommands
|
|
90
|
+
|
|
91
|
+
add_subcommands(subparsers)
|
|
92
|
+
gen_parser = subparsers.add_parser("generate", help="Generate isometric city SVG")
|
|
93
|
+
|
|
94
|
+
for p in (parser, gen_parser):
|
|
95
|
+
# Allow options either directly or under generate subcommand
|
|
96
|
+
if p == gen_parser:
|
|
97
|
+
p.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
98
|
+
p.add_argument(
|
|
99
|
+
"-i",
|
|
100
|
+
"--input",
|
|
101
|
+
dest="input_file",
|
|
102
|
+
help="Path to local contributions JSON file (offline / fixture mode)",
|
|
103
|
+
)
|
|
104
|
+
p.add_argument(
|
|
105
|
+
"-u",
|
|
106
|
+
"--username",
|
|
107
|
+
help="GitHub username to fetch live contributions from",
|
|
108
|
+
)
|
|
109
|
+
p.add_argument(
|
|
110
|
+
"-t",
|
|
111
|
+
"--token",
|
|
112
|
+
help="GitHub personal access token (defaults to GITHUB_TOKEN or GH_TOKEN env vars)",
|
|
113
|
+
)
|
|
114
|
+
p.add_argument(
|
|
115
|
+
"-c",
|
|
116
|
+
"--config",
|
|
117
|
+
dest="config_file",
|
|
118
|
+
help="Path to YAML configuration file",
|
|
119
|
+
)
|
|
120
|
+
p.add_argument(
|
|
121
|
+
"-o",
|
|
122
|
+
"--output",
|
|
123
|
+
dest="output_file",
|
|
124
|
+
help="Destination file path for generated SVG (default: dist/city.svg)",
|
|
125
|
+
)
|
|
126
|
+
p.add_argument(
|
|
127
|
+
"--theme",
|
|
128
|
+
choices=list(THEMES.keys()),
|
|
129
|
+
help="Visual theme (default: navy-cyan)",
|
|
130
|
+
)
|
|
131
|
+
p.add_argument(
|
|
132
|
+
"--layout",
|
|
133
|
+
choices=list(LAYOUTS),
|
|
134
|
+
help="Grid layout style (default: calendar)",
|
|
135
|
+
)
|
|
136
|
+
p.add_argument("--width", type=int, help="Canvas width in pixels (min 300)")
|
|
137
|
+
p.add_argument("--height", type=int, help="Canvas height in pixels (min 200)")
|
|
138
|
+
p.add_argument(
|
|
139
|
+
"--scale",
|
|
140
|
+
type=float,
|
|
141
|
+
dest="building_scale",
|
|
142
|
+
help="Multiplier for building heights, 0.1-5 (e.g. 0.8, 1.5). Without --height-preset "
|
|
143
|
+
"this keeps the 1.2.0 'custom' behaviour",
|
|
144
|
+
)
|
|
145
|
+
p.add_argument(
|
|
146
|
+
"--height-preset",
|
|
147
|
+
dest="height_preset",
|
|
148
|
+
choices=list(HEIGHT_PRESET_NAMES),
|
|
149
|
+
help="Overall building height profile: standard (default), tall (the 1.0-1.2 look) or "
|
|
150
|
+
"custom (your own --scale / --min-building-height / --max-building-height)",
|
|
151
|
+
)
|
|
152
|
+
p.add_argument(
|
|
153
|
+
"--min-building-height",
|
|
154
|
+
dest="min_building_height",
|
|
155
|
+
type=float,
|
|
156
|
+
help="Height of the smallest building in unscaled pixels (0-40)",
|
|
157
|
+
)
|
|
158
|
+
p.add_argument(
|
|
159
|
+
"--max-building-height",
|
|
160
|
+
dest="max_building_height",
|
|
161
|
+
type=float,
|
|
162
|
+
help="Tallest allowed building in unscaled pixels (8-400)",
|
|
163
|
+
)
|
|
164
|
+
p.add_argument(
|
|
165
|
+
"--building-style",
|
|
166
|
+
dest="building_style",
|
|
167
|
+
choices=list(BUILDING_STYLES),
|
|
168
|
+
help="detailed (windows, rooftop beacons; default) or solid (plain shaded buildings)",
|
|
169
|
+
)
|
|
170
|
+
p.add_argument(
|
|
171
|
+
"--view-angle",
|
|
172
|
+
dest="view_angle",
|
|
173
|
+
choices=list(VIEW_ANGLE_NAMES),
|
|
174
|
+
help="Camera preset: isometric (default), low, high or top-down",
|
|
175
|
+
)
|
|
176
|
+
p.add_argument(
|
|
177
|
+
"--view-elevation",
|
|
178
|
+
dest="view_elevation",
|
|
179
|
+
type=float,
|
|
180
|
+
help="Camera elevation in degrees (10-70); overrides --view-angle",
|
|
181
|
+
)
|
|
182
|
+
p.add_argument(
|
|
183
|
+
"--no-windows",
|
|
184
|
+
dest="show_windows",
|
|
185
|
+
action="store_false",
|
|
186
|
+
default=None,
|
|
187
|
+
help="Disable glowing pixel windows on building sides",
|
|
188
|
+
)
|
|
189
|
+
p.add_argument(
|
|
190
|
+
"--no-stars",
|
|
191
|
+
dest="show_stars",
|
|
192
|
+
action="store_false",
|
|
193
|
+
default=None,
|
|
194
|
+
help="Disable sky constellation stars",
|
|
195
|
+
)
|
|
196
|
+
p.add_argument(
|
|
197
|
+
"--no-header",
|
|
198
|
+
dest="show_header",
|
|
199
|
+
action="store_false",
|
|
200
|
+
default=None,
|
|
201
|
+
help="Disable developer HUD stats header overlay",
|
|
202
|
+
)
|
|
203
|
+
p.add_argument(
|
|
204
|
+
"--no-platform",
|
|
205
|
+
dest="show_platform",
|
|
206
|
+
action="store_false",
|
|
207
|
+
default=None,
|
|
208
|
+
help="Disable ground plinth platform beneath the city",
|
|
209
|
+
)
|
|
210
|
+
p.add_argument(
|
|
211
|
+
"--title",
|
|
212
|
+
dest="custom_title",
|
|
213
|
+
help="Custom title in HUD header",
|
|
214
|
+
)
|
|
215
|
+
p.add_argument(
|
|
216
|
+
"--appearance",
|
|
217
|
+
choices=list(APPEARANCES),
|
|
218
|
+
help="'fixed' (default) or 'auto' (adds the theme's light palette; needs a theme with one)",
|
|
219
|
+
)
|
|
220
|
+
p.add_argument(
|
|
221
|
+
"--height-mode",
|
|
222
|
+
dest="height_mode",
|
|
223
|
+
choices=list(HEIGHT_MODES),
|
|
224
|
+
help="How building height grows with commits (default: power; 'linear' is proportional)",
|
|
225
|
+
)
|
|
226
|
+
p.add_argument(
|
|
227
|
+
"--year",
|
|
228
|
+
dest="years",
|
|
229
|
+
type=int,
|
|
230
|
+
action="append",
|
|
231
|
+
help="Calendar year to include; repeat for several years (works with --username and --input)",
|
|
232
|
+
)
|
|
233
|
+
p.add_argument(
|
|
234
|
+
"--from",
|
|
235
|
+
dest="date_from",
|
|
236
|
+
metavar="YYYY-MM-DD",
|
|
237
|
+
help="First day to include (live data: at most one year long)",
|
|
238
|
+
)
|
|
239
|
+
p.add_argument(
|
|
240
|
+
"--to",
|
|
241
|
+
dest="date_to",
|
|
242
|
+
metavar="YYYY-MM-DD",
|
|
243
|
+
help="Last day to include",
|
|
244
|
+
)
|
|
245
|
+
p.add_argument(
|
|
246
|
+
"--retries",
|
|
247
|
+
type=int,
|
|
248
|
+
help="How many times to retry temporary GitHub API failures (default: 2)",
|
|
249
|
+
)
|
|
250
|
+
p.add_argument(
|
|
251
|
+
"--background",
|
|
252
|
+
choices=list(BACKGROUND_MODES),
|
|
253
|
+
help=(
|
|
254
|
+
"Background mode: solid, transparent (no rectangle, no stars), transparent-sky "
|
|
255
|
+
"(no rectangle, stars kept) or auto. Overrides --transparent when both are given."
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
p.add_argument(
|
|
259
|
+
"--id-prefix",
|
|
260
|
+
dest="id_prefix",
|
|
261
|
+
help="Prefix for element ids and CSS rules; use when inlining several SVGs in one page",
|
|
262
|
+
)
|
|
263
|
+
p.add_argument(
|
|
264
|
+
"--transparent",
|
|
265
|
+
dest="transparent",
|
|
266
|
+
action="store_true",
|
|
267
|
+
default=None,
|
|
268
|
+
help="No background rectangle or stars, so the page behind the image shows through",
|
|
269
|
+
)
|
|
270
|
+
p.add_argument(
|
|
271
|
+
"--no-tooltips",
|
|
272
|
+
dest="show_tooltips",
|
|
273
|
+
action="store_false",
|
|
274
|
+
default=None,
|
|
275
|
+
help="Omit hover tooltips (smaller file)",
|
|
276
|
+
)
|
|
277
|
+
p.add_argument(
|
|
278
|
+
"--no-fit",
|
|
279
|
+
dest="auto_fit",
|
|
280
|
+
action="store_false",
|
|
281
|
+
default=None,
|
|
282
|
+
help="Do not scale the city to fill the canvas",
|
|
283
|
+
)
|
|
284
|
+
p.add_argument(
|
|
285
|
+
"--smooth",
|
|
286
|
+
dest="pixel_art",
|
|
287
|
+
action="store_false",
|
|
288
|
+
default=None,
|
|
289
|
+
help="Use anti-aliased edges instead of hard pixel-art edges",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return parser
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def build_config(args: argparse.Namespace) -> AppConfig:
|
|
296
|
+
"""Build and merge configuration from file, defaults, and CLI arguments."""
|
|
297
|
+
# 1. Base from YAML config if supplied
|
|
298
|
+
if args.config_file:
|
|
299
|
+
config = AppConfig.from_file(args.config_file)
|
|
300
|
+
else:
|
|
301
|
+
config = AppConfig()
|
|
302
|
+
|
|
303
|
+
# 2. Override with explicit CLI flags
|
|
304
|
+
if args.username is not None:
|
|
305
|
+
config.username = args.username
|
|
306
|
+
if args.input_file is not None:
|
|
307
|
+
config.input_file = args.input_file
|
|
308
|
+
if args.output_file is not None:
|
|
309
|
+
config.output_file = args.output_file
|
|
310
|
+
if args.theme is not None:
|
|
311
|
+
config.theme = args.theme
|
|
312
|
+
if args.layout is not None:
|
|
313
|
+
config.layout = args.layout
|
|
314
|
+
if args.token is not None:
|
|
315
|
+
config.token = args.token
|
|
316
|
+
if args.appearance is not None:
|
|
317
|
+
config.appearance = args.appearance
|
|
318
|
+
if args.years:
|
|
319
|
+
config.years = list(args.years)
|
|
320
|
+
if args.date_from is not None:
|
|
321
|
+
config.date_from = args.date_from
|
|
322
|
+
if args.date_to is not None:
|
|
323
|
+
config.date_to = args.date_to
|
|
324
|
+
if args.retries is not None:
|
|
325
|
+
config.retries = args.retries
|
|
326
|
+
if args.height_mode is not None:
|
|
327
|
+
config.city.height_mode = args.height_mode
|
|
328
|
+
if args.transparent is not None:
|
|
329
|
+
config.city.transparent = args.transparent
|
|
330
|
+
if args.background is not None:
|
|
331
|
+
config.city.background = args.background
|
|
332
|
+
if args.id_prefix is not None:
|
|
333
|
+
config.city.id_prefix = args.id_prefix
|
|
334
|
+
if args.show_tooltips is not None:
|
|
335
|
+
config.city.show_tooltips = args.show_tooltips
|
|
336
|
+
if args.auto_fit is not None:
|
|
337
|
+
config.city.auto_fit = args.auto_fit
|
|
338
|
+
if args.pixel_art is not None:
|
|
339
|
+
config.city.pixel_art = args.pixel_art
|
|
340
|
+
|
|
341
|
+
if args.width is not None:
|
|
342
|
+
config.city.width = args.width
|
|
343
|
+
if args.height is not None:
|
|
344
|
+
config.city.height = args.height
|
|
345
|
+
if args.building_scale is not None:
|
|
346
|
+
config.city.building_scale = args.building_scale
|
|
347
|
+
for name in (
|
|
348
|
+
"height_preset",
|
|
349
|
+
"min_building_height",
|
|
350
|
+
"max_building_height",
|
|
351
|
+
"building_style",
|
|
352
|
+
"view_angle",
|
|
353
|
+
"view_elevation",
|
|
354
|
+
):
|
|
355
|
+
value = getattr(args, name)
|
|
356
|
+
if value is not None:
|
|
357
|
+
setattr(config.city, name, value)
|
|
358
|
+
if args.show_windows is not None:
|
|
359
|
+
config.city.show_windows = args.show_windows
|
|
360
|
+
if args.show_stars is not None:
|
|
361
|
+
config.city.show_stars = args.show_stars
|
|
362
|
+
if args.show_header is not None:
|
|
363
|
+
config.city.show_header = args.show_header
|
|
364
|
+
if args.show_platform is not None:
|
|
365
|
+
config.city.show_platform = args.show_platform
|
|
366
|
+
if args.custom_title is not None:
|
|
367
|
+
config.city.custom_title = args.custom_title
|
|
368
|
+
|
|
369
|
+
config.validate()
|
|
370
|
+
return config
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def load_dataset(config: AppConfig) -> ContributionDataSet:
|
|
374
|
+
"""Load contribution dataset from either input file or GitHub API."""
|
|
375
|
+
if config.input_file:
|
|
376
|
+
path = Path(config.input_file)
|
|
377
|
+
if not path.is_file():
|
|
378
|
+
raise FileNotFoundError(f"Input file not found: {path}")
|
|
379
|
+
dataset = ContributionDataSet.from_file(path)
|
|
380
|
+
if config.years or config.date_from or config.date_to:
|
|
381
|
+
dataset = dataset.restrict(config.date_from, config.date_to, config.years)
|
|
382
|
+
if not dataset.days:
|
|
383
|
+
sys.stderr.write(
|
|
384
|
+
"Note: no days in the selected years/dates; the city will be empty.\n"
|
|
385
|
+
)
|
|
386
|
+
return dataset
|
|
387
|
+
|
|
388
|
+
if config.username:
|
|
389
|
+
client = GitHubClient(token=config.token, max_retries=config.retries)
|
|
390
|
+
if config.years:
|
|
391
|
+
return client.fetch_years(config.username, config.years)
|
|
392
|
+
return client.fetch_contributions(config.username, config.date_from, config.date_to)
|
|
393
|
+
|
|
394
|
+
raise ValueError(
|
|
395
|
+
"No data source specified. Please provide either --input <file.json> "
|
|
396
|
+
"or --username <github-username>."
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def run(args: argparse.Namespace) -> int:
|
|
401
|
+
"""Execute city generation pipeline."""
|
|
402
|
+
try:
|
|
403
|
+
config = build_config(args)
|
|
404
|
+
except ConfigError as err:
|
|
405
|
+
sys.stderr.write(f"Configuration Error: {err}\n")
|
|
406
|
+
return 1
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
dataset = load_dataset(config)
|
|
410
|
+
except FileNotFoundError as err:
|
|
411
|
+
sys.stderr.write(f"File Error: {err}\n")
|
|
412
|
+
return 1
|
|
413
|
+
except (GitHubUserNotFoundError, GitHubAuthError, GitHubRateLimitError, GitHubAPIError) as err:
|
|
414
|
+
sys.stderr.write(f"GitHub API Error: {err}\n")
|
|
415
|
+
return 1
|
|
416
|
+
except ValueError as err:
|
|
417
|
+
sys.stderr.write(f"Input Error: {err}\n")
|
|
418
|
+
return 1
|
|
419
|
+
except Exception as err:
|
|
420
|
+
sys.stderr.write(f"Unexpected Error loading data: {err}\n")
|
|
421
|
+
return 1
|
|
422
|
+
|
|
423
|
+
# Render SVG
|
|
424
|
+
try:
|
|
425
|
+
render_cfg = config.to_render_config()
|
|
426
|
+
renderer = CityRenderer(render_cfg)
|
|
427
|
+
svg_content = renderer.render(dataset)
|
|
428
|
+
except Exception as err:
|
|
429
|
+
sys.stderr.write(f"Rendering Error: {err}\n")
|
|
430
|
+
return 1
|
|
431
|
+
|
|
432
|
+
# Save to output file
|
|
433
|
+
output_path = Path(config.output_file)
|
|
434
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
438
|
+
f.write(svg_content)
|
|
439
|
+
except OSError as err:
|
|
440
|
+
sys.stderr.write(f"Write Error: Could not save output to {output_path}: {err}\n")
|
|
441
|
+
return 1
|
|
442
|
+
|
|
443
|
+
user_label = dataset.username or "anonymous"
|
|
444
|
+
print(
|
|
445
|
+
f"[OK] Successfully generated Contribution City for '{user_label}' "
|
|
446
|
+
f"({dataset.total_contributions:,} contributions across {len(dataset.days)} days) -> {output_path}"
|
|
447
|
+
)
|
|
448
|
+
return 0
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
452
|
+
"""CLI main entry point."""
|
|
453
|
+
parser = create_parser()
|
|
454
|
+
args = parser.parse_args(argv)
|
|
455
|
+
if args.command == "gallery":
|
|
456
|
+
from contribution_city.visual.cli import run_gallery
|
|
457
|
+
|
|
458
|
+
return run_gallery(args)
|
|
459
|
+
if args.command == "visual":
|
|
460
|
+
from contribution_city.visual.cli import run_visual
|
|
461
|
+
|
|
462
|
+
return run_visual(args)
|
|
463
|
+
if args.command == "previews":
|
|
464
|
+
from contribution_city.visual.cli import run_previews
|
|
465
|
+
|
|
466
|
+
return run_previews(args)
|
|
467
|
+
if args.command == "check-svg":
|
|
468
|
+
from contribution_city.visual.cli import run_check_svg
|
|
469
|
+
|
|
470
|
+
return run_check_svg(args)
|
|
471
|
+
return run(args)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
if __name__ == "__main__":
|
|
475
|
+
sys.exit(main())
|