awcs-scripts 1.0.2__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: awcs-scripts
3
- Version: 1.0.2
3
+ Version: 1.1.0
4
4
  Summary: Command line scripts for WCS maps and the ARMS API
5
5
  Project-URL: Repository, https://gitlab.sprydev.ru/automacon/agv/wcs/awcs_scripts
6
6
  Author: AWCS
@@ -43,7 +43,7 @@ uv tool install .
43
43
 
44
44
  ```
45
45
  uvx --from awcs-scripts awcs help
46
- uvx --from awcs-scripts awcs mrmap-to-raster path/to/map.mrmap --resolution 0.05 --out-dir path/to/out
46
+ uvx --from awcs-scripts awcs mrmap-to-raster path/to/map.mrmap [--resolution 0.05] [--out-dir path/to/out]
47
47
  uvx --from awcs-scripts awcs users me
48
48
  ```
49
49
 
@@ -59,8 +59,8 @@ awcs version
59
59
  #### Карты
60
60
 
61
61
  ```
62
- awcs mrmap-to-raster path/to/map.mrmap --out-dir path/to/out
63
- awcs mrmap-to-bg path/to/map.mrmap path/to/layout.lif --layout_id layoutId --resolution 0.05 --output path/to/layout.out.lif
62
+ awcs mrmap-to-raster path/to/map.mrmap [--out-dir path/to/out]
63
+ awcs mrmap-to-bg path/to/map.mrmap path/to/layout.lif --layout_id layoutId [--resolution 0.05] [--output path/to/layout.out.lif]
64
64
  ```
65
65
 
66
66
  `awcs mrmap-to-raster` пишет PNG, GeoTIFF и JSON. `--resolution` задаёт метры на пиксель, по умолчанию берётся `resolution_2d` из карты. `awcs mrmap-to-bg` подставляет этот растр в `backgroundImage` одного layout. Без `--output` исходный `.lif` перезаписывается.
@@ -74,16 +74,19 @@ awcs users me
74
74
  awcs mission_templates list
75
75
  awcs robots list
76
76
  awcs missions list
77
- awcs missions create --template_code PickPallet --params "{\"zone\":\"A\"}"
78
- awcs mission_templates execute PickPallet --params "{\"zone\":\"A\"}"
77
+ awcs missions create --template_code PickPallet [--params "{\"zone\":\"A\"}"]
78
+ awcs mission_templates execute PickPallet [--params "{\"zone\":\"A\"}"]
79
79
  awcs projects list
80
- awcs projects create --lif_json path/to/layout.lif -m MAIRUI -v 1.0.0 --desc "line 1" --sync
80
+ awcs projects create --lif_json path/to/layout.lif -m MAIRUI -v 1.0.0 [--desc "line 1"] [--sync]
81
+ awcs projects upload --lif_json path/to/layout.lif -m MAIRUI -v 1.0.0 [--desc "line 1"] [--sync]
82
+ awcs projects download
83
+ awcs projects download 12 -o path/to/layout.lif
81
84
  awcs projects activate 12
82
85
  ```
83
86
 
84
87
  `awcs login` проверяет ключ через `GET /users/me` и только после ответа 200 сохраняет origin и ключ. Профиль лежит вне репозитория: путь из `AWCS_CONFIG`, иначе `%APPDATA%\awcs\config.ini` на Windows и `~/.config/awcs/config.ini` в остальных системах. Ключ в вывод не печатается. Клиент сам дописывает `/api/v1` и шлёт заголовок `X-Api-Key`.
85
88
 
86
- У списков есть `--page`, `--per_page`, `--filter`, `--sort_by`. `awcs missions create` принимает `--template_id` или `--template_code` и необязательный `--params`. `awcs mission_templates execute` отправляет `--params` плоским JSON-объектом. `awcs projects create` загружает LIF как `multipart/form-data`: `-m` это `--manufacturer`, `-v` и `--version` это `--project_version`, `--desc` это `--description`, `--sync` это `--synchronous`.
89
+ У списков есть `--page`, `--per_page`, `--filter`, `--sort_by`. `awcs missions create` принимает `--template_id` или `--template_code` и необязательный `--params`. `awcs mission_templates execute` отправляет `--params` плоским JSON-объектом. `awcs projects create` загружает LIF как `multipart/form-data`: `-m` это `--manufacturer`, `-v` и `--version` это `--project_version`, `--desc` это `--description`, `--sync` это `--synchronous`. `awcs projects upload` делает то же самое. `awcs projects download` без id забирает активный проект (`GET /projects/active`), с id — этот проект, и сохраняет исходный LIF. `-o` задаёт файл или существующую папку; без него файл пишется в текущий каталог под именем вложения. Путь печатается в stdout.
87
90
 
88
91
  ### Удаление
89
92
 
@@ -28,7 +28,7 @@ uv tool install .
28
28
 
29
29
  ```
30
30
  uvx --from awcs-scripts awcs help
31
- uvx --from awcs-scripts awcs mrmap-to-raster path/to/map.mrmap --resolution 0.05 --out-dir path/to/out
31
+ uvx --from awcs-scripts awcs mrmap-to-raster path/to/map.mrmap [--resolution 0.05] [--out-dir path/to/out]
32
32
  uvx --from awcs-scripts awcs users me
33
33
  ```
34
34
 
@@ -44,8 +44,8 @@ awcs version
44
44
  #### Карты
45
45
 
46
46
  ```
47
- awcs mrmap-to-raster path/to/map.mrmap --out-dir path/to/out
48
- awcs mrmap-to-bg path/to/map.mrmap path/to/layout.lif --layout_id layoutId --resolution 0.05 --output path/to/layout.out.lif
47
+ awcs mrmap-to-raster path/to/map.mrmap [--out-dir path/to/out]
48
+ awcs mrmap-to-bg path/to/map.mrmap path/to/layout.lif --layout_id layoutId [--resolution 0.05] [--output path/to/layout.out.lif]
49
49
  ```
50
50
 
51
51
  `awcs mrmap-to-raster` пишет PNG, GeoTIFF и JSON. `--resolution` задаёт метры на пиксель, по умолчанию берётся `resolution_2d` из карты. `awcs mrmap-to-bg` подставляет этот растр в `backgroundImage` одного layout. Без `--output` исходный `.lif` перезаписывается.
@@ -59,16 +59,19 @@ awcs users me
59
59
  awcs mission_templates list
60
60
  awcs robots list
61
61
  awcs missions list
62
- awcs missions create --template_code PickPallet --params "{\"zone\":\"A\"}"
63
- awcs mission_templates execute PickPallet --params "{\"zone\":\"A\"}"
62
+ awcs missions create --template_code PickPallet [--params "{\"zone\":\"A\"}"]
63
+ awcs mission_templates execute PickPallet [--params "{\"zone\":\"A\"}"]
64
64
  awcs projects list
65
- awcs projects create --lif_json path/to/layout.lif -m MAIRUI -v 1.0.0 --desc "line 1" --sync
65
+ awcs projects create --lif_json path/to/layout.lif -m MAIRUI -v 1.0.0 [--desc "line 1"] [--sync]
66
+ awcs projects upload --lif_json path/to/layout.lif -m MAIRUI -v 1.0.0 [--desc "line 1"] [--sync]
67
+ awcs projects download
68
+ awcs projects download 12 -o path/to/layout.lif
66
69
  awcs projects activate 12
67
70
  ```
68
71
 
69
72
  `awcs login` проверяет ключ через `GET /users/me` и только после ответа 200 сохраняет origin и ключ. Профиль лежит вне репозитория: путь из `AWCS_CONFIG`, иначе `%APPDATA%\awcs\config.ini` на Windows и `~/.config/awcs/config.ini` в остальных системах. Ключ в вывод не печатается. Клиент сам дописывает `/api/v1` и шлёт заголовок `X-Api-Key`.
70
73
 
71
- У списков есть `--page`, `--per_page`, `--filter`, `--sort_by`. `awcs missions create` принимает `--template_id` или `--template_code` и необязательный `--params`. `awcs mission_templates execute` отправляет `--params` плоским JSON-объектом. `awcs projects create` загружает LIF как `multipart/form-data`: `-m` это `--manufacturer`, `-v` и `--version` это `--project_version`, `--desc` это `--description`, `--sync` это `--synchronous`.
74
+ У списков есть `--page`, `--per_page`, `--filter`, `--sort_by`. `awcs missions create` принимает `--template_id` или `--template_code` и необязательный `--params`. `awcs mission_templates execute` отправляет `--params` плоским JSON-объектом. `awcs projects create` загружает LIF как `multipart/form-data`: `-m` это `--manufacturer`, `-v` и `--version` это `--project_version`, `--desc` это `--description`, `--sync` это `--synchronous`. `awcs projects upload` делает то же самое. `awcs projects download` без id забирает активный проект (`GET /projects/active`), с id — этот проект, и сохраняет исходный LIF. `-o` задаёт файл или существующую папку; без него файл пишется в текущий каталог под именем вложения. Путь печатается в stdout.
72
75
 
73
76
  ### Удаление
74
77
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "awcs-scripts"
3
- version = "1.0.2"
3
+ version = "1.1.0"
4
4
  description = "Command line scripts for WCS maps and the ARMS API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """Scripts for WCS maps and the ARMS API."""
2
2
 
3
- __version__ = "1.0.2"
3
+ __version__ = "1.1.0"
@@ -24,6 +24,13 @@ def origin_of(url: str) -> str:
24
24
  return value
25
25
 
26
26
 
27
+ def _strip_api_prefix(path: str) -> str:
28
+ """Drop a leading /api/v1 so the client base URL does not double it."""
29
+ if path.startswith(_API_PREFIX + "/"):
30
+ return path[len(_API_PREFIX) :]
31
+ return path
32
+
33
+
27
34
  class Client:
28
35
  def __init__(
29
36
  self,
@@ -64,3 +71,30 @@ class Client:
64
71
  if response.status_code >= 400:
65
72
  raise ApiError(response.status_code, response.text)
66
73
  return response
74
+
75
+ def get(self, url_or_path: str) -> httpx.Response:
76
+ """GET an API path or an absolute URL.
77
+
78
+ A leading /api/v1 is removed so the client base URL does not double it.
79
+ An absolute URL on this origin is sent with the API key. An absolute URL
80
+ on another host is fetched without the key.
81
+ """
82
+ if url_or_path.startswith(("http://", "https://")):
83
+ return self._get_absolute(url_or_path)
84
+ return self.request("GET", _strip_api_prefix(url_or_path))
85
+
86
+ def _get_absolute(self, url: str) -> httpx.Response:
87
+ target = httpx.URL(url)
88
+ origin = httpx.URL(self.origin)
89
+ same_origin = (
90
+ target.scheme == origin.scheme
91
+ and target.host == origin.host
92
+ and target.port == origin.port
93
+ )
94
+ if not same_origin:
95
+ response = httpx.get(url, timeout=self._client.timeout, follow_redirects=True)
96
+ if response.status_code >= 400:
97
+ raise ApiError(response.status_code, response.text)
98
+ return response
99
+ params = dict(target.params) or None
100
+ return self.request("GET", _strip_api_prefix(target.path), params=params)
@@ -158,6 +158,45 @@ def _cmd_projects_activate(args: argparse.Namespace) -> None:
158
158
  _emit(client.request("POST", f"/projects/{args.id}/activate"))
159
159
 
160
160
 
161
+ def _download_name(source: dict[str, Any], project_id: object) -> str:
162
+ raw = str(source.get("filename") or "").replace("\\", "/")
163
+ name = Path(raw).name
164
+ if name and name not in {".", ".."}:
165
+ return name
166
+ return f"project-{project_id}.lif"
167
+
168
+
169
+ def _download_destination(output: Path | None, filename: str) -> Path:
170
+ if output is None:
171
+ return Path(filename)
172
+ output = output.expanduser()
173
+ if output.is_dir():
174
+ return output / filename
175
+ return output
176
+
177
+
178
+ def _cmd_projects_download(args: argparse.Namespace) -> None:
179
+ with _open_client() as client:
180
+ if args.id is None:
181
+ project = client.request("GET", "/projects/active").json()
182
+ else:
183
+ project = client.request("GET", f"/projects/{args.id}").json()
184
+ source = project.get("source") if isinstance(project, dict) else None
185
+ if not isinstance(source, dict):
186
+ source = {}
187
+ download_url = source.get("download_url")
188
+ if not download_url:
189
+ attachment_id = source.get("id")
190
+ if not attachment_id:
191
+ _fail("project has no map file")
192
+ download_url = f"/attachments/{attachment_id}/download"
193
+ response = client.get(str(download_url))
194
+ project_id = project.get("id", args.id) if isinstance(project, dict) else args.id
195
+ dest = _download_destination(args.output, _download_name(source, project_id))
196
+ dest.write_bytes(response.content)
197
+ print(dest)
198
+
199
+
161
200
  _TOOL_COMMANDS = {
162
201
  "mrmap-to-raster": "mrmap-to-raster.py",
163
202
  "mrmap-to-bg": "mrmap-to-bg.py",
@@ -229,13 +268,24 @@ def build_parser() -> argparse.ArgumentParser:
229
268
  projects_list = projects_sub.add_parser("list", help="GET /projects")
230
269
  _add_list_flags(projects_list)
231
270
  projects_list.set_defaults(func=_cmd_projects_list)
232
- projects_create = projects_sub.add_parser("create", help="POST /projects with a LIF file")
271
+ projects_create = projects_sub.add_parser(
272
+ "create",
273
+ aliases=["upload"],
274
+ help="POST /projects with a LIF file (alias: upload)",
275
+ )
233
276
  projects_create.add_argument("--lif_json", type=Path, required=True, help="Path to a .lif file")
234
277
  projects_create.add_argument("--manufacturer", "-m", required=True)
235
278
  projects_create.add_argument("--project_version", "--version", "-v", required=True, dest="project_version")
236
279
  projects_create.add_argument("--description", "--desc", dest="description")
237
280
  projects_create.add_argument("--synchronous", "--sync", action="store_true", dest="synchronous")
238
281
  projects_create.set_defaults(func=_cmd_projects_create)
282
+ projects_download = projects_sub.add_parser(
283
+ "download",
284
+ help="Save a project LIF file; the active project when id is omitted",
285
+ )
286
+ projects_download.add_argument("id", type=int, nargs="?", help="Project id; omit to download the active project")
287
+ projects_download.add_argument("--output", "-o", type=Path, help="Output file, or an existing directory")
288
+ projects_download.set_defaults(func=_cmd_projects_download)
239
289
  projects_activate = projects_sub.add_parser("activate", help="POST /projects/{id}/activate")
240
290
  projects_activate.add_argument("id", type=int)
241
291
  projects_activate.set_defaults(func=_cmd_projects_activate)
@@ -7,6 +7,8 @@ from awcs_scripts.api import Client, origin_of
7
7
  from awcs_scripts.cli import main
8
8
  from awcs_scripts.config import load_profile
9
9
 
10
+ _LIF = b'{"layouts":[]}'
11
+
10
12
 
11
13
  def test_origin_strips_api_prefix():
12
14
  assert origin_of("https://dev.wcs.automacon.ru/") == "https://dev.wcs.automacon.ru"
@@ -100,7 +102,8 @@ def test_template_execute_body(tmp_path, monkeypatch):
100
102
  assert seen["body"] == {"zone": "A"}
101
103
 
102
104
 
103
- def test_projects_create_and_activate(tmp_path, monkeypatch):
105
+ @pytest.mark.parametrize("command", ["create", "upload"])
106
+ def test_projects_create_and_activate(tmp_path, monkeypatch, command):
104
107
  _profile(tmp_path, monkeypatch)
105
108
  lif = tmp_path / "layout.lif"
106
109
  lif.write_text("{}", encoding="utf-8")
@@ -113,7 +116,7 @@ def test_projects_create_and_activate(tmp_path, monkeypatch):
113
116
  _install(monkeypatch, handler)
114
117
  main([
115
118
  "projects",
116
- "create",
119
+ command,
117
120
  "--lif_json",
118
121
  str(lif),
119
122
  "-m",
@@ -143,6 +146,91 @@ def test_projects_create_and_activate(tmp_path, monkeypatch):
143
146
  assert seen[1].url.path == "/api/v1/projects/12/activate"
144
147
 
145
148
 
149
+ def test_projects_download_active_uses_source_filename(tmp_path, monkeypatch, capsys):
150
+ _profile(tmp_path, monkeypatch)
151
+ monkeypatch.chdir(tmp_path)
152
+ seen = []
153
+
154
+ def handler(request: httpx.Request) -> httpx.Response:
155
+ seen.append(request.url.path)
156
+ if request.url.path == "/api/v1/projects/active":
157
+ return httpx.Response(200, json={
158
+ "id": 4,
159
+ "source": {
160
+ "id": 9,
161
+ "filename": "nested/line.lif",
162
+ "download_url": "/api/v1/attachments/9/download",
163
+ },
164
+ })
165
+ assert request.headers["x-api-key"] == "secret-key"
166
+ return httpx.Response(200, content=_LIF)
167
+
168
+ _install(monkeypatch, handler)
169
+ main(["projects", "download"])
170
+ assert seen == ["/api/v1/projects/active", "/api/v1/attachments/9/download"]
171
+ assert (tmp_path / "line.lif").read_bytes() == _LIF
172
+ assert capsys.readouterr().out.strip() == "line.lif"
173
+
174
+
175
+ def test_projects_download_id_to_output(tmp_path, monkeypatch, capsys):
176
+ _profile(tmp_path, monkeypatch)
177
+ dest = tmp_path / "saved.lif"
178
+ folder = tmp_path / "maps"
179
+ folder.mkdir()
180
+
181
+ def handler(request: httpx.Request) -> httpx.Response:
182
+ if request.url.path == "/api/v1/projects/12":
183
+ return httpx.Response(200, json={
184
+ "id": 12,
185
+ "source": {"id": 3, "filename": "layout.lif"},
186
+ })
187
+ assert request.url.path == "/api/v1/attachments/3/download"
188
+ return httpx.Response(200, content=_LIF)
189
+
190
+ _install(monkeypatch, handler)
191
+ main(["projects", "download", "12", "-o", str(dest)])
192
+ assert dest.read_bytes() == _LIF
193
+ assert capsys.readouterr().out.strip() == str(dest)
194
+
195
+ main(["projects", "download", "12", "-o", str(folder)])
196
+ assert (folder / "layout.lif").read_bytes() == _LIF
197
+
198
+
199
+ def test_projects_download_without_source_fails(tmp_path, monkeypatch):
200
+ _profile(tmp_path, monkeypatch)
201
+ _install(monkeypatch, lambda request: httpx.Response(200, json={"id": 1, "source": None}))
202
+ with pytest.raises(SystemExit) as caught:
203
+ main(["projects", "download", "1"])
204
+ assert caught.value.code == 1
205
+
206
+
207
+ def test_client_get_same_origin_keeps_query():
208
+ def handler(request: httpx.Request) -> httpx.Response:
209
+ assert request.url.path == "/api/v1/attachments/9/download"
210
+ assert request.url.params["x"] == "1"
211
+ assert request.headers["x-api-key"] == "secret-key"
212
+ return httpx.Response(200, content=_LIF)
213
+
214
+ with Client("https://dev.wcs.automacon.ru", "secret-key", transport=httpx.MockTransport(handler)) as client:
215
+ response = client.get("https://dev.wcs.automacon.ru/api/v1/attachments/9/download?x=1")
216
+ assert response.content == _LIF
217
+
218
+
219
+ def test_client_get_external_omits_api_key(monkeypatch):
220
+ seen = {}
221
+
222
+ def fake_get(url, **kwargs):
223
+ seen["url"] = url
224
+ seen["headers"] = kwargs.get("headers")
225
+ return httpx.Response(200, content=_LIF)
226
+
227
+ monkeypatch.setattr("awcs_scripts.api.httpx.get", fake_get)
228
+ with Client("https://dev.wcs.automacon.ru", "secret-key") as client:
229
+ response = client.get("https://files.example/map.lif")
230
+ assert response.content == _LIF
231
+ assert seen == {"url": "https://files.example/map.lif", "headers": None}
232
+
233
+
146
234
  def _profile(tmp_path, monkeypatch):
147
235
  config = tmp_path / "config.ini"
148
236
  config.write_text("[default]\nurl = https://dev.wcs.automacon.ru\napikey = secret-key\n", encoding="utf-8")
@@ -23,7 +23,7 @@ wheels = [
23
23
 
24
24
  [[package]]
25
25
  name = "awcs-scripts"
26
- version = "1.0.2"
26
+ version = "1.1.0"
27
27
  source = { editable = "." }
28
28
  dependencies = [
29
29
  { name = "httpx" },
File without changes
File without changes