ghlang 2.2.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.
ghlang/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Language statistics analyzer for GitHub repositories and local files"""
2
+
3
+ __version__ = "2.2.0"
ghlang/cli/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ import typer
2
+
3
+ from ghlang import __version__
4
+ from ghlang.cli.config import config
5
+ from ghlang.cli.github import github
6
+ from ghlang.cli.local import local
7
+
8
+
9
+ app = typer.Typer(help="See what languages you've been coding in", add_completion=True)
10
+ app.command()(config)
11
+ app.command()(github)
12
+ app.command()(local)
13
+
14
+
15
+ def _version_callback(value: bool) -> None:
16
+ if value:
17
+ typer.echo(f"ghlang v{__version__}")
18
+ raise typer.Exit()
19
+
20
+
21
+ @app.callback()
22
+ def main(
23
+ version: bool = typer.Option(
24
+ False,
25
+ "--version",
26
+ help="Show version and exit",
27
+ callback=_version_callback,
28
+ is_eager=True,
29
+ ),
30
+ ) -> None:
31
+ pass
32
+
33
+
34
+ if __name__ == "__main__":
35
+ app()
ghlang/cli/config.py ADDED
@@ -0,0 +1,154 @@
1
+ import os
2
+ from pathlib import Path
3
+ import platform
4
+ import subprocess
5
+
6
+ from rich.console import Console
7
+ from rich.syntax import Syntax
8
+ from rich.table import Table
9
+ import typer
10
+
11
+ from ghlang.config import create_default_config
12
+ from ghlang.config import get_config_path
13
+ from ghlang.config import load_config
14
+
15
+
16
+ def _open_in_editor(path: Path) -> None:
17
+ """Open file in default editor"""
18
+ editor = os.environ.get("EDITOR")
19
+
20
+ if editor:
21
+ subprocess.run([editor, str(path)], check=False)
22
+ elif platform.system() == "Darwin":
23
+ subprocess.run(["open", str(path)], check=False)
24
+ elif platform.system() == "Windows":
25
+ os.startfile(str(path)) # type: ignore[attr-defined]
26
+ else:
27
+ subprocess.run(["xdg-open", str(path)], check=False)
28
+
29
+
30
+ def _format_value(value: object) -> str:
31
+ """Format a config value for display"""
32
+ if isinstance(value, bool):
33
+ return "[green]true[/green]" if value else "[red]false[/red]"
34
+
35
+ if isinstance(value, list):
36
+ if not value:
37
+ return "[dim][][/dim]"
38
+
39
+ return ", ".join(str(v) for v in value)
40
+
41
+ if isinstance(value, Path):
42
+ return str(value)
43
+
44
+ return str(value)
45
+
46
+
47
+ def _print_config_table(config_path: Path) -> None:
48
+ """Print config as a formatted table"""
49
+ console = Console()
50
+
51
+ try:
52
+ cfg = load_config(config_path=config_path, require_token=False)
53
+
54
+ except Exception as e:
55
+ console.print(f"[red]Error loading config:[/red] {e}")
56
+ raise typer.Exit(1)
57
+
58
+ console.print(f"\n[bold]Config:[/bold] {config_path}\n")
59
+
60
+ # GitHub section
61
+ table = Table(show_header=False, box=None, padding=(0, 2))
62
+ table.add_column("Key", style="cyan")
63
+ table.add_column("Value")
64
+
65
+ console.print("[bold yellow]GitHub[/bold yellow]")
66
+ table.add_row("token", cfg.token if cfg.token else "[dim]not set[/dim]")
67
+ table.add_row("affiliation", cfg.affiliation)
68
+ table.add_row("visibility", cfg.visibility)
69
+ table.add_row("ignored_repos", _format_value(cfg.ignored_repos))
70
+ console.print(table)
71
+ console.print()
72
+
73
+ # Cloc section
74
+ table = Table(show_header=False, box=None, padding=(0, 2))
75
+ table.add_column("Key", style="cyan")
76
+ table.add_column("Value")
77
+
78
+ console.print("[bold yellow]Cloc[/bold yellow]")
79
+ table.add_row("ignored_dirs", _format_value(cfg.ignored_dirs))
80
+ console.print(table)
81
+ console.print()
82
+
83
+ # Output section
84
+ table = Table(show_header=False, box=None, padding=(0, 2))
85
+ table.add_column("Key", style="cyan")
86
+ table.add_column("Value")
87
+
88
+ console.print("[bold yellow]Output[/bold yellow]")
89
+ table.add_row("directory", str(cfg.output_dir))
90
+ table.add_row("save_json", _format_value(cfg.save_json))
91
+ table.add_row("save_repos", _format_value(cfg.save_repos))
92
+ table.add_row("top_n_languages", str(cfg.top_n_languages))
93
+ console.print(table)
94
+ console.print()
95
+
96
+ # Preferences section
97
+ table = Table(show_header=False, box=None, padding=(0, 2))
98
+ table.add_column("Key", style="cyan")
99
+ table.add_column("Value")
100
+
101
+ console.print("[bold yellow]Preferences[/bold yellow]")
102
+ table.add_row("verbose", _format_value(cfg.verbose))
103
+ table.add_row("theme", cfg.theme)
104
+ console.print(table)
105
+ console.print()
106
+
107
+
108
+ def config(
109
+ show: bool = typer.Option(
110
+ False,
111
+ "--show",
112
+ help="Print config as formatted table",
113
+ ),
114
+ path: bool = typer.Option(
115
+ False,
116
+ "--path",
117
+ help="Print config file path",
118
+ ),
119
+ raw: bool = typer.Option(
120
+ False,
121
+ "--raw",
122
+ help="Print raw config file contents",
123
+ ),
124
+ ) -> None:
125
+ """Manage config file"""
126
+ config_path = get_config_path()
127
+
128
+ if path:
129
+ print(config_path)
130
+ return
131
+
132
+ if raw:
133
+ if not config_path.exists():
134
+ typer.echo(f"Config file doesn't exist yet: {config_path}")
135
+ raise typer.Exit(1)
136
+
137
+ console = Console()
138
+ syntax = Syntax(config_path.read_text(), "toml", theme="ansi_dark", line_numbers=True)
139
+ console.print(syntax)
140
+ return
141
+
142
+ if show:
143
+ if not config_path.exists():
144
+ typer.echo(f"Config file doesn't exist yet: {config_path}")
145
+ raise typer.Exit(1)
146
+
147
+ _print_config_table(config_path)
148
+ return
149
+
150
+ if not config_path.exists():
151
+ create_default_config(config_path)
152
+ typer.echo(f"Created config at {config_path}")
153
+
154
+ _open_in_editor(config_path)
ghlang/cli/github.py ADDED
@@ -0,0 +1,147 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import typer
5
+
6
+ from ghlang.cli.utils import generate_charts
7
+ from ghlang.config import load_config
8
+ from ghlang.exceptions import ConfigError
9
+ from ghlang.github_client import GitHubClient
10
+ from ghlang.logging import logger
11
+
12
+
13
+ def github(
14
+ config_path: Path | None = typer.Option(
15
+ None,
16
+ "--config",
17
+ help="Use a different config file",
18
+ exists=True,
19
+ dir_okay=False,
20
+ file_okay=True,
21
+ readable=True,
22
+ path_type=Path,
23
+ ),
24
+ output_dir: Path | None = typer.Option(
25
+ None,
26
+ "--output-dir",
27
+ help="Where to save the charts",
28
+ file_okay=False,
29
+ dir_okay=True,
30
+ writable=True,
31
+ path_type=Path,
32
+ ),
33
+ output: Path | None = typer.Option(
34
+ None,
35
+ "--output",
36
+ "-o",
37
+ help="Custom output path/filename",
38
+ path_type=Path,
39
+ ),
40
+ title: str | None = typer.Option(
41
+ None,
42
+ "--title",
43
+ "-t",
44
+ help="Custom chart title",
45
+ ),
46
+ top_n: int | None = typer.Option(
47
+ None,
48
+ "--top-n",
49
+ help="How many languages to show in the bar chart",
50
+ ),
51
+ json_only: bool = typer.Option(
52
+ False,
53
+ "--json-only",
54
+ help="Output JSON only, skip chart generation",
55
+ ),
56
+ stdout: bool = typer.Option(
57
+ False,
58
+ "--stdout",
59
+ help="Output stats to stdout instead of files (implies --json-only --quiet)",
60
+ ),
61
+ quiet: bool = typer.Option(
62
+ False,
63
+ "--quiet",
64
+ "-q",
65
+ help="Suppress log output (only show errors)",
66
+ ),
67
+ verbose: bool = typer.Option(
68
+ False,
69
+ "--verbose",
70
+ "-v",
71
+ help="Show more details",
72
+ ),
73
+ theme: str | None = typer.Option(
74
+ None,
75
+ "--theme",
76
+ help="Chart theme (default: light)",
77
+ ),
78
+ fmt: str | None = typer.Option(
79
+ None,
80
+ "--format",
81
+ "-f",
82
+ help="Output format, overrides --output extension (png or svg)",
83
+ ),
84
+ ) -> None:
85
+ """Analyze your GitHub repos"""
86
+ if stdout:
87
+ quiet = True
88
+ json_only = True
89
+
90
+ logger.configure(verbose, quiet=quiet)
91
+
92
+ try:
93
+ cli_overrides = {
94
+ "output_dir": output_dir,
95
+ "top_n_languages": top_n,
96
+ "verbose": verbose or None,
97
+ "theme": theme,
98
+ }
99
+ cfg = load_config(config_path=config_path, cli_overrides=cli_overrides, require_token=True)
100
+
101
+ except ConfigError as e:
102
+ logger.error(str(e))
103
+ raise typer.Exit(1)
104
+
105
+ if not stdout:
106
+ cfg.output_dir.mkdir(parents=True, exist_ok=True)
107
+ logger.info(f"Saving to {cfg.output_dir}")
108
+
109
+ try:
110
+ client = GitHubClient(
111
+ token=cfg.token,
112
+ affiliation=cfg.affiliation,
113
+ visibility=cfg.visibility,
114
+ ignored_repos=cfg.ignored_repos,
115
+ )
116
+
117
+ language_stats = client.get_all_language_stats(
118
+ repos_output=(
119
+ cfg.output_dir / "repositories.json" if cfg.save_repos and not stdout else None
120
+ ),
121
+ stats_output=(
122
+ cfg.output_dir / "language_stats.json" if cfg.save_json and not stdout else None
123
+ ),
124
+ )
125
+
126
+ if not language_stats:
127
+ logger.error("No language statistics found, nothing to visualize")
128
+ raise typer.Exit(1)
129
+
130
+ if stdout:
131
+ print(json.dumps(language_stats, indent=2))
132
+ elif json_only:
133
+ stats_file = cfg.output_dir / "language_stats.json"
134
+
135
+ with stats_file.open("w") as f:
136
+ json.dump(language_stats, f, indent=2)
137
+
138
+ logger.success(f"Saved stats to {stats_file}")
139
+ else:
140
+ chart_title = title if title else "GitHub Language Stats"
141
+ generate_charts(language_stats, cfg, title=chart_title, output=output, fmt=fmt)
142
+
143
+ except typer.Exit:
144
+ raise
145
+ except Exception as e:
146
+ logger.exception(f"Something went wrong: {e}")
147
+ raise typer.Exit(1)
ghlang/cli/local.py ADDED
@@ -0,0 +1,183 @@
1
+ import json
2
+ from pathlib import Path
3
+ import sys
4
+
5
+ import typer
6
+
7
+ from ghlang.cli.utils import generate_charts
8
+ from ghlang.cloc_client import ClocClient
9
+ from ghlang.config import load_config
10
+ from ghlang.exceptions import ClocNotFoundError
11
+ from ghlang.exceptions import ConfigError
12
+ from ghlang.logging import logger
13
+ from ghlang.visualizers import normalize_language_stats
14
+
15
+
16
+ def local(
17
+ path: Path = typer.Argument(
18
+ ".",
19
+ exists=True,
20
+ file_okay=True,
21
+ dir_okay=True,
22
+ readable=True,
23
+ path_type=Path,
24
+ ),
25
+ config_path: Path | None = typer.Option(
26
+ None,
27
+ "--config",
28
+ help="Use a different config file",
29
+ exists=True,
30
+ dir_okay=False,
31
+ file_okay=True,
32
+ readable=True,
33
+ path_type=Path,
34
+ ),
35
+ output_dir: Path | None = typer.Option(
36
+ None,
37
+ "--output-dir",
38
+ help="Where to save the charts",
39
+ file_okay=False,
40
+ dir_okay=True,
41
+ writable=True,
42
+ path_type=Path,
43
+ ),
44
+ output: Path | None = typer.Option(
45
+ None,
46
+ "--output",
47
+ "-o",
48
+ help="Custom output path/filename",
49
+ path_type=Path,
50
+ ),
51
+ title: str | None = typer.Option(
52
+ None,
53
+ "--title",
54
+ "-t",
55
+ help="Custom chart title",
56
+ ),
57
+ top_n: int | None = typer.Option(
58
+ None,
59
+ "--top-n",
60
+ help="How many languages to show in the bar chart",
61
+ ),
62
+ json_only: bool = typer.Option(
63
+ False,
64
+ "--json-only",
65
+ help="Output JSON only, skip chart generation",
66
+ ),
67
+ stdout: bool = typer.Option(
68
+ False,
69
+ "--stdout",
70
+ help="Output stats to stdout instead of files (implies --json-only --quiet)",
71
+ ),
72
+ quiet: bool = typer.Option(
73
+ False,
74
+ "--quiet",
75
+ "-q",
76
+ help="Suppress log output (only show errors)",
77
+ ),
78
+ verbose: bool = typer.Option(
79
+ False,
80
+ "--verbose",
81
+ "-v",
82
+ help="Show more details",
83
+ ),
84
+ follow_links: bool = typer.Option(
85
+ False,
86
+ "--follow-links",
87
+ help="Follow symlinks when analyzing (unix only)",
88
+ ),
89
+ theme: str | None = typer.Option(
90
+ None,
91
+ "--theme",
92
+ help="Chart theme (default: light)",
93
+ ),
94
+ fmt: str | None = typer.Option(
95
+ None,
96
+ "--format",
97
+ "-f",
98
+ help="Output format, overrides --output extension (png or svg)",
99
+ ),
100
+ ) -> None:
101
+ """Analyze local files with cloc"""
102
+ if stdout:
103
+ quiet = True
104
+ json_only = True
105
+
106
+ logger.configure(verbose, quiet=quiet)
107
+
108
+ try:
109
+ cli_overrides = {
110
+ "output_dir": output_dir,
111
+ "top_n_languages": top_n,
112
+ "verbose": verbose or None,
113
+ "theme": theme,
114
+ }
115
+ cfg = load_config(config_path=config_path, cli_overrides=cli_overrides, require_token=False)
116
+
117
+ except ConfigError as e:
118
+ logger.error(str(e))
119
+ raise typer.Exit(1)
120
+
121
+ if follow_links and sys.platform == "win32":
122
+ logger.warning("--follow-links is not supported on Windows, ignoring")
123
+ follow_links = False
124
+
125
+ try:
126
+ cloc = ClocClient(ignored_dirs=cfg.ignored_dirs, follow_links=follow_links)
127
+
128
+ except ClocNotFoundError as e:
129
+ logger.error(str(e))
130
+ raise typer.Exit(1)
131
+
132
+ if not stdout:
133
+ cfg.output_dir.mkdir(parents=True, exist_ok=True)
134
+ logger.info(f"Saving to {cfg.output_dir}")
135
+
136
+ try:
137
+ detailed_stats = cloc.get_language_stats(
138
+ path,
139
+ stats_output=(
140
+ cfg.output_dir / "cloc_stats.json" if cfg.save_json and not stdout else None
141
+ ),
142
+ )
143
+ raw_stats = {
144
+ lang: data["code"]
145
+ for lang, data in detailed_stats.items()
146
+ if lang != "_summary" and data["code"] > 0
147
+ }
148
+ language_stats = normalize_language_stats(raw_stats)
149
+
150
+ if not language_stats:
151
+ logger.error("No code found to analyze, nothing to visualize")
152
+ raise typer.Exit(1)
153
+
154
+ if stdout:
155
+ print(json.dumps(language_stats, indent=2))
156
+ elif json_only:
157
+ stats_file = cfg.output_dir / "language_stats.json"
158
+
159
+ with stats_file.open("w") as f:
160
+ json.dump(language_stats, f, indent=2)
161
+
162
+ logger.success(f"Saved stats to {stats_file}")
163
+ else:
164
+ if title:
165
+ chart_title = title
166
+ else:
167
+ resolved = path.expanduser().resolve()
168
+ chart_title = f"Local: {resolved.name}"
169
+
170
+ generate_charts(
171
+ language_stats,
172
+ cfg,
173
+ colors_required=False,
174
+ title=chart_title,
175
+ output=output,
176
+ fmt=fmt,
177
+ )
178
+
179
+ except typer.Exit:
180
+ raise
181
+ except Exception as e:
182
+ logger.exception(f"Something went wrong: {e}")
183
+ raise typer.Exit(1)
ghlang/cli/utils.py ADDED
@@ -0,0 +1,82 @@
1
+ from pathlib import Path
2
+ from typing import TYPE_CHECKING
3
+
4
+ import typer
5
+
6
+ from ghlang.logging import logger
7
+ from ghlang.visualizers import generate_bar
8
+ from ghlang.visualizers import generate_pie
9
+ from ghlang.visualizers import load_github_colors
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from ghlang.config import Config
14
+
15
+
16
+ def generate_charts(
17
+ language_stats: dict[str, int],
18
+ cfg: "Config",
19
+ colors_required: bool = True,
20
+ title: str | None = None,
21
+ output: Path | None = None,
22
+ fmt: str | None = None,
23
+ ) -> None:
24
+ """Load colors and generate pie/bar charts with progress"""
25
+ with logger.progress() as progress:
26
+ task = progress.add_task("Generating charts", total=3)
27
+
28
+ progress.update(task, description="Loading language colors...")
29
+ colors_file = cfg.output_dir / "github_colors.json" if cfg.save_json else None
30
+ colors = load_github_colors(output_file=colors_file)
31
+ progress.advance(task)
32
+
33
+ if not colors:
34
+ if colors_required:
35
+ logger.error("Couldn't load GitHub colors, can't continue without them")
36
+ raise typer.Exit(1)
37
+
38
+ logger.warning("Couldn't load GitHub colors, charts will be gray")
39
+ colors = {}
40
+
41
+ # determine output format
42
+ # priority: --format > --output suffix > default png
43
+ if fmt:
44
+ if fmt not in ("png", "svg"):
45
+ logger.warning(f"We only support png and svg, not '{fmt}', using png instead")
46
+ suffix = ".png"
47
+ else:
48
+ suffix = f".{fmt}"
49
+ elif output and output.suffix:
50
+ suffix = output.suffix
51
+ else:
52
+ suffix = ".png"
53
+
54
+ if output:
55
+ if output.is_absolute():
56
+ parent = output.parent
57
+ elif str(output.parent) != ".":
58
+ parent = cfg.output_dir / output.parent
59
+ else:
60
+ parent = cfg.output_dir
61
+
62
+ stem = output.stem
63
+ pie_output = parent / f"{stem}_pie{suffix}"
64
+ bar_output = parent / f"{stem}_bar{suffix}"
65
+ else:
66
+ pie_output = cfg.output_dir / f"language_pie{suffix}"
67
+ bar_output = cfg.output_dir / f"language_bar{suffix}"
68
+
69
+ progress.update(task, description="Generating pie chart...")
70
+ generate_pie(language_stats, colors, pie_output, title=title, theme=cfg.theme)
71
+ progress.advance(task)
72
+
73
+ progress.update(task, description="Generating bar chart...")
74
+ generate_bar(
75
+ language_stats,
76
+ colors,
77
+ bar_output,
78
+ top_n=cfg.top_n_languages,
79
+ title=title,
80
+ theme=cfg.theme,
81
+ )
82
+ progress.advance(task)