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) 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: ... diff --git a/src/libvcs/url/git.py b/src/libvcs/url/git.py index 470ea7b45..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'. @@ -613,12 +614,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() diff --git a/src/libvcs/url/hg.py b/src/libvcs/url/hg.py index 74917b2d9..9e4266803 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,23 +222,29 @@ 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 """ + 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: @@ -219,10 +253,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 +279,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 +295,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 +317,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 +407,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/src/libvcs/url/svn.py b/src/libvcs/url/svn.py index 1e32a8772..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 ---------- @@ -188,20 +207,26 @@ 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 -------- - >>> 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: + 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: @@ -210,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, @@ -236,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}"]) @@ -248,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_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 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