diff --git a/.github/workflows/pydoctor.yml b/.github/workflows/pydoctor.yml index d47cb950..c237de5c 100644 --- a/.github/workflows/pydoctor.yml +++ b/.github/workflows/pydoctor.yml @@ -37,6 +37,12 @@ jobs: pip install pydoctor - name: Build API docs run: | + # Remove Rust proc-macro .so build artifacts that confuse pydoctor + find subvertpy -name 'lib*.so' -delete + # Work around pydoctor bug: _parseFile only catches SyntaxError but + # binary .so files raise UnicodeDecodeError, causing an UnboundLocalError crash. + sed -i 's/except SyntaxError/except (SyntaxError, UnicodeDecodeError)/g' \ + "$(python -c 'import pydoctor.astbuilder; print(pydoctor.astbuilder.__file__)')" pydoctor --introspect-c-modules -c subvertpy.cfg --make-html subvertpy - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 diff --git a/Cargo.lock b/Cargo.lock index 00deeeaa..f79c4060 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -494,9 +494,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "subversion" -version = "0.1.7" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b3a0f1dec583d374a0c6eaef76a4efc393500770a5dde12906eb6e93373bae" +checksum = "6c4a327e0acd08a2024df39b86e281cf963a0edb772918d4273ad59ecd19c693" dependencies = [ "apr", "apr-sys", diff --git a/Cargo.toml b/Cargo.toml index 99280538..a64c9dca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,5 +16,6 @@ edition = "2021" [workspace.dependencies] pyo3 = { version = "0.27" } #subversion = { version = ">=0.0.5" } -subversion = { version = "0.1.6" } +subversion = { version = "0.1.10" } +#subversion = { path = "../subversion-rs" } pyo3-filelike = { version = "0.5" } diff --git a/subvertpy/__init__.py b/subvertpy/__init__.py index b0f8ff7a..a07c8d05 100644 --- a/subvertpy/__init__.py +++ b/subvertpy/__init__.py @@ -53,6 +53,7 @@ ERR_WC_NOT_WORKING_COPY = ERR_WC_NOT_DIRECTORY = 155007 ERR_ENTRY_EXISTS = 150002 ERR_WC_PATH_NOT_FOUND = 155010 +ERR_WC_PATH_UNEXPECTED_STATUS = 155035 ERR_CANCELLED = 200015 ERR_WC_UNSUPPORTED_FORMAT = 155021 ERR_UNKNOWN_CAPABILITY = 200026 diff --git a/tests/test_wc.py b/tests/test_wc.py index cebbe090..ec7dca3d 100644 --- a/tests/test_wc.py +++ b/tests/test_wc.py @@ -571,6 +571,163 @@ def test_process_committed_queue_requires_write_lock(self): ) + def test_context_manager(self): + with wc.Context() as ctx: + self.assertIsNotNone(ctx) + result = ctx.locked(os.path.abspath("checkout")) + self.assertIsInstance(result, tuple) + + def test_close(self): + ctx = wc.Context() + ctx.close() + + +class AdmObjTests(SubversionTestCase): + def setUp(self): + super().setUp() + self.repos_url = self.make_client("repos", "checkout") + self._adm = None + + def tearDown(self): + if self._adm is not None: + self._adm.close() + self._adm = None + super().tearDown() + + def _open_adm(self, **kwargs): + self._adm = wc.Adm(**kwargs) + return self._adm + + def test_open(self): + adm = self._open_adm(path=os.path.abspath("checkout"), write_lock=False) + self.assertIsNotNone(adm) + + def test_open_positional(self): + self._adm = wc.Adm(None, os.path.abspath("checkout")) + self.assertIsNotNone(self._adm) + + def test_open_no_path(self): + self.assertRaises(TypeError, wc.Adm) + + def test_open_none_path(self): + self.assertRaises(TypeError, wc.Adm, None, None) + + def test_access_path(self): + adm = self._open_adm(path=os.path.abspath("checkout"), write_lock=False) + self.assertEqual( + os.path.normpath(os.path.abspath("checkout")), + os.path.normpath(adm.access_path()), + ) + + def test_is_locked_read(self): + adm = self._open_adm(path=os.path.abspath("checkout"), write_lock=False) + self.assertFalse(adm.is_locked()) + + def test_is_locked_write(self): + adm = self._open_adm( + path=os.path.abspath("checkout"), write_lock=True, depth=-1 + ) + self.assertTrue(adm.is_locked()) + + def test_context_manager(self): + with wc.Adm(path=os.path.abspath("checkout"), write_lock=False) as adm: + self.assertIsNotNone(adm) + self.assertEqual( + os.path.normpath(os.path.abspath("checkout")), + os.path.normpath(adm.access_path()), + ) + + def test_is_wc_root(self): + adm = self._open_adm(path=os.path.abspath("checkout"), write_lock=False) + self.assertTrue(adm.is_wc_root(os.path.abspath("checkout"))) + + def test_prop_set_get(self): + self.build_tree({"checkout/proptest": b"content"}) + self.client_add("checkout/proptest") + self.client_commit("checkout", message="add proptest") + adm = self._open_adm( + path=os.path.abspath("checkout"), write_lock=True, depth=-1 + ) + adm.prop_set("svn:eol-style", b"native", os.path.abspath("checkout/proptest")) + val = adm.prop_get("svn:eol-style", os.path.abspath("checkout/proptest")) + self.assertEqual(b"native", val) + + def test_prop_get_nonexistent(self): + self.build_tree({"checkout/propnone": b"content"}) + self.client_add("checkout/propnone") + self.client_commit("checkout", message="add propnone") + adm = self._open_adm(path=os.path.abspath("checkout"), write_lock=False) + val = adm.prop_get("svn:nonexistent", os.path.abspath("checkout/propnone")) + self.assertIsNone(val) + + def test_text_modified(self): + self.build_tree({"checkout/txtmod": b"content"}) + self.client_add("checkout/txtmod") + self.client_commit("checkout", message="add txtmod") + adm = self._open_adm( + path=os.path.abspath("checkout"), write_lock=False, depth=-1 + ) + self.assertFalse(adm.text_modified(os.path.abspath("checkout/txtmod"), False)) + self.build_tree({"checkout/txtmod": b"changed"}) + self.assertTrue(adm.text_modified(os.path.abspath("checkout/txtmod"), False)) + + def test_props_modified(self): + self.build_tree({"checkout/pmod": b"content"}) + self.client_add("checkout/pmod") + self.client_commit("checkout", message="add pmod") + adm = self._open_adm( + path=os.path.abspath("checkout"), write_lock=False, depth=-1 + ) + self.assertFalse(adm.props_modified(os.path.abspath("checkout/pmod"))) + + def test_conflicted(self): + self.build_tree({"checkout/conflfile": b"content"}) + self.client_add("checkout/conflfile") + self.client_commit("checkout", message="add conflfile") + adm = self._open_adm( + path=os.path.abspath("checkout"), write_lock=False, depth=-1 + ) + result = adm.conflicted(os.path.abspath("checkout/conflfile")) + self.assertIsInstance(result, tuple) + self.assertEqual(3, len(result)) + self.assertEqual((False, False, False), result) + + def test_has_binary_prop(self): + self.build_tree({"checkout/binfile": b"content"}) + self.client_add("checkout/binfile") + self.client_commit("checkout", message="add binfile") + adm = self._open_adm( + path=os.path.abspath("checkout"), write_lock=False, depth=-1 + ) + self.assertFalse(adm.has_binary_prop(os.path.abspath("checkout/binfile"))) + + def test_add(self): + self.build_tree({"checkout/addfile": b"content"}) + adm = self._open_adm( + path=os.path.abspath("checkout"), write_lock=True, depth=-1 + ) + adm.add(os.path.abspath("checkout/addfile")) + + def test_delete(self): + self.build_tree({"checkout/delfile": b"content"}) + self.client_add("checkout/delfile") + self.client_commit("checkout", message="add delfile") + adm = self._open_adm( + path=os.path.abspath("checkout"), write_lock=True, depth=-1 + ) + adm.delete(os.path.abspath("checkout/delfile")) + + def test_delete_keep_local(self): + self.build_tree({"checkout/delkeep": b"content"}) + self.client_add("checkout/delkeep") + self.client_commit("checkout", message="add delkeep") + adm = self._open_adm( + path=os.path.abspath("checkout"), write_lock=True, depth=-1 + ) + adm.delete(os.path.abspath("checkout/delkeep"), keep_local=True) + self.assertTrue(os.path.exists("checkout/delkeep")) + + class LockTests(TestCase): def test_create_lock(self): lock = wc.Lock() diff --git a/wc/src/adm.rs b/wc/src/adm.rs new file mode 100644 index 00000000..9bf17d91 --- /dev/null +++ b/wc/src/adm.rs @@ -0,0 +1,213 @@ +//! Deprecated Adm (svn_wc_adm_access_t) Python bindings. + +use pyo3::prelude::*; +use subvertpy_util::error::svn_err_to_py; + +/// Deprecated working copy administrative access baton. +/// +/// Wraps the deprecated ``svn_wc_adm_access_t`` based API. +/// New code should use :class:`Context` instead. +#[pyclass(name = "Adm", unsendable)] +pub struct Adm { + #[allow(deprecated)] + pub(crate) inner: subversion::wc::Adm, +} + +#[pymethods] +#[allow(deprecated)] +impl Adm { + /// Open an access baton for a working copy directory. + /// + /// :param associated: Associated access baton (ignored, for backwards compat). + /// :param path: Path to the working copy directory. + /// :param write_lock: If True, acquire a write lock. + /// :param depth: Levels to lock: 0 = just this dir, -1 = infinite. + #[new] + #[pyo3(signature = (associated=None, path=None, write_lock=false, depth=0))] + fn init( + associated: Option<&Bound>, + path: Option<&Bound>, + write_lock: bool, + depth: i32, + ) -> PyResult { + // Support both Adm(path, write_lock=...) and Adm(None, path, write_lock=...) + let actual_path = if let Some(p) = path { + p + } else if let Some(a) = associated { + if a.is_none() { + return Err(pyo3::exceptions::PyTypeError::new_err( + "Adm() requires a path argument", + )); + } + a + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "Adm() requires a path argument", + )); + }; + let path_str = subvertpy_util::py_to_svn_abspath(actual_path)?; + let adm = subversion::wc::Adm::open(&path_str, write_lock, depth).map_err(svn_err_to_py)?; + Ok(Self { inner: adm }) + } + + /// Return the path this access baton is for. + fn access_path(&self) -> PyResult { + Ok(self.inner.access_path().to_string()) + } + + /// Check if this access baton is locked. + fn is_locked(&self) -> PyResult { + Ok(self.inner.is_locked()) + } + + /// Close the access baton, releasing all resources and locks. + fn close(&mut self) { + self.inner.close(); + } + + /// Add a file or directory to version control. + #[pyo3(signature = (path, copyfrom_url=None, copyfrom_rev=-1))] + fn add( + &self, + path: &Bound, + copyfrom_url: Option<&str>, + copyfrom_rev: i64, + ) -> PyResult<()> { + let path_str = subvertpy_util::py_to_svn_abspath(path)?; + let rev = subvertpy_util::to_revnum(copyfrom_rev); + self.inner + .add(&path_str, copyfrom_url, rev) + .map_err(svn_err_to_py) + } + + /// Delete a file or directory from version control. + #[pyo3(signature = (path, keep_local=false))] + fn delete(&self, path: &Bound, keep_local: bool) -> PyResult<()> { + let path_str = subvertpy_util::py_to_svn_abspath(path)?; + self.inner + .delete(&path_str, keep_local) + .map_err(svn_err_to_py) + } + + /// Copy a file or directory in the working copy. + fn copy(&self, src: &Bound, dst_basename: &str) -> PyResult<()> { + let src_str = subvertpy_util::py_to_svn_abspath(src)?; + self.inner + .copy(&src_str, dst_basename) + .map_err(svn_err_to_py) + } + + /// Set a property on a path. + fn prop_set(&self, name: &str, value: Option<&[u8]>, path: &Bound) -> PyResult<()> { + let path_str = subvertpy_util::py_to_svn_abspath(path)?; + self.inner + .prop_set(name, value, &path_str) + .map_err(svn_err_to_py) + } + + /// Get a property on a path. + fn prop_get(&self, name: &str, path: &Bound) -> PyResult> { + let path_str = subvertpy_util::py_to_svn_abspath(path)?; + let val = self + .inner + .prop_get(name, &path_str) + .map_err(svn_err_to_py)?; + pyo3::Python::with_gil(|py| match val { + None => Ok(None), + Some(v) => Ok(Some(pyo3::types::PyBytes::new(py, &v).into())), + }) + } + + /// Check if a path has a binary property. + fn has_binary_prop(&self, path: &Bound) -> PyResult { + let path_str = subvertpy_util::py_to_svn_abspath(path)?; + self.inner.has_binary_prop(&path_str).map_err(svn_err_to_py) + } + + /// Check if the text content of a path has been modified. + fn text_modified(&self, path: &Bound, force_comparison: bool) -> PyResult { + let path_str = subvertpy_util::py_to_svn_abspath(path)?; + self.inner + .text_modified(&path_str, force_comparison) + .map_err(svn_err_to_py) + } + + /// Check if properties of a path have been modified. + fn props_modified(&self, path: &Bound) -> PyResult { + let path_str = subvertpy_util::py_to_svn_abspath(path)?; + self.inner.props_modified(&path_str).map_err(svn_err_to_py) + } + + /// Check if a path is the root of a working copy. + fn is_wc_root(&self, path: &Bound) -> PyResult { + let path_str = subvertpy_util::py_to_svn_abspath(path)?; + self.inner.is_wc_root(&path_str).map_err(svn_err_to_py) + } + + /// Check if a path is conflicted. + fn conflicted(&self, path: &Bound) -> PyResult<(bool, bool, bool)> { + let path_str = subvertpy_util::py_to_svn_abspath(path)?; + self.inner.conflicted(&path_str).map_err(svn_err_to_py) + } + + /// Queue a path for post-commit processing using this access baton. + #[pyo3(signature = (path, queue, recurse=false, remove_lock=false, remove_changelist=false, md5_digest=None))] + fn queue_committed( + &self, + path: &Bound, + queue: &mut crate::committed::CommittedQueue, + recurse: bool, + remove_lock: bool, + remove_changelist: bool, + md5_digest: Option<&[u8]>, + ) -> PyResult<()> { + let path_str = subvertpy_util::py_to_svn_abspath(path)?; + let digest: Option<[u8; 16]> = md5_digest.map(|d| { + let mut arr = [0u8; 16]; + arr.copy_from_slice(&d[..16]); + arr + }); + self.inner + .queue_committed( + &path_str, + &mut queue.inner, + recurse, + remove_lock, + remove_changelist, + digest.as_ref(), + ) + .map_err(svn_err_to_py) + } + + /// Process the committed queue using this access baton. + fn process_committed_queue( + &self, + queue: &mut crate::committed::CommittedQueue, + revnum: i64, + date: &str, + author: &str, + ) -> PyResult<()> { + self.inner + .process_committed_queue( + &mut queue.inner, + subvertpy_util::to_revnum(revnum).unwrap_or(subversion::Revnum::invalid()), + Some(date), + Some(author), + ) + .map_err(svn_err_to_py) + } + + fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __exit__( + &mut self, + _exc_type: Option<&Bound>, + _exc_val: Option<&Bound>, + _exc_tb: Option<&Bound>, + ) -> PyResult { + self.inner.close(); + Ok(false) + } +} diff --git a/wc/src/context.rs b/wc/src/context.rs index d98f7ca1..d4b501ef 100644 --- a/wc/src/context.rs +++ b/wc/src/context.rs @@ -58,6 +58,27 @@ impl Context { Ok(Self { inner: ctx }) } + /// Explicitly close and release all resources held by this context. + /// + /// After calling this, the context is no longer usable. + fn close(&mut self) { + self.inner.close(); + } + + fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __exit__( + &mut self, + _exc_type: Option<&Bound>, + _exc_val: Option<&Bound>, + _exc_tb: Option<&Bound>, + ) -> PyResult { + self.inner.close(); + Ok(false) + } + /// Check whether a path is locked. fn locked(&mut self, path: &Bound) -> PyResult<(bool, bool)> { let path_str = subvertpy_util::py_to_svn_abspath(path)?; diff --git a/wc/src/lib.rs b/wc/src/lib.rs index 9265e1dc..14f6ec86 100644 --- a/wc/src/lib.rs +++ b/wc/src/lib.rs @@ -5,11 +5,13 @@ use pyo3::prelude::*; use subvertpy_util::error::svn_err_to_py; +mod adm; mod committed; mod context; mod lock; mod status; +use adm::Adm; use committed::CommittedQueue; use context::Context; use lock::Lock; @@ -184,6 +186,7 @@ fn revision_status( /// Python module initialization #[pymodule] fn wc(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?;