operx-cli 0.3.0__tar.gz → 0.3.2__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 (63) hide show
  1. {operx_cli-0.3.0 → operx_cli-0.3.2}/PKG-INFO +8 -5
  2. {operx_cli-0.3.0 → operx_cli-0.3.2}/README.md +7 -4
  3. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/__init__.py +1 -1
  4. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/app.py +6 -1
  5. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/commands/common.py +3 -0
  6. operx_cli-0.3.2/operx_cli/commands/update.py +37 -0
  7. operx_cli-0.3.2/operx_cli/self_update.py +79 -0
  8. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/shell.py +53 -17
  9. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/updates.py +6 -2
  10. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/welcome.py +1 -2
  11. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli.egg-info/PKG-INFO +8 -5
  12. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli.egg-info/SOURCES.txt +3 -0
  13. {operx_cli-0.3.0 → operx_cli-0.3.2}/pyproject.toml +1 -1
  14. operx_cli-0.3.2/tests/test_self_update.py +155 -0
  15. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_shell.py +54 -2
  16. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_welcome.py +1 -1
  17. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/AGENTES.md +0 -0
  18. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/agent_guide.py +0 -0
  19. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/browser_login.py +0 -0
  20. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/client.py +0 -0
  21. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/commands/__init__.py +0 -0
  22. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/commands/account.py +0 -0
  23. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/commands/ambiental.py +0 -0
  24. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/commands/empresas.py +0 -0
  25. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/commands/inventario.py +0 -0
  26. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/commands/lecturas.py +0 -0
  27. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/commands/ordenes.py +0 -0
  28. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/commands/personas.py +0 -0
  29. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/commands/preventivos.py +0 -0
  30. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/commands/raw.py +0 -0
  31. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/commands/registros.py +0 -0
  32. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/commands/search.py +0 -0
  33. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/commands/sst.py +0 -0
  34. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/lookup.py +0 -0
  35. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/output.py +0 -0
  36. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/record_completion.py +0 -0
  37. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/settings.py +0 -0
  38. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/spreadsheet.py +0 -0
  39. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli/values.py +0 -0
  40. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli.egg-info/dependency_links.txt +0 -0
  41. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli.egg-info/entry_points.txt +0 -0
  42. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli.egg-info/requires.txt +0 -0
  43. {operx_cli-0.3.0 → operx_cli-0.3.2}/operx_cli.egg-info/top_level.txt +0 -0
  44. {operx_cli-0.3.0 → operx_cli-0.3.2}/setup.cfg +0 -0
  45. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_agentes.py +0 -0
  46. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_app.py +0 -0
  47. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_cards.py +0 -0
  48. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_client.py +0 -0
  49. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_inventario_lecturas.py +0 -0
  50. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_login.py +0 -0
  51. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_logout_and_routes.py +0 -0
  52. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_lookup.py +0 -0
  53. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_ordenes.py +0 -0
  54. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_output.py +0 -0
  55. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_personas.py +0 -0
  56. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_preventivos.py +0 -0
  57. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_record_completion.py +0 -0
  58. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_registros.py +0 -0
  59. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_settings.py +0 -0
  60. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_spreadsheet.py +0 -0
  61. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_sst_ambiental.py +0 -0
  62. {operx_cli-0.3.0 → operx_cli-0.3.2}/tests/test_updates.py +0 -0
  63. {operx_cli-0.3.0 → operx_cli-0.3.2}/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.0
3
+ Version: 0.3.2
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
@@ -36,8 +36,10 @@ curl -LsSf https://astral.sh/uv/install.sh | sh && uv tool install operx-cli
36
36
  Python 3.10 o más nuevo, también sirve `pipx install operx-cli`. Abre una
37
37
  terminal nueva y comprueba con `operx --version`.
38
38
 
39
- Para actualizar: cierra `operx` y escribe `uv tool upgrade operx-cli`. La
40
- pantalla de bienvenida avisa cuando hay una versión nueva.
39
+ Para actualizar: `operx update`. La pantalla de bienvenida avisa cuando hay una
40
+ versión nueva. En Windows la instala otra ventana, que espera a que se cierre
41
+ `operx` (Windows no deja reemplazar un programa abierto); si quedó a medias,
42
+ `uv tool install operx-cli --force --refresh` lo deja bien.
41
43
 
42
44
  ## Entrar
43
45
 
@@ -87,8 +89,9 @@ operx › ayuda
87
89
  operx › salir
88
90
  ```
89
91
 
90
- `salir` (o Ctrl+D) termina la sesión sin borrar tu token. En un script o una
91
- tubería, `operx` solo muestra la ayuda y no espera a nadie.
92
+ `salir` (o Ctrl+D) termina la sesión sin borrar tu token. `ayuda` y Tab
93
+ muestran solo los comandos de los productos que tiene tu empresa. En un script
94
+ o una tubería, `operx` solo muestra la ayuda y no espera a nadie.
92
95
 
93
96
  ## Comandos
94
97
 
@@ -22,8 +22,10 @@ curl -LsSf https://astral.sh/uv/install.sh | sh && uv tool install operx-cli
22
22
  Python 3.10 o más nuevo, también sirve `pipx install operx-cli`. Abre una
23
23
  terminal nueva y comprueba con `operx --version`.
24
24
 
25
- Para actualizar: cierra `operx` y escribe `uv tool upgrade operx-cli`. La
26
- pantalla de bienvenida avisa cuando hay una versión nueva.
25
+ Para actualizar: `operx update`. La pantalla de bienvenida avisa cuando hay una
26
+ versión nueva. En Windows la instala otra ventana, que espera a que se cierre
27
+ `operx` (Windows no deja reemplazar un programa abierto); si quedó a medias,
28
+ `uv tool install operx-cli --force --refresh` lo deja bien.
27
29
 
28
30
  ## Entrar
29
31
 
@@ -73,8 +75,9 @@ operx › ayuda
73
75
  operx › salir
74
76
  ```
75
77
 
76
- `salir` (o Ctrl+D) termina la sesión sin borrar tu token. En un script o una
77
- tubería, `operx` solo muestra la ayuda y no espera a nadie.
78
+ `salir` (o Ctrl+D) termina la sesión sin borrar tu token. `ayuda` y Tab
79
+ muestran solo los comandos de los productos que tiene tu empresa. En un script
80
+ o una tubería, `operx` solo muestra la ayuda y no espera a nadie.
78
81
 
79
82
  ## Comandos
80
83
 
@@ -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.3.0"
5
+ __version__ = "0.3.2"
@@ -12,7 +12,9 @@ from typer.core import TyperGroup
12
12
  from . import __version__, settings, shell
13
13
  from .agent_guide import guide_text
14
14
  from .client import ApiError
15
- from .commands import ambiental, account, inventario, lecturas, ordenes, personas, preventivos, raw, registros, search, sst
15
+ from .commands import (
16
+ account, ambiental, inventario, lecturas, ordenes, personas, preventivos, raw, registros, search, sst, update,
17
+ )
16
18
  from .commands.common import CliError, Session
17
19
  from .lookup import NoSingleMatch
18
20
  from .spreadsheet import SpreadsheetError
@@ -123,6 +125,9 @@ app.command()(account.logout)
123
125
  app.command(name="entrar", hidden=True)(account.entrar)
124
126
  app.command(name="salir", hidden=True)(account.logout)
125
127
  app.command()(account.cuenta)
128
+ app.command()(update.update)
129
+ # In Spanish too, as the other commands are: out of the help.
130
+ app.command(name="actualizar", hidden=True)(update.update)
126
131
  app.command()(search.buscar)
127
132
  app.command()(raw.api)
128
133
  app.add_typer(ordenes.app, name="ordenes")
@@ -36,6 +36,9 @@ class Session:
36
36
  # While the interactive prompt runs: what is typed there must not open
37
37
  # another one.
38
38
  in_shell: bool = False
39
+ # Set by a command that needs this operx closed (`update` on Windows):
40
+ # the prompt ends after it.
41
+ leave_shell: bool = False
39
42
 
40
43
  def address(self):
41
44
  return settings.address_of(self.profile)
@@ -0,0 +1,37 @@
1
+ """`operx update` (self_update.py does the installing)."""
2
+
3
+ import os
4
+
5
+ import typer
6
+
7
+ from .. import __version__, self_update, updates
8
+ from .common import CliError, Session
9
+
10
+ NO_INSTALLER = (
11
+ "No encontré uv ni pipx para actualizar. Actualiza como instalaste operx: "
12
+ "uv tool upgrade operx-cli (o pipx upgrade operx-cli)."
13
+ )
14
+
15
+
16
+ def update(ctx: typer.Context):
17
+ """Instala la versión más nueva de operx."""
18
+ # Asked now, not the welcome's once-a-day answer: the person wants to know.
19
+ latest = updates.fetch_latest()
20
+ if latest and not updates.is_newer(latest, __version__):
21
+ typer.echo(f"Ya tienes la versión más nueva: {__version__}.")
22
+ return
23
+ command = self_update.installer_command(latest)
24
+ if command is None:
25
+ raise CliError(NO_INSTALLER)
26
+ target = f"la {latest}" if latest else "la versión más nueva"
27
+
28
+ if self_update.on_windows():
29
+ self_update.start_window(self_update.window_script(command, os.getpid()))
30
+ typer.echo(f"Instalando {target} en otra ventana. Esta sesión de operx se cierra para que Windows deje reemplazarlo.")
31
+ ctx.find_object(Session).leave_shell = True
32
+ return
33
+
34
+ if self_update.run_here(command) != 0:
35
+ raise CliError("No se pudo actualizar: mira el mensaje de arriba.")
36
+ typer.echo(f"Listo: tienes {target}. Abre operx de nuevo para usarla.")
37
+ ctx.find_object(Session).leave_shell = True
@@ -0,0 +1,79 @@
1
+ """`operx update`: the newest operx from PyPI, installed the way it came
2
+ (uv, or pipx).
3
+
4
+ On Windows a running program can't be replaced: uv answers "Access is
5
+ denied" and leaves an install that no longer starts. There the work goes to
6
+ a PowerShell window that waits for this operx to close first. Elsewhere it
7
+ happens right here."""
8
+
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+
14
+ PACKAGE = "operx-cli"
15
+ # How long the window waits for this operx to close before trying anyway.
16
+ WAIT_SECONDS = 60
17
+
18
+
19
+ def on_windows():
20
+ return os.name == "nt"
21
+
22
+
23
+ def installer_command(latest=None):
24
+ """The command that installs the newest operx, or None when neither uv
25
+ nor pipx is here. --force reinstalls without a pin (a pinned install
26
+ never upgrades); --refresh skips uv's cached answers, which can lag
27
+ behind a release (--refresh-package alone put 0.3.0 back the minute
28
+ 0.3.1 came out). With `latest`, PyPI's newest, uv must install at least
29
+ that one: it fails rather than put the old one back."""
30
+ if "pipx" in sys.prefix.lower():
31
+ pipx = shutil.which("pipx")
32
+ return [pipx, "upgrade", PACKAGE] if pipx else None
33
+ uv = shutil.which("uv")
34
+ spec = f"{PACKAGE}>={latest}" if latest else PACKAGE
35
+ return [uv, "tool", "install", spec, "--force", "--refresh"] if uv else None
36
+
37
+
38
+ def _quoted(part):
39
+ """A PowerShell literal: single quotes, a quote inside doubled."""
40
+ return "'" + str(part).replace("'", "''") + "'"
41
+
42
+
43
+ def window_script(command, pid):
44
+ """What the window runs: wait for operx (process `pid`) and any other
45
+ open operx to close, install, and say how it went. It closes by itself
46
+ when it worked; when not, it says the command that repairs it, since a
47
+ half-done install may not start operx anymore."""
48
+ install = "& " + " ".join(_quoted(part) for part in command)
49
+ by_hand = " ".join([os.path.splitext(os.path.basename(command[0]))[0], *command[1:]])
50
+ return "; ".join([
51
+ "$Host.UI.RawUI.WindowTitle = 'Actualizando operx'",
52
+ "Write-Host 'Esperando a que se cierre operx…'",
53
+ f"Wait-Process -Id {pid} -Timeout {WAIT_SECONDS} -ErrorAction SilentlyContinue",
54
+ # operx.exe, the launcher around this Python, closes right after it.
55
+ "Start-Sleep -Seconds 1",
56
+ # Another window with operx open would make the install fail halfway.
57
+ "$others = Get-Process operx -ErrorAction SilentlyContinue",
58
+ "if ($others) { Write-Host 'Hay otra ventana con operx abierto: ciérrala para seguir.' -ForegroundColor Yellow; "
59
+ f"$others | Wait-Process -Timeout {WAIT_SECONDS * 2} -ErrorAction SilentlyContinue }}",
60
+ install,
61
+ "if ($LASTEXITCODE -eq 0) { "
62
+ "Write-Host ''; Write-Host 'Listo: ya tienes la versión nueva. Abre operx de nuevo.' -ForegroundColor Green; "
63
+ "Start-Sleep -Seconds 6 } else { "
64
+ "Write-Host ''; Write-Host 'No se pudo actualizar. Cierra las ventanas con operx abierto y escribe: "
65
+ f"{by_hand}' -ForegroundColor Red; Read-Host 'Pulsa Enter para cerrar' }}",
66
+ ])
67
+
68
+
69
+ def start_window(script):
70
+ """A PowerShell window of its own: it outlives this operx."""
71
+ subprocess.Popen(
72
+ ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
73
+ creationflags=subprocess.CREATE_NEW_CONSOLE,
74
+ )
75
+
76
+
77
+ def run_here(command):
78
+ """The install in this terminal, its output shown; its exit code."""
79
+ return subprocess.run(command).returncode
@@ -30,6 +30,27 @@ SHELL_WORDS = [
30
30
  ("ayuda", "Muestra los comandos. También: help, ?."),
31
31
  ]
32
32
  AMBER = "#fbbf24"
33
+ # What a command needs: one of these products (the backend's tenants/products.py
34
+ # API_PRODUCT). Without one, the API answers "Tu empresa no tiene …", so the
35
+ # prompt doesn't offer it. A command not here works for every company.
36
+ COMMAND_PRODUCTS = {
37
+ "ordenes": ("maintenance",),
38
+ "preventivos": ("maintenance",),
39
+ "equipos": ("maintenance", "inventory", "equipment", "environment"),
40
+ "clientes": ("maintenance", "inventory", "sst", "environment"),
41
+ "inventario": ("inventory",),
42
+ "lecturas": ("equipment",),
43
+ "personas importar": ("people",),
44
+ "sst": ("sst",),
45
+ "ambiental": ("environment",),
46
+ }
47
+
48
+
49
+ def offered_to(command_path, products):
50
+ """Whether the company's products let it use the command; every command
51
+ when they aren't known (not signed in)."""
52
+ needed = COMMAND_PRODUCTS.get(command_path)
53
+ return products is None or needed is None or bool(set(needed) & set(products))
33
54
 
34
55
 
35
56
  def split_line(line, windows=None):
@@ -66,16 +87,18 @@ def _option_named(command, word):
66
87
  return None
67
88
 
68
89
 
69
- def suggestions(root, text):
90
+ def suggestions(root, text, products=None):
70
91
  """What to offer after `text`, as (word, help) pairs: the commands of
71
- where the line has got to, the options of a command, or the values an
72
- option allows."""
92
+ where the line has got to (only those `products` allow, when known), the
93
+ options of a command, or the values an option allows."""
73
94
  words = text.split()
74
95
  current = "" if not words or text.endswith(" ") else words.pop()
75
96
  node = root
97
+ path = []
76
98
  for word in words:
77
99
  if hasattr(node, "commands") and word in node.commands:
78
100
  node = node.commands[word]
101
+ path.append(word)
79
102
 
80
103
  option = _option_named(node, words[-1]) if words else None
81
104
  if option is not None and not getattr(option, "is_flag", False):
@@ -83,7 +106,10 @@ def suggestions(root, text):
83
106
  return [(choice, "") for choice in choices if choice.startswith(current)]
84
107
 
85
108
  if hasattr(node, "commands"):
86
- offered = [(name, _first_line(command.help)) for name, command in node.commands.items() if _visible(command)]
109
+ offered = [
110
+ (name, _first_line(command.help)) for name, command in node.commands.items()
111
+ if _visible(command) and offered_to(" ".join([*path, name]), products)
112
+ ]
87
113
  if node is root:
88
114
  offered += SHELL_WORDS
89
115
  return [(name, help_text) for name, help_text in offered if name.startswith(current)]
@@ -114,9 +140,12 @@ def spanish_usage_error(error):
114
140
  return f"No entendí eso ({message}). Prueba con --help."
115
141
 
116
142
 
117
- def print_help(root, console):
143
+ def print_help(root, console, products=None):
118
144
  console.print(Text("Comandos", style=f"bold {AMBER}"))
119
- commands = [(name, _first_line(command.help)) for name, command in root.commands.items() if _visible(command)]
145
+ commands = [
146
+ (name, _first_line(command.help)) for name, command in root.commands.items()
147
+ if _visible(command) and offered_to(name, products)
148
+ ]
120
149
  # Two columns: on a narrow screen a long description wraps inside its own.
121
150
  print_aligned(console, commands + SHELL_WORDS, label_style="bold")
122
151
  console.print(Text("Escribe un comando con --help para ver sus opciones.", style="dim"))
@@ -137,8 +166,9 @@ def run_command(session, root, argv):
137
166
  typer.echo(f"Error: algo falló ({type(error).__name__}).", err=True)
138
167
 
139
168
 
140
- def handle_line(session, root, line, console):
141
- """Runs one typed line; False when the person asked to leave."""
169
+ def handle_line(session, root, line, console, products=None):
170
+ """Runs one typed line; False when the person asked to leave. `products`:
171
+ the company's, so `ayuda` lists only what it can use."""
142
172
  try:
143
173
  argv = split_line(line)
144
174
  except ValueError:
@@ -151,7 +181,7 @@ def handle_line(session, root, line, console):
151
181
  if word in STOP_WORDS:
152
182
  return False
153
183
  if word in HELP_WORDS:
154
- print_help(root, console)
184
+ print_help(root, console, products)
155
185
  return True
156
186
  if word in CLEAR_WORDS:
157
187
  console.clear()
@@ -166,11 +196,11 @@ def history_path():
166
196
  return folder / "historial"
167
197
 
168
198
 
169
- def make_reader(root, status="", records=None):
199
+ def make_reader(root, status="", records=None, products=None):
170
200
  """A function that reads a line: prompt_toolkit with completion and a
171
201
  history where the terminal allows it, else the plain input(). `records`
172
202
  (record_completion.RecordNames) adds the company's records where a
173
- command takes one."""
203
+ command takes one; `products` leaves out the commands it can't use."""
174
204
  try:
175
205
  from prompt_toolkit import PromptSession
176
206
  from prompt_toolkit.completion import Completer, Completion, ThreadedCompleter
@@ -182,7 +212,7 @@ def make_reader(root, status="", records=None):
182
212
  text = document.text_before_cursor
183
213
  words = text.split()
184
214
  typed = "" if not text or text.endswith(" ") else words.pop()
185
- offered = suggestions(root, text)
215
+ offered = suggestions(root, text, products)
186
216
  for name, help_text in offered:
187
217
  yield Completion(name, start_position=-len(typed), display_meta=help_text)
188
218
  if offered or records is None:
@@ -246,14 +276,17 @@ def enter(session, root):
246
276
  console = Console()
247
277
  me = print_welcome(session, console)
248
278
  print_tips(console, (me or {}).get("products") or [])
249
- run_shell(session, root, console, reader=session.reader, status=status_line(me))
279
+ # Unknown without signing in: then every command is offered.
280
+ products = me.get("products") if me else None
281
+ run_shell(session, root, console, reader=session.reader, status=status_line(me), products=products)
250
282
 
251
283
 
252
- def run_shell(session, root, console=None, reader=None, status=""):
284
+ def run_shell(session, root, console=None, reader=None, status="", products=None):
253
285
  """The prompt: reads a line, runs it, until the person leaves (salir,
254
- Ctrl+D). Ctrl+C only clears the line."""
286
+ Ctrl+D). Ctrl+C only clears the line. `products`: the company's, so
287
+ `ayuda` and Tab offer only what it can use."""
255
288
  console = console or Console()
256
- reader = reader or make_reader(root, status, RecordNames(session))
289
+ reader = reader or make_reader(root, status, RecordNames(session), products)
257
290
  session.in_shell = True
258
291
  try:
259
292
  while True:
@@ -263,7 +296,10 @@ def run_shell(session, root, console=None, reader=None, status=""):
263
296
  break
264
297
  except KeyboardInterrupt:
265
298
  continue
266
- if not handle_line(session, root, line, console):
299
+ if not handle_line(session, root, line, console, products):
300
+ break
301
+ if session.leave_shell:
302
+ # `update` on Windows: this operx must close to be replaced.
267
303
  break
268
304
  finally:
269
305
  session.in_shell = False
@@ -14,7 +14,7 @@ PYPI_URL = "https://pypi.org/pypi/operx-cli/json"
14
14
  CHECK_EVERY = timedelta(days=1)
15
15
  TIMEOUT_SECONDS = 1.0
16
16
  SAVED_FILE = "version.json"
17
- UPGRADE_LINE = "uv tool upgrade operx-cli"
17
+ UPGRADE_LINE = "operx update"
18
18
 
19
19
 
20
20
  def fetch_latest(transport=None):
@@ -40,11 +40,15 @@ def newer_version(current, fetch=None, now=None):
40
40
  latest = fetch()
41
41
  # Kept even when PyPI didn't answer: offline, it's tried again tomorrow.
42
42
  _save(now, latest)
43
- if latest and _parts(latest) > _parts(current):
43
+ if latest and is_newer(latest, current):
44
44
  return latest
45
45
  return None
46
46
 
47
47
 
48
+ def is_newer(version, current):
49
+ return _parts(version) > _parts(current)
50
+
51
+
48
52
  def _parts(version):
49
53
  """'0.10.2' → (0, 10, 2), so 0.10 comes after 0.9."""
50
54
  try:
@@ -87,8 +87,7 @@ def print_welcome(session, console=None):
87
87
  console.print(Text(f"OperX desde la terminal · v{__version__}", style="dim"))
88
88
  newer = updates.newer_version(__version__)
89
89
  if newer:
90
- # An open operx keeps Windows from replacing it: close it first.
91
- console.print(Text(f"Hay una versión nueva: {newer}. Cierra operx y escribe: {updates.UPGRADE_LINE}", style=AMBER))
90
+ console.print(Text(f"Hay una versión nueva: {newer}. Escribe: {updates.UPGRADE_LINE}", style=AMBER))
92
91
  console.print()
93
92
 
94
93
  token = settings.token_of(session.profile)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: operx-cli
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
@@ -36,8 +36,10 @@ curl -LsSf https://astral.sh/uv/install.sh | sh && uv tool install operx-cli
36
36
  Python 3.10 o más nuevo, también sirve `pipx install operx-cli`. Abre una
37
37
  terminal nueva y comprueba con `operx --version`.
38
38
 
39
- Para actualizar: cierra `operx` y escribe `uv tool upgrade operx-cli`. La
40
- pantalla de bienvenida avisa cuando hay una versión nueva.
39
+ Para actualizar: `operx update`. La pantalla de bienvenida avisa cuando hay una
40
+ versión nueva. En Windows la instala otra ventana, que espera a que se cierre
41
+ `operx` (Windows no deja reemplazar un programa abierto); si quedó a medias,
42
+ `uv tool install operx-cli --force --refresh` lo deja bien.
41
43
 
42
44
  ## Entrar
43
45
 
@@ -87,8 +89,9 @@ operx › ayuda
87
89
  operx › salir
88
90
  ```
89
91
 
90
- `salir` (o Ctrl+D) termina la sesión sin borrar tu token. En un script o una
91
- tubería, `operx` solo muestra la ayuda y no espera a nadie.
92
+ `salir` (o Ctrl+D) termina la sesión sin borrar tu token. `ayuda` y Tab
93
+ muestran solo los comandos de los productos que tiene tu empresa. En un script
94
+ o una tubería, `operx` solo muestra la ayuda y no espera a nadie.
92
95
 
93
96
  ## Comandos
94
97
 
@@ -9,6 +9,7 @@ operx_cli/client.py
9
9
  operx_cli/lookup.py
10
10
  operx_cli/output.py
11
11
  operx_cli/record_completion.py
12
+ operx_cli/self_update.py
12
13
  operx_cli/settings.py
13
14
  operx_cli/shell.py
14
15
  operx_cli/spreadsheet.py
@@ -35,6 +36,7 @@ operx_cli/commands/raw.py
35
36
  operx_cli/commands/registros.py
36
37
  operx_cli/commands/search.py
37
38
  operx_cli/commands/sst.py
39
+ operx_cli/commands/update.py
38
40
  tests/test_agentes.py
39
41
  tests/test_app.py
40
42
  tests/test_cards.py
@@ -49,6 +51,7 @@ tests/test_personas.py
49
51
  tests/test_preventivos.py
50
52
  tests/test_record_completion.py
51
53
  tests/test_registros.py
54
+ tests/test_self_update.py
52
55
  tests/test_settings.py
53
56
  tests/test_shell.py
54
57
  tests/test_spreadsheet.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "operx-cli"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "La línea de comandos de OperX: tu empresa desde la terminal, con tu token de API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,155 @@
1
+ """`operx update`: the newest operx from PyPI, installed the way it was. On
2
+ Windows a running program can't be replaced, so a PowerShell window does it
3
+ once this operx has closed."""
4
+
5
+ import os
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+ from operx_cli import __version__, self_update
11
+ from operx_cli.commands.common import Session
12
+ from operx_cli.shell import run_shell
13
+
14
+ from .fake_api import FakeApi, run
15
+ from .test_shell import ROOT, typed
16
+
17
+ UV = r"C:\Users\Ana\.local\bin\uv.exe"
18
+
19
+
20
+ @pytest.fixture
21
+ def started(monkeypatch):
22
+ """The PowerShell windows `operx update` asked for, and the commands it ran here."""
23
+ calls = {"windows": [], "ran": []}
24
+ monkeypatch.setattr(self_update, "start_window", lambda script: calls["windows"].append(script))
25
+ monkeypatch.setattr(self_update, "run_here", lambda command: calls["ran"].append(command) or 0)
26
+ monkeypatch.setattr(
27
+ self_update, "installer_command", lambda latest=None: [UV, "tool", "install", "operx-cli", "--force"],
28
+ )
29
+ return calls
30
+
31
+
32
+ def newest_is(monkeypatch, version):
33
+ monkeypatch.setattr("operx_cli.updates.fetch_latest", lambda transport=None: version)
34
+
35
+
36
+ def test_with_the_newest_already_it_does_nothing(monkeypatch, started):
37
+ newest_is(monkeypatch, __version__)
38
+
39
+ result = run(FakeApi({}), "update")
40
+
41
+ assert result.exit_code == 0, result.output
42
+ assert f"Ya tienes la versión más nueva: {__version__}" in result.output
43
+ assert started == {"windows": [], "ran": []}
44
+
45
+
46
+ def test_on_windows_a_window_updates_once_this_operx_closes(monkeypatch, started):
47
+ newest_is(monkeypatch, "9.9.9")
48
+ monkeypatch.setattr(self_update, "on_windows", lambda: True)
49
+
50
+ result = run(FakeApi({}), "update")
51
+
52
+ assert result.exit_code == 0, result.output
53
+ assert "9.9.9" in result.output and "otra ventana" in result.output
54
+ [script] = started["windows"]
55
+ assert f"Wait-Process -Id {os.getpid()}" in script
56
+ assert f"& '{UV}' 'tool' 'install' 'operx-cli' '--force'" in script
57
+ assert started["ran"] == []
58
+
59
+
60
+ def test_at_the_prompt_on_windows_the_session_ends_so_it_can_be_replaced(monkeypatch, started):
61
+ newest_is(monkeypatch, "9.9.9")
62
+ monkeypatch.setattr(self_update, "on_windows", lambda: True)
63
+ session = Session(transport=httpx.MockTransport(FakeApi({})))
64
+ lines_left = typed("update", "cuenta")
65
+
66
+ run_shell(session, ROOT, reader=lines_left)
67
+
68
+ assert len(started["windows"]) == 1
69
+ assert lines_left() == "cuenta" # never read: the prompt closed after update
70
+
71
+
72
+ def test_elsewhere_it_updates_right_here(monkeypatch, started):
73
+ newest_is(monkeypatch, "9.9.9")
74
+ monkeypatch.setattr(self_update, "on_windows", lambda: False)
75
+
76
+ result = run(FakeApi({}), "update")
77
+
78
+ assert result.exit_code == 0, result.output
79
+ assert started["ran"] == [[UV, "tool", "install", "operx-cli", "--force"]]
80
+ assert "9.9.9" in result.output
81
+
82
+
83
+ def test_a_failed_install_says_so(monkeypatch, started):
84
+ newest_is(monkeypatch, "9.9.9")
85
+ monkeypatch.setattr(self_update, "on_windows", lambda: False)
86
+ monkeypatch.setattr(self_update, "run_here", lambda command: 1)
87
+
88
+ result = run(FakeApi({}), "update")
89
+
90
+ assert result.exit_code == 1
91
+ assert "No se pudo actualizar" in result.output
92
+
93
+
94
+ def test_when_pypi_cannot_say_it_updates_anyway(monkeypatch, started):
95
+ newest_is(monkeypatch, None)
96
+ monkeypatch.setattr(self_update, "on_windows", lambda: False)
97
+
98
+ assert run(FakeApi({}), "update").exit_code == 0
99
+ assert len(started["ran"]) == 1
100
+
101
+
102
+ def test_without_uv_or_pipx_it_says_how(monkeypatch, started):
103
+ newest_is(monkeypatch, "9.9.9")
104
+ monkeypatch.setattr(self_update, "installer_command", lambda latest=None: None)
105
+
106
+ result = run(FakeApi({}), "update")
107
+
108
+ assert result.exit_code == 1
109
+ assert "uv tool upgrade operx-cli" in result.output
110
+
111
+
112
+ def test_the_installer_is_uv_unless_operx_came_with_pipx(monkeypatch):
113
+ monkeypatch.setattr(self_update.shutil, "which", lambda name: f"/bin/{name}")
114
+ monkeypatch.setattr(self_update.sys, "prefix", "/home/ana/.local/share/uv/tools/operx-cli")
115
+ assert self_update.installer_command() == ["/bin/uv", "tool", "install", "operx-cli", "--force", "--refresh"]
116
+
117
+ monkeypatch.setattr(self_update.sys, "prefix", "/home/ana/.local/pipx/venvs/operx-cli")
118
+ assert self_update.installer_command() == ["/bin/pipx", "upgrade", "operx-cli"]
119
+
120
+
121
+ def test_uv_is_asked_for_at_least_the_version_pypi_named(monkeypatch):
122
+ # Right after a release uv's answer can lag behind PyPI's: then it fails
123
+ # instead of putting the old version back and saying it worked.
124
+ monkeypatch.setattr(self_update.shutil, "which", lambda name: f"/bin/{name}")
125
+ monkeypatch.setattr(self_update.sys, "prefix", "/home/ana/.local/share/uv/tools/operx-cli")
126
+
127
+ assert self_update.installer_command("0.3.2") == [
128
+ "/bin/uv", "tool", "install", "operx-cli>=0.3.2", "--force", "--refresh",
129
+ ]
130
+
131
+
132
+ def test_update_passes_pypi_s_version_to_the_installer(monkeypatch, started):
133
+ newest_is(monkeypatch, "9.9.9")
134
+ monkeypatch.setattr(self_update, "on_windows", lambda: False)
135
+ asked = []
136
+ monkeypatch.setattr(self_update, "installer_command", lambda latest=None: asked.append(latest) or ["uv"])
137
+
138
+ run(FakeApi({}), "update")
139
+
140
+ assert asked == ["9.9.9"]
141
+
142
+
143
+ def test_the_window_quotes_a_path_with_spaces_and_quotes():
144
+ script = self_update.window_script([r"C:\Users\Ana O'Neil\uv.exe", "tool"], 4242)
145
+
146
+ assert "& 'C:\\Users\\Ana O''Neil\\uv.exe' 'tool'" in script
147
+ assert "Wait-Process -Id 4242" in script
148
+
149
+
150
+ def test_the_window_waits_for_other_operx_windows_and_says_how_to_repair():
151
+ # Another open operx makes uv fail halfway and leaves an operx that won't start.
152
+ script = self_update.window_script([r"C:\bin\uv.exe", "tool", "install", "operx-cli", "--force"], 4242)
153
+
154
+ assert "Get-Process operx" in script
155
+ assert "uv tool install operx-cli --force" in script
@@ -32,10 +32,10 @@ def typed(*lines):
32
32
  return read
33
33
 
34
34
 
35
- def shell(fake, *lines):
35
+ def shell(fake, *lines, products=None):
36
36
  settings.save_token("principal", "opx_test")
37
37
  session = Session(transport=httpx.MockTransport(fake), sleep=lambda seconds: None)
38
- run_shell(session, ROOT, reader=typed(*lines))
38
+ run_shell(session, ROOT, reader=typed(*lines), products=products)
39
39
 
40
40
 
41
41
  # ── Reading a line the way a person types it ─────────────────────────────────
@@ -82,6 +82,19 @@ def test_operx_alone_on_a_terminal_shows_the_welcome_and_opens_the_prompt(monkey
82
82
  assert result.output.index("Para empezar") < result.output.index("Correo")
83
83
 
84
84
 
85
+ def test_the_prompt_takes_the_company_s_products_from_who_signed_in(monkeypatch):
86
+ monkeypatch.setattr("operx_cli.app.is_interactive", lambda: True)
87
+ settings.save_token("principal", "opx_test")
88
+ fake = FakeApi({("GET", "auth/me/"): ME, ("GET", "work-orders/facets/"): FACETS}) # ME has only maintenance
89
+ session = Session(transport=httpx.MockTransport(fake), reader=typed("ayuda", "salir"))
90
+
91
+ result = CliRunner().invoke(app, [], obj=session)
92
+
93
+ listed = help_commands(result.output.split("Comandos", 1)[1])
94
+ assert "ordenes" in listed
95
+ assert "sst" not in listed and "inventario" not in listed
96
+
97
+
85
98
  def test_operx_alone_without_a_token_still_opens_the_prompt_so_login_can_be_typed(monkeypatch):
86
99
  monkeypatch.setattr("operx_cli.app.is_interactive", lambda: True)
87
100
  session = Session(transport=httpx.MockTransport(FakeApi({})), reader=typed("salir"))
@@ -206,6 +219,45 @@ def test_ayuda_lists_the_commands(capsys):
206
219
  assert "entrar" not in out
207
220
 
208
221
 
222
+ def help_commands(out):
223
+ """The command names `ayuda` printed, one per line."""
224
+ return {line.split()[0] for line in out.splitlines()[1:] if line.startswith(" ") and line.strip()}
225
+
226
+
227
+ def test_ayuda_shows_only_the_products_the_company_has(capsys):
228
+ shell(FakeApi({}), "ayuda", products=["maintenance"])
229
+ listed = help_commands(capsys.readouterr().out)
230
+
231
+ assert {"ordenes", "preventivos", "equipos", "clientes", "lugares", "buscar", "personas"} <= listed
232
+ assert not {"inventario", "lecturas", "sst", "ambiental"} & listed
233
+
234
+
235
+ def test_a_shared_record_shows_with_any_of_its_products(capsys):
236
+ shell(FakeApi({}), "ayuda", products=["inventory"])
237
+ listed = help_commands(capsys.readouterr().out)
238
+
239
+ assert {"inventario", "equipos", "clientes"} <= listed
240
+ assert not {"ordenes", "preventivos", "lecturas"} & listed
241
+
242
+
243
+ def test_without_knowing_the_products_ayuda_shows_everything(capsys):
244
+ shell(FakeApi({}), "ayuda")
245
+
246
+ assert {"ordenes", "inventario", "lecturas", "sst", "ambiental"} <= help_commands(capsys.readouterr().out)
247
+
248
+
249
+ def test_tab_offers_only_the_products_the_company_has():
250
+ offered = names(suggestions(ROOT, "", products=["sst"]))
251
+
252
+ assert "sst" in offered and "lugares" in offered
253
+ assert "ordenes" not in offered and "ambiental" not in offered
254
+
255
+
256
+ def test_tab_hides_a_subcommand_of_a_product_the_company_lacks():
257
+ assert names(suggestions(ROOT, "personas ", products=["maintenance"])) == ["listar"]
258
+ assert set(names(suggestions(ROOT, "personas ", products=["people"]))) == {"listar", "importar"}
259
+
260
+
209
261
  def test_ayuda_lists_every_word_the_prompt_understands(capsys):
210
262
  shell(FakeApi({}), "ayuda")
211
263
  out = capsys.readouterr().out
@@ -64,7 +64,7 @@ def test_a_newer_version_is_announced_with_how_to_update(monkeypatch):
64
64
  text = welcome(api())
65
65
 
66
66
  assert "9.9.9" in text
67
- assert "Cierra operx" in text and "uv tool upgrade operx-cli" in text
67
+ assert "operx update" in text
68
68
 
69
69
 
70
70
  def test_no_notice_when_this_is_the_newest_version():
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes