arcsecond 3.10.2__tar.gz → 3.12.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.
- {arcsecond-3.10.2 → arcsecond-3.12.0}/.github/workflows/tests.yml +1 -1
- {arcsecond-3.10.2 → arcsecond-3.12.0}/PKG-INFO +2 -1
- arcsecond-3.12.0/arcsecond/alpaca/commands.py +186 -0
- arcsecond-3.12.0/arcsecond/alpaca/dome_probe.py +432 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/api/endpoint.py +6 -2
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/api/resources.py +6 -2
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cli.py +8 -1
- arcsecond-3.12.0/arcsecond/hosting/__init__.py +3 -0
- arcsecond-3.12.0/arcsecond/hosting/backups.py +969 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/local.py +15 -4
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/main.py +10 -10
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/setup.py +2 -1
- arcsecond-3.12.0/arcsecond/hosting/utils.py +48 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/imagesources/commands.py +73 -42
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/imagesources/proxy.py +32 -21
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/imagesources/registry.py +8 -4
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/imagesources/sources/base.py +4 -3
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/imagesources/sources/filewatch.py +8 -6
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/imagesources/sources/opencv.py +10 -5
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/targets.py +35 -14
- {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/.vitepress/config.js +4 -1
- arcsecond-3.12.0/docs/backups.md +158 -0
- arcsecond-3.12.0/docs/install-windows.md +215 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/install.md +7 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/package-lock.json +12 -12
- {arcsecond-3.10.2 → arcsecond-3.12.0}/pyproject.toml +2 -1
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/api/test_api_endpoint.py +3 -1
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/api/test_targets.py +3 -1
- arcsecond-3.12.0/tests/cloud/uploader/datafiles/__init__.py +0 -0
- arcsecond-3.12.0/tests/test_alpaca_probe.py +403 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/test_cli.py +3 -3
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/test_hosting_local.py +23 -7
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/test_targets_planning.py +4 -1
- arcsecond-3.10.2/arcsecond/hosting/__init__.py +0 -2
- arcsecond-3.10.2/arcsecond/hosting/utils.py +0 -24
- {arcsecond-3.10.2 → arcsecond-3.12.0}/.docker/Dockerfile_postgres +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/.docker/Dockerfile_redis +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/.github/dependabot.yml +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/.github/workflows/docsdeploy.yml +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/.github/workflows/pythonpublish.yml +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/.gitignore +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/LICENSE +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/Makefile +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/README.md +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/__init__.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/__version__.py +0 -0
- {arcsecond-3.10.2/arcsecond/cloud/uploader/allskycameraimages → arcsecond-3.12.0/arcsecond/alpaca}/__init__.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/api/__init__.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/api/config.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/api/constants.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/api/main.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/__init__.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/auth.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/resources.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/__init__.py +0 -0
- {arcsecond-3.10.2/arcsecond/cloud/uploader/datafiles → arcsecond-3.12.0/arcsecond/cloud/uploader/allskycameraimages}/__init__.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/allskycameraimages/context.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/allskycameraimages/errors.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/allskycameraimages/uploader.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/allskycameraimages/utils.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/constants.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/context.py +0 -0
- {arcsecond-3.10.2/arcsecond/imagesources → arcsecond-3.12.0/arcsecond/cloud/uploader/datafiles}/__init__.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/errors.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/logger.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/uploader.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/utils.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploader/walker.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/cloud/uploads.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/errors.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/checks.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/constants.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/docker/__init__.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/docker/constants.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/docker/containers.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/docker/docker-compose.yml +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/docker/images.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/docker/utils.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/keygen/__init__.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/keygen/client.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/keygen/utils.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/postgres/init-db.sh +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/hosting/validation.py +0 -0
- {arcsecond-3.10.2/arcsecond/imagesources/sources → arcsecond-3.12.0/arcsecond/imagesources}/__init__.py +0 -0
- {arcsecond-3.10.2/tests → arcsecond-3.12.0/arcsecond/imagesources/sources}/__init__.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/arcsecond/options.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/deploy.sh +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/.vitepress/theme/custom.css +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/.vitepress/theme/index.js +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/api-basics.md +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/img/icon-logo.png +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/index.md +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/public/img/icon-logo.png +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/resources.md +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/rotate-postgres-password.md +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/upload.md +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/docs/webcam.md +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/examples/example_upload_files.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/examples/example_upload_images.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/package.json +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/poetry.lock +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/requirements.txt +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/setup.cfg +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/setup.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/sonar-project.properties +0 -0
- {arcsecond-3.10.2/tests/api → arcsecond-3.12.0/tests}/__init__.py +0 -0
- {arcsecond-3.10.2/tests/cloud → arcsecond-3.12.0/tests/api}/__init__.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/api/test_api.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/api/test_config.py +0 -0
- {arcsecond-3.10.2/tests/cloud/uploader → arcsecond-3.12.0/tests/cloud}/__init__.py +0 -0
- {arcsecond-3.10.2/tests/cloud/uploader/allskycameraimages → arcsecond-3.12.0/tests/cloud/uploader}/__init__.py +0 -0
- {arcsecond-3.10.2/tests/cloud/uploader/datafiles → arcsecond-3.12.0/tests/cloud/uploader/allskycameraimages}/__init__.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/cloud/uploader/allskycameraimages/test_context.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/cloud/uploader/allskycameraimages/test_uploader_full_process.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/conftest.py +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/fixtures/file1.fits +0 -0
- {arcsecond-3.10.2 → arcsecond-3.12.0}/tests/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arcsecond
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.12.0
|
|
4
4
|
Summary: CLI for arcsecond.io
|
|
5
5
|
Project-URL: Homepage, https://github.com/arcsecond-io/cli
|
|
6
6
|
Project-URL: Issues, https://github.com/arcsecond-io/cli/issues
|
|
@@ -36,6 +36,7 @@ Classifier: Operating System :: OS Independent
|
|
|
36
36
|
Classifier: Programming Language :: Python :: 3
|
|
37
37
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
38
38
|
Requires-Python: >=3.9
|
|
39
|
+
Requires-Dist: alpyca==3.1.2
|
|
39
40
|
Requires-Dist: click>=8
|
|
40
41
|
Requires-Dist: configparser
|
|
41
42
|
Requires-Dist: docker
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Click command group: ``arcsecond alpaca``.
|
|
3
|
+
|
|
4
|
+
First citizen: ``arcsecond alpaca probe dome`` — a read-only diagnostic that
|
|
5
|
+
captures what surface a given Alpaca dome server exposes (device metadata,
|
|
6
|
+
``SupportedActions``, ``CommandString`` / ``CommandBool`` / ``CommandBlind``
|
|
7
|
+
passthrough behaviour, and optional local host hints).
|
|
8
|
+
|
|
9
|
+
Structure leaves room for ``arcsecond alpaca probe telescope`` /
|
|
10
|
+
``probe camera`` / ``probe focuser`` and ``arcsecond alpaca discover`` later
|
|
11
|
+
under the same group.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
|
|
23
|
+
from ..errors import ArcsecondError
|
|
24
|
+
from .dome_probe import ProbeProgress, probe_dome
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group(name="alpaca", help="Diagnostics for local ASCOM Alpaca devices.")
|
|
28
|
+
def alpaca_group() -> None:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@alpaca_group.group(name="probe", help="Inspect an Alpaca device read-only.")
|
|
33
|
+
def probe_group() -> None:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_COMMON_OPTIONS = [
|
|
38
|
+
click.option(
|
|
39
|
+
"--host",
|
|
40
|
+
required=True,
|
|
41
|
+
metavar="HOST",
|
|
42
|
+
help="Alpaca server hostname or IP (without scheme).",
|
|
43
|
+
),
|
|
44
|
+
click.option(
|
|
45
|
+
"--port",
|
|
46
|
+
type=int,
|
|
47
|
+
required=True,
|
|
48
|
+
metavar="PORT",
|
|
49
|
+
help="Alpaca server TCP port (e.g. 11111).",
|
|
50
|
+
),
|
|
51
|
+
click.option(
|
|
52
|
+
"--device-number",
|
|
53
|
+
type=int,
|
|
54
|
+
default=0,
|
|
55
|
+
show_default=True,
|
|
56
|
+
help="Alpaca device number on the server.",
|
|
57
|
+
),
|
|
58
|
+
click.option(
|
|
59
|
+
"--protocol",
|
|
60
|
+
type=click.Choice(["http", "https"], case_sensitive=False),
|
|
61
|
+
default="http",
|
|
62
|
+
show_default=True,
|
|
63
|
+
help="Protocol to reach the Alpaca server with.",
|
|
64
|
+
),
|
|
65
|
+
click.option(
|
|
66
|
+
"--allow-active",
|
|
67
|
+
is_flag=True,
|
|
68
|
+
default=False,
|
|
69
|
+
help=(
|
|
70
|
+
"Also send CommandBlind and active-class probes. OFF by default "
|
|
71
|
+
"to keep the run safe on real hardware."
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
click.option(
|
|
75
|
+
"--collect-host-info",
|
|
76
|
+
is_flag=True,
|
|
77
|
+
default=False,
|
|
78
|
+
help=(
|
|
79
|
+
"Additionally collect best-effort local OS hints (open ports, "
|
|
80
|
+
"COM ProgIDs matching TCS/Galil). Read-only."
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
click.option(
|
|
84
|
+
"--output",
|
|
85
|
+
"output_path",
|
|
86
|
+
type=click.Path(dir_okay=False, writable=True, resolve_path=True),
|
|
87
|
+
default=None,
|
|
88
|
+
help="Where to write the JSON report. Defaults to cwd with a UTC timestamp.",
|
|
89
|
+
),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _add_options(options):
|
|
94
|
+
def _wrap(fn):
|
|
95
|
+
for opt in reversed(options):
|
|
96
|
+
fn = opt(fn)
|
|
97
|
+
return fn
|
|
98
|
+
|
|
99
|
+
return _wrap
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _default_output_path(kind: str) -> str:
|
|
103
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
104
|
+
return str(Path.cwd() / f"alpaca_{kind}_probe_{stamp}.json")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _format_progress(label: str, ok: bool, detail: str | None) -> str:
|
|
108
|
+
tag = click.style("[OK ]", fg="green") if ok else click.style("[ERR]", fg="red")
|
|
109
|
+
suffix = f" — {detail}" if detail else ""
|
|
110
|
+
return f" {tag} {label}{suffix}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@probe_group.command(
|
|
114
|
+
name="dome",
|
|
115
|
+
help=(
|
|
116
|
+
"Probe a local Alpaca dome device read-only and write a JSON report.\n\n"
|
|
117
|
+
"Captures device metadata, SupportedActions, and the behaviour of the "
|
|
118
|
+
"legacy CommandString / CommandBool / CommandBlind passthroughs. "
|
|
119
|
+
"Useful for figuring out whether a proprietary driver (e.g. TCSGalil) "
|
|
120
|
+
"exposes any vendor-specific extension surface beyond the standard "
|
|
121
|
+
"ASCOM IDome interface."
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
@_add_options(_COMMON_OPTIONS)
|
|
125
|
+
def probe_dome_cmd(
|
|
126
|
+
host: str,
|
|
127
|
+
port: int,
|
|
128
|
+
device_number: int,
|
|
129
|
+
protocol: str,
|
|
130
|
+
allow_active: bool,
|
|
131
|
+
collect_host_info: bool,
|
|
132
|
+
output_path: str | None,
|
|
133
|
+
) -> None:
|
|
134
|
+
output_path = output_path or _default_output_path("dome")
|
|
135
|
+
|
|
136
|
+
click.echo(
|
|
137
|
+
click.style("Alpaca dome probe", bold=True)
|
|
138
|
+
+ f" → {protocol}://{host}:{port} (device {device_number})"
|
|
139
|
+
)
|
|
140
|
+
if allow_active:
|
|
141
|
+
click.echo(
|
|
142
|
+
click.style(" ⚠ ", fg="yellow")
|
|
143
|
+
+ "--allow-active is ON: CommandBlind and active-class probes will be sent."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
progress = ProbeProgress(
|
|
147
|
+
on_probe=lambda label, ok, detail: click.echo(
|
|
148
|
+
_format_progress(label, ok, detail)
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
result = probe_dome(
|
|
154
|
+
host=host,
|
|
155
|
+
port=port,
|
|
156
|
+
device_number=device_number,
|
|
157
|
+
protocol=protocol.lower(),
|
|
158
|
+
allow_active=allow_active,
|
|
159
|
+
collect_host_info=collect_host_info,
|
|
160
|
+
progress=progress,
|
|
161
|
+
)
|
|
162
|
+
except RuntimeError as exc:
|
|
163
|
+
raise ArcsecondError(str(exc)) from exc
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
with open(output_path, "w", encoding="utf-8") as fp:
|
|
167
|
+
json.dump(result.report, fp, indent=2, sort_keys=False)
|
|
168
|
+
fp.write("\n")
|
|
169
|
+
except OSError as exc:
|
|
170
|
+
raise ArcsecondError(
|
|
171
|
+
f"Could not write report to {output_path}: {exc}"
|
|
172
|
+
) from exc
|
|
173
|
+
|
|
174
|
+
counts = result.counts
|
|
175
|
+
supported = result.report.get("supported_actions", {})
|
|
176
|
+
supported_n = (
|
|
177
|
+
len(supported.get("value") or []) if supported.get("ok") else 0
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
click.echo("")
|
|
181
|
+
click.echo(click.style("Summary", bold=True))
|
|
182
|
+
click.echo(f" Probes: {counts.ok}/{counts.total} OK")
|
|
183
|
+
click.echo(f" SupportedActions: {supported_n} entries")
|
|
184
|
+
click.echo(
|
|
185
|
+
f" Report written to {click.style(os.fspath(output_path), fg='cyan')}"
|
|
186
|
+
)
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read-only diagnostic for an ASCOM Alpaca *dome* device.
|
|
3
|
+
|
|
4
|
+
The standard ASCOM Alpaca ``IDome`` interface only exposes a single
|
|
5
|
+
``OpenShutter`` / ``CloseShutter`` pair. Multi-shutter domes (e.g. DFM domes
|
|
6
|
+
driven by TCSGalil) coordinate their shutters internally; if the driver does
|
|
7
|
+
not advertise per-shutter custom actions via ``SupportedActions``, there is
|
|
8
|
+
no canonical client-side way to drive the upper and lower shutters
|
|
9
|
+
independently.
|
|
10
|
+
|
|
11
|
+
This module produces a structured JSON report describing exactly what surface
|
|
12
|
+
a given Alpaca dome server exposes:
|
|
13
|
+
|
|
14
|
+
* environment / connectivity
|
|
15
|
+
* standard read-only device metadata and dome state
|
|
16
|
+
* the raw ``SupportedActions`` list
|
|
17
|
+
* legacy ``CommandString`` / ``CommandBool`` / ``CommandBlind`` passthrough
|
|
18
|
+
behaviour against a small set of safe candidate commands
|
|
19
|
+
* optional best-effort local host hints (open ports, COM ProgIDs)
|
|
20
|
+
|
|
21
|
+
It never issues motion-class commands. ``CommandBlind`` and active Galil DMC
|
|
22
|
+
verbs are gated behind ``allow_active=True``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import platform
|
|
29
|
+
import socket
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import traceback
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from typing import Any, Callable, Iterable
|
|
36
|
+
|
|
37
|
+
# Read-only device descriptors and dome state, in the order we want them
|
|
38
|
+
# presented in the report. Names are PascalCase to match the alpyca surface.
|
|
39
|
+
_READ_ONLY_PROPERTIES: tuple[str, ...] = (
|
|
40
|
+
"Name",
|
|
41
|
+
"Description",
|
|
42
|
+
"DriverInfo",
|
|
43
|
+
"DriverVersion",
|
|
44
|
+
"InterfaceVersion",
|
|
45
|
+
"ShutterStatus",
|
|
46
|
+
"Altitude",
|
|
47
|
+
"Azimuth",
|
|
48
|
+
"AtHome",
|
|
49
|
+
"AtPark",
|
|
50
|
+
"Slewing",
|
|
51
|
+
"Slaved",
|
|
52
|
+
"CanFindHome",
|
|
53
|
+
"CanPark",
|
|
54
|
+
"CanSetAltitude",
|
|
55
|
+
"CanSetPark",
|
|
56
|
+
"CanSetShutter",
|
|
57
|
+
"CanSlave",
|
|
58
|
+
"CanSyncAzimuth",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Safe Galil DMC read-style verbs: print/report values without commanding
|
|
62
|
+
# motion. ``MG`` is "message" (print expression), ``TE`` is "tell error",
|
|
63
|
+
# ``TP`` is "tell position".
|
|
64
|
+
_READ_ONLY_COMMANDS: tuple[str, ...] = (
|
|
65
|
+
"MG TIME",
|
|
66
|
+
"MG _BGA",
|
|
67
|
+
"TE",
|
|
68
|
+
"TP",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Active commands: kept separate and only sent when allow_active=True.
|
|
72
|
+
# ``XQ#STATUS`` jumps to a label called STATUS if it exists; whether that
|
|
73
|
+
# label has side effects depends on the driver, hence "active".
|
|
74
|
+
_ACTIVE_COMMANDS: tuple[str, ...] = (
|
|
75
|
+
"XQ#STATUS",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
_RESPONSE_TRUNCATE = 2000
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class ProbeProgress:
|
|
83
|
+
"""Optional callback bag for streaming progress to the caller (CLI)."""
|
|
84
|
+
|
|
85
|
+
on_probe: Callable[[str, bool, str | None], None] | None = None
|
|
86
|
+
|
|
87
|
+
def emit(self, label: str, ok: bool, detail: str | None = None) -> None:
|
|
88
|
+
if self.on_probe is not None:
|
|
89
|
+
self.on_probe(label, ok, detail)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class ProbeCounts:
|
|
94
|
+
total: int = 0
|
|
95
|
+
ok: int = 0
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def failed(self) -> int:
|
|
99
|
+
return self.total - self.ok
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class ProbeResult:
|
|
104
|
+
report: dict
|
|
105
|
+
counts: ProbeCounts = field(default_factory=ProbeCounts)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _truncate(value: Any) -> Any:
|
|
109
|
+
if isinstance(value, str) and len(value) > _RESPONSE_TRUNCATE:
|
|
110
|
+
return value[:_RESPONSE_TRUNCATE] + f"... [truncated, total {len(value)} chars]"
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _coerce_jsonable(value: Any) -> Any:
|
|
115
|
+
"""Make values from alpyca safe to dump as JSON."""
|
|
116
|
+
# IntEnum is a subclass of int, so check it *before* the primitive branch.
|
|
117
|
+
# We want {"name": "shutterClosed", "value": 1} not just 1.
|
|
118
|
+
from enum import Enum
|
|
119
|
+
if isinstance(value, Enum):
|
|
120
|
+
return {"name": value.name, "value": value.value}
|
|
121
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
122
|
+
return value
|
|
123
|
+
if isinstance(value, (list, tuple)):
|
|
124
|
+
return [_coerce_jsonable(item) for item in value]
|
|
125
|
+
if isinstance(value, dict):
|
|
126
|
+
return {str(k): _coerce_jsonable(v) for k, v in value.items()}
|
|
127
|
+
return str(value)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _capture(call: Callable[[], Any]) -> dict:
|
|
131
|
+
"""Run ``call()`` and capture either its value or its exception."""
|
|
132
|
+
try:
|
|
133
|
+
raw = call()
|
|
134
|
+
except Exception as exc: # noqa: BLE001 — errors are data here
|
|
135
|
+
return {
|
|
136
|
+
"ok": False,
|
|
137
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
138
|
+
}
|
|
139
|
+
return {"ok": True, "value": _truncate(_coerce_jsonable(raw))}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _build_environment(
|
|
143
|
+
host: str,
|
|
144
|
+
port: int,
|
|
145
|
+
device_number: int,
|
|
146
|
+
protocol: str,
|
|
147
|
+
connected_outcome: dict,
|
|
148
|
+
) -> dict:
|
|
149
|
+
try:
|
|
150
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
151
|
+
|
|
152
|
+
alpaca_version = version("alpyca")
|
|
153
|
+
except PackageNotFoundError:
|
|
154
|
+
alpaca_version = "unknown"
|
|
155
|
+
except Exception: # noqa: BLE001
|
|
156
|
+
alpaca_version = "unknown"
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
|
|
160
|
+
"host": host,
|
|
161
|
+
"port": port,
|
|
162
|
+
"device_number": device_number,
|
|
163
|
+
"protocol": protocol,
|
|
164
|
+
"alpyca_version": alpaca_version,
|
|
165
|
+
"python_version": sys.version.split()[0],
|
|
166
|
+
"platform": platform.platform(),
|
|
167
|
+
"hostname": socket.gethostname(),
|
|
168
|
+
"connected": connected_outcome,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _probe_device_metadata(
|
|
173
|
+
dome: Any,
|
|
174
|
+
progress: ProbeProgress,
|
|
175
|
+
counts: ProbeCounts,
|
|
176
|
+
) -> dict:
|
|
177
|
+
section: dict = {}
|
|
178
|
+
for name in _READ_ONLY_PROPERTIES:
|
|
179
|
+
outcome = _capture(lambda n=name: getattr(dome, n))
|
|
180
|
+
section[name] = outcome
|
|
181
|
+
counts.total += 1
|
|
182
|
+
if outcome["ok"]:
|
|
183
|
+
counts.ok += 1
|
|
184
|
+
progress.emit(name, True, str(outcome["value"]))
|
|
185
|
+
else:
|
|
186
|
+
progress.emit(name, False, outcome["error"])
|
|
187
|
+
return section
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _probe_supported_actions(
|
|
191
|
+
dome: Any,
|
|
192
|
+
progress: ProbeProgress,
|
|
193
|
+
counts: ProbeCounts,
|
|
194
|
+
) -> dict:
|
|
195
|
+
outcome = _capture(lambda: dome.SupportedActions)
|
|
196
|
+
counts.total += 1
|
|
197
|
+
if outcome["ok"]:
|
|
198
|
+
counts.ok += 1
|
|
199
|
+
actions = outcome["value"] or []
|
|
200
|
+
progress.emit("SupportedActions", True, f"{len(actions)} entries")
|
|
201
|
+
else:
|
|
202
|
+
progress.emit("SupportedActions", False, outcome["error"])
|
|
203
|
+
return outcome
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _send_command(
|
|
207
|
+
method: Callable[..., Any],
|
|
208
|
+
command: str,
|
|
209
|
+
raw: bool,
|
|
210
|
+
) -> dict:
|
|
211
|
+
entry: dict = {"command": command, "raw": raw}
|
|
212
|
+
try:
|
|
213
|
+
response = method(command, raw)
|
|
214
|
+
except Exception as exc: # noqa: BLE001
|
|
215
|
+
entry.update(ok=False, error=f"{type(exc).__name__}: {exc}")
|
|
216
|
+
else:
|
|
217
|
+
entry.update(ok=True, response=_truncate(_coerce_jsonable(response)))
|
|
218
|
+
return entry
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _probe_command_passthrough(
|
|
222
|
+
dome: Any,
|
|
223
|
+
*,
|
|
224
|
+
allow_active: bool,
|
|
225
|
+
progress: ProbeProgress,
|
|
226
|
+
counts: ProbeCounts,
|
|
227
|
+
) -> dict:
|
|
228
|
+
section: dict = {
|
|
229
|
+
"command_string": [],
|
|
230
|
+
"command_bool": [],
|
|
231
|
+
"command_blind": [],
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
commands: tuple[str, ...] = _READ_ONLY_COMMANDS
|
|
235
|
+
if allow_active:
|
|
236
|
+
commands = commands + _ACTIVE_COMMANDS
|
|
237
|
+
|
|
238
|
+
for cmd in commands:
|
|
239
|
+
entry = _send_command(dome.CommandString, cmd, False)
|
|
240
|
+
section["command_string"].append(entry)
|
|
241
|
+
counts.total += 1
|
|
242
|
+
counts.ok += 1 if entry["ok"] else 0
|
|
243
|
+
progress.emit(
|
|
244
|
+
f"CommandString({cmd!r})",
|
|
245
|
+
entry["ok"],
|
|
246
|
+
entry.get("error") or str(entry.get("response", ""))[:80],
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
entry = _send_command(dome.CommandBool, cmd, False)
|
|
250
|
+
section["command_bool"].append(entry)
|
|
251
|
+
counts.total += 1
|
|
252
|
+
counts.ok += 1 if entry["ok"] else 0
|
|
253
|
+
progress.emit(
|
|
254
|
+
f"CommandBool({cmd!r})",
|
|
255
|
+
entry["ok"],
|
|
256
|
+
entry.get("error") or str(entry.get("response", ""))[:80],
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if allow_active:
|
|
260
|
+
# CommandBlind is fire-and-forget — only attempt under --allow-active.
|
|
261
|
+
for cmd in commands:
|
|
262
|
+
entry = _send_command(dome.CommandBlind, cmd, False)
|
|
263
|
+
section["command_blind"].append(entry)
|
|
264
|
+
counts.total += 1
|
|
265
|
+
counts.ok += 1 if entry["ok"] else 0
|
|
266
|
+
progress.emit(
|
|
267
|
+
f"CommandBlind({cmd!r})",
|
|
268
|
+
entry["ok"],
|
|
269
|
+
entry.get("error") or "sent",
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
section["command_blind_skipped_reason"] = (
|
|
273
|
+
"CommandBlind is fire-and-forget; gated behind --allow-active."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
return section
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _safe_subprocess(cmd: list[str], timeout: float = 5.0) -> dict:
|
|
280
|
+
try:
|
|
281
|
+
completed = subprocess.run(
|
|
282
|
+
cmd,
|
|
283
|
+
capture_output=True,
|
|
284
|
+
text=True,
|
|
285
|
+
timeout=timeout,
|
|
286
|
+
check=False,
|
|
287
|
+
)
|
|
288
|
+
except FileNotFoundError:
|
|
289
|
+
return {"ok": False, "error": f"command not found: {cmd[0]}"}
|
|
290
|
+
except subprocess.TimeoutExpired:
|
|
291
|
+
return {"ok": False, "error": "timeout"}
|
|
292
|
+
except Exception as exc: # noqa: BLE001
|
|
293
|
+
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
294
|
+
return {
|
|
295
|
+
"ok": True,
|
|
296
|
+
"returncode": completed.returncode,
|
|
297
|
+
"stdout": _truncate(completed.stdout),
|
|
298
|
+
"stderr": _truncate(completed.stderr),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _collect_windows_host_hints() -> dict:
|
|
303
|
+
hints: dict = {"os": "windows"}
|
|
304
|
+
hints["netstat"] = _safe_subprocess(["netstat", "-ano"], timeout=8.0)
|
|
305
|
+
|
|
306
|
+
progids: list[str] = []
|
|
307
|
+
try:
|
|
308
|
+
import winreg # type: ignore[import-not-found]
|
|
309
|
+
except ImportError:
|
|
310
|
+
hints["com_progids"] = {"ok": False, "error": "winreg unavailable"}
|
|
311
|
+
else:
|
|
312
|
+
try:
|
|
313
|
+
root = winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, "")
|
|
314
|
+
i = 0
|
|
315
|
+
while True:
|
|
316
|
+
try:
|
|
317
|
+
name = winreg.EnumKey(root, i)
|
|
318
|
+
except OSError:
|
|
319
|
+
break
|
|
320
|
+
upper = name.upper()
|
|
321
|
+
if "TCS" in upper or "GALIL" in upper:
|
|
322
|
+
progids.append(name)
|
|
323
|
+
i += 1
|
|
324
|
+
winreg.CloseKey(root)
|
|
325
|
+
hints["com_progids"] = {"ok": True, "matches": progids}
|
|
326
|
+
except Exception as exc: # noqa: BLE001
|
|
327
|
+
hints["com_progids"] = {
|
|
328
|
+
"ok": False,
|
|
329
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
330
|
+
}
|
|
331
|
+
return hints
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _collect_posix_host_hints() -> dict:
|
|
335
|
+
hints: dict = {"os": platform.system().lower()}
|
|
336
|
+
# Try ss first (Linux), fall back to lsof (macOS / Linux).
|
|
337
|
+
hints["listening_sockets"] = _safe_subprocess(["ss", "-ltnp"], timeout=5.0)
|
|
338
|
+
if not hints["listening_sockets"].get("ok"):
|
|
339
|
+
hints["listening_sockets"] = _safe_subprocess(
|
|
340
|
+
["lsof", "-iTCP", "-sTCP:LISTEN", "-P", "-n"], timeout=5.0
|
|
341
|
+
)
|
|
342
|
+
return hints
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _collect_host_hints() -> dict:
|
|
346
|
+
if os.name == "nt":
|
|
347
|
+
return _collect_windows_host_hints()
|
|
348
|
+
return _collect_posix_host_hints()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def probe_dome(
|
|
352
|
+
host: str,
|
|
353
|
+
port: int,
|
|
354
|
+
device_number: int = 0,
|
|
355
|
+
*,
|
|
356
|
+
protocol: str = "http",
|
|
357
|
+
allow_active: bool = False,
|
|
358
|
+
collect_host_info: bool = False,
|
|
359
|
+
dome_factory: Callable[[str, int, str], Any] | None = None,
|
|
360
|
+
progress: ProbeProgress | None = None,
|
|
361
|
+
) -> ProbeResult:
|
|
362
|
+
"""
|
|
363
|
+
Build a JSON-serialisable diagnostic report for an Alpaca dome at
|
|
364
|
+
``protocol://host:port`` (device_number).
|
|
365
|
+
|
|
366
|
+
``dome_factory(address, device_number, protocol)`` is only there for
|
|
367
|
+
tests — production callers leave it ``None`` and the real
|
|
368
|
+
``alpaca.dome.Dome`` is used.
|
|
369
|
+
"""
|
|
370
|
+
progress = progress or ProbeProgress()
|
|
371
|
+
counts = ProbeCounts()
|
|
372
|
+
address = f"{host}:{port}"
|
|
373
|
+
|
|
374
|
+
if dome_factory is None:
|
|
375
|
+
from alpaca.dome import Dome # local import: keep cost off cold paths
|
|
376
|
+
|
|
377
|
+
dome_factory = Dome
|
|
378
|
+
|
|
379
|
+
# Build dome client. A failure here is fatal (host unreachable, or the
|
|
380
|
+
# alpyca constructor itself blew up) so we surface it to the caller.
|
|
381
|
+
try:
|
|
382
|
+
dome = dome_factory(address, device_number, protocol)
|
|
383
|
+
except Exception as exc: # noqa: BLE001
|
|
384
|
+
raise RuntimeError(
|
|
385
|
+
f"Could not construct Alpaca Dome client for "
|
|
386
|
+
f"{protocol}://{address} (device {device_number}): "
|
|
387
|
+
f"{type(exc).__name__}: {exc}\n"
|
|
388
|
+
+ traceback.format_exc()
|
|
389
|
+
) from exc
|
|
390
|
+
|
|
391
|
+
connected_outcome = _capture(lambda: dome.Connected)
|
|
392
|
+
counts.total += 1
|
|
393
|
+
counts.ok += 1 if connected_outcome["ok"] else 0
|
|
394
|
+
progress.emit(
|
|
395
|
+
"Connected", connected_outcome["ok"],
|
|
396
|
+
connected_outcome.get("error") or str(connected_outcome.get("value")),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
environment = _build_environment(
|
|
400
|
+
host, port, device_number, protocol, connected_outcome
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
device_metadata = _probe_device_metadata(dome, progress, counts)
|
|
404
|
+
supported_actions = _probe_supported_actions(dome, progress, counts)
|
|
405
|
+
command_passthrough = _probe_command_passthrough(
|
|
406
|
+
dome,
|
|
407
|
+
allow_active=allow_active,
|
|
408
|
+
progress=progress,
|
|
409
|
+
counts=counts,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
report: dict = {
|
|
413
|
+
"environment": environment,
|
|
414
|
+
"device_metadata": device_metadata,
|
|
415
|
+
"supported_actions": supported_actions,
|
|
416
|
+
"command_passthrough": command_passthrough,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if collect_host_info:
|
|
420
|
+
report["host_hints"] = _collect_host_hints()
|
|
421
|
+
|
|
422
|
+
return ProbeResult(report=report, counts=counts)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def iter_probe_labels() -> Iterable[str]:
|
|
426
|
+
"""Convenience for tests / docs: enumerate the labels emitted by a run."""
|
|
427
|
+
yield "Connected"
|
|
428
|
+
yield from _READ_ONLY_PROPERTIES
|
|
429
|
+
yield "SupportedActions"
|
|
430
|
+
for cmd in _READ_ONLY_COMMANDS:
|
|
431
|
+
yield f"CommandString({cmd!r})"
|
|
432
|
+
yield f"CommandBool({cmd!r})"
|
|
@@ -68,7 +68,9 @@ class ArcsecondAPIEndpoint(object):
|
|
|
68
68
|
payload = {}
|
|
69
69
|
if json:
|
|
70
70
|
payload.update(json)
|
|
71
|
-
payload.update(
|
|
71
|
+
payload.update(
|
|
72
|
+
{key: value for key, value in fields.items() if value is not None}
|
|
73
|
+
)
|
|
72
74
|
return payload or None
|
|
73
75
|
|
|
74
76
|
def _extract_results(self, response):
|
|
@@ -151,7 +153,9 @@ class ArcsecondAPIEndpoint(object):
|
|
|
151
153
|
|
|
152
154
|
identifier = self._extract_identifier(existing)
|
|
153
155
|
if identifier is None:
|
|
154
|
-
return None, ArcsecondError(
|
|
156
|
+
return None, ArcsecondError(
|
|
157
|
+
f"Could not find an identifier for '{match_value}'."
|
|
158
|
+
)
|
|
155
159
|
|
|
156
160
|
return self.update(identifier, json=payload)
|
|
157
161
|
|
|
@@ -90,7 +90,9 @@ class ArcsecondTargetListsResource(ArcsecondAPIEndpoint):
|
|
|
90
90
|
)
|
|
91
91
|
return ArcsecondAPIEndpoint.update(self, id_name_uuid, json=payload)
|
|
92
92
|
|
|
93
|
-
def upsert(
|
|
93
|
+
def upsert(
|
|
94
|
+
self, match_field="name", json=None, targets=None, target_key=None, **fields
|
|
95
|
+
):
|
|
94
96
|
payload = self._build_payload(
|
|
95
97
|
json=json, targets=targets, target_key=target_key, **fields
|
|
96
98
|
)
|
|
@@ -104,7 +106,9 @@ class ArcsecondTargetListsResource(ArcsecondAPIEndpoint):
|
|
|
104
106
|
|
|
105
107
|
def set_targets(self, id_name_uuid, targets, target_key=None):
|
|
106
108
|
target_key = target_key or self.target_relation_key
|
|
107
|
-
return self.update(
|
|
109
|
+
return self.update(
|
|
110
|
+
id_name_uuid, **{target_key: self._normalise_target_payloads(targets)}
|
|
111
|
+
)
|
|
108
112
|
|
|
109
113
|
def clear_targets(self, id_name_uuid, target_key=None):
|
|
110
114
|
return self.set_targets(id_name_uuid, [], target_key=target_key)
|