olink 0.1.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.
olink/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """olink - Open external URLs related to your project."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
olink/cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """CLI interface for olink."""
2
+
3
+ from olink.cli.app import app, main
4
+
5
+ __all__ = ["app", "main"]
olink/cli/app.py ADDED
@@ -0,0 +1,132 @@
1
+ """CLI interface for olink."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from olink import __version__
9
+ from olink.core.catalog import get_target, list_available_targets, list_targets
10
+ from olink.core.exceptions import OlinkError
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _TUI_OPTIONAL_DEPS = frozenset({"olink.tui", "textual", "pyperclip"})
15
+
16
+ app = typer.Typer(
17
+ name="olink",
18
+ help="Open external URLs related to your project.",
19
+ no_args_is_help=False,
20
+ )
21
+
22
+
23
+ def _version_callback(value: bool) -> None:
24
+ if value:
25
+ typer.echo(f"olink {__version__}")
26
+ raise typer.Exit(0)
27
+
28
+
29
+ @app.callback(invoke_without_command=True)
30
+ def main_callback(
31
+ target: str | None = typer.Argument(
32
+ None,
33
+ help="Target to open (e.g. origin, issues, pypi, npm, crates, and more — use --list-all to see all)",
34
+ ),
35
+ directory: str | None = typer.Option(
36
+ None,
37
+ "--directory",
38
+ "-d",
39
+ help="Project directory (defaults to current directory)",
40
+ ),
41
+ dry_run: bool = typer.Option(
42
+ False,
43
+ "--dry-run",
44
+ "-n",
45
+ help="Print URL without opening it",
46
+ ),
47
+ list_available_flag: bool = typer.Option(
48
+ False,
49
+ "--list",
50
+ "-l",
51
+ help="List targets available for current project",
52
+ ),
53
+ list_all_flag: bool = typer.Option(
54
+ False,
55
+ "--list-all",
56
+ "-a",
57
+ help="List all targets",
58
+ ),
59
+ _version: bool = typer.Option(
60
+ False,
61
+ "--version",
62
+ "-V",
63
+ help="Show olink version and exit",
64
+ callback=_version_callback,
65
+ is_eager=True,
66
+ ),
67
+ ) -> None:
68
+ """Open external URLs related to your project."""
69
+ cwd = directory or str(Path.cwd())
70
+
71
+ cwd_path = Path(cwd)
72
+ if not cwd_path.exists():
73
+ typer.echo(f"Error: Directory does not exist: {cwd}", err=True)
74
+ raise typer.Exit(1)
75
+ if not cwd_path.is_dir():
76
+ typer.echo(f"Error: Not a directory: {cwd}", err=True)
77
+ raise typer.Exit(1)
78
+
79
+ if list_available_flag:
80
+ available = list_available_targets(cwd)
81
+
82
+ if available:
83
+ typer.echo("Available targets for this project:\n")
84
+ for name, description, _, _ in available:
85
+ typer.echo(f" {name:16} - {description}")
86
+ typer.echo(f"\n({len(available)} targets available)")
87
+ else:
88
+ typer.echo("No targets available for this project.")
89
+ raise typer.Exit(0)
90
+
91
+ if list_all_flag:
92
+ typer.echo("All targets:\n")
93
+ for name, description in list_targets():
94
+ typer.echo(f" {name:16} - {description}")
95
+ raise typer.Exit(0)
96
+
97
+ if target is None:
98
+ try:
99
+ from olink.tui import launch_tui # pylint: disable=import-outside-toplevel
100
+ except ImportError as e:
101
+ if e.name not in _TUI_OPTIONAL_DEPS:
102
+ raise
103
+ typer.echo(
104
+ "Error: TUI requires extra dependencies. Install with: "
105
+ "pip install olink[tui] (or: uv tool install 'olink[tui]')",
106
+ err=True,
107
+ )
108
+ raise typer.Exit(1) from None
109
+
110
+ try:
111
+ launch_tui(cwd)
112
+ except KeyboardInterrupt, SystemExit:
113
+ pass
114
+ raise typer.Exit(0)
115
+
116
+ try:
117
+ target_instance = get_target(target)
118
+ url = target_instance.get_url(cwd)
119
+
120
+ if dry_run:
121
+ typer.echo(url)
122
+ else:
123
+ typer.echo(f"Opening: {url}")
124
+ typer.launch(url)
125
+ except OlinkError as e:
126
+ typer.echo(f"Error: {e}", err=True)
127
+ raise typer.Exit(1) from e
128
+
129
+
130
+ def main() -> None:
131
+ """Entry point for the CLI."""
132
+ app()
olink/core/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Core functionality for olink."""
2
+
3
+ from olink.core.catalog import REGISTRY, get_target, list_targets
4
+ from olink.core.exceptions import (
5
+ NoRemoteError,
6
+ NotGitRepoError,
7
+ OlinkError,
8
+ ProjectMetadataError,
9
+ UnknownPlatformError,
10
+ UnknownTargetError,
11
+ UnsupportedFeatureError,
12
+ )
13
+ from olink.core.targets import GitPageTarget, MultiEcosystemTarget, Target
14
+
15
+ __all__ = [
16
+ # Targets
17
+ "Target",
18
+ "GitPageTarget",
19
+ "MultiEcosystemTarget",
20
+ "REGISTRY",
21
+ "get_target",
22
+ "list_targets",
23
+ # Exceptions
24
+ "OlinkError",
25
+ "NotGitRepoError",
26
+ "NoRemoteError",
27
+ "UnknownPlatformError",
28
+ "UnknownTargetError",
29
+ "ProjectMetadataError",
30
+ "UnsupportedFeatureError",
31
+ ]
olink/core/catalog.py ADDED
@@ -0,0 +1,225 @@
1
+ """Target catalog - explicit registration of all targets."""
2
+
3
+ import logging
4
+
5
+ from olink.core.exceptions import (
6
+ NoRemoteError,
7
+ NotGitRepoError,
8
+ ProjectMetadataError,
9
+ UnknownTargetError,
10
+ UnsupportedFeatureError,
11
+ )
12
+ from olink.core.project import detect_ecosystems
13
+ from olink.core.targets import (
14
+ ActionsTarget,
15
+ BranchesTarget,
16
+ BundlephobiaTarget,
17
+ ClickPyTarget,
18
+ # Service targets
19
+ CodecovTarget,
20
+ CommitsTarget,
21
+ CoverallsTarget,
22
+ CpanTarget,
23
+ # Rust
24
+ CratesTarget,
25
+ DepsDevTarget,
26
+ DiscussionsTarget,
27
+ DocsRsTarget,
28
+ EcosystemsTarget,
29
+ # Other ecosystems
30
+ GemsTarget,
31
+ GoDocsTarget,
32
+ # Go
33
+ GoPkgTarget,
34
+ HackageTarget,
35
+ HexTarget,
36
+ InspectorTarget,
37
+ IssuesTarget,
38
+ JsDelivrTarget,
39
+ LibrariesIOTarget,
40
+ LibRsTarget,
41
+ MavenTarget,
42
+ MultiEcosystemTarget,
43
+ NPMStatTarget,
44
+ # npm
45
+ NPMTarget,
46
+ NuGetTarget,
47
+ OpenVSXTarget,
48
+ # Git targets
49
+ OriginTarget,
50
+ PackagephobiaTarget,
51
+ PackagistTarget,
52
+ PePyTarget,
53
+ PipTrendsTarget,
54
+ PiWheelsTarget,
55
+ PubTarget,
56
+ PullsTarget,
57
+ PyPIJSONTarget,
58
+ PyPIStatsTarget,
59
+ # Python / PyPI
60
+ PyPITarget,
61
+ ReleasesTarget,
62
+ RubyGemsStatsTarget,
63
+ SafetyDBTarget,
64
+ SecurityTarget,
65
+ SkypackTarget,
66
+ # Multi-ecosystem
67
+ SnykTarget,
68
+ SocketTarget,
69
+ Target,
70
+ UnpkgTarget,
71
+ UpstreamTarget,
72
+ WikiTarget,
73
+ )
74
+
75
+ # Explicit registry of all available targets
76
+ REGISTRY: dict[str, type[Target]] = {
77
+ # Git targets
78
+ "origin": OriginTarget,
79
+ "upstream": UpstreamTarget,
80
+ "issues": IssuesTarget,
81
+ "pulls": PullsTarget,
82
+ "actions": ActionsTarget,
83
+ "wiki": WikiTarget,
84
+ "releases": ReleasesTarget,
85
+ "branches": BranchesTarget,
86
+ "commits": CommitsTarget,
87
+ "security": SecurityTarget,
88
+ "discussions": DiscussionsTarget,
89
+ # Python / PyPI targets
90
+ "pypi": PyPITarget,
91
+ "inspector": InspectorTarget,
92
+ "pypi-json": PyPIJSONTarget,
93
+ "pepy": PePyTarget,
94
+ "piwheels": PiWheelsTarget,
95
+ "pypistats": PyPIStatsTarget,
96
+ "piptrends": PipTrendsTarget,
97
+ "clickpy": ClickPyTarget,
98
+ "snyk": SnykTarget,
99
+ "safety-db": SafetyDBTarget,
100
+ # Multi-ecosystem targets
101
+ "libraries-io": LibrariesIOTarget,
102
+ "deps": DepsDevTarget,
103
+ "ecosystems": EcosystemsTarget,
104
+ "socket": SocketTarget,
105
+ # npm targets
106
+ "npm": NPMTarget,
107
+ "bundlephobia": BundlephobiaTarget,
108
+ "packagephobia": PackagephobiaTarget,
109
+ "npm-stat": NPMStatTarget,
110
+ "jsdelivr": JsDelivrTarget,
111
+ "unpkg": UnpkgTarget,
112
+ "skypack": SkypackTarget,
113
+ # Rust targets
114
+ "crates": CratesTarget,
115
+ "librs": LibRsTarget,
116
+ "docsrs": DocsRsTarget,
117
+ # Go targets
118
+ "pkg-go": GoPkgTarget,
119
+ "go-docs": GoDocsTarget,
120
+ # Other ecosystem targets
121
+ "gems": GemsTarget,
122
+ "rubygems-stats": RubyGemsStatsTarget,
123
+ "packagist": PackagistTarget,
124
+ "pub": PubTarget,
125
+ "hex": HexTarget,
126
+ "nuget": NuGetTarget,
127
+ "open-vsx": OpenVSXTarget,
128
+ "maven": MavenTarget,
129
+ "hackage": HackageTarget,
130
+ "cpan": CpanTarget,
131
+ # Service targets
132
+ "codecov": CodecovTarget,
133
+ "coveralls": CoverallsTarget,
134
+ }
135
+
136
+
137
+ def get_target(name: str) -> Target:
138
+ """Get a target instance by name.
139
+
140
+ Supports suffix notation for multi-ecosystem targets:
141
+ - "snyk" - auto-detect ecosystem
142
+ - "snyk:pypi" - explicit Python ecosystem
143
+ - "deps:npm" - explicit npm ecosystem
144
+ """
145
+ if ":" in name:
146
+ base_name, ecosystem = name.split(":", 1)
147
+ else:
148
+ base_name, ecosystem = name, None
149
+
150
+ if base_name not in REGISTRY:
151
+ available = ", ".join(sorted(REGISTRY.keys()))
152
+ raise UnknownTargetError(f"Unknown target: '{base_name}'. Available targets: {available}")
153
+
154
+ target_cls = REGISTRY[base_name]
155
+
156
+ if ecosystem is not None:
157
+ if not issubclass(target_cls, MultiEcosystemTarget):
158
+ raise UnknownTargetError(
159
+ f"Target '{base_name}' doesn't support ecosystem suffix. "
160
+ f"Use '{base_name}' without suffix."
161
+ )
162
+ supported = sorted(target_cls.ecosystem_url_map.keys())
163
+ if ecosystem not in target_cls.ecosystem_url_map:
164
+ raise UnknownTargetError(
165
+ f"Target '{base_name}' doesn't support ecosystem '{ecosystem}'. "
166
+ f"Supported: {', '.join(supported)}"
167
+ )
168
+ return target_cls(ecosystem=ecosystem)
169
+
170
+ return target_cls()
171
+
172
+
173
+ logger = logging.getLogger(__name__)
174
+
175
+ # Exceptions that mean "this target doesn't apply here" — expected and safe to skip.
176
+ UNAVAILABLE_ERRORS = (
177
+ NoRemoteError,
178
+ NotGitRepoError,
179
+ ProjectMetadataError,
180
+ UnsupportedFeatureError,
181
+ )
182
+
183
+
184
+ def list_targets() -> list[tuple[str, str]]:
185
+ """List all available targets with their descriptions."""
186
+ return [(name, target_cls.description) for name, target_cls in sorted(REGISTRY.items())]
187
+
188
+
189
+ def list_available_targets(
190
+ cwd: str,
191
+ ) -> list[tuple[str, str, type[Target], str | None]]:
192
+ """List targets available for the current project.
193
+
194
+ Returns (name, description, target_cls, ecosystem) tuples.
195
+ """
196
+ results: list[tuple[str, str, type[Target], str | None]] = []
197
+ detected_ecosystems = detect_ecosystems(cwd)
198
+
199
+ for name, target_cls in sorted(REGISTRY.items()):
200
+ if issubclass(target_cls, MultiEcosystemTarget):
201
+ supported = [e for e in detected_ecosystems if e in target_cls.ecosystem_url_map]
202
+ if not supported:
203
+ continue
204
+ if len(supported) == 1:
205
+ try:
206
+ target_cls(ecosystem=supported[0]).get_url(cwd)
207
+ desc = f"{target_cls.description} ({supported[0]})"
208
+ results.append((name, desc, target_cls, supported[0]))
209
+ except UNAVAILABLE_ERRORS as e:
210
+ logger.debug("Skipping %s: %s", name, e)
211
+ else:
212
+ for eco in sorted(supported):
213
+ try:
214
+ target_cls(ecosystem=eco).get_url(cwd)
215
+ results.append((f"{name}:{eco}", target_cls.description, target_cls, eco))
216
+ except UNAVAILABLE_ERRORS as e:
217
+ logger.debug("Skipping %s:%s: %s", name, eco, e)
218
+ else:
219
+ try:
220
+ target_cls().get_url(cwd)
221
+ results.append((name, target_cls.description, target_cls, None))
222
+ except UNAVAILABLE_ERRORS as e:
223
+ logger.debug("Skipping %s: %s", name, e)
224
+
225
+ return results
@@ -0,0 +1,29 @@
1
+ """Custom exceptions for olink."""
2
+
3
+
4
+ class OlinkError(Exception):
5
+ """Base exception for olink."""
6
+
7
+
8
+ class NotGitRepoError(OlinkError):
9
+ """Not inside a git repository."""
10
+
11
+
12
+ class NoRemoteError(OlinkError):
13
+ """No git remote configured."""
14
+
15
+
16
+ class UnknownPlatformError(OlinkError):
17
+ """Unknown git hosting platform."""
18
+
19
+
20
+ class UnknownTargetError(OlinkError):
21
+ """Unknown target specified."""
22
+
23
+
24
+ class ProjectMetadataError(OlinkError):
25
+ """Could not read project metadata."""
26
+
27
+
28
+ class UnsupportedFeatureError(OlinkError):
29
+ """Feature not available on this platform."""