ReMe/tests/unit/test_service_utils.py
jinliyl 618e8cec66
feat: add entry-point plugin system and extract Auto Fin (#459)
* feat: add entry-point plugin system

* fix: harden plugin config and client loading

* docs(workflow): add detailed manual for publishing reme-auto-fin to PyPI

- Provide step-by-step instructions for updating project.version and merging branches
- Explain dependency verification for reme-ai on PyPI during build
- Specify requirements for GitHub Actions secret configuration and version uniqueness
- Describe manual workflow triggering and input of version number
- Recommend publishing order for related projects
- Clarify that only manual dispatch triggers publishing, no automatic triggers on push or tag

* feat: support plugin-defined component types

* refactor: simplify plugin configuration

* fix: isolate plugin loading and defer client fallback

* refactor: freeze built-in component registry

* fix: isolate config entry point loading

* fix: complete auto-fin package metadata
2026-08-19 17:23:23 +08:00

177 lines
6.2 KiB
Python

"""Tests for the psutil-based service discovery helpers in ``service_utils``.
Covers the cross-platform replacements for the old ``lsof`` / ``pgrep``
shell-outs:
* ``_pid_on_port`` — real integration (open a listening socket, find its
PID) plus a mock test proving inaccessible processes are skipped, not
crashed on.
* ``_scan_reme_procs`` — mock ``psutil.process_iter`` to assert cmdline
parsing, defaulting, filtering, and AccessDenied tolerance.
"""
# pylint: disable=protected-access,missing-function-docstring,unused-argument
import os
import socket
from types import SimpleNamespace
import psutil
from reme.utils import service_utils as su
# ----------------------------------------------------------------------
# Fakes for mocking psutil.process_iter
# ----------------------------------------------------------------------
def _conn(port: int, status=psutil.CONN_LISTEN, has_laddr: bool = True):
return SimpleNamespace(status=status, laddr=SimpleNamespace(port=port) if has_laddr else None)
class _FakeProc:
"""Stand-in for a psutil.Process yielded by process_iter."""
def __init__(self, pid: int, *, cmdline=None, conns=None, conn_exc=None):
self.info = {"pid": pid, "cmdline": cmdline if cmdline is not None else []}
self._conns = conns or []
self._conn_exc = conn_exc
def net_connections(self, kind="tcp"):
if self._conn_exc is not None:
raise self._conn_exc
return self._conns
class _RaisingInfoProc:
"""Process whose `.info` access raises (simulates AccessDenied in iteration)."""
def __init__(self, exc):
self._exc = exc
@property
def info(self):
raise self._exc
def _patch_iter(monkeypatch, procs):
monkeypatch.setattr(su.psutil, "process_iter", lambda attrs=None: iter(procs))
# ----------------------------------------------------------------------
# _pid_on_port
# ----------------------------------------------------------------------
def test_pid_on_port_finds_own_listening_socket():
"""Real integration: a listening socket is attributed to this process's PID."""
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(1)
port = srv.getsockname()[1]
try:
assert su._pid_on_port(port) == os.getpid()
finally:
srv.close()
def test_pid_on_port_none_when_nobody_listening():
"""A port with no listener resolves to None."""
# Grab then immediately release an ephemeral port to get a number that is
# (almost certainly) unbound right now.
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
free_port = s.getsockname()[1]
s.close()
assert su._pid_on_port(free_port) is None
def test_pid_on_port_skips_inaccessible_processes(monkeypatch):
"""AccessDenied on one process must not abort the scan of the rest."""
procs = [
_FakeProc(111, conn_exc=psutil.AccessDenied(pid=111)),
_FakeProc(222, conn_exc=psutil.NoSuchProcess(pid=222)),
_FakeProc(333, conns=[_conn(9999)]),
]
_patch_iter(monkeypatch, procs)
assert su._pid_on_port(9999) == 333
def test_pid_on_port_ignores_non_listen_and_mismatched(monkeypatch):
"""Only LISTEN sockets on the exact port match."""
procs = [
_FakeProc(1, conns=[_conn(9999, status=psutil.CONN_ESTABLISHED)]), # right port, wrong state
_FakeProc(2, conns=[_conn(8888)]), # listening, wrong port
_FakeProc(3, conns=[_conn(9999, has_laddr=False)]), # no laddr
]
_patch_iter(monkeypatch, procs)
assert su._pid_on_port(9999) is None
# ----------------------------------------------------------------------
# _scan_reme_procs
# ----------------------------------------------------------------------
def test_scan_reme_procs_parses_host_and_port(monkeypatch):
procs = [
_FakeProc(
123,
cmdline=["python", "-m", "reme.reme", "start", "service.host=0.0.0.0", "service.port=8123"],
),
]
_patch_iter(monkeypatch, procs)
assert su._scan_reme_procs() == [(123, "0.0.0.0", 8123)]
def test_scan_reme_procs_defaults_when_args_absent(monkeypatch):
procs = [_FakeProc(7, cmdline=["reme", "start"])]
_patch_iter(monkeypatch, procs)
assert su._scan_reme_procs() == [(7, su.REME_DEFAULT_HOST, su.REME_DEFAULT_PORT)]
def test_scan_reme_procs_filters_unrelated(monkeypatch):
procs = [
_FakeProc(1, cmdline=["reme", "find_reme"]), # no 'start' token
_FakeProc(2, cmdline=["python", "-m", "http.server", "start"]), # no reme token
_FakeProc(3, cmdline=[]), # empty cmdline
]
_patch_iter(monkeypatch, procs)
assert not su._scan_reme_procs()
def test_scan_reme_procs_ignores_non_digit_port(monkeypatch):
"""A malformed service.port= falls back to the default port, not a crash."""
procs = [_FakeProc(9, cmdline=["reme", "start", "service.port=notaport"])]
_patch_iter(monkeypatch, procs)
assert su._scan_reme_procs() == [(9, su.REME_DEFAULT_HOST, su.REME_DEFAULT_PORT)]
def test_scan_reme_procs_skips_access_denied(monkeypatch):
"""A process that denies cmdline access is skipped, others still scanned."""
procs = [
_RaisingInfoProc(psutil.AccessDenied(pid=99)),
_FakeProc(5, cmdline=["reme", "start"]),
]
_patch_iter(monkeypatch, procs)
assert su._scan_reme_procs() == [(5, su.REME_DEFAULT_HOST, su.REME_DEFAULT_PORT)]
def test_running_app_config_preserves_plugins(monkeypatch):
"""Process replay exposes the full config while the compatibility helper returns service only."""
monkeypatch.setattr(su, "_reme_start_argv", lambda: [["config=example"]])
monkeypatch.setattr("reme.config.parse_args", lambda *_args: ("start", {"config": "example"}))
monkeypatch.setattr(
"reme.config.resolve_app_config",
lambda **_kwargs: {
"plugins": ["example"],
"service": {"backend": "plugin-client", "port": 9911},
},
)
assert su.running_app_config() == {
"plugins": ["example"],
"service": {"backend": "plugin-client", "port": 9911},
}
assert su.running_service_config() == {"backend": "plugin-client", "port": 9911}