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.
@@ -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
+ ]
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "1.3.0"
@@ -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())