arcsecond 3.16.0__tar.gz → 3.16.2__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 (106) hide show
  1. {arcsecond-3.16.0 → arcsecond-3.16.2}/PKG-INFO +2 -2
  2. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/backups.py +22 -3
  3. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/database.py +20 -4
  4. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/docker/docker-compose.yml +23 -3
  5. arcsecond-3.16.2/arcsecond/hosting/local.py +468 -0
  6. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/sources/opencv.py +30 -3
  7. {arcsecond-3.16.0 → arcsecond-3.16.2}/pyproject.toml +1 -1
  8. arcsecond-3.16.2/tests/test_hosting_local.py +412 -0
  9. arcsecond-3.16.2/tests/test_imagesources_webcam.py +140 -0
  10. {arcsecond-3.16.0 → arcsecond-3.16.2}/uv.lock +1 -1
  11. arcsecond-3.16.0/arcsecond/hosting/local.py +0 -170
  12. arcsecond-3.16.0/tests/test_hosting_local.py +0 -115
  13. {arcsecond-3.16.0 → arcsecond-3.16.2}/.docker/Dockerfile_postgres +0 -0
  14. {arcsecond-3.16.0 → arcsecond-3.16.2}/.docker/Dockerfile_redis +0 -0
  15. {arcsecond-3.16.0 → arcsecond-3.16.2}/.github/dependabot.yml +0 -0
  16. {arcsecond-3.16.0 → arcsecond-3.16.2}/.github/workflows/pythonpublish.yml +0 -0
  17. {arcsecond-3.16.0 → arcsecond-3.16.2}/.github/workflows/tests.yml +0 -0
  18. {arcsecond-3.16.0 → arcsecond-3.16.2}/.gitignore +0 -0
  19. {arcsecond-3.16.0 → arcsecond-3.16.2}/LICENSE +0 -0
  20. {arcsecond-3.16.0 → arcsecond-3.16.2}/Makefile +0 -0
  21. {arcsecond-3.16.0 → arcsecond-3.16.2}/README.md +0 -0
  22. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/__init__.py +0 -0
  23. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/__version__.py +0 -0
  24. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/alpaca/__init__.py +0 -0
  25. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/alpaca/commands.py +0 -0
  26. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/alpaca/dome_probe.py +0 -0
  27. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/api/__init__.py +0 -0
  28. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/api/config.py +0 -0
  29. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/api/constants.py +0 -0
  30. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/api/endpoint.py +0 -0
  31. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/api/main.py +0 -0
  32. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/api/resources.py +0 -0
  33. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cli.py +0 -0
  34. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/__init__.py +0 -0
  35. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/auth.py +0 -0
  36. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/resources.py +0 -0
  37. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/__init__.py +0 -0
  38. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/constants.py +0 -0
  39. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/context.py +0 -0
  40. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  41. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  42. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  43. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  44. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  45. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/errors.py +0 -0
  46. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/logger.py +0 -0
  47. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/uploader.py +0 -0
  48. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/utils.py +0 -0
  49. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploader/walker.py +0 -0
  50. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/cloud/uploads.py +0 -0
  51. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/errors.py +0 -0
  52. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/__init__.py +0 -0
  53. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/checks.py +0 -0
  54. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/constants.py +0 -0
  55. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/docker/__init__.py +0 -0
  56. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/docker/constants.py +0 -0
  57. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/docker/containers.py +0 -0
  58. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/docker/images.py +0 -0
  59. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/docker/utils.py +0 -0
  60. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/keygen/__init__.py +0 -0
  61. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/keygen/client.py +0 -0
  62. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/keygen/utils.py +0 -0
  63. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/main.py +0 -0
  64. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/postgres/init-db.sh +0 -0
  65. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/setup.py +0 -0
  66. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/utils.py +0 -0
  67. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/hosting/validation.py +0 -0
  68. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/__init__.py +0 -0
  69. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/commands.py +0 -0
  70. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/proxy.py +0 -0
  71. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/registry.py +0 -0
  72. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/sources/__init__.py +0 -0
  73. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/sources/base.py +0 -0
  74. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/sources/filewatch.py +0 -0
  75. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/sources/network.py +0 -0
  76. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/imagesources/store.py +0 -0
  77. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/options.py +0 -0
  78. {arcsecond-3.16.0 → arcsecond-3.16.2}/arcsecond/targets.py +0 -0
  79. {arcsecond-3.16.0 → arcsecond-3.16.2}/examples/example_upload_files.py +0 -0
  80. {arcsecond-3.16.0 → arcsecond-3.16.2}/requirements.txt +0 -0
  81. {arcsecond-3.16.0 → arcsecond-3.16.2}/setup.cfg +0 -0
  82. {arcsecond-3.16.0 → arcsecond-3.16.2}/setup.py +0 -0
  83. {arcsecond-3.16.0 → arcsecond-3.16.2}/sonar-project.properties +0 -0
  84. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/__init__.py +0 -0
  85. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/api/__init__.py +0 -0
  86. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/api/test_api.py +0 -0
  87. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/api/test_api_endpoint.py +0 -0
  88. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/api/test_config.py +0 -0
  89. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/api/test_targets.py +0 -0
  90. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/__init__.py +0 -0
  91. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/uploader/__init__.py +0 -0
  92. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/uploader/datafiles/__init__.py +0 -0
  93. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  94. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  95. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  96. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  97. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  98. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/conftest.py +0 -0
  99. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/fixtures/file1.fits +0 -0
  100. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/test_alpaca_probe.py +0 -0
  101. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/test_cli.py +0 -0
  102. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/test_hosting_database.py +0 -0
  103. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/test_imagesources_netcam.py +0 -0
  104. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/test_imagesources_store.py +0 -0
  105. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/test_targets_planning.py +0 -0
  106. {arcsecond-3.16.0 → arcsecond-3.16.2}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: arcsecond
3
- Version: 3.16.0
3
+ Version: 3.16.2
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
@@ -20,9 +20,18 @@ API_CONTAINER = "arcsecond-api"
20
20
  WORKER_CONTAINER = "arcsecond-worker"
21
21
  BEAT_CONTAINER = "arcsecond-beat"
22
22
  WEB_CONTAINER = "arcsecond-web"
23
+ # Optional service: absent on most installs. The stop loop skips it when it
24
+ # is not running, and the docker-start fallback tolerates the missing name.
25
+ ALERTS_CONTAINER = "arcsecond-alerts"
23
26
 
24
27
  # Services to stop before wiping the DB. db is kept up so we can talk to it.
25
- SERVICES_TO_STOP = [API_CONTAINER, WORKER_CONTAINER, BEAT_CONTAINER, WEB_CONTAINER]
28
+ SERVICES_TO_STOP = [
29
+ API_CONTAINER,
30
+ WORKER_CONTAINER,
31
+ ALERTS_CONTAINER,
32
+ BEAT_CONTAINER,
33
+ WEB_CONTAINER,
34
+ ]
26
35
 
27
36
  BACKUP_PATTERN = re.compile(r"^backup-(\d{8}-\d{6})\.sql\.gz$")
28
37
  PRE_RESTORE_PREFIX = "pre-restore-"
@@ -322,7 +331,15 @@ def _run_destination_backups_command(*args, timeout=60):
322
331
  return None
323
332
  try:
324
333
  out = subprocess.run(
325
- ["docker", "exec", API_CONTAINER, "python", "manage.py", "destination_backups", *args],
334
+ [
335
+ "docker",
336
+ "exec",
337
+ API_CONTAINER,
338
+ "python",
339
+ "manage.py",
340
+ "destination_backups",
341
+ *args,
342
+ ],
326
343
  capture_output=True,
327
344
  text=True,
328
345
  check=False,
@@ -358,7 +375,9 @@ def _remote_only_items(items, remote_names):
358
375
  if not m or name in local_names:
359
376
  continue
360
377
  try:
361
- ts = datetime.strptime(m.group(1), "%Y%m%d-%H%M%S").replace(tzinfo=timezone.utc)
378
+ ts = datetime.strptime(m.group(1), "%Y%m%d-%H%M%S").replace(
379
+ tzinfo=timezone.utc
380
+ )
362
381
  except ValueError:
363
382
  continue
364
383
  result.append((name, ts))
@@ -37,6 +37,21 @@ DB_CONTAINER = "arcsecond-db"
37
37
  # directory, so recreating it would be downtime for nothing.
38
38
  SERVICES_TO_RECREATE = ["backend", "worker", "beat"]
39
39
 
40
+
41
+ def _services_to_recreate():
42
+ """The optional alerts consumer joins the recreate list only when the
43
+ operator's compose file actually carries it — naming an unknown service
44
+ makes `docker compose up` fail outright on installs without the block."""
45
+ services = list(SERVICES_TO_RECREATE)
46
+ compose_path = Path.cwd() / "docker-compose.yml"
47
+ try:
48
+ if "# >>> arcsecond:alerts" in compose_path.read_text(encoding="utf-8"):
49
+ services.append("alerts")
50
+ except OSError:
51
+ pass
52
+ return services
53
+
54
+
40
55
  ENV_KEY = "POSTGRES_PASSWORD"
41
56
 
42
57
  # The .env in the install directory does double duty: compose also reads it to
@@ -248,7 +263,7 @@ def set_password_cmd(password, show, no_restart, dry_run):
248
263
  click.echo(f" would run ALTER ROLE \"{db_user}\" WITH PASSWORD '***'")
249
264
  if not no_restart:
250
265
  click.echo(
251
- f" would recreate {', '.join(SERVICES_TO_RECREATE)} "
266
+ f" would recreate {', '.join(_services_to_recreate())} "
252
267
  "(docker compose up -d --force-recreate)"
253
268
  )
254
269
  sys.exit(0)
@@ -324,21 +339,22 @@ def set_password_cmd(password, show, no_restart, dry_run):
324
339
 
325
340
  click.echo(click.style("Password changed and verified.", fg="green"))
326
341
 
342
+ services_to_recreate = _services_to_recreate()
327
343
  if no_restart:
328
344
  click.echo(
329
345
  "\nSkipping the restart, as asked. The running containers still hold the\n"
330
346
  "old password and will fail on their next reconnect. Apply it with:\n"
331
- f" docker compose up -d --force-recreate {' '.join(SERVICES_TO_RECREATE)}"
347
+ f" docker compose up -d --force-recreate {' '.join(services_to_recreate)}"
332
348
  )
333
349
  else:
334
- click.echo(f"Recreating {', '.join(SERVICES_TO_RECREATE)}...")
350
+ click.echo(f"Recreating {', '.join(services_to_recreate)}...")
335
351
  cmd = [
336
352
  "docker",
337
353
  "compose",
338
354
  "up",
339
355
  "-d",
340
356
  "--force-recreate",
341
- *SERVICES_TO_RECREATE,
357
+ *services_to_recreate,
342
358
  ]
343
359
  click.echo(click.style(f"$ {' '.join(cmd)}", fg="cyan"))
344
360
  recreate = subprocess.run(cmd, capture_output=True, text=True, check=False)
@@ -1,6 +1,6 @@
1
1
  # === Arcsecond.local config. ===
2
2
  # *** DO NOT change any name or port number. ***
3
- # Version 6.2
3
+ # Version 6.3
4
4
 
5
5
  services:
6
6
  # Database (PostgresQL)
@@ -105,7 +105,9 @@ services:
105
105
  restart: unless-stopped
106
106
  ports: [ "8900:8900" ]
107
107
 
108
- # Arcsecond webapp.
108
+ # Arcsecond webapp. Served on 5555 only: a self-hosted install is
109
+ # organisation-based and therefore always a portal, so the second port that
110
+ # used to be here (5577) served the very same app.
109
111
  web:
110
112
  image: ghcr.io/arcsecond-io/arcsecond-web:latest
111
113
  container_name: arcsecond-web
@@ -115,7 +117,25 @@ services:
115
117
  condition: service_started
116
118
  ports:
117
119
  - "5555:5555"
118
- - "5577:5577"
120
+
121
+ # >>> arcsecond:alerts
122
+ # Arcsecond transient-alerts consumer (optional).
123
+ # Long-lived Kafka client for NASA GCN. Outbound TLS to kafka.gcn.nasa.gov
124
+ # and auth.gcn.nasa.gov only — nothing listens. Requires GCN_CONSUMER_CLIENT_ID /
125
+ # GCN_CONSUMER_CLIENT_SECRET in .env; idles harmlessly when they are absent.
126
+ # Single instance only — do not scale it.
127
+ alerts:
128
+ image: ghcr.io/arcsecond-io/arcsecond-api:latest
129
+ container_name: arcsecond-alerts
130
+ restart: unless-stopped
131
+ entrypoint: /home/app/entrypoint-alerts.sh
132
+ depends_on:
133
+ backend:
134
+ condition: service_healthy
135
+ env_file:
136
+ - .env
137
+ stop_grace_period: 30s
138
+ # <<< arcsecond:alerts
119
139
 
120
140
  volumes:
121
141
  arcsecond_postgres_data:
@@ -0,0 +1,468 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ from importlib import resources
5
+ from pathlib import Path, PurePosixPath, PureWindowsPath
6
+
7
+ import click
8
+
9
+ from arcsecond.options import basic_options
10
+
11
+ from .utils import (
12
+ _get_encryption_key,
13
+ _get_random_postgres_password,
14
+ _get_random_secret_key,
15
+ )
16
+
17
+ ENV_FILENAME = ".env"
18
+
19
+ # Stable across installs — operators connect with this username when running
20
+ # manual psql / pg_dump commands. The actual security boundary is the password
21
+ # (generated per-install) and the network exposure (localhost-only).
22
+ POSTGRES_USER = "arcsecond_docker"
23
+ POSTGRES_DB = "arcsecond_docker"
24
+
25
+ # Services an installation can opt out of. Each one lives in the packaged
26
+ # docker-compose.yml between "# >>> arcsecond:<name>" / "# <<< arcsecond:<name>"
27
+ # marker lines, and is added or removed by splicing those blocks as text —
28
+ # never by parsing and re-emitting YAML, which would destroy the compose
29
+ # file's comments (they are operator documentation).
30
+ OPTIONAL_SERVICES = {
31
+ "alerts": "transient-alerts (ToO)",
32
+ }
33
+
34
+ # One .env key records every answered yes/no, e.g. "alerts:yes". A service
35
+ # absent from the value has never been decided, so setup may still ask —
36
+ # that distinction is what lets a future CLI version introduce a new
37
+ # optional service without re-asking about the old ones.
38
+ OPTIONAL_SERVICES_ENV_KEY = "ARCSECOND_OPTIONAL_SERVICES"
39
+
40
+ GCN_ENV_COMMENT = (
41
+ "# NASA GCN credentials (optional — used by the transient-alerts service):"
42
+ " see https://docs.arcsecond.io/local/transient-alerts"
43
+ )
44
+
45
+
46
+ # Compose reads .env itself, and a backslash in a value is an escape sequence
47
+ # there — a Windows default like C:\Users\Obs\Data reaches the daemon mangled
48
+ # (\U, \O, \D swallowed), and the bind mount then points somewhere that does
49
+ # not exist. Windows accepts forward slashes in every path API and Docker
50
+ # accepts C:/Users/Obs/Data, so we write the path posix-style on every
51
+ # platform. Picked at import time rather than branching inside expand_path so
52
+ # tests can exercise the Windows flavour from any host.
53
+ _PATH_FLAVOUR = PureWindowsPath if os.name == "nt" else PurePosixPath
54
+
55
+
56
+ def expand_path(value: str) -> str:
57
+ expanded = os.path.expandvars(value)
58
+ expanded = os.path.expanduser(expanded)
59
+ # A backslash is a legal filename character on POSIX, so the conversion
60
+ # must only ever happen with the Windows flavour.
61
+ return _PATH_FLAVOUR(expanded).as_posix()
62
+
63
+
64
+ def prompt_shared_data_path() -> str:
65
+ default_path = str(Path.cwd())
66
+
67
+ print("SHARED_DATA_PATH configuration")
68
+ print(f"Default (current folder): {default_path}")
69
+ user_input = input(
70
+ "Press Enter to accept, or type a different path (supports ~ and $VARS): "
71
+ ).strip()
72
+
73
+ chosen = user_input if user_input else default_path
74
+ return expand_path(chosen)
75
+
76
+
77
+ # Values are callables so nothing is computed — and no prompt fires — for a
78
+ # key that is already present in an existing .env.
79
+ REQUIRED_ENV_PROVIDERS = {
80
+ "SECRET_KEY": lambda: _get_random_secret_key(),
81
+ "AUTH_JWT_SIGNING_KEY": lambda: _get_random_secret_key(),
82
+ "AGENT_JWT_SIGNING_KEY": lambda: _get_random_secret_key(),
83
+ "FIELD_ENCRYPTION_KEY": lambda: _get_encryption_key(),
84
+ "SHARED_DATA_PATH": lambda: prompt_shared_data_path(),
85
+ "POSTGRES_USER": lambda: POSTGRES_USER,
86
+ # Per-install random; never overwritten on repeat runs (see write_env_file).
87
+ # Postgres only reads this on first container boot to bootstrap the role,
88
+ # so the .env value and the live DB password must stay in sync — that's
89
+ # why we never regenerate it after the .env exists.
90
+ "POSTGRES_PASSWORD": lambda: _get_random_postgres_password(),
91
+ "POSTGRES_DB": lambda: POSTGRES_DB,
92
+ # Empty placeholders: the operator pastes their own GCN credentials here.
93
+ "GCN_CONSUMER_CLIENT_ID": lambda: "",
94
+ "GCN_CONSUMER_CLIENT_SECRET": lambda: "",
95
+ }
96
+
97
+
98
+ def _parse_env_keys(lines):
99
+ keys = set()
100
+ for line in lines:
101
+ stripped = line.strip()
102
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
103
+ continue
104
+ key, _ = stripped.split("=", 1)
105
+ keys.add(key.strip())
106
+ return keys
107
+
108
+
109
+ def _format_env_line(key, value):
110
+ if key == "SHARED_DATA_PATH":
111
+ return f'{key}="{value}"'
112
+ return f"{key}={value}"
113
+
114
+
115
+ def write_env_file():
116
+ env_path = Path.cwd() / ENV_FILENAME
117
+ ordered_required_keys = [
118
+ "SECRET_KEY",
119
+ "AUTH_JWT_SIGNING_KEY",
120
+ "AGENT_JWT_SIGNING_KEY",
121
+ "FIELD_ENCRYPTION_KEY",
122
+ "SHARED_DATA_PATH",
123
+ "POSTGRES_USER",
124
+ "POSTGRES_PASSWORD",
125
+ "POSTGRES_DB",
126
+ "GCN_CONSUMER_CLIENT_ID",
127
+ "GCN_CONSUMER_CLIENT_SECRET",
128
+ ]
129
+
130
+ def env_lines_for(keys):
131
+ lines = []
132
+ gcn_comment_pending = any(key.startswith("GCN_CONSUMER_") for key in keys)
133
+ for key in keys:
134
+ if key.startswith("GCN_CONSUMER_") and gcn_comment_pending:
135
+ # Once, before whichever GCN key lands first — an operator may
136
+ # have hand-added one of the two already.
137
+ lines.append(GCN_ENV_COMMENT)
138
+ gcn_comment_pending = False
139
+ lines.append(_format_env_line(key, REQUIRED_ENV_PROVIDERS[key]()))
140
+ return lines
141
+
142
+ if env_path.exists():
143
+ existing_lines = env_path.read_text(encoding="utf-8").splitlines()
144
+ existing_keys = _parse_env_keys(existing_lines)
145
+ missing_keys = [
146
+ key for key in ordered_required_keys if key not in existing_keys
147
+ ]
148
+
149
+ if not missing_keys:
150
+ print(f"{ENV_FILENAME} already contains all required keys.")
151
+ return
152
+
153
+ if existing_lines and existing_lines[-1].strip():
154
+ existing_lines.append("")
155
+ existing_lines.extend(env_lines_for(missing_keys))
156
+
157
+ env_path.write_text("\n".join(existing_lines) + "\n", encoding="utf-8")
158
+ print(
159
+ f"Updated {ENV_FILENAME} at: {env_path} (added keys: {', '.join(missing_keys)})"
160
+ )
161
+ return
162
+
163
+ env_contents = "\n".join(env_lines_for(ordered_required_keys))
164
+ env_path.write_text(env_contents + "\n", encoding="utf-8")
165
+ print(f"Wrote {ENV_FILENAME} to: {env_path}")
166
+
167
+
168
+ def _read_optional_service_decisions(env_path):
169
+ """The recorded yes/no answers, as {name: bool}. Unknown names are kept
170
+ out of the dict but preserved in the file (see the recorder)."""
171
+ decisions = {}
172
+ if not env_path.exists():
173
+ return decisions
174
+ for line in env_path.read_text(encoding="utf-8").splitlines():
175
+ stripped = line.strip()
176
+ if not stripped.startswith(OPTIONAL_SERVICES_ENV_KEY + "="):
177
+ continue
178
+ _, _, value = stripped.partition("=")
179
+ for token in value.split(","):
180
+ token = token.strip()
181
+ if not token:
182
+ continue
183
+ name, sep, verdict = token.partition(":")
184
+ if not sep or verdict not in ("yes", "no"):
185
+ print(
186
+ f"Ignoring malformed token '{token}' in {OPTIONAL_SERVICES_ENV_KEY}."
187
+ )
188
+ continue
189
+ if name in OPTIONAL_SERVICES:
190
+ decisions[name] = verdict == "yes"
191
+ return decisions
192
+
193
+
194
+ def _record_optional_service_decision(env_path, name, enabled):
195
+ """Rewrite only this service's token; tokens for services this CLI
196
+ version does not know about survive verbatim (downgrades happen)."""
197
+ verdict = f"{name}:{'yes' if enabled else 'no'}"
198
+ lines = (
199
+ env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else []
200
+ )
201
+ for index, line in enumerate(lines):
202
+ stripped = line.strip()
203
+ if not stripped.startswith(OPTIONAL_SERVICES_ENV_KEY + "="):
204
+ continue
205
+ _, _, value = stripped.partition("=")
206
+ tokens = [t.strip() for t in value.split(",") if t.strip()]
207
+ tokens = [t for t in tokens if t.partition(":")[0] != name]
208
+ tokens.append(verdict)
209
+ lines[index] = f"{OPTIONAL_SERVICES_ENV_KEY}={','.join(sorted(tokens))}"
210
+ break
211
+ else:
212
+ if lines and lines[-1].strip():
213
+ lines.append("")
214
+ lines.append(f"{OPTIONAL_SERVICES_ENV_KEY}={verdict}")
215
+ env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
216
+
217
+
218
+ def _optional_service_markers(name):
219
+ return f"# >>> arcsecond:{name}", f"# <<< arcsecond:{name}"
220
+
221
+
222
+ def _extract_optional_service_block(packaged_text, name):
223
+ begin, end = _optional_service_markers(name)
224
+ lines = packaged_text.splitlines()
225
+ begin_index = next(
226
+ (i for i, line in enumerate(lines) if line.strip() == begin), None
227
+ )
228
+ end_index = next((i for i, line in enumerate(lines) if line.strip() == end), None)
229
+ if begin_index is None or end_index is None or end_index < begin_index:
230
+ return None
231
+ return lines[begin_index : end_index + 1]
232
+
233
+
234
+ def _with_trailing_newline_like(lines, original_text):
235
+ return "\n".join(lines) + ("\n" if original_text.endswith("\n") else "")
236
+
237
+
238
+ def _strip_optional_service_block(text, name):
239
+ begin, end = _optional_service_markers(name)
240
+ lines = text.splitlines()
241
+ begin_index = next(
242
+ (i for i, line in enumerate(lines) if line.strip() == begin), None
243
+ )
244
+ end_index = next((i for i, line in enumerate(lines) if line.strip() == end), None)
245
+ if begin_index is None or end_index is None or end_index < begin_index:
246
+ return text
247
+ del lines[begin_index : end_index + 1]
248
+ # Drop the blank separator the block carried, so strip(splice(x)) == x.
249
+ if begin_index < len(lines) and not lines[begin_index].strip():
250
+ del lines[begin_index]
251
+ return _with_trailing_newline_like(lines, text)
252
+
253
+
254
+ def _splice_optional_service_block(current_text, packaged_text, name):
255
+ """Insert the packaged block before the top-level "volumes:" line.
256
+ Returns the new text, current_text if the block is already there,
257
+ or None when there is no anchor to splice against."""
258
+ begin, _ = _optional_service_markers(name)
259
+ lines = current_text.splitlines()
260
+ if any(line.strip() == begin for line in lines):
261
+ return current_text
262
+ block = _extract_optional_service_block(packaged_text, name)
263
+ if block is None:
264
+ return None
265
+ anchor = next(
266
+ (
267
+ i
268
+ for i, line in enumerate(lines)
269
+ if line.rstrip() == "volumes:" and not line[:1].isspace()
270
+ ),
271
+ None,
272
+ )
273
+ if anchor is None:
274
+ return None
275
+ new_lines = lines[:anchor] + block + [""] + lines[anchor:]
276
+ return _with_trailing_newline_like(new_lines, current_text)
277
+
278
+
279
+ VERSION_HEADER_RE = re.compile(r"^# Version .+$", flags=re.MULTILINE)
280
+
281
+
282
+ def _compose_version(text):
283
+ match = re.search(r"^# Version (.+)$", text, flags=re.MULTILINE)
284
+ return match.group(1).strip() if match else None
285
+
286
+
287
+ def _reconcile_version_header(current_text, expected_text):
288
+ """When the *only* remaining difference is the '# Version X.Y' comment
289
+ line, adopt the packaged one — the header is ours, not operator content.
290
+ Without this, every pre-existing install would trail one version behind
291
+ forever and collect a spurious docker-compose.latest.yml on every run.
292
+ Returns the updated text, or None when the files differ beyond it."""
293
+ current_match = VERSION_HEADER_RE.search(current_text)
294
+ expected_match = VERSION_HEADER_RE.search(expected_text)
295
+ if current_match is None or expected_match is None:
296
+ return None
297
+ updated = (
298
+ current_text[: current_match.start()]
299
+ + expected_match.group(0)
300
+ + current_text[current_match.end() :]
301
+ )
302
+ return updated if updated == expected_text else None
303
+
304
+
305
+ def write_docker_compose_file(
306
+ enabled_services=frozenset(), removed_services=frozenset()
307
+ ) -> Path:
308
+ """
309
+ Materialise the packaged docker-compose.yml in the current directory.
310
+
311
+ The expected content is the packaged file minus the marker-delimited
312
+ blocks of optional services that are not enabled. Three cases:
313
+ 1. No file present → write the expected content.
314
+ 2. File present and identical to the expected content → no-op.
315
+ 3. File present but different → splice enabled optional services in
316
+ (idempotently, before the top-level "volumes:" line) and remove
317
+ explicitly-disabled ones; if the file still differs — it carries
318
+ local customisations — leave it untouched and drop the expected
319
+ version next to it as docker-compose.latest.yml so the operator
320
+ can diff and merge intentionally.
321
+
322
+ Works from any CWD and when installed from a wheel/sdist.
323
+ """
324
+ dest = Path.cwd() / "docker-compose.yml"
325
+
326
+ # arcsecond/hosting/docker/docker-compose.yml
327
+ compose = resources.files("arcsecond.hosting.docker").joinpath("docker-compose.yml")
328
+
329
+ with compose.open("rb") as src:
330
+ packaged_text = src.read().decode("utf-8")
331
+
332
+ expected_text = packaged_text
333
+ for name in OPTIONAL_SERVICES:
334
+ if name not in enabled_services:
335
+ expected_text = _strip_optional_service_block(expected_text, name)
336
+ expected_content = expected_text.encode("utf-8")
337
+
338
+ if not dest.exists():
339
+ dest.write_bytes(expected_content)
340
+ print(f"Wrote docker-compose.yml to: {dest}")
341
+ return dest
342
+
343
+ if dest.read_bytes() == expected_content:
344
+ print("docker-compose.yml is already up to date.")
345
+ return dest
346
+
347
+ current_text = dest.read_text(encoding="utf-8")
348
+ changes = []
349
+ unspliceable = []
350
+
351
+ for name in sorted(enabled_services):
352
+ spliced = _splice_optional_service_block(current_text, packaged_text, name)
353
+ if spliced is None:
354
+ unspliceable.append(name)
355
+ elif spliced != current_text:
356
+ current_text = spliced
357
+ changes.append(f"added the optional '{name}' service")
358
+
359
+ for name in sorted(removed_services):
360
+ stripped = _strip_optional_service_block(current_text, name)
361
+ if stripped != current_text:
362
+ current_text = stripped
363
+ changes.append(f"removed the optional '{name}' service")
364
+
365
+ if current_text.encode("utf-8") != expected_content:
366
+ reconciled = _reconcile_version_header(current_text, expected_text)
367
+ if reconciled is not None:
368
+ current_text = reconciled
369
+ changes.append(
370
+ f"updated the version header to {_compose_version(expected_text)}"
371
+ )
372
+
373
+ if changes:
374
+ dest.write_text(current_text, encoding="utf-8")
375
+ print(f"Updated docker-compose.yml: {', '.join(changes)}.")
376
+
377
+ if current_text.encode("utf-8") == expected_content:
378
+ return dest
379
+
380
+ current_version = _compose_version(current_text)
381
+ yours = f"Version {current_version}" if current_version else "no Version header"
382
+ latest = dest.with_name("docker-compose.latest.yml")
383
+ latest.write_bytes(expected_content)
384
+ messages = [
385
+ "docker-compose.yml differs from the packaged version "
386
+ f"(yours: {yours}, packaged: Version {_compose_version(expected_text)}); "
387
+ f"leaving it untouched and writing the latest packaged copy to: {latest}"
388
+ ]
389
+ for name in unspliceable:
390
+ messages.append(
391
+ f"Could not find a top-level 'volumes:' line to splice the "
392
+ f"'{name}' service into — merge it from {latest.name} by hand."
393
+ )
394
+ print("\n".join(messages))
395
+ return dest
396
+
397
+
398
+ def _stdin_is_interactive():
399
+ return sys.stdin.isatty()
400
+
401
+
402
+ def _resolve_optional_services(env_path, flags):
403
+ """Fold the explicit flags, the recorded decisions and (on a TTY) the
404
+ operator's answers into (enabled, removed, decisions_to_record)."""
405
+ decisions = _read_optional_service_decisions(env_path)
406
+ enabled, removed, to_record = set(), set(), []
407
+ prompts_skipped = False
408
+
409
+ for name, label in OPTIONAL_SERVICES.items():
410
+ flag = flags.get(name)
411
+ if flag is not None:
412
+ # An explicit flag always wins and rewrites the recorded answer.
413
+ (enabled if flag else removed).add(name)
414
+ to_record.append((name, flag))
415
+ elif name in decisions:
416
+ if decisions[name]:
417
+ enabled.add(name)
418
+ elif _stdin_is_interactive():
419
+ answer = click.confirm(
420
+ f"Include the optional {label} service in docker-compose.yml?",
421
+ default=False,
422
+ )
423
+ if answer:
424
+ enabled.add(name)
425
+ to_record.append((name, answer))
426
+ else:
427
+ prompts_skipped = True
428
+
429
+ if prompts_skipped:
430
+ print(
431
+ "Skipping optional-service prompts (non-interactive run); "
432
+ "use --with-alerts/--without-alerts to decide."
433
+ )
434
+ return enabled, removed, to_record
435
+
436
+
437
+ @click.command(help="Prepare the installation of Arcsecond.local.")
438
+ @click.option(
439
+ "--with-alerts/--without-alerts",
440
+ "with_alerts",
441
+ default=None,
442
+ help="Include (or remove) the optional transient-alerts (ToO) service in "
443
+ "docker-compose.yml without prompting.",
444
+ )
445
+ @basic_options
446
+ def setup(with_alerts):
447
+ click.echo("\nWelcome to Arcsecond.local setup.")
448
+ click.echo(
449
+ "\nThis will write or update two files in this folder (.env and docker-compose.yml)."
450
+ )
451
+ click.echo("")
452
+
453
+ env_path = Path.cwd() / ENV_FILENAME
454
+ enabled, removed, to_record = _resolve_optional_services(
455
+ env_path, {"alerts": with_alerts}
456
+ )
457
+ write_env_file()
458
+ for name, answer in to_record:
459
+ _record_optional_service_decision(env_path, name, answer)
460
+ write_docker_compose_file(enabled_services=enabled, removed_services=removed)
461
+
462
+ if "alerts" in enabled:
463
+ click.echo(
464
+ "\nTransient alerts next steps: create GCN credentials (see "
465
+ "https://docs.arcsecond.io/local/transient-alerts), paste them into "
466
+ ".env as GCN_CONSUMER_CLIENT_ID / GCN_CONSUMER_CLIENT_SECRET, then "
467
+ "run: docker compose up -d"
468
+ )
@@ -7,6 +7,7 @@ Designed for USB webcams attached to the host running the proxy.
7
7
 
8
8
  import asyncio
9
9
  import logging
10
+ import sys
10
11
  from typing import Optional
11
12
 
12
13
  from .base import FrameSource, SourceInfo
@@ -18,6 +19,28 @@ _JPEG_QUALITY = 60 # 0-100
18
19
  _MAX_PROBE = 10 # device indices to probe during detection
19
20
 
20
21
 
22
+ def _capture_backend():
23
+ """The OpenCV backend to open device *indices* with, for this platform.
24
+
25
+ Never ``CAP_ANY``. Left to choose for itself, OpenCV walks its backend list
26
+ and — on a machine with no camera at that index — ends up at FFMPEG, which
27
+ interprets an integer index by asking libavdevice to enumerate DirectShow
28
+ devices. That path is slow, cannot work, and prints a wall of
29
+ ``Could not enumerate audio only devices`` to stderr for every index probed.
30
+
31
+ Pinning the platform's native backend also keeps index numbering stable:
32
+ detection and ``open()`` must agree on what device index 1 means, and each
33
+ backend enumerates in its own order.
34
+ """
35
+ import cv2
36
+
37
+ if sys.platform == "win32":
38
+ return cv2.CAP_DSHOW
39
+ if sys.platform == "darwin":
40
+ return cv2.CAP_AVFOUNDATION
41
+ return cv2.CAP_V4L2
42
+
43
+
21
44
  class OpenCVWebcamSource(FrameSource):
22
45
  kind = "webcam"
23
46
  poll_interval = _FRAME_INTERVAL
@@ -29,10 +52,13 @@ class OpenCVWebcamSource(FrameSource):
29
52
  self._cap = None
30
53
 
31
54
  async def open(self) -> None:
32
- import cv2
55
+ import cv2 # noqa: F401
33
56
 
34
57
  loop = asyncio.get_running_loop()
35
- self._cap = await loop.run_in_executor(None, cv2.VideoCapture, self.index)
58
+ backend = _capture_backend()
59
+ self._cap = await loop.run_in_executor(
60
+ None, cv2.VideoCapture, self.index, backend
61
+ )
36
62
  if not await loop.run_in_executor(None, self._cap.isOpened):
37
63
  raise RuntimeError(f"Cannot open webcam at device index {self.index}.")
38
64
 
@@ -75,9 +101,10 @@ def detect_webcams(max_index: int = _MAX_PROBE) -> list[SourceInfo]:
75
101
  """Blocking probe of device indices 0..max_index-1."""
76
102
  import cv2
77
103
 
104
+ backend = _capture_backend()
78
105
  found: list[SourceInfo] = []
79
106
  for i in range(max_index):
80
- cap = cv2.VideoCapture(i)
107
+ cap = cv2.VideoCapture(i, backend)
81
108
  if not cap.isOpened():
82
109
  cap.release()
83
110
  continue