arcsecond 3.18.2__tar.gz → 3.20.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 (114) hide show
  1. {arcsecond-3.18.2 → arcsecond-3.20.0}/PKG-INFO +1 -1
  2. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/docker/docker-compose.yml +8 -11
  3. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/local.py +4 -3
  4. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/imagesources/commands.py +8 -6
  5. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/imagesources/detection.py +34 -4
  6. arcsecond-3.20.0/arcsecond/imagesources/sources/mdns.py +221 -0
  7. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/imagesources/sources/network.py +72 -1
  8. {arcsecond-3.18.2 → arcsecond-3.20.0}/pyproject.toml +1 -1
  9. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/test_imagesources_detection.py +57 -4
  10. arcsecond-3.20.0/tests/test_imagesources_mdns.py +273 -0
  11. {arcsecond-3.18.2 → arcsecond-3.20.0}/uv.lock +1 -1
  12. {arcsecond-3.18.2 → arcsecond-3.20.0}/.docker/Dockerfile_postgres +0 -0
  13. {arcsecond-3.18.2 → arcsecond-3.20.0}/.docker/Dockerfile_redis +0 -0
  14. {arcsecond-3.18.2 → arcsecond-3.20.0}/.flake8 +0 -0
  15. {arcsecond-3.18.2 → arcsecond-3.20.0}/.github/dependabot.yml +0 -0
  16. {arcsecond-3.18.2 → arcsecond-3.20.0}/.github/workflows/pythonpublish.yml +0 -0
  17. {arcsecond-3.18.2 → arcsecond-3.20.0}/.github/workflows/tests.yml +0 -0
  18. {arcsecond-3.18.2 → arcsecond-3.20.0}/.gitignore +0 -0
  19. {arcsecond-3.18.2 → arcsecond-3.20.0}/LICENSE +0 -0
  20. {arcsecond-3.18.2 → arcsecond-3.20.0}/Makefile +0 -0
  21. {arcsecond-3.18.2 → arcsecond-3.20.0}/README.md +0 -0
  22. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/__init__.py +0 -0
  23. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/__version__.py +0 -0
  24. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/alpaca/__init__.py +0 -0
  25. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/alpaca/commands.py +0 -0
  26. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/alpaca/dome_probe.py +0 -0
  27. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/api/__init__.py +0 -0
  28. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/api/config.py +0 -0
  29. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/api/constants.py +0 -0
  30. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/api/endpoint.py +0 -0
  31. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/api/main.py +0 -0
  32. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/api/resources.py +0 -0
  33. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cli.py +0 -0
  34. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/__init__.py +0 -0
  35. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/auth.py +0 -0
  36. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/resources.py +0 -0
  37. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/uploader/__init__.py +0 -0
  38. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/uploader/constants.py +0 -0
  39. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/uploader/context.py +0 -0
  40. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  41. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  42. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  43. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  44. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  45. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/uploader/errors.py +0 -0
  46. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/uploader/logger.py +0 -0
  47. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/uploader/uploader.py +0 -0
  48. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/uploader/utils.py +0 -0
  49. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/uploader/walker.py +0 -0
  50. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/cloud/uploads.py +0 -0
  51. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/errors.py +0 -0
  52. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/__init__.py +0 -0
  53. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/backups.py +0 -0
  54. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/checks.py +0 -0
  55. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/constants.py +0 -0
  56. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/database.py +0 -0
  57. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/docker/__init__.py +0 -0
  58. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/docker/constants.py +0 -0
  59. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/docker/containers.py +0 -0
  60. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/docker/images.py +0 -0
  61. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/docker/utils.py +0 -0
  62. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/keygen/__init__.py +0 -0
  63. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/keygen/client.py +0 -0
  64. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/keygen/utils.py +0 -0
  65. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/main.py +0 -0
  66. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/postgres/init-db.sh +0 -0
  67. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/setup.py +0 -0
  68. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/utils.py +0 -0
  69. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/hosting/validation.py +0 -0
  70. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/imagesources/__init__.py +0 -0
  71. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/imagesources/proxy.py +0 -0
  72. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/imagesources/registry.py +0 -0
  73. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/imagesources/runtime.py +0 -0
  74. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/imagesources/sources/__init__.py +0 -0
  75. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/imagesources/sources/base.py +0 -0
  76. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/imagesources/sources/filewatch.py +0 -0
  77. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/imagesources/sources/opencv.py +0 -0
  78. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/imagesources/store.py +0 -0
  79. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/options.py +0 -0
  80. {arcsecond-3.18.2 → arcsecond-3.20.0}/arcsecond/targets.py +0 -0
  81. {arcsecond-3.18.2 → arcsecond-3.20.0}/examples/example_upload_files.py +0 -0
  82. {arcsecond-3.18.2 → arcsecond-3.20.0}/requirements.txt +0 -0
  83. {arcsecond-3.18.2 → arcsecond-3.20.0}/setup.cfg +0 -0
  84. {arcsecond-3.18.2 → arcsecond-3.20.0}/setup.py +0 -0
  85. {arcsecond-3.18.2 → arcsecond-3.20.0}/sonar-project.properties +0 -0
  86. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/__init__.py +0 -0
  87. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/api/__init__.py +0 -0
  88. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/api/test_api.py +0 -0
  89. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/api/test_api_endpoint.py +0 -0
  90. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/api/test_config.py +0 -0
  91. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/api/test_targets.py +0 -0
  92. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/cloud/__init__.py +0 -0
  93. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/cloud/uploader/__init__.py +0 -0
  94. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/cloud/uploader/datafiles/__init__.py +0 -0
  95. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  96. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  97. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  98. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  99. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  100. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/conftest.py +0 -0
  101. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/fixtures/file1.fits +0 -0
  102. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/test_alpaca_probe.py +0 -0
  103. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/test_cli.py +0 -0
  104. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/test_hosting_backups_restore.py +0 -0
  105. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/test_hosting_database.py +0 -0
  106. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/test_hosting_local.py +0 -0
  107. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/test_hosting_utils_secret_key.py +0 -0
  108. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/test_imagesources_commands.py +0 -0
  109. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/test_imagesources_network.py +0 -0
  110. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/test_imagesources_proxy.py +0 -0
  111. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/test_imagesources_store.py +0 -0
  112. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/test_imagesources_webcam.py +0 -0
  113. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/test_targets_planning.py +0 -0
  114. {arcsecond-3.18.2 → arcsecond-3.20.0}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arcsecond
3
- Version: 3.18.2
3
+ Version: 3.20.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
@@ -1,6 +1,6 @@
1
1
  # === Arcsecond.local config. ===
2
2
  # *** DO NOT change any name or port number. ***
3
- # Version 6.3
3
+ # Version 6.4
4
4
 
5
5
  services:
6
6
  # Database (PostgresQL)
@@ -8,12 +8,11 @@ services:
8
8
  image: postgres:16
9
9
  container_name: arcsecond-db
10
10
  restart: unless-stopped
11
- # Bind to localhost only — the backend container reaches the DB over the
12
- # internal Docker network (hostname `arcsecond-db`), and operators can
13
- # still run `psql -h localhost` / `pg_dump` from the host. Anything on the
14
- # LAN is firewalled out.
15
- ports:
16
- - "127.0.0.1:5432:5432"
11
+ # No host port. The backend reaches the DB over the internal Docker
12
+ # network (hostname `arcsecond-db`), and every CLI command that needs
13
+ # the DB (backups, restore, password rotation) goes through `docker exec`
14
+ # into this container. Publishing 5432 on the host bought nothing and
15
+ # failed the whole stack whenever another Postgres already held it.
17
16
  volumes:
18
17
  - arcsecond_postgres_data:/var/lib/postgresql/data
19
18
  env_file:
@@ -25,10 +24,8 @@ services:
25
24
  image: redis:7.4
26
25
  container_name: arcsecond-broker
27
26
  restart: unless-stopped
28
- # Localhost-only as well — Redis with no auth on a LAN-exposed port is a
29
- # well-known foot-gun. Backend reaches it over the Docker network.
30
- ports:
31
- - "127.0.0.1:6379:6379"
27
+ # No host port either: Redis with no auth must never be reachable from
28
+ # outside the stack, and the backend reaches it over the Docker network.
32
29
 
33
30
  # Arcsecond backend (REST APIs). Can be used for API calls and external pipelines, scripts etc.
34
31
  backend:
@@ -16,9 +16,10 @@ from .utils import (
16
16
 
17
17
  ENV_FILENAME = ".env"
18
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).
19
+ # Stable across installs — it is the username every `docker exec ... psql`
20
+ # in this package and in the docs uses. The actual security boundary is the
21
+ # password (generated per-install) and the fact that the database container
22
+ # publishes no host port at all.
22
23
  POSTGRES_USER = "arcsecond_docker"
23
24
  POSTGRES_DB = "arcsecond_docker"
24
25
 
@@ -613,13 +613,15 @@ def _unreachable_note(url: str) -> Optional[str]:
613
613
  )
614
614
  host = (urlsplit(url).hostname or "").lower()
615
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.
616
+ # Worth explaining rather than merely reporting. A .local name is
617
+ # answered by its own machine over multicast, and the proxy asks that
618
+ # way itself when this computer's resolver cannot — so reaching here
619
+ # means neither worked, and something is eating the multicast.
619
620
  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."
621
+ " A .local name is answered by that machine itself over mDNS. The "
622
+ "proxy asks that way too when this computer cannot, so something "
623
+ "on the network is stopping both — a firewall, or a subnet the "
624
+ "multicast does not cross. Its IP address is the reliable form."
623
625
  )
624
626
  return note
625
627
 
@@ -35,6 +35,8 @@ from urllib.parse import urlsplit
35
35
 
36
36
  from .sources.base import DetectedDevice
37
37
  from .sources.filewatch import detect_allsky
38
+ from .sources.mdns import resolve as resolve_over_mdns
39
+ from .sources.network import HTTP_SCHEMES
38
40
  from .store import ALLSKY, USB, Camera
39
41
 
40
42
  logger = logging.getLogger(__name__)
@@ -109,17 +111,45 @@ def is_reachable(url: str, timeout: float = NETWORK_TIMEOUT) -> tuple:
109
111
  if endpoint is None:
110
112
  return False, "the address cannot be read"
111
113
  host, port = endpoint
114
+
112
115
  try:
113
116
  with socket.create_connection((host, port), timeout=timeout):
114
117
  return True, f"answering on {host}:{port}"
115
118
  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"
119
+ pass
120
120
  except OSError as e:
121
121
  return False, f"no answer on {host}:{port} ({e.strerror or e})"
122
122
 
123
+ # The machine's resolver has no answer. A `.local` name can still be
124
+ # answered by the machine that owns it, which is exactly what the proxy
125
+ # does when it connects — so the same fallback belongs here, or `detect`
126
+ # would call a camera missing that the proxy serves perfectly well.
127
+ #
128
+ # Only for the addresses the proxy fetches itself: an RTSP stream is
129
+ # opened by FFmpeg, with its own resolver and no way in, so promising that
130
+ # one works would be a promise this cannot keep.
131
+ if urlsplit(url).scheme.lower() not in HTTP_SCHEMES:
132
+ return False, f"{host} cannot be resolved"
133
+
134
+ addresses = resolve_over_mdns(host)
135
+ if not addresses:
136
+ # Told apart from a connection that fails: nothing is wrong with the
137
+ # camera, the name simply does not resolve here, and that is a
138
+ # different thing to go and fix.
139
+ return False, f"{host} cannot be resolved"
140
+
141
+ # A machine with several interfaces answers with an address for each, and
142
+ # only some of them are reachable from here — the same reason the proxy
143
+ # hands the whole list to aiohttp rather than picking one.
144
+ why = ""
145
+ for address in addresses:
146
+ try:
147
+ with socket.create_connection((address, port), timeout=timeout):
148
+ return True, f"answering on {address}:{port}, found over mDNS"
149
+ except OSError as e:
150
+ why = f"no answer on {address}:{port} ({e.strerror or e})"
151
+ return False, why
152
+
123
153
 
124
154
  # ---------------------------------------------------------------------------
125
155
  # The report
@@ -0,0 +1,221 @@
1
+ """
2
+ Resolving a ``.local`` name when the machine's own resolver will not.
3
+
4
+ A ``.local`` name is not in any DNS server: the machine that owns it answers
5
+ for itself, over multicast, on 224.0.0.251:5353 (RFC 6762). Most operating
6
+ systems do that for every application — macOS through mDNSResponder, Linux
7
+ through Avahi, Windows through its DNS client — but not all of them do, and
8
+ not on every network profile. When they do not, ``getaddrinfo`` sends the
9
+ query to the configured DNS server instead, which knows nothing about
10
+ ``.local`` and answers SERVFAIL. That is what a proxy sees as::
11
+
12
+ Cannot connect to host skykot.local:80 [DNS server returned general failure]
13
+
14
+ while the browser two windows away, which speaks mDNS itself, shows the very
15
+ same camera. So the proxy speaks it too, as a fallback and only for ``.local``
16
+ names — anything else belongs to the machine's resolver and stays there.
17
+
18
+ This is deliberately a *one-shot* query, not a discovery service: one question
19
+ about one name, the first answer wins, and nothing is kept running. mDNS on
20
+ the wire is ordinary DNS, so what follows is a small DNS encoder and decoder
21
+ rather than anything exotic.
22
+ """
23
+
24
+ import logging
25
+ import socket
26
+ import struct
27
+ import time
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ MDNS_ADDRESS = "224.0.0.251"
32
+ MDNS_PORT = 5353
33
+ SUFFIX = ".local"
34
+
35
+ # Long enough for a machine on the same network to answer, short enough that a
36
+ # camera which is simply switched off does not hold anything up. A responder
37
+ # on the same LAN answers in milliseconds.
38
+ DEFAULT_TIMEOUT = 1.5
39
+
40
+ _TYPE_A = 1
41
+ _CLASS_IN = 1
42
+ # RFC 6762 §5.4: the top bit of the class field asks the responder to answer
43
+ # this one directly to our port rather than multicasting it to the network.
44
+ # Without it the answer goes to port 5353, where the system responder — if
45
+ # there is one — is listening instead of us.
46
+ _UNICAST_RESPONSE = 0x8000
47
+ _HEADER = struct.Struct("!6H")
48
+ _POINTER_MASK = 0xC0
49
+
50
+
51
+ def is_mdns_name(host: str) -> bool:
52
+ """Whether ``host`` is a name only multicast DNS can answer for."""
53
+ host = (host or "").strip().rstrip(".").lower()
54
+ return host.endswith(SUFFIX) and host != SUFFIX
55
+
56
+
57
+ def _encode_name(name: str) -> bytes:
58
+ parts = [p for p in name.strip().rstrip(".").split(".") if p]
59
+ encoded = b""
60
+ for part in parts:
61
+ raw = part.encode("utf-8")
62
+ if len(raw) > 63:
63
+ raise ValueError(f"label too long in {name!r}")
64
+ encoded += bytes([len(raw)]) + raw
65
+ return encoded + b"\x00"
66
+
67
+
68
+ def _build_query(name: str) -> bytes:
69
+ # Transaction id 0: a responder echoes it back, and having only one query
70
+ # in flight per socket means there is nothing to match it against.
71
+ header = _HEADER.pack(0, 0, 1, 0, 0, 0)
72
+ question = _encode_name(name)
73
+ question += struct.pack("!HH", _TYPE_A, _CLASS_IN | _UNICAST_RESPONSE)
74
+ return header + question
75
+
76
+
77
+ def _read_name(data: bytes, offset: int) -> tuple:
78
+ """``(name, offset_after)``, following compression pointers.
79
+
80
+ Answers routinely point back into the question rather than repeating the
81
+ name, so a decoder that cannot follow a pointer reads garbage.
82
+ """
83
+ labels = []
84
+ jumped = False
85
+ end = offset
86
+ seen = 0
87
+
88
+ while True:
89
+ if offset >= len(data):
90
+ raise ValueError("truncated name")
91
+ length = data[offset]
92
+ if length == 0:
93
+ offset += 1
94
+ if not jumped:
95
+ end = offset
96
+ break
97
+ if length & _POINTER_MASK == _POINTER_MASK:
98
+ if offset + 1 >= len(data):
99
+ raise ValueError("truncated pointer")
100
+ target = ((length & 0x3F) << 8) | data[offset + 1]
101
+ if not jumped:
102
+ end = offset + 2
103
+ jumped = True
104
+ # A pointer chain must make progress towards the start of the
105
+ # packet; anything else is a loop, and a packet from the network
106
+ # is not to be trusted to avoid one.
107
+ if target >= offset:
108
+ raise ValueError("pointer does not move backwards")
109
+ offset = target
110
+ seen += 1
111
+ if seen > 32:
112
+ raise ValueError("too many pointers")
113
+ continue
114
+ offset += 1
115
+ labels.append(data[offset : offset + length].decode("utf-8", "replace"))
116
+ offset += length
117
+
118
+ return ".".join(labels), end
119
+
120
+
121
+ def _parse_response(data: bytes, wanted: str) -> list:
122
+ """Every IPv4 address ``wanted`` resolves to in ``data``.
123
+
124
+ Only A records for the name that was asked about count. A response may
125
+ carry records about other names entirely — a responder is free to include
126
+ whatever it thinks useful — and answering with one of those would send the
127
+ proxy to a different machine.
128
+
129
+ All of them, not the first: a machine with more than one interface answers
130
+ with an address per interface, and which of those the caller can actually
131
+ reach is not something this can tell from here. A Mac answering for itself
132
+ while a virtual network is up is the ordinary case, not an exotic one.
133
+ """
134
+ found: list = []
135
+ try:
136
+ _, _, questions, answers, authority, additional = _HEADER.unpack_from(data)
137
+ except struct.error:
138
+ return found
139
+
140
+ offset = _HEADER.size
141
+ try:
142
+ for _ in range(questions):
143
+ _, offset = _read_name(data, offset)
144
+ offset += 4 # QTYPE, QCLASS
145
+
146
+ target = wanted.rstrip(".").lower()
147
+ for _ in range(answers + authority + additional):
148
+ name, offset = _read_name(data, offset)
149
+ rtype, rclass, _, length = struct.unpack_from("!HHIH", data, offset)
150
+ offset += 10
151
+ rdata = data[offset : offset + length]
152
+ offset += length
153
+ if (
154
+ rtype == _TYPE_A
155
+ and (rclass & 0x7FFF) == _CLASS_IN
156
+ and len(rdata) == 4
157
+ and name.rstrip(".").lower() == target
158
+ ):
159
+ address = socket.inet_ntoa(rdata)
160
+ if address not in found:
161
+ found.append(address)
162
+ except (ValueError, struct.error, IndexError):
163
+ # A malformed packet is one more reason to fall back to failing, not a
164
+ # reason to take the proxy down. Anyone on the network can send one.
165
+ # Whatever was read before the damage is still good.
166
+ logger.debug("Ignoring the rest of a malformed mDNS response for %s", wanted)
167
+
168
+ return found
169
+
170
+
171
+ def resolve(host: str, timeout: float = DEFAULT_TIMEOUT) -> list:
172
+ """Ask the network for ``host``'s IPv4 addresses. Empty if none answer.
173
+
174
+ In the order the responder gave them, which is the order to try them in:
175
+ the caller connects to each until one works, since a machine with several
176
+ interfaces answers for all of them and only some may be reachable.
177
+
178
+ Blocking, and short. Call it from a thread when the caller is async.
179
+ """
180
+ if not is_mdns_name(host):
181
+ return []
182
+
183
+ name = host.strip().rstrip(".")
184
+ try:
185
+ query = _build_query(name)
186
+ except ValueError:
187
+ return []
188
+
189
+ sock = None
190
+ try:
191
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
192
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
193
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
194
+ sock.settimeout(timeout)
195
+ sock.bind(("", 0))
196
+ sock.sendto(query, (MDNS_ADDRESS, MDNS_PORT))
197
+
198
+ deadline = time.monotonic() + timeout
199
+ while time.monotonic() < deadline:
200
+ sock.settimeout(max(0.05, deadline - time.monotonic()))
201
+ try:
202
+ data, _ = sock.recvfrom(4096)
203
+ except socket.timeout:
204
+ return []
205
+ addresses = _parse_response(data, name)
206
+ if addresses:
207
+ logger.info("Resolved %s to %s over mDNS.", name, addresses)
208
+ return addresses
209
+ # Something else on the network answered, or answered about
210
+ # another name. Keep listening until the deadline rather than
211
+ # taking the first packet as the last word.
212
+ except OSError as e:
213
+ # No multicast route, a firewall in the way, a network that is down:
214
+ # all of them mean "not resolved here", which is what the caller does
215
+ # something about.
216
+ logger.debug("mDNS query for %s failed: %s", name, e)
217
+ finally:
218
+ if sock is not None:
219
+ sock.close()
220
+
221
+ return []
@@ -22,9 +22,11 @@ import asyncio
22
22
  import hashlib
23
23
  import logging
24
24
  import os
25
+ import socket
25
26
  from typing import Optional
26
27
  from urllib.parse import urlsplit, urlunsplit
27
28
 
29
+ from . import mdns
28
30
  from .base import FrameSource, SourceInfo
29
31
 
30
32
  logger = logging.getLogger(__name__)
@@ -163,6 +165,7 @@ class HTTPImageSource(FrameSource):
163
165
  self._etag: Optional[str] = None
164
166
  self._last_modified: Optional[str] = None
165
167
  self._digest: Optional[bytes] = None
168
+ self._resolver = None
166
169
 
167
170
  async def open(self) -> None:
168
171
  import aiohttp
@@ -170,7 +173,13 @@ class HTTPImageSource(FrameSource):
170
173
  timeout = aiohttp.ClientTimeout(
171
174
  total=None, connect=_CONNECT_TIMEOUT, sock_read=_READ_TIMEOUT
172
175
  )
173
- self._session = aiohttp.ClientSession(timeout=timeout)
176
+ # aiohttp never closes a resolver it was handed, so this one is kept
177
+ # and closed alongside the session.
178
+ self._resolver = _mdns_aware_resolver()
179
+ self._session = aiohttp.ClientSession(
180
+ timeout=timeout,
181
+ connector=aiohttp.TCPConnector(resolver=self._resolver),
182
+ )
174
183
 
175
184
  try:
176
185
  response = await self._session.get(self.url)
@@ -266,6 +275,9 @@ class HTTPImageSource(FrameSource):
266
275
  if self._session is not None:
267
276
  await self._session.close()
268
277
  self._session = None
278
+ if self._resolver is not None:
279
+ await self._resolver.close()
280
+ self._resolver = None
269
281
 
270
282
  def info(self) -> SourceInfo:
271
283
  return SourceInfo(
@@ -279,6 +291,65 @@ class HTTPImageSource(FrameSource):
279
291
  )
280
292
 
281
293
 
294
+ async def _resolve_over_mdns(host: str) -> list:
295
+ """``host``'s addresses according to the machine that owns the name."""
296
+ if not mdns.is_mdns_name(host):
297
+ return []
298
+ loop = asyncio.get_running_loop()
299
+ return await loop.run_in_executor(None, mdns.resolve, host)
300
+
301
+
302
+ def _mdns_aware_resolver():
303
+ """An aiohttp resolver that falls back to mDNS for a ``.local`` name.
304
+
305
+ The machine's own resolver is asked first and is almost always the end of
306
+ it. Only when it has no answer, and only for a ``.local`` name, is the
307
+ network asked directly — see ``mdns``. A camera registered by such a name
308
+ therefore keeps working when its address changes, which is the whole
309
+ reason for resolving at connect time rather than storing an address.
310
+
311
+ Built inside a function because aiohttp is an optional dependency: the CLI
312
+ has to stay importable on a machine that never installed the proxy extra.
313
+ """
314
+ import aiohttp
315
+ from aiohttp.abc import AbstractResolver
316
+
317
+ class _MDNSFallbackResolver(AbstractResolver):
318
+ def __init__(self):
319
+ self._default = aiohttp.DefaultResolver()
320
+
321
+ async def resolve(self, host, port=0, family=socket.AF_INET):
322
+ try:
323
+ return await self._default.resolve(host, port, family)
324
+ except OSError:
325
+ # Only A records are asked for, so an IPv6-only lookup is not
326
+ # something this can answer.
327
+ if family not in (socket.AF_INET, socket.AF_UNSPEC):
328
+ raise
329
+ addresses = await _resolve_over_mdns(host)
330
+ if not addresses:
331
+ raise
332
+ # Every address the machine answered with, in the order it
333
+ # gave them: aiohttp tries them in turn, which is what makes
334
+ # a responder with several interfaces work.
335
+ return [
336
+ {
337
+ "hostname": host,
338
+ "host": address,
339
+ "port": port,
340
+ "family": socket.AF_INET,
341
+ "proto": 0,
342
+ "flags": socket.AI_NUMERICHOST,
343
+ }
344
+ for address in addresses
345
+ ]
346
+
347
+ async def close(self) -> None:
348
+ await self._default.close()
349
+
350
+ return _MDNSFallbackResolver()
351
+
352
+
282
353
  class AllSkyHTTPSource(HTTPImageSource):
283
354
  """An all-sky camera that publishes its JPEG over HTTP.
284
355
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arcsecond"
7
- version = "3.18.2"
7
+ version = "3.20.0"
8
8
  authors = [
9
9
  { name = "Cedric Foellmi", email = "cedric@arcsecond.io" },
10
10
  ]
@@ -6,6 +6,7 @@ buckets is asserted on its own, because the old command printed detection and
6
6
  registration one after the other with no way to tell which was which.
7
7
  """
8
8
 
9
+ from contextlib import nullcontext
9
10
  from unittest.mock import patch
10
11
 
11
12
  import pytest
@@ -135,19 +136,71 @@ def test_reachability_never_leaks_the_password():
135
136
  assert "hunter2" not in why
136
137
 
137
138
 
138
- def test_a_name_that_does_not_resolve_says_so(monkeypatch):
139
- """Distinct from a refused connection: the camera may be perfectly fine."""
139
+ def _unresolvable(monkeypatch, answering=()):
140
+ """Make the machine's resolver fail, except for addresses in ``answering``."""
140
141
  import socket as socket_module
141
142
 
142
- def no_such_name(*a, **k):
143
+ def connect(endpoint, timeout=None):
144
+ host, _ = endpoint
145
+ if host in answering:
146
+ return nullcontext()
143
147
  raise socket_module.gaierror(-2, "Name or service not known")
144
148
 
145
- monkeypatch.setattr(detection.socket, "create_connection", no_such_name)
149
+ monkeypatch.setattr(detection.socket, "create_connection", connect)
150
+
151
+
152
+ def test_a_name_that_does_not_resolve_says_so(monkeypatch):
153
+ """Distinct from a refused connection: the camera may be perfectly fine."""
154
+ _unresolvable(monkeypatch)
155
+ monkeypatch.setattr(detection, "resolve_over_mdns", lambda host, **k: [])
156
+
146
157
  reachable, why = detection.is_reachable("http://skykot.local/image.jpg")
147
158
  assert reachable is False
148
159
  assert why == "skykot.local cannot be resolved"
149
160
 
150
161
 
162
+ def test_a_local_name_the_machine_cannot_resolve_is_asked_of_the_network(monkeypatch):
163
+ """What the proxy does when it connects, so `detect` must agree with it."""
164
+ _unresolvable(monkeypatch, answering={"192.168.1.42"})
165
+ monkeypatch.setattr(
166
+ detection, "resolve_over_mdns", lambda host, **k: ["192.168.1.42"]
167
+ )
168
+
169
+ reachable, why = detection.is_reachable("http://skykot.local/image.jpg")
170
+ assert reachable is True
171
+ assert why == "answering on 192.168.1.42:80, found over mDNS"
172
+
173
+
174
+ def test_an_address_that_does_not_answer_is_not_the_last_word(monkeypatch):
175
+ """The first address a multi-homed machine gives may be one this network
176
+ cannot reach; the others are still worth trying."""
177
+ _unresolvable(monkeypatch, answering={"192.168.1.97"})
178
+ monkeypatch.setattr(
179
+ detection,
180
+ "resolve_over_mdns",
181
+ lambda host, **k: ["192.168.64.1", "192.168.1.97"],
182
+ )
183
+
184
+ reachable, why = detection.is_reachable("http://skykot.local/image.jpg")
185
+ assert reachable is True
186
+ assert "192.168.1.97" in why
187
+
188
+
189
+ def test_a_video_stream_is_not_asked_of_the_network(monkeypatch):
190
+ """FFmpeg opens an RTSP stream with its own resolver, out of our reach —
191
+ so promising it works would be a promise we cannot keep."""
192
+ _unresolvable(monkeypatch, answering={"192.168.1.42"})
193
+
194
+ def never(*a, **k):
195
+ raise AssertionError("asked over mDNS")
196
+
197
+ monkeypatch.setattr(detection, "resolve_over_mdns", never)
198
+
199
+ reachable, why = detection.is_reachable("rtsp://cam.local/stream1")
200
+ assert reachable is False
201
+ assert why == "cam.local cannot be resolved"
202
+
203
+
151
204
  def test_an_address_that_cannot_be_read_is_not_contacted():
152
205
  reachable, why = detection.is_reachable("http://")
153
206
  assert reachable is False
@@ -0,0 +1,273 @@
1
+ """Tests for resolving a `.local` name when the machine's resolver will not.
2
+
3
+ Nothing here touches the network: the wire format is what matters, so the
4
+ packets are built by hand and fed to the parser. A responder on the other end
5
+ is somebody else's software, and a test that waited for one would pass or fail
6
+ according to what happens to be plugged in.
7
+ """
8
+
9
+ import socket
10
+ import struct
11
+
12
+ import pytest
13
+
14
+ from arcsecond.imagesources.sources import mdns
15
+
16
+ NAME = "skykot.local"
17
+
18
+
19
+ def _response(records, questions=(NAME,), flags=0x8400):
20
+ """A response packet carrying ``records`` as ``(name, type, rdata)``."""
21
+ header = mdns._HEADER.pack(0, flags, len(questions), len(records), 0, 0)
22
+ body = b""
23
+ for question in questions:
24
+ body += mdns._encode_name(question) + struct.pack("!HH", 1, 1)
25
+ for name, rtype, rdata in records:
26
+ body += mdns._encode_name(name)
27
+ body += struct.pack("!HHIH", rtype, 1, 120, len(rdata)) + rdata
28
+ return header + body
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Which names this is for
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ @pytest.mark.parametrize(
37
+ "host,expected",
38
+ [
39
+ ("skykot.local", True),
40
+ ("skykot.local.", True),
41
+ ("SKYKOT.LOCAL", True),
42
+ ("allsky.example.com", False),
43
+ ("192.168.1.42", False),
44
+ (".local", False),
45
+ ("", False),
46
+ ],
47
+ )
48
+ def test_only_local_names_are_asked_of_the_network(host, expected):
49
+ """Everything else belongs to the machine's resolver and stays there."""
50
+ assert mdns.is_mdns_name(host) is expected
51
+
52
+
53
+ def test_a_name_that_is_not_local_is_not_even_asked(monkeypatch):
54
+ def no_sockets(*a, **k):
55
+ raise AssertionError("opened a socket")
56
+
57
+ monkeypatch.setattr(mdns.socket, "socket", no_sockets)
58
+ assert mdns.resolve("allsky.example.com") == []
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # The query
63
+ # ---------------------------------------------------------------------------
64
+
65
+
66
+ def test_the_query_asks_for_one_a_record_by_name():
67
+ query = mdns._build_query(NAME)
68
+ _, _, questions, answers, _, _ = mdns._HEADER.unpack_from(query)
69
+ assert (questions, answers) == (1, 0)
70
+ assert mdns._encode_name(NAME) in query
71
+
72
+
73
+ def test_the_query_asks_to_be_answered_directly():
74
+ """Without the unicast bit the answer goes to port 5353, where the
75
+ system responder is listening instead of us."""
76
+ query = mdns._build_query(NAME)
77
+ _, qclass = struct.unpack("!HH", query[-4:])
78
+ assert qclass & mdns._UNICAST_RESPONSE
79
+ assert qclass & 0x7FFF == mdns._CLASS_IN
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # The answer
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ def test_an_address_is_read_from_the_answer():
88
+ packet = _response([(NAME, 1, socket.inet_aton("192.168.1.42"))])
89
+ assert mdns._parse_response(packet, NAME) == ["192.168.1.42"]
90
+
91
+
92
+ def test_a_name_written_as_a_pointer_is_followed():
93
+ """Responders routinely point back at the question instead of repeating
94
+ the name, so a decoder that cannot follow one reads garbage."""
95
+ header = mdns._HEADER.pack(0, 0x8400, 1, 1, 0, 0)
96
+ question = mdns._encode_name(NAME) + struct.pack("!HH", 1, 1)
97
+ pointer = struct.pack("!H", 0xC000 | mdns._HEADER.size)
98
+ answer = pointer + struct.pack("!HHIH", 1, 1, 120, 4)
99
+ answer += socket.inet_aton("192.168.1.42")
100
+
101
+ assert mdns._parse_response(header + question + answer, NAME) == ["192.168.1.42"]
102
+
103
+
104
+ def test_an_answer_about_another_machine_is_not_taken():
105
+ """A responder may say whatever it finds useful. Taking one of those
106
+ would send the proxy to a different machine entirely."""
107
+ packet = _response([("other.local", 1, socket.inet_aton("10.0.0.1"))])
108
+ assert mdns._parse_response(packet, NAME) == []
109
+
110
+
111
+ def test_the_right_answer_is_found_among_others():
112
+ packet = _response(
113
+ [
114
+ ("other.local", 1, socket.inet_aton("10.0.0.1")),
115
+ (NAME, 1, socket.inet_aton("192.168.1.42")),
116
+ ]
117
+ )
118
+ assert mdns._parse_response(packet, NAME) == ["192.168.1.42"]
119
+
120
+
121
+ def test_every_address_a_machine_answers_with_is_kept():
122
+ """A machine with more than one interface answers with one address each,
123
+ and which of them the caller can reach is not knowable from here."""
124
+ packet = _response(
125
+ [
126
+ (NAME, 1, socket.inet_aton("192.168.64.1")),
127
+ (NAME, 1, socket.inet_aton("192.168.1.97")),
128
+ ]
129
+ )
130
+ assert mdns._parse_response(packet, NAME) == ["192.168.64.1", "192.168.1.97"]
131
+
132
+
133
+ def test_the_same_address_twice_is_kept_once():
134
+ packet = _response(
135
+ [
136
+ (NAME, 1, socket.inet_aton("192.168.1.97")),
137
+ (NAME, 1, socket.inet_aton("192.168.1.97")),
138
+ ]
139
+ )
140
+ assert mdns._parse_response(packet, NAME) == ["192.168.1.97"]
141
+
142
+
143
+ def test_a_record_that_is_not_an_address_is_ignored():
144
+ packet = _response([(NAME, 12, mdns._encode_name("something.local"))])
145
+ assert mdns._parse_response(packet, NAME) == []
146
+
147
+
148
+ @pytest.mark.parametrize(
149
+ "packet",
150
+ [
151
+ b"",
152
+ b"\x00\x01\x02",
153
+ mdns._HEADER.pack(0, 0x8400, 1, 1, 0, 0) + b"\x05sky", # truncated name
154
+ # A pointer to itself: a packet from the network must not be trusted
155
+ # to avoid a loop.
156
+ mdns._HEADER.pack(0, 0x8400, 0, 1, 0, 0) + struct.pack("!H", 0xC00C),
157
+ ],
158
+ )
159
+ def test_a_malformed_packet_is_ignored_rather_than_fatal(packet):
160
+ """Anyone on the network can send one, and the proxy must survive it."""
161
+ assert mdns._parse_response(packet, NAME) == []
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # The query as a whole
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ def test_nothing_answering_is_an_answer(monkeypatch):
170
+ class SilentSocket:
171
+ def setsockopt(self, *a):
172
+ pass
173
+
174
+ def settimeout(self, *a):
175
+ pass
176
+
177
+ def bind(self, *a):
178
+ pass
179
+
180
+ def sendto(self, *a):
181
+ pass
182
+
183
+ def recvfrom(self, *a):
184
+ raise socket.timeout()
185
+
186
+ def close(self):
187
+ pass
188
+
189
+ monkeypatch.setattr(mdns.socket, "socket", lambda *a, **k: SilentSocket())
190
+ assert mdns.resolve(NAME, timeout=0.05) == []
191
+
192
+
193
+ def test_a_network_that_refuses_multicast_is_not_fatal(monkeypatch):
194
+ """No route, or a firewall in the way: that means "not resolved here",
195
+ which is a thing the caller reports rather than a crash."""
196
+
197
+ def no_multicast(*a, **k):
198
+ raise OSError("Network is unreachable")
199
+
200
+ monkeypatch.setattr(mdns.socket, "socket", no_multicast)
201
+ assert mdns.resolve(NAME, timeout=0.05) == []
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # The resolver the proxy hands to aiohttp
206
+ # ---------------------------------------------------------------------------
207
+
208
+
209
+ def _resolve(host, family=socket.AF_INET, default=None):
210
+ """Run the proxy's resolver for ``host``, with ``default`` standing in for
211
+ the machine's own resolver."""
212
+ import asyncio
213
+ from unittest.mock import AsyncMock, patch
214
+
215
+ from arcsecond.imagesources.sources.network import _mdns_aware_resolver
216
+
217
+ if default is None:
218
+ machine = AsyncMock(side_effect=socket.gaierror(-2, "not known"))
219
+ else:
220
+ machine = AsyncMock(return_value=default)
221
+
222
+ async def main():
223
+ resolver = _mdns_aware_resolver()
224
+ with patch.object(
225
+ resolver, "_default", AsyncMock(resolve=machine, close=AsyncMock())
226
+ ):
227
+ try:
228
+ return await resolver.resolve(host, 80, family)
229
+ finally:
230
+ await resolver.close()
231
+
232
+ return asyncio.run(main())
233
+
234
+
235
+ def test_the_machine_resolver_is_asked_first():
236
+ """It is the answer almost every time, and mDNS is only for what is left."""
237
+ answer = [{"hostname": "cam.example.com", "host": "10.0.0.5", "port": 80}]
238
+ assert _resolve("cam.example.com", default=answer) == answer
239
+
240
+
241
+ def test_a_local_name_falls_back_to_the_network(monkeypatch):
242
+ monkeypatch.setattr(mdns, "resolve", lambda host, *a, **k: ["192.168.1.42"])
243
+
244
+ (result,) = _resolve(NAME)
245
+ assert result["host"] == "192.168.1.42"
246
+ assert result["hostname"] == NAME
247
+ assert result["port"] == 80
248
+ assert result["family"] == socket.AF_INET
249
+
250
+
251
+ def test_every_address_is_offered_to_aiohttp(monkeypatch):
252
+ """aiohttp tries them in turn, which is what makes a multi-homed
253
+ responder work rather than work half the time."""
254
+ monkeypatch.setattr(
255
+ mdns, "resolve", lambda host, *a, **k: ["192.168.64.1", "192.168.1.97"]
256
+ )
257
+ assert [r["host"] for r in _resolve(NAME)] == ["192.168.64.1", "192.168.1.97"]
258
+
259
+
260
+ def test_a_name_nothing_answers_for_still_fails(monkeypatch):
261
+ """The connection error the operator gets must stay the real one."""
262
+ monkeypatch.setattr(mdns, "resolve", lambda host, *a, **k: [])
263
+
264
+ with pytest.raises(socket.gaierror):
265
+ _resolve(NAME)
266
+
267
+
268
+ def test_an_ipv6_lookup_is_not_answered_with_an_ipv4_address(monkeypatch):
269
+ """Only A records are asked for, so this is not ours to answer."""
270
+ monkeypatch.setattr(mdns, "resolve", lambda host, *a, **k: ["192.168.1.42"])
271
+
272
+ with pytest.raises(socket.gaierror):
273
+ _resolve(NAME, family=socket.AF_INET6)
@@ -155,7 +155,7 @@ wheels = [
155
155
 
156
156
  [[package]]
157
157
  name = "arcsecond"
158
- version = "3.18.2"
158
+ version = "3.20.0"
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