repos.yaml imports by diffing (#870)
* repos.yaml imports by diffing
repos.yaml declares one section, its Repos keyed by repo_url_key (another
URL of the same Repo is that Repo), comparing name, description,
directory, Tag, mode, branch, submodules, frequency and clone token (a
missing directory or clone token keeps the database's). An unchanged
import writes nothing (every import rewrote every Repo and its
Download).
An invalid entry is skipped and counted, and its Repo is kept; before,
a Repo whose entry failed validation was deleted. New Repos are created
inside the import's one write: recurring_download (via
get_or_create_download and _set_repository_download) takes commit=False,
and the URL is not looked up again for each new Repo (O(N^2)).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* repos.yaml: a branch URL is keyed by its Repo's URL
file_rows keyed the raw URL, but stores parse_repo_url's URL, which
turns a forge's branch URL (GitHub /tree/<branch>, GitLab
/-/tree/<branch>) into the repo URL and a branch. The entry looked like
a new Repo: its insert hit the existing Repo's name (rolling back every
import of that file), and an invalid such entry did not protect its Repo
from deletion. Rows and invalid keys now use the parsed URL; a URL that
cannot be parsed is skipped by its raw key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:35:57 -06:00
|
|
|
"""repos.yaml imports by diffing its Repos against the database, and writing only the differences (see
|
|
|
|
|
wrolpi.config_import). A Repo is keyed by its URL (`repo_url_key`)."""
|
|
|
|
|
import pytest
|
|
|
|
|
import yaml
|
|
|
|
|
|
|
|
|
|
from modules.repos import lib
|
|
|
|
|
from modules.repos.models import Repository
|
|
|
|
|
from wrolpi.config_version import get_config_version
|
|
|
|
|
from wrolpi.downloader import DownloadFrequency
|
|
|
|
|
from wrolpi.test.test_config_import import edit_config, statements, writes
|
|
|
|
|
|
|
|
|
|
ONE = 'https://github.com/a/one'
|
|
|
|
|
TWO = 'https://github.com/a/two'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
async def saved_repos(test_session, async_client, test_wrolpi_config, await_switches):
|
|
|
|
|
"""Repo `one` (tagged, daily) and `two` (a snapshot of a branch), saved to the config (which records its
|
|
|
|
|
version)."""
|
|
|
|
|
lib.create_repository(test_session, ONE, tag_name='software', description='One', frequency=DownloadFrequency.daily)
|
|
|
|
|
lib.create_repository(test_session, TWO, mode='snapshot', branch='dev')
|
|
|
|
|
await await_switches()
|
|
|
|
|
config = lib.get_repos_config()
|
|
|
|
|
config.dump_config()
|
|
|
|
|
await await_switches()
|
|
|
|
|
assert get_config_version(test_session, 'repos.yaml') is not None
|
|
|
|
|
return config.get_file()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def edit_repo(path, repo_url, **values):
|
|
|
|
|
def change(data):
|
|
|
|
|
next(i for i in data["repos"] if i["url"] == repo_url).update(values)
|
|
|
|
|
|
|
|
|
|
edit_config(path, change)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def repos(test_session) -> dict:
|
|
|
|
|
test_session.expire_all()
|
|
|
|
|
return {i.url: i for i in test_session.query(Repository)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_unchanged_import_writes_nothing(test_session, saved_repos):
|
|
|
|
|
"""Importing a config the database already matches opens no write transaction."""
|
|
|
|
|
config = lib.get_repos_config()
|
|
|
|
|
with statements() as collected:
|
|
|
|
|
config.import_config()
|
|
|
|
|
|
|
|
|
|
assert config.successful_import is True
|
|
|
|
|
assert writes(collected) == []
|
|
|
|
|
# The version, then one snapshot of the Repos.
|
|
|
|
|
assert len(collected) <= 3, collected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_same_repo_by_another_url_is_unchanged(test_session, saved_repos):
|
|
|
|
|
"""A URL of the same Repo (a `.git` suffix, the host's case) is that Repo."""
|
|
|
|
|
edit_repo(saved_repos, TWO, url='https://GitHub.com/a/two.git')
|
|
|
|
|
config = lib.get_repos_config()
|
|
|
|
|
with statements() as collected:
|
|
|
|
|
config.import_config()
|
|
|
|
|
|
|
|
|
|
assert writes(collected) == []
|
|
|
|
|
assert set(repos(test_session)) == {ONE, TWO}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_import_writes_only_the_differences(test_session, saved_repos):
|
|
|
|
|
"""A changed Repo is updated, a removed one is deleted (with its Download), an added one is created."""
|
|
|
|
|
def change(data):
|
|
|
|
|
one = next(i for i in data['repos'] if i['url'] == ONE)
|
|
|
|
|
one.update(description='Changed', frequency='never', mode='snapshot', branch='main')
|
|
|
|
|
data['repos'] = [one, dict(url='https://github.com/a/three', mode='full')]
|
|
|
|
|
|
|
|
|
|
edit_config(saved_repos, change)
|
|
|
|
|
config = lib.get_repos_config()
|
|
|
|
|
config.import_config()
|
|
|
|
|
|
|
|
|
|
assert config.successful_import is True
|
|
|
|
|
found = repos(test_session)
|
|
|
|
|
assert set(found) == {ONE, 'https://github.com/a/three'}
|
|
|
|
|
one = found[ONE]
|
|
|
|
|
assert (one.collection.description, one.mode, one.branch, one.download) == ('Changed', 'snapshot', 'main', None)
|
|
|
|
|
assert found['https://github.com/a/three'].download.frequency == lib.DEFAULT_REPO_FREQUENCY
|
|
|
|
|
with statements() as collected:
|
|
|
|
|
config.import_config()
|
|
|
|
|
assert writes(collected) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_invalid_entry_is_kept(test_session, saved_repos):
|
|
|
|
|
"""A Repo whose entry is invalid is skipped, not deleted; the others are still imported."""
|
|
|
|
|
edit_repo(saved_repos, TWO, mode='bogus')
|
|
|
|
|
edit_repo(saved_repos, ONE, description='Changed')
|
|
|
|
|
|
|
|
|
|
config = lib.get_repos_config()
|
|
|
|
|
config.import_config()
|
|
|
|
|
|
|
|
|
|
assert config.import_skipped == 1
|
|
|
|
|
found = repos(test_session)
|
|
|
|
|
assert set(found) == {ONE, TWO}
|
|
|
|
|
assert (found[TWO].mode, found[ONE].collection.description) == ('snapshot', 'Changed')
|
|
|
|
|
|
|
|
|
|
|
A malformed config entry is skipped and counted instead of failing the import (#875)
* A malformed config entry is skipped and counted instead of failing the import
An entry that is not a mapping (or whose key is not text, or a list that is not a list) made the whole file fail to
import in playlists.yaml, repos.yaml, download_manager.yaml and tags.yaml (tag_files/tag_zims), and a non-text name
did in domains.yaml. Each is now skipped and counted in import_skipped, so the config is not saved over it, and the
rest of the file imports. A Channel whose `downloads` is not a list keeps its Downloads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* An unreadable entry keeps no row and does not make a section present
The key of an entry too malformed to name a row is now `Unreadable(position)`. It is still counted in
import_skipped, but `present` ignores it, so a section whose entries are all unreadable removes nothing (as an empty
section), instead of deleting every row. A playlist whose `items` is not a list keeps its items, and a Channel
Download whose url is not text is counted (its Channel keeps its Downloads) instead of failing the import.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:28:30 -06:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_malformed_entry_is_skipped(test_session, saved_repos):
|
|
|
|
|
"""An entry that is not a mapping is skipped and counted; the rest of the file still imports."""
|
|
|
|
|
edit_repo(saved_repos, ONE, description='Changed')
|
|
|
|
|
edit_config(saved_repos, lambda data: data['repos'].insert(0, 'not a mapping'))
|
|
|
|
|
|
|
|
|
|
config = lib.get_repos_config()
|
|
|
|
|
config.import_config()
|
|
|
|
|
|
|
|
|
|
assert config.successful_import is True
|
|
|
|
|
assert config.import_skipped == 1
|
|
|
|
|
found = repos(test_session)
|
|
|
|
|
assert set(found) == {ONE, TWO}
|
|
|
|
|
assert found[ONE].collection.description == 'Changed'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_only_malformed_entries_delete_nothing(test_session, saved_repos):
|
|
|
|
|
"""A file whose entries are all unreadable deletes no Repo (like an empty list)."""
|
|
|
|
|
edit_config(saved_repos, lambda data: data.update(repos=['not a mapping', dict(url=['a', 'list'])]))
|
|
|
|
|
|
|
|
|
|
config = lib.get_repos_config()
|
|
|
|
|
config.import_config()
|
|
|
|
|
|
|
|
|
|
assert config.import_skipped == 2
|
|
|
|
|
assert set(repos(test_session)) == {ONE, TWO}
|
|
|
|
|
|
|
|
|
|
|
repos.yaml imports by diffing (#870)
* repos.yaml imports by diffing
repos.yaml declares one section, its Repos keyed by repo_url_key (another
URL of the same Repo is that Repo), comparing name, description,
directory, Tag, mode, branch, submodules, frequency and clone token (a
missing directory or clone token keeps the database's). An unchanged
import writes nothing (every import rewrote every Repo and its
Download).
An invalid entry is skipped and counted, and its Repo is kept; before,
a Repo whose entry failed validation was deleted. New Repos are created
inside the import's one write: recurring_download (via
get_or_create_download and _set_repository_download) takes commit=False,
and the URL is not looked up again for each new Repo (O(N^2)).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* repos.yaml: a branch URL is keyed by its Repo's URL
file_rows keyed the raw URL, but stores parse_repo_url's URL, which
turns a forge's branch URL (GitHub /tree/<branch>, GitLab
/-/tree/<branch>) into the repo URL and a branch. The entry looked like
a new Repo: its insert hit the existing Repo's name (rolling back every
import of that file), and an invalid such entry did not protect its Repo
from deletion. Rows and invalid keys now use the parsed URL; a URL that
cannot be parsed is skipped by its raw key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:35:57 -06:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_failed_import_creates_nothing(test_session, saved_repos, monkeypatch):
|
|
|
|
|
"""A new Repo is created in the import's one write: a failure later in the write leaves nothing (each new Repo
|
|
|
|
|
was committed on its own)."""
|
|
|
|
|
edit_config(saved_repos, lambda data: data['repos'].append(dict(url='https://github.com/a/three')))
|
|
|
|
|
edit_config(saved_repos, lambda data: data.update(repos=[i for i in data['repos'] if i['url'] != TWO]))
|
|
|
|
|
|
|
|
|
|
def fail(*args, **kwargs):
|
|
|
|
|
raise RuntimeError('Write failed')
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(lib, '_delete_repository', fail)
|
|
|
|
|
with pytest.raises(RuntimeError):
|
|
|
|
|
lib.get_repos_config().import_config()
|
|
|
|
|
|
|
|
|
|
assert set(repos(test_session)) == {ONE, TWO}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_branch_url_is_its_repo(test_session, saved_repos):
|
|
|
|
|
"""A forge's branch URL (GitHub `/tree/<branch>`, GitLab `/-/tree/<branch>`) is its Repo, with that branch;
|
|
|
|
|
the next import writes nothing."""
|
|
|
|
|
edit_repo(saved_repos, TWO, url=f'{TWO}/tree/main', branch=None)
|
|
|
|
|
edit_config(saved_repos, lambda data: data['repos'].append(
|
|
|
|
|
dict(url='https://gitlab.com/group/sub/project/-/tree/dev')))
|
|
|
|
|
|
|
|
|
|
config = lib.get_repos_config()
|
|
|
|
|
config.import_config()
|
|
|
|
|
|
|
|
|
|
assert config.successful_import is True
|
|
|
|
|
found = repos(test_session)
|
|
|
|
|
assert set(found) == {ONE, TWO, 'https://gitlab.com/group/sub/project'}
|
|
|
|
|
assert (found[TWO].branch, found['https://gitlab.com/group/sub/project'].branch) == ('main', 'dev')
|
|
|
|
|
with statements() as collected:
|
|
|
|
|
config.import_config()
|
|
|
|
|
assert writes(collected) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_invalid_branch_url_entry_is_kept(test_session, saved_repos):
|
|
|
|
|
"""An invalid entry named by its Repo's branch URL keeps that Repo."""
|
|
|
|
|
edit_repo(saved_repos, TWO, url=f'{TWO}/tree/main', mode='bogus')
|
|
|
|
|
|
|
|
|
|
config = lib.get_repos_config()
|
|
|
|
|
config.import_config()
|
|
|
|
|
|
|
|
|
|
assert config.import_skipped == 1
|
|
|
|
|
assert set(repos(test_session)) == {ONE, TWO}
|