From c4043ad9ace27a173bc2b37de90556ce2e6ab7b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Mon, 23 Mar 2026 18:13:52 +0000 Subject: [PATCH 1/3] Fix error code in svn_err_to_py to use raw SVN error codes Use raw_apr_err() instead of apr_err() to preserve the SVN-specific error code (e.g. 155005) rather than the coarse APR status (e.g. 15). This is needed for the exception subclass lookup to work correctly. --- Cargo.lock | 20 +-- subvertpy/__init__.py | 289 ++++++++++++++++++++++++++++++++++++ subvertpy_util/src/error.rs | 11 +- tests/test_wc.py | 32 ++-- 4 files changed, 316 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58503b36..00deeeaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "apr" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "542b26acc95b84446737e8eca862606d11443caf086d5cfd3cf0e4c4e6b29346" +checksum = "9971979c9854b5d346c3df17719bca4ada9ad5b55ec3b772b23379c9ba188578" dependencies = [ "apr-sys", "ctor", @@ -473,9 +473,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" dependencies = [ "serde_core", ] @@ -494,9 +494,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "subversion" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9ee247990bd6b895efd57136190ca79e2c8af250c7b8a6d0b3db47db6c1d55a" +checksum = "e8b3a0f1dec583d374a0c6eaef76a4efc393500770a5dde12906eb6e93373bae" dependencies = [ "apr", "apr-sys", @@ -602,18 +602,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.10+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ "winnow 1.0.0", ] [[package]] name = "toml_writer" -version = "1.0.7+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" [[package]] name = "unicode-ident" diff --git a/subvertpy/__init__.py b/subvertpy/__init__.py index 1550add1..b0f8ff7a 100644 --- a/subvertpy/__init__.py +++ b/subvertpy/__init__.py @@ -121,6 +121,295 @@ def __init__(self, msg, num, child=None, location=None): self.location = location +class UnsupportedFeature(SubversionException): + """Trying to use an unsupported feature.""" + + +class RaSvnUnknownCmd(SubversionException): + """Unknown svn protocol command.""" + + +class RaSvnConnectionClosed(SubversionException): + """Network connection closed unexpectedly.""" + + +class WcLocked(SubversionException): + """Attempted to lock an already-locked directory.""" + + +class RaNotAuthorized(SubversionException): + """Authorization failed.""" + + +class IncompleteData(SubversionException): + """Incomplete data.""" + + +class DirNotEmpty(SubversionException): + """Directory needs to be empty but is not.""" + + +class RaSvnMalformedData(SubversionException): + """Malformed network data.""" + + +class RaNotImplemented(SubversionException): + """Repository access method not implemented.""" + + +class FsNoSuchRevision(SubversionException): + """Invalid filesystem revision number.""" + + +class FsTxnOutOfDate(SubversionException): + """Transaction is out of date.""" + + +class ReposDisabledFeature(SubversionException): + """Disabled repository feature.""" + + +class StreamMalformedData(SubversionException): + """Malformed stream data.""" + + +class RaIllegalUrl(SubversionException): + """Bad URL passed to RA layer.""" + + +class RaLocalReposOpenFailed(SubversionException): + """Couldn't open a repository.""" + + +class BadFilename(SubversionException): + """Bogus filename.""" + + +class BadUrl(SubversionException): + """Bogus URL.""" + + +class BadDate(SubversionException): + """Bogus date.""" + + +class RaDavRequestFailed(SubversionException): + """RA layer request failed.""" + + +class RaDavPathNotFound(SubversionException): + """HTTP path not found.""" + + +class FsNotDirectory(SubversionException): + """Name does not refer to a filesystem directory.""" + + +class FsNotFound(SubversionException): + """Filesystem has no item.""" + + +class FsAlreadyExists(SubversionException): + """Item already exists in filesystem.""" + + +class RaSvnReposNotFound(SubversionException): + """Couldn't find a repository.""" + + +class WcNotWorkingCopy(SubversionException): + """Path is not a working copy directory.""" + + +class EntryExists(SubversionException): + """Entry already exists.""" + + +class WcPathNotFound(SubversionException): + """Can't find a working copy path.""" + + +class Cancelled(SubversionException): + """The operation was interrupted.""" + + +class WcUnsupportedFormat(SubversionException): + """Unsupported working copy format.""" + + +class UnknownCapability(SubversionException): + """Inquiry about unknown capability.""" + + +class AuthnNoProvider(SubversionException): + """No authentication provider available.""" + + +class RaDavRelocated(SubversionException): + """Repository has been moved.""" + + +class FsNotFile(SubversionException): + """Name does not refer to a filesystem file.""" + + +class WcBadAdmLog(SubversionException): + """Problem running log.""" + + +class WcBadAdmLogStart(SubversionException): + """Problem on first log entry in a working copy.""" + + +class WcNotLocked(SubversionException): + """Working copy not locked.""" + + +class RaDavNotVcc(SubversionException): + """DAV version-controlled configuration error.""" + + +class ReposHookFailure(SubversionException): + """A repository hook failed.""" + + +class XmlMalformed(SubversionException): + """XML data was not well-formed.""" + + +class MalformedFile(SubversionException): + """Malformed file.""" + + +class FsPathSyntax(SubversionException): + """Invalid filesystem path syntax.""" + + +class RaDavForbidden(SubversionException): + """URL access forbidden.""" + + +class WcScheduleConflict(SubversionException): + """Unmergeable scheduling requested on an entry.""" + + +class RaDavProppatchFailed(SubversionException): + """Failed to execute WebDAV PROPPATCH.""" + + +class SvndiffCorruptWindow(SubversionException): + """Svndiff data contains corrupt window.""" + + +class FsConflict(SubversionException): + """Merge conflict during commit.""" + + +class NodeUnknownKind(SubversionException): + """Unknown node kind.""" + + +class RaSerfSslCertUntrusted(SubversionException): + """Server SSL certificate untrusted.""" + + +class EntryNotFound(SubversionException): + """Can't find an entry.""" + + +class BadPropertyValue(SubversionException): + """Wrong or unexpected property value.""" + + +class FsRootDir(SubversionException): + """Attempt to remove or recreate filesystem root directory.""" + + +class WcNodeKindChange(SubversionException): + """Cannot change node kind.""" + + +class WcUpgradeRequired(SubversionException): + """The working copy needs to be upgraded.""" + + +class RaCannotCreateSession(SubversionException): + """Can't create session.""" + + +class ReposBadArgs(SubversionException): + """Incorrect arguments supplied.""" + + +class EaiNoname(SubversionException): + """Name or service not known.""" + + +class UnknownHostname(SubversionException): + """Unknown hostname.""" + + +_error_code_to_class: dict[int, type[SubversionException]] = { + ERR_UNSUPPORTED_FEATURE: UnsupportedFeature, + ERR_RA_SVN_UNKNOWN_CMD: RaSvnUnknownCmd, + ERR_RA_SVN_CONNECTION_CLOSED: RaSvnConnectionClosed, + ERR_WC_LOCKED: WcLocked, + ERR_RA_NOT_AUTHORIZED: RaNotAuthorized, + ERR_INCOMPLETE_DATA: IncompleteData, + ERR_DIR_NOT_EMPTY: DirNotEmpty, + ERR_RA_SVN_MALFORMED_DATA: RaSvnMalformedData, + ERR_RA_NOT_IMPLEMENTED: RaNotImplemented, + ERR_FS_NO_SUCH_REVISION: FsNoSuchRevision, + ERR_FS_TXN_OUT_OF_DATE: FsTxnOutOfDate, + ERR_REPOS_DISABLED_FEATURE: ReposDisabledFeature, + ERR_STREAM_MALFORMED_DATA: StreamMalformedData, + ERR_RA_ILLEGAL_URL: RaIllegalUrl, + ERR_RA_LOCAL_REPOS_OPEN_FAILED: RaLocalReposOpenFailed, + ERR_BAD_FILENAME: BadFilename, + ERR_BAD_URL: BadUrl, + ERR_BAD_DATE: BadDate, + ERR_RA_DAV_REQUEST_FAILED: RaDavRequestFailed, + ERR_RA_DAV_PATH_NOT_FOUND: RaDavPathNotFound, + ERR_FS_NOT_DIRECTORY: FsNotDirectory, + ERR_FS_NOT_FOUND: FsNotFound, + ERR_FS_ALREADY_EXISTS: FsAlreadyExists, + ERR_RA_SVN_REPOS_NOT_FOUND: RaSvnReposNotFound, + ERR_WC_NOT_WORKING_COPY: WcNotWorkingCopy, + ERR_ENTRY_EXISTS: EntryExists, + ERR_WC_PATH_NOT_FOUND: WcPathNotFound, + ERR_CANCELLED: Cancelled, + ERR_WC_UNSUPPORTED_FORMAT: WcUnsupportedFormat, + ERR_UNKNOWN_CAPABILITY: UnknownCapability, + ERR_AUTHN_NO_PROVIDER: AuthnNoProvider, + ERR_RA_DAV_RELOCATED: RaDavRelocated, + ERR_FS_NOT_FILE: FsNotFile, + ERR_WC_BAD_ADM_LOG: WcBadAdmLog, + ERR_WC_BAD_ADM_LOG_START: WcBadAdmLogStart, + ERR_WC_NOT_LOCKED: WcNotLocked, + ERR_RA_DAV_NOT_VCC: RaDavNotVcc, + ERR_REPOS_HOOK_FAILURE: ReposHookFailure, + ERR_XML_MALFORMED: XmlMalformed, + ERR_MALFORMED_FILE: MalformedFile, + ERR_FS_PATH_SYNTAX: FsPathSyntax, + ERR_RA_DAV_FORBIDDEN: RaDavForbidden, + ERR_WC_SCHEDULE_CONFLICT: WcScheduleConflict, + ERR_RA_DAV_PROPPATCH_FAILED: RaDavProppatchFailed, + ERR_SVNDIFF_CORRUPT_WINDOW: SvndiffCorruptWindow, + ERR_FS_CONFLICT: FsConflict, + ERR_NODE_UNKNOWN_KIND: NodeUnknownKind, + ERR_RA_SERF_SSL_CERT_UNTRUSTED: RaSerfSslCertUntrusted, + ERR_ENTRY_NOT_FOUND: EntryNotFound, + ERR_BAD_PROPERTY_VALUE: BadPropertyValue, + ERR_FS_ROOT_DIR: FsRootDir, + ERR_WC_NODE_KIND_CHANGE: WcNodeKindChange, + ERR_WC_UPGRADE_REQUIRED: WcUpgradeRequired, + ERR_RA_CANNOT_CREATE_SESSION: RaCannotCreateSession, + ERR_REPOS_BAD_ARGS: ReposBadArgs, + ERR_EAI_NONAME: EaiNoname, + ERR_UNKNOWN_HOSTNAME: UnknownHostname, +} + + def _check_mtime(m): """Check whether a C extension is out of date. diff --git a/subvertpy_util/src/error.rs b/subvertpy_util/src/error.rs index c9de2412..f9981c6e 100644 --- a/subvertpy_util/src/error.rs +++ b/subvertpy_util/src/error.rs @@ -12,15 +12,20 @@ pub fn svn_err_to_py(err: SvnError) -> PyErr { let message = err.message().unwrap_or("Unknown SVN error"); let code = err.raw_apr_err(); - // Get the SubversionException class from the subvertpy module let module = py .import("subvertpy") .expect("Failed to import subvertpy module"); - let exc_class = module + + // Look up a specific subclass for this error code, falling back to SubversionException + let fallback = module .getattr("SubversionException") .expect("SubversionException not found"); + let exc_class = module + .getattr("_error_code_to_class") + .ok() + .and_then(|mapping| mapping.get_item(code).ok()) + .unwrap_or(fallback); - // Create exception instance with message and error code let exc_instance = exc_class .call1((message, code)) .expect("Failed to create exception"); diff --git a/tests/test_wc.py b/tests/test_wc.py index 388fd035..cebbe090 100644 --- a/tests/test_wc.py +++ b/tests/test_wc.py @@ -18,6 +18,8 @@ import os from subvertpy import ( + SubversionException, + WcNotLocked, wc, ) from tests import ( @@ -282,11 +284,9 @@ def abort(self): def test_add_from_disk_no_lock(self): ctx = wc.Context() self.build_tree({"checkout/diskfile": b"disk content"}) - from subvertpy import SubversionException - # add_from_disk requires a write lock self.assertRaises( - SubversionException, ctx.add_from_disk, os.path.abspath("checkout/diskfile") + WcNotLocked, ctx.add_from_disk, os.path.abspath("checkout/diskfile") ) def test_add_lock_requires_write_lock(self): @@ -295,11 +295,9 @@ def test_add_lock_requires_write_lock(self): self.client_add("checkout/lockwc") self.client_commit("checkout", message="add lockwc") lock = wc.Lock(token="opaquelocktoken:test-token") - from subvertpy import SubversionException - # add_lock requires a write lock on the WC self.assertRaises( - SubversionException, ctx.add_lock, os.path.abspath("checkout/lockwc"), lock + WcNotLocked, ctx.add_lock, os.path.abspath("checkout/lockwc"), lock ) def test_remove_lock_requires_write_lock(self): @@ -307,10 +305,8 @@ def test_remove_lock_requires_write_lock(self): self.build_tree({"checkout/rmlockwc": b"data"}) self.client_add("checkout/rmlockwc") self.client_commit("checkout", message="add rmlockwc") - from subvertpy import SubversionException - self.assertRaises( - SubversionException, ctx.remove_lock, os.path.abspath("checkout/rmlockwc") + WcNotLocked, ctx.remove_lock, os.path.abspath("checkout/rmlockwc") ) def test_crawl_revisions(self): @@ -445,12 +441,10 @@ def test_get_update_editor_dirents_func_not_implemented(self): def test_add_from_disk_with_props(self): ctx = wc.Context() self.build_tree({"checkout/diskprops": b"data"}) - from subvertpy import SubversionException - # add_from_disk requires a write lock, but we can test that # the props parameter is accepted self.assertRaises( - SubversionException, + WcNotLocked, ctx.add_from_disk, os.path.abspath("checkout/diskprops"), props={b"svn:eol-style": b"native"}, @@ -459,11 +453,9 @@ def test_add_from_disk_with_props(self): def test_add_from_disk_skip_checks(self): ctx = wc.Context() self.build_tree({"checkout/diskskip": b"data"}) - from subvertpy import SubversionException - # add_from_disk requires a write lock self.assertRaises( - SubversionException, + WcNotLocked, ctx.add_from_disk, os.path.abspath("checkout/diskskip"), skip_checks=True, @@ -472,8 +464,6 @@ def test_add_from_disk_skip_checks(self): def test_add_from_disk_notify(self): ctx = wc.Context() self.build_tree({"checkout/disknot": b"data"}) - from subvertpy import SubversionException - notifications = [] def notify_cb(info): @@ -481,7 +471,7 @@ def notify_cb(info): # add_from_disk requires a write lock self.assertRaises( - SubversionException, + WcNotLocked, ctx.add_from_disk, os.path.abspath("checkout/disknot"), notify=notify_cb, @@ -565,8 +555,6 @@ def test_committed_queue_queue(self): queue.queue(os.path.abspath("checkout/qfile"), ctx) def test_process_committed_queue_requires_write_lock(self): - from subvertpy import SubversionException - ctx = wc.Context() self.build_tree({"checkout/pcqfile": b"data"}) self.client_add("checkout/pcqfile") @@ -574,7 +562,7 @@ def test_process_committed_queue_requires_write_lock(self): queue = wc.CommittedQueue() queue.queue(os.path.abspath("checkout/pcqfile"), ctx) self.assertRaises( - SubversionException, + WcNotLocked, ctx.process_committed_queue, queue, 1, @@ -637,8 +625,6 @@ def test_get_pristine_copy_path(self): class EnsureAdmTests(SubversionTestCase): def test_ensure_adm_bogus_url(self): - from subvertpy import SubversionException - self.make_client("repos", "checkout") self.assertRaises( SubversionException, wc.ensure_adm, "checkout", "fake-uuid", "file:///fake" From 724552c0cc7061f04f7ec992f53dc124ce4040c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Mon, 23 Mar 2026 18:30:51 +0000 Subject: [PATCH 2/3] Update subversion crate --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00deeeaa..6ea7ca75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -494,9 +494,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "subversion" -version = "0.1.7" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b3a0f1dec583d374a0c6eaef76a4efc393500770a5dde12906eb6e93373bae" +checksum = "648af4f7d621a7d558c6742d99aa8cc950b7a276c046bb84b0eb1c0acfc519b6" dependencies = [ "apr", "apr-sys", diff --git a/Cargo.toml b/Cargo.toml index 99280538..a2e7117c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,5 +16,5 @@ edition = "2021" [workspace.dependencies] pyo3 = { version = "0.27" } #subversion = { version = ">=0.0.5" } -subversion = { version = "0.1.6" } +subversion = { version = "0.1.9" } pyo3-filelike = { version = "0.5" } From a59ddf2d6e069f3d493a5e341e0eb8b7372ff65b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Tue, 24 Mar 2026 00:23:40 +0000 Subject: [PATCH 3/3] pydoctor: remove Rust proc-macro .so files before building docs The editable install places Rust build artifacts (libpyo3_macros-*.so, libctor_proc_macro-*.so, etc.) in the subvertpy package directory. These are not Python extension modules and cause pydoctor to crash when it tries to parse or introspect them. --- .github/workflows/pydoctor.yml | 3 ++ wc/src/adm.rs | 95 ++++++++++++++++++++++++++++++++++ wc/src/lib.rs | 3 ++ 3 files changed, 101 insertions(+) create mode 100644 wc/src/adm.rs diff --git a/.github/workflows/pydoctor.yml b/.github/workflows/pydoctor.yml index d47cb950..2c1c5bd5 100644 --- a/.github/workflows/pydoctor.yml +++ b/.github/workflows/pydoctor.yml @@ -37,6 +37,9 @@ jobs: pip install pydoctor - name: Build API docs run: | + # Remove non-Python .so files (Rust proc-macro build artifacts) that + # confuse pydoctor's introspection + find subvertpy -name 'lib*.so' -delete pydoctor --introspect-c-modules -c subvertpy.cfg --make-html subvertpy - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 diff --git a/wc/src/adm.rs b/wc/src/adm.rs new file mode 100644 index 00000000..d1731fe2 --- /dev/null +++ b/wc/src/adm.rs @@ -0,0 +1,95 @@ +//! 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 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 = (path, write_lock=false, depth=0))] + fn init(path: &Bound, write_lock: bool, depth: i32) -> PyResult { + let path_str = subvertpy_util::py_to_svn_abspath(path)?; + let adm = subversion::wc::Adm::open(&path_str, write_lock, depth).map_err(svn_err_to_py)?; + Ok(Self { inner: adm }) + } + + /// 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. + /// + /// Calls the deprecated ``svn_wc_process_committed_queue`` which takes + /// an ``svn_wc_adm_access_t`` rather than an ``svn_wc_context_t``. + 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 { + // Drop closes the adm baton automatically + Ok(false) + } +} 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::()?;