Заливка изменений из SVN репозитория

Есть сайт, который хранится в Subversion репозитории. Есть хостинг с ssh или ftp доступом. Задача: безопасно заливать на хостинг новые и измененные файлы сайта.

Для работы с SVN используется модуль pysvn (пакет python-svn). Если есть доступ через ssh, будем заливать файлы с помощью rsync, который запускается скриптом как отдельный процесс. Для работы с FTP используется стандартный модуль ftplib.

Файл конфигурации скрипта (~/.svn-rsync/config) содержит набор заданий, при каждом запуске выполняется одно из них:

# Каждая секция - отдельное задание.
# Имя задания указывается в качестве аргумента при запуске скрипта.
[gelin.ru]
# URL SVN репозитория или каталога в репозитории,
# должен завершаться слешем.
svn_url = file:///home/gelin/svn/web/gelin.ru/trunk/
# Месторасположение файла, где будет хранится
# номер последней обработанной ревизии.
# Таким образом, скрипт обрабатывает только файлы,
# измененные с момента последнего запуска.
# Если файл отсутствует, будет автоматически определена ревизия,
# в которой был создан данный путь в репозитории.
revision_file = ~/.svn-rsync/gelin.ru.rev
# Цель синхронизации для rsync.
# Содержит имя пользователя, адрес удаленного сервера, каталог.
# Подробности смотрите в man rsync.
rsync_dest = feshost.ru:public_html/

[test]
svn_url = file:///home/gelin/svn/web/test/trunk/
revision_file = ~/.svn-rsync/test.rev
# URL FTP сервера.
# Содержит имя хоста (или IP адрес) сервера, каталог.
# Логин и пароль берется из ~/.netrc, см. man netrc, man ftp.
ftp_url = ftp://localhost/public_html/
# Можно указать логин и пароль в URL.
#ftp_url = ftp://test:123@localhost/public_html/

Вот сам скрипт:

#!/usr/bin/python
# (c) 2008 Denis Nelubin aka Gelin
# This script exports last changed files from Subversion repository
# and uploads the files to remote server using rsync or ftp

import sys
import pysvn
import os
import os.path
import tempfile
import urlparse
import ConfigParser
import ftplib
import netrc

CONFIG = "~/.svn-rsync/config"

class Config:

    def __init__(self, target='default'):
        self._config = ConfigParser.ConfigParser()
        self._section = target
        self._config.read(os.path.expanduser(CONFIG))

    def __getattr__(self, name):
        if name == 'svn_revision':
            try:
                return self._read_revision
            except:
                try:
                    self._read_revision = int(open(
                            os.path.expanduser(self.revision_file)).read())
                except:
                    self._read_revision = 0
                return self._read_revision
        return self._config.get(self._section, name)

    def save_revision(self, revision):
        open(os.path.expanduser(self.revision_file), 'wt').write(str(revision))

    def targets(self):
        return self._config.sections()

class SVN:

    def __init__(self, config):
        self._config = config
        self._client = pysvn.Client()

    def export(self, todir):
        from_rev = self._first_revision()
        to_rev = self._last_revision()
        summary = self._summary(from_rev, to_rev)
        for item in summary:
            if item['node_kind'] == pysvn.node_kind.file and \
                    item['summarize_kind'] != pysvn.diff_summarize_kind.delete:
                print '%(summarize_kind)s\t%(path)s' % item
                path = item['path']
                file = os.path.join(todir, path)
                try:
                    os.makedirs(os.path.dirname(file))
                except:
                    pass    #dirs can be created multiple times
                self._client.export(self._svnurl(path),
                        file,
                        revision=to_rev,
                        recurse=False)

    def _first_revision(self):
        if self._config.svn_revision > 0:
            return pysvn.Revision(pysvn.opt_revision_kind.number,
                self._config.svn_revision)
        log = self._client.log(self._config.svn_url,
                pysvn.Revision(pysvn.opt_revision_kind.number, 0),
                pysvn.Revision(pysvn.opt_revision_kind.head),
                limit=1)
        return log[0]['revision']

    def _last_revision(self):
        info = self._client.info2(self._config.svn_url, recurse=False)[0][1]
        self.last_revision = info['last_changed_rev'].number
        return info['last_changed_rev']

    def _summary(self, from_rev, to_rev):
        summary = self._client.diff_summarize(self._config.svn_url,
                revision1=from_rev, revision2=to_rev)
        return summary

    def _svnurl(self, file):
        return urlparse.urljoin(self._config.svn_url, file)

class Uploader:

    def __init__(self, config):
        self._config = config

    def upload(self, fromdir):
        if hasattr(self._config, 'rsync_dest'):
            self._rsync(fromdir)
        elif hasattr(self._config, 'ftp_url'):
            self._ftp(fromdir)

    def _rsync(self, fromdir):
        print 'Syncing to %s' % self._config.rsync_dest
        status = os.spawnlp(os.P_WAIT, 'rsync', 'rsync', '-rv', os.path.join(fromdir, ''),
                self._config.rsync_dest)
        if not status == os.EX_OK:
            raise OSError

    def _ftp(self, fromdir):
        print 'Uploading to %s' % self._config.ftp_url
        client = self._createFTP(self._config.ftp_url)
        self._uploadFTP(client, fromdir)

    def _createFTP(self, url):
        ftp = ftplib.FTP()
        urlparts = urlparse.urlparse(url)
        ftp.connect(urlparts.hostname, urlparts.port)
        if urlparts.username:
            ftp.login(urlparts.username, urlparts.password)
        else:
            auths = netrc.netrc().authenticators(urlparts.hostname)
            if auths:
                ftp.login(auths[0], auths[2], auths[1])
            else:
                ftp.login()
        ftp.cwd(urlparts.path.strip('/'))
        return ftp

    def _uploadFTP(self, client, fromdir):
        pwd = client.pwd()
        for root, dirs, files in os.walk(fromdir):
            relroot = self._relative_path(fromdir, root)
            client.cwd(relroot)
            ftpfiles = []
            client.dir(lambda(line): ftpfiles.append(self._parse_ftp_dir(line)))
            for name in files:
                print os.path.join(relroot, name)
                client.storbinary('STOR %s' % name, open(os.path.join(root, name)))
            for name in dirs:
                if not name in ftpfiles:
                    client.mkd(name)
            client.cwd(pwd)

    def _relative_path(self, base, path):
        (head, tail) = (path, '')
        result = []
        while not os.path.samefile(head, base) and head:
            (head, tail) = os.path.split(head)
            result.insert(0, tail)
        result.append('')
        return os.path.join(*result)

    def _parse_ftp_dir(self, line):
        #only UNIX listing is supported
        #copied from ftpmirror.py
        words = line.split(None, 8)
        if len(words) >= 6:
            filename = words[-1].lstrip()
            i = filename.find(" -> ")   #symlink support?
            if i >= 0:
                filename = filename[:i]
            return filename

def rmdir(directory):
    for root, dirs, files in os.walk(directory, topdown=False):
        for name in files:
            os.remove(os.path.join(root, name))
        for name in dirs:
            os.rmdir(os.path.join(root, name))
    os.rmdir(directory)

if __name__ == '__main__':

    if len(sys.argv) < 2:
        print "Usage: svn-rsync target"
        print "Available targets:"
        print "\n".join(Config().targets())
        sys.exit(2)

    config = Config(sys.argv[1])

    svn = SVN(config)
    uploader = Uploader(config)
    tmpdir = tempfile.mkdtemp('svn-rsync')

    print 'Exporting from %s -r %i:HEAD' % (config.svn_url, config.svn_revision)
    svn.export(tmpdir)

    uploader.upload(tmpdir)

    config.save_revision(svn.last_revision)
    rmdir(tmpdir)

Запускаем svn-rsync gelin.ru и автоматически получаем обновление нашего сайта.