├── .gitignore ├── .nutstore.config.example ├── AUTHORS ├── ChangeLog ├── MANIFEST.in ├── README.rst ├── TODO.md ├── docs ├── images │ ├── add_app.png │ ├── get_key.png │ └── new_dir.png └── tutorial.md ├── nutstore_cli ├── __init__.py ├── cli.py ├── client │ ├── __init__.py │ ├── base.py │ ├── client.py │ ├── exceptions.py │ ├── path_helper.py │ └── utils.py ├── command_help.py ├── completer.py ├── config.py ├── context.py ├── execution.py └── utils │ ├── __init__.py │ ├── codecs.py │ ├── functional.py │ └── output.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── raw └── two_files.pickle ├── test_cli.py ├── test_client.py ├── test_file.py └── test_grammar.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .DS_Store 3 | .env* 4 | demo.py 5 | bumpversion 6 | Makefile 7 | docker-compose.yaml 8 | Dockerfile 9 | 10 | *egg-info 11 | .eggs 12 | .idea 13 | venv 14 | dist 15 | build 16 | -------------------------------------------------------------------------------- /.nutstore.config.example: -------------------------------------------------------------------------------- 1 | username=i@example.com 2 | key=a2mqieixzkm5t5h4 3 | working_dir=/photos 4 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Kxrr 2 | -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | * Set cli as default subcommand 5 | * Convert unicode type to string type explicitly due to lack of supports in easywebdav 6 | * Add contributing section in readme 7 | 8 | 0.4.1 9 | ----- 10 | 11 | * Release 0.4.1 12 | * Disable default command due to not working appropriately 13 | 14 | 0.4.0 15 | ----- 16 | 17 | * Add upload and download sub commands 18 | 19 | 0.3.6 20 | ----- 21 | 22 | * Display modify time as local timezone 23 | * Use humanbytes 24 | * List nested directory 25 | * Fix docs format 26 | 27 | 0.3.5 28 | ----- 29 | 30 | * Add config file support 31 | 32 | 0.3.4 33 | ----- 34 | 35 | * Add tutorial doc 36 | 37 | 0.3.3 38 | ----- 39 | 40 | * Hide prompt if has default 41 | * Read variable from environment 42 | 43 | 0.3.2 44 | ----- 45 | 46 | * Abstract NutPath 47 | * Release 0.3.1 48 | 49 | 0.3.1 50 | ----- 51 | 52 | * Add doc in --help 53 | 54 | 0.3.0 55 | ----- 56 | 57 | * Release 0.3.0 58 | * Beautify command help 59 | * Introduce tabulate library 60 | 61 | 0.2.2 62 | ----- 63 | 64 | * Add completer for commands 65 | * Add README.rst 66 | 67 | 0.2.1 68 | ----- 69 | 70 | * Bump version to 0.2.1 71 | * Setup test env 72 | 73 | 0.2.0 74 | ----- 75 | 76 | * Fallback to python2.7 due to \`process-dependency-links\` had be deprecated 77 | * Update docs 78 | * Fix error path 79 | * Using pbr to pack 80 | * Grep supporting 81 | * Bump version to 0.1.0 82 | * Use \`tabulate\` to output columns 83 | * Add setup.py 84 | * Ship to python3 85 | * Handle some exceptions 86 | * Display ls command by FileTable 87 | * Change directory switch strategy 88 | * Clean \`prompt\_toolkit\` 89 | * First blood 90 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include README.md 3 | include setup.cfg 4 | include setup.py 5 | 6 | recursive-include *.py 7 | 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | recursive-exclude * .*.sw[a-z] 11 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | NutStore CLI 2 | ============ 3 | 4 | |VERSION| |PYVERSION| 5 | 6 | 坚果云 WebDAV 命令行工具 7 | 8 | A command-line interface for `NutStore`_ based on WebDAV. 9 | 10 | Inspired by `http-prompt`_. 11 | 12 | 13 | Screenshot 14 | ----------- 15 | 16 | https://asciinema.org/a/T0AwPltSoPZYSYQ7OHQng15rg 17 | 18 | 19 | Install 20 | ------- 21 | 22 | **Only works on Python2.7 now** 23 | 24 | .. code:: 25 | 26 | $ pip install nutstore-cli 27 | 28 | 29 | How to setup WebDAV on NutStore 30 | ------------------------------- 31 | 32 | https://github.com/Kxrr/nutstore-cli/blob/master/docs/tutorial.md 33 | 34 | 35 | Usage 36 | ----- 37 | .. code:: 38 | 39 | $ nutstore-cli --help 40 | 41 | 42 | Config 43 | ------ 44 | 45 | Config by a config file 46 | ^^^^^^^^^^^^^^^^^^^^^^^ 47 | 48 | Nutstore-cli will try to load the config file in ``~/.nutstore.config`` whose format should like `.nutstore.config.example`_. 49 | 50 | Config by environment variable 51 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 52 | 53 | * NUTSTORE_USERNAME 54 | * NUTSTORE_KEY 55 | * NUTSTORE_WORKING_DIR 56 | 57 | Config by command-line args 58 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 59 | 60 | You can pass args like ``--username=i@example.com`` directly to nutstore-cli, 61 | use ``Nutstore-cli --help`` to see all command-line args that we support. 62 | 63 | 64 | Debugging 65 | --------- 66 | 67 | Set the environment variable ``DEBUG`` to ``1`` to print the debug output. 68 | 69 | Contributing 70 | ------------ 71 | 72 | You're highly encouraged to participate in the development of this project. 73 | 74 | All the developing works is on the ``dev`` branch. 75 | 76 | By the way, because of lack of documents and tests, please feel free to make a issue before developing if you have any questions. 77 | 78 | TODO 79 | ---- 80 | 81 | See `TODO.md`_ 82 | 83 | 84 | .. |PYVERSION| image:: https://img.shields.io/badge/python-2.7-blue.svg 85 | .. |VERSION| image:: https://img.shields.io/badge/version-0.4.3-blue.svg 86 | .. |SCREENSHOT| image:: ./docs/sreenshot.png 87 | .. _NutStore: https://www.jianguoyun.com 88 | .. _http-prompt: https://github.com/eliangcs/http-prompt 89 | .. _.nutstore.config.example: https://github.com/Kxrr/nutstore-cli/blob/master/.nutstore.config.example 90 | .. _TODO.md: https://github.com/Kxrr/nutstore-cli/blob/master/TODO.md 91 | 92 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - [x] 登录逻辑 4 | - [x] 统一帮助信息 5 | - [x] Sub command support, such as `nutstore-cli upload ` 6 | - [x] `ls` Human readable size 7 | - [x] format `ls` output's time field to local timezone 8 | 9 | - [x] `nutstore-cli cli` as default command 10 | - [ ] highlight dir in `ls` output 11 | - [x] decode filename in `ls` output 12 | -------------------------------------------------------------------------------- /docs/images/add_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kxrr/nutstore-cli/1f422cd59bb3749b9190cd2b5176660ed7d9c8c4/docs/images/add_app.png -------------------------------------------------------------------------------- /docs/images/get_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kxrr/nutstore-cli/1f422cd59bb3749b9190cd2b5176660ed7d9c8c4/docs/images/get_key.png -------------------------------------------------------------------------------- /docs/images/new_dir.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kxrr/nutstore-cli/1f422cd59bb3749b9190cd2b5176660ed7d9c8c4/docs/images/new_dir.png -------------------------------------------------------------------------------- /docs/tutorial.md: -------------------------------------------------------------------------------- 1 | # How to setup Nutstore WebDAV 2 | 3 | 如何在坚果云中开启 WebDAV 支持 4 | 5 | 1. Register a Nutstore account 6 | 7 | If you don't have a Nutstore account, register one here: 8 | https://www.jianguoyun.com/d/signup 9 | 10 | * Let's assume your new account's username is `a@example.com` 11 | 12 | 13 | 2. Enable WebDAV in Nutstore Setting, get the WebDAV key 14 | 15 | * Visit: https://www.jianguoyun.com/d/account#safe 16 | * Setup like the image below 17 | 18 | ![](images/add_app.png) 19 | 20 | * Now you should have your WebDAV key 21 | 22 | ![](images/get_key.png) 23 | 24 | * As you can see, my WebDAV key is `ah2fxzeki48kw2xh` 25 | 26 | 3. Make a new directory on Nutstore 27 | 28 | * Your will see making directory button here: https://www.jianguoyun.com/ 29 | 30 | ![](images/new_dir.png) 31 | 32 | Now you have a directory named `photos` 33 | 34 | 4. Use nutstore-cli now 35 | 36 | * Until now, ou have `username`, `WebDAV key` and `new directory name` 37 | * You can start nutstore-cli with: 38 | 39 | ``` 40 | $ nutstore-cli --username a@example.com --key ah2fxzeki48kw2xh --working_dir /photos 41 | ``` 42 | 43 | 44 | * Enjoy! 45 | -------------------------------------------------------------------------------- /nutstore_cli/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import logging 3 | 4 | __author__ = 'Kxrr ' 5 | __version__ = '0.4.3' 6 | 7 | logging.getLogger('webdav').setLevel(logging.WARNING) 8 | -------------------------------------------------------------------------------- /nutstore_cli/cli.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import absolute_import 3 | import textwrap 4 | 5 | import click 6 | from prompt_toolkit import prompt 7 | from prompt_toolkit.auto_suggest import AutoSuggestFromHistory 8 | from prompt_toolkit.history import InMemoryHistory 9 | 10 | from nutstore_cli import __version__ 11 | from nutstore_cli.client.client import NutStoreClient 12 | from nutstore_cli.context import Context 13 | from nutstore_cli.completer import completer 14 | from nutstore_cli.execution import execute 15 | from nutstore_cli.utils import ( 16 | output, 17 | save_text, 18 | to_str, 19 | to_unicode, 20 | ) 21 | 22 | from nutstore_cli.config import get_config 23 | 24 | 25 | class NoPromptIfDefaultOption(click.Option): 26 | def prompt_for_value(self, ctx): 27 | default = self.get_default(ctx) 28 | if default: 29 | return default 30 | if self.is_bool_flag: 31 | return click.confirm(self.prompt) 32 | return click.prompt( 33 | self.prompt, 34 | hide_input=self.hide_input, 35 | confirmation_prompt=self.confirmation_prompt, 36 | value_proc=lambda x: self.process_value(ctx, x) 37 | ) 38 | 39 | 40 | def _launch_cli(client): 41 | """ 42 | NutStore Command Line Interface (0.4.3) 43 | 44 | NutStore WebDAV Settings: https://github.com/Kxrr/nutstore-cli/blob/master/docs/tutorial.md 45 | 46 | Project Page: https://github.com/Kxrr/nutstore-cli 47 | """ 48 | output.debug('Client setup done') 49 | output.info('Hello.'.format(client.username)) 50 | output.info('Type "help" to see supported commands.') 51 | context = Context(client=client) 52 | history = InMemoryHistory() 53 | while True: 54 | try: 55 | text = prompt( 56 | message=u'[{}] > '.format(to_unicode(context.path)), # message param needs to be unicode 57 | completer=completer, 58 | history=history, 59 | auto_suggest=AutoSuggestFromHistory(), 60 | ) 61 | except EOFError: 62 | break 63 | else: 64 | execute(text, context) 65 | if context.should_exit: 66 | break 67 | output.info('Goodbye.') 68 | 69 | 70 | @click.group(help=textwrap.dedent(_launch_cli.__doc__)) 71 | @click.pass_context 72 | @click.option( 73 | '--username', 74 | prompt='Username', 75 | default=get_config('username'), 76 | help='Example: i@example.com', 77 | cls=NoPromptIfDefaultOption, 78 | ) 79 | @click.option( 80 | '--key', 81 | prompt='App Key', 82 | default=get_config('key'), 83 | help='Example: a2mqieixzkm5t5h4', 84 | hide_input=True, 85 | cls=NoPromptIfDefaultOption, 86 | 87 | ) 88 | @click.option( 89 | '--working_dir', 90 | prompt='Working Dir', 91 | default=get_config('working_dir'), 92 | help='Example: /photos', 93 | cls=NoPromptIfDefaultOption, 94 | ) 95 | def _main(ctx, username, key, working_dir): 96 | client = NutStoreClient(username=username, password=key, working_dir=to_str(working_dir), check_conn=False) 97 | output.debug('Try to initial a client by given args') 98 | try: 99 | client.check_conn() 100 | except Exception as e: 101 | import traceback 102 | output.error('Login failed, detail: {0}\n'.format(save_text(traceback.format_exc()))) 103 | import sys 104 | sys.exit(-1) 105 | else: 106 | ctx.obj['client'] = client 107 | 108 | 109 | @_main.command(help='Launch cli (default)') 110 | @click.pass_context 111 | def interact(ctx): 112 | _launch_cli(ctx.obj['client']) 113 | 114 | 115 | @_main.command(help='Upload local file to remote') 116 | @click.pass_context 117 | @click.argument('local_path', required=True) 118 | @click.argument('remote_dir', default=get_config('working_dir')) 119 | def upload(ctx, local_path, remote_dir): 120 | output.echo(ctx.obj['client'].upload( 121 | to_str(local_path), 122 | to_str(remote_dir) 123 | )) 124 | 125 | 126 | @_main.command(help='Download remote file to local machine') 127 | @click.pass_context 128 | @click.argument('remote_path', required=True) 129 | @click.argument('local_path', required=False) 130 | def download(ctx, remote_path, local_path): 131 | output.echo(ctx.obj['client'].download( 132 | to_str(remote_path), 133 | to_str(local_path) 134 | )) 135 | 136 | 137 | def main(): 138 | output.debug('Current version: {}'.format(__version__)) 139 | import sys 140 | output.debug('Args: {}'.format(sys.argv)) 141 | if '--help' not in sys.argv and not set(sys.argv) & {'interact', 'upload', 'download'}: 142 | output.debug('Set "interact" as sub command') 143 | sys.argv.insert(1, 'interact') 144 | _main(obj={}) 145 | 146 | 147 | if __name__ == '__main__': 148 | main() 149 | 150 | -------------------------------------------------------------------------------- /nutstore_cli/client/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import logging 3 | 4 | logging.basicConfig(level=logging.DEBUG) 5 | logging.getLogger('requests').setLevel(logging.WARNING) 6 | logging.getLogger("urllib3").setLevel(logging.WARNING) 7 | 8 | 9 | from .client import NutStoreClient 10 | -------------------------------------------------------------------------------- /nutstore_cli/client/base.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import re 3 | import tempfile 4 | from contextlib import contextmanager 5 | 6 | from nutstore_cli.utils import output 7 | from nutstore_cli.client.utils import check_local_path 8 | from nutstore_cli.client.path_helper import * 9 | 10 | import easywebdav 11 | 12 | 13 | class BaseNutStoreClient(object): 14 | """坚果云""" 15 | 16 | api = 'dav.jianguoyun.com' 17 | 18 | def __init__(self, username, password, working_dir, check_conn=True): 19 | if not working_dir.startswith('/'): 20 | working_dir = '/' + working_dir 21 | self.username = username 22 | self.np = PathHelper(start=working_dir) 23 | self._client = easywebdav.connect(self.api, username=username, password=password) 24 | if check_conn: 25 | self.check_conn() 26 | 27 | @property 28 | def cwd(self): 29 | """Current working directory for display""" 30 | return self.np.pretty 31 | 32 | @check_local_path 33 | def upload(self, local_path, remote_dir=None): 34 | """Upload a local file to the remote(with the same filename)""" 35 | name = basename(local_path) 36 | directory = remote_dir or self.cwd 37 | remote_path = join(directory, name) 38 | output.debug('[UPLOAD] {0} => {1}'.format(local_path, remote_path)) 39 | self._client.upload(local_path, self._to_real_path(remote_path)) 40 | return remote_path 41 | 42 | def download(self, remote_path, local_path=None): 43 | """Download a remote file to your machine.""" 44 | local_path = local_path or tempfile.mktemp(suffix=splitext(remote_path)[-1]) 45 | output.debug('[DOWNLOAD] {0} => {1}'.format(remote_path, local_path)) 46 | self._client.download(self._to_real_path(remote_path), local_path) 47 | return local_path 48 | 49 | def ls(self): 50 | """ 51 | :rtype: list[easywebdav.client.File] 52 | """ 53 | def file_in_dir(filename, directory): 54 | return (directory in filename) and (filename != directory) 55 | 56 | real_path = self.np.real 57 | output.debug('List "{}"'.format(real_path)) 58 | return filter(lambda f: file_in_dir(f.name, real_path), self._client.ls(real_path)) 59 | 60 | def cd(self, directory): 61 | self.np.cd(directory) 62 | output.debug('Change directory to "{}"'.format(self.np.real)) 63 | 64 | def rm(self, remote_path): 65 | """Remove a file on the remote.""" 66 | output.debug('[DELETE] {0}'.format(remote_path)) 67 | self._client.delete(self._to_real_path(remote_path)) 68 | return remote_path 69 | 70 | def mkdir(self, directory): 71 | return self._client.mkdir(self._to_real_path(directory)) 72 | 73 | @contextmanager 74 | def cd_context(self, directory): 75 | saved = self.cwd 76 | self.cd(directory) 77 | yield 78 | self.cd(saved) 79 | 80 | def search(self, pattern): 81 | files = self.ls() 82 | return filter( 83 | lambda f: (re.search(pattern=pattern, string=f.name, flags=re.IGNORECASE) and f.size != 0), files 84 | ) 85 | 86 | def _to_real_path(self, path): 87 | """Turn remote path(like /photos) into a real path on server""" 88 | return self.np.to_real(path) 89 | 90 | def check_conn(self): 91 | self.ls() 92 | return True 93 | -------------------------------------------------------------------------------- /nutstore_cli/client/client.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from dateutil.parser import parse as dt_parse 3 | 4 | from nutstore_cli.client.base import BaseNutStoreClient 5 | from nutstore_cli.client.utils import get_attr 6 | 7 | 8 | class NutStoreClient(BaseNutStoreClient): 9 | def search_latest(self, pattern): 10 | """ 11 | 根据pattern获取最新的文件 12 | """ 13 | sorted_files = sorted(self.search(pattern), key=lambda f: dt_parse(f.mtime)) 14 | return sorted_files[-1].name if sorted_files else None 15 | 16 | def download_latest_file(self): 17 | filename = self.search_latest('') 18 | return self.download(filename) 19 | -------------------------------------------------------------------------------- /nutstore_cli/client/exceptions.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from easywebdav.client import WebdavException 3 | 4 | 5 | class NutStoreClientException(WebdavException): 6 | pass 7 | 8 | 9 | class LocalException(NutStoreClientException): 10 | pass 11 | 12 | 13 | class CloudException(NutStoreClientException): 14 | pass 15 | 16 | 17 | class FileNotExistException(LocalException): 18 | 19 | @classmethod 20 | def make_exception(cls, path): 21 | return cls('The path <{}> does not exist on your machine.'.format(path)) 22 | 23 | 24 | -------------------------------------------------------------------------------- /nutstore_cli/client/path_helper.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from os.path import ( 3 | join, 4 | basename, 5 | splitext, 6 | ) 7 | from urlparse import urljoin 8 | 9 | __all__ = ( 10 | 'join', 11 | 'basename', 12 | 'path_resolve', 13 | 'splitext', 14 | 'PathHelper', 15 | ) 16 | 17 | 18 | class PathHelper(object): 19 | """A helper that wraps pretty remote path to real dav path""" 20 | 21 | def __init__(self, prefix='/dav', start='/'): 22 | assert start.startswith('/') 23 | self._prefix = prefix 24 | self.pretty = start 25 | 26 | def cd(self, directory): 27 | self.pretty = path_resolve(self.pretty, directory) 28 | assert self.pretty.startswith('/') 29 | 30 | def _real_getter(self): 31 | return self.to_real(self.pretty) 32 | 33 | real = property(_real_getter) 34 | 35 | def to_real(self, path): 36 | return '{0}{1}'.format(self._prefix, path_resolve(self.pretty, path)) 37 | 38 | def __str__(self): 39 | return ''.format(self.real) 40 | 41 | 42 | def path_resolve(base, path, **kwargs): 43 | """ 44 | Copied from http-prompt/http_prompt/execution.py 45 | 46 | >>>path_resolve('a', '/b') 47 | "/b" 48 | 49 | >>>path_resolve('a', 'b') 50 | "a/b" 51 | """ 52 | if not base.endswith('/'): 53 | base += '/' 54 | url = urljoin(base, path, **kwargs) 55 | if url.endswith('/') and not path.endswith('/'): 56 | url = url[:-1] 57 | return url 58 | -------------------------------------------------------------------------------- /nutstore_cli/client/utils.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import inspect 3 | import os 4 | 5 | from nutstore_cli.client.exceptions import FileNotExistException 6 | 7 | 8 | def get_attr(obj, attr_or_fn): 9 | if isinstance(attr_or_fn, basestring): 10 | return getattr(obj, attr_or_fn) 11 | elif callable(attr_or_fn): 12 | return attr_or_fn(obj) 13 | else: 14 | raise TypeError(type(attr_or_fn)) 15 | 16 | 17 | def check_local_path(func): 18 | def deco(*args, **kwargs): 19 | arguments = inspect.getcallargs(func, *args, **kwargs) 20 | local_path = arguments.get('local_path') 21 | if local_path and not os.path.exists(local_path): 22 | raise FileNotExistException.make_exception(local_path) 23 | return func(*args, **kwargs) 24 | 25 | return deco -------------------------------------------------------------------------------- /nutstore_cli/command_help.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import tabulate 3 | 4 | help_rows = [] 5 | help_labels = ('Command', 'Description', 'Examples') 6 | 7 | 8 | def add_help(command, description, examples=None): 9 | examples = examples or [''] 10 | for idx, example in enumerate(examples): 11 | if idx == 0: 12 | help_rows.append( 13 | [command, description, example] 14 | ) 15 | else: 16 | help_rows.append( 17 | ['', '', example] 18 | ) 19 | 20 | 21 | add_help( 22 | 'cd', 23 | 'change working directory', 24 | ['cd {absolute_remote_path}', 'cd {remote_path}'] 25 | ) 26 | 27 | add_help( 28 | 'download', 29 | 'download a remote file to the local temp path ', 30 | ['download {remote_file_name}'], 31 | ) 32 | 33 | add_help( 34 | 'exit', 35 | 'exit from the interface', 36 | ['exit'] 37 | ) 38 | 39 | add_help( 40 | 'help', 41 | 'show help', 42 | ['help'] 43 | ) 44 | 45 | add_help( 46 | 'ls', 47 | 'list remote files in working directory', 48 | ['ls', 'ls | grep {keyword}'] 49 | ) 50 | 51 | add_help( 52 | 'rm', 53 | 'delete a remote file', 54 | ['rm {remote_file_name}'] 55 | ) 56 | 57 | add_help( 58 | 'upload', 59 | 'upload a local file to remote ', 60 | ['upload {local_file_path}'] 61 | ) 62 | 63 | help_table = tabulate.tabulate( 64 | help_rows, 65 | headers=help_labels, 66 | tablefmt='grid', 67 | ) 68 | -------------------------------------------------------------------------------- /nutstore_cli/completer.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from nutstore_cli.execution import COMMANDS 3 | from prompt_toolkit.contrib.completers import WordCompleter 4 | 5 | completer = WordCompleter(words=COMMANDS, ignore_case=False) 6 | -------------------------------------------------------------------------------- /nutstore_cli/config.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import re 3 | from os.path import expanduser, join, exists 4 | from os import getenv 5 | 6 | from nutstore_cli.utils.output import debug 7 | 8 | CONFIG_KEYS = ( 9 | 'username', 10 | 'key', 11 | 'working_dir', 12 | ) 13 | 14 | DEFAULT_CONFIG_FILENAME = join(expanduser('~'), '.nutstore.config') 15 | DEFAULT_ENV_PREFIX = 'NUTSTORE_' 16 | 17 | 18 | class ConfigLoader(object): 19 | PARSE_RE = re.compile('(.+)=(.*)') 20 | 21 | def __init__(self, filename): 22 | self.config = self.load(filename) 23 | 24 | def load(self, filename): 25 | config = {} 26 | if not exists(filename): 27 | debug('Config file {} not exist'.format(filename)) 28 | return config 29 | debug('Loading config from {}'.format(filename)) 30 | with open(filename) as f: 31 | for line in f.xreadlines(): 32 | m = self.PARSE_RE.search(line) 33 | if m and (m.group(1).strip() in CONFIG_KEYS): 34 | k = m.group(1).strip() 35 | v = m.group(2).strip() 36 | debug('Set "{}" to "{}" in {}'.format(k, v, filename)) 37 | config[k] = v 38 | return config 39 | 40 | 41 | class EnvLoader(object): 42 | NOT_SET = object() 43 | 44 | def __init__(self, prefix): 45 | self.config = {} 46 | for k in CONFIG_KEYS: 47 | env_key = '{prefix}{key}'.format(prefix=prefix, key=k).upper() 48 | v = getenv( 49 | env_key, 50 | self.NOT_SET 51 | ) 52 | if v is not self.NOT_SET: 53 | debug('Set "{}" to "{}" from environment variable {}'.format(k, v, env_key)) 54 | self.config[k] = v 55 | 56 | 57 | def merge_config(config_filename, env_prefix): 58 | file_config = ConfigLoader(config_filename) 59 | env_config = EnvLoader(env_prefix) 60 | return dict(file_config.config, **env_config.config) # env config has higher priority 61 | 62 | 63 | # TODO: set config filename and env prefix in command line 64 | config = merge_config(DEFAULT_CONFIG_FILENAME, DEFAULT_ENV_PREFIX) 65 | 66 | 67 | def get_config(key): 68 | return config.get(key, '') 69 | -------------------------------------------------------------------------------- /nutstore_cli/context.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | 4 | class Context(object): 5 | should_exit = False 6 | 7 | def __init__(self, client): 8 | """ 9 | :type client: nutstore_cli.client.client.NutStoreClient 10 | """ 11 | 12 | self.client = client 13 | 14 | @property 15 | def path(self): 16 | return self.client.cwd 17 | -------------------------------------------------------------------------------- /nutstore_cli/execution.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import re 3 | from collections import OrderedDict 4 | from os import path 5 | from itertools import ifilter 6 | from urllib import unquote 7 | 8 | import click 9 | import tabulate 10 | from dateutil.parser import parse as dt_parse 11 | from dateutil import tz 12 | from parsimonious import ParseError 13 | from parsimonious.grammar import Grammar 14 | from parsimonious.nodes import NodeVisitor 15 | 16 | from nutstore_cli.utils import output, humanbytes, to_str 17 | from nutstore_cli.command_help import help_table 18 | from nutstore_cli.client.exceptions import WebdavException 19 | 20 | COMMANDS = ['cd', 'download', 'exit', 'grep', 'help', 'ls', 'll', 'rm', 'upload'] 21 | 22 | RULES = r""" 23 | command = cd / ls / exit / help / download / upload / rm 24 | 25 | rm = "rm" _ string 26 | upload = "upload" _ string 27 | download = "download" _ string _ (string)? 28 | help = "help" / "h" / "?" 29 | exit = "exit" / "quit" / "q" 30 | ls = ("ls" / "ll") _ (grep)? 31 | cd = _ "cd" _ string _ 32 | 33 | grep = pipe _ "grep" _ ex_string 34 | 35 | pipe = "|" 36 | 37 | ex_string = string / "*" / "-" / "_" / "." 38 | string = char+ 39 | char = ~r"[^\s'\\]" 40 | _ = ~r"\s*" 41 | """ 42 | 43 | grammar = Grammar(RULES) 44 | 45 | 46 | class PrettyFile(object): 47 | def __init__(self, efile): 48 | """ 49 | :type efile: easywebdav.client.File 50 | """ 51 | self._file = efile 52 | self._name = unquote(path.basename(efile.name)).decode('utf-8') 53 | 54 | self.is_dir = efile.contenttype == 'httpd/unix-directory' 55 | self.name = self._name 56 | self.size = humanbytes(int(efile.size)) 57 | self.modify_time = dt_parse(efile.mtime).astimezone(tz.tzlocal()).strftime('%Y-%m-%d %H:%M:%S') 58 | if self.is_dir: 59 | self.name = click.style(self._name, fg='cyan') 60 | self.size = '' 61 | 62 | def pack(self): 63 | return self.name, self.size, self.modify_time 64 | 65 | 66 | class ExecutionVisitor(NodeVisitor): 67 | unwrapped_exceptions = (WebdavException,) 68 | 69 | def __init__(self, context): 70 | """ 71 | :type context: nutstore_cli.cli.Context 72 | """ 73 | super(ExecutionVisitor, self).__init__() 74 | self.context = context 75 | 76 | def visit_cd(self, node, children): 77 | path = children[3].text 78 | self.context.client.cd(to_str(path)) 79 | 80 | def visit_exit(self, node, children): 81 | self.context.should_exit = True 82 | 83 | def visit_ls(self, node, children): 84 | pretty_files = [PrettyFile(ef) for ef in self.context.client.ls()] 85 | grep_keywords = children[2].children[4].children[0].text if children[2].children else None 86 | if grep_keywords: 87 | output.debug('Issue a grep "{}"'.format(grep_keywords)) 88 | pretty_files = ifilter(lambda pfile: re.search(grep_keywords, pfile._name, flags=re.IGNORECASE), 89 | pretty_files) 90 | pretty_files = ifilter(lambda pfile: bool(pfile._name), pretty_files) # ignore who has a empty filename 91 | pretty_files = sorted(pretty_files, key=lambda pfile: pfile.modify_time) 92 | output.echo(tabulate.tabulate( 93 | [pfile.pack() for pfile in pretty_files], 94 | headers=['Filename', 'Size', 'Modify Time'] 95 | )) 96 | 97 | def visit_download(self, node, children): 98 | cloud_path = children[2].text 99 | store_path = children[4].text if len(node.children) == 5 else None 100 | dest = self.context.client.download(to_str(cloud_path), to_str(store_path)) 101 | output.echo(dest) 102 | 103 | def visit_upload(self, node, children): 104 | local_path = to_str(children[2].text) 105 | remote_path = self.context.client.upload(local_path) 106 | output.echo(remote_path) 107 | 108 | def visit_rm(self, node, children): 109 | cloud_path = to_str(children[2].text) 110 | if click.confirm('rm {}?'.format(cloud_path)): 111 | self.context.client.rm(cloud_path) 112 | 113 | def visit_help(self, node, children): 114 | output.info(help_table) 115 | 116 | def generic_visit(self, node, children): 117 | if (not node.expr_name) and node.children: 118 | if len(children) == 1: 119 | return children[0] 120 | return children 121 | return node 122 | 123 | 124 | def execute(command, context): 125 | if not command.strip(): 126 | return 127 | 128 | visitor = ExecutionVisitor(context) 129 | try: 130 | root = grammar.parse(command) 131 | except ParseError: 132 | output.error('Invalid command "{0}".'.format(command)) 133 | output.info('Type "help" to see supported commands.') 134 | return 135 | 136 | try: 137 | visitor.visit(root) 138 | except WebdavException as e: 139 | output.error(str(e)) 140 | -------------------------------------------------------------------------------- /nutstore_cli/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import absolute_import 3 | from .codecs import * 4 | from .functional import * 5 | from .output import * 6 | -------------------------------------------------------------------------------- /nutstore_cli/utils/codecs.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import absolute_import 3 | import logging 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | __all__ = ( 9 | 'to_str', 10 | 'to_unicode' 11 | ) 12 | 13 | 14 | def to_str(s): 15 | if isinstance(s, str): 16 | return s 17 | elif isinstance(s, unicode): 18 | return s.encode('utf-8') 19 | elif s is None: 20 | return None 21 | else: 22 | raise UnicodeEncodeError('can\'t encode "{}" to "str" '.format(type(s))) 23 | 24 | 25 | def to_unicode(s): 26 | if isinstance(s, unicode): 27 | return s 28 | elif isinstance(s, str): 29 | return s.decode('utf-8') 30 | elif s is None: 31 | return None 32 | else: 33 | raise UnicodeDecodeError('can\'t convert "{}" to "unicode" '.format(type(s))) 34 | -------------------------------------------------------------------------------- /nutstore_cli/utils/functional.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import absolute_import 3 | 4 | import logging 5 | import tempfile 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | __all__ = ( 10 | 'hfloat', 11 | 'humanbytes', 12 | 'save_text' 13 | ) 14 | 15 | UNITS = ( 16 | (2 ** 30.0, 'GB'), 17 | (2 ** 20.0, 'MB'), 18 | (2 ** 10.0, 'KB'), 19 | (0.0, 'b'),) 20 | 21 | 22 | def hfloat(f, p=5): 23 | """Convert float to value suitable for humans. 24 | 25 | Arguments: 26 | f (float): The floating point number. 27 | p (int): Floating point precision (default is 5). 28 | 29 | Copied from celery 30 | """ 31 | i = int(f) 32 | return i if i == f else '{0:.{p}}'.format(f, p=p) 33 | 34 | 35 | def humanbytes(s): 36 | """Convert bytes to human-readable form (e.g., KB, MB). 37 | 38 | Copied from celery 39 | """ 40 | return next( 41 | '{0}{1}'.format(hfloat(s / div if div else s), unit) 42 | for div, unit in UNITS if s >= div 43 | ) 44 | 45 | 46 | def save_text(s, dest=None): 47 | dest = dest or tempfile.mktemp(suffix='.log') 48 | with open(dest, 'w') as g: 49 | g.write(s) 50 | return dest 51 | -------------------------------------------------------------------------------- /nutstore_cli/utils/output.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import absolute_import 3 | 4 | import os 5 | import click 6 | 7 | __all__ = ( 8 | 'info', 9 | 'error', 10 | 'echo', 11 | 'debug', 12 | ) 13 | 14 | 15 | def info(s): 16 | click.secho(s, fg='cyan') 17 | 18 | 19 | def error(s): 20 | click.secho(s, fg='red') 21 | 22 | 23 | def echo(s): 24 | click.secho(s) 25 | 26 | 27 | DEBUG_ON = bool(os.getenv('DEBUG', False)) 28 | 29 | 30 | def debug(s): 31 | if DEBUG_ON: 32 | click.secho('[DEBUG] {}'.format(s), fg='green') 33 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | easywebdav==1.2.0 2 | six==1.10.0 3 | parsimonious==0.7.0 4 | python-dateutil==2.6.0 5 | click==6.7 6 | prompt-toolkit==1.0.9 7 | tabulate==0.7.7 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | name = nutstore-cli 6 | url = https://github.com/Kxrr/nutstore-cli 7 | version = 0.4.3 8 | author = Kxrr 9 | author-email = hi@kxrr.us 10 | description = A command-line interface for NutStore based on WebDAV. 11 | description-file = README.rst 12 | license = MIT 13 | classifier = 14 | Development Status :: 3 - Alpha 15 | Environment :: Console 16 | Intended Audience :: Developers 17 | License :: OSI Approved :: MIT License 18 | Topic :: Terminals 19 | Programming Language :: Python 20 | Programming Language :: Python :: 2.7 21 | keywords = 22 | nutstore 23 | jianguoyun 24 | cli 25 | 26 | [entry_points] 27 | console_scripts = 28 | nutstore-cli = nutstore_cli.cli:main 29 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import os 3 | import re 4 | import setuptools 5 | import codecs 6 | 7 | 8 | here = os.path.abspath(os.path.dirname(__file__)) 9 | with open(os.path.join(here, 'nutstore_cli', '__init__.py')) as f: 10 | META_CONTENT = f.read() 11 | 12 | 13 | def read(variable): 14 | m = re.search('__%s__\s*=\s*[\'\"](\S*)[\'\"]$' % variable, META_CONTENT, flags=re.M) 15 | return m.group(1) 16 | 17 | 18 | def get_requirements(): 19 | reqs = codecs.open(os.path.join(here, 'requirements.txt'), encoding='utf-8').read().splitlines() 20 | return [req for req in reqs if not req.startswith('-e')] 21 | 22 | 23 | setuptools.setup( 24 | packages=setuptools.find_packages(exclude=['tests', 'tests.*']), 25 | setup_requires=['pbr'], 26 | pbr=True, 27 | ) 28 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import unittest 3 | 4 | 5 | class NutStoreTestCase(unittest.TestCase): 6 | pass 7 | -------------------------------------------------------------------------------- /tests/raw/two_files.pickle: -------------------------------------------------------------------------------- 1 | (lp0 2 | ccopy_reg 3 | _reconstructor 4 | p1 5 | (ceasywebdav.client 6 | File 7 | p2 8 | c__builtin__ 9 | tuple 10 | p3 11 | (S'/dav/python/vpn.tar.gz' 12 | p4 13 | I6075011 14 | S'Tue, 07 Feb 2017 14:06:53 GMT' 15 | p5 16 | S'' 17 | p6 18 | S'application/octet-stream' 19 | p7 20 | tp8 21 | tp9 22 | Rp10 23 | ag1 24 | (g2 25 | g3 26 | (S'/dav/python/%e5%bc%a0%e5%93%b2-%e6%88%90%e9%83%bd-python.pdf' 27 | p11 28 | I71294 29 | S'Wed, 28 Dec 2016 02:01:45 GMT' 30 | p12 31 | g6 32 | S'application/pdf' 33 | p13 34 | tp14 35 | tp15 36 | Rp16 37 | a. -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import unittest 3 | 4 | from nutstore_cli.client.client import NutStoreClient 5 | from tests import NutStoreTestCase 6 | 7 | 8 | class NutStoreClientTestCase(NutStoreTestCase): 9 | 10 | def test_cd(self): 11 | client = NutStoreClient('', '', 'demo', check_conn=False) 12 | self.assertEqual(client._working_dir, 'dav/demo') 13 | 14 | client.cd('/mov') 15 | self.assertEqual(client._working_dir, 'dav/mov') 16 | 17 | client.cd('a') 18 | self.assertEqual(client._working_dir, 'dav/mov/a') 19 | 20 | client.working_dir = '/mov/a' 21 | client.cd('..') 22 | self.assertEqual(client._working_dir, 'dav/mov') 23 | 24 | 25 | if __name__ == '__main__': 26 | unittest.main() 27 | -------------------------------------------------------------------------------- /tests/test_file.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import pickle 4 | import unittest 5 | from os.path import join 6 | 7 | from nutstore_cli.client.file import FileTable 8 | 9 | 10 | class FileTestCase(unittest.TestCase): 11 | with open(join('raw', 'two_files.pickle'), 'rb') as f: 12 | files = pickle.loads(f.read()) 13 | 14 | def test_table_display(self): 15 | table = FileTable(self.files) 16 | print(table.get_listing_columns()) 17 | 18 | 19 | if __name__ == '__main__': 20 | unittest.main() 21 | -------------------------------------------------------------------------------- /tests/test_grammar.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import unittest 3 | 4 | from nutstore_cli.execution import grammar 5 | 6 | 7 | class GrammarTestCase(unittest.TestCase): 8 | def test_parse_cd(self): 9 | for c in ['a', 'a_', 'a.b']: 10 | root = grammar.parse('cd {}'.format(c)) 11 | cd = root.children[0] 12 | self.assertEqual(cd.expr_name, 'cd') 13 | 14 | def test_parse_download(self): 15 | text = 'download a.txt /tmp/a.txt' 16 | root = grammar.parse(text) 17 | download = root.children[0] 18 | self.assertEqual(download.expr_name, 'download') 19 | self.assertEqual(len(download.children), 5) 20 | 21 | def test_parse_upload(self): 22 | text = 'upload /tmp/a.txt' 23 | root = grammar.parse(text) 24 | upload = root.children[0] 25 | self.assertEqual(upload.expr_name, 'upload') 26 | 27 | def test_parse_ls(self): 28 | for text in ( 29 | 'ls', 30 | 'ls | grep something', 31 | ): 32 | root = grammar.parse(text) 33 | 34 | def test_parse_help(self): 35 | text = 'help' 36 | grammar.parse(text) 37 | 38 | 39 | if __name__ == '__main__': 40 | unittest.main() 41 | --------------------------------------------------------------------------------