awcs-scripts 1.0.1__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """Scripts for WCS maps and the ARMS API."""
2
+
3
+ __version__ = "0.1.0"
awcs_scripts/api.py ADDED
@@ -0,0 +1,66 @@
1
+ """ARMS HTTP client. The API key is sent as X-Api-Key."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ _API_PREFIX = "/api/v1"
10
+
11
+
12
+ class ApiError(Exception):
13
+ def __init__(self, status_code: int, body: str) -> None:
14
+ self.status_code = status_code
15
+ self.body = body
16
+ super().__init__(f"HTTP {status_code}")
17
+
18
+
19
+ def origin_of(url: str) -> str:
20
+ """Return the site origin. A trailing /api/v1 is stripped so it is not doubled."""
21
+ value = url.strip().rstrip("/")
22
+ if value.endswith(_API_PREFIX):
23
+ value = value[: -len(_API_PREFIX)].rstrip("/")
24
+ return value
25
+
26
+
27
+ class Client:
28
+ def __init__(
29
+ self,
30
+ url: str,
31
+ apikey: str,
32
+ *,
33
+ transport: httpx.BaseTransport | None = None,
34
+ timeout: float = 120,
35
+ ) -> None:
36
+ self.origin = origin_of(url)
37
+ self._client = httpx.Client(
38
+ base_url=self.origin + _API_PREFIX,
39
+ headers={"X-Api-Key": apikey},
40
+ transport=transport,
41
+ timeout=timeout,
42
+ )
43
+
44
+ def close(self) -> None:
45
+ self._client.close()
46
+
47
+ def __enter__(self) -> Client:
48
+ return self
49
+
50
+ def __exit__(self, *args: object) -> None:
51
+ self.close()
52
+
53
+ def request(
54
+ self,
55
+ method: str,
56
+ path: str,
57
+ *,
58
+ params: dict[str, Any] | None = None,
59
+ json: Any = None,
60
+ data: dict[str, Any] | None = None,
61
+ files: dict[str, Any] | None = None,
62
+ ) -> httpx.Response:
63
+ response = self._client.request(method, path, params=params, json=json, data=data, files=files)
64
+ if response.status_code >= 400:
65
+ raise ApiError(response.status_code, response.text)
66
+ return response
awcs_scripts/cli.py ADDED
@@ -0,0 +1,264 @@
1
+ """Single `awcs` command for map tools and a slice of the ARMS API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib.util
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import quote
12
+
13
+ import httpx
14
+
15
+ from awcs_scripts.api import ApiError, Client, origin_of
16
+ from awcs_scripts.config import ConfigError, load_profile, save_profile
17
+
18
+ _TOOLS = Path(__file__).resolve().parent / "mrmap"
19
+
20
+
21
+ def _load_tool(filename: str):
22
+ path = _TOOLS / filename
23
+ spec = importlib.util.spec_from_file_location(filename.removesuffix(".py").replace("-", "_"), path)
24
+ if spec is None or spec.loader is None:
25
+ raise ImportError(f"cannot load {path}")
26
+ module = importlib.util.module_from_spec(spec)
27
+ spec.loader.exec_module(module)
28
+ return module
29
+
30
+
31
+ def _fail(message: str) -> None:
32
+ print(message, file=sys.stderr)
33
+ raise SystemExit(1)
34
+
35
+
36
+ def _emit(response: httpx.Response) -> None:
37
+ if not response.content:
38
+ return
39
+ print(json.dumps(response.json(), ensure_ascii=False, indent=2))
40
+
41
+
42
+ def _open_client() -> Client:
43
+ try:
44
+ url, apikey = load_profile()
45
+ except ConfigError as exc:
46
+ _fail(str(exc))
47
+ return Client(url, apikey)
48
+
49
+
50
+ def _params_object(raw: str | None) -> dict[str, Any] | None:
51
+ if raw is None:
52
+ return None
53
+ payload = json.loads(raw)
54
+ if not isinstance(payload, dict):
55
+ _fail("--params must be a JSON object")
56
+ return payload
57
+
58
+
59
+ def _list_params(args: argparse.Namespace) -> dict[str, Any]:
60
+ params: dict[str, Any] = {}
61
+ if args.page is not None:
62
+ params["page"] = args.page
63
+ if args.per_page is not None:
64
+ params["per_page"] = args.per_page
65
+ if args.filter is not None:
66
+ params["filter"] = args.filter
67
+ if args.sort_by is not None:
68
+ params["sort_by"] = args.sort_by
69
+ return params
70
+
71
+
72
+ def _add_list_flags(parser: argparse.ArgumentParser) -> None:
73
+ parser.add_argument("--page", type=int)
74
+ parser.add_argument("--per_page", type=int)
75
+ parser.add_argument("--filter")
76
+ parser.add_argument("--sort_by")
77
+
78
+
79
+ def _cmd_login(args: argparse.Namespace) -> None:
80
+ origin = origin_of(args.url)
81
+ with Client(origin, args.apikey) as client:
82
+ response = client.request("GET", "/users/me")
83
+ path = save_profile(origin, args.apikey)
84
+ _emit(response)
85
+ print(f"saved {path}", file=sys.stderr)
86
+
87
+
88
+ def _cmd_users_list(args: argparse.Namespace) -> None:
89
+ with _open_client() as client:
90
+ _emit(client.request("GET", "/users", params=_list_params(args)))
91
+
92
+
93
+ def _cmd_users_me(_args: argparse.Namespace) -> None:
94
+ with _open_client() as client:
95
+ _emit(client.request("GET", "/users/me"))
96
+
97
+
98
+ def _cmd_templates_list(args: argparse.Namespace) -> None:
99
+ with _open_client() as client:
100
+ _emit(client.request("GET", "/mission_templates", params=_list_params(args)))
101
+
102
+
103
+ def _cmd_templates_execute(args: argparse.Namespace) -> None:
104
+ body = _params_object(args.params)
105
+ if body is None:
106
+ body = {}
107
+ target = quote(args.id_or_code, safe="")
108
+ with _open_client() as client:
109
+ _emit(client.request("POST", f"/mission_templates/{target}/execute", json=body))
110
+
111
+
112
+ def _cmd_robots_list(args: argparse.Namespace) -> None:
113
+ with _open_client() as client:
114
+ _emit(client.request("GET", "/robots", params=_list_params(args)))
115
+
116
+
117
+ def _cmd_missions_list(args: argparse.Namespace) -> None:
118
+ with _open_client() as client:
119
+ _emit(client.request("GET", "/missions", params=_list_params(args)))
120
+
121
+
122
+ def _cmd_missions_create(args: argparse.Namespace) -> None:
123
+ if args.template_id is not None:
124
+ body: dict[str, Any] = {"template_id": args.template_id}
125
+ else:
126
+ body = {"template_code": args.template_code}
127
+ params = _params_object(args.params)
128
+ if params is not None:
129
+ body["params"] = params
130
+ with _open_client() as client:
131
+ _emit(client.request("POST", "/missions", json=body))
132
+
133
+
134
+ def _cmd_projects_list(args: argparse.Namespace) -> None:
135
+ with _open_client() as client:
136
+ _emit(client.request("GET", "/projects", params=_list_params(args)))
137
+
138
+
139
+ def _cmd_projects_create(args: argparse.Namespace) -> None:
140
+ lif_path = args.lif_json.expanduser()
141
+ data = {"manufacturer": args.manufacturer, "project_version": args.project_version}
142
+ if args.description is not None:
143
+ data["description"] = args.description
144
+ params = {"synchronous": "true"} if args.synchronous else None
145
+ with _open_client() as client:
146
+ with lif_path.open("rb") as handle:
147
+ files = {"lif_json": (lif_path.name, handle, "application/json")}
148
+ _emit(client.request("POST", "/projects", params=params, data=data, files=files))
149
+
150
+
151
+ def _cmd_projects_activate(args: argparse.Namespace) -> None:
152
+ with _open_client() as client:
153
+ _emit(client.request("POST", f"/projects/{args.id}/activate"))
154
+
155
+
156
+ _TOOL_COMMANDS = {
157
+ "mrmap-to-raster": "mrmap-to-raster.py",
158
+ "mrmap-to-bg": "mrmap-to-bg.py",
159
+ }
160
+
161
+
162
+ def _add_tool(sub: argparse._SubParsersAction, name: str, help_text: str) -> None:
163
+ # The real argv is handled in main() before parse_args, so --help reaches the tool.
164
+ # The subparser exists so `awcs help` lists the command.
165
+ sub.add_parser(name, help=help_text)
166
+
167
+
168
+ def build_parser() -> argparse.ArgumentParser:
169
+ parser = argparse.ArgumentParser(prog="awcs", description="AWCS API client")
170
+ sub = parser.add_subparsers(dest="command")
171
+
172
+ help_parser = sub.add_parser("help", help="Show this help")
173
+ help_parser.set_defaults(func=lambda _args: parser.print_help())
174
+
175
+ _add_tool(sub, "mrmap-to-raster", "Rasterize a .mrmap to PNG, GeoTIFF, and JSON")
176
+ _add_tool(sub, "mrmap-to-bg", "Set a .mrmap raster as a LIF layout background")
177
+
178
+ login = sub.add_parser("login", help="Check an API key and save the profile")
179
+ login.add_argument("--url", required=True, help="ARMS origin, for example https://dev.wcs.automacon.ru")
180
+ login.add_argument("--apikey", required=True)
181
+ login.set_defaults(func=_cmd_login)
182
+
183
+ users = sub.add_parser("users", help="Users")
184
+ users_sub = users.add_subparsers(dest="users_command", required=True)
185
+ users_list = users_sub.add_parser("list", help="GET /users")
186
+ _add_list_flags(users_list)
187
+ users_list.set_defaults(func=_cmd_users_list)
188
+ users_me = users_sub.add_parser("me", help="GET /users/me")
189
+ users_me.set_defaults(func=_cmd_users_me)
190
+
191
+ templates = sub.add_parser("mission_templates", help="Mission templates")
192
+ templates_sub = templates.add_subparsers(dest="mission_templates_command", required=True)
193
+ templates_list = templates_sub.add_parser("list", help="GET /mission_templates")
194
+ _add_list_flags(templates_list)
195
+ templates_list.set_defaults(func=_cmd_templates_list)
196
+ templates_execute = templates_sub.add_parser("execute", help="POST /mission_templates/{id}/execute")
197
+ templates_execute.add_argument("id_or_code", help="Numeric id or template code")
198
+ templates_execute.add_argument("--params", help="JSON object sent as the request body")
199
+ templates_execute.set_defaults(func=_cmd_templates_execute)
200
+
201
+ robots = sub.add_parser("robots", help="Robots")
202
+ robots_sub = robots.add_subparsers(dest="robots_command", required=True)
203
+ robots_list = robots_sub.add_parser("list", help="GET /robots")
204
+ _add_list_flags(robots_list)
205
+ robots_list.set_defaults(func=_cmd_robots_list)
206
+
207
+ missions = sub.add_parser("missions", help="Missions")
208
+ missions_sub = missions.add_subparsers(dest="missions_command", required=True)
209
+ missions_list = missions_sub.add_parser("list", help="GET /missions")
210
+ _add_list_flags(missions_list)
211
+ missions_list.set_defaults(func=_cmd_missions_list)
212
+ missions_create = missions_sub.add_parser("create", help="POST /missions")
213
+ template = missions_create.add_mutually_exclusive_group(required=True)
214
+ template.add_argument("--template_id", type=int)
215
+ template.add_argument("--template_code")
216
+ missions_create.add_argument("--params", help="JSON object stored in params")
217
+ missions_create.set_defaults(func=_cmd_missions_create)
218
+
219
+ projects = sub.add_parser("projects", help="Navigation projects")
220
+ projects_sub = projects.add_subparsers(dest="projects_command", required=True)
221
+ projects_list = projects_sub.add_parser("list", help="GET /projects")
222
+ _add_list_flags(projects_list)
223
+ projects_list.set_defaults(func=_cmd_projects_list)
224
+ projects_create = projects_sub.add_parser("create", help="POST /projects with a LIF file")
225
+ projects_create.add_argument("--lif_json", type=Path, required=True, help="Path to a .lif file")
226
+ projects_create.add_argument("--manufacturer", "-m", required=True)
227
+ projects_create.add_argument("--project_version", "--version", "-v", required=True, dest="project_version")
228
+ projects_create.add_argument("--description", "--desc", dest="description")
229
+ projects_create.add_argument("--synchronous", "--sync", action="store_true", dest="synchronous")
230
+ projects_create.set_defaults(func=_cmd_projects_create)
231
+ projects_activate = projects_sub.add_parser("activate", help="POST /projects/{id}/activate")
232
+ projects_activate.add_argument("id", type=int)
233
+ projects_activate.set_defaults(func=_cmd_projects_activate)
234
+
235
+ return parser
236
+
237
+
238
+ def main(argv: list[str] | None = None) -> None:
239
+ args_list = list(sys.argv[1:] if argv is None else argv)
240
+ if args_list and args_list[0] in _TOOL_COMMANDS:
241
+ _load_tool(_TOOL_COMMANDS[args_list[0]]).main(args_list[1:])
242
+ return
243
+ parser = build_parser()
244
+ args = parser.parse_args(args_list)
245
+ func = getattr(args, "func", None)
246
+ if func is None:
247
+ parser.print_help()
248
+ return
249
+ try:
250
+ func(args)
251
+ except ApiError as exc:
252
+ _fail(exc.body or f"HTTP {exc.status_code}")
253
+ except ConfigError as exc:
254
+ _fail(str(exc))
255
+ except json.JSONDecodeError as exc:
256
+ _fail(f"invalid JSON: {exc}")
257
+ except httpx.HTTPError as exc:
258
+ _fail(str(exc))
259
+ except OSError as exc:
260
+ _fail(str(exc))
261
+
262
+
263
+ if __name__ == "__main__":
264
+ main()
awcs_scripts/config.py ADDED
@@ -0,0 +1,71 @@
1
+ """Profile with the ARMS origin and API key, stored outside the repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import configparser
6
+ import os
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ _SECTION = "default"
11
+
12
+
13
+ class ConfigError(Exception):
14
+ pass
15
+
16
+
17
+ def config_path() -> Path:
18
+ override = os.environ.get("AWCS_CONFIG")
19
+ if override:
20
+ return Path(override).expanduser()
21
+ if os.name == "nt":
22
+ appdata = os.environ.get("APPDATA")
23
+ if appdata:
24
+ return Path(appdata) / "awcs" / "config.ini"
25
+ return Path.home() / ".config" / "awcs" / "config.ini"
26
+
27
+
28
+ def load_profile() -> tuple[str, str]:
29
+ path = config_path()
30
+ if not path.is_file():
31
+ raise ConfigError(f"no profile at {path}; run awcs login")
32
+ parser = configparser.ConfigParser()
33
+ parser.read(path, encoding="utf-8")
34
+ if not parser.has_section(_SECTION):
35
+ raise ConfigError(f"profile {path} has no [{_SECTION}] section")
36
+ url = parser.get(_SECTION, "url", fallback="").strip()
37
+ apikey = parser.get(_SECTION, "apikey", fallback="").strip()
38
+ if not url or not apikey:
39
+ raise ConfigError(f"profile {path} is missing url or apikey")
40
+ return url, apikey
41
+
42
+
43
+ def save_profile(url: str, apikey: str) -> Path:
44
+ path = config_path()
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ parser = configparser.ConfigParser()
47
+ parser[_SECTION] = {"url": url, "apikey": apikey}
48
+ with path.open("w", encoding="utf-8", newline="\n") as handle:
49
+ parser.write(handle)
50
+ _user_only(path)
51
+ return path
52
+
53
+
54
+ def _user_only(path: Path) -> None:
55
+ try:
56
+ os.chmod(path, 0o600)
57
+ except OSError:
58
+ pass
59
+ if os.name != "nt":
60
+ return
61
+ user = os.environ.get("USERNAME")
62
+ if not user:
63
+ return
64
+ try:
65
+ subprocess.run(
66
+ ["icacls", str(path), "/inheritance:r", "/grant:r", f"{user}:F"],
67
+ check=False,
68
+ capture_output=True,
69
+ )
70
+ except OSError:
71
+ pass
@@ -0,0 +1 @@
1
+ """MAIRUI map tools. Command modules use hyphenated filenames and are loaded by path."""
@@ -0,0 +1,434 @@
1
+ """Put a MAIRUI .mrmap raster into one LIF layout's backgroundImage.
2
+
3
+ Only the backgroundImage object of the chosen layout is rewritten. Every other
4
+ byte of the .lif file stays as it was, including node coordinates.
5
+
6
+ Usage:
7
+ awcs mrmap-to-bg MAP.mrmap MAP.lif --layout_id LAYOUT_ID [--resolution METERS] [--output OUT.lif]
8
+
9
+ The raster uses resolution_2d from the .mrmap (override with --resolution).
10
+ Without --output the .lif is overwritten. Node coordinates are not edited.
11
+
12
+ awcs mrmap-to-bg MM_before_BODY01_shift.mrmap map_5.0.69.lif --layout_id mapmain
13
+ awcs mrmap-to-bg MM_before_BODY01_shift.mrmap map_5.0.69.lif --layout_id mapmain --output map_with_bg.lif
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import base64
20
+ import importlib.util
21
+ import io
22
+ import json
23
+ import os
24
+ import zipfile
25
+ from pathlib import Path
26
+
27
+ from PIL import Image
28
+
29
+
30
+ def _load_raster():
31
+ path = Path(__file__).with_name("mrmap-to-raster.py")
32
+ spec = importlib.util.spec_from_file_location("mrmap_to_raster", path)
33
+ if spec is None or spec.loader is None:
34
+ raise ImportError(f"cannot load {path}")
35
+ module = importlib.util.module_from_spec(spec)
36
+ spec.loader.exec_module(module)
37
+ return module
38
+
39
+
40
+ _raster = _load_raster()
41
+ load_head = _raster.load_head
42
+ rasterize = _raster.rasterize
43
+
44
+ _WS = " \t\r\n"
45
+
46
+
47
+ class LifEditError(ValueError):
48
+ pass
49
+
50
+
51
+ def _skip_string(text: str, start: int) -> int:
52
+ """Return the index just after the closing quote. start points at the opening quote."""
53
+ i = start + 1
54
+ n = len(text)
55
+ while i < n:
56
+ quote = text.find('"', i)
57
+ if quote < 0:
58
+ raise LifEditError(f"unterminated string at {start}")
59
+ slashes = 0
60
+ j = quote - 1
61
+ while j >= start and text[j] == "\\":
62
+ slashes += 1
63
+ j -= 1
64
+ if slashes % 2 == 0:
65
+ return quote + 1
66
+ i = quote + 1
67
+ raise LifEditError(f"unterminated string at {start}")
68
+
69
+
70
+ def _skip_number(text: str, start: int) -> int:
71
+ i = start + 1
72
+ n = len(text)
73
+ while i < n and text[i] in "0123456789eE+-.":
74
+ i += 1
75
+ return i
76
+
77
+
78
+ def _value_done(stack: list[dict]) -> None:
79
+ if stack and stack[-1]["kind"] == "obj" and stack[-1].get("expect") == "value":
80
+ stack[-1]["expect"] = "comma"
81
+
82
+
83
+ def find_layout_spans(text: str, layout_id: str) -> list[tuple[int, int]]:
84
+ """Return [start, end) brace spans of objects whose layoutId equals layout_id."""
85
+ spans: list[tuple[int, int]] = []
86
+ stack: list[dict] = []
87
+ i = 0
88
+ n = len(text)
89
+ while i < n:
90
+ char = text[i]
91
+ if char in _WS:
92
+ i += 1
93
+ continue
94
+ if char == "{":
95
+ stack.append({"kind": "obj", "start": i, "expect": "key", "match": False})
96
+ i += 1
97
+ continue
98
+ if char == "[":
99
+ stack.append({"kind": "arr", "start": i})
100
+ i += 1
101
+ continue
102
+ if char == "}":
103
+ if not stack or stack[-1]["kind"] != "obj":
104
+ raise LifEditError(f"unexpected closing brace at {i}")
105
+ frame = stack.pop()
106
+ end = i + 1
107
+ if frame["match"]:
108
+ spans.append((frame["start"], end))
109
+ _value_done(stack)
110
+ i = end
111
+ continue
112
+ if char == "]":
113
+ if not stack or stack[-1]["kind"] != "arr":
114
+ raise LifEditError(f"unexpected closing bracket at {i}")
115
+ stack.pop()
116
+ _value_done(stack)
117
+ i += 1
118
+ continue
119
+ if char == ",":
120
+ if stack and stack[-1]["kind"] == "obj":
121
+ stack[-1]["expect"] = "key"
122
+ i += 1
123
+ continue
124
+ if char == ":":
125
+ if not stack or stack[-1]["kind"] != "obj" or stack[-1].get("expect") != "colon":
126
+ raise LifEditError(f"unexpected colon at {i}")
127
+ stack[-1]["expect"] = "value"
128
+ i += 1
129
+ continue
130
+ if char == '"':
131
+ end = _skip_string(text, i)
132
+ if stack and stack[-1]["kind"] == "obj":
133
+ expect = stack[-1].get("expect")
134
+ if expect == "key":
135
+ stack[-1]["key"] = json.loads(text[i:end])
136
+ stack[-1]["expect"] = "colon"
137
+ elif expect == "value":
138
+ if stack[-1].get("key") == "layoutId" and json.loads(text[i:end]) == layout_id:
139
+ stack[-1]["match"] = True
140
+ _value_done(stack)
141
+ else:
142
+ raise LifEditError(f"unexpected string at {i}")
143
+ i = end
144
+ continue
145
+ if char == "-" or char.isdigit():
146
+ end = _skip_number(text, i)
147
+ if stack and stack[-1]["kind"] == "obj" and stack[-1].get("expect") != "value":
148
+ raise LifEditError(f"unexpected number at {i}")
149
+ _value_done(stack)
150
+ i = end
151
+ continue
152
+ word = next((item for item in ("true", "false", "null") if text.startswith(item, i)), None)
153
+ if word is None:
154
+ raise LifEditError(f"unexpected character {char!r} at {i}")
155
+ if stack and stack[-1]["kind"] == "obj" and stack[-1].get("expect") != "value":
156
+ raise LifEditError(f"unexpected literal at {i}")
157
+ _value_done(stack)
158
+ i += len(word)
159
+ if stack:
160
+ raise LifEditError("unbalanced JSON")
161
+ return spans
162
+
163
+
164
+ def _skip_container(text: str, start: int) -> int:
165
+ """Return the index just after the container that starts at start."""
166
+ opener = text[start]
167
+ closer = "}" if opener == "{" else "]"
168
+ if opener not in "{[":
169
+ raise LifEditError(f"expected container at {start}")
170
+ depth = 0
171
+ i = start
172
+ n = len(text)
173
+ while i < n:
174
+ char = text[i]
175
+ if char == '"':
176
+ i = _skip_string(text, i)
177
+ continue
178
+ if char == opener:
179
+ depth += 1
180
+ elif char == closer:
181
+ depth -= 1
182
+ if depth == 0:
183
+ return i + 1
184
+ i += 1
185
+ raise LifEditError(f"unbalanced container at {start}")
186
+
187
+
188
+ def find_member(text: str, obj_start: int, obj_end: int, name: str) -> tuple[int, int, int] | None:
189
+ """Find a direct child of the object. Returns (key_quote, value_start, value_end)."""
190
+ i = obj_start + 1
191
+ depth = 1
192
+ expect = "key"
193
+ key = None
194
+ key_at = None
195
+ while i < obj_end and depth:
196
+ char = text[i]
197
+ if char in _WS:
198
+ i += 1
199
+ continue
200
+ if char == "{":
201
+ if depth == 1 and expect == "value" and key == name:
202
+ return key_at, i, _skip_container(text, i)
203
+ depth += 1
204
+ i += 1
205
+ continue
206
+ if char == "[":
207
+ if depth == 1 and expect == "value" and key == name:
208
+ return key_at, i, _skip_container(text, i)
209
+ depth += 1
210
+ i += 1
211
+ continue
212
+ if char in "}]":
213
+ depth -= 1
214
+ i += 1
215
+ if depth == 1:
216
+ expect = "comma"
217
+ continue
218
+ if char == ",":
219
+ if depth == 1:
220
+ expect = "key"
221
+ i += 1
222
+ continue
223
+ if char == ":":
224
+ if depth == 1:
225
+ expect = "value"
226
+ i += 1
227
+ continue
228
+ if char == '"':
229
+ end = _skip_string(text, i)
230
+ if depth == 1 and expect == "key":
231
+ key = json.loads(text[i:end])
232
+ key_at = i
233
+ expect = "colon"
234
+ elif depth == 1 and expect == "value":
235
+ if key == name:
236
+ return key_at, i, end
237
+ expect = "comma"
238
+ i = end
239
+ continue
240
+ if char == "-" or char.isdigit():
241
+ end = _skip_number(text, i)
242
+ if depth == 1 and expect == "value" and key == name:
243
+ return key_at, i, end
244
+ if depth == 1 and expect == "value":
245
+ expect = "comma"
246
+ i = end
247
+ continue
248
+ word = next((item for item in ("true", "false", "null") if text.startswith(item, i)), None)
249
+ if word is None:
250
+ raise LifEditError(f"unexpected character {char!r} at {i}")
251
+ if depth == 1 and expect == "value" and key == name:
252
+ return key_at, i, i + len(word)
253
+ if depth == 1 and expect == "value":
254
+ expect = "comma"
255
+ i += len(word)
256
+ return None
257
+
258
+
259
+ def _line_indent(text: str, index: int) -> str:
260
+ line_start = text.rfind("\n", 0, index) + 1
261
+ indent = text[line_start:index]
262
+ if indent.strip():
263
+ raise LifEditError(f"key at {index} is not at the start of its line")
264
+ return indent
265
+
266
+
267
+ def format_background(meta: dict, png_bytes: bytes, key_indent: str, include_key_indent: bool) -> str:
268
+ field_indent = key_indent + " "
269
+ uri = "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii")
270
+ first = '"backgroundImage": {'
271
+ if include_key_indent:
272
+ first = key_indent + first
273
+ lines = [
274
+ first,
275
+ f'{field_indent}"image": {json.dumps(uri)},',
276
+ f'{field_indent}"x": {json.dumps(meta["x"])},',
277
+ f'{field_indent}"y": {json.dumps(meta["y"])},',
278
+ f'{field_indent}"width": {json.dumps(meta["width"])},',
279
+ f'{field_indent}"height": {json.dumps(meta["height"])},',
280
+ f'{field_indent}"naturalWidth": {json.dumps(meta["naturalWidth"])},',
281
+ f'{field_indent}"naturalHeight": {json.dumps(meta["naturalHeight"])}',
282
+ f"{key_indent}}}",
283
+ ]
284
+ return "\n".join(lines)
285
+
286
+
287
+ def replace_background(text: str, layout_id: str, meta: dict, png_bytes: bytes) -> tuple[str, tuple[int, int]]:
288
+ """Return new text and the [start, end) span in the original that was replaced.
289
+
290
+ The span covers only the edited region. Prefix and suffix are copied unchanged.
291
+ For an insert, the span is the whitespace immediately before the layout's closing brace.
292
+ """
293
+ spans = find_layout_spans(text, layout_id)
294
+ if not spans:
295
+ raise LifEditError(f"layoutId {layout_id!r} not found")
296
+ if len(spans) > 1:
297
+ raise LifEditError(f"layoutId {layout_id!r} is not unique ({len(spans)} matches)")
298
+ obj_start, obj_end = spans[0]
299
+ member = find_member(text, obj_start, obj_end, "backgroundImage")
300
+ if member is not None:
301
+ key_at, _value_start, value_end = member
302
+ key_indent = _line_indent(text, key_at)
303
+ block = format_background(meta, png_bytes, key_indent, include_key_indent=False)
304
+ new_text = text[:key_at] + block + text[value_end:]
305
+ return new_text, (key_at, value_end)
306
+
307
+ layout_id_member = find_member(text, obj_start, obj_end, "layoutId")
308
+ if layout_id_member is None:
309
+ raise LifEditError(f"layout object for {layout_id!r} has no layoutId key")
310
+ key_indent = _line_indent(text, layout_id_member[0])
311
+ block = format_background(meta, png_bytes, key_indent, include_key_indent=True)
312
+ closing_line = text.rfind("\n", obj_start, obj_end - 1) + 1
313
+ content_end = closing_line - 1
314
+ while content_end > obj_start and text[content_end] in " \t":
315
+ content_end -= 1
316
+ # content_end is the last newline before the closing brace, or the last content char.
317
+ if text[content_end] == "\n":
318
+ last = content_end - 1
319
+ while last > obj_start and text[last] in " \t":
320
+ last -= 1
321
+ else:
322
+ last = content_end
323
+ if text[last] == "{":
324
+ new_text = text[: last + 1] + "\n" + block + "\n" + text[closing_line:]
325
+ return new_text, (last + 1, closing_line)
326
+ new_text = text[: last + 1] + ",\n" + block + "\n" + text[closing_line:]
327
+ return new_text, (last + 1, closing_line)
328
+
329
+
330
+ def render_png(mrmap_path: Path, resolution: float | None) -> tuple[bytes, dict]:
331
+ with zipfile.ZipFile(mrmap_path) as archive:
332
+ head = load_head(archive)
333
+ if head.get("magic") != "MAIRUI":
334
+ raise LifEditError(f"unexpected map magic: {head.get('magic')!r}")
335
+ if resolution is None:
336
+ resolution = float(head["resolution_2d"])
337
+ if resolution <= 0:
338
+ raise LifEditError(f"resolution must be positive, got {resolution}")
339
+ image, info = rasterize(archive, head, resolution)
340
+ buffer = io.BytesIO()
341
+ Image.fromarray(image, mode="L").save(buffer, format="PNG")
342
+ info["png"] = buffer.getvalue()
343
+ return info["png"], info
344
+
345
+
346
+ def _check_background(data: dict, layout_id: str, meta: dict, png_bytes: bytes) -> None:
347
+ matches = [layout for layout in data["layouts"] if layout.get("layoutId") == layout_id]
348
+ if len(matches) != 1:
349
+ raise LifEditError(f"parsed layout count for {layout_id!r} is {len(matches)}")
350
+ background = matches[0].get("backgroundImage")
351
+ if not isinstance(background, dict):
352
+ raise LifEditError("backgroundImage missing after edit")
353
+ for key in ("x", "y", "width", "height", "naturalWidth", "naturalHeight"):
354
+ if background.get(key) != meta[key]:
355
+ raise LifEditError(f"{key} is {background.get(key)!r}, expected {meta[key]!r}")
356
+ prefix = "data:image/png;base64,"
357
+ image = background.get("image")
358
+ if not isinstance(image, str) or not image.startswith(prefix):
359
+ raise LifEditError("background image is not a png data uri")
360
+ decoded = base64.b64decode(image[len(prefix):], validate=True)
361
+ if decoded != png_bytes:
362
+ raise LifEditError("embedded png does not match the raster")
363
+ with Image.open(io.BytesIO(decoded)) as picture:
364
+ if picture.size != (meta["naturalWidth"], meta["naturalHeight"]):
365
+ raise LifEditError(f"png size {picture.size} does not match natural size")
366
+
367
+
368
+ def apply_background(
369
+ mrmap_path: Path,
370
+ lif_path: Path,
371
+ layout_id: str,
372
+ output_path: Path | None,
373
+ resolution: float | None,
374
+ ) -> dict:
375
+ png_bytes, info = render_png(mrmap_path, resolution)
376
+ original = lif_path.read_bytes()
377
+ text = original.decode("utf-8")
378
+ new_text, (old_start, old_end) = replace_background(text, layout_id, info["meta"], png_bytes)
379
+ suffix_len = len(text) - old_end
380
+ if new_text[:old_start] != text[:old_start]:
381
+ raise LifEditError("edit touched text before backgroundImage")
382
+ if suffix_len and new_text[-suffix_len:] != text[old_end:]:
383
+ raise LifEditError("edit touched text after backgroundImage")
384
+ if suffix_len and len(new_text) < old_start + suffix_len:
385
+ raise LifEditError("edit overlapped the unchanged suffix")
386
+ parsed = json.loads(new_text)
387
+ _check_background(parsed, layout_id, info["meta"], png_bytes)
388
+ new_bytes = new_text.encode("utf-8")
389
+ destination = output_path or lif_path
390
+ destination.parent.mkdir(parents=True, exist_ok=True)
391
+ temporary = destination.with_suffix(destination.suffix + ".tmp")
392
+ temporary.write_bytes(new_bytes)
393
+ os.replace(temporary, destination)
394
+ info["output"] = destination
395
+ info["unchanged_prefix"] = old_start
396
+ info["unchanged_suffix"] = suffix_len
397
+ return info
398
+
399
+
400
+ def main(argv: list[str] | None = None) -> None:
401
+ parser = argparse.ArgumentParser(
402
+ prog="awcs mrmap-to-bg",
403
+ description="Rasterize a .mrmap and set it as a LIF layout backgroundImage.",
404
+ )
405
+ parser.add_argument("mrmap", type=Path, help="path to a .mrmap file")
406
+ parser.add_argument("lif", type=Path, help="path to a .lif file")
407
+ parser.add_argument("--layout_id", required=True, help="layoutId whose backgroundImage is replaced")
408
+ parser.add_argument("--resolution", type=float, default=None, help="meters per pixel (default: resolution_2d)")
409
+ parser.add_argument("--output", type=Path, default=None, help="output .lif (default: overwrite the input)")
410
+ args = parser.parse_args(argv)
411
+ info = apply_background(
412
+ args.mrmap.expanduser().resolve(),
413
+ args.lif.expanduser().resolve(),
414
+ args.layout_id,
415
+ args.output.expanduser().resolve() if args.output else None,
416
+ args.resolution,
417
+ )
418
+ meta = info["meta"]
419
+ print(f"layout {args.layout_id}")
420
+ print(f"resolution_m {info['resolution']}")
421
+ print(f"pixels {meta['naturalWidth']} x {meta['naturalHeight']}")
422
+ print(f"x {meta['x']}")
423
+ print(f"y {meta['y']}")
424
+ print(f"width {meta['width']}")
425
+ print(f"height {meta['height']}")
426
+ print(f"points_kept {info['kept']}")
427
+ print(f"points_dropped {info['dropped']}")
428
+ print(f"unchanged_prefix {info['unchanged_prefix']}")
429
+ print(f"unchanged_suffix {info['unchanged_suffix']}")
430
+ print(f"output {info['output']}")
431
+
432
+
433
+ if __name__ == "__main__":
434
+ main()
@@ -0,0 +1,174 @@
1
+ """Rasterize a MAIRUI .mrmap point cloud into PNG, GeoTIFF, and placement JSON.
2
+
3
+ World frame of each submap point (x, y), yaw in radians:
4
+
5
+ wx = origin_x + cos(yaw) * x - sin(yaw) * y
6
+ wy = origin_y + sin(yaw) * x + cos(yaw) * y
7
+
8
+ The raster is anchored at bbox_min (lower-left). Pixel (0, 0) is the upper-left
9
+ corner of the image, so world Y decreases as the row index increases.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import math
17
+ import zipfile
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+ import tifffile
22
+ from PIL import Image
23
+
24
+ _DATA_BINARY = b"DATA binary\n"
25
+ _POINT_STRIDE = 16 # x, y, z, intensity as float32
26
+
27
+
28
+ def pixel_count(span: float, resolution: float) -> int:
29
+ count = int(math.ceil(span / resolution))
30
+ if count > 0 and (count - 1) * resolution >= span:
31
+ count -= 1
32
+ return max(count, 1)
33
+
34
+
35
+ def load_head(archive: zipfile.ZipFile) -> dict:
36
+ return json.loads(archive.read("map_head.json"))
37
+
38
+
39
+ def iter_world_xy(archive: zipfile.ZipFile, submap: dict):
40
+ raw = archive.read(submap["pc_entry"])
41
+ start = raw.find(_DATA_BINARY)
42
+ if start < 0:
43
+ raise ValueError(f"{submap['pc_entry']}: expected 'DATA binary'")
44
+ payload = raw[start + len(_DATA_BINARY) :]
45
+ payload = payload[: len(payload) - (len(payload) % _POINT_STRIDE)]
46
+ if not payload:
47
+ return np.empty((0, 2), dtype=np.float64)
48
+ local = np.frombuffer(payload, dtype="<f4").reshape(-1, 4)
49
+ yaw = math.radians(float(submap["origin_yaw_deg"]))
50
+ cos_yaw = math.cos(yaw)
51
+ sin_yaw = math.sin(yaw)
52
+ origin_x = float(submap["origin_x"])
53
+ origin_y = float(submap["origin_y"])
54
+ lx = local[:, 0].astype(np.float64, copy=False)
55
+ ly = local[:, 1].astype(np.float64, copy=False)
56
+ world = np.empty((lx.shape[0], 2), dtype=np.float64)
57
+ world[:, 0] = origin_x + cos_yaw * lx - sin_yaw * ly
58
+ world[:, 1] = origin_y + sin_yaw * lx + cos_yaw * ly
59
+ return world
60
+
61
+
62
+ def rasterize(archive: zipfile.ZipFile, head: dict, resolution: float) -> tuple[np.ndarray, dict]:
63
+ xmin, ymin = (float(v) for v in head["bbox_min_xy"])
64
+ xmax, ymax_bbox = (float(v) for v in head["bbox_max_xy"])
65
+ cols = pixel_count(xmax - xmin, resolution)
66
+ rows = pixel_count(ymax_bbox - ymin, resolution)
67
+ width = cols * resolution
68
+ height = rows * resolution
69
+ top = ymin + height
70
+ image = np.full((rows, cols), 255, dtype=np.uint8)
71
+ kept = 0
72
+ dropped = 0
73
+ for submap in head["submaps_meta"]:
74
+ world = iter_world_xy(archive, submap)
75
+ if world.size == 0:
76
+ continue
77
+ col = np.floor((world[:, 0] - xmin) / resolution).astype(np.int64)
78
+ row = np.floor((top - world[:, 1]) / resolution).astype(np.int64)
79
+ inside = (
80
+ (world[:, 0] >= xmin)
81
+ & (world[:, 0] <= xmin + width)
82
+ & (world[:, 1] >= ymin)
83
+ & (world[:, 1] <= top)
84
+ )
85
+ col = np.clip(col, 0, cols - 1)
86
+ row = np.clip(row, 0, rows - 1)
87
+ if np.any(inside):
88
+ image[row[inside], col[inside]] = 0
89
+ kept += int(np.count_nonzero(inside))
90
+ dropped += int(np.count_nonzero(~inside))
91
+ meta = {
92
+ "x": xmin,
93
+ "y": ymin,
94
+ "width": width,
95
+ "height": height,
96
+ "naturalWidth": cols,
97
+ "naturalHeight": rows,
98
+ }
99
+ return image, {"meta": meta, "resolution": resolution, "kept": kept, "dropped": dropped, "top": top}
100
+
101
+
102
+ def write_geotiff(path: Path, image: np.ndarray, meta: dict, resolution: float) -> None:
103
+ """Upper-left corner of pixel (0, 0) is (x, y + height). Y decreases downward."""
104
+ top = meta["y"] + meta["height"]
105
+ geokeys = (
106
+ 1, 1, 0, 2,
107
+ 1024, 0, 1, 1, # GTModelTypeGeoKey = ModelTypeProjected
108
+ 1025, 0, 1, 1, # GTRasterTypeGeoKey = RasterPixelIsArea
109
+ )
110
+ tifffile.imwrite(
111
+ path,
112
+ image,
113
+ photometric="minisblack",
114
+ extratags=[
115
+ (33550, "d", 3, (float(resolution), float(resolution), 0.0), True),
116
+ (33922, "d", 6, (0.0, 0.0, 0.0, float(meta["x"]), float(top), 0.0), True),
117
+ (34735, "H", len(geokeys), geokeys, True),
118
+ ],
119
+ )
120
+
121
+
122
+ def convert(mrmap_path: Path, out_dir: Path, resolution: float | None) -> dict:
123
+ with zipfile.ZipFile(mrmap_path) as archive:
124
+ head = load_head(archive)
125
+ if head.get("magic") != "MAIRUI":
126
+ raise ValueError(f"unexpected map magic: {head.get('magic')!r}")
127
+ if resolution is None:
128
+ resolution = float(head["resolution_2d"])
129
+ if resolution <= 0:
130
+ raise ValueError(f"resolution must be positive, got {resolution}")
131
+ image, info = rasterize(archive, head, resolution)
132
+
133
+ out_dir.mkdir(parents=True, exist_ok=True)
134
+ stem = mrmap_path.stem
135
+ png_path = out_dir / f"{stem}.png"
136
+ tif_path = out_dir / f"{stem}.tif"
137
+ json_path = out_dir / f"{stem}.json"
138
+ meta = info["meta"]
139
+
140
+ Image.fromarray(image, mode="L").save(png_path)
141
+ write_geotiff(tif_path, image, meta, info["resolution"])
142
+ json_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
143
+
144
+ info["paths"] = {"png": png_path, "tif": tif_path, "json": json_path}
145
+ return info
146
+
147
+
148
+ def main(argv: list[str] | None = None) -> None:
149
+ parser = argparse.ArgumentParser(
150
+ prog="awcs mrmap-to-raster",
151
+ description="Convert a MAIRUI .mrmap to PNG, GeoTIFF, and placement JSON.",
152
+ )
153
+ parser.add_argument("mrmap", type=Path, help="path to a .mrmap file")
154
+ parser.add_argument("--resolution", type=float, default=None, help="meters per pixel (default: resolution_2d)")
155
+ parser.add_argument("--out-dir", type=Path, default=None, help="output directory (default: next to the input)")
156
+ args = parser.parse_args(argv)
157
+ mrmap_path = args.mrmap.expanduser().resolve()
158
+ out_dir = (args.out_dir or mrmap_path.parent).expanduser().resolve()
159
+ info = convert(mrmap_path, out_dir, args.resolution)
160
+ meta = info["meta"]
161
+ print(f"resolution_m {info['resolution']}")
162
+ print(f"pixels {meta['naturalWidth']} x {meta['naturalHeight']}")
163
+ print(f"x {meta['x']}")
164
+ print(f"y {meta['y']}")
165
+ print(f"width {meta['width']}")
166
+ print(f"height {meta['height']}")
167
+ print(f"points_kept {info['kept']}")
168
+ print(f"points_dropped {info['dropped']}")
169
+ for kind, path in info["paths"].items():
170
+ print(f"{kind} {path}")
171
+
172
+
173
+ if __name__ == "__main__":
174
+ main()
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.5
2
+ Name: awcs-scripts
3
+ Version: 1.0.1
4
+ Summary: Command line scripts for WCS maps and the ARMS API
5
+ Project-URL: Repository, https://gitlab.sprydev.ru/automacon/agv/wcs/awcs_scripts
6
+ Author: AWCS
7
+ License: Apache-2.0
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: httpx
11
+ Requires-Dist: numpy
12
+ Requires-Dist: pillow
13
+ Requires-Dist: tifffile
14
+ Description-Content-Type: text/markdown
15
+
16
+ # awcs-scripts
17
+
18
+ Скрипты для карт WCS и части ARMS API. Пакет называется `awcs-scripts`, команда после установки — `awcs`.
19
+
20
+ ## Публикация в PyPI
21
+
22
+ Выкладка происходит только по тегу `vX.Y.Z`. Обычный push и merge request пакет не публикуют: CI прогоняет тесты и собирает wheel. Версию берёт джоба `publish` из тега (`v0.1.0` → `0.1.0`), поле `version` в `pyproject.toml` на публикацию не влияет.
23
+
24
+ Токен в репозиторий не входит. Его нужно завести один раз:
25
+
26
+ 1. На [pypi.org](https://pypi.org) создать API-токен с правом загрузки. Для первой выкладки имя `awcs-scripts` должно быть свободно: проект создаётся при первой успешной загрузке. До этого нужен токен уровня аккаунта, после первой публикации его можно ограничить этим проектом.
27
+ 2. В GitLab, в CI/CD-переменных этого репозитория, добавить `UV_PUBLISH_TOKEN` со значением токена и включить **Masked**. **Protected** включать только если теги `v*` тоже protected: иначе на теге переменная в джобу не попадёт и `uv publish` упадёт без токена.
28
+
29
+ Тег и отправка:
30
+
31
+ ```
32
+ git tag v0.1.0
33
+ git push origin v0.1.0
34
+ ```
35
+
36
+ Репозиторий закрытый, а `uv publish` пишет на публичный PyPI. После зелёного пайплайна wheel доступен по имени пакета.
37
+
38
+ ## Установка
39
+
40
+ ```
41
+ uv tool install awcs-scripts
42
+ ```
43
+
44
+ До первого тега те же подкоманды запускаются из git. Репозиторий закрытый, доступ к GitLab уже должен быть настроен, токен в команду не подставляется:
45
+
46
+ ```
47
+ uvx --from git+https://gitlab.sprydev.ru/automacon/agv/wcs/awcs_scripts.git awcs help
48
+ ```
49
+
50
+ Из клона репозитория, без публикации:
51
+
52
+ ```
53
+ uv tool install .
54
+ ```
55
+
56
+ После этого команда `awcs` доступна в системе. Для разработки в том же клоне:
57
+
58
+ ```
59
+ uv sync
60
+ uv run awcs help
61
+ uv build
62
+ ```
63
+
64
+ ## Удаление
65
+
66
+ Снимает команду `awcs`, поставленную через `uv tool install` из клона или с PyPI:
67
+
68
+ ```
69
+ uv tool uninstall awcs-scripts
70
+ ```
71
+
72
+ Профиль с адресом и ключом эта команда не трогает. Его файл лежит по `AWCS_CONFIG`, иначе `%APPDATA%\awcs\config.ini` на Windows и `~/.config/awcs/config.ini` в остальных системах. Чтобы убрать и ключ, удалите этот файл.
73
+
74
+ ## Команды
75
+
76
+ ```
77
+ awcs help
78
+ awcs mrmap-to-raster path/to/map.mrmap --out-dir path/to/out
79
+ awcs mrmap-to-bg path/to/map.mrmap path/to/layout.lif --layout_id layoutId --resolution 0.05 --output path/to/layout.out.lif
80
+ awcs login --url https://dev.wcs.automacon.ru --apikey <apikey>
81
+ awcs users list
82
+ awcs users me
83
+ awcs mission_templates list
84
+ awcs robots list
85
+ awcs missions list
86
+ awcs missions create --template_code PickPallet --params "{\"zone\":\"A\"}"
87
+ awcs mission_templates execute PickPallet --params "{\"zone\":\"A\"}"
88
+ awcs projects list
89
+ awcs projects create --lif_json path/to/layout.lif -m MAIRUI -v 1.0.0 --desc "line 1" --sync
90
+ awcs projects activate 12
91
+ ```
92
+
93
+ Разовый запуск после публикации:
94
+
95
+ ```
96
+ uvx --from awcs-scripts awcs help
97
+ uvx --from awcs-scripts awcs mrmap-to-raster path/to/map.mrmap --resolution 0.05 --out-dir path/to/out
98
+ uvx --from awcs-scripts awcs users me
99
+ ```
100
+
101
+ `awcs mrmap-to-raster` пишет PNG, GeoTIFF и JSON. `--resolution` задаёт метры на пиксель, по умолчанию берётся `resolution_2d` из карты. `awcs mrmap-to-bg` подставляет этот растр в `backgroundImage` одного layout. Без `--output` исходный `.lif` перезаписывается.
102
+
103
+ `awcs login` проверяет ключ через `GET /users/me` и только после ответа 200 сохраняет origin и ключ. Профиль лежит вне репозитория: путь из `AWCS_CONFIG`, иначе `%APPDATA%\awcs\config.ini` на Windows и `~/.config/awcs/config.ini` в остальных системах. Ключ в вывод не печатается. Клиент сам дописывает `/api/v1` и шлёт заголовок `X-Api-Key`.
104
+
105
+ У списков есть `--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`.
106
+
107
+ ## Разработка
108
+
109
+ Карта живёт в `src/awcs_scripts/mrmap/`. Имена файлов совпадают с подкомандами: `mrmap-to-raster.py` и `mrmap-to-bg.py`. Будущие shell-скрипты кладутся в `bash/` и тоже вызываются только через `awcs`.
@@ -0,0 +1,12 @@
1
+ awcs_scripts/__init__.py,sha256=qZwBaSA9sxWB32dGuygTwqLWo36eHDhPwoH1mi1aluw,68
2
+ awcs_scripts/api.py,sha256=4z_OD2qxGKiPUYPi5yDu95f7AD2ZILfF9_eiJ-CnXvc,1743
3
+ awcs_scripts/cli.py,sha256=qvwO0UBzL3NuGJa-JXBKScBVUvJ-ycXfsUAOxiU1tkw,10086
4
+ awcs_scripts/config.py,sha256=hLft4fmTk3K97Qt51pfEgPltYnPhL09raPgjh0wP1tI,1978
5
+ awcs_scripts/mrmap/__init__.py,sha256=2UI1KO_YhOO1HCOHV-Q9FJm5Fxup_jE5DI00IGHPDDQ,89
6
+ awcs_scripts/mrmap/mrmap-to-bg.py,sha256=26H4oOyaF-FPELd_mLMIvJ8MG_XDZX7-8EKsERdHhwE,16508
7
+ awcs_scripts/mrmap/mrmap-to-raster.py,sha256=VlCib6Z84CEiRoobQaNvZ16ebtJT3ZcTe9iE1KhEQiY,6383
8
+ awcs_scripts-1.0.1.dist-info/METADATA,sha256=cqj8JvOTEYOrVWN7ExAEVhkNxKRTkT10WwexutljSUA,6100
9
+ awcs_scripts-1.0.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ awcs_scripts-1.0.1.dist-info/entry_points.txt,sha256=dgDy55MUX47MYoo2R-ocEYevBZbFKqI468upVmAUiIs,47
11
+ awcs_scripts-1.0.1.dist-info/licenses/LICENSE,sha256=AQ59EbGYZeqWXEy59FCXjUH5KbOoAFcGjqbGzkWJzHg,733
12
+ awcs_scripts-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ awcs = awcs_scripts.cli:main
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 AWCS
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.