arcsecond 3.7.3__tar.gz → 3.8.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.7.3 → arcsecond-3.8.0}/PKG-INFO +3 -1
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/__init__.py +10 -1
- arcsecond-3.8.0/arcsecond/api/__init__.py +11 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/api/endpoint.py +77 -4
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/api/main.py +5 -0
- arcsecond-3.8.0/arcsecond/api/resources.py +114 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cli.py +4 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/docker/docker-compose.yml +6 -0
- arcsecond-3.8.0/arcsecond/targets.py +211 -0
- arcsecond-3.8.0/arcsecond/webcam/commands.py +101 -0
- arcsecond-3.8.0/arcsecond/webcam/proxy.py +135 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/docs/.vitepress/config.js +10 -2
- arcsecond-3.8.0/docs/api-basics.md +91 -0
- arcsecond-3.8.0/docs/resources.md +335 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/package-lock.json +2 -2
- {arcsecond-3.7.3 → arcsecond-3.8.0}/package.json +1 -1
- {arcsecond-3.7.3 → arcsecond-3.8.0}/pyproject.toml +6 -1
- {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/api/test_api_endpoint.py +50 -0
- arcsecond-3.8.0/tests/api/test_targets.py +196 -0
- arcsecond-3.8.0/tests/cloud/uploader/datafiles/__init__.py +0 -0
- arcsecond-3.8.0/tests/test_targets_planning.py +86 -0
- arcsecond-3.7.3/arcsecond/api/__init__.py +0 -5
- {arcsecond-3.7.3 → arcsecond-3.8.0}/.docker/Dockerfile_postgres +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/.docker/Dockerfile_redis +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/.github/dependabot.yml +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/.github/workflows/docsdeploy.yml +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/.github/workflows/pythonpublish.yml +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/.github/workflows/tests.yml +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/.gitignore +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/LICENSE +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/Makefile +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/README.md +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/__version__.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/api/config.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/api/constants.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/__init__.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/auth.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/resources.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/__init__.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/allskycameraimages/__init__.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/allskycameraimages/context.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/allskycameraimages/errors.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/allskycameraimages/uploader.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/allskycameraimages/utils.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/constants.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/context.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/errors.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/logger.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/uploader.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/utils.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/walker.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploads.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/errors.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/__init__.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/checks.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/constants.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/docker/__init__.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/docker/constants.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/docker/containers.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/docker/images.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/docker/utils.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/keygen/__init__.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/keygen/client.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/keygen/utils.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/local.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/main.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/postgres/init-db.sh +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/setup.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/utils.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/validation.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/options.py +0 -0
- {arcsecond-3.7.3/tests → arcsecond-3.8.0/arcsecond/webcam}/__init__.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/deploy.sh +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/docs/.vitepress/theme/custom.css +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/docs/.vitepress/theme/index.js +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/docs/img/logo-circle.png +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/docs/index.md +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/docs/install.md +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/examples/example_upload_files.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/examples/example_upload_images.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/poetry.lock +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/requirements.txt +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/setup.cfg +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/setup.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/sonar-project.properties +0 -0
- {arcsecond-3.7.3/tests/api → arcsecond-3.8.0/tests}/__init__.py +0 -0
- {arcsecond-3.7.3/tests/cloud → arcsecond-3.8.0/tests/api}/__init__.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/api/test_api.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/api/test_config.py +0 -0
- {arcsecond-3.7.3/tests/cloud/uploader → arcsecond-3.8.0/tests/cloud}/__init__.py +0 -0
- {arcsecond-3.7.3/tests/cloud/uploader/allskycameraimages → arcsecond-3.8.0/tests/cloud/uploader}/__init__.py +0 -0
- {arcsecond-3.7.3/tests/cloud/uploader/datafiles → arcsecond-3.8.0/tests/cloud/uploader/allskycameraimages}/__init__.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/cloud/uploader/allskycameraimages/test_context.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/cloud/uploader/allskycameraimages/test_uploader_full_process.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/conftest.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/fixtures/file1.fits +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/test_cli.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/test_hosting_local.py +0 -0
- {arcsecond-3.7.3 → arcsecond-3.8.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.8.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,10 +36,12 @@ 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: aiohttp>=3.9
|
|
39
40
|
Requires-Dist: click>=8
|
|
40
41
|
Requires-Dist: configparser
|
|
41
42
|
Requires-Dist: docker
|
|
42
43
|
Requires-Dist: httpx
|
|
44
|
+
Requires-Dist: opencv-python-headless<5,>=4.10
|
|
43
45
|
Requires-Dist: py-machineid
|
|
44
46
|
Requires-Dist: tqdm<5.0.0,>=4.67.1
|
|
45
47
|
Requires-Dist: wait-for-it
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
from .api import
|
|
1
|
+
from .api import (
|
|
2
|
+
ArcsecondAPI,
|
|
3
|
+
ArcsecondAPIEndpoint,
|
|
4
|
+
ArcsecondConfig,
|
|
5
|
+
ArcsecondTargetListsResource,
|
|
6
|
+
)
|
|
2
7
|
from .cloud.uploader import (
|
|
3
8
|
AllSkyCameraImageFileUploader,
|
|
4
9
|
AllSkyCameraImageUploadContext,
|
|
@@ -7,6 +12,7 @@ from .cloud.uploader import (
|
|
|
7
12
|
)
|
|
8
13
|
from .cloud.uploader.walker import walk_folder_and_upload_files
|
|
9
14
|
from .errors import ArcsecondError
|
|
15
|
+
from .targets import ArcsecondTargetPayloadPlan, plan_target_payload
|
|
10
16
|
|
|
11
17
|
name = "arcsecond"
|
|
12
18
|
|
|
@@ -15,9 +21,12 @@ __all__ = [
|
|
|
15
21
|
"ArcsecondError",
|
|
16
22
|
"ArcsecondConfig",
|
|
17
23
|
"ArcsecondAPIEndpoint",
|
|
24
|
+
"ArcsecondTargetListsResource",
|
|
25
|
+
"ArcsecondTargetPayloadPlan",
|
|
18
26
|
"DatasetUploadContext",
|
|
19
27
|
"DatasetFileUploader",
|
|
20
28
|
"AllSkyCameraImageFileUploader",
|
|
21
29
|
"AllSkyCameraImageUploadContext",
|
|
30
|
+
"plan_target_payload",
|
|
22
31
|
"walk_folder_and_upload_files",
|
|
23
32
|
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .config import ArcsecondConfig
|
|
2
|
+
from .endpoint import ArcsecondAPIEndpoint
|
|
3
|
+
from .main import ArcsecondAPI
|
|
4
|
+
from .resources import ArcsecondTargetListsResource
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ArcsecondAPI",
|
|
8
|
+
"ArcsecondConfig",
|
|
9
|
+
"ArcsecondAPIEndpoint",
|
|
10
|
+
"ArcsecondTargetListsResource",
|
|
11
|
+
]
|
|
@@ -12,6 +12,13 @@ WRITABLE_MEMBERSHIPS = ["superadmin", "admin", "member"]
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class ArcsecondAPIEndpoint(object):
|
|
15
|
+
"""
|
|
16
|
+
Generic REST endpoint wrapper for Arcsecond resources.
|
|
17
|
+
|
|
18
|
+
It owns transport-level CRUD plus resource-agnostic conveniences such as
|
|
19
|
+
payload merging, `find_one()`, and `upsert()`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
15
22
|
def __init__(
|
|
16
23
|
self,
|
|
17
24
|
config: ArcsecondConfig,
|
|
@@ -57,6 +64,30 @@ class ArcsecondAPIEndpoint(object):
|
|
|
57
64
|
def _detail_url(self, uuid_or_id):
|
|
58
65
|
return self._build_url(self.__path, str(uuid_or_id))
|
|
59
66
|
|
|
67
|
+
def _build_payload(self, json=None, **fields):
|
|
68
|
+
payload = {}
|
|
69
|
+
if json:
|
|
70
|
+
payload.update(json)
|
|
71
|
+
payload.update({key: value for key, value in fields.items() if value is not None})
|
|
72
|
+
return payload or None
|
|
73
|
+
|
|
74
|
+
def _extract_results(self, response):
|
|
75
|
+
if isinstance(response, dict):
|
|
76
|
+
if isinstance(response.get("results"), list):
|
|
77
|
+
return response["results"]
|
|
78
|
+
if response:
|
|
79
|
+
return [response]
|
|
80
|
+
elif isinstance(response, list):
|
|
81
|
+
return response
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
def _extract_identifier(self, resource, identifier_fields=("uuid", "id", "pk")):
|
|
85
|
+
for key in identifier_fields:
|
|
86
|
+
value = resource.get(key)
|
|
87
|
+
if value is not None:
|
|
88
|
+
return value
|
|
89
|
+
return None
|
|
90
|
+
|
|
60
91
|
def list(self, **filters):
|
|
61
92
|
return self._perform_request(self._list_url(**filters), "get")
|
|
62
93
|
|
|
@@ -65,16 +96,20 @@ class ArcsecondAPIEndpoint(object):
|
|
|
65
96
|
self._detail_url(id_name_uuid), "get", headers=headers
|
|
66
97
|
)
|
|
67
98
|
|
|
68
|
-
def create(self, json=None, files=None, headers=None):
|
|
99
|
+
def create(self, json=None, files=None, headers=None, **fields):
|
|
69
100
|
return self._perform_request(
|
|
70
|
-
self._list_url(),
|
|
101
|
+
self._list_url(),
|
|
102
|
+
"post",
|
|
103
|
+
json=self._build_payload(json=json, **fields),
|
|
104
|
+
files=files,
|
|
105
|
+
headers=headers,
|
|
71
106
|
)
|
|
72
107
|
|
|
73
|
-
def update(self, id_name_uuid, json=None, files=None, headers=None):
|
|
108
|
+
def update(self, id_name_uuid, json=None, files=None, headers=None, **fields):
|
|
74
109
|
return self._perform_request(
|
|
75
110
|
self._detail_url(id_name_uuid),
|
|
76
111
|
"patch",
|
|
77
|
-
json=json,
|
|
112
|
+
json=self._build_payload(json=json, **fields),
|
|
78
113
|
files=files,
|
|
79
114
|
headers=headers,
|
|
80
115
|
)
|
|
@@ -82,6 +117,44 @@ class ArcsecondAPIEndpoint(object):
|
|
|
82
117
|
def delete(self, id_name_uuid):
|
|
83
118
|
return self._perform_request(self._detail_url(id_name_uuid), "delete")
|
|
84
119
|
|
|
120
|
+
def find_one(self, **filters):
|
|
121
|
+
response, error = self.list(**filters)
|
|
122
|
+
if error:
|
|
123
|
+
return None, error
|
|
124
|
+
|
|
125
|
+
results = self._extract_results(response)
|
|
126
|
+
if len(results) == 0:
|
|
127
|
+
return None, None
|
|
128
|
+
if len(results) > 1:
|
|
129
|
+
return (
|
|
130
|
+
None,
|
|
131
|
+
ArcsecondError(
|
|
132
|
+
f"Expected one '{self.path}' match for filters {filters}, got {len(results)}."
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
return results[0], None
|
|
136
|
+
|
|
137
|
+
def upsert(self, match_field="name", json=None, **fields):
|
|
138
|
+
payload = self._build_payload(json=json, **fields)
|
|
139
|
+
if payload is None:
|
|
140
|
+
return None, ArcsecondError("Cannot upsert an empty payload.")
|
|
141
|
+
|
|
142
|
+
match_value = payload.get(match_field)
|
|
143
|
+
if match_value in (None, ""):
|
|
144
|
+
return self.create(json=payload)
|
|
145
|
+
|
|
146
|
+
existing, error = self.find_one(**{match_field: match_value})
|
|
147
|
+
if error:
|
|
148
|
+
return None, error
|
|
149
|
+
if existing is None:
|
|
150
|
+
return self.create(json=payload)
|
|
151
|
+
|
|
152
|
+
identifier = self._extract_identifier(existing)
|
|
153
|
+
if identifier is None:
|
|
154
|
+
return None, ArcsecondError(f"Could not find an identifier for '{match_value}'.")
|
|
155
|
+
|
|
156
|
+
return self.update(identifier, json=payload)
|
|
157
|
+
|
|
85
158
|
def _perform_request(self, url, method_name, json=None, files=None, headers=None):
|
|
86
159
|
if self.__config.verbose:
|
|
87
160
|
click.echo(f"Sending {method_name} request to {url}")
|
|
@@ -8,6 +8,7 @@ from arcsecond.options import State
|
|
|
8
8
|
from .config import ArcsecondConfig
|
|
9
9
|
from .constants import API_AUTH_PATH_VERIFY
|
|
10
10
|
from .endpoint import ArcsecondAPIEndpoint
|
|
11
|
+
from .resources import ArcsecondTargetListsResource
|
|
11
12
|
|
|
12
13
|
__all__ = [
|
|
13
14
|
"ArcsecondAPI",
|
|
@@ -40,6 +41,10 @@ class ArcsecondAPI(object):
|
|
|
40
41
|
self.calibrations = ArcsecondAPIEndpoint(
|
|
41
42
|
self.config, "calibrations", self.subdomain
|
|
42
43
|
)
|
|
44
|
+
self.targets = ArcsecondAPIEndpoint(self.config, "targets", self.subdomain)
|
|
45
|
+
self.targetlists = ArcsecondTargetListsResource(
|
|
46
|
+
self.config, "targetlists", self.subdomain
|
|
47
|
+
)
|
|
43
48
|
|
|
44
49
|
self.datapackages = ArcsecondAPIEndpoint(
|
|
45
50
|
self.config, "datapackages", self.subdomain
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from arcsecond.errors import ArcsecondError
|
|
2
|
+
|
|
3
|
+
from .endpoint import ArcsecondAPIEndpoint
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ArcsecondTargetListsResource(ArcsecondAPIEndpoint):
|
|
7
|
+
"""Target-list specific helpers built on top of the generic endpoint contract."""
|
|
8
|
+
|
|
9
|
+
target_relation_keys = ("targets", "target_uuids", "target_ids")
|
|
10
|
+
|
|
11
|
+
def _ensure_iterable(self, values):
|
|
12
|
+
if values is None:
|
|
13
|
+
return None
|
|
14
|
+
if isinstance(values, (str, int)):
|
|
15
|
+
return [values]
|
|
16
|
+
return list(values)
|
|
17
|
+
|
|
18
|
+
def _normalise_target_references(self, targets):
|
|
19
|
+
values = self._ensure_iterable(targets)
|
|
20
|
+
if values is None:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
refs = []
|
|
24
|
+
for target in values:
|
|
25
|
+
if isinstance(target, dict):
|
|
26
|
+
ref = (
|
|
27
|
+
target.get("uuid")
|
|
28
|
+
or target.get("id")
|
|
29
|
+
or target.get("pk")
|
|
30
|
+
or target.get("name")
|
|
31
|
+
)
|
|
32
|
+
if ref is None:
|
|
33
|
+
raise ArcsecondError(
|
|
34
|
+
"Target dictionaries must include one of: uuid, id, pk or name."
|
|
35
|
+
)
|
|
36
|
+
refs.append(ref)
|
|
37
|
+
else:
|
|
38
|
+
refs.append(target)
|
|
39
|
+
return refs
|
|
40
|
+
|
|
41
|
+
def _target_key_from_payload(self, payload, target_key=None):
|
|
42
|
+
if target_key:
|
|
43
|
+
return target_key
|
|
44
|
+
for key in self.target_relation_keys:
|
|
45
|
+
if payload and key in payload:
|
|
46
|
+
return key
|
|
47
|
+
return self.target_relation_keys[0]
|
|
48
|
+
|
|
49
|
+
def _build_payload(self, json=None, targets=None, target_key=None, **fields):
|
|
50
|
+
payload = super()._build_payload(json=json, **fields) or {}
|
|
51
|
+
normalised_targets = self._normalise_target_references(targets)
|
|
52
|
+
if normalised_targets is not None:
|
|
53
|
+
payload[self._target_key_from_payload(payload, target_key=target_key)] = (
|
|
54
|
+
normalised_targets
|
|
55
|
+
)
|
|
56
|
+
return payload or None
|
|
57
|
+
|
|
58
|
+
def create(self, json=None, targets=None, target_key=None, **fields):
|
|
59
|
+
payload = self._build_payload(
|
|
60
|
+
json=json, targets=targets, target_key=target_key, **fields
|
|
61
|
+
)
|
|
62
|
+
return ArcsecondAPIEndpoint.create(self, json=payload)
|
|
63
|
+
|
|
64
|
+
def update(self, id_name_uuid, json=None, targets=None, target_key=None, **fields):
|
|
65
|
+
payload = self._build_payload(
|
|
66
|
+
json=json, targets=targets, target_key=target_key, **fields
|
|
67
|
+
)
|
|
68
|
+
return ArcsecondAPIEndpoint.update(self, id_name_uuid, json=payload)
|
|
69
|
+
|
|
70
|
+
def upsert(self, match_field="name", json=None, targets=None, target_key=None, **fields):
|
|
71
|
+
payload = self._build_payload(
|
|
72
|
+
json=json, targets=targets, target_key=target_key, **fields
|
|
73
|
+
)
|
|
74
|
+
return super().upsert(match_field=match_field, json=payload)
|
|
75
|
+
|
|
76
|
+
def _read_target_refs(self, target_list, target_key=None):
|
|
77
|
+
key = self._target_key_from_payload(target_list or {}, target_key=target_key)
|
|
78
|
+
raw_targets = (target_list or {}).get(key, [])
|
|
79
|
+
refs = self._normalise_target_references(raw_targets) or []
|
|
80
|
+
return key, refs
|
|
81
|
+
|
|
82
|
+
def set_targets(self, id_name_uuid, targets, target_key=None):
|
|
83
|
+
target_key = self._target_key_from_payload({}, target_key=target_key)
|
|
84
|
+
return self.update(id_name_uuid, **{target_key: self._normalise_target_references(targets)})
|
|
85
|
+
|
|
86
|
+
def clear_targets(self, id_name_uuid, target_key=None):
|
|
87
|
+
return self.set_targets(id_name_uuid, [], target_key=target_key)
|
|
88
|
+
|
|
89
|
+
def add_targets(self, id_name_uuid, targets, target_key=None):
|
|
90
|
+
target_list, error = self.read(id_name_uuid)
|
|
91
|
+
if error:
|
|
92
|
+
return None, error
|
|
93
|
+
|
|
94
|
+
key, current_refs = self._read_target_refs(target_list, target_key=target_key)
|
|
95
|
+
for ref in self._normalise_target_references(targets) or []:
|
|
96
|
+
if ref not in current_refs:
|
|
97
|
+
current_refs.append(ref)
|
|
98
|
+
return self.update(id_name_uuid, **{key: current_refs})
|
|
99
|
+
|
|
100
|
+
def remove_targets(self, id_name_uuid, targets, target_key=None):
|
|
101
|
+
target_list, error = self.read(id_name_uuid)
|
|
102
|
+
if error:
|
|
103
|
+
return None, error
|
|
104
|
+
|
|
105
|
+
key, current_refs = self._read_target_refs(target_list, target_key=target_key)
|
|
106
|
+
refs_to_remove = set(self._normalise_target_references(targets) or [])
|
|
107
|
+
remaining_refs = [ref for ref in current_refs if ref not in refs_to_remove]
|
|
108
|
+
return self.update(id_name_uuid, **{key: remaining_refs})
|
|
109
|
+
|
|
110
|
+
def add_target(self, id_name_uuid, target, target_key=None):
|
|
111
|
+
return self.add_targets(id_name_uuid, [target], target_key=target_key)
|
|
112
|
+
|
|
113
|
+
def remove_target(self, id_name_uuid, target, target_key=None):
|
|
114
|
+
return self.remove_targets(id_name_uuid, [target], target_key=target_key)
|
|
@@ -10,6 +10,7 @@ from arcsecond.cloud import (
|
|
|
10
10
|
upload_data,
|
|
11
11
|
)
|
|
12
12
|
from arcsecond.hosting import setup
|
|
13
|
+
from arcsecond.webcam import commands as webcam
|
|
13
14
|
|
|
14
15
|
from . import __version__
|
|
15
16
|
from .options import State
|
|
@@ -59,3 +60,6 @@ main.add_command(upload_data)
|
|
|
59
60
|
|
|
60
61
|
# Allow to try arcsecond by installing a local version
|
|
61
62
|
main.add_command(setup)
|
|
63
|
+
|
|
64
|
+
# Native webcam proxy — lets Docker containers reach USB webcams on the host.
|
|
65
|
+
main.add_command(webcam.webcam)
|
|
@@ -35,6 +35,12 @@ services:
|
|
|
35
35
|
depends_on:
|
|
36
36
|
- db
|
|
37
37
|
- broker
|
|
38
|
+
# Allows the backend to reach the host machine via host.docker.internal.
|
|
39
|
+
# Required on Linux; Docker Desktop on Windows/macOS adds this automatically.
|
|
40
|
+
# Used by the webcam proxy: set WEBCAM_PROXY_URL=http://host.docker.internal:8765
|
|
41
|
+
# in your .env file and run `arcsecond webcam start` on the host.
|
|
42
|
+
extra_hosts:
|
|
43
|
+
- "host.docker.internal:host-gateway"
|
|
38
44
|
env_file:
|
|
39
45
|
# You must have a .env file with secret keys beside this yml file.
|
|
40
46
|
- .env
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any, Mapping, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
TARGET_CLASS_ASTRONOMICAL_OBJECT = "AstronomicalObject"
|
|
6
|
+
TARGET_CLASS_EXOPLANET = "Exoplanet"
|
|
7
|
+
TARGET_CLASS_STANDARD_STAR = "StandardStar"
|
|
8
|
+
TARGET_CLASS_SOLAR_SYSTEM_PLANET = "SolarSystemPlanet"
|
|
9
|
+
TARGET_CLASS_SMALL_BODY = "SmallBody"
|
|
10
|
+
TARGET_CLASS_MICROLENSING = "Microlensing"
|
|
11
|
+
TARGET_CLASS_TRANSIENT = "Transient"
|
|
12
|
+
|
|
13
|
+
TARGET_CLASSES = {
|
|
14
|
+
TARGET_CLASS_ASTRONOMICAL_OBJECT,
|
|
15
|
+
TARGET_CLASS_EXOPLANET,
|
|
16
|
+
TARGET_CLASS_STANDARD_STAR,
|
|
17
|
+
TARGET_CLASS_SOLAR_SYSTEM_PLANET,
|
|
18
|
+
TARGET_CLASS_SMALL_BODY,
|
|
19
|
+
TARGET_CLASS_MICROLENSING,
|
|
20
|
+
TARGET_CLASS_TRANSIENT,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
TARGET_MODE_MANUAL = "manual"
|
|
24
|
+
|
|
25
|
+
TARGET_CLASSES_REQUIRING_NAME = {
|
|
26
|
+
TARGET_CLASS_ASTRONOMICAL_OBJECT,
|
|
27
|
+
TARGET_CLASS_EXOPLANET,
|
|
28
|
+
TARGET_CLASS_STANDARD_STAR,
|
|
29
|
+
TARGET_CLASS_SOLAR_SYSTEM_PLANET,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
TARGET_CLASSES_REQUIRING_IDENTIFIER = {
|
|
33
|
+
TARGET_CLASS_SMALL_BODY,
|
|
34
|
+
TARGET_CLASS_MICROLENSING,
|
|
35
|
+
TARGET_CLASS_TRANSIENT,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ArcsecondTargetPayloadPlan:
|
|
41
|
+
payload: dict[str, Any]
|
|
42
|
+
target_class: Optional[str]
|
|
43
|
+
mode: str
|
|
44
|
+
target_class_source: Optional[str]
|
|
45
|
+
coordinates_source: Optional[str]
|
|
46
|
+
warnings: tuple[str, ...] = field(default_factory=tuple)
|
|
47
|
+
errors: tuple[str, ...] = field(default_factory=tuple)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_valid(self) -> bool:
|
|
51
|
+
return len(self.errors) == 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _clean_string(value: Optional[str]) -> str:
|
|
55
|
+
return (value or "").strip()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _normalise_coordinates(coordinates: Optional[Mapping[str, Any]]) -> Optional[dict[str, Any]]:
|
|
59
|
+
if coordinates is None:
|
|
60
|
+
return None
|
|
61
|
+
return {
|
|
62
|
+
key: value
|
|
63
|
+
for key, value in dict(coordinates).items()
|
|
64
|
+
if value is not None
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _validate_target_class(target_class: str, label: str, errors: list[str]) -> None:
|
|
69
|
+
if target_class and target_class not in TARGET_CLASSES:
|
|
70
|
+
errors.append(f"{label} '{target_class}' is not a supported Arcsecond target class.")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def plan_target_payload(
|
|
74
|
+
*,
|
|
75
|
+
name: Optional[str] = None,
|
|
76
|
+
identifier: Optional[str] = None,
|
|
77
|
+
target_class: Optional[str] = None,
|
|
78
|
+
coordinates: Optional[Mapping[str, Any]] = None,
|
|
79
|
+
inferred_name: Optional[str] = None,
|
|
80
|
+
inferred_identifier: Optional[str] = None,
|
|
81
|
+
inferred_target_class: Optional[str] = None,
|
|
82
|
+
color: Optional[str] = None,
|
|
83
|
+
notes: Optional[str] = None,
|
|
84
|
+
profile: Optional[str] = None,
|
|
85
|
+
organisation: Optional[str] = None,
|
|
86
|
+
extra_fields: Optional[Mapping[str, Any]] = None,
|
|
87
|
+
) -> ArcsecondTargetPayloadPlan:
|
|
88
|
+
"""
|
|
89
|
+
Build a backend-compatible target payload while preserving the rule:
|
|
90
|
+
user-provided values override inferred ones.
|
|
91
|
+
|
|
92
|
+
This helper is pure: it does not perform network lookups or create/update targets.
|
|
93
|
+
It simply turns user input plus optional inferred metadata into a target payload,
|
|
94
|
+
warnings, and validation errors so callers can inspect the plan before applying it.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
errors: list[str] = []
|
|
98
|
+
warnings: list[str] = []
|
|
99
|
+
|
|
100
|
+
user_name = _clean_string(name)
|
|
101
|
+
user_identifier = _clean_string(identifier)
|
|
102
|
+
user_target_class = _clean_string(target_class)
|
|
103
|
+
|
|
104
|
+
inferred_name = _clean_string(inferred_name)
|
|
105
|
+
inferred_identifier = _clean_string(inferred_identifier)
|
|
106
|
+
inferred_target_class = _clean_string(inferred_target_class)
|
|
107
|
+
|
|
108
|
+
_validate_target_class(user_target_class, "target_class", errors)
|
|
109
|
+
_validate_target_class(inferred_target_class, "inferred_target_class", errors)
|
|
110
|
+
|
|
111
|
+
effective_name = user_name or inferred_name
|
|
112
|
+
effective_identifier = user_identifier or inferred_identifier
|
|
113
|
+
effective_coordinates = _normalise_coordinates(coordinates)
|
|
114
|
+
|
|
115
|
+
if not effective_name and not effective_identifier:
|
|
116
|
+
errors.append("One of name or identifier must be provided.")
|
|
117
|
+
|
|
118
|
+
if user_target_class and inferred_target_class and user_target_class != inferred_target_class:
|
|
119
|
+
warnings.append(
|
|
120
|
+
f"User-provided target_class '{user_target_class}' overrides inferred target_class '{inferred_target_class}'."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
payload: dict[str, Any] = {}
|
|
124
|
+
if effective_name:
|
|
125
|
+
payload["name"] = effective_name
|
|
126
|
+
if effective_identifier:
|
|
127
|
+
payload["identifier"] = effective_identifier
|
|
128
|
+
for key, value in {
|
|
129
|
+
"color": color,
|
|
130
|
+
"notes": notes,
|
|
131
|
+
"profile": profile,
|
|
132
|
+
"organisation": organisation,
|
|
133
|
+
}.items():
|
|
134
|
+
if value is not None:
|
|
135
|
+
payload[key] = value
|
|
136
|
+
if extra_fields:
|
|
137
|
+
payload.update({key: value for key, value in dict(extra_fields).items() if value is not None})
|
|
138
|
+
|
|
139
|
+
if effective_coordinates is not None:
|
|
140
|
+
if user_target_class and user_target_class != TARGET_CLASS_ASTRONOMICAL_OBJECT:
|
|
141
|
+
errors.append(
|
|
142
|
+
"Manual coordinates are currently supported only for 'AstronomicalObject'. "
|
|
143
|
+
f"Received target_class '{user_target_class}'."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
effective_target_class = user_target_class or TARGET_CLASS_ASTRONOMICAL_OBJECT
|
|
147
|
+
target_class_source = "user" if user_target_class else "default"
|
|
148
|
+
coordinates_source = "user"
|
|
149
|
+
|
|
150
|
+
if inferred_target_class and not user_target_class and inferred_target_class != TARGET_CLASS_ASTRONOMICAL_OBJECT:
|
|
151
|
+
warnings.append(
|
|
152
|
+
f"Inferred target_class '{inferred_target_class}' is ignored because user-provided coordinates "
|
|
153
|
+
"require a manual 'AstronomicalObject' payload with the current backend."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if not effective_name:
|
|
157
|
+
errors.append("Manual coordinates require a target name.")
|
|
158
|
+
|
|
159
|
+
payload["target_class"] = effective_target_class
|
|
160
|
+
payload["mode"] = TARGET_MODE_MANUAL
|
|
161
|
+
payload["object"] = {
|
|
162
|
+
"name": effective_name or effective_identifier,
|
|
163
|
+
"equatorial_coordinates": effective_coordinates,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return ArcsecondTargetPayloadPlan(
|
|
167
|
+
payload=payload,
|
|
168
|
+
target_class=effective_target_class or None,
|
|
169
|
+
mode=TARGET_MODE_MANUAL,
|
|
170
|
+
target_class_source=target_class_source,
|
|
171
|
+
coordinates_source=coordinates_source,
|
|
172
|
+
warnings=tuple(warnings),
|
|
173
|
+
errors=tuple(errors),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
effective_target_class = user_target_class or inferred_target_class
|
|
177
|
+
target_class_source = None
|
|
178
|
+
if user_target_class:
|
|
179
|
+
target_class_source = "user"
|
|
180
|
+
elif inferred_target_class:
|
|
181
|
+
target_class_source = "inferred"
|
|
182
|
+
|
|
183
|
+
if not effective_target_class:
|
|
184
|
+
errors.append(
|
|
185
|
+
"target_class could not be determined. Provide target_class explicitly, provide coordinates, "
|
|
186
|
+
"or infer the class before creating/updating the target."
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
payload["target_class"] = effective_target_class
|
|
190
|
+
|
|
191
|
+
if effective_target_class in TARGET_CLASSES_REQUIRING_NAME and not effective_name:
|
|
192
|
+
errors.append(f"Target class '{effective_target_class}' requires a name.")
|
|
193
|
+
|
|
194
|
+
if effective_target_class in TARGET_CLASSES_REQUIRING_IDENTIFIER and not effective_identifier:
|
|
195
|
+
errors.append(f"Target class '{effective_target_class}' requires an identifier.")
|
|
196
|
+
|
|
197
|
+
if effective_target_class and "object" not in payload:
|
|
198
|
+
warnings.append(
|
|
199
|
+
"This payload does not contain manual coordinates. Target creation will rely on the backend "
|
|
200
|
+
"to resolve the object from name/identifier and target_class."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return ArcsecondTargetPayloadPlan(
|
|
204
|
+
payload=payload,
|
|
205
|
+
target_class=effective_target_class or None,
|
|
206
|
+
mode="",
|
|
207
|
+
target_class_source=target_class_source,
|
|
208
|
+
coordinates_source=None,
|
|
209
|
+
warnings=tuple(warnings),
|
|
210
|
+
errors=tuple(errors),
|
|
211
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Click command group: ``arcsecond webcam``
|
|
3
|
+
|
|
4
|
+
Sub-commands
|
|
5
|
+
------------
|
|
6
|
+
detect Scan for attached webcams and print a summary table.
|
|
7
|
+
start Start the native webcam proxy server so Docker containers can
|
|
8
|
+
reach USB webcams on this host via host.docker.internal.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group(help="Manage the native webcam proxy for Docker containers.")
|
|
20
|
+
def webcam():
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@webcam.command(name='detect', help="Scan for locally attached webcams and print their details.")
|
|
25
|
+
def detect_cmd():
|
|
26
|
+
try:
|
|
27
|
+
import cv2 # noqa: F401
|
|
28
|
+
except ImportError:
|
|
29
|
+
click.echo(
|
|
30
|
+
click.style("Error: ", fg='red') +
|
|
31
|
+
"opencv-python-headless is not installed.\n"
|
|
32
|
+
"Run: pip install opencv-python-headless"
|
|
33
|
+
)
|
|
34
|
+
raise SystemExit(1)
|
|
35
|
+
|
|
36
|
+
from arcsecond.webcam.proxy import _detect_webcams_sync
|
|
37
|
+
webcams = _detect_webcams_sync()
|
|
38
|
+
|
|
39
|
+
if not webcams:
|
|
40
|
+
click.echo("No webcams detected.")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
click.echo(f"Found {len(webcams)} webcam(s):\n")
|
|
44
|
+
for w in webcams:
|
|
45
|
+
click.echo(
|
|
46
|
+
f" index={w.index} {w.width}×{w.height} {w.fps:.1f} fps"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@webcam.command(name='start', help=(
|
|
51
|
+
"Start the webcam proxy server.\n\n"
|
|
52
|
+
"The proxy exposes two endpoints that the Arcsecond backend container can "
|
|
53
|
+
"reach via host.docker.internal:\n\n"
|
|
54
|
+
" GET /detect — list attached webcams (JSON)\n\n"
|
|
55
|
+
" WS /stream/{index} — JPEG frame stream\n\n"
|
|
56
|
+
"Set WEBCAM_PROXY_URL=http://host.docker.internal:<PORT> in your .env file "
|
|
57
|
+
"so the backend knows where to find the proxy."
|
|
58
|
+
))
|
|
59
|
+
@click.option('--port', default=8765, show_default=True, help="TCP port to listen on.")
|
|
60
|
+
@click.option('--host', default='0.0.0.0', show_default=True, help="Interface to bind.")
|
|
61
|
+
@click.option('--log-level', default='INFO', show_default=True,
|
|
62
|
+
type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'ERROR'], case_sensitive=False),
|
|
63
|
+
help="Logging verbosity.")
|
|
64
|
+
def start_cmd(port, host, log_level):
|
|
65
|
+
try:
|
|
66
|
+
import aiohttp # noqa: F401
|
|
67
|
+
except ImportError:
|
|
68
|
+
click.echo(
|
|
69
|
+
click.style("Error: ", fg='red') +
|
|
70
|
+
"aiohttp is not installed.\n"
|
|
71
|
+
"Run: pip install aiohttp"
|
|
72
|
+
)
|
|
73
|
+
raise SystemExit(1)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
import cv2 # noqa: F401
|
|
77
|
+
except ImportError:
|
|
78
|
+
click.echo(
|
|
79
|
+
click.style("Error: ", fg='red') +
|
|
80
|
+
"opencv-python-headless is not installed.\n"
|
|
81
|
+
"Run: pip install opencv-python-headless"
|
|
82
|
+
)
|
|
83
|
+
raise SystemExit(1)
|
|
84
|
+
|
|
85
|
+
logging.basicConfig(
|
|
86
|
+
level=getattr(logging, log_level.upper()),
|
|
87
|
+
format='%(asctime)s %(levelname)-8s %(name)s %(message)s',
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
click.echo(
|
|
91
|
+
click.style("Arcsecond webcam proxy", bold=True) +
|
|
92
|
+
f" listening on {host}:{port}\n"
|
|
93
|
+
f" Detection → http://{host}:{port}/detect\n"
|
|
94
|
+
f" Streaming → ws://{host}:{port}/stream/{{index}}\n\n"
|
|
95
|
+
"Set in your .env file:\n"
|
|
96
|
+
f" WEBCAM_PROXY_URL=http://host.docker.internal:{port}\n\n"
|
|
97
|
+
"Press Ctrl-C to stop."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
from arcsecond.webcam.proxy import run
|
|
101
|
+
run(host=host, port=port)
|