├── .coveragerc ├── .flake8 ├── .gitignore ├── CHANGELOG ├── LICENSE ├── Makefile ├── README.rst ├── docs ├── torf.1 ├── torf.1.asciidoc └── torf.1.html ├── pyproject.toml ├── ruff.toml ├── tests ├── conftest.py ├── test_basics.py ├── test_configfile.py ├── test_configformat.py ├── test_create.py ├── test_edit.py ├── test_errors.py ├── test_info.py ├── test_json.py ├── test_metainfo.py ├── test_profiles.py ├── test_progress.py ├── test_reuse.py ├── test_stdin.py ├── test_utils.py └── test_verify.py ├── torfcli ├── __init__.py ├── __main__.py ├── _config.py ├── _errors.py ├── _main.py ├── _term.py ├── _ui.py ├── _utils.py └── _vars.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [html] 2 | directory = /tmp/htmlcov 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | # visually indented line with same indent as next logical line 4 | E129, 5 | # missing whitespace before ':' 6 | E203, 7 | # multiple spaces before operator 8 | E221, 9 | # missing whitespace after ',' 10 | E231, 11 | # too many leading '#' for block comment 12 | E266, 13 | # multiple spaces after keyword 14 | E271, 15 | # multiple spaces before keyword 16 | E272, 17 | # line too long 18 | E501, 19 | # expected 2 blank lines 20 | E302, 21 | # too many blank lines 22 | E303, 23 | # expected 2 blank lines after class or function definition 24 | E305, 25 | # multiple spaces after ',' 26 | E241, 27 | # multiple statements on one line (colon) 28 | E701, 29 | # multiple statements on one line (def) 30 | E704, 31 | # line break before binary operator 32 | W503, 33 | # line break after binary operator 34 | W504, 35 | # invalid escape sequence '\ ' 36 | W605, 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *.egg-info/ 5 | 6 | # Pytest cache 7 | .cache 8 | .pytest_cache 9 | .tox 10 | .python-version 11 | 12 | # Virtual environment 13 | venv 14 | 15 | pypirc 16 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 2024-06-13 5.2.1 2 | - Exclude tests from package 3 | 4 | 5 | 2024-03-25 5.2.0 6 | - New option: --merge JSON: Add custom information to a torrent's metainfo or 7 | remove arbitrary information from it 8 | 9 | 10 | 2023-04-29 5.1.0 11 | - New option: --creator 12 | 13 | 14 | 2023-04-13 5.0.0 15 | - Include profile name (if given) in torrent file name by default 16 | 17 | 18 | 2022-12-17 4.0.4 19 | - Bugfix: Fix --max-piece-size 20 | 21 | 22 | 2022-07-05 4.0.3 23 | - Bugfix: Fix --help output: Short form of --nocreator is now -A. 24 | 25 | 26 | 2022-06-19 4.0.2 27 | - Bugfix: --max-piece-size was ignored with reused torrents. 28 | 29 | 30 | 2022-06-02 4.0.1 31 | - Bugfix: --nomagnet was ignored in info mode. 32 | - Include magnet link in output even if input is also a magnet link. 33 | Only exclude it with --nomagnet. 34 | 35 | 36 | 2022-05-05 4.0.0 37 | - New option: --reuse copies piece hashes from an 38 | existing torrent file if it contains the same files. If given a directory, 39 | it is recursively searched for a matching torrent file. 40 | - The short flag -R for --nocreator was renamed to -A so -R can be used for 41 | --noreuse. 42 | - When verifying files against a torrent in previous versions, each file was 43 | only verified up to the first corrupt piece unless --verbose was given. Now 44 | all files are always completely verified. 45 | - The "Files" field in --json output is now a list of objects like this: 46 | {"Path": , "Size": } 47 | - Bugfix: The --max-piece-size parameter was always used as the actual piece 48 | size, even if it was much too large for the given content. 49 | 50 | 51 | 2021-02-20 3.4.0 52 | - New option: --threads 53 | 54 | 55 | 2020-08-11 3.3.0 56 | - Always display which tier a tracker belongs to unless the whole torrent has 57 | only one tracker. 58 | - New options: --include and --include-regex include files that match exclude 59 | patterns. 60 | - Bugfix: --exclude-regex was ignored when editing an existing torrent. 61 | 62 | 63 | 2020-06-21 3.2.0 64 | - Support for reading magnet URIs was added, e.g. "torf -i 'magnet:...' -o 65 | foo.torrent". The missing information is downloaded from the parameters 66 | "xs", "as", "ws" and "tr", if possible. Support for DHT and UDP trackers is 67 | not implemented. 68 | - The --in option now supports "-" as a parameter to read a torrent or magnet 69 | URI from stdin. 70 | - When verifying torrent content, a trailing slash in PATH automatically 71 | appends the torrent's name to PATH. For example, "torf -i foo.torrent 72 | path/to/foo" is identical to "torf -i foo.torrent path/to/" while "torf -i 73 | foo.torrent path/to" looks for foo.torrent's files in "path/to". 74 | - --in, --out and --name are now illegal in config files. 75 | 76 | 77 | 2020-04-07 3.1.1 78 | - Bugfix: Allow all torf 3.*.* version, not just 3.0.0. 79 | 80 | 81 | 2020-04-02 3.1.0 82 | - Huge performance increase due to multithreading. 83 | - Verify a torrent's content: torf -i content.torrent path/to/content 84 | - Progress is now reported in two lines with more information. 85 | - New option: --metainfo prints a JSON object of the torrent's metainfo. 86 | - New option: --json prints a JSON object of the regular output. 87 | - New option: --verbose shows file sizes in plain bytes, verifies file 88 | content more thoroughly, etc. 89 | - New option: --exclude-regex excludes files that match a regular expression. 90 | - --exclude and --exclude-regex patterns are now matched against the complete 91 | relative path within the torrent instead of individual path segments. 92 | - Support for multiple tiers of announce URLs when creating torrents. 93 | - Exit codes have changed and are now properly documented in the man page. 94 | - Bugfix: --max-piece-size can now set piece sizes larger than 16 MiB. 95 | 96 | 97 | 2019-06-03 3.0.1 98 | - Fixed minor bug that caused trailing zeros to be removed from numbers, e.g. 99 | "10 GiB" was displayed as "1 GiB" 100 | 101 | 102 | 2019-04-04 3.0.0 103 | - Use proper version number scheme. 104 | - Fixed "--exclude requires PATH" error when editing a torrent with global 105 | "exclude" options in the config file. 106 | - New options: --source to add a "source" field to the torrent and --nosource 107 | to remove it from an existing torrent. 108 | - New option: --max-piece-size optionally limits the piece size. 109 | 110 | 111 | 2018-06-19 2.0 112 | - Support for default arguments and special profiles in ~/.config/torf/config 113 | or any file specified by --config or -f. 114 | - Use \e[0E instead of \e[1` to clear the line when showing progress. 115 | (marcelpaulo) 116 | - If output is not a TTY, "Progress ..." lines are not cleared but followed by 117 | a newline character and the rest of the line is parsable like the other 118 | output. 119 | - Long or multiline torrent file comments are now properly indented. 120 | - --exclude patterns are now matched against each directory/file name in a 121 | path instead of just the file name. 122 | - Torrent file and magnet link are now both created by default, and the 123 | --magnet option was replaced by --nomagnet and --notorrent. 124 | - In the output 'Magnet URI' was shortened to 'Magnet', 'Torrent File' was 125 | shortened to 'Torrent' and 'Creation Date' was shortened to 'Created'. 126 | - The default for --date was changed from 'today' to 'now'. 127 | 128 | 129 | 2018-04-08 1.1 130 | - Major rewrite with lots of tests that should fix the most obvious bugs 131 | - The options --source and --nosource have been removed 132 | - The option --nocreator has been added 133 | - Output is now easier to parse when stdout is not a TTY (e.g. when piping to 134 | grep, cut, awk, etc) 135 | 136 | 137 | 2018-02-01 1.0 138 | Final release 139 | 140 | 141 | 2018-01-15 1.0a1 142 | Initial release 143 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PYTHON?=python3 2 | VENV_PATH?=venv 3 | MANPAGE ?= docs/torf.1 4 | MANPAGE_HTML ?= docs/torf.1.html 5 | MANPAGE_SRC ?= docs/torf.1.asciidoc 6 | 7 | .PHONY: clean man release 8 | 9 | clean: 10 | find . -name "*.pyc" -delete 11 | find . -name "__pycache__" -delete 12 | rm -rf dist build 13 | rm -rf .pytest_cache .cache 14 | rm -rf $(MANDIR) 15 | rm -rf "$(VENV_PATH)" 16 | rm -rf .tox 17 | 18 | venv: 19 | "$(PYTHON)" -m venv "$(VENV_PATH)" 20 | "$(VENV_PATH)"/bin/pip install --editable '.[dev]' 21 | 22 | man: 23 | asciidoctor $(MANPAGE_SRC) -o $(MANPAGE) --doctype=manpage --backend=manpage 24 | asciidoctor $(MANPAGE_SRC) -o $(MANPAGE_HTML) --doctype=manpage --backend=html 25 | 26 | release: man 27 | pyrelease CHANGELOG ./torfcli/_vars.py 28 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | torf-cli 2 | ======== 3 | 4 | torf-cli is a command line tool that can create, read and edit torrent files and 5 | magnet URIs. It can also verify a file system path against a torrent and provide 6 | detailed errors. When creating a torrent, it can find an existing torrent with 7 | the same files and copy its piece hashes to the freshly created torrent to avoid 8 | hashing the files again. 9 | 10 | The output is pleasant to read for humans or easy to parse with common CLI 11 | tools. 12 | 13 | An optional configuration file specifies custom default options and profiles 14 | that combine commonly used options. 15 | 16 | Documentation is available as a man page, or you can `read it here 17 | `_. 18 | 19 | The only dependencies are `torf `_ and `pyxdg 20 | `_. 21 | 22 | 23 | Examples 24 | -------- 25 | 26 | Create private torrent with two trackers and a specific creation date: 27 | 28 | .. code:: sh 29 | 30 | $ torf ./docs -t http://bar:123/announce -t http://baz:321/announce \ 31 | --private --date '2020-03-31 21:23:42' 32 | Name docs 33 | Size 74.43 KiB 34 | Created 2020-03-31 21:23:42 35 | Created By torf 3.1.0 36 | Private yes 37 | Trackers http://bar:123/announce 38 | http://baz:321/announce 39 | Piece Size 16 KiB 40 | Piece Count 5 41 | File Count 3 42 | Files docs 43 | ├─torf.1 [14.53 KiB] 44 | ├─torf.1.asciidoc [10.56 KiB] 45 | └─torf.1.html [49.34 KiB] 46 | Progress 100.00 % | 0:00:00 total | 72.69 MiB/s 47 | Info Hash 0a9dfcf07feb2a82da11b509e8929266d8510a02 48 | Magnet magnet:?xt=urn:btih:0a9dfcf07feb2a82da11b509e8929266d8510a02&dn=docs&xl=76217&tr=http%3A%2F%2Fbar%3A123%2Fannounce&tr=http%3A%2F%2Fbaz%3A321%2Fannounce 49 | Torrent docs.torrent 50 | 51 | Display information about an existing torrent: 52 | 53 | .. code:: sh 54 | 55 | $ torf -i docs.torrent 56 | Name docs 57 | Info Hash 0a9dfcf07feb2a82da11b509e8929266d8510a02 58 | Size 74.43 KiB 59 | Created 2020-03-31 21:23:42 60 | Created By torf 3.1.0 61 | Private yes 62 | Trackers http://bar:123/announce 63 | http://baz:321/announce 64 | Piece Size 16 KiB 65 | Piece Count 5 66 | File Count 3 67 | Files docs 68 | ├─torf.1 [14.53 KiB] 69 | ├─torf.1.asciidoc [10.56 KiB] 70 | └─torf.1.html [49.34 KiB] 71 | Magnet magnet:?xt=urn:btih:0a9dfcf07feb2a82da11b509e8929266d8510a02&dn=docs&xl=76217&tr=http%3A%2F%2Fbar%3A123%2Fannounce&tr=http%3A%2F%2Fbaz%3A321%2Fannounce 72 | 73 | Quickly add a comment to an existing torrent: 74 | 75 | .. code:: sh 76 | 77 | $ torf -i docs.torrent --comment 'Forgot to add this comment.' -o docs.revised.torrent 78 | Name docs 79 | Info Hash 0a9dfcf07feb2a82da11b509e8929266d8510a02 80 | Size 74.43 KiB 81 | Comment Forgot to add this comment. 82 | Created 2020-03-31 21:23:42 83 | Created By torf 3.1.0 84 | Private yes 85 | Trackers http://bar:123/announce 86 | http://baz:321/announce 87 | Piece Size 16 KiB 88 | Piece Count 5 89 | File Count 3 90 | Files docs 91 | ├─torf.1 [14.53 KiB] 92 | ├─torf.1.asciidoc [10.56 KiB] 93 | └─torf.1.html [49.34 KiB] 94 | Magnet magnet:?xt=urn:btih:0a9dfcf07feb2a82da11b509e8929266d8510a02&dn=docs&xl=76217&tr=http%3A%2F%2Fbar%3A123%2Fannounce&tr=http%3A%2F%2Fbaz%3A321%2Fannounce 95 | Torrent docs.revised.torrent 96 | 97 | Verify the files in ``docs``: 98 | 99 | .. code:: sh 100 | 101 | $ 102 | $ torf -i docs.revised.torrent docs 103 | Name docs 104 | Info Hash 0a9dfcf07feb2a82da11b509e8929266d8510a02 105 | Size 74.43 KiB 106 | Comment Forgot to add this comment. 107 | Created 2020-03-31 21:23:42 108 | Created By torf 3.1.0 109 | Private yes 110 | Trackers http://bar:123/announce 111 | http://baz:321/announce 112 | Piece Size 16 KiB 113 | Piece Count 5 114 | File Count 3 115 | Files docs 116 | ├─torf.1 [14.53 KiB] 117 | ├─torf.1.asciidoc [10.56 KiB] 118 | └─torf.1.html [49.34 KiB] 119 | Path docs 120 | Info Hash 0a9dfcf07feb2a82da11b509e8929266d8510a02 121 | Error docs/torf.1.html: Too big: 50523 instead of 50522 bytes 122 | Error Corruption in piece 2, at least one of these files is corrupt: 123 | docs/torf.1.asciidoc 124 | docs/torf.1.html 125 | Progress 100.00 % | 0:00:00 total | 72.69 MiB/s 126 | torf: docs does not satisfy docs.revised.torrent 127 | 128 | Get a list of files via ``grep`` and ``cut``: 129 | 130 | .. code:: sh 131 | 132 | $ torf -i docs.revised.torrent | grep '^Files' | cut -f2- 133 | docs/torf.1 docs/torf.1.asciidoc docs/torf.1.html 134 | # Files are delimited by a horizontal tab (``\t``) 135 | 136 | Get a list of files via `jq `_: 137 | 138 | .. code:: sh 139 | 140 | $ torf -i docs.revised.torrent --json | jq .Files 141 | [ 142 | "docs/torf.1", 143 | "docs/torf.1.asciidoc", 144 | "docs/torf.1.html" 145 | ] 146 | 147 | Get metainfo as JSON: 148 | 149 | .. code:: sh 150 | 151 | $ torf -i docs.revised.torrent -m 152 | { 153 | "announce": "http://bar:123/announce", 154 | "announce-list": [ 155 | [ 156 | "http://bar:123/announce" 157 | ], 158 | [ 159 | "http://baz:321/announce" 160 | ] 161 | ], 162 | "comment": "Forgot to add this comment.", 163 | "created by": "torf 3.1.0", 164 | "creation date": 1585682622, 165 | "info": { 166 | "name": "docs", 167 | "piece length": 16384, 168 | "private": 1, 169 | "files": [ 170 | { 171 | "length": 14877, 172 | "path": [ 173 | "torf.1" 174 | ] 175 | }, 176 | { 177 | "length": 10818, 178 | "path": [ 179 | "torf.1.asciidoc" 180 | ] 181 | }, 182 | { 183 | "length": 50522, 184 | "path": [ 185 | "torf.1.html" 186 | ] 187 | } 188 | ] 189 | } 190 | } 191 | 192 | 193 | Installation 194 | ------------ 195 | 196 | The latest release is available on `PyPI `_ 197 | and on `AUR `_. 198 | 199 | 200 | pipx 201 | ```` 202 | 203 | The easiest and cleanest installation method is `pipx 204 | `__, which installs each application with all 205 | dependencies in a separate virtual environment in ``~/.local/venvs/`` and links 206 | the executable to ``~/.local/bin/``. 207 | 208 | .. code:: sh 209 | 210 | $ pipx install torf-cli 211 | $ pipx upgrade torf-cli 212 | $ pipx uninstall torf-cli # Also removes dependencies 213 | 214 | The only drawback is that, at the time of writing, pipx doesn't make the man 215 | page available, but `it's also available here 216 | `_. 217 | 218 | 219 | pip 220 | ``` 221 | 222 | The alternative is regular `pip `__, but if you 223 | decide to uninstall, you have to manually uninstall the dependencies. 224 | 225 | .. code:: sh 226 | 227 | $ pip3 install torf-cli # Installs system-wide (/usr/local/) 228 | $ pip3 install --user torf-cli # Installs in your home (~/.local/) 229 | 230 | The `latest development version `_ is 231 | available on GitHub in the master branch. 232 | 233 | .. code:: sh 234 | 235 | $ pip3 install [--user] git+https://github.com/rndusr/torf-cli.git 236 | 237 | 238 | Contributing 239 | ------------ 240 | 241 | Bug reports and feature requests are welcome in the `issue tracker 242 | `_. 243 | 244 | 245 | License 246 | ------- 247 | 248 | torf-cli is free software: you can redistribute it and/or modify it under the 249 | terms of the GNU General Public License as published by the Free Software 250 | Foundation, either version 3 of the License, or (at your option) any later 251 | version. 252 | 253 | This program is distributed in the hope that it will be useful but WITHOUT ANY 254 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A 255 | PARTICULAR PURPOSE. See the `GNU General Public License 256 | `_ for more details. 257 | -------------------------------------------------------------------------------- /docs/torf.1: -------------------------------------------------------------------------------- 1 | '\" t 2 | .\" Title: torf 3 | .\" Author: [see the "AUTHOR(S)" section] 4 | .\" Generator: Asciidoctor 2.0.20 5 | .\" Date: 2024-03-25 6 | .\" Manual: \ \& 7 | .\" Source: \ \& 8 | .\" Language: English 9 | .\" 10 | .TH "TORF" "1" "2024-03-25" "\ \&" "\ \&" 11 | .ie \n(.g .ds Aq \(aq 12 | .el .ds Aq ' 13 | .ss \n[.ss] 0 14 | .nh 15 | .ad l 16 | .de URL 17 | \fI\\$2\fP <\\$1>\\$3 18 | .. 19 | .als MTO URL 20 | .if \n[.g] \{\ 21 | . mso www.tmac 22 | . am URL 23 | . ad l 24 | . . 25 | . am MTO 26 | . ad l 27 | . . 28 | . LINKSTYLE blue R < > 29 | .\} 30 | .SH "NAME" 31 | torf \- command line tool to create, display and edit torrents 32 | .SH "SYNOPSIS" 33 | .sp 34 | \fBtorf\fP \fIPATH\fP [\fIOPTIONS\fP] [\fB\-o\fP \fITORRENT\fP] 35 | .br 36 | \fBtorf\fP \fB\-i\fP \fIINPUT\fP 37 | .br 38 | \fBtorf\fP \fB\-i\fP \fIINPUT\fP [\fIOPTIONS\fP] \fB\-o\fP \fITORRENT\fP 39 | .br 40 | \fBtorf\fP \fB\-i\fP \fITORRENT\fP \fIPATH\fP 41 | .br 42 | .SH "DESCRIPTION" 43 | .sp 44 | torf can create, display and edit torrent files and verify the integrity of the 45 | files in a torrent. 46 | .sp 47 | .RS 4 48 | .ie n \{\ 49 | \h'-04'\(bu\h'+03'\c 50 | .\} 51 | .el \{\ 52 | . sp -1 53 | . IP \(bu 2.3 54 | .\} 55 | \fBtorf\fP \fIPATH\fP [\fIOPTIONS\fP] [\fB\-o\fP \fITORRENT\fP] 56 | .br 57 | Create the torrent file \fITORRENT\fP from the file or directory \fIPATH\fP. 58 | .RE 59 | .sp 60 | .RS 4 61 | .ie n \{\ 62 | \h'-04'\(bu\h'+03'\c 63 | .\} 64 | .el \{\ 65 | . sp -1 66 | . IP \(bu 2.3 67 | .\} 68 | \fBtorf\fP \fB\-i\fP \fIINPUT\fP 69 | .br 70 | Display information stored in the torrent file or magnet URI \fIINPUT\fP. 71 | .RE 72 | .sp 73 | .RS 4 74 | .ie n \{\ 75 | \h'-04'\(bu\h'+03'\c 76 | .\} 77 | .el \{\ 78 | . sp -1 79 | . IP \(bu 2.3 80 | .\} 81 | \fBtorf\fP \fB\-i\fP \fIINPUT\fP [\fIOPTIONS\fP] \fB\-o\fP \fITORRENT\fP 82 | .br 83 | Edit the existing torrent file or magnet URI \fIINPUT\fP (e.g. to fix a typo) and 84 | create the new torrent file \fITORRENT\fP. 85 | .if n .sp 86 | .RS 4 87 | .it 1 an-trap 88 | .nr an-no-space-flag 1 89 | .nr an-break-flag 1 90 | .br 91 | .ps +1 92 | .B Warning 93 | .ps -1 94 | .br 95 | .sp 96 | Editing a torrent can change its hash, depending on what is changed, 97 | which essentially makes it a new torrent. See OPTIONS to find out 98 | whether a certain option will change the hash. 99 | .sp .5v 100 | .RE 101 | .RE 102 | .sp 103 | .RS 4 104 | .ie n \{\ 105 | \h'-04'\(bu\h'+03'\c 106 | .\} 107 | .el \{\ 108 | . sp -1 109 | . IP \(bu 2.3 110 | .\} 111 | \fBtorf\fP \fB\-i\fP \fITORRENT\fP \fIPATH\fP 112 | .br 113 | Verify that the content in \fIPATH\fP matches the metadata in the torrent file 114 | \fITORRENT\fP. 115 | .sp 116 | If \fIPATH\fP ends with a path separator (usually \(lq/\(rq), the name of the torrent 117 | (as specified by the metadata in \fITORRENT\fP) is appended. 118 | .RE 119 | .SH "OPTIONS" 120 | .sp 121 | Options that start with \fB\-\-no\fP take precedence. 122 | .sp 123 | \fIPATH\fP 124 | .RS 4 125 | The path to the torrent\(cqs content. 126 | .RE 127 | .sp 128 | \fB\-\-in\fP, \fB\-i\fP \fIINPUT\fP 129 | .RS 4 130 | Read metainfo from the torrent file or magnet URI \fIINPUT\fP. If \fIINPUT\fP is \(lq\-\(rq 131 | and does not exist, the torrent data or magnet URI is read from stdin. 132 | .RE 133 | .sp 134 | \fB\-\-out\fP, \fB\-o\fP \fITORRENT\fP 135 | .RS 4 136 | Write to torrent file \fITORRENT\fP. 137 | .br 138 | Default: \fINAME\fP\fB.torrent\fP 139 | .RE 140 | .sp 141 | \fB\-\-reuse\fP, \fB\-r\fP \fIPATH\fP 142 | .RS 4 143 | Copy piece size and piece hashes from existing torrent \fIPATH\fP. The existing 144 | torrent must have identical files. If \fIPATH\fP is a directory, it is searched 145 | recursively for a matching torrent. This option may be given multiple times. 146 | .RE 147 | .sp 148 | \fB\-\-noreuse\fP, \fB\-R\fP 149 | .RS 4 150 | Ignore all \fB\-\-reuse\fP arguments. This is particularly useful if you have reuse 151 | paths in your configuration file. 152 | .RE 153 | .sp 154 | \fB\-\-exclude\fP, \fB\-e\fP \fIPATTERN\fP 155 | .RS 4 156 | Exclude files from \fIPATH\fP that match the glob pattern \fIPATTERN\fP. This option 157 | may be given multiple times. See \fBEXCLUDING FILES\fP. 158 | .RE 159 | .sp 160 | \fB\-\-include\fP \fIPATTERN\fP 161 | .RS 4 162 | Include files from \fIPATH\fP that match the glob pattern \fIPATTERN\fP even if they 163 | match any \fB\-\-exclude\fP or \fB\-\-exclude\-regex\fP patterns. This option may be given 164 | multiple times. See \fBEXCLUDING FILES\fP. 165 | .RE 166 | .sp 167 | \fB\-\-exclude\-regex\fP, \fB\-er\fP \fIPATTERN\fP 168 | .RS 4 169 | Exclude files from \fIPATH\fP that match the regular expression \fIPATTERN\fP. This 170 | option may be given multiple times. See \fBEXCLUDING FILES\fP. 171 | .RE 172 | .sp 173 | \fB\-\-include\-regex\fP, \fB\-ir\fP \fIPATTERN\fP 174 | .RS 4 175 | Include files from \fIPATH\fP that match the regular expression \fIPATTERN\fP even if 176 | they match any \fB\-\-exclude\fP or \fB\-\-exclude\-regex\fP patterns. This option may be 177 | given multiple times. See \fBEXCLUDING FILES\fP. 178 | .RE 179 | .sp 180 | \fB\-\-notorrent\fP, \fB\-N\fP 181 | .RS 4 182 | Do not create a torrent file. 183 | .RE 184 | .sp 185 | \fB\-\-nomagnet\fP, \fB\-M\fP 186 | .RS 4 187 | Do not create a magnet URI. 188 | .RE 189 | .sp 190 | \fB\-\-name\fP, \fB\-n\fP \fINAME\fP 191 | .RS 4 192 | Destination file or directory when the torrent is downloaded. 193 | .br 194 | Default: Basename of \fIPATH\fP 195 | .if n .sp 196 | .RS 4 197 | .it 1 an-trap 198 | .nr an-no-space-flag 1 199 | .nr an-break-flag 1 200 | .br 201 | .ps +1 202 | .B Warning 203 | .ps -1 204 | .br 205 | .sp 206 | When editing, this option changes the info hash and creates a new 207 | torrent. 208 | .sp .5v 209 | .RE 210 | .RE 211 | .sp 212 | \fB\-\-tracker\fP, \fB\-t\fP \fIURL\fP 213 | .RS 4 214 | List of comma\-separated announce URLs. This option may be given multiple times 215 | for multiple tiers. Clients try all URLs from one tier in random order before 216 | moving on to the next tier. 217 | .RE 218 | .sp 219 | \fB\-\-notracker\fP, \fB\-T\fP 220 | .RS 4 221 | Remove trackers from an existing torrent. 222 | .RE 223 | .sp 224 | \fB\-\-webseed\fP, \fB\-w\fP \fIURL\fP 225 | .RS 4 226 | A webseed URL (BEP19). This option may be given multiple times. 227 | .RE 228 | .sp 229 | \fB\-\-nowebseed\fP, \fB\-W\fP 230 | .RS 4 231 | Remove webseeds from an existing torrent. 232 | .RE 233 | .sp 234 | \fB\-\-private\fP, \fB\-p\fP 235 | .RS 4 236 | Tell clients to only use tracker(s) for peer discovery, not DHT or PEX. 237 | .if n .sp 238 | .RS 4 239 | .it 1 an-trap 240 | .nr an-no-space-flag 1 241 | .nr an-break-flag 1 242 | .br 243 | .ps +1 244 | .B Warning 245 | .ps -1 246 | .br 247 | .sp 248 | When editing, this option changes the info hash and creates a new 249 | torrent. 250 | .sp .5v 251 | .RE 252 | .RE 253 | .sp 254 | \fB\-\-noprivate\fP, \fB\-P\fP 255 | .RS 4 256 | Allow clients to use trackerless methods like DHT and PEX for peer discovery. 257 | .if n .sp 258 | .RS 4 259 | .it 1 an-trap 260 | .nr an-no-space-flag 1 261 | .nr an-break-flag 1 262 | .br 263 | .ps +1 264 | .B Warning 265 | .ps -1 266 | .br 267 | .sp 268 | When editing, this option changes the info hash and creates a new 269 | torrent. 270 | .sp .5v 271 | .RE 272 | .RE 273 | .sp 274 | \fB\-\-comment\fP, \fB\-c\fP \fICOMMENT\fP 275 | .RS 4 276 | A comment that is stored in the torrent file. 277 | .RE 278 | .sp 279 | \fB\-\-nocomment\fP, \fB\-C\fP 280 | .RS 4 281 | Remove the comment from an existing torrent. 282 | .RE 283 | .sp 284 | \fB\-\-date\fP, \fB\-d\fP \fIDATE\fP 285 | .RS 4 286 | The creation date in the format \fIYYYY\fP\fB\-\fP\fIMM\fP\fB\-\fP\fIDD\fP[ 287 | \fIHH\fP\fB:\fP\fIMM\fP[\fB:\fP\fISS\fP]], \fBnow\fP for the current time or \fBtoday\fP for today 288 | at midnight. 289 | .br 290 | Default: \fBnow\fP 291 | .RE 292 | .sp 293 | \fB\-\-nodate\fP, \fB\-D\fP 294 | .RS 4 295 | Remove the creation date from an existing torrent. 296 | .RE 297 | .sp 298 | \fB\-\-source\fP, \fB\-s\fP \fISOURCE\fP 299 | .RS 4 300 | Add a \(lqsource\(rq field to the torrent file. This is usually used to make the 301 | torrent\(cqs info hash unique per tracker. 302 | .if n .sp 303 | .RS 4 304 | .it 1 an-trap 305 | .nr an-no-space-flag 1 306 | .nr an-break-flag 1 307 | .br 308 | .ps +1 309 | .B Warning 310 | .ps -1 311 | .br 312 | .sp 313 | When editing, this option changes the info hash and creates a new 314 | torrent. 315 | .sp .5v 316 | .RE 317 | .RE 318 | .sp 319 | \fB\-\-merge\fP \fIJSON\fP 320 | .RS 4 321 | Update existing metainfo in \fITORRENT\fP with a JSON object. This option may be 322 | given multiple times. Fields in \fIJSON\fP that have a value of \f(CRnull\fP (unquoted) 323 | are removed in the output \fITORRENT\fP. Adding or removing items from an existing 324 | list is not supported. 325 | .sp 326 | This example adds add a \(lqcustom\(rq section to the \(lqinfo\(rq section, removes the 327 | \(lqcomment\(rq field and changes \(lqcreation date\(rq. 328 | .sp 329 | .if n .RS 4 330 | .nf 331 | .fam C 332 | $ torf \-i old.torrent \(rs 333 | \-\-merge \*(Aq{"info": {"custom": {"this": "that", "numbers": [1, 2, 3]}}}\*(Aq \(rs 334 | \-\-merge \*(Aq{"comment": null, "creation date": 123456789}\*(Aq \(rs 335 | \-o new.torrent 336 | .fam 337 | .fi 338 | .if n .RE 339 | .sp 340 | This also works when creating a torrent. 341 | .sp 342 | .if n .RS 4 343 | .nf 344 | .fam C 345 | $ torf path/to/my/files \(rs 346 | \-\-merge \*(Aq{"my stuff": {"my": ["s", "e", "c", "r", "e", "t"]}}\*(Aq 347 | .fam 348 | .fi 349 | .if n .RE 350 | .if n .sp 351 | .RS 4 352 | .it 1 an-trap 353 | .nr an-no-space-flag 1 354 | .nr an-break-flag 1 355 | .br 356 | .ps +1 357 | .B Warning 358 | .ps -1 359 | .br 360 | .sp 361 | If the \(lqinfo\(rq section is modified, the info hash changes and a new 362 | torrent is created. 363 | .sp .5v 364 | .RE 365 | .RE 366 | .sp 367 | \fB\-\-nosource\fP, \fB\-S\fP 368 | .RS 4 369 | Remove the \(lqsource\(rq field from an existing torrent. 370 | .if n .sp 371 | .RS 4 372 | .it 1 an-trap 373 | .nr an-no-space-flag 1 374 | .nr an-break-flag 1 375 | .br 376 | .ps +1 377 | .B Warning 378 | .ps -1 379 | .br 380 | .sp 381 | When editing, this option changes the info hash and creates a new 382 | torrent. 383 | .sp .5v 384 | .RE 385 | .RE 386 | .sp 387 | \fB\-\-xseed\fP, \fB\-x\fP 388 | .RS 4 389 | Randomize the info hash to help with cross\-seeding. This simply adds an 390 | \fBentropy\fP field to the \fBinfo\fP section of the metainfo and sets it to a random 391 | integer. 392 | .if n .sp 393 | .RS 4 394 | .it 1 an-trap 395 | .nr an-no-space-flag 1 396 | .nr an-break-flag 1 397 | .br 398 | .ps +1 399 | .B Warning 400 | .ps -1 401 | .br 402 | .sp 403 | When editing, this option changes the info hash and creates a new 404 | torrent. 405 | .sp .5v 406 | .RE 407 | .RE 408 | .sp 409 | \fB\-\-noxseed\fP, \fB\-X\fP 410 | .RS 4 411 | De\-randomize a previously randomized info hash of an existing torrent. This 412 | removes the \fBentropy\fP field from the \fBinfo\fP section of the metainfo. 413 | .if n .sp 414 | .RS 4 415 | .it 1 an-trap 416 | .nr an-no-space-flag 1 417 | .nr an-break-flag 1 418 | .br 419 | .ps +1 420 | .B Warning 421 | .ps -1 422 | .br 423 | .sp 424 | When editing, this option changes the info hash and creates a new 425 | torrent. 426 | .sp .5v 427 | .RE 428 | .RE 429 | .sp 430 | \fB\-\-max\-piece\-size\fP \fISIZE\fP 431 | .RS 4 432 | The maximum piece size when creating a torrent. SIZE is multiplied by 1 MiB 433 | (1048576 bytes). The resulting number must be a multiple of 16 KiB (16384 434 | bytes). Use fractions for piece sizes smaller than 1 MiB (e.g. 0.5 for 512 435 | KiB). 436 | .RE 437 | .sp 438 | \fB\-\-creator\fP, \fB\-a\fP \fICREATOR\fP 439 | .RS 4 440 | Name and version of the application that created the torrent. 441 | .RE 442 | .sp 443 | \fB\-\-nocreator\fP, \fB\-A\fP 444 | .RS 4 445 | Remove the name of the application that created the torrent from an existing 446 | torrent. 447 | .RE 448 | .sp 449 | \fB\-\-yes\fP, \fB\-y\fP 450 | .RS 4 451 | Answer all yes/no prompts with \(lqyes\(rq. At the moment, all this does is 452 | overwrite \fITORRENT\fP without asking. 453 | .RE 454 | .sp 455 | \fB\-\-config\fP, \fB\-f\fP \fIFILE\fP 456 | .RS 4 457 | Read command line arguments from configuration FILE. See \fBCONFIGURATION 458 | FILE\fP. 459 | .br 460 | Default: \fI$XDG_CONFIG_HOME\fP\fB/torf/config\fP where \fI$XDG_CONFIG_HOME\fP defaults 461 | to \fB~/.config\fP 462 | .RE 463 | .sp 464 | \fB\-\-noconfig\fP, \fB\-F\fP 465 | .RS 4 466 | Do not use any configuration file. 467 | .RE 468 | .sp 469 | \fB\-\-profile\fP, \fB\-z\fP \fIPROFILE\fP 470 | .RS 4 471 | Use predefined arguments specified in \fIPROFILE\fP. This option may be given 472 | multiple times. See \fBCONFIGURATION FILE\fP. 473 | .RE 474 | .sp 475 | \fB\-\-verbose\fP, \fB\-v\fP 476 | .RS 4 477 | Produce more output or be more thorough. This option may be given multiple 478 | times. 479 | .sp 480 | .RS 4 481 | .ie n \{\ 482 | \h'-04'\(bu\h'+03'\c 483 | .\} 484 | .el \{\ 485 | . sp -1 486 | . IP \(bu 2.3 487 | .\} 488 | Display bytes with and without unit prefix, e.g. \(lq1.38 MiB / 1,448,576 B\(rq. 489 | .RE 490 | .sp 491 | .RS 4 492 | .ie n \{\ 493 | \h'-04'\(bu\h'+03'\c 494 | .\} 495 | .el \{\ 496 | . sp -1 497 | . IP \(bu 2.3 498 | .\} 499 | Any other effects are explained in the relevant arguments\*(Aq documentation. 500 | .RE 501 | .RE 502 | .sp 503 | \fB\-\-json\fP, \fB\-j\fP 504 | .RS 4 505 | Print information and errors as a JSON object. Progress is not reported. 506 | .RE 507 | .sp 508 | \fB\-\-metainfo\fP, \fB\-m\fP 509 | .RS 4 510 | Print the torrent\(cqs metainfo as a JSON object. Byte strings (e.g. \(lqpieces\(rq in 511 | the \(lqinfo\(rq section) are encoded in Base64. Progress is not reported. Errors 512 | are reported normally on stderr. 513 | .sp 514 | Unless \fB\-\-verbose\fP is given, any non\-standard fields are excluded and metainfo 515 | that doesn\(cqt represent a valid torrent results in an error. 516 | .sp 517 | Unless \fB\-\-verbose\fP is given twice, the \(lqpieces\(rq field in the \(lqinfo\(rq section 518 | is excluded. 519 | .RE 520 | .sp 521 | \fB\-\-human\fP, \fB\-u\fP 522 | .RS 4 523 | Display information in human\-readable output even if stdout is not a TTY. See 524 | \fBPIPING OUTPUT\fP. 525 | .RE 526 | .sp 527 | \fB\-\-nohuman\fP, \fB\-U\fP 528 | .RS 4 529 | Display information in machine\-readable output even if stdout is a TTY. See 530 | \fBPIPING OUTPUT\fP. 531 | .RE 532 | .sp 533 | \fB\-\-help\fP, \fB\-h\fP 534 | .RS 4 535 | Display a short help text and exit. 536 | .RE 537 | .sp 538 | \fB\-\-version\fP, \fB\-V\fP 539 | .RS 4 540 | Display the version number and exit. 541 | .RE 542 | .SH "EXAMPLES" 543 | .sp 544 | Create \(lqfoo.torrent\(rq with two trackers and don\(cqt store the creation date: 545 | .sp 546 | .if n .RS 4 547 | .nf 548 | .fam C 549 | $ torf path/to/foo \(rs 550 | \-t http://example.org:6881/announce \(rs 551 | \-t http://example.com:6881/announce \(rs 552 | \-\-nodate 553 | .fam 554 | .fi 555 | .if n .RE 556 | .sp 557 | Read \(lqfoo.torrent\(rq and print its metainfo: 558 | .sp 559 | .if n .RS 4 560 | .nf 561 | .fam C 562 | $ torf \-i foo.torrent 563 | .fam 564 | .fi 565 | .if n .RE 566 | .sp 567 | Print only the name: 568 | .sp 569 | .if n .RS 4 570 | .nf 571 | .fam C 572 | $ torf \-i foo.torrent | grep \*(Aq^Name\*(Aq | cut \-f2 573 | .fam 574 | .fi 575 | .if n .RE 576 | .sp 577 | Change the comment and remove the date from \(lqfoo.torrent\(rq, write the result to 578 | \(lqbar.torrent\(rq: 579 | .sp 580 | .if n .RS 4 581 | .nf 582 | .fam C 583 | $ torf \-i foo.torrent \-c \*(AqNew comment\*(Aq \-D \-o bar.torrent 584 | .fam 585 | .fi 586 | .if n .RE 587 | .sp 588 | Check if \(lqpath/to/foo\(rq contains valid data as specified in \(lqbar.torrent\(rq: 589 | .sp 590 | .if n .RS 4 591 | .nf 592 | .fam C 593 | $ torf \-i bar.torrent path/to/foo 594 | .fam 595 | .fi 596 | .if n .RE 597 | .SH "EXCLUDING FILES" 598 | .sp 599 | The \fB\-\-exclude\fP option takes a glob pattern that is matched against each file 600 | path beneath \fIPATH\fP. Files that match are not included in the torrent. 601 | Matching is case\-insensitive. 602 | .sp 603 | The \fB\-\-exclude\-regex\fP option works like \fB\-\-exclude\fP but it takes a regular 604 | expression pattern and it does case\-sensitive matching. 605 | .sp 606 | The \fB\-\-include\fP and \fB\-\-include\-regex\fP options are applied like their excluding 607 | counterparts, but any matching files are included even if they match any exclude 608 | patterns. 609 | .sp 610 | File paths start with the torrent\(cqs name (usually the last segment of \fIPATH\fP), 611 | e.g. if \fIPATH\fP is \(lq/home/foo/bar\(rq, each file path starts with \(lqbar/\(rq 612 | .sp 613 | Empty directories and empty files are automatically excluded. 614 | .sp 615 | Regular expressions should be Perl\-compatible for simple patterns. See 616 | .URL "https://docs.python.org/3/library/re.html#regular\-expression\-syntax" "" "" 617 | for the 618 | complete documentation. 619 | .sp 620 | Glob patterns support these wildcard characters: 621 | .TS 622 | allbox tab(:); 623 | lt lt. 624 | T{ 625 | .sp 626 | * 627 | T}:T{ 628 | .sp 629 | matches everything 630 | T} 631 | T{ 632 | .sp 633 | ? 634 | T}:T{ 635 | .sp 636 | matches any single character 637 | T} 638 | T{ 639 | .sp 640 | [\fISEQ\fP] 641 | T}:T{ 642 | .sp 643 | matches any character in \fISEQ\fP 644 | T} 645 | T{ 646 | .sp 647 | [!\fISEQ\fP] 648 | T}:T{ 649 | .sp 650 | matches any character not in \fISEQ\fP 651 | T} 652 | .TE 653 | .sp 654 | .SH "CONFIGURATION FILE" 655 | .sp 656 | A configuration file lists long\-form command line options with all leading \(lq\-\(rq 657 | characters removed. If an option takes a parameter, \(lq=\(rq is used as a 658 | separator. Spaces before and after the \(lq=\(rq are ignored. The parameter may be 659 | quoted with single or double quotes to preserve leading and/or trailing spaces. 660 | Lines that start with \(lq#\(rq are ignored. 661 | .sp 662 | All of the options listed in the \fBOPTIONS\fP section are allowed except for 663 | \fIPATH\fP, \fBin\fP, \fBout\fP, \fBname\fP, \fBconfig\fP, \fBnoconfig\fP, \fBprofile\fP, \fBhelp\fP, \fBversion\fP. 664 | .sp 665 | There is rudimental support for environment variables in parameters. As usual, 666 | \(lq$FOO\(rq or \(lq${FOO}\(rq is replaced with the value of the variable \fBFOO\fP, \(lq$\(rq 667 | is escaped with \(lq\(rs\(rq (backslash) and a literal \(lq\(rs\(rq is represented by two 668 | \(lq\(rs\(rq. More complex string manipulation syntax (e.g. \(lq${FOO:3}\(rq) is not 669 | supported. 670 | .SS "Profiles" 671 | .sp 672 | A profile is a set of options bound to a name that is given to the \fB\-\-profile\fP 673 | option. In the configuration file it is specified as \(lq[\fIPROFILE NAME\fP]\(rq 674 | followed by a list of options. Profiles inherit any options specified globally 675 | at the top of the file, but they can overload them. 676 | .SS "Example" 677 | .sp 678 | This is an example configuration file with some global custom defaults and the 679 | two profiles \(lqfoo\(rq and \(lqbar\(rq: 680 | .sp 681 | .if n .RS 4 682 | .nf 683 | .fam C 684 | yes 685 | nodate 686 | exclude = *.txt 687 | 688 | [foo] 689 | tracker = https://foo1/announce 690 | tracker = https://foo2/announce 691 | private 692 | 693 | [bar] 694 | tracker = https://bar/announce 695 | comment = I love bar. 696 | .fam 697 | .fi 698 | .if n .RE 699 | .sp 700 | With this configuration file, these arguments are always used: 701 | .sp 702 | .if n .RS 4 703 | .nf 704 | .fam C 705 | \-\-yes 706 | \-\-nodate 707 | \-\-exclude \*(Aq*.txt\*(Aq 708 | .fam 709 | .fi 710 | .if n .RE 711 | .sp 712 | If \(lq\-\-profile foo\(rq is given, it also adds these arguments: 713 | .sp 714 | .if n .RS 4 715 | .nf 716 | .fam C 717 | \-\-tracker https://foo1/announce 718 | \-\-tracker https://foo2/announce 719 | \-\-private 720 | .fam 721 | .fi 722 | .if n .RE 723 | .sp 724 | If \(lq\-\-profile bar\(rq is given, it also adds these arguments: 725 | .sp 726 | .if n .RS 4 727 | .nf 728 | .fam C 729 | \-\-tracker https://bar/announce 730 | \-\-comment \*(AqI love bar.\*(Aq 731 | .fam 732 | .fi 733 | .if n .RE 734 | .SH "PIPING OUTPUT" 735 | .sp 736 | If stdout is not a TTY (i.e. when output is piped) or if the \fB\-\-nohuman\fP option 737 | is provided, the output format is different: 738 | .sp 739 | .RS 4 740 | .ie n \{\ 741 | \h'-04'\(bu\h'+03'\c 742 | .\} 743 | .el \{\ 744 | . sp -1 745 | . IP \(bu 2.3 746 | .\} 747 | Leading spaces are removed from each line. 748 | .RE 749 | .sp 750 | .RS 4 751 | .ie n \{\ 752 | \h'-04'\(bu\h'+03'\c 753 | .\} 754 | .el \{\ 755 | . sp -1 756 | . IP \(bu 2.3 757 | .\} 758 | The delimiter between label and value as well as between multiple values 759 | (files, trackers, etc) is a tab character (\(lq\(rst\(rq or ASCII code 0x9). 760 | Trackers are flattened into a one\-dimensional list. 761 | .RE 762 | .sp 763 | .RS 4 764 | .ie n \{\ 765 | \h'-04'\(bu\h'+03'\c 766 | .\} 767 | .el \{\ 768 | . sp -1 769 | . IP \(bu 2.3 770 | .\} 771 | Numbers are not formatted (UNIX timestamps for times, seconds for time deltas, 772 | raw bytes for sizes, etc). 773 | .RE 774 | .SH "EXIT CODES" 775 | .sp 776 | 1 777 | .RS 4 778 | Anything not specified below 779 | .RE 780 | .sp 781 | 2 782 | .RS 4 783 | Unknown or invalid command line arguments 784 | .RE 785 | .sp 786 | 3 787 | .RS 4 788 | Error while reading or parsing the config file 789 | .RE 790 | .sp 791 | 4 792 | .RS 4 793 | Error while reading a torrent file or content 794 | .RE 795 | .sp 796 | 5 797 | .RS 4 798 | Error while writing a torrent file 799 | .RE 800 | .sp 801 | 6 802 | .RS 4 803 | Error while verifying a torrent\(cqs content 804 | .RE 805 | .sp 806 | 128 807 | .RS 4 808 | Aborted by SIGINT (typically Ctrl\-c was pressed) 809 | .RE 810 | .SH "REPORTING BUGS" 811 | .sp 812 | Bug reports, feature requests and poems about hedgehogs are welcome on the 813 | .URL "https://github.com/rndusr/torf\-cli/issues" "issue tracker" "." -------------------------------------------------------------------------------- /docs/torf.1.asciidoc: -------------------------------------------------------------------------------- 1 | = TORF(1) 2 | 3 | 4 | == NAME 5 | 6 | torf - command line tool to create, display and edit torrents 7 | 8 | 9 | == SYNOPSIS 10 | 11 | *torf* _PATH_ [_OPTIONS_] [*-o* _TORRENT_] + 12 | *torf* *-i* _INPUT_ + 13 | *torf* *-i* _INPUT_ [_OPTIONS_] *-o* _TORRENT_ + 14 | *torf* *-i* _TORRENT_ _PATH_ + 15 | 16 | 17 | == DESCRIPTION 18 | 19 | torf can create, display and edit torrent files and verify the integrity of the 20 | files in a torrent. 21 | 22 | * *torf* _PATH_ [_OPTIONS_] [*-o* _TORRENT_] + 23 | Create the torrent file _TORRENT_ from the file or directory _PATH_. 24 | 25 | * *torf* *-i* _INPUT_ + 26 | Display information stored in the torrent file or magnet URI _INPUT_. 27 | 28 | * *torf* *-i* _INPUT_ [_OPTIONS_] *-o* _TORRENT_ + 29 | Edit the existing torrent file or magnet URI _INPUT_ (e.g. to fix a typo) and 30 | create the new torrent file _TORRENT_. 31 | + 32 | WARNING: Editing a torrent can change its hash, depending on what is changed, 33 | which essentially makes it a new torrent. See OPTIONS to find out 34 | whether a certain option will change the hash. 35 | 36 | * *torf* *-i* _TORRENT_ _PATH_ + 37 | Verify that the content in _PATH_ matches the metadata in the torrent file 38 | _TORRENT_. 39 | + 40 | If _PATH_ ends with a path separator (usually "`/`"), the name of the torrent 41 | (as specified by the metadata in _TORRENT_) is appended. 42 | 43 | 44 | == OPTIONS 45 | 46 | Options that start with *--no* take precedence. 47 | 48 | _PATH_:: 49 | The path to the torrent's content. 50 | 51 | *--in*, *-i* _INPUT_:: 52 | Read metainfo from the torrent file or magnet URI _INPUT_. If _INPUT_ is "`-`" 53 | and does not exist, the torrent data or magnet URI is read from stdin. 54 | 55 | *--out*, *-o* _TORRENT_:: 56 | Write to torrent file _TORRENT_. + 57 | Default: __NAME__**.torrent** 58 | 59 | *--reuse*, *-r* _PATH_:: 60 | Copy piece size and piece hashes from existing torrent _PATH_. The existing 61 | torrent must have identical files. If _PATH_ is a directory, it is searched 62 | recursively for a matching torrent. This option may be given multiple times. 63 | 64 | *--noreuse*, *-R*:: 65 | Ignore all *--reuse* arguments. This is particularly useful if you have reuse 66 | paths in your configuration file. 67 | 68 | *--exclude*, *-e* _PATTERN_:: 69 | Exclude files from _PATH_ that match the glob pattern _PATTERN_. This option 70 | may be given multiple times. See *EXCLUDING FILES*. 71 | 72 | *--include* _PATTERN_:: 73 | Include files from _PATH_ that match the glob pattern _PATTERN_ even if they 74 | match any *--exclude* or *--exclude-regex* patterns. This option may be given 75 | multiple times. See *EXCLUDING FILES*. 76 | 77 | *--exclude-regex*, *-er* _PATTERN_:: 78 | Exclude files from _PATH_ that match the regular expression _PATTERN_. This 79 | option may be given multiple times. See *EXCLUDING FILES*. 80 | 81 | *--include-regex*, *-ir* _PATTERN_:: 82 | Include files from _PATH_ that match the regular expression _PATTERN_ even if 83 | they match any *--exclude* or *--exclude-regex* patterns. This option may be 84 | given multiple times. See *EXCLUDING FILES*. 85 | 86 | *--notorrent*, *-N*:: 87 | Do not create a torrent file. 88 | 89 | *--nomagnet*, *-M*:: 90 | Do not create a magnet URI. 91 | 92 | *--name*, *-n* _NAME_:: 93 | Destination file or directory when the torrent is downloaded. + 94 | Default: Basename of _PATH_ 95 | + 96 | WARNING: When editing, this option changes the info hash and creates a new 97 | torrent. 98 | 99 | *--tracker*, *-t* _URL_:: 100 | List of comma-separated announce URLs. This option may be given multiple times 101 | for multiple tiers. Clients try all URLs from one tier in random order before 102 | moving on to the next tier. 103 | 104 | *--notracker*, *-T*:: 105 | Remove trackers from an existing torrent. 106 | 107 | *--webseed*, *-w* _URL_:: 108 | A webseed URL (BEP19). This option may be given multiple times. 109 | 110 | *--nowebseed*, *-W*:: 111 | Remove webseeds from an existing torrent. 112 | 113 | *--private*, *-p*:: 114 | Tell clients to only use tracker(s) for peer discovery, not DHT or PEX. 115 | + 116 | WARNING: When editing, this option changes the info hash and creates a new 117 | torrent. 118 | 119 | *--noprivate*, *-P*:: 120 | Allow clients to use trackerless methods like DHT and PEX for peer discovery. 121 | + 122 | WARNING: When editing, this option changes the info hash and creates a new 123 | torrent. 124 | 125 | *--comment*, *-c* _COMMENT_:: 126 | A comment that is stored in the torrent file. 127 | 128 | *--nocomment*, *-C*:: 129 | Remove the comment from an existing torrent. 130 | 131 | *--date*, *-d* _DATE_:: 132 | The creation date in the format __YYYY__**-**__MM__**-**__DD__[ 133 | __HH__**:**__MM__[**:**__SS__]], *now* for the current time or *today* for today 134 | at midnight. + 135 | Default: *now* 136 | 137 | *--nodate*, *-D*:: 138 | Remove the creation date from an existing torrent. 139 | 140 | *--source*, *-s* _SOURCE_:: 141 | Add a "`source`" field to the torrent file. This is usually used to make the 142 | torrent's info hash unique per tracker. 143 | + 144 | WARNING: When editing, this option changes the info hash and creates a new 145 | torrent. 146 | 147 | *--merge* _JSON_:: 148 | Update existing metainfo in _TORRENT_ with a JSON object. This option may be 149 | given multiple times. Fields in _JSON_ that have a value of `null` (unquoted) 150 | are removed in the output _TORRENT_. Adding or removing items from an existing 151 | list is not supported. 152 | + 153 | This example adds add a "`custom`" section to the "`info`" section, removes the 154 | "`comment`" field and changes "`creation date`". 155 | + 156 | $ torf -i old.torrent \ 157 | --merge '{"info": {"custom": {"this": "that", "numbers": [1, 2, 3]}}}' \ 158 | --merge '{"comment": null, "creation date": 123456789}' \ 159 | -o new.torrent 160 | + 161 | This also works when creating a torrent. 162 | + 163 | $ torf path/to/my/files \ 164 | --merge '{"my stuff": {"my": ["s", "e", "c", "r", "e", "t"]}}' 165 | + 166 | WARNING: If the "`info`" section is modified, the info hash changes and a new 167 | torrent is created. 168 | 169 | *--nosource*, *-S*:: 170 | Remove the "`source`" field from an existing torrent. 171 | + 172 | WARNING: When editing, this option changes the info hash and creates a new 173 | torrent. 174 | 175 | *--xseed*, *-x*:: 176 | Randomize the info hash to help with cross-seeding. This simply adds an 177 | *entropy* field to the *info* section of the metainfo and sets it to a random 178 | integer. 179 | + 180 | WARNING: When editing, this option changes the info hash and creates a new 181 | torrent. 182 | 183 | *--noxseed*, *-X*:: 184 | De-randomize a previously randomized info hash of an existing torrent. This 185 | removes the *entropy* field from the *info* section of the metainfo. 186 | + 187 | WARNING: When editing, this option changes the info hash and creates a new 188 | torrent. 189 | 190 | *--max-piece-size* _SIZE_:: 191 | The maximum piece size when creating a torrent. SIZE is multiplied by 1 MiB 192 | (1048576 bytes). The resulting number must be a multiple of 16 KiB (16384 193 | bytes). Use fractions for piece sizes smaller than 1 MiB (e.g. 0.5 for 512 194 | KiB). 195 | 196 | *--creator*, *-a* _CREATOR_:: 197 | Name and version of the application that created the torrent. 198 | 199 | *--nocreator*, *-A*:: 200 | Remove the name of the application that created the torrent from an existing 201 | torrent. 202 | 203 | *--yes*, *-y*:: 204 | Answer all yes/no prompts with "`yes`". At the moment, all this does is 205 | overwrite _TORRENT_ without asking. 206 | 207 | *--config*, *-f* _FILE_:: 208 | Read command line arguments from configuration FILE. See *CONFIGURATION 209 | FILE*. + 210 | Default: __$XDG_CONFIG_HOME__**/torf/config** where _$XDG_CONFIG_HOME_ defaults 211 | to *~/.config* 212 | 213 | *--noconfig*, *-F*:: 214 | Do not use any configuration file. 215 | 216 | *--profile*, *-z* _PROFILE_:: 217 | Use predefined arguments specified in _PROFILE_. This option may be given 218 | multiple times. See *CONFIGURATION FILE*. 219 | 220 | *--verbose*, *-v*:: 221 | Produce more output or be more thorough. This option may be given multiple 222 | times. 223 | + 224 | * Display bytes with and without unit prefix, e.g. "`1.38 MiB / 1,448,576 B`". 225 | * Any other effects are explained in the relevant arguments' documentation. 226 | 227 | *--json*, *-j*:: 228 | Print information and errors as a JSON object. Progress is not reported. 229 | 230 | *--metainfo*, *-m*:: 231 | Print the torrent's metainfo as a JSON object. Byte strings (e.g. "`pieces`" in 232 | the "`info`" section) are encoded in Base64. Progress is not reported. Errors 233 | are reported normally on stderr. 234 | + 235 | Unless *--verbose* is given, any non-standard fields are excluded and metainfo 236 | that doesn't represent a valid torrent results in an error. 237 | + 238 | Unless *--verbose* is given twice, the "`pieces`" field in the "`info`" section 239 | is excluded. 240 | 241 | *--human*, *-u*:: 242 | Display information in human-readable output even if stdout is not a TTY. See 243 | *PIPING OUTPUT*. 244 | 245 | *--nohuman*, *-U*:: 246 | Display information in machine-readable output even if stdout is a TTY. See 247 | *PIPING OUTPUT*. 248 | 249 | *--help*, *-h*:: 250 | Display a short help text and exit. 251 | 252 | *--version*, *-V*:: 253 | Display the version number and exit. 254 | 255 | 256 | == EXAMPLES 257 | 258 | Create "`foo.torrent`" with two trackers and don't store the creation date: 259 | 260 | $ torf path/to/foo \ 261 | -t http://example.org:6881/announce \ 262 | -t http://example.com:6881/announce \ 263 | --nodate 264 | 265 | Read "`foo.torrent`" and print its metainfo: 266 | 267 | $ torf -i foo.torrent 268 | 269 | Print only the name: 270 | 271 | $ torf -i foo.torrent | grep '^Name' | cut -f2 272 | 273 | Change the comment and remove the date from "`foo.torrent`", write the result to 274 | "`bar.torrent`": 275 | 276 | $ torf -i foo.torrent -c 'New comment' -D -o bar.torrent 277 | 278 | Check if "`path/to/foo`" contains valid data as specified in "`bar.torrent`": 279 | 280 | $ torf -i bar.torrent path/to/foo 281 | 282 | 283 | == EXCLUDING FILES 284 | 285 | The *--exclude* option takes a glob pattern that is matched against each file 286 | path beneath _PATH_. Files that match are not included in the torrent. 287 | Matching is case-insensitive. 288 | 289 | The *--exclude-regex* option works like *--exclude* but it takes a regular 290 | expression pattern and it does case-sensitive matching. 291 | 292 | The *--include* and *--include-regex* options are applied like their excluding 293 | counterparts, but any matching files are included even if they match any exclude 294 | patterns. 295 | 296 | File paths start with the torrent's name (usually the last segment of _PATH_), 297 | e.g. if _PATH_ is "`/home/foo/bar`", each file path starts with "`bar/`" 298 | 299 | Empty directories and empty files are automatically excluded. 300 | 301 | Regular expressions should be Perl-compatible for simple patterns. See 302 | https://docs.python.org/3/library/re.html#regular-expression-syntax for the 303 | complete documentation. 304 | 305 | Glob patterns support these wildcard characters: 306 | 307 | [%autowidth, frame=none, grid=none, cols=">,<"] 308 | |=== 309 | | * |matches everything 310 | | ? |matches any single character 311 | | [_SEQ_] |matches any character in _SEQ_ 312 | | [!_SEQ_] |matches any character not in _SEQ_ 313 | |=== 314 | 315 | 316 | == CONFIGURATION FILE 317 | 318 | A configuration file lists long-form command line options with all leading "`-`" 319 | characters removed. If an option takes a parameter, "`=`" is used as a 320 | separator. Spaces before and after the "`=`" are ignored. The parameter may be 321 | quoted with single or double quotes to preserve leading and/or trailing spaces. 322 | Lines that start with "`#`" are ignored. 323 | 324 | All of the options listed in the *OPTIONS* section are allowed except for 325 | _PATH_, *in*, *out*, *name*, *config*, *noconfig*, *profile*, *help*, *version*. 326 | 327 | There is rudimental support for environment variables in parameters. As usual, 328 | "`$FOO`" or "`${FOO}`" is replaced with the value of the variable *FOO*, "`$`" 329 | is escaped with "`\`" (backslash) and a literal "`\`" is represented by two 330 | "`\`". More complex string manipulation syntax (e.g. "`${FOO:3}`") is not 331 | supported. 332 | 333 | === Profiles 334 | 335 | A profile is a set of options bound to a name that is given to the *--profile* 336 | option. In the configuration file it is specified as "`[_PROFILE NAME_]`" 337 | followed by a list of options. Profiles inherit any options specified globally 338 | at the top of the file, but they can overload them. 339 | 340 | === Example 341 | 342 | This is an example configuration file with some global custom defaults and the 343 | two profiles "`foo`" and "`bar`": 344 | 345 | ---- 346 | yes 347 | nodate 348 | exclude = *.txt 349 | 350 | [foo] 351 | tracker = https://foo1/announce 352 | tracker = https://foo2/announce 353 | private 354 | 355 | [bar] 356 | tracker = https://bar/announce 357 | comment = I love bar. 358 | ---- 359 | 360 | With this configuration file, these arguments are always used: 361 | 362 | --yes 363 | --nodate 364 | --exclude '*.txt' 365 | 366 | If "`--profile foo`" is given, it also adds these arguments: 367 | 368 | --tracker https://foo1/announce 369 | --tracker https://foo2/announce 370 | --private 371 | 372 | If "`--profile bar`" is given, it also adds these arguments: 373 | 374 | --tracker https://bar/announce 375 | --comment 'I love bar.' 376 | 377 | 378 | == PIPING OUTPUT 379 | 380 | If stdout is not a TTY (i.e. when output is piped) or if the *--nohuman* option 381 | is provided, the output format is different: 382 | 383 | - Leading spaces are removed from each line. 384 | 385 | - The delimiter between label and value as well as between multiple values 386 | (files, trackers, etc) is a tab character ("`\t`" or ASCII code 0x9). 387 | Trackers are flattened into a one-dimensional list. 388 | 389 | - Numbers are not formatted (UNIX timestamps for times, seconds for time deltas, 390 | raw bytes for sizes, etc). 391 | 392 | 393 | == EXIT CODES 394 | 395 | 1:: Anything not specified below 396 | 397 | 2:: Unknown or invalid command line arguments 398 | 399 | 3:: Error while reading or parsing the config file 400 | 401 | 4:: Error while reading a torrent file or content 402 | 403 | 5:: Error while writing a torrent file 404 | 405 | 6:: Error while verifying a torrent's content 406 | 407 | 128:: Aborted by SIGINT (typically Ctrl-c was pressed) 408 | 409 | 410 | == REPORTING BUGS 411 | 412 | Bug reports, feature requests and poems about hedgehogs are welcome on the 413 | https://github.com/rndusr/torf-cli/issues[issue tracker]. 414 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "torf-cli" 3 | description = "CLI tool to create, read and edit torrent files" 4 | readme = "README.rst" 5 | license = {text = "GPL-3.0-or-later"} 6 | authors = [ 7 | { name="Random User", email="rndusr@posteo.de" }, 8 | ] 9 | keywords = ["bittorrent", "torrent", "magnet", "cli"] 10 | dynamic = ["version"] # Get version from PROJECT/__version__ 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Intended Audience :: End Users/Desktop", 14 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", 15 | "Programming Language :: Python :: 3", 16 | ] 17 | requires-python = ">=3.7" 18 | dependencies = [ 19 | "torf==4.*,>=4.1.2", 20 | "pyxdg", 21 | ] 22 | 23 | [project.optional-dependencies] 24 | dev = [ 25 | "pytest", 26 | 27 | "tox", 28 | 29 | "coverage", 30 | "pytest-cov", 31 | 32 | "ruff", 33 | "flake8", 34 | "isort", 35 | ] 36 | 37 | [project.urls] 38 | Repository = "https://github.com/rndusr/torf-cli" 39 | Documentation = "https://rndusr.github.io/torf-cli/torf.1.html" 40 | "Bug Tracker" = "https://github.com/rndusr/torf-cli/issues" 41 | Changelog = "https://raw.githubusercontent.com/rndusr/torf-cli/master/CHANGELOG" 42 | 43 | 44 | [build-system] 45 | requires = ["setuptools"] 46 | build-backend = "setuptools.build_meta" 47 | 48 | [tool.setuptools.packages.find] 49 | include = ["torfcli*"] 50 | 51 | [tool.setuptools.dynamic] 52 | version = {attr = "torfcli._vars.__version__"} 53 | 54 | [project.scripts] 55 | torf = "torfcli:run" 56 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 120 2 | 3 | lint.select = [ 4 | "E", # pycodestyle 5 | "F", # pyflakes 6 | "I", # isort 7 | ] 8 | 9 | [lint.per-file-ignores] 10 | "__init__.py" = [ 11 | # imported but unused 12 | "F401", 13 | ] 14 | "tests/*" = [ 15 | # Line too long 16 | "E501", 17 | ] 18 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import functools 3 | import os 4 | import re 5 | from types import GeneratorType 6 | from unittest import mock 7 | 8 | import pytest 9 | import torf 10 | 11 | 12 | @pytest.fixture 13 | def regex(): 14 | # https://kalnytskyi.com/howto/assert-str-matches-regex-in-pytest/ 15 | class _regex: 16 | def __init__(self, pattern, flags=0, show_groups=False): 17 | self._regex = re.compile(pattern, flags) 18 | self._show_groups = show_groups 19 | 20 | def __eq__(self, string): 21 | match = self._regex.search(string) 22 | if match is not None and self._show_groups: 23 | print(match.groups()) 24 | return False 25 | else: 26 | return bool(match) 27 | 28 | def __repr__(self): 29 | return self._regex.pattern 30 | return _regex 31 | 32 | 33 | @pytest.fixture(autouse=True) 34 | def change_cwd(tmp_path): 35 | orig_dir = os.getcwd() 36 | os.chdir(str(tmp_path)) 37 | try: 38 | yield 39 | finally: 40 | os.chdir(orig_dir) 41 | 42 | 43 | @pytest.fixture(autouse=True) 44 | def cfgfile(tmp_path, monkeypatch): 45 | cfgdir = tmp_path / 'configdir' 46 | cfgdir.mkdir() 47 | cfgfile = cfgdir / 'config' 48 | from torfcli import _config 49 | monkeypatch.setattr(_config, 'DEFAULT_CONFIG_FILE', str(cfgfile)) 50 | return cfgfile 51 | 52 | 53 | def _assert_torrents_equal(orig, new, path_map=None, ignore=(), **new_attrs, ): 54 | attrs = ['comment', 'created_by', 'creation_date', 'files', 'filetree', 55 | 'httpseeds', 'name', 'piece_size', 'pieces', 56 | 'private', 'randomize_infohash', 'size', 'source', 'trackers', 57 | 'webseeds'] 58 | for attr in attrs: 59 | if attr not in new_attrs and attr not in ignore: 60 | orig_val, new_val = getattr(orig, attr), getattr(new, attr) 61 | if isinstance(orig_val, GeneratorType): 62 | orig_val, new_val = tuple(orig_val), tuple(new_val) 63 | assert orig_val == new_val 64 | 65 | for attr,val in new_attrs.items(): 66 | assert getattr(new, attr) == val 67 | 68 | if path_map: 69 | for path, exp_value in path_map.items(): 70 | path = list(path) 71 | value = new.metainfo 72 | while path: 73 | key = path.pop(0) 74 | value = value[key] 75 | assert value == exp_value 76 | 77 | 78 | @pytest.fixture 79 | def assert_torrents_equal(): 80 | return _assert_torrents_equal 81 | 82 | 83 | @pytest.fixture 84 | def human_readable(monkeypatch): 85 | @contextlib.contextmanager 86 | def _human_readable(monkeypatch, human_readable): 87 | from torfcli import _ui 88 | monkeypatch.setattr(_ui.UI, '_human', lambda self: bool(human_readable)) 89 | yield 90 | return functools.partial(_human_readable, monkeypatch) 91 | 92 | 93 | @pytest.fixture 94 | def mock_content(tmp_path): 95 | base = tmp_path / 'My Torrent' 96 | base.mkdir() 97 | file1 = base / 'Something.jpg' 98 | file2 = base / 'Anotherthing.iso' 99 | file3 = base / 'Thirdthing.txt' 100 | for f in (file1, file2, file3): 101 | f.write_text('some data') 102 | return base 103 | 104 | 105 | @pytest.fixture 106 | def mock_create_mode(monkeypatch): 107 | from torfcli import _main 108 | mock_create_mode = mock.MagicMock() 109 | monkeypatch.setattr(_main, '_create_mode', mock_create_mode) 110 | return mock_create_mode 111 | 112 | 113 | @contextlib.contextmanager 114 | def _create_torrent(tmp_path, mock_content, **kwargs): 115 | torrent_file = str(tmp_path / 'test.torrent') 116 | kw = {'path': str(mock_content), 117 | 'exclude_globs': ['Original', 'exclusions'], 118 | 'trackers': ['http://some.tracker'], 119 | 'webseeds': ['http://some.webseed'], 120 | 'private': False, 121 | 'randomize_infohash': False, 122 | 'comment': 'Original Comment', 123 | 'created_by': 'Original Creator'} 124 | kw.update(kwargs) 125 | t = torf.Torrent(**kw) 126 | t.generate() 127 | t.write(torrent_file) 128 | try: 129 | yield torrent_file 130 | finally: 131 | if os.path.exists(torrent_file): 132 | os.remove(torrent_file) 133 | 134 | @pytest.fixture 135 | def create_torrent(tmp_path, mock_content): 136 | return functools.partial(_create_torrent, tmp_path, mock_content) 137 | 138 | 139 | ansi_regex = re.compile(r'(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]') 140 | erase_line_regex = re.compile(r'^(.*?)\x1b\[2K.*?$', flags=re.MULTILINE) 141 | @pytest.fixture 142 | def clear_ansi(): 143 | def _clear_ansi(string): 144 | string = erase_line_regex.sub(r'\1', string) 145 | string = ansi_regex.sub('', string) 146 | string = re.sub(r'\x1b[78]', '', string) 147 | string = re.sub(r'(?:\r|^).*\r', '', string, flags=re.MULTILINE) 148 | return string 149 | return _clear_ansi 150 | 151 | @pytest.fixture 152 | def assert_no_ctrl(): 153 | """Assert string doesn't contain control sequences except for \n and \t""" 154 | def _assert_no_ctrl(string): 155 | for c in string: 156 | assert ord(c) >= 32 or c in ('\n', '\t') 157 | return _assert_no_ctrl 158 | -------------------------------------------------------------------------------- /tests/test_basics.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from torfcli import _errors, _vars, run 4 | 5 | 6 | def test_no_arguments(capsys): 7 | with patch('sys.exit') as mock_exit: 8 | run([]) 9 | mock_exit.assert_called_once_with(_errors.Code.CLI) 10 | cap = capsys.readouterr() 11 | assert cap.out == '' 12 | assert cap.err == (f'{_vars.__appname__}: Not sure what to do ' 13 | f'(see USAGE in `{_vars.__appname__} -h`)\n') 14 | 15 | 16 | def test_help(capsys): 17 | for arg in ('--help', '-h'): 18 | with patch('sys.exit') as mock_exit: 19 | run([arg]) 20 | mock_exit.assert_not_called() 21 | cap = capsys.readouterr() 22 | from torfcli._config import HELP_TEXT 23 | assert cap.out == HELP_TEXT + '\n' 24 | assert cap.err == '' 25 | 26 | 27 | def test_version(capsys): 28 | with patch('sys.exit') as mock_exit: 29 | run(['--version']) 30 | mock_exit.assert_not_called() 31 | cap = capsys.readouterr() 32 | from torfcli._config import VERSION_TEXT 33 | assert cap.out == VERSION_TEXT + '\n' 34 | assert cap.err == '' 35 | -------------------------------------------------------------------------------- /tests/test_configfile.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import textwrap 4 | from unittest.mock import patch 5 | 6 | from torfcli import _errors, _vars, run 7 | 8 | 9 | def test_default_configfile_doesnt_exist(cfgfile, mock_content, mock_create_mode): 10 | run([str(mock_content)]) 11 | cfg = mock_create_mode.call_args[0][1] 12 | assert cfg['PATH'] == str(mock_content) 13 | 14 | 15 | def test_custom_configfile_doesnt_exist(capsys, tmp_path, mock_content, mock_create_mode): 16 | cfgfile = tmp_path / 'wrong_special_config' 17 | with patch('sys.exit') as mock_exit: 18 | run(['--config', str(cfgfile), str(mock_content)]) 19 | mock_exit.assert_called_once_with(_errors.Code.CONFIG) 20 | cap = capsys.readouterr() 21 | assert cap.out == '' 22 | assert cap.err == f'{_vars.__appname__}: {cfgfile}: No such file or directory\n' 23 | assert mock_create_mode.call_args is None 24 | 25 | 26 | def test_config_unreadable(capsys, cfgfile, mock_content, mock_create_mode): 27 | cfgfile.write_text('something') 28 | import os 29 | os.chmod(cfgfile, 0o000) 30 | with patch('sys.exit') as mock_exit: 31 | run([str(mock_content)]) 32 | mock_exit.assert_called_once_with(_errors.Code.CONFIG) 33 | cap = capsys.readouterr() 34 | assert cap.out == '' 35 | assert cap.err == f'{_vars.__appname__}: {cfgfile}: Permission denied\n' 36 | assert mock_create_mode.call_args is None 37 | 38 | 39 | def test_custom_configfile(tmp_path, mock_content, mock_create_mode): 40 | cfgfile = tmp_path / 'special_config' 41 | cfgfile.write_text(textwrap.dedent(''' 42 | comment = asdf 43 | ''')) 44 | run(['--config', str(cfgfile), str(mock_content)]) 45 | cfg = mock_create_mode.call_args[0][1] 46 | assert cfg['comment'] == 'asdf' 47 | 48 | 49 | def test_noconfig_option(cfgfile, mock_content, mock_create_mode): 50 | cfgfile.write_text(textwrap.dedent(''' 51 | private 52 | comment = Nobody shall see this! 53 | ''')) 54 | run([str(mock_content), '--noconfig']) 55 | cfg = mock_create_mode.call_args[0][1] 56 | assert cfg['private'] is None 57 | assert cfg['comment'] is None 58 | 59 | 60 | def test_cli_args_take_precedence(cfgfile, mock_content, mock_create_mode): 61 | cfgfile.write_text(textwrap.dedent(''' 62 | xseed 63 | comment = Generic description 64 | date = 1970-01-01 65 | ''')) 66 | run([str(mock_content), '--noxseed', '--date', '2001-02-03 04:05']) 67 | cfg = mock_create_mode.call_args[0][1] 68 | assert cfg['noxseed'] is True 69 | assert cfg['xseed'] is True 70 | assert cfg['comment'] == 'Generic description' 71 | assert cfg['date'] == datetime.datetime(2001, 2, 3, 4, 5) 72 | 73 | 74 | def test_adding_to_list_via_cli(cfgfile, mock_content, mock_create_mode): 75 | cfgfile.write_text(textwrap.dedent(''' 76 | tracker = https://foo 77 | tracker = https://bar 78 | ''')) 79 | run([str(mock_content), '--tracker', 'https://baz']) 80 | cfg = mock_create_mode.call_args[0][1] 81 | assert cfg['tracker'] == ['https://foo', 'https://bar', 'https://baz'] 82 | 83 | 84 | def test_invalid_option_name(capsys, cfgfile, mock_content, mock_create_mode): 85 | cfgfile.write_text(textwrap.dedent(''' 86 | foo = 123 87 | ''')) 88 | with patch('sys.exit') as mock_exit: 89 | run([]) 90 | mock_exit.assert_called_once_with(_errors.Code.CONFIG) 91 | cap = capsys.readouterr() 92 | assert cap.out == '' 93 | assert cap.err == f'{_vars.__appname__}: {cfgfile}: Unrecognized arguments: --foo\n' 94 | assert mock_create_mode.call_args is None 95 | 96 | 97 | def test_invalid_boolean_name(capsys, cfgfile, mock_content, mock_create_mode): 98 | cfgfile.write_text(textwrap.dedent(''' 99 | foo 100 | ''')) 101 | with patch('sys.exit') as mock_exit: 102 | run([]) 103 | mock_exit.assert_called_once_with(_errors.Code.CONFIG) 104 | cap = capsys.readouterr() 105 | assert cap.out == '' 106 | assert cap.err == f'{_vars.__appname__}: {cfgfile}: Unrecognized arguments: --foo\n' 107 | assert mock_create_mode.call_args is None 108 | 109 | 110 | def test_illegal_configfile_arguments(capsys, cfgfile, mock_content, mock_create_mode): 111 | for arg in ('config', 'noconfig', 'profile', 'help', 'version'): 112 | cfgfile.write_text(textwrap.dedent(f''' 113 | {arg} = foo 114 | ''')) 115 | with patch('sys.exit') as mock_exit: 116 | run(['--config', str(cfgfile), str(mock_content)]) 117 | mock_exit.assert_called_once_with(_errors.Code.CONFIG) 118 | cap = capsys.readouterr() 119 | assert cap.out == '' 120 | assert cap.err == f'{_vars.__appname__}: {cfgfile}: Not allowed in config file: {arg}\n' 121 | assert mock_create_mode.call_args is None 122 | 123 | for arg in ('config', 'noconfig', 'profile', 'help', 'version'): 124 | cfgfile.write_text(textwrap.dedent(f''' 125 | {arg} 126 | ''')) 127 | with patch('sys.exit') as mock_exit: 128 | run(['--config', str(cfgfile), str(mock_content)]) 129 | mock_exit.assert_called_once_with(_errors.Code.CONFIG) 130 | cap = capsys.readouterr() 131 | assert cap.out == '' 132 | assert cap.err == f'{_vars.__appname__}: {cfgfile}: Not allowed in config file: {arg}\n' 133 | assert mock_create_mode.call_args is None 134 | 135 | 136 | def test_environment_variable_resolution(cfgfile, mock_content, mock_create_mode): 137 | cfgfile.write_text(textwrap.dedent(''' 138 | tracker = https://$DOMAIN:$PORT${PATH} 139 | date = $DATE 140 | comment = $UNDEFINED 141 | ''')) 142 | 143 | with patch.dict(os.environ, {'DOMAIN': 'tracker.example.org', 144 | 'PORT': '123', 145 | 'PATH': '/announce', 146 | 'DATE': '1999-12-31'}): 147 | run([str(mock_content)]) 148 | cfg = mock_create_mode.call_args[0][1] 149 | assert cfg['tracker'] == ['https://tracker.example.org:123/announce'] 150 | assert cfg['date'] == datetime.datetime(1999, 12, 31, 0, 0) 151 | assert cfg['comment'] == '$UNDEFINED' 152 | 153 | 154 | def test_environment_variable_resolution_in_profile(cfgfile, mock_content, mock_create_mode): 155 | cfgfile.write_text(textwrap.dedent(''' 156 | [foo] 157 | tracker = https://$DOMAIN:${PORT}/$PATH 158 | date = $DATE 159 | comment = $UNDEFINED 160 | ''')) 161 | 162 | with patch.dict(os.environ, {'DOMAIN': 'tracker.example.org', 163 | 'PORT': '123', 164 | 'PATH': 'announce', 165 | 'DATE': '1999-12-31'}): 166 | run([str(mock_content), '--profile', 'foo']) 167 | cfg = mock_create_mode.call_args[0][1] 168 | assert cfg['tracker'] == ['https://tracker.example.org:123/announce'] 169 | assert cfg['date'] == datetime.datetime(1999, 12, 31, 0, 0) 170 | assert cfg['comment'] == '$UNDEFINED' 171 | 172 | 173 | def test_escaping_dollar(cfgfile, mock_content, mock_create_mode): 174 | cfgfile.write_text(textwrap.dedent(''' 175 | [one] 176 | comment = \\$COMMENT 177 | [two] 178 | comment = \\\\$COMMENT 179 | [three] 180 | comment = \\\\\\$COMMENT 181 | [four] 182 | comment = \\\\\\\\$COMMENT 183 | [five] 184 | comment = \\\\\\\\\\$COMMENT 185 | [six] 186 | comment = \\\\\\\\\\\\$COMMENT 187 | [seven] 188 | comment = \\\\\\\\\\\\\\$COMMENT 189 | ''')) 190 | 191 | with patch.dict(os.environ, {'COMMENT': 'The comment.'}): 192 | run([str(mock_content), '--profile', 'one']) 193 | cfg = mock_create_mode.call_args[0][1] 194 | assert cfg['comment'] == '$COMMENT' 195 | 196 | with patch.dict(os.environ, {'COMMENT': 'The comment.'}): 197 | run([str(mock_content), '--profile', 'two']) 198 | cfg = mock_create_mode.call_args[0][1] 199 | assert cfg['comment'] == '\\The comment.' 200 | 201 | with patch.dict(os.environ, {'COMMENT': 'The comment.'}): 202 | run([str(mock_content), '--profile', 'three']) 203 | cfg = mock_create_mode.call_args[0][1] 204 | assert cfg['comment'] == '\\$COMMENT' 205 | 206 | with patch.dict(os.environ, {'COMMENT': 'The comment.'}): 207 | run([str(mock_content), '--profile', 'four']) 208 | cfg = mock_create_mode.call_args[0][1] 209 | assert cfg['comment'] == '\\\\The comment.' 210 | 211 | with patch.dict(os.environ, {'COMMENT': 'The comment.'}): 212 | run([str(mock_content), '--profile', 'five']) 213 | cfg = mock_create_mode.call_args[0][1] 214 | assert cfg['comment'] == '\\\\$COMMENT' 215 | 216 | with patch.dict(os.environ, {'COMMENT': 'The comment.'}): 217 | run([str(mock_content), '--profile', 'six']) 218 | cfg = mock_create_mode.call_args[0][1] 219 | assert cfg['comment'] == '\\\\\\The comment.' 220 | 221 | with patch.dict(os.environ, {'COMMENT': 'The comment.'}): 222 | run([str(mock_content), '--profile', 'seven']) 223 | cfg = mock_create_mode.call_args[0][1] 224 | assert cfg['comment'] == '\\\\\\$COMMENT' 225 | -------------------------------------------------------------------------------- /tests/test_configformat.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | from torfcli._config import _readfile 4 | 5 | 6 | def test_boolean_options(cfgfile): 7 | cfgfile.write_text(textwrap.dedent(''' 8 | foo 9 | bar 10 | ''')) 11 | cfg = _readfile(cfgfile) 12 | assert cfg == {'foo': True, 'bar': True} 13 | 14 | 15 | def test_options_with_single_values(cfgfile): 16 | cfgfile.write_text(textwrap.dedent(''' 17 | foo = 1 18 | bar = two 19 | ''')) 20 | cfg = _readfile(cfgfile) 21 | assert cfg == {'foo': '1', 'bar': 'two'} 22 | 23 | 24 | def test_options_with_empty_value(cfgfile): 25 | cfgfile.write_text(textwrap.dedent(''' 26 | foo = 27 | ''')) 28 | cfg = _readfile(cfgfile) 29 | assert cfg == {'foo': ''} 30 | 31 | 32 | def test_options_with_list_values(cfgfile): 33 | cfgfile.write_text(textwrap.dedent(''' 34 | foo = 1 35 | foo = 2 36 | foo = three 37 | ''')) 38 | cfg = _readfile(cfgfile) 39 | assert cfg == {'foo': ['1', '2', 'three']} 40 | 41 | 42 | def test_optional_quotes(cfgfile): 43 | for comment_cfg,comment_exp in ((' A comment ', 'A comment'), 44 | ("' A comment '", ' A comment '), 45 | ('" A comment "', ' A comment '), 46 | ('\' A comment "', '\' A comment "')): 47 | cfgfile.write_text(textwrap.dedent(f''' 48 | comment = {comment_cfg} 49 | ''')) 50 | cfg = _readfile(cfgfile) 51 | assert cfg == {'comment': comment_exp} 52 | 53 | 54 | def test_comments(cfgfile): 55 | cfgfile.write_text(textwrap.dedent(''' 56 | # This is a config file 57 | date = 1970-01-01 58 | # The next line is empty 59 | 60 | # This is a boolean value 61 | private 62 | # And here's comment 63 | comment=A comment 64 | # Goodbye! 65 | ''')) 66 | cfg = _readfile(cfgfile) 67 | assert cfg == {'date': '1970-01-01', 68 | 'private': True, 69 | 'comment': 'A comment'} 70 | 71 | 72 | def test_sections(cfgfile): 73 | cfgfile.write_text(textwrap.dedent(''' 74 | date = 1970-01-01 75 | x = 0 76 | 77 | [foo] 78 | x = 10 79 | date = never 80 | yup 81 | 82 | [bar] 83 | yup 84 | x = -100 85 | y = 25 86 | ''')) 87 | cfg = _readfile(cfgfile) 88 | assert cfg == {'date': '1970-01-01', 89 | 'x': '0', 90 | 'foo': {'x': '10', 'date': 'never', 'yup': True}, 91 | 'bar': {'x': '-100', 'y': '25', 'yup': True}} 92 | -------------------------------------------------------------------------------- /tests/test_edit.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from datetime import datetime 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | import torf 8 | 9 | from torfcli import _config as config 10 | from torfcli import _errors as err 11 | from torfcli import _vars, run 12 | 13 | 14 | def test_nonexisting_input(capsys): 15 | nonexisting_path = '/no/such/file' 16 | with patch('sys.exit') as mock_exit: 17 | run(['-i', nonexisting_path, '-o', 'out.torrent']) 18 | mock_exit.assert_called_once_with(err.Code.READ) 19 | cap = capsys.readouterr() 20 | assert cap.err == f'{_vars.__appname__}: {nonexisting_path}: No such file or directory\n' 21 | 22 | def test_existing_output(capsys, tmp_path, create_torrent): 23 | outfile = tmp_path / 'out.torrent' 24 | outfile.write_text('some existing file content') 25 | with create_torrent() as infile: 26 | with patch('sys.exit') as mock_exit: 27 | run(['-i', infile, '-o', str(outfile)]) 28 | mock_exit.assert_called_once_with(err.Code.WRITE) 29 | cap = capsys.readouterr() 30 | assert cap.err == f'{_vars.__appname__}: {outfile}: File exists\n' 31 | 32 | def test_unwritable_output(capsys, create_torrent): 33 | unwritable_path = '/out.torrent' 34 | with create_torrent() as infile: 35 | with patch('sys.exit') as mock_exit: 36 | run(['-i', infile, '-o', unwritable_path]) 37 | mock_exit.assert_called_once_with(err.Code.WRITE) 38 | cap = capsys.readouterr() 39 | assert cap.err == f'{_vars.__appname__}: {unwritable_path}: Permission denied\n' 40 | 41 | 42 | def test_no_changes(create_torrent, tmp_path, assert_torrents_equal): 43 | outfile = str(tmp_path / 'out.torrent') 44 | with create_torrent() as infile: 45 | orig = torf.Torrent.read(infile) 46 | run(['-i', infile, '-o', outfile]) 47 | new = torf.Torrent.read(outfile) 48 | assert_torrents_equal(orig, new) 49 | 50 | 51 | def test_edit_comment(create_torrent, tmp_path, assert_torrents_equal): 52 | outfile = str(tmp_path / 'out.torrent') 53 | with create_torrent(comment='A comment') as infile: 54 | orig = torf.Torrent.read(infile) 55 | run(['-i', infile, '--comment', 'A different comment', '-o', outfile]) 56 | new = torf.Torrent.read(outfile) 57 | assert_torrents_equal(orig, new, comment='A different comment') 58 | 59 | def test_remove_comment(create_torrent, tmp_path, assert_torrents_equal): 60 | outfile = str(tmp_path / 'out.torrent') 61 | with create_torrent(comment='A comment') as infile: 62 | orig = torf.Torrent.read(infile) 63 | run(['-i', infile, '--nocomment', '-o', outfile]) 64 | new = torf.Torrent.read(outfile) 65 | assert_torrents_equal(orig, new, comment=None) 66 | 67 | 68 | def test_remove_creator(create_torrent, tmp_path, assert_torrents_equal): 69 | outfile = str(tmp_path / 'out.torrent') 70 | with create_torrent(created_by='The creator') as infile: 71 | orig = torf.Torrent.read(infile) 72 | run(['-i', infile, '--nocreator', '-o', outfile]) 73 | new = torf.Torrent.read(outfile) 74 | assert_torrents_equal(orig, new, created_by=None) 75 | 76 | def test_remove_creator_even_when_creator_provided(create_torrent, tmp_path, assert_torrents_equal): 77 | outfile = str(tmp_path / 'out.torrent') 78 | with create_torrent(created_by='The creator') as infile: 79 | orig = torf.Torrent.read(infile) 80 | run(['-i', infile, '--nocreator', '--creator', 'A conflicting creator', '-o', outfile]) 81 | new = torf.Torrent.read(outfile) 82 | assert_torrents_equal(orig, new, created_by=None) 83 | 84 | def test_edit_creator(create_torrent, tmp_path, assert_torrents_equal): 85 | outfile = str(tmp_path / 'out.torrent') 86 | with create_torrent(created_by='The creator') as infile: 87 | orig = torf.Torrent.read(infile) 88 | run(['-i', infile, '--creator', 'A different creator', '-o', outfile]) 89 | new = torf.Torrent.read(outfile) 90 | assert_torrents_equal(orig, new, created_by='A different creator') 91 | 92 | def test_edit_default_creator(create_torrent, tmp_path, assert_torrents_equal): 93 | outfile = str(tmp_path / 'out.torrent') 94 | with create_torrent(created_by='The creator') as infile: 95 | orig = torf.Torrent.read(infile) 96 | run(['-i', infile, '--creator', '-o', outfile]) 97 | new = torf.Torrent.read(outfile) 98 | assert_torrents_equal(orig, new, created_by=config.DEFAULT_CREATOR) 99 | 100 | 101 | def test_remove_private(create_torrent, tmp_path, assert_torrents_equal): 102 | outfile = str(tmp_path / 'out.torrent') 103 | with create_torrent(private=True) as infile: 104 | orig = torf.Torrent.read(infile) 105 | run(['-i', infile, '--noprivate', '-o', outfile]) 106 | new = torf.Torrent.read(outfile) 107 | assert_torrents_equal(orig, new, private=None) 108 | 109 | def test_add_private(create_torrent, tmp_path, assert_torrents_equal): 110 | outfile = str(tmp_path / 'out.torrent') 111 | with create_torrent(private=False) as infile: 112 | orig = torf.Torrent.read(infile) 113 | run(['-i', infile, '--private', '-o', outfile]) 114 | new = torf.Torrent.read(outfile) 115 | assert_torrents_equal(orig, new, private=True) 116 | 117 | def test_add_private_and_remove_all_trackers(create_torrent, tmp_path, assert_torrents_equal, capsys): 118 | outfile = str(tmp_path / 'out.torrent') 119 | with create_torrent(private=False) as infile: 120 | orig = torf.Torrent.read(infile) 121 | run(['-i', infile, '--private', '--notracker', '-o', outfile]) 122 | cap = capsys.readouterr() 123 | assert cap.err == f'{_vars.__appname__}: WARNING: Torrent is private and has no trackers\n' 124 | new = torf.Torrent.read(outfile) 125 | assert_torrents_equal(orig, new, private=True, trackers=()) 126 | 127 | 128 | def test_edit_source(create_torrent, tmp_path, assert_torrents_equal): 129 | outfile = str(tmp_path / 'out.torrent') 130 | with create_torrent(source='the source') as infile: 131 | orig = torf.Torrent.read(infile) 132 | run(['-i', infile, '--source', 'another source', '-o', outfile]) 133 | new = torf.Torrent.read(outfile) 134 | assert_torrents_equal(orig, new, source='another source') 135 | 136 | def test_remove_source(create_torrent, tmp_path, assert_torrents_equal): 137 | outfile = str(tmp_path / 'out.torrent') 138 | with create_torrent(source='the source') as infile: 139 | orig = torf.Torrent.read(infile) 140 | run(['-i', infile, '--nosource', '-o', outfile]) 141 | new = torf.Torrent.read(outfile) 142 | assert_torrents_equal(orig, new, source=None) 143 | 144 | 145 | def test_remove_xseed(create_torrent, tmp_path, assert_torrents_equal): 146 | outfile = str(tmp_path / 'out.torrent') 147 | with create_torrent(randomize_infohash=True) as infile: 148 | orig = torf.Torrent.read(infile) 149 | run(['-i', infile, '--noxseed', '-o', outfile]) 150 | new = torf.Torrent.read(outfile) 151 | assert_torrents_equal(orig, new, randomize_infohash=False) 152 | assert orig.infohash != new.infohash 153 | 154 | def test_add_xseed(create_torrent, tmp_path, assert_torrents_equal): 155 | outfile = str(tmp_path / 'out.torrent') 156 | with create_torrent(randomize_infohash=False) as infile: 157 | orig = torf.Torrent.read(infile) 158 | run(['-i', infile, '--xseed', '-o', outfile]) 159 | new = torf.Torrent.read(outfile) 160 | assert_torrents_equal(orig, new, randomize_infohash=True) 161 | assert orig.infohash != new.infohash 162 | 163 | 164 | def test_remove_trackers(create_torrent, tmp_path, assert_torrents_equal): 165 | outfile = str(tmp_path / 'out.torrent') 166 | with create_torrent(trackers=['http://tracker1', 'http://tracker2']) as infile: 167 | orig = torf.Torrent.read(infile) 168 | run(['-i', infile, '--notracker', '-o', outfile]) 169 | new = torf.Torrent.read(outfile) 170 | assert_torrents_equal(orig, new, trackers=[]) 171 | 172 | def test_add_trackers(create_torrent, tmp_path, assert_torrents_equal): 173 | outfile = str(tmp_path / 'out.torrent') 174 | with create_torrent(trackers=['http://tracker1', 'http://tracker2']) as infile: 175 | orig = torf.Torrent.read(infile) 176 | run(['-i', infile, '--tracker', 'http://a', '-o', outfile]) 177 | new = torf.Torrent.read(outfile) 178 | assert_torrents_equal(orig, new, trackers=[['http://tracker1', 'http://a'], ['http://tracker2']]) 179 | 180 | outfile = str(tmp_path / 'out.torrent') 181 | with create_torrent(trackers=['http://foo', 'http://bar']) as infile: 182 | orig = torf.Torrent.read(infile) 183 | run(['-i', infile, 184 | '--tracker', 'http://a,http://b', 185 | '--tracker', 'http://x', 186 | '--tracker', 'http://y', 187 | '-o', outfile, '-y']) 188 | new = torf.Torrent.read(outfile) 189 | assert_torrents_equal(orig, new, trackers=[['http://foo', 'http://a', 'http://b'], 190 | ['http://bar', 'http://x'], 191 | ['http://y']]) 192 | 193 | def test_replace_trackers(create_torrent, tmp_path, assert_torrents_equal): 194 | outfile = str(tmp_path / 'out.torrent') 195 | with create_torrent(trackers=['http://tracker1', 'http://tracker2']) as infile: 196 | orig = torf.Torrent.read(infile) 197 | run(['-i', infile, '--notracker', '--tracker', 'http://tracker10', '--tracker', 'http://tracker20', '-o', outfile]) 198 | new = torf.Torrent.read(outfile) 199 | assert_torrents_equal(orig, new, trackers=[['http://tracker10'], ['http://tracker20']]) 200 | 201 | def test_invalid_tracker_url(capsys, create_torrent, tmp_path, assert_torrents_equal): 202 | outfile = str(tmp_path / 'out.torrent') 203 | with create_torrent(trackers=['http://tracker1', 'http://tracker2']) as infile: 204 | with patch('sys.exit') as mock_exit: 205 | run(['-i', infile, '--tracker', 'not a url', '-o', outfile]) 206 | mock_exit.assert_called_once_with(err.Code.CLI) 207 | cap = capsys.readouterr() 208 | assert cap.err == f'{_vars.__appname__}: not a url: Invalid URL\n' 209 | assert not os.path.exists(outfile) 210 | 211 | 212 | 213 | def test_remove_webseeds(create_torrent, tmp_path, assert_torrents_equal): 214 | outfile = str(tmp_path / 'out.torrent') 215 | with create_torrent(webseeds=['http://webseed1', 'http://webseed2']) as infile: 216 | orig = torf.Torrent.read(infile) 217 | run(['-i', infile, '--nowebseed', '-o', outfile]) 218 | new = torf.Torrent.read(outfile) 219 | assert_torrents_equal(orig, new, webseeds=[]) 220 | 221 | def test_add_webseed(create_torrent, tmp_path, assert_torrents_equal): 222 | outfile = str(tmp_path / 'out.torrent') 223 | with create_torrent(webseeds=['http://webseed1', 'http://webseed2']) as infile: 224 | orig = torf.Torrent.read(infile) 225 | run(['-i', infile, '--webseed', 'http://webseed3', '-o', outfile]) 226 | new = torf.Torrent.read(outfile) 227 | assert_torrents_equal(orig, new, webseeds=['http://webseed1', 'http://webseed2', 'http://webseed3']) 228 | 229 | def test_replace_webseeds(create_torrent, tmp_path, assert_torrents_equal): 230 | outfile = str(tmp_path / 'out.torrent') 231 | with create_torrent(webseeds=['http://webseed1', 'http://webseed2']) as infile: 232 | orig = torf.Torrent.read(infile) 233 | run(['-i', infile, '--nowebseed', '--webseed', 'http://webseed10', '--webseed', 'http://webseed20', '-o', outfile]) 234 | new = torf.Torrent.read(outfile) 235 | assert_torrents_equal(orig, new, webseeds=['http://webseed10', 'http://webseed20']) 236 | 237 | def test_invalid_webseed_url(capsys, create_torrent, tmp_path, assert_torrents_equal): 238 | outfile = str(tmp_path / 'out.torrent') 239 | with create_torrent(webseeds=['http://webseed1', 'http://webseed2']) as infile: 240 | with patch('sys.exit') as mock_exit: 241 | run(['-i', infile, '--webseed', 'not a url', '-o', outfile]) 242 | mock_exit.assert_called_once_with(err.Code.CLI) 243 | cap = capsys.readouterr() 244 | assert cap.err == f'{_vars.__appname__}: not a url: Invalid URL\n' 245 | assert not os.path.exists(outfile) 246 | 247 | 248 | def test_edit_creation_date(create_torrent, tmp_path, assert_torrents_equal): 249 | outfile = str(tmp_path / 'out.torrent') 250 | with create_torrent() as infile: 251 | orig = torf.Torrent.read(infile) 252 | run(['-i', infile, '--date', '3000-05-30 15:03:01', '-o', outfile]) 253 | new = torf.Torrent.read(outfile) 254 | assert_torrents_equal(orig, new, creation_date=datetime(3000, 5, 30, 15, 3, 1)) 255 | 256 | def test_remove_creation_date(create_torrent, tmp_path, assert_torrents_equal): 257 | outfile = str(tmp_path / 'out.torrent') 258 | with create_torrent() as infile: 259 | orig = torf.Torrent.read(infile) 260 | run(['-i', infile, '--nodate', '-o', outfile]) 261 | new = torf.Torrent.read(outfile) 262 | assert_torrents_equal(orig, new, creation_date=None) 263 | 264 | def test_invalid_creation_date(capsys, create_torrent, tmp_path, assert_torrents_equal): 265 | outfile = str(tmp_path / 'out.torrent') 266 | with create_torrent() as infile: 267 | with patch('sys.exit') as mock_exit: 268 | run(['-i', infile, '--date', 'foo', '-o', outfile]) 269 | mock_exit.assert_called_once_with(err.Code.CLI) 270 | cap = capsys.readouterr() 271 | assert cap.err == f'{_vars.__appname__}: foo: Invalid date\n' 272 | assert not os.path.exists(outfile) 273 | 274 | 275 | def test_edit_path(create_torrent, tmp_path, assert_torrents_equal): 276 | outfile = str(tmp_path / 'out.torrent') 277 | new_content = tmp_path / 'new content' 278 | new_content.mkdir() 279 | new_file = new_content / 'some file' 280 | new_file.write_text('different data') 281 | with create_torrent() as infile: 282 | orig = torf.Torrent.read(infile) 283 | run(['-i', infile, str(new_content), '-o', outfile]) 284 | new = torf.Torrent.read(outfile) 285 | assert_torrents_equal(orig, new, ignore=('files', 'filetree', 'name', 286 | 'piece_size', 'pieces', 'size')) 287 | assert tuple(new.files) == (torf.File('new content/some file', size=14),) 288 | assert new.filetree == {'new content': {'some file': torf.File('new content/some file', size=14)}} 289 | assert new.name == 'new content' 290 | assert new.size == len('different data') 291 | 292 | 293 | def test_edit_path_with_exclude_option(create_torrent, tmp_path, assert_torrents_equal): 294 | outfile = str(tmp_path / 'out.torrent') 295 | new_content = tmp_path / 'new content' 296 | new_content.mkdir() 297 | new_file1 = new_content / 'some image.jpg' 298 | new_file1.write_text('image data') 299 | new_file2 = new_content / 'some text.txt' 300 | new_file2.write_text('text data') 301 | with create_torrent() as infile: 302 | orig = torf.Torrent.read(infile) 303 | run(['-i', infile, str(new_content), '--exclude', '*.txt', '-o', outfile]) 304 | new = torf.Torrent.read(outfile) 305 | assert_torrents_equal(orig, new, ignore=('files', 'filetree', 'name', 306 | 'piece_size', 'pieces', 'size')) 307 | assert tuple(new.files) == (torf.File('new content/some image.jpg', size=10),) 308 | assert new.filetree == {'new content': {'some image.jpg': torf.File('new content/some image.jpg', 309 | size=10)}} 310 | assert new.name == 'new content' 311 | assert new.size == len('image data') 312 | 313 | 314 | def test_edit_path_with_exclude_regex_option(create_torrent, tmp_path, assert_torrents_equal): 315 | outfile = str(tmp_path / 'out.torrent') 316 | new_content = tmp_path / 'new content' 317 | new_content.mkdir() 318 | new_file1 = new_content / 'some image.jpg' 319 | new_file1.write_text('image data') 320 | new_file2 = new_content / 'some text.txt' 321 | new_file2.write_text('text data') 322 | with create_torrent() as infile: 323 | orig = torf.Torrent.read(infile) 324 | run(['-i', infile, str(new_content), '--exclude-regex', r'.*\.txt$', '-o', outfile]) 325 | new = torf.Torrent.read(outfile) 326 | assert_torrents_equal(orig, new, ignore=('files', 'filetree', 'name', 327 | 'piece_size', 'pieces', 'size')) 328 | assert tuple(new.files) == (torf.File('new content/some image.jpg', size=10),) 329 | assert new.filetree == {'new content': {'some image.jpg': torf.File('new content/some image.jpg', 330 | size=10)}} 331 | assert new.name == 'new content' 332 | assert new.size == len('image data') 333 | 334 | 335 | def test_edit_name(create_torrent, tmp_path, assert_torrents_equal): 336 | outfile = str(tmp_path / 'out.torrent') 337 | with create_torrent() as infile: 338 | orig = torf.Torrent.read(infile) 339 | run(['-i', infile, '--name', 'new name', '-o', outfile]) 340 | new = torf.Torrent.read(outfile) 341 | assert_torrents_equal(orig, new, ignore=('name', 'files', 'filetree')) 342 | 343 | assert new.name == 'new name' 344 | for of,nf in zip(orig.files, new.files): 345 | assert nf.parts[0] == 'new name' 346 | assert nf.parts[1:] == of.parts[1:] 347 | 348 | assert new.filetree == {'new name': {'Anotherthing.iso': torf.File('new name/Anotherthing.iso', size=9), 349 | 'Something.jpg': torf.File('new name/Something.jpg', size=9), 350 | 'Thirdthing.txt': torf.File('new name/Thirdthing.txt', size=9)}} 351 | 352 | 353 | def test_edit_invalid_torrent_with_validation_enabled(tmp_path, capsys): 354 | infile = tmp_path / 'in.torrent' 355 | outfile = tmp_path / 'out.torrent' 356 | with open(infile, 'wb') as f: 357 | f.write(b'd1:2i3e4:thisl2:is3:note5:validd2:is2:ok8:metainfol3:but4:thateee') 358 | with patch('sys.exit') as mock_exit: 359 | run(['-i', str(infile), '--name', 'New Name', '-o', str(outfile)]) 360 | mock_exit.assert_called_once_with(err.Code.READ) 361 | cap = capsys.readouterr() 362 | assert cap.err == f"{_vars.__appname__}: Invalid metainfo: Missing 'info'\n" 363 | assert cap.out == '' 364 | assert not os.path.exists(outfile) 365 | 366 | def test_edit_invalid_torrent_with_validation_disabled(tmp_path, capsys, regex): 367 | infile = tmp_path / 'in.torrent' 368 | outfile = tmp_path / 'out.torrent' 369 | with open(infile, 'wb') as f: 370 | f.write(b'd1:2i3e4:thisl2:is3:note5:validd2:is2:ok8:metainfol3:but4:thateee') 371 | run(['-i', str(infile), '--name', 'New Name', '-o', str(outfile), '--novalidate']) 372 | cap = capsys.readouterr() 373 | assert cap.err == f"{_vars.__appname__}: WARNING: Invalid metainfo: Missing 'piece length' in ['info']\n" 374 | assert cap.out == regex(r'^Name\tNew Name$', flags=re.MULTILINE) 375 | assert cap.out == regex(fr'^Torrent\t{outfile}$', flags=re.MULTILINE) 376 | assert os.path.exists(outfile) 377 | 378 | 379 | def test_edit_magnet_uri_and_dont_create_torrent(capsys, regex): 380 | magnet = ('magnet:?xt=urn:btih:e167b1fbb42ea72f051f4f50432703308efb8fd1&dn=My+Torrent&xl=142631' 381 | '&tr=https%3A%2F%2Flocalhost%3A123%2Fannounce') 382 | run(['-i', magnet, '--name', 'New Name', '--notracker', '--webseed', 'http://foo', 383 | '--notorrent']) 384 | cap = capsys.readouterr() 385 | assert cap.err == '' 386 | new_magnet = ('magnet:?xt=urn:btih:e167b1fbb42ea72f051f4f50432703308efb8fd1&dn=New+Name&xl=142631' 387 | '&ws=http%3A%2F%2Ffoo') 388 | assert cap.out == regex(fr'^Magnet\t{re.escape(new_magnet)}\n$', flags=re.MULTILINE) 389 | 390 | def test_edit_magnet_uri_and_create_torrent_with_validation_enabled(capsys, tmp_path, regex): 391 | magnet = ('magnet:?xt=urn:btih:e167b1fbb42ea72f051f4f50432703308efb8fd1&dn=My+Torrent&xl=142631' 392 | '&tr=https%3A%2F%2Flocalhost%3A123%2Fannounce') 393 | outfile = tmp_path / 'out.torrent' 394 | with patch('sys.exit') as mock_exit: 395 | run(['-i', magnet, '--name', 'New Name', '--notracker', '--tracker', 'http://bar', 396 | '-o', str(outfile)]) 397 | mock_exit.assert_called_once_with(err.Code.READ) 398 | cap = capsys.readouterr() 399 | assert cap.err == (f"{_vars.__appname__}: https://localhost:123/file?info_hash=%E1g%B1%FB%B4.%A7/%05%1FOPC%27%030%8E%FB%8F%D1" 400 | f': Connection refused\n' 401 | f"{_vars.__appname__}: Invalid metainfo: Missing 'piece length' in ['info']\n") 402 | 403 | def test_edit_magnet_uri_and_create_torrent_with_validation_disabled(capsys, tmp_path, regex): 404 | magnet = ('magnet:?xt=urn:btih:e167b1fbb42ea72f051f4f50432703308efb8fd1&dn=My+Torrent&xl=142631' 405 | '&tr=https%3A%2F%2Flocalhost%3A123%2Fannounce') 406 | outfile = tmp_path / 'out.torrent' 407 | run(['-i', magnet, '--name', 'New Name', '--notracker', '--tracker', 'http://bar', 408 | '-o', str(outfile), '--novalidate']) 409 | cap = capsys.readouterr() 410 | assert cap.err == (f"{_vars.__appname__}: https://localhost:123/file?info_hash=%E1g%B1%FB%B4.%A7/%05%1FOPC%27%030%8E%FB%8F%D1" 411 | f': Connection refused\n' 412 | f"{_vars.__appname__}: WARNING: Invalid metainfo: Missing 'piece length' in ['info']\n") 413 | new_magnet = ('magnet:?xt=urn:btih:e167b1fbb42ea72f051f4f50432703308efb8fd1&dn=New+Name&xl=142631' 414 | '&tr=http%3A%2F%2Fbar') 415 | assert cap.out == regex(fr'^Magnet\t{re.escape(new_magnet)}$', flags=re.MULTILINE) 416 | assert cap.out == regex(fr'^Torrent\t{re.escape(str(outfile))}$', flags=re.MULTILINE) 417 | torrent = torf.Torrent.read(outfile, validate=False) 418 | assert torrent.size == 142631 419 | assert torrent.name == 'New Name' 420 | assert torrent.trackers == [['http://bar']] 421 | 422 | 423 | @pytest.mark.parametrize( 424 | argnames='merges, exp_result', 425 | argvalues=( 426 | ( 427 | [ 428 | f'{{"creation date": {int(datetime(2012, 11, 10, 9, 8, 7).timestamp())}}}', 429 | '{"info": {"foo": ["Hello", "World!"], "bar": "baz", "private": null, "nosuchkey": null}}', 430 | '{"created by": null}', 431 | ], 432 | { 433 | 'creation_date': datetime(2012, 11, 10, 9, 8, 7), 434 | 'created_by': None, 435 | 'private': None, 436 | 'path_map': { 437 | ('info', 'foo'): ['Hello', 'World!'], 438 | ('info', 'bar'): 'baz', 439 | }, 440 | }, 441 | ), 442 | ( 443 | ['["Hello", "World!"]'], 444 | err.CliError("Not a JSON object: ['Hello', 'World!']"), 445 | ), 446 | ), 447 | ids=lambda v: repr(v), 448 | ) 449 | def test_merge_option(merges, exp_result, create_torrent, tmp_path, assert_torrents_equal, capsys): 450 | outfile = str(tmp_path / 'out.torrent') 451 | with create_torrent() as infile: 452 | orig = torf.Torrent.read(infile) 453 | cmd = ['-i', infile, '-o', outfile] 454 | for merge in merges: 455 | cmd.extend(('--merge', merge)) 456 | 457 | if isinstance(exp_result, err.Error): 458 | with patch('sys.exit') as mock_exit: 459 | run(cmd) 460 | mock_exit.assert_called_once_with(exp_result.exit_code) 461 | cap = capsys.readouterr() 462 | assert cap.err == f'{_vars.__appname__}: {str(exp_result)}\n' 463 | else: 464 | run(cmd) 465 | new = torf.Torrent.read(outfile) 466 | assert_torrents_equal(orig, new, **exp_result) 467 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | import errno 2 | 3 | import pytest 4 | import torf 5 | 6 | from torfcli import _errors as err 7 | 8 | 9 | def test_CliError(): 10 | for cls,args,kwargs in ((err.CliError, ('invalid argument: --foo',), {}), 11 | (err.Error, ('invalid argument: --foo', err.Code.CLI), {}), 12 | (err.Error, ('invalid argument: --foo',), {'code': err.Code.CLI})): 13 | with pytest.raises(err.CliError) as exc_info: 14 | raise cls(*args, **kwargs) 15 | assert exc_info.value.exit_code is err.Code.CLI 16 | assert str(exc_info.value) == 'invalid argument: --foo' 17 | 18 | 19 | def test_ConfigError(): 20 | for cls,args,kwargs in ((err.ConfigError, ('config error',), {}), 21 | (err.Error, ('config error', err.Code.CONFIG), {}), 22 | (err.Error, ('config error',), {'code': err.Code.CONFIG})): 23 | with pytest.raises(err.ConfigError) as exc_info: 24 | raise cls(*args, **kwargs) 25 | assert exc_info.value.exit_code is err.Code.CONFIG 26 | assert str(exc_info.value) == 'config error' 27 | 28 | 29 | def test_ReadError(): 30 | for cls,args,kwargs in ((err.ReadError, ('path/to/file: No such file or directory',), {}), 31 | (err.Error, (torf.ReadError(errno.ENOENT, 'path/to/file'),), {}), 32 | (err.Error, (torf.PathError('path/to/file', msg='No such file or directory'),), {}), 33 | (err.Error, ('path/to/file: No such file or directory', err.Code.READ), {}), 34 | (err.Error, ('path/to/file: No such file or directory',), {'code': err.Code.READ})): 35 | with pytest.raises(err.ReadError) as exc_info: 36 | raise cls(*args, **kwargs) 37 | assert exc_info.value.exit_code is err.Code.READ 38 | assert str(exc_info.value) == 'path/to/file: No such file or directory' 39 | 40 | 41 | def test_WriteError(): 42 | for cls,args,kwargs in ((err.WriteError, ('path/to/file: No space left on device',), {}), 43 | (err.Error, (torf.WriteError(errno.ENOSPC, 'path/to/file'),), {}), 44 | (err.Error, ('path/to/file: No space left on device', err.Code.WRITE), {}), 45 | (err.Error, ('path/to/file: No space left on device',), {'code': err.Code.WRITE})): 46 | with pytest.raises(err.WriteError) as exc_info: 47 | raise cls(*args, **kwargs) 48 | assert exc_info.value.exit_code is err.Code.WRITE 49 | assert str(exc_info.value) == 'path/to/file: No space left on device' 50 | 51 | 52 | def test_VerifyError(): 53 | for cls,args,kwargs in ((err.VerifyError, (), {'content': 'path/to/content', 'torrent': 'path/to/torrent'}), 54 | (err.Error, ('path/to/content does not satisfy path/to/torrent', err.Code.VERIFY), {}), 55 | (err.Error, ('path/to/content does not satisfy path/to/torrent',), {'code': err.Code.VERIFY})): 56 | with pytest.raises(err.VerifyError) as exc_info: 57 | raise cls(*args, **kwargs) 58 | assert exc_info.value.exit_code is err.Code.VERIFY 59 | assert str(exc_info.value) == 'path/to/content does not satisfy path/to/torrent' 60 | 61 | with pytest.raises(err.VerifyError) as exc_info: 62 | raise err.Error(torf.VerifyNotDirectoryError('path/to/file')) 63 | assert exc_info.value.exit_code is err.Code.VERIFY 64 | assert str(exc_info.value) == 'path/to/file: Not a directory' 65 | 66 | with pytest.raises(err.VerifyError) as exc_info: 67 | raise err.Error(torf.VerifyIsDirectoryError('path/to/file')) 68 | assert exc_info.value.exit_code is err.Code.VERIFY 69 | assert str(exc_info.value) == 'path/to/file: Is a directory' 70 | 71 | with pytest.raises(err.VerifyError) as exc_info: 72 | raise err.Error(torf.VerifyFileSizeError('path/to/file', 123, 456)) 73 | assert exc_info.value.exit_code is err.Code.VERIFY 74 | assert str(exc_info.value) == 'path/to/file: Too small: 123 instead of 456 bytes' 75 | -------------------------------------------------------------------------------- /tests/test_info.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from datetime import datetime 4 | from unittest.mock import patch 5 | 6 | from torfcli import _errors as err 7 | from torfcli import _vars, run 8 | 9 | 10 | def test_nonexisting_torrent_file(capsys): 11 | nonexising_path = '/no/such/file' 12 | with patch('sys.exit') as mock_exit: 13 | run(['-i', nonexising_path]) 14 | mock_exit.assert_called_once_with(err.Code.READ) 15 | cap = capsys.readouterr() 16 | assert cap.err == f'{_vars.__appname__}: {nonexising_path}: No such file or directory\n' 17 | assert cap.out == '' 18 | 19 | 20 | def test_insufficient_permissions(capsys, create_torrent): 21 | with create_torrent() as torrent_file: 22 | os.chmod(torrent_file, 0o000) 23 | with patch('sys.exit') as mock_exit: 24 | run(['-i', torrent_file]) 25 | mock_exit.assert_called_once_with(err.Code.READ) 26 | cap = capsys.readouterr() 27 | assert cap.err == f'{_vars.__appname__}: {torrent_file}: Permission denied\n' 28 | assert cap.out == '' 29 | 30 | 31 | def test_magnet(capsys, create_torrent, human_readable, clear_ansi, regex): 32 | with create_torrent(name='foo') as torrent_file: 33 | with human_readable(True): 34 | run(['-i', torrent_file]) 35 | cap = capsys.readouterr() 36 | assert clear_ansi(cap.out) == regex(r'^\s*Magnet magnet:\?xt=urn:btih:[0-9a-z]{40}', flags=re.MULTILINE) 37 | assert cap.err == '' 38 | 39 | with human_readable(False): 40 | run(['-i', torrent_file]) 41 | cap = capsys.readouterr() 42 | assert cap.out == regex(r'^Magnet\tmagnet:\?xt=urn:btih:[0-9a-z]{40}', flags=re.MULTILINE) 43 | assert cap.err == '' 44 | 45 | 46 | def test_nomagnet(capsys, create_torrent, human_readable, clear_ansi, regex): 47 | with create_torrent(name='foo') as torrent_file: 48 | with human_readable(True): 49 | run(['-i', torrent_file, '--nomagnet']) 50 | cap = capsys.readouterr() 51 | assert clear_ansi(cap.out) != regex(r'^\s*Magnet', flags=re.MULTILINE) 52 | assert cap.err == '' 53 | 54 | with human_readable(False): 55 | run(['-i', torrent_file, '--nomagnet']) 56 | cap = capsys.readouterr() 57 | assert cap.out != regex(r'^Magnet', flags=re.MULTILINE) 58 | assert cap.err == '' 59 | 60 | 61 | def test_name(capsys, create_torrent, human_readable, clear_ansi, regex): 62 | with create_torrent(name='foo') as torrent_file: 63 | with human_readable(True): 64 | run(['-i', torrent_file]) 65 | cap = capsys.readouterr() 66 | assert clear_ansi(cap.out) == regex(r'^\s*Name foo$', flags=re.MULTILINE) 67 | assert cap.err == '' 68 | 69 | with human_readable(False): 70 | run(['-i', torrent_file]) 71 | cap = capsys.readouterr() 72 | assert cap.out == regex(r'^Name\tfoo$', flags=re.MULTILINE) 73 | assert cap.err == '' 74 | 75 | 76 | def test_info_hash(capsys, create_torrent, human_readable, clear_ansi, regex): 77 | with create_torrent() as torrent_file: 78 | with human_readable(True): 79 | run(['-i', torrent_file]) 80 | cap = capsys.readouterr() 81 | assert clear_ansi(cap.out) == regex(r'^\s*Info Hash [0-9a-z]{40}$', flags=re.MULTILINE) 82 | assert cap.err == '' 83 | 84 | with human_readable(False): 85 | run(['-i', torrent_file]) 86 | cap = capsys.readouterr() 87 | assert cap.out == regex(r'^Info Hash\t[0-9a-z]{40}$', flags=re.MULTILINE) 88 | assert cap.err == '' 89 | 90 | 91 | def test_size(capsys, create_torrent, human_readable, clear_ansi, regex): 92 | with create_torrent() as torrent_file: 93 | with human_readable(True): 94 | run(['-i', torrent_file]) 95 | cap = capsys.readouterr() 96 | assert clear_ansi(cap.out) == regex(r'^\s*Size [0-9]+ [KMGT]?i?B$', flags=re.MULTILINE) 97 | assert cap.err == '' 98 | 99 | with human_readable(False): 100 | run(['-i', torrent_file]) 101 | cap = capsys.readouterr() 102 | assert cap.out == regex(r'^Size\t[0-9]+$', flags=re.MULTILINE) 103 | assert cap.err == '' 104 | 105 | 106 | def test_piece_size(capsys, create_torrent, human_readable, clear_ansi, regex): 107 | with create_torrent() as torrent_file: 108 | with human_readable(True): 109 | run(['-i', torrent_file]) 110 | cap = capsys.readouterr() 111 | assert clear_ansi(cap.out) == regex(r'^\s*Piece Size [0-9]+ [KMGT]?i?B$', flags=re.MULTILINE) 112 | assert cap.err == '' 113 | 114 | with human_readable(False): 115 | run(['-i', torrent_file]) 116 | cap = capsys.readouterr() 117 | assert cap.out == regex(r'^Piece Size\t[0-9]+$', flags=re.MULTILINE) 118 | assert cap.err == '' 119 | 120 | 121 | def test_piece_count(capsys, create_torrent, human_readable, clear_ansi, regex): 122 | with create_torrent() as torrent_file: 123 | with human_readable(True): 124 | run(['-i', torrent_file]) 125 | cap = capsys.readouterr() 126 | assert clear_ansi(cap.out) == regex(r'^\s*Piece Count [0-9]+$', flags=re.MULTILINE) 127 | assert cap.err == '' 128 | 129 | with human_readable(False): 130 | run(['-i', torrent_file]) 131 | cap = capsys.readouterr() 132 | assert cap.out == regex(r'^Piece Count\t[0-9]+$', flags=re.MULTILINE) 133 | assert cap.err == '' 134 | 135 | 136 | def test_single_line_comment(capsys, create_torrent, human_readable, clear_ansi, regex): 137 | with create_torrent(comment='This is my torrent.') as torrent_file: 138 | with human_readable(True): 139 | run(['-i', torrent_file]) 140 | cap = capsys.readouterr() 141 | assert clear_ansi(cap.out) == regex(r'^\s*Comment This is my torrent\.$', flags=re.MULTILINE) 142 | assert cap.err == '' 143 | 144 | with human_readable(False): 145 | run(['-i', torrent_file]) 146 | cap = capsys.readouterr() 147 | assert cap.out == regex(r'^Comment\tThis is my torrent\.$', flags=re.MULTILINE) 148 | assert cap.err == '' 149 | 150 | def test_multiline_comment(capsys, create_torrent, human_readable, clear_ansi, regex): 151 | comment = 'This is my torrent.\nShare it!' 152 | with create_torrent(comment=comment) as torrent_file: 153 | with human_readable(True): 154 | run(['-i', torrent_file]) 155 | cap = capsys.readouterr() 156 | assert clear_ansi(cap.out) == regex(r'^(\s*)Comment This is my torrent\.\n' 157 | r'\1 Share it!$', flags=re.MULTILINE) 158 | assert cap.err == '' 159 | 160 | with human_readable(False): 161 | run(['-i', torrent_file]) 162 | cap = capsys.readouterr() 163 | assert cap.out == regex(r'^Comment\tThis is my torrent\.\tShare it!$', flags=re.MULTILINE) 164 | assert cap.err == '' 165 | 166 | 167 | def test_creation_date(capsys, create_torrent, human_readable, clear_ansi, regex): 168 | date = datetime(2000, 5, 10, 0, 30, 45) 169 | with create_torrent(creation_date=date) as torrent_file: 170 | with human_readable(True): 171 | run(['-i', torrent_file]) 172 | cap = capsys.readouterr() 173 | assert clear_ansi(cap.out) == regex(r'^\s*Created 2000-05-10 00:30:45$', flags=re.MULTILINE) 174 | assert cap.err == '' 175 | 176 | with human_readable(False): 177 | run(['-i', torrent_file]) 178 | cap = capsys.readouterr() 179 | exp_timestamp = int(date.timestamp()) 180 | assert cap.out == regex(rf'^Created\t{exp_timestamp}$', flags=re.MULTILINE) 181 | assert cap.err == '' 182 | 183 | 184 | def test_created_by(capsys, create_torrent, human_readable, clear_ansi, regex): 185 | with create_torrent(created_by='foo') as torrent_file: 186 | with human_readable(True): 187 | run(['-i', torrent_file]) 188 | cap = capsys.readouterr() 189 | assert clear_ansi(cap.out) == regex(r'^\s*Created By foo$', flags=re.MULTILINE) 190 | assert cap.err == '' 191 | 192 | with human_readable(False): 193 | run(['-i', torrent_file]) 194 | cap = capsys.readouterr() 195 | assert cap.out == regex(r'^Created By\tfoo$', flags=re.MULTILINE) 196 | assert cap.err == '' 197 | 198 | 199 | def test_private(capsys, create_torrent, human_readable, clear_ansi, regex): 200 | with create_torrent(private=True) as torrent_file: 201 | with human_readable(True): 202 | run(['-i', torrent_file]) 203 | cap = capsys.readouterr() 204 | assert clear_ansi(cap.out) == regex(r'^\s*Private yes$', flags=re.MULTILINE) 205 | assert cap.err == '' 206 | 207 | with human_readable(False): 208 | run(['-i', torrent_file]) 209 | cap = capsys.readouterr() 210 | assert cap.out == regex(r'^Private\tyes$', flags=re.MULTILINE) 211 | assert cap.err == '' 212 | 213 | with create_torrent(private=False) as torrent_file: 214 | with human_readable(True): 215 | run(['-i', torrent_file]) 216 | cap = capsys.readouterr() 217 | assert clear_ansi(cap.out) == regex(r'^\s*Private no', flags=re.MULTILINE) 218 | assert cap.err == '' 219 | 220 | with human_readable(False): 221 | run(['-i', torrent_file]) 222 | cap = capsys.readouterr() 223 | assert cap.out == regex(r'^Private\tno', flags=re.MULTILINE) 224 | assert cap.err == '' 225 | 226 | 227 | def test_trackers___single_tracker_per_tier(capsys, create_torrent, human_readable, clear_ansi, regex): 228 | trackers = ['http://tracker1.1', 'http://tracker2.1'] 229 | with create_torrent(trackers=trackers) as torrent_file: 230 | with human_readable(True): 231 | run(['-i', torrent_file]) 232 | cap = capsys.readouterr() 233 | assert clear_ansi(cap.out) == regex(rf'^(\s*)Trackers Tier 1: {trackers[0]}\n' 234 | rf'\1 Tier 2: {trackers[1]}$', flags=re.MULTILINE) 235 | assert cap.err == '' 236 | 237 | with human_readable(False): 238 | run(['-i', torrent_file]) 239 | cap = capsys.readouterr() 240 | exp_trackers = '\t'.join(trackers) 241 | assert cap.out == regex(rf'^Trackers\t{exp_trackers}$', flags=re.MULTILINE) 242 | assert cap.err == '' 243 | 244 | def test_trackers___multiple_trackers_per_tier(capsys, create_torrent, human_readable, clear_ansi, regex): 245 | trackers = ['http://tracker1.1', 246 | ['http://tracker2.1', 'http://tracker2.2'], 247 | ['http://tracker3.1']] 248 | with create_torrent(trackers=trackers) as torrent_file: 249 | with human_readable(True): 250 | run(['-i', torrent_file]) 251 | cap = capsys.readouterr() 252 | assert clear_ansi(cap.out) == regex(r'^(\s*)Trackers Tier 1: http://tracker1.1\n' 253 | r'\1 Tier 2: http://tracker2.1\n' 254 | r'\1 http://tracker2.2\n' 255 | r'\1 Tier 3: http://tracker3.1$', flags=re.MULTILINE) 256 | assert cap.err == '' 257 | 258 | with human_readable(False): 259 | run(['-i', torrent_file]) 260 | cap = capsys.readouterr() 261 | exp_trackers = '\t'.join(('http://tracker1.1', 'http://tracker2.1', 262 | 'http://tracker2.2', 'http://tracker3.1')) 263 | assert cap.out == regex(rf'^Trackers\t{exp_trackers}$', flags=re.MULTILINE) 264 | assert cap.err == '' 265 | 266 | 267 | def test_webseeds(capsys, create_torrent, human_readable, clear_ansi, regex): 268 | webseeds = ['http://webseed1', 'http://webseed2'] 269 | with create_torrent(webseeds=webseeds) as torrent_file: 270 | with human_readable(True): 271 | run(['-i', torrent_file]) 272 | cap = capsys.readouterr() 273 | assert clear_ansi(cap.out) == regex((rf'^(\s*)Webseeds {webseeds[0]}\n' 274 | rf'\1 {webseeds[1]}$'), flags=re.MULTILINE) 275 | assert cap.err == '' 276 | 277 | with human_readable(False): 278 | run(['-i', torrent_file]) 279 | cap = capsys.readouterr() 280 | exp_webseeds = '\t'.join(webseeds) 281 | assert cap.out == regex(rf'^Webseeds\t{exp_webseeds}$', flags=re.MULTILINE) 282 | assert cap.err == '' 283 | 284 | 285 | def test_httpseeds(capsys, create_torrent, human_readable, clear_ansi, regex): 286 | httpseeds = ['http://httpseed1', 'http://httpseed2'] 287 | with create_torrent(httpseeds=httpseeds) as torrent_file: 288 | with human_readable(True): 289 | run(['-i', torrent_file]) 290 | cap = capsys.readouterr() 291 | assert clear_ansi(cap.out) == regex((rf'^(\s*)HTTP Seeds {httpseeds[0]}\n' 292 | rf'\1 {httpseeds[1]}$'), flags=re.MULTILINE) 293 | assert cap.err == '' 294 | 295 | with human_readable(False): 296 | run(['-i', torrent_file]) 297 | cap = capsys.readouterr() 298 | exp_httpseeds = '\t'.join(httpseeds) 299 | assert cap.out == regex(rf'^HTTP Seeds\t{exp_httpseeds}$', flags=re.MULTILINE) 300 | assert cap.err == '' 301 | 302 | 303 | def test_file_tree_and_file_count(capsys, create_torrent, human_readable, tmp_path, clear_ansi, regex): 304 | root = tmp_path / 'root' 305 | (root / 'subdir1' / 'subdir1.0' / 'subdir1.0.0').mkdir(parents=True) 306 | (root / 'subdir2').mkdir(parents=True) 307 | (root / 'subdir1' / 'file1').write_text('data') 308 | (root / 'subdir1' / 'subdir1.0' / 'file2').write_text('data') 309 | (root / 'subdir1' / 'subdir1.0' / 'subdir1.0.0' / 'file3').write_text('data') 310 | (root / 'subdir2' / 'file4').write_text('data') 311 | 312 | with create_torrent(path=str(root)) as torrent_file: 313 | with human_readable(True): 314 | run(['-i', torrent_file]) 315 | cap = capsys.readouterr() 316 | assert clear_ansi(cap.out) == regex(r'^\s*File Count 4$', flags=re.MULTILINE) 317 | assert clear_ansi(cap.out) == regex(r'^(\s*) Files root\n' 318 | r'\1 ├─subdir1\n' 319 | r'\1 │ ├─file1 \[4 B\]\n' 320 | r'\1 │ └─subdir1.0\n' 321 | r'\1 │ ├─file2 \[4 B\]\n' 322 | r'\1 │ └─subdir1.0.0\n' 323 | r'\1 │ └─file3 \[4 B\]\n' 324 | r'\1 └─subdir2\n' 325 | r'\1 └─file4 \[4 B\]$', flags=re.MULTILINE) 326 | assert cap.err == '' 327 | 328 | with human_readable(False): 329 | run(['-i', torrent_file]) 330 | cap = capsys.readouterr() 331 | assert cap.out == regex(r'^File Count\t4$', flags=re.MULTILINE) 332 | exp_files = '\t'.join(('root/subdir1/file1', 333 | 'root/subdir1/subdir1.0/file2', 334 | 'root/subdir1/subdir1.0/subdir1.0.0/file3', 335 | 'root/subdir2/file4')) 336 | assert cap.out == regex(rf'^Files\t{exp_files}$', flags=re.MULTILINE) 337 | assert cap.err == '' 338 | 339 | 340 | def test_reading_magnet(capsys, human_readable, clear_ansi, regex): 341 | magnet = ('magnet:?xt=urn:btih:e167b1fbb42ea72f051f4f50432703308efb8fd1&dn=My+Torrent&xl=142631' 342 | '&tr=https%3A%2F%2Flocalhost%3A123%2Fannounce' 343 | '&xs=https%3A%2F%2Flocalhost%3A123%2FMy+Torrent.torrent' 344 | '&as=https%3A%2F%2Flocalhost%3A456%2FMy+Torrent.torrent' 345 | '&ws=https%3A%2F%2Flocalhost%2FMy+Torrent') 346 | 347 | with human_readable(True): 348 | run(['-i', magnet]) 349 | cap = capsys.readouterr() 350 | assert clear_ansi(cap.out) == regex((r'^\s*Name My Torrent\n' 351 | r'\s*Size \d+\.\d+ [TMK]iB\n' 352 | r'\s*Tracker https://localhost:123/announce\n' 353 | r'\s*Webseed https://localhost/My\+Torrent\n' 354 | r'\s*File Count \d+\n' 355 | r'\s*Files My Torrent \[\d+\.\d+ [TMK]iB\]\n' 356 | r'\s*Magnet magnet:\?xt=urn:btih:[0-9a-z]{40}.*?\n$')) 357 | assert cap.err == regex((rf'^{_vars.__appname__}: https://localhost:123/My\+Torrent.torrent: [\w\s]+\n' 358 | rf'{_vars.__appname__}: https://localhost:456/My\+Torrent.torrent: [\w\s]+\n' 359 | rf'{_vars.__appname__}: https://localhost/My\+Torrent.torrent: [\w\s]+\n' 360 | rf'{_vars.__appname__}: https://localhost:123/file\?info_hash=' 361 | r'%E1g%B1%FB%B4\.%A7/%05%1FOPC%27%030%8E%FB%8F%D1: [\w\s]+\n$')) 362 | 363 | with human_readable(False): 364 | run(['-i', magnet]) 365 | cap = capsys.readouterr() 366 | assert cap.out == regex((r'^Name\tMy Torrent\n' 367 | r'Size\t\d+\n' 368 | r'Tracker\thttps://localhost:123/announce\n' 369 | r'Webseed\thttps://localhost/My\+Torrent\n' 370 | r'File Count\t\d+\n' 371 | r'Files\tMy Torrent\n' 372 | r'Magnet\tmagnet:\?xt=urn:btih:[0-9a-z]{40}.*?\n$')) 373 | assert cap.err == regex((rf'^{_vars.__appname__}: https://localhost:123/My\+Torrent.torrent: [\w\s]+\n' 374 | rf'{_vars.__appname__}: https://localhost:456/My\+Torrent.torrent: [\w\s]+\n' 375 | rf'{_vars.__appname__}: https://localhost/My\+Torrent.torrent: [\w\s]+\n' 376 | rf'{_vars.__appname__}: https://localhost:123/file\?info_hash=' 377 | r'%E1g%B1%FB%B4\.%A7/%05%1FOPC%27%030%8E%FB%8F%D1: [\w\s]+\n$')) 378 | 379 | def test_reading_invalid_magnet(capsys): 380 | magnet = 'magnet:?xt=urn:btih:e167b1fbb42ea72f051f4f50432703308efb8fd1&xl=not_an_int' 381 | with patch('sys.exit') as mock_exit: 382 | run(['-i', magnet]) 383 | mock_exit.assert_called_once_with(err.Code.READ) 384 | cap = capsys.readouterr() 385 | assert cap.err == f'{_vars.__appname__}: not_an_int: Invalid exact length ("xl")\n' 386 | assert cap.out == '' 387 | -------------------------------------------------------------------------------- /tests/test_json.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | 7 | from torfcli import _errors as err 8 | from torfcli import _vars, run 9 | 10 | 11 | def test_json_contains_standard_fields(capsys, mock_content): 12 | now = time.time() 13 | run([str(mock_content), '--json']) 14 | cap = capsys.readouterr() 15 | j = json.loads(cap.out) 16 | assert isinstance(j['Name'], str) 17 | assert isinstance(j['Size'], int) 18 | assert j['Created'] == pytest.approx(now - 1, abs=2) 19 | assert j['Created By'] == f'{_vars.__appname__} {_vars.__version__}' 20 | assert isinstance(j['Piece Size'], int) 21 | assert isinstance(j['Piece Count'], int) 22 | assert isinstance(j['File Count'], int) 23 | assert isinstance(j['Files'], list) 24 | for f in j['Files']: 25 | assert isinstance(f, dict) 26 | assert tuple(f.keys()) == ('Path', 'Size') 27 | assert isinstance(f['Path'], str) 28 | assert isinstance(f['Size'], int) 29 | assert isinstance(j['Info Hash'], str) 30 | assert len(j['Info Hash']) == 40 31 | assert j['Magnet'].startswith('magnet:?xt=urn:btih:') 32 | assert isinstance(j['Torrent'], str) 33 | 34 | def test_json_does_not_contain_progress(capsys, mock_content): 35 | run([str(mock_content), '--json']) 36 | cap = capsys.readouterr() 37 | assert cap.err == '' 38 | j = json.loads(cap.out) 39 | assert 'Progress' not in j 40 | 41 | def test_json_contains_cli_errors(capsys): 42 | with patch('sys.exit') as mock_exit: 43 | run(['--foo', '--json']) 44 | mock_exit.assert_called_once_with(err.Code.CLI) 45 | cap = capsys.readouterr() 46 | assert cap.err == '' 47 | j = json.loads(cap.out) 48 | assert j['Error'] == ['Unrecognized arguments: --foo'] 49 | 50 | def test_json_contains_config_errors(capsys, cfgfile): 51 | cfgfile.write_text(''' 52 | foo 53 | ''') 54 | with patch('sys.exit') as mock_exit: 55 | run(['--json']) 56 | mock_exit.assert_called_once_with(err.Code.CONFIG) 57 | cap = capsys.readouterr() 58 | assert cap.err == '' 59 | j = json.loads(cap.out) 60 | assert j['Error'] == [f'{cfgfile}: Unrecognized arguments: --foo'] 61 | 62 | def test_json_contains_regular_errors(capsys): 63 | with patch('sys.exit') as mock_exit: 64 | run(['-i', 'path/to/nonexisting.torrent', '--json']) 65 | mock_exit.assert_called_once_with(err.Code.READ) 66 | cap = capsys.readouterr() 67 | assert cap.err == '' 68 | j = json.loads(cap.out) 69 | assert j['Error'] == ['path/to/nonexisting.torrent: No such file or directory'] 70 | 71 | def test_json_contains_sigint(capsys, mock_create_mode, mock_content): 72 | mock_create_mode.side_effect = KeyboardInterrupt() 73 | with patch('sys.exit') as mock_exit: 74 | run([str(mock_content), '--json']) 75 | mock_exit.assert_called_once_with(err.Code.ABORTED) 76 | cap = capsys.readouterr() 77 | assert cap.err == '' 78 | j = json.loads(cap.out) 79 | assert j['Error'] == ['Aborted'] 80 | 81 | def test_json_contains_verification_errors(capsys, tmp_path, create_torrent): 82 | content_path = tmp_path / 'file.jpg' 83 | content_path.write_text('some data') 84 | 85 | with create_torrent(path=content_path) as torrent_file: 86 | content_path.write_text('some data!!!') 87 | with patch('sys.exit') as mock_exit: 88 | run([str(content_path), '-i', torrent_file, '--json']) 89 | mock_exit.assert_called_once_with(err.Code.VERIFY) 90 | cap = capsys.readouterr() 91 | assert cap.err == '' 92 | j = json.loads(cap.out) 93 | assert j['Error'] == [f'{content_path}: Too big: 12 instead of 9 bytes', 94 | f'{content_path} does not satisfy {torrent_file}'] 95 | 96 | def test_json_with_magnet_uri(capsys, regex): 97 | # Notice the double "&" in the URI, which is syntactically correct but 98 | # should be fixed in the output. 99 | magnet = ('magnet:?xt=urn:btih:e167b1fbb42ea72f051f4f50432703308efb8fd1&dn=My+Torrent&xl=142631' 100 | '&tr=https%3A%2F%2Flocalhost%3A123%2Fannounce&&tr=https%3A%2F%2Flocalhost%3A456%2Fannounce') 101 | run(['-i', magnet, '--json']) 102 | cap = capsys.readouterr() 103 | assert cap.err == '' 104 | assert json.loads(cap.out) == { 105 | "Error": ['https://localhost:123/file?info_hash=%E1g%B1%FB%B4.%A7/%05%1FOPC%27%030%8E%FB%8F%D1: Connection refused', 106 | 'https://localhost:456/file?info_hash=%E1g%B1%FB%B4.%A7/%05%1FOPC%27%030%8E%FB%8F%D1: Connection refused'], 107 | 'Name': 'My Torrent', 108 | 'Size': 142631, 109 | 'Trackers': ['https://localhost:123/announce', 'https://localhost:456/announce'], 110 | 'File Count': 1, 111 | 'Files': [ 112 | {'Path': 'My Torrent', 'Size': 142631}, 113 | ], 114 | 'Magnet': magnet.replace('&&', '&'), 115 | } 116 | -------------------------------------------------------------------------------- /tests/test_metainfo.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | import torf 7 | 8 | from torfcli import _errors as err 9 | from torfcli import _vars, run 10 | 11 | 12 | @pytest.fixture 13 | def nonstandard_torrent(tmp_path): 14 | (tmp_path / 'content').mkdir() 15 | (tmp_path / 'content' / 'file1').write_text('foo') 16 | (tmp_path / 'content' / 'file2').write_text('bar') 17 | (tmp_path / 'content' / 'dir').mkdir() 18 | (tmp_path / 'content' / 'dir' / 'file3').write_text('baz') 19 | 20 | t = torf.Torrent(path=tmp_path / 'content', private=True, 21 | trackers=('https://foo.example.org',)) 22 | t.metainfo['foo'] = 'bar' 23 | t.metainfo['info']['baz'] = (1, 2, 3) 24 | t.metainfo['info']['files'][0]['sneaky'] = 'pete' 25 | t.generate() 26 | t.write(tmp_path / 'nonstandard.torrent') 27 | return str(tmp_path / 'nonstandard.torrent') 28 | 29 | def test_metainfo_with_verbosity_level_zero(capsys, nonstandard_torrent): 30 | run(['-i', nonstandard_torrent, '--metainfo']) 31 | cap = capsys.readouterr() 32 | assert cap.err == '' 33 | assert json.loads(cap.out) == {'created by': f'torf {torf.__version__}', 34 | 'announce': 'https://foo.example.org', 35 | 'info': {'name': 'content', 36 | 'piece length': 16384, 37 | 'private': 1, 38 | 'files': [{'length': 3, 'path': ['dir', 'file3']}, 39 | {'length': 3, 'path': ['file1']}, 40 | {'length': 3, 'path': ['file2']}]}} 41 | 42 | def test_metainfo_with_verbosity_level_one(capsys, nonstandard_torrent): 43 | run(['-i', nonstandard_torrent, '--metainfo', '--verbose']) 44 | cap = capsys.readouterr() 45 | assert cap.err == '' 46 | assert json.loads(cap.out) == {'created by': f'torf {torf.__version__}', 47 | 'announce': 'https://foo.example.org', 48 | 'foo': 'bar', 49 | 'info': {'baz': [1, 2, 3], 50 | 'private': 1, 51 | 'files': [{'length': 3, 'path': ['dir', 'file3'], 'sneaky': 'pete'}, 52 | {'length': 3, 'path': ['file1']}, 53 | {'length': 3, 'path': ['file2']}], 54 | 'name': 'content', 55 | 'piece length': 16384}} 56 | 57 | def test_metainfo_with_verbosity_level_two(capsys, nonstandard_torrent): 58 | run(['-i', nonstandard_torrent, '--metainfo', '--verbose', '--verbose']) 59 | cap = capsys.readouterr() 60 | assert cap.err == '' 61 | assert json.loads(cap.out) == {'created by': f'torf {torf.__version__}', 62 | 'announce': 'https://foo.example.org', 63 | 'foo': 'bar', 64 | 'info': {'baz': [1, 2, 3], 65 | 'private': 1, 66 | 'files': [{'length': 3, 'path': ['dir', 'file3'], 'sneaky': 'pete'}, 67 | {'length': 3, 'path': ['file1']}, 68 | {'length': 3, 'path': ['file2']}], 69 | 'name': 'content', 70 | 'piece length': 16384, 71 | 'pieces': 'YscFPSkTuTXkBSgIyyaqj/HVRXU='}} 72 | 73 | def test_metainfo_uses_one_and_zero_for_boolean_values(capsys, create_torrent): 74 | with create_torrent(private=True) as torrent_file: 75 | run(['-i', torrent_file, '--metainfo']) 76 | cap = capsys.readouterr() 77 | assert cap.err == '' 78 | assert json.loads(cap.out)['info']['private'] == 1 79 | 80 | def test_metainfo_with_disabled_validation(capsys, tmp_path): 81 | with open(tmp_path / 'nonstandard.torrent', 'wb') as f: 82 | f.write(b'd1:2i3e4:thisl2:is3:note5:validd2:is2:ok8:metainfol3:but4:thateee') 83 | torf.Torrent.read(tmp_path / 'nonstandard.torrent', validate=False) 84 | 85 | with patch('sys.exit') as mock_exit: 86 | run(['-i', str(tmp_path / 'nonstandard.torrent'), '--metainfo']) 87 | mock_exit.assert_called_once_with(err.Code.READ) 88 | cap = capsys.readouterr() 89 | assert cap.err == f"{_vars.__appname__}: Invalid metainfo: Missing 'info'\n" 90 | assert json.loads(cap.out) == {} 91 | 92 | run(['-i', str(tmp_path / 'nonstandard.torrent'), '--metainfo', '--novalidate']) 93 | cap = capsys.readouterr() 94 | assert cap.err == f"{_vars.__appname__}: WARNING: Invalid metainfo: Missing 'name' in ['info']\n" 95 | assert json.loads(cap.out) == {} 96 | 97 | run(['-i', str(tmp_path / 'nonstandard.torrent'), '--metainfo', '--novalidate', '--verbose']) 98 | cap = capsys.readouterr() 99 | assert cap.err == f"{_vars.__appname__}: WARNING: Invalid metainfo: Missing 'name' in ['info']\n" 100 | assert json.loads(cap.out) == {"2": 3, 101 | "this": ["is", "not"], 102 | "valid": {"is": "ok", 103 | "metainfo": ["but", "that"]}} 104 | 105 | def test_metainfo_with_unreadable_torrent(capsys): 106 | with patch('sys.exit') as mock_exit: 107 | run(['-i', 'no/such/path.torrent', '--metainfo']) 108 | mock_exit.assert_called_once_with(err.Code.READ) 109 | cap = capsys.readouterr() 110 | assert cap.err == f'{_vars.__appname__}: no/such/path.torrent: No such file or directory\n' 111 | assert json.loads(cap.out) == {} 112 | 113 | def test_metainfo_when_creating_torrent(capsys, mock_content): 114 | run([str(mock_content), '--metainfo', '-vv']) 115 | cap = capsys.readouterr() 116 | assert cap.err == '' 117 | j = json.loads(cap.out) 118 | assert 'info' in j 119 | assert 'name' in j['info'] 120 | assert 'pieces' in j['info'] 121 | 122 | def test_metainfo_when_editing_torrent(capsys, create_torrent): 123 | date = '1999-07-23 14:00' 124 | with create_torrent(trackers=['http://foo', 'http://bar']) as orig_torrent: 125 | run(['-i', str(orig_torrent), 126 | '--comment', 'This comment was not here before.', 127 | '--date', date, 128 | '--nowebseed', '--webseed', 'https://new.webseeds', 129 | '-o', 'new.torrent', '--metainfo', '-v']) 130 | cap = capsys.readouterr() 131 | assert cap.err == '' 132 | j = json.loads(cap.out) 133 | assert 'info' in j 134 | assert 'name' in j['info'] 135 | assert 'pieces' not in j['info'] 136 | assert j['comment'] == 'This comment was not here before.' 137 | assert j['creation date'] == datetime.datetime.strptime(date, '%Y-%m-%d %H:%M').timestamp() 138 | assert j['url-list'] == ['https://new.webseeds'] 139 | assert j['announce-list'] == [['http://foo'], ['http://bar']] 140 | assert j['announce'] == 'http://foo' 141 | 142 | 143 | def test_metainfo_when_verifying_torrent(capsys, create_torrent, mock_content, tmp_path): 144 | with create_torrent(path=mock_content) as torrent_file: 145 | run(['-i', str(torrent_file), str(mock_content), '--metainfo']) 146 | cap = capsys.readouterr() 147 | assert cap.err == '' 148 | j = json.loads(cap.out) 149 | assert 'info' in j 150 | assert 'name' in j['info'] 151 | assert 'pieces' not in j['info'] 152 | 153 | with create_torrent(path=mock_content) as torrent_file: 154 | wrong_content = (tmp_path / 'wrong_content') 155 | wrong_content.write_text('foo') 156 | with patch('sys.exit') as mock_exit: 157 | run(['-i', str(torrent_file), str(wrong_content), '--metainfo']) 158 | mock_exit.assert_called_once_with(err.Code.VERIFY) 159 | cap = capsys.readouterr() 160 | assert cap.err == f'{_vars.__appname__}: {wrong_content} does not satisfy {torrent_file}\n' 161 | assert json.loads(cap.out) == {} 162 | 163 | def test_metainfo_with_magnet_uri(capsys, regex): 164 | magnet = ('magnet:?xt=urn:btih:e167b1fbb42ea72f051f4f50432703308efb8fd1&dn=My+Torrent&xl=142631' 165 | '&tr=https%3A%2F%2Flocalhost%3A123%2Fannounce&&tr=https%3A%2F%2Flocalhost%3A456%2Fannounce') 166 | run(['-i', magnet, '--metainfo']) 167 | cap = capsys.readouterr() 168 | assert cap.err == regex(rf'^{_vars.__appname__}: https://localhost:123/file\?info_hash=' 169 | r'%E1g%B1%FB%B4\.%A7/%05%1FOPC%27%030%8E%FB%8F%D1: [\w\s]+\n' 170 | rf'{_vars.__appname__}: https://localhost:456/file\?info_hash=' 171 | r'%E1g%B1%FB%B4\.%A7/%05%1FOPC%27%030%8E%FB%8F%D1: [\w\s]+\n$') 172 | j = json.loads(cap.out) 173 | assert j == {'announce': 'https://localhost:123/announce', 174 | 'announce-list': [['https://localhost:123/announce'], ['https://localhost:456/announce']], 175 | 'info': {'name': 'My Torrent', 'length': 142631}} 176 | -------------------------------------------------------------------------------- /tests/test_profiles.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import textwrap 3 | from unittest.mock import patch 4 | 5 | from torfcli import _errors as err 6 | from torfcli import _vars, run 7 | 8 | 9 | def test_unknown_profile(capsys, cfgfile, mock_content, mock_create_mode): 10 | cfgfile.write_text(textwrap.dedent(''' 11 | [foo] 12 | comment = Foo! 13 | ''')) 14 | with patch('sys.exit') as mock_exit: 15 | run([str(mock_content), '--profile', 'bar']) 16 | mock_exit.assert_called_once_with(err.Code.CONFIG) 17 | cap = capsys.readouterr() 18 | assert cap.err == f'{_vars.__appname__}: {cfgfile}: No such profile: bar\n' 19 | assert mock_create_mode.call_args is None 20 | 21 | 22 | def test_profile_option(cfgfile, mock_content, mock_create_mode): 23 | cfgfile.write_text(textwrap.dedent(''' 24 | xseed 25 | date = 2000-01-02 26 | [foo] 27 | comment = Foo! 28 | ''')) 29 | run([str(mock_content)]) 30 | cfg = mock_create_mode.call_args[0][1] 31 | assert cfg['xseed'] is True 32 | assert cfg['date'] == datetime.datetime(2000, 1, 2) 33 | assert cfg['comment'] is None 34 | 35 | run([str(mock_content), '--profile', 'foo']) 36 | cfg = mock_create_mode.call_args[0][1] 37 | assert cfg['xseed'] is True 38 | assert cfg['date'] == datetime.datetime(2000, 1, 2) 39 | assert cfg['comment'] == 'Foo!' 40 | 41 | 42 | def test_overloading_values(cfgfile, mock_content, mock_create_mode): 43 | cfgfile.write_text(textwrap.dedent(''' 44 | [foo] 45 | comment = Foo 46 | private 47 | 48 | [bar] 49 | comment = Bar 50 | yes 51 | 52 | [baz] 53 | comment = Baz 54 | xseed 55 | ''')) 56 | run([str(mock_content), '--profile', 'foo', '--profile', 'bar']) 57 | cfg = mock_create_mode.call_args[0][1] 58 | assert cfg['comment'] == 'Bar' 59 | assert cfg['private'] is True 60 | assert cfg['yes'] is True 61 | assert cfg['xseed'] is False 62 | 63 | run([str(mock_content), '--profile', 'bar', '--profile', 'foo']) 64 | cfg = mock_create_mode.call_args[0][1] 65 | assert cfg['comment'] == 'Foo' 66 | assert cfg['private'] is True 67 | assert cfg['yes'] is True 68 | assert cfg['xseed'] is False 69 | 70 | run([str(mock_content), '--profile', 'bar', '--profile', 'baz']) 71 | cfg = mock_create_mode.call_args[0][1] 72 | assert cfg['comment'] == 'Baz' 73 | assert cfg['private'] is None 74 | assert cfg['yes'] is True 75 | assert cfg['xseed'] is True 76 | 77 | 78 | def test_list_value(cfgfile, mock_content, mock_create_mode): 79 | cfgfile.write_text(textwrap.dedent(''' 80 | [foo] 81 | webseed = https://foo 82 | [bar] 83 | webseed = https://bar 84 | [baz] 85 | nowebseed 86 | webseed = https://baz 87 | ''')) 88 | run([str(mock_content), '--profile', 'foo', '--profile', 'bar']) 89 | cfg = mock_create_mode.call_args[0][1] 90 | assert cfg['webseed'] == ['https://foo', 'https://bar'] 91 | assert cfg['nowebseed'] is False 92 | 93 | run([str(mock_content), '--profile', 'bar', '--profile', 'foo']) 94 | cfg = mock_create_mode.call_args[0][1] 95 | assert cfg['webseed'] == ['https://bar', 'https://foo'] 96 | assert cfg['nowebseed'] is False 97 | 98 | run([str(mock_content), '--profile', 'bar', '--profile', 'baz']) 99 | cfg = mock_create_mode.call_args[0][1] 100 | assert cfg['webseed'] == ['https://bar', 'https://baz'] 101 | assert cfg['nowebseed'] is True 102 | 103 | run([str(mock_content), '--profile', 'bar', '--profile', 'baz', '--profile', 'foo']) 104 | cfg = mock_create_mode.call_args[0][1] 105 | assert cfg['webseed'] == ['https://bar', 'https://baz', 'https://foo'] 106 | assert cfg['nowebseed'] is True 107 | 108 | 109 | def test_illegal_configfile_arguments(capsys, cfgfile, mock_content, mock_create_mode): 110 | for arg in ('config', 'profile'): 111 | cfgfile.write_text(textwrap.dedent(f''' 112 | [foo] 113 | {arg} = foo 114 | ''')) 115 | 116 | with patch('sys.exit') as mock_exit: 117 | run(['--config', str(cfgfile), str(mock_content)]) 118 | mock_exit.assert_called_once_with(err.Code.CONFIG) 119 | cap = capsys.readouterr() 120 | assert cap.err == f'{_vars.__appname__}: {cfgfile}: Not allowed in config file: {arg}\n' 121 | assert mock_create_mode.call_args is None 122 | 123 | 124 | for arg in ('noconfig', 'help', 'version'): 125 | cfgfile.write_text(textwrap.dedent(f''' 126 | [foo] 127 | {arg} 128 | ''')) 129 | with patch('sys.exit') as mock_exit: 130 | run(['--config', str(cfgfile), str(mock_content)]) 131 | mock_exit.assert_called_once_with(err.Code.CONFIG) 132 | cap = capsys.readouterr() 133 | assert cap.err == f'{_vars.__appname__}: {cfgfile}: Not allowed in config file: {arg}\n' 134 | assert mock_create_mode.call_args is None 135 | -------------------------------------------------------------------------------- /tests/test_progress.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | from torfcli import _errors as err 7 | from torfcli import _vars, run 8 | 9 | 10 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 11 | def test_creating_prints_performance_summary_on_success(tmp_path, human_readable, hr_enabled, capsys, clear_ansi, regex): 12 | content_path = tmp_path / 'foo' 13 | content_path.write_text('bar') 14 | 15 | with human_readable(hr_enabled): 16 | run([str(content_path)]) 17 | 18 | cap = capsys.readouterr() 19 | if hr_enabled: 20 | pattern = (r'\s*Progress 100.00 % \| \d+:\d{2}:\d{2} total \| \s*\d+\.\d{2} [KMGT]iB/s\n' 21 | r'\s*Info Hash [0-9a-f]{40}\n' 22 | r'\s*Magnet magnet:\?xt=urn:btih:[0-9a-f]{40}&dn=foo&xl=3\n' 23 | r'\s*Torrent foo.torrent\n$') 24 | assert clear_ansi(cap.out) == regex(pattern), clear_ansi(cap.out) 25 | else: 26 | pattern = (r'\nProgress\t100\.000\t\d+\t\d+\t\d+\t\d+\t\d+\t' + str(content_path) + '\n' 27 | r'Info Hash\t[0-9a-f]{40}\n' 28 | r'Magnet\tmagnet:\?xt=urn:btih:[0-9a-f]{40}&dn=foo&xl=3\n' 29 | r'Torrent\tfoo.torrent\n$') 30 | assert cap.out == regex(pattern), cap.out 31 | 32 | 33 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 34 | def test_creating_keeps_progress_when_aborted(tmp_path, human_readable, hr_enabled, capsys, clear_ansi, monkeypatch, regex): 35 | content_path = tmp_path / 'foo' 36 | content_path.write_bytes(os.urandom(int(1e6))) 37 | 38 | import torfcli 39 | if hr_enabled: 40 | status_reporter_cls = torfcli._ui._HumanStatusReporter 41 | else: 42 | status_reporter_cls = torfcli._ui._MachineStatusReporter 43 | 44 | class MockStatusReporter(status_reporter_cls): 45 | def generate_callback(self, torrent, filepath, pieces_done, pieces_total): 46 | if pieces_done / pieces_total >= 0.5: 47 | raise KeyboardInterrupt() 48 | else: 49 | super().generate_callback(torrent, filepath, pieces_done, pieces_total) 50 | 51 | monkeypatch.setattr(torfcli._ui, status_reporter_cls.__name__, MockStatusReporter) 52 | monkeypatch.setattr(torfcli._main, 'PROGRESS_INTERVAL', 0) 53 | 54 | with human_readable(hr_enabled): 55 | with patch('sys.exit') as mock_exit: 56 | run([str(content_path)]) 57 | mock_exit.assert_called_once_with(err.Code.ABORTED) 58 | cap = capsys.readouterr() 59 | assert cap.err == f'{_vars.__appname__}: Aborted\n' 60 | 61 | if hr_enabled: 62 | pattern = (r'\s*Progress \d+:\d{2}:\d{2} elapsed \| \d+:\d{2}:\d{2} left \| ' 63 | r'\d+:\d{2}:\d{2} total \| ETA: \d{2}:\d{2}:\d{2}' 64 | r'\d{1,2}\.\d{2} % ▕foo\s+▏ \s*\d+\.\d{2} [KMGT]iB/s\n\n$') 65 | assert clear_ansi(cap.out) == regex(pattern), clear_ansi(cap.out) 66 | else: 67 | pattern = (r'\nProgress\t\d+\.\d+\t\d+\t\d+\t\d+\t\d+\t\d+\t' + str(content_path) + '\n$') 68 | assert cap.out == regex(pattern), cap.out 69 | 70 | 71 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 72 | def test_verifying_prints_performance_summary_on_success(tmp_path, human_readable, hr_enabled, capsys, clear_ansi, regex): 73 | content_path = tmp_path / 'foo' 74 | content_path.write_text('bar') 75 | run([str(content_path)]) 76 | 77 | with human_readable(hr_enabled): 78 | run([str(content_path), '-i', 'foo.torrent']) 79 | 80 | cap = capsys.readouterr() 81 | if hr_enabled: 82 | pattern = r'\s*Progress 100.00 % \| \d+:\d{2}:\d{2} total \| \s*\d+\.\d{2} [KMGT]iB/s\n$' 83 | assert clear_ansi(cap.out) == regex(pattern) 84 | else: 85 | pattern = rf'Progress\t100.000\t\d+\t\d+\t\d+\t\d+\t\d+\t{content_path}\n$' 86 | assert clear_ansi(cap.out) == regex(pattern) 87 | 88 | 89 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 90 | def test_verifying_keeps_progress_when_aborted(tmp_path, human_readable, hr_enabled, capsys, clear_ansi, monkeypatch, regex): 91 | content_path = tmp_path / 'foo' 92 | content_path.write_bytes(os.urandom(int(1e6))) 93 | run([str(content_path)]) 94 | 95 | import torfcli 96 | if hr_enabled: 97 | status_reporter_cls = torfcli._ui._HumanStatusReporter 98 | else: 99 | status_reporter_cls = torfcli._ui._MachineStatusReporter 100 | 101 | class MockStatusReporter(status_reporter_cls): 102 | def verify_callback(self, torrent, filepath, pieces_done, pieces_total, 103 | piece_index, piece_hash, exception): 104 | if pieces_done / pieces_total >= 0.5: 105 | raise KeyboardInterrupt() 106 | else: 107 | super().verify_callback(torrent, filepath, pieces_done, pieces_total, 108 | piece_index, piece_hash, exception) 109 | 110 | monkeypatch.setattr(torfcli._ui, status_reporter_cls.__name__, MockStatusReporter) 111 | monkeypatch.setattr(torfcli._main, 'PROGRESS_INTERVAL', 0) 112 | 113 | with human_readable(hr_enabled): 114 | with patch('sys.exit') as mock_exit: 115 | run([str(content_path), '-i', 'foo.torrent']) 116 | mock_exit.assert_called_once_with(err.Code.ABORTED) 117 | cap = capsys.readouterr() 118 | assert cap.err == f'{_vars.__appname__}: Aborted\n' 119 | 120 | if hr_enabled: 121 | pattern = (r'\s*Progress \d+:\d{2}:\d{2} elapsed \| \d+:\d{2}:\d{2} left \| ' 122 | r'\d+:\d{2}:\d{2} total \| ETA: \d{2}:\d{2}:\d{2}' 123 | r'\d{1,2}\.\d{2} % ▕foo\s+▏ \s*\d+\.\d{2} [KMGT]iB/s\n\n$') 124 | assert clear_ansi(cap.out) == regex(pattern), clear_ansi(cap.out) 125 | else: 126 | pattern = (r'\nProgress\t\d+\.\d+\t\d+\t\d+\t\d+\t\d+\t\d+\t' + str(content_path) + '\n$') 127 | assert cap.out == regex(pattern), cap.out 128 | -------------------------------------------------------------------------------- /tests/test_reuse.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import pathlib 4 | import random 5 | import re 6 | 7 | import pytest 8 | import torf 9 | 10 | from torfcli import run 11 | 12 | 13 | @pytest.fixture 14 | def create_existing_torrent(tmp_path): 15 | torrents_path = tmp_path / 'torrents' 16 | torrents_path.mkdir(exist_ok=True) 17 | contents_path = tmp_path / 'contents' 18 | contents_path.mkdir(exist_ok=True) 19 | 20 | kwargs_base = { 21 | 'trackers': ['http://some.tracker'], 22 | 'webseeds': ['http://some.webseed'], 23 | 'private': bool(random.randint(0, 1)), 24 | 'source': 'ASDF', 25 | 'randomize_infohash': False, 26 | 'comment': 'Original Comment', 27 | 'created_by': 'Original Creator', 28 | 'creation_date': datetime.datetime.fromisoformat('1975-05-23'), 29 | } 30 | 31 | def create_torrent(*files, **kwargs): 32 | # Generate content 33 | if len(files) == 1: 34 | name = files[0][0] 35 | content = files[0][1] 36 | content_path = contents_path / name 37 | content_path.write_bytes(content) 38 | else: 39 | name = files[0][0].split('/')[0] 40 | content_path = contents_path / name 41 | content_path.mkdir() 42 | for file, data in files: 43 | assert file.startswith(name) 44 | (content_path / file).parent.mkdir(parents=True, exist_ok=True) 45 | (content_path / file).write_bytes(data) 46 | 47 | # Generate Torrent arguments 48 | kw = {**kwargs_base, **kwargs} 49 | for _ in range(random.randint(0, 5)): 50 | del kw[random.choice(tuple(kw))] 51 | 52 | t = torf.Torrent(path=content_path, **kw) 53 | t.generate() 54 | torrent_file = torrents_path / f'{name}.torrent' 55 | t.write(torrent_file) 56 | return torrent_file 57 | 58 | return create_torrent 59 | 60 | 61 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 62 | def test_finds_matching_torrent(hr_enabled, create_existing_torrent, regex, capsys, 63 | human_readable, clear_ansi, assert_no_ctrl): 64 | existing_torrents = [ 65 | create_existing_torrent(('foo1.jpg', b'just an image 1')), 66 | create_existing_torrent(('foo2.jpg', b'just an image 2')), 67 | create_existing_torrent(('foo3.jpg', b'just an image 3')), 68 | create_existing_torrent( 69 | ('bar/this.mp4', b'just a video'), 70 | ('bar/that.txt', b'just a text'), 71 | ('bar/baz/oh.pdf', b'a subdirectory!'), 72 | ), 73 | create_existing_torrent( 74 | ('baz/hello.mp4', b'just a video'), 75 | ('baz/yo.txt', b'just a text'), 76 | ), 77 | ] 78 | 79 | existing_torrents_path = pathlib.Path(os.path.commonpath(existing_torrents)) 80 | existing_contents_path = existing_torrents_path.parent / 'contents' 81 | 82 | # Copy matching torrent with different piece sizes 83 | for piece_size in (4, 2, 8): 84 | t = torf.Torrent.read(existing_torrents_path / 'foo2.jpg.torrent') 85 | piece_size_max = t.piece_size_max 86 | t.piece_size_max = 16 * 1048576 87 | t.piece_size = piece_size * 1048576 88 | t.piece_size_max = piece_size_max 89 | t.write(existing_torrents_path / f'foo2.{piece_size}.jpg.torrent') 90 | os.unlink(existing_torrents_path / 'foo2.jpg.torrent') 91 | 92 | content_path = existing_contents_path / 'foo2.jpg' 93 | exp_reused_torrent = existing_torrents_path / 'foo2.2.jpg.torrent' 94 | exp_torrent = content_path.name + '.torrent' 95 | 96 | with human_readable(hr_enabled): 97 | run([str(content_path), '--reuse', str(existing_torrents_path), '--max-piece-size', '2']) 98 | cap = capsys.readouterr() 99 | assert cap.err == '' 100 | if hr_enabled: 101 | assert cap.out == regex(rf'Verifying {exp_reused_torrent}', flags=re.MULTILINE) 102 | assert clear_ansi(cap.out) == regex(rf'^\s*Reused {exp_reused_torrent}$', flags=re.MULTILINE) 103 | assert clear_ansi(cap.out) != regex(r'^\s+Progress\s+', flags=re.MULTILINE) 104 | assert clear_ansi(cap.out) == regex(rf'^\s*Torrent {exp_torrent}$', flags=re.MULTILINE) 105 | else: 106 | assert_no_ctrl(cap.out) 107 | assert cap.out == regex(r'^Reuse\t' 108 | rf'{existing_torrents_path}{os.sep}(?:foo[\d\.]+\.jpg|bar|baz)\.torrent\t' 109 | r'\d+\.\d+\t\d+\t\d+$', 110 | flags=re.MULTILINE) 111 | assert cap.out == regex(rf'^Verifying\t{exp_reused_torrent}$', flags=re.MULTILINE) 112 | assert cap.out == regex(rf'^Reused\t{exp_reused_torrent}$', flags=re.MULTILINE) 113 | assert cap.out != regex(r'^Progress\t', flags=re.MULTILINE) 114 | assert cap.out == regex(rf'^Torrent\t{exp_torrent}$', flags=re.MULTILINE) 115 | 116 | 117 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 118 | def test_does_not_find_matching_torrent(hr_enabled, create_existing_torrent, regex, capsys, 119 | human_readable, clear_ansi, assert_no_ctrl, mock_content): 120 | existing_torrents = [ 121 | create_existing_torrent(('foo1.jpg', b'just an image 1')), 122 | create_existing_torrent(('foo2.jpg', b'just an image 2')), 123 | create_existing_torrent(('foo3.jpg', b'just an image 3')), 124 | create_existing_torrent( 125 | ('bar/this.mp4', b'just a video'), 126 | ('bar/that.txt', b'just a text'), 127 | ('bar/baz/oh.pdf', b'a subdirectory!'), 128 | ), 129 | create_existing_torrent( 130 | ('baz/hello.mp4', b'just a video'), 131 | ('baz/yo.txt', b'just a text'), 132 | ), 133 | ] 134 | existing_torrents_path = pathlib.Path(os.path.commonpath(existing_torrents)) 135 | exp_torrent = mock_content.name + '.torrent' 136 | 137 | with human_readable(hr_enabled): 138 | run([str(mock_content), '--reuse', str(existing_torrents_path)]) 139 | cap = capsys.readouterr() 140 | assert cap.err == '' 141 | if hr_enabled: 142 | assert cap.out == regex(r'\s+Reuse\s+', flags=re.MULTILINE) 143 | assert cap.out != regex(r'\s+Verifying\s+', flags=re.MULTILINE) 144 | assert clear_ansi(cap.out) != regex(r'^\s+Reused\s+', flags=re.MULTILINE) 145 | assert clear_ansi(cap.out) == regex(r'^\s+Progress 100\.00 % \| \d+:\d+:\d+ total \| \d+.\d+ \w+/s$', 146 | flags=re.MULTILINE) 147 | assert clear_ansi(cap.out) == regex(rf'^\s*Torrent {exp_torrent}$', flags=re.MULTILINE) 148 | else: 149 | assert_no_ctrl(cap.out) 150 | assert cap.out == regex(r'^Reuse\t', flags=re.MULTILINE) 151 | assert cap.out != regex(r'^Verifying\t$', flags=re.MULTILINE) 152 | assert cap.out != regex(r'^Reused\t$', flags=re.MULTILINE) 153 | assert cap.out == regex(rf'^Progress\t.*?/{mock_content.name}/', flags=re.MULTILINE) 154 | assert cap.out == regex(rf'^Torrent\t{exp_torrent}$', flags=re.MULTILINE) 155 | 156 | 157 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 158 | def test_noreuse_argument(hr_enabled, create_existing_torrent, regex, capsys, 159 | human_readable, clear_ansi, assert_no_ctrl): 160 | existing_torrents = [ 161 | create_existing_torrent(('foo1.jpg', b'just an image 1')), 162 | create_existing_torrent(('foo2.jpg', b'just an image 2')), 163 | create_existing_torrent(('foo3.jpg', b'just an image 3')), 164 | create_existing_torrent( 165 | ('bar/this.mp4', b'just a video'), 166 | ('bar/that.txt', b'just a text'), 167 | ('bar/baz/oh.pdf', b'a subdirectory!'), 168 | ), 169 | create_existing_torrent( 170 | ('baz/hello.mp4', b'just a video'), 171 | ('baz/yo.txt', b'just a text'), 172 | ), 173 | ] 174 | existing_torrents_path = pathlib.Path(os.path.commonpath(existing_torrents)) 175 | existing_contents_path = existing_torrents_path.parent / 'contents' 176 | content_path = existing_contents_path / 'foo2.jpg' 177 | exp_torrent = content_path.name + '.torrent' 178 | 179 | with human_readable(hr_enabled): 180 | run([str(content_path), '--reuse', str(existing_torrents_path), '--noreuse']) 181 | cap = capsys.readouterr() 182 | assert cap.err == '' 183 | if hr_enabled: 184 | assert cap.out != regex(r'Reuse', flags=re.MULTILINE) 185 | assert cap.out != regex(r'Verifying', flags=re.MULTILINE) 186 | assert clear_ansi(cap.out) != regex(r'^\s*Reused', flags=re.MULTILINE) 187 | assert clear_ansi(cap.out) == regex(r'^\s+Progress\s+', flags=re.MULTILINE) 188 | assert clear_ansi(cap.out) == regex(rf'^\s*Torrent {exp_torrent}$', flags=re.MULTILINE) 189 | else: 190 | assert_no_ctrl(cap.out) 191 | assert cap.out != regex(r'^Reuse\t', flags=re.MULTILINE) 192 | assert cap.out != regex(r'^Verifying\t', flags=re.MULTILINE) 193 | assert cap.out != regex(r'^Reused\t', flags=re.MULTILINE) 194 | assert cap.out == regex(r'^Progress\t', flags=re.MULTILINE) 195 | assert cap.out == regex(rf'^Torrent\t{exp_torrent}$', flags=re.MULTILINE) 196 | -------------------------------------------------------------------------------- /tests/test_stdin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | from unittest.mock import patch 5 | 6 | import torf 7 | 8 | from torfcli import _errors, _vars, run 9 | 10 | 11 | def test_reading_valid_torrent_data_from_stdin(capsys, monkeypatch, clear_ansi, create_torrent, regex): 12 | with create_torrent(name='Foo', comment='Bar.') as torrent_file: 13 | monkeypatch.setattr(sys, 'stdin', open(torrent_file, 'rb')) 14 | run(['-i', '-']) 15 | cap = capsys.readouterr() 16 | assert clear_ansi(cap.out) == regex(r'^Name\tFoo$', flags=re.MULTILINE) 17 | assert clear_ansi(cap.out) == regex(r'^Comment\tBar.$', flags=re.MULTILINE) 18 | assert cap.err == '' 19 | 20 | def test_reading_invalid_torrent_data_from_stdin(capsys, tmp_path, monkeypatch, clear_ansi, regex): 21 | torrent = torf.Torrent(name='Foo', comment='Bar.') 22 | r, w = os.pipe() 23 | monkeypatch.setattr(sys, 'stdin', os.fdopen(r)) 24 | os.fdopen(w, 'wb').write(torrent.dump(validate=False)) 25 | 26 | with patch('sys.exit') as mock_exit: 27 | run(['-i', '-']) 28 | mock_exit.assert_called_once_with(_errors.Code.READ) 29 | cap = capsys.readouterr() 30 | assert cap.out == '' 31 | assert cap.err == f"{_vars.__appname__}: Invalid metainfo: Missing 'piece length' in ['info']\n" 32 | 33 | def test_reading_valid_magnet_URI_from_stdin(capsys, monkeypatch, clear_ansi, regex): 34 | magnet = torf.Magnet('7edbb76b446f87617393537fffa48af733cb4127', dn='Foo', xl=12345) 35 | r, w = os.pipe() 36 | monkeypatch.setattr(sys, 'stdin', os.fdopen(r)) 37 | os.fdopen(w, 'wb').write(str(magnet).encode('utf-8')) 38 | 39 | run(['-i', '-']) 40 | cap = capsys.readouterr() 41 | assert clear_ansi(cap.out) == regex(r'^Name\tFoo$', flags=re.MULTILINE) 42 | assert clear_ansi(cap.out) == regex(r'^Size\t12345$', flags=re.MULTILINE) 43 | assert cap.err == '' 44 | 45 | def test_reading_invalid_magnet_URI_from_stdin(capsys, monkeypatch, clear_ansi, create_torrent, regex): 46 | magnet = 'magnet:?xt=urn:btih:7edbb76b446f87617393537fffa48af733cb4127&dn=Foo&xl=one+million+things' 47 | r, w = os.pipe() 48 | monkeypatch.setattr(sys, 'stdin', os.fdopen(r)) 49 | os.fdopen(w, 'wb').write(magnet.encode('utf-8')) 50 | 51 | with patch('sys.exit') as mock_exit: 52 | run(['-i', '-']) 53 | mock_exit.assert_called_once_with(_errors.Code.READ) 54 | cap = capsys.readouterr() 55 | assert cap.out == '' 56 | assert cap.err == f'{_vars.__appname__}: one million things: Invalid exact length ("xl")\n' 57 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | 3 | import pytest 4 | 5 | from torfcli import _utils 6 | 7 | 8 | def test_bytes2string__rounding(): 9 | assert _utils.bytes2string(1.455 * 2**30) == '1.46 GiB' 10 | assert _utils.bytes2string(1.454 * 2**30) == '1.45 GiB' 11 | 12 | def test_bytes2string__trailing_zeroes(): 13 | assert _utils.bytes2string(1.5 * 2**30, trailing_zeros=True) == '1.50 GiB' 14 | assert _utils.bytes2string(1.5 * 2**30, trailing_zeros=False) == '1.5 GiB' 15 | 16 | assert _utils.bytes2string(1 * 2**30, trailing_zeros=True) == '1.00 GiB' 17 | assert _utils.bytes2string(1 * 2**30, trailing_zeros=False) == '1 GiB' 18 | 19 | assert _utils.bytes2string(10 * 2**30, trailing_zeros=True) == '10.00 GiB' 20 | assert _utils.bytes2string(10 * 2**30, trailing_zeros=False) == '10 GiB' 21 | 22 | 23 | @pytest.mark.parametrize( 24 | argnames='torrent, cfg, exp_return_value', 25 | argvalues=( 26 | (None, {'out': 'user-given.torrent'}, 'user-given.torrent'), 27 | (SimpleNamespace(name='foo'), {'out': ''}, 'foo.torrent'), 28 | (SimpleNamespace(name='foo'), {'out': '', 'profile': ['this']}, 'foo.this.torrent'), 29 | (SimpleNamespace(name='foo'), {'out': '', 'profile': ['this', 'that']}, 'foo.this.that.torrent'), 30 | ), 31 | ids=lambda v: repr(v), 32 | ) 33 | def test_get_torrent_filepath(torrent, cfg, exp_return_value): 34 | return_value = _utils.get_torrent_filepath(torrent, cfg) 35 | assert return_value == exp_return_value 36 | -------------------------------------------------------------------------------- /tests/test_verify.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | import torf 7 | 8 | from torfcli import _errors as err 9 | from torfcli import _vars, run 10 | 11 | 12 | def test_torrent_unreadable(capsys, mock_content): 13 | with patch('sys.exit') as mock_exit: 14 | run([str(mock_content), '-i', 'nonexisting.torrent']) 15 | mock_exit.assert_called_once_with(err.Code.READ) 16 | cap = capsys.readouterr() 17 | assert cap.err == f'{_vars.__appname__}: nonexisting.torrent: No such file or directory\n' 18 | 19 | 20 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 21 | def test_PATH_unreadable(create_torrent, human_readable, hr_enabled, capsys, clear_ansi, regex, assert_no_ctrl): 22 | with create_torrent() as torrent_file: 23 | with human_readable(hr_enabled): 24 | with patch('sys.exit') as mock_exit: 25 | run(['path/to/nothing', '-i', torrent_file]) 26 | mock_exit.assert_called_once_with(err.Code.VERIFY) 27 | cap = capsys.readouterr() 28 | assert cap.err == f'{_vars.__appname__}: path/to/nothing does not satisfy {torrent_file}\n' 29 | 30 | if hr_enabled: 31 | assert clear_ansi(cap.out) == regex(r'^\s*Error path/to/nothing: Not a directory$', flags=re.MULTILINE) 32 | else: 33 | assert_no_ctrl(cap.out) 34 | assert cap.out == regex(r'^Error\tpath/to/nothing: Not a directory$', flags=re.MULTILINE) 35 | 36 | 37 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 38 | def test_singlefile_torrent__path_is_dir(tmp_path, create_torrent, human_readable, hr_enabled, 39 | capsys, clear_ansi, assert_no_ctrl, regex): 40 | content_path = tmp_path / 'content' 41 | content_path.write_bytes(b'some data') 42 | assert os.path.isfile(content_path) is True 43 | 44 | with create_torrent(path=content_path) as torrent_file: 45 | os.remove(content_path) 46 | content_path.mkdir() 47 | (content_path / 'some.file').write_bytes(b'some data') 48 | assert os.path.isfile(content_path) is False 49 | 50 | with human_readable(hr_enabled): 51 | with patch('sys.exit') as mock_exit: 52 | run([str(content_path), '-i', torrent_file]) 53 | mock_exit.assert_called_once_with(err.Code.VERIFY) 54 | cap = capsys.readouterr() 55 | assert cap.err == f'{_vars.__appname__}: {content_path} does not satisfy {torrent_file}\n' 56 | 57 | if hr_enabled: 58 | assert clear_ansi(cap.out) == regex(rf'^\s*Error {content_path}: Is a directory$', flags=re.MULTILINE) 59 | else: 60 | assert_no_ctrl(cap.out) 61 | assert cap.out == regex(rf'^Error\t{content_path}: Is a directory$', flags=re.MULTILINE) 62 | 63 | 64 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 65 | def test_singlefile_torrent__wrong_size(tmp_path, create_torrent, human_readable, hr_enabled, 66 | capsys, clear_ansi, assert_no_ctrl, regex): 67 | content_path = tmp_path / 'file.jpg' 68 | content_path.write_text('some data') 69 | 70 | with create_torrent(path=content_path) as torrent_file: 71 | content_path.write_text('some data!!!') 72 | 73 | with human_readable(hr_enabled): 74 | with patch('sys.exit') as mock_exit: 75 | run([str(content_path), '-i', torrent_file]) 76 | mock_exit.assert_called_once_with(err.Code.VERIFY) 77 | cap = capsys.readouterr() 78 | assert cap.err == f'{_vars.__appname__}: {content_path} does not satisfy {torrent_file}\n' 79 | 80 | if hr_enabled: 81 | assert clear_ansi(cap.out) == regex(rf'^\s*Error {content_path}: Too big: 12 instead of 9 bytes$', 82 | flags=re.MULTILINE) 83 | else: 84 | assert_no_ctrl(cap.out) 85 | assert cap.out == regex(rf'^Error\t{content_path}: Too big: 12 instead of 9 bytes$', 86 | flags=re.MULTILINE) 87 | 88 | 89 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 90 | def test_singlefile_torrent__correct_size_but_corrupt(tmp_path, create_torrent, human_readable, hr_enabled, 91 | capsys, clear_ansi, assert_no_ctrl, regex): 92 | content_path = tmp_path / 'content' 93 | content_path.write_text('some data') 94 | 95 | with create_torrent(path=content_path) as torrent_file: 96 | content_path.write_text('somm date') 97 | 98 | with human_readable(hr_enabled): 99 | with patch('sys.exit') as mock_exit: 100 | run([str(content_path), '-i', torrent_file]) 101 | mock_exit.assert_called_once_with(err.Code.VERIFY) 102 | cap = capsys.readouterr() 103 | assert cap.err == f'{_vars.__appname__}: {content_path} does not satisfy {torrent_file}\n' 104 | 105 | if hr_enabled: 106 | assert clear_ansi(cap.out) == regex(r'^\s*Error Corruption in piece 1$', flags=re.MULTILINE) 107 | else: 108 | assert_no_ctrl(cap.out) 109 | assert cap.out == regex(r'^Error\tCorruption in piece 1$', flags=re.MULTILINE) 110 | 111 | 112 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 113 | def test_multifile_torrent__path_is_file(tmp_path, create_torrent, human_readable, hr_enabled, 114 | capsys, clear_ansi, assert_no_ctrl, regex): 115 | content_path = tmp_path / 'content' 116 | content_path.mkdir() 117 | file1 = content_path / 'file1.jpg' 118 | file1.write_text('some data') 119 | assert os.path.isdir(content_path) is True 120 | 121 | with create_torrent(path=content_path) as torrent_file: 122 | os.remove(file1) 123 | os.rmdir(content_path) 124 | content_path.write_text('some data') 125 | assert os.path.isdir(content_path) is False 126 | 127 | with human_readable(hr_enabled): 128 | with patch('sys.exit') as mock_exit: 129 | run([str(content_path), '-i', torrent_file]) 130 | mock_exit.assert_called_once_with(err.Code.VERIFY) 131 | cap = capsys.readouterr() 132 | assert cap.err == f'{_vars.__appname__}: {content_path} does not satisfy {torrent_file}\n' 133 | 134 | if hr_enabled: 135 | assert clear_ansi(cap.out) == regex(rf'^\s*Error {content_path}: Not a directory$', flags=re.MULTILINE) 136 | else: 137 | assert_no_ctrl(cap.out) 138 | assert cap.out == regex(rf'^Error\t{content_path}: Not a directory$', flags=re.MULTILINE) 139 | 140 | 141 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 142 | def test_multifile_torrent__missing_file(tmp_path, create_torrent, human_readable, hr_enabled, 143 | capsys, clear_ansi, assert_no_ctrl, regex): 144 | content_path = tmp_path / 'content' 145 | content_path.mkdir() 146 | file1 = content_path / 'file1.jpg' 147 | file1.write_text('some data') 148 | file2 = content_path / 'file2.jpg' 149 | file2.write_text('some other data') 150 | 151 | with create_torrent(path=content_path) as torrent_file: 152 | os.remove(file1) 153 | 154 | with human_readable(hr_enabled): 155 | with patch('sys.exit') as mock_exit: 156 | run([str(content_path), '-i', torrent_file]) 157 | mock_exit.assert_called_once_with(err.Code.VERIFY) 158 | cap = capsys.readouterr() 159 | assert cap.err == f'{_vars.__appname__}: {content_path} does not satisfy {torrent_file}\n' 160 | 161 | if hr_enabled: 162 | assert clear_ansi(cap.out) == regex(rf'^\s*Error {file1}: No such file or directory$', 163 | flags=re.MULTILINE) 164 | else: 165 | assert_no_ctrl(cap.out) 166 | assert cap.out == regex(rf'^Error\t{file1}: No such file or directory$', flags=re.MULTILINE) 167 | 168 | 169 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 170 | def test_multifile_torrent__wrong_size(tmp_path, create_torrent, human_readable, hr_enabled, 171 | capsys, clear_ansi, assert_no_ctrl, regex): 172 | content_path = tmp_path / 'content' 173 | content_path.mkdir() 174 | file1 = content_path / 'file1.jpg' 175 | file1.write_text('some data') 176 | file2 = content_path / 'file2.jpg' 177 | file2.write_text('some other data') 178 | file2_size = os.path.getsize(file2) 179 | 180 | with create_torrent(path=content_path) as torrent_file: 181 | file2.write_text('some more other data') 182 | assert os.path.getsize(file2) != file2_size 183 | 184 | with human_readable(hr_enabled): 185 | with patch('sys.exit') as mock_exit: 186 | run([str(content_path), '-i', torrent_file]) 187 | mock_exit.assert_called_once_with(err.Code.VERIFY) 188 | cap = capsys.readouterr() 189 | assert cap.err == f'{_vars.__appname__}: {content_path} does not satisfy {torrent_file}\n' 190 | 191 | if hr_enabled: 192 | assert clear_ansi(cap.out) == regex(rf'^\s*Error {file2}: Too big: 20 instead of 15 bytes$', 193 | flags=re.MULTILINE) 194 | else: 195 | assert_no_ctrl(cap.out) 196 | assert cap.out == regex(rf'^Error\t{file2}: Too big: 20 instead of 15 bytes$', flags=re.MULTILINE) 197 | 198 | 199 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 200 | def test_multifile_torrent__correct_size_but_corrupt(tmp_path, create_torrent, human_readable, hr_enabled, 201 | capsys, clear_ansi, assert_no_ctrl, regex): 202 | content_path = tmp_path / 'content' 203 | content_path.mkdir() 204 | file1 = content_path / 'file1.jpg' 205 | file1_data = bytearray(b'\x00' * int(1e6)) 206 | file1.write_bytes(file1_data) 207 | file1_size = os.path.getsize(file1) 208 | file2 = content_path / 'file2.jpg' 209 | file2.write_text('some other data') 210 | 211 | with create_torrent(path=content_path) as torrent_file: 212 | file1_data[int(1e6 / 2)] = (file1_data[int(1e6 / 2)] + 1) % 256 213 | file1.write_bytes(file1_data) 214 | assert os.path.getsize(file1) == file1_size 215 | 216 | with human_readable(hr_enabled): 217 | with patch('sys.exit') as mock_exit: 218 | run([str(content_path), '-i', torrent_file]) 219 | mock_exit.assert_called_once_with(err.Code.VERIFY) 220 | cap = capsys.readouterr() 221 | assert cap.err == f'{_vars.__appname__}: {content_path} does not satisfy {torrent_file}\n' 222 | 223 | if hr_enabled: 224 | assert clear_ansi(cap.out) == regex(rf'^\s*Error Corruption in piece 31 in {file1}$', 225 | flags=re.MULTILINE) 226 | else: 227 | assert_no_ctrl(cap.out) 228 | assert cap.out == regex((rf'^Error\tCorruption in piece 31 in {file1}$'), 229 | flags=re.MULTILINE) 230 | 231 | 232 | @pytest.mark.parametrize('hr_enabled', (True, False), ids=('human_readable=True', 'human_readable=False')) 233 | def test_success(tmp_path, create_torrent, human_readable, hr_enabled, capsys, clear_ansi, assert_no_ctrl, regex): 234 | content_path = tmp_path / 'content' 235 | content_path.write_text('some data') 236 | 237 | with create_torrent(path=content_path) as torrent_file: 238 | with human_readable(hr_enabled): 239 | run([str(content_path), '-i', torrent_file]) 240 | 241 | cap = capsys.readouterr() 242 | if hr_enabled: 243 | assert clear_ansi(cap.out) == regex(r'^\s*Progress 100.00 % \| \d+:\d+:\d+ total \| \s*\d+\.\d+ [KMGT]iB/s$', 244 | flags=re.MULTILINE) 245 | else: 246 | assert_no_ctrl(cap.out) 247 | assert cap.out == regex(rf'^Progress\t100\.000\t\d+\t\d+\t\d+\t\d+\t\d+\t{content_path}$', 248 | flags=re.MULTILINE) 249 | 250 | 251 | def test_metainfo_with_magnet_uri(capsys, tmp_path, regex): 252 | magnet = ('magnet:?xt=urn:btih:e167b1fbb42ea72f051f4f50432703308efb8fd1&dn=My+Torrent&xl=142631' 253 | '&tr=https%3A%2F%2Flocalhost%3A123%2Fannounce&&tr=https%3A%2F%2Flocalhost%3A456%2Fannounce') 254 | filepath = tmp_path / 'My Torrent' 255 | filepath.write_text('something') 256 | with patch('sys.exit') as mock_exit: 257 | run(['-i', magnet, str(filepath)]) 258 | mock_exit.assert_called_once_with(err.Code.READ) 259 | cap = capsys.readouterr() 260 | assert cap.err == regex(rf'^{_vars.__appname__}: https://localhost:123/file\?info_hash=' 261 | r'%E1g%B1%FB%B4\.%A7/%05%1FOPC%27%030%8E%FB%8F%D1: [\w\s]+\n' 262 | rf'{_vars.__appname__}: https://localhost:456/file\?info_hash=' 263 | r'%E1g%B1%FB%B4\.%A7/%05%1FOPC%27%030%8E%FB%8F%D1: [\w\s]+\n' 264 | rf"{_vars.__appname__}: Invalid metainfo: Missing 'piece length' in \['info'\]\n$") 265 | 266 | 267 | def test_PATH_argument_with_trailing_slash(capsys, create_torrent): 268 | with create_torrent() as torrent_file: 269 | torrent_name = torf.Torrent.read(torrent_file).name 270 | 271 | with patch('torf.Torrent.verify') as mock_verify: 272 | run(['-i', torrent_file, 'some/path']) 273 | assert mock_verify.call_args_list[0][0][0] == 'some/path' 274 | 275 | with patch('torf.Torrent.verify') as mock_verify: 276 | run(['-i', torrent_file, 'some/path/']) 277 | assert mock_verify.call_args_list[0][0][0] == f'some/path/{torrent_name}' 278 | -------------------------------------------------------------------------------- /torfcli/__init__.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details 10 | # http://www.gnu.org/licenses/gpl-3.0.txt 11 | 12 | import sys 13 | 14 | 15 | def run(args=sys.argv[1:]): 16 | from . import _config, _errors, _main, _ui 17 | 18 | # Only parse --json, --human and --nohuman so UI can report errors. 19 | ui = _ui.UI(_config.parse_early_args(args)) 20 | 21 | # Parse the rest of the args; report any errors as specified by early args. 22 | torrent = None 23 | try: 24 | ui.cfg = _config.get_cfg(args) 25 | except (_errors.CliError, _errors.ConfigError) as e: 26 | ui.error(e) 27 | else: 28 | try: 29 | torrent = _main.run(ui) 30 | except _errors.Error as e: 31 | ui.error(e) 32 | except KeyboardInterrupt: 33 | ui.error(_errors.Error('Aborted', code=_errors.Code.ABORTED)) 34 | finally: 35 | ui.terminate(torrent) 36 | -------------------------------------------------------------------------------- /torfcli/__main__.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details 10 | # http://www.gnu.org/licenses/gpl-3.0.txt 11 | 12 | # This file is evaluated when the torfcli module is loaded with 13 | # `python -m torfcli`. 14 | 15 | from . import run 16 | 17 | run() 18 | -------------------------------------------------------------------------------- /torfcli/_config.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details 10 | # http://www.gnu.org/licenses/gpl-3.0.txt 11 | 12 | import argparse 13 | import itertools 14 | import json 15 | import os 16 | import re 17 | 18 | import torf 19 | from xdg import BaseDirectory 20 | 21 | from . import _errors, _utils, _vars 22 | 23 | DEFAULT_CONFIG_FILE = os.path.join(BaseDirectory.xdg_config_home, _vars.__appname__, 'config') 24 | DEFAULT_CREATOR = f'{_vars.__appname__} {_vars.__version__}' 25 | VERSION_TEXT = f'{_vars.__appname__} {_vars.__version__} <{_vars.__url__}>' 26 | HELP_TEXT = f""" 27 | {_vars.__appname__} - {_vars.__description__} 28 | 29 | USAGE 30 | {_vars.__appname__} PATH [OPTIONS] [-o TORRENT] # Create torrent 31 | {_vars.__appname__} -i INPUT # Display torrent 32 | {_vars.__appname__} -i INPUT [OPTIONS] -o TORRENT # Edit torrent 33 | {_vars.__appname__} -i TORRENT PATH # Verify file content 34 | 35 | ARGUMENTS 36 | PATH Path to torrent's content file or directory 37 | --in, -i INPUT Read metainfo from torrent file or magnet URI 38 | --out, -o TORRENT Write metainfo to TORRENT (default: NAME.torrent) 39 | --reuse, -r REUSE Copy pieces from existing torrent file if possible 40 | --noreuse, -R Ignore any --reuse paths 41 | 42 | FILES SELECTION 43 | --exclude, -e PATTERN Exclude files that match this glob pattern 44 | (e.g. "*.txt") 45 | --include PATTERN Include excluded files that match this glob 46 | pattern 47 | --exclude-regex, -er PATTERN 48 | Exclude files that match this regular expression 49 | (e.g. ".*\\.txt$") 50 | --include-regex, -ir PATTERN 51 | Include excluded files that match this regular 52 | expression 53 | 54 | TORRENT METADATA 55 | --name, -n NAME Torrent name (default: basename of PATH) 56 | --tracker, -t TRACKER List of comma-separated announce URLs; may be 57 | given multiple times for multiple tiers 58 | --webseed, -w WEBSEED Webseed URL; may be given multiple times 59 | --private, -p Forbid clients to use DHT and PEX 60 | --comment, -c COMMENT Comment that is stored in the torrent file 61 | --date, -d DATE Creation date as YYYY-MM-DD[ HH:MM[:SS]], 'now' 62 | or 'today' (default: 'now') 63 | --creator, -a CREATOR Name of application used to create torrent file. 64 | (default: '{DEFAULT_CREATOR}') 65 | --source, -s SOURCE Add "source" field 66 | --merge JSON Insert or remove arbitrary metainfo (see man page) 67 | --xseed, -x Randomize info hash 68 | --max-piece-size SIZE Maximum piece size in multiples of 1 MiB 69 | --notracker, -T Remove trackers from INPUT 70 | --nowebseed, -W Remove webseeds from INPUT 71 | --noprivate, -P Remove private flag from INPUT 72 | --nocomment, -C Remove comment from INPUT 73 | --nosource, -S Remove "source" field from INPUT 74 | --noxseed, -X De-randomize info hash of INPUT 75 | --nodate, -D Don't include date or remove date from INPUT 76 | --nocreator, -A Don't include creator or remove creator from INPUT 77 | --notorrent, -N Don't create torrent file 78 | --nomagnet, -M Don't create magnet URI 79 | --novalidate, -V Don't check INPUT and/or TORRENT for errors 80 | 81 | CONFIGURATION 82 | --yes, -y Answer all yes/no prompts with "yes" 83 | --config, -f FILE Read configuration from FILE 84 | (default: ~/.config/{_vars.__appname__}/config 85 | --noconfig, -F Ignore configuration file 86 | --profile, -z PROFILE Use options from PROFILE 87 | --threads THREADS Number of threads to use for hashing 88 | 89 | TEXT OUTPUT 90 | --json, -j Print output as JSON object 91 | --metainfo, -m Print torrent metainfo as JSON object 92 | --human, -u Force human-readable output 93 | --nohuman, -U Force machine-readable output 94 | --verbose, -v Increase verbosity 95 | --help, -h Show this help screen and exit 96 | --version Show version number and exit 97 | """.strip() 98 | 99 | 100 | class DictFromJSON(dict): 101 | def __new__(cls, string): 102 | try: 103 | return json.loads(string) 104 | except ValueError as e: 105 | raise argparse.ArgumentTypeError(f'Invalid JSON: {e}') 106 | 107 | 108 | class CLIParser(argparse.ArgumentParser): 109 | def error(self, msg): 110 | msg = msg[0].upper() + msg[1:] 111 | raise _errors.CliError(msg) 112 | 113 | _cliparser = CLIParser(add_help=False) 114 | 115 | _cliparser.add_argument('PATH', nargs='?') 116 | _cliparser.add_argument('--in', '-i', default='') 117 | _cliparser.add_argument('--out', '-o', default='') 118 | _cliparser.add_argument('--reuse', '-r', default=[], action='append') 119 | _cliparser.add_argument('--noreuse', '-R', action='store_true') 120 | _cliparser.add_argument('--exclude', '-e', default=[], action='append') 121 | _cliparser.add_argument('--include', default=[], action='append') 122 | _cliparser.add_argument('--exclude-regex', '-er', default=[], action='append') 123 | _cliparser.add_argument('--include-regex', '-ir', default=[], action='append') 124 | 125 | _cliparser.add_argument('--name', '-n', default='') 126 | _cliparser.add_argument('--tracker', '-t', default=[], action='append') 127 | _cliparser.add_argument('--webseed', '-w', default=[], action='append') 128 | _cliparser.add_argument('--private', '-p', action='store_true', default=None) 129 | _cliparser.add_argument('--comment', '-c') 130 | _cliparser.add_argument('--date', '-d', default='') 131 | _cliparser.add_argument('--creator', '-a', nargs='?', const=DEFAULT_CREATOR) 132 | _cliparser.add_argument('--source', '-s', default='') 133 | _cliparser.add_argument('--merge', type=DictFromJSON, action='append') 134 | _cliparser.add_argument('--xseed', '-x', action='store_true') 135 | _cliparser.add_argument('--max-piece-size', default=0, type=float) 136 | 137 | _cliparser.add_argument('--notracker', '-T', action='store_true') 138 | _cliparser.add_argument('--nowebseed', '-W', action='store_true') 139 | _cliparser.add_argument('--noprivate', '-P', action='store_true') 140 | _cliparser.add_argument('--nocomment', '-C', action='store_true') 141 | _cliparser.add_argument('--nosource', '-S', action='store_true') 142 | _cliparser.add_argument('--noxseed', '-X', action='store_true') 143 | _cliparser.add_argument('--nodate', '-D', action='store_true') 144 | _cliparser.add_argument('--nocreator', '-A', action='store_true') 145 | _cliparser.add_argument('--notorrent', '-N', action='store_true') 146 | _cliparser.add_argument('--nomagnet', '-M', action='store_true') 147 | _cliparser.add_argument('--novalidate', '-V', action='store_true') 148 | 149 | _cliparser.add_argument('--yes', '-y', action='store_true') 150 | _cliparser.add_argument('--config', '-f') 151 | _cliparser.add_argument('--noconfig', '-F', action='store_true') 152 | _cliparser.add_argument('--profile', '-z', default=[], action='append') 153 | _cliparser.add_argument('--threads', type=int, default=0) 154 | 155 | _cliparser.add_argument('--json', '-j', action='store_true') 156 | _cliparser.add_argument('--metainfo', '-m', action='store_true') 157 | _cliparser.add_argument('--human', '-u', action='store_true') 158 | _cliparser.add_argument('--nohuman', '-U', action='store_true') 159 | _cliparser.add_argument('--verbose', '-v', action='count', default=0) 160 | _cliparser.add_argument('--help', '-h', action='store_true') 161 | _cliparser.add_argument('--version', action='store_true') 162 | _cliparser.add_argument('--debug-file') 163 | 164 | 165 | def parse_early_args(args): 166 | # Parse only some arguments we need to figure out how to report errors. 167 | # Ignore all other arguments and any errors we might encounter. 168 | parser = argparse.ArgumentParser(add_help=False) 169 | parser.add_argument('--json', '-j', action='store_true') 170 | parser.add_argument('--human', '-u', action='store_true') 171 | parser.add_argument('--nohuman', '-U', action='store_true') 172 | return vars(parser.parse_known_args(args)[0]) 173 | 174 | 175 | def parse_args(args): 176 | cfg = vars(_cliparser.parse_args(args)) 177 | 178 | # Validate creation date 179 | if cfg['date']: 180 | try: 181 | cfg['date'] = _utils.parse_date(cfg['date'] or 'now') 182 | except ValueError: 183 | raise _errors.CliError(f'{cfg["date"]}: Invalid date') 184 | 185 | # Validate max piece size 186 | if cfg['max_piece_size']: 187 | cfg['max_piece_size'] = cfg['max_piece_size'] * 1048576 188 | try: 189 | torf.Torrent( 190 | piece_size_min=131072, # 128 kiB 191 | piece_size_max=134217728, # 128 MiB 192 | ).piece_size = cfg['max_piece_size'] 193 | except torf.PieceSizeError as e: 194 | raise _errors.CliError(e) 195 | 196 | # Validate tracker URLs 197 | for tier in cfg['tracker']: 198 | for url in tier.split(','): 199 | try: 200 | torf.Torrent().trackers = url 201 | except torf.URLError as e: 202 | raise _errors.CliError(e) 203 | 204 | # Validate webseed URLs 205 | for webseed in cfg['webseed']: 206 | try: 207 | torf.Torrent().webseeds = (webseed,) 208 | except torf.URLError as e: 209 | raise _errors.CliError(e) 210 | 211 | # Validate regular expressions 212 | for regex in itertools.chain(cfg['exclude_regex'], cfg['include_regex']): 213 | try: 214 | re.compile(regex) 215 | except re.error as e: 216 | raise _errors.CliError(f'Invalid regular expression: {regex}: ' 217 | f'{str(e)[0].upper()}{str(e)[1:]}') 218 | 219 | cfg['validate'] = not cfg['novalidate'] 220 | 221 | return cfg 222 | 223 | 224 | def get_cfg(cliargs): 225 | """Combine values from CLI, config file, profiles and defaults""" 226 | clicfg = parse_args(cliargs) 227 | 228 | if clicfg['debug_file']: 229 | import logging 230 | logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(message)s', 231 | filename=clicfg['debug_file']) 232 | 233 | # If we don't need to read a config file, return parsed CLI arguments 234 | cfgfile = clicfg['config'] or DEFAULT_CONFIG_FILE 235 | if clicfg['noconfig'] or (not clicfg['config'] and not os.path.exists(cfgfile)): 236 | return clicfg 237 | 238 | # Read config file 239 | filecfg = _readfile(cfgfile) 240 | 241 | # Check for illegal arguments 242 | _check_illegal_configfile_arguments(filecfg, cfgfile) 243 | for cfg in filecfg.values(): 244 | if isinstance(cfg, dict): 245 | _check_illegal_configfile_arguments(cfg, cfgfile) 246 | 247 | # Parse combined arguments from config file and CLI to allow --profile in 248 | # CLI and config file 249 | try: 250 | cfg = parse_args(_cfg2args(filecfg) + cliargs) 251 | except _errors.CliError as e: 252 | raise _errors.ConfigError(f'{cfgfile}: {e}') 253 | 254 | # Apply profiles specified in config file or on CLI 255 | def apply_profile(profname): 256 | prof = filecfg.get(profname) 257 | if prof is None: 258 | raise _errors.ConfigError(f'{cfgfile}: No such profile: {profname}') 259 | else: 260 | profargs.extend(_cfg2args(prof)) 261 | 262 | profargs = [] 263 | for profname in cfg['profile']: 264 | apply_profile(profname) 265 | 266 | # Combine arguments from profiles with arguments from global config and CLI 267 | args = _cfg2args(filecfg) + profargs + cliargs 268 | try: 269 | return parse_args(args) 270 | except _errors.CliError as e: 271 | raise _errors.ConfigError(f'{cfgfile}: {e}') 272 | 273 | def _check_illegal_configfile_arguments(cfg, cfgfile): 274 | for arg in ('in', 'name', 'out', 'config', 'noconfig', 'profile', 'help', 'version'): 275 | if arg in cfg: 276 | raise _errors.ConfigError(f'{cfgfile}: Not allowed in config file: {arg}') 277 | 278 | 279 | _re_bool = re.compile(r'^(\S+)$') 280 | _re_assign = re.compile(r'^(\S+)\s*=\s*(.*)\s*$') 281 | 282 | def _readfile(filepath): 283 | """Read INI-style file into dictionary""" 284 | 285 | # Catch any errors from the OS 286 | try: 287 | with open(filepath, 'r') as f: 288 | lines = tuple(line.strip() for line in f.readlines()) 289 | except OSError as e: 290 | raise _errors.ConfigError(f'{filepath}: {os.strerror(e.errno)}') 291 | 292 | # Parse lines 293 | cfg = subcfg = {} 294 | for line in lines: 295 | # Skip empty lines and comments 296 | if not line or line[0] == '#': 297 | continue 298 | 299 | # Start new profile 300 | if line[0] == '[' and line[-1] == ']': 301 | profile_name = line[1:-1] 302 | cfg[profile_name] = subcfg = {} 303 | continue 304 | 305 | # Boolean option 306 | bool_match = _re_bool.match(line) 307 | if bool_match: 308 | name = bool_match.group(1) 309 | subcfg[name] = True 310 | continue 311 | 312 | # String option 313 | assign_match = _re_assign.match(line) 314 | if assign_match: 315 | name = assign_match.group(1) 316 | value = assign_match.group(2).strip() 317 | 318 | # Strip off optional quotes 319 | if value: 320 | if value[0] == value[-1] == '"' or value[0] == value[-1] == "'": 321 | value = value[1:-1] 322 | 323 | value = _resolve_envvars(value) 324 | 325 | # Multiple occurences of the same name turn its value into a list 326 | if name in subcfg: 327 | if not isinstance(subcfg[name], list): 328 | subcfg[name] = [subcfg[name]] 329 | subcfg[name].append(value) 330 | else: 331 | subcfg[name] = value 332 | 333 | continue 334 | 335 | return cfg 336 | 337 | 338 | def _resolve_envvars(string): 339 | def resolve(m): 340 | # The string of \ chars is halfed because every \ escapes the next \. 341 | esc_count = len(m.group(1)) 342 | esc_str = int(esc_count / 2) * '\\' 343 | varname = m.group(2) or m.group(3) 344 | value = os.environ.get(varname, '$' + varname) 345 | # Uneven number of \ means $varname is escaped, even number of \ means 346 | # it is not. 347 | if esc_count and esc_count % 2 != 0: 348 | return f'{esc_str}${varname}' 349 | else: 350 | return f'{esc_str}{value}' 351 | regex = re.compile(r'(\\*)\$(?:(\w+)|\{(\w+)\})') 352 | return regex.sub(resolve, string) 353 | 354 | 355 | def _cfg2args(cfg): 356 | args = [] 357 | for name,value in cfg.items(): 358 | option = '--' + name 359 | 360 | # Option with parameter 361 | if isinstance(value, str): 362 | args.extend((option, value)) 363 | 364 | # Switch without parameter 365 | elif isinstance(value, (bool, type(None))): 366 | args.append(option) 367 | 368 | # Option that can occur multiple times 369 | elif isinstance(value, list): 370 | for item in value: 371 | args.extend((option, item)) 372 | return args 373 | -------------------------------------------------------------------------------- /torfcli/_errors.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details 10 | # http://www.gnu.org/licenses/gpl-3.0.txt 11 | 12 | from collections import defaultdict 13 | from enum import IntEnum 14 | 15 | import torf 16 | 17 | 18 | class Code(IntEnum): 19 | GENERIC = 1 20 | CLI = 2 21 | CONFIG = 3 22 | READ = 4 23 | WRITE = 5 24 | VERIFY = 6 25 | ABORTED = 128 26 | 27 | 28 | class Error(Exception): 29 | """ 30 | Automatically return the appropriate subclass instance based on error code 31 | or passed message. 32 | 33 | >>> Error('foo', code=Code.READ) 34 | ReadError('foo') 35 | >>> Error(torf.ReadError(errno.ENOENT, 'foo')) 36 | ReadError('foo: No such file or directory') 37 | """ 38 | 39 | _subclsmap = defaultdict( 40 | lambda: Code.GENERIC, 41 | # torf.URLError and torf.PieceSizeError are handled in _config.py 42 | {torf.ReadError : Code.READ, 43 | torf.PathError : Code.READ, 44 | torf.BdecodeError : Code.READ, 45 | torf.MetainfoError : Code.READ, 46 | torf.MagnetError : Code.READ, 47 | torf.WriteError : Code.WRITE, 48 | torf.VerifyNotDirectoryError : Code.VERIFY, 49 | torf.VerifyIsDirectoryError : Code.VERIFY, 50 | torf.VerifyFileSizeError : Code.VERIFY, 51 | torf.VerifyContentError : Code.VERIFY}) 52 | 53 | @classmethod 54 | def _get_exception_cls(cls, msg, code): 55 | if code is None: 56 | # If `msg` is a torf.*Error, translate it into an error code 57 | code = cls._subclsmap[type(msg)] 58 | assert code in Code, f'Not an error code: {code}' 59 | # Translate error code name to exception class 60 | cls_name = code.name.capitalize() + 'Error' 61 | try: 62 | return globals()[cls_name] 63 | except KeyError: 64 | return None 65 | 66 | def __new__(cls, msg='Unspecified error', code=None, **kwargs): 67 | subcls = cls._get_exception_cls(msg, code) 68 | if subcls is not None: 69 | self = super(Error, cls).__new__(subcls) 70 | else: 71 | self = super().__new__(cls) 72 | return self 73 | 74 | def __init__(self, msg=None, code=None): 75 | msg = msg or 'Unspecified error' 76 | self._exit_code = code or self._subclsmap[type(self)] 77 | super().__init__(str(msg)) 78 | 79 | @property 80 | def exit_code(self): 81 | return self._exit_code 82 | 83 | class CliError(Error): 84 | def __init__(self, msg, code=None): 85 | super().__init__(msg, code=Code.CLI) 86 | 87 | class ConfigError(Error): 88 | def __init__(self, msg, code=None): 89 | super().__init__(msg, code=Code.CONFIG) 90 | 91 | class ReadError(Error): 92 | def __init__(self, msg, code=None): 93 | super().__init__(msg, code=Code.READ) 94 | 95 | class WriteError(Error): 96 | def __init__(self, msg, code=None): 97 | super().__init__(msg, code=Code.WRITE) 98 | 99 | class VerifyError(Error): 100 | def __init__(self, content=None, code=None, torrent=None): 101 | if torrent is None: 102 | # Content is a complete message 103 | super().__init__(content, code=Code.VERIFY) 104 | else: 105 | # Content is a path 106 | super().__init__(f'{content} does not satisfy {torrent}', code=Code.VERIFY) 107 | -------------------------------------------------------------------------------- /torfcli/_main.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details 10 | # http://www.gnu.org/licenses/gpl-3.0.txt 11 | 12 | import datetime 13 | import os.path 14 | 15 | import torf 16 | 17 | from . import _config, _errors, _utils, _vars 18 | 19 | # Seconds between progress updates 20 | PROGRESS_INTERVAL = 0.5 21 | 22 | 23 | def run(ui): 24 | cfg = ui.cfg 25 | if cfg['help']: 26 | print(_config.HELP_TEXT) 27 | elif cfg['version']: 28 | print(_config.VERSION_TEXT) 29 | else: 30 | # Figure out our modus operandi 31 | if cfg['PATH'] and not cfg['in']: 32 | return _create_mode(ui, cfg) 33 | elif cfg['in'] and ( 34 | # Create new torrent file 35 | cfg['out'] 36 | # Create new magnet URI 37 | or cfg['name'] or cfg['tracker'] or cfg['webseed'] 38 | or cfg['notracker'] or cfg['nowebseed'] 39 | ): 40 | return _edit_mode(ui, cfg) 41 | elif not cfg['PATH'] and not cfg['out'] and cfg['in']: 42 | return _info_mode(ui, cfg) 43 | elif cfg['PATH'] and not cfg['out'] and cfg['in']: 44 | return _verify_mode(ui, cfg) 45 | else: 46 | raise _errors.CliError(f'Not sure what to do (see USAGE in `{_vars.__appname__} -h`)') 47 | 48 | 49 | def _info_mode(ui, cfg): 50 | torrent = _utils.get_torrent(cfg, ui) 51 | ui.show_torrent(torrent) 52 | if not cfg['nomagnet']: 53 | try: 54 | ui.info('Magnet', torrent.magnet()) 55 | except torf.TorfError as e: 56 | if cfg['validate']: 57 | raise _errors.Error(e) 58 | else: 59 | ui.warn(_errors.Error(e)) 60 | return torrent 61 | 62 | def _create_mode(ui, cfg): 63 | trackers = [tier.split(',') for tier in cfg['tracker']] 64 | try: 65 | torrent = torf.Torrent( 66 | path=cfg['PATH'], 67 | name=cfg['name'] or None, 68 | exclude_globs=cfg['exclude'], 69 | exclude_regexs=cfg['exclude_regex'], 70 | include_globs=cfg['include'], 71 | include_regexs=cfg['include_regex'], 72 | piece_size_max=cfg['max_piece_size'] if cfg['max_piece_size'] else None, 73 | trackers=() if cfg['notracker'] else trackers, 74 | webseeds=() if cfg['nowebseed'] else cfg['webseed'], 75 | private=False if cfg['noprivate'] else cfg['private'], 76 | source=None if cfg['nosource'] or not cfg['source'] else cfg['source'], 77 | randomize_infohash=False if cfg['noxseed'] else cfg['xseed'], 78 | comment=None if cfg['nocomment'] else cfg['comment'], 79 | created_by=None if cfg['nocreator'] else (cfg['creator'] or _config.DEFAULT_CREATOR), 80 | ) 81 | except torf.TorfError as e: 82 | raise _errors.Error(e) 83 | 84 | if cfg['nodate']: 85 | torrent.creation_date = None 86 | elif cfg['date']: 87 | torrent.creation_date = cfg['date'] 88 | else: 89 | torrent.creation_date = datetime.datetime.now() 90 | 91 | # Apply custom JSON objects from --merge 92 | _customize_torrent(torrent, cfg) 93 | 94 | ui.check_output_file_exists(_utils.get_torrent_filepath(torrent, cfg)) 95 | ui.show_torrent(torrent) 96 | _hash_pieces( 97 | ui=ui, 98 | torrent=torrent, 99 | reuse_paths=cfg['reuse'] if not cfg['noreuse'] else (), 100 | threads=cfg['threads'], 101 | ) 102 | _write_torrent(ui, torrent, cfg) 103 | return torrent 104 | 105 | def _edit_mode(ui, cfg): 106 | torrent = _utils.get_torrent(cfg, ui) 107 | 108 | # Make sure we can write before we start editing 109 | ui.check_output_file_exists(_utils.get_torrent_filepath(torrent, cfg)) 110 | 111 | # Make changes according to CLI args 112 | def set_or_remove(arg_name, attr_name): 113 | if cfg.get('no' + arg_name): 114 | setattr(torrent, attr_name, None) 115 | elif cfg[arg_name]: 116 | try: 117 | setattr(torrent, attr_name, cfg[arg_name]) 118 | except torf.TorfError as e: 119 | raise _errors.Error(e) 120 | set_or_remove('comment', 'comment') 121 | set_or_remove('private', 'private') 122 | set_or_remove('source', 'source') 123 | set_or_remove('xseed', 'randomize_infohash') 124 | 125 | def list_set_or_remove(arg_name, attr_name, split_values_at=None): 126 | if cfg.get('no' + arg_name): 127 | setattr(torrent, attr_name, None) 128 | if cfg[arg_name]: 129 | old_list = getattr(torrent, attr_name) or [] 130 | if split_values_at is not None: 131 | add_list = [tier.split(split_values_at) for tier in cfg[arg_name]] 132 | else: 133 | add_list = cfg[arg_name] 134 | new_list = old_list + add_list 135 | try: 136 | setattr(torrent, attr_name, new_list) 137 | except torf.TorfError as e: 138 | raise _errors.Error(e) 139 | list_set_or_remove('tracker', 'trackers', split_values_at=',') 140 | list_set_or_remove('webseed', 'webseeds') 141 | 142 | if cfg['nocreator']: 143 | torrent.created_by = None 144 | elif cfg['creator']: 145 | torrent.created_by = cfg['creator'] 146 | 147 | if cfg['nodate']: 148 | torrent.creation_date = None 149 | elif cfg['date']: 150 | torrent.creation_date = cfg['date'] 151 | 152 | # Apply custom JSON objects from --merge 153 | _customize_torrent(torrent, cfg) 154 | 155 | if cfg['PATH']: 156 | list_set_or_remove('exclude', 'exclude_globs') 157 | list_set_or_remove('exclude_regex', 'exclude_regexs') 158 | list_set_or_remove('include', 'include_globs') 159 | list_set_or_remove('include_regex', 'include_regexs') 160 | try: 161 | torrent.path = cfg['PATH'] 162 | except torf.TorfError as e: 163 | raise _errors.Error(e) 164 | else: 165 | # Setting torrent.path overwrites torrent.name, so we must set any 166 | # custom name after setting path 167 | if cfg['name']: 168 | torrent.name = cfg['name'] 169 | ui.show_torrent(torrent) 170 | _hash_pieces(ui, torrent) 171 | else: 172 | if cfg['name']: 173 | torrent.name = cfg['name'] 174 | ui.show_torrent(torrent) 175 | _write_torrent(ui, torrent, cfg) 176 | return torrent 177 | 178 | def _verify_mode(ui, cfg): 179 | torrent = _utils.get_torrent(cfg, ui) 180 | # Append torrent's name to path if it ends with "/" 181 | path = cfg['PATH'] 182 | if path[-1] == os.path.sep: 183 | path = os.path.join(path, torrent.metainfo['info'].get('name', '')) 184 | 185 | ui.show_torrent(torrent) 186 | ui.info('Path', path) 187 | 188 | try: 189 | ui.info('Info Hash', torrent.infohash) 190 | except torf.TorfError as e: 191 | raise _errors.Error(e) 192 | 193 | with ui.StatusReporter() as sr: 194 | try: 195 | success = torrent.verify(path, 196 | callback=sr.verify_callback, 197 | interval=PROGRESS_INTERVAL) 198 | except torf.TorfError as e: 199 | raise _errors.Error(e) 200 | except KeyboardInterrupt: 201 | sr.keep_progress() 202 | raise 203 | else: 204 | sr.keep_progress_summary() 205 | if not success: 206 | raise _errors.VerifyError(content=cfg['PATH'], torrent=cfg['in']) 207 | return torrent 208 | 209 | def _hash_pieces(ui, torrent, reuse_paths=None, threads=0): 210 | with ui.StatusReporter() as sr: 211 | try: 212 | # Try reusing existing torrent and generate() if that fails 213 | success = False 214 | if reuse_paths and torrent.files: 215 | success = torrent.reuse(reuse_paths, 216 | callback=sr.reuse_callback, 217 | interval=PROGRESS_INTERVAL) 218 | if not success: 219 | sr.reset() 220 | success = torrent.generate(callback=sr.generate_callback, 221 | interval=PROGRESS_INTERVAL, 222 | threads=threads or None) 223 | except torf.TorfError as e: 224 | raise _errors.Error(e) 225 | except KeyboardInterrupt: 226 | sr.keep_progress() 227 | raise 228 | else: 229 | sr.keep_progress_summary() 230 | if success: 231 | try: 232 | ui.info('Info Hash', torrent.infohash) 233 | except torf.TorfError as e: 234 | raise _errors.Error(e) 235 | 236 | def _write_torrent(ui, torrent, cfg): 237 | _validate_torrent(ui, torrent, cfg) 238 | 239 | if not cfg['nomagnet']: 240 | try: 241 | ui.info('Magnet', torrent.magnet()) 242 | except torf.TorfError: 243 | # Error was already reported 244 | pass 245 | 246 | if not cfg['notorrent']: 247 | filepath = _utils.get_torrent_filepath(torrent, cfg) 248 | try: 249 | torrent.write(filepath, overwrite=True, validate=cfg['validate']) 250 | except torf.WriteError as e: 251 | # Errors other than WriteError should already be reported by 252 | # torrent.validate() above 253 | raise _errors.Error(e) 254 | else: 255 | ui.info('Torrent', filepath) 256 | 257 | if torrent.private and not torrent.trackers: 258 | ui.warn('Torrent is private and has no trackers') 259 | 260 | def _validate_torrent(ui, torrent, cfg): 261 | try: 262 | torrent.validate() 263 | except torf.TorfError as e: 264 | if cfg['notorrent']: 265 | # Not writing torrent file; do not fail because, 266 | # e.g., magnet URI lacks ['info'] 267 | pass 268 | elif cfg['validate']: 269 | # Croak with validation error 270 | raise _errors.Error(e) 271 | else: 272 | # Report validation error but write torrent/magnet anyway 273 | ui.warn(_errors.Error(e)) 274 | 275 | def _customize_torrent(torrent, cfg): 276 | # Apply JSON objects from --merge argument(s) 277 | if cfg['merge']: 278 | for merge in cfg['merge']: 279 | try: 280 | _utils.merge_metainfo(torrent.metainfo, merge) 281 | except ValueError as e: 282 | raise _errors.CliError(e) 283 | -------------------------------------------------------------------------------- /torfcli/_term.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details 10 | # http://www.gnu.org/licenses/gpl-3.0.txt 11 | 12 | import io 13 | import sys 14 | 15 | # References: 16 | # https://www.vt100.net/docs/vt100-ug/chapter3.html#DECSCNM 17 | 18 | erase_line = '\x1b[2K' 19 | erase_to_eol = '\x1b[K' 20 | reverse_on = '\x1b[7m' 21 | reverse_off = '\x1b[0m' 22 | hide_cursor = '\x1b[?25l' 23 | show_cursor = '\x1b[?25h' 24 | ensure_line_below = '\n\x1b[1A' 25 | save_cursor_pos = '\x1b7' 26 | restore_cursor_pos = '\x1b8' 27 | move_pos1 = '\r' 28 | move_up = '\x1b[1A' 29 | move_down = '\x1b[1B' 30 | move_right = '\x1b[1C' 31 | move_left = '\x1b[1D' 32 | 33 | def echo(*names): 34 | seqs = ''.join(globals()[name] for name in names) 35 | print(seqs, end='') 36 | 37 | def getch(): 38 | with raw_mode: 39 | return sys.stdin.read(1) 40 | 41 | class _raw_mode(): 42 | _orig_attrs = None 43 | 44 | def enable(self): 45 | try: 46 | import termios 47 | import tty 48 | 49 | fd = sys.stdin.fileno() 50 | self._orig_attrs = termios.tcgetattr(fd) 51 | tty.setraw(sys.stdin.fileno()) 52 | except (ImportError, io.UnsupportedOperation): 53 | pass 54 | 55 | def disable(self): 56 | try: 57 | import termios 58 | 59 | if self._orig_attrs is not None: 60 | fd = sys.stdin.fileno() 61 | termios.tcsetattr(fd, termios.TCSADRAIN, self._orig_attrs) 62 | except (ImportError, io.UnsupportedOperation): 63 | pass 64 | 65 | def __enter__(self): 66 | self.enable() 67 | 68 | def __exit__(self, _, __, ___): 69 | self.disable() 70 | 71 | raw_mode = _raw_mode() 72 | 73 | class _no_user_input(): 74 | """Disable printing of characters as they are typed and hide cursor""" 75 | def enable(self): 76 | try: 77 | import termios 78 | 79 | fd = sys.stdin.fileno() 80 | self._orig_attrs = termios.tcgetattr(fd) 81 | new = termios.tcgetattr(fd) 82 | new[3] = new[3] & ~termios.ECHO # lflags 83 | termios.tcsetattr(fd, termios.TCSADRAIN, new) 84 | echo('hide_cursor') 85 | except (ImportError, io.UnsupportedOperation): 86 | pass 87 | 88 | def disable(self): 89 | orig_attrs = getattr(self, '_orig_attrs', None) 90 | if orig_attrs is not None: 91 | try: 92 | import termios 93 | 94 | fd = sys.stdin.fileno() 95 | termios.tcsetattr(fd, termios.TCSADRAIN, orig_attrs) 96 | echo('show_cursor') 97 | except (ImportError, io.UnsupportedOperation): 98 | pass 99 | 100 | def __enter__(self): 101 | self.enable() 102 | 103 | def __exit__(self, _, __, ___): 104 | self.disable() 105 | 106 | no_user_input = _no_user_input() 107 | -------------------------------------------------------------------------------- /torfcli/_utils.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details 10 | # http://www.gnu.org/licenses/gpl-3.0.txt 11 | 12 | import base64 13 | import contextlib 14 | import datetime 15 | import io 16 | import json 17 | import os 18 | import sys 19 | import time 20 | from collections import abc 21 | 22 | import torf 23 | 24 | from . import _errors 25 | 26 | 27 | def get_torrent(cfg, ui): 28 | """ 29 | Read --in parameter and return torf.Torrent instance 30 | 31 | The --in parameter may be the path to a torrent file, a magnet URI or "-". 32 | If "-", stdin is read and interpreted as the content of a torrent file or a 33 | magnet URI. 34 | """ 35 | # Create torf.Torrent instance from INPUT 36 | if not cfg['in']: 37 | raise RuntimeError('--in option not given; mode detection is probably kaput') 38 | 39 | def get_torrent_from_magnet(string, fallback_exc): 40 | # Parse magnet URI from stdin 41 | try: 42 | magnet = torf.Magnet.from_string(string) 43 | except torf.TorfError as exc: 44 | # Raise magnet parsing error if INPUT looks like magnet URI, 45 | # torrent parsing error otherwise. 46 | if string.startswith('magnet:'): 47 | raise _errors.Error(exc) 48 | else: 49 | raise _errors.Error(fallback_exc) 50 | else: 51 | # Get "info" section (files, sizes, etc) unless the user is not 52 | # interested in a complete torrent, e.g. when editing a magnet URI 53 | if not cfg['notorrent']: 54 | def callback(exc): 55 | ui.error(_errors.Error(exc), exit=False) 56 | magnet.get_info(callback=callback) 57 | torrent = magnet.torrent() 58 | torrent.created_by = None 59 | return torrent 60 | 61 | if cfg['in'] == '-' and not os.path.exists('-'): 62 | data = os.read(sys.stdin.fileno(), torf.Torrent.MAX_TORRENT_FILE_SIZE) 63 | try: 64 | # Read torrent data from stdin 65 | return torf.Torrent.read_stream(io.BytesIO(data), validate=cfg['validate']) 66 | except torf.TorfError as exc: 67 | # Parse magnet URI from stdin 68 | return get_torrent_from_magnet(data.decode('utf-8'), exc) 69 | else: 70 | try: 71 | # Read torrent data from file path 72 | return torf.Torrent.read(cfg['in'], validate=cfg['validate']) 73 | except torf.TorfError as exc: 74 | # Parse magnet URI from string 75 | return get_torrent_from_magnet(cfg['in'], exc) 76 | 77 | 78 | def get_torrent_filepath(torrent, cfg): 79 | """Return the file path of the output torrent file""" 80 | if cfg['out']: 81 | # User-given torrent file path 82 | return cfg['out'] 83 | else: 84 | filename = torrent.name 85 | profiles = cfg.get('profile', ()) 86 | if profiles: 87 | filename += '.' + '.'.join(profiles) 88 | return filename + '.torrent' 89 | 90 | 91 | def is_magnet(string): 92 | return not os.path.exists(string) and string.startswith('magnet:') 93 | 94 | 95 | class Average(): 96 | def __init__(self, samples): 97 | self.times = [] 98 | self.values = [] 99 | self.samples = samples 100 | 101 | def add(self, value): 102 | self.times.append(time.time()) 103 | self.values.append(value) 104 | while len(self.values) > self.samples: 105 | self.times.pop(0) 106 | self.values.pop(0) 107 | 108 | @property 109 | def avg(self): 110 | return sum(self.values) / len(self.values) 111 | 112 | 113 | _C_DOWN = '\u2502' # │ 114 | _C_DOWN_RIGHT = '\u251C' # ├ 115 | _C_RIGHT = '\u2500' # ─ 116 | _C_CORNER = '\u2514' # └ 117 | def make_filetree(tree, parents_is_last=(), plain_bytes=False): 118 | lines = [] 119 | items = tuple(tree.items()) 120 | max_i = len(items) - 1 121 | 122 | for i,(name,node) in enumerate(items): 123 | is_last = i >= max_i 124 | 125 | # Assemble indentation string (`parents_is_last` being empty means 126 | # this is the top node) 127 | indent = '' 128 | if parents_is_last: 129 | # `parents_is_last` holds the `is_last` values of our ancestors. 130 | # This lets us construct the correct indentation string: For 131 | # each parent, if it has any siblings below it in the directory, 132 | # print a vertical bar ('|') that leads to the siblings. 133 | # Otherwise the indentation string for that parent is empty. 134 | # We ignore the first/top/root node because it isn't indented. 135 | for parent_is_last in parents_is_last[1:]: 136 | if parent_is_last: 137 | indent += ' ' 138 | else: 139 | indent += f'{_C_DOWN} ' 140 | 141 | # If this is the last node, use '└' to stop the line, otherwise 142 | # branch off with '├'. 143 | if is_last: 144 | indent += f'{_C_CORNER}{_C_RIGHT}' 145 | else: 146 | indent += f'{_C_DOWN_RIGHT}{_C_RIGHT}' 147 | 148 | if isinstance(node, torf.File): 149 | lines.append(f'{indent}{name} [{bytes2string(node.size, plain_bytes=plain_bytes)}]') 150 | else: 151 | lines.append(f'{indent}{name}') 152 | # Descend into child node 153 | sub_parents_is_last = parents_is_last + (is_last,) 154 | lines.extend(make_filetree(node, parents_is_last=sub_parents_is_last, 155 | plain_bytes=plain_bytes)) 156 | return lines 157 | 158 | 159 | def merge_metainfo(a, b): 160 | # Merge dictionary `b` into dictionary `a`, overwriting or adding values 161 | # from `b` and preserving existing values in `a`. 162 | if isinstance(b, abc.Mapping): 163 | for k, v in b.items(): 164 | if k in a and isinstance(a[k], abc.Mapping) and isinstance(v, abc.Mapping): 165 | merge_metainfo(a[k], v) 166 | elif k in a and v is None: 167 | del a[k] 168 | elif v is not None: 169 | a[k] = v 170 | else: 171 | raise ValueError(f'Not a JSON object: {b}') 172 | 173 | 174 | _DATE_FORMATS = ('%Y-%m-%d %H:%M:%S', 175 | '%Y-%m-%dT%H:%M:%S', 176 | '%Y-%m-%d %H:%M', 177 | '%Y-%m-%dT%H:%M', 178 | '%Y-%m-%d') 179 | def parse_date(date_str): 180 | if date_str == 'now': 181 | return datetime.datetime.now() 182 | elif date_str == 'today': 183 | return datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) 184 | elif isinstance(date_str, str): 185 | for f in _DATE_FORMATS: 186 | try: 187 | return datetime.datetime.strptime(date_str, f) 188 | except ValueError: 189 | pass 190 | raise ValueError('Invalid date') 191 | 192 | 193 | _PREFIXES = ((1024**4, 'Ti'), (1024**3, 'Gi'), (1024**2, 'Mi'), (1024, 'Ki')) 194 | def bytes2string(b, plain_bytes=False, trailing_zeros=False): 195 | string = str(b) 196 | prefix = '' 197 | for minval,_prefix in _PREFIXES: 198 | if b >= minval: 199 | prefix = _prefix 200 | string = f'{b / minval:.02f}' 201 | # Remove trailing zeros after the point 202 | while not trailing_zeros and string[-1] == '0': 203 | string = string[:-1] 204 | if not trailing_zeros: 205 | if string[-1] == '.': 206 | string = string[:-1] 207 | break 208 | if plain_bytes and prefix: 209 | return f'{string} {prefix}B / {b:,} B' 210 | else: 211 | return f'{string} {prefix}B' 212 | 213 | 214 | @contextlib.contextmanager 215 | def caught_BrokenPipeError(): 216 | try: 217 | yield 218 | except BrokenPipeError: 219 | # Prevent Python interpreter from printing redundant error message 220 | # "BrokenPipeError: [Errno 32] Broken pipe" and exit with correct exit 221 | # code. 222 | # https://bugs.python.org/issue11380#msg248579 223 | try: 224 | sys.stdout.flush() 225 | finally: 226 | try: 227 | sys.stdout.close() 228 | finally: 229 | try: 230 | sys.stderr.flush() 231 | finally: 232 | sys.stderr.close() 233 | sys.exit(0) 234 | 235 | def flush(f): 236 | with caught_BrokenPipeError(): 237 | f.flush() 238 | 239 | 240 | # torf.Torrent.metainfo stores boolean values (i.e. "private") as True/False 241 | # and JSON converts them to true/false, but bencode doesn't know booleans 242 | # and uses integers (1/0) instead. 243 | def bool2int(obj): 244 | if isinstance(obj, bool): 245 | return int(obj) 246 | elif isinstance(obj, abc.Mapping): 247 | return {k:bool2int(v) for k,v in obj.items()} 248 | elif isinstance(obj, abc.Iterable) and not isinstance(obj, (str, bytes, bytearray)): 249 | return [bool2int(item) for item in obj] 250 | else: 251 | return obj 252 | 253 | _main_fields = ('announce', 'announce-list', 'comment', 254 | 'created by', 'creation date', 'encoding', 255 | 'info', 'url-list', 'httpseed') 256 | _info_fields = ('files', 'length', 'md5sum', 'name', 'piece length', 'private') 257 | _files_fields = ('length', 'path', 'md5sum') 258 | def metainfo(dct, all_fields=False, remove_pieces=True): 259 | """ 260 | Return user-friendly copy of metainfo `dct` 261 | 262 | all_fields: Whether to remove any non-standard entries in `dct` 263 | remove_pieces: Whether to remove ['info']['pieces'] 264 | """ 265 | 266 | def copy(obj, only=(), exclude=()): 267 | if isinstance(obj, abc.Mapping): 268 | cp = type(obj)() 269 | for k,v in obj.items(): 270 | if k not in exclude and (not only or k in only): 271 | cp[k] = copy(v) 272 | return cp 273 | elif isinstance(obj, abc.Iterable) and not isinstance(obj, (str, bytes, bytearray)): 274 | return [copy(v) for v in obj] 275 | else: 276 | return obj 277 | 278 | new = copy(dct) 279 | 280 | if remove_pieces: 281 | if 'pieces' in new.get('info', {}): 282 | del new['info']['pieces'] 283 | 284 | if not all_fields: 285 | # Remove non-standard top-level fields 286 | for k in tuple(new): 287 | if k not in _main_fields: 288 | del new[k] 289 | 290 | # Remove non-standard fields in "info" or "info" itself if non-dict 291 | if 'info' in new: 292 | if not isinstance(new['info'], dict): 293 | del new['info'] 294 | else: 295 | for k in tuple(new['info']): 296 | if k not in _info_fields: 297 | del new['info'][k] 298 | 299 | if 'files' in new['info'] and isinstance(new['info']['files'], list): 300 | for file in new['info']['files']: 301 | for k in tuple(file): 302 | if k not in _files_fields: 303 | del file[k] 304 | 305 | if 'info' in new and not new['info']: 306 | del new['info'] 307 | 308 | return bool2int(new) 309 | 310 | def json_dumps(obj): 311 | def default(obj): 312 | if isinstance(obj, datetime.datetime): 313 | return int(obj.timestamp()) 314 | elif isinstance(obj, (bytes, bytearray)): 315 | return base64.standard_b64encode(obj).decode() 316 | else: 317 | return str(obj) 318 | return json.dumps(obj, allow_nan=False, indent=4, default=default) + '\n' 319 | -------------------------------------------------------------------------------- /torfcli/_vars.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details 10 | # http://www.gnu.org/licenses/gpl-3.0.txt 11 | 12 | __version__ = '5.2.1' 13 | __appname__ = 'torf' 14 | __url__ = 'https://github.com/rndusr/torf-cli' 15 | __description__ = 'CLI tool to create, read and edit torrent files' 16 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py313, py312, py311, py310, py39, py38, lint 3 | 4 | [testenv] 5 | deps = 6 | pytest 7 | ../torf 8 | commands = 9 | pytest {posargs} 10 | 11 | [testenv:lint] 12 | deps = 13 | ruff 14 | flake8 15 | isort 16 | commands = 17 | ruff check . 18 | flake8 torfcli tests 19 | isort --check-only torfcli tests 20 | --------------------------------------------------------------------------------