arcsecond 3.7.3__tar.gz → 3.8.1__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 (116) hide show
  1. arcsecond-3.8.1/.github/workflows/docsdeploy.yml +28 -0
  2. {arcsecond-3.7.3 → arcsecond-3.8.1}/.github/workflows/pythonpublish.yml +3 -3
  3. {arcsecond-3.7.3 → arcsecond-3.8.1}/.github/workflows/tests.yml +8 -8
  4. {arcsecond-3.7.3 → arcsecond-3.8.1}/PKG-INFO +3 -1
  5. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/__init__.py +10 -1
  6. arcsecond-3.8.1/arcsecond/api/__init__.py +11 -0
  7. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/api/endpoint.py +77 -4
  8. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/api/main.py +5 -0
  9. arcsecond-3.8.1/arcsecond/api/resources.py +149 -0
  10. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cli.py +4 -0
  11. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/docker/docker-compose.yml +6 -0
  12. arcsecond-3.8.1/arcsecond/targets.py +211 -0
  13. arcsecond-3.8.1/arcsecond/webcam/commands.py +101 -0
  14. arcsecond-3.8.1/arcsecond/webcam/proxy.py +135 -0
  15. arcsecond-3.8.1/docs/.vitepress/config.js +27 -0
  16. arcsecond-3.8.1/docs/api-basics.md +97 -0
  17. arcsecond-3.8.1/docs/img/icon-logo.png +0 -0
  18. arcsecond-3.8.1/docs/index.md +36 -0
  19. arcsecond-3.8.1/docs/install.md +78 -0
  20. arcsecond-3.8.1/docs/public/img/icon-logo.png +0 -0
  21. arcsecond-3.8.1/docs/resources.md +359 -0
  22. arcsecond-3.8.1/docs/upload.md +128 -0
  23. {arcsecond-3.7.3 → arcsecond-3.8.1}/package-lock.json +2 -2
  24. {arcsecond-3.7.3 → arcsecond-3.8.1}/package.json +2 -2
  25. {arcsecond-3.7.3 → arcsecond-3.8.1}/pyproject.toml +6 -1
  26. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/api/test_api_endpoint.py +50 -0
  27. arcsecond-3.8.1/tests/api/test_targets.py +243 -0
  28. arcsecond-3.8.1/tests/cloud/uploader/datafiles/__init__.py +0 -0
  29. arcsecond-3.8.1/tests/test_targets_planning.py +86 -0
  30. arcsecond-3.7.3/.github/workflows/docsdeploy.yml +0 -30
  31. arcsecond-3.7.3/arcsecond/api/__init__.py +0 -5
  32. arcsecond-3.7.3/docs/.vitepress/config.js +0 -13
  33. arcsecond-3.7.3/docs/img/logo-circle.png +0 -0
  34. arcsecond-3.7.3/docs/index.md +0 -156
  35. arcsecond-3.7.3/docs/install.md +0 -67
  36. {arcsecond-3.7.3 → arcsecond-3.8.1}/.docker/Dockerfile_postgres +0 -0
  37. {arcsecond-3.7.3 → arcsecond-3.8.1}/.docker/Dockerfile_redis +0 -0
  38. {arcsecond-3.7.3 → arcsecond-3.8.1}/.github/dependabot.yml +0 -0
  39. {arcsecond-3.7.3 → arcsecond-3.8.1}/.gitignore +0 -0
  40. {arcsecond-3.7.3 → arcsecond-3.8.1}/LICENSE +0 -0
  41. {arcsecond-3.7.3 → arcsecond-3.8.1}/Makefile +0 -0
  42. {arcsecond-3.7.3 → arcsecond-3.8.1}/README.md +0 -0
  43. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/__version__.py +0 -0
  44. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/api/config.py +0 -0
  45. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/api/constants.py +0 -0
  46. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/__init__.py +0 -0
  47. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/auth.py +0 -0
  48. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/resources.py +0 -0
  49. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/__init__.py +0 -0
  50. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/allskycameraimages/__init__.py +0 -0
  51. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/allskycameraimages/context.py +0 -0
  52. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/allskycameraimages/errors.py +0 -0
  53. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/allskycameraimages/uploader.py +0 -0
  54. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/allskycameraimages/utils.py +0 -0
  55. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/constants.py +0 -0
  56. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/context.py +0 -0
  57. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  58. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  59. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  60. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  61. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  62. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/errors.py +0 -0
  63. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/logger.py +0 -0
  64. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/uploader.py +0 -0
  65. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/utils.py +0 -0
  66. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploader/walker.py +0 -0
  67. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/cloud/uploads.py +0 -0
  68. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/errors.py +0 -0
  69. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/__init__.py +0 -0
  70. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/checks.py +0 -0
  71. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/constants.py +0 -0
  72. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/docker/__init__.py +0 -0
  73. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/docker/constants.py +0 -0
  74. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/docker/containers.py +0 -0
  75. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/docker/images.py +0 -0
  76. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/docker/utils.py +0 -0
  77. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/keygen/__init__.py +0 -0
  78. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/keygen/client.py +0 -0
  79. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/keygen/utils.py +0 -0
  80. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/local.py +0 -0
  81. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/main.py +0 -0
  82. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/postgres/init-db.sh +0 -0
  83. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/setup.py +0 -0
  84. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/utils.py +0 -0
  85. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/hosting/validation.py +0 -0
  86. {arcsecond-3.7.3 → arcsecond-3.8.1}/arcsecond/options.py +0 -0
  87. {arcsecond-3.7.3/tests → arcsecond-3.8.1/arcsecond/webcam}/__init__.py +0 -0
  88. {arcsecond-3.7.3 → arcsecond-3.8.1}/deploy.sh +0 -0
  89. {arcsecond-3.7.3 → arcsecond-3.8.1}/docs/.vitepress/theme/custom.css +0 -0
  90. {arcsecond-3.7.3 → arcsecond-3.8.1}/docs/.vitepress/theme/index.js +0 -0
  91. {arcsecond-3.7.3 → arcsecond-3.8.1}/examples/example_upload_files.py +0 -0
  92. {arcsecond-3.7.3 → arcsecond-3.8.1}/examples/example_upload_images.py +0 -0
  93. {arcsecond-3.7.3 → arcsecond-3.8.1}/poetry.lock +0 -0
  94. {arcsecond-3.7.3 → arcsecond-3.8.1}/requirements.txt +0 -0
  95. {arcsecond-3.7.3 → arcsecond-3.8.1}/setup.cfg +0 -0
  96. {arcsecond-3.7.3 → arcsecond-3.8.1}/setup.py +0 -0
  97. {arcsecond-3.7.3 → arcsecond-3.8.1}/sonar-project.properties +0 -0
  98. {arcsecond-3.7.3/tests/api → arcsecond-3.8.1/tests}/__init__.py +0 -0
  99. {arcsecond-3.7.3/tests/cloud → arcsecond-3.8.1/tests/api}/__init__.py +0 -0
  100. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/api/test_api.py +0 -0
  101. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/api/test_config.py +0 -0
  102. {arcsecond-3.7.3/tests/cloud/uploader → arcsecond-3.8.1/tests/cloud}/__init__.py +0 -0
  103. {arcsecond-3.7.3/tests/cloud/uploader/allskycameraimages → arcsecond-3.8.1/tests/cloud/uploader}/__init__.py +0 -0
  104. {arcsecond-3.7.3/tests/cloud/uploader/datafiles → arcsecond-3.8.1/tests/cloud/uploader/allskycameraimages}/__init__.py +0 -0
  105. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/cloud/uploader/allskycameraimages/test_context.py +0 -0
  106. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/cloud/uploader/allskycameraimages/test_uploader_full_process.py +0 -0
  107. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  108. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  109. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  110. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  111. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  112. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/conftest.py +0 -0
  113. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/fixtures/file1.fits +0 -0
  114. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/test_cli.py +0 -0
  115. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/test_hosting_local.py +0 -0
  116. {arcsecond-3.7.3 → arcsecond-3.8.1}/tests/utils.py +0 -0
@@ -0,0 +1,28 @@
1
+ name: Deploy Docs
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v5
16
+ - name: Setup node
17
+ uses: actions/setup-node@v5
18
+ with:
19
+ node-version: '24'
20
+ cache: 'npm'
21
+ - run: npm ci
22
+ env:
23
+ NODE_AUTH_TOKEN: ${{ secrets.READ_PACKAGE_TOKEN }}
24
+ CI: true
25
+ - name: Deploy docs
26
+ env:
27
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28
+ run: ./deploy.sh
@@ -22,7 +22,7 @@ jobs:
22
22
  exit 1
23
23
  fi
24
24
 
25
- - uses: actions/checkout@v4
25
+ - uses: actions/checkout@v5
26
26
 
27
27
  - name: Validate pyproject.toml version matches tag
28
28
  run: |
@@ -41,7 +41,7 @@ jobs:
41
41
  PY
42
42
 
43
43
  - name: Set up Python
44
- uses: actions/setup-python@v5
44
+ uses: actions/setup-python@v6
45
45
  with:
46
46
  python-version: '3.12'
47
47
 
@@ -54,4 +54,4 @@ jobs:
54
54
  - name: Publish to PyPI (OIDC)
55
55
  uses: pypa/gh-action-pypi-publish@release/v1
56
56
  with:
57
- packages-dir: dist
57
+ packages-dir: dist
@@ -14,10 +14,10 @@ jobs:
14
14
  python-version: [ '3.10', '3.11', '3.12', '3.13' ]
15
15
 
16
16
  steps:
17
- - uses: actions/checkout@v4
17
+ - uses: actions/checkout@v5
18
18
 
19
19
  - name: Set up Python ${{ matrix.python-version }}
20
- uses: actions/setup-python@v4
20
+ uses: actions/setup-python@v6
21
21
  with:
22
22
  python-version: ${{ matrix.python-version }}
23
23
 
@@ -39,7 +39,7 @@ jobs:
39
39
 
40
40
  - name: Upload coverage artifact (only on 3.12)
41
41
  if: matrix.python-version == '3.12'
42
- uses: actions/upload-artifact@v4
42
+ uses: actions/upload-artifact@v6
43
43
  with:
44
44
  name: coverage
45
45
  path: coverage.xml
@@ -49,10 +49,10 @@ jobs:
49
49
  needs: tests
50
50
 
51
51
  steps:
52
- - uses: actions/checkout@v4
52
+ - uses: actions/checkout@v5
53
53
 
54
54
  - name: Download coverage artifact
55
- uses: actions/download-artifact@v4
55
+ uses: actions/download-artifact@v5
56
56
  with:
57
57
  name: coverage
58
58
  path: .
@@ -65,10 +65,10 @@ jobs:
65
65
  lint:
66
66
  runs-on: ubuntu-latest
67
67
  steps:
68
- - uses: actions/checkout@v4
68
+ - uses: actions/checkout@v5
69
69
 
70
70
  - name: Set up Python
71
- uses: actions/setup-python@v4
71
+ uses: actions/setup-python@v6
72
72
  with:
73
73
  python-version: '3.12'
74
74
 
@@ -87,4 +87,4 @@ jobs:
87
87
 
88
88
  - name: Check imports with isort
89
89
  run: |
90
- isort --check-only --profile black arcsecond tests
90
+ isort --check-only --profile black arcsecond tests
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arcsecond
3
- Version: 3.7.3
3
+ Version: 3.8.1
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,149 @@
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_key = "targets"
10
+ target_writable_fields = (
11
+ "id",
12
+ "pk",
13
+ "object",
14
+ "name",
15
+ "identifier",
16
+ "target_class",
17
+ "mode",
18
+ "color",
19
+ "notes",
20
+ "tags",
21
+ "profile",
22
+ "organisation",
23
+ )
24
+
25
+ def _ensure_iterable(self, values):
26
+ if values is None:
27
+ return None
28
+ if isinstance(values, dict):
29
+ return [values]
30
+ if isinstance(values, (str, int)):
31
+ return [values]
32
+ return list(values)
33
+
34
+ def _target_payload_identity(self, target):
35
+ if target.get("id") is not None:
36
+ return ("id", target["id"])
37
+ if target.get("pk") is not None:
38
+ return ("pk", target["pk"])
39
+ return (
40
+ "composite",
41
+ target.get("target_class"),
42
+ target.get("identifier"),
43
+ target.get("name"),
44
+ target.get("mode"),
45
+ )
46
+
47
+ def _normalise_target_payloads(self, targets):
48
+ values = self._ensure_iterable(targets)
49
+ if values is None:
50
+ return None
51
+
52
+ payloads = []
53
+ for target in values:
54
+ if not isinstance(target, dict):
55
+ raise ArcsecondError(
56
+ "Target list helpers expect target payload dictionaries, not scalar IDs or UUIDs. "
57
+ "Pass dictionaries such as `plan_target_payload(...).payload` or target objects returned "
58
+ "by `api.targets.read()/list()/upsert()`."
59
+ )
60
+
61
+ payload = {
62
+ key: value
63
+ for key, value in target.items()
64
+ if key in self.target_writable_fields and value is not None
65
+ }
66
+ if not payload:
67
+ raise ArcsecondError(
68
+ "Target dictionaries must include at least one writable target field."
69
+ )
70
+
71
+ payloads.append(payload)
72
+ return payloads
73
+
74
+ def _build_payload(self, json=None, targets=None, target_key=None, **fields):
75
+ payload = super()._build_payload(json=json, **fields) or {}
76
+ normalised_targets = self._normalise_target_payloads(targets)
77
+ if normalised_targets is not None:
78
+ payload[target_key or self.target_relation_key] = normalised_targets
79
+ return payload or None
80
+
81
+ def create(self, json=None, targets=None, target_key=None, **fields):
82
+ payload = self._build_payload(
83
+ json=json, targets=targets, target_key=target_key, **fields
84
+ )
85
+ return ArcsecondAPIEndpoint.create(self, json=payload)
86
+
87
+ def update(self, id_name_uuid, json=None, targets=None, target_key=None, **fields):
88
+ payload = self._build_payload(
89
+ json=json, targets=targets, target_key=target_key, **fields
90
+ )
91
+ return ArcsecondAPIEndpoint.update(self, id_name_uuid, json=payload)
92
+
93
+ def upsert(self, match_field="name", json=None, targets=None, target_key=None, **fields):
94
+ payload = self._build_payload(
95
+ json=json, targets=targets, target_key=target_key, **fields
96
+ )
97
+ return super().upsert(match_field=match_field, json=payload)
98
+
99
+ def _read_target_refs(self, target_list, target_key=None):
100
+ key = target_key or self.target_relation_key
101
+ raw_targets = (target_list or {}).get(key, [])
102
+ refs = self._normalise_target_payloads(raw_targets) or []
103
+ return key, refs
104
+
105
+ def set_targets(self, id_name_uuid, targets, target_key=None):
106
+ target_key = target_key or self.target_relation_key
107
+ return self.update(id_name_uuid, **{target_key: self._normalise_target_payloads(targets)})
108
+
109
+ def clear_targets(self, id_name_uuid, target_key=None):
110
+ return self.set_targets(id_name_uuid, [], target_key=target_key)
111
+
112
+ def add_targets(self, id_name_uuid, targets, target_key=None):
113
+ target_list, error = self.read(id_name_uuid)
114
+ if error:
115
+ return None, error
116
+
117
+ key, current_refs = self._read_target_refs(target_list, target_key=target_key)
118
+ current_identities = {
119
+ self._target_payload_identity(target): target for target in current_refs
120
+ }
121
+ for target in self._normalise_target_payloads(targets) or []:
122
+ identity = self._target_payload_identity(target)
123
+ if identity not in current_identities:
124
+ current_refs.append(target)
125
+ current_identities[identity] = target
126
+ return self.update(id_name_uuid, **{key: current_refs})
127
+
128
+ def remove_targets(self, id_name_uuid, targets, target_key=None):
129
+ target_list, error = self.read(id_name_uuid)
130
+ if error:
131
+ return None, error
132
+
133
+ key, current_refs = self._read_target_refs(target_list, target_key=target_key)
134
+ refs_to_remove = {
135
+ self._target_payload_identity(target)
136
+ for target in (self._normalise_target_payloads(targets) or [])
137
+ }
138
+ remaining_refs = [
139
+ ref
140
+ for ref in current_refs
141
+ if self._target_payload_identity(ref) not in refs_to_remove
142
+ ]
143
+ return self.update(id_name_uuid, **{key: remaining_refs})
144
+
145
+ def add_target(self, id_name_uuid, target, target_key=None):
146
+ return self.add_targets(id_name_uuid, [target], target_key=target_key)
147
+
148
+ def remove_target(self, id_name_uuid, target, target_key=None):
149
+ 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