mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: add compatibility layer for LXST vendored pyogg to handle ctypes aliases, improve installation process and ensure proper imports
This commit is contained in:
parent
b27028a7fb
commit
f4d561cc86
9 changed files with 377 additions and 79 deletions
|
|
@ -67,6 +67,7 @@ COPY vendor ./vendor
|
|||
COPY scripts/docker-bake-lxst-filterlib-musl.py ./scripts/docker-bake-lxst-filterlib-musl.py
|
||||
COPY scripts/patch_lxst_pyogg_ogg_ctypes.py ./scripts/patch_lxst_pyogg_ogg_ctypes.py
|
||||
COPY scripts/patch_lxst_codec2_optional.py ./scripts/patch_lxst_codec2_optional.py
|
||||
COPY meshchatx/src/backend/lxst_pyogg_ctypes_compat.py ./meshchatx/src/backend/lxst_pyogg_ctypes_compat.py
|
||||
# Third-party deps layer: stable across app-only updates when uv.lock is unchanged.
|
||||
# --inexact keeps setuptools/jaraco.context already in the venv (cffi bake needs them).
|
||||
RUN uv sync --no-group dev --no-install-project --inexact && \
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ COPY logo ./logo
|
|||
COPY vendor ./vendor
|
||||
COPY scripts/patch_lxst_pyogg_ogg_ctypes.py ./scripts/patch_lxst_pyogg_ogg_ctypes.py
|
||||
COPY scripts/patch_lxst_codec2_optional.py ./scripts/patch_lxst_codec2_optional.py
|
||||
COPY meshchatx/src/backend/lxst_pyogg_ctypes_compat.py ./meshchatx/src/backend/lxst_pyogg_ctypes_compat.py
|
||||
# Third-party deps layer: stable across app-only updates when uv.lock is unchanged.
|
||||
# --inexact keeps setuptools/jaraco.context already in the venv (cffi needs them).
|
||||
RUN uv sync --no-group dev --no-install-project --inexact && \
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ meshchatx --headless --host 127.0.0.1
|
|||
|
||||
The `meshchat` command is a compatibility alias for the same entry point.
|
||||
|
||||
On hosts where `libopus` is installed but `libogg` is not, LXST's vendored pyogg can raise `NameError: c_int_p` on import. MeshChatX applies a ctypes compatibility fix at startup (the same patch Docker runs after install). Optional telephony audio still needs the usual Opus/Ogg system libraries when you use those codecs.
|
||||
|
||||
## Linux AppImage and packages
|
||||
|
||||
**AppImage**
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -4,3 +4,14 @@
|
|||
|
||||
# Synced from package.json via scripts/sync_version.js (also writes meshchatx/src/version.py).
|
||||
__version__ = "4.8.0"
|
||||
|
||||
# LXST vendored pyogg can NameError on import when libopus is present but
|
||||
# libogg is not. Apply before any meshchatx module imports LXST.
|
||||
try:
|
||||
from meshchatx.src.backend.lxst_pyogg_ctypes_compat import (
|
||||
ensure_lxst_pyogg_ctypes_compat,
|
||||
)
|
||||
|
||||
ensure_lxst_pyogg_ctypes_compat()
|
||||
except (ImportError, OSError, ValueError):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -42,9 +42,12 @@ from urllib.parse import urlparse
|
|||
import aiohttp
|
||||
import bcrypt
|
||||
import LXMF
|
||||
import LXST
|
||||
import psutil
|
||||
import RNS
|
||||
|
||||
# meshchatx/__init__ already ensures pyogg ctypes aliases. Import LXST after
|
||||
# that package init so plain pip installs do not crash without the Docker patch.
|
||||
import LXST
|
||||
from aiohttp import WSCloseCode, WSMessage, WSMsgType, web
|
||||
from aiohttp_session import get_session
|
||||
from aiohttp_session import setup as setup_session
|
||||
|
|
|
|||
204
meshchatx/src/backend/lxst_pyogg_ctypes_compat.py
Normal file
204
meshchatx/src/backend/lxst_pyogg_ctypes_compat.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
"""Ensure LXST vendored pyogg exports ctypes aliases Opus needs.
|
||||
|
||||
Upstream ogg.py defines c_int_p and related names only inside
|
||||
if PYOGG_OGG_AVAIL. When libopus loads but libogg does not, opus.py
|
||||
still references those names and raises NameError on import.
|
||||
|
||||
Docker and Taskfile run a post-install disk patch. Plain pip install
|
||||
does not, so MeshChatX applies the same fix before importing LXST.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.abc
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import sys
|
||||
from ctypes import POINTER, c_char_p, c_float, c_int, c_ubyte
|
||||
from pathlib import Path
|
||||
|
||||
_MARKER = "# meshchatx-lxst-pyogg-ctypes-compat (do not remove; idempotency)\n"
|
||||
|
||||
_LEGACY_MARKER = (
|
||||
"# meshchatx-lxst-pyogg-ctypes-pointer-aliases (do not remove; idempotency)\n"
|
||||
)
|
||||
|
||||
_LEGACY_BLOCK = (
|
||||
_LEGACY_MARKER
|
||||
+ "c_int_p = POINTER(c_int)\n"
|
||||
+ "c_float_p = POINTER(c_float)\n"
|
||||
+ "c_uchar_p = POINTER(c_ubyte)\n"
|
||||
+ "c_char_p_p = POINTER(c_char_p)\n"
|
||||
)
|
||||
|
||||
_INSERT = (
|
||||
_MARKER
|
||||
+ "c_int_p = POINTER(c_int)\n"
|
||||
+ "c_float_p = POINTER(c_float)\n"
|
||||
+ "c_uchar_p = POINTER(c_ubyte)\n"
|
||||
+ "c_char_p_p = POINTER(c_char_p)\n"
|
||||
+ "c_uchar = c_ubyte\n"
|
||||
)
|
||||
|
||||
_NEEDLE = (
|
||||
"from ctypes import c_int, c_int8, c_int16, c_int32, c_int64, c_uint, c_uint8, "
|
||||
"c_uint16, c_uint32, c_uint64, c_float, c_long, c_ulong, c_char, c_char_p, "
|
||||
"c_ubyte, c_longlong, c_ulonglong, c_size_t, c_void_p, c_double, POINTER, "
|
||||
"pointer, cast\n"
|
||||
)
|
||||
|
||||
_OGG_MODULE = "LXST.Codecs.libs.pyogg.ogg"
|
||||
_HOOK_INSTALLED = False
|
||||
|
||||
_ALIAS_VALUES = {
|
||||
"c_int_p": POINTER(c_int),
|
||||
"c_float_p": POINTER(c_float),
|
||||
"c_uchar_p": POINTER(c_ubyte),
|
||||
"c_char_p_p": POINTER(c_char_p),
|
||||
"c_uchar": c_ubyte,
|
||||
}
|
||||
|
||||
|
||||
def find_ogg_py() -> Path | None:
|
||||
"""Locate LXST bundled pyogg ogg.py without importing LXST."""
|
||||
try:
|
||||
dist = importlib.metadata.distribution("lxst")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
for rel in dist.files or ():
|
||||
parts = rel.parts
|
||||
if len(parts) >= 2 and parts[-2] == "pyogg" and parts[-1] == "ogg.py":
|
||||
return Path(dist.locate_file(rel))
|
||||
return None
|
||||
|
||||
|
||||
def inject_ctypes_aliases(ogg_module) -> list[str]:
|
||||
"""Set missing ctypes aliases on a loaded ogg module. Returns names set."""
|
||||
added: list[str] = []
|
||||
for name, value in _ALIAS_VALUES.items():
|
||||
if not hasattr(ogg_module, name):
|
||||
setattr(ogg_module, name, value)
|
||||
added.append(name)
|
||||
return added
|
||||
|
||||
|
||||
def apply_disk_patch(ogg: Path | None = None) -> str:
|
||||
"""Patch ogg.py on disk. Returns status token."""
|
||||
target = ogg if ogg is not None else find_ogg_py()
|
||||
if target is None:
|
||||
return "missing"
|
||||
text = target.read_text(encoding="utf-8")
|
||||
if _MARKER in text:
|
||||
return "already"
|
||||
if _LEGACY_BLOCK in text:
|
||||
target.write_text(text.replace(_LEGACY_BLOCK, _INSERT, 1), encoding="utf-8")
|
||||
return "upgraded"
|
||||
if _NEEDLE not in text:
|
||||
return "unexpected"
|
||||
target.write_text(text.replace(_NEEDLE, _NEEDLE + _INSERT, 1), encoding="utf-8")
|
||||
return "patched"
|
||||
|
||||
|
||||
class _OggCompatLoader(importlib.abc.Loader):
|
||||
"""Load ogg.py then inject module-level ctypes aliases."""
|
||||
|
||||
def __init__(self, origin: Path):
|
||||
self.origin = origin
|
||||
|
||||
def create_module(self, spec):
|
||||
return None
|
||||
|
||||
def exec_module(self, module):
|
||||
source = self.origin.read_text(encoding="utf-8")
|
||||
code = compile(source, str(self.origin), "exec")
|
||||
exec(code, module.__dict__)
|
||||
inject_ctypes_aliases(module)
|
||||
|
||||
|
||||
class _OggCompatFinder(importlib.abc.MetaPathFinder):
|
||||
"""Intercept LXST pyogg ogg import when disk patch is unavailable."""
|
||||
|
||||
def find_spec(self, fullname, path, target=None):
|
||||
if fullname != _OGG_MODULE:
|
||||
return None
|
||||
if fullname in sys.modules:
|
||||
return None
|
||||
ogg = find_ogg_py()
|
||||
if ogg is None or not ogg.is_file():
|
||||
return None
|
||||
try:
|
||||
text = ogg.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
if _MARKER in text:
|
||||
return None
|
||||
loader = _OggCompatLoader(ogg)
|
||||
return importlib.util.spec_from_file_location(
|
||||
fullname,
|
||||
ogg,
|
||||
loader=loader,
|
||||
submodule_search_locations=None,
|
||||
)
|
||||
|
||||
|
||||
def install_import_hook() -> bool:
|
||||
"""Install a one-shot meta path finder for read-only site-packages."""
|
||||
global _HOOK_INSTALLED
|
||||
if _HOOK_INSTALLED:
|
||||
return False
|
||||
for finder in sys.meta_path:
|
||||
if isinstance(finder, _OggCompatFinder):
|
||||
_HOOK_INSTALLED = True
|
||||
return False
|
||||
sys.meta_path.insert(0, _OggCompatFinder())
|
||||
_HOOK_INSTALLED = True
|
||||
return True
|
||||
|
||||
|
||||
def ensure_lxst_pyogg_ctypes_compat() -> str:
|
||||
"""Disk-patch when possible and install an import hook as fallback.
|
||||
|
||||
Safe to call repeatedly. Does not import LXST.
|
||||
"""
|
||||
status = "skipped"
|
||||
try:
|
||||
status = apply_disk_patch()
|
||||
except OSError:
|
||||
status = "readonly"
|
||||
install_import_hook()
|
||||
existing = sys.modules.get(_OGG_MODULE)
|
||||
if existing is not None:
|
||||
inject_ctypes_aliases(existing)
|
||||
return status
|
||||
|
||||
|
||||
def patch_cli() -> int:
|
||||
"""CLI used by scripts/patch_lxst_pyogg_ogg_ctypes.py."""
|
||||
try:
|
||||
status = apply_disk_patch()
|
||||
except OSError as exc:
|
||||
print(f"patch_lxst_pyogg_ogg_ctypes: write failed ({exc})", file=sys.stderr)
|
||||
return 1
|
||||
ogg = find_ogg_py()
|
||||
if status == "missing":
|
||||
print(
|
||||
"patch_lxst_pyogg_ogg_ctypes: lxst not installed, nothing to do",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
if status == "already":
|
||||
print(f"patch_lxst_pyogg_ogg_ctypes: already applied ({ogg})")
|
||||
return 0
|
||||
if status == "upgraded":
|
||||
print(f"patch_lxst_pyogg_ogg_ctypes: upgraded legacy block ({ogg})")
|
||||
return 0
|
||||
if status == "unexpected":
|
||||
print(
|
||||
"patch_lxst_pyogg_ogg_ctypes: unexpected ogg.py layout; "
|
||||
f"manual check required ({ogg})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(f"patch_lxst_pyogg_ogg_ctypes: patched {ogg}")
|
||||
return 0
|
||||
|
|
@ -1,95 +1,46 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: 0BSD
|
||||
"""Patch LXST bundled pyogg ogg.py for Python 3.14+.
|
||||
"""Patch LXST bundled pyogg ogg.py for missing ctypes aliases.
|
||||
|
||||
opus.py does from .ogg import * but needs extra ctypes names that
|
||||
ogg.py never defined: POINTER aliases, and c_uchar (used as c_uchar*0
|
||||
for flexible array argtypes; same layout as c_ubyte).
|
||||
ogg.py only defines when libogg loads. Idempotent: safe after every
|
||||
pip install / poetry install / uv sync.
|
||||
|
||||
Idempotent: safe to run after every pip install / poetry install.
|
||||
MeshChatX also applies this at package import for plain PyPI installs.
|
||||
This script remains for Docker, CI, and packaging (including the Docker
|
||||
deps layer before the project package is installed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import sys
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
_MARKER = "# meshchatx-lxst-pyogg-ctypes-compat (do not remove; idempotency)\n"
|
||||
|
||||
# First revision (older Docker/local installs): bump handled below.
|
||||
_LEGACY_MARKER = (
|
||||
"# meshchatx-lxst-pyogg-ctypes-pointer-aliases (do not remove; idempotency)\n"
|
||||
)
|
||||
|
||||
_LEGACY_BLOCK = (
|
||||
_LEGACY_MARKER
|
||||
+ "c_int_p = POINTER(c_int)\n"
|
||||
+ "c_float_p = POINTER(c_float)\n"
|
||||
+ "c_uchar_p = POINTER(c_ubyte)\n"
|
||||
+ "c_char_p_p = POINTER(c_char_p)\n"
|
||||
)
|
||||
|
||||
_INSERT = (
|
||||
_MARKER
|
||||
+ "c_int_p = POINTER(c_int)\n"
|
||||
+ "c_float_p = POINTER(c_float)\n"
|
||||
+ "c_uchar_p = POINTER(c_ubyte)\n"
|
||||
+ "c_char_p_p = POINTER(c_char_p)\n"
|
||||
+ "c_uchar = c_ubyte\n"
|
||||
)
|
||||
|
||||
_NEEDLE = (
|
||||
"from ctypes import c_int, c_int8, c_int16, c_int32, c_int64, c_uint, c_uint8, "
|
||||
"c_uint16, c_uint32, c_uint64, c_float, c_long, c_ulong, c_char, c_char_p, "
|
||||
"c_ubyte, c_longlong, c_ulonglong, c_size_t, c_void_p, c_double, POINTER, "
|
||||
"pointer, cast\n"
|
||||
)
|
||||
|
||||
|
||||
def _find_ogg_py() -> Path | None:
|
||||
def _load_compat():
|
||||
try:
|
||||
dist = importlib.metadata.distribution("lxst")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
for rel in dist.files or ():
|
||||
parts = rel.parts
|
||||
if len(parts) >= 2 and parts[-2] == "pyogg" and parts[-1] == "ogg.py":
|
||||
return Path(dist.locate_file(rel))
|
||||
return None
|
||||
from meshchatx.src.backend.lxst_pyogg_ctypes_compat import patch_cli
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ogg = _find_ogg_py()
|
||||
if ogg is None:
|
||||
print(
|
||||
"patch_lxst_pyogg_ogg_ctypes: lxst not installed, nothing to do",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
text = ogg.read_text(encoding="utf-8")
|
||||
|
||||
if _MARKER in text:
|
||||
print(f"patch_lxst_pyogg_ogg_ctypes: already applied ({ogg})")
|
||||
return 0
|
||||
|
||||
if _LEGACY_BLOCK in text:
|
||||
ogg.write_text(text.replace(_LEGACY_BLOCK, _INSERT, 1), encoding="utf-8")
|
||||
print(f"patch_lxst_pyogg_ogg_ctypes: upgraded legacy block ({ogg})")
|
||||
return 0
|
||||
|
||||
if _NEEDLE not in text:
|
||||
print(
|
||||
"patch_lxst_pyogg_ogg_ctypes: unexpected ogg.py layout; "
|
||||
f"manual check required ({ogg})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
ogg.write_text(text.replace(_NEEDLE, _NEEDLE + _INSERT, 1), encoding="utf-8")
|
||||
print(f"patch_lxst_pyogg_ogg_ctypes: patched {ogg}")
|
||||
return 0
|
||||
return patch_cli
|
||||
except ImportError:
|
||||
pass
|
||||
path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "meshchatx"
|
||||
/ "src"
|
||||
/ "backend"
|
||||
/ "lxst_pyogg_ctypes_compat.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"lxst_pyogg_ctypes_compat",
|
||||
path,
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"cannot load compat module from {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module.patch_cli
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
raise SystemExit(_load_compat()())
|
||||
|
|
|
|||
125
tests/backend/test_lxst_pyogg_ctypes_compat.py
Normal file
125
tests/backend/test_lxst_pyogg_ctypes_compat.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
"""Tests for LXST pyogg ctypes alias compatibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from meshchatx.src.backend import lxst_pyogg_ctypes_compat as compat
|
||||
|
||||
|
||||
_NEEDLE = compat._NEEDLE
|
||||
|
||||
|
||||
def _ogg_source(*, with_marker: bool = False, with_legacy: bool = False) -> str:
|
||||
body = _NEEDLE
|
||||
if with_marker:
|
||||
body += compat._INSERT
|
||||
elif with_legacy:
|
||||
body += compat._LEGACY_BLOCK
|
||||
body += textwrap.dedent(
|
||||
"""
|
||||
libogg = None
|
||||
PYOGG_OGG_AVAIL = False
|
||||
"""
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
def test_apply_disk_patch_inserts_module_level_aliases(tmp_path: Path):
|
||||
ogg = tmp_path / "ogg.py"
|
||||
ogg.write_text(_ogg_source(), encoding="utf-8")
|
||||
|
||||
assert compat.apply_disk_patch(ogg) == "patched"
|
||||
text = ogg.read_text(encoding="utf-8")
|
||||
assert compat._MARKER in text
|
||||
assert "c_int_p = POINTER(c_int)" in text
|
||||
assert "c_uchar = c_ubyte" in text
|
||||
assert compat.apply_disk_patch(ogg) == "already"
|
||||
|
||||
|
||||
def test_apply_disk_patch_upgrades_legacy_block(tmp_path: Path):
|
||||
ogg = tmp_path / "ogg.py"
|
||||
ogg.write_text(_ogg_source(with_legacy=True), encoding="utf-8")
|
||||
|
||||
assert compat.apply_disk_patch(ogg) == "upgraded"
|
||||
text = ogg.read_text(encoding="utf-8")
|
||||
assert compat._MARKER in text
|
||||
assert compat._LEGACY_MARKER not in text
|
||||
assert "c_uchar = c_ubyte" in text
|
||||
|
||||
|
||||
def test_apply_disk_patch_unexpected_layout(tmp_path: Path):
|
||||
ogg = tmp_path / "ogg.py"
|
||||
ogg.write_text("print('not pyogg')\n", encoding="utf-8")
|
||||
assert compat.apply_disk_patch(ogg) == "unexpected"
|
||||
|
||||
|
||||
def test_inject_ctypes_aliases_only_fills_missing():
|
||||
module = type(sys)("fake_ogg")
|
||||
module.c_int_p = object()
|
||||
added = compat.inject_ctypes_aliases(module)
|
||||
assert "c_int_p" not in added
|
||||
assert "c_float_p" in added
|
||||
assert "c_uchar" in added
|
||||
assert hasattr(module, "c_float_p")
|
||||
assert hasattr(module, "c_uchar")
|
||||
|
||||
|
||||
def test_import_hook_loader_injects_aliases(tmp_path: Path):
|
||||
ogg = tmp_path / "ogg.py"
|
||||
ogg.write_text(_ogg_source(), encoding="utf-8")
|
||||
module = type(sys)("test_ogg_compat")
|
||||
module.__file__ = str(ogg)
|
||||
compat._OggCompatLoader(ogg).exec_module(module)
|
||||
assert hasattr(module, "c_int_p")
|
||||
assert hasattr(module, "c_uchar")
|
||||
assert module.PYOGG_OGG_AVAIL is False
|
||||
|
||||
|
||||
def test_finder_returns_spec_only_when_unpatched(tmp_path: Path, monkeypatch):
|
||||
ogg = tmp_path / "ogg.py"
|
||||
ogg.write_text(_ogg_source(), encoding="utf-8")
|
||||
monkeypatch.setattr(compat, "find_ogg_py", lambda: ogg)
|
||||
sys.modules.pop(compat._OGG_MODULE, None)
|
||||
finder = compat._OggCompatFinder()
|
||||
spec = finder.find_spec(compat._OGG_MODULE, None)
|
||||
assert spec is not None
|
||||
assert isinstance(spec.loader, compat._OggCompatLoader)
|
||||
|
||||
ogg.write_text(_ogg_source(with_marker=True), encoding="utf-8")
|
||||
assert finder.find_spec(compat._OGG_MODULE, None) is None
|
||||
|
||||
|
||||
def test_ensure_prefers_disk_patch(tmp_path: Path, monkeypatch):
|
||||
ogg = tmp_path / "ogg.py"
|
||||
ogg.write_text(_ogg_source(), encoding="utf-8")
|
||||
monkeypatch.setattr(compat, "find_ogg_py", lambda: ogg)
|
||||
compat._HOOK_INSTALLED = False
|
||||
sys.meta_path[:] = [
|
||||
f for f in sys.meta_path if not isinstance(f, compat._OggCompatFinder)
|
||||
]
|
||||
|
||||
status = compat.ensure_lxst_pyogg_ctypes_compat()
|
||||
assert status == "patched"
|
||||
assert compat._MARKER in ogg.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "expected_code"),
|
||||
[
|
||||
("missing", 0),
|
||||
("already", 0),
|
||||
("upgraded", 0),
|
||||
("patched", 0),
|
||||
("unexpected", 1),
|
||||
],
|
||||
)
|
||||
def test_patch_cli_exit_codes(monkeypatch, status, expected_code):
|
||||
monkeypatch.setattr(compat, "apply_disk_patch", lambda: status)
|
||||
monkeypatch.setattr(compat, "find_ogg_py", lambda: Path("/tmp/ogg.py"))
|
||||
assert compat.patch_cli() == expected_code
|
||||
Loading…
Add table
Add a link
Reference in a new issue