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/.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: diff --git a/CHANGES b/CHANGES index 0a992cf85..a0a276146 100644 --- a/CHANGES +++ b/CHANGES @@ -9,10 +9,59 @@ 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) +- `create_project`: Learn to guess VCS from URL, if none provided (#420) + +### Breaking changes + +URL renamings (#417): + +- `Matcher` -> `Rule`, `MatcherRegistry` -> `Rules` +- `matches` -> `rule_map` +- `default_patterns` -> `patterns` +- `MATCHERS` -> `RULES` + +### 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) + +URL (#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) +- 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) ### Bug fixes 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/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/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/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: +``` diff --git a/poetry.lock b/poetry.lock index fe4ed5ad5..29a4a0fe5 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 @@ -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 @@ -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 @@ -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.1.1" +version = "5.2.1" 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" @@ -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"}, @@ -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"}, @@ -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"}, @@ -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.1.1-py3-none-any.whl", hash = "sha256:309a8da80cb6da9f4713438e5b55861877d5d7976b69d87e336733637ea12693"}, - {file = "Sphinx-5.1.1.tar.gz", hash = "sha256:ba3224a4e206e1fbdecf98a4fae4992ef9b24b85ebf7b584bb340156eaf08d89"}, + {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"}, diff --git a/pyproject.toml b/pyproject.toml index e9b611082..101092983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "libvcs" -version = "0.16.5" +version = "0.17.0" description = "Lite, typed, python utilities for Git, SVN, Mercurial, etc." license = "MIT" authors = ["Tony Narlock "] @@ -50,6 +50,7 @@ packages = [ ] include = [ { path = "tests", format = "sdist" }, + { path = "conftest.py", format = "sdist" }, ] [tool.poetry.urls] @@ -133,6 +134,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" diff --git a/src/libvcs/__about__.py b/src/libvcs/__about__.py index 17460a0ea..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.16.5" +__version__ = "0.17.0" __author__ = "Tony Narlock" __github__ = "https://github.com/vcs-python/libvcs" __docs__ = "https://libvcs.git-pull.com" 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 diff --git a/src/libvcs/_internal/shortcuts.py b/src/libvcs/_internal/shortcuts.py index f24460f8c..98491cff0 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. @@ -68,9 +72,38 @@ 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 """ + 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": 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/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py index d84891f53..303171547 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,6 +198,7 @@ def __call__( remote_repos_path: pathlib.Path = ..., remote_repo_name: Optional[str] = ..., remote_repo_post_init: Optional[CreateProjectCallbackProtocol] = ..., + init_cmd_args: InitCmdArgs = ..., ) -> pathlib.Path: ... @@ -200,9 +207,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: InitCmdArgs = ["--bare"], ) -> 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 +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 = ["--bare"], ) -> pathlib.Path: return _create_git_remote_repo( remote_repos_path=remote_repos_path, @@ -228,6 +239,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 @@ -249,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 ) @@ -256,11 +269,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: InitCmdArgs = 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 +295,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, ) -> pathlib.Path: return _create_svn_remote_repo( remote_repos_path=remote_repos_path, @@ -286,6 +303,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 +327,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: InitCmdArgs = 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 +362,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, ) -> pathlib.Path: return _create_hg_remote_repo( remote_repos_path=remote_repos_path, @@ -347,6 +370,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 @@ -427,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 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/src/libvcs/url/base.py b/src/libvcs/url/base.py index d79545b75..fb37ccc17 100644 --- a/src/libvcs/url/base.py +++ b/src/libvcs/url/base.py @@ -18,13 +18,14 @@ 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: ... @dataclasses.dataclass(repr=False) -class Matcher(SkipDefaultFieldsReprMixin): - """Structure for a matcher""" +class Rule(SkipDefaultFieldsReprMixin): + """A Rule represents an eligible pattern mapping to URL.""" label: str """Computer readable name / ID""" @@ -32,18 +33,18 @@ class Matcher(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 @dataclasses.dataclass(repr=False) -class MatcherRegistry(SkipDefaultFieldsReprMixin): +class RuleMap(SkipDefaultFieldsReprMixin): """Pattern matching and parsing capabilities for URL parsers, e.g. GitURL""" - _matchers: dict[str, Matcher] = dataclasses.field(default_factory=dict) + _rule_map: 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 +73,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,11 +85,11 @@ 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.*)$' - ... pattern_defaults = { + ... defaults = { ... 'hostname': 'github.com', ... 'scheme': 'https' ... } @@ -97,8 +98,8 @@ def register(self, cls: Matcher) -> None: >>> @dataclasses.dataclass(repr=False) ... class GitHubURL(GitURL): - ... matchers: MatcherRegistry = MatcherRegistry( - ... _matchers={'github_prefix': GitHubPrefix} + ... rule_map: RuleMap = RuleMap( + ... _rule_map={'github_prefix': GitHubPrefix} ... ) >>> GitHubURL.is_valid(url='github:vcs-python/libvcs') @@ -107,14 +108,14 @@ def register(self, cls: Matcher) -> 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, 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 +123,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,25 +131,25 @@ 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)' - ... pattern_defaults = { + ... defaults = { ... 'hostname': 'gitlab.com', ... 'scheme': 'https', ... '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} + ... rule_map: RuleMap = RuleMap( + ... _rule_map={'gitlab_prefix': GitLabPrefix} ... ) >>> GitLabURL.is_valid(url='gitlab:vcs-python/libvcs') @@ -161,30 +162,30 @@ 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.rule_map.register(GitLabPrefix) >>> GitURL.is_valid(url='gitlab:vcs-python/libvcs') True **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): - ... matchers: MatcherRegistry = MatcherRegistry( - ... _matchers={m.label: m for m in [*DEFAULT_MATCHERS, *PIP_DEFAULT_MATCHERS]} + ... rule_map: RuleMap = RuleMap( + ... _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") @@ -197,19 +198,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._rule_map: + self._rule_map[cls.label] = cls def unregister(self, label: str) -> None: - if label in self._matchers: - del self._matchers[label] + if label in self._rule_map: + del self._rule_map[label] def __iter__(self) -> Iterator[str]: - return self._matchers.__iter__() + return self._rule_map.__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._rule_map.values() diff --git a/src/libvcs/url/git.py b/src/libvcs/url/git.py index 76636cda0..df87386a9 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.RuleMap`, + :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, RuleMap, URLProtocol # Credit, pip (license: MIT): # https://github.com/pypa/pip/blob/22.1.2/src/pip/_internal/vcs/git.py#L39-L52 @@ -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'. @@ -60,8 +61,8 @@ # Some https repos have .git at the end, e.g. https://github.com/org/repo.git -DEFAULT_MATCHERS: list[Matcher] = [ - Matcher( +DEFAULT_RULES: list[Rule] = [ + Rule( label="core-git-https", description="Vanilla git pattern, URL ending with optional .git suffix", pattern=re.compile( @@ -76,7 +77,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( @@ -87,7 +88,7 @@ """, re.VERBOSE, ), - pattern_defaults={"username": "git"}, + defaults={"username": "git"}, ), # SCP-style URLs, e.g. git@ ] @@ -125,8 +126,8 @@ """ -PIP_DEFAULT_MATCHERS: list[Matcher] = [ - Matcher( +PIP_DEFAULT_RULES: list[Rule] = [ + Rule( label="pip-url", description="pip-style git URL", pattern=re.compile( @@ -141,7 +142,7 @@ ), is_explicit=True, ), - Matcher( + Rule( label="pip-scp-url", description="pip-style git ssh/scp URL", pattern=re.compile( @@ -156,7 +157,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 +192,7 @@ - https://pip.pypa.io/en/stable/topics/vcs-support/ """ # NOQA: E501 -NPM_DEFAULT_MATCHERS: list[Matcher] = [] +NPM_DEFAULT_RULES: list[Rule] = [] """NPM-style git URLs. Git URL pattern (from docs.npmjs.com):: @@ -224,7 +225,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 +241,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 +262,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 + rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in DEFAULT_RULES}) def __post_init__(self) -> None: url = self.url - for matcher in self.matchers.values(): - match = re.match(matcher.pattern, url) + for rule in self.rule_map.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.defaults.items(): if getattr(self, k, None) is None: - setattr(self, k, matcher.pattern_defaults[k]) + setattr(self, k, rule.defaults[k]) @classmethod def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: @@ -312,11 +311,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.rule_map.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.rule_map.values()) def to_url(self) -> str: """Return a ``git(1)``-compatible URL. Can be used with ``git clone``. @@ -332,7 +331,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 +371,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} - ) + 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. @@ -394,7 +391,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 +410,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 +453,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 rule_map can unambigously narrow the type of VCS: >>> GitPipURL.is_valid( @@ -482,13 +479,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]} + 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 Git URL matchers or not. + r"""Whether URL is compatible included Git URL rule_map or not. Examples -------- @@ -514,7 +511,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 rule_map can unambigously narrow the type of VCS: >>> GitURL.is_valid( @@ -530,12 +527,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''' @@ -548,26 +545,26 @@ def is_valid(cls, url: str, is_explicit: Optional[bool] = None) -> bool: ... re.VERBOSE, ... ), ... is_explicit=True, - ... pattern_defaults={ + ... defaults={ ... 'hostname': 'github.com' ... } ... ) - >>> GitURL.matchers.register(GitHubMatcher) + >>> GitURL.rule_map.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.rule_map.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) @@ -617,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 412f1cee6..9e4266803 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.RuleMap`, + :class:`~libvcs.url.base.Rule` .. Note:: @@ -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 Matcher, MatcherRegistry, URLProtocol +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 ( @@ -43,8 +45,8 @@ ) """ -DEFAULT_MATCHERS: list[Matcher] = [ - Matcher( +DEFAULT_RULES: list[Rule] = [ + Rule( label="core-hg", description="Vanilla hg pattern", pattern=re.compile( @@ -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)``""" @@ -74,21 +92,24 @@ ) """ -PIP_DEFAULT_MATCHERS: list[Matcher] = [ - Matcher( +PIP_DEFAULT_RULES: list[Rule] = [ + Rule( label="pip-url", 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 - Matcher( + Rule( label="pip-file-url", description="pip-style hg+file:// URL", pattern=re.compile( @@ -98,6 +119,7 @@ """, re.VERBOSE, ), + is_explicit=True, ), ] """pip-style hg URLs. @@ -118,28 +140,27 @@ 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 ---------- - matcher : str - name of the :class:`~libvcs.url.base.Matcher` + rule : str + name of the :class:`~libvcs.url.base.Rule` 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/, - matcher=core-hg) + rule=core-hg) >>> myrepo = HgURL(url='https://hg.mozilla.org/mozilla-central/') @@ -149,13 +170,16 @@ 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, 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()` @@ -169,51 +193,59 @@ 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 # 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` + + rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in DEFAULT_RULES}) def __post_init__(self) -> None: url = self.url - for matcher in self.matchers.values(): - match = re.match(matcher.pattern, url) + for rule in self.rule_map.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.defaults.items(): if getattr(self, k, None) is None: - setattr(self, k, matcher.pattern_defaults[k]) + 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(matcher.pattern, url) for matcher in cls.matchers.values()) + 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: """Return a ``hg(1)``-compatible URL. Can be used with ``hg clone``. @@ -221,14 +253,14 @@ 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, - matcher=core-hg) + rule=core-hg) Switch repo libvcs -> vcspull: @@ -247,32 +279,33 @@ 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, path=hgroot/hugin/hugin, - matcher=core-hg) + rule=core-hg) >>> hugin.to_url() 'http://hugin.hg.sourceforge.net:8000/hgroot/hugin/hugin' 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, path=/hg/GraphicsMagick, - matcher=core-hg) + rule=core-hg) >>> graphicsmagick.to_url() 'ssh://yourid@hg.GraphicsMagick.org//hg/GraphicsMagick' @@ -284,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: @@ -297,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/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/src/libvcs/url/svn.py b/src/libvcs/url/svn.py index ddafb3a8d..0b56c6a2f 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.RuleMap`, + :class:`~libvcs.url.base.Rule` .. Note:: @@ -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 Matcher, MatcherRegistry, URLProtocol +from .base import Rule, RuleMap, URLProtocol RE_PATH = r""" - ((?P.*)@)? - (?P([^/:]+)) + ((?P[^/:@]+)@)? + (?P([^/:@]+)) (:(?P\d{1,5}))? - (?P/)? + (?P[:,/])? (?P - (\w[^:.]*) + (\w[^:.@]*) )? """ @@ -47,8 +48,8 @@ ) """ -DEFAULT_MATCHERS: list[Matcher] = [ - Matcher( +DEFAULT_RULES: list[Rule] = [ + Rule( label="core-svn", description="Vanilla svn pattern", pattern=re.compile( @@ -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,27 +88,28 @@ ( svn\+ssh| svn\+https| - svn\+http| - svn\+file + svn\+http ) ) """ -PIP_DEFAULT_MATCHERS: list[Matcher] = [ - Matcher( +PIP_DEFAULT_RULES: list[Rule] = [ + Rule( label="pip-url", 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 - Matcher( + Rule( label="pip-file-url", description="pip-style svn+file:// URL", pattern=re.compile( @@ -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, - matcher=core-svn) + rule=core-svn) - >>> myrepo = SvnURL( + >>> myrepo = SvnBaseURL( ... url='svn+ssh://svn.debian.org/svn/aliothproj/path/in/project/repository' ... ) @@ -147,13 +166,13 @@ 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 ---------- - matcher : str - name of the :class:`~libvcs.url.base.Matcher` + rule : str + name of the :class:`~libvcs.url.base.Rule` """ url: str @@ -169,42 +188,46 @@ 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 + rule_map: RuleMap = RuleMap(_rule_map={m.label: m for m in DEFAULT_RULES}) def __post_init__(self) -> None: url = self.url - for matcher in self.matchers.values(): - match = re.match(matcher.pattern, url) + for rule in self.rule_map.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.defaults.items(): if getattr(self, k, None) is None: - setattr(self, k, matcher.pattern_defaults[k]) + 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 """ - return any(re.search(matcher.pattern, url) for matcher in cls.matchers.values()) + 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: """Return a ``svn(1)``-compatible URL. Can be used with ``svn checkout``. @@ -212,17 +235,17 @@ 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, path=vcs-python/libvcs, - matcher=core-svn) + rule=core-svn) Switch repo libvcs -> vcspull: @@ -238,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}"]) @@ -250,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/_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"] 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", diff --git a/tests/url/test_git.py b/tests/url/test_git.py index 76102863d..ebf1397a8 100644 --- a/tests/url/test_git.py +++ b/tests/url/test_git.py @@ -3,8 +3,8 @@ import pytest from libvcs.sync.git import GitSync -from libvcs.url.base import MatcherRegistry -from libvcs.url.git import DEFAULT_MATCHERS, PIP_DEFAULT_MATCHERS, GitBaseURL, GitURL +from libvcs.url.base import RuleMap +from libvcs.url.git import DEFAULT_RULES, PIP_DEFAULT_RULES, GitBaseURL, GitURL class GitURLFixture(typing.NamedTuple): @@ -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]} + rule_map: RuleMap = RuleMap( + _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) @@ -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]} + rule_map: RuleMap = RuleMap( + _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 283a4e1a5..0273ca62f 100644 --- a/tests/url/test_hg.py +++ b/tests/url/test_hg.py @@ -3,8 +3,8 @@ import pytest from libvcs.sync.hg import HgSync -from libvcs.url.base import MatcherRegistry -from libvcs.url.hg import DEFAULT_MATCHERS, PIP_DEFAULT_MATCHERS, HgURL +from libvcs.url.base import RuleMap +from libvcs.url.hg import DEFAULT_RULES, PIP_DEFAULT_RULES, HgBaseURL, HgURL class HgURLFixture(typing.NamedTuple): @@ -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]} + rule_map: RuleMap = RuleMap( + _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) @@ -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_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 diff --git a/tests/url/test_svn.py b/tests/url/test_svn.py index 0a607c7a6..151e72324 100644 --- a/tests/url/test_svn.py +++ b/tests/url/test_svn.py @@ -3,8 +3,8 @@ import pytest from libvcs.sync.svn import SvnSync -from libvcs.url.base import MatcherRegistry -from libvcs.url.svn import DEFAULT_MATCHERS, PIP_DEFAULT_MATCHERS, SvnURL +from libvcs.url.base import RuleMap +from libvcs.url.svn import DEFAULT_RULES, PIP_DEFAULT_RULES, SvnBaseURL, SvnURL class SvnURLFixture(typing.NamedTuple): @@ -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]} + rule_map: RuleMap = RuleMap( + _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) @@ -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