├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── doc └── faq.md ├── issue_template.md ├── pylintrc ├── roamer ├── __init__.py ├── command.py ├── constant.py ├── database.py ├── directory.py ├── edit_directory.py ├── engine.py ├── entry.py ├── file_edit.py ├── main.py ├── record.py └── session.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── mock_session.py └── test_integration.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | .venv/ 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | tmp/ 93 | tags 94 | 95 | MANIFEST 96 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | - "3.5" 7 | - "3.6" 8 | install: pip install tox-travis 9 | script: tox 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | If you are adding a new feature please open an issue to discuss first. If you are working on an existing one please drop a note in that issue to let others know. 2 | 3 | ### Developer Install 4 | 5 | ``` 6 | # fork it on github 7 | cd ~ 8 | git clone https://github.com/YOUR_USER_NAME/roamer.git 9 | cd roamer 10 | sudo pip install -e . 11 | ``` 12 | 13 | ### Tests 14 | 15 | Make sure there are no pylint issues and that all tests are passing: 16 | 17 | Run tests using your local python version: 18 | ``` 19 | pylint roamer tests 20 | python -m unittest discover 21 | ``` 22 | 23 | Run tests across all supported python versions: 24 | ``` 25 | tox 26 | ``` 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Alex Baldwin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Roamer [![Build Status](https://travis-ci.org/abaldwin88/roamer.svg?branch=master)](https://travis-ci.org/abaldwin88/roamer) 2 | ### The Plain Text File Manager 3 | 4 | [![asciicast](https://asciinema.org/a/132587.png)](https://asciinema.org/a/132587) 5 | 6 | Roamer turns your favorite text editor into a lightweight file manager. Copy, Cut & Paste files en masse without leaving your terminal window. 7 | 8 | ## Install 9 | #### Requirements 10 | * Python version: 2.7+, 3.4+ 11 | * OS: Linux, MacOS, Windows WSL (Windows Subsystem for Linux) 12 | 13 | #### Command 14 | ```shell 15 | sudo pip install roamer 16 | ``` 17 | 18 | For a high security install see [here](doc/faq.md). 19 | 20 | ## Usage 21 | 22 | ### Start Roamer 23 | ```shell 24 | $ roamer 25 | ``` 26 | This will open the current working directory in your default $EDITOR. (See options section to override editor) 27 | 28 | ### Example Output 29 | ```shell 30 | " pwd: /Users/abaldwin/Desktop/stuff 31 | my_directory/ | b0556598b8f8 32 | my_file_1.txt | ce9b0a287985 33 | my_file_2.txt | fc3da7f790a6 34 | my_file_3.txt | fc3da7f790a6 35 | ``` 36 | 37 | ### Explanation 38 | 39 | * Each line represents a single entry (file or directory) 40 | * On the left side of the pipe character is the entry's name 41 | * On the right side is the entry's hash. You can think of the hash as a link to that entry's contents. 42 | * A line starting with double quote (") is a comment and will be ignored. 43 | 44 | --> Make changes as desired. When finished save and quit to commit the changes. e.g. vim `:wq` 45 | 46 | ### Common Operations 47 | 48 | #### Delete a file 49 | * Delete the line 50 | 51 | #### Copy a file 52 | * Copy the entire line 53 | * Paste it onto a new line 54 | 55 | #### Rename a file 56 | * Type over the existing file's name 57 | * Do not modify the hash on the right side 58 | 59 | #### Copy over a file 60 | * Copy the hash from the first file 61 | * Replace the second file's hash 62 | 63 | #### Make a new empty file 64 | * Add a new line 65 | * Type the new file's name 66 | 67 | #### Move files between directories 68 | * Open up another terminal tab and run second roamer session 69 | * Copy / Paste lines between both sessions of roamer 70 | 71 | 72 | 73 | ## Options 74 | 75 | #### Editor 76 | Roamer uses your default $EDITOR environment variable. 77 | 78 | To override a specific editor for roamer add this to your shell's config. (~/.bashrc ~/.zshrc etc) 79 | ```shell 80 | export ROAMER_EDITOR=emacs 81 | ``` 82 | 83 | If no editor is set then vi will be used. 84 | 85 | [Works with any editor?](doc/faq.md#any-text-editor) 86 | 87 | #### Data Directory 88 | Roamer needs a directory for storing data between sessions. By default this will be saved in `.roamer-data` in your home directory. 89 | 90 | To override: 91 | ```shell 92 | export ROAMER_DATA_PATH=~/meh/ 93 | ``` 94 | 95 | 96 | ## Editor Plugins 97 | 98 | This roamer library is editor agnostic and focused on processing plain text. To enhance your experience with roamer consider installing roamer editor plug-ins: 99 | 100 | * https://github.com/abaldwin88/roamer.vim 101 | -------------------------------------------------------------------------------- /doc/faq.md: -------------------------------------------------------------------------------- 1 | # FAQs 2 | 3 | #### High Security Install? 4 | 5 | Steps for installing roamer without using sudo... 6 | 7 | ```shell 8 | pip install --user roamer 9 | ``` 10 | 11 | Check that `~/.local/bin` is in your PATH 12 | ```shell 13 | echo "$PATH"|grep -q ~/.local/bin && echo "Ready to use roamer!" 14 | ``` 15 | 16 | Add `~/.local/bin` to your PATH: 17 | ```shell 18 | # Bash Users 19 | echo "export PATH=\$PATH:~/.local/bin" >> ~/.bashrc 20 | source ~/.bashrc 21 | 22 | # Z Shell Users 23 | echo "export PATH=\$PATH:~/.local/bin" >> ~/.zshrc 24 | source ~/.zshrc 25 | ``` 26 | 27 | ### Any Text Editor? 28 | 29 | Roamer should work with any text editor that blocks. That is any editor that lets you write messages with `git commit`. ( e.g. Atom's `--wait` flag ) 30 | 31 | Tested Editors: 32 | 33 | * vi 34 | * nano 35 | * vim 36 | * emacs 37 | * neovim 38 | * atom 39 | * sublime text 3 40 | * vscode 41 | -------------------------------------------------------------------------------- /issue_template.md: -------------------------------------------------------------------------------- 1 | Bug Report Template 2 | 3 | ## roamer --version: 4 | ## OS: 5 | ## Python version: 6 | ## Steps to reproduce (start with removing ~/.roamer-data): 7 | 8 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore=CVS 13 | 14 | # Add files or directories matching the regex patterns to the blacklist. The 15 | # regex matches against base names, not paths. 16 | ignore-patterns= 17 | 18 | # Pickle collected data for later comparisons. 19 | persistent=yes 20 | 21 | # List of plugins (as comma separated values of python modules names) to load, 22 | # usually to register additional checkers. 23 | load-plugins= 24 | 25 | # Use multiple processes to speed up Pylint. 26 | jobs=1 27 | 28 | # Allow loading of arbitrary C extensions. Extensions are imported into the 29 | # active Python interpreter and may run arbitrary code. 30 | unsafe-load-any-extension=no 31 | 32 | # A comma-separated list of package or module names from where C extensions may 33 | # be loaded. Extensions are loading into the active Python interpreter and may 34 | # run arbitrary code 35 | extension-pkg-whitelist= 36 | 37 | # Allow optimization of some AST trees. This will activate a peephole AST 38 | # optimizer, which will apply various small optimizations. For instance, it can 39 | # be used to obtain the result of joining multiple strings with the addition 40 | # operator. Joining a lot of strings can lead to a maximum recursion error in 41 | # Pylint and this flag can prevent that. It has one side effect, the resulting 42 | # AST will be different than the one from reality. This option is deprecated 43 | # and it will be removed in Pylint 2.0. 44 | optimize-ast=no 45 | 46 | 47 | [MESSAGES CONTROL] 48 | 49 | # Only show warnings with the listed confidence levels. Leave empty to show 50 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 51 | confidence= 52 | 53 | # Enable the message, report, category or checker with the given id(s). You can 54 | # either give multiple identifier separated by comma (,) or put this option 55 | # multiple time (only on the command line, not in the configuration file where 56 | # it should appear only once). See also the "--disable" option for examples. 57 | #enable= 58 | 59 | # Disable the message, report, category or checker with the given id(s). You 60 | # can either give multiple identifiers separated by comma (,) or put this 61 | # option multiple times (only on the command line, not in the configuration 62 | # file where it should appear only once).You can also use "--disable=all" to 63 | # disable everything first and then reenable specific checks. For example, if 64 | # you want to run only the similarities checker, you can use "--disable=all 65 | # --enable=similarities". If you want to run only the classes checker, but have 66 | # no Warning level messages displayed, use"--disable=all --enable=classes 67 | # --disable=W" 68 | disable=import-star-module-level,old-octal-literal,oct-method,print-statement,unpacking-in-except,parameter-unpacking,backtick,old-raise-syntax,old-ne-operator,long-suffix,dict-view-method,dict-iter-method,metaclass-assignment,next-method-called,raising-string,indexing-exception,raw_input-builtin,long-builtin,file-builtin,execfile-builtin,coerce-builtin,cmp-builtin,buffer-builtin,basestring-builtin,apply-builtin,filter-builtin-not-iterating,using-cmp-argument,useless-suppression,range-builtin-not-iterating,suppressed-message,no-absolute-import,old-division,cmp-method,reload-builtin,zip-builtin-not-iterating,intern-builtin,unichr-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,input-builtin,round-builtin,hex-method,nonzero-method,map-builtin-not-iterating,attribute-defined-outside-init,locally-disabled,fixme,useless-object-inheritance 69 | 70 | 71 | [REPORTS] 72 | 73 | # Set the output format. Available formats are text, parseable, colorized, msvs 74 | # (visual studio) and html. You can also give a reporter class, eg 75 | # mypackage.mymodule.MyReporterClass. 76 | output-format=text 77 | 78 | # Put messages in a separate file for each module / package specified on the 79 | # command line instead of printing them on stdout. Reports (if any) will be 80 | # written in a file name "pylint_global.[txt|html]". This option is deprecated 81 | # and it will be removed in Pylint 2.0. 82 | files-output=no 83 | 84 | # Tells whether to display a full report or only the messages 85 | reports=yes 86 | 87 | # Python expression which should return a note less than 10 (10 is the highest 88 | # note). You have access to the variables errors warning, statement which 89 | # respectively contain the number of errors / warnings messages and the total 90 | # number of statements analyzed. This is used by the global evaluation report 91 | # (RP0004). 92 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 93 | 94 | # Template used to display messages. This is a python new-style format string 95 | # used to format the message information. See doc for all details 96 | #msg-template= 97 | 98 | 99 | [BASIC] 100 | 101 | # Good variable names which should always be accepted, separated by a comma 102 | good-names=i,j,k,ex,Run,_ 103 | 104 | # Bad variable names which should always be refused, separated by a comma 105 | bad-names=foo,bar,baz,toto,tutu,tata 106 | 107 | # Colon-delimited sets of names that determine each other's naming style when 108 | # the name regexes allow several styles. 109 | name-group= 110 | 111 | # Include a hint for the correct naming format with invalid-name 112 | include-naming-hint=no 113 | 114 | # List of decorators that produce properties, such as abc.abstractproperty. Add 115 | # to this list to register other decorators that produce valid properties. 116 | property-classes=abc.abstractproperty 117 | 118 | # Regular expression matching correct function names 119 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 120 | 121 | # Naming hint for function names 122 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 123 | 124 | # Regular expression matching correct variable names 125 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 126 | 127 | # Naming hint for variable names 128 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 129 | 130 | # Regular expression matching correct constant names 131 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 132 | 133 | # Naming hint for constant names 134 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 135 | 136 | # Regular expression matching correct attribute names 137 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 138 | 139 | # Naming hint for attribute names 140 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 141 | 142 | # Regular expression matching correct argument names 143 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 144 | 145 | # Naming hint for argument names 146 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 147 | 148 | # Regular expression matching correct class attribute names 149 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 150 | 151 | # Naming hint for class attribute names 152 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 153 | 154 | # Regular expression matching correct inline iteration names 155 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 156 | 157 | # Naming hint for inline iteration names 158 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 159 | 160 | # Regular expression matching correct class names 161 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 162 | 163 | # Naming hint for class names 164 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 165 | 166 | # Regular expression matching correct module names 167 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 168 | 169 | # Naming hint for module names 170 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 171 | 172 | # Regular expression matching correct method names 173 | method-rgx=[a-z_][a-z0-9_]{2,50}$ 174 | 175 | # Naming hint for method names 176 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 177 | 178 | # Regular expression which should only match function or class names that do 179 | # not require a docstring. 180 | no-docstring-rgx=^_ 181 | 182 | # Minimum line length for functions/classes that require docstrings, shorter 183 | # ones are exempt. 184 | # TODO: Reduce this once more stuff is done 185 | docstring-min-length=1000 186 | 187 | 188 | [ELIF] 189 | 190 | # Maximum number of nested blocks for function / method body 191 | max-nested-blocks=5 192 | 193 | 194 | [FORMAT] 195 | 196 | # Maximum number of characters on a single line. 197 | max-line-length=100 198 | 199 | # Regexp for a line that is allowed to be longer than the limit. 200 | ignore-long-lines=^\s*(# )??$ 201 | 202 | # Allow the body of an if to be on the same line as the test if there is no 203 | # else. 204 | single-line-if-stmt=no 205 | 206 | # List of optional constructs for which whitespace checking is disabled. `dict- 207 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 208 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 209 | # `empty-line` allows space-only lines. 210 | no-space-check=trailing-comma,dict-separator 211 | 212 | # Maximum number of lines in a module 213 | max-module-lines=1000 214 | 215 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 216 | # tab). 217 | indent-string=' ' 218 | 219 | # Number of spaces of indent required inside a hanging or continued line. 220 | indent-after-paren=4 221 | 222 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 223 | expected-line-ending-format= 224 | 225 | 226 | [LOGGING] 227 | 228 | # Logging modules to check that the string format arguments are in logging 229 | # function parameter format 230 | logging-modules=logging 231 | 232 | 233 | [MISCELLANEOUS] 234 | 235 | # List of note tags to take in consideration, separated by a comma. 236 | notes=FIXME,XXX,TODO 237 | 238 | 239 | [SIMILARITIES] 240 | 241 | # Minimum lines number of a similarity. 242 | min-similarity-lines=4 243 | 244 | # Ignore comments when computing similarities. 245 | ignore-comments=yes 246 | 247 | # Ignore docstrings when computing similarities. 248 | ignore-docstrings=yes 249 | 250 | # Ignore imports when computing similarities. 251 | ignore-imports=no 252 | 253 | 254 | [SPELLING] 255 | 256 | # Spelling dictionary name. Available dictionaries: de_DE (myspell), en_AU 257 | # (myspell), en_GB (myspell), en_US (myspell), fr_FR (myspell). 258 | spelling-dict= 259 | 260 | # List of comma separated words that should not be checked. 261 | spelling-ignore-words= 262 | 263 | # A path to a file that contains private dictionary; one word per line. 264 | spelling-private-dict-file= 265 | 266 | # Tells whether to store unknown words to indicated private dictionary in 267 | # --spelling-private-dict-file option instead of raising a message. 268 | spelling-store-unknown-words=no 269 | 270 | 271 | [TYPECHECK] 272 | 273 | # Tells whether missing members accessed in mixin class should be ignored. A 274 | # mixin class is detected if its name ends with "mixin" (case insensitive). 275 | ignore-mixin-members=yes 276 | 277 | # List of module names for which member attributes should not be checked 278 | # (useful for modules/projects where namespaces are manipulated during runtime 279 | # and thus existing member attributes cannot be deduced by static analysis. It 280 | # supports qualified module names, as well as Unix pattern matching. 281 | ignored-modules= 282 | 283 | # List of class names for which member attributes should not be checked (useful 284 | # for classes with dynamically set attributes). This supports the use of 285 | # qualified names. 286 | ignored-classes=optparse.Values,thread._local,_thread._local 287 | 288 | # List of members which are set dynamically and missed by pylint inference 289 | # system, and so shouldn't trigger E1101 when accessed. Python regular 290 | # expressions are accepted. 291 | generated-members= 292 | 293 | # List of decorators that produce context managers, such as 294 | # contextlib.contextmanager. Add to this list to register other decorators that 295 | # produce valid context managers. 296 | contextmanager-decorators=contextlib.contextmanager 297 | 298 | 299 | [VARIABLES] 300 | 301 | # Tells whether we should check for unused import in __init__ files. 302 | init-import=no 303 | 304 | # A regular expression matching the name of dummy variables (i.e. expectedly 305 | # not used). 306 | dummy-variables-rgx=(_+[a-zA-Z0-9]*?$)|dummy 307 | 308 | # List of additional names supposed to be defined in builtins. Remember that 309 | # you should avoid to define new builtins when possible. 310 | additional-builtins= 311 | 312 | # List of strings which can identify a callback function by name. A callback 313 | # name must start or end with one of those strings. 314 | callbacks=cb_,_cb 315 | 316 | # List of qualified module names which can have objects that can redefine 317 | # builtins. 318 | redefining-builtins-modules=six.moves,future.builtins 319 | 320 | 321 | [CLASSES] 322 | 323 | # List of method names used to declare (i.e. assign) instance attributes. 324 | defining-attr-methods=__init__,__new__,setUp 325 | 326 | # List of valid names for the first argument in a class method. 327 | valid-classmethod-first-arg=cls 328 | 329 | # List of valid names for the first argument in a metaclass class method. 330 | valid-metaclass-classmethod-first-arg=mcs 331 | 332 | # List of member names, which should be excluded from the protected access 333 | # warning. 334 | exclude-protected=_asdict,_fields,_replace,_source,_make 335 | 336 | 337 | [DESIGN] 338 | 339 | # Maximum number of arguments for function / method 340 | max-args=5 341 | 342 | # Argument names that match this expression will be ignored. Default to name 343 | # with leading underscore 344 | ignored-argument-names=_.* 345 | 346 | # Maximum number of locals for function / method body 347 | max-locals=15 348 | 349 | # Maximum number of return / yield for function / method body 350 | max-returns=6 351 | 352 | # Maximum number of branch for function / method body 353 | max-branches=12 354 | 355 | # Maximum number of statements in function / method body 356 | max-statements=50 357 | 358 | # Maximum number of parents for a class (see R0901). 359 | max-parents=7 360 | 361 | # Maximum number of attributes for a class (see R0902). 362 | max-attributes=7 363 | 364 | # Minimum number of public methods for a class (see R0903). 365 | # TODO: Reduce this once more stuff is done 366 | min-public-methods=0 367 | 368 | # Maximum number of public methods for a class (see R0904). 369 | max-public-methods=20 370 | 371 | # Maximum number of boolean expressions in a if statement 372 | max-bool-expr=5 373 | 374 | 375 | [IMPORTS] 376 | 377 | # Deprecated modules which should not be used, separated by a comma 378 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 379 | 380 | # Create a graph of every (i.e. internal and external) dependencies in the 381 | # given file (report RP0402 must not be disabled) 382 | import-graph= 383 | 384 | # Create a graph of external dependencies in the given file (report RP0402 must 385 | # not be disabled) 386 | ext-import-graph= 387 | 388 | # Create a graph of internal dependencies in the given file (report RP0402 must 389 | # not be disabled) 390 | int-import-graph= 391 | 392 | # Force import order to recognize a module as part of the standard 393 | # compatibility libraries. 394 | known-standard-library= 395 | 396 | # Force import order to recognize a module as part of a third party library. 397 | known-third-party=enchant 398 | 399 | # Analyse import fallback blocks. This can be used to support both Python 2 and 400 | # 3 compatible code, which means that the block might have code that exists 401 | # only in one or another interpreter, leading to false positives when analysed. 402 | analyse-fallback-blocks=no 403 | 404 | 405 | [EXCEPTIONS] 406 | 407 | # Exceptions that will emit a warning when being caught. Defaults to 408 | # "Exception" 409 | overgeneral-exceptions=Exception 410 | -------------------------------------------------------------------------------- /roamer/__init__.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring 2 | __version__ = '0.3.2' 3 | -------------------------------------------------------------------------------- /roamer/command.py: -------------------------------------------------------------------------------- 1 | """ 2 | Represents a single os command 3 | """ 4 | import os 5 | import subprocess 6 | from roamer.entry import Entry 7 | from roamer import record 8 | from roamer.constant import TRASH_DIR 9 | 10 | COMMAND_ORDER = {'touch': 1, 'mkdir': 2, 'roamer-trash-copy': 3, 'cp': 4, 'rm': 5} 11 | 12 | class Command(object): 13 | def __init__(self, cmd, first_entry, second_entry=None): 14 | if cmd not in ('touch', 'roamer-trash-copy', 'cp', 'rm'): 15 | raise ValueError('Invalid command') 16 | if first_entry.__class__ != Entry: 17 | raise TypeError('first_entry not of type Entry') 18 | if second_entry.__class__ != Entry and second_entry is not None: 19 | raise TypeError('second_entry not of type Entry or None') 20 | self.cmd = cmd 21 | self.first_entry = first_entry 22 | self.second_entry = second_entry 23 | self.options = None 24 | if self.first_entry.is_dir(): 25 | if cmd == 'touch': 26 | self.cmd = 'mkdir' 27 | elif cmd == 'rm': 28 | self.options = '-R' 29 | elif cmd == 'roamer-trash-copy': 30 | self.options = '-R' 31 | elif not self.second_entry.is_dir(): 32 | raise ValueError('Directories and Files cannot be interchanged. ' \ 33 | 'Trailing slash (/) designates a directory.') 34 | elif cmd == 'cp': 35 | self.options = '-R' 36 | elif self.first_entry.is_symlink() and cmd == 'cp': 37 | self.options = '-a' 38 | 39 | 40 | def __str__(self): 41 | return ' '.join(self.to_list()) 42 | 43 | def __lt__(self, other): 44 | return self.order_int() < other.order_int() 45 | 46 | def to_list(self): 47 | second_path = None 48 | if self.second_entry: 49 | second_path = self.second_entry.path 50 | return filter(None, (self.cmd, self.options, self.first_entry.path, second_path)) 51 | 52 | def order_int(self): 53 | return COMMAND_ORDER[self.cmd] 54 | 55 | def execute(self): 56 | if self.cmd == 'cp': 57 | name, directory = record.path_by_digest(self.first_entry.digest) 58 | self.first_entry = Entry(name, directory, self.first_entry.digest) 59 | if self.cmd == 'roamer-trash-copy': 60 | self.cmd = 'cp' 61 | trash_entry_dir = os.path.join(TRASH_DIR, self.first_entry.digest) 62 | self.second_entry = Entry(self.first_entry.name, trash_entry_dir, 63 | self.first_entry.digest) 64 | if self.second_entry.persisted(): 65 | if self.second_entry.is_dir(): 66 | subprocess.call(['rm', '-R', self.second_entry.path]) 67 | else: 68 | subprocess.call(['rm', self.second_entry.path]) 69 | if not os.path.exists(trash_entry_dir): 70 | os.makedirs(trash_entry_dir) 71 | 72 | record.add_trash(self.first_entry.digest, self.second_entry.name, trash_entry_dir) 73 | subprocess.call(self.to_list(), shell=False) 74 | self._increment_version() 75 | 76 | def _increment_version(self): 77 | if self.cmd == 'touch' or self.cmd == 'mkdir': 78 | self.first_entry.increment_version() 79 | elif self.cmd == 'cp': 80 | self.second_entry.increment_version() 81 | -------------------------------------------------------------------------------- /roamer/constant.py: -------------------------------------------------------------------------------- 1 | """ 2 | App wide constants. 3 | """ 4 | import os 5 | from os.path import expanduser, join, exists 6 | 7 | ROAMER_DATA_PATH = os.environ.get('ROAMER_DATA_PATH') or expanduser('~/.roamer-data/') 8 | ROAMER_DATABASE = expanduser(join(ROAMER_DATA_PATH, 'roamer.db')) 9 | TRASH_DIR = expanduser(join(ROAMER_DATA_PATH, 'trash/')) 10 | TEST_DIR = expanduser(join(ROAMER_DATA_PATH, 'tmp/test/mock_dir')) 11 | 12 | if not exists(TRASH_DIR): 13 | os.makedirs(TRASH_DIR) 14 | -------------------------------------------------------------------------------- /roamer/database.py: -------------------------------------------------------------------------------- 1 | """ 2 | Handle connection and initialization of sqlite3 database 3 | """ 4 | import sqlite3 5 | from roamer.constant import ROAMER_DATABASE 6 | 7 | DB = sqlite3.connect(ROAMER_DATABASE) 8 | 9 | def connection(): 10 | conn = sqlite3.connect(ROAMER_DATABASE) 11 | conn.row_factory = sqlite3.Row 12 | conn.text_factory = str 13 | return conn 14 | 15 | def db_init(): 16 | conn = connection() 17 | query = """ 18 | CREATE TABLE IF NOT EXISTS entries ( 19 | id INTEGER PRIMARY KEY, 20 | digest TEXT, 21 | name TEXT, 22 | path TEXT, 23 | trash BOOLEAN NOT NULL CHECK (trash IN (0,1)), 24 | CONSTRAINT unique_digest UNIQUE (digest), 25 | CONSTRAINT unique_full_path UNIQUE (name, path) 26 | ) 27 | """ 28 | conn.execute(query) 29 | query = """ 30 | CREATE TABLE IF NOT EXISTS modifications ( 31 | id INTEGER PRIMARY KEY, 32 | name TEXT, 33 | path TEXT, 34 | version INTEGER, 35 | CONSTRAINT unique_full_path UNIQUE (name, path) 36 | ) 37 | """ 38 | conn.execute(query) 39 | conn.commit() 40 | -------------------------------------------------------------------------------- /roamer/directory.py: -------------------------------------------------------------------------------- 1 | """ 2 | Represents the original directory before any changes have been made. 3 | """ 4 | from roamer.entry import Entry 5 | 6 | class Directory(object): 7 | def __init__(self, path, raw_entries): 8 | self.path = path 9 | self.entries = {} 10 | for raw_entry in raw_entries: 11 | entry = Entry(raw_entry, self) 12 | self.entries[entry.digest] = entry 13 | 14 | def __str__(self): 15 | return self.path 16 | 17 | def __eq__(self, other): 18 | if other is None: 19 | return False 20 | return self.path == other.path 21 | 22 | def __ne__(self, other): 23 | return not self.__eq__(other) 24 | 25 | def text(self): 26 | pwd_comment = '" pwd: %s' % self.path 27 | content = [pwd_comment] 28 | entries = sorted(self.entries.values()) 29 | for entry in entries: 30 | content.append(str(entry)) 31 | return '\n'.join(content) 32 | 33 | def find(self, digest): 34 | return self.entries.get(digest) 35 | -------------------------------------------------------------------------------- /roamer/edit_directory.py: -------------------------------------------------------------------------------- 1 | """ 2 | Represents a directory after the user has submitted edits 3 | """ 4 | from collections import Counter 5 | from roamer.entry import Entry 6 | 7 | class EditDirectory(object): 8 | def __init__(self, path, content): 9 | self.path = path 10 | self.entries = {} 11 | self.process_lines(content) 12 | self.handle_duplicate_names() 13 | 14 | def process_lines(self, content): 15 | for line in content.splitlines(): 16 | name, digest = process_line(line) 17 | if name is None: 18 | continue 19 | entry = Entry(name, self.path, digest) 20 | if digest in self.entries: 21 | self.entries[digest].append(entry) 22 | else: 23 | self.entries[digest] = [entry] 24 | 25 | def handle_duplicate_names(self): 26 | for entries in self.entries.values(): 27 | entry_names = [entry.name for entry in entries] 28 | entry_counts = Counter(entry_names) 29 | for entry_name, count in entry_counts.items(): 30 | for i in range(count - 1): 31 | duplicate_entry = next(entry for entry in entries if entry.name == entry_name) 32 | duplicate_entry.append_to_name('_copy_%s' % str(i + 1)) 33 | 34 | def find(self, digest): 35 | return self.entries.get(digest) 36 | 37 | 38 | def process_line(line): 39 | columns = line.split('|') 40 | name = columns[0] 41 | if name.isspace() or name == '': 42 | name = None 43 | elif name[-1] == ' ': 44 | name = name[:-1] 45 | 46 | if name and name[0] == '"': 47 | # Hashtags are commented lines 48 | name = None 49 | 50 | if len(columns) == 1: 51 | digest = None 52 | else: 53 | digest = columns[1].replace(' ', '') 54 | if digest == '': 55 | digest = None 56 | return name, digest 57 | -------------------------------------------------------------------------------- /roamer/engine.py: -------------------------------------------------------------------------------- 1 | """ 2 | Determines commands to be run in order to update the original directory and match 3 | the state of the edit directory. 4 | """ 5 | from roamer.command import Command 6 | from roamer import record 7 | from roamer.entry import Entry 8 | from roamer.directory import Directory 9 | 10 | class Engine(object): 11 | def __init__(self, original_dir, edit_dir): 12 | self.original_dir = original_dir 13 | self.edit_dir = edit_dir 14 | self.commands = [] 15 | 16 | def compile_commands(self): 17 | self.compare_dirs() 18 | self.new_entries() 19 | self.handle_unknown_digests() 20 | self.save_copy_over_files_to_trash() 21 | 22 | def compare_dirs(self): 23 | for digest, original_entry in self.original_dir.entries.items(): 24 | new_entries = self.edit_dir.find(digest) 25 | if new_entries is None: 26 | self.commands.append(Command('roamer-trash-copy', original_entry)) 27 | continue 28 | found_original = False 29 | for new_entry in new_entries: 30 | if new_entry.name == original_entry.name: 31 | found_original = True 32 | else: 33 | self.commands.append(Command('cp', original_entry, new_entry)) 34 | if not found_original: 35 | self.commands.append(Command('roamer-trash-copy', original_entry)) 36 | 37 | def new_entries(self): 38 | add_blank_entries = self.edit_dir.find(None) 39 | if add_blank_entries: 40 | for entry in add_blank_entries: 41 | self.commands.append(Command('touch', entry)) 42 | 43 | def handle_unknown_digests(self): 44 | unknown_digests = set(self.edit_dir.entries.keys()) - set(self.original_dir.entries.keys()) 45 | 46 | for digest in filter(None, unknown_digests): 47 | entries = load_entries(filter_dir=self.original_dir) 48 | trash_entries = load_entries(trash=True) 49 | outside_entry = entries.get(digest) or trash_entries.get(digest) 50 | if outside_entry is None: 51 | raise Exception('digest %s not found' % digest) 52 | 53 | for entry in self.edit_dir.find(digest): 54 | new_entry = Entry(entry.name, self.original_dir) 55 | self.commands.append(Command('cp', outside_entry, new_entry)) 56 | 57 | def save_copy_over_files_to_trash(self): 58 | trash_entries = [c.first_entry for c in self.commands if c.cmd == 'roamer-trash-copy'] 59 | copy_over_entires = [c.second_entry.name for c in self.commands if c.cmd == 'cp'] 60 | for entry in trash_entries: 61 | if entry.name not in copy_over_entires: 62 | self.commands.append(Command('rm', entry)) 63 | 64 | def commands_to_str(self): 65 | string_commands = [str(command) for command in sorted(self.commands)] 66 | # sort so that cp comes first. Need to copy before removals happen 67 | return '\n'.join(string_commands) 68 | 69 | def run_commands(self): 70 | return [command.execute() for command in sorted(self.commands)] 71 | 72 | 73 | def load_entries(**kwargs): 74 | dictionary = {} 75 | for row in record.load(**kwargs): 76 | entry = Entry(row['name'], Directory(row['path'], []), row['digest']) 77 | dictionary[row['digest']] = entry 78 | return dictionary 79 | -------------------------------------------------------------------------------- /roamer/entry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Represents a single entry from the operating system. Either a file or a directory. 3 | """ 4 | 5 | import os 6 | import sys 7 | import hashlib 8 | from roamer import record 9 | 10 | class Entry(object): 11 | """ 12 | argh 13 | """ 14 | def __init__(self, name, directory, digest=None): 15 | self.directory = directory 16 | self.name = name 17 | self._set_path() 18 | if self.name and self.name[0] == '"': 19 | raise TypeError('Unexpected double quote ( " )') 20 | if os.path.isdir(self.path) and name[-1] != '/': 21 | self.name = name + '/' 22 | self.digest = digest 23 | if digest is None and self.persisted(): 24 | self._set_version() 25 | digest_text = self.path + str(self.version) 26 | if sys.version_info[0] == 3 and digest_text: 27 | digest_text = digest_text.encode('utf-8') 28 | self.full_digest = hashlib.sha224(digest_text).hexdigest() 29 | self.digest = self.full_digest[0:12] 30 | 31 | def __str__(self): 32 | return "%s | %s" % (self.name, self.digest) 33 | 34 | def __lt__(self, other): 35 | return self.path < other.path 36 | 37 | def _set_path(self): 38 | self.path = os.path.join(str(self.directory), self.name) 39 | 40 | def _set_version(self): 41 | self.version = record.get_version(self.path, self.name) 42 | if self.version is None and self.persisted(): 43 | self.version = int(os.lstat(self.path).st_mtime) 44 | record.set_version(self.path, self.name, self.version) 45 | 46 | def increment_version(self): 47 | self._set_version() 48 | if self.version: 49 | self.version += 1 50 | record.set_version(self.path, self.name, self.version) 51 | 52 | def append_to_name(self, text): 53 | self.name = self.base_name() + text + self.extension() 54 | self._set_path() 55 | 56 | def is_dir(self): 57 | return self.name[-1] == '/' 58 | 59 | def is_symlink(self): 60 | return os.path.islink(self.path) 61 | 62 | def persisted(self): 63 | return os.path.lexists(self.path) 64 | 65 | def base_name(self): 66 | extension_length = len(self.extension()) 67 | if extension_length == 0: 68 | return self.name 69 | return self.name[:-extension_length] 70 | 71 | def extension(self): 72 | if self.is_dir(): 73 | return '/' 74 | return os.path.splitext(self.name)[1] 75 | -------------------------------------------------------------------------------- /roamer/file_edit.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module handles the temp file creation, processing it's output and hand off to the editor 3 | """ 4 | 5 | import sys 6 | import tempfile 7 | import os 8 | from subprocess import call 9 | 10 | ROAMER_EDITOR = os.environ.get('ROAMER_EDITOR') 11 | 12 | if ROAMER_EDITOR: 13 | EDITOR = ROAMER_EDITOR 14 | else: 15 | EDITOR = os.environ.get('EDITOR') 16 | if not EDITOR: 17 | EDITOR = 'vi' 18 | 19 | if ' ' in EDITOR: 20 | EXTRA_EDITOR_COMMAND = None 21 | else: 22 | if os.path.basename(EDITOR) in ('vim', 'nvim', 'vi'): 23 | EXTRA_EDITOR_COMMAND = '+set backupcopy=yes' 24 | elif os.path.basename(EDITOR) in ('atom', 'code', 'mate'): 25 | EXTRA_EDITOR_COMMAND = '-w' 26 | elif os.path.basename(EDITOR) == 'subl': 27 | EXTRA_EDITOR_COMMAND = '-n -w' 28 | else: 29 | # nano and emacs work without any extra commands 30 | EXTRA_EDITOR_COMMAND = None 31 | 32 | 33 | def file_editor(content): 34 | with tempfile.NamedTemporaryFile(suffix=".roamer") as temp: 35 | if sys.version_info[0] == 3: 36 | content = content.encode('utf-8') 37 | temp.write(content) 38 | temp.flush() 39 | if EXTRA_EDITOR_COMMAND: 40 | exit_code = call([EDITOR, EXTRA_EDITOR_COMMAND, temp.name]) 41 | else: 42 | exit_code = call(EDITOR.split() + [temp.name]) 43 | if exit_code != 0: 44 | sys.exit() 45 | temp.seek(0) 46 | output = temp.read() 47 | if sys.version_info[0] == 3: 48 | output = output.decode('UTF-8') 49 | return output 50 | -------------------------------------------------------------------------------- /roamer/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is the entry point for the application. 3 | Process command line arguments and pass appropriate flags to Session 4 | """ 5 | 6 | from __future__ import print_function 7 | import sys 8 | 9 | import click 10 | 11 | import roamer 12 | from roamer.session import Session 13 | 14 | 15 | @click.command() 16 | @click.option("--path", type=click.Path(file_okay=False, exists=True), default=None) 17 | @click.option("--raw-out/--no-raw-out", default=False) 18 | @click.option("--raw-in/--no-raw-in", default=False) 19 | @click.option("--skip-approval/--no-skip-approval", "skipapproval", default=False) 20 | @click.option("--version/--no-version", default=False) 21 | def start(path, raw_out, raw_in, skipapproval, version): 22 | if version: 23 | print(roamer.__version__) 24 | return 25 | 26 | # TODO: Is this a bug? If --raw-out/in is specified, then --skip-approval is ignored. 27 | # Is that correct? For now, to remain backwards compatible I am maintaining this behaviour. 28 | if raw_out: 29 | Session(cwd=path).print_raw() 30 | return 31 | 32 | if raw_in: 33 | Session(cwd=path).process(sys.stdin.read()) 34 | return 35 | 36 | Session(cwd=path, skipapproval=skipapproval).run() 37 | 38 | 39 | if __name__ == "__main__": 40 | # It looks like we are calling start without providing any of the required 41 | # arguments, but actually it's fine due to the click decorators. 42 | # pylint doesn't know this though, so we tell it to ignore the call: 43 | # pylint: disable=no-value-for-parameter 44 | start() 45 | -------------------------------------------------------------------------------- /roamer/record.py: -------------------------------------------------------------------------------- 1 | """ 2 | Saves entries and their digests onto disk so they can be used in later sessions. 3 | """ 4 | from roamer.constant import ROAMER_DATA_PATH 5 | from roamer.database import connection 6 | 7 | def load(filter_dir=None, trash=False): 8 | conn = connection() 9 | cursor = conn.cursor() 10 | query = 'SELECT * FROM entries WHERE trash = ? AND path != ?' 11 | cursor.execute(query, (trash, str(filter_dir))) 12 | return cursor.fetchall() 13 | 14 | def add_dir(directory): 15 | conn = connection() 16 | query = 'DELETE FROM entries WHERE path = ?' 17 | conn.execute(query, (str(directory),)) 18 | for entry in directory.entries.values(): 19 | check_conflict(conn, entry.digest, entry.name, entry.directory.path, False) 20 | query = 'INSERT OR REPLACE INTO entries(digest, name, path, trash)' \ 21 | 'VALUES ( ?, ?, ?, ? )' 22 | conn.execute(query, (entry.digest, entry.name, entry.directory.path, False)) 23 | conn.commit() 24 | 25 | def add_trash(digest, name, directory): 26 | conn = connection() 27 | check_conflict(conn, digest, name, directory, True) 28 | query = 'INSERT OR REPLACE INTO entries(digest, name, path, trash) VALUES ( ?, ?, ?, ? )' 29 | conn.execute(query, (digest, name, directory, True)) 30 | conn.commit() 31 | 32 | def check_conflict(conn, digest, name, path, is_trash): 33 | query = 'SELECT * FROM entries WHERE digest = ? AND trash = ?' 34 | cursor = conn.cursor() 35 | cursor.execute(query, (digest, is_trash)) 36 | result = cursor.fetchone() 37 | if not result: 38 | return False 39 | if result['name'] == name and result['path'] == path and not is_trash: 40 | return False 41 | raise DigesetCollision('Hash collision. Try deleting: %s' % ROAMER_DATA_PATH) 42 | 43 | def get_version(path, name): 44 | conn = connection() 45 | cursor = conn.cursor() 46 | query = 'SELECT version FROM modifications WHERE path = ? AND name = ?' 47 | cursor.execute(query, (path, name)) 48 | result = cursor.fetchone() 49 | if not result: 50 | return None 51 | return result['version'] 52 | 53 | def set_version(path, name, version): 54 | conn = connection() 55 | query = 'INSERT OR REPLACE INTO modifications(path, name, version) VALUES ( ?, ?, ? )' 56 | conn.execute(query, (path, name, version)) 57 | conn.commit() 58 | 59 | def path_by_digest(digest): 60 | conn = connection() 61 | cursor = conn.cursor() 62 | query = 'SELECT name, path FROM entries WHERE digest = ?' 63 | cursor.execute(query, (digest,)) 64 | result = cursor.fetchone() 65 | if not result: 66 | raise Exception('digest %s not found in database' % digest) 67 | return result['name'], result['path'] 68 | 69 | 70 | class DigesetCollision(Exception): 71 | pass 72 | -------------------------------------------------------------------------------- /roamer/session.py: -------------------------------------------------------------------------------- 1 | """ 2 | Represents a single session of roamer. 3 | Preps the state, gathers records from disk, opens user's file editor 4 | and passes into the Engine for processing 5 | """ 6 | 7 | from __future__ import print_function 8 | import os 9 | import sys 10 | from roamer.file_edit import file_editor 11 | from roamer.directory import Directory 12 | from roamer.edit_directory import EditDirectory 13 | from roamer.engine import Engine 14 | from roamer import record 15 | from roamer.database import db_init 16 | 17 | try: 18 | input = raw_input # pylint: disable=invalid-name, redefined-builtin 19 | except NameError: 20 | pass 21 | 22 | class Session(object): 23 | def __init__(self, cwd=None, skipapproval=True): 24 | self.cwd = cwd 25 | self.skipapproval = skipapproval 26 | if cwd is None: 27 | self.cwd = os.getcwd() 28 | db_init() 29 | raw_entries = os.listdir(self.cwd) 30 | self.directory = Directory(self.cwd, raw_entries) 31 | self.edit_directory = None 32 | record.add_dir(self.directory) 33 | 34 | def run(self): 35 | output = file_editor(self.directory.text()) 36 | self.process(output) 37 | 38 | def print_raw(self): 39 | print(self.directory.text()) 40 | 41 | def process(self, output): 42 | self.edit_directory = EditDirectory(self.cwd, output) 43 | engine = Engine(self.directory, self.edit_directory) 44 | engine.compile_commands() 45 | print(engine.commands_to_str()) 46 | if engine.commands and not self.skipapproval: 47 | print( 48 | 'Argument --skip-approval could be used to run roamer ' 49 | 'in a noninteractive mode.' 50 | ) 51 | try: 52 | answer = input('Please indicate approval: [y/N] ') 53 | except KeyboardInterrupt: 54 | # Add line feed 55 | print() 56 | answer = None 57 | if not answer or answer[0].lower() != 'y': 58 | print('You did not indicate approval.') 59 | sys.exit(1) 60 | engine.run_commands() 61 | record.add_dir(Directory(self.cwd, os.listdir(self.cwd))) 62 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import roamer 3 | setup( 4 | name = 'roamer', 5 | packages = ['roamer'], 6 | version = roamer.__version__, 7 | description = 'Plain Text File Explorer', 8 | author = 'Alex Baldwin', 9 | author_email = 'abaldwintech+hub@gmail.com', 10 | url = 'https://github.com/abaldwin88/roamer', 11 | download_url = 'https://github.com/abaldwin88/roamer/archive/v%s.zip' % roamer.__version__, 12 | keywords = ['explorer', 'plain text', 'directory'], # arbitrary keywords 13 | classifiers = [], 14 | install_requires=['click>=7.0'], 15 | entry_points=''' 16 | [console_scripts] 17 | roamer=roamer.main:start 18 | ''', 19 | ) 20 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abaldwin88/roamer/b4c66fac0a26c6612b504f809d7ffde397a115a9/tests/__init__.py -------------------------------------------------------------------------------- /tests/mock_session.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mocks a roamer session for testing purposes. 3 | Wraps the session process to avoid actually interacting with a text editor. 4 | Instead the text output that is normally destined for the users editor is available 5 | for manipulation inside the tests. 6 | """ 7 | import re 8 | from roamer.session import Session 9 | 10 | class MockSession(object): 11 | def __init__(self, directory): 12 | self.load(directory) 13 | 14 | def load(self, directory): 15 | self.directory = directory 16 | self.session = Session(directory) 17 | self.text = self.session.directory.text() 18 | 19 | def reload(self): 20 | self.load(self.directory) 21 | 22 | def process(self): 23 | self.session.process(self.text) 24 | 25 | def add_entry(self, name, digest=None): 26 | if digest: 27 | new_line = '%s | %s' % (name, digest) 28 | self.text += '\n%s' % new_line 29 | else: 30 | self.text += '\n%s' % name 31 | 32 | def remove_entry(self, name): 33 | self.text = re.sub(r'%s.*\n?' % name, '', self.text, flags=re.MULTILINE) 34 | 35 | def get_digest(self, name): 36 | return re.search(r'%s\ \|\ (.*)' % name, self.text, re.MULTILINE).group(1) 37 | 38 | def rename(self, old_name, new_name): 39 | self.text = re.sub(old_name, new_name, self.text) 40 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integration tests for roamer 3 | """ 4 | 5 | import os 6 | from os.path import expanduser 7 | import shutil 8 | import unittest 9 | BASE_ROAMER_PATH = os.environ.get('ROAMER_DATA_PATH') or expanduser('~/.roamer-data/') 10 | os.environ["ROAMER_DATA_PATH"] = expanduser(os.path.join(BASE_ROAMER_PATH, 'tmp/test/.roamer-data')) 11 | from tests.mock_session import MockSession # pylint: disable=wrong-import-position 12 | from roamer.constant import TEST_DIR, ROAMER_DATA_PATH, TRASH_DIR # pylint: disable=wrong-import-position 13 | from roamer import record # pylint: disable=wrong-import-position 14 | 15 | HELLO_DIR = os.path.join(TEST_DIR, 'hello/') 16 | DOC_DIR = os.path.join(TEST_DIR, 'docs/') 17 | SPAM_FILE = os.path.join(TEST_DIR, 'spam.txt') 18 | EGG_FILE = os.path.join(TEST_DIR, 'egg.txt') 19 | ARGH_FILE = os.path.join(TEST_DIR, 'argh.md') 20 | RESEARCH_FILE = os.path.join(DOC_DIR, 'research.txt') 21 | 22 | def reset_dirs(directories): 23 | for directory in directories: 24 | if os.path.exists(directory): 25 | shutil.rmtree(directory) 26 | os.makedirs(directory) 27 | 28 | def build_testing_entries(): 29 | os.makedirs(HELLO_DIR) 30 | os.makedirs(DOC_DIR) 31 | with open(SPAM_FILE, "w") as text_file: 32 | text_file.write('spam file content') 33 | with open(EGG_FILE, "w") as text_file: 34 | text_file.write('egg file content') 35 | with open(ARGH_FILE, "w") as text_file: 36 | text_file.write('argh file content') 37 | with open(RESEARCH_FILE, "w") as text_file: 38 | text_file.write('research file content') 39 | 40 | 41 | class TestOperations(unittest.TestCase): #pylint: disable=too-many-public-methods 42 | def setUp(self): 43 | reset_dirs([ROAMER_DATA_PATH, TRASH_DIR, TEST_DIR]) 44 | build_testing_entries() 45 | self.session = MockSession(TEST_DIR) 46 | 47 | def test_directory_text_output(self): 48 | self.assertTrue('hello/' in self.session.text) 49 | self.assertTrue('docs/' in self.session.text) 50 | self.assertTrue('spam.txt' in self.session.text) 51 | self.assertTrue('egg.txt' in self.session.text) 52 | self.assertTrue('argh.md' in self.session.text) 53 | 54 | def test_no_changes_does_nothing(self): 55 | self.session.process() 56 | self.assertTrue('hello/' in self.session.text) 57 | self.assertTrue('docs/' in self.session.text) 58 | self.assertTrue('spam.txt' in self.session.text) 59 | self.assertTrue('egg.txt' in self.session.text) 60 | self.assertTrue('argh.md' in self.session.text) 61 | 62 | def test_create_new_file(self): 63 | self.session.add_entry('new_file.txt') 64 | self.session.process() 65 | path = os.path.join(TEST_DIR, 'new_file.txt') 66 | self.assertTrue(os.path.exists(path)) 67 | self.assertTrue(os.path.isfile(path)) 68 | 69 | def test_create_new_directory(self): 70 | self.session.add_entry('new_dir/') 71 | self.session.process() 72 | path = os.path.join(TEST_DIR, 'new_dir/') 73 | self.assertTrue(os.path.exists(path)) 74 | self.assertTrue(os.path.isdir(path)) 75 | 76 | def test_delete_file(self): 77 | self.session.remove_entry('argh.md') 78 | self.session.process() 79 | self.assertFalse(os.path.exists(ARGH_FILE)) 80 | 81 | def test_delete_directory(self): 82 | self.session.remove_entry('hello/') 83 | self.session.process() 84 | self.assertFalse(os.path.exists(HELLO_DIR)) 85 | 86 | def test_copy_file(self): 87 | digest = self.session.get_digest('egg.txt') 88 | self.session.add_entry('egg2.txt', digest) 89 | self.session.process() 90 | path = os.path.join(TEST_DIR, 'egg2.txt') 91 | self.assertTrue(os.path.exists(path)) 92 | with open(path, 'r') as egg2_file: 93 | self.assertEqual(egg2_file.read(), 'egg file content') 94 | 95 | def test_copy_directory(self): 96 | digest = self.session.get_digest('docs/') 97 | self.session.add_entry('docs2/', digest) 98 | self.session.process() 99 | path = os.path.join(TEST_DIR, 'docs2/') 100 | self.assertTrue(os.path.exists(path)) 101 | contents = os.listdir(path) 102 | self.assertEqual(contents, ['research.txt']) 103 | 104 | def test_rename_file(self): 105 | self.session.rename('argh.md', 'blarg.md') 106 | self.session.process() 107 | self.assertFalse(os.path.exists(ARGH_FILE)) 108 | path = os.path.join(TEST_DIR, 'blarg.md') 109 | self.assertTrue(os.path.exists(path)) 110 | with open(path, 'r') as blarg_file: 111 | self.assertEqual(blarg_file.read(), 'argh file content') 112 | 113 | def test_rename_directory(self): 114 | self.session.rename('docs/', 'my-docs/') 115 | self.session.process() 116 | self.assertFalse(os.path.exists(DOC_DIR)) 117 | path = os.path.join(TEST_DIR, 'my-docs/') 118 | self.assertTrue(os.path.exists(path)) 119 | contents = os.listdir(path) 120 | self.assertEqual(contents, ['research.txt']) 121 | 122 | def disabled_clear_persisted_file_contents(self): 123 | self.session.remove_entry('argh.md') 124 | self.session.add_entry('argh.md') 125 | self.session.process() 126 | path = ARGH_FILE 127 | self.assertTrue(os.path.exists(path)) 128 | with open(path, 'r') as argh_file: 129 | self.assertEqual(argh_file.read(), '') 130 | 131 | def test_comment_line(self): 132 | original_entry_count = len(os.listdir(TEST_DIR)) 133 | self.session.add_entry('" Comment Line') 134 | self.session.process() 135 | entry_count = len(os.listdir(TEST_DIR)) 136 | self.assertEqual(entry_count, original_entry_count) 137 | 138 | def test_rename_file_to_directory(self): 139 | self.session.rename('hello/', 'hello.txt') 140 | with self.assertRaises(ValueError): 141 | self.session.process() 142 | 143 | def test_empty_lines(self): 144 | self.session.text += '\n \n \n ' 145 | self.session.process() 146 | self.assertTrue(os.path.exists(ARGH_FILE)) 147 | 148 | def test_swap_files(self): 149 | spam_digest = self.session.get_digest('spam.txt') 150 | self.session.remove_entry('spam.txt') 151 | self.session.rename('egg.txt', 'spam.txt') 152 | self.session.add_entry('egg.txt', spam_digest) 153 | self.session.process() 154 | path = os.path.join(TEST_DIR, 'egg.txt') 155 | with open(path, 'r') as egg_file: 156 | self.assertEqual(egg_file.read(), 'spam file content') 157 | path = os.path.join(TEST_DIR, 'spam.txt') 158 | with open(path, 'r') as spam_file: 159 | self.assertEqual(spam_file.read(), 'egg file content') 160 | 161 | def test_multiple_simple_operations(self): 162 | self.test_create_new_file() 163 | self.session.reload() 164 | self.test_delete_directory() 165 | self.session.reload() 166 | self.test_rename_file() 167 | self.session.reload() 168 | self.test_delete_file() 169 | 170 | def test_copy_file_between_directories(self): 171 | digest = self.session.get_digest('egg.txt') 172 | self.session.process() 173 | second_session = MockSession(DOC_DIR) 174 | second_session.add_entry('egg.txt', digest) 175 | second_session.process() 176 | path = os.path.join(DOC_DIR, 'egg.txt') 177 | self.assertTrue(os.path.exists(path)) 178 | with open(path, 'r') as egg_file: 179 | self.assertEqual(egg_file.read(), 'egg file content') 180 | 181 | def test_cut_paste_files_between_directories(self): 182 | digest = self.session.get_digest('egg.txt') 183 | self.session.remove_entry('egg.txt') 184 | self.session.process() 185 | second_session = MockSession(DOC_DIR) 186 | second_session.add_entry('egg.txt', digest) 187 | second_session.add_entry('egg2.txt', digest) 188 | second_session.process() 189 | path = os.path.join(DOC_DIR, 'egg.txt') 190 | self.assertTrue(os.path.exists(path)) 191 | with open(path, 'r') as egg_file: 192 | self.assertEqual(egg_file.read(), 'egg file content') 193 | path = os.path.join(DOC_DIR, 'egg2.txt') 194 | self.assertTrue(os.path.exists(path)) 195 | with open(path, 'r') as egg_file: 196 | self.assertEqual(egg_file.read(), 'egg file content') 197 | 198 | def test_cut_paste_file_same_name(self): 199 | doc_session = MockSession(DOC_DIR) 200 | digest = doc_session.get_digest('research.txt') 201 | doc_session.remove_entry('research.txt') 202 | doc_session.process() 203 | 204 | self.session.add_entry('research.txt') 205 | self.session.process() 206 | 207 | self.session.reload() 208 | self.session.remove_entry('research.txt') 209 | self.session.process() 210 | 211 | self.session.reload() 212 | self.session.add_entry('my_new_research.txt', digest) 213 | self.session.process() 214 | 215 | path = os.path.join(TEST_DIR, 'my_new_research.txt') 216 | self.assertTrue(os.path.exists(path)) 217 | with open(path, 'r') as research_file: 218 | self.assertEqual(research_file.read(), 'research file content') 219 | 220 | def test_cut_paste_directory(self): 221 | digest = self.session.get_digest('docs/') 222 | self.session.remove_entry('docs/') 223 | self.session.process() 224 | second_session = MockSession(HELLO_DIR) 225 | second_session.add_entry('docs/', digest) 226 | second_session.process() 227 | path = os.path.join(HELLO_DIR, 'docs/') 228 | self.assertTrue(os.path.exists(path)) 229 | contents = os.listdir(path) 230 | self.assertEqual(contents, ['research.txt']) 231 | 232 | def test_multiple_file_deletes(self): 233 | self.session.remove_entry('argh.md') 234 | self.session.process() 235 | self.assertFalse(os.path.exists(ARGH_FILE)) 236 | second_session = MockSession(TEST_DIR) 237 | second_session.add_entry('argh.md') 238 | second_session.process() 239 | self.assertTrue(os.path.exists(ARGH_FILE)) 240 | second_session.reload() 241 | second_session.remove_entry('argh.md') 242 | second_session.process() 243 | self.assertFalse(os.path.exists(ARGH_FILE)) 244 | 245 | def test_copy_over_existing_file(self): 246 | erased_spam_digest = self.session.get_digest('spam.txt') 247 | egg_digest = self.session.get_digest('egg.txt') 248 | self.session.remove_entry('spam.txt') 249 | self.session.add_entry('spam.txt', egg_digest) 250 | self.session.process() 251 | path = os.path.join(TEST_DIR, 'spam.txt') 252 | self.assertTrue(os.path.exists(path)) 253 | with open(path, 'r') as spam_file: 254 | self.assertEqual(spam_file.read(), 'egg file content') 255 | 256 | second_session = MockSession(DOC_DIR) 257 | second_session.add_entry('spam.txt', erased_spam_digest) 258 | second_session.process() 259 | path = os.path.join(DOC_DIR, 'spam.txt') 260 | self.assertTrue(os.path.exists(path)) 261 | with open(path, 'r') as spam_file: 262 | self.assertEqual(spam_file.read(), 'spam file content') 263 | 264 | def test_copy_file_same_name(self): 265 | digest = self.session.get_digest('egg.txt') 266 | for _ in range(3): 267 | self.session.add_entry('egg.txt', digest) 268 | self.session.process() 269 | for egg_file in ['egg.txt', 'egg_copy_1.txt', 'egg_copy_2.txt', 'egg_copy_3.txt']: 270 | path = os.path.join(TEST_DIR, egg_file) 271 | self.assertTrue(os.path.exists(path)) 272 | with open(path, 'r') as new_file: 273 | self.assertEqual(new_file.read(), 'egg file content') 274 | 275 | def test_copy_dir_same_name(self): 276 | digest = self.session.get_digest('docs/') 277 | self.session.add_entry('docs/', digest) 278 | self.session.process() 279 | for doc_dir in ['docs/', 'docs_copy_1/']: 280 | path = os.path.join(TEST_DIR, doc_dir) 281 | self.assertTrue(os.path.exists(path)) 282 | contents = os.listdir(path) 283 | self.assertEqual(contents, ['research.txt']) 284 | 285 | def test_create_new_directory_and_file(self): 286 | self.session.add_entry('new_file.txt') 287 | self.session.add_entry('new_dir/') 288 | self.session.process() 289 | path = os.path.join(TEST_DIR, 'new_dir/') 290 | self.assertTrue(os.path.exists(path)) 291 | self.assertTrue(os.path.isdir(path)) 292 | 293 | def test_copy_file_same_name_no_extension(self): 294 | digest = self.session.get_digest('egg.txt') 295 | for _ in range(3): 296 | self.session.add_entry('egg', digest) 297 | self.session.process() 298 | for egg_file in ['egg.txt', 'egg', 'egg_copy_1', 'egg_copy_2']: 299 | path = os.path.join(TEST_DIR, egg_file) 300 | self.assertTrue(os.path.exists(path)) 301 | with open(path, 'r') as new_file: 302 | self.assertEqual(new_file.read(), 'egg file content') 303 | 304 | 305 | def test_shell_injection(self): 306 | self.session.add_entry('new_file.txt; rm %s' % SPAM_FILE) 307 | self.session.process() 308 | self.assertTrue(os.path.exists(SPAM_FILE)) 309 | 310 | def test_unicode_character(self): 311 | new_file = 'n\xc3\xa9w_file.txt' 312 | self.session.add_entry(new_file) 313 | self.session.process() 314 | second_session = MockSession(DOC_DIR) 315 | second_session.process() 316 | path = os.path.join(TEST_DIR, new_file) 317 | self.assertTrue(os.path.exists(path)) 318 | self.assertTrue(os.path.isfile(path)) 319 | 320 | def test_entries_collision(self): 321 | digest = self.session.get_digest('egg.txt') 322 | directory = self.session.session.directory 323 | entry = directory.entries[digest] 324 | entry.digest = self.session.get_digest('argh.md') 325 | with self.assertRaises(record.DigesetCollision): 326 | record.add_dir(directory) 327 | 328 | def test_trash_collision(self): 329 | digest = self.session.get_digest('egg.txt') 330 | directory = self.session.session.directory 331 | entry = directory.entries[digest] 332 | with self.assertRaises(record.DigesetCollision): 333 | record.add_trash(entry.digest, entry.name, 'irrelevant-param') 334 | record.add_trash(entry.digest, entry.name, 'irrelevant-param') 335 | 336 | def test_missing_file_for_symbolic_link(self): 337 | spam_link = os.path.join(TEST_DIR, 'spam_link') 338 | os.symlink(SPAM_FILE, spam_link) 339 | os.remove(SPAM_FILE) 340 | self.session.reload() 341 | self.session.remove_entry('spam_link') 342 | self.session.process() 343 | self.assertFalse(os.path.exists(spam_link)) 344 | 345 | def test_copy_symbolic_link(self): 346 | spam_link = os.path.join(TEST_DIR, 'spam_link') 347 | os.symlink(SPAM_FILE, spam_link) 348 | self.session.reload() 349 | digest = self.session.get_digest('spam_link') 350 | self.session.add_entry('spam_link2', digest) 351 | self.session.process() 352 | spam_link2 = os.path.join(TEST_DIR, 'spam_link2') 353 | self.assertEqual(os.readlink(spam_link2), SPAM_FILE) 354 | 355 | def test_file_save_outside_roamer(self): 356 | digest = self.session.get_digest('egg.txt') 357 | 358 | path = os.path.join(TEST_DIR, 'egg.txt') 359 | with open(path, 'a') as egg_file: 360 | egg_file.write(' extra content') 361 | os.utime(path, (1330712280, 1330712292)) 362 | self.session.process() 363 | 364 | second_session = MockSession(DOC_DIR) 365 | second_session.add_entry('egg.txt', digest) 366 | second_session.process() 367 | path = os.path.join(DOC_DIR, 'egg.txt') 368 | self.assertTrue(os.path.exists(path)) 369 | with open(path, 'r') as egg_file: 370 | self.assertEqual(egg_file.read(), 'egg file content extra content') 371 | 372 | 373 | if __name__ == '__main__': 374 | unittest.main() 375 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py34,py35,py36 3 | 4 | [testenv] 5 | deps = pylint 6 | commands = 7 | python -m unittest discover 8 | pylint roamer tests 9 | 10 | --------------------------------------------------------------------------------