Repository: Backblaze/b2-sdk-python

Issue:
No public API for getting a list of file ids for a filename
In order to delete a file you need to know the file id of the version to delete but unless I'm missing something there is no straightforward way to list the different versions of a file with a given filename. `bucket.ls` exists but since it enforces a trailing `/` on the folder you pass the only way I can see right now to get this through the public API is to do something like this:

```python
def get_file_ids(bucket, filename):
    for file_info, folder_name in bucket.ls(folder_to_list=os.path.dirname(filename), show_versions=True):
        if file_info.file_name == filename:
            yield file_info
```

Obviously this potentially wastes bandwidth by returning all files in the same "folder".

Comments:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 3e06cfe7..806c817e 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -37,9 +37,9 @@ matrix:
     # Enable nightly without globally enabling sudo and xenial for other build jobs
     # disabled because nosetests is not supported on 3.9. We'll move to pytest.
     # disabled by commenting out because Travis does not listen to matrix.allow_failures.python = [pypy3, nightly] for some reason
-    #- python: nightly
-    #  dist: xenial
-    #  sudo: true
+    - python: nightly
+      #dist: xenial
+      #sudo: true
 
     - python: 3
       os: osx
diff --git a/README.md b/README.md
index da6f4932..6b12564f 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ b2sdk>=0.0.0,<1.0.0
 
 * Make sync treat hidden files as deleted
 * Remove arrow warnings caused by https://github.com/crsmithdev/arrow/issues/612
+* Add a `list_file_versions` to buckets.
 
 ## 1.0.2 (2019-10-15)
 
diff --git a/b2sdk/b2http.py b/b2sdk/b2http.py
index 4fe5a172..c2230bb9 100644
--- a/b2sdk/b2http.py
+++ b/b2sdk/b2http.py
@@ -307,7 +307,12 @@ class B2Http(object):
             self._run_post_request_hooks('POST', url, headers, response)
             return response
 
-        response = _translate_and_retry(do_post, try_count, post_params)
+        try:
+            response = _translate_and_retry(do_post, try_count, post_params)
+        except B2RequestTimeout:
+            # this forces a token refresh, which is necessary if request is still alive
+            # on the server but has terminated for some reason on the client. See #79
+            raise B2RequestTimeoutDuringUpload()
 
         # Decode the JSON that came back.  If we've gotten this far,
         # we know we have a status of 200 OK.  In this case, the body
diff --git a/b2sdk/bucket.py b/b2sdk/bucket.py
index 15d45f21..b95dd265 100644
--- a/b2sdk/bucket.py
+++ b/b2sdk/bucket.py
@@ -196,6 +196,36 @@ class Bucket(object):
         """
         return self.api.list_parts(file_id, start_part_number, batch_size)
 
+    def list_file_versions(self, file_name, fetch_count=None):
+        """
+        Lists all of the versions for a single file.
+
+        :param str file_name: the name of the file to list.
+        :param int,None fetch_count: how many entries to list per API call or ``None`` to use the default. Acceptable values: 1 - 10000
+        :rtype: generator[b2sdk.v1.FileVersionInfo]
+        """
+        if fetch_count is not None and fetch_count <= 0:
+            # fetch_count equal to 0 means "use API default", which we don't want to support here
+            raise ValueError("unsupported fetch_count value")
+        start_file_name = file_name
+        start_file_id = None
+        session = self.api.session
+        while 1:
+            response = session.list_file_versions(
+                self.id_, start_file_name, start_file_id, fetch_count, file_name
+            )
+
+            for entry in response['files']:
+                file_version_info = FileVersionInfoFactory.from_api_response(entry)
+                if file_version_info.file_name != file_name:
+                    # All versions for the requested file name have been listed.
+                    return
+                yield file_version_info
+            start_file_name = response['nextFileName']
+            start_file_id = response['nextFileId']
+            if start_file_name is None:
+                return
+
     def ls(self, folder_to_list='', show_versions=False, recursive=False, fetch_count=None):
         """
         Pretend that folders exist and yields the information about the files in a folder.
diff --git a/b2sdk/exception.py b/b2sdk/exception.py
index 61d9fe5f..fdeb2d63 100644
--- a/b2sdk/exception.py
+++ b/b2sdk/exception.py
@@ -108,8 +108,9 @@ class BadJson(B2SimpleError):
     prefix = 'Bad request'
 
 
-class BadUploadUrl(TransientErrorMixin, B2SimpleError):
-    pass
+class BadUploadUrl(B2SimpleError):
+    def should_retry_upload(self):
+        return True
 
 
 class BrokenPipe(B2Error):
@@ -209,6 +210,12 @@ class B2RequestTimeout(TransientErrorMixin, B2SimpleError):
     pass
 
 
+class B2RequestTimeoutDuringUpload(B2RequestTimeout):
+    # if a timeout is hit during upload, it is not guaranteed that the the server has released the upload token lock already, so we'll use a new token
+    def should_retry_http(self):
+        return False
+
+
 class DestFileNewer(B2Error):
     def __init__(self, dest_file, source_file, dest_prefix, source_prefix):
         super(DestFileNewer, self).__init__()
diff --git a/b2sdk/raw_simulator.py b/b2sdk/raw_simulator.py
index 6b5a091d..50fa5ff4 100644
--- a/b2sdk/raw_simulator.py
+++ b/b2sdk/raw_simulator.py
@@ -510,8 +510,10 @@ class BucketSimulator(object):
         next_file_id = None
         for key in sorted(six.iterkeys(self.file_name_and_id_to_file)):
             (file_name, file_id) = key
-            if (start_file_name <
-                file_name) or (start_file_name == file_name and start_file_id <= file_id):
+            if (start_file_name < file_name) or (
+                start_file_name == file_name and
+                (start_file_id == '' or int(start_file_id) <= int(file_id))
+            ):
                 file_sim = self.file_name_and_id_to_file[key]
                 if prefix is not None and not file_name.startswith(prefix):
                     break
diff --git a/b2sdk/v1/exception.py b/b2sdk/v1/exception.py
index cf149a68..7815a28f 100644
--- a/b2sdk/v1/exception.py
+++ b/b2sdk/v1/exception.py
@@ -18,6 +18,7 @@ from b2sdk.exception import B2HttpCallbackException
 from b2sdk.exception import B2HttpCallbackPostRequestException
 from b2sdk.exception import B2HttpCallbackPreRequestException
 from b2sdk.exception import B2RequestTimeout
+from b2sdk.exception import B2RequestTimeoutDuringUpload
 from b2sdk.exception import B2SimpleError
 from b2sdk.exception import BadDateFormat
 from b2sdk.exception import BadFileInfo
@@ -73,6 +74,7 @@ __all__ = (
     'B2HttpCallbackPostRequestException',
     'B2HttpCallbackPreRequestException',
     'B2RequestTimeout',
+    'B2RequestTimeoutDuringUpload',
     'B2SimpleError',
     'BadDateFormat',
     'BadFileInfo',


Test patch:
diff --git a/test/v0/test_exception.py b/test/v0/test_exception.py
index 5bcb7fca..a90e5329 100644
--- a/test/v0/test_exception.py
+++ b/test/v0/test_exception.py
@@ -50,7 +50,7 @@ class TestExceptions(TestBase):
         try:
             raise BadUploadUrl('foo')
         except BadUploadUrl as e:
-            assert e.should_retry_http()
+            assert not e.should_retry_http()
             assert e.should_retry_upload()
             assert str(e) == 'Bad upload url: foo', str(e)
 
diff --git a/test/v1/test_bucket.py b/test/v1/test_bucket.py
index 0dcccab7..8f71157a 100644
--- a/test/v1/test_bucket.py
+++ b/test/v1/test_bucket.py
@@ -21,6 +21,7 @@ from .test_base import TestBase
 from .deps_exception import (
     AlreadyFailed,
     B2Error,
+    B2RequestTimeoutDuringUpload,
     InvalidAuthToken,
     InvalidMetadataDirective,
     InvalidRange,
@@ -303,6 +304,81 @@ class TestLs(TestCaseWithBucket):
         self.assertBucketContents(expected, '', show_versions=True)
 
 
+class TestListVersions(TestCaseWithBucket):
+    def test_single_version(self):
+        data = six.b('hello world')
+        a_id = self.bucket.upload_bytes(data, 'a').id_
+        b_id = self.bucket.upload_bytes(data, 'b').id_
+        c_id = self.bucket.upload_bytes(data, 'c').id_
+
+        expected = [(a_id, 'a', 11, 'upload')]
+        actual = [
+            (info.id_, info.file_name, info.size, info.action)
+            for info in self.bucket.list_file_versions('a')
+        ]
+        self.assertEqual(expected, actual)
+
+        expected = [(b_id, 'b', 11, 'upload')]
+        actual = [
+            (info.id_, info.file_name, info.size, info.action)
+            for info in self.bucket.list_file_versions('b')
+        ]
+        self.assertEqual(expected, actual)
+
+        expected = [(c_id, 'c', 11, 'upload')]
+        actual = [
+            (info.id_, info.file_name, info.size, info.action)
+            for info in self.bucket.list_file_versions('c')
+        ]
+        self.assertEqual(expected, actual)
+
+    def test_multiple_version(self):
+        a_id1 = self.bucket.upload_bytes(six.b('first version'), 'a').id_
+        a_id2 = self.bucket.upload_bytes(six.b('second version'), 'a').id_
+        a_id3 = self.bucket.upload_bytes(six.b('last version'), 'a').id_
+
+        expected = [
+            (a_id3, 'a', 12, 'upload'), (a_id2, 'a', 14, 'upload'), (a_id1, 'a', 13, 'upload')
+        ]
+        actual = [
+            (info.id_, info.file_name, info.size, info.action)
+            for info in self.bucket.list_file_versions('a')
+        ]
+        self.assertEqual(expected, actual)
+
+    def test_ignores_subdirectory(self):
+        data = six.b('hello world')
+        file_id = self.bucket.upload_bytes(data, 'a/b').id_
+        self.bucket.upload_bytes(data, 'a/b/c')
+
+        expected = [(file_id, 'a/b', 11, 'upload')]
+        actual = [
+            (info.id_, info.file_name, info.size, info.action)
+            for info in self.bucket.list_file_versions('a/b')
+        ]
+        self.assertEqual(expected, actual)
+
+    def test_all_versions_in_response(self):
+        data = six.b('hello world')
+        file_id = self.bucket.upload_bytes(data, 'a/b').id_
+        self.bucket.upload_bytes(data, 'a/b/c')
+
+        expected = [(file_id, 'a/b', 11, 'upload')]
+        actual = [
+            (info.id_, info.file_name, info.size, info.action)
+            for info in self.bucket.list_file_versions('a/b', fetch_count=1)
+        ]
+        self.assertEqual(expected, actual)
+
+    def test_bad_fetch_count(self):
+        try:
+            # Convert to a list to cause the generator to execute.
+            list(self.bucket.list_file_versions('a', fetch_count=0))
+            self.fail('should have raised ValueError')
+        except ValueError as e:
+            self.assertEqual('unsupported fetch_count value', str(e))
+
+
 class TestCopyFile(TestCaseWithBucket):
     def test_copy_without_optional_params(self):
         file_id = self._make_file()
@@ -452,6 +528,11 @@ class TestUpload(TestCaseWithBucket):
         data = six.b('hello world')
         self.bucket.upload_bytes(data, 'file1')
 
+    def test_upload_timeout(self):
+        self.simulator.set_upload_errors([B2RequestTimeoutDuringUpload()])
+        data = six.b('hello world')
+        self.bucket.upload_bytes(data, 'file1')
+
     def test_upload_file_one_fatal_error(self):
         self.simulator.set_upload_errors([CanRetry(False)])
         data = six.b('hello world')
@@ -553,6 +634,18 @@ class TestUpload(TestCaseWithBucket):
         self._check_file_contents('file1', data)
         self.assertEqual("600: 200 400 600 closed", progress_listener.get_history())
 
+    def test_upload_large_file_with_restricted_api_key(self):
+        self.simulator.key_id_to_key[self.account_id].name_prefix_or_none = 'path/to'
+        part_size = self.simulator.MIN_PART_SIZE
+        data = self._make_data(part_size * 3)
+        progress_listener = StubProgressListener()
+        file_info = self.bucket.upload_bytes(
+            data, 'path/to/file1', progress_listener=progress_listener
+        )
+        self.assertEqual(len(data), file_info.size)
+        self._check_file_contents('path/to/file1', data)
+        self.assertEqual("600: 200 400 600 closed", progress_listener.get_history())
+
     def _start_large_file(self, file_name, file_info=None):
         if file_info is None:
             file_info = {}
diff --git a/test/v1/test_exception.py b/test/v1/test_exception.py
index a49969d8..2440a39f 100644
--- a/test/v1/test_exception.py
+++ b/test/v1/test_exception.py
@@ -50,7 +50,7 @@ class TestExceptions(TestBase):
         try:
             raise BadUploadUrl('foo')
         except BadUploadUrl as e:
-            assert e.should_retry_http()
+            assert not e.should_retry_http()
             assert e.should_retry_upload()
             assert str(e) == 'Bad upload url: foo', str(e)