diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2341f6dad..5da69bd96 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,24 +1,12 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# name: "CodeQL" on: push: branches: [ master ] pull_request: - # The branches below must be a subset of the branches above branches: [ master ] schedule: - - cron: '21 7 * * 4' + - cron: '16 5 * * 2' jobs: analyze: @@ -29,42 +17,17 @@ jobs: contents: read security-events: write - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://git.io/codeql-language-support - steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main + languages: "python" - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release + uses: github/codeql-action/autobuild@v2 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 335326e03..81b772ffa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -52,6 +52,27 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} + release: + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + + strategy: + matrix: + python-version: ["3.10"] + + steps: + - uses: actions/checkout@v3 + + - name: Install poetry + run: pipx install "poetry==1.1.15" + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'poetry' + - name: Build package if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') run: poetry build diff --git a/CHANGES b/CHANGES index 35e6ceb1e..79b55863b 100644 --- a/CHANGES +++ b/CHANGES @@ -13,6 +13,30 @@ $ pip install --user --upgrade --pre libvcs - _Add your latest changes from PRs here_ +## libvcs 0.16.3 (2022-09-18) + +### Bug fixes + +- `QueryList`: Fix lookups of objects (#415) + +### Tests + +- Basic pytest plugin test (#413) +- Add test for object based lookups (#414) + +### Documentation + +- Improve doc examples / tests for `keygetter` and `QueryList` to show deep lookups for objects + (#415) + +### Infrastructure + +- CI speedups (#416) + + - Avoid fetching unused apt package + - Split out release to separate job so the PyPI Upload docker image isn't pulled on normal runs + - Clean up CodeQL + ## libvcs 0.16.2 (2022-09-11) ### Bug fix diff --git a/poetry.lock b/poetry.lock index 8ba9cfce8..bddb1c833 100644 --- a/poetry.lock +++ b/poetry.lock @@ -70,7 +70,7 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2022.6.15.1" +version = "2022.9.14" description = "Python package for providing Mozilla's CA Bundle." category = "dev" optional = false @@ -181,7 +181,7 @@ flake8 = ">=3.0,<3.2.0 || >3.2.0" [[package]] name = "furo" -version = "2022.6.21" +version = "2022.9.15" description = "A clean customisable Sphinx documentation theme." category = "dev" optional = false @@ -189,7 +189,7 @@ python-versions = ">=3.7" [package.dependencies] beautifulsoup4 = "*" -pygments = "*" +pygments = ">=2.7" sphinx = ">=4.0,<6.0" sphinx-basic-ng = "*" @@ -207,7 +207,7 @@ myst_parser = "*" [[package]] name = "idna" -version = "3.3" +version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" category = "dev" optional = false @@ -936,8 +936,8 @@ black = [ {file = "black-22.8.0.tar.gz", hash = "sha256:792f7eb540ba9a17e8656538701d3eb1afcb134e3b45b71f20b25c77a8db7e6e"}, ] certifi = [ - {file = "certifi-2022.6.15.1-py3-none-any.whl", hash = "sha256:43dadad18a7f168740e66944e4fa82c6611848ff9056ad910f8f7a3e46ab89e0"}, - {file = "certifi-2022.6.15.1.tar.gz", hash = "sha256:cffdcd380919da6137f76633531a5817e3a9f268575c128249fb637e4f9e73fb"}, + {file = "certifi-2022.9.14-py3-none-any.whl", hash = "sha256:e232343de1ab72c2aa521b625c80f699e356830fd0e2c620b465b304b17b0516"}, + {file = "certifi-2022.9.14.tar.gz", hash = "sha256:36973885b9542e6bd01dea287b2b4b3b21236307c56324fcc3f1160f2d655ed5"}, ] charset-normalizer = [ {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, @@ -1025,16 +1025,16 @@ flake8-comprehensions = [ {file = "flake8_comprehensions-3.10.0-py3-none-any.whl", hash = "sha256:dad454fd3d525039121e98fa1dd90c46bc138708196a4ebbc949ad3c859adedb"}, ] furo = [ - {file = "furo-2022.6.21-py3-none-any.whl", hash = "sha256:061b68e323345e27fcba024cf33a1e77f3dfd8d9987410be822749a706e2add6"}, - {file = "furo-2022.6.21.tar.gz", hash = "sha256:9aa983b7488a4601d13113884bfb7254502c8729942e073a0acb87a5512af223"}, + {file = "furo-2022.9.15-py3-none-any.whl", hash = "sha256:9129dead1f75e9fb4fa407612f1d5a0d0320767e6156c925aafe36f362f9b11a"}, + {file = "furo-2022.9.15.tar.gz", hash = "sha256:4a7ef1c8a3b615171592da4d2ad8a53cc4aacfbe111958890f5f9ff7279066ab"}, ] gp-libs = [ {file = "gp-libs-0.0.1a10.tar.gz", hash = "sha256:54fbe07f42628b114f0b1472bb03ce75be2928090ec26d8d6f675f3bd9f58c2e"}, {file = "gp_libs-0.0.1a10-py3-none-any.whl", hash = "sha256:701b190ffd468ca4d776b196707344748dd550aea03db9aaa1ffdecdd0c32506"}, ] idna = [ - {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, - {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] imagesize = [ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, diff --git a/pyproject.toml b/pyproject.toml index bf493f3ba..59bee713f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "libvcs" -version = "0.16.2" +version = "0.16.3" description = "Lite, typed, python utilities for Git, SVN, Mercurial, etc." license = "MIT" authors = ["Tony Narlock "] diff --git a/src/libvcs/__about__.py b/src/libvcs/__about__.py index f3c6ec9fa..0dfb32601 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.2" +__version__ = "0.16.3" __author__ = "Tony Narlock" __github__ = "https://github.com/vcs-python/libvcs" __docs__ = "https://libvcs.git-pull.com" diff --git a/src/libvcs/_internal/query_list.py b/src/libvcs/_internal/query_list.py index c8f54ddee..214dfadc0 100644 --- a/src/libvcs/_internal/query_list.py +++ b/src/libvcs/_internal/query_list.py @@ -17,22 +17,68 @@ def keygetter( obj: Mapping[str, Any], path: str, ) -> Union[None, Any, str, list[str], Mapping[str, str]]: - """obj, "foods__breakfast", obj['foods']['breakfast'] + """Fetch values in objects and keys, deeply. - >>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods__breakfast") - 'cereal' - >>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods") + **With dictionaries**: + + >>> keygetter({ "menu": { "breakfast": "cereal" } }, "menu") {'breakfast': 'cereal'} + >>> keygetter({ "menu": { "breakfast": "cereal" } }, "menu__breakfast") + 'cereal' + + **With objects**: + + >>> from typing import Optional + >>> from dataclasses import dataclass, field + + >>> @dataclass() + ... class Menu: + ... fruit: list[str] = field(default_factory=list) + ... breakfast: Optional[str] = None + + + >>> @dataclass() + ... class Restaurant: + ... place: str + ... city: str + ... state: str + ... menu: Menu = field(default_factory=Menu) + + + >>> restaurant = Restaurant( + ... place="Largo", + ... city="Tampa", + ... state="Florida", + ... menu=Menu( + ... fruit=["banana", "orange"], breakfast="cereal" + ... ) + ... ) + + >>> restaurant + Restaurant(place='Largo', + city='Tampa', + state='Florida', + menu=Menu(fruit=['banana', 'orange'], breakfast='cereal')) + + >>> keygetter(restaurant, "menu") + Menu(fruit=['banana', 'orange'], breakfast='cereal') + + >>> keygetter(restaurant, "menu__breakfast") + 'cereal' """ try: sub_fields = path.split("__") dct = obj for sub_field in sub_fields: - dct = dct[sub_field] + if isinstance(dct, dict): + dct = dct[sub_field] + elif hasattr(dct, sub_field): + dct = getattr(dct, sub_field) return dct - except Exception: + except Exception as e: traceback.print_stack() + print(f"Above error was {e}") return None @@ -41,10 +87,24 @@ def parse_lookup(obj: Mapping[str, Any], path: str, lookup: str) -> Optional[Any If comparator not used or value not found, return None. - mykey__endswith("mykey") -> "mykey" else None - >>> parse_lookup({ "food": "red apple" }, "food__istartswith", "__istartswith") 'red apple' + + It can also look up objects: + + >>> from dataclasses import dataclass + + >>> @dataclass() + ... class Inventory: + ... food: str + + >>> item = Inventory(food="red apple") + + >>> item + Inventory(food='red apple') + + >>> parse_lookup(item, "food__istartswith", "__istartswith") + 'red apple' """ try: if isinstance(path, str) and isinstance(lookup, str) and path.endswith(lookup): @@ -258,6 +318,89 @@ class QueryList(list[T]): 'Elmhurst' >>> query.filter(foods__fruit__in="orange")[0]['city'] 'Tampa' + + Examples of object lookups: + + >>> from typing import Any + >>> from dataclasses import dataclass, field + + >>> @dataclass() + ... class Restaurant: + ... place: str + ... city: str + ... state: str + ... foods: dict[str, Any] + + >>> restaurant = Restaurant( + ... place="Largo", + ... city="Tampa", + ... state="Florida", + ... foods={ + ... "fruit": ["banana", "orange"], "breakfast": "cereal" + ... } + ... ) + + >>> restaurant + Restaurant(place='Largo', + city='Tampa', + state='Florida', + foods={'fruit': ['banana', 'orange'], 'breakfast': 'cereal'}) + + >>> query = QueryList([restaurant]) + + >>> query.filter(foods__fruit__in="banana") + [Restaurant(place='Largo', + city='Tampa', + state='Florida', + foods={'fruit': ['banana', 'orange'], 'breakfast': 'cereal'})] + + >>> query.filter(foods__fruit__in="banana")[0].city + 'Tampa' + + Example of deeper object lookups: + + >>> from typing import Optional + >>> from dataclasses import dataclass, field + + >>> @dataclass() + ... class Menu: + ... fruit: list[str] = field(default_factory=list) + ... breakfast: Optional[str] = None + + + >>> @dataclass() + ... class Restaurant: + ... place: str + ... city: str + ... state: str + ... menu: Menu = field(default_factory=Menu) + + + >>> restaurant = Restaurant( + ... place="Largo", + ... city="Tampa", + ... state="Florida", + ... menu=Menu( + ... fruit=["banana", "orange"], breakfast="cereal" + ... ) + ... ) + + >>> restaurant + Restaurant(place='Largo', + city='Tampa', + state='Florida', + menu=Menu(fruit=['banana', 'orange'], breakfast='cereal')) + + >>> query = QueryList([restaurant]) + + >>> query.filter(menu__fruit__in="banana") + [Restaurant(place='Largo', + city='Tampa', + state='Florida', + menu=Menu(fruit=['banana', 'orange'], breakfast='cereal'))] + + >>> query.filter(menu__fruit__in="banana")[0].city + 'Tampa' """ data: Sequence[T] diff --git a/tests/_internal/test_query_list.py b/tests/_internal/test_query_list.py index 2ec9ea97f..552b360e9 100644 --- a/tests/_internal/test_query_list.py +++ b/tests/_internal/test_query_list.py @@ -1,3 +1,4 @@ +import dataclasses from typing import Any, Optional, Union import pytest @@ -5,9 +6,23 @@ from libvcs._internal.query_list import QueryList +@dataclasses.dataclass +class Obj: + test: int + fruit: list[str] = dataclasses.field(default_factory=list) + + @pytest.mark.parametrize( "items,filter_expr,expected_result", [ + [[Obj(test=1)], None, [Obj(test=1)]], + [[Obj(test=1)], dict(test=1), [Obj(test=1)]], + [[Obj(test=1)], dict(test=2), []], + [ + [Obj(test=2, fruit=["apple"])], + dict(fruit__in="apple"), + QueryList([Obj(test=2, fruit=["apple"])]), + ], [[{"test": 1}], None, [{"test": 1}]], [[{"test": 1}], None, QueryList([{"test": 1}])], [[{"fruit": "apple"}], None, QueryList([{"fruit": "apple"}])], diff --git a/tests/conftest.py b/tests/conftest.py index 08fea4da3..66bc15aca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1,3 @@ from libvcs.conftest import * # NOQA: F4 + +pytest_plugins = ["pytester"] diff --git a/tests/sync/test_pytest_plugin.py b/tests/sync/test_pytest_plugin.py deleted file mode 100644 index 284cf2f6f..000000000 --- a/tests/sync/test_pytest_plugin.py +++ /dev/null @@ -1,30 +0,0 @@ -import pathlib -import shutil - -import pytest - -from libvcs.pytest_plugin import CreateProjectCallbackFixtureProtocol - - -@pytest.mark.skipif(not shutil.which("git"), reason="git is not available") -def test_create_git_remote_repo( - create_git_remote_repo: CreateProjectCallbackFixtureProtocol, - tmp_path: pathlib.Path, - projects_path: pathlib.Path, -) -> None: - git_remote_1 = create_git_remote_repo() - git_remote_2 = create_git_remote_repo() - - assert git_remote_1 != git_remote_2 - - -@pytest.mark.skipif(not shutil.which("svn"), reason="svn is not available") -def test_create_svn_remote_repo( - create_svn_remote_repo: CreateProjectCallbackFixtureProtocol, - tmp_path: pathlib.Path, - projects_path: pathlib.Path, -) -> None: - svn_remote_1 = create_svn_remote_repo() - svn_remote_2 = create_svn_remote_repo() - - assert svn_remote_1 != svn_remote_2 diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py new file mode 100644 index 000000000..467a5c489 --- /dev/null +++ b/tests/test_pytest_plugin.py @@ -0,0 +1,109 @@ +import pathlib +import shutil +import textwrap + +import pytest + +import _pytest.pytester + +from libvcs.pytest_plugin import CreateProjectCallbackFixtureProtocol + + +@pytest.mark.skipif(not shutil.which("git"), reason="git is not available") +def test_create_git_remote_repo( + create_git_remote_repo: CreateProjectCallbackFixtureProtocol, + tmp_path: pathlib.Path, + projects_path: pathlib.Path, +) -> None: + git_remote_1 = create_git_remote_repo() + git_remote_2 = create_git_remote_repo() + + assert git_remote_1 != git_remote_2 + + +@pytest.mark.skipif(not shutil.which("svn"), reason="svn is not available") +def test_create_svn_remote_repo( + create_svn_remote_repo: CreateProjectCallbackFixtureProtocol, + tmp_path: pathlib.Path, + projects_path: pathlib.Path, +) -> None: + svn_remote_1 = create_svn_remote_repo() + svn_remote_2 = create_svn_remote_repo() + + assert svn_remote_1 != svn_remote_2 + + +def test_plugin( + pytester: _pytest.pytester.Pytester, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Initialize variables + pytester.plugins = ["pytest_plugin"] + pytester.makefile( + ".ini", + pytest=textwrap.dedent( + """ +[pytest] +addopts=-vv + """.strip() + ), + ) + pytester.makeconftest( + textwrap.dedent( + r""" +import pathlib +import pytest + +@pytest.fixture(autouse=True) +def setup( + request: pytest.FixtureRequest, + gitconfig: pathlib.Path, + set_home: pathlib.Path, +) -> None: + pass + """ + ) + ) + tests_path = pytester.path / "tests" + files = { + "example.py": textwrap.dedent( + """ +import pathlib + +from libvcs.sync.git import GitSync +from libvcs.pytest_plugin import CreateProjectCallbackFixtureProtocol + +def test_repo_git_remote_checkout( + create_git_remote_repo: CreateProjectCallbackFixtureProtocol, + tmp_path: pathlib.Path, + projects_path: pathlib.Path, +) -> None: + git_server = create_git_remote_repo() + git_repo_checkout_dir = projects_path / "my_git_checkout" + git_repo = GitSync(dir=str(git_repo_checkout_dir), url=f"file://{git_server!s}") + + git_repo.obtain() + git_repo.update_repo() + + assert git_repo.get_revision() == "initial" + + assert git_repo_checkout_dir.exists() + assert pathlib.Path(git_repo_checkout_dir / ".git").exists() + """ + ) + } + first_test_key = list(files.keys())[0] + first_test_filename = str(tests_path / first_test_key) + + # Setup: Files + tests_path.mkdir() + for file_name, text in files.items(): + test_file = tests_path / file_name + test_file.write_text( + text, + encoding="utf-8", + ) + + # Test + result = pytester.runpytest(str(first_test_filename)) + result.assert_outcomes(passed=1)