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.
Files changed (109) hide show
  1. {arcsecond-3.7.3 → arcsecond-3.8.0}/PKG-INFO +3 -1
  2. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/__init__.py +10 -1
  3. arcsecond-3.8.0/arcsecond/api/__init__.py +11 -0
  4. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/api/endpoint.py +77 -4
  5. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/api/main.py +5 -0
  6. arcsecond-3.8.0/arcsecond/api/resources.py +114 -0
  7. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cli.py +4 -0
  8. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/docker/docker-compose.yml +6 -0
  9. arcsecond-3.8.0/arcsecond/targets.py +211 -0
  10. arcsecond-3.8.0/arcsecond/webcam/commands.py +101 -0
  11. arcsecond-3.8.0/arcsecond/webcam/proxy.py +135 -0
  12. {arcsecond-3.7.3 → arcsecond-3.8.0}/docs/.vitepress/config.js +10 -2
  13. arcsecond-3.8.0/docs/api-basics.md +91 -0
  14. arcsecond-3.8.0/docs/resources.md +335 -0
  15. {arcsecond-3.7.3 → arcsecond-3.8.0}/package-lock.json +2 -2
  16. {arcsecond-3.7.3 → arcsecond-3.8.0}/package.json +1 -1
  17. {arcsecond-3.7.3 → arcsecond-3.8.0}/pyproject.toml +6 -1
  18. {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/api/test_api_endpoint.py +50 -0
  19. arcsecond-3.8.0/tests/api/test_targets.py +196 -0
  20. arcsecond-3.8.0/tests/cloud/uploader/datafiles/__init__.py +0 -0
  21. arcsecond-3.8.0/tests/test_targets_planning.py +86 -0
  22. arcsecond-3.7.3/arcsecond/api/__init__.py +0 -5
  23. {arcsecond-3.7.3 → arcsecond-3.8.0}/.docker/Dockerfile_postgres +0 -0
  24. {arcsecond-3.7.3 → arcsecond-3.8.0}/.docker/Dockerfile_redis +0 -0
  25. {arcsecond-3.7.3 → arcsecond-3.8.0}/.github/dependabot.yml +0 -0
  26. {arcsecond-3.7.3 → arcsecond-3.8.0}/.github/workflows/docsdeploy.yml +0 -0
  27. {arcsecond-3.7.3 → arcsecond-3.8.0}/.github/workflows/pythonpublish.yml +0 -0
  28. {arcsecond-3.7.3 → arcsecond-3.8.0}/.github/workflows/tests.yml +0 -0
  29. {arcsecond-3.7.3 → arcsecond-3.8.0}/.gitignore +0 -0
  30. {arcsecond-3.7.3 → arcsecond-3.8.0}/LICENSE +0 -0
  31. {arcsecond-3.7.3 → arcsecond-3.8.0}/Makefile +0 -0
  32. {arcsecond-3.7.3 → arcsecond-3.8.0}/README.md +0 -0
  33. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/__version__.py +0 -0
  34. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/api/config.py +0 -0
  35. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/api/constants.py +0 -0
  36. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/__init__.py +0 -0
  37. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/auth.py +0 -0
  38. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/resources.py +0 -0
  39. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/__init__.py +0 -0
  40. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/allskycameraimages/__init__.py +0 -0
  41. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/allskycameraimages/context.py +0 -0
  42. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/allskycameraimages/errors.py +0 -0
  43. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/allskycameraimages/uploader.py +0 -0
  44. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/allskycameraimages/utils.py +0 -0
  45. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/constants.py +0 -0
  46. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/context.py +0 -0
  47. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  48. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  49. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  50. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  51. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  52. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/errors.py +0 -0
  53. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/logger.py +0 -0
  54. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/uploader.py +0 -0
  55. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/utils.py +0 -0
  56. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploader/walker.py +0 -0
  57. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/cloud/uploads.py +0 -0
  58. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/errors.py +0 -0
  59. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/__init__.py +0 -0
  60. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/checks.py +0 -0
  61. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/constants.py +0 -0
  62. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/docker/__init__.py +0 -0
  63. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/docker/constants.py +0 -0
  64. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/docker/containers.py +0 -0
  65. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/docker/images.py +0 -0
  66. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/docker/utils.py +0 -0
  67. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/keygen/__init__.py +0 -0
  68. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/keygen/client.py +0 -0
  69. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/keygen/utils.py +0 -0
  70. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/local.py +0 -0
  71. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/main.py +0 -0
  72. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/postgres/init-db.sh +0 -0
  73. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/setup.py +0 -0
  74. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/utils.py +0 -0
  75. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/hosting/validation.py +0 -0
  76. {arcsecond-3.7.3 → arcsecond-3.8.0}/arcsecond/options.py +0 -0
  77. {arcsecond-3.7.3/tests → arcsecond-3.8.0/arcsecond/webcam}/__init__.py +0 -0
  78. {arcsecond-3.7.3 → arcsecond-3.8.0}/deploy.sh +0 -0
  79. {arcsecond-3.7.3 → arcsecond-3.8.0}/docs/.vitepress/theme/custom.css +0 -0
  80. {arcsecond-3.7.3 → arcsecond-3.8.0}/docs/.vitepress/theme/index.js +0 -0
  81. {arcsecond-3.7.3 → arcsecond-3.8.0}/docs/img/logo-circle.png +0 -0
  82. {arcsecond-3.7.3 → arcsecond-3.8.0}/docs/index.md +0 -0
  83. {arcsecond-3.7.3 → arcsecond-3.8.0}/docs/install.md +0 -0
  84. {arcsecond-3.7.3 → arcsecond-3.8.0}/examples/example_upload_files.py +0 -0
  85. {arcsecond-3.7.3 → arcsecond-3.8.0}/examples/example_upload_images.py +0 -0
  86. {arcsecond-3.7.3 → arcsecond-3.8.0}/poetry.lock +0 -0
  87. {arcsecond-3.7.3 → arcsecond-3.8.0}/requirements.txt +0 -0
  88. {arcsecond-3.7.3 → arcsecond-3.8.0}/setup.cfg +0 -0
  89. {arcsecond-3.7.3 → arcsecond-3.8.0}/setup.py +0 -0
  90. {arcsecond-3.7.3 → arcsecond-3.8.0}/sonar-project.properties +0 -0
  91. {arcsecond-3.7.3/tests/api → arcsecond-3.8.0/tests}/__init__.py +0 -0
  92. {arcsecond-3.7.3/tests/cloud → arcsecond-3.8.0/tests/api}/__init__.py +0 -0
  93. {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/api/test_api.py +0 -0
  94. {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/api/test_config.py +0 -0
  95. {arcsecond-3.7.3/tests/cloud/uploader → arcsecond-3.8.0/tests/cloud}/__init__.py +0 -0
  96. {arcsecond-3.7.3/tests/cloud/uploader/allskycameraimages → arcsecond-3.8.0/tests/cloud/uploader}/__init__.py +0 -0
  97. {arcsecond-3.7.3/tests/cloud/uploader/datafiles → arcsecond-3.8.0/tests/cloud/uploader/allskycameraimages}/__init__.py +0 -0
  98. {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/cloud/uploader/allskycameraimages/test_context.py +0 -0
  99. {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/cloud/uploader/allskycameraimages/test_uploader_full_process.py +0 -0
  100. {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  101. {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  102. {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  103. {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  104. {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  105. {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/conftest.py +0 -0
  106. {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/fixtures/file1.fits +0 -0
  107. {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/test_cli.py +0 -0
  108. {arcsecond-3.7.3 → arcsecond-3.8.0}/tests/test_hosting_local.py +0 -0
  109. {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.7.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 ArcsecondAPI, ArcsecondAPIEndpoint, ArcsecondConfig
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(), "post", json=json, files=files, headers=headers
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)