oko-cli 0.1.0__tar.gz
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.
- oko_cli-0.1.0/.gitignore +85 -0
- oko_cli-0.1.0/LICENSE +21 -0
- oko_cli-0.1.0/PKG-INFO +29 -0
- oko_cli-0.1.0/README.md +1 -0
- oko_cli-0.1.0/oko/__init__.py +0 -0
- oko_cli-0.1.0/oko/cli.py +207 -0
- oko_cli-0.1.0/oko/core/__init__.py +0 -0
- oko_cli-0.1.0/oko/core/config.py +132 -0
- oko_cli-0.1.0/oko/core/endpoints.py +14 -0
- oko_cli-0.1.0/oko/core/runner.py +150 -0
- oko_cli-0.1.0/oko/ui.py +86 -0
- oko_cli-0.1.0/pyproject.toml +38 -0
oko_cli-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Entornos virtuales
|
|
2
|
+
.env
|
|
3
|
+
.venv
|
|
4
|
+
venv/
|
|
5
|
+
env/
|
|
6
|
+
ENV/
|
|
7
|
+
env.bak/
|
|
8
|
+
venv.bak/
|
|
9
|
+
|
|
10
|
+
# Python cache y compilados
|
|
11
|
+
__pycache__/
|
|
12
|
+
*.py[cod]
|
|
13
|
+
*$py.class
|
|
14
|
+
*.so
|
|
15
|
+
.Python
|
|
16
|
+
|
|
17
|
+
# Eggs y distribución
|
|
18
|
+
*.egg
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.eggs/
|
|
21
|
+
eggs/
|
|
22
|
+
develop-eggs/
|
|
23
|
+
oko.egg-info
|
|
24
|
+
|
|
25
|
+
# Build y distribución
|
|
26
|
+
build/
|
|
27
|
+
dist/
|
|
28
|
+
sdist/
|
|
29
|
+
wheels/
|
|
30
|
+
*.egg-info
|
|
31
|
+
*.egg
|
|
32
|
+
|
|
33
|
+
# Instalación
|
|
34
|
+
pip-log.txt
|
|
35
|
+
pip-delete-this-directory.txt
|
|
36
|
+
|
|
37
|
+
# Testing y coverage
|
|
38
|
+
.coverage
|
|
39
|
+
htmlcov/
|
|
40
|
+
.tox/
|
|
41
|
+
.nox/
|
|
42
|
+
.pytest_cache/
|
|
43
|
+
nosetests.xml
|
|
44
|
+
coverage.xml
|
|
45
|
+
*.cover
|
|
46
|
+
|
|
47
|
+
# Jupyter Notebook
|
|
48
|
+
.ipynb_checkpoints/
|
|
49
|
+
|
|
50
|
+
# IDE/Editores
|
|
51
|
+
.vscode/
|
|
52
|
+
.idea/
|
|
53
|
+
*.swp
|
|
54
|
+
*.swo
|
|
55
|
+
*~
|
|
56
|
+
.project
|
|
57
|
+
.pydevproject
|
|
58
|
+
|
|
59
|
+
# Sistema operativo
|
|
60
|
+
.DS_Store
|
|
61
|
+
.DS_Store?
|
|
62
|
+
._*
|
|
63
|
+
.Spotlight-V100
|
|
64
|
+
.Trashes
|
|
65
|
+
ehthumbs.db
|
|
66
|
+
Thumbs.db
|
|
67
|
+
|
|
68
|
+
# Configuraciones y secretos
|
|
69
|
+
*.env
|
|
70
|
+
.env.local
|
|
71
|
+
.env.development.local
|
|
72
|
+
.env.test.local
|
|
73
|
+
.env.production.local
|
|
74
|
+
secrets.py
|
|
75
|
+
config_local.py
|
|
76
|
+
config.ini
|
|
77
|
+
settings_local.py
|
|
78
|
+
credentials.json
|
|
79
|
+
service_account.json
|
|
80
|
+
|
|
81
|
+
# Logs
|
|
82
|
+
*.log
|
|
83
|
+
logs/
|
|
84
|
+
|
|
85
|
+
# Archivos temporales
|
oko_cli-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Edward Ojeda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
oko_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oko-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Elegant CLI for API endpoint testing
|
|
5
|
+
Project-URL: Homepage, https://github.com/poll7872/oko
|
|
6
|
+
Project-URL: Repository, https://github.com/tuusuario/oko
|
|
7
|
+
Project-URL: Issues, https://github.com/poll7872/oko/issues
|
|
8
|
+
Author-email: Edward Ojeda <edward.ojeda.es@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,cli,endpoints,http,rest,testing
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Requires-Dist: httpx>=0.25.0
|
|
25
|
+
Requires-Dist: rich>=13.0.0
|
|
26
|
+
Requires-Dist: typer>=0.9.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
Proyecto OKO
|
oko_cli-0.1.0/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Proyecto OKO
|
|
File without changes
|
oko_cli-0.1.0/oko/cli.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
import typer
|
|
3
|
+
from rich.prompt import Prompt, Confirm
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .core.config import config, ConfigType, load_endpoints
|
|
9
|
+
from .core.endpoints import add_endpoint
|
|
10
|
+
from .core.runner import run_endpoint
|
|
11
|
+
from .ui import (
|
|
12
|
+
console,
|
|
13
|
+
print_logo,
|
|
14
|
+
print_success,
|
|
15
|
+
print_error,
|
|
16
|
+
print_warning,
|
|
17
|
+
print_info_panel,
|
|
18
|
+
get_table,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
help="OKO CLI - API Testing made simple",
|
|
23
|
+
rich_markup_mode="rich",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
)
|
|
26
|
+
endpoint_app = typer.Typer(help="Manage API endpoints")
|
|
27
|
+
app.add_typer(endpoint_app, name="endpoint")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("init")
|
|
31
|
+
def init():
|
|
32
|
+
"""Initialize OKO configuration."""
|
|
33
|
+
print_logo()
|
|
34
|
+
console.print("[bold primary]🚀 Welcome to OKO CLI![/bold primary]")
|
|
35
|
+
console.print("Let's set up your configuration.\n")
|
|
36
|
+
|
|
37
|
+
if config.find_config():
|
|
38
|
+
current_config = config.load_config()
|
|
39
|
+
warning_message = (
|
|
40
|
+
f"Configuration already exists at: [info]{config.config_dir}[/info]\n"
|
|
41
|
+
f"Type: [info]{current_config.get('type', 'unknown')}[/info]"
|
|
42
|
+
)
|
|
43
|
+
print_warning(warning_message, title="Configuration Found")
|
|
44
|
+
|
|
45
|
+
if not Confirm.ask("\nDo you want to reinitialize?"):
|
|
46
|
+
console.print("[dim]Initialization cancelled.[/dim]")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
console.print("\n[bold primary]Where would you like to store endpoints?[/bold primary]")
|
|
50
|
+
options = [
|
|
51
|
+
("project", "In this project (.oko/) - For team collaboration"),
|
|
52
|
+
("global", "Global (~/.oko/) - For personal use"),
|
|
53
|
+
("custom", "Custom location - Specify a path"),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
for i, (_, desc) in enumerate(options, 1):
|
|
57
|
+
console.print(f" [secondary]{i}[/secondary]. {desc}")
|
|
58
|
+
|
|
59
|
+
choice = Prompt.ask("\nSelect option (1-3)", choices=["1", "2", "3"], default="1")
|
|
60
|
+
|
|
61
|
+
config_type: ConfigType
|
|
62
|
+
config_dir: Path
|
|
63
|
+
|
|
64
|
+
if choice == "1":
|
|
65
|
+
config_type = "project"
|
|
66
|
+
config_dir = Path.cwd() / ".oko"
|
|
67
|
+
elif choice == "2":
|
|
68
|
+
config_type = "global"
|
|
69
|
+
config_dir = Path.home() / ".oko"
|
|
70
|
+
else:
|
|
71
|
+
config_type = "custom"
|
|
72
|
+
custom_path = Prompt.ask("\nEnter custom path")
|
|
73
|
+
config_dir = Path(custom_path).expanduser().resolve()
|
|
74
|
+
|
|
75
|
+
project_name = None
|
|
76
|
+
if config_type == "project":
|
|
77
|
+
project_name = Prompt.ask("\nProject name", default=Path.cwd().name)
|
|
78
|
+
|
|
79
|
+
summary = (
|
|
80
|
+
f" [bold]Type:[/bold] [info]{config_type}[/info]\n"
|
|
81
|
+
f" [bold]Location:[/bold] [info]{config_dir}[/info]\n"
|
|
82
|
+
)
|
|
83
|
+
if project_name:
|
|
84
|
+
summary += f" [bold]Project:[/bold] [info]{project_name}[/info]"
|
|
85
|
+
|
|
86
|
+
print_info_panel(summary, title="Summary")
|
|
87
|
+
|
|
88
|
+
if not Confirm.ask("\nCreate configuration?"):
|
|
89
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
config_data = {
|
|
93
|
+
"created_at": datetime.now().isoformat(),
|
|
94
|
+
"project_name": project_name,
|
|
95
|
+
}
|
|
96
|
+
config.save_config(config_type, config_dir, config_data)
|
|
97
|
+
|
|
98
|
+
success_message = (
|
|
99
|
+
f"Configuration saved at: [info]{config.config_dir}[/info]\n\n"
|
|
100
|
+
"[bold]Next steps:[/bold]\n"
|
|
101
|
+
" • Add an endpoint: [secondary]oko endpoint add <alias> <url>[/secondary]\n"
|
|
102
|
+
" • Run an endpoint: [secondary]oko run <alias>[/secondary]"
|
|
103
|
+
)
|
|
104
|
+
print_success(success_message, title="✅ OKO initialized successfully!")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command("info")
|
|
108
|
+
def info():
|
|
109
|
+
"""Show current OKO configuration."""
|
|
110
|
+
if not config.find_config():
|
|
111
|
+
print_warning("No OKO configuration found. Run [secondary]oko init[/secondary] to get started.")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
print_logo()
|
|
115
|
+
current_config = config.load_config()
|
|
116
|
+
|
|
117
|
+
config_details = (
|
|
118
|
+
f" [bold]Type:[/bold] [info]{current_config.get('type', 'unknown')}[/info]\n"
|
|
119
|
+
f" [bold]Location:[/bold] [info]{config.config_dir}[/info]\n"
|
|
120
|
+
f" [bold]Version:[/bold] [info]{current_config.get('version', '1.0')}[/info]\n"
|
|
121
|
+
)
|
|
122
|
+
if current_config.get("project_name"):
|
|
123
|
+
config_details += f" [bold]Project:[/bold] [info]{current_config.get('project_name')}[/info]\n"
|
|
124
|
+
if current_config.get("created_at"):
|
|
125
|
+
config_details += f" [bold]Created:[/bold] [info]{current_config.get('created_at')}[/info]"
|
|
126
|
+
|
|
127
|
+
print_info_panel(config_details, title="OKO Configuration")
|
|
128
|
+
|
|
129
|
+
endpoints_data = load_endpoints()
|
|
130
|
+
endpoints = endpoints_data.get("endpoints", {})
|
|
131
|
+
console.print(f"\n[bold primary]📊 Endpoints found:[/bold primary] [bold secondary]{len(endpoints)}[/bold secondary]")
|
|
132
|
+
|
|
133
|
+
if endpoints:
|
|
134
|
+
table = get_table()
|
|
135
|
+
table.add_column("Alias", style="blue", no_wrap=True)
|
|
136
|
+
table.add_column("Method", style="cyan")
|
|
137
|
+
table.add_column("URL", style="white")
|
|
138
|
+
|
|
139
|
+
for alias, endpoint in endpoints.items():
|
|
140
|
+
method = endpoint.get("method", "GET")
|
|
141
|
+
url = endpoint.get("url", "")
|
|
142
|
+
table.add_row(alias, method, url[:70] + ("..." if len(url) > 70 else ""))
|
|
143
|
+
|
|
144
|
+
console.print(table)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@endpoint_app.command("add")
|
|
148
|
+
def endpoint_add(
|
|
149
|
+
alias: str = typer.Argument(..., help="Alias to identify the endpoint"),
|
|
150
|
+
url: str = typer.Argument(..., help="Full URL of the endpoint"),
|
|
151
|
+
method: str = typer.Option(
|
|
152
|
+
"GET", "--method", "-M", help="HTTP method"
|
|
153
|
+
),
|
|
154
|
+
):
|
|
155
|
+
"""Register a new endpoint."""
|
|
156
|
+
if not config.find_config():
|
|
157
|
+
print_warning("No OKO configuration found. Run [secondary]oko init[/secondary] first.")
|
|
158
|
+
raise typer.Exit(1)
|
|
159
|
+
|
|
160
|
+
method = method.upper()
|
|
161
|
+
SUPPORTED_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
|
|
162
|
+
|
|
163
|
+
if method not in SUPPORTED_METHODS:
|
|
164
|
+
print_error(f"Method '{method}' not supported. Choose from: [info]{', '.join(SUPPORTED_METHODS)}[/info]")
|
|
165
|
+
raise typer.Exit(1)
|
|
166
|
+
|
|
167
|
+
add_endpoint(alias, url, method)
|
|
168
|
+
|
|
169
|
+
message = (
|
|
170
|
+
f"Alias: [primary]{alias}[/primary]\n"
|
|
171
|
+
f"URL: [info]{method} {url}[/info]\n"
|
|
172
|
+
f"Saved in: [dim]{config.config_dir}[/dim]"
|
|
173
|
+
)
|
|
174
|
+
print_success(message, title=f"Endpoint '{alias}' saved!")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.command("run")
|
|
178
|
+
def run(
|
|
179
|
+
alias: str,
|
|
180
|
+
param: List[str] = typer.Option(
|
|
181
|
+
None, "--param", "-p", help="Query params (key=value). Use multiple times."
|
|
182
|
+
),
|
|
183
|
+
header: List[str] = typer.Option(
|
|
184
|
+
None, "--header", "-H", help="HTTP headers (key=value). Use multiple times."
|
|
185
|
+
),
|
|
186
|
+
body: Optional[str] = typer.Option(
|
|
187
|
+
None, "--body", "-B", help="JSON body or use '@file.json' to load from a file."
|
|
188
|
+
),
|
|
189
|
+
):
|
|
190
|
+
"""
|
|
191
|
+
Run a saved endpoint.
|
|
192
|
+
|
|
193
|
+
Examples:
|
|
194
|
+
- [cyan]oko run my_api -p id=123[/cyan]
|
|
195
|
+
- [cyan]oko run create_user -B '{"name":"Test"}'[/cyan]
|
|
196
|
+
- [cyan]oko run upload -B @data.json[/cyan]
|
|
197
|
+
"""
|
|
198
|
+
if not config.find_config():
|
|
199
|
+
print_warning("No OKO configuration found. Run [secondary]oko init[/secondary] first.")
|
|
200
|
+
raise typer.Exit(1)
|
|
201
|
+
|
|
202
|
+
with console.status(f"[bold green]Running [primary]'{alias}'[/primary]...[/bold green]", spinner="dots"):
|
|
203
|
+
run_endpoint(alias, console, param, header, body)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional, Literal
|
|
4
|
+
|
|
5
|
+
ConfigType = Literal["project", "global", "custom"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OkoConfig:
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.config_type: Optional[ConfigType] = None
|
|
11
|
+
self.config_dir: Optional[Path] = None
|
|
12
|
+
self.config_file: Optional[Path] = None
|
|
13
|
+
self.endpoints_file: Optional[Path] = None
|
|
14
|
+
|
|
15
|
+
def find_config(self) -> bool:
|
|
16
|
+
"""
|
|
17
|
+
Buscar configuración jerárquicamente:
|
|
18
|
+
1. Directorio actual o padres (.oko/)
|
|
19
|
+
2. Global (~/.oko/)
|
|
20
|
+
3. Si no existe, retornar False
|
|
21
|
+
"""
|
|
22
|
+
# Buscar .oko/ en directorio actual o padres
|
|
23
|
+
current = Path.cwd()
|
|
24
|
+
for parent in [current] + list(current.parents):
|
|
25
|
+
oko_dir = parent / ".oko"
|
|
26
|
+
if oko_dir.exists() and (oko_dir / "config.json").exists():
|
|
27
|
+
self.config_type = "project"
|
|
28
|
+
self.config_dir = oko_dir
|
|
29
|
+
self.config_file = oko_dir / "config.json"
|
|
30
|
+
self.endpoints_file = oko_dir / "endpoints.json"
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
# Buscar global
|
|
34
|
+
global_dir = Path.home() / ".oko"
|
|
35
|
+
if global_dir.exists() and (global_dir / "config.json").exists():
|
|
36
|
+
self.config_type = "global"
|
|
37
|
+
self.config_dir = global_dir
|
|
38
|
+
self.config_file = global_dir / "config.json"
|
|
39
|
+
self.endpoints_file = global_dir / "endpoints.json"
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
def load_config(self):
|
|
45
|
+
"""Cargar configuración si existe"""
|
|
46
|
+
if self.config_file and self.config_file.exists():
|
|
47
|
+
try:
|
|
48
|
+
with open(self.config_file, "r", encoding="utf-8") as f:
|
|
49
|
+
content = f.read()
|
|
50
|
+
if not content.strip():
|
|
51
|
+
return {"version": "1.0", "type": self.config_type or "unknown"}
|
|
52
|
+
return json.loads(content)
|
|
53
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
54
|
+
pass
|
|
55
|
+
return {"version": "1.0", "type": self.config_type or "unknown"}
|
|
56
|
+
|
|
57
|
+
def save_config(self, config_type: ConfigType, config_dir: Path, data: dict = None):
|
|
58
|
+
"""Guardar nueva configuración"""
|
|
59
|
+
self.config_type = config_type
|
|
60
|
+
self.config_dir = config_dir
|
|
61
|
+
self.config_file = config_dir / "config.json"
|
|
62
|
+
self.endpoints_file = config_dir / "endpoints.json"
|
|
63
|
+
|
|
64
|
+
# Crear directorio
|
|
65
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
# Guardar config
|
|
68
|
+
config_data = {
|
|
69
|
+
"version": "1.0",
|
|
70
|
+
"type": config_type,
|
|
71
|
+
"created_at": data.get("created_at") if data else None,
|
|
72
|
+
"project_name": data.get("project_name") if data else None,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
with open(self.config_file, "w", encoding="utf-8") as f:
|
|
76
|
+
json.dump(config_data, f, indent=2)
|
|
77
|
+
|
|
78
|
+
# Crear endpoints.json vacío si no existe
|
|
79
|
+
if not self.endpoints_file.exists():
|
|
80
|
+
with open(self.endpoints_file, "w", encoding="utf-8") as f:
|
|
81
|
+
json.dump({"endpoints": {}}, f, indent=2)
|
|
82
|
+
|
|
83
|
+
def get_endpoints_file(self) -> Path:
|
|
84
|
+
"""Obtener archivo de endpoints (con fallback)"""
|
|
85
|
+
if self.endpoints_file and self.endpoints_file.exists():
|
|
86
|
+
return self.endpoints_file
|
|
87
|
+
|
|
88
|
+
# Si no hay config, usar global como fallback
|
|
89
|
+
if not self.find_config():
|
|
90
|
+
global_dir = Path.home() / ".oko"
|
|
91
|
+
global_dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
return global_dir / "endpoints.json"
|
|
93
|
+
|
|
94
|
+
return self.endpoints_file
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Instancia global
|
|
98
|
+
config = OkoConfig()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def ensure_config():
|
|
102
|
+
"""Versión simple para compatibilidad"""
|
|
103
|
+
return config.get_endpoints_file().parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_endpoints():
|
|
107
|
+
"""Cargar endpoints desde la ubicación activa"""
|
|
108
|
+
endpoints_file = config.get_endpoints_file()
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
if endpoints_file.exists():
|
|
112
|
+
with open(endpoints_file, "r", encoding="utf-8") as f:
|
|
113
|
+
content = f.read()
|
|
114
|
+
if not content.strip():
|
|
115
|
+
return {"endpoints": {}}
|
|
116
|
+
return json.loads(content)
|
|
117
|
+
except (json.JSONDecodeError, FileNotFoundError) as e:
|
|
118
|
+
print(f"[yellow]⚠ Error loading endpoints: {e}. Creating new.[/yellow]")
|
|
119
|
+
|
|
120
|
+
# Crear nuevo si hay error
|
|
121
|
+
with open(endpoints_file, "w", encoding="utf-8") as f:
|
|
122
|
+
json.dump({"endpoints": {}}, f, indent=2)
|
|
123
|
+
return {"endpoints": {}}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def save_endpoints(data: dict):
|
|
127
|
+
"""Guardar endpoints en la ubicación activa"""
|
|
128
|
+
endpoints_file = config.get_endpoints_file()
|
|
129
|
+
endpoints_file.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
|
|
131
|
+
with open(endpoints_file, "w", encoding="utf-8") as f:
|
|
132
|
+
json.dump(data, f, indent=2)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .config import load_endpoints, save_endpoints
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def add_endpoint(alias: str, url: str, method: str = "GET"):
|
|
5
|
+
"""Add a new endpoint using current config"""
|
|
6
|
+
data = load_endpoints()
|
|
7
|
+
|
|
8
|
+
endpoint_data = {
|
|
9
|
+
"url": url,
|
|
10
|
+
"method": method.upper(),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
data["endpoints"][alias] = endpoint_data
|
|
14
|
+
save_endpoints(data)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import json
|
|
3
|
+
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.pretty import Pretty
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.box import ROUNDED
|
|
9
|
+
|
|
10
|
+
from .config import load_endpoints
|
|
11
|
+
from ..ui import print_error
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _make_key_value_table(data: dict) -> Table:
|
|
15
|
+
"""Creates a styled table for key-value data."""
|
|
16
|
+
table = Table(box=ROUNDED, show_header=False, border_style="secondary")
|
|
17
|
+
table.add_column("Key", style="info", no_wrap=True)
|
|
18
|
+
table.add_column("Value", style="white")
|
|
19
|
+
for key, value in data.items():
|
|
20
|
+
table.add_row(str(key), str(value))
|
|
21
|
+
return table
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_params(param_list, console: Console):
|
|
25
|
+
"""Convert ['a=1', 'b=2'] into {'a': '1', 'b': '2'}"""
|
|
26
|
+
params = {}
|
|
27
|
+
if not param_list:
|
|
28
|
+
return params
|
|
29
|
+
for item in param_list:
|
|
30
|
+
if "=" not in item:
|
|
31
|
+
console.print(f"[warning]Skipping invalid param:[/] {item}")
|
|
32
|
+
continue
|
|
33
|
+
key, value = item.split("=", 1)
|
|
34
|
+
params[key] = value
|
|
35
|
+
return params
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_headers(header_list, console: Console):
|
|
39
|
+
"""Convert headers list to dict"""
|
|
40
|
+
headers = {}
|
|
41
|
+
if not header_list:
|
|
42
|
+
return headers
|
|
43
|
+
for item in header_list:
|
|
44
|
+
if "=" not in item:
|
|
45
|
+
console.print(f"[warning]Skipping invalid header:[/] {item}")
|
|
46
|
+
continue
|
|
47
|
+
key, value = item.split("=", 1)
|
|
48
|
+
headers[key] = value
|
|
49
|
+
return headers
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_body(body_data, console: Console):
|
|
53
|
+
"""Parse JSON body from string or file"""
|
|
54
|
+
if not body_data:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
if body_data.startswith("@"):
|
|
58
|
+
filepath = body_data[1:]
|
|
59
|
+
try:
|
|
60
|
+
with open(filepath, "r") as f:
|
|
61
|
+
return json.load(f)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
print_error(f"Error reading body file: {e}")
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
return json.loads(body_data)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
print_error(f"Invalid JSON body: {body_data}")
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def merge_query_params(url, extra_params):
|
|
74
|
+
"""Merge base URL query params with user-provided params"""
|
|
75
|
+
parsed = urlparse(url)
|
|
76
|
+
base_params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
|
|
77
|
+
final_params = {**base_params, **extra_params}
|
|
78
|
+
new_query = urlencode(final_params)
|
|
79
|
+
|
|
80
|
+
return urlunparse(
|
|
81
|
+
(
|
|
82
|
+
parsed.scheme,
|
|
83
|
+
parsed.netloc,
|
|
84
|
+
parsed.path,
|
|
85
|
+
parsed.params,
|
|
86
|
+
new_query,
|
|
87
|
+
parsed.fragment,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run_endpoint(alias: str, console: Console, params=None, headers=None, body=None):
|
|
93
|
+
data = load_endpoints()
|
|
94
|
+
endpoints = data.get("endpoints", {})
|
|
95
|
+
|
|
96
|
+
if alias not in endpoints:
|
|
97
|
+
print_error(f"Endpoint '{alias}' not found.")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
endpoint = endpoints[alias]
|
|
101
|
+
url = endpoint["url"]
|
|
102
|
+
method = endpoint["method"].upper()
|
|
103
|
+
|
|
104
|
+
# Parse inputs
|
|
105
|
+
extra_params = parse_params(params, console)
|
|
106
|
+
header_dict = parse_headers(headers, console)
|
|
107
|
+
body_data = parse_body(body, console)
|
|
108
|
+
|
|
109
|
+
# Merge URL params
|
|
110
|
+
final_url = merge_query_params(url, extra_params)
|
|
111
|
+
|
|
112
|
+
console.rule("[bold primary]Request[/bold primary]")
|
|
113
|
+
console.print(f"[bold]→ {method} {final_url}[/bold]")
|
|
114
|
+
|
|
115
|
+
if extra_params:
|
|
116
|
+
console.print(Panel(_make_key_value_table(extra_params), title="[info]Query Parameters[/info]", border_style="secondary"))
|
|
117
|
+
|
|
118
|
+
if header_dict:
|
|
119
|
+
console.print(Panel(_make_key_value_table(header_dict), title="[info]Headers[/info]", border_style="secondary"))
|
|
120
|
+
|
|
121
|
+
if body_data and method in ["POST", "PUT", "PATCH"]:
|
|
122
|
+
console.print(Panel(Pretty(body_data, expand_all=True), title="[info]Body[/info]", border_style="secondary"))
|
|
123
|
+
|
|
124
|
+
console.print()
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
response = httpx.request(
|
|
128
|
+
method, final_url, headers=header_dict, json=body_data if body_data else None
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
status_style = "success" if response.is_success else "error"
|
|
132
|
+
console.rule(f"[bold {status_style}]Response[/bold {status_style}]")
|
|
133
|
+
console.print(f"\n[bold]Status:[/bold] [{status_style}]{response.status_code}[/]")
|
|
134
|
+
|
|
135
|
+
if response.headers:
|
|
136
|
+
console.print(Panel(_make_key_value_table(response.headers), title="[info]Headers[/info]", border_style="secondary"))
|
|
137
|
+
|
|
138
|
+
if response.content:
|
|
139
|
+
try:
|
|
140
|
+
json_data = response.json()
|
|
141
|
+
console.print(Panel(Pretty(json_data, expand_all=True), title="[info]Body[/info]", border_style="secondary"))
|
|
142
|
+
except Exception:
|
|
143
|
+
console.print(Panel(response.text[:2000], title="[warning]Response (Not JSON)[/warning]", border_style="warning"))
|
|
144
|
+
else:
|
|
145
|
+
console.print("\n[dim]No response body[/dim]")
|
|
146
|
+
|
|
147
|
+
except Exception as e:
|
|
148
|
+
print_error(f"Error connecting to endpoint: {e}")
|
|
149
|
+
|
|
150
|
+
|
oko_cli-0.1.0/oko/ui.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
from rich.panel import Panel
|
|
3
|
+
from rich.theme import Theme
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from rich.box import ROUNDED
|
|
6
|
+
|
|
7
|
+
LOGO_CAT = """[bold blue]
|
|
8
|
+
/\\_/\\
|
|
9
|
+
( o.o )
|
|
10
|
+
> ^ <
|
|
11
|
+
[/bold blue]"""
|
|
12
|
+
|
|
13
|
+
LOGO_OKO = """[bold bright_cyan]
|
|
14
|
+
___ _ __ ___
|
|
15
|
+
/ _ \| |/ / / _ \\
|
|
16
|
+
| | | | ' / | | | |
|
|
17
|
+
| |_| | . \\ | |_| |
|
|
18
|
+
\\___/|_|\\_\\ \\___/
|
|
19
|
+
[/bold bright_cyan]"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Custom theme for consistent styling
|
|
23
|
+
custom_theme = Theme(
|
|
24
|
+
{
|
|
25
|
+
"info": "cyan",
|
|
26
|
+
"warning": "yellow",
|
|
27
|
+
"error": "bold red",
|
|
28
|
+
"success": "bold green",
|
|
29
|
+
"primary": "blue",
|
|
30
|
+
"secondary": "bright_cyan",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Global console object
|
|
35
|
+
console = Console(theme=custom_theme)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def print_logo():
|
|
39
|
+
"""Prints the OKO logo."""
|
|
40
|
+
console.print(LOGO_CAT, justify="center")
|
|
41
|
+
console.print(LOGO_OKO, justify="center")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def print_success(message: str, title: str = "✅ Success"):
|
|
45
|
+
"""Prints a success message in a panel."""
|
|
46
|
+
console.print(
|
|
47
|
+
Panel(
|
|
48
|
+
f"[success]{message}[/success]",
|
|
49
|
+
title=title,
|
|
50
|
+
border_style="success",
|
|
51
|
+
expand=False,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def print_error(message: str, title: str = "❌ Error"):
|
|
57
|
+
"""Prints an error message in a panel."""
|
|
58
|
+
console.print(
|
|
59
|
+
Panel(
|
|
60
|
+
f"[error]{message}[/error]", title=title, border_style="error", expand=False
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def print_warning(message: str, title: str = "⚠️ Warning"):
|
|
66
|
+
"""Prints a warning message in a panel."""
|
|
67
|
+
console.print(
|
|
68
|
+
Panel(
|
|
69
|
+
f"[warning]{message}[/warning]",
|
|
70
|
+
title=title,
|
|
71
|
+
border_style="warning",
|
|
72
|
+
expand=False,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def print_info_panel(message: str, title: str = "📋 Info"):
|
|
78
|
+
"""Prints an info message in a panel."""
|
|
79
|
+
console.print(Panel(message, title=title, border_style="info", expand=False))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_table() -> Table:
|
|
83
|
+
"""Returns a nicely styled table."""
|
|
84
|
+
return Table(
|
|
85
|
+
show_header=True, header_style="bold bright_cyan", box=ROUNDED, padding=(0, 2)
|
|
86
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "oko-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Elegant CLI for API endpoint testing"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "Edward Ojeda", email = "edward.ojeda.es@gmail.com" }]
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
requires-python = ">=3.8"
|
|
13
|
+
keywords = ["api", "cli", "testing", "http", "rest", "endpoints"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Topic :: Software Development :: Testing",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.8",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["httpx>=0.25.0", "typer>=0.9.0", "rich>=13.0.0"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
oko = "oko.cli:app"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/poll7872/oko"
|
|
34
|
+
Repository = "https://github.com/tuusuario/oko"
|
|
35
|
+
Issues = "https://github.com/poll7872/oko/issues"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build]
|
|
38
|
+
include = ["oko/**/*.py", "README.md", "LICENSE"]
|