proxy2vpn 0.1.3__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.
proxy2vpn/typer_ext.py ADDED
@@ -0,0 +1,379 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ from typing import List, Optional
5
+
6
+ from click.exceptions import UsageError
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+ import typer
12
+
13
+
14
+ class HelpfulTyper(typer.Typer):
15
+ """Typer app with smart error messages and suggestions."""
16
+
17
+ def __init__(self, *args, **kwargs) -> None:
18
+ ctx_settings = kwargs.setdefault("context_settings", {})
19
+ ctx_settings.setdefault("help_option_names", ["-h", "--help"])
20
+ super().__init__(*args, **kwargs)
21
+ self.console = Console(stderr=True)
22
+
23
+ def __call__(self, *args, **kwargs): # type: ignore[override]
24
+ kwargs.setdefault("standalone_mode", False)
25
+ try:
26
+ return super().__call__(*args, **kwargs)
27
+ except UsageError as exc:
28
+ self._handle_usage_error(exc)
29
+ raise SystemExit(2)
30
+ except Exception as exc:
31
+ # Handle other exceptions more gracefully
32
+ if "Missing argument" in str(exc) or "required" in str(exc).lower():
33
+ self.console.print("\n[red]Error:[/red] Missing required arguments")
34
+ self.console.print(
35
+ "[dim]Run the command with '--help' to see required arguments.[/dim]"
36
+ )
37
+ raise SystemExit(2)
38
+ raise
39
+
40
+ def _handle_usage_error(self, exc: UsageError) -> None:
41
+ """Handle usage errors with helpful messages."""
42
+ # Handle "Missing command" error for subcommand groups
43
+ if "Missing command" in exc.message:
44
+ self._show_missing_command_help(exc)
45
+ # Handle "No such command" error
46
+ elif "No such command" in exc.message:
47
+ self._show_no_such_command_help(exc)
48
+ else:
49
+ # For other errors, format them nicely
50
+ error_msg = exc.message.strip()
51
+
52
+ # Handle missing argument errors
53
+ if "Missing argument" in error_msg:
54
+ self._show_missing_argument_help(exc, error_msg)
55
+ # Handle missing option errors
56
+ elif "Missing option" in error_msg:
57
+ self._show_missing_option_help(exc, error_msg)
58
+ else:
59
+ # Generic error display
60
+ self.console.print(f"[red]Error:[/red] {error_msg}")
61
+ if exc.ctx:
62
+ self.console.print(
63
+ f"\nUse '[cyan]{exc.ctx.command_path} --help[/cyan]' for more information."
64
+ )
65
+
66
+ def _show_missing_argument_help(self, exc: UsageError, error_msg: str) -> None:
67
+ """Show help when required arguments are missing."""
68
+ import re
69
+
70
+ # Extract the argument name from the error message
71
+ match = re.search(r"'([^']+)'", error_msg)
72
+ arg_name = match.group(1) if match else "argument"
73
+
74
+ error_text = Text()
75
+ error_text.append("Missing required argument: ", style="red")
76
+ error_text.append(f"'{arg_name}'", style="yellow")
77
+
78
+ self.console.print()
79
+ self.console.print(error_text)
80
+
81
+ # Show command usage
82
+ if exc.ctx:
83
+ self.console.print(
84
+ f"\n[yellow]Usage:[/yellow] [cyan]{exc.ctx.command_path} {arg_name.upper()}[/cyan]"
85
+ )
86
+
87
+ # Show examples
88
+ examples = self._get_example_commands(exc.ctx.command_path, "")
89
+ if examples and len(examples) > 0:
90
+ self.console.print("\n[blue]Example:[/blue]")
91
+ self.console.print(f" [dim]$[/dim] [cyan]{examples[0]}[/cyan]")
92
+
93
+ self.console.print(
94
+ f"\n[dim]Use '[cyan]{exc.ctx.command_path} --help[/cyan]' for more information.[/dim]"
95
+ )
96
+
97
+ def _show_missing_option_help(self, exc: UsageError, error_msg: str) -> None:
98
+ """Show help when required options are missing."""
99
+ import re
100
+
101
+ # Extract the option name from the error message
102
+ match = re.search(r"'([^']+)'", error_msg)
103
+ option_name = match.group(1) if match else "option"
104
+
105
+ error_text = Text()
106
+ error_text.append("Missing required option: ", style="red")
107
+ error_text.append(f"'{option_name}'", style="yellow")
108
+
109
+ self.console.print()
110
+ self.console.print(error_text)
111
+
112
+ if exc.ctx:
113
+ self.console.print(
114
+ "\n[yellow]This option is required for this command.[/yellow]"
115
+ )
116
+
117
+ # Show example with the option
118
+ self.console.print("\n[blue]Example:[/blue]")
119
+ self.console.print(
120
+ f" [dim]$[/dim] [cyan]{exc.ctx.command_path} {option_name} <value>[/cyan]"
121
+ )
122
+
123
+ self.console.print(
124
+ f"\n[dim]Use '[cyan]{exc.ctx.command_path} --help[/cyan]' for more information.[/dim]"
125
+ )
126
+
127
+ def _show_missing_command_help(self, exc: UsageError) -> None:
128
+ """Show help when a subcommand is missing."""
129
+ if not exc.ctx:
130
+ self.console.print("[red]Error:[/red] Missing command.")
131
+ return
132
+
133
+ # Create a nice panel with available commands
134
+ command_path = exc.ctx.command_path
135
+ available_commands = exc.ctx.command.list_commands(exc.ctx) if exc.ctx else []
136
+
137
+ # Create a table of commands with their help text
138
+ table = Table(
139
+ show_header=True, header_style="bold cyan", box=None, padding=(0, 2)
140
+ )
141
+ table.add_column("Command", style="green")
142
+ table.add_column("Description")
143
+
144
+ # Get command descriptions
145
+ for cmd_name in available_commands:
146
+ cmd_obj = self._get_command_object(exc.ctx.command, cmd_name)
147
+ description = self._get_command_help(cmd_obj) if cmd_obj else ""
148
+ table.add_row(cmd_name, description)
149
+
150
+ # Create the error panel
151
+ error_text = Text()
152
+ error_text.append("Missing command for ", style="red")
153
+ error_text.append(f"'{command_path}'", style="yellow")
154
+
155
+ panel = Panel(
156
+ table,
157
+ title="[red]Error: Missing Command[/red]",
158
+ subtitle=f"[dim]Use '[cyan]{command_path} <command> --help[/cyan]' for details[/dim]",
159
+ border_style="red",
160
+ expand=False,
161
+ )
162
+
163
+ self.console.print()
164
+ self.console.print(error_text)
165
+ self.console.print()
166
+ self.console.print(panel)
167
+
168
+ def _show_no_such_command_help(self, exc: UsageError) -> None:
169
+ """Show help when a command doesn't exist."""
170
+ import re
171
+
172
+ match = re.search(r"'([^']+)'", exc.message)
173
+ bad_cmd = match.group(1) if match else exc.message
174
+ possibilities = exc.ctx.command.list_commands(exc.ctx) if exc.ctx else []
175
+
176
+ # Get close matches using different strategies
177
+ matches: List[str] = []
178
+
179
+ # 1. Standard fuzzy matching
180
+ fuzzy_matches = difflib.get_close_matches(bad_cmd, possibilities, cutoff=0.4)
181
+ matches.extend(fuzzy_matches)
182
+
183
+ # 2. Check for partial matches (prefix matching)
184
+ partial_matches = [
185
+ cmd for cmd in possibilities if cmd.startswith(bad_cmd.lower())
186
+ ]
187
+ for pm in partial_matches:
188
+ if pm not in matches:
189
+ matches.append(pm)
190
+
191
+ # 3. Check all command paths (including subcommands)
192
+ all_paths = self._all_command_paths()
193
+ more = difflib.get_close_matches(bad_cmd, all_paths, cutoff=0.4)
194
+ for m in more:
195
+ if m not in matches:
196
+ matches.append(m)
197
+
198
+ # 4. Check for common typos (transposed characters, missing letters)
199
+ typo_matches = self._find_typo_matches(bad_cmd, possibilities)
200
+ for tm in typo_matches:
201
+ if tm not in matches:
202
+ matches.append(tm)
203
+
204
+ # Create error message
205
+ error_text = Text()
206
+ error_text.append("No such command: ", style="red")
207
+ error_text.append(f"'{bad_cmd}'", style="yellow")
208
+
209
+ self.console.print()
210
+ self.console.print(error_text)
211
+
212
+ # Show suggestions if available
213
+ if matches:
214
+ self.console.print("\n[green]Did you mean one of these?[/green]")
215
+ for match in matches[:5]: # Limit to top 5 suggestions
216
+ self.console.print(f" • [cyan]{match}[/cyan]")
217
+
218
+ # Show available commands
219
+ if possibilities:
220
+ self.console.print("\n[yellow]Available commands:[/yellow]")
221
+ table = Table(show_header=False, box=None, padding=(0, 2))
222
+ table.add_column("Command", style="cyan")
223
+ table.add_column("Description")
224
+
225
+ for cmd_name in possibilities:
226
+ cmd_obj = self._get_command_object(exc.ctx.command, cmd_name)
227
+ description = self._get_command_help(cmd_obj) if cmd_obj else ""
228
+ table.add_row(cmd_name, description)
229
+
230
+ self.console.print(table)
231
+
232
+ # Show example commands if context-appropriate
233
+ if exc.ctx:
234
+ examples = self._get_example_commands(exc.ctx.command_path, bad_cmd)
235
+ if examples:
236
+ self.console.print("\n[blue]Example commands:[/blue]")
237
+ for example in examples:
238
+ self.console.print(f" [dim]$[/dim] [cyan]{example}[/cyan]")
239
+
240
+ # Show help command
241
+ if exc.ctx:
242
+ self.console.print(
243
+ f"\n[dim]Use '[cyan]{exc.ctx.command_path} --help[/cyan]' for more information.[/dim]"
244
+ )
245
+
246
+ # Add documentation link if available
247
+ doc_link = self._get_documentation_link(exc.ctx.command_path)
248
+ if doc_link:
249
+ self.console.print(f"[dim]Documentation: [link]{doc_link}[/link][/dim]")
250
+
251
+ def _get_command_object(self, parent_command, cmd_name: str):
252
+ """Get the command object for a given command name."""
253
+ if hasattr(parent_command, "commands"):
254
+ return parent_command.commands.get(cmd_name)
255
+ return None
256
+
257
+ def _get_command_help(self, cmd_obj) -> str:
258
+ """Extract help text from a command object."""
259
+ if not cmd_obj:
260
+ return ""
261
+
262
+ # Try to get help from various places
263
+ if hasattr(cmd_obj, "help"):
264
+ return cmd_obj.help or ""
265
+ elif hasattr(cmd_obj, "short_help"):
266
+ return cmd_obj.short_help or ""
267
+ elif hasattr(cmd_obj, "__doc__"):
268
+ doc = cmd_obj.__doc__
269
+ if doc:
270
+ # Take first line of docstring
271
+ return doc.strip().split("\n")[0]
272
+ return ""
273
+
274
+ def _find_typo_matches(self, bad_cmd: str, possibilities: List[str]) -> List[str]:
275
+ """Find commands that might be typos of the bad command."""
276
+ typo_matches = []
277
+ bad_lower = bad_cmd.lower()
278
+
279
+ for cmd in possibilities:
280
+ cmd_lower = cmd.lower()
281
+
282
+ # Check for transposed adjacent characters
283
+ if len(bad_lower) == len(cmd_lower) and len(bad_lower) > 1:
284
+ diffs = sum(1 for a, b in zip(bad_lower, cmd_lower) if a != b)
285
+ if diffs <= 2: # Allow up to 2 character differences
286
+ typo_matches.append(cmd)
287
+ continue
288
+
289
+ # Check for single missing or extra character
290
+ if abs(len(bad_lower) - len(cmd_lower)) == 1:
291
+ if len(bad_lower) < len(cmd_lower):
292
+ # Check if bad_cmd is missing one character
293
+ for i in range(len(cmd_lower)):
294
+ if cmd_lower[:i] + cmd_lower[i + 1 :] == bad_lower:
295
+ typo_matches.append(cmd)
296
+ break
297
+ else:
298
+ # Check if bad_cmd has one extra character
299
+ for i in range(len(bad_lower)):
300
+ if bad_lower[:i] + bad_lower[i + 1 :] == cmd_lower:
301
+ typo_matches.append(cmd)
302
+ break
303
+
304
+ return typo_matches
305
+
306
+ def _get_example_commands(self, command_path: str, bad_cmd: str) -> List[str]:
307
+ """Get example commands based on context."""
308
+ examples = []
309
+
310
+ # Context-specific examples based on the command path
311
+ if "vpn" in command_path:
312
+ examples = [
313
+ f"{command_path} create myservice --profile default --port 8888",
314
+ f"{command_path} list",
315
+ f"{command_path} start myservice",
316
+ ]
317
+ elif "profile" in command_path:
318
+ examples = [
319
+ f"{command_path} create myprofile --env-file /path/to/.env",
320
+ f"{command_path} list",
321
+ f"{command_path} delete myprofile",
322
+ ]
323
+ elif "servers" in command_path:
324
+ examples = [
325
+ f"{command_path} update",
326
+ f"{command_path} list-providers",
327
+ f"{command_path} list-countries nordvpn",
328
+ ]
329
+ elif "bulk" in command_path:
330
+ examples = [
331
+ f"{command_path} up",
332
+ f"{command_path} down",
333
+ f"{command_path} status",
334
+ ]
335
+ elif "preset" in command_path:
336
+ examples = [
337
+ f"{command_path} list",
338
+ f"{command_path} apply mypreset myservice",
339
+ ]
340
+
341
+ # Limit to 3 most relevant examples
342
+ return examples[:3]
343
+
344
+ def _get_documentation_link(self, command_path: str) -> Optional[str]:
345
+ """Get documentation link for the command."""
346
+ # This could be configured per-project
347
+ # For now, return GitHub repository link
348
+ base_url = "https://github.com/eirenik0/proxy2vpn"
349
+
350
+ # Could map specific commands to doc sections
351
+ doc_sections = {
352
+ "vpn": f"{base_url}#vpn-management",
353
+ "profile": f"{base_url}#profile-management",
354
+ "servers": f"{base_url}#server-lists",
355
+ "bulk": f"{base_url}#bulk-operations",
356
+ "preset": f"{base_url}#presets",
357
+ }
358
+
359
+ # Find the relevant section based on command path
360
+ for key, url in doc_sections.items():
361
+ if key in command_path.lower():
362
+ return url
363
+
364
+ return base_url
365
+
366
+ def _all_command_paths(self) -> List[str]:
367
+ """Return all command and subcommand paths for this app."""
368
+
369
+ def walk(app: typer.Typer, prefix: str = "") -> List[str]:
370
+ items: List[str] = []
371
+ for cmd in app.registered_commands:
372
+ items.append(f"{prefix}{cmd.name}".strip())
373
+ for grp in app.registered_groups:
374
+ full = f"{prefix}{grp.name}".strip()
375
+ items.append(full)
376
+ items.extend(walk(grp.typer_instance, f"{full} "))
377
+ return items
378
+
379
+ return walk(self)
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: proxy2vpn
3
+ Version: 0.1.3
4
+ Summary: Proxy2VPN Python utilities
5
+ Author-email: Serhii Khalymon <serhii.khalymon@pm.me>
6
+ License: MIT
7
+ Keywords: vpn,proxy,utilities
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: typer
14
+ Requires-Dist: ruamel.yaml
15
+ Requires-Dist: docker
16
+ Requires-Dist: requests
17
+ Provides-Extra: dev
18
+ Requires-Dist: towncrier; extra == "dev"
19
+
20
+ # Proxy2VPN
21
+
22
+ Python command-line interface for managing multiple VPN containers with Docker.
23
+
24
+ ## Features
25
+ - Manage VPN credentials as reusable profiles
26
+ - Create and control VPN services
27
+ - Bulk start/stop operations
28
+ - Query and validate provider server locations
29
+ - Apply predefined presets for common setups
30
+
31
+ ## Installation
32
+
33
+ ### Using uv (recommended)
34
+ ```bash
35
+ git clone https://github.com/eirenik0/proxy2vpn.git
36
+ cd proxy2vpn
37
+ uv sync
38
+ uv run proxy2vpn --help
39
+ ```
40
+
41
+ ### Using pip
42
+ ```bash
43
+ git clone https://github.com/eirenik0/proxy2vpn.git
44
+ cd proxy2vpn
45
+ pip install -e .
46
+ proxy2vpn --help
47
+ ```
48
+
49
+ ## Quick Start
50
+ 1. Create a profile file with your VPN credentials:
51
+ ```bash
52
+ mkdir -p profiles
53
+ cat <<'EOF' > profiles/myprofile.env
54
+ OPENVPN_USER=your_username
55
+ OPENVPN_PASSWORD=your_password
56
+ EOF
57
+ ```
58
+
59
+ 2. Register the profile:
60
+ ```bash
61
+ proxy2vpn profile create myprofile profiles/myprofile.env
62
+ ```
63
+
64
+ 3. Create and start a VPN service:
65
+ ```bash
66
+ proxy2vpn vpn create vpn1 myprofile --port 8888 --provider protonvpn --location "New York"
67
+ proxy2vpn vpn start vpn1
68
+ ```
69
+
70
+ 4. View status and test connectivity:
71
+ ```bash
72
+ proxy2vpn vpn list
73
+ proxy2vpn test vpn1
74
+ ```
75
+
76
+ ## Command overview
77
+
78
+ ### Profiles
79
+ - `proxy2vpn profile create NAME ENV_FILE`
80
+ - `proxy2vpn profile list`
81
+ - `proxy2vpn profile delete NAME`
82
+
83
+ ### VPN services
84
+ - `proxy2vpn vpn create NAME PROFILE [--port PORT] [--provider PROVIDER] [--location LOCATION]`
85
+ - `proxy2vpn vpn list`
86
+ - `proxy2vpn vpn start NAME`
87
+ - `proxy2vpn vpn stop NAME`
88
+ - `proxy2vpn vpn restart NAME`
89
+ - `proxy2vpn vpn logs NAME [--lines N] [--follow]`
90
+ - `proxy2vpn vpn delete NAME`
91
+
92
+ ### Bulk operations
93
+ - `proxy2vpn bulk up`
94
+ - `proxy2vpn bulk down`
95
+ - `proxy2vpn bulk status`
96
+ - `proxy2vpn bulk ips`
97
+
98
+ ### Server database
99
+ - `proxy2vpn servers update`
100
+ - `proxy2vpn servers list-providers`
101
+ - `proxy2vpn servers list-countries PROVIDER`
102
+ - `proxy2vpn servers list-cities PROVIDER COUNTRY`
103
+ - `proxy2vpn servers validate-location PROVIDER LOCATION`
104
+
105
+ ### Presets
106
+ - `proxy2vpn preset list`
107
+ - `proxy2vpn preset apply PRESET SERVICE [--port PORT]`
108
+
109
+ ### Testing
110
+ - `proxy2vpn test SERVICE` – verify that a proxy container is reachable
111
+
112
+ ## Development
113
+
114
+ ### Setup
115
+ ```bash
116
+ # Install with development dependencies
117
+ uv sync
118
+ # or
119
+ pip install -e ".[dev]"
120
+ ```
121
+
122
+ ### Testing
123
+ ```bash
124
+ # Run tests (if available)
125
+ pytest
126
+ ```
127
+
128
+ ### Changelog Management
129
+ This project uses [Towncrier](https://towncrier.readthedocs.io/) for changelog management:
130
+
131
+ ```bash
132
+ # Add a news fragment for your changes
133
+ echo "Your feature description" > news/<PR_NUMBER>.feature.md
134
+
135
+ # Preview the changelog
136
+ make changelog-draft
137
+
138
+ # Build the changelog (maintainers)
139
+ make changelog VERSION=x.y.z
140
+ ```
141
+
142
+ ## License
143
+ MIT
@@ -0,0 +1,15 @@
1
+ proxy2vpn/__init__.py,sha256=jfctUiEaedYR-08PksSDiVwe-wVJhS6ZFOc-4Xyu6Ts,384
2
+ proxy2vpn/cli.py,sha256=x5iJmbv_e1EN7gso4mu1x5kaJNvJI1SUJCMXIdskTrs,11831
3
+ proxy2vpn/compose_manager.py,sha256=dcqS8s_ZQ0w-Cq2MGlhFeEIw7dcaYJr0-JKqLmm0hOU,4200
4
+ proxy2vpn/compose_utils.py,sha256=XO0owJE0Th0Yvcp6Yx-elCj-dhZOiJ2schPdozztcsQ,1078
5
+ proxy2vpn/config.py,sha256=k5deUPaazAv8dAzs9DK6UIE8wNmOXQxqIxziy266nE4,1378
6
+ proxy2vpn/docker_ops.py,sha256=2UGwVKj3NJOKsl1wlrx3of8ube0WSOz0kT3aG3WpaPY,5033
7
+ proxy2vpn/models.py,sha256=8BKSr9omEL79EoMy2Bqn1l1zLxGPRI3IX-JS9BaWre8,3188
8
+ proxy2vpn/preset_manager.py,sha256=yMqledCWm37uK1amIo9dKtmrGr6Or-6JO1otpKb_GoE,1305
9
+ proxy2vpn/server_manager.py,sha256=oF-jJg1GfBy7T44TTErL2xBIRYYeV3_bHKjkjU1-MCY,3853
10
+ proxy2vpn/typer_ext.py,sha256=m0aY9VMzFJJiyljVuzA57WYW503Ups-uRkNh5X-oM6k,14724
11
+ proxy2vpn-0.1.3.dist-info/METADATA,sha256=cN9dsSCqQ06m_Jmu-pxflOSR_HgqYZBjJHEEON9c6Ko,3259
12
+ proxy2vpn-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
13
+ proxy2vpn-0.1.3.dist-info/entry_points.txt,sha256=8TGD35AHEg9kXgIiIZtx6vDYFyHDaxS1kwlF-befq54,48
14
+ proxy2vpn-0.1.3.dist-info/top_level.txt,sha256=_NBXdNrzaYifJYpH2nLe37rdEPjy9sTw4AKd2fFVCb0,10
15
+ proxy2vpn-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ proxy2vpn = proxy2vpn.cli:app
@@ -0,0 +1 @@
1
+ proxy2vpn