Skip to content

Commit 1c77d8a

Browse files
committed
sync/_async(feat[AsyncSvnSync]): Add async SVN sync class
why: Complete Phase 7 asyncio support with repository synchronization what: - Add AsyncSvnSync inheriting from AsyncBaseSync - Implement async obtain(), update_repo(), get_revision() - Support SVN auth credentials (username, password, svn_trust_cert)
1 parent 8c6eb7a commit 1c77d8a

1 file changed

Lines changed: 270 additions & 0 deletions

File tree

src/libvcs/sync/_async/svn.py

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
"""Async tool to manage a local SVN (Subversion) working copy from a repository.
2+
3+
Async equivalent of :mod:`libvcs.sync.svn`.
4+
5+
Note
6+
----
7+
This is an internal API not covered by versioning policy.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import logging
13+
import os
14+
import pathlib
15+
import re
16+
import typing as t
17+
18+
from libvcs._internal.async_run import AsyncProgressCallbackProtocol
19+
from libvcs._internal.types import StrPath
20+
from libvcs.cmd._async.svn import AsyncSvn
21+
from libvcs.sync._async.base import AsyncBaseSync
22+
from libvcs.sync.svn import SvnUrlRevFormattingError
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
class AsyncSvnSync(AsyncBaseSync):
28+
"""Async tool to manage a local SVN working copy from a SVN repository.
29+
30+
Async equivalent of :class:`~libvcs.sync.svn.SvnSync`.
31+
32+
Examples
33+
--------
34+
>>> import asyncio
35+
>>> async def example():
36+
... repo = AsyncSvnSync(
37+
... url="svn://svn.example.com/repo",
38+
... path="/tmp/myrepo",
39+
... )
40+
... await repo.obtain()
41+
... await repo.update_repo()
42+
>>> # asyncio.run(example())
43+
"""
44+
45+
bin_name = "svn"
46+
schemes = ("svn", "svn+ssh", "svn+http", "svn+https", "svn+svn")
47+
cmd: AsyncSvn
48+
49+
def __init__(
50+
self,
51+
*,
52+
url: str,
53+
path: StrPath,
54+
progress_callback: AsyncProgressCallbackProtocol | None = None,
55+
**kwargs: t.Any,
56+
) -> None:
57+
"""Initialize async SVN working copy manager.
58+
59+
Parameters
60+
----------
61+
url : str
62+
URL of the SVN repository
63+
path : str | Path
64+
Local path for the working copy
65+
progress_callback : AsyncProgressCallbackProtocol, optional
66+
Async callback for progress updates
67+
username : str, optional
68+
Username for SVN authentication
69+
password : str, optional
70+
Password for SVN authentication
71+
svn_trust_cert : bool, optional
72+
Trust the SVN server certificate, default False
73+
"""
74+
self.svn_trust_cert = kwargs.pop("svn_trust_cert", False)
75+
self.username = kwargs.get("username")
76+
self.password = kwargs.get("password")
77+
self.rev = kwargs.get("rev")
78+
79+
super().__init__(
80+
url=url, path=path, progress_callback=progress_callback, **kwargs
81+
)
82+
83+
self.cmd = AsyncSvn(path=path, progress_callback=self.progress_callback)
84+
85+
async def obtain(
86+
self, quiet: bool | None = None, *args: t.Any, **kwargs: t.Any
87+
) -> None:
88+
"""Check out a working copy from a SVN repository asynchronously.
89+
90+
Async equivalent of :meth:`~libvcs.sync.svn.SvnSync.obtain`.
91+
92+
Parameters
93+
----------
94+
quiet : bool, optional
95+
Suppress output
96+
"""
97+
url, rev = self.url, self.rev
98+
99+
if rev is not None:
100+
kwargs["revision"] = rev
101+
if self.svn_trust_cert:
102+
kwargs["trust_server_cert"] = True
103+
104+
await self.cmd.checkout(
105+
url=url,
106+
username=self.username,
107+
password=self.password,
108+
non_interactive=True,
109+
quiet=True,
110+
check_returncode=True,
111+
**kwargs,
112+
)
113+
114+
async def get_revision_file(self, location: str) -> int:
115+
"""Return revision for a file asynchronously.
116+
117+
Async equivalent of :meth:`~libvcs.sync.svn.SvnSync.get_revision_file`.
118+
119+
Parameters
120+
----------
121+
location : str
122+
Path to the file
123+
124+
Returns
125+
-------
126+
int
127+
Revision number
128+
"""
129+
current_rev = await self.cmd.info(target=location)
130+
131+
INI_RE = re.compile(r"^([^:]+):\s+(\S.*)$", re.MULTILINE)
132+
133+
info_list = INI_RE.findall(current_rev)
134+
return int(dict(info_list)["Revision"])
135+
136+
async def get_revision(self, location: str | None = None) -> int:
137+
"""Return maximum revision for all files under a given location asynchronously.
138+
139+
Async equivalent of :meth:`~libvcs.sync.svn.SvnSync.get_revision`.
140+
141+
Parameters
142+
----------
143+
location : str, optional
144+
Path to check, defaults to self.url
145+
146+
Returns
147+
-------
148+
int
149+
Maximum revision number
150+
"""
151+
if not location:
152+
location = self.url
153+
154+
if pathlib.Path(location).exists() and not pathlib.Path(location).is_dir():
155+
return await self.get_revision_file(location)
156+
157+
# Note: taken from setuptools.command.egg_info
158+
revision = 0
159+
160+
for base, dirs, _files in os.walk(location):
161+
if ".svn" not in dirs:
162+
dirs[:] = []
163+
continue # no sense walking uncontrolled subdirs
164+
dirs.remove(".svn")
165+
entries_fn = pathlib.Path(base) / ".svn" / "entries"
166+
if not entries_fn.exists():
167+
# FIXME: should we warn?
168+
continue
169+
170+
dirurl, localrev = await self._get_svn_url_rev(base)
171+
172+
if base == location:
173+
assert dirurl is not None
174+
base = dirurl + "/" # save the root url
175+
elif not dirurl or not dirurl.startswith(base):
176+
dirs[:] = []
177+
continue # not part of the same svn tree, skip it
178+
revision = max(revision, localrev)
179+
return revision
180+
181+
async def update_repo(
182+
self,
183+
dest: str | None = None,
184+
*args: t.Any,
185+
**kwargs: t.Any,
186+
) -> None:
187+
"""Fetch changes from SVN repository to local working copy asynchronously.
188+
189+
Async equivalent of :meth:`~libvcs.sync.svn.SvnSync.update_repo`.
190+
191+
Parameters
192+
----------
193+
dest : str, optional
194+
Destination path (unused, for API compatibility)
195+
"""
196+
self.ensure_dir()
197+
if pathlib.Path(self.path / ".svn").exists():
198+
await self.cmd.checkout(
199+
url=self.url,
200+
username=self.username,
201+
password=self.password,
202+
non_interactive=True,
203+
quiet=True,
204+
check_returncode=True,
205+
**kwargs,
206+
)
207+
else:
208+
await self.obtain()
209+
await self.update_repo()
210+
211+
async def _get_svn_url_rev(self, location: str) -> tuple[str | None, int]:
212+
"""Get SVN URL and revision from a working copy location asynchronously.
213+
214+
Async equivalent of :meth:`~libvcs.sync.svn.SvnSync._get_svn_url_rev`.
215+
216+
Parameters
217+
----------
218+
location : str
219+
Path to the working copy
220+
221+
Returns
222+
-------
223+
tuple[str | None, int]
224+
Repository URL and revision number
225+
"""
226+
svn_xml_url_re = re.compile(r'url="([^"]+)"')
227+
svn_rev_re = re.compile(r'committed-rev="(\d+)"')
228+
svn_info_xml_rev_re = re.compile(r'\s*revision="(\d+)"')
229+
svn_info_xml_url_re = re.compile(r"<url>(.*)</url>")
230+
231+
entries_path = pathlib.Path(location) / ".svn" / "entries"
232+
if entries_path.exists():
233+
with entries_path.open() as f:
234+
data = f.read()
235+
else: # subversion >= 1.7 does not have the 'entries' file
236+
data = ""
237+
238+
url = None
239+
if data.startswith(("8", "9", "10")):
240+
entries = list(map(str.splitlines, data.split("\n\x0c\n")))
241+
del entries[0][0] # get rid of the '8'
242+
url = entries[0][3]
243+
revs = [int(d[9]) for d in entries if len(d) > 9 and d[9]] + [0]
244+
elif data.startswith("<?xml"):
245+
match = svn_xml_url_re.search(data)
246+
if not match:
247+
raise SvnUrlRevFormattingError(data=data)
248+
url = match.group(1) # get repository URL
249+
revs = [int(m.group(1)) for m in svn_rev_re.finditer(data)] + [0]
250+
else:
251+
try:
252+
# Note that using get_remote_call_options is not necessary here
253+
# because `svn info` is being run against a local directory.
254+
# We don't need to worry about making sure interactive mode
255+
# is being used to prompt for passwords, because passwords
256+
# are only potentially needed for remote server requests.
257+
xml = await AsyncSvn(path=pathlib.Path(location).parent).info(
258+
target=pathlib.Path(location),
259+
xml=True,
260+
)
261+
match = svn_info_xml_url_re.search(xml)
262+
assert match is not None
263+
url = match.group(1)
264+
revs = [int(m.group(1)) for m in svn_info_xml_rev_re.finditer(xml)]
265+
except Exception:
266+
url, revs = None, []
267+
268+
rev = max(revs) if revs else 0
269+
270+
return url, rev

0 commit comments

Comments
 (0)