operx-cli 0.5.0__tar.gz → 0.6.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 (75) hide show
  1. {operx_cli-0.5.0 → operx_cli-0.6.0}/PKG-INFO +13 -7
  2. {operx_cli-0.5.0 → operx_cli-0.6.0}/README.md +12 -6
  3. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/AGENTES.md +60 -13
  4. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/__init__.py +1 -1
  5. operx_cli-0.6.0/operx_cli/api_fields.py +232 -0
  6. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/ambiental.py +12 -6
  7. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/articulos.py +5 -2
  8. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/avisos.py +4 -2
  9. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/common.py +22 -2
  10. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/empresas.py +6 -6
  11. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/inventario.py +19 -12
  12. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/lecturas.py +9 -5
  13. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/ordenes.py +20 -12
  14. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/personas.py +13 -4
  15. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/planta.py +14 -4
  16. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/preventivos.py +7 -3
  17. operx_cli-0.6.0/operx_cli/commands/raw.py +288 -0
  18. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/registros.py +14 -9
  19. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/sst.py +4 -2
  20. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli.egg-info/PKG-INFO +13 -7
  21. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli.egg-info/SOURCES.txt +3 -0
  22. {operx_cli-0.5.0 → operx_cli-0.6.0}/pyproject.toml +1 -1
  23. operx_cli-0.6.0/tests/test_api_campos.py +166 -0
  24. operx_cli-0.6.0/tests/test_json_writes.py +184 -0
  25. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_logout_and_routes.py +25 -2
  26. operx_cli-0.5.0/operx_cli/commands/raw.py +0 -151
  27. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/agent_guide.py +0 -0
  28. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/app.py +0 -0
  29. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/browser_login.py +0 -0
  30. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/client.py +0 -0
  31. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/__init__.py +0 -0
  32. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/account.py +0 -0
  33. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/completar.py +0 -0
  34. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/reportes.py +0 -0
  35. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/search.py +0 -0
  36. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/commands/update.py +0 -0
  37. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/lookup.py +0 -0
  38. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/output.py +0 -0
  39. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/record_completion.py +0 -0
  40. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/self_update.py +0 -0
  41. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/settings.py +0 -0
  42. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/shell.py +0 -0
  43. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/spreadsheet.py +0 -0
  44. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/updates.py +0 -0
  45. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/values.py +0 -0
  46. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli/welcome.py +0 -0
  47. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli.egg-info/dependency_links.txt +0 -0
  48. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli.egg-info/entry_points.txt +0 -0
  49. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli.egg-info/requires.txt +0 -0
  50. {operx_cli-0.5.0 → operx_cli-0.6.0}/operx_cli.egg-info/top_level.txt +0 -0
  51. {operx_cli-0.5.0 → operx_cli-0.6.0}/setup.cfg +0 -0
  52. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_agentes.py +0 -0
  53. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_app.py +0 -0
  54. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_articulos.py +0 -0
  55. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_cards.py +0 -0
  56. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_client.py +0 -0
  57. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_completar.py +0 -0
  58. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_inventario_lecturas.py +0 -0
  59. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_login.py +0 -0
  60. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_lookup.py +0 -0
  61. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_more_of_the_api.py +0 -0
  62. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_ordenes.py +0 -0
  63. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_output.py +0 -0
  64. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_personas.py +0 -0
  65. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_preventivos.py +0 -0
  66. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_record_completion.py +0 -0
  67. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_registros.py +0 -0
  68. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_self_update.py +0 -0
  69. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_settings.py +0 -0
  70. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_shell.py +0 -0
  71. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_spreadsheet.py +0 -0
  72. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_sst_ambiental.py +0 -0
  73. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_updates.py +0 -0
  74. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_values.py +0 -0
  75. {operx_cli-0.5.0 → operx_cli-0.6.0}/tests/test_welcome.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: operx-cli
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -196,7 +196,9 @@ llevas el SG-SST o lo ambiental de tus clientes, `operx sst empresas` y
196
196
  ### Antes de escribir
197
197
 
198
198
  Todo lo que crea o cambia algo pregunta antes. En un script o una tarea
199
- programada no hay a quién preguntar: agrega `--si`. Cada escritura queda en
199
+ programada no hay a quién preguntar: agrega `--si`. Con `--json`, lo que
200
+ escribe responde el registro creado o cambiado (con su `id`) en vez de una
201
+ frase. Cada escritura queda en
200
202
  el historial del token (Acceso por API › el token › Actividad) marcada como
201
203
  hecha desde la CLI.
202
204
 
@@ -211,13 +213,17 @@ Usuarios y acceso.
211
213
 
212
214
  ### Cualquier otra llamada
213
215
 
214
- Lo que no tiene comando propio se hace con `operx api`. `operx api rutas`
215
- lista todas las direcciones que tu token puede llamar (`operx api rutas
216
- compresor` deja las que lo mencionan); también están en Acceso por API ›
217
- Documentación:
216
+ Lo que no tiene comando propio se hace con `operx api`: todo lo que tu
217
+ token puede hacer, más de 400 llamadas. `operx api rutas` las lista
218
+ (`operx api rutas sst inspections` deja las que tienen esas palabras), y
219
+ `operx api campos RUTA` dice qué lleva una: sus filtros, cada campo con su
220
+ tipo, si es obligatorio y los valores permitidos. También están en Acceso
221
+ por API › Documentación:
218
222
 
219
223
  ```sh
220
- operx api rutas schedules
224
+ operx api rutas repuestos
225
+ operx api campos inventory/work-orders/1234/parts/ --metodo POST
226
+ operx api POST inventory/work-orders/1234/parts/ -d '{"action": "usar", "item": 3, "quantity": "2", "location": 2}'
221
227
  operx api GET pm/schedules/ -q search=compresor
222
228
  operx api POST contacts/ -d @contacto.json
223
229
  operx api GET work-orders/export/ -o ordenes.csv
@@ -182,7 +182,9 @@ llevas el SG-SST o lo ambiental de tus clientes, `operx sst empresas` y
182
182
  ### Antes de escribir
183
183
 
184
184
  Todo lo que crea o cambia algo pregunta antes. En un script o una tarea
185
- programada no hay a quién preguntar: agrega `--si`. Cada escritura queda en
185
+ programada no hay a quién preguntar: agrega `--si`. Con `--json`, lo que
186
+ escribe responde el registro creado o cambiado (con su `id`) en vez de una
187
+ frase. Cada escritura queda en
186
188
  el historial del token (Acceso por API › el token › Actividad) marcada como
187
189
  hecha desde la CLI.
188
190
 
@@ -197,13 +199,17 @@ Usuarios y acceso.
197
199
 
198
200
  ### Cualquier otra llamada
199
201
 
200
- Lo que no tiene comando propio se hace con `operx api`. `operx api rutas`
201
- lista todas las direcciones que tu token puede llamar (`operx api rutas
202
- compresor` deja las que lo mencionan); también están en Acceso por API ›
203
- Documentación:
202
+ Lo que no tiene comando propio se hace con `operx api`: todo lo que tu
203
+ token puede hacer, más de 400 llamadas. `operx api rutas` las lista
204
+ (`operx api rutas sst inspections` deja las que tienen esas palabras), y
205
+ `operx api campos RUTA` dice qué lleva una: sus filtros, cada campo con su
206
+ tipo, si es obligatorio y los valores permitidos. También están en Acceso
207
+ por API › Documentación:
204
208
 
205
209
  ```sh
206
- operx api rutas schedules
210
+ operx api rutas repuestos
211
+ operx api campos inventory/work-orders/1234/parts/ --metodo POST
212
+ operx api POST inventory/work-orders/1234/parts/ -d '{"action": "usar", "item": 3, "quantity": "2", "location": 2}'
207
213
  operx api GET pm/schedules/ -q search=compresor
208
214
  operx api POST contacts/ -d @contacto.json
209
215
  operx api GET work-orders/export/ -o ordenes.csv
@@ -9,8 +9,9 @@ llamada queda en el historial del token, marcada como hecha desde la CLI.
9
9
 
10
10
  1. Escribe siempre el comando completo (`operx ordenes listar …`). No abras el
11
11
  modo interactivo: `operx` solo, fuera de una terminal, solo muestra la ayuda.
12
- 2. Lee con `--json`: es lo que responde la API, completo. Las tablas son para
13
- personas.
12
+ 2. Usa `--json` siempre: al leer es lo que responde la API, completo; al
13
+ escribir es el registro creado o cambiado, con su `id`. Las tablas y las
14
+ frases son para personas.
14
15
  3. Antes de crear o cambiar algo, dile a la persona qué vas a hacer y espera
15
16
  su sí. Con su sí, agrega `--si`: sin él, toda escritura falla fuera de una
16
17
  terminal.
@@ -20,7 +21,9 @@ llamada queda en el historial del token, marcada como hecha desde la CLI.
20
21
  consulta primero si quedó hecha.
21
22
  6. Si un nombre coincide con varios registros, `operx` lo dice con sus ids:
22
23
  usa el id, no adivines.
23
- 7. Usa un comando propio cuando exista; `operx api` es para lo demás.
24
+ 7. Usa un comando propio cuando exista: acepta nombres y fechas como los
25
+ escribe la persona. Para todo lo demás está `operx api` (abajo): con él
26
+ puedes hacer todo lo que el token permite.
24
27
  8. `operx update` y `operx completar` cambian el computador de la persona, no
25
28
  los datos de su empresa: son para ella, no los ejecutes.
26
29
 
@@ -54,6 +57,8 @@ llamada queda en el historial del token, marcada como hecha desde la CLI.
54
57
 
55
58
  ## Escribir
56
59
 
60
+ - Todos los comandos que escriben aceptan `--json`: la salida es solo la
61
+ respuesta de la API (el registro, con su `id`). Úsalo para encadenar pasos.
57
62
  - Escriben: `ordenes crear`, `ordenes editar`, `ordenes estado`,
58
63
  `ordenes asignar`, `ordenes comentar`, `ordenes adjuntar`,
59
64
  `preventivos crear`, `preventivos nota`, `equipos crear`, `equipos editar`,
@@ -110,13 +115,46 @@ Lo que responde la API:
110
115
  - **429**: demasiadas llamadas (120 por minuto por token, 600 por empresa).
111
116
  `operx` espera y reintenta solo, hasta 3 veces.
112
117
 
113
- ## Cualquier otra llamada: `operx api`
114
-
115
- `operx api MÉTODO RUTA` hace cualquier llamada que el token pueda hacer y
116
- responde el JSON de la API. `operx api rutas --json` lista todas las rutas
117
- que el token puede llamar, con lo que hace cada una; `operx api rutas TEXTO`
118
- deja solo las que lo mencionan. Escríbelas como salen ahí: sin `/api/v1/` y
119
- sin barra inicial.
118
+ ## Todo lo demás: `operx api`
119
+
120
+ El token puede hacer más de 400 llamadas; los comandos propios cubren las
121
+ del día a día. Todas las demás se hacen con `operx api MÉTODO RUTA`, en
122
+ cinco pasos:
123
+
124
+ 1. **Busca la ruta.** `operx api rutas TEXTO --json` deja las que tienen
125
+ cada palabra del texto en la ruta, en su `producto` (SG-SST, Inventario…)
126
+ o en lo que hace. Las rutas están en inglés: busca también así
127
+ (`operx api rutas sst inspections`, `operx api rutas repuestos`).
128
+ 2. **Lee lo que lleva.** `operx api campos RUTA --json` (con `--metodo POST`
129
+ para uno solo) da, por método:
130
+ - `en_la_ruta`: los valores de la dirección, como `{pk}`.
131
+ - `filtros`: lo que va en `-q nombre=valor`.
132
+ - `datos`: cada campo del cuerpo, con su `tipo`, su `formato`, si es
133
+ `obligatorio`, y en `valores` los permitidos con su etiqueta en español
134
+ (para entender lo que dice la persona). `lines[].item` es un campo de
135
+ cada elemento de la lista `lines`.
136
+ - `envio`: `json` (todo en `-d`) o `archivos` (el archivo con
137
+ `-a campo=ruta`, lo demás en `-d`).
138
+ - `responde`: los campos de la respuesta.
139
+ 3. **Busca los ids.** La API toma ids, no nombres: búscalos con un comando
140
+ (`operx equipos listar --buscar bomba --json`) o con
141
+ `operx api GET RUTA -q search=TEXTO`.
142
+ 4. **Escribe**, después del sí de la persona: `-d '{"campo": valor}'` y `--si`.
143
+ 5. **Lee la respuesta**: es el registro creado o cambiado.
144
+
145
+ En `-d` los datos van como los toma la API, no como en los comandos propios:
146
+
147
+ - Decimales como texto con punto: `"2.5"`, nunca `2,5`.
148
+ - Fechas `"2026-10-30"`; fecha y hora con su zona:
149
+ `"2026-10-30T08:00:00-05:00"`.
150
+ - Ids, no nombres: `"item": 3`, no `"item": "FIL-010"`.
151
+ - Los valores de una lista, en inglés, como salen en `valores`:
152
+ `"priority": "high"`.
153
+
154
+ Si la API responde 400, el mensaje dice qué campo falló y por qué; corrige
155
+ eso, no lo demás.
156
+
157
+ Más opciones de `operx api`:
120
158
 
121
159
  - `-q nombre=valor`: un filtro. Se repite.
122
160
  - `-d '{"campo": "valor"}'` o `-d @archivo.json`: el cuerpo de la llamada.
@@ -125,9 +163,9 @@ sin barra inicial.
125
163
  - `-o archivo`: guarda una respuesta que es un archivo (CSV, PDF).
126
164
 
127
165
  ```sh
128
- operx api rutas schedules --json
129
- operx api GET pm/schedules/ -q search=compresor
130
- operx api POST contacts/ -d @contacto.json --si
166
+ operx api rutas repuestos --json
167
+ operx api campos inventory/work-orders/1234/parts/ --metodo POST --json
168
+ operx api POST inventory/work-orders/1234/parts/ -d '{"action": "usar", "item": 3, "quantity": "2", "location": 2}' --si
131
169
  operx api GET work-orders/export/ -o ordenes.csv
132
170
  ```
133
171
 
@@ -155,6 +193,15 @@ operx inventario entrada --articulo CON-0001 --lugar "Bodega central" --cantidad
155
193
  # Un equipo que entró a mantenimiento
156
194
  operx equipos editar BOM-04 --estado en-mantenimiento --si
157
195
 
196
+ # Un repuesto usado en una orden (no tiene comando propio): ruta, campos, ids, escribir
197
+ operx api rutas repuestos --json
198
+ operx api campos inventory/work-orders/1234/parts/ --metodo POST --json
199
+ operx inventario existencias --buscar filtro --json
200
+ operx api POST inventory/work-orders/1234/parts/ -d '{"action": "usar", "item": 3, "quantity": "2", "location": 2}' --si
201
+
202
+ # Crear y seguir con el id que responde
203
+ operx ordenes crear --titulo "Cambio de rodamiento" --equipo BOM-04 --si --json
204
+
158
205
  # Los equipos en un CSV para Excel
159
206
  operx equipos listar --todas --csv > equipos.csv
160
207
 
@@ -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.5.0"
5
+ __version__ = "0.6.0"
@@ -0,0 +1,232 @@
1
+ """What a route takes, read from the OpenAPI schema the API serves a token
2
+ (auth/api-tokens/schema/, drf-spectacular): the values in its address, its
3
+ filters, the fields of its body and what it answers. `operx api campos`
4
+ prints it, so an AI agent can call any route with `operx api` without
5
+ guessing; no network here, so the MCP server can use it as it is.
6
+
7
+ The schema's shapes this follows: a field's type behind a $ref; an allOf
8
+ of one $ref (a nested record); a oneOf of an enum with BlankEnum and
9
+ NullEnum (a choice that may be "" or null); each enum's labels in its
10
+ description, one "* `value` - Label" a line.
11
+ """
12
+
13
+ import re
14
+
15
+ METHODS = ("get", "post", "put", "patch", "delete")
16
+ # A field inside a list inside a list… is shown this many levels deep.
17
+ MAX_DEPTH = 3
18
+ LABEL_LINE = re.compile(r"^\* `([^`]*)` - (.+)$", re.MULTILINE)
19
+ DECIMALS = re.compile(r"\\\.\\d\{0,(\d+)\}")
20
+ # Django's integer field bounds: every integer has them, they say nothing.
21
+ INT32 = (-2147483648, 2147483647)
22
+ PAGE_FIELDS = {"count", "next", "previous", "results"}
23
+
24
+
25
+ def find_route(schema, route):
26
+ """The schema's path for a route as a person or an agent types it: with
27
+ real ids (work-orders/1234/) or the placeholders `operx api rutas` prints
28
+ ({pk}). A fixed word beats a placeholder, so items/siguiente-codigo/
29
+ isn't items/{pk}/. LookupError when no route fits."""
30
+ wanted = _segments(route)
31
+ best = None
32
+ for path in schema.get("paths", {}):
33
+ segments = _segments(path)
34
+ if len(segments) != len(wanted):
35
+ continue
36
+ fixed = 0
37
+ for template, given in zip(segments, wanted):
38
+ if template.startswith("{"):
39
+ continue
40
+ if template != given:
41
+ break
42
+ fixed += 1
43
+ else:
44
+ if best is None or fixed > best[0]:
45
+ best = (fixed, path)
46
+ if best is None:
47
+ raise LookupError(route)
48
+ return best[1]
49
+
50
+
51
+ def _segments(route):
52
+ route = route.strip().split("?")[0].strip("/")
53
+ route = route.removeprefix("api/v1/")
54
+ return route.split("/")
55
+
56
+
57
+ def describe(schema, path, method=None):
58
+ """Each operation of the route (or only `method`): a dict an agent reads
59
+ as JSON and `operx api campos` prints as tables."""
60
+ components = schema.get("components", {}).get("schemas", {})
61
+ operations = schema["paths"][path]
62
+ described = []
63
+ for name in METHODS:
64
+ if name not in operations or (method and method.lower() != name):
65
+ continue
66
+ operation = operations[name]
67
+ parameters = operation.get("parameters", [])
68
+ body_schema, sending = _body(operation)
69
+ described.append({
70
+ "metodo": name.upper(),
71
+ "ruta": path.removeprefix("/api/v1/"),
72
+ "descripcion": (operation.get("description") or "").strip(),
73
+ "en_la_ruta": [_parameter(components, parameter) for parameter in parameters if parameter.get("in") == "path"],
74
+ "filtros": [_parameter(components, parameter) for parameter in parameters if parameter.get("in") == "query"],
75
+ "datos": _fields(components, body_schema) if body_schema else [],
76
+ "envio": sending if body_schema else None,
77
+ "responde": _answer(components, operation),
78
+ })
79
+ return described
80
+
81
+
82
+ def methods_of(schema, path):
83
+ return [name.upper() for name in METHODS if name in schema["paths"][path]]
84
+
85
+
86
+ def _body(operation):
87
+ """The body's schema and how it goes: JSON (-d), or a form with a file
88
+ (-a for the file, -d for the rest)."""
89
+ content = operation.get("requestBody", {}).get("content", {})
90
+ if "application/json" in content:
91
+ return content["application/json"].get("schema"), "json"
92
+ for kind in ("multipart/form-data", "application/x-www-form-urlencoded"):
93
+ if kind in content:
94
+ return content[kind].get("schema"), "archivos"
95
+ return None, None
96
+
97
+
98
+ def _resolve(components, node):
99
+ """A field's schema with its $ref, its one-ref allOf and its
100
+ enum-or-blank-or-null oneOf followed. What the wrapper says (nullable,
101
+ readOnly, its own description) stays; an enum's description becomes
102
+ its labels."""
103
+ node = dict(node or {})
104
+ if "$ref" in node:
105
+ name = node.pop("$ref").split("/")[-1]
106
+ target = _labelled(components.get(name, {}))
107
+ node = {**target, **node, "_name": name}
108
+ if len(node.get("allOf", [])) == 1:
109
+ inner = _resolve(components, node.pop("allOf")[0])
110
+ node = {**inner, **node}
111
+ if "oneOf" in node:
112
+ choices = [_resolve(components, choice) for choice in node.pop("oneOf")]
113
+ kept = []
114
+ for choice in choices:
115
+ if choice.get("enum") == [""] or choice.get("maxLength") == 0:
116
+ node["_blank"] = True
117
+ elif choice.get("enum") == [None]:
118
+ node["nullable"] = True
119
+ else:
120
+ kept.append(choice)
121
+ if kept:
122
+ node = {**kept[0], **node}
123
+ if node.get("type") == "array" and "items" in node:
124
+ # One level only: a record's own fields are followed by _fields.
125
+ node["items"] = _resolve(components, node["items"]) if "$ref" in node["items"] else node["items"]
126
+ return node
127
+
128
+
129
+ def _labelled(node):
130
+ """An enum's "* `low` - Baja" description as its labels."""
131
+ if "enum" not in node:
132
+ return node
133
+ node = dict(node)
134
+ labels = dict(LABEL_LINE.findall(node.pop("description", "") or ""))
135
+ node["_labels"] = labels
136
+ return node
137
+
138
+
139
+ def _fields(components, body_schema, prefix="", depth=0, seen=frozenset()):
140
+ record = _resolve(components, body_schema)
141
+ required = set(record.get("required", []))
142
+ rows = []
143
+ for name, value in record.get("properties", {}).items():
144
+ field = _resolve(components, value)
145
+ if field.get("readOnly"):
146
+ continue
147
+ path = prefix + name
148
+ rows.append(_row(path, field, name in required))
149
+ if depth >= MAX_DEPTH:
150
+ continue
151
+ if field.get("type") == "array":
152
+ items = _resolve(components, field.get("items"))
153
+ if "properties" in items and items.get("_name") not in seen:
154
+ rows.extend(_fields(components, items, f"{path}[].", depth + 1, seen | {items.get("_name")}))
155
+ elif "properties" in field and field.get("_name") not in seen:
156
+ rows.extend(_fields(components, field, f"{path}.", depth + 1, seen | {field.get("_name")}))
157
+ return rows
158
+
159
+
160
+ def _row(name, field, required):
161
+ """One field: its JSON type and format, and only what applies to it."""
162
+ row = {"campo": name, "tipo": field.get("type") or ("string" if "enum" in field else None), "obligatorio": required}
163
+ if field.get("format"):
164
+ row["formato"] = field["format"]
165
+ if field.get("type") == "array":
166
+ items = field.get("items") or {}
167
+ row["de"] = "object" if "properties" in items else items.get("type") or ("string" if "enum" in items else None)
168
+ values = _values(field)
169
+ if field.get("type") == "array" and not values:
170
+ values = _values(field.get("items") or {})
171
+ if values:
172
+ row["valores"] = values
173
+ if field.get("nullable"):
174
+ row["nulo"] = True
175
+ if field.get("_blank"):
176
+ row["vacio"] = True
177
+ if "default" in field:
178
+ row["por_defecto"] = field["default"]
179
+ if field.get("maxLength"):
180
+ row["maximo_largo"] = field["maxLength"]
181
+ for key, label in (("minimum", "minimo"), ("maximum", "maximo")):
182
+ if key in field and field[key] not in INT32:
183
+ row[label] = field[key]
184
+ decimals = DECIMALS.search(field.get("pattern", "")) if field.get("format") == "decimal" else None
185
+ if decimals:
186
+ row["decimales"] = int(decimals.group(1))
187
+ if field.get("description"):
188
+ row["nota"] = field["description"].strip()
189
+ return row
190
+
191
+
192
+ def _values(field):
193
+ labels = field.get("_labels") or {}
194
+ values = [value for value in field.get("enum", []) if value not in (None, "")]
195
+ return [{"valor": value, "etiqueta": labels.get(value, value)} for value in values]
196
+
197
+
198
+ def _parameter(components, parameter):
199
+ """A filter or a value in the address. A filter's labels come in its own
200
+ description, not in its schema's."""
201
+ field = _resolve(components, parameter.get("schema"))
202
+ description = (parameter.get("description") or "").strip()
203
+ labels = dict(LABEL_LINE.findall(description))
204
+ if "enum" in field and labels:
205
+ field["_labels"] = labels
206
+ description = ""
207
+ if description:
208
+ field["description"] = description
209
+ row = _row(parameter["name"], field, bool(parameter.get("required")))
210
+ if parameter.get("in") == "path":
211
+ # Only the name and type: it's written in the address itself.
212
+ return {key: row[key] for key in ("campo", "tipo", "obligatorio")}
213
+ return row
214
+
215
+
216
+ def _answer(components, operation):
217
+ """What a call answers: its status, whether it comes by pages, and the
218
+ names of a record's fields."""
219
+ for status, response in operation.get("responses", {}).items():
220
+ if not status.startswith("2"):
221
+ continue
222
+ schema = (response.get("content", {}).get("application/json") or {}).get("schema")
223
+ if schema is None:
224
+ return {"estado": int(status), "pagina": False, "campos": []}
225
+ record = _resolve(components, schema)
226
+ paged = PAGE_FIELDS <= set(record.get("properties", {}))
227
+ if paged:
228
+ record = _resolve(components, record["properties"]["results"])
229
+ if record.get("type") == "array":
230
+ record = _resolve(components, record.get("items"))
231
+ return {"estado": int(status), "pagina": paged, "campos": list(record.get("properties", {}))}
232
+ return {"estado": None, "pagina": False, "campos": []}
@@ -15,12 +15,14 @@ from .common import (
15
15
  CsvOption,
16
16
  JsonOption,
17
17
  YesOption,
18
+ answer_of,
18
19
  client_of,
19
20
  confirm_write,
20
21
  day,
21
22
  month,
22
23
  number,
23
24
  output_format,
25
+ print_result,
24
26
  )
25
27
  from .empresas import (
26
28
  ENVIRONMENT,
@@ -71,9 +73,10 @@ def nueva_accion(
71
73
  tipo: Annotated[TipoAccion, typer.Option("--tipo")] = TipoAccion.correctiva,
72
74
  empresa: EmpresaOption = None,
73
75
  si: YesOption = False,
76
+ as_json: JsonOption = False,
74
77
  ):
75
78
  """Agrega una acción al plan de acción ambiental."""
76
- create_action(ctx, ENVIRONMENT, titulo, para, responsable, tipo, empresa, si)
79
+ create_action(ctx, ENVIRONMENT, titulo, para, responsable, tipo, empresa, si, as_json)
77
80
 
78
81
 
79
82
  @app.command("accion-hecha")
@@ -83,9 +86,10 @@ def accion_hecha(
83
86
  evidencia: Annotated[str, typer.Option("--evidencia", help="Qué se hizo.")],
84
87
  empresa: EmpresaOption = None,
85
88
  si: YesOption = False,
89
+ as_json: JsonOption = False,
86
90
  ):
87
91
  """Marca hecha una acción, con lo que se hizo. Verificarla se hace en la app."""
88
- mark_done(ctx, ENVIRONMENT, accion, evidencia, empresa, si)
92
+ mark_done(ctx, ENVIRONMENT, accion, evidencia, empresa, si, as_json)
89
93
 
90
94
 
91
95
  @app.command()
@@ -126,6 +130,7 @@ def residuo(
126
130
  sede: Annotated[Optional[str], typer.Option("--sede", help="La sede, si dos tienen una corriente con ese nombre.")] = None,
127
131
  empresa: EmpresaOption = None,
128
132
  si: YesOption = False,
133
+ as_json: JsonOption = False,
129
134
  ):
130
135
  """Registra los kilos de residuos generados (pesados)."""
131
136
  client = client_of(ctx)
@@ -133,8 +138,8 @@ def residuo(
133
138
  stream = _stream(client, path, corriente, sede)
134
139
  entry = {"stream": stream["id"], "date": day(fecha) or date.today().isoformat(), "kg": number(kg)}
135
140
  confirm_write(f"Registrar {kg} kg de «{stream['name']}» el {shown_moment(entry['date'])}.", si)
136
- client.send("POST", f"{path}/waste/entries", json=entry)
137
- typer.echo(f"Registrados {kg} kg de {stream['name']}.")
141
+ recorded = answer_of(client.send("POST", f"{path}/waste/entries", json=entry))
142
+ print_result(recorded, as_json, f"Registrados {kg} kg de {stream['name']}.")
138
143
 
139
144
 
140
145
  def _stream(client, path, corriente, sede):
@@ -163,6 +168,7 @@ def lectura(
163
168
  fecha: FechaOption = None,
164
169
  empresa: EmpresaOption = None,
165
170
  si: YesOption = False,
171
+ as_json: JsonOption = False,
166
172
  ):
167
173
  """Registra la lectura de un medidor (agua, energía, gas)."""
168
174
  client = client_of(ctx)
@@ -170,5 +176,5 @@ def lectura(
170
176
  meter = find_in(client.get(f"{path}/consumption/meters"), medidor, ["name", "meter_name"], "medidor")
171
177
  reading = {"source": meter["id"], "read_on": day(fecha) or date.today().isoformat(), "value": number(valor)}
172
178
  confirm_write(f"Lectura {valor} en «{meter['name']}» el {shown_moment(reading['read_on'])}.", si)
173
- client.send("POST", f"{path}/consumption/readings", json=reading)
174
- typer.echo(f"Lectura registrada: {valor} en {meter['name']}.")
179
+ recorded = answer_of(client.send("POST", f"{path}/consumption/readings", json=reading))
180
+ print_result(recorded, as_json, f"Lectura registrada: {valor} en {meter['name']}.")
@@ -10,7 +10,7 @@ from ..lookup import NoSingleMatch, find_in, find_one
10
10
  from ..output import cell_text, print_card, print_record, print_rows
11
11
  from .common import (
12
12
  AllOption, CliError, CsvOption, JsonOption, SearchOption, YesOption, client_of, confirm_write, list_rows,
13
- output_format, without_empty,
13
+ output_format, print_result, without_empty,
14
14
  )
15
15
 
16
16
  app = typer.Typer(help="Artículos (Inventario): los repuestos, consumibles y herramientas que manejas.", no_args_is_help=True)
@@ -98,6 +98,7 @@ def crear(
98
98
  fabricante: Annotated[Optional[str], typer.Option("--fabricante")] = None,
99
99
  referencia: Annotated[Optional[str], typer.Option("--referencia", help="La referencia del fabricante.")] = None,
100
100
  si: YesOption = False,
101
+ as_json: JsonOption = False,
101
102
  ):
102
103
  """Crea un artículo en el catálogo. Lo que hay se carga después con `inventario entrada`."""
103
104
  client = client_of(ctx)
@@ -119,7 +120,9 @@ def crear(
119
120
  })
120
121
  confirm_write(f"Crear el artículo «{nombre}».", si)
121
122
  created = client.send("POST", "inventory/items", json=item).json()
122
- typer.echo(f"Artículo {created['code']} creado: {created['name']}.")
123
+ print_result(created, as_json, f"Artículo {created['code']} creado: {created['name']}.")
124
+ if as_json:
125
+ return
123
126
  typer.echo(
124
127
  f"Para cargar lo que hay: operx inventario entrada --articulo {created['code']} --lugar … --cantidad … --costo-unitario …"
125
128
  )
@@ -14,11 +14,13 @@ from .common import (
14
14
  CsvOption,
15
15
  JsonOption,
16
16
  YesOption,
17
+ answer_of,
17
18
  client_of,
18
19
  confirm_write,
19
20
  day,
20
21
  list_rows,
21
22
  output_format,
23
+ print_result,
22
24
  )
23
25
 
24
26
  NOTICE_COLUMNS = [("when", "Cuándo"), ("title", "Aviso"), ("body", "Detalle"), ("read", "Leído")]
@@ -40,8 +42,8 @@ def avisos(
40
42
  client = client_of(ctx)
41
43
  if marcar_leidos:
42
44
  confirm_write("Marcar todos tus avisos como leídos.", si)
43
- client.send("POST", "notifications/mark-read")
44
- typer.echo("Listo: no te quedan avisos sin leer.")
45
+ marked = answer_of(client.send("POST", "notifications/mark-read"))
46
+ print_result(marked, as_json, "Listo: no te quedan avisos sin leer.")
45
47
  return
46
48
  output = output_format(as_json, as_csv)
47
49
  rows = list_rows(client, "notifications", {"is_read": None if todos else "false"}, todas)
@@ -12,7 +12,7 @@ import typer
12
12
 
13
13
  from .. import settings
14
14
  from ..client import ApiClient
15
- from ..output import note
15
+ from ..output import json_text, note
16
16
  from ..values import decimal_text, iso_date, iso_datetime, month_text
17
17
 
18
18
  NOT_SIGNED_IN = "No has entrado en este perfil. Escribe `operx login`."
@@ -103,7 +103,8 @@ def confirm_write(what, yes):
103
103
  if not can_ask():
104
104
  raise CliError(NEEDS_SI)
105
105
  try:
106
- answer = typer.prompt(f"{what} ¿Confirmas? (s/n)", default="n", show_default=False)
106
+ # On stderr: `operx … --json | jq` must get only the API's answer.
107
+ answer = typer.prompt(f"{what} ¿Confirmas? (s/n)", default="n", show_default=False, err=True)
107
108
  except typer.Abort:
108
109
  raise CliError(NEEDS_SI) from None
109
110
  if answer.strip().casefold() not in YES_ANSWERS:
@@ -147,3 +148,22 @@ def month(text):
147
148
  def without_empty(fields):
148
149
  """The fields someone gave: an option left out isn't sent."""
149
150
  return {key: value for key, value in fields.items() if value not in (None, "")}
151
+
152
+
153
+ def answer_of(response):
154
+ """A write's answer as JSON; {} when the API sent nothing back."""
155
+ if not response.content:
156
+ return {}
157
+ try:
158
+ return response.json()
159
+ except ValueError:
160
+ return {}
161
+
162
+
163
+ def print_result(answer, as_json, message):
164
+ """What a write did. With --json, only the API's answer (the new record,
165
+ its id), for a script or an AI agent to read; else a line for a person."""
166
+ if as_json:
167
+ typer.echo(json_text(answer))
168
+ return
169
+ typer.echo(message)
@@ -12,7 +12,7 @@ import typer
12
12
  from ..lookup import find_in
13
13
  from ..output import note, print_rows
14
14
  from ..values import shown_moment
15
- from .common import CliError, client_of, confirm_write, day
15
+ from .common import CliError, answer_of, client_of, confirm_write, day, print_result
16
16
 
17
17
 
18
18
  @dataclass(frozen=True)
@@ -115,7 +115,7 @@ def print_actions(client, product, empresa, estado, output):
115
115
  print_rows(ACTION_COLUMNS, actions, output)
116
116
 
117
117
 
118
- def create_action(ctx, product, titulo, para, responsable, tipo, empresa, si):
118
+ def create_action(ctx, product, titulo, para, responsable, tipo, empresa, si, as_json=False):
119
119
  client = client_of(ctx)
120
120
  path = company_path(client, product, empresa)
121
121
  owner_id, owner_name = owner_of(client, responsable)
@@ -126,12 +126,12 @@ def create_action(ctx, product, titulo, para, responsable, tipo, empresa, si):
126
126
  action["owner_name"] = owner_name
127
127
  confirm_write(f"Crear la acción «{titulo}», para el {shown_moment(action['due_on'])}, a cargo de {responsable}.", si)
128
128
  created = client.send("POST", f"{path}/actions", json=action).json()
129
- typer.echo(f"Acción {created['id']} creada: {created.get(product.title_field, titulo)}.")
129
+ print_result(created, as_json, f"Acción {created['id']} creada: {created.get(product.title_field, titulo)}.")
130
130
 
131
131
 
132
- def mark_done(ctx, product, accion, evidencia, empresa, si):
132
+ def mark_done(ctx, product, accion, evidencia, empresa, si, as_json=False):
133
133
  client = client_of(ctx)
134
134
  path = company_path(client, product, empresa)
135
135
  confirm_write(f"Marcar hecha la acción {accion}.", si)
136
- client.send("POST", f"{path}/actions/{accion}/done", json={"evidence": evidencia})
137
- typer.echo(f"Acción {accion} hecha.")
136
+ done = answer_of(client.send("POST", f"{path}/actions/{accion}/done", json={"evidence": evidencia}))
137
+ print_result(done, as_json, f"Acción {accion} hecha.")