From ce1b04e1a57b2c993ffb7a51bbb0848c644b5971 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 20 Sep 2022 19:27:15 -0500 Subject: [PATCH 01/42] refactor(url): Matcher -> Rule, MatcherRegistry -> Rules --- README.md | 2 +- src/libvcs/url/base.py | 56 +++++++++++++------------- src/libvcs/url/git.py | 90 ++++++++++++++++++++---------------------- src/libvcs/url/hg.py | 50 +++++++++++------------ src/libvcs/url/svn.py | 42 ++++++++++---------- tests/url/test_git.py | 10 ++--- tests/url/test_hg.py | 6 +-- tests/url/test_svn.py | 6 +-- 8 files changed, 127 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index 7ffd24ee8..4a06c9408 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ GitURL(url=git@github.com:vcs-python/libvcs.git, hostname=github.com, path=vcs-python/libvcs, suffix=.git, - matcher=core-git-scp) + rule=core-git-scp) ``` Switch repo libvcs -> vcspull: diff --git a/src/libvcs/url/base.py b/src/libvcs/url/base.py index d79545b75..ac784d3cd 100644 --- a/src/libvcs/url/base.py +++ b/src/libvcs/url/base.py @@ -23,8 +23,8 @@ def is_valid(self, url: str, is_explicit: Optional[bool] = None) -> bool: @dataclasses.dataclass(repr=False) -class Matcher(SkipDefaultFieldsReprMixin): - """Structure for a matcher""" +class Rule(SkipDefaultFieldsReprMixin): + """Structure for a rule""" label: str """Computer readable name / ID""" @@ -38,12 +38,12 @@ class Matcher(SkipDefaultFieldsReprMixin): @dataclasses.dataclass(repr=False) -class MatcherRegistry(SkipDefaultFieldsReprMixin): +class Rules(SkipDefaultFieldsReprMixin): """Pattern matching and parsing capabilities for URL parsers, e.g. GitURL""" - _matchers: dict[str, Matcher] = dataclasses.field(default_factory=dict) + _rules: dict[str, Rule] = dataclasses.field(default_factory=dict) - def register(self, cls: Matcher) -> None: + def register(self, cls: Rule) -> None: r""" .. currentmodule:: libvcs.url.git @@ -72,7 +72,7 @@ def register(self, cls: Matcher) -> None: GitURL(url=github:org/repo, hostname=github, path=org/repo, - matcher=core-git-scp) + rule=core-git-scp) >>> GitURL(url="github:org/repo").to_url() 'git@github:org/repo' @@ -84,7 +84,7 @@ def register(self, cls: Matcher) -> None: **Extending matching capability:** - >>> class GitHubPrefix(Matcher): + >>> class GitHubPrefix(Rule): ... label = 'gh-prefix' ... description ='Matches prefixes like github:org/repo' ... pattern = r'^github:(?P.*)$' @@ -97,8 +97,8 @@ def register(self, cls: Matcher) -> None: >>> @dataclasses.dataclass(repr=False) ... class GitHubURL(GitURL): - ... matchers: MatcherRegistry = MatcherRegistry( - ... _matchers={'github_prefix': GitHubPrefix} + ... rules: Rules = Rules( + ... _rules={'github_prefix': GitHubPrefix} ... ) >>> GitHubURL.is_valid(url='github:vcs-python/libvcs') @@ -114,7 +114,7 @@ def register(self, cls: Matcher) -> None: scheme=https, hostname=github.com, path=vcs-python/libvcs, - matcher=gh-prefix) + rule=gh-prefix) >>> GitHubURL(url='github:vcs-python/libvcs').to_url() 'https://github.com/vcs-python/libvcs' @@ -122,7 +122,7 @@ def register(self, cls: Matcher) -> None: >>> GitHubURL.is_valid(url='gitlab:vcs-python/libvcs') False - ``GitHubURL`` sees this as invalid since it only has one matcher, + ``GitHubURL`` sees this as invalid since it only has one rule, ``GitHubPrefix``. >>> GitURL.is_valid(url='gitlab:vcs-python/libvcs') @@ -130,10 +130,10 @@ def register(self, cls: Matcher) -> None: Same story, getting caught in ``git(1)``'s own liberal scp-style URL: - >>> GitURL(url='gitlab:vcs-python/libvcs').matcher + >>> GitURL(url='gitlab:vcs-python/libvcs').rule 'core-git-scp' - >>> class GitLabPrefix(Matcher): + >>> class GitLabPrefix(Rule): ... label = 'gl-prefix' ... description ='Matches prefixes like gitlab:org/repo' ... pattern = r'^gitlab:(?P)' @@ -143,12 +143,12 @@ def register(self, cls: Matcher) -> None: ... 'suffix': '.git' ... } - Option 1: Create a brand new matcher + Option 1: Create a brand new rule >>> @dataclasses.dataclass(repr=False) ... class GitLabURL(GitURL): - ... matchers: MatcherRegistry = MatcherRegistry( - ... _matchers={'gitlab_prefix': GitLabPrefix} + ... rules: Rules = Rules( + ... _rules={'gitlab_prefix': GitLabPrefix} ... ) >>> GitLabURL.is_valid(url='gitlab:vcs-python/libvcs') @@ -161,12 +161,12 @@ def register(self, cls: Matcher) -> None: Are we home free, though? Remember our issue with vague matches. - >>> GitURL(url='gitlab:vcs-python/libvcs').matcher + >>> GitURL(url='gitlab:vcs-python/libvcs').rule 'core-git-scp' Register: - >>> GitURL.matchers.register(GitLabPrefix) + >>> GitURL.rules.register(GitLabPrefix) >>> GitURL.is_valid(url='gitlab:vcs-python/libvcs') True @@ -183,8 +183,8 @@ def register(self, cls: Matcher) -> None: >>> @dataclasses.dataclass(repr=False) ... class GitURLWithPip(GitBaseURL): - ... matchers: MatcherRegistry = MatcherRegistry( - ... _matchers={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + ... rules: Rules = Rules( + ... _rules={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} ... ) >>> GitURLWithPip.is_valid(url="git+ssh://git@github.com/tony/AlgoXY.git") @@ -197,19 +197,19 @@ def register(self, cls: Matcher) -> None: hostname=github.com, path=tony/AlgoXY, suffix=.git, - matcher=pip-url) + rule=pip-url) """ # NOQA: E501 - if cls.label not in self._matchers: - self._matchers[cls.label] = cls + if cls.label not in self._rules: + self._rules[cls.label] = cls def unregister(self, label: str) -> None: - if label in self._matchers: - del self._matchers[label] + if label in self._rules: + del self._rules[label] def __iter__(self) -> Iterator[str]: - return self._matchers.__iter__() + return self._rules.__iter__() def values( self, # https://github.com/python/typing/discussions/1033 - ) -> "dict_values[str, Matcher]": - return self._matchers.values() + ) -> "dict_values[str, Rule]": + return self._rules.values() diff --git a/src/libvcs/url/git.py b/src/libvcs/url/git.py index 76636cda0..c29b7e2b2 100644 --- a/src/libvcs/url/git.py +++ b/src/libvcs/url/git.py @@ -12,8 +12,8 @@ - Strict ``git(1)`` compatibility: :class:`GitBaseURL`. - Output ``git(1)`` URL: :meth:`GitBaseURL.to_url()` -- Extendable via :class:`~libvcs.url.base.MatcherRegistry`, - :class:`~libvcs.url.base.Matcher` +- Extendable via :class:`~libvcs.url.base.Rules`, + :class:`~libvcs.url.base.Rule` """ import dataclasses @@ -22,7 +22,7 @@ from libvcs._internal.dataclasses import SkipDefaultFieldsReprMixin -from .base import Matcher, MatcherRegistry, URLProtocol +from .base import Rule, Rules, URLProtocol # Credit, pip (license: MIT): # https://github.com/pypa/pip/blob/22.1.2/src/pip/_internal/vcs/git.py#L39-L52 @@ -60,8 +60,8 @@ # Some https repos have .git at the end, e.g. https://github.com/org/repo.git -DEFAULT_MATCHERS: list[Matcher] = [ - Matcher( +DEFAULT_MATCHERS: list[Rule] = [ + Rule( label="core-git-https", description="Vanilla git pattern, URL ending with optional .git suffix", pattern=re.compile( @@ -76,7 +76,7 @@ ), # ends with .git. Including ones starting with https:// # e.g. https://github.com/vcs-python/libvcs.git - Matcher( + Rule( label="core-git-scp", description="Vanilla scp(1) / ssh(1) type URL", pattern=re.compile( @@ -125,8 +125,8 @@ """ -PIP_DEFAULT_MATCHERS: list[Matcher] = [ - Matcher( +PIP_DEFAULT_MATCHERS: list[Rule] = [ + Rule( label="pip-url", description="pip-style git URL", pattern=re.compile( @@ -141,7 +141,7 @@ ), is_explicit=True, ), - Matcher( + Rule( label="pip-scp-url", description="pip-style git ssh/scp URL", pattern=re.compile( @@ -156,7 +156,7 @@ is_explicit=True, ), # file://, RTC 8089, File:// https://datatracker.ietf.org/doc/html/rfc8089 - Matcher( + Rule( label="pip-file-url", description="pip-style git+file:// URL", pattern=re.compile( @@ -191,7 +191,7 @@ - https://pip.pypa.io/en/stable/topics/vcs-support/ """ # NOQA: E501 -NPM_DEFAULT_MATCHERS: list[Matcher] = [] +NPM_DEFAULT_MATCHERS: list[Rule] = [] """NPM-style git URLs. Git URL pattern (from docs.npmjs.com):: @@ -224,7 +224,7 @@ class GitBaseURL(URLProtocol, SkipDefaultFieldsReprMixin): hostname=github.com, path=vcs-python/libvcs, suffix=.git, - matcher=core-git-https) + rule=core-git-https) >>> myrepo = GitBaseURL(url='https://github.com/myproject/myrepo.git') @@ -240,15 +240,15 @@ class GitBaseURL(URLProtocol, SkipDefaultFieldsReprMixin): hostname=github.com, path=vcs-python/libvcs, suffix=.git, - matcher=core-git-scp) + rule=core-git-scp) - Compatibility checking: :meth:`GitBaseURL.is_valid()` - URLs compatible with ``git(1)``: :meth:`GitBaseURL.to_url()` Attributes ---------- - matcher : str - name of the :class:`~libvcs.url.base.Matcher` + rule : str + name of the :class:`~libvcs.url.base.Rule` """ url: str @@ -261,25 +261,23 @@ class GitBaseURL(URLProtocol, SkipDefaultFieldsReprMixin): # Decoration suffix: Optional[str] = None - matcher: Optional[str] = None - matchers: MatcherRegistry = MatcherRegistry( - _matchers={m.label: m for m in DEFAULT_MATCHERS} - ) + rule: Optional[str] = None + rules: Rules = Rules(_rules={m.label: m for m in DEFAULT_MATCHERS}) def __post_init__(self) -> None: url = self.url - for matcher in self.matchers.values(): - match = re.match(matcher.pattern, url) + for rule in self.rules.values(): + match = re.match(rule.pattern, url) if match is None: continue groups = match.groupdict() - setattr(self, "matcher", matcher.label) + setattr(self, "rule", rule.label) for k, v in groups.items(): setattr(self, k, v) - for k, v in matcher.pattern_defaults.items(): + for k, v in rule.pattern_defaults.items(): if getattr(self, k, None) is None: - setattr(self, k, matcher.pattern_defaults[k]) + setattr(self, k, rule.pattern_defaults[k]) @classmethod def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: @@ -312,11 +310,11 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: """ if is_explicit is not None: return any( - re.search(matcher.pattern, url) - for matcher in cls.matchers.values() - if matcher.is_explicit == is_explicit + re.search(rule.pattern, url) + for rule in cls.rules.values() + if rule.is_explicit == is_explicit ) - return any(re.search(matcher.pattern, url) for matcher in cls.matchers.values()) + return any(re.search(rule.pattern, url) for rule in cls.rules.values()) def to_url(self) -> str: """Return a ``git(1)``-compatible URL. Can be used with ``git clone``. @@ -332,7 +330,7 @@ def to_url(self) -> str: hostname=github.com, path=vcs-python/libvcs, suffix=.git, - matcher=core-git-scp) + rule=core-git-scp) Switch repo libvcs -> vcspull: @@ -372,9 +370,7 @@ class GitPipURL(GitBaseURL, URLProtocol, SkipDefaultFieldsReprMixin): # commit-ish (rev): tag, branch, ref rev: Optional[str] = None - matchers: MatcherRegistry = MatcherRegistry( - _matchers={m.label: m for m in PIP_DEFAULT_MATCHERS} - ) + rules: Rules = Rules(_rules={m.label: m for m in PIP_DEFAULT_MATCHERS}) def to_url(self) -> str: """Exports a pip-compliant URL. @@ -394,7 +390,7 @@ def to_url(self) -> str: port=7999, path=PROJ/repo, suffix=.git, - matcher=pip-url) + rule=pip-url) >>> git_url.path = 'libvcs/vcspull' @@ -413,7 +409,7 @@ def to_url(self) -> str: hostname=github.com, path=vcs-python/libvcs, suffix=.git, - matcher=pip-url, + rule=pip-url, rev=v0.10.0) >>> git_url.path = 'libvcs/vcspull' @@ -456,7 +452,7 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: **Explicit VCS detection** - Pip-style URLs are prefixed with the VCS name in front, so its matchers can + Pip-style URLs are prefixed with the VCS name in front, so its rules can unambigously narrow the type of VCS: >>> GitPipURL.is_valid( @@ -482,13 +478,13 @@ class GitURL(GitPipURL, GitBaseURL, URLProtocol, SkipDefaultFieldsReprMixin): - :meth:`GitBaseURL.to_url` """ - matchers: MatcherRegistry = MatcherRegistry( - _matchers={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + rules: Rules = Rules( + _rules={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} ) @classmethod def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: - r"""Whether URL is compatible included Git URL matchers or not. + r"""Whether URL is compatible included Git URL rules or not. Examples -------- @@ -514,7 +510,7 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: **Explicit VCS detection** - Pip-style URLs are prefixed with the VCS name in front, so its matchers can + Pip-style URLs are prefixed with the VCS name in front, so its rules can unambigously narrow the type of VCS: >>> GitURL.is_valid( @@ -530,12 +526,12 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: ... ) False - You could create a GitHub matcher that consider github.com hostnames to be + You could create a GitHub rule that consider github.com hostnames to be exclusively git: - >>> GitHubMatcher = Matcher( + >>> GitHubRule = Rule( ... # Since github.com exclusively serves git repos, make explicit - ... label='gh-matcher', + ... label='gh-rule', ... description='Matches github.com https URLs, exact VCS match', ... pattern=re.compile( ... rf''' @@ -553,21 +549,21 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: ... } ... ) - >>> GitURL.matchers.register(GitHubMatcher) + >>> GitURL.rules.register(GitHubRule) >>> GitURL.is_valid( ... url='git@github.com:vcs-python/libvcs.git', is_explicit=True ... ) True - >>> GitURL(url='git@github.com:vcs-python/libvcs.git').matcher - 'gh-matcher' + >>> GitURL(url='git@github.com:vcs-python/libvcs.git').rule + 'gh-rule' This is just us cleaning up: - >>> GitURL.matchers.unregister('gh-matcher') + >>> GitURL.rules.unregister('gh-rule') - >>> GitURL(url='git@github.com:vcs-python/libvcs.git').matcher + >>> GitURL(url='git@github.com:vcs-python/libvcs.git').rule 'core-git-scp' """ return super().is_valid(url=url, is_explicit=is_explicit) diff --git a/src/libvcs/url/hg.py b/src/libvcs/url/hg.py index 412f1cee6..560724f1f 100644 --- a/src/libvcs/url/hg.py +++ b/src/libvcs/url/hg.py @@ -6,8 +6,8 @@ compare to :class:`urllib.parse.ParseResult` - Output ``hg(1)`` URL: :meth:`HgURL.to_url()` -- Extendable via :class:`~libvcs.url.base.MatcherRegistry`, - :class:`~libvcs.url.base.Matcher` +- Extendable via :class:`~libvcs.url.base.Rules`, + :class:`~libvcs.url.base.Rule` .. Note:: @@ -23,7 +23,7 @@ from libvcs._internal.dataclasses import SkipDefaultFieldsReprMixin -from .base import Matcher, MatcherRegistry, URLProtocol +from .base import Rule, Rules, URLProtocol RE_PATH = r""" ((?P\w+)@)? @@ -43,8 +43,8 @@ ) """ -DEFAULT_MATCHERS: list[Matcher] = [ - Matcher( +DEFAULT_MATCHERS: list[Rule] = [ + Rule( label="core-hg", description="Vanilla hg pattern", pattern=re.compile( @@ -74,8 +74,8 @@ ) """ -PIP_DEFAULT_MATCHERS: list[Matcher] = [ - Matcher( +PIP_DEFAULT_MATCHERS: list[Rule] = [ + Rule( label="pip-url", description="pip-style hg URL", pattern=re.compile( @@ -88,7 +88,7 @@ ), ), # file://, RTC 8089, File:// https://datatracker.ietf.org/doc/html/rfc8089 - Matcher( + Rule( label="pip-file-url", description="pip-style hg+file:// URL", pattern=re.compile( @@ -129,8 +129,8 @@ class HgURL(URLProtocol, SkipDefaultFieldsReprMixin): Attributes ---------- - matcher : str - name of the :class:`~libvcs.url.base.Matcher` + rule : str + name of the :class:`~libvcs.url.base.Rule` Examples -------- @@ -139,7 +139,7 @@ class HgURL(URLProtocol, SkipDefaultFieldsReprMixin): scheme=https, hostname=hg.mozilla.org, path=mozilla-central/, - matcher=core-hg) + rule=core-hg) >>> myrepo = HgURL(url='https://hg.mozilla.org/mozilla-central/') @@ -155,7 +155,7 @@ class HgURL(URLProtocol, SkipDefaultFieldsReprMixin): user=username, hostname=machinename, path=path/to/repo, - matcher=core-hg) + rule=core-hg) - Compatibility checking: :meth:`HgURL.is_valid()` - URLs compatible with ``hg(1)``: :meth:`HgURL.to_url()` @@ -174,26 +174,24 @@ class HgURL(URLProtocol, SkipDefaultFieldsReprMixin): # ref: Optional[str] = None - matcher: Optional[str] = None - # name of the :class:`Matcher` - matchers: MatcherRegistry = MatcherRegistry( - _matchers={m.label: m for m in DEFAULT_MATCHERS} - ) + rule: Optional[str] = None + # name of the :class:`Rule` + rules: Rules = Rules(_rules={m.label: m for m in DEFAULT_MATCHERS}) def __post_init__(self) -> None: url = self.url - for matcher in self.matchers.values(): - match = re.match(matcher.pattern, url) + for rule in self.rules.values(): + match = re.match(rule.pattern, url) if match is None: continue groups = match.groupdict() - setattr(self, "matcher", matcher.label) + setattr(self, "rule", rule.label) for k, v in groups.items(): setattr(self, k, v) - for k, v in matcher.pattern_defaults.items(): + for k, v in rule.pattern_defaults.items(): if getattr(self, k, None) is None: - setattr(self, k, matcher.pattern_defaults[k]) + setattr(self, k, rule.pattern_defaults[k]) @classmethod def is_valid(cls, url: str, is_explicit: Optional[bool] = False) -> bool: @@ -213,7 +211,7 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = False) -> bool: >>> HgURL.is_valid(url='notaurl') False """ - return any(re.search(matcher.pattern, url) for matcher in cls.matchers.values()) + return any(re.search(rule.pattern, url) for rule in cls.rules.values()) def to_url(self) -> str: """Return a ``hg(1)``-compatible URL. Can be used with ``hg clone``. @@ -228,7 +226,7 @@ def to_url(self) -> str: scheme=https, hostname=hg.mozilla.org, path=mozilla-central, - matcher=core-hg) + rule=core-hg) Switch repo libvcs -> vcspull: @@ -255,7 +253,7 @@ def to_url(self) -> str: hostname=hugin.hg.sourceforge.net, port=8000, path=hgroot/hugin/hugin, - matcher=core-hg) + rule=core-hg) >>> hugin.to_url() 'http://hugin.hg.sourceforge.net:8000/hgroot/hugin/hugin' @@ -272,7 +270,7 @@ def to_url(self) -> str: user=yourid, hostname=hg.GraphicsMagick.org, path=/hg/GraphicsMagick, - matcher=core-hg) + rule=core-hg) >>> graphicsmagick.to_url() 'ssh://yourid@hg.GraphicsMagick.org//hg/GraphicsMagick' diff --git a/src/libvcs/url/svn.py b/src/libvcs/url/svn.py index ddafb3a8d..f58210a7d 100644 --- a/src/libvcs/url/svn.py +++ b/src/libvcs/url/svn.py @@ -6,8 +6,8 @@ compare to :class:`urllib.parse.ParseResult` - Output ``svn(1)`` URL: :meth:`SvnURL.to_url()` -- Extendable via :class:`~libvcs.url.base.MatcherRegistry`, - :class:`~libvcs.url.base.Matcher` +- Extendable via :class:`~libvcs.url.base.Rules`, + :class:`~libvcs.url.base.Rule` .. Note:: @@ -24,7 +24,7 @@ from libvcs._internal.dataclasses import SkipDefaultFieldsReprMixin -from .base import Matcher, MatcherRegistry, URLProtocol +from .base import Rule, Rules, URLProtocol RE_PATH = r""" ((?P.*)@)? @@ -47,8 +47,8 @@ ) """ -DEFAULT_MATCHERS: list[Matcher] = [ - Matcher( +DEFAULT_MATCHERS: list[Rule] = [ + Rule( label="core-svn", description="Vanilla svn pattern", pattern=re.compile( @@ -78,8 +78,8 @@ ) """ -PIP_DEFAULT_MATCHERS: list[Matcher] = [ - Matcher( +PIP_DEFAULT_MATCHERS: list[Rule] = [ + Rule( label="pip-url", description="pip-style svn URL", pattern=re.compile( @@ -92,7 +92,7 @@ ), ), # file://, RTC 8089, File:// https://datatracker.ietf.org/doc/html/rfc8089 - Matcher( + Rule( label="pip-file-url", description="pip-style svn+file:// URL", pattern=re.compile( @@ -135,7 +135,7 @@ class SvnURL(URLProtocol, SkipDefaultFieldsReprMixin): scheme=svn+ssh, hostname=svn.debian.org, path=svn/aliothproj/path/in/project/repository, - matcher=core-svn) + rule=core-svn) >>> myrepo = SvnURL( ... url='svn+ssh://svn.debian.org/svn/aliothproj/path/in/project/repository' @@ -152,8 +152,8 @@ class SvnURL(URLProtocol, SkipDefaultFieldsReprMixin): Attributes ---------- - matcher : str - name of the :class:`~libvcs.url.base.Matcher` + rule : str + name of the :class:`~libvcs.url.base.Rule` """ url: str @@ -169,25 +169,23 @@ class SvnURL(URLProtocol, SkipDefaultFieldsReprMixin): # ref: Optional[str] = None - matcher: Optional[str] = None - matchers: MatcherRegistry = MatcherRegistry( - _matchers={m.label: m for m in DEFAULT_MATCHERS} - ) + rule: Optional[str] = None + rules: Rules = Rules(_rules={m.label: m for m in DEFAULT_MATCHERS}) def __post_init__(self) -> None: url = self.url - for matcher in self.matchers.values(): - match = re.match(matcher.pattern, url) + for rule in self.rules.values(): + match = re.match(rule.pattern, url) if match is None: continue groups = match.groupdict() - setattr(self, "matcher", matcher.label) + setattr(self, "rule", rule.label) for k, v in groups.items(): setattr(self, k, v) - for k, v in matcher.pattern_defaults.items(): + for k, v in rule.pattern_defaults.items(): if getattr(self, k, None) is None: - setattr(self, k, matcher.pattern_defaults[k]) + setattr(self, k, rule.pattern_defaults[k]) @classmethod def is_valid(cls, url: str, is_explicit: Optional[bool] = False) -> bool: @@ -204,7 +202,7 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = False) -> bool: >>> SvnURL.is_valid(url='notaurl') False """ - return any(re.search(matcher.pattern, url) for matcher in cls.matchers.values()) + return any(re.search(rule.pattern, url) for rule in cls.rules.values()) def to_url(self) -> str: """Return a ``svn(1)``-compatible URL. Can be used with ``svn checkout``. @@ -222,7 +220,7 @@ def to_url(self) -> str: user=my-username, hostname=my-server, path=vcs-python/libvcs, - matcher=core-svn) + rule=core-svn) Switch repo libvcs -> vcspull: diff --git a/tests/url/test_git.py b/tests/url/test_git.py index 76102863d..e7d007a56 100644 --- a/tests/url/test_git.py +++ b/tests/url/test_git.py @@ -3,7 +3,7 @@ import pytest from libvcs.sync.git import GitSync -from libvcs.url.base import MatcherRegistry +from libvcs.url.base import Rules from libvcs.url.git import DEFAULT_MATCHERS, PIP_DEFAULT_MATCHERS, GitBaseURL, GitURL @@ -142,8 +142,8 @@ def test_git_url_extension_pip( git_repo: GitSync, ) -> None: class GitURLWithPip(GitBaseURL): - matchers: MatcherRegistry = MatcherRegistry( - _matchers={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + rules: Rules = Rules( + _rules={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} ) git_url_kwargs["url"] = git_url_kwargs["url"].format(local_repo=git_repo.dir) @@ -255,8 +255,8 @@ def test_git_revs( git_url_kwargs: GitURLKwargs, ) -> None: class GitURLWithPip(GitURL): - matchers: MatcherRegistry = MatcherRegistry( - _matchers={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + rules: Rules = Rules( + _rules={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} ) git_url = GitURLWithPip(**git_url_kwargs) diff --git a/tests/url/test_hg.py b/tests/url/test_hg.py index 283a4e1a5..3d5e0576f 100644 --- a/tests/url/test_hg.py +++ b/tests/url/test_hg.py @@ -3,7 +3,7 @@ import pytest from libvcs.sync.hg import HgSync -from libvcs.url.base import MatcherRegistry +from libvcs.url.base import Rules from libvcs.url.hg import DEFAULT_MATCHERS, PIP_DEFAULT_MATCHERS, HgURL @@ -107,8 +107,8 @@ def test_hg_url_extension_pip( hg_repo: HgSync, ) -> None: class HgURLWithPip(HgURL): - matchers: MatcherRegistry = MatcherRegistry( - _matchers={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + rules: Rules = Rules( + _rules={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} ) hg_url_kwargs["url"] = hg_url_kwargs["url"].format(local_repo=hg_repo.dir) diff --git a/tests/url/test_svn.py b/tests/url/test_svn.py index 0a607c7a6..104c9c4ec 100644 --- a/tests/url/test_svn.py +++ b/tests/url/test_svn.py @@ -3,7 +3,7 @@ import pytest from libvcs.sync.svn import SvnSync -from libvcs.url.base import MatcherRegistry +from libvcs.url.base import Rules from libvcs.url.svn import DEFAULT_MATCHERS, PIP_DEFAULT_MATCHERS, SvnURL @@ -124,8 +124,8 @@ def test_svn_url_extension_pip( svn_repo: SvnSync, ) -> None: class SvnURLWithPip(SvnURL): - matchers: MatcherRegistry = MatcherRegistry( - _matchers={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + rules: Rules = Rules( + _rules={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} ) svn_url_kwargs["url"] = svn_url_kwargs["url"].format(local_repo=svn_repo.dir) From b9bd0f5dbbf2663e6797b02cdab1e42df9bcbfe4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 20 Sep 2022 19:44:44 -0500 Subject: [PATCH 02/42] refactor!(url): rules -> rule_map --- src/libvcs/url/base.py | 30 +++++++++++++++--------------- src/libvcs/url/git.py | 28 ++++++++++++++-------------- src/libvcs/url/hg.py | 10 +++++----- src/libvcs/url/svn.py | 10 +++++----- tests/url/test_git.py | 10 +++++----- tests/url/test_hg.py | 6 +++--- tests/url/test_svn.py | 6 +++--- 7 files changed, 50 insertions(+), 50 deletions(-) diff --git a/src/libvcs/url/base.py b/src/libvcs/url/base.py index ac784d3cd..5ac446008 100644 --- a/src/libvcs/url/base.py +++ b/src/libvcs/url/base.py @@ -38,10 +38,10 @@ class Rule(SkipDefaultFieldsReprMixin): @dataclasses.dataclass(repr=False) -class Rules(SkipDefaultFieldsReprMixin): +class RuleMap(SkipDefaultFieldsReprMixin): """Pattern matching and parsing capabilities for URL parsers, e.g. GitURL""" - _rules: dict[str, Rule] = dataclasses.field(default_factory=dict) + _rule_map: dict[str, Rule] = dataclasses.field(default_factory=dict) def register(self, cls: Rule) -> None: r""" @@ -97,8 +97,8 @@ def register(self, cls: Rule) -> None: >>> @dataclasses.dataclass(repr=False) ... class GitHubURL(GitURL): - ... rules: Rules = Rules( - ... _rules={'github_prefix': GitHubPrefix} + ... rule_map: RuleMap = RuleMap( + ... _rule_map={'github_prefix': GitHubPrefix} ... ) >>> GitHubURL.is_valid(url='github:vcs-python/libvcs') @@ -147,8 +147,8 @@ def register(self, cls: Rule) -> None: >>> @dataclasses.dataclass(repr=False) ... class GitLabURL(GitURL): - ... rules: Rules = Rules( - ... _rules={'gitlab_prefix': GitLabPrefix} + ... rule_map: RuleMap = RuleMap( + ... _rule_map={'gitlab_prefix': GitLabPrefix} ... ) >>> GitLabURL.is_valid(url='gitlab:vcs-python/libvcs') @@ -166,7 +166,7 @@ def register(self, cls: Rule) -> None: Register: - >>> GitURL.rules.register(GitLabPrefix) + >>> GitURL.rule_map.register(GitLabPrefix) >>> GitURL.is_valid(url='gitlab:vcs-python/libvcs') True @@ -183,8 +183,8 @@ def register(self, cls: Rule) -> None: >>> @dataclasses.dataclass(repr=False) ... class GitURLWithPip(GitBaseURL): - ... rules: Rules = Rules( - ... _rules={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + ... rule_map: RuleMap = RuleMap( + ... _rule_map={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} ... ) >>> GitURLWithPip.is_valid(url="git+ssh://git@github.com/tony/AlgoXY.git") @@ -199,17 +199,17 @@ def register(self, cls: Rule) -> None: suffix=.git, rule=pip-url) """ # NOQA: E501 - if cls.label not in self._rules: - self._rules[cls.label] = cls + if cls.label not in self._rule_map: + self._rule_map[cls.label] = cls def unregister(self, label: str) -> None: - if label in self._rules: - del self._rules[label] + if label in self._rule_map: + del self._rule_map[label] def __iter__(self) -> Iterator[str]: - return self._rules.__iter__() + return self._rule_map.__iter__() def values( self, # https://github.com/python/typing/discussions/1033 ) -> "dict_values[str, Rule]": - return self._rules.values() + return self._rule_map.values() diff --git a/src/libvcs/url/git.py b/src/libvcs/url/git.py index c29b7e2b2..2891d7495 100644 --- a/src/libvcs/url/git.py +++ b/src/libvcs/url/git.py @@ -12,7 +12,7 @@ - Strict ``git(1)`` compatibility: :class:`GitBaseURL`. - Output ``git(1)`` URL: :meth:`GitBaseURL.to_url()` -- Extendable via :class:`~libvcs.url.base.Rules`, +- Extendable via :class:`~libvcs.url.base.RuleMap`, :class:`~libvcs.url.base.Rule` """ @@ -22,7 +22,7 @@ from libvcs._internal.dataclasses import SkipDefaultFieldsReprMixin -from .base import Rule, Rules, URLProtocol +from .base import Rule, RuleMap, URLProtocol # Credit, pip (license: MIT): # https://github.com/pypa/pip/blob/22.1.2/src/pip/_internal/vcs/git.py#L39-L52 @@ -262,11 +262,11 @@ class GitBaseURL(URLProtocol, SkipDefaultFieldsReprMixin): suffix: Optional[str] = None rule: Optional[str] = None - rules: Rules = Rules(_rules={m.label: m for m in DEFAULT_MATCHERS}) + rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in DEFAULT_MATCHERS}) def __post_init__(self) -> None: url = self.url - for rule in self.rules.values(): + for rule in self.rule_map.values(): match = re.match(rule.pattern, url) if match is None: continue @@ -311,10 +311,10 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: if is_explicit is not None: return any( re.search(rule.pattern, url) - for rule in cls.rules.values() + for rule in cls.rule_map.values() if rule.is_explicit == is_explicit ) - return any(re.search(rule.pattern, url) for rule in cls.rules.values()) + return any(re.search(rule.pattern, url) for rule in cls.rule_map.values()) def to_url(self) -> str: """Return a ``git(1)``-compatible URL. Can be used with ``git clone``. @@ -370,7 +370,7 @@ class GitPipURL(GitBaseURL, URLProtocol, SkipDefaultFieldsReprMixin): # commit-ish (rev): tag, branch, ref rev: Optional[str] = None - rules: Rules = Rules(_rules={m.label: m for m in PIP_DEFAULT_MATCHERS}) + rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in PIP_DEFAULT_MATCHERS}) def to_url(self) -> str: """Exports a pip-compliant URL. @@ -452,7 +452,7 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: **Explicit VCS detection** - Pip-style URLs are prefixed with the VCS name in front, so its rules can + Pip-style URLs are prefixed with the VCS name in front, so its rule_map can unambigously narrow the type of VCS: >>> GitPipURL.is_valid( @@ -478,13 +478,13 @@ class GitURL(GitPipURL, GitBaseURL, URLProtocol, SkipDefaultFieldsReprMixin): - :meth:`GitBaseURL.to_url` """ - rules: Rules = Rules( - _rules={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + rule_map: RuleMap = RuleMap( + _rule_map={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} ) @classmethod def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: - r"""Whether URL is compatible included Git URL rules or not. + r"""Whether URL is compatible included Git URL rule_map or not. Examples -------- @@ -510,7 +510,7 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: **Explicit VCS detection** - Pip-style URLs are prefixed with the VCS name in front, so its rules can + Pip-style URLs are prefixed with the VCS name in front, so its rule_map can unambigously narrow the type of VCS: >>> GitURL.is_valid( @@ -549,7 +549,7 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: ... } ... ) - >>> GitURL.rules.register(GitHubRule) + >>> GitURL.rule_map.register(GitHubRule) >>> GitURL.is_valid( ... url='git@github.com:vcs-python/libvcs.git', is_explicit=True @@ -561,7 +561,7 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: This is just us cleaning up: - >>> GitURL.rules.unregister('gh-rule') + >>> GitURL.rule_map.unregister('gh-rule') >>> GitURL(url='git@github.com:vcs-python/libvcs.git').rule 'core-git-scp' diff --git a/src/libvcs/url/hg.py b/src/libvcs/url/hg.py index 560724f1f..373eb44be 100644 --- a/src/libvcs/url/hg.py +++ b/src/libvcs/url/hg.py @@ -6,7 +6,7 @@ compare to :class:`urllib.parse.ParseResult` - Output ``hg(1)`` URL: :meth:`HgURL.to_url()` -- Extendable via :class:`~libvcs.url.base.Rules`, +- Extendable via :class:`~libvcs.url.base.RuleMap`, :class:`~libvcs.url.base.Rule` .. Note:: @@ -23,7 +23,7 @@ from libvcs._internal.dataclasses import SkipDefaultFieldsReprMixin -from .base import Rule, Rules, URLProtocol +from .base import Rule, RuleMap, URLProtocol RE_PATH = r""" ((?P\w+)@)? @@ -176,11 +176,11 @@ class HgURL(URLProtocol, SkipDefaultFieldsReprMixin): rule: Optional[str] = None # name of the :class:`Rule` - rules: Rules = Rules(_rules={m.label: m for m in DEFAULT_MATCHERS}) + rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in DEFAULT_MATCHERS}) def __post_init__(self) -> None: url = self.url - for rule in self.rules.values(): + for rule in self.rule_map.values(): match = re.match(rule.pattern, url) if match is None: continue @@ -211,7 +211,7 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = False) -> bool: >>> HgURL.is_valid(url='notaurl') False """ - return any(re.search(rule.pattern, url) for rule in cls.rules.values()) + return any(re.search(rule.pattern, url) for rule in cls.rule_map.values()) def to_url(self) -> str: """Return a ``hg(1)``-compatible URL. Can be used with ``hg clone``. diff --git a/src/libvcs/url/svn.py b/src/libvcs/url/svn.py index f58210a7d..63cc1ba4a 100644 --- a/src/libvcs/url/svn.py +++ b/src/libvcs/url/svn.py @@ -6,7 +6,7 @@ compare to :class:`urllib.parse.ParseResult` - Output ``svn(1)`` URL: :meth:`SvnURL.to_url()` -- Extendable via :class:`~libvcs.url.base.Rules`, +- Extendable via :class:`~libvcs.url.base.RuleMap`, :class:`~libvcs.url.base.Rule` .. Note:: @@ -24,7 +24,7 @@ from libvcs._internal.dataclasses import SkipDefaultFieldsReprMixin -from .base import Rule, Rules, URLProtocol +from .base import Rule, RuleMap, URLProtocol RE_PATH = r""" ((?P.*)@)? @@ -170,11 +170,11 @@ class SvnURL(URLProtocol, SkipDefaultFieldsReprMixin): ref: Optional[str] = None rule: Optional[str] = None - rules: Rules = Rules(_rules={m.label: m for m in DEFAULT_MATCHERS}) + rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in DEFAULT_MATCHERS}) def __post_init__(self) -> None: url = self.url - for rule in self.rules.values(): + for rule in self.rule_map.values(): match = re.match(rule.pattern, url) if match is None: continue @@ -202,7 +202,7 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = False) -> bool: >>> SvnURL.is_valid(url='notaurl') False """ - return any(re.search(rule.pattern, url) for rule in cls.rules.values()) + return any(re.search(rule.pattern, url) for rule in cls.rule_map.values()) def to_url(self) -> str: """Return a ``svn(1)``-compatible URL. Can be used with ``svn checkout``. diff --git a/tests/url/test_git.py b/tests/url/test_git.py index e7d007a56..9d62afbac 100644 --- a/tests/url/test_git.py +++ b/tests/url/test_git.py @@ -3,7 +3,7 @@ import pytest from libvcs.sync.git import GitSync -from libvcs.url.base import Rules +from libvcs.url.base import RuleMap from libvcs.url.git import DEFAULT_MATCHERS, PIP_DEFAULT_MATCHERS, GitBaseURL, GitURL @@ -142,8 +142,8 @@ def test_git_url_extension_pip( git_repo: GitSync, ) -> None: class GitURLWithPip(GitBaseURL): - rules: Rules = Rules( - _rules={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + rule_map: RuleMap = RuleMap( + _rule_map={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} ) git_url_kwargs["url"] = git_url_kwargs["url"].format(local_repo=git_repo.dir) @@ -255,8 +255,8 @@ def test_git_revs( git_url_kwargs: GitURLKwargs, ) -> None: class GitURLWithPip(GitURL): - rules: Rules = Rules( - _rules={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + rule_map: RuleMap = RuleMap( + _rule_map={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} ) git_url = GitURLWithPip(**git_url_kwargs) diff --git a/tests/url/test_hg.py b/tests/url/test_hg.py index 3d5e0576f..a8eb75434 100644 --- a/tests/url/test_hg.py +++ b/tests/url/test_hg.py @@ -3,7 +3,7 @@ import pytest from libvcs.sync.hg import HgSync -from libvcs.url.base import Rules +from libvcs.url.base import RuleMap from libvcs.url.hg import DEFAULT_MATCHERS, PIP_DEFAULT_MATCHERS, HgURL @@ -107,8 +107,8 @@ def test_hg_url_extension_pip( hg_repo: HgSync, ) -> None: class HgURLWithPip(HgURL): - rules: Rules = Rules( - _rules={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + rule_map: RuleMap = RuleMap( + _rule_map={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} ) hg_url_kwargs["url"] = hg_url_kwargs["url"].format(local_repo=hg_repo.dir) diff --git a/tests/url/test_svn.py b/tests/url/test_svn.py index 104c9c4ec..b757f961a 100644 --- a/tests/url/test_svn.py +++ b/tests/url/test_svn.py @@ -3,7 +3,7 @@ import pytest from libvcs.sync.svn import SvnSync -from libvcs.url.base import Rules +from libvcs.url.base import RuleMap from libvcs.url.svn import DEFAULT_MATCHERS, PIP_DEFAULT_MATCHERS, SvnURL @@ -124,8 +124,8 @@ def test_svn_url_extension_pip( svn_repo: SvnSync, ) -> None: class SvnURLWithPip(SvnURL): - rules: Rules = Rules( - _rules={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + rule_map: RuleMap = RuleMap( + _rule_map={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} ) svn_url_kwargs["url"] = svn_url_kwargs["url"].format(local_repo=svn_repo.dir) From 543e609a57956d2b2aedddef05d30f4c514663e1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 20 Sep 2022 19:55:46 -0500 Subject: [PATCH 03/42] docs(url): Update docstring --- src/libvcs/url/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libvcs/url/base.py b/src/libvcs/url/base.py index 5ac446008..202680fdf 100644 --- a/src/libvcs/url/base.py +++ b/src/libvcs/url/base.py @@ -24,7 +24,7 @@ def is_valid(self, url: str, is_explicit: Optional[bool] = None) -> bool: @dataclasses.dataclass(repr=False) class Rule(SkipDefaultFieldsReprMixin): - """Structure for a rule""" + """A Rule represents an eligible pattern mapping to URL.""" label: str """Computer readable name / ID""" From 8287b4b3ae85b534f5f66c1c025cf644e9364633 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 20 Sep 2022 19:56:34 -0500 Subject: [PATCH 04/42] refactor!(url): pattern_defaults -> defaults --- src/libvcs/url/base.py | 8 ++++---- src/libvcs/url/git.py | 8 ++++---- src/libvcs/url/hg.py | 4 ++-- src/libvcs/url/svn.py | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/libvcs/url/base.py b/src/libvcs/url/base.py index 202680fdf..af413f6f2 100644 --- a/src/libvcs/url/base.py +++ b/src/libvcs/url/base.py @@ -32,7 +32,7 @@ class Rule(SkipDefaultFieldsReprMixin): """Human readable description""" pattern: Pattern[str] """Regex pattern""" - pattern_defaults: dict[str, str] = dataclasses.field(default_factory=dict) + defaults: dict[str, str] = dataclasses.field(default_factory=dict) """Is the match unambiguous with other VCS systems? e.g. git+ prefix""" is_explicit: bool = False @@ -88,7 +88,7 @@ def register(self, cls: Rule) -> None: ... label = 'gh-prefix' ... description ='Matches prefixes like github:org/repo' ... pattern = r'^github:(?P.*)$' - ... pattern_defaults = { + ... defaults = { ... 'hostname': 'github.com', ... 'scheme': 'https' ... } @@ -107,7 +107,7 @@ def register(self, cls: Rule) -> None: >>> GitHubURL.is_valid(url='github:vcs-python/libvcs', is_explicit=True) True - Notice how ``pattern_defaults`` neatly fills the values for us. + Notice how ``defaults`` neatly fills the values for us. >>> GitHubURL(url='github:vcs-python/libvcs') GitHubURL(url=github:vcs-python/libvcs, @@ -137,7 +137,7 @@ def register(self, cls: Rule) -> None: ... label = 'gl-prefix' ... description ='Matches prefixes like gitlab:org/repo' ... pattern = r'^gitlab:(?P)' - ... pattern_defaults = { + ... defaults = { ... 'hostname': 'gitlab.com', ... 'scheme': 'https', ... 'suffix': '.git' diff --git a/src/libvcs/url/git.py b/src/libvcs/url/git.py index 2891d7495..2f0d61385 100644 --- a/src/libvcs/url/git.py +++ b/src/libvcs/url/git.py @@ -87,7 +87,7 @@ """, re.VERBOSE, ), - pattern_defaults={"username": "git"}, + defaults={"username": "git"}, ), # SCP-style URLs, e.g. git@ ] @@ -275,9 +275,9 @@ def __post_init__(self) -> None: for k, v in groups.items(): setattr(self, k, v) - for k, v in rule.pattern_defaults.items(): + for k, v in rule.defaults.items(): if getattr(self, k, None) is None: - setattr(self, k, rule.pattern_defaults[k]) + setattr(self, k, rule.defaults[k]) @classmethod def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: @@ -544,7 +544,7 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: ... re.VERBOSE, ... ), ... is_explicit=True, - ... pattern_defaults={ + ... defaults={ ... 'hostname': 'github.com' ... } ... ) diff --git a/src/libvcs/url/hg.py b/src/libvcs/url/hg.py index 373eb44be..00af58293 100644 --- a/src/libvcs/url/hg.py +++ b/src/libvcs/url/hg.py @@ -189,9 +189,9 @@ def __post_init__(self) -> None: for k, v in groups.items(): setattr(self, k, v) - for k, v in rule.pattern_defaults.items(): + for k, v in rule.defaults.items(): if getattr(self, k, None) is None: - setattr(self, k, rule.pattern_defaults[k]) + setattr(self, k, rule.defaults[k]) @classmethod def is_valid(cls, url: str, is_explicit: Optional[bool] = False) -> bool: diff --git a/src/libvcs/url/svn.py b/src/libvcs/url/svn.py index 63cc1ba4a..fbda96546 100644 --- a/src/libvcs/url/svn.py +++ b/src/libvcs/url/svn.py @@ -183,9 +183,9 @@ def __post_init__(self) -> None: for k, v in groups.items(): setattr(self, k, v) - for k, v in rule.pattern_defaults.items(): + for k, v in rule.defaults.items(): if getattr(self, k, None) is None: - setattr(self, k, rule.pattern_defaults[k]) + setattr(self, k, rule.defaults[k]) @classmethod def is_valid(cls, url: str, is_explicit: Optional[bool] = False) -> bool: From 7315799708a1a13617f2af3116d7fd36126f94a6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 21 Sep 2022 06:07:08 -0500 Subject: [PATCH 05/42] refactor!(url): MATCHERS -> RULES --- src/libvcs/url/base.py | 6 +++--- src/libvcs/url/git.py | 12 ++++++------ src/libvcs/url/hg.py | 6 +++--- src/libvcs/url/svn.py | 6 +++--- tests/url/test_git.py | 6 +++--- tests/url/test_hg.py | 4 ++-- tests/url/test_svn.py | 4 ++-- 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/libvcs/url/base.py b/src/libvcs/url/base.py index af413f6f2..de316daa0 100644 --- a/src/libvcs/url/base.py +++ b/src/libvcs/url/base.py @@ -173,18 +173,18 @@ def register(self, cls: Rule) -> None: **Example: git URLs + pip-style git URLs:** - This is already in :class:`GitURL` via :data:`PIP_DEFAULT_MATCHERS`. For the + This is already in :class:`GitURL` via :data:`PIP_DEFAULT_RULES`. For the sake of showing how extensibility works, here is a recreation based on :class:`GitBaseURL`: >>> from libvcs.url.git import GitBaseURL - >>> from libvcs.url.git import DEFAULT_MATCHERS, PIP_DEFAULT_MATCHERS + >>> from libvcs.url.git import DEFAULT_RULES, PIP_DEFAULT_RULES >>> @dataclasses.dataclass(repr=False) ... class GitURLWithPip(GitBaseURL): ... rule_map: RuleMap = RuleMap( - ... _rule_map={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + ... _rule_map={m.label: m for m in [*DEFAULT_RULES, *PIP_DEFAULT_RULES]} ... ) >>> GitURLWithPip.is_valid(url="git+ssh://git@github.com/tony/AlgoXY.git") diff --git a/src/libvcs/url/git.py b/src/libvcs/url/git.py index 2f0d61385..470ea7b45 100644 --- a/src/libvcs/url/git.py +++ b/src/libvcs/url/git.py @@ -60,7 +60,7 @@ # Some https repos have .git at the end, e.g. https://github.com/org/repo.git -DEFAULT_MATCHERS: list[Rule] = [ +DEFAULT_RULES: list[Rule] = [ Rule( label="core-git-https", description="Vanilla git pattern, URL ending with optional .git suffix", @@ -125,7 +125,7 @@ """ -PIP_DEFAULT_MATCHERS: list[Rule] = [ +PIP_DEFAULT_RULES: list[Rule] = [ Rule( label="pip-url", description="pip-style git URL", @@ -191,7 +191,7 @@ - https://pip.pypa.io/en/stable/topics/vcs-support/ """ # NOQA: E501 -NPM_DEFAULT_MATCHERS: list[Rule] = [] +NPM_DEFAULT_RULES: list[Rule] = [] """NPM-style git URLs. Git URL pattern (from docs.npmjs.com):: @@ -262,7 +262,7 @@ class GitBaseURL(URLProtocol, SkipDefaultFieldsReprMixin): suffix: Optional[str] = None rule: Optional[str] = None - rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in DEFAULT_MATCHERS}) + rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in DEFAULT_RULES}) def __post_init__(self) -> None: url = self.url @@ -370,7 +370,7 @@ class GitPipURL(GitBaseURL, URLProtocol, SkipDefaultFieldsReprMixin): # commit-ish (rev): tag, branch, ref rev: Optional[str] = None - rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in PIP_DEFAULT_MATCHERS}) + rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in PIP_DEFAULT_RULES}) def to_url(self) -> str: """Exports a pip-compliant URL. @@ -479,7 +479,7 @@ class GitURL(GitPipURL, GitBaseURL, URLProtocol, SkipDefaultFieldsReprMixin): """ rule_map: RuleMap = RuleMap( - _rule_map={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + _rule_map={m.label: m for m in [*DEFAULT_RULES, *PIP_DEFAULT_RULES]} ) @classmethod diff --git a/src/libvcs/url/hg.py b/src/libvcs/url/hg.py index 00af58293..74917b2d9 100644 --- a/src/libvcs/url/hg.py +++ b/src/libvcs/url/hg.py @@ -43,7 +43,7 @@ ) """ -DEFAULT_MATCHERS: list[Rule] = [ +DEFAULT_RULES: list[Rule] = [ Rule( label="core-hg", description="Vanilla hg pattern", @@ -74,7 +74,7 @@ ) """ -PIP_DEFAULT_MATCHERS: list[Rule] = [ +PIP_DEFAULT_RULES: list[Rule] = [ Rule( label="pip-url", description="pip-style hg URL", @@ -176,7 +176,7 @@ class HgURL(URLProtocol, SkipDefaultFieldsReprMixin): rule: Optional[str] = None # name of the :class:`Rule` - rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in DEFAULT_MATCHERS}) + rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in DEFAULT_RULES}) def __post_init__(self) -> None: url = self.url diff --git a/src/libvcs/url/svn.py b/src/libvcs/url/svn.py index fbda96546..1e32a8772 100644 --- a/src/libvcs/url/svn.py +++ b/src/libvcs/url/svn.py @@ -47,7 +47,7 @@ ) """ -DEFAULT_MATCHERS: list[Rule] = [ +DEFAULT_RULES: list[Rule] = [ Rule( label="core-svn", description="Vanilla svn pattern", @@ -78,7 +78,7 @@ ) """ -PIP_DEFAULT_MATCHERS: list[Rule] = [ +PIP_DEFAULT_RULES: list[Rule] = [ Rule( label="pip-url", description="pip-style svn URL", @@ -170,7 +170,7 @@ class SvnURL(URLProtocol, SkipDefaultFieldsReprMixin): ref: Optional[str] = None rule: Optional[str] = None - rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in DEFAULT_MATCHERS}) + rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in DEFAULT_RULES}) def __post_init__(self) -> None: url = self.url diff --git a/tests/url/test_git.py b/tests/url/test_git.py index 9d62afbac..ebf1397a8 100644 --- a/tests/url/test_git.py +++ b/tests/url/test_git.py @@ -4,7 +4,7 @@ from libvcs.sync.git import GitSync from libvcs.url.base import RuleMap -from libvcs.url.git import DEFAULT_MATCHERS, PIP_DEFAULT_MATCHERS, GitBaseURL, GitURL +from libvcs.url.git import DEFAULT_RULES, PIP_DEFAULT_RULES, GitBaseURL, GitURL class GitURLFixture(typing.NamedTuple): @@ -143,7 +143,7 @@ def test_git_url_extension_pip( ) -> None: class GitURLWithPip(GitBaseURL): rule_map: RuleMap = RuleMap( - _rule_map={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + _rule_map={m.label: m for m in [*DEFAULT_RULES, *PIP_DEFAULT_RULES]} ) git_url_kwargs["url"] = git_url_kwargs["url"].format(local_repo=git_repo.dir) @@ -256,7 +256,7 @@ def test_git_revs( ) -> None: class GitURLWithPip(GitURL): rule_map: RuleMap = RuleMap( - _rule_map={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + _rule_map={m.label: m for m in [*DEFAULT_RULES, *PIP_DEFAULT_RULES]} ) git_url = GitURLWithPip(**git_url_kwargs) diff --git a/tests/url/test_hg.py b/tests/url/test_hg.py index a8eb75434..6de7c64b9 100644 --- a/tests/url/test_hg.py +++ b/tests/url/test_hg.py @@ -4,7 +4,7 @@ from libvcs.sync.hg import HgSync from libvcs.url.base import RuleMap -from libvcs.url.hg import DEFAULT_MATCHERS, PIP_DEFAULT_MATCHERS, HgURL +from libvcs.url.hg import DEFAULT_RULES, PIP_DEFAULT_RULES, HgURL class HgURLFixture(typing.NamedTuple): @@ -108,7 +108,7 @@ def test_hg_url_extension_pip( ) -> None: class HgURLWithPip(HgURL): rule_map: RuleMap = RuleMap( - _rule_map={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + _rule_map={m.label: m for m in [*DEFAULT_RULES, *PIP_DEFAULT_RULES]} ) hg_url_kwargs["url"] = hg_url_kwargs["url"].format(local_repo=hg_repo.dir) diff --git a/tests/url/test_svn.py b/tests/url/test_svn.py index b757f961a..d86a33125 100644 --- a/tests/url/test_svn.py +++ b/tests/url/test_svn.py @@ -4,7 +4,7 @@ from libvcs.sync.svn import SvnSync from libvcs.url.base import RuleMap -from libvcs.url.svn import DEFAULT_MATCHERS, PIP_DEFAULT_MATCHERS, SvnURL +from libvcs.url.svn import DEFAULT_RULES, PIP_DEFAULT_RULES, SvnURL class SvnURLFixture(typing.NamedTuple): @@ -125,7 +125,7 @@ def test_svn_url_extension_pip( ) -> None: class SvnURLWithPip(SvnURL): rule_map: RuleMap = RuleMap( - _rule_map={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + _rule_map={m.label: m for m in [*DEFAULT_RULES, *PIP_DEFAULT_RULES]} ) svn_url_kwargs["url"] = svn_url_kwargs["url"].format(local_repo=svn_repo.dir) From a0385711456b15433be3470ec2e314fedffa1594 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 21 Sep 2022 06:15:01 -0500 Subject: [PATCH 06/42] docs(CHANGES): Note URL renames --- CHANGES | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGES b/CHANGES index 0a992cf85..5f3890bd9 100644 --- a/CHANGES +++ b/CHANGES @@ -13,6 +13,15 @@ $ pip install --user --upgrade --pre libvcs - _Add your latest changes from PRs here_ +### Breaking changes + +URL renamings (#417): + +- `Matcher` -> `Rule`, `MatcherRegistry` -> `Rules` +- `matches` -> `rule_map` +- `default_patterns` -> `patterns` +- `MATCHERS` -> `RULES` + ## libvcs 0.16.5 (2022-09-21) ### Bug fixes From 032008e2186d419ec54e0b8b8115fc214724adbc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 23 Sep 2022 17:14:32 -0500 Subject: [PATCH 07/42] build(deps): Update flake8-bugbear --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index fe4ed5ad5..e708053bd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -155,7 +155,7 @@ pyflakes = ">=2.5.0,<2.6.0" [[package]] name = "flake8-bugbear" -version = "22.9.11" +version = "22.9.23" description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." category = "dev" optional = false @@ -1030,8 +1030,8 @@ flake8 = [ {file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"}, ] flake8-bugbear = [ - {file = "flake8-bugbear-22.9.11.tar.gz", hash = "sha256:39236c0e97160d1ab05d9f87422173d16e925a6220b3635bfc4aee766bf8194a"}, - {file = "flake8_bugbear-22.9.11-py3-none-any.whl", hash = "sha256:e74350a4cfc670e184f3433c223b1e7378f1cf8345ded6c8f12ac1a50c5df22b"}, + {file = "flake8-bugbear-22.9.23.tar.gz", hash = "sha256:17b9623325e6e0dcdcc80ed9e4aa811287fcc81d7e03313b8736ea5733759937"}, + {file = "flake8_bugbear-22.9.23-py3-none-any.whl", hash = "sha256:cd2779b2b7ada212d7a322814a1e5651f1868ab0d3f24cc9da66169ab8fda474"}, ] flake8-comprehensions = [ {file = "flake8-comprehensions-3.10.0.tar.gz", hash = "sha256:181158f7e7aa26a63a0a38e6017cef28c6adee71278ce56ce11f6ec9c4905058"}, From f2f1d96296ec3e372b9d7557f99d8dbf59673d21 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 23 Sep 2022 21:18:38 -0500 Subject: [PATCH 08/42] build(coverage): Migrate .coveragerc to pyroject.toml --- .coveragerc | 21 --------------------- pyproject.toml | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+), 21 deletions(-) delete mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 3a22d6c00..000000000 --- a/.coveragerc +++ /dev/null @@ -1,21 +0,0 @@ -[run] -parallel = 1 -branch = 1 - -omit = - docs/conf.py - */_compat.py - */conftest.py - -[report] -skip_covered = True -show_missing = True -exclude_lines = - \#\s*pragma: no cover - ^\s*raise NotImplementedError\b - ^\s*return NotImplemented\b - ^\s*assert False(,|$) - ^\s*assert_never\( - - ^\s*if TYPE_CHECKING: - ^\s*@overload( |$) diff --git a/pyproject.toml b/pyproject.toml index e9b611082..dd65b05c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,6 +133,28 @@ libvcs = "libvcs.pytest_plugin" [tool.mypy] strict = true +[tool.coverage.run] +branch = true +parallel = true +omit = [ + "*/_compat.py", + "docs/conf.py", +] + +[tool.coverage.report] +show_missing = true +skip_covered = true +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "return NotImplemented", + "def parse_args", + "if TYPE_CHECKING:", + "if t.TYPE_CHECKING:", + "@overload( |$)", +] + [build-system] requires = ["poetry_core>=1.0.0"] build-backend = "poetry.core.masonry.api" From 5365ebca631d1c5a441c17d4977a5c76775ed65b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 23 Sep 2022 21:30:20 -0500 Subject: [PATCH 09/42] docs(CHANGES): Note .coveragerc being moved to pyproject.toml --- CHANGES | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES b/CHANGES index 5f3890bd9..48a045eec 100644 --- a/CHANGES +++ b/CHANGES @@ -22,6 +22,10 @@ URL renamings (#417): - `default_patterns` -> `patterns` - `MATCHERS` -> `RULES` +### Packaging + +- Migrate `.coveragerc` to `pyproject.toml` (#421) + ## libvcs 0.16.5 (2022-09-21) ### Bug fixes From bfbebf39b64120e34b2753711002c3b48e2413e4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 23 Sep 2022 21:31:43 -0500 Subject: [PATCH 10/42] ci(tmuxp): Remove .tmuxp-before-script.sh --- .tmuxp-before-script.sh | 3 --- .tmuxp.yaml | 1 - 2 files changed, 4 deletions(-) delete mode 100755 .tmuxp-before-script.sh diff --git a/.tmuxp-before-script.sh b/.tmuxp-before-script.sh deleted file mode 100755 index 0721faabd..000000000 --- a/.tmuxp-before-script.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -poetry shell --no-ansi --no-interaction &2> /dev/null -poetry install --no-ansi --no-interaction &2> /dev/null diff --git a/.tmuxp.yaml b/.tmuxp.yaml index 9318f018d..d524776d6 100644 --- a/.tmuxp.yaml +++ b/.tmuxp.yaml @@ -1,6 +1,5 @@ session_name: libvcs start_directory: ./ # load session relative to config location (project root). -before_script: ./.tmuxp-before-script.sh shell_command_before: - '[ -f .venv/bin/activate ] && source .venv/bin/activate && reset' windows: From 3778bf219391b07da8a5d47441a246cf850cb53d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 23 Sep 2022 21:44:23 -0500 Subject: [PATCH 11/42] docs(CHANGES): Note removal of .tmuxp-before-script.sh --- CHANGES | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES b/CHANGES index 48a045eec..19277d2a1 100644 --- a/CHANGES +++ b/CHANGES @@ -25,6 +25,7 @@ URL renamings (#417): ### Packaging - Migrate `.coveragerc` to `pyproject.toml` (#421) +- Remove `.tmuxp.before-script.sh` (was a `before_script` in `.tmuxp.yaml`) that was unused. ## libvcs 0.16.5 (2022-09-21) From 1f09111892a984e8059ed85fd4cf4f15f8e6df4f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 08:47:27 -0500 Subject: [PATCH 12/42] build(conftest): Move to root level Required by pytest_plugins to be at the top level, makes py.test README.md possible to run, as well as keeping conftest.py excluded from wheel package. --- conftest.py | 41 ++++++++++++++++++++++++++ pyproject.toml | 1 + src/libvcs/conftest.py | 25 ---------------- tests/_internal/subprocess/conftest.py | 8 ----- tests/conftest.py | 3 -- 5 files changed, 42 insertions(+), 36 deletions(-) create mode 100644 conftest.py delete mode 100644 src/libvcs/conftest.py delete mode 100644 tests/_internal/subprocess/conftest.py delete mode 100644 tests/conftest.py diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..98bc77c0f --- /dev/null +++ b/conftest.py @@ -0,0 +1,41 @@ +"""Conftest.py (root-level) + +We keep this in root pytest fixtures in pytest's doctest plugin to be available, as well +as avoiding conftest.py from being included in the wheel, in addition to pytest_plugin +for pytester only being available via the root directory. + +See "pytest_plugins in non-top-level conftest files" in +https://docs.pytest.org/en/stable/deprecations.html +""" +import pathlib +import typing as t + +import pytest + +pytest_plugins = ["pytester"] + + +@pytest.fixture(autouse=True) +def add_doctest_fixtures( + request: pytest.FixtureRequest, + doctest_namespace: t.Dict[str, t.Any], +) -> None: + from _pytest.doctest import DoctestItem + + if isinstance(request._pyfuncitem, DoctestItem): + request.getfixturevalue("add_doctest_fixtures") + request.getfixturevalue("set_home") + + +@pytest.fixture(autouse=True) +def setup( + request: pytest.FixtureRequest, + gitconfig: pathlib.Path, + set_home: pathlib.Path, +) -> None: + pass + + +@pytest.fixture(autouse=True) +def cwd_default(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None: + monkeypatch.chdir(tmp_path) diff --git a/pyproject.toml b/pyproject.toml index dd65b05c6..630558941 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ packages = [ ] include = [ { path = "tests", format = "sdist" }, + { path = "conftest.py", format = "sdist" }, ] [tool.poetry.urls] diff --git a/src/libvcs/conftest.py b/src/libvcs/conftest.py deleted file mode 100644 index 4dd3149ec..000000000 --- a/src/libvcs/conftest.py +++ /dev/null @@ -1,25 +0,0 @@ -import pathlib -import typing as t - -import pytest - - -@pytest.fixture(autouse=True) -def add_doctest_fixtures( - request: pytest.FixtureRequest, - doctest_namespace: t.Dict[str, t.Any], -) -> None: - from _pytest.doctest import DoctestItem - - if isinstance(request._pyfuncitem, DoctestItem): - request.getfixturevalue("add_doctest_fixtures") - request.getfixturevalue("set_home") - - -@pytest.fixture(autouse=True) -def setup( - request: pytest.FixtureRequest, - gitconfig: pathlib.Path, - set_home: pathlib.Path, -) -> None: - pass diff --git a/tests/_internal/subprocess/conftest.py b/tests/_internal/subprocess/conftest.py deleted file mode 100644 index d81fcdf23..000000000 --- a/tests/_internal/subprocess/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import pathlib - -import pytest - - -@pytest.fixture(autouse=True) -def cwd_default(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None: - monkeypatch.chdir(tmp_path) diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 66bc15aca..000000000 --- a/tests/conftest.py +++ /dev/null @@ -1,3 +0,0 @@ -from libvcs.conftest import * # NOQA: F4 - -pytest_plugins = ["pytester"] From f17a469354babe44e6a115a218ee0e1c0c197db9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 08:50:48 -0500 Subject: [PATCH 13/42] docs(CHANGES): Note moving of conftest.py to root level --- CHANGES | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGES b/CHANGES index 19277d2a1..714e7dc7a 100644 --- a/CHANGES +++ b/CHANGES @@ -26,6 +26,13 @@ URL renamings (#417): - Migrate `.coveragerc` to `pyproject.toml` (#421) - Remove `.tmuxp.before-script.sh` (was a `before_script` in `.tmuxp.yaml`) that was unused. +- Move `conftest.py` to root level + + - Can be excluded from wheel, included in sdist + - Required to satisfy pytest's `pytest_plugins` only being in top-level confte conftest.py files + since 4.0 (see + [notice](https://docs.pytest.org/en/stable/deprecations.html#pytest-plugins-in-non-top-level-conftest-files)) + - Makes it possible to run `pytest README.md` with doctest plugin ## libvcs 0.16.5 (2022-09-21) From c2f17c864c65f7ac1aeee1df9707813bc89cd136 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 12:18:25 -0500 Subject: [PATCH 14/42] build(deps): Update certifi --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index e708053bd..63bdd1aa0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -70,7 +70,7 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2022.9.14" +version = "2022.9.24" description = "Python package for providing Mozilla's CA Bundle." category = "dev" optional = false @@ -950,8 +950,8 @@ black = [ {file = "black-22.8.0.tar.gz", hash = "sha256:792f7eb540ba9a17e8656538701d3eb1afcb134e3b45b71f20b25c77a8db7e6e"}, ] certifi = [ - {file = "certifi-2022.9.14-py3-none-any.whl", hash = "sha256:e232343de1ab72c2aa521b625c80f699e356830fd0e2c620b465b304b17b0516"}, - {file = "certifi-2022.9.14.tar.gz", hash = "sha256:36973885b9542e6bd01dea287b2b4b3b21236307c56324fcc3f1160f2d655ed5"}, + {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"}, + {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"}, ] charset-normalizer = [ {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, From bab4958c685b2ddfd38e655e3692e1b52ca7b977 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 12:22:50 -0500 Subject: [PATCH 15/42] build(deps): Bump sphinx to 5.2, use toc + autodoc fix See also: - https://github.com/sphinx-doc/sphinx/issues/6316 - https://github.com/sphinx-doc/sphinx/pull/10807 --- docs/conf.py | 2 +- poetry.lock | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 00f6f56c7..5ba29385c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,7 +32,6 @@ "sphinxext.opengraph", "sphinxext.rediraffe", "myst_parser", - "sphinx_toctree_autodoc_fix", "linkify_issues", ] myst_enable_extensions = [ @@ -98,6 +97,7 @@ # sphinx.ext.autodoc autoclass_content = "both" autodoc_member_order = "bysource" +toc_object_entries_show_parents = "hide" # sphinx-autodoc-typehints autodoc_typehints = "description" # show type hints in doc body instead of signature diff --git a/poetry.lock b/poetry.lock index 63bdd1aa0..c4966cf9d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -629,7 +629,7 @@ python-versions = ">=3.6" [[package]] name = "Sphinx" -version = "5.1.1" +version = "5.2.0" description = "Python documentation generator" category = "dev" optional = false @@ -637,16 +637,16 @@ python-versions = ">=3.6" [package.dependencies] alabaster = ">=0.7,<0.8" -babel = ">=1.3" -colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} +babel = ">=2.9" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} docutils = ">=0.14,<0.20" -imagesize = "*" -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} -Jinja2 = ">=2.3" -packaging = "*" -Pygments = ">=2.0" +imagesize = ">=1.3" +importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""} +Jinja2 = ">=3.0" +packaging = ">=21.0" +Pygments = ">=2.12" requests = ">=2.5.0" -snowballstemmer = ">=1.1" +snowballstemmer = ">=2.0" sphinxcontrib-applehelp = "*" sphinxcontrib-devhelp = "*" sphinxcontrib-htmlhelp = ">=2.0.0" @@ -656,8 +656,8 @@ sphinxcontrib-serializinghtml = ">=1.1.5" [package.extras] docs = ["sphinxcontrib-websupport"] -lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-bugbear", "flake8-comprehensions", "isort", "mypy (>=0.971)", "sphinx-lint", "types-requests", "types-typed-ast"] -test = ["cython", "html5lib", "pytest (>=4.6)", "typed-ast"] +lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-bugbear", "flake8-comprehensions", "flake8-simplify", "isort", "mypy (>=0.971)", "sphinx-lint", "types-requests", "types-typed-ast"] +test = ["cython", "html5lib", "pytest (>=4.6)", "typed_ast"] [[package]] name = "sphinx-autobuild" @@ -1279,8 +1279,8 @@ soupsieve = [ {file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"}, ] Sphinx = [ - {file = "Sphinx-5.1.1-py3-none-any.whl", hash = "sha256:309a8da80cb6da9f4713438e5b55861877d5d7976b69d87e336733637ea12693"}, - {file = "Sphinx-5.1.1.tar.gz", hash = "sha256:ba3224a4e206e1fbdecf98a4fae4992ef9b24b85ebf7b584bb340156eaf08d89"}, + {file = "Sphinx-5.2.0.tar.gz", hash = "sha256:1790c2098937dcfa7871c9d102c24eccd4a8b883b67c5c1e26892fb52d102542"}, + {file = "sphinx-5.2.0-py3-none-any.whl", hash = "sha256:422812bdf2dacab55c47ee4dd4746bb82e739fe4c97ce16dd68bcc208e348e73"}, ] sphinx-autobuild = [ {file = "sphinx-autobuild-2021.3.14.tar.gz", hash = "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05"}, From e6da55ac0d5dd95a7a1e5eb6f0e974f04123c0b3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 11:46:24 -0500 Subject: [PATCH 16/42] fix(URLProtocol): Use classmethod for is_valid --- src/libvcs/url/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libvcs/url/base.py b/src/libvcs/url/base.py index de316daa0..fb37ccc17 100644 --- a/src/libvcs/url/base.py +++ b/src/libvcs/url/base.py @@ -18,7 +18,8 @@ def __init__(self, url: str): def to_url(self) -> str: ... - def is_valid(self, url: str, is_explicit: Optional[bool] = None) -> bool: + @classmethod + def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: ... From 5799245f727ed5736ec166de105d87da376d0590 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 16:30:29 -0500 Subject: [PATCH 17/42] refactor(url[git]): De-duplicate code in GitURL --- src/libvcs/url/git.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/libvcs/url/git.py b/src/libvcs/url/git.py index 470ea7b45..1889bc6c3 100644 --- a/src/libvcs/url/git.py +++ b/src/libvcs/url/git.py @@ -613,12 +613,4 @@ def to_url(self) -> str: :meth:`GitBaseURL.to_url`, :meth:`GitPipURL.to_url` """ - if self.scheme is not None: - parts = [self.scheme, "://", self.hostname, "/", self.path] - else: - parts = [self.user or "git", "@", self.hostname, ":", self.path] - - if self.suffix: - parts.append(self.suffix) - - return "".join(part for part in parts if isinstance(part, str)) + return super().to_url() From accc010cb220f25cbc3f4d27e5c7aa120f33d6dd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 18:59:15 -0500 Subject: [PATCH 18/42] chore(url[git]): Always fetch colon separator in scp-URLs --- src/libvcs/url/git.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libvcs/url/git.py b/src/libvcs/url/git.py index 1889bc6c3..df87386a9 100644 --- a/src/libvcs/url/git.py +++ b/src/libvcs/url/git.py @@ -31,7 +31,8 @@ # Optional user, e.g. 'git@' ((?P\w+)@)? # Server, e.g. 'github.com'. - (?P([^/:]+)): + (?P([^/:]+)) + (?P:) # The server-side path. e.g. 'user/project.git'. Must start with an # alphanumeric character so as not to be confusable with a Windows paths # like 'C:/foo/bar' or 'C:\foo\bar'. From 7908a8384b1636140be3b8fc288471b89e99bdc2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 16:26:46 -0500 Subject: [PATCH 19/42] refactor(url[hg]): Add HgBaseURL, HgPipURL, update tests --- src/libvcs/url/hg.py | 305 ++++++++++++++++++++++++++++++++++++++++--- tests/url/test_hg.py | 4 +- 2 files changed, 287 insertions(+), 22 deletions(-) diff --git a/src/libvcs/url/hg.py b/src/libvcs/url/hg.py index 74917b2d9..abec046af 100644 --- a/src/libvcs/url/hg.py +++ b/src/libvcs/url/hg.py @@ -22,19 +22,21 @@ from typing import Optional from libvcs._internal.dataclasses import SkipDefaultFieldsReprMixin +from libvcs.url.git import RE_PIP_REV, RE_SUFFIX, SCP_REGEX from .base import Rule, RuleMap, URLProtocol RE_PATH = r""" ((?P\w+)@)? - (?P([^/:@]+)) + (?P([^/:]+)) (:(?P\d{1,5}))? - (?P/)? + (?P[:,/])? (?P - /?(\w[^:.]*) + /?(\w[^:.@]*) )? """ + RE_SCHEME = r""" (?P ( @@ -52,10 +54,26 @@ ^{RE_SCHEME} :// {RE_PATH} + {RE_SUFFIX}? + {RE_PIP_REV}? + """, + re.VERBOSE, + ), + ), + Rule( + label="core-hg-scp", + description="Vanilla scp(1) / ssh(1) type URL", + pattern=re.compile( + rf""" + ^(?Pssh)? + {SCP_REGEX} + {RE_SUFFIX}? """, re.VERBOSE, ), + defaults={"username": "hg"}, ), + # SCP-style URLs, e.g. hg@ ] """Core regular expressions. These are patterns understood by ``hg(1)``""" @@ -80,12 +98,15 @@ description="pip-style hg URL", pattern=re.compile( rf""" - {RE_PIP_SCHEME} + ^{RE_PIP_SCHEME} :// {RE_PATH} + {RE_SUFFIX}? + {RE_PIP_REV}? """, re.VERBOSE, ), + is_explicit=True, ), # file://, RTC 8089, File:// https://datatracker.ietf.org/doc/html/rfc8089 Rule( @@ -98,6 +119,7 @@ """, re.VERBOSE, ), + is_explicit=True, ), ] """pip-style hg URLs. @@ -118,13 +140,12 @@ Notes ----- - - https://pip.pypa.io/en/stable/topics/vcs-support/ """ # NOQA: E501 @dataclasses.dataclass(repr=False) -class HgURL(URLProtocol, SkipDefaultFieldsReprMixin): +class HgBaseURL(URLProtocol, SkipDefaultFieldsReprMixin): """Mercurial repository location. Parses URLs on initialization. Attributes @@ -134,8 +155,8 @@ class HgURL(URLProtocol, SkipDefaultFieldsReprMixin): Examples -------- - >>> HgURL(url='https://hg.mozilla.org/mozilla-central/') - HgURL(url=https://hg.mozilla.org/mozilla-central/, + >>> HgBaseURL(url='https://hg.mozilla.org/mozilla-central/') + HgBaseURL(url=https://hg.mozilla.org/mozilla-central/, scheme=https, hostname=hg.mozilla.org, path=mozilla-central/, @@ -149,8 +170,11 @@ class HgURL(URLProtocol, SkipDefaultFieldsReprMixin): >>> myrepo.path 'mozilla-central/' - >>> HgURL(url='ssh://username@machinename/path/to/repo') - HgURL(url=ssh://username@machinename/path/to/repo, + >>> HgBaseURL.is_valid(url='ssh://username@machinename/path/to/repo') + True + + >>> HgBaseURL(url='ssh://username@machinename/path/to/repo') + HgBaseURL(url=ssh://username@machinename/path/to/repo, scheme=ssh, user=username, hostname=machinename, @@ -169,6 +193,9 @@ class HgURL(URLProtocol, SkipDefaultFieldsReprMixin): separator: str = dataclasses.field(default="/") path: str = dataclasses.field(default="") + # Decoration + suffix: Optional[str] = None + # # commit-ish: tag, branch, ref, revision # @@ -176,6 +203,7 @@ class HgURL(URLProtocol, SkipDefaultFieldsReprMixin): rule: Optional[str] = None # name of the :class:`Rule` + rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in DEFAULT_RULES}) def __post_init__(self) -> None: @@ -194,21 +222,21 @@ def __post_init__(self) -> None: setattr(self, k, rule.defaults[k]) @classmethod - def is_valid(cls, url: str, is_explicit: Optional[bool] = False) -> bool: + def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: """Whether URL is compatible with VCS or not. Examples -------- - >>> HgURL.is_valid( + >>> HgBaseURL.is_valid( ... url='https://hg.mozilla.org/mozilla-central' ... ) True - >>> HgURL.is_valid(url='ssh://hg@hg.python.org/cpython') + >>> HgBaseURL.is_valid(url='ssh://hg@hg.python.org/cpython') True - >>> HgURL.is_valid(url='notaurl') + >>> HgBaseURL.is_valid(url='notaurl') False """ return any(re.search(rule.pattern, url) for rule in cls.rule_map.values()) @@ -219,10 +247,10 @@ def to_url(self) -> str: Examples -------- - >>> hg_url = HgURL(url='https://hg.mozilla.org/mozilla-central') + >>> hg_url = HgBaseURL(url='https://hg.mozilla.org/mozilla-central') >>> hg_url - HgURL(url=https://hg.mozilla.org/mozilla-central, + HgBaseURL(url=https://hg.mozilla.org/mozilla-central, scheme=https, hostname=hg.mozilla.org, path=mozilla-central, @@ -245,10 +273,11 @@ def to_url(self) -> str: Another example, `hugin `_: - >>> hugin = HgURL(url="http://hugin.hg.sourceforge.net:8000/hgroot/hugin/hugin") + >>> hugin = HgBaseURL( + ... url="http://hugin.hg.sourceforge.net:8000/hgroot/hugin/hugin") >>> hugin - HgURL(url=http://hugin.hg.sourceforge.net:8000/hgroot/hugin/hugin, + HgBaseURL(url=http://hugin.hg.sourceforge.net:8000/hgroot/hugin/hugin, scheme=http, hostname=hugin.hg.sourceforge.net, port=8000, @@ -260,12 +289,12 @@ def to_url(self) -> str: SSH URL with a username, `graphicsmagic `_: - >>> graphicsmagick = HgURL( + >>> graphicsmagick = HgBaseURL( ... url="ssh://yourid@hg.GraphicsMagick.org//hg/GraphicsMagick" ... ) >>> graphicsmagick - HgURL(url=ssh://yourid@hg.GraphicsMagick.org//hg/GraphicsMagick, + HgBaseURL(url=ssh://yourid@hg.GraphicsMagick.org//hg/GraphicsMagick, scheme=ssh, user=yourid, hostname=hg.GraphicsMagick.org, @@ -282,6 +311,83 @@ def to_url(self) -> str: >>> graphicsmagick.to_url() 'ssh://lucas@hg.GraphicsMagick.org//hg/GraphicsMagick' + """ + if self.scheme is not None: + parts = [self.scheme, "://"] + + if self.user is not None: + parts.append(f"{self.user}@") + parts.append(self.hostname) + else: + parts = [self.user or "hg", "@", self.hostname] + + if self.port is not None: + parts.extend([":", f"{self.port}"]) + + parts.extend([self.separator, self.path]) + + return "".join(part for part in parts if isinstance(part, str)) + + +@dataclasses.dataclass(repr=False) +class HgPipURL(HgBaseURL, URLProtocol, SkipDefaultFieldsReprMixin): + """Supports pip hg URLs.""" + + # commit-ish (rev): tag, branch, ref + rev: Optional[str] = None + + rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in PIP_DEFAULT_RULES}) + + @classmethod + def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: + """Whether URL is compatible with VCS or not. + + Examples + -------- + + >>> HgPipURL.is_valid( + ... url='hg+https://hg.mozilla.org/mozilla-central' + ... ) + True + + >>> HgPipURL.is_valid(url='hg+ssh://hg@hg.python.org:cpython') + True + + >>> HgPipURL.is_valid(url='notaurl') + False + """ + return super().is_valid(url=url, is_explicit=is_explicit) + + def to_url(self) -> str: + """Return a ``hg(1)``-compatible URL. Can be used with ``hg clone``. + + Examples + -------- + + >>> hg_url = HgPipURL(url='hg+https://hg.mozilla.org/mozilla-central') + + >>> hg_url + HgPipURL(url=hg+https://hg.mozilla.org/mozilla-central, + scheme=hg+https, + hostname=hg.mozilla.org, + path=mozilla-central, + rule=pip-url) + + Switch repo mozilla-central -> mobile-browser: + + >>> hg_url.path = 'mobile-browser' + + >>> hg_url.to_url() + 'hg+https://hg.mozilla.org/mobile-browser' + + Switch them to localhost: + + >>> hg_url.hostname = 'localhost' + >>> hg_url.scheme = 'http' + + >>> hg_url.to_url() + 'http://localhost/mobile-browser' + """ parts = [self.scheme or "ssh", "://"] if self.user: @@ -295,3 +401,162 @@ def to_url(self) -> str: parts.extend([self.separator, self.path]) return "".join(part for part in parts if isinstance(part, str)) + + +@dataclasses.dataclass(repr=False) +class HgURL(HgPipURL, HgBaseURL, URLProtocol, SkipDefaultFieldsReprMixin): + """Batteries included URL Parser. Supports hg(1) and pip URLs. + + **Ancestors (MRO)** + This URL parser inherits methods and attributes from the following parsers: + + - :class:`HgPipURL` + + - :meth:`HgPipURL.to_url` + - :class:`HgBaseURL` + + - :meth:`HgBaseURL.to_url` + """ + + rule_map: RuleMap = RuleMap( + _rule_map={m.label: m for m in [*DEFAULT_RULES, *PIP_DEFAULT_RULES]} + ) + + @classmethod + def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: + r"""Whether URL is compatible included Hg URL rule_map or not. + + Examples + -------- + + **Will** match normal ``hg(1)`` URLs, use :meth:`HgURL.is_valid` for that. + + >>> HgURL.is_valid(url='https://hg.mozilla.org/mozilla-central/mozilla-central') + True + + >>> HgURL.is_valid(url='hg@hg.mozilla.org:MyProject/project') + True + + Pip-style URLs: + + >>> HgURL.is_valid(url='hg+https://hg.mozilla.org/mozilla-central/project') + True + + >>> HgURL.is_valid(url='hg+ssh://hg@hg.mozilla.org:MyProject/project') + True + + >>> HgURL.is_valid(url='notaurl') + False + + **Explicit VCS detection** + + Pip-style URLs are prefixed with the VCS name in front, so its rule_map can + unambigously narrow the type of VCS: + + >>> HgURL.is_valid( + ... url='hg+ssh://hg@hg.mozilla.org:mozilla-central/image', is_explicit=True + ... ) + True + + Below, while it's hg.mozilla.org, that doesn't necessarily mean that the URL + itself is conclusively a `hg` URL (e.g. the pattern is too broad): + + >>> HgURL.is_valid( + ... url='hg@hg.mozilla.org:mozilla-central/image', is_explicit=True + ... ) + False + + You could create a Mozilla rule that consider hg.mozilla.org hostnames to be + exclusively hg: + + >>> MozillaRule = Rule( + ... # Since hg.mozilla.org exclusively serves hg repos, make explicit + ... label='mozilla-rule', + ... description='Matches hg.mozilla.org https URLs, exact VCS match', + ... pattern=re.compile( + ... rf''' + ... ^(?Pssh)? + ... ((?P\w+)@)? + ... (?P(hg.mozilla.org)+): + ... (?P(\w[^:]+)) + ... {RE_SUFFIX}? + ... ''', + ... re.VERBOSE, + ... ), + ... is_explicit=True, + ... defaults={ + ... 'hostname': 'hg.mozilla.org' + ... } + ... ) + + >>> HgURL.rule_map.register(MozillaRule) + + >>> HgURL.is_valid( + ... url='hg@hg.mozilla.org:mozilla-central/image', is_explicit=True + ... ) + True + + >>> HgURL(url='hg@hg.mozilla.org:mozilla-central/image').rule + 'mozilla-rule' + + This is just us cleaning up: + + >>> HgURL.rule_map.unregister('mozilla-rule') + + >>> HgURL(url='hg@hg.mozilla.org:mozilla-central/mozilla-rule').rule + 'core-hg-scp' + """ + return super().is_valid(url=url, is_explicit=is_explicit) + + def to_url(self) -> str: + """Return a ``hg(1)``-compatible URL. Can be used with ``hg clone``. + + Examples + -------- + + SSH style URL: + + >>> hg_url = HgURL(url='hg@hg.mozilla.org:mozilla-central/browser') + + >>> hg_url.path = 'mozilla-central/gfx' + + >>> hg_url.to_url() + 'ssh://hg@hg.mozilla.org:mozilla-central/gfx' + + HTTPs URL: + + >>> hg_url = HgURL(url='https://hg.mozilla.org/mozilla-central/memory') + + >>> hg_url.path = 'mozilla-central/image' + + >>> hg_url.to_url() + 'https://hg.mozilla.org/mozilla-central/image' + + Switch them to hglab: + + >>> hg_url.hostname = 'localhost' + >>> hg_url.scheme = 'http' + + >>> hg_url.to_url() + 'http://localhost/mozilla-central/image' + + Pip style URL, thanks to this class implementing :class:`HgPipURL`: + + >>> hg_url = HgURL(url='hg+ssh://hg@hg.mozilla.org/mozilla-central/image') + + >>> hg_url.hostname = 'localhost' + + >>> hg_url.to_url() + 'hg+ssh://hg@localhost/mozilla-central/image' + + >>> hg_url.user = None + + >>> hg_url.to_url() + 'hg+ssh://localhost/mozilla-central/image' + + See also + -------- + + :meth:`HgBaseURL.to_url`, :meth:`HgPipURL.to_url` + """ + return super().to_url() diff --git a/tests/url/test_hg.py b/tests/url/test_hg.py index 6de7c64b9..0273ca62f 100644 --- a/tests/url/test_hg.py +++ b/tests/url/test_hg.py @@ -4,7 +4,7 @@ from libvcs.sync.hg import HgSync from libvcs.url.base import RuleMap -from libvcs.url.hg import DEFAULT_RULES, PIP_DEFAULT_RULES, HgURL +from libvcs.url.hg import DEFAULT_RULES, PIP_DEFAULT_RULES, HgBaseURL, HgURL class HgURLFixture(typing.NamedTuple): @@ -117,7 +117,7 @@ class HgURLWithPip(HgURL): hg_url.url = hg_url.url.format(local_repo=hg_repo.dir) assert ( - HgURL.is_valid(url) != is_valid + HgBaseURL.is_valid(url) != is_valid ), f"{url} compatibility should work with core, expects {not is_valid}" assert ( HgURLWithPip.is_valid(url) == is_valid From 03602f46184453177e8e363a03ba2af3b1fa907a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 16:31:45 -0500 Subject: [PATCH 20/42] fix(url[svn]): Fix svn to be None by default This way it won't filter in affirmative or negative that it's explicit --- src/libvcs/url/svn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libvcs/url/svn.py b/src/libvcs/url/svn.py index 1e32a8772..26cd09fe8 100644 --- a/src/libvcs/url/svn.py +++ b/src/libvcs/url/svn.py @@ -188,7 +188,7 @@ def __post_init__(self) -> None: setattr(self, k, rule.defaults[k]) @classmethod - def is_valid(cls, url: str, is_explicit: Optional[bool] = False) -> bool: + def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: """Whether URL is compatible with VCS or not. Examples From d4a03b965e395799273de0c39c09c9bd4c4cfe97 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 13:49:51 -0500 Subject: [PATCH 21/42] feat(url): Add is_explicit for is_valid on Svn and Mercurial --- src/libvcs/url/hg.py | 6 ++++++ src/libvcs/url/svn.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/libvcs/url/hg.py b/src/libvcs/url/hg.py index abec046af..9e4266803 100644 --- a/src/libvcs/url/hg.py +++ b/src/libvcs/url/hg.py @@ -239,6 +239,12 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: >>> HgBaseURL.is_valid(url='notaurl') False """ + if is_explicit is not None: + return any( + re.search(rule.pattern, url) + for rule in cls.rule_map.values() + if rule.is_explicit == is_explicit + ) return any(re.search(rule.pattern, url) for rule in cls.rule_map.values()) def to_url(self) -> str: diff --git a/src/libvcs/url/svn.py b/src/libvcs/url/svn.py index 26cd09fe8..84d146819 100644 --- a/src/libvcs/url/svn.py +++ b/src/libvcs/url/svn.py @@ -202,6 +202,12 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: >>> SvnURL.is_valid(url='notaurl') False """ + if is_explicit is not None: + return any( + re.search(rule.pattern, url) + for rule in cls.rule_map.values() + if rule.is_explicit == is_explicit + ) return any(re.search(rule.pattern, url) for rule in cls.rule_map.values()) def to_url(self) -> str: From 1d0fb50549ef67ef1ae3b3fe52e49a9c5c33b7ae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 19:00:11 -0500 Subject: [PATCH 22/42] refactor!(url[svn]): Examples and further parsing tests --- src/libvcs/url/svn.py | 287 ++++++++++++++++++++++++++++++++++++++---- tests/url/test_svn.py | 7 +- 2 files changed, 271 insertions(+), 23 deletions(-) diff --git a/src/libvcs/url/svn.py b/src/libvcs/url/svn.py index 84d146819..0b56c6a2f 100644 --- a/src/libvcs/url/svn.py +++ b/src/libvcs/url/svn.py @@ -23,16 +23,17 @@ from typing import Optional from libvcs._internal.dataclasses import SkipDefaultFieldsReprMixin +from libvcs.url.git import RE_PIP_REV, SCP_REGEX from .base import Rule, RuleMap, URLProtocol RE_PATH = r""" - ((?P.*)@)? - (?P([^/:]+)) + ((?P[^/:@]+)@)? + (?P([^/:@]+)) (:(?P\d{1,5}))? - (?P/)? + (?P[:,/])? (?P - (\w[^:.]*) + (\w[^:.@]*) )? """ @@ -56,10 +57,25 @@ ^{RE_SCHEME} :// {RE_PATH} + {RE_PIP_REV}? """, re.VERBOSE, ), ), + Rule( + label="core-svn-scp", + description="Vanilla scp(1) / ssh(1) type URL", + pattern=re.compile( + rf""" + ^(?Pssh)? + {SCP_REGEX} + {RE_PIP_REV}? + """, + re.VERBOSE, + ), + defaults={"username": "svn"}, + ), + # SCP-style URLs, e.g. hg@ ] """Core regular expressions. These are patterns understood by ``svn(1)``""" @@ -72,8 +88,7 @@ ( svn\+ssh| svn\+https| - svn\+http| - svn\+file + svn\+http ) ) """ @@ -84,12 +99,14 @@ description="pip-style svn URL", pattern=re.compile( rf""" - {RE_PIP_SCHEME} + ^{RE_PIP_SCHEME} :// {RE_PATH} + {RE_PIP_REV}? """, re.VERBOSE, ), + is_explicit=True, ), # file://, RTC 8089, File:// https://datatracker.ietf.org/doc/html/rfc8089 Rule( @@ -102,6 +119,7 @@ """, re.VERBOSE, ), + is_explicit=True, ), ] """pip-style svn URLs. @@ -125,19 +143,20 @@ @dataclasses.dataclass(repr=False) -class SvnURL(URLProtocol, SkipDefaultFieldsReprMixin): +class SvnBaseURL(URLProtocol, SkipDefaultFieldsReprMixin): """SVN repository location. Parses URLs on initialization. Examples -------- - >>> SvnURL(url='svn+ssh://svn.debian.org/svn/aliothproj/path/in/project/repository') - SvnURL(url=svn+ssh://svn.debian.org/svn/aliothproj/path/in/project/repository, + >>> SvnBaseURL( + ... url='svn+ssh://svn.debian.org/svn/aliothproj/path/in/project/repository') + SvnBaseURL(url=svn+ssh://svn.debian.org/svn/aliothproj/path/in/project/repository, scheme=svn+ssh, hostname=svn.debian.org, path=svn/aliothproj/path/in/project/repository, rule=core-svn) - >>> myrepo = SvnURL( + >>> myrepo = SvnBaseURL( ... url='svn+ssh://svn.debian.org/svn/aliothproj/path/in/project/repository' ... ) @@ -147,8 +166,8 @@ class SvnURL(URLProtocol, SkipDefaultFieldsReprMixin): >>> myrepo.path 'svn/aliothproj/path/in/project/repository' - - Compatibility checking: :meth:`SvnURL.is_valid()` - - URLs compatible with ``svn(1)``: :meth:`SvnURL.to_url()` + - Compatibility checking: :meth:`SvnBaseURL.is_valid()` + - URLs compatible with ``svn(1)``: :meth:`SvnBaseURL.to_url()` Attributes ---------- @@ -194,12 +213,12 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: Examples -------- - >>> SvnURL.is_valid( + >>> SvnBaseURL.is_valid( ... url='svn+ssh://svn.debian.org/svn/aliothproj/path/in/project/repository' ... ) True - >>> SvnURL.is_valid(url='notaurl') + >>> SvnBaseURL.is_valid(url='notaurl') False """ if is_explicit is not None: @@ -216,12 +235,12 @@ def to_url(self) -> str: Examples -------- - >>> svn_url = SvnURL( + >>> svn_url = SvnBaseURL( ... url='svn+ssh://my-username@my-server/vcs-python/libvcs' ... ) >>> svn_url - SvnURL(url=svn+ssh://my-username@my-server/vcs-python/libvcs, + SvnBaseURL(url=svn+ssh://my-username@my-server/vcs-python/libvcs, scheme=svn+ssh, user=my-username, hostname=my-server, @@ -242,11 +261,14 @@ def to_url(self) -> str: >>> svn_url.to_url() 'svn+ssh://tom@my-server/vcs-python/vcspull' """ - parts = [self.scheme or "ssh", "://"] - if self.user: - parts.extend([self.user, "@"]) + if self.scheme is not None: + parts = [self.scheme, "://"] - parts.append(self.hostname) + if self.user is not None: + parts.append(f"{self.user}@") + parts.append(self.hostname) + else: + parts = [self.user or "hg", "@", self.hostname] if self.port is not None: parts.extend([":", f"{self.port}"]) @@ -254,3 +276,226 @@ def to_url(self) -> str: parts.extend([self.separator, self.path]) return "".join(part for part in parts if isinstance(part, str)) + + +@dataclasses.dataclass(repr=False) +class SvnPipURL(SvnBaseURL, URLProtocol, SkipDefaultFieldsReprMixin): + """Supports pip svn URLs.""" + + # commit-ish (rev): tag, branch, ref + rev: Optional[str] = None + + rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in PIP_DEFAULT_RULES}) + + @classmethod + def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: + """Whether URL is compatible with VCS or not. + + Examples + -------- + + >>> SvnPipURL.is_valid( + ... url='svn+https://svn.project.org/project-central' + ... ) + True + + >>> SvnPipURL.is_valid(url='svn+ssh://svn@svn.python.org:cpython') + True + + >>> SvnPipURL.is_valid(url='notaurl') + False + """ + return super().is_valid(url=url, is_explicit=is_explicit) + + def to_url(self) -> str: + """Return a ``svn(1)``-compatible URL. Can be used with ``svn clone``. + + Examples + -------- + + >>> svn_url = SvnPipURL(url='svn+https://svn.project.org/project-central') + + >>> svn_url + SvnPipURL(url=svn+https://svn.project.org/project-central, + scheme=svn+https, + hostname=svn.project.org, + path=project-central, + rule=pip-url) + + Switch repo project-central -> mobile-browser: + + >>> svn_url.path = 'mobile-browser' + + >>> svn_url.to_url() + 'svn+https://svn.project.org/mobile-browser' + + Switch them to localhost: + + >>> svn_url.hostname = 'localhost' + >>> svn_url.scheme = 'http' + + >>> svn_url.to_url() + 'http://localhost/mobile-browser' + + """ + return super().to_url() + + +@dataclasses.dataclass(repr=False) +class SvnURL(SvnPipURL, SvnBaseURL, URLProtocol, SkipDefaultFieldsReprMixin): + """Batteries included URL Parser. Supports svn(1) and pip URLs. + + **Ancestors (MRO)** + This URL parser inherits methods and attributes from the following parsers: + + - :class:`SvnPipURL` + + - :meth:`SvnPipURL.to_url` + - :class:`SvnBaseURL` + + - :meth:`SvnBaseURL.to_url` + """ + + rule_map: RuleMap = RuleMap( + _rule_map={m.label: m for m in [*DEFAULT_RULES, *PIP_DEFAULT_RULES]} + ) + + @classmethod + def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: + r"""Whether URL is compatible included Svn URL rule_map or not. + + Examples + -------- + + **Will** match normal ``svn(1)`` URLs, use :meth:`SvnURL.is_valid` for that. + + >>> SvnURL.is_valid( + ... url='https://svn.project.org/project-central/project-central') + True + + >>> SvnURL.is_valid(url='svn@svn.project.org:MyProject/project') + True + + Pip-style URLs: + + >>> SvnURL.is_valid(url='svn+https://svn.project.org/project-central/project') + True + + >>> SvnURL.is_valid(url='svn+ssh://svn@svn.project.org:MyProject/project') + True + + >>> SvnURL.is_valid(url='notaurl') + False + + **Explicit VCS detection** + + Pip-style URLs are prefixed with the VCS name in front, so its rule_map can + unambigously narrow the type of VCS: + + >>> SvnURL.is_valid( + ... url='svn+ssh://svn@svn.project.org:project-central/image', + ... is_explicit=True + ... ) + True + + Below, while it's svn.project.org, that doesn't necessarily mean that the URL + itself is conclusively a `svn` URL (e.g. the pattern is too broad): + + >>> SvnURL.is_valid( + ... url='svn@svn.project.org:project-central/image', is_explicit=True + ... ) + False + + You could create a project rule that consider svn.project.org hostnames to be + exclusively svn: + + >>> projectRule = Rule( + ... # Since svn.project.org exclusively serves svn repos, make explicit + ... label='project-rule', + ... description='Matches svn.project.org https URLs, exact VCS match', + ... pattern=re.compile( + ... rf''' + ... ^(?Pssh)? + ... ((?P\w+)@)? + ... (?P(svn.project.org)+): + ... (?P(\w[^:]+)) + ... ''', + ... re.VERBOSE, + ... ), + ... is_explicit=True, + ... defaults={ + ... 'hostname': 'svn.project.org' + ... } + ... ) + + >>> SvnURL.rule_map.register(projectRule) + + >>> SvnURL.is_valid( + ... url='svn@svn.project.org:project-central/image', is_explicit=True + ... ) + True + + >>> SvnURL(url='svn@svn.project.org:project-central/image').rule + 'project-rule' + + This is just us cleaning up: + + >>> SvnURL.rule_map.unregister('project-rule') + + >>> SvnURL(url='svn@svn.project.org:project-central/project-rule').rule + 'core-svn-scp' + """ + return super().is_valid(url=url, is_explicit=is_explicit) + + def to_url(self) -> str: + """Return a ``svn(1)``-compatible URL. Can be used with ``svn clone``. + + Examples + -------- + + SSH style URL: + + >>> svn_url = SvnURL(url='svn@svn.project.org:project-central/browser') + + >>> svn_url.path = 'project-central/gfx' + + >>> svn_url.to_url() + 'svn@svn.project.org:project-central/gfx' + + HTTPs URL: + + >>> svn_url = SvnURL(url='https://svn.project.org/project-central/memory') + + >>> svn_url.path = 'project-central/image' + + >>> svn_url.to_url() + 'https://svn.project.org/project-central/image' + + Switch them to svnlab: + + >>> svn_url.hostname = 'localhost' + >>> svn_url.scheme = 'http' + + >>> svn_url.to_url() + 'http://localhost/project-central/image' + + Pip style URL, thanks to this class implementing :class:`SvnPipURL`: + + >>> svn_url = SvnURL(url='svn+ssh://svn@svn.project.org/project-central/image') + + >>> svn_url.hostname = 'localhost' + + >>> svn_url.to_url() + 'svn+ssh://svn@localhost/project-central/image' + + >>> svn_url.user = None + + >>> svn_url.to_url() + 'svn+ssh://localhost/project-central/image' + + See also + -------- + + :meth:`SvnBaseURL.to_url`, :meth:`SvnPipURL.to_url` + """ + return super().to_url() diff --git a/tests/url/test_svn.py b/tests/url/test_svn.py index d86a33125..151e72324 100644 --- a/tests/url/test_svn.py +++ b/tests/url/test_svn.py @@ -4,7 +4,7 @@ from libvcs.sync.svn import SvnSync from libvcs.url.base import RuleMap -from libvcs.url.svn import DEFAULT_RULES, PIP_DEFAULT_RULES, SvnURL +from libvcs.url.svn import DEFAULT_RULES, PIP_DEFAULT_RULES, SvnBaseURL, SvnURL class SvnURLFixture(typing.NamedTuple): @@ -134,7 +134,10 @@ class SvnURLWithPip(SvnURL): svn_url.url = svn_url.url.format(local_repo=svn_repo.dir) assert ( - SvnURL.is_valid(url) != is_valid + SvnBaseURL.is_valid(url) != is_valid + ), f"{url} compatibility should work with core, expects {not is_valid}" + assert ( + SvnURL.is_valid(url) == is_valid ), f"{url} compatibility should work with core, expects {not is_valid}" assert ( SvnURLWithPip.is_valid(url) == is_valid From cd9bb0a2e1039b79589f362520e36933a6bdf212 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 19:41:32 -0500 Subject: [PATCH 23/42] docs(CHANGES): Note url updates --- CHANGES | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGES b/CHANGES index 714e7dc7a..c26fa0818 100644 --- a/CHANGES +++ b/CHANGES @@ -22,6 +22,15 @@ URL renamings (#417): - `default_patterns` -> `patterns` - `MATCHERS` -> `RULES` +### Improvements + +- URLs (#423): + - `hg`: Add `HgBaseURL`, `HgPipURL` + - `svn`: Add `SvnBaseURL`, `SvnPipURL` + - `URLProtocol`: Fix `is_valid` to use `classmethod` + - All: Fix `is_valid` to use default of `None` to avoid implicitly filtering + - Reduce duplicated code in methods by using `super()` + ### Packaging - Migrate `.coveragerc` to `pyproject.toml` (#421) From 5f5547769b0598afc0dc6e1a406cb7fd66e6aab0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 11:51:20 -0500 Subject: [PATCH 24/42] feat(str): import_loader from werkzeug --- src/libvcs/_internal/module_loading.py | 113 +++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/libvcs/_internal/module_loading.py diff --git a/src/libvcs/_internal/module_loading.py b/src/libvcs/_internal/module_loading.py new file mode 100644 index 000000000..464419b0b --- /dev/null +++ b/src/libvcs/_internal/module_loading.py @@ -0,0 +1,113 @@ +import sys +import typing as t + + +class ImportStringError(ImportError): + """ + Provides information about a failed :func:`import_string` attempt. + + Notes + ----- + This is from werkzeug.utils d36aaf1 on August 20 2022, LICENSE BSD. + https://github.com/pallets/werkzeug + + Changes: + - Deferred load import import_string + - Format with black + """ + + #: String in dotted notation that failed to be imported. + import_name: str + #: Wrapped exception. + exception: BaseException + + def __init__(self, import_name: str, exception: BaseException) -> None: + self.import_name = import_name + self.exception = exception + msg = import_name + name = "" + tracked = [] + for part in import_name.replace(":", ".").split("."): + name = f"{name}.{part}" if name else part + imported = import_string(name, silent=True) + if imported: + tracked.append((name, getattr(imported, "__file__", None))) + else: + track = [f"- {n!r} found in {i!r}." for n, i in tracked] + track.append(f"- {name!r} not found.") + track_str = "\n".join(track) + msg = ( + f"import_string() failed for {import_name!r}. Possible reasons" + f" are:\n\n" + "- missing __init__.py in a package;\n" + "- package or module path not included in sys.path;\n" + "- duplicated package or module name taking precedence in" + " sys.path;\n" + "- missing module, class, function or variable;\n\n" + f"Debugged import:\n\n{track_str}\n\n" + f"Original exception:\n\n{type(exception).__name__}: {exception}" + ) + break + + super().__init__(msg) + + def __repr__(self) -> str: + return f"<{type(self).__name__}({self.import_name!r}, {self.exception!r})>" + + +def import_string(import_name: str, silent: bool = False) -> t.Any: + """Imports an object based on a string. + + This is useful if you want to use import paths as endpoints or + something similar. An import path can be specified either in dotted + notation (``xml.sax.saxutils.escape``) or with a colon as object + delimiter (``xml.sax.saxutils:escape``). + + If `silent` is True the return value will be `None` if the import fails. + + Parameters + ---------- + import_name : string + the dotted name for the object to import. + silent : bool + if set to `True` import errors are ignored and `None` is returned instead. + + Returns + ------- + imported object + + Raises + ------ + ImportStringError (ImportError, libvcs.exc.libvcsException) + + Notes + ----- + This is from werkzeug.utils d36aaf1 on May 23, 2022, LICENSE BSD. + https://github.com/pallets/werkzeug + + Changes: + - Exception raised is ImportStringError + - Format with black + """ + import_name = import_name.replace(":", ".") + try: + try: + __import__(import_name) + except ImportError: + if "." not in import_name: + raise + else: + return sys.modules[import_name] + + module_name, obj_name = import_name.rsplit(".", 1) + module = __import__(module_name, globals(), locals(), [obj_name]) + try: + return getattr(module, obj_name) + except AttributeError as e: + raise ImportError(e) from None + except ImportError as e: + if not silent: + raise ImportStringError(import_name, e).with_traceback( + sys.exc_info()[2] + ) from None + return None From d747513973a7141f418bf94c15005ac45275988c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 22 Sep 2022 21:52:01 -0500 Subject: [PATCH 25/42] feat(registry): Add VCS detection --- src/libvcs/url/registry.py | 49 +++++++++++++++++++ tests/url/test_registry.py | 97 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/libvcs/url/registry.py create mode 100644 tests/url/test_registry.py diff --git a/src/libvcs/url/registry.py b/src/libvcs/url/registry.py new file mode 100644 index 000000000..a4e5cc5a5 --- /dev/null +++ b/src/libvcs/url/registry.py @@ -0,0 +1,49 @@ +import typing as t + +from libvcs._internal.module_loading import import_string + +from .base import URLProtocol + +if t.TYPE_CHECKING: + from typing_extensions import TypeAlias + + ParserLazyMap: TypeAlias = t.Dict[str, t.Union[t.Type[URLProtocol], str]] + ParserMap: TypeAlias = t.Dict[str, t.Type[URLProtocol]] + +DEFAULT_PARSERS: "ParserLazyMap" = { + "git": "libvcs.url.git.GitURL", + "hg": "libvcs.url.hg.HgURL", + "svn": "libvcs.url.svn.SvnURL", +} + + +class ParserMatch(t.NamedTuple): + vcs: str + """VCS system matched""" + match: URLProtocol + """Matcher vcs detected with""" + + +class VCSRegistry: + """Index of parsers""" + + parser_map: t.ClassVar["ParserMap"] = {} + + def __init__(self, parsers: "ParserLazyMap"): + for k, v in parsers.items(): + if isinstance(v, str): + v = import_string(v) + assert callable(v) + self.parser_map[k] = v + + def match( + self, url: str, is_explicit: t.Optional[bool] = None + ) -> t.List["ParserMatch"]: + matches: t.List[ParserMatch] = [] + for vcs, parser in self.parser_map.items(): + if parser.is_valid(url=url, is_explicit=is_explicit): + matches.append(ParserMatch(vcs=vcs, match=parser(url))) + return matches + + +registry = VCSRegistry(parsers=DEFAULT_PARSERS) diff --git a/tests/url/test_registry.py b/tests/url/test_registry.py new file mode 100644 index 000000000..7a83408a2 --- /dev/null +++ b/tests/url/test_registry.py @@ -0,0 +1,97 @@ +import typing as t + +import pytest + +from libvcs.url import registry +from libvcs.url.git import GitURL +from libvcs.url.hg import HgURL +from libvcs.url.svn import SvnURL + +if t.TYPE_CHECKING: + from typing_extensions import TypeAlias + + ParserMatchLazy: TypeAlias = t.Callable[[str], registry.ParserMatch] + DetectVCSFixtureExpectedMatch: TypeAlias = t.Union[ + registry.ParserMatch, ParserMatchLazy + ] + + +class DetectVCSFixture(t.NamedTuple): + url: str + expected_matches_lazy: t.List["DetectVCSFixtureExpectedMatch"] + is_explicit: bool + + +TEST_FIXTURES: list[DetectVCSFixture] = [ + *[ + DetectVCSFixture( + url=url, + expected_matches_lazy=[ + lambda url: registry.ParserMatch(vcs="git", match=GitURL(url)) + ], + is_explicit=True, + ) + for url in [ + "git+https://github.com/vcs-python/libvcs", + "git+https://github.com/vcs-python/libvcs.git", + "git+https://github.com:vcs-python/libvcs.git", + "git+ssh://git@github.com:vcs-python/libvcs.git", + "git+ssh://git@github.com:vcs-python/libvcs", + "git+ssh://git@github.com/tony/ScreenToGif.git", + "git+https://github.com/nltk/nltk.git", + "git+https://github.com/nltk/nltk", + ] + ], + *[ + DetectVCSFixture( + url=url, + expected_matches_lazy=[ + lambda url: registry.ParserMatch(vcs="hg", match=HgURL(url)) + ], + is_explicit=True, + ) + for url in [ + "hg+http://hg.example.com/MyProject@da39a3ee5e6b", + "hg+ssh://hg.example.com:MyProject@da39a3ee5e6b", + "hg+https://hg.mozilla.org/mozilla-central/", + ] + ], + *[ + DetectVCSFixture( + url=url, + expected_matches_lazy=[ + lambda url: registry.ParserMatch(vcs="svn", match=SvnURL(url)) + ], + is_explicit=True, + ) + for url in [ + "svn+http://svn.example.com/MyProject@da39a3ee5e6b", + "svn+ssh://svn.example.com:MyProject@da39a3ee5e6b", + "svn+ssh://svn.example.com:MyProject@da39a3ee5e6b", + ] + ], +] + + +@pytest.mark.parametrize( + list(DetectVCSFixture._fields), + TEST_FIXTURES, +) +def test_registry( + url: str, + expected_matches_lazy: t.List["DetectVCSFixtureExpectedMatch"], + is_explicit: bool, +) -> None: + assert url + assert registry.registry + + matches = registry.registry.match(url, is_explicit=is_explicit) + + # Just add water + expected_matches: t.List["DetectVCSFixtureExpectedMatch"] = [] + for idx, expected_match in enumerate(expected_matches_lazy): + if callable(expected_match): + assert callable(expected_match) + expected_matches.append(expected_match(url)) + + assert matches == expected_matches From ed735aaa5976e00ecb4dfcbe5ea03e8a2174c338 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 21:23:11 -0500 Subject: [PATCH 26/42] docs(CHANGES): Note registry --- CHANGES | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES b/CHANGES index c26fa0818..b237ac4f7 100644 --- a/CHANGES +++ b/CHANGES @@ -13,6 +13,10 @@ $ pip install --user --upgrade --pre libvcs - _Add your latest changes from PRs here_ +### New features + +- URLs: Added `registry`, match find which VCS a URL matches with (#420) + ### Breaking changes URL renamings (#417): From e4bdfad2716e9cb428c682ababa8c61247ef8132 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 24 Sep 2022 21:25:59 -0500 Subject: [PATCH 27/42] docs(url): Add registry --- docs/url/index.md | 1 + docs/url/registry.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 docs/url/registry.md diff --git a/docs/url/index.md b/docs/url/index.md index e8708c1ee..67b412f15 100644 --- a/docs/url/index.md +++ b/docs/url/index.md @@ -159,4 +159,5 @@ git svn hg base +registry ``` diff --git a/docs/url/registry.md b/docs/url/registry.md new file mode 100644 index 000000000..18304953c --- /dev/null +++ b/docs/url/registry.md @@ -0,0 +1,29 @@ +# VCS Detection - `libvcs.url.registry` + +Detect VCS from `git`, `hg`, and `svn` URLs. + +```python +>>> from libvcs.url.registry import registry, ParserMatch +>>> from libvcs.url.git import GitURL + +>>> registry.match('git@invent.kde.org:plasma/plasma-sdk.git') +[ParserMatch(vcs='git', match=GitURL(...))] + +>>> registry.match('git@invent.kde.org:plasma/plasma-sdk.git', is_explicit=True) +[] + +>>> registry.match('git+ssh://git@invent.kde.org:plasma/plasma-sdk.git') +[ParserMatch(vcs='git', match=GitURL(...))] + +>>> registry.match('git+ssh://git@invent.kde.org:plasma/plasma-sdk.git', is_explicit=False) +[] + +>>> registry.match('git+ssh://git@invent.kde.org:plasma/plasma-sdk.git', is_explicit=True) +[ParserMatch(vcs='git', match=GitURL(...))] +``` + +```{eval-rst} +.. automodule:: libvcs.url.registry + :members: + :undoc-members: +``` From 9325a7e9ec21f7cb40bd1dd05d5087c9f92bb875 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 25 Sep 2022 08:17:31 -0500 Subject: [PATCH 28/42] feat(create_project): Guess VCS from URL --- src/libvcs/_internal/shortcuts.py | 34 +++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/libvcs/_internal/shortcuts.py b/src/libvcs/_internal/shortcuts.py index f24460f8c..ab1f3779d 100644 --- a/src/libvcs/_internal/shortcuts.py +++ b/src/libvcs/_internal/shortcuts.py @@ -10,7 +10,11 @@ from libvcs import GitSync, HgSync, SvnSync from libvcs._internal.run import ProgressCallbackProtocol from libvcs._internal.types import StrPath, VCSLiteral -from libvcs.exc import InvalidVCS +from libvcs.exc import InvalidVCS, LibVCSException +from libvcs.url import registry as url_tools + +if t.TYPE_CHECKING: + from typing_extensions import TypeGuard @t.overload @@ -20,7 +24,7 @@ def create_project( dir: StrPath, vcs: t.Literal["git"], progress_callback: t.Optional[ProgressCallbackProtocol] = None, - **kwargs: dict[t.Any, t.Any] + **kwargs: dict[t.Any, t.Any], ) -> GitSync: ... @@ -32,7 +36,7 @@ def create_project( dir: StrPath, vcs: t.Literal["svn"], progress_callback: t.Optional[ProgressCallbackProtocol] = None, - **kwargs: dict[t.Any, t.Any] + **kwargs: dict[t.Any, t.Any], ) -> SvnSync: ... @@ -44,7 +48,7 @@ def create_project( dir: StrPath, vcs: t.Literal["hg"], progress_callback: t.Optional[ProgressCallbackProtocol] = ..., - **kwargs: dict[t.Any, t.Any] + **kwargs: dict[t.Any, t.Any], ) -> HgSync: ... @@ -53,9 +57,9 @@ def create_project( *, url: str, dir: StrPath, - vcs: VCSLiteral, + vcs: t.Optional[VCSLiteral] = None, progress_callback: t.Optional[ProgressCallbackProtocol] = None, - **kwargs: dict[t.Any, t.Any] + **kwargs: dict[t.Any, t.Any], ) -> Union[GitSync, HgSync, SvnSync]: r"""Return an object representation of a VCS repository. @@ -71,6 +75,24 @@ def create_project( >>> isinstance(r, GitSync) True """ + if vcs is None: + vcs_matches = url_tools.registry.match(url=url, is_explicit=True) + + if len(vcs_matches) == 0: + raise LibVCSException(f"No vcs found for {url}") + if len(vcs_matches) > 1: + raise LibVCSException(f"No exact matches for {url}") + + assert vcs_matches[0].vcs is not None + + def is_vcs(val: t.Any) -> "TypeGuard[VCSLiteral]": + return isinstance(val, str) and val in ["git", "hg", "svn"] + + if is_vcs(vcs_matches[0].vcs): + vcs = vcs_matches[0].vcs + else: + raise InvalidVCS(f"{url} does not have supported vcs: {vcs}") + if vcs == "git": return GitSync(url=url, dir=dir, progress_callback=progress_callback, **kwargs) elif vcs == "hg": From 135e5066e7bdd24ecee0f6fb2bc21186a77830ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 25 Sep 2022 08:21:01 -0500 Subject: [PATCH 29/42] docs(CHANGES): Note create_project VCS guessing --- CHANGES | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES b/CHANGES index b237ac4f7..3242cd546 100644 --- a/CHANGES +++ b/CHANGES @@ -16,6 +16,7 @@ $ pip install --user --upgrade --pre libvcs ### New features - URLs: Added `registry`, match find which VCS a URL matches with (#420) +- `create_project`: Learn to guess VCS from URL, if none provided (#420) ### Breaking changes From 79d9b010edfacde45451ea278457995fd831fc6a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 25 Sep 2022 08:22:22 -0500 Subject: [PATCH 30/42] tests(create_project): Add example of URL guessing --- src/libvcs/_internal/shortcuts.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/libvcs/_internal/shortcuts.py b/src/libvcs/_internal/shortcuts.py index ab1f3779d..98491cff0 100644 --- a/src/libvcs/_internal/shortcuts.py +++ b/src/libvcs/_internal/shortcuts.py @@ -72,6 +72,17 @@ def create_project( ... dir=tmp_path ... ) + >>> isinstance(r, GitSync) + True + + create_project can also guess VCS for certain URLs: + + >>> r = create_project( + ... # Note the git+ before the URL + ... url=f'git+file://{create_git_remote_repo()}', + ... dir=tmp_path + ... ) + >>> isinstance(r, GitSync) True """ From 073675957e388261785d283c632af1be14344e2a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 25 Sep 2022 08:27:14 -0500 Subject: [PATCH 31/42] build(deps): Bump sphinx and setuptools --- poetry.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index c4966cf9d..cae14f4ff 100644 --- a/poetry.lock +++ b/poetry.lock @@ -592,7 +592,7 @@ use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "setuptools" -version = "65.3.0" +version = "65.4.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" category = "dev" optional = false @@ -629,7 +629,7 @@ python-versions = ">=3.6" [[package]] name = "Sphinx" -version = "5.2.0" +version = "5.2.0.post0" description = "Python documentation generator" category = "dev" optional = false @@ -1263,8 +1263,8 @@ requests = [ {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, ] setuptools = [ - {file = "setuptools-65.3.0-py3-none-any.whl", hash = "sha256:2e24e0bec025f035a2e72cdd1961119f557d78ad331bb00ff82efb2ab8da8e82"}, - {file = "setuptools-65.3.0.tar.gz", hash = "sha256:7732871f4f7fa58fb6bdcaeadb0161b2bd046c85905dbaa066bdcbcc81953b57"}, + {file = "setuptools-65.4.0-py3-none-any.whl", hash = "sha256:c2d2709550f15aab6c9110196ea312f468f41cd546bceb24127a1be6fdcaeeb1"}, + {file = "setuptools-65.4.0.tar.gz", hash = "sha256:a8f6e213b4b0661f590ccf40de95d28a177cd747d098624ad3f69c40287297e9"}, ] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, @@ -1279,8 +1279,8 @@ soupsieve = [ {file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"}, ] Sphinx = [ - {file = "Sphinx-5.2.0.tar.gz", hash = "sha256:1790c2098937dcfa7871c9d102c24eccd4a8b883b67c5c1e26892fb52d102542"}, - {file = "sphinx-5.2.0-py3-none-any.whl", hash = "sha256:422812bdf2dacab55c47ee4dd4746bb82e739fe4c97ce16dd68bcc208e348e73"}, + {file = "Sphinx-5.2.0.post0.tar.gz", hash = "sha256:68e7833263a961521f45302fa87285f9395ecf385f1eefd85cd61ddff0b15bc1"}, + {file = "sphinx-5.2.0.post0-py3-none-any.whl", hash = "sha256:db93dc52cc90d12ef38c9f506eab9171813041204d8270e30ffad2be511e7ced"}, ] sphinx-autobuild = [ {file = "sphinx-autobuild-2021.3.14.tar.gz", hash = "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05"}, From dc25ac0d7fb8707a68ca7982daebeadb44d64445 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 25 Sep 2022 08:27:33 -0500 Subject: [PATCH 32/42] Tag v0.17.0a0 --- pyproject.toml | 2 +- src/libvcs/__about__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 630558941..ef2b65834 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "libvcs" -version = "0.16.5" +version = "0.17.0a0" description = "Lite, typed, python utilities for Git, SVN, Mercurial, etc." license = "MIT" authors = ["Tony Narlock "] diff --git a/src/libvcs/__about__.py b/src/libvcs/__about__.py index 17460a0ea..94866e464 100644 --- a/src/libvcs/__about__.py +++ b/src/libvcs/__about__.py @@ -1,7 +1,7 @@ __title__ = "libvcs" __package_name__ = "libvcs" __description__ = "Lite, typed, python utilities for Git, SVN, Mercurial, etc." -__version__ = "0.16.5" +__version__ = "0.17.0a0" __author__ = "Tony Narlock" __github__ = "https://github.com/vcs-python/libvcs" __docs__ = "https://libvcs.git-pull.com" From a9f8af332fc57890ef5413e2e1401f3b015e94ca Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 25 Sep 2022 16:59:58 -0500 Subject: [PATCH 33/42] build(deps): Bump sphinx to 5.2.1 --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index cae14f4ff..f0373cb17 100644 --- a/poetry.lock +++ b/poetry.lock @@ -629,7 +629,7 @@ python-versions = ">=3.6" [[package]] name = "Sphinx" -version = "5.2.0.post0" +version = "5.2.1" description = "Python documentation generator" category = "dev" optional = false @@ -1279,8 +1279,8 @@ soupsieve = [ {file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"}, ] Sphinx = [ - {file = "Sphinx-5.2.0.post0.tar.gz", hash = "sha256:68e7833263a961521f45302fa87285f9395ecf385f1eefd85cd61ddff0b15bc1"}, - {file = "sphinx-5.2.0.post0-py3-none-any.whl", hash = "sha256:db93dc52cc90d12ef38c9f506eab9171813041204d8270e30ffad2be511e7ced"}, + {file = "Sphinx-5.2.1.tar.gz", hash = "sha256:c009bb2e9ac5db487bcf53f015504005a330ff7c631bb6ab2604e0d65bae8b54"}, + {file = "sphinx-5.2.1-py3-none-any.whl", hash = "sha256:3dcf00fcf82cf91118db9b7177edea4fc01998976f893928d0ab0c58c54be2ca"}, ] sphinx-autobuild = [ {file = "sphinx-autobuild-2021.3.14.tar.gz", hash = "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05"}, From 9fddb55d9db374fa9d99926206eff4036efb5b54 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 25 Sep 2022 17:01:34 -0500 Subject: [PATCH 34/42] build(deps): Bump gp-libs (sphinx autodoc toc fix removed) --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index f0373cb17..29a4a0fe5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -195,7 +195,7 @@ sphinx-basic-ng = "*" [[package]] name = "gp-libs" -version = "0.0.1a11" +version = "0.0.1a12" description = "Internal utilities for projects following git-pull python package spec" category = "dev" optional = false @@ -1042,8 +1042,8 @@ furo = [ {file = "furo-2022.9.15.tar.gz", hash = "sha256:4a7ef1c8a3b615171592da4d2ad8a53cc4aacfbe111958890f5f9ff7279066ab"}, ] gp-libs = [ - {file = "gp-libs-0.0.1a11.tar.gz", hash = "sha256:2fa1d886aae88f17614c052652509ce6347adc5f39e1b35223ef0ee2b56069e5"}, - {file = "gp_libs-0.0.1a11-py3-none-any.whl", hash = "sha256:75248e0409e8af142cd2ccdbf3382300d26eb73f0155b0de721368c3543e5c35"}, + {file = "gp-libs-0.0.1a12.tar.gz", hash = "sha256:3a9a3018fa524a0008dd2a88197b2ab503a769bfa780337bf00f5753e1b95552"}, + {file = "gp_libs-0.0.1a12-py3-none-any.whl", hash = "sha256:7115eb6f65de812352fd08da1316a31458d3ceedede3fb9f7f4d2236aae0ca27"}, ] idna = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, From 2f94fcfd13cdcec57a2377bd6cb93e66b8c9db4d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 25 Sep 2022 19:09:17 -0500 Subject: [PATCH 35/42] feat(pytest_plugin): Allow passing init flags to repo fixtures --- src/libvcs/pytest_plugin.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py index d84891f53..0c57d9f80 100644 --- a/src/libvcs/pytest_plugin.py +++ b/src/libvcs/pytest_plugin.py @@ -192,6 +192,7 @@ def __call__( remote_repos_path: pathlib.Path = ..., remote_repo_name: Optional[str] = ..., remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = ..., + init_cmd_args: Optional[list[str]] = ..., ) -> pathlib.Path: ... @@ -200,9 +201,12 @@ def _create_git_remote_repo( remote_repos_path: pathlib.Path, remote_repo_name: str, remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, + init_cmd_args: Optional[list[str]] = None, ) -> pathlib.Path: + if init_cmd_args is None: + init_cmd_args = [] remote_repo_path = remote_repos_path / remote_repo_name - run(["git", "init", remote_repo_name], cwd=remote_repos_path) + run(["git", "init", remote_repo_name, *init_cmd_args], cwd=remote_repos_path) if remote_repo_post_init is not None and callable(remote_repo_post_init): remote_repo_post_init(remote_repo_path=remote_repo_path) @@ -221,6 +225,7 @@ def fn( remote_repos_path: pathlib.Path = remote_repos_path, remote_repo_name: Optional[str] = None, remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, + init_cmd_args: Optional[list[str]] = None, ) -> pathlib.Path: return _create_git_remote_repo( remote_repos_path=remote_repos_path, @@ -228,6 +233,7 @@ def fn( if remote_repo_name is not None else unique_repo_name(remote_repos_path=remote_repos_path), remote_repo_post_init=remote_repo_post_init, + init_cmd_args=init_cmd_args, ) return fn @@ -256,11 +262,14 @@ def _create_svn_remote_repo( remote_repos_path: pathlib.Path, remote_repo_name: str, remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, + init_cmd_args: Optional[list[str]] = None, ) -> pathlib.Path: """Create a test SVN repo to for checkout / commit purposes""" + if init_cmd_args is None: + init_cmd_args = [] remote_repo_path = remote_repos_path / remote_repo_name - run(["svnadmin", "create", remote_repo_path]) + run(["svnadmin", "create", remote_repo_path, *init_cmd_args]) if remote_repo_post_init is not None and callable(remote_repo_post_init): remote_repo_post_init(remote_repo_path=remote_repo_path) @@ -279,6 +288,7 @@ def fn( remote_repos_path: pathlib.Path = remote_repos_path, remote_repo_name: Optional[str] = None, remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, + init_cmd_args: Optional[list[str]] = None, ) -> pathlib.Path: return _create_svn_remote_repo( remote_repos_path=remote_repos_path, @@ -286,6 +296,7 @@ def fn( if remote_repo_name is not None else unique_repo_name(remote_repos_path=remote_repos_path), remote_repo_post_init=remote_repo_post_init, + init_cmd_args=init_cmd_args, ) return fn @@ -309,10 +320,14 @@ def _create_hg_remote_repo( remote_repos_path: pathlib.Path, remote_repo_name: str, remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, + init_cmd_args: Optional[list[str]] = None, ) -> pathlib.Path: """Create a test hg repo to for checkout / commit purposes""" + if init_cmd_args is None: + init_cmd_args = [] + remote_repo_path = remote_repos_path / remote_repo_name - run(["hg", "init", remote_repo_name], cwd=remote_repos_path) + run(["hg", "init", remote_repo_name, *init_cmd_args], cwd=remote_repos_path) if remote_repo_post_init is not None and callable(remote_repo_post_init): remote_repo_post_init(remote_repo_path=remote_repo_path) @@ -340,6 +355,7 @@ def fn( remote_repos_path: pathlib.Path = remote_repos_path, remote_repo_name: Optional[str] = None, remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, + init_cmd_args: Optional[list[str]] = None, ) -> pathlib.Path: return _create_hg_remote_repo( remote_repos_path=remote_repos_path, @@ -347,6 +363,7 @@ def fn( if remote_repo_name is not None else unique_repo_name(remote_repos_path=remote_repos_path), remote_repo_post_init=remote_repo_post_init, + init_cmd_args=init_cmd_args, ) return fn From 91cd1638cb149c032260d02e0e8820b2edb0cf35 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 25 Sep 2022 19:12:28 -0500 Subject: [PATCH 36/42] chore(pytest_plugin): Use TypeAlias --- src/libvcs/pytest_plugin.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py index 0c57d9f80..45af1c7e3 100644 --- a/src/libvcs/pytest_plugin.py +++ b/src/libvcs/pytest_plugin.py @@ -5,7 +5,7 @@ import random import shutil import textwrap -from typing import Any, Optional, Protocol +from typing import TYPE_CHECKING, Any, Optional, Protocol import pytest @@ -14,6 +14,9 @@ from libvcs.sync.hg import HgSync from libvcs.sync.svn import SvnSync +if TYPE_CHECKING: + from typing_extensions import TypeAlias + skip_if_git_missing = pytest.mark.skipif( not shutil.which("git"), reason="git is not available" ) @@ -181,6 +184,9 @@ def unique_repo_name(remote_repos_path: pathlib.Path, max_retries: int = 15) -> return remote_repo_name +InitCmdArgs: "TypeAlias" = Optional[list[str]] + + class CreateProjectCallbackProtocol(Protocol): def __call__(self, remote_repo_path: pathlib.Path) -> None: ... @@ -192,7 +198,7 @@ def __call__( remote_repos_path: pathlib.Path = ..., remote_repo_name: Optional[str] = ..., remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = ..., - init_cmd_args: Optional[list[str]] = ..., + init_cmd_args: InitCmdArgs = ..., ) -> pathlib.Path: ... @@ -201,7 +207,7 @@ def _create_git_remote_repo( remote_repos_path: pathlib.Path, remote_repo_name: str, remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, - init_cmd_args: Optional[list[str]] = None, + init_cmd_args: InitCmdArgs = None, ) -> pathlib.Path: if init_cmd_args is None: init_cmd_args = [] @@ -225,7 +231,7 @@ def fn( remote_repos_path: pathlib.Path = remote_repos_path, remote_repo_name: Optional[str] = None, remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, - init_cmd_args: Optional[list[str]] = None, + init_cmd_args: InitCmdArgs = None, ) -> pathlib.Path: return _create_git_remote_repo( remote_repos_path=remote_repos_path, @@ -262,7 +268,7 @@ def _create_svn_remote_repo( remote_repos_path: pathlib.Path, remote_repo_name: str, remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, - init_cmd_args: Optional[list[str]] = None, + init_cmd_args: InitCmdArgs = None, ) -> pathlib.Path: """Create a test SVN repo to for checkout / commit purposes""" if init_cmd_args is None: @@ -288,7 +294,7 @@ def fn( remote_repos_path: pathlib.Path = remote_repos_path, remote_repo_name: Optional[str] = None, remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, - init_cmd_args: Optional[list[str]] = None, + init_cmd_args: InitCmdArgs = None, ) -> pathlib.Path: return _create_svn_remote_repo( remote_repos_path=remote_repos_path, @@ -320,7 +326,7 @@ def _create_hg_remote_repo( remote_repos_path: pathlib.Path, remote_repo_name: str, remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, - init_cmd_args: Optional[list[str]] = None, + init_cmd_args: InitCmdArgs = None, ) -> pathlib.Path: """Create a test hg repo to for checkout / commit purposes""" if init_cmd_args is None: @@ -355,7 +361,7 @@ def fn( remote_repos_path: pathlib.Path = remote_repos_path, remote_repo_name: Optional[str] = None, remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, - init_cmd_args: Optional[list[str]] = None, + init_cmd_args: InitCmdArgs = None, ) -> pathlib.Path: return _create_hg_remote_repo( remote_repos_path=remote_repos_path, From 21e643e6dbf816c79189481a22cb7fe7c7bee9ff Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 25 Sep 2022 19:14:22 -0500 Subject: [PATCH 37/42] refactor!(pytest_plugin): create_git_remote_repo: Use bare by default --- src/libvcs/pytest_plugin.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py index 45af1c7e3..303171547 100644 --- a/src/libvcs/pytest_plugin.py +++ b/src/libvcs/pytest_plugin.py @@ -207,7 +207,7 @@ def _create_git_remote_repo( remote_repos_path: pathlib.Path, remote_repo_name: str, remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, - init_cmd_args: InitCmdArgs = None, + init_cmd_args: InitCmdArgs = ["--bare"], ) -> pathlib.Path: if init_cmd_args is None: init_cmd_args = [] @@ -231,7 +231,7 @@ def fn( remote_repos_path: pathlib.Path = remote_repos_path, remote_repo_name: Optional[str] = None, remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = None, - init_cmd_args: InitCmdArgs = None, + init_cmd_args: InitCmdArgs = ["--bare"], ) -> pathlib.Path: return _create_git_remote_repo( remote_repos_path=remote_repos_path, @@ -261,6 +261,7 @@ def git_remote_repo(remote_repos_path: pathlib.Path) -> pathlib.Path: remote_repos_path=remote_repos_path, remote_repo_name="dummyrepo", remote_repo_post_init=git_remote_repo_single_commit_post_init, + init_cmd_args=None, # Don't do --bare ) @@ -450,6 +451,7 @@ def add_doctest_fixtures( doctest_namespace["create_git_remote_repo"] = functools.partial( create_git_remote_repo, remote_repo_post_init=git_remote_repo_single_commit_post_init, + init_cmd_args=None, ) doctest_namespace["create_git_remote_repo_bare"] = create_git_remote_repo doctest_namespace["git_local_clone"] = git_repo From ab81dff67f2d3e63f853ddc59babc6a4a5898515 Mon Sep 17 00:00:00 2001 From: Jhon Pedroza Date: Sun, 25 Sep 2022 17:16:32 -0500 Subject: [PATCH 38/42] fix(git): Fix update_repo when there are untracked files --- CHANGES | 1 + src/libvcs/sync/git.py | 4 +-- tests/sync/test_git.py | 65 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 3242cd546..6cba649fa 100644 --- a/CHANGES +++ b/CHANGES @@ -35,6 +35,7 @@ URL renamings (#417): - `URLProtocol`: Fix `is_valid` to use `classmethod` - All: Fix `is_valid` to use default of `None` to avoid implicitly filtering - Reduce duplicated code in methods by using `super()` + - `git`: Fix `update_repo` when there are only untracked files ### Packaging diff --git a/src/libvcs/sync/git.py b/src/libvcs/sync/git.py index c54a70d63..3c019e2ad 100644 --- a/src/libvcs/sync/git.py +++ b/src/libvcs/sync/git.py @@ -408,7 +408,7 @@ def update_repo(self, set_remotes: bool = False, *args: Any, **kwargs: Any) -> N if is_remote_ref: # Check if stash is needed try: - process = self.run(["status", "--porcelain"]) + process = self.run(["status", "--porcelain", "--untracked-files=no"]) except exc.CommandError: self.log.error("Failed to get the status") return @@ -435,7 +435,7 @@ def update_repo(self, set_remotes: bool = False, *args: Any, **kwargs: Any) -> N try: process = self.run(["rebase", git_remote_name + "/" + git_tag]) except exc.CommandError as e: - if "invalid_upstream" in str(e): + if any(msg in str(e) for msg in ["invalid_upstream", "Aborting"]): self.log.error(e) else: # Rebase failed: Restore previous state. diff --git a/tests/sync/test_git.py b/tests/sync/test_git.py index 7c1fb4cbe..fe2597683 100644 --- a/tests/sync/test_git.py +++ b/tests/sync/test_git.py @@ -2,6 +2,7 @@ import datetime import os import pathlib +import random import shutil import textwrap from typing import Callable, TypedDict @@ -141,6 +142,7 @@ def test_repo_update_handle_cases( ) -> None: git_repo: GitSync = constructor(**lazy_constructor_options(**locals())) git_repo.obtain() # clone initial repo + mocka = mocker.spy(git_repo, "run") git_repo.update_repo() @@ -154,6 +156,69 @@ def test_repo_update_handle_cases( assert mocker.call(["symbolic-ref", "--short", "HEAD"]) not in mocka.mock_calls +@pytest.mark.parametrize( + "has_untracked_files,needs_stash,has_remote_changes", + [ + [True, True, True], + [True, True, False], + [True, False, True], + [True, False, False], + [False, True, True], + [False, True, False], + [False, False, True], + [False, False, False], + ], +) +def test_repo_update_stash_cases( + tmp_path: pathlib.Path, + create_git_remote_repo: CreateProjectCallbackFixtureProtocol, + mocker: MockerFixture, + has_untracked_files: bool, + needs_stash: bool, + has_remote_changes: bool, +) -> None: + git_remote_repo = create_git_remote_repo() + + git_repo: GitSync = GitSync( + url=f"file://{git_remote_repo}", + dir=tmp_path / "myrepo", + vcs="git", + ) + git_repo.obtain() # clone initial repo + + def make_file(filename: str) -> pathlib.Path: + some_file = git_repo.dir.joinpath(filename) + with open(some_file, "w") as file: + file.write("some content: " + str(random.random())) + + return some_file + + # Make an initial commit so we can reset + some_file = make_file("initial_file") + git_repo.run(["add", some_file]) + git_repo.run(["commit", "-m", "a commit"]) + git_repo.run(["push"]) + + if has_remote_changes: + some_file = make_file("some_file") + git_repo.run(["add", some_file]) + git_repo.run(["commit", "-m", "a commit"]) + git_repo.run(["push"]) + git_repo.run(["reset", "--hard", "HEAD^"]) + + if has_untracked_files: + make_file("some_file") + + if needs_stash: + some_file = make_file("some_stashed_file") + git_repo.run(["add", some_file]) + + mocka = mocker.spy(git_repo, "run") + git_repo.update_repo() + + mocka.assert_any_call(["symbolic-ref", "--short", "HEAD"]) + + @pytest.mark.parametrize( # Postpone evaluation of options so fixture variables can interpolate "constructor,lazy_constructor_options", From e575dda10cb088c2a03a18643e8eb2f90bc88d2d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 25 Sep 2022 20:45:44 -0500 Subject: [PATCH 39/42] docs(CHANGES): Note #425 and organize / tidy up --- CHANGES | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGES b/CHANGES index 6cba649fa..199295145 100644 --- a/CHANGES +++ b/CHANGES @@ -29,13 +29,17 @@ URL renamings (#417): ### Improvements -- URLs (#423): - - `hg`: Add `HgBaseURL`, `HgPipURL` - - `svn`: Add `SvnBaseURL`, `SvnPipURL` - - `URLProtocol`: Fix `is_valid` to use `classmethod` - - All: Fix `is_valid` to use default of `None` to avoid implicitly filtering - - Reduce duplicated code in methods by using `super()` - - `git`: Fix `update_repo` when there are only untracked files +Sync: + +- `git`: Fix `update_repo` when there are only untracked files (#425, credit: @jfpedroza) + +URLs (#423): + +- `hg`: Add `HgBaseURL`, `HgPipURL` +- `svn`: Add `SvnBaseURL`, `SvnPipURL` +- `URLProtocol`: Fix `is_valid` to use `classmethod` +- All: Fix `is_valid` to use default of `None` to avoid implicitly filtering +- Reduce duplicated code in methods by using `super()` ### Packaging From 6d329bd7511477cf72c41ccb591bef74a909d7ca Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 25 Sep 2022 20:48:11 -0500 Subject: [PATCH 40/42] Tag v0.17.0a1 (includes #425 update_repo fix) --- pyproject.toml | 2 +- src/libvcs/__about__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ef2b65834..db35e0d07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "libvcs" -version = "0.17.0a0" +version = "0.17.0a1" description = "Lite, typed, python utilities for Git, SVN, Mercurial, etc." license = "MIT" authors = ["Tony Narlock "] diff --git a/src/libvcs/__about__.py b/src/libvcs/__about__.py index 94866e464..64d53ef9f 100644 --- a/src/libvcs/__about__.py +++ b/src/libvcs/__about__.py @@ -1,7 +1,7 @@ __title__ = "libvcs" __package_name__ = "libvcs" __description__ = "Lite, typed, python utilities for Git, SVN, Mercurial, etc." -__version__ = "0.17.0a0" +__version__ = "0.17.0a1" __author__ = "Tony Narlock" __github__ = "https://github.com/vcs-python/libvcs" __docs__ = "https://libvcs.git-pull.com" From 54d4d8dc7a113737de99626d7c19add7ca198495 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 25 Sep 2022 20:51:29 -0500 Subject: [PATCH 41/42] docs(CHANGES): Note pytest plugin update --- CHANGES | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 199295145..dbbcd981d 100644 --- a/CHANGES +++ b/CHANGES @@ -29,11 +29,18 @@ URL renamings (#417): ### Improvements +pytest plugin: + +- `create_{git,svn,hg}_remote_repo()` now accepts `init_cmd_args` (`list[str]`, default: + `['--bare']`, #426) + + To not use bare, pass `init_cmd_args=None` + Sync: - `git`: Fix `update_repo` when there are only untracked files (#425, credit: @jfpedroza) -URLs (#423): +URL (#423): - `hg`: Add `HgBaseURL`, `HgPipURL` - `svn`: Add `SvnBaseURL`, `SvnPipURL` From f5c12ccd6a1225bf6421331239abafe7f192488b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 25 Sep 2022 21:09:43 -0500 Subject: [PATCH 42/42] Tag v0.17.0 (URL matching that unblocks vcspull, and more) --- CHANGES | 4 +++- pyproject.toml | 2 +- src/libvcs/__about__.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGES b/CHANGES index dbbcd981d..a0a276146 100644 --- a/CHANGES +++ b/CHANGES @@ -9,10 +9,12 @@ To install the unreleased libvcs version, see $ pip install --user --upgrade --pre libvcs ``` -## libvcs 0.17.x (unreleased) +## libvcs 0.18.x (unreleased) - _Add your latest changes from PRs here_ +## libvcs 0.17.0 (2022-09-25) + ### New features - URLs: Added `registry`, match find which VCS a URL matches with (#420) diff --git a/pyproject.toml b/pyproject.toml index db35e0d07..101092983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "libvcs" -version = "0.17.0a1" +version = "0.17.0" description = "Lite, typed, python utilities for Git, SVN, Mercurial, etc." license = "MIT" authors = ["Tony Narlock "] diff --git a/src/libvcs/__about__.py b/src/libvcs/__about__.py index 64d53ef9f..ff3297c11 100644 --- a/src/libvcs/__about__.py +++ b/src/libvcs/__about__.py @@ -1,7 +1,7 @@ __title__ = "libvcs" __package_name__ = "libvcs" __description__ = "Lite, typed, python utilities for Git, SVN, Mercurial, etc." -__version__ = "0.17.0a1" +__version__ = "0.17.0" __author__ = "Tony Narlock" __github__ = "https://github.com/vcs-python/libvcs" __docs__ = "https://libvcs.git-pull.com"