├── .github └── workflows │ └── test.yaml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── YaDiskClient ├── YaDiskClient.py └── __init__.py ├── setup.py └── tests └── test_yaDisk.py /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | # You can use PyPy versions in python-version. 11 | # For example, pypy-2.7 and pypy-3.8 12 | matrix: 13 | python-version: ["2.7", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10"] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | # You can test your matrix by printing the current Python version 22 | - name: Install dependencies 23 | run: python -m pip install requests 24 | - name: Run tests 25 | env: 26 | YANDEX_LOGIN: ${{ secrets.YANDEX_LOGIN }} 27 | YANDEX_PASSWORD: ${{ secrets.YANDEX_PASSWORD }} 28 | run: python -m unittest discover -s tests -t tests -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | .idea 22 | .vscode 23 | # Installer logs 24 | pip-log.txt 25 | 26 | # Unit test / coverage reports 27 | .coverage 28 | .tox 29 | nosetests.xml 30 | 31 | # Translations 32 | *.mo 33 | 34 | # Mr Developer 35 | .mr.developer.cfg 36 | .project 37 | .pydevproject 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Efremov Alexey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | YaDiskClient 2 | ============ 3 | 4 | .. image:: https://github.com/TyVik/YaDiskClient/actions/workflows/test.yaml/badge.svg?branch=master 5 | :target: https://github.com/TyVik/YaDiskClient/actions/workflows/test.yaml?branch=master 6 | .. image:: https://coveralls.io/repos/github/TyVik/YaDiskClient/badge.svg?branch=master 7 | :target: https://coveralls.io/github/TyVik/YaDiskClient?branch=master 8 | .. image:: https://img.shields.io/pypi/pyversions/YaDiskClient.svg 9 | :target: https://pypi.python.org/pypi/YaDiskClient/ 10 | .. image:: https://img.shields.io/pypi/v/YaDiskClient.svg 11 | :target: https://pypi.python.org/pypi/YaDiskClient/ 12 | .. image:: https://img.shields.io/pypi/status/YaDiskClient.svg 13 | :target: https://pypi.python.org/pypi/YaDiskClient/ 14 | .. image:: https://img.shields.io/pypi/l/YaDiskClient.svg 15 | :target: https://pypi.python.org/pypi/YaDiskClient/ 16 | 17 | Client for Yandex.Disk based on WebDav. 18 | 19 | Install 20 | ======= 21 | 22 | pip install YaDiskClient 23 | 24 | Source code 25 | =========== 26 | 27 | `github `_ 28 | 29 | `explanatory article `_ 30 | 31 | Passwords and tokens 32 | ==================== 33 | 34 | You must use application password, not account password! Details - https://yandex.ru/support/id/authorization/app-passwords.html 35 | 36 | Also, you can create OAuth-token for your application. Details - https://yandex.ru/dev/disk/doc/dg/concepts/quickstart.html 37 | 38 | Both methods are supported. You should use method `set_login` or `set_token` before start. 39 | 40 | Using API 41 | ========= 42 | 43 | :: 44 | 45 | from YaDiskClient.YaDiskClient import YaDisk 46 | disk = YaDisk() 47 | disk.set_auth(login, password) 48 | 49 | """ 50 | Library also supports token authorization via: 51 | disk.set_token(token) 52 | """ 53 | 54 | disk.df() # show used and available space 55 | 56 | disk.ls(path) # list of files/folder with attributes 57 | disk.mkdir(path) # create directory 58 | 59 | disk.rm(path) # remove file or directory 60 | disk.cp(src, dst) # copy from src to dst 61 | disk.mv(src, dst) # move from src to dst 62 | 63 | disk.upload(src, dst) # upload local file src to remote file dst 64 | disk.download(src, dst) # download remote file src to local file dst 65 | 66 | disk.publish_doc(path) # return public url 67 | disk.hide_doc(path) # remove public url form Yandex Disk 68 | 69 | Tests 70 | ===== 71 | 72 | For run tests: 73 | 1. Set Yandex username and password in file tests/test_YaDiskClient.py 74 | 2. python -m unittest discover -s tests -t tests 75 | -------------------------------------------------------------------------------- /YaDiskClient/YaDiskClient.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from warnings import warn 4 | 5 | from requests import request 6 | import xml.etree.ElementTree as ET 7 | 8 | 9 | class YaDiskException(Exception): 10 | """Common exception class for YaDisk. Arg 'code' have code of HTTP Error.""" 11 | code = None 12 | 13 | def __init__(self, code, text): 14 | super(YaDiskException, self).__init__(text) 15 | self.code = code 16 | 17 | def __str__(self): 18 | return "{code}. {message}".format(code=self.code, message=super(YaDiskException, self).__str__()) 19 | 20 | 21 | class YaDiskXML(object): 22 | namespaces = {'d': "DAV:"} 23 | 24 | def find(self, node, path): 25 | """Wrapper for lxml`s find.""" 26 | 27 | return node.find(path, namespaces=self.namespaces) 28 | 29 | def xpath(self, node, path): 30 | """Wrapper for lxml`s xpath.""" 31 | 32 | return node.xpath(path, namespaces=self.namespaces) 33 | 34 | 35 | def _check_dst_absolute(dst): 36 | if dst[0] != '/': 37 | raise YaDiskException(400, "Destination path must be absolute") 38 | 39 | 40 | class YaDisk(object): 41 | """Main object for work with Yandex.disk.""" 42 | 43 | token = None 44 | login = None 45 | password = None 46 | url = "https://webdav.yandex.ru/" 47 | namespaces = {'d': 'DAV:'} 48 | 49 | def set_token(self, token): 50 | self.token = token 51 | self.login = None 52 | self.password = None 53 | 54 | def set_auth(self, login, password): 55 | self.token = None 56 | self.login = login 57 | self.password = password 58 | 59 | def _sendRequest(self, type, addUrl="/", addHeaders={}, data=None): 60 | if self.token is None and (self.login is None or self.password is None): 61 | raise YaDiskException(400, "Specify token or login/password for Yandex.Disk account.") 62 | 63 | headers = {"Accept": "*/*"} 64 | auth = None 65 | if self.token is not None: 66 | headers["Authorization"] = "OAuth %s".format(self.token) 67 | else: 68 | auth = (self.login, self.password) 69 | 70 | headers.update(addHeaders) 71 | url = self.url + addUrl 72 | return request(type, url, headers=headers, auth=auth, data=data) 73 | 74 | def ls(self, path, offset=None, amount=None): 75 | """ 76 | Return list of files/directories. Each item is a dict. 77 | Keys: 'path', 'creationdate', 'displayname', 'length', 'lastmodified', 'isDir'. 78 | """ 79 | 80 | def parseContent(content): 81 | result = [] 82 | root = ET.fromstring(content) 83 | for response in root.findall('.//d:response', namespaces=self.namespaces): 84 | node = { 85 | 'path': response.find("d:href", namespaces=self.namespaces).text, 86 | 'creationdate': response.find("d:propstat/d:prop/d:creationdate", namespaces=self.namespaces).text, 87 | 'displayname': response.find("d:propstat/d:prop/d:displayname", namespaces=self.namespaces).text, 88 | 'lastmodified': response.find("d:propstat/d:prop/d:getlastmodified", namespaces=self.namespaces).text, 89 | 'isDir': response.find("d:propstat/d:prop/d:resourcetype/d:collection", namespaces=self.namespaces) != None 90 | } 91 | if not node['isDir']: 92 | node['length'] = response.find("d:propstat/d:prop/d:getcontentlength", namespaces=self.namespaces).text 93 | node['etag'] = response.find("d:propstat/d:prop/d:getetag", namespaces=self.namespaces).text 94 | node['type'] = response.find("d:propstat/d:prop/d:getcontenttype", namespaces=self.namespaces).text 95 | result.append(node) 96 | return result 97 | 98 | url = path 99 | if (offset is not None) and (amount is not None): 100 | url += "?offset={offset}&amount={amount}".format(offset=offset, amount=amount) 101 | resp = self._sendRequest("PROPFIND", url, {'Depth': '1'}) 102 | if resp.status_code == 207: 103 | return parseContent(resp.content) 104 | else: 105 | raise YaDiskException(resp.status_code, resp.content) 106 | 107 | def df(self): 108 | """Return dict with size of Ya.Disk. Keys: 'available', 'used'.""" 109 | 110 | def parseContent(content): 111 | root = ET.fromstring(content) 112 | return { 113 | 'available': root.find(".//d:quota-available-bytes", namespaces=self.namespaces).text, 114 | 'used': root.find(".//d:quota-used-bytes", namespaces=self.namespaces).text 115 | } 116 | 117 | data = """ 118 | 119 | 120 | 121 | 122 | 123 | 124 | """ 125 | resp = self._sendRequest("PROPFIND", "/", {'Depth': '0'}, data) 126 | if resp.status_code == 207: 127 | return parseContent(resp.content) 128 | else: 129 | raise YaDiskException(resp.status_code, resp.content) 130 | 131 | def mkdir(self, path): 132 | """Create directory. All part of path must be exists. Raise exception when path already exists.""" 133 | 134 | resp = self._sendRequest("MKCOL", path) 135 | if resp.status_code != 201: 136 | if resp.status_code == 409: 137 | raise YaDiskException(409, "Part of path {} does not exists".format(path)) 138 | elif resp.status_code == 405: 139 | raise YaDiskException(405, "Path {} already exists".format(path)) 140 | else: 141 | raise YaDiskException(resp.status_code, resp.content) 142 | 143 | def rm(self, path): 144 | """Delete file or directory.""" 145 | 146 | resp = self._sendRequest("DELETE", path) 147 | # By documentation server must return 200 "OK", but I get 204 "No Content". 148 | # Anyway file or directory have been removed. 149 | if not (resp.status_code in (200, 204)): 150 | raise YaDiskException(resp.status_code, resp.content) 151 | 152 | def cp(self, src, dst): 153 | """Copy file or directory.""" 154 | 155 | _check_dst_absolute(dst) 156 | resp = self._sendRequest("COPY", src, {'Destination': dst}) 157 | if resp.status_code not in (201, 202): 158 | raise YaDiskException(resp.status_code, resp.content) 159 | 160 | def mv(self, src, dst): 161 | """Move file or directory.""" 162 | 163 | _check_dst_absolute(dst) 164 | resp = self._sendRequest("MOVE", src, {'Destination': dst}) 165 | if resp.status_code not in (201, 202): 166 | raise YaDiskException(resp.status_code, resp.content) 167 | 168 | def upload(self, file, path): 169 | """Upload file.""" 170 | 171 | with open(file, "rb") as f: 172 | resp = self._sendRequest("PUT", path, data=f) 173 | if resp.status_code not in (201, 202): 174 | raise YaDiskException(resp.status_code, resp.content) 175 | 176 | def download(self, path, file): 177 | """Download remote file to disk.""" 178 | 179 | resp = self._sendRequest("GET", path) 180 | if resp.status_code == 200: 181 | with open(file, "wb") as f: 182 | f.write(resp.content) 183 | else: 184 | raise YaDiskException(resp.status_code, resp.content) 185 | 186 | def publish(self, path): 187 | """Publish file or folder and return public url""" 188 | 189 | def parseContent(content): 190 | root = ET.fromstring(content) 191 | prop = root.find(".//d:prop", namespaces=self.namespaces) 192 | return prop.find("{urn:yandex:disk:meta}public_url").text.strip() 193 | 194 | data = """ 195 | 196 | 197 | 198 | true 199 | 200 | 201 | 202 | """ 203 | 204 | _check_dst_absolute(path) 205 | resp = self._sendRequest("PROPPATCH", addUrl=path, data=data) 206 | if resp.status_code == 207: 207 | return parseContent(resp.content) 208 | else: 209 | raise YaDiskException(resp.status_code, resp.content) 210 | 211 | def unpublish(self, path): 212 | """Make public file or folder private (delete public url)""" 213 | 214 | data = """ 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | """ 223 | 224 | _check_dst_absolute(path) 225 | resp = self._sendRequest("PROPPATCH", addUrl=path, data=data) 226 | if resp.status_code == 207: 227 | pass 228 | else: 229 | raise YaDiskException(resp.status_code, resp.content) 230 | 231 | def publish_doc(self, path): 232 | warn('This method was deprecated in favor method "publish"', DeprecationWarning, stacklevel=2) 233 | return self.publish(path) 234 | 235 | def hide_doc(self, path): 236 | warn('This method was deprecated in favor method "unpublish"', DeprecationWarning, stacklevel=2) 237 | return self.unpublish(path) 238 | -------------------------------------------------------------------------------- /YaDiskClient/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Client for Yandex.Disk. 3 | """ 4 | __version__ = '1.0.1' 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #-*- coding: utf-8 -*- 2 | from setuptools import setup, find_packages 3 | import YaDiskClient 4 | 5 | setup( 6 | name='YaDiskClient', 7 | version=YaDiskClient.__version__, 8 | include_package_data=True, 9 | py_modules=['YaDiskClient'], 10 | url='https://github.com/TyVik/YaDiskClient', 11 | license='MIT', 12 | author='TyVik', 13 | author_email='tyvik8@gmail.com', 14 | description='Clent for Yandex.Disk', 15 | long_description=open('README.rst').read(), 16 | install_requires=['requests'], 17 | packages=find_packages(), 18 | keywords='Yandex.Disk, webdav, client, python, Yandex', 19 | # test_suite='YaDiskClient.test_YaDiskClient' # this line is commented because tests needs Yandex login and password 20 | classifiers=[ 21 | 'Development Status :: 5 - Production/Stable', 22 | 'Environment :: Console', 23 | 'Intended Audience :: Developers', 24 | 'Intended Audience :: System Administrators', 25 | 'License :: OSI Approved :: MIT License', 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python :: 2.7', 28 | 'Programming Language :: Python :: 3.3', 29 | 'Programming Language :: Python :: 3.4', 30 | 'Programming Language :: Python :: 3.5', 31 | 'Programming Language :: Python :: 3.6', 32 | 'Programming Language :: Python :: 3.7', 33 | 'Programming Language :: Python :: 3.8', 34 | 'Programming Language :: Python :: 3.9', 35 | 'Programming Language :: Python :: 3.10', 36 | 'Programming Language :: Python', 37 | 'Topic :: Internet', 38 | 'Topic :: Utilities', 39 | 'Topic :: System :: Archiving :: Backup', 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /tests/test_yaDisk.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | import os 5 | import random 6 | import string 7 | import unittest 8 | 9 | from YaDiskClient.YaDiskClient import YaDisk, YaDiskException 10 | 11 | 12 | LOGIN = os.environ.get('YANDEX_LOGIN') 13 | PASSWORD = os.environ.get('YANDEX_PASSWORD') 14 | 15 | 16 | class TestYaDisk(unittest.TestCase): 17 | disk = None 18 | remote_folder = None 19 | remote_file = None 20 | remote_path = None 21 | 22 | @classmethod 23 | def setUpClass(cls): 24 | cls.disk = YaDisk() 25 | cls.disk.set_auth(LOGIN, PASSWORD) 26 | # take any file in work directory 27 | for item in os.listdir('.'): 28 | if os.path.isfile(item): 29 | cls.remote_file = item 30 | break 31 | 32 | cls.remote_folder = '/TestYaDisk_{}'.format(''.join(random.choice(string.ascii_uppercase) for _ in range(6))) 33 | cls.remote_path = "{folder}/{file}".format(folder=cls.remote_folder, file=cls.remote_file) 34 | 35 | def test_00main(self): 36 | def mkdir(remote_folder): 37 | self.disk.mkdir(remote_folder) 38 | 39 | try: 40 | self.disk.mkdir('{folder}/dir/bad'.format(folder=remote_folder)) 41 | except YaDiskException as e: 42 | self.assertEqual(e.code, 409) 43 | 44 | try: 45 | self.disk.mkdir(remote_folder) 46 | except YaDiskException as e: 47 | self.assertEqual(e.code, 405) 48 | 49 | tmp_remote_path = "{path}~".format(path=self.remote_path) 50 | tmp_local_file = "{file}~".format(file=self.remote_file) 51 | 52 | mkdir(self.remote_folder) 53 | self.disk.upload(self.remote_file, self.remote_path) 54 | 55 | self.disk.mv(self.remote_path, tmp_remote_path) 56 | self.assertRaises(YaDiskException, self.disk.mv, self.remote_path, tmp_remote_path) 57 | 58 | self.assertRaises(YaDiskException, self.disk.cp, self.remote_path, self.remote_path) 59 | self.disk.cp(tmp_remote_path, self.remote_path) 60 | 61 | self.assertRaises(YaDiskException, self.disk.ls, 'fake_folder') 62 | ls = self.disk.ls(self.remote_folder) 63 | self.assertEqual(len(ls), 3) 64 | self.assertEqual(ls[2]['length'], ls[1]['length']) 65 | 66 | ls = self.disk.ls(self.remote_folder, offset=100, amount=5) 67 | self.assertEqual(len(ls), 1) # only root element 68 | 69 | self.disk.download(self.remote_path, tmp_local_file) 70 | 71 | self.disk.rm(self.remote_folder) 72 | self.assertRaises(YaDiskException, self.disk.rm, self.remote_folder) 73 | self.assertRaises(YaDiskException, self.disk.download, self.remote_path, tmp_local_file) 74 | 75 | os.remove(tmp_local_file) 76 | 77 | def test_df(self): 78 | result = self.disk.df() 79 | self.assertIsInstance(result, dict) 80 | self.assertTrue('available' in result.keys()) 81 | self.assertTrue('used' in result.keys()) 82 | 83 | def test_publish(self): 84 | fake_file = '/fake_file.txt' 85 | 86 | self.disk.mkdir(self.remote_folder) 87 | try: 88 | self.disk.upload(self.remote_file, self.remote_path) 89 | self.assertRaises(YaDiskException, self.disk.upload, self.remote_file, '') 90 | self.disk.publish(self.remote_path) 91 | self.assertRaises(YaDiskException, self.disk.publish, fake_file) 92 | 93 | # failed for validate absolute path 94 | self.assertRaises(YaDiskException, self.disk.publish, 'fake_file.txt') 95 | 96 | self.disk.unpublish(self.remote_path) 97 | self.assertRaises(YaDiskException, self.disk.unpublish, fake_file) 98 | self.disk.unpublish(self.remote_path) 99 | finally: 100 | self.disk.rm(self.remote_folder) 101 | 102 | def test_deprecation(self): 103 | self.disk.mkdir(self.remote_folder) 104 | try: 105 | self.disk.upload(self.remote_file, self.remote_path) 106 | self.disk.publish_doc(self.remote_path) 107 | self.disk.hide_doc(self.remote_path) 108 | finally: 109 | self.disk.rm(self.remote_folder) 110 | 111 | def test_bad_auth(self): 112 | try: 113 | disk = YaDisk() 114 | disk.df() 115 | except YaDiskException as e: 116 | self.assertTrue(str(e).startswith(str(e.code))) 117 | --------------------------------------------------------------------------------