sourcecode 0.26.0__py3-none-any.whl → 0.28.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.
- sourcecode/__init__.py +1 -1
- sourcecode/cli.py +310 -23
- sourcecode/telemetry/__init__.py +111 -0
- sourcecode/telemetry/config.py +67 -0
- sourcecode/telemetry/consent.py +63 -0
- sourcecode/telemetry/events.py +78 -0
- sourcecode/telemetry/filters.py +140 -0
- sourcecode/telemetry/transport.py +52 -0
- sourcecode-0.28.0.dist-info/METADATA +565 -0
- {sourcecode-0.26.0.dist-info → sourcecode-0.28.0.dist-info}/RECORD +13 -6
- sourcecode-0.28.0.dist-info/entry_points.txt +2 -0
- sourcecode-0.28.0.dist-info/licenses/LICENSE +186 -0
- sourcecode-0.26.0.dist-info/METADATA +0 -724
- sourcecode-0.26.0.dist-info/entry_points.txt +0 -2
- {sourcecode-0.26.0.dist-info → sourcecode-0.28.0.dist-info}/WHEEL +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/cli.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import time
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Any, Optional, cast
|
|
6
7
|
|
|
@@ -8,12 +9,173 @@ import typer
|
|
|
8
9
|
|
|
9
10
|
from sourcecode import __version__
|
|
10
11
|
|
|
12
|
+
_HELP = """\
|
|
13
|
+
Deterministic codebase context for AI coding agents.
|
|
14
|
+
|
|
15
|
+
[bold]Usage:[/bold]
|
|
16
|
+
sourcecode [dim]# analyze current directory[/dim]
|
|
17
|
+
sourcecode /path/to/repo [dim]# analyze specific path[/dim]
|
|
18
|
+
sourcecode --agent [dim]# structured output for AI agents[/dim]
|
|
19
|
+
|
|
20
|
+
[bold]Subcommands:[/bold]
|
|
21
|
+
prepare-context TASK [PATH] [dim]# task-specific context[/dim]
|
|
22
|
+
telemetry status|enable|disable
|
|
23
|
+
version
|
|
24
|
+
config
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# Known subcommand names — tokens matching these are routed as subcommands,
|
|
28
|
+
# not consumed as a repository path.
|
|
29
|
+
_SUBCOMMANDS: frozenset[str] = frozenset(
|
|
30
|
+
{"telemetry", "prepare-context", "version", "config", "analyze"}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Mutable container holding the path extracted by _preprocess_argv().
|
|
34
|
+
# Default "." means "current directory" when no path is given.
|
|
35
|
+
_detected_path: list[str] = ["."]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Options that take a value token — their next arg must not be treated as a path.
|
|
39
|
+
_OPTIONS_WITH_VALUE: frozenset[str] = frozenset({
|
|
40
|
+
"--format", "-f",
|
|
41
|
+
"--output", "-o",
|
|
42
|
+
"--graph-detail",
|
|
43
|
+
"--graph-edges",
|
|
44
|
+
"--max-nodes",
|
|
45
|
+
"--docs-depth",
|
|
46
|
+
"--depth",
|
|
47
|
+
"--git-depth",
|
|
48
|
+
"--git-days",
|
|
49
|
+
"--since",
|
|
50
|
+
"--path", "-p",
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _preprocess_args(args: list[str]) -> list[str]:
|
|
55
|
+
"""Extract a repository path token from an args list and store it in _detected_path.
|
|
56
|
+
|
|
57
|
+
Returns the modified args list (path token removed).
|
|
58
|
+
Correctly skips option values (e.g. ``yaml`` in ``--format yaml``).
|
|
59
|
+
If the first non-flag, non-value positional token is a known subcommand name,
|
|
60
|
+
args are returned unchanged so Click can dispatch the subcommand.
|
|
61
|
+
"""
|
|
62
|
+
result = list(args)
|
|
63
|
+
skip_next = False
|
|
64
|
+
for i, arg in enumerate(result):
|
|
65
|
+
if skip_next:
|
|
66
|
+
skip_next = False
|
|
67
|
+
continue
|
|
68
|
+
if arg.startswith("-"):
|
|
69
|
+
# Does this option consume the next token as its value?
|
|
70
|
+
flag_name = arg.split("=")[0]
|
|
71
|
+
if flag_name in _OPTIONS_WITH_VALUE and "=" not in arg:
|
|
72
|
+
skip_next = True
|
|
73
|
+
continue
|
|
74
|
+
if arg in _SUBCOMMANDS:
|
|
75
|
+
return result # known subcommand — leave for Click to dispatch
|
|
76
|
+
# First genuine positional: treat as repository path
|
|
77
|
+
_detected_path[0] = arg
|
|
78
|
+
result.pop(i)
|
|
79
|
+
return result
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _preprocess_argv() -> None:
|
|
84
|
+
"""Apply _preprocess_args to sys.argv in-place (used by main_entry)."""
|
|
85
|
+
import sys as _sys
|
|
86
|
+
modified = _preprocess_args(_sys.argv[1:])
|
|
87
|
+
_sys.argv = _sys.argv[:1] + modified
|
|
88
|
+
|
|
89
|
+
|
|
11
90
|
app = typer.Typer(
|
|
12
91
|
name="sourcecode",
|
|
13
|
-
help=
|
|
92
|
+
help=_HELP,
|
|
14
93
|
add_completion=False,
|
|
94
|
+
rich_markup_mode="rich",
|
|
95
|
+
no_args_is_help=False,
|
|
15
96
|
)
|
|
16
97
|
|
|
98
|
+
# ── Hook preprocessing into the Click command layer ───────────────────────────
|
|
99
|
+
# Typer's CliRunner (and app() itself) calls typer.main.get_command(app) to
|
|
100
|
+
# create a Click Group, then calls click_group.main(args=...).
|
|
101
|
+
# We patch get_command so the returned Click Group always preprocesses args —
|
|
102
|
+
# this covers both main_entry() (sys.argv path) and runner.invoke(app, args).
|
|
103
|
+
import typer.main as _typer_main_module # noqa: E402
|
|
104
|
+
|
|
105
|
+
_orig_get_command = _typer_main_module.get_command
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _get_command_with_preprocessing(typer_instance: Any) -> Any:
|
|
109
|
+
cmd = _orig_get_command(typer_instance)
|
|
110
|
+
if typer_instance is not app:
|
|
111
|
+
return cmd # only wrap the root app, not telemetry_app etc.
|
|
112
|
+
_orig_cmd_main = cmd.main
|
|
113
|
+
|
|
114
|
+
def _cmd_main(args: Optional[list[str]] = None, **kwargs: Any) -> Any:
|
|
115
|
+
if args is not None:
|
|
116
|
+
# CliRunner / programmatic call: preprocess the explicit args list.
|
|
117
|
+
_detected_path[0] = "."
|
|
118
|
+
args = _preprocess_args(list(args))
|
|
119
|
+
# args=None → Click reads sys.argv; _preprocess_argv() in main_entry handled it.
|
|
120
|
+
return _orig_cmd_main(args=args, **kwargs)
|
|
121
|
+
|
|
122
|
+
cmd.main = _cmd_main
|
|
123
|
+
return cmd
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
_typer_main_module.get_command = _get_command_with_preprocessing
|
|
127
|
+
|
|
128
|
+
# typer.testing imports get_command as a private alias _get_command at module
|
|
129
|
+
# load time; patch that reference too so CliRunner.invoke uses our version.
|
|
130
|
+
try:
|
|
131
|
+
import typer.testing as _typer_testing_module
|
|
132
|
+
_typer_testing_module._get_command = _get_command_with_preprocessing # type: ignore[attr-defined]
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
telemetry_app = typer.Typer(help="Manage anonymous telemetry (opt-in).", rich_markup_mode="rich")
|
|
137
|
+
app.add_typer(telemetry_app, name="telemetry")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _maybe_ask_consent() -> None:
|
|
141
|
+
"""Show first-run consent prompt once, on interactive TTYs only."""
|
|
142
|
+
try:
|
|
143
|
+
from sourcecode.telemetry.config import has_been_asked, mark_asked, set_enabled
|
|
144
|
+
from sourcecode.telemetry.consent import ask_for_consent
|
|
145
|
+
if not has_been_asked():
|
|
146
|
+
enabled = ask_for_consent()
|
|
147
|
+
set_enabled(enabled)
|
|
148
|
+
if enabled:
|
|
149
|
+
typer.echo("Telemetry enabled. Thank you. Disable: sourcecode telemetry disable", err=True)
|
|
150
|
+
else:
|
|
151
|
+
typer.echo("Telemetry disabled. Enable anytime: sourcecode telemetry enable", err=True)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _active_flags(
|
|
157
|
+
dependencies: bool, graph_modules: bool, docs: bool, full_metrics: bool,
|
|
158
|
+
semantics: bool, architecture: bool, git_context: bool, env_map: bool,
|
|
159
|
+
code_notes: bool, agent: bool, compact: bool, tree: bool, no_redact: bool,
|
|
160
|
+
fmt: str,
|
|
161
|
+
) -> list[str]:
|
|
162
|
+
flags: list[str] = []
|
|
163
|
+
if agent: flags.append("--agent")
|
|
164
|
+
if compact: flags.append("--compact")
|
|
165
|
+
if dependencies: flags.append("--dependencies")
|
|
166
|
+
if graph_modules: flags.append("--graph-modules")
|
|
167
|
+
if docs: flags.append("--docs")
|
|
168
|
+
if full_metrics: flags.append("--full-metrics")
|
|
169
|
+
if semantics: flags.append("--semantics")
|
|
170
|
+
if architecture: flags.append("--architecture")
|
|
171
|
+
if git_context: flags.append("--git-context")
|
|
172
|
+
if env_map: flags.append("--env-map")
|
|
173
|
+
if code_notes: flags.append("--code-notes")
|
|
174
|
+
if tree: flags.append("--tree")
|
|
175
|
+
if no_redact: flags.append("--no-redact")
|
|
176
|
+
if fmt != "json": flags.append("--format")
|
|
177
|
+
return flags
|
|
178
|
+
|
|
17
179
|
FORMAT_CHOICES = ["json", "yaml"]
|
|
18
180
|
GRAPH_DETAIL_CHOICES = ["high", "medium", "full"]
|
|
19
181
|
GRAPH_EDGE_CHOICES = {"imports", "calls", "contains", "extends"}
|
|
@@ -29,7 +191,6 @@ def version_callback(value: bool) -> None:
|
|
|
29
191
|
@app.callback(invoke_without_command=True)
|
|
30
192
|
def main(
|
|
31
193
|
ctx: typer.Context,
|
|
32
|
-
path: Path = typer.Argument(Path("."), help="Directorio a analizar (default: directorio actual)"),
|
|
33
194
|
format: str = typer.Option(
|
|
34
195
|
"json",
|
|
35
196
|
"--format",
|
|
@@ -167,11 +328,25 @@ def main(
|
|
|
167
328
|
help="Modo agente: output estructurado y sin ruido para consumo por IA. Incluye identidad, entrypoints, arquitectura, dependencias clave, señales operacionales y gaps. Sin arbol de ficheros ni secciones vacias.",
|
|
168
329
|
),
|
|
169
330
|
) -> None:
|
|
170
|
-
"""
|
|
171
|
-
|
|
331
|
+
"""Analyze a repository and produce structured context for AI coding agents.
|
|
332
|
+
|
|
333
|
+
\b
|
|
334
|
+
Examples:
|
|
335
|
+
sourcecode analyze current directory
|
|
336
|
+
sourcecode /path/to/repo analyze specific path
|
|
337
|
+
sourcecode --agent agent-optimized output
|
|
338
|
+
sourcecode --agent --git-context include git activity signals
|
|
339
|
+
"""
|
|
340
|
+
# First-run consent (skip for telemetry/version/config subcommands)
|
|
341
|
+
if ctx.invoked_subcommand not in ("telemetry", "version", "config"):
|
|
342
|
+
_maybe_ask_consent()
|
|
343
|
+
|
|
344
|
+
# When a subcommand is invoked, skip the main analysis.
|
|
172
345
|
if ctx.invoked_subcommand is not None:
|
|
173
346
|
return
|
|
174
347
|
|
|
348
|
+
_t0 = time.monotonic()
|
|
349
|
+
|
|
175
350
|
# Validar formato
|
|
176
351
|
if format not in FORMAT_CHOICES:
|
|
177
352
|
typer.echo(
|
|
@@ -192,13 +367,13 @@ def main(
|
|
|
192
367
|
)
|
|
193
368
|
raise typer.Exit(code=1)
|
|
194
369
|
|
|
195
|
-
#
|
|
196
|
-
target =
|
|
370
|
+
# Path was extracted from argv by _preprocess_argv() before Click ran.
|
|
371
|
+
target = Path(_detected_path[0]).resolve()
|
|
197
372
|
if not target.exists():
|
|
198
|
-
typer.echo(f"Error:
|
|
373
|
+
typer.echo(f"Error: directory '{target}' does not exist.", err=True)
|
|
199
374
|
raise typer.Exit(code=1)
|
|
200
375
|
if not target.is_dir():
|
|
201
|
-
typer.echo(f"Error: '{target}'
|
|
376
|
+
typer.echo(f"Error: '{target}' is not a directory.", err=True)
|
|
202
377
|
raise typer.Exit(code=1)
|
|
203
378
|
|
|
204
379
|
# --- Importar modulos de logica ---
|
|
@@ -672,7 +847,26 @@ def main(
|
|
|
672
847
|
else:
|
|
673
848
|
content = json.dumps(raw_dict, indent=2, ensure_ascii=False)
|
|
674
849
|
|
|
675
|
-
# 5.
|
|
850
|
+
# 5. Telemetry (fire-and-forget, never blocks)
|
|
851
|
+
try:
|
|
852
|
+
from sourcecode import telemetry as _tel
|
|
853
|
+
_tel.record(
|
|
854
|
+
"execution_completed",
|
|
855
|
+
cmd="analyze",
|
|
856
|
+
flags=_active_flags(
|
|
857
|
+
dependencies, graph_modules, docs, full_metrics,
|
|
858
|
+
semantics, architecture, git_context, env_map,
|
|
859
|
+
code_notes, agent, compact, tree, no_redact, format,
|
|
860
|
+
),
|
|
861
|
+
output_fmt=format,
|
|
862
|
+
file_count=len(sm.file_paths),
|
|
863
|
+
duration_s=time.monotonic() - _t0,
|
|
864
|
+
success=True,
|
|
865
|
+
)
|
|
866
|
+
except Exception:
|
|
867
|
+
pass
|
|
868
|
+
|
|
869
|
+
# 6. Escribir output (CLI-04)
|
|
676
870
|
write_output(content, output=output)
|
|
677
871
|
|
|
678
872
|
|
|
@@ -682,15 +876,14 @@ def prepare_context_cmd(
|
|
|
682
876
|
None,
|
|
683
877
|
help="Task: explain | fix-bug | refactor | generate-tests | onboard | review-pr | delta",
|
|
684
878
|
),
|
|
685
|
-
path: Path = typer.
|
|
879
|
+
path: Path = typer.Argument(
|
|
686
880
|
Path("."),
|
|
687
|
-
"
|
|
688
|
-
help="Project directory to analyze (default: current directory)",
|
|
881
|
+
help="Repository path to analyze (default: current directory)",
|
|
689
882
|
),
|
|
690
883
|
since: Optional[str] = typer.Option(
|
|
691
884
|
None,
|
|
692
885
|
"--since",
|
|
693
|
-
help="Git ref for delta task
|
|
886
|
+
help="Git ref for delta task (e.g. HEAD~3, main)",
|
|
694
887
|
),
|
|
695
888
|
llm_prompt: bool = typer.Option(
|
|
696
889
|
False,
|
|
@@ -708,25 +901,26 @@ def prepare_context_cmd(
|
|
|
708
901
|
help="Show what would be analyzed without running it",
|
|
709
902
|
),
|
|
710
903
|
) -> None:
|
|
711
|
-
"""
|
|
904
|
+
"""Task-specific context for AI coding agents.
|
|
712
905
|
|
|
713
906
|
\b
|
|
714
907
|
Tasks:
|
|
715
|
-
explain
|
|
716
|
-
fix-bug Risk-ranked files, suspected areas,
|
|
908
|
+
explain Architecture, entry points, key dependencies
|
|
909
|
+
fix-bug Risk-ranked files, suspected areas, annotations
|
|
717
910
|
refactor Structural issues, improvement opportunities
|
|
718
911
|
generate-tests Untested source files, test gap analysis
|
|
719
|
-
onboard Full project context for
|
|
720
|
-
review-pr
|
|
912
|
+
onboard Full project context for new agents/developers
|
|
913
|
+
review-pr Changed files + architectural impact
|
|
721
914
|
delta Incremental context: git-changed files only
|
|
722
915
|
|
|
723
916
|
\b
|
|
724
917
|
Examples:
|
|
725
|
-
sourcecode
|
|
726
|
-
sourcecode
|
|
727
|
-
sourcecode
|
|
728
|
-
sourcecode
|
|
729
|
-
sourcecode
|
|
918
|
+
sourcecode prepare-context explain
|
|
919
|
+
sourcecode prepare-context explain /path/to/repo
|
|
920
|
+
sourcecode prepare-context fix-bug
|
|
921
|
+
sourcecode prepare-context delta --since main
|
|
922
|
+
sourcecode prepare-context onboard --llm-prompt
|
|
923
|
+
sourcecode prepare-context --task-help
|
|
730
924
|
"""
|
|
731
925
|
from sourcecode.prepare_context import TASKS, TaskContextBuilder
|
|
732
926
|
|
|
@@ -804,3 +998,96 @@ def prepare_context_cmd(
|
|
|
804
998
|
out["llm_prompt"] = builder.render_prompt(output)
|
|
805
999
|
|
|
806
1000
|
typer.echo(json.dumps(out, indent=2, ensure_ascii=False))
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
# ── Telemetry commands ────────────────────────────────────────────────────────
|
|
1004
|
+
|
|
1005
|
+
@telemetry_app.command("status")
|
|
1006
|
+
def telemetry_status() -> None:
|
|
1007
|
+
"""Show current telemetry setting."""
|
|
1008
|
+
from sourcecode.telemetry.config import config_file_path, has_been_asked, is_enabled
|
|
1009
|
+
enabled = is_enabled()
|
|
1010
|
+
asked = has_been_asked()
|
|
1011
|
+
status = "enabled" if enabled else "disabled"
|
|
1012
|
+
typer.echo(f"Telemetry: {status}")
|
|
1013
|
+
if not asked:
|
|
1014
|
+
typer.echo(" (consent not yet shown — will prompt on next run)")
|
|
1015
|
+
typer.echo(f" Config: {config_file_path()}")
|
|
1016
|
+
typer.echo(" Disable permanently: sourcecode telemetry disable")
|
|
1017
|
+
typer.echo(" Or set env var: SOURCECODE_TELEMETRY=0")
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
@telemetry_app.command("enable")
|
|
1021
|
+
def telemetry_enable() -> None:
|
|
1022
|
+
"""Opt in to anonymous telemetry."""
|
|
1023
|
+
from sourcecode.telemetry.config import set_enabled
|
|
1024
|
+
from sourcecode import telemetry as _tel
|
|
1025
|
+
set_enabled(True)
|
|
1026
|
+
typer.echo("Telemetry enabled. Thank you — this helps improve sourcecode.")
|
|
1027
|
+
typer.echo("What is collected: version, OS, commands, flags, duration, repo size range, errors.")
|
|
1028
|
+
typer.echo("What is never collected: source code, paths, secrets, or any output content.")
|
|
1029
|
+
typer.echo("Disable at any time: sourcecode telemetry disable")
|
|
1030
|
+
_tel.record("telemetry_enabled", cmd="telemetry")
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
@telemetry_app.command("disable")
|
|
1034
|
+
def telemetry_disable() -> None:
|
|
1035
|
+
"""Opt out of anonymous telemetry."""
|
|
1036
|
+
from sourcecode.telemetry.config import set_enabled
|
|
1037
|
+
set_enabled(False)
|
|
1038
|
+
typer.echo("Telemetry disabled. No data will be collected or sent.")
|
|
1039
|
+
typer.echo("Re-enable at any time: sourcecode telemetry enable")
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
# ── version ───────────────────────────────────────────────────────────────────
|
|
1043
|
+
|
|
1044
|
+
@app.command("version")
|
|
1045
|
+
def version_cmd() -> None:
|
|
1046
|
+
"""Show version and exit."""
|
|
1047
|
+
typer.echo(f"sourcecode {__version__}")
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
# ── config ────────────────────────────────────────────────────────────────────
|
|
1051
|
+
|
|
1052
|
+
@app.command("config")
|
|
1053
|
+
def config_cmd() -> None:
|
|
1054
|
+
"""Show current configuration."""
|
|
1055
|
+
from sourcecode.telemetry.config import config_file_path, is_enabled
|
|
1056
|
+
typer.echo(f"sourcecode {__version__}")
|
|
1057
|
+
typer.echo(f"Config: {config_file_path()}")
|
|
1058
|
+
typer.echo(f"Telemetry: {'enabled' if is_enabled() else 'disabled'}")
|
|
1059
|
+
typer.echo("")
|
|
1060
|
+
typer.echo("Manage telemetry:")
|
|
1061
|
+
typer.echo(" sourcecode telemetry enable")
|
|
1062
|
+
typer.echo(" sourcecode telemetry disable")
|
|
1063
|
+
typer.echo(" sourcecode telemetry status")
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
# ── analyze (legacy alias) ────────────────────────────────────────────────────
|
|
1067
|
+
|
|
1068
|
+
@app.command("analyze", hidden=True)
|
|
1069
|
+
def analyze_cmd(
|
|
1070
|
+
path: Path = typer.Argument(Path("."), help="Repository path to analyze"),
|
|
1071
|
+
) -> None:
|
|
1072
|
+
"""[deprecated] Use: sourcecode [PATH]"""
|
|
1073
|
+
typer.echo(
|
|
1074
|
+
"Warning: 'analyze' subcommand is deprecated.\n"
|
|
1075
|
+
"Use: sourcecode .\n"
|
|
1076
|
+
" sourcecode /path/to/repo",
|
|
1077
|
+
err=True,
|
|
1078
|
+
)
|
|
1079
|
+
raise typer.Exit(code=1)
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
1083
|
+
|
|
1084
|
+
def main_entry() -> None:
|
|
1085
|
+
"""CLI entry point.
|
|
1086
|
+
|
|
1087
|
+
Calls _preprocess_argv() before Typer/Click parses sys.argv so that
|
|
1088
|
+
repository path tokens are extracted before Click's Group callback
|
|
1089
|
+
can consume them as positional arguments (which would prevent subcommand
|
|
1090
|
+
routing for tokens like 'version' or 'config').
|
|
1091
|
+
"""
|
|
1092
|
+
_preprocess_argv()
|
|
1093
|
+
app()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""sourcecode telemetry — opt-in anonymous usage metrics.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
is_enabled() → bool
|
|
5
|
+
record(event, **kw) → None (fire-and-forget)
|
|
6
|
+
session_id() → str (ephemeral 8-char hex, new each process)
|
|
7
|
+
|
|
8
|
+
Telemetry is strictly opt-in. It is disabled by default and can be disabled
|
|
9
|
+
at any time via `sourcecode telemetry disable` or SOURCECODE_TELEMETRY=0.
|
|
10
|
+
|
|
11
|
+
Nothing sensitive (code, paths, secrets, output) is ever collected.
|
|
12
|
+
See docs/privacy.md for full details.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import platform
|
|
18
|
+
import sys
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
from sourcecode.telemetry.config import is_enabled
|
|
23
|
+
from sourcecode.telemetry.events import (
|
|
24
|
+
TelemetryEvent,
|
|
25
|
+
duration_bucket,
|
|
26
|
+
file_count_bucket,
|
|
27
|
+
)
|
|
28
|
+
from sourcecode.telemetry.filters import sanitize
|
|
29
|
+
from sourcecode.telemetry.transport import send
|
|
30
|
+
|
|
31
|
+
# Ephemeral session identifier — new random value each process start.
|
|
32
|
+
# 8 hex chars, never persisted, used only to correlate events within one run.
|
|
33
|
+
_SESSION: str = uuid.uuid4().hex[:8]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def session_id() -> str:
|
|
37
|
+
return _SESSION
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _platform_os() -> str:
|
|
41
|
+
system = platform.system().lower()
|
|
42
|
+
if system == "darwin":
|
|
43
|
+
return "macos"
|
|
44
|
+
if system in ("linux", "windows"):
|
|
45
|
+
return system
|
|
46
|
+
return "other"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _platform_arch() -> str:
|
|
50
|
+
machine = platform.machine().lower()
|
|
51
|
+
if machine in ("x86_64", "amd64", "i386", "i686"):
|
|
52
|
+
return "x64"
|
|
53
|
+
if machine in ("arm64", "aarch64", "armv8l"):
|
|
54
|
+
return "arm64"
|
|
55
|
+
return "other"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _python_version() -> str:
|
|
59
|
+
return f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _sourcecode_version() -> str:
|
|
63
|
+
try:
|
|
64
|
+
from sourcecode import __version__
|
|
65
|
+
return __version__
|
|
66
|
+
except Exception:
|
|
67
|
+
return "unknown"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def record(
|
|
71
|
+
event: str,
|
|
72
|
+
*,
|
|
73
|
+
cmd: str = "analyze",
|
|
74
|
+
flags: Optional[list[str]] = None,
|
|
75
|
+
output_fmt: str = "json",
|
|
76
|
+
file_count: Optional[int] = None,
|
|
77
|
+
duration_s: Optional[float] = None,
|
|
78
|
+
success: bool = True,
|
|
79
|
+
error_kind: Optional[str] = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Record a telemetry event. Fire-and-forget — never blocks or raises.
|
|
82
|
+
|
|
83
|
+
All data is privacy-filtered before transmission. If telemetry is disabled,
|
|
84
|
+
this function returns immediately without doing anything.
|
|
85
|
+
"""
|
|
86
|
+
if not is_enabled():
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
ev = TelemetryEvent(
|
|
91
|
+
event=event,
|
|
92
|
+
v=_sourcecode_version(),
|
|
93
|
+
py=_python_version(),
|
|
94
|
+
os=_platform_os(),
|
|
95
|
+
arch=_platform_arch(),
|
|
96
|
+
cmd=cmd,
|
|
97
|
+
flags=flags or [],
|
|
98
|
+
output_fmt=output_fmt,
|
|
99
|
+
repo_size=file_count_bucket(file_count) if file_count is not None else "unknown",
|
|
100
|
+
duration=duration_bucket(duration_s) if duration_s is not None else "unknown",
|
|
101
|
+
success=success,
|
|
102
|
+
error_kind=error_kind,
|
|
103
|
+
session=_SESSION,
|
|
104
|
+
)
|
|
105
|
+
payload = sanitize(ev)
|
|
106
|
+
send(payload)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass # telemetry must never affect the main process
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["is_enabled", "record", "session_id"]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Persistent telemetry configuration.
|
|
2
|
+
|
|
3
|
+
Config file: ~/.config/sourcecode/config.json
|
|
4
|
+
Env override: SOURCECODE_TELEMETRY=0 (disable) or =1 (enable)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
_ENV_VAR = "SOURCECODE_TELEMETRY"
|
|
15
|
+
_CONFIG_FILE = Path.home() / ".config" / "sourcecode" / "config.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load() -> dict[str, Any]:
|
|
19
|
+
try:
|
|
20
|
+
return json.loads(_CONFIG_FILE.read_text(encoding="utf-8")) # type: ignore[no-any-return]
|
|
21
|
+
except Exception:
|
|
22
|
+
return {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _save(data: dict[str, Any]) -> None:
|
|
26
|
+
try:
|
|
27
|
+
_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
_CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
29
|
+
except Exception:
|
|
30
|
+
pass # config write failure is non-fatal
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_enabled() -> bool:
|
|
34
|
+
"""True only when telemetry is explicitly opted in.
|
|
35
|
+
|
|
36
|
+
Env var takes absolute precedence over config file.
|
|
37
|
+
Default is always disabled — telemetry is strictly opt-in.
|
|
38
|
+
"""
|
|
39
|
+
env = os.environ.get(_ENV_VAR, "").strip()
|
|
40
|
+
if env == "0":
|
|
41
|
+
return False
|
|
42
|
+
if env == "1":
|
|
43
|
+
return True
|
|
44
|
+
return bool(_load().get("telemetry", {}).get("enabled", False))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def has_been_asked() -> bool:
|
|
48
|
+
"""True if the consent prompt has already been shown."""
|
|
49
|
+
return bool(_load().get("telemetry", {}).get("asked", False))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def set_enabled(value: bool) -> None:
|
|
53
|
+
data = _load()
|
|
54
|
+
data.setdefault("telemetry", {})["enabled"] = value
|
|
55
|
+
data["telemetry"]["asked"] = True
|
|
56
|
+
_save(data)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def mark_asked() -> None:
|
|
60
|
+
"""Record that the consent prompt was shown (regardless of answer)."""
|
|
61
|
+
data = _load()
|
|
62
|
+
data.setdefault("telemetry", {})["asked"] = True
|
|
63
|
+
_save(data)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def config_file_path() -> Path:
|
|
67
|
+
return _CONFIG_FILE
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""First-run consent prompt.
|
|
2
|
+
|
|
3
|
+
Shown exactly once, only on interactive TTYs, only when the user hasn't been
|
|
4
|
+
asked before. Default answer is NO — the user must explicitly type 'y' to opt in.
|
|
5
|
+
|
|
6
|
+
Prompt is written to stderr so it doesn't pollute stdout output.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
_PROMPT = """\
|
|
15
|
+
\033[2m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
16
|
+
sourcecode — optional anonymous telemetry
|
|
17
|
+
|
|
18
|
+
Help improve sourcecode by sharing anonymous usage metrics.
|
|
19
|
+
|
|
20
|
+
Collected: tool version, Python version, OS, commands used,
|
|
21
|
+
flags used, approximate repo size, execution duration, errors.
|
|
22
|
+
|
|
23
|
+
Never collected: source code, file paths, file names, secrets,
|
|
24
|
+
tokens, environment variables, or any repository content.
|
|
25
|
+
|
|
26
|
+
You can change this at any time:
|
|
27
|
+
sourcecode telemetry disable
|
|
28
|
+
export SOURCECODE_TELEMETRY=0
|
|
29
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_interactive() -> bool:
|
|
34
|
+
"""True when running in an interactive terminal (not CI, not piped)."""
|
|
35
|
+
if not sys.stdin.isatty() or not sys.stderr.isatty():
|
|
36
|
+
return False
|
|
37
|
+
# Common CI environment variables
|
|
38
|
+
ci_vars = {
|
|
39
|
+
"CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", "CIRCLECI",
|
|
40
|
+
"TRAVIS", "JENKINS_URL", "BUILDKITE", "GITLAB_CI", "TF_BUILD",
|
|
41
|
+
"TEAMCITY_VERSION", "DRONE", "SEMAPHORE",
|
|
42
|
+
}
|
|
43
|
+
return not any(os.environ.get(v) for v in ci_vars)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ask_for_consent() -> bool:
|
|
47
|
+
"""Show the consent prompt and return the user's choice.
|
|
48
|
+
|
|
49
|
+
Returns True if the user opted in, False otherwise.
|
|
50
|
+
Never raises. Default (Enter / non-y input) is False.
|
|
51
|
+
"""
|
|
52
|
+
if not _is_interactive():
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
sys.stderr.write(_PROMPT)
|
|
57
|
+
sys.stderr.write(" Enable anonymous telemetry? [y/N]: ")
|
|
58
|
+
sys.stderr.flush()
|
|
59
|
+
answer = sys.stdin.readline().strip().lower()
|
|
60
|
+
sys.stderr.write("\n")
|
|
61
|
+
return answer == "y"
|
|
62
|
+
except Exception:
|
|
63
|
+
return False
|