arcsecond 3.18.0__tar.gz → 3.18.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 (112) hide show
  1. {arcsecond-3.18.0 → arcsecond-3.18.2}/PKG-INFO +1 -1
  2. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/imagesources/commands.py +40 -3
  3. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/imagesources/detection.py +13 -6
  4. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/imagesources/sources/filewatch.py +13 -2
  5. {arcsecond-3.18.0 → arcsecond-3.18.2}/pyproject.toml +1 -1
  6. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/test_imagesources_commands.py +74 -0
  7. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/test_imagesources_detection.py +56 -8
  8. {arcsecond-3.18.0 → arcsecond-3.18.2}/uv.lock +1 -1
  9. {arcsecond-3.18.0 → arcsecond-3.18.2}/.docker/Dockerfile_postgres +0 -0
  10. {arcsecond-3.18.0 → arcsecond-3.18.2}/.docker/Dockerfile_redis +0 -0
  11. {arcsecond-3.18.0 → arcsecond-3.18.2}/.flake8 +0 -0
  12. {arcsecond-3.18.0 → arcsecond-3.18.2}/.github/dependabot.yml +0 -0
  13. {arcsecond-3.18.0 → arcsecond-3.18.2}/.github/workflows/pythonpublish.yml +0 -0
  14. {arcsecond-3.18.0 → arcsecond-3.18.2}/.github/workflows/tests.yml +0 -0
  15. {arcsecond-3.18.0 → arcsecond-3.18.2}/.gitignore +0 -0
  16. {arcsecond-3.18.0 → arcsecond-3.18.2}/LICENSE +0 -0
  17. {arcsecond-3.18.0 → arcsecond-3.18.2}/Makefile +0 -0
  18. {arcsecond-3.18.0 → arcsecond-3.18.2}/README.md +0 -0
  19. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/__init__.py +0 -0
  20. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/__version__.py +0 -0
  21. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/alpaca/__init__.py +0 -0
  22. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/alpaca/commands.py +0 -0
  23. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/alpaca/dome_probe.py +0 -0
  24. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/api/__init__.py +0 -0
  25. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/api/config.py +0 -0
  26. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/api/constants.py +0 -0
  27. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/api/endpoint.py +0 -0
  28. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/api/main.py +0 -0
  29. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/api/resources.py +0 -0
  30. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cli.py +0 -0
  31. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/__init__.py +0 -0
  32. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/auth.py +0 -0
  33. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/resources.py +0 -0
  34. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/uploader/__init__.py +0 -0
  35. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/uploader/constants.py +0 -0
  36. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/uploader/context.py +0 -0
  37. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  38. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  39. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  40. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  41. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  42. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/uploader/errors.py +0 -0
  43. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/uploader/logger.py +0 -0
  44. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/uploader/uploader.py +0 -0
  45. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/uploader/utils.py +0 -0
  46. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/uploader/walker.py +0 -0
  47. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/cloud/uploads.py +0 -0
  48. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/errors.py +0 -0
  49. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/__init__.py +0 -0
  50. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/backups.py +0 -0
  51. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/checks.py +0 -0
  52. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/constants.py +0 -0
  53. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/database.py +0 -0
  54. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/docker/__init__.py +0 -0
  55. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/docker/constants.py +0 -0
  56. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/docker/containers.py +0 -0
  57. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/docker/docker-compose.yml +0 -0
  58. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/docker/images.py +0 -0
  59. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/docker/utils.py +0 -0
  60. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/keygen/__init__.py +0 -0
  61. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/keygen/client.py +0 -0
  62. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/keygen/utils.py +0 -0
  63. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/local.py +0 -0
  64. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/main.py +0 -0
  65. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/postgres/init-db.sh +0 -0
  66. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/setup.py +0 -0
  67. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/utils.py +0 -0
  68. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/hosting/validation.py +0 -0
  69. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/imagesources/__init__.py +0 -0
  70. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/imagesources/proxy.py +0 -0
  71. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/imagesources/registry.py +0 -0
  72. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/imagesources/runtime.py +0 -0
  73. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/imagesources/sources/__init__.py +0 -0
  74. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/imagesources/sources/base.py +0 -0
  75. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/imagesources/sources/network.py +0 -0
  76. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/imagesources/sources/opencv.py +0 -0
  77. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/imagesources/store.py +0 -0
  78. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/options.py +0 -0
  79. {arcsecond-3.18.0 → arcsecond-3.18.2}/arcsecond/targets.py +0 -0
  80. {arcsecond-3.18.0 → arcsecond-3.18.2}/examples/example_upload_files.py +0 -0
  81. {arcsecond-3.18.0 → arcsecond-3.18.2}/requirements.txt +0 -0
  82. {arcsecond-3.18.0 → arcsecond-3.18.2}/setup.cfg +0 -0
  83. {arcsecond-3.18.0 → arcsecond-3.18.2}/setup.py +0 -0
  84. {arcsecond-3.18.0 → arcsecond-3.18.2}/sonar-project.properties +0 -0
  85. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/__init__.py +0 -0
  86. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/api/__init__.py +0 -0
  87. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/api/test_api.py +0 -0
  88. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/api/test_api_endpoint.py +0 -0
  89. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/api/test_config.py +0 -0
  90. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/api/test_targets.py +0 -0
  91. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/cloud/__init__.py +0 -0
  92. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/cloud/uploader/__init__.py +0 -0
  93. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/cloud/uploader/datafiles/__init__.py +0 -0
  94. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  95. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  96. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  97. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  98. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  99. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/conftest.py +0 -0
  100. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/fixtures/file1.fits +0 -0
  101. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/test_alpaca_probe.py +0 -0
  102. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/test_cli.py +0 -0
  103. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/test_hosting_backups_restore.py +0 -0
  104. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/test_hosting_database.py +0 -0
  105. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/test_hosting_local.py +0 -0
  106. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/test_hosting_utils_secret_key.py +0 -0
  107. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/test_imagesources_network.py +0 -0
  108. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/test_imagesources_proxy.py +0 -0
  109. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/test_imagesources_store.py +0 -0
  110. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/test_imagesources_webcam.py +0 -0
  111. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/test_targets_planning.py +0 -0
  112. {arcsecond-3.18.0 → arcsecond-3.18.2}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arcsecond
3
- Version: 3.18.0
3
+ Version: 3.18.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
@@ -551,7 +551,10 @@ def webcam_add_cmd(camera, label):
551
551
  + ".",
552
552
  )
553
553
 
554
- _add(Camera(id="", kind=NET, url=target, label=label))
554
+ _add(
555
+ Camera(id="", kind=NET, url=target, label=label),
556
+ absent_note=lambda: _unreachable_note(expanded),
557
+ )
555
558
 
556
559
 
557
560
  def _probe_index(index: int):
@@ -590,6 +593,37 @@ def _specs(device) -> Optional[dict]:
590
593
  return kept or None
591
594
 
592
595
 
596
+ def _unreachable_note(url: str) -> Optional[str]:
597
+ """Said when a camera is registered at an address that does not answer.
598
+
599
+ It is registered either way, for the same reason an unplugged webcam is: a
600
+ camera that is switched off, or on a machine still booting, is one the
601
+ operator owns. But saying nothing lets a typo — or a name this machine
602
+ cannot resolve — surface hours later and two machines away, as a camera
603
+ that is simply blank in Arcsecond.local, instead of as an answer to the
604
+ line just typed.
605
+ """
606
+ reachable, why = detection.is_reachable(url)
607
+ if reachable:
608
+ return None
609
+
610
+ note = (
611
+ f"{why}. It stays registered — the proxy will serve it as soon as it "
612
+ "answers."
613
+ )
614
+ host = (urlsplit(url).hostname or "").lower()
615
+ if host.endswith(".local"):
616
+ # The failure that is worth explaining rather than merely reporting:
617
+ # the browser you found the address in resolves .local names over
618
+ # mDNS, and the proxy asks the machine's resolver, which often cannot.
619
+ note += (
620
+ " A .local name is answered by that machine itself over mDNS, "
621
+ "which not every computer can do — registering its IP address "
622
+ "instead is the reliable form."
623
+ )
624
+ return note
625
+
626
+
593
627
  def _nothing_at_index(index: int) -> str:
594
628
  """Said when a camera is registered while nothing is plugged in at its index.
595
629
 
@@ -835,8 +869,11 @@ def allsky_add_cmd(target, label):
835
869
  # password stays a variable name on disk. Doing it here is what makes an
836
870
  # unset variable a complaint about the line just typed, rather than a
837
871
  # camera quietly missing from the next `proxy start`.
838
- _expand_env_vars(target)
839
- _add(Camera(id="", kind=ALLSKY, url=target, label=label))
872
+ expanded = _expand_env_vars(target)
873
+ _add(
874
+ Camera(id="", kind=ALLSKY, url=target, label=label),
875
+ absent_note=lambda: _unreachable_note(expanded),
876
+ )
840
877
  return
841
878
 
842
879
  # A single letter is a Windows drive, not a scheme: `C:\allsky\latest.jpg`
@@ -96,20 +96,27 @@ def _network_endpoint(url: str) -> Optional[tuple]:
96
96
  return (host, port)
97
97
 
98
98
 
99
- def _is_reachable(camera: Camera, timeout: float) -> tuple:
100
- """``(reachable, why)`` for one camera registered by address.
99
+ def is_reachable(url: str, timeout: float = NETWORK_TIMEOUT) -> tuple:
100
+ """``(reachable, why)`` for one camera address.
101
+
102
+ Takes the URL rather than the camera because ``add`` asks this about an
103
+ address that is not registered yet, and ``detect`` about one that is.
101
104
 
102
105
  Only the address is contacted, and the answer never contains the URL's
103
- password: ``Camera.target`` is redacted, and the endpoint printed here is
104
- the host and port alone.
106
+ password: the endpoint named here is the host and port alone.
105
107
  """
106
- endpoint = _network_endpoint(camera.url or "")
108
+ endpoint = _network_endpoint(url or "")
107
109
  if endpoint is None:
108
110
  return False, "the address cannot be read"
109
111
  host, port = endpoint
110
112
  try:
111
113
  with socket.create_connection((host, port), timeout=timeout):
112
114
  return True, f"answering on {host}:{port}"
115
+ except socket.gaierror:
116
+ # Told apart from a connection that fails: nothing is wrong with the
117
+ # camera, the name simply does not resolve on this machine, and that
118
+ # is a different thing to go and fix.
119
+ return False, f"{host} cannot be resolved"
113
120
  except OSError as e:
114
121
  return False, f"no answer on {host}:{port} ({e.strerror or e})"
115
122
 
@@ -152,7 +159,7 @@ def _reachability(cameras: list[Camera], timeout: float) -> dict:
152
159
  if not cameras:
153
160
  return {}
154
161
  with ThreadPoolExecutor(max_workers=min(8, len(cameras))) as pool:
155
- outcomes = pool.map(lambda c: _is_reachable(c, timeout), cameras)
162
+ outcomes = pool.map(lambda c: is_reachable(c.url or "", timeout), cameras)
156
163
  return {camera.id: outcome for camera, outcome in zip(cameras, outcomes)}
157
164
 
158
165
 
@@ -21,10 +21,21 @@ logger = logging.getLogger(__name__)
21
21
  _POLL_INTERVAL = 5.0 # seconds — image cadence is much slower; this is fine
22
22
 
23
23
  # Common locations to probe at startup. First match wins per software.
24
+ #
25
+ # For Thomas Jacquin's allsky these are its own variables, resolved: since the
26
+ # 2023 rearrangement everything lives under ALLSKY_HOME (``~/allsky``), with
27
+ # ALLSKY_TMP at ``${ALLSKY_HOME}/tmp`` and the local website at
28
+ # ``${ALLSKY_HOME}/html/allsky``. Installs older than that kept the website in
29
+ # ``/var/www/html/allsky`` instead. Beware the ``/current/tmp/`` in the URL its
30
+ # web interface uses: that is a lighttpd alias for ALLSKY_TMP, not a directory,
31
+ # and pasting it in here as a path is how this list went stale before.
24
32
  ALLSKY_DISCOVERY_PATHS: list[Path] = [
25
- # Thomas Jacquin's allsky (https://github.com/AllskyTeam/allsky)
33
+ # Thomas Jacquin's allsky (https://github.com/AllskyTeam/allsky).
34
+ # ALLSKY_TMP first: the capture loop rewrites it every cycle, and it is
35
+ # there whether or not the local website is enabled.
26
36
  Path.home() / "allsky" / "tmp" / "image.jpg",
27
- Path("/var/www/html/allsky/current/tmp/image.jpg"),
37
+ Path.home() / "allsky" / "html" / "allsky" / "image.jpg",
38
+ Path("/var/www/html/allsky/image.jpg"),
28
39
  # indi-allsky (https://github.com/aaronwmorris/indi-allsky)
29
40
  Path("/var/lib/indi-allsky/images/latest.jpg"),
30
41
  Path.home() / "indi-allsky" / "latest.jpg",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arcsecond"
7
- version = "3.18.0"
7
+ version = "3.18.2"
8
8
  authors = [
9
9
  { name = "Cedric Foellmi", email = "cedric@arcsecond.io" },
10
10
  ]
@@ -39,6 +39,15 @@ def cli(tmp_path, monkeypatch):
39
39
  )
40
40
 
41
41
  monkeypatch.setattr(commands.subprocess, "Popen", no_real_processes)
42
+
43
+ # `add` checks that an address answers before saying it is registered.
44
+ # Left alone, every test registering a camera would open a real socket to
45
+ # a made-up host and wait out the timeout — 2 seconds each, and a suite
46
+ # whose result depends on what the network does. Reachable is the quiet
47
+ # default; the tests about the note say otherwise for themselves.
48
+ monkeypatch.setattr(
49
+ commands.detection, "is_reachable", lambda url, **k: (True, "answering")
50
+ )
42
51
  return CliRunner()
43
52
 
44
53
 
@@ -230,6 +239,71 @@ def test_an_address_allsky_cannot_read_is_refused(cli):
230
239
  assert store.all_cameras() == []
231
240
 
232
241
 
242
+ def test_an_address_that_does_not_answer_is_registered_with_a_note(cli, monkeypatch):
243
+ """A camera that is off is still a camera — but the operator is told now."""
244
+ monkeypatch.setattr(
245
+ commands.detection,
246
+ "is_reachable",
247
+ lambda url, **k: (False, "no answer on 10.0.0.9:80 (Connection refused)"),
248
+ )
249
+ result = _run(cli, allsky, ["add", "http://10.0.0.9/latest.jpg"])
250
+ assert result.exit_code == 0
251
+ assert "Note:" in result.output
252
+ assert "no answer on 10.0.0.9:80" in result.output
253
+ assert len(store.all_cameras()) == 1
254
+
255
+
256
+ def test_an_address_that_answers_says_nothing_extra(cli):
257
+ result = _run(cli, allsky, ["add", "http://10.0.0.9/latest.jpg"])
258
+ assert "Note:" not in result.output
259
+
260
+
261
+ def test_a_local_name_that_will_not_resolve_explains_itself(cli, monkeypatch):
262
+ """The failure worth explaining: the browser resolves .local, we may not."""
263
+ monkeypatch.setattr(
264
+ commands.detection,
265
+ "is_reachable",
266
+ lambda url, **k: (False, "skykot.local cannot be resolved"),
267
+ )
268
+ result = _run(cli, allsky, ["add", "http://skykot.local/current/tmp/image.jpg"])
269
+ assert "mDNS" in result.output
270
+ assert "IP address" in result.output
271
+
272
+
273
+ def test_a_network_camera_that_does_not_answer_is_noted_too(cli, monkeypatch):
274
+ monkeypatch.setattr(
275
+ commands.detection, "is_reachable", lambda url, **k: (False, "no answer")
276
+ )
277
+ result = _run(cli, webcam, ["add", "rtsp://cam.local/s"])
278
+ assert result.exit_code == 0
279
+ assert "Note:" in result.output
280
+
281
+
282
+ def test_the_address_contacted_is_the_one_with_the_password_filled_in(cli, monkeypatch):
283
+ """The check must reach the real camera, not `${VARIABLE}` as a hostname."""
284
+ monkeypatch.setenv("PW", "hunter2")
285
+ asked = []
286
+ monkeypatch.setattr(
287
+ commands.detection,
288
+ "is_reachable",
289
+ lambda url, **k: (asked.append(url), (True, "answering"))[1],
290
+ )
291
+ _run(cli, allsky, ["add", "http://sky:${PW}@10.0.0.9/latest.jpg"])
292
+ assert asked == ["http://sky:hunter2@10.0.0.9/latest.jpg"]
293
+
294
+
295
+ def test_re_running_an_add_does_not_contact_the_camera_again(cli, monkeypatch):
296
+ """Re-running a line from history should be quiet, and cost nothing."""
297
+ _run(cli, allsky, ["add", "http://10.0.0.9/latest.jpg"])
298
+ monkeypatch.setattr(
299
+ commands.detection,
300
+ "is_reachable",
301
+ lambda url, **k: (_ for _ in ()).throw(AssertionError("contacted again")),
302
+ )
303
+ result = _run(cli, allsky, ["add", "http://10.0.0.9/latest.jpg"])
304
+ assert "Already registered" in result.output
305
+
306
+
233
307
  def test_a_password_in_an_all_sky_address_is_never_written_in_the_clear(
234
308
  cli, monkeypatch, tmp_path
235
309
  ):
@@ -106,7 +106,7 @@ def test_all_sky_detection_ignores_webcams():
106
106
 
107
107
  def test_a_reachable_network_camera_is_present():
108
108
  camera = Camera(id="abc", kind=NET, url="rtsp://cam.local/s")
109
- with patch.object(detection, "_is_reachable", return_value=(True, "answering")):
109
+ with patch.object(detection, "is_reachable", return_value=(True, "answering")):
110
110
  report = _report([camera])
111
111
  assert report.present == [camera]
112
112
  assert report.new == []
@@ -114,7 +114,7 @@ def test_a_reachable_network_camera_is_present():
114
114
 
115
115
  def test_an_unreachable_network_camera_is_missing_with_a_reason():
116
116
  camera = Camera(id="abc", kind=NET, url="rtsp://cam.local/s")
117
- with patch.object(detection, "_is_reachable", return_value=(False, "no answer")):
117
+ with patch.object(detection, "is_reachable", return_value=(False, "no answer")):
118
118
  report = _report([camera])
119
119
  assert report.missing == [camera]
120
120
  assert report.detail["abc"] == "no answer"
@@ -122,19 +122,38 @@ def test_an_unreachable_network_camera_is_missing_with_a_reason():
122
122
 
123
123
  def test_no_network_says_so_rather_than_guessing():
124
124
  camera = Camera(id="abc", kind=NET, url="rtsp://cam.local/s")
125
- with patch.object(detection, "_is_reachable", side_effect=AssertionError("called")):
125
+ with patch.object(detection, "is_reachable", side_effect=AssertionError("called")):
126
126
  report = _report([camera], check_network=False)
127
127
  assert report.present == [camera]
128
128
  assert report.detail["abc"] == "not contacted"
129
129
 
130
130
 
131
131
  def test_reachability_never_leaks_the_password():
132
- camera = Camera(id="abc", kind=NET, url="rtsp://admin:hunter2@127.0.0.1:1/s")
133
- reachable, why = detection._is_reachable(camera, timeout=0.2)
132
+ url = "rtsp://admin:hunter2@127.0.0.1:1/s"
133
+ reachable, why = detection.is_reachable(url, timeout=0.2)
134
134
  assert reachable is False
135
135
  assert "hunter2" not in why
136
136
 
137
137
 
138
+ def test_a_name_that_does_not_resolve_says_so(monkeypatch):
139
+ """Distinct from a refused connection: the camera may be perfectly fine."""
140
+ import socket as socket_module
141
+
142
+ def no_such_name(*a, **k):
143
+ raise socket_module.gaierror(-2, "Name or service not known")
144
+
145
+ monkeypatch.setattr(detection.socket, "create_connection", no_such_name)
146
+ reachable, why = detection.is_reachable("http://skykot.local/image.jpg")
147
+ assert reachable is False
148
+ assert why == "skykot.local cannot be resolved"
149
+
150
+
151
+ def test_an_address_that_cannot_be_read_is_not_contacted():
152
+ reachable, why = detection.is_reachable("http://")
153
+ assert reachable is False
154
+ assert why == "the address cannot be read"
155
+
156
+
138
157
  @pytest.mark.parametrize(
139
158
  "url,expected",
140
159
  [
@@ -201,7 +220,7 @@ def test_a_detected_all_sky_camera_that_is_registered_is_not_reported_twice(tmp_
201
220
  def test_an_all_sky_camera_at_an_address_is_confirmed_by_contacting_it():
202
221
  """It has no path to look at, so it is settled the way any address is."""
203
222
  camera = Camera(id="abc", kind=ALLSKY, url="http://sky.local/latest.jpg")
204
- with patch.object(detection, "_is_reachable", return_value=(True, "answering")):
223
+ with patch.object(detection, "is_reachable", return_value=(True, "answering")):
205
224
  with patch.object(detection, "detect_allsky", return_value=[]):
206
225
  report = detection.report([camera], (ALLSKY,))
207
226
  assert report.present == [camera]
@@ -210,7 +229,7 @@ def test_an_all_sky_camera_at_an_address_is_confirmed_by_contacting_it():
210
229
 
211
230
  def test_an_all_sky_camera_at_an_address_that_is_down_is_missing():
212
231
  camera = Camera(id="abc", kind=ALLSKY, url="http://sky.local/latest.jpg")
213
- with patch.object(detection, "_is_reachable", return_value=(False, "no answer")):
232
+ with patch.object(detection, "is_reachable", return_value=(False, "no answer")):
214
233
  with patch.object(detection, "detect_allsky", return_value=[]):
215
234
  report = detection.report([camera], (ALLSKY,))
216
235
  assert report.missing == [camera]
@@ -221,12 +240,41 @@ def test_an_all_sky_address_is_never_looked_for_on_this_disk():
221
240
  """The old check called a URL a missing file — the wrong answer entirely."""
222
241
  camera = Camera(id="abc", kind=ALLSKY, url="http://sky.local/latest.jpg")
223
242
  with patch.object(detection, "resolve_allsky_path") as looked_at_disk:
224
- with patch.object(detection, "_is_reachable", return_value=(True, "answering")):
243
+ with patch.object(detection, "is_reachable", return_value=(True, "answering")):
225
244
  with patch.object(detection, "detect_allsky", return_value=[]):
226
245
  detection.report([camera], (ALLSKY,))
227
246
  looked_at_disk.assert_not_called()
228
247
 
229
248
 
249
+ # ---------------------------------------------------------------------------
250
+ # Probing the well-known locations
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def test_an_image_at_a_well_known_location_is_detected(tmp_path, monkeypatch):
255
+ from arcsecond.imagesources.sources import filewatch
256
+
257
+ image = tmp_path / "image.jpg"
258
+ image.write_bytes(b"x")
259
+ monkeypatch.setattr(filewatch, "ALLSKY_DISCOVERY_PATHS", [image])
260
+
261
+ (device,) = filewatch.detect_allsky()
262
+ assert device.identity == (ALLSKY, str(image))
263
+ assert device.extra["path"] == str(image)
264
+
265
+
266
+ def test_a_well_known_location_with_nothing_at_it_is_not_reported(
267
+ tmp_path, monkeypatch
268
+ ):
269
+ """Probing reports what is there — an empty list is an answer, not a fault."""
270
+ from arcsecond.imagesources.sources import filewatch
271
+
272
+ monkeypatch.setattr(
273
+ filewatch, "ALLSKY_DISCOVERY_PATHS", [tmp_path / "nothing-here.jpg"]
274
+ )
275
+ assert filewatch.detect_allsky() == []
276
+
277
+
230
278
  # ---------------------------------------------------------------------------
231
279
  # Probing must never take the report down
232
280
  # ---------------------------------------------------------------------------
@@ -155,7 +155,7 @@ wheels = [
155
155
 
156
156
  [[package]]
157
157
  name = "arcsecond"
158
- version = "3.18.0"
158
+ version = "3.18.2"
159
159
  source = { editable = "." }
160
160
  dependencies = [
161
161
  { name = "alpyca" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes