diff --git a/pygit2/index.py b/pygit2/index.py index 0a19a3e15..7c04166fd 100644 --- a/pygit2/index.py +++ b/pygit2/index.py @@ -78,7 +78,7 @@ def __contains__(self, path): def __getitem__(self, key): centry = ffi.NULL - if isinstance(key, str): + if isinstance(key, str) or hasattr(key, '__fspath__'): centry = C.git_index_get_bypath(self._index, to_bytes(key), 0) elif isinstance(key, int): if key >= 0: @@ -200,13 +200,13 @@ def add(self, path_or_entry): Index without checking for the existence of the path or id. """ - if isinstance(path_or_entry, str): - path = path_or_entry - err = C.git_index_add_bypath(self._index, to_bytes(path)) - elif isinstance(path_or_entry, IndexEntry): + if isinstance(path_or_entry, IndexEntry): entry = path_or_entry centry, str_ref = entry._to_c() err = C.git_index_add(self._index, centry) + elif isinstance(path_or_entry, str) or hasattr(path_or_entry, '__fspath__'): + path = path_or_entry + err = C.git_index_add_bypath(self._index, to_bytes(path)) else: raise AttributeError('argument must be string or IndexEntry') diff --git a/pygit2/repository.py b/pygit2/repository.py index 697cf905d..395455c5c 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -1280,6 +1280,9 @@ def compress(self): class Repository(BaseRepository): def __init__(self, path, *args, **kwargs): + if hasattr(path, "__fspath__"): + path = path.__fspath__() + if not isinstance(path, str): path = path.decode('utf-8') diff --git a/pygit2/utils.py b/pygit2/utils.py index 534698703..3ef9b1ee3 100644 --- a/pygit2/utils.py +++ b/pygit2/utils.py @@ -23,6 +23,8 @@ # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +import os + # Import from pygit2 from .ffi import ffi @@ -31,6 +33,9 @@ def to_bytes(s, encoding='utf-8', errors='strict'): if s == ffi.NULL or s is None: return ffi.NULL + if hasattr(s, '__fspath__'): + s = os.fspath(s) + if isinstance(s, bytes): return s @@ -38,6 +43,9 @@ def to_bytes(s, encoding='utf-8', errors='strict'): def to_str(s): + if hasattr(s, '__fspath__'): + s = os.fspath(s) + if type(s) is str: return s @@ -76,10 +84,11 @@ def __init__(self, l): strings = [None] * len(l) for i in range(len(l)): - if not isinstance(l[i], str): - raise TypeError("Value must be a string") + li = l[i] + if not isinstance(li, str) and not hasattr(li, '__fspath__'): + raise TypeError("Value must be a string or PathLike object") - strings[i] = ffi.new('char []', to_bytes(l[i])) + strings[i] = ffi.new('char []', to_bytes(li)) self._arr = ffi.new('char *[]', strings) self._strings = strings diff --git a/src/pygit2.c b/src/pygit2.c index 9ec828679..554c60229 100644 --- a/src/pygit2.c +++ b/src/pygit2.c @@ -85,17 +85,28 @@ PyObject * discover_repository(PyObject *self, PyObject *args) { git_buf repo_path = {NULL}; - const char *path; - PyObject *py_repo_path; + const char *path = NULL; + PyBytesObject *py_path = NULL; int across_fs = 0; + PyBytesObject *py_ceiling_dirs = NULL; const char *ceiling_dirs = NULL; + PyObject *py_repo_path = NULL; int err; - if (!PyArg_ParseTuple(args, "s|Is", &path, &across_fs, &ceiling_dirs)) + if (!PyArg_ParseTuple(args, "O&|IO&", PyUnicode_FSConverter, &py_path, &across_fs, PyUnicode_FSConverter, &py_ceiling_dirs)) return NULL; + if (py_path != NULL) + path = PyBytes_AS_STRING(py_path); + if (py_ceiling_dirs != NULL) + ceiling_dirs = PyBytes_AS_STRING(py_ceiling_dirs); + memset(&repo_path, 0, sizeof(git_buf)); err = git_repository_discover(&repo_path, path, across_fs, ceiling_dirs); + + Py_XDECREF(py_path); + Py_XDECREF(py_ceiling_dirs); + if (err == GIT_ENOTFOUND) Py_RETURN_NONE; if (err < 0) @@ -116,13 +127,18 @@ PyObject * hashfile(PyObject *self, PyObject *args) { git_oid oid; - const char* path; + PyBytesObject *py_path = NULL; + const char* path = NULL; int err; - if (!PyArg_ParseTuple(args, "s", &path)) + if (!PyArg_ParseTuple(args, "O&", PyUnicode_FSConverter, &py_path)) return NULL; + if (py_path != NULL) + path = PyBytes_AS_STRING(py_path); + err = git_odb_hashfile(&oid, path, GIT_OBJ_BLOB); + Py_XDECREF(py_path); if (err < 0) return Error_set(err); @@ -161,14 +177,18 @@ PyDoc_STRVAR(init_file_backend__doc__, PyObject * init_file_backend(PyObject *self, PyObject *args) { + PyBytesObject *py_path = NULL; const char* path = NULL; int err = GIT_OK; git_repository *repository = NULL; - if (!PyArg_ParseTuple(args, "s", &path)) { + if (!PyArg_ParseTuple(args, "O&", PyUnicode_FSConverter, &py_path)) { return NULL; } + if (py_path != NULL) + path = PyBytes_AS_STRING(py_path); err = git_repository_open(&repository, path); + Py_XDECREF(py_path); if (err < 0) { Error_set_str(err, path); goto cleanup; diff --git a/src/repository.c b/src/repository.c index b0f8c2116..b8d781d70 100755 --- a/src/repository.c +++ b/src/repository.c @@ -724,13 +724,18 @@ PyObject * Repository_create_blob_fromworkdir(Repository *self, PyObject *args) { git_oid oid; - const char* path; + PyBytesObject *py_path = NULL; + const char* path = NULL; int err; - if (!PyArg_ParseTuple(args, "s", &path)) + if (!PyArg_ParseTuple(args, "O&", PyUnicode_FSConverter, &py_path)) return NULL; + if (py_path != NULL) + path = PyBytes_AS_STRING(py_path); + err = git_blob_create_fromworkdir(&oid, self->repo, path); + Py_XDECREF(py_path); if (err < 0) return Error_set(err); @@ -747,13 +752,18 @@ PyObject * Repository_create_blob_fromdisk(Repository *self, PyObject *args) { git_oid oid; - const char* path; + PyBytesObject *py_path = NULL; + const char* path = NULL; int err; - if (!PyArg_ParseTuple(args, "s", &path)) + if (!PyArg_ParseTuple(args, "O&", PyUnicode_FSConverter, &py_path)) return NULL; + if (py_path != NULL) + path = PyBytes_AS_STRING(py_path); + err = git_blob_create_fromdisk(&oid, self->repo, path); + Py_XDECREF(py_path); if (err < 0) return Error_set(err); @@ -1757,20 +1767,25 @@ PyObject * Repository_add_worktree(Repository *self, PyObject *args) { char *c_name; - char *c_path; + PyBytesObject *py_path = NULL; + char *c_path = NULL; Reference *py_reference = NULL; git_worktree *wt; git_worktree_add_options add_opts = GIT_WORKTREE_ADD_OPTIONS_INIT; - + int err; - if (!PyArg_ParseTuple(args, "ss|O!", &c_name, &c_path, &ReferenceType, &py_reference)) + if (!PyArg_ParseTuple(args, "sO&|O!", &c_name, PyUnicode_FSConverter, &py_path, &ReferenceType, &py_reference)) return NULL; + if (py_path != NULL) + c_path = PyBytes_AS_STRING(py_path); + if(py_reference != NULL) add_opts.ref = py_reference->reference; - + err = git_worktree_add(&wt, self->repo, c_name, c_path, &add_opts); + Py_XDECREF(py_path); if (err < 0) return Error_set(err); diff --git a/src/utils.c b/src/utils.c index 6b2ee385e..7cf385058 100644 --- a/src/utils.c +++ b/src/utils.c @@ -72,19 +72,32 @@ pgit_encode_fsdefault(PyObject *value) const char* pgit_borrow_encoding(PyObject *value, const char *encoding, PyObject **tvalue) { - PyObject *py_str; + PyObject *py_value = NULL; + PyObject *py_str = NULL; + +#if defined(HAS_FSPATH_SUPPORT) + py_value = PyOS_FSPath(value); + if (py_value == NULL) { + Error_type_error("unexpected %.200s", value); + return NULL; + } +#else + py_value = value; + Py_INCREF(value); +#endif // Get new PyBytes reference from value - if (PyUnicode_Check(value)) { // Text string - py_str = (encoding) ? PyUnicode_AsEncodedString(value, encoding, "strict") - : PyUnicode_AsUTF8String(value); + if (PyUnicode_Check(py_value)) { // Text string + py_str = (encoding) ? PyUnicode_AsEncodedString(py_value, encoding, "strict") + : PyUnicode_AsUTF8String(py_value); + Py_DECREF(py_value); if (py_str == NULL) return NULL; - } else if (PyBytes_Check(value)) { // Byte string - py_str = value; - Py_INCREF(py_str); + } else if (PyBytes_Check(py_value)) { // Byte string + py_str = py_value; } else { // Type error Error_type_error("unexpected %.200s", value); + Py_DECREF(py_value); return NULL; } diff --git a/src/utils.h b/src/utils.h index e2b7c791b..eae8c261c 100644 --- a/src/utils.h +++ b/src/utils.h @@ -39,6 +39,10 @@ # define PYGIT2_FN_UNUSED #endif +#if PY_MAJOR_VERSION >= 3 && PY_MINOR_VERSION >= 6 && (!defined(PYPY_VERSION) || PYPY_VERSION_NUM >= 0x07030000) +#define HAS_FSPATH_SUPPORT +#endif + #define to_path(x) to_unicode(x, Py_FileSystemDefaultEncoding, "strict") #define to_encoding(x) PyUnicode_DecodeASCII(x, strlen(x), "strict") diff --git a/test/test_attributes.py b/test/test_attributes.py index 1796e6349..ccd09d8bd 100644 --- a/test/test_attributes.py +++ b/test/test_attributes.py @@ -24,7 +24,9 @@ # Boston, MA 02110-1301, USA. # Standard Library +import unittest from os.path import join +from pathlib import Path # pygit2 from . import utils @@ -49,3 +51,10 @@ def test_no_attr(self): assert self.repo.get_attr('file.py', 'text') assert not self.repo.get_attr('file.jpg', 'text') assert "lf" == self.repo.get_attr('file.sh', 'eol') + + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_no_attr_aspath(self): + with open(join(self.repo.workdir, '.gitattributes'), 'w+') as f: + print('*.py text\n', file=f) + + assert self.repo.get_attr(Path('file.py'), 'text') diff --git a/test/test_blob.py b/test/test_blob.py index f3222aef1..e8cded2cb 100644 --- a/test/test_blob.py +++ b/test/test_blob.py @@ -26,6 +26,8 @@ """Tests for Blob objects.""" import io +import unittest +from pathlib import Path import pytest @@ -126,6 +128,13 @@ def test_create_blob_fromworkdir(self): assert len(BLOB_FILE_CONTENT) == blob.size assert BLOB_FILE_CONTENT == blob.read_raw() + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_create_blob_fromworkdir_aspath(self): + + blob_oid = self.repo.create_blob_fromworkdir(Path("bye.txt")) + blob = self.repo[blob_oid] + + assert isinstance(blob, pygit2.Blob) def test_create_blob_outside_workdir(self): with pytest.raises(KeyError): diff --git a/test/test_config.py b/test/test_config.py index aa760b5d4..d6393ac5c 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -24,6 +24,8 @@ # Boston, MA 02110-1301, USA. import os +import unittest +from pathlib import Path import pytest @@ -89,6 +91,17 @@ def test_add(self): assert 'something.other.here' in config assert not config.get_bool('something.other.here') + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_add_aspath(self): + config = Config() + + new_file = open(CONFIG_FILENAME, "w") + new_file.write("[this]\n\tthat = true\n") + new_file.close() + + config.add_file(Path(CONFIG_FILENAME), 0) + assert 'this.that' in config + def test_read(self): config = self.repo.config diff --git a/test/test_credentials.py b/test/test_credentials.py index 76209263a..f71b35f3e 100644 --- a/test/test_credentials.py +++ b/test/test_credentials.py @@ -26,6 +26,7 @@ """Tests for credentials""" import unittest +from pathlib import Path import pytest @@ -67,6 +68,16 @@ def test_ssh_key(self): cred = Keypair(username, pubkey, privkey, passphrase) assert (username, pubkey, privkey, passphrase) == cred.credential_tuple + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_ssh_key_aspath(self): + username = "git" + pubkey = Path("id_rsa.pub") + privkey = Path("id_rsa") + passphrase = "bad wolf" + + cred = Keypair(username, pubkey, privkey, passphrase) + assert (username, pubkey, privkey, passphrase) == cred.credential_tuple + def test_ssh_agent(self): username = "git" diff --git a/test/test_index.py b/test/test_index.py index 35ca18a8d..1e59a1bb5 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -26,6 +26,8 @@ """Tests for Index files.""" import os +import unittest +from pathlib import Path import pytest @@ -70,6 +72,14 @@ def test_add(self): assert len(index) == 3 assert index['bye.txt'].hex == sha + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_add_aspath(self): + index = self.repo.index + + assert 'bye.txt' not in index + index.add(Path('bye.txt')) + assert 'bye.txt' in index + def test_add_all(self): self.test_clear() @@ -106,6 +116,17 @@ def test_add_all(self): assert index['bye.txt'].hex == sha_bye assert index['hello.txt'].hex == sha_hello + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_add_all_aspath(self): + self.test_clear() + + index = self.repo.index + + index.add_all([Path('bye.txt'), Path('hello.txt')]) + + assert 'bye.txt' in index + assert 'hello.txt' in index + def test_clear(self): index = self.repo.index assert len(index) == 2 @@ -181,6 +202,20 @@ def test_remove_all(self): index.remove_all(['not-existing']) # this doesn't error + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_remove_aspath(self): + index = self.repo.index + assert 'hello.txt' in index + index.remove(Path('hello.txt')) + assert 'hello.txt' not in index + + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_remove_all_aspath(self): + index = self.repo.index + assert 'hello.txt' in index + index.remove_all([Path('hello.txt')]) + assert 'hello.txt' not in index + def test_change_attributes(self): index = self.repo.index entry = index['hello.txt'] @@ -211,6 +246,15 @@ def test_create_entry(self): tree_id = index.write_tree() assert '60e769e57ae1d6a2ab75d8d253139e6260e1f912' == str(tree_id) + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_create_entry_aspath(self): + index = self.repo.index + hello_entry = index[Path('hello.txt')] + entry = pygit2.IndexEntry(Path('README.md'), hello_entry.id, hello_entry.mode) + index.add(entry) + index.write_tree() + + class StandaloneIndexTest(utils.RepoTestCase): def test_create_empty(self): diff --git a/test/test_repository.py b/test/test_repository.py index b9d483b2e..16d46552a 100644 --- a/test/test_repository.py +++ b/test/test_repository.py @@ -32,6 +32,7 @@ import tempfile import os from os.path import join, realpath +from pathlib import Path import sys from urllib.request import pathname2url @@ -517,6 +518,11 @@ def test_no_arg(self): repo = init_repository(self._temp_dir) assert not repo.is_bare + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_no_arg_aspath(self): + repo = init_repository(Path(self._temp_dir)) + assert not repo.is_bare + def test_pos_arg_false(self): repo = init_repository(self._temp_dir, False) assert not repo.is_bare @@ -542,6 +548,13 @@ def test_discover_repo(self): os.makedirs(subdir) assert repo.path == discover_repository(subdir) + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_discover_repo_aspath(self): + repo = init_repository(Path(self._temp_dir), False) + subdir = Path(self._temp_dir) / "test1" / "test2" + os.makedirs(subdir) + assert repo.path == discover_repository(subdir) + def test_discover_repo_not_found(self): assert discover_repository(tempfile.tempdir) is None @@ -570,6 +583,11 @@ def test_unicode_string(self): repo_path = './test/data/testrepo.git/' pygit2.Repository(repo_path) + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_aspath(self): + repo_path = Path('./test/data/testrepo.git/') + pygit2.Repository(repo_path) + class CloneRepositoryTest(utils.NoRepoTestCase): @@ -579,6 +597,13 @@ def test_clone_repository(self): assert not repo.is_empty assert not repo.is_bare + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_clone_repository_aspath(self): + repo_path = Path("./test/data/testrepo.git/") + repo = clone_repository(repo_path, Path(self._temp_dir)) + assert not repo.is_empty + assert not repo.is_bare + def test_clone_bare_repository(self): repo_path = "./test/data/testrepo.git/" repo = clone_repository(repo_path, self._temp_dir, bare=True) @@ -710,6 +735,16 @@ def _check_worktree(worktree): worktree.prune(True) assert self.repo.list_worktrees() == [] + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_worktree_aspath(self): + worktree_name = 'foo' + worktree_dir = Path(tempfile.mkdtemp()) + # Delete temp path so that it's not present when we attempt to add the + # worktree later + os.rmdir(worktree_dir) + self.repo.add_worktree(worktree_name, worktree_dir) + assert self.repo.list_worktrees() == [worktree_name] + def test_worktree_custom_ref(self): worktree_name = 'foo' worktree_dir = tempfile.mkdtemp() diff --git a/test/test_submodule.py b/test/test_submodule.py index b598acf1a..284ef27d5 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -27,6 +27,7 @@ import os import unittest +from pathlib import Path from . import utils @@ -36,12 +37,18 @@ SUBM_URL = 'https://github.com/libgit2/pygit2' SUBM_HEAD_SHA = '819cbff552e46ac4b8d10925cc422a30aa04e78e' + class SubmoduleTest(utils.SubmoduleRepoTestCase): def test_lookup_submodule(self): s = self.repo.lookup_submodule(SUBM_PATH) assert s is not None + @unittest.skipIf(not utils.has_fspath, "Requires PEP-519 (FSPath) support") + def test_lookup_submodule_aspath(self): + s = self.repo.lookup_submodule(Path(SUBM_PATH)) + assert s is not None + def test_listall_submodules(self): submodules = self.repo.listall_submodules() assert len(submodules) == 1 diff --git a/test/utils.py b/test/utils.py index b3098635c..775157a45 100644 --- a/test/utils.py +++ b/test/utils.py @@ -29,6 +29,7 @@ import shutil import socket import stat +import sys import tarfile import tempfile import unittest @@ -52,6 +53,14 @@ def no_network(): return _no_network +is_pypy = '__pypy__' in sys.builtin_module_names + +if is_pypy: + has_fspath = sys.pypy_version_info >= (7, 3) +else: + has_fspath = sys.version_info >= (3, 6) + + def force_rm_handle(remove_path, path, excinfo): os.chmod( path,