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.
Files changed (45) hide show
  1. {operx_cli-0.1.0 → operx_cli-0.2.0}/PKG-INFO +70 -15
  2. {operx_cli-0.1.0 → operx_cli-0.2.0}/README.md +68 -14
  3. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/__init__.py +1 -1
  4. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/app.py +62 -7
  5. operx_cli-0.2.0/operx_cli/browser_login.py +91 -0
  6. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/client.py +13 -11
  7. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/account.py +50 -14
  8. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/common.py +16 -2
  9. operx_cli-0.2.0/operx_cli/shell.py +254 -0
  10. operx_cli-0.2.0/operx_cli/welcome.py +108 -0
  11. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli.egg-info/PKG-INFO +70 -15
  12. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli.egg-info/SOURCES.txt +7 -1
  13. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli.egg-info/requires.txt +1 -0
  14. {operx_cli-0.1.0 → operx_cli-0.2.0}/pyproject.toml +3 -1
  15. {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_app.py +44 -12
  16. {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_client.py +31 -1
  17. operx_cli-0.2.0/tests/test_login.py +196 -0
  18. operx_cli-0.2.0/tests/test_shell.py +248 -0
  19. operx_cli-0.2.0/tests/test_welcome.py +124 -0
  20. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/__init__.py +0 -0
  21. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/inventario.py +0 -0
  22. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/lecturas.py +0 -0
  23. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/ordenes.py +0 -0
  24. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/personas.py +0 -0
  25. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/raw.py +0 -0
  26. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/registros.py +0 -0
  27. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/commands/search.py +0 -0
  28. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/lookup.py +0 -0
  29. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/output.py +0 -0
  30. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/settings.py +0 -0
  31. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/spreadsheet.py +0 -0
  32. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli/values.py +0 -0
  33. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli.egg-info/dependency_links.txt +0 -0
  34. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli.egg-info/entry_points.txt +0 -0
  35. {operx_cli-0.1.0 → operx_cli-0.2.0}/operx_cli.egg-info/top_level.txt +0 -0
  36. {operx_cli-0.1.0 → operx_cli-0.2.0}/setup.cfg +0 -0
  37. {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_inventario_lecturas.py +0 -0
  38. {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_lookup.py +0 -0
  39. {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_ordenes.py +0 -0
  40. {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_output.py +0 -0
  41. {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_personas.py +0 -0
  42. {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_registros.py +0 -0
  43. {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_settings.py +0 -0
  44. {operx_cli-0.1.0 → operx_cli-0.2.0}/tests/test_spreadsheet.py +0 -0
  45. {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.1.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
- Necesitas Python 3.10 o más nuevo.
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
- pipx install ./cli # desde esta carpeta del repositorio
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
- 1. En la app, abre el menú de tu cuenta › **Acceso por API** y crea un token.
32
- Elige *Lectura y escritura* si vas a crear o cambiar datos.
33
- 2. Guárdalo en tu equipo:
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
- ```sh
36
- operx entrar # pega el token; no se muestra
37
- operx cuenta # quién eres, tu empresa y tu rol
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 llavero, usa variables
42
- de entorno: `OPERX_TOKEN` (el token) y, si no es producción, `OPERX_DIRECCION`.
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 entrar --direccion https://…
67
+ operx --perfil pruebas login --direccion https://…
48
68
  operx --perfil pruebas ordenes listar
49
69
  ```
50
70
 
51
- `operx salir` borra el token de este equipo; para que deje de funcionar,
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
- Necesitas Python 3.10 o más nuevo.
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
- pipx install ./cli # desde esta carpeta del repositorio
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
- 1. En la app, abre el menú de tu cuenta › **Acceso por API** y crea un token.
19
- Elige *Lectura y escritura* si vas a crear o cambiar datos.
20
- 2. Guárdalo en tu equipo:
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
- ```sh
23
- operx entrar # pega el token; no se muestra
24
- operx cuenta # quién eres, tu empresa y tu rol
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 llavero, usa variables
29
- de entorno: `OPERX_TOKEN` (el token) y, si no es producción, `OPERX_DIRECCION`.
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 entrar --direccion https://…
53
+ operx --perfil pruebas login --direccion https://…
35
54
  operx --perfil pruebas ordenes listar
36
55
  ```
37
56
 
38
- `operx salir` borra el token de este equipo; para que deje de funcionar,
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.
@@ -2,4 +2,4 @@
2
2
  API (backend/users/api_allowlist.py) from a terminal, with a personal API
3
3
  token."""
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "0.2.0"
@@ -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
- no_args_is_help=True,
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
- @app.callback()
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
- ctx.obj.profile = settings.profile_name(perfil)
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.entrar)
68
- app.command()(account.salir)
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 entrar`."
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
- self.http = httpx.Client(
81
- base_url=self.address + "/api/v1/",
82
- headers={"Authorization": f"Bearer {token}", "User-Agent": USER_AGENT, "Accept": "application/json"},
83
- timeout=60,
84
- transport=transport,
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 entrar`, `operx salir` and `operx cuenta`: the token of a profile,
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 ..client import ApiClient
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 entrar(
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
- """Guarda tu token de API en el llavero del sistema. Créalo en la app: menú de tu cuenta › Acceso por API."""
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
- # Checked before it's kept: a token the API refuses is never saved.
37
- me = ApiClient(address, token, transport=session.transport).get("auth/me")
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
- typer.echo(f"Listo: entraste como {full_name(me)} en {me.get('client_name')}.")
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 salir(ctx: typer.Context):
46
- """Borra el token de este perfil del llavero. El token sigue vivo: revócalo en Acceso por API si ya no lo usas."""
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. Usa `operx entrar` con un token de Acceso por API."
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):