diff --git a/CHANGELOG.md b/CHANGELOG.md index 078a8653..dcf397b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * Add `__slots__` and `__eq__` to `FileVersionInfo` for memory usage optimization and ease of testing * Add support for SSE-C server-side encryption mode +* Add support for `XDG_CONFIG_HOME` for determining the location of `SqliteAccountInfo` db file ### Changed * `BasicSyncEncryptionSettingsProvider` supports different settings sets for reading and writing diff --git a/b2sdk/_v2/__init__.py b/b2sdk/_v2/__init__.py index 3dc75e3c..ff2822ec 100644 --- a/b2sdk/_v2/__init__.py +++ b/b2sdk/_v2/__init__.py @@ -34,7 +34,7 @@ from b2sdk.account_info.abstract import AbstractAccountInfo from b2sdk.account_info.in_memory import InMemoryAccountInfo from b2sdk.account_info.sqlite_account_info import SqliteAccountInfo -from b2sdk.account_info.sqlite_account_info import B2_ACCOUNT_INFO_ENV_VAR, B2_ACCOUNT_INFO_DEFAULT_FILE +from b2sdk.account_info.sqlite_account_info import B2_ACCOUNT_INFO_ENV_VAR, B2_ACCOUNT_INFO_DEFAULT_FILE, XDG_CONFIG_HOME_ENV_VAR from b2sdk.account_info.stub import StubAccountInfo from b2sdk.account_info.upload_url_pool import UploadUrlPool from b2sdk.account_info.upload_url_pool import UrlPoolAccountInfo diff --git a/b2sdk/account_info/sqlite_account_info.py b/b2sdk/account_info/sqlite_account_info.py index a5aad526..1cd56fba 100644 --- a/b2sdk/account_info/sqlite_account_info.py +++ b/b2sdk/account_info/sqlite_account_info.py @@ -24,6 +24,7 @@ B2_ACCOUNT_INFO_ENV_VAR = 'B2_ACCOUNT_INFO' B2_ACCOUNT_INFO_DEFAULT_FILE = '~/.b2_account_info' +XDG_CONFIG_HOME_ENV_VAR = 'XDG_CONFIG_HOME' DEFAULT_ABSOLUTE_MINIMUM_PART_SIZE = 5000000 # this value is used ONLY in migrating db, and in v1 wrapper, it is not # meant to be a default for other applications @@ -40,22 +41,59 @@ class SqliteAccountInfo(UrlPoolAccountInfo): def __init__(self, file_name=None, last_upgrade_to_run=None): """ - If ``file_name`` argument is empty or ``None``, path from ``B2_ACCOUNT_INFO`` environment variable is used. If that is not available, a default of ``~/.b2_account_info`` is used. + Initialize SqliteAccountInfo. + + The exact algorithm used to determine the location of the database file is not API in any sense. + If the location of the database file is required (for cleanup, etc), do not assume a specific resolution: + instead, use ``self.filename`` to get the actual resolved location. + + SqliteAccountInfo currently checks locations in the following order: + + * ``file_name``, if truthy + * ``{B2_ACCOUNT_INFO_ENV_VAR}`` env var's value, if set + * ``{B2_ACCOUNT_INFO_DEFAULT_FILE}``, if it exists + * ``{XDG_CONFIG_HOME_ENV_VAR}/b2/account_info``, if ``{XDG_CONFIG_HOME_ENV_VAR}`` env var is set + * ``{B2_ACCOUNT_INFO_DEFAULT_FILE}``, as default + + If the directory ``{XDG_CONFIG_HOME_ENV_VAR}/b2`` does not exist (and is needed), it is created. :param str file_name: The sqlite file to use; overrides the default. :param int last_upgrade_to_run: For testing only, override the auto-update on the db. """ self.thread_local = threading.local() - user_account_info_path = file_name or os.environ.get( - B2_ACCOUNT_INFO_ENV_VAR, B2_ACCOUNT_INFO_DEFAULT_FILE - ) - self.filename = file_name or os.path.expanduser(user_account_info_path) + + if file_name: + user_account_info_path = file_name + elif B2_ACCOUNT_INFO_ENV_VAR in os.environ: + user_account_info_path = os.environ[B2_ACCOUNT_INFO_ENV_VAR] + elif os.path.exists(os.path.expanduser(B2_ACCOUNT_INFO_DEFAULT_FILE)): + user_account_info_path = B2_ACCOUNT_INFO_DEFAULT_FILE + elif XDG_CONFIG_HOME_ENV_VAR in os.environ: + config_home = os.environ[XDG_CONFIG_HOME_ENV_VAR] + user_account_info_path = os.path.join(config_home, 'b2', 'account_info') + if not os.path.exists(os.path.join(config_home, 'b2')): + os.makedirs(os.path.join(config_home, 'b2'), mode=0o755) + else: + user_account_info_path = B2_ACCOUNT_INFO_DEFAULT_FILE + + self.filename = os.path.expanduser(user_account_info_path) logger.debug('%s file path to use: %s', self.__class__.__name__, self.filename) + self._validate_database(last_upgrade_to_run) with self._get_connection() as conn: self._create_tables(conn, last_upgrade_to_run) super(SqliteAccountInfo, self).__init__() + # dirty trick to use parameters in the docstring + if getattr(__init__, '__doc__', None): # don't break when using `python -oo` + __init__.__doc__ = __init__.__doc__.format( + **dict( + B2_ACCOUNT_INFO_ENV_VAR=B2_ACCOUNT_INFO_ENV_VAR, + B2_ACCOUNT_INFO_DEFAULT_FILE=B2_ACCOUNT_INFO_DEFAULT_FILE, + XDG_CONFIG_HOME_ENV_VAR=XDG_CONFIG_HOME_ENV_VAR, + ) + ) + def _validate_database(self, last_upgrade_to_run=None): """ Make sure that the database is openable. Removes the file if it's not. diff --git a/b2sdk/v1/account_info.py b/b2sdk/v1/account_info.py index b9bdccc4..7b772983 100644 --- a/b2sdk/v1/account_info.py +++ b/b2sdk/v1/account_info.py @@ -10,12 +10,16 @@ from abc import abstractmethod import inspect +import logging +import os from typing import Optional from b2sdk import _v2 as v2 from b2sdk.account_info.sqlite_account_info import DEFAULT_ABSOLUTE_MINIMUM_PART_SIZE from b2sdk.utils import limit_trace_arguments +logger = logging.getLogger(__name__) + # Retain legacy get_minimum_part_size and facilitate for optional s3_api_url class OldAccountInfoMethods: @@ -168,7 +172,18 @@ class UrlPoolAccountInfo(OldAccountInfoMethods, v2.UrlPoolAccountInfo): class SqliteAccountInfo(MinimumPartSizeTranslator, OldAccountInfoMethods, v2.SqliteAccountInfo): - pass + def __init__(self, file_name=None, last_upgrade_to_run=None): + """ + If ``file_name`` argument is empty or ``None``, path from ``B2_ACCOUNT_INFO`` environment variable is used. If that is not available, a default of ``~/.b2_account_info`` is used. + + :param str file_name: The sqlite file to use; overrides the default. + :param int last_upgrade_to_run: For testing only, override the auto-update on the db. + """ + # use legacy env var resolution, XDG not supported + file_name = file_name or os.environ.get( + v2.B2_ACCOUNT_INFO_ENV_VAR, v2.B2_ACCOUNT_INFO_DEFAULT_FILE + ) + super().__init__(file_name=file_name, last_upgrade_to_run=last_upgrade_to_run) class StubAccountInfo(MinimumPartSizeTranslator, OldAccountInfoMethods, v2.StubAccountInfo): diff --git a/test/unit/account_info/test_account_info.py b/test/unit/account_info/test_account_info.py index 10bd287e..d4a8986a 100644 --- a/test/unit/account_info/test_account_info.py +++ b/test/unit/account_info/test_account_info.py @@ -13,16 +13,33 @@ import unittest.mock as mock import os import platform +import shutil import tempfile import pytest -from apiver_deps import AbstractAccountInfo, InMemoryAccountInfo, UploadUrlPool, SqliteAccountInfo +from apiver_deps import ( + AbstractAccountInfo, + InMemoryAccountInfo, + UploadUrlPool, + SqliteAccountInfo, + TempDir, + B2_ACCOUNT_INFO_ENV_VAR, + XDG_CONFIG_HOME_ENV_VAR, +) from apiver_deps_exception import CorruptAccountInfo, MissingAccountData from .fixtures import * +class WindowsSafeTempDir(TempDir): + def __exit__(self, exc_type, exc_val, exc_tb): + try: + super().__exit__(exc_type, exc_val, exc_tb) + except OSError: + pass + + class TestAccountInfo: @pytest.fixture(autouse=True) def setup(self, account_info_factory, account_info_default_data): @@ -297,12 +314,14 @@ def setUp(self, request): os.unlink(self.db_path) except OSError: pass - print('using %s' % self.db_path) + self.home = tempfile.mkdtemp() + yield - try: - os.unlink(self.db_path) - except OSError: - pass + for cleanup_method in [lambda: os.unlink(self.db_path), lambda: shutil.rmtree(self.home)]: + try: + cleanup_method + except OSError: + pass def test_corrupted(self): """ @@ -340,8 +359,71 @@ def test_convert_from_json(self): def _make_info(self): return self._make_sqlite_account_info() - def _make_sqlite_account_info(self, last_upgrade_to_run=None): + def _make_sqlite_account_info(self, env=None, last_upgrade_to_run=None): """ Returns a new SqliteAccountInfo that has just read the data from the file. + + :param dict env: Override Environment variables. """ - return SqliteAccountInfo(file_name=self.db_path, last_upgrade_to_run=None) + # Override HOME to ensure hermetic tests + with mock.patch('os.environ', env or {'HOME': self.home}): + return SqliteAccountInfo( + file_name=self.db_path if not env else None, + last_upgrade_to_run=last_upgrade_to_run, + ) + + def test_uses_default(self): + account_info = self._make_sqlite_account_info( + env={ + 'HOME': self.home, + 'USERPROFILE': self.home, + } + ) + actual_path = os.path.abspath(account_info.filename) + assert os.path.join(self.home, '.b2_account_info') == actual_path + + def test_uses_xdg_config_home(self, apiver): + with WindowsSafeTempDir() as d: + account_info = self._make_sqlite_account_info( + env={ + 'HOME': self.home, + 'USERPROFILE': self.home, + XDG_CONFIG_HOME_ENV_VAR: d, + } + ) + if apiver in ['v0', 'v1']: + expected_path = os.path.abspath(os.path.join(self.home, '.b2_account_info')) + else: + assert os.path.exists(os.path.join(d, 'b2')) + expected_path = os.path.abspath(os.path.join(d, 'b2', 'account_info')) + actual_path = os.path.abspath(account_info.filename) + assert expected_path == actual_path + + def test_uses_existing_file_and_ignores_xdg(self): + with WindowsSafeTempDir() as d: + default_db_file_location = os.path.join(self.home, '.b2_account_info') + open(default_db_file_location, 'a').close() + account_info = self._make_sqlite_account_info( + env={ + 'HOME': self.home, + 'USERPROFILE': self.home, + XDG_CONFIG_HOME_ENV_VAR: d, + } + ) + actual_path = os.path.abspath(account_info.filename) + assert default_db_file_location == actual_path + assert not os.path.exists(os.path.join(d, 'b2')) + + def test_account_info_env_var_overrides_xdg_config_home(self): + with WindowsSafeTempDir() as d: + account_info = self._make_sqlite_account_info( + env={ + 'HOME': self.home, + 'USERPROFILE': self.home, + XDG_CONFIG_HOME_ENV_VAR: d, + B2_ACCOUNT_INFO_ENV_VAR: os.path.join(d, 'b2_account_info'), + } + ) + expected_path = os.path.abspath(os.path.join(d, 'b2_account_info')) + actual_path = os.path.abspath(account_info.filename) + assert expected_path == actual_path