memgres 0.5.0__tar.gz → 0.5.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 (72) hide show
  1. {memgres-0.5.0 → memgres-0.5.2}/PKG-INFO +1 -1
  2. {memgres-0.5.0 → memgres-0.5.2}/memgres/_version.py +1 -1
  3. memgres-0.5.2/memgres/healthcheck.py +88 -0
  4. {memgres-0.5.0 → memgres-0.5.2}/memgres/mcp_server.py +12 -3
  5. {memgres-0.5.0 → memgres-0.5.2}/memgres.egg-info/PKG-INFO +1 -1
  6. {memgres-0.5.0 → memgres-0.5.2}/memgres.egg-info/SOURCES.txt +3 -0
  7. {memgres-0.5.0 → memgres-0.5.2}/memgres.egg-info/entry_points.txt +1 -0
  8. {memgres-0.5.0 → memgres-0.5.2}/pyproject.toml +1 -0
  9. memgres-0.5.2/tests/test_healthcheck.py +146 -0
  10. memgres-0.5.2/tests/test_mcp_http_transport.py +86 -0
  11. {memgres-0.5.0 → memgres-0.5.2}/LICENSE +0 -0
  12. {memgres-0.5.0 → memgres-0.5.2}/README.md +0 -0
  13. {memgres-0.5.0 → memgres-0.5.2}/memgres/__init__.py +0 -0
  14. {memgres-0.5.0 → memgres-0.5.2}/memgres/admin_cli.py +0 -0
  15. {memgres-0.5.0 → memgres-0.5.2}/memgres/blame.py +0 -0
  16. {memgres-0.5.0 → memgres-0.5.2}/memgres/bootstrap.py +0 -0
  17. {memgres-0.5.0 → memgres-0.5.2}/memgres/config.py +0 -0
  18. {memgres-0.5.0 → memgres-0.5.2}/memgres/diffing.py +0 -0
  19. {memgres-0.5.0 → memgres-0.5.2}/memgres/embed_worker.py +0 -0
  20. {memgres-0.5.0 → memgres-0.5.2}/memgres/embeddings.py +0 -0
  21. {memgres-0.5.0 → memgres-0.5.2}/memgres/identity.py +0 -0
  22. {memgres-0.5.0 → memgres-0.5.2}/memgres/indexing.py +0 -0
  23. {memgres-0.5.0 → memgres-0.5.2}/memgres/info.py +0 -0
  24. {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0001_core.sql +0 -0
  25. {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0002_identity.sql +0 -0
  26. {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0003_history_author.sql +0 -0
  27. {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0004_title.sql +0 -0
  28. {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0005_chunk_index.sql +0 -0
  29. {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0006_reader_floor.sql +0 -0
  30. {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0007_embed_retry.sql +0 -0
  31. {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0008_service_roles.sql +0 -0
  32. {memgres-0.5.0 → memgres-0.5.2}/memgres/reembed.py +0 -0
  33. {memgres-0.5.0 → memgres-0.5.2}/memgres/schema.py +0 -0
  34. {memgres-0.5.0 → memgres-0.5.2}/memgres/search.py +0 -0
  35. {memgres-0.5.0 → memgres-0.5.2}/memgres/segments.py +0 -0
  36. {memgres-0.5.0 → memgres-0.5.2}/memgres/server.py +0 -0
  37. {memgres-0.5.0 → memgres-0.5.2}/memgres/store.py +0 -0
  38. {memgres-0.5.0 → memgres-0.5.2}/memgres/vector/__init__.py +0 -0
  39. {memgres-0.5.0 → memgres-0.5.2}/memgres/vector/base.py +0 -0
  40. {memgres-0.5.0 → memgres-0.5.2}/memgres/vector/pgvector.py +0 -0
  41. {memgres-0.5.0 → memgres-0.5.2}/memgres/vector/qdrant.py +0 -0
  42. {memgres-0.5.0 → memgres-0.5.2}/memgres/worker.py +0 -0
  43. {memgres-0.5.0 → memgres-0.5.2}/memgres.egg-info/dependency_links.txt +0 -0
  44. {memgres-0.5.0 → memgres-0.5.2}/memgres.egg-info/requires.txt +0 -0
  45. {memgres-0.5.0 → memgres-0.5.2}/memgres.egg-info/top_level.txt +0 -0
  46. {memgres-0.5.0 → memgres-0.5.2}/setup.cfg +0 -0
  47. {memgres-0.5.0 → memgres-0.5.2}/tests/test_blame_integration.py +0 -0
  48. {memgres-0.5.0 → memgres-0.5.2}/tests/test_chunk_index.py +0 -0
  49. {memgres-0.5.0 → memgres-0.5.2}/tests/test_claim_and_reembed.py +0 -0
  50. {memgres-0.5.0 → memgres-0.5.2}/tests/test_config.py +0 -0
  51. {memgres-0.5.0 → memgres-0.5.2}/tests/test_diffing.py +0 -0
  52. {memgres-0.5.0 → memgres-0.5.2}/tests/test_embed_worker.py +0 -0
  53. {memgres-0.5.0 → memgres-0.5.2}/tests/test_embeddings.py +0 -0
  54. {memgres-0.5.0 → memgres-0.5.2}/tests/test_identity_integration.py +0 -0
  55. {memgres-0.5.0 → memgres-0.5.2}/tests/test_lexical_match.py +0 -0
  56. {memgres-0.5.0 → memgres-0.5.2}/tests/test_limits.py +0 -0
  57. {memgres-0.5.0 → memgres-0.5.2}/tests/test_list.py +0 -0
  58. {memgres-0.5.0 → memgres-0.5.2}/tests/test_mcp_instructions.py +0 -0
  59. {memgres-0.5.0 → memgres-0.5.2}/tests/test_mcp_recall_schema.py +0 -0
  60. {memgres-0.5.0 → memgres-0.5.2}/tests/test_migration_upgrade.py +0 -0
  61. {memgres-0.5.0 → memgres-0.5.2}/tests/test_qdrant_ca.py +0 -0
  62. {memgres-0.5.0 → memgres-0.5.2}/tests/test_qdrant_integration.py +0 -0
  63. {memgres-0.5.0 → memgres-0.5.2}/tests/test_replace_build.py +0 -0
  64. {memgres-0.5.0 → memgres-0.5.2}/tests/test_roles_bootstrap.py +0 -0
  65. {memgres-0.5.0 → memgres-0.5.2}/tests/test_search_integration.py +0 -0
  66. {memgres-0.5.0 → memgres-0.5.2}/tests/test_security_integration.py +0 -0
  67. {memgres-0.5.0 → memgres-0.5.2}/tests/test_segments.py +0 -0
  68. {memgres-0.5.0 → memgres-0.5.2}/tests/test_segments_store.py +0 -0
  69. {memgres-0.5.0 → memgres-0.5.2}/tests/test_server_info.py +0 -0
  70. {memgres-0.5.0 → memgres-0.5.2}/tests/test_server_integration.py +0 -0
  71. {memgres-0.5.0 → memgres-0.5.2}/tests/test_snippets.py +0 -0
  72. {memgres-0.5.0 → memgres-0.5.2}/tests/test_store_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Drop-in memory for AI agents: one Postgres, lexical + semantic recall, diff-versioned history, GDPR-erasable.
5
5
  Author: mozgsml
6
6
  License-Expression: MIT
@@ -8,4 +8,4 @@ here at release; nowhere else carries the number.
8
8
  PEP 440: a ``.devN`` suffix marks an unreleased build ahead of the last tag.
9
9
  """
10
10
 
11
- __version__ = "0.5.0"
11
+ __version__ = "0.5.2"
@@ -0,0 +1,88 @@
1
+ """Container liveness probe that knows which role the container is running.
2
+
3
+ One image, two entrypoints: ``memgres-server`` (REST, ``MEMGRES_HTTP_PORT``,
4
+ answers ``/healthz``) and ``memgres-mcp`` (MCP, ``MEMGRES_MCP_PORT``, answers
5
+ ``/mcp`` and has no ``/healthz``). The old Dockerfile HEALTHCHECK probed a
6
+ hardcoded ``localhost:8080/healthz``, so an MCP container was reported unhealthy
7
+ forever — it never listens on 8080. A permanently-red healthcheck is worse than
8
+ none: it masks real failures and blocks anything waiting on ``service_healthy``.
9
+
10
+ The role comes from PID 1's argv, not from an env convention, so it stays right
11
+ even when a deployment sets MCP variables on a REST container or vice versa.
12
+
13
+ Two details that make the probe honest rather than merely green:
14
+
15
+ * **127.0.0.1, not ``localhost``** — a server bound to ``0.0.0.0`` listens on
16
+ IPv4 only, while ``localhost`` may resolve to ``::1`` first and be refused.
17
+ * **For MCP, any HTTP answer means healthy.** Streamable HTTP rejects a bare GET
18
+ with 400; that response still proves the port is bound and the app is serving.
19
+ Only a refused connection or a timeout is a real failure.
20
+ """
21
+
22
+ import os
23
+ import sys
24
+ import urllib.error
25
+ import urllib.request
26
+
27
+ #: MEMGRES_MCP_TRANSPORT values that make the MCP server listen on a socket.
28
+ #: Anything else (i.e. stdio) is spoken over the process's own pipes.
29
+ HTTP_TRANSPORTS = ("http", "streamable-http", "streamable_http")
30
+
31
+ _TIMEOUT = 3
32
+
33
+
34
+ def _pid1_argv() -> list:
35
+ """PID 1's argv — the command the container was actually started with."""
36
+ try:
37
+ with open("/proc/1/cmdline", "rb") as fh:
38
+ return [a for a in fh.read().decode("utf-8", "replace").split("\0") if a]
39
+ except OSError:
40
+ return [] # not Linux / no procfs — fall back to REST
41
+
42
+
43
+ def role(argv=None) -> str:
44
+ """``"mcp"`` or ``"rest"``. REST is the default, matching the image's CMD."""
45
+ argv = _pid1_argv() if argv is None else argv
46
+ for arg in argv:
47
+ if "memgres-mcp" in arg or "mcp_server" in arg:
48
+ return "mcp"
49
+ return "rest"
50
+
51
+
52
+ def _answers(url: str) -> bool:
53
+ """True if something at `url` speaks HTTP — any status, including errors."""
54
+ try:
55
+ urllib.request.urlopen(url, timeout=_TIMEOUT)
56
+ return True
57
+ except urllib.error.HTTPError:
58
+ return True # bound and serving (MCP GET → 400)
59
+ except (urllib.error.URLError, OSError):
60
+ return False # refused, unresolved, timed out
61
+
62
+
63
+ def _status_ok(url: str) -> bool:
64
+ """True only on a 200 — used where a real health endpoint exists."""
65
+ try:
66
+ return urllib.request.urlopen(url, timeout=_TIMEOUT).status == 200
67
+ except (urllib.error.HTTPError, urllib.error.URLError, OSError):
68
+ return False
69
+
70
+
71
+ def check() -> int:
72
+ """Exit code for the probe: 0 healthy, 1 unhealthy."""
73
+ if role() == "mcp":
74
+ transport = os.environ.get("MEMGRES_MCP_TRANSPORT", "stdio").lower()
75
+ if transport not in HTTP_TRANSPORTS:
76
+ return 0 # stdio: no socket; docker already sees the process
77
+ port = os.environ.get("MEMGRES_MCP_PORT", "8765")
78
+ return 0 if _answers(f"http://127.0.0.1:{port}/mcp") else 1
79
+ port = os.environ.get("MEMGRES_HTTP_PORT", "8080")
80
+ return 0 if _status_ok(f"http://127.0.0.1:{port}/healthz") else 1
81
+
82
+
83
+ def main(): # pragma: no cover - entrypoint
84
+ sys.exit(check())
85
+
86
+
87
+ if __name__ == "__main__": # pragma: no cover
88
+ main()
@@ -426,9 +426,18 @@ def main(): # pragma: no cover - entrypoint
426
426
  server = build_server()
427
427
  transport = os.environ.get("MEMGRES_MCP_TRANSPORT", "stdio").lower()
428
428
  if transport in ("http", "streamable-http", "streamable_http"):
429
- server.settings.host = os.environ.get("MEMGRES_MCP_HOST", "0.0.0.0")
430
- server.settings.port = int(os.environ.get("MEMGRES_MCP_PORT", "8765"))
431
- server.run(transport="streamable-http")
429
+ host = os.environ.get("MEMGRES_MCP_HOST", "0.0.0.0")
430
+ port = int(os.environ.get("MEMGRES_MCP_PORT", "8765"))
431
+ # mcp SDK 1.x (FastMCP) reads host/port from `settings`; 2.x (MCPServer)
432
+ # dropped those fields and takes them as run() kwargs (forwarded to
433
+ # run_streamable_http_async). Pick the path the installed SDK supports.
434
+ fields = getattr(type(server.settings), "model_fields", {})
435
+ if "host" in fields:
436
+ server.settings.host = host
437
+ server.settings.port = port
438
+ server.run(transport="streamable-http")
439
+ else:
440
+ server.run(transport="streamable-http", host=host, port=port)
432
441
  else:
433
442
  server.run()
434
443
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Drop-in memory for AI agents: one Postgres, lexical + semantic recall, diff-versioned history, GDPR-erasable.
5
5
  Author: mozgsml
6
6
  License-Expression: MIT
@@ -10,6 +10,7 @@ memgres/config.py
10
10
  memgres/diffing.py
11
11
  memgres/embed_worker.py
12
12
  memgres/embeddings.py
13
+ memgres/healthcheck.py
13
14
  memgres/identity.py
14
15
  memgres/indexing.py
15
16
  memgres/info.py
@@ -46,10 +47,12 @@ tests/test_config.py
46
47
  tests/test_diffing.py
47
48
  tests/test_embed_worker.py
48
49
  tests/test_embeddings.py
50
+ tests/test_healthcheck.py
49
51
  tests/test_identity_integration.py
50
52
  tests/test_lexical_match.py
51
53
  tests/test_limits.py
52
54
  tests/test_list.py
55
+ tests/test_mcp_http_transport.py
53
56
  tests/test_mcp_instructions.py
54
57
  tests/test_mcp_recall_schema.py
55
58
  tests/test_migration_upgrade.py
@@ -1,5 +1,6 @@
1
1
  [console_scripts]
2
2
  memgres-grant-superadmin = memgres.admin_cli:main
3
+ memgres-healthcheck = memgres.healthcheck:main
3
4
  memgres-mcp = memgres.mcp_server:main
4
5
  memgres-reembed = memgres.reembed:main
5
6
  memgres-server = memgres.server:main
@@ -41,6 +41,7 @@ memgres-mcp = "memgres.mcp_server:main"
41
41
  memgres-worker = "memgres.worker:main"
42
42
  memgres-reembed = "memgres.reembed:main"
43
43
  memgres-grant-superadmin = "memgres.admin_cli:main"
44
+ memgres-healthcheck = "memgres.healthcheck:main"
44
45
 
45
46
  [build-system]
46
47
  requires = ["setuptools>=68"]
@@ -0,0 +1,146 @@
1
+ """The container probe must match the role the container is running.
2
+
3
+ Regression guard: the Dockerfile HEALTHCHECK used to hit a fixed
4
+ ``localhost:8080/healthz``, which the MCP entrypoint never serves — every MCP
5
+ container sat "unhealthy" forever while serving fine. The last test here is that
6
+ exact scenario.
7
+
8
+ No database needed: these exercise role detection and the probes against a
9
+ throwaway HTTP server.
10
+ """
11
+
12
+ import socket
13
+ import threading
14
+ from http.server import BaseHTTPRequestHandler, HTTPServer
15
+
16
+ import pytest
17
+
18
+ from memgres import healthcheck as hc
19
+
20
+
21
+ def _serve(status: int):
22
+ """Run a one-route HTTP server on a free port; yields its port."""
23
+ class Handler(BaseHTTPRequestHandler):
24
+ def do_GET(self): # noqa: N802 - stdlib naming
25
+ self.send_response(status)
26
+ self.end_headers()
27
+ self.wfile.write(b"{}")
28
+
29
+ def log_message(self, *a): # keep pytest output clean
30
+ pass
31
+
32
+ srv = HTTPServer(("127.0.0.1", 0), Handler)
33
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
34
+ return srv
35
+
36
+
37
+ def _free_port() -> int:
38
+ s = socket.socket()
39
+ s.bind(("127.0.0.1", 0))
40
+ port = s.getsockname()[1]
41
+ s.close()
42
+ return port
43
+
44
+
45
+ # ─── role detection ────────────────────────────────────────────────────────────
46
+
47
+ @pytest.mark.parametrize("argv", [
48
+ ["/usr/local/bin/memgres-mcp"],
49
+ ["python", "-m", "memgres.mcp_server"],
50
+ ])
51
+ def test_role_detects_mcp(argv):
52
+ assert hc.role(argv) == "mcp"
53
+
54
+
55
+ @pytest.mark.parametrize("argv", [
56
+ ["/usr/local/bin/memgres-server"],
57
+ [], # no procfs → default
58
+ ])
59
+ def test_role_defaults_to_rest(argv):
60
+ assert hc.role(argv) == "rest"
61
+
62
+
63
+ # ─── probes ────────────────────────────────────────────────────────────────────
64
+
65
+ def test_answers_accepts_any_http_status():
66
+ """A bare GET to Streamable HTTP returns 400 — still proof it is serving."""
67
+ srv = _serve(400)
68
+ try:
69
+ assert hc._answers(f"http://127.0.0.1:{srv.server_port}/mcp") is True
70
+ finally:
71
+ srv.shutdown()
72
+
73
+
74
+ def test_answers_false_when_nothing_listens():
75
+ assert hc._answers(f"http://127.0.0.1:{_free_port()}/mcp") is False
76
+
77
+
78
+ def test_status_ok_requires_200():
79
+ srv = _serve(500)
80
+ try:
81
+ assert hc._status_ok(f"http://127.0.0.1:{srv.server_port}/healthz") is False
82
+ finally:
83
+ srv.shutdown()
84
+
85
+
86
+ # ─── check() per role ──────────────────────────────────────────────────────────
87
+
88
+ def test_mcp_http_healthy_when_port_answers(monkeypatch):
89
+ srv = _serve(400)
90
+ try:
91
+ monkeypatch.setattr(hc, "role", lambda *a: "mcp")
92
+ monkeypatch.setenv("MEMGRES_MCP_TRANSPORT", "http")
93
+ monkeypatch.setenv("MEMGRES_MCP_PORT", str(srv.server_port))
94
+ assert hc.check() == 0
95
+ finally:
96
+ srv.shutdown()
97
+
98
+
99
+ def test_mcp_http_unhealthy_when_dead(monkeypatch):
100
+ monkeypatch.setattr(hc, "role", lambda *a: "mcp")
101
+ monkeypatch.setenv("MEMGRES_MCP_TRANSPORT", "http")
102
+ monkeypatch.setenv("MEMGRES_MCP_PORT", str(_free_port()))
103
+ assert hc.check() == 1
104
+
105
+
106
+ def test_mcp_stdio_is_healthy_without_a_socket(monkeypatch):
107
+ """stdio speaks over pipes; there is no port to probe, so never fail it."""
108
+ monkeypatch.setattr(hc, "role", lambda *a: "mcp")
109
+ monkeypatch.delenv("MEMGRES_MCP_TRANSPORT", raising=False)
110
+ assert hc.check() == 0
111
+
112
+
113
+ def test_rest_healthy_on_200(monkeypatch):
114
+ srv = _serve(200)
115
+ try:
116
+ monkeypatch.setattr(hc, "role", lambda *a: "rest")
117
+ monkeypatch.setenv("MEMGRES_HTTP_PORT", str(srv.server_port))
118
+ assert hc.check() == 0
119
+ finally:
120
+ srv.shutdown()
121
+
122
+
123
+ def test_rest_honours_configured_port(monkeypatch):
124
+ """The old probe hardcoded 8080 and broke on a remapped REST port."""
125
+ srv = _serve(200)
126
+ try:
127
+ monkeypatch.setattr(hc, "role", lambda *a: "rest")
128
+ monkeypatch.setenv("MEMGRES_HTTP_PORT", str(srv.server_port))
129
+ assert hc.check() == 0
130
+ monkeypatch.setenv("MEMGRES_HTTP_PORT", str(_free_port()))
131
+ assert hc.check() == 1
132
+ finally:
133
+ srv.shutdown()
134
+
135
+
136
+ def test_regression_mcp_container_without_8080_is_healthy(monkeypatch):
137
+ """THE BUG: MCP serves on 8765 and nothing listens on 8080 — must be green."""
138
+ srv = _serve(400)
139
+ try:
140
+ monkeypatch.setattr(hc, "role", lambda *a: "mcp")
141
+ monkeypatch.setenv("MEMGRES_MCP_TRANSPORT", "streamable-http")
142
+ monkeypatch.setenv("MEMGRES_MCP_PORT", str(srv.server_port))
143
+ monkeypatch.setenv("MEMGRES_HTTP_PORT", "8080") # nothing there, as in prod
144
+ assert hc.check() == 0
145
+ finally:
146
+ srv.shutdown()
@@ -0,0 +1,86 @@
1
+ """The http transport must actually bind its port.
2
+
3
+ Regression guard for the mcp SDK 2.x drift: `main()` used to set
4
+ `server.settings.host`, which 2.x's MCPServer rejects ("Settings object has no
5
+ field host"), so the server crash-looped and never served. This spawns the real
6
+ entrypoint over HTTP and asserts the port answers.
7
+ """
8
+
9
+ import os
10
+ import socket
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+
20
+ psycopg = pytest.importorskip("psycopg")
21
+
22
+ DSN = os.environ.get("MEMGRES_TEST_DSN",
23
+ "postgresql://memgres:memgres@localhost:55432/memgres")
24
+
25
+
26
+ def _reachable() -> bool:
27
+ try:
28
+ psycopg.connect(DSN, connect_timeout=2).close()
29
+ return True
30
+ except Exception:
31
+ return False
32
+
33
+
34
+ pytestmark = pytest.mark.skipif(not _reachable(), reason="no test Postgres")
35
+
36
+
37
+ def _free_port() -> int:
38
+ s = socket.socket()
39
+ s.bind(("127.0.0.1", 0))
40
+ port = s.getsockname()[1]
41
+ s.close()
42
+ return port
43
+
44
+
45
+ def test_http_transport_binds_and_answers():
46
+ port = _free_port()
47
+ env = {**os.environ,
48
+ "MEMGRES_DATABASE_URL": DSN,
49
+ "MEMGRES_KEY_MODE": "single",
50
+ "MEMGRES_EMBED_PROVIDER": "none",
51
+ "MEMGRES_MCP_TRANSPORT": "http",
52
+ "MEMGRES_MCP_HOST": "127.0.0.1",
53
+ "MEMGRES_MCP_PORT": str(port)}
54
+ root = str(Path(__file__).resolve().parent.parent)
55
+ env["PYTHONPATH"] = root + os.pathsep + env.get("PYTHONPATH", "")
56
+ proc = subprocess.Popen([sys.executable, "-m", "memgres.mcp_server"],
57
+ env=env, stdout=subprocess.PIPE,
58
+ stderr=subprocess.STDOUT, text=True)
59
+ url = f"http://127.0.0.1:{port}/mcp"
60
+ try:
61
+ deadline = time.time() + 25
62
+ answered = False
63
+ while time.time() < deadline:
64
+ if proc.poll() is not None:
65
+ out = proc.stdout.read() if proc.stdout else ""
66
+ pytest.fail(f"server exited early (code {proc.returncode}):\n{out}")
67
+ try:
68
+ urllib.request.urlopen(url, timeout=2)
69
+ answered = True # 2xx (unlikely for a bare GET)
70
+ break
71
+ except urllib.error.HTTPError:
72
+ answered = True # bound and speaking HTTP (e.g. 400/406)
73
+ break
74
+ except (urllib.error.URLError, ConnectionError, OSError):
75
+ time.sleep(0.5) # not up yet — retry
76
+ assert answered, f"{url} never answered — the transport did not bind"
77
+ finally:
78
+ proc.terminate()
79
+ try:
80
+ proc.wait(timeout=10)
81
+ except subprocess.TimeoutExpired:
82
+ proc.kill()
83
+
84
+
85
+ if __name__ == "__main__": # pragma: no cover
86
+ sys.exit(pytest.main([__file__, "-v"]))
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
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
File without changes
File without changes
File without changes