mirror of
https://github.com/PR0M3TH3AN/LBRY-Downloader.git
synced 2026-10-27 08:22:24 +00:00
Complete LBRY Downloader implementation v1.0.0
Features: - Incremental sync with version tracking (sd_hash) - Per-channel download paths - Download limit (default: 10 most recent per channel) - Interactive setup script with user prompts - Uninstall script for clean removal - Comprehensive test suite (42 tests) - Full documentation suite Files: - 9 Python core modules (main, models, utils, config, client, state, planner, downloader, init) - 3 Setup scripts (setup.py, uninstall.py, install.sh) - 5 Test files (42 tests, all passing) - 9 Documentation files - 4 Build/config files Total: 31 files, ~4,500 lines
This commit is contained in:
1 parent
db4f11ba6d
commit
e707400ffd
31 files changed
+5825
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,137 @@
|
||||
"""Tests for configuration loading."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from config_loader import load_config, ConfigError, create_default_config
|
||||
from models import Config
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
def test_missing_config_file(self):
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
load_config("/nonexistent/path/config.yaml")
|
||||
|
||||
assert "not found" in str(exc_info.value)
|
||||
|
||||
def test_empty_channels_list(self, tmp_path):
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("""
|
||||
lbrynet:
|
||||
api_url: "http://127.0.0.1:5279"
|
||||
|
||||
general:
|
||||
base_dir: "~/test"
|
||||
|
||||
channels: []
|
||||
""")
|
||||
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
load_config(str(config_file))
|
||||
|
||||
assert "at least one channel" in str(exc_info.value)
|
||||
|
||||
def test_all_channels_disabled(self, tmp_path):
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("""
|
||||
lbrynet:
|
||||
api_url: "http://127.0.0.1:5279"
|
||||
|
||||
general:
|
||||
base_dir: "~/test"
|
||||
|
||||
channels:
|
||||
- input: "https://odysee.com/@Test:1"
|
||||
enabled: false
|
||||
""")
|
||||
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
load_config(str(config_file))
|
||||
|
||||
assert "at least one channel" in str(exc_info.value).lower()
|
||||
|
||||
def test_valid_config(self, tmp_path):
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("""
|
||||
lbrynet:
|
||||
api_url: "http://127.0.0.1:5279"
|
||||
timeout_seconds: 120
|
||||
|
||||
general:
|
||||
base_dir: "~/Documents/lbry-downloads"
|
||||
max_workers: 3
|
||||
log_level: "DEBUG"
|
||||
|
||||
channels:
|
||||
- input: "https://odysee.com/@Test:1"
|
||||
enabled: true
|
||||
tags_include: ["tech"]
|
||||
tags_exclude: ["spam"]
|
||||
""")
|
||||
|
||||
config = load_config(str(config_file))
|
||||
|
||||
assert isinstance(config, Config)
|
||||
assert config.lbrynet.api_url == "http://127.0.0.1:5279"
|
||||
assert config.lbrynet.timeout_seconds == 120
|
||||
assert config.general.max_workers == 3
|
||||
assert config.general.log_level == "DEBUG"
|
||||
assert len(config.channels) == 1
|
||||
assert config.channels[0].input == "https://odysee.com/@Test:1"
|
||||
assert config.channels[0].tags_include == ["tech"]
|
||||
|
||||
def test_invalid_log_level(self, tmp_path):
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("""
|
||||
lbrynet:
|
||||
api_url: "http://127.0.0.1:5279"
|
||||
|
||||
general:
|
||||
base_dir: "~/test"
|
||||
log_level: "INVALID"
|
||||
|
||||
channels:
|
||||
- input: "https://odysee.com/@Test:1"
|
||||
enabled: true
|
||||
""")
|
||||
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
load_config(str(config_file))
|
||||
|
||||
assert "log_level" in str(exc_info.value)
|
||||
|
||||
def test_invalid_filename_mode(self, tmp_path):
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("""
|
||||
lbrynet:
|
||||
api_url: "http://127.0.0.1:5279"
|
||||
|
||||
general:
|
||||
base_dir: "~/test"
|
||||
filename_mode: "invalid"
|
||||
|
||||
channels:
|
||||
- input: "https://odysee.com/@Test:1"
|
||||
enabled: true
|
||||
""")
|
||||
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
load_config(str(config_file))
|
||||
|
||||
assert "filename_mode" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestDefaultConfig:
|
||||
def test_create_default_config(self):
|
||||
config = create_default_config()
|
||||
|
||||
assert "lbrynet:" in config
|
||||
assert "general:" in config
|
||||
assert "channels:" in config
|
||||
assert "http://127.0.0.1:5279" in config
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Tests for download limit feature."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
from models import Channel, Config, GeneralConfig, StateDatabase, DownloadAction
|
||||
|
||||
|
||||
class TestDownloadLimit:
|
||||
"""Test the download limit functionality."""
|
||||
|
||||
def test_download_limit_zero_means_all(self):
|
||||
"""Test that download_limit=0 means download all (no limit)."""
|
||||
config = Config()
|
||||
config.general = GeneralConfig()
|
||||
config.general.download_limit = 0
|
||||
|
||||
# download_limit of 0 should mean no limit
|
||||
assert config.general.download_limit == 0
|
||||
|
||||
def test_download_limit_default_is_10(self):
|
||||
"""Test that default download_limit is 10."""
|
||||
config = Config()
|
||||
config.general = GeneralConfig()
|
||||
|
||||
# Default should be 10
|
||||
assert config.general.download_limit == 10
|
||||
|
||||
def test_negative_download_limit_invalid(self):
|
||||
"""Test that negative download_limit is invalid."""
|
||||
from config_loader import ConfigError, _parse_config
|
||||
|
||||
raw_config = {
|
||||
"lbrynet": {},
|
||||
"general": {
|
||||
"download_limit": -5 # Invalid
|
||||
},
|
||||
"channels": [{"input": "https://odysee.com/@Test:1", "enabled": True}],
|
||||
}
|
||||
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
_parse_config(raw_config)
|
||||
|
||||
assert "download_limit" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Tests for planner module."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
from planner import Planner
|
||||
from models import Channel, Config, GeneralConfig, StateDatabase, Claim, ClaimVersion
|
||||
from utils import create_channel_folder_name, create_claim_folder_name
|
||||
|
||||
|
||||
class TestPlanner:
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.config = Config()
|
||||
self.config.general = GeneralConfig()
|
||||
self.config.general.verify_existing_files = True
|
||||
self.state = StateDatabase()
|
||||
self.planner = Planner(self.config, self.state, self.temp_dir)
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_new_claim_action(self):
|
||||
channel = Channel(
|
||||
input="test",
|
||||
normalized_uri="lbry://@Test#1",
|
||||
channel_claim_id="channel123",
|
||||
channel_name="@Test",
|
||||
folder="Test__channel123",
|
||||
)
|
||||
|
||||
claim_data = {
|
||||
"claim_id": "claim456",
|
||||
"name": "my-video",
|
||||
"value_type": "stream",
|
||||
"value": {"source": {"sd_hash": "abc123"}, "title": "My Video"},
|
||||
"permanent_url": "lbry://@Test#1/my-video#claim456",
|
||||
"txid": "tx789",
|
||||
"nout": 0,
|
||||
}
|
||||
|
||||
actions = self.planner.process_channel_claims(channel, [claim_data])
|
||||
|
||||
assert len(actions) == 1
|
||||
assert actions[0].action == "download_new"
|
||||
assert actions[0].claim_id == "claim456"
|
||||
|
||||
def test_skip_existing_claim(self):
|
||||
channel = Channel(
|
||||
input="test",
|
||||
normalized_uri="lbry://@Test#1",
|
||||
channel_claim_id="channel123",
|
||||
channel_name="@Test",
|
||||
folder="Test__channel123",
|
||||
)
|
||||
|
||||
# Add existing claim to state
|
||||
claim_folder = create_claim_folder_name("my-video", "claim456")
|
||||
claim = Claim(
|
||||
claim_id="claim456",
|
||||
channel_claim_id="channel123",
|
||||
name="my-video",
|
||||
permanent_url="lbry://@Test#1/my-video#claim456",
|
||||
claim_folder=f"channels/Test__channel123/claims/{claim_folder}",
|
||||
)
|
||||
|
||||
# Create version token matching the claim data
|
||||
from utils import generate_version_token
|
||||
|
||||
version_token = generate_version_token(sd_hash="abc123")
|
||||
|
||||
claim.versions[version_token] = ClaimVersion(
|
||||
version_token=version_token,
|
||||
txid="tx789",
|
||||
nout=0,
|
||||
sd_hash="abc123",
|
||||
downloaded=True,
|
||||
file_relpath=f"channels/Test__channel123/claims/{claim_folder}/versions/{version_token}/video.mp4",
|
||||
)
|
||||
|
||||
self.state.claims["claim456"] = claim
|
||||
|
||||
# Create the "downloaded" file
|
||||
version_dir = (
|
||||
Path(self.temp_dir)
|
||||
/ f"channels/Test__channel123/claims/{claim_folder}/versions/{version_token}"
|
||||
)
|
||||
version_dir.mkdir(parents=True)
|
||||
(version_dir / "video.mp4").touch()
|
||||
|
||||
claim_data = {
|
||||
"claim_id": "claim456",
|
||||
"name": "my-video",
|
||||
"value_type": "stream",
|
||||
"value": {"source": {"sd_hash": "abc123"}, "title": "My Video"},
|
||||
"permanent_url": "lbry://@Test#1/my-video#claim456",
|
||||
"txid": "tx789",
|
||||
"nout": 0,
|
||||
}
|
||||
|
||||
actions = self.planner.process_channel_claims(channel, [claim_data])
|
||||
|
||||
assert len(actions) == 1
|
||||
assert actions[0].action == "skip_existing"
|
||||
|
||||
def test_new_version_detected(self):
|
||||
channel = Channel(
|
||||
input="test",
|
||||
normalized_uri="lbry://@Test#1",
|
||||
channel_claim_id="channel123",
|
||||
channel_name="@Test",
|
||||
folder="Test__channel123",
|
||||
)
|
||||
|
||||
# Add existing claim with old version
|
||||
claim_folder = create_claim_folder_name("my-video", "claim456")
|
||||
claim = Claim(
|
||||
claim_id="claim456",
|
||||
channel_claim_id="channel123",
|
||||
name="my-video",
|
||||
permanent_url="lbry://@Test#1/my-video#claim456",
|
||||
claim_folder=f"channels/Test__channel123/claims/{claim_folder}",
|
||||
)
|
||||
|
||||
old_version_token = "sd_oldhash123"
|
||||
claim.versions[old_version_token] = ClaimVersion(
|
||||
version_token=old_version_token,
|
||||
txid="tx789",
|
||||
nout=0,
|
||||
sd_hash="oldhash123",
|
||||
downloaded=True,
|
||||
)
|
||||
|
||||
self.state.claims["claim456"] = claim
|
||||
|
||||
# New claim data with different sd_hash
|
||||
claim_data = {
|
||||
"claim_id": "claim456",
|
||||
"name": "my-video",
|
||||
"value_type": "stream",
|
||||
"value": {
|
||||
"source": {"sd_hash": "newhash456"}, # Different!
|
||||
"title": "My Video Updated",
|
||||
},
|
||||
"permanent_url": "lbry://@Test#1/my-video#claim456",
|
||||
"txid": "tx999",
|
||||
"nout": 0,
|
||||
}
|
||||
|
||||
actions = self.planner.process_channel_claims(channel, [claim_data])
|
||||
|
||||
assert len(actions) == 1
|
||||
assert actions[0].action == "download_new_version"
|
||||
|
||||
def test_non_downloadable_claim_skipped(self):
|
||||
channel = Channel(
|
||||
input="test",
|
||||
normalized_uri="lbry://@Test#1",
|
||||
channel_claim_id="channel123",
|
||||
channel_name="@Test",
|
||||
folder="Test__channel123",
|
||||
)
|
||||
|
||||
# Channel type claim (not downloadable)
|
||||
claim_data = {
|
||||
"claim_id": "claim456",
|
||||
"name": "@SomeChannel",
|
||||
"value_type": "channel",
|
||||
"value": {},
|
||||
"permanent_url": "lbry://@SomeChannel#claim456",
|
||||
}
|
||||
|
||||
actions = self.planner.process_channel_claims(channel, [claim_data])
|
||||
|
||||
assert len(actions) == 0
|
||||
|
||||
def test_custom_download_path(self):
|
||||
"""Test that custom download_path is used for channel."""
|
||||
custom_path = "/custom/download/location"
|
||||
channel = Channel(
|
||||
input="test",
|
||||
normalized_uri="lbry://@Test#1",
|
||||
channel_claim_id="channel123",
|
||||
channel_name="@Test",
|
||||
folder="Test__channel123",
|
||||
download_path=custom_path,
|
||||
)
|
||||
|
||||
claim_data = {
|
||||
"claim_id": "claim456",
|
||||
"name": "my-video",
|
||||
"value_type": "stream",
|
||||
"value": {"source": {"sd_hash": "abc123"}, "title": "My Video"},
|
||||
"permanent_url": "lbry://@Test#1/my-video#claim456",
|
||||
"txid": "tx789",
|
||||
"nout": 0,
|
||||
}
|
||||
|
||||
actions = self.planner.process_channel_claims(channel, [claim_data])
|
||||
|
||||
assert len(actions) == 1
|
||||
assert actions[0].action == "download_new"
|
||||
# Check that target_dir uses custom path
|
||||
assert custom_path in actions[0].target_dir
|
||||
assert "Test__channel123" in actions[0].target_dir
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Tests for state database."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
from state_db import StateDb
|
||||
from models import Channel, Claim, ClaimVersion
|
||||
|
||||
|
||||
class TestStateDb:
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.state_file = Path(self.temp_dir) / "test_state.json"
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_load_new_database(self):
|
||||
db = StateDb(str(self.state_file))
|
||||
state = db.load()
|
||||
|
||||
assert state.schema_version == 1
|
||||
assert state.channels == {}
|
||||
assert state.claims == {}
|
||||
|
||||
def test_save_and_load(self):
|
||||
db = StateDb(str(self.state_file))
|
||||
db.load()
|
||||
|
||||
# Add a channel
|
||||
channel = Channel(
|
||||
input="https://odysee.com/@Test:1",
|
||||
normalized_uri="lbry://@Test#1",
|
||||
channel_claim_id="abc123",
|
||||
channel_name="@Test",
|
||||
folder="Test__abc123",
|
||||
)
|
||||
db.set_channel(channel)
|
||||
db.save()
|
||||
|
||||
# Load fresh
|
||||
db2 = StateDb(str(self.state_file))
|
||||
state = db2.load()
|
||||
|
||||
assert "abc123" in state.channels
|
||||
assert state.channels["abc123"].channel_name == "@Test"
|
||||
|
||||
def test_channel_operations(self):
|
||||
db = StateDb(str(self.state_file))
|
||||
db.load()
|
||||
|
||||
channel = Channel(
|
||||
input="test",
|
||||
normalized_uri="lbry://@Test#1",
|
||||
channel_claim_id="xyz789",
|
||||
channel_name="@Test",
|
||||
folder="Test__xyz789",
|
||||
)
|
||||
|
||||
db.set_channel(channel)
|
||||
retrieved = db.get_channel("xyz789")
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.channel_name == "@Test"
|
||||
assert db.get_channel("nonexistent") is None
|
||||
|
||||
def test_claim_operations(self):
|
||||
db = StateDb(str(self.state_file))
|
||||
db.load()
|
||||
|
||||
claim = Claim(
|
||||
claim_id="claim123",
|
||||
channel_claim_id="channel456",
|
||||
name="my-video",
|
||||
permanent_url="lbry://@Test#1/my-video#claim123",
|
||||
claim_folder="channels/Test__channel456/claims/my-video__claim123",
|
||||
)
|
||||
|
||||
db.set_claim(claim)
|
||||
retrieved = db.get_claim("claim123")
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.name == "my-video"
|
||||
|
||||
def test_run_history_logging(self):
|
||||
db = StateDb(str(self.state_file))
|
||||
db.load()
|
||||
|
||||
history_file = Path(self.temp_dir) / "history.jsonl"
|
||||
|
||||
db.log_run_history(
|
||||
str(history_file),
|
||||
"channel123",
|
||||
"claim456",
|
||||
"download_new",
|
||||
"sd_abc123",
|
||||
"success",
|
||||
)
|
||||
|
||||
assert history_file.exists()
|
||||
|
||||
with open(history_file) as f:
|
||||
line = json.loads(f.readline())
|
||||
assert line["action"] == "download_new"
|
||||
assert line["status"] == "success"
|
||||
|
||||
def test_get_stats(self):
|
||||
db = StateDb(str(self.state_file))
|
||||
db.load()
|
||||
|
||||
# Add test data
|
||||
claim = Claim(
|
||||
claim_id="c1",
|
||||
channel_claim_id="ch1",
|
||||
name="video1",
|
||||
permanent_url="lbry://test",
|
||||
claim_folder="test",
|
||||
)
|
||||
claim.versions["v1"] = ClaimVersion(
|
||||
version_token="v1", txid="tx1", nout=0, downloaded=True
|
||||
)
|
||||
|
||||
db.set_claim(claim)
|
||||
|
||||
stats = db.get_stats()
|
||||
assert stats["claims"] == 1
|
||||
assert stats["total_versions"] == 1
|
||||
assert stats["downloaded_versions"] == 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for utility functions."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from utils import (
|
||||
sanitize_filename,
|
||||
slugify,
|
||||
generate_version_token,
|
||||
expand_path,
|
||||
is_downloadable_claim,
|
||||
normalize_odysee_url,
|
||||
)
|
||||
|
||||
|
||||
class TestSanitizeFilename:
|
||||
def test_basic_sanitization(self):
|
||||
assert sanitize_filename("hello/world") == "hello_world"
|
||||
assert sanitize_filename("file:name") == "file_name"
|
||||
assert sanitize_filename('file"name') == "file_name"
|
||||
|
||||
def test_whitespace_collapse(self):
|
||||
assert sanitize_filename("hello world") == "hello world"
|
||||
|
||||
def test_trailing_cleanup(self):
|
||||
assert sanitize_filename("hello.") == "hello"
|
||||
assert sanitize_filename("hello ") == "hello"
|
||||
|
||||
def test_length_limit(self):
|
||||
long_name = "a" * 200
|
||||
result = sanitize_filename(long_name)
|
||||
assert len(result) <= 120
|
||||
|
||||
def test_extension_preservation(self):
|
||||
result = sanitize_filename("my/file:name.txt")
|
||||
assert result.endswith(".txt")
|
||||
|
||||
|
||||
class TestSlugify:
|
||||
def test_basic_slug(self):
|
||||
assert slugify("Hello World") == "hello-world"
|
||||
assert slugify("Test!!!") == "test"
|
||||
|
||||
def test_special_chars_removed(self):
|
||||
assert slugify("hello@world#123") == "helloworld123"
|
||||
|
||||
def test_multiple_dashes_collapsed(self):
|
||||
assert slugify("hello---world") == "hello-world"
|
||||
|
||||
|
||||
class TestGenerateVersionToken:
|
||||
def test_sd_hash_priority(self):
|
||||
token = generate_version_token(
|
||||
sd_hash="abc123", stream_hash="def456", txid="ghi789", nout=0
|
||||
)
|
||||
assert token.startswith("sd_")
|
||||
|
||||
def test_stream_hash_fallback(self):
|
||||
token = generate_version_token(stream_hash="def456", txid="ghi789", nout=0)
|
||||
assert token.startswith("stream_")
|
||||
|
||||
def test_txid_fallback(self):
|
||||
token = generate_version_token(txid="ghi789", nout=5)
|
||||
assert token.startswith("tx_")
|
||||
|
||||
def test_metadata_fallback(self):
|
||||
token = generate_version_token(metadata={"name": "test", "id": 123})
|
||||
assert token.startswith("meta_")
|
||||
|
||||
|
||||
class TestExpandPath:
|
||||
def test_home_expansion(self):
|
||||
path = expand_path("~/test")
|
||||
assert not str(path).startswith("~")
|
||||
assert path.is_absolute()
|
||||
|
||||
|
||||
class TestIsDownloadableClaim:
|
||||
def test_stream_claim_with_source(self):
|
||||
claim = {"value_type": "stream", "value": {"source": {"sd_hash": "abc123"}}}
|
||||
assert is_downloadable_claim(claim) is True
|
||||
|
||||
def test_stream_claim_with_stream_hash(self):
|
||||
claim = {"value_type": "stream", "value": {}, "stream_hash": "abc123"}
|
||||
assert is_downloadable_claim(claim) is True
|
||||
|
||||
def test_non_stream_claim(self):
|
||||
claim = {"value_type": "channel"}
|
||||
assert is_downloadable_claim(claim) is False
|
||||
|
||||
def test_stream_without_source(self):
|
||||
claim = {"value_type": "stream", "value": {}}
|
||||
assert is_downloadable_claim(claim) is False
|
||||
|
||||
|
||||
class TestNormalizeOdyseeUrl:
|
||||
def test_https_odysee(self):
|
||||
result = normalize_odysee_url("https://odysee.com/@Channel:1")
|
||||
assert result == "lbry://@Channel:1"
|
||||
|
||||
def test_http_odysee(self):
|
||||
result = normalize_odysee_url("http://odysee.com/@Channel:1")
|
||||
assert result == "lbry://@Channel:1"
|
||||
|
||||
def test_already_lbry(self):
|
||||
result = normalize_odysee_url("lbry://@Channel:1")
|
||||
assert result == "lbry://@Channel:1"
|
||||
|
||||
def test_short_form(self):
|
||||
result = normalize_odysee_url("@Channel:1")
|
||||
assert result == "lbry://@Channel:1"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in new issue
Block a user