Repository: borgbackup/borg

Issue:
Progress output generates extra lines with some filenames
I'm running `borg create --no-files-cache --progress --numeric-owner` in an interactive terminal, and with most filenames, the command shows only one line of progress with some counters and the current filename. However, with some filenames, it seems to go on to the next line instead of overwriting the current line. Basically, in the screenshot below, I expect to see only the last line, not all the previous lines.

![image](https://user-images.githubusercontent.com/15314936/30412513-063b9a3e-98e6-11e7-85ff-efd61a011259.png)

Discussion:
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/borgbackup/borg/pull/7549?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup) Report
> Merging [#7549](https://app.codecov.io/gh/borgbackup/borg/pull/7549?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup) (aa6a0b1) into [1.2-maint](https://app.codecov.io/gh/borgbackup/borg/commit/d1ddd57eaf19c8b223ac1611ebb6ce0224284e07?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup) (d1ddd57) will **decrease** coverage by `0.09%`.
> The diff coverage is `100.00%`.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup)

```diff
@@              Coverage Diff              @@
##           1.2-maint    #7549      +/-   ##
=============================================
- Coverage      82.29%   82.21%   -0.09%     
=============================================
  Files             38       38              
  Lines          10627    10628       +1     
  Branches        2040     2031       -9     
=============================================
- Hits            8746     8738       -8     
- Misses          1336     1343       +7     
- Partials         545      547       +2     
```


| [Impacted Files](https://app.codecov.io/gh/borgbackup/borg/pull/7549?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup) | Coverage Δ | |
|---|---|---|
| [src/borg/helpers/progress.py](https://app.codecov.io/gh/borgbackup/borg/pull/7549?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup#diff-c3JjL2JvcmcvaGVscGVycy9wcm9ncmVzcy5weQ==) | `94.21% <100.00%> (+2.54%)` | :arrow_up: |

... and [4 files with indirect coverage changes](https://app.codecov.io/gh/borgbackup/borg/pull/7549/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup)



Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
deleted file mode 100644
index f7a03542..00000000
--- a/.pre-commit-config.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-repos:
--   repo: https://github.com/pycqa/flake8
-    rev: 6.0.0
-    hooks:
-    -   id: flake8
-        files: '(src|scripts|conftest.py)'
diff --git a/docs/development.rst b/docs/development.rst
index f589f25b..d54c6c4e 100644
--- a/docs/development.rst
+++ b/docs/development.rst
@@ -164,13 +164,6 @@ virtual env and run::
   pip install -r requirements.d/development.txt
 
 
-This project utilizes pre-commit to lint code before it is committed.
-Although pre-commit is installed when running the command above, the pre-commit hooks
-will have to be installed separately. Run this command to install the pre-commit hooks::
-
-  pre-commit install
-
-
 Running the tests
 -----------------
 
diff --git a/requirements.d/development.txt b/requirements.d/development.txt
index a8da71dc..3c30b2ac 100644
--- a/requirements.d/development.txt
+++ b/requirements.d/development.txt
@@ -11,4 +11,3 @@ pytest-benchmark
 Cython
 twine
 python-dateutil
-pre-commit
diff --git a/src/borg/compress.pyx b/src/borg/compress.pyx
index 012d124d..98a54650 100644
--- a/src/borg/compress.pyx
+++ b/src/borg/compress.pyx
@@ -15,7 +15,6 @@ which compressor has been used to compress the data and dispatch to the correct
 decompressor.
 """
 
-from argparse import ArgumentTypeError
 import random
 from struct import Struct
 import zlib
@@ -552,7 +551,7 @@ class CompressionSpec:
         values = s.split(',')
         count = len(values)
         if count < 1:
-            raise ArgumentTypeError("not enough arguments")
+            raise ValueError
         # --compression algo[,level]
         self.name = values[0]
         if self.name in ('none', 'lz4', ):
@@ -563,9 +562,9 @@ class CompressionSpec:
             elif count == 2:
                 level = int(values[1])
                 if not 0 <= level <= 9:
-                    raise ArgumentTypeError("level must be >= 0 and <= 9")
+                    raise ValueError
             else:
-                raise ArgumentTypeError("too many arguments")
+                raise ValueError
             self.level = level
         elif self.name in ('zstd', ):
             if count < 2:
@@ -573,28 +572,28 @@ class CompressionSpec:
             elif count == 2:
                 level = int(values[1])
                 if not 1 <= level <= 22:
-                    raise ArgumentTypeError("level must be >= 1 and <= 22")
+                    raise ValueError
             else:
-                raise ArgumentTypeError("too many arguments")
+                raise ValueError
             self.level = level
         elif self.name == 'auto':
             if 2 <= count <= 3:
                 compression = ','.join(values[1:])
             else:
-                raise ArgumentTypeError("bad arguments")
+                raise ValueError
             self.inner = CompressionSpec(compression)
         elif self.name == 'obfuscate':
             if 3 <= count <= 5:
                 level = int(values[1])
                 if not ((1 <= level <= 6) or (110 <= level <= 123)):
-                    raise ArgumentTypeError("level must be >= 1 and <= 6 or >= 110 and <= 123")
+                    raise ValueError
                 self.level = level
                 compression = ','.join(values[2:])
             else:
-                raise ArgumentTypeError("bad arguments")
+                raise ValueError
             self.inner = CompressionSpec(compression)
         else:
-            raise ArgumentTypeError("unsupported compression type")
+            raise ValueError
 
     @property
     def compressor(self):
diff --git a/src/borg/helpers/parseformat.py b/src/borg/helpers/parseformat.py
index 9dfda8d2..087c718b 100644
--- a/src/borg/helpers/parseformat.py
+++ b/src/borg/helpers/parseformat.py
@@ -98,7 +98,7 @@ def ChunkerParams(s):
     params = s.strip().split(',')
     count = len(params)
     if count == 0:
-        raise argparse.ArgumentTypeError('no chunker params given')
+        raise ValueError('no chunker params given')
     algo = params[0].lower()
     if algo == CH_FIXED and 2 <= count <= 3:  # fixed, block_size[, header_size]
         block_size = int(params[1])
@@ -109,11 +109,9 @@ def ChunkerParams(s):
             # or in-memory chunk management.
             # choose the block (chunk) size wisely: if you have a lot of data and you cut
             # it into very small chunks, you are asking for trouble!
-            raise argparse.ArgumentTypeError('block_size must not be less than 64 Bytes')
+            raise ValueError('block_size must not be less than 64 Bytes')
         if block_size > MAX_DATA_SIZE or header_size > MAX_DATA_SIZE:
-            raise argparse.ArgumentTypeError(
-                'block_size and header_size must not exceed MAX_DATA_SIZE [%d]' % MAX_DATA_SIZE
-            )
+            raise ValueError('block_size and header_size must not exceed MAX_DATA_SIZE [%d]' % MAX_DATA_SIZE)
         return algo, block_size, header_size
     if algo == 'default' and count == 1:  # default
         return CHUNKER_PARAMS
@@ -121,18 +119,14 @@ def ChunkerParams(s):
     if algo == CH_BUZHASH and count == 5 or count == 4:  # [buzhash, ]chunk_min, chunk_max, chunk_mask, window_size
         chunk_min, chunk_max, chunk_mask, window_size = (int(p) for p in params[count - 4:])
         if not (chunk_min <= chunk_mask <= chunk_max):
-            raise argparse.ArgumentTypeError('required: chunk_min <= chunk_mask <= chunk_max')
+            raise ValueError('required: chunk_min <= chunk_mask <= chunk_max')
         if chunk_min < 6:
             # see comment in 'fixed' algo check
-            raise argparse.ArgumentTypeError(
-                'min. chunk size exponent must not be less than 6 (2^6 = 64B min. chunk size)'
-            )
+            raise ValueError('min. chunk size exponent must not be less than 6 (2^6 = 64B min. chunk size)')
         if chunk_max > 23:
-            raise argparse.ArgumentTypeError(
-                'max. chunk size exponent must not be more than 23 (2^23 = 8MiB max. chunk size)'
-            )
+            raise ValueError('max. chunk size exponent must not be more than 23 (2^23 = 8MiB max. chunk size)')
         return CH_BUZHASH, chunk_min, chunk_max, chunk_mask, window_size
-    raise argparse.ArgumentTypeError('invalid chunker params')
+    raise ValueError('invalid chunker params')
 
 
 def FilesCacheMode(s):
@@ -140,13 +134,11 @@ def FilesCacheMode(s):
     VALID_MODES = ('cis', 'ims', 'cs', 'ms', 'cr', 'mr', 'd', 's')  # letters in alpha order
     entries = set(s.strip().split(','))
     if not entries <= set(ENTRIES_MAP):
-        raise argparse.ArgumentTypeError(
-            'cache mode must be a comma-separated list of: %s' % ','.join(sorted(ENTRIES_MAP))
-        )
+        raise ValueError('cache mode must be a comma-separated list of: %s' % ','.join(sorted(ENTRIES_MAP)))
     short_entries = {ENTRIES_MAP[entry] for entry in entries}
     mode = ''.join(sorted(short_entries))
     if mode not in VALID_MODES:
-        raise argparse.ArgumentTypeError('cache mode short must be one of: %s' % ','.join(VALID_MODES))
+        raise ValueError('cache mode short must be one of: %s' % ','.join(VALID_MODES))
     return mode
 
 
@@ -227,7 +219,7 @@ def SortBySpec(text):
     from .manifest import AI_HUMAN_SORT_KEYS
     for token in text.split(','):
         if token not in AI_HUMAN_SORT_KEYS:
-            raise argparse.ArgumentTypeError('Invalid sort key: %s' % token)
+            raise ValueError('Invalid sort key: %s' % token)
     return text.replace('timestamp', 'ts')
 
 
diff --git a/src/borg/helpers/progress.py b/src/borg/helpers/progress.py
index 312ff258..9784f80e 100644
--- a/src/borg/helpers/progress.py
+++ b/src/borg/helpers/progress.py
@@ -147,10 +147,12 @@ def show(self, current=None, increase=1, info=None):
             # truncate the last argument, if no space is available
             if info is not None:
                 if not self.json:
+                    from ..platform import swidth  # avoid circular import
+
                     # no need to truncate if we're not outputting to a terminal
                     terminal_space = get_terminal_size(fallback=(-1, -1))[0]
                     if terminal_space != -1:
-                        space = terminal_space - len(self.msg % tuple([pct] + info[:-1] + ['']))
+                        space = terminal_space - swidth(self.msg % tuple([pct] + info[:-1] + ['']))
                         info[-1] = ellipsis_truncate(info[-1], space)
                 return self.output(self.msg % tuple([pct] + info), justify=False, info=info)
 


Test patch:
diff --git a/src/borg/testsuite/compress.py b/src/borg/testsuite/compress.py
index 7c5dbcbe..b7818e99 100644
--- a/src/borg/testsuite/compress.py
+++ b/src/borg/testsuite/compress.py
@@ -1,4 +1,3 @@
-import argparse
 import os
 import zlib
 try:
@@ -199,7 +198,7 @@ def test_obfuscate():
 
 
 def test_compression_specs():
-    with pytest.raises(argparse.ArgumentTypeError):
+    with pytest.raises(ValueError):
         CompressionSpec('')
 
     assert isinstance(CompressionSpec('none').compressor, CNONE)
@@ -214,7 +213,7 @@ def test_compression_specs():
     zlib = CompressionSpec('zlib,9').compressor
     assert isinstance(zlib, ZLIB)
     assert zlib.level == 9
-    with pytest.raises(argparse.ArgumentTypeError):
+    with pytest.raises(ValueError):
         CompressionSpec('zlib,9,invalid')
 
     lzma = CompressionSpec('lzma').compressor
@@ -237,7 +236,7 @@ def test_compression_specs():
     assert isinstance(zstd, ZSTD)
     assert zstd.level == 22
 
-    with pytest.raises(argparse.ArgumentTypeError):
+    with pytest.raises(ValueError):
         CompressionSpec('lzma,9,invalid')
-    with pytest.raises(argparse.ArgumentTypeError):
+    with pytest.raises(ValueError):
         CompressionSpec('invalid')
diff --git a/src/borg/testsuite/helpers.py b/src/borg/testsuite/helpers.py
index 0785a82c..df7bed31 100644
--- a/src/borg/testsuite/helpers.py
+++ b/src/borg/testsuite/helpers.py
@@ -30,6 +30,7 @@
 from ..helpers import dash_open
 from ..helpers import iter_separated
 from ..helpers import eval_escapes
+from ..platform import is_win32, swidth
 
 from . import BaseTestCase, FakeInputs
 
@@ -348,19 +349,19 @@ def test_chunkerparams():
     assert ChunkerParams('fixed,4096') == ('fixed', 4096, 0)
     assert ChunkerParams('fixed,4096,200') == ('fixed', 4096, 200)
     # invalid values checking
-    with pytest.raises(ArgumentTypeError):
+    with pytest.raises(ValueError):
         ChunkerParams('crap,1,2,3,4')  # invalid algo
-    with pytest.raises(ArgumentTypeError):
+    with pytest.raises(ValueError):
         ChunkerParams('buzhash,5,7,6,4095')  # too small min. size
-    with pytest.raises(ArgumentTypeError):
+    with pytest.raises(ValueError):
         ChunkerParams('buzhash,19,24,21,4095')  # too big max. size
-    with pytest.raises(ArgumentTypeError):
+    with pytest.raises(ValueError):
         ChunkerParams('buzhash,23,19,21,4095')  # violates min <= mask <= max
-    with pytest.raises(ArgumentTypeError):
+    with pytest.raises(ValueError):
         ChunkerParams('fixed,63')  # too small block size
-    with pytest.raises(ArgumentTypeError):
+    with pytest.raises(ValueError):
         ChunkerParams('fixed,%d,%d' % (MAX_DATA_SIZE + 1, 4096))  # too big block size
-    with pytest.raises(ArgumentTypeError):
+    with pytest.raises(ValueError):
         ChunkerParams('fixed,%d,%d' % (4096, MAX_DATA_SIZE + 1))  # too big header size
 
 
@@ -896,6 +897,29 @@ def test_progress_percentage_sameline(capfd, monkeypatch):
     assert err == ' ' * 4 + '\r'
 
 
+@pytest.mark.skipif(is_win32, reason='no working swidth() implementation on this platform')
+def test_progress_percentage_widechars(capfd, monkeypatch):
+    st = 'スター・トレック'  # 'startrek' :-)
+    assert swidth(st) == 16
+    path = '/カーク船長です。'  # 'Captain Kirk'
+    assert swidth(path) == 17
+    spaces = ' ' * 4  # to avoid usage of '...'
+    width = len('100%') + 1 + swidth(st) + 1 + swidth(path) + swidth(spaces)
+    monkeypatch.setenv('COLUMNS', str(width))
+    monkeypatch.setenv('LINES', '1')
+    pi = ProgressIndicatorPercent(100, step=5, start=0, msg=f'%3.0f%% {st} %s')
+    pi.logger.setLevel('INFO')
+    pi.show(0, info=[path])
+    out, err = capfd.readouterr()
+    assert err == f'  0% {st} {path}{spaces}\r'
+    pi.show(100, info=[path])
+    out, err = capfd.readouterr()
+    assert err == f'100% {st} {path}{spaces}\r'
+    pi.finish()
+    out, err = capfd.readouterr()
+    assert err == ' ' * width + '\r'
+
+
 def test_progress_percentage_step(capfd, monkeypatch):
     # run the test as if it was in a 4x1 terminal
     monkeypatch.setenv('COLUMNS', '4')