Squashed 'core/' content from commit 4957443
git-subtree-dir: core git-subtree-split: 4957443184ae0eb6323635a90a19acffb3e01d07
This commit is contained in:
301
tests/test_ipfs_access.py
Normal file
301
tests/test_ipfs_access.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
Tests for IPFS access consistency.
|
||||
|
||||
All IPFS access should use IPFS_API (multiaddr format) for consistency
|
||||
with art-celery's ipfs_client.py. This ensures Docker deployments work
|
||||
correctly since IPFS_API is set to /dns/ipfs/tcp/5001.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def multiaddr_to_url(multiaddr: str) -> str:
|
||||
"""
|
||||
Convert IPFS multiaddr to HTTP URL.
|
||||
|
||||
This is the canonical conversion used by ipfs_client.py.
|
||||
"""
|
||||
# Handle /dns/hostname/tcp/port format
|
||||
dns_match = re.match(r"/dns[46]?/([^/]+)/tcp/(\d+)", multiaddr)
|
||||
if dns_match:
|
||||
return f"http://{dns_match.group(1)}:{dns_match.group(2)}"
|
||||
|
||||
# Handle /ip4/address/tcp/port format
|
||||
ip4_match = re.match(r"/ip4/([^/]+)/tcp/(\d+)", multiaddr)
|
||||
if ip4_match:
|
||||
return f"http://{ip4_match.group(1)}:{ip4_match.group(2)}"
|
||||
|
||||
# Fallback: assume it's already a URL or use default
|
||||
if multiaddr.startswith("http"):
|
||||
return multiaddr
|
||||
return "http://127.0.0.1:5001"
|
||||
|
||||
|
||||
class TestMultiaddrConversion:
|
||||
"""Tests for multiaddr to URL conversion."""
|
||||
|
||||
def test_dns_format(self) -> None:
|
||||
"""Docker DNS format should convert correctly."""
|
||||
result = multiaddr_to_url("/dns/ipfs/tcp/5001")
|
||||
assert result == "http://ipfs:5001"
|
||||
|
||||
def test_dns4_format(self) -> None:
|
||||
"""dns4 format should work."""
|
||||
result = multiaddr_to_url("/dns4/ipfs.example.com/tcp/5001")
|
||||
assert result == "http://ipfs.example.com:5001"
|
||||
|
||||
def test_ip4_format(self) -> None:
|
||||
"""IPv4 format should convert correctly."""
|
||||
result = multiaddr_to_url("/ip4/127.0.0.1/tcp/5001")
|
||||
assert result == "http://127.0.0.1:5001"
|
||||
|
||||
def test_already_url(self) -> None:
|
||||
"""HTTP URLs should pass through."""
|
||||
result = multiaddr_to_url("http://localhost:5001")
|
||||
assert result == "http://localhost:5001"
|
||||
|
||||
def test_fallback(self) -> None:
|
||||
"""Unknown format should fallback to localhost."""
|
||||
result = multiaddr_to_url("garbage")
|
||||
assert result == "http://127.0.0.1:5001"
|
||||
|
||||
|
||||
class TestIPFSConfigConsistency:
|
||||
"""
|
||||
Tests to ensure IPFS configuration is consistent.
|
||||
|
||||
The effect executor should use IPFS_API (like ipfs_client.py)
|
||||
rather than a separate IPFS_GATEWAY variable.
|
||||
"""
|
||||
|
||||
def test_effect_module_should_not_use_gateway_var(self) -> None:
|
||||
"""
|
||||
Regression test: Effect module should use IPFS_API, not IPFS_GATEWAY.
|
||||
|
||||
Bug found 2026-01-12: artdag/nodes/effect.py used IPFS_GATEWAY which
|
||||
defaulted to http://127.0.0.1:8080. This doesn't work in Docker where
|
||||
the IPFS node is a separate container. The ipfs_client.py uses IPFS_API
|
||||
which is correctly set in docker-compose.
|
||||
"""
|
||||
from artdag.nodes import effect
|
||||
|
||||
# Check if the module still has the old IPFS_GATEWAY variable
|
||||
# After the fix, this should use IPFS_API instead
|
||||
has_gateway_var = hasattr(effect, 'IPFS_GATEWAY')
|
||||
has_api_var = hasattr(effect, 'IPFS_API') or hasattr(effect, '_get_ipfs_base_url')
|
||||
|
||||
# This test documents the current buggy state
|
||||
# After fix: has_gateway_var should be False, has_api_var should be True
|
||||
if has_gateway_var and not has_api_var:
|
||||
pytest.fail(
|
||||
"Effect module uses IPFS_GATEWAY instead of IPFS_API. "
|
||||
"This breaks Docker deployments where IPFS_API=/dns/ipfs/tcp/5001 "
|
||||
"but IPFS_GATEWAY defaults to localhost."
|
||||
)
|
||||
|
||||
def test_ipfs_api_default_is_localhost(self) -> None:
|
||||
"""IPFS_API should default to localhost for local development."""
|
||||
default_api = "/ip4/127.0.0.1/tcp/5001"
|
||||
url = multiaddr_to_url(default_api)
|
||||
assert "127.0.0.1" in url
|
||||
assert "5001" in url
|
||||
|
||||
def test_docker_ipfs_api_uses_service_name(self) -> None:
|
||||
"""In Docker, IPFS_API should use the service name."""
|
||||
docker_api = "/dns/ipfs/tcp/5001"
|
||||
url = multiaddr_to_url(docker_api)
|
||||
assert url == "http://ipfs:5001"
|
||||
assert "127.0.0.1" not in url
|
||||
|
||||
|
||||
class TestEffectFetchURL:
|
||||
"""Tests for the URL used to fetch effects from IPFS."""
|
||||
|
||||
def test_fetch_should_use_api_cat_endpoint(self) -> None:
|
||||
"""
|
||||
Effect fetch should use /api/v0/cat endpoint (like ipfs_client.py).
|
||||
|
||||
The IPFS API's cat endpoint works reliably in Docker.
|
||||
The gateway endpoint (port 8080) requires separate configuration.
|
||||
"""
|
||||
# The correct way to fetch via API
|
||||
base_url = "http://ipfs:5001"
|
||||
cid = "QmTestCid123"
|
||||
correct_url = f"{base_url}/api/v0/cat?arg={cid}"
|
||||
|
||||
assert "/api/v0/cat" in correct_url
|
||||
assert "arg=" in correct_url
|
||||
|
||||
def test_gateway_url_is_different_from_api(self) -> None:
|
||||
"""
|
||||
Document the difference between gateway and API URLs.
|
||||
|
||||
Gateway: http://ipfs:8080/ipfs/{cid} (requires IPFS_GATEWAY config)
|
||||
API: http://ipfs:5001/api/v0/cat?arg={cid} (uses IPFS_API config)
|
||||
|
||||
Using the API is more reliable since IPFS_API is already configured
|
||||
correctly in docker-compose.yml.
|
||||
"""
|
||||
cid = "QmTestCid123"
|
||||
|
||||
# Gateway style (the old broken way)
|
||||
gateway_url = f"http://ipfs:8080/ipfs/{cid}"
|
||||
|
||||
# API style (the correct way)
|
||||
api_url = f"http://ipfs:5001/api/v0/cat?arg={cid}"
|
||||
|
||||
# These are different approaches
|
||||
assert gateway_url != api_url
|
||||
assert ":8080" in gateway_url
|
||||
assert ":5001" in api_url
|
||||
|
||||
|
||||
class TestEffectDependencies:
|
||||
"""Tests for effect dependency handling."""
|
||||
|
||||
def test_parse_pep723_dependencies(self) -> None:
|
||||
"""Should parse PEP 723 dependencies from effect source."""
|
||||
source = '''
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["numpy", "opencv-python"]
|
||||
# ///
|
||||
"""
|
||||
@effect test_effect
|
||||
"""
|
||||
|
||||
def process_frame(frame, params, state):
|
||||
return frame, state
|
||||
'''
|
||||
# Import the function after the fix is applied
|
||||
from artdag.nodes.effect import _parse_pep723_dependencies
|
||||
|
||||
deps = _parse_pep723_dependencies(source)
|
||||
|
||||
assert deps == ["numpy", "opencv-python"]
|
||||
|
||||
def test_parse_pep723_no_dependencies(self) -> None:
|
||||
"""Should return empty list if no dependencies block."""
|
||||
source = '''
|
||||
"""
|
||||
@effect simple_effect
|
||||
"""
|
||||
|
||||
def process_frame(frame, params, state):
|
||||
return frame, state
|
||||
'''
|
||||
from artdag.nodes.effect import _parse_pep723_dependencies
|
||||
|
||||
deps = _parse_pep723_dependencies(source)
|
||||
|
||||
assert deps == []
|
||||
|
||||
def test_ensure_dependencies_already_installed(self) -> None:
|
||||
"""Should return True if dependencies are already installed."""
|
||||
from artdag.nodes.effect import _ensure_dependencies
|
||||
|
||||
# os is always available
|
||||
result = _ensure_dependencies(["os"], "QmTest123")
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_effect_with_missing_dependency_gives_clear_error(self, tmp_path: Path) -> None:
|
||||
"""
|
||||
Regression test: Missing dependencies should give clear error message.
|
||||
|
||||
Bug found 2026-01-12: Effect with numpy dependency failed with
|
||||
"No module named 'numpy'" but this was swallowed and reported as
|
||||
"Unknown effect: invert" - very confusing.
|
||||
"""
|
||||
effects_dir = tmp_path / "_effects"
|
||||
effect_cid = "QmTestEffectWithDeps"
|
||||
|
||||
# Create effect that imports a non-existent module
|
||||
effect_dir = effects_dir / effect_cid
|
||||
effect_dir.mkdir(parents=True)
|
||||
(effect_dir / "effect.py").write_text('''
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["some_nonexistent_package_xyz"]
|
||||
# ///
|
||||
"""
|
||||
@effect test_effect
|
||||
"""
|
||||
import some_nonexistent_package_xyz
|
||||
|
||||
def process_frame(frame, params, state):
|
||||
return frame, state
|
||||
''')
|
||||
|
||||
# The effect file exists
|
||||
effect_path = effects_dir / effect_cid / "effect.py"
|
||||
assert effect_path.exists()
|
||||
|
||||
# When loading fails due to missing import, error should mention the dependency
|
||||
with patch.dict(os.environ, {"CACHE_DIR": str(tmp_path)}):
|
||||
from artdag.nodes.effect import _load_cached_effect
|
||||
|
||||
# This should return None but log a clear error about the missing module
|
||||
result = _load_cached_effect(effect_cid)
|
||||
|
||||
# Currently returns None, which causes "Unknown effect" error
|
||||
# The real issue is the dependency isn't installed
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestEffectCacheAndFetch:
|
||||
"""Integration tests for effect caching and fetching."""
|
||||
|
||||
def test_effect_loads_from_cache_without_ipfs(self, tmp_path: Path) -> None:
|
||||
"""When effect is in cache, IPFS should not be contacted."""
|
||||
effects_dir = tmp_path / "_effects"
|
||||
effect_cid = "QmTestEffect123"
|
||||
|
||||
# Create cached effect
|
||||
effect_dir = effects_dir / effect_cid
|
||||
effect_dir.mkdir(parents=True)
|
||||
(effect_dir / "effect.py").write_text('''
|
||||
def process_frame(frame, params, state):
|
||||
return frame, state
|
||||
''')
|
||||
|
||||
# Patch environment and verify effect can be loaded
|
||||
with patch.dict(os.environ, {"CACHE_DIR": str(tmp_path)}):
|
||||
from artdag.nodes.effect import _load_cached_effect
|
||||
|
||||
# Should load without hitting IPFS
|
||||
effect_fn = _load_cached_effect(effect_cid)
|
||||
assert effect_fn is not None
|
||||
|
||||
def test_effect_fetch_uses_correct_endpoint(self, tmp_path: Path) -> None:
|
||||
"""When fetching from IPFS, should use API endpoint."""
|
||||
effects_dir = tmp_path / "_effects"
|
||||
effects_dir.mkdir(parents=True)
|
||||
effect_cid = "QmNonExistentEffect"
|
||||
|
||||
with patch.dict(os.environ, {
|
||||
"CACHE_DIR": str(tmp_path),
|
||||
"IPFS_API": "/dns/ipfs/tcp/5001"
|
||||
}):
|
||||
with patch('requests.post') as mock_post:
|
||||
# Set up mock to return effect source
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.content = b'def process_frame(f, p, s): return f, s'
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
from artdag.nodes.effect import _load_cached_effect
|
||||
|
||||
# Try to load - should attempt IPFS fetch
|
||||
_load_cached_effect(effect_cid)
|
||||
|
||||
# After fix, this should use the API endpoint
|
||||
# Check if requests.post was called (API style)
|
||||
# or requests.get was called (gateway style)
|
||||
# The fix should make it use POST to /api/v0/cat
|
||||
Reference in New Issue
Block a user