operx-cli 0.1.0__tar.gz → 0.2.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.
- {operx_cli-0.1.0 → operx_cli-0.2.0}/PKG-INFO +70 -15
- {operx_cli-0.1.0 → operx_cli-0.2.0}/README.md +68 -14
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/__init__.py +1 -1
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/app.py +62 -7
- operx_cli-0.2.0/operx_cli/browser_login.py +91 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/client.py +13 -11
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/account.py +50 -14
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/common.py +16 -2
- operx_cli-0.2.0/operx_cli/shell.py +254 -0
- operx_cli-0.2.0/operx_cli/welcome.py +108 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli.egg-info/PKG-INFO +70 -15
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli.egg-info/SOURCES.txt +7 -1
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli.egg-info/requires.txt +1 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/pyproject.toml +3 -1
- {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_app.py +44 -12
- {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_client.py +31 -1
- operx_cli-0.2.0/tests/test_login.py +196 -0
- operx_cli-0.2.0/tests/test_shell.py +248 -0
- operx_cli-0.2.0/tests/test_welcome.py +124 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/__init__.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/inventario.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/lecturas.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/ordenes.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/personas.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/raw.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/registros.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/search.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/lookup.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/output.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/settings.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/spreadsheet.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/values.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli.egg-info/dependency_links.txt +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli.egg-info/entry_points.txt +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli.egg-info/top_level.txt +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/setup.cfg +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_inventario_lecturas.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_lookup.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_ordenes.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_output.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_personas.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_registros.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_settings.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_spreadsheet.py +0 -0
- {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_values.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: operx-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: La línea de comandos de OperX: tu empresa desde la terminal, con tu token de API.
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -8,6 +8,7 @@ Requires-Dist: typer>=0.27
|
|
|
8
8
|
Requires-Dist: httpx>=0.28
|
|
9
9
|
Requires-Dist: keyring>=25
|
|
10
10
|
Requires-Dist: openpyxl>=3.1
|
|
11
|
+
Requires-Dist: prompt_toolkit>=3.0
|
|
11
12
|
Provides-Extra: test
|
|
12
13
|
Requires-Dist: pytest>=8; extra == "test"
|
|
13
14
|
|
|
@@ -19,38 +20,74 @@ tú, con tu rol, y solo en tu empresa.
|
|
|
19
20
|
|
|
20
21
|
## Instalar
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
Windows (PowerShell):
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"; uv tool install operx-cli
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Mac y Linux:
|
|
23
30
|
|
|
24
31
|
```sh
|
|
25
|
-
|
|
26
|
-
operx --version
|
|
32
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh && uv tool install operx-cli
|
|
27
33
|
```
|
|
28
34
|
|
|
35
|
+
`uv` instala su propio Python, así que no necesitas tener uno. Si ya tienes
|
|
36
|
+
Python 3.10 o más nuevo, también sirve `pipx install operx-cli`. Abre una
|
|
37
|
+
terminal nueva y comprueba con `operx --version`.
|
|
38
|
+
|
|
39
|
+
Para actualizar: `uv tool upgrade operx-cli`.
|
|
40
|
+
|
|
29
41
|
## Entrar
|
|
30
42
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
```sh
|
|
44
|
+
operx login
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
1. La terminal muestra un código y abre el navegador en la página de OperX.
|
|
48
|
+
2. Entras como siempre. La página dice qué terminal pide acceso: apruébalo solo
|
|
49
|
+
si acabas de escribir `operx login` en tu propio computador y el código es
|
|
50
|
+
el mismo. Escribe tu contraseña y elige *Solo lectura* o *Lectura y
|
|
51
|
+
escritura*.
|
|
52
|
+
3. Vuelve a la terminal: ya entraste. Nunca ves ni copias un token.
|
|
34
53
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
```
|
|
54
|
+
Tu rol necesita el permiso «Acceso por API» (Ajustes › Usuarios y acceso ›
|
|
55
|
+
Roles); un administrador lo da. El acceso dura 90 días y lo ves, y puedes
|
|
56
|
+
revocarlo, en el menú de tu cuenta › Acceso por API.
|
|
39
57
|
|
|
40
58
|
El token queda en el llavero del sistema (en Windows, el Administrador de
|
|
41
|
-
credenciales), nunca en un archivo. En un servidor sin
|
|
42
|
-
|
|
59
|
+
credenciales), nunca en un archivo. En un servidor sin navegador ni llavero:
|
|
60
|
+
`operx login --token` te deja pegar un token creado en Acceso por API, o puedes
|
|
61
|
+
usar variables de entorno: `OPERX_TOKEN` (el token) y, si no es producción,
|
|
62
|
+
`OPERX_DIRECCION`.
|
|
43
63
|
|
|
44
64
|
Para otra dirección u otra empresa, usa un perfil:
|
|
45
65
|
|
|
46
66
|
```sh
|
|
47
|
-
operx --perfil pruebas
|
|
67
|
+
operx --perfil pruebas login --direccion https://…
|
|
48
68
|
operx --perfil pruebas ordenes listar
|
|
49
69
|
```
|
|
50
70
|
|
|
51
|
-
`operx
|
|
71
|
+
`operx logout` borra el token de este equipo; para que deje de funcionar,
|
|
52
72
|
revócalo en Acceso por API.
|
|
53
73
|
|
|
74
|
+
## Modo interactivo
|
|
75
|
+
|
|
76
|
+
Escribe `operx` solo, en una terminal, y se abre la pantalla de bienvenida
|
|
77
|
+
(quién eres y los números de hoy: órdenes abiertas, vencidas, artículos bajo
|
|
78
|
+
el mínimo) con un prompt. Ahí escribes los comandos sin el `operx`, con
|
|
79
|
+
Tab para completar y ↑ para repetir lo anterior:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
operx › ordenes listar --vencidas
|
|
83
|
+
operx › buscar "bomba"
|
|
84
|
+
operx › ayuda
|
|
85
|
+
operx › salir
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`salir` (o Ctrl+D) termina la sesión sin borrar tu token. En un script o una
|
|
89
|
+
tubería, `operx` solo muestra la ayuda y no espera a nadie.
|
|
90
|
+
|
|
54
91
|
## Comandos
|
|
55
92
|
|
|
56
93
|
```sh
|
|
@@ -134,3 +171,21 @@ python -m venv .venv && .venv/Scripts/python -m pip install -e ".[test]"
|
|
|
134
171
|
|
|
135
172
|
`operx_cli/client.py` es lo único que habla con la red; el servidor MCP lo
|
|
136
173
|
usará igual.
|
|
174
|
+
|
|
175
|
+
### Publicar una versión
|
|
176
|
+
|
|
177
|
+
El paquete es `operx-cli` en PyPI. Una versión publicada no se puede reemplazar:
|
|
178
|
+
si algo falla, se sube la siguiente.
|
|
179
|
+
|
|
180
|
+
1. Sube el número en `pyproject.toml` y en `operx_cli/__init__.py`.
|
|
181
|
+
2. Las funciones nuevas del servidor (como `operx login`) tienen que estar ya
|
|
182
|
+
desplegadas en producción antes de publicar: el paquete nuevo las llama.
|
|
183
|
+
3. Desde `cli/`, con un entorno que tenga `build` y `twine`:
|
|
184
|
+
|
|
185
|
+
```sh
|
|
186
|
+
python -m build
|
|
187
|
+
python -m twine upload dist/* # usuario __token__, y el token del proyecto
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
El token de PyPI es secreto: se escribe en tu terminal, nunca en un archivo del
|
|
191
|
+
repositorio ni en un chat.
|
|
@@ -6,38 +6,74 @@ tú, con tu rol, y solo en tu empresa.
|
|
|
6
6
|
|
|
7
7
|
## Instalar
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Windows (PowerShell):
|
|
10
|
+
|
|
11
|
+
```powershell
|
|
12
|
+
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"; uv tool install operx-cli
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Mac y Linux:
|
|
10
16
|
|
|
11
17
|
```sh
|
|
12
|
-
|
|
13
|
-
operx --version
|
|
18
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh && uv tool install operx-cli
|
|
14
19
|
```
|
|
15
20
|
|
|
21
|
+
`uv` instala su propio Python, así que no necesitas tener uno. Si ya tienes
|
|
22
|
+
Python 3.10 o más nuevo, también sirve `pipx install operx-cli`. Abre una
|
|
23
|
+
terminal nueva y comprueba con `operx --version`.
|
|
24
|
+
|
|
25
|
+
Para actualizar: `uv tool upgrade operx-cli`.
|
|
26
|
+
|
|
16
27
|
## Entrar
|
|
17
28
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
29
|
+
```sh
|
|
30
|
+
operx login
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
1. La terminal muestra un código y abre el navegador en la página de OperX.
|
|
34
|
+
2. Entras como siempre. La página dice qué terminal pide acceso: apruébalo solo
|
|
35
|
+
si acabas de escribir `operx login` en tu propio computador y el código es
|
|
36
|
+
el mismo. Escribe tu contraseña y elige *Solo lectura* o *Lectura y
|
|
37
|
+
escritura*.
|
|
38
|
+
3. Vuelve a la terminal: ya entraste. Nunca ves ni copias un token.
|
|
21
39
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
```
|
|
40
|
+
Tu rol necesita el permiso «Acceso por API» (Ajustes › Usuarios y acceso ›
|
|
41
|
+
Roles); un administrador lo da. El acceso dura 90 días y lo ves, y puedes
|
|
42
|
+
revocarlo, en el menú de tu cuenta › Acceso por API.
|
|
26
43
|
|
|
27
44
|
El token queda en el llavero del sistema (en Windows, el Administrador de
|
|
28
|
-
credenciales), nunca en un archivo. En un servidor sin
|
|
29
|
-
|
|
45
|
+
credenciales), nunca en un archivo. En un servidor sin navegador ni llavero:
|
|
46
|
+
`operx login --token` te deja pegar un token creado en Acceso por API, o puedes
|
|
47
|
+
usar variables de entorno: `OPERX_TOKEN` (el token) y, si no es producción,
|
|
48
|
+
`OPERX_DIRECCION`.
|
|
30
49
|
|
|
31
50
|
Para otra dirección u otra empresa, usa un perfil:
|
|
32
51
|
|
|
33
52
|
```sh
|
|
34
|
-
operx --perfil pruebas
|
|
53
|
+
operx --perfil pruebas login --direccion https://…
|
|
35
54
|
operx --perfil pruebas ordenes listar
|
|
36
55
|
```
|
|
37
56
|
|
|
38
|
-
`operx
|
|
57
|
+
`operx logout` borra el token de este equipo; para que deje de funcionar,
|
|
39
58
|
revócalo en Acceso por API.
|
|
40
59
|
|
|
60
|
+
## Modo interactivo
|
|
61
|
+
|
|
62
|
+
Escribe `operx` solo, en una terminal, y se abre la pantalla de bienvenida
|
|
63
|
+
(quién eres y los números de hoy: órdenes abiertas, vencidas, artículos bajo
|
|
64
|
+
el mínimo) con un prompt. Ahí escribes los comandos sin el `operx`, con
|
|
65
|
+
Tab para completar y ↑ para repetir lo anterior:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
operx › ordenes listar --vencidas
|
|
69
|
+
operx › buscar "bomba"
|
|
70
|
+
operx › ayuda
|
|
71
|
+
operx › salir
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`salir` (o Ctrl+D) termina la sesión sin borrar tu token. En un script o una
|
|
75
|
+
tubería, `operx` solo muestra la ayuda y no espera a nadie.
|
|
76
|
+
|
|
41
77
|
## Comandos
|
|
42
78
|
|
|
43
79
|
```sh
|
|
@@ -121,3 +157,21 @@ python -m venv .venv && .venv/Scripts/python -m pip install -e ".[test]"
|
|
|
121
157
|
|
|
122
158
|
`operx_cli/client.py` es lo único que habla con la red; el servidor MCP lo
|
|
123
159
|
usará igual.
|
|
160
|
+
|
|
161
|
+
### Publicar una versión
|
|
162
|
+
|
|
163
|
+
El paquete es `operx-cli` en PyPI. Una versión publicada no se puede reemplazar:
|
|
164
|
+
si algo falla, se sube la siguiente.
|
|
165
|
+
|
|
166
|
+
1. Sube el número en `pyproject.toml` y en `operx_cli/__init__.py`.
|
|
167
|
+
2. Las funciones nuevas del servidor (como `operx login`) tienen que estar ya
|
|
168
|
+
desplegadas en producción antes de publicar: el paquete nuevo las llama.
|
|
169
|
+
3. Desde `cli/`, con un entorno que tenga `build` y `twine`:
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
python -m build
|
|
173
|
+
python -m twine upload dist/* # usuario __token__, y el token del proyecto
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
El token de PyPI es secreto: se escribe en tu terminal, nunca en un archivo del
|
|
177
|
+
repositorio ni en un chat.
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"""`operx`: the root of the commands, and what turns a refused call into
|
|
2
2
|
one line instead of a traceback."""
|
|
3
3
|
|
|
4
|
+
import errno
|
|
5
|
+
import os
|
|
4
6
|
import sys
|
|
5
7
|
from typing import Annotated, Optional
|
|
6
8
|
|
|
7
9
|
import typer
|
|
8
10
|
from typer.core import TyperGroup
|
|
9
11
|
|
|
10
|
-
from . import __version__, settings
|
|
12
|
+
from . import __version__, settings, shell
|
|
11
13
|
from .client import ApiError
|
|
12
14
|
from .commands import account, inventario, lecturas, ordenes, personas, raw, registros, search
|
|
13
15
|
from .commands.common import CliError, Session
|
|
@@ -18,6 +20,24 @@ from .spreadsheet import SpreadsheetError
|
|
|
18
20
|
EXPECTED_ERRORS = (ApiError, CliError, NoSingleMatch, SpreadsheetError, settings.SettingsError)
|
|
19
21
|
|
|
20
22
|
|
|
23
|
+
def _is_closed_pipe(error):
|
|
24
|
+
"""What writing to a pipe whose reader left raises: BrokenPipeError (EPIPE),
|
|
25
|
+
and on Windows a bare EINVAL. That EINVAL names no file: a bad file name
|
|
26
|
+
raises the same errno with the name in it, and must not be hidden."""
|
|
27
|
+
if isinstance(error, BrokenPipeError) or error.errno == errno.EPIPE:
|
|
28
|
+
return True
|
|
29
|
+
return error.errno == errno.EINVAL and error.filename is None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _silence_stdout():
|
|
33
|
+
"""Python flushes stdout once more on the way out, which would print
|
|
34
|
+
"Exception ignored…" over the quiet exit: point it at nothing."""
|
|
35
|
+
try:
|
|
36
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
37
|
+
except (OSError, ValueError, AttributeError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
21
41
|
class ReportedErrors(TyperGroup):
|
|
22
42
|
def invoke(self, ctx):
|
|
23
43
|
try:
|
|
@@ -25,16 +45,23 @@ class ReportedErrors(TyperGroup):
|
|
|
25
45
|
except EXPECTED_ERRORS as error:
|
|
26
46
|
typer.echo(f"Error: {error}", err=True)
|
|
27
47
|
raise typer.Exit(1) from None
|
|
28
|
-
except typer.Abort:
|
|
48
|
+
except (typer.Abort, KeyboardInterrupt):
|
|
29
49
|
# Ctrl+C, or a question whose answer never came.
|
|
30
50
|
typer.echo("Se canceló.", err=True)
|
|
31
51
|
raise typer.Exit(1) from None
|
|
52
|
+
except OSError as error:
|
|
53
|
+
if not _is_closed_pipe(error):
|
|
54
|
+
raise
|
|
55
|
+
# `operx … | head`: whoever reads left before the end. Not an error.
|
|
56
|
+
_silence_stdout()
|
|
57
|
+
raise typer.Exit(0) from None
|
|
32
58
|
|
|
33
59
|
|
|
34
60
|
app = typer.Typer(
|
|
35
61
|
cls=ReportedErrors,
|
|
36
62
|
help="OperX desde la terminal: tu empresa con tu token de API.",
|
|
37
|
-
|
|
63
|
+
# `operx` alone is the welcome and the prompt (root below), not the help.
|
|
64
|
+
no_args_is_help=False,
|
|
38
65
|
add_completion=False,
|
|
39
66
|
context_settings={"help_option_names": ["-h", "--help"]},
|
|
40
67
|
# A crash must never print the token, which lives in the client's locals.
|
|
@@ -48,7 +75,13 @@ def _show_version(value):
|
|
|
48
75
|
raise typer.Exit()
|
|
49
76
|
|
|
50
77
|
|
|
51
|
-
|
|
78
|
+
def is_interactive():
|
|
79
|
+
"""A person at a terminal, not a script or a pipe: only then does a prompt
|
|
80
|
+
make sense."""
|
|
81
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.callback(invoke_without_command=True)
|
|
52
85
|
def root(
|
|
53
86
|
ctx: typer.Context,
|
|
54
87
|
perfil: Annotated[
|
|
@@ -61,11 +94,33 @@ def root(
|
|
|
61
94
|
):
|
|
62
95
|
if ctx.obj is None:
|
|
63
96
|
ctx.obj = Session()
|
|
64
|
-
|
|
97
|
+
ctx.obj.profile = settings.profile_name(perfil)
|
|
98
|
+
elif perfil:
|
|
99
|
+
ctx.obj.profile = perfil
|
|
100
|
+
# Else the interactive mode's session, which already has its profile:
|
|
101
|
+
# every line typed runs this callback again and must not reset it.
|
|
102
|
+
if ctx.invoked_subcommand is None:
|
|
103
|
+
open_the_prompt(ctx)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def open_the_prompt(ctx):
|
|
107
|
+
"""`operx` alone: at a terminal, the welcome and the prompt; in a script
|
|
108
|
+
or a pipe, the help (a prompt would hang it)."""
|
|
109
|
+
if ctx.obj.in_shell:
|
|
110
|
+
# Typed at the prompt (`--perfil staging`): it already did what it
|
|
111
|
+
# says; a second prompt must not open inside the first.
|
|
112
|
+
return
|
|
113
|
+
if not is_interactive() and ctx.obj.reader is None:
|
|
114
|
+
typer.echo(ctx.get_help())
|
|
115
|
+
raise typer.Exit()
|
|
116
|
+
shell.enter(ctx.obj, ctx.command)
|
|
65
117
|
|
|
66
118
|
|
|
67
|
-
app.command()(account.
|
|
68
|
-
app.command()(account.
|
|
119
|
+
app.command()(account.login)
|
|
120
|
+
app.command()(account.logout)
|
|
121
|
+
# The first version's names, for whoever typed them: out of the help.
|
|
122
|
+
app.command(name="entrar", hidden=True)(account.entrar)
|
|
123
|
+
app.command(name="salir", hidden=True)(account.logout)
|
|
69
124
|
app.command()(account.cuenta)
|
|
70
125
|
app.command()(search.buscar)
|
|
71
126
|
app.command()(raw.api)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""`operx login`: signing in through the browser, the way `gh auth login`
|
|
2
|
+
does it (backend users/cli_login.py).
|
|
3
|
+
|
|
4
|
+
The terminal asks the server for a login and shows the person a short code;
|
|
5
|
+
the person opens the page, signs in as always and approves it; the terminal,
|
|
6
|
+
asking every few seconds, gets a personal API token that the person never
|
|
7
|
+
sees or copies. Nothing here is saved until the server hands the token over.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import socket
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from .client import ApiError, error_message
|
|
16
|
+
from .commands.common import CliError
|
|
17
|
+
|
|
18
|
+
NOT_A_WEB_ADDRESS = "El servidor devolvió una dirección que no es una página web, y no se abrió. No se guardó nada."
|
|
19
|
+
|
|
20
|
+
# Why a login ended without a token, by what the server's poll said.
|
|
21
|
+
ENDINGS = {
|
|
22
|
+
"denied": "Se rechazó el acceso en el navegador. No se guardó nada.",
|
|
23
|
+
"expired": "El código venció. Escribe `operx login` otra vez para pedir uno nuevo.",
|
|
24
|
+
"no_access": "Tu rol no tiene acceso por API. Pídele a un administrador de tu empresa el permiso «Acceso por API».",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ask_for_login(client):
|
|
29
|
+
"""The server's answer to a new login: the secret to poll with, the code
|
|
30
|
+
and page to show, how often to ask and how long it lasts."""
|
|
31
|
+
started = client.send("POST", "auth/cli-login/start", json={"client_name": socket.gethostname()}).json()
|
|
32
|
+
# The terminal opens this in the person's browser: only a web page.
|
|
33
|
+
if not str(started.get("verification_url", "")).lower().startswith(("https://", "http://")):
|
|
34
|
+
raise CliError(NOT_A_WEB_ADDRESS)
|
|
35
|
+
return started
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def show_how_to_approve(started, opened):
|
|
39
|
+
typer.echo("")
|
|
40
|
+
typer.echo(" Para entrar, abre esta página y escribe el código:")
|
|
41
|
+
typer.echo("")
|
|
42
|
+
typer.echo(f" {started['verification_url']} código {started['user_code']}")
|
|
43
|
+
typer.echo("")
|
|
44
|
+
typer.echo(" Abriendo el navegador…" if opened else " Abre la página en tu navegador.")
|
|
45
|
+
typer.echo(" Esperando que lo apruebes… (Ctrl+C para cancelar)")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def try_to_open(open_browser, address):
|
|
49
|
+
"""The browser, if this computer has one: a server has none, and the
|
|
50
|
+
person can open the page anywhere."""
|
|
51
|
+
try:
|
|
52
|
+
return bool(open_browser(address))
|
|
53
|
+
except Exception:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def wait_for_approval(client, session, started):
|
|
58
|
+
"""Polls until the person answers or the code runs out; the token's answer."""
|
|
59
|
+
interval = started["interval"]
|
|
60
|
+
waited = 0
|
|
61
|
+
while waited < started["expires_in"]:
|
|
62
|
+
answer = client.send("POST", "auth/cli-login/poll", json={"device_code": started["device_code"]}, check=False)
|
|
63
|
+
body = _json_of(answer)
|
|
64
|
+
if answer.status_code == 200:
|
|
65
|
+
return body
|
|
66
|
+
if answer.status_code == 202:
|
|
67
|
+
# The server may ask for more time between asks ("slow_down").
|
|
68
|
+
interval = body.get("interval", interval)
|
|
69
|
+
session.sleep(interval)
|
|
70
|
+
waited += interval
|
|
71
|
+
continue
|
|
72
|
+
ending = ENDINGS.get(body.get("status"))
|
|
73
|
+
if ending is None:
|
|
74
|
+
raise ApiError(answer.status_code, error_message(answer))
|
|
75
|
+
raise CliError(ending)
|
|
76
|
+
raise CliError(ENDINGS["expired"])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _json_of(answer):
|
|
80
|
+
try:
|
|
81
|
+
body = answer.json()
|
|
82
|
+
except ValueError:
|
|
83
|
+
return {}
|
|
84
|
+
return body if isinstance(body, dict) else {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def access_line(scope, expires_at):
|
|
88
|
+
"""'Acceso de lectura y escritura · vence el 20/01/2027'."""
|
|
89
|
+
what = "lectura y escritura" if scope == "write" else "solo lectura"
|
|
90
|
+
expires = datetime.fromisoformat(expires_at.replace("Z", "+00:00")).astimezone()
|
|
91
|
+
return f"Acceso de {what} · vence el {expires:%d/%m/%Y}"
|
|
@@ -23,7 +23,7 @@ PAGE_SIZE = 200
|
|
|
23
23
|
RETRIES_ON_429 = 3
|
|
24
24
|
LONGEST_WAIT = 60
|
|
25
25
|
|
|
26
|
-
SIGN_IN_AGAIN = "Entra de nuevo con `operx
|
|
26
|
+
SIGN_IN_AGAIN = "Entra de nuevo con `operx login`."
|
|
27
27
|
TIMED_OUT_WRITE = (
|
|
28
28
|
"La API no respondió a tiempo. Revisa en la app si el cambio quedó hecho antes de repetirlo."
|
|
29
29
|
)
|
|
@@ -77,15 +77,17 @@ class ApiClient:
|
|
|
77
77
|
def __init__(self, address, token, transport=None, sleep=time.sleep):
|
|
78
78
|
self.address = address.rstrip("/")
|
|
79
79
|
self.sleep = sleep
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
def send(self, method, path, params=None, json=None, files=None, data=None):
|
|
88
|
-
"""One call; its answer, or ApiError when the API refused it.
|
|
80
|
+
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
81
|
+
# No token (the login's first calls): no header at all. An empty
|
|
82
|
+
# "Bearer " makes the server's JWT check raise instead of answering.
|
|
83
|
+
if token:
|
|
84
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
85
|
+
self.http = httpx.Client(base_url=self.address + "/api/v1/", headers=headers, timeout=60, transport=transport)
|
|
86
|
+
|
|
87
|
+
def send(self, method, path, params=None, json=None, files=None, data=None, check=True):
|
|
88
|
+
"""One call; its answer, or ApiError when the API refused it. With
|
|
89
|
+
check=False the answer comes back whatever its status, for a call
|
|
90
|
+
whose status is its protocol (the login's poll)."""
|
|
89
91
|
method = method.upper()
|
|
90
92
|
headers = {"Idempotency-Key": str(uuid.uuid4())} if method in WRITES else {}
|
|
91
93
|
waits = 0
|
|
@@ -95,7 +97,7 @@ class ApiClient:
|
|
|
95
97
|
break
|
|
96
98
|
self.sleep(seconds_to_wait(response))
|
|
97
99
|
waits += 1
|
|
98
|
-
if response.status_code >= 400:
|
|
100
|
+
if check and response.status_code >= 400:
|
|
99
101
|
message = error_message(response)
|
|
100
102
|
if response.status_code == 401:
|
|
101
103
|
message = f"{message}\n{SIGN_IN_AGAIN}"
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
"""`operx
|
|
1
|
+
"""`operx login`, `operx logout` and `operx cuenta`: the token of a profile,
|
|
2
2
|
and who it acts as."""
|
|
3
3
|
|
|
4
|
+
import os
|
|
4
5
|
from typing import Annotated, Optional
|
|
5
6
|
|
|
6
7
|
import typer
|
|
7
8
|
|
|
8
|
-
from .. import settings
|
|
9
|
-
from ..
|
|
10
|
-
from ..output import print_record
|
|
9
|
+
from .. import browser_login, settings
|
|
10
|
+
from ..output import note, print_record
|
|
11
11
|
from .common import CliError, JsonOption, Session, client_of
|
|
12
12
|
|
|
13
13
|
TOKEN_PREFIX = "opx_"
|
|
@@ -21,29 +21,65 @@ def full_name(me):
|
|
|
21
21
|
return " ".join(f"{me.get('first_name', '')} {me.get('last_name', '')}".split()) or me.get("email", "")
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def
|
|
24
|
+
def login(
|
|
25
25
|
ctx: typer.Context,
|
|
26
26
|
direccion: Annotated[
|
|
27
27
|
Optional[str], typer.Option("--direccion", help="La dirección de la API. Sin ella, la de producción.")
|
|
28
28
|
] = None,
|
|
29
|
+
con_token: Annotated[
|
|
30
|
+
bool, typer.Option("--token", help="Pega un token en vez de abrir el navegador (para un servidor sin navegador).")
|
|
31
|
+
] = False,
|
|
29
32
|
):
|
|
30
|
-
"""
|
|
33
|
+
"""Entra con tu cuenta: abre el navegador, apruebas el código y listo. Nunca ves ni copias un token."""
|
|
31
34
|
session = ctx.find_object(Session)
|
|
32
|
-
token = typer.prompt("Pega tu token (no se mostrará)", hide_input=True).strip()
|
|
33
|
-
if not token.startswith(TOKEN_PREFIX):
|
|
34
|
-
raise CliError("Eso no es un token de OperX: empieza por «opx_». Créalo en la app, en Acceso por API.")
|
|
35
35
|
address = direccion.rstrip("/") if direccion else session.address()
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
if os.environ.get("OPERX_TOKEN"):
|
|
37
|
+
note("Ojo: OPERX_TOKEN está definido y gana sobre el token que se guarde aquí.")
|
|
38
|
+
if con_token:
|
|
39
|
+
token = _pasted_token()
|
|
40
|
+
me = session.client_with(address, token).get("auth/me")
|
|
41
|
+
extra = None
|
|
42
|
+
else:
|
|
43
|
+
token, me, extra = _token_from_browser(session, address)
|
|
44
|
+
# Kept only after the API accepted it.
|
|
38
45
|
settings.save_token(session.profile, token)
|
|
39
46
|
if direccion:
|
|
40
47
|
settings.save_address(session.profile, address)
|
|
41
|
-
|
|
48
|
+
company = str(me.get("client_name") or "tu empresa")
|
|
49
|
+
typer.echo(f"Entraste como {full_name(me)} en {company}" + ("" if company.endswith(".") else "."))
|
|
50
|
+
if extra:
|
|
51
|
+
typer.echo(extra)
|
|
42
52
|
typer.echo(f"Perfil «{session.profile}», {address}.")
|
|
43
53
|
|
|
44
54
|
|
|
45
|
-
def
|
|
46
|
-
|
|
55
|
+
def _pasted_token():
|
|
56
|
+
token = typer.prompt("Pega tu token (no se mostrará)", hide_input=True).strip()
|
|
57
|
+
if not token.startswith(TOKEN_PREFIX):
|
|
58
|
+
raise CliError("Eso no es un token de OperX: empieza por «opx_». Créalo en la app, en Acceso por API.")
|
|
59
|
+
return token
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _token_from_browser(session, address):
|
|
63
|
+
anonymous = session.client_with(address, None)
|
|
64
|
+
started = browser_login.ask_for_login(anonymous)
|
|
65
|
+
opened = browser_login.try_to_open(session.open_browser, started["verification_url"])
|
|
66
|
+
browser_login.show_how_to_approve(started, opened)
|
|
67
|
+
approved = browser_login.wait_for_approval(anonymous, session, started)
|
|
68
|
+
me = session.client_with(address, approved["key"]).get("auth/me")
|
|
69
|
+
return approved["key"], me, browser_login.access_line(approved["scope"], approved["expires_at"])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def entrar(
|
|
73
|
+
ctx: typer.Context,
|
|
74
|
+
direccion: Annotated[Optional[str], typer.Option("--direccion")] = None,
|
|
75
|
+
):
|
|
76
|
+
"""The first version's name for `login --token`: it reads a pasted token,
|
|
77
|
+
as it did, so a script that piped one in doesn't wait for a browser."""
|
|
78
|
+
login(ctx, direccion=direccion, con_token=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def logout(ctx: typer.Context):
|
|
82
|
+
"""Borra el token de este equipo. El token sigue vivo: revócalo en Acceso por API si ya no lo usas."""
|
|
47
83
|
session = ctx.find_object(Session)
|
|
48
84
|
settings.forget_token(session.profile)
|
|
49
85
|
typer.echo(f"Se borró el token del perfil «{session.profile}».")
|
|
@@ -3,6 +3,8 @@ options that choose how results are shown, and the question before a
|
|
|
3
3
|
write."""
|
|
4
4
|
|
|
5
5
|
import sys
|
|
6
|
+
import time
|
|
7
|
+
import webbrowser
|
|
6
8
|
from dataclasses import dataclass
|
|
7
9
|
from typing import Annotated, Optional
|
|
8
10
|
|
|
@@ -13,7 +15,7 @@ from ..client import ApiClient
|
|
|
13
15
|
from ..output import note
|
|
14
16
|
from ..values import decimal_text, iso_date, iso_datetime
|
|
15
17
|
|
|
16
|
-
NOT_SIGNED_IN = "No has entrado en este perfil.
|
|
18
|
+
NOT_SIGNED_IN = "No has entrado en este perfil. Escribe `operx login`."
|
|
17
19
|
NEEDS_SI = "Esto cambia datos de tu empresa. Fuera de una terminal agrega --si para confirmarlo."
|
|
18
20
|
|
|
19
21
|
|
|
@@ -26,6 +28,14 @@ class Session:
|
|
|
26
28
|
profile: str = settings.DEFAULT_PROFILE
|
|
27
29
|
# httpx's transport: None goes to the network; the tests pass a fake API.
|
|
28
30
|
transport: object = None
|
|
31
|
+
# What waits and what opens the browser, in the tests' hands too.
|
|
32
|
+
open_browser: object = webbrowser.open
|
|
33
|
+
sleep: object = time.sleep
|
|
34
|
+
# Reads a line at the interactive prompt; None: the terminal's own.
|
|
35
|
+
reader: object = None
|
|
36
|
+
# While the interactive prompt runs: what is typed there must not open
|
|
37
|
+
# another one.
|
|
38
|
+
in_shell: bool = False
|
|
29
39
|
|
|
30
40
|
def address(self):
|
|
31
41
|
return settings.address_of(self.profile)
|
|
@@ -34,7 +44,11 @@ class Session:
|
|
|
34
44
|
token = settings.token_of(self.profile)
|
|
35
45
|
if not token:
|
|
36
46
|
raise CliError(NOT_SIGNED_IN)
|
|
37
|
-
return ApiClient(self.address(), token, transport=self.transport)
|
|
47
|
+
return ApiClient(self.address(), token, transport=self.transport, sleep=self.sleep)
|
|
48
|
+
|
|
49
|
+
def client_with(self, address, token):
|
|
50
|
+
"""A client for an address and a token not saved (yet): the login's."""
|
|
51
|
+
return ApiClient(address, token, transport=self.transport, sleep=self.sleep)
|
|
38
52
|
|
|
39
53
|
|
|
40
54
|
def client_of(ctx):
|