From b5a2facefba0ca977c58ce360227090ed40ff3fb Mon Sep 17 00:00:00 2001 From: Richard Terry Date: Wed, 25 Sep 2019 02:12:42 +0100 Subject: [PATCH] Add per-config process locking --- README.rst | 22 ++++++++++++++++++++++ serac/commands.py | 12 +++++++++++- serac/config.py | 10 +++++----- tests/test_config.py | 12 ++++++------ 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/README.rst b/README.rst index 755bf24..0e58216 100644 --- a/README.rst +++ b/README.rst @@ -47,6 +47,9 @@ To run serac:: /path/to/venv/bin/serac CONFIG COMMAND [OPTIONS] +It is safe to run Serac from a cron job; it will not allow multiple processes to work +with the same config file at the same time. + Commands -------- @@ -165,3 +168,22 @@ To run tests:: cd serac/repo . ../venv/bin/activate pytest + + +Changelog +========= + +0.0.2, 2019-09-25 +----------------- + +Feature: + +* Add process locking + + +0.0.1, 2019-09-23 +----------------- + +Feature: + +* Initial release diff --git a/serac/commands.py b/serac/commands.py index e95c63e..e074f54 100644 --- a/serac/commands.py +++ b/serac/commands.py @@ -1,6 +1,7 @@ """ Commands """ +import fcntl import sys from datetime import datetime from pathlib import Path @@ -46,12 +47,21 @@ def __repr__(self): # pragma: no cover type=click.Path(exists=True, file_okay=True, dir_okay=False, resolve_path=True), ) @click.pass_context -def cli(ctx, config): +def cli(ctx, config: str): try: ctx.obj["config"] = Config(config) except Exception as e: raise click.ClickException(f"Invalid config: {e}") + # Lock - only one process on a config at a time + ctx.obj["lock"] = open(config, "r") + try: + fcntl.flock(ctx.obj["lock"], fcntl.LOCK_EX | fcntl.LOCK_NB) + except IOError: + raise click.ClickException( + f"Config {config} is already in use by another process" + ) + @cli.command() @click.pass_context diff --git a/serac/config.py b/serac/config.py index 05378b8..0aeb3cc 100644 --- a/serac/config.py +++ b/serac/config.py @@ -108,15 +108,15 @@ class Config: archive: ArchiveConfig index: IndexConfig - def __init__(self, path: Path = None) -> None: - if path: - self.load(path) + def __init__(self, filename: str = None) -> None: + if filename: + self.load(filename) - def load(self, path: Path) -> None: + def load(self, filename: str) -> None: parser = ConfigParser() # Let parsing errors go through unchanged - parser.read(path) + parser.read(filename) if sorted(parser.sections()) != sorted(self.sections): raise ValueError( diff --git a/tests/test_config.py b/tests/test_config.py index c9bc53e..5a26765 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -18,7 +18,7 @@ def test_parser_source__valid(fs): ) fs.create_dir("/path/to") fs.create_dir("/path/to/backup") - config = Config(path=Path("/sample.conf")) + config = Config(filename="/sample.conf") assert isinstance(config.source, SourceConfig) assert config.source.includes == ["/path/to/source", "/path/somewhere/else"] @@ -48,7 +48,7 @@ def test_parser_archive__local(fs): ) fs.create_dir("/path/to") fs.create_dir("/path/to/backup") - config = Config(path=Path("/sample.conf")) + config = Config(filename="/sample.conf") assert isinstance(config.archive, ArchiveConfig) assert isinstance(config.archive.storage, Local) @@ -61,7 +61,7 @@ def test_parser_archive__s3(fs): "/sample.conf", contents=SAMPLE_CONFIG.format(storage=SAMPLE_STORAGE_S3) ) fs.create_dir("/path/to") - config = Config(path=Path("/sample.conf")) + config = Config(filename="/sample.conf") assert isinstance(config.archive, ArchiveConfig) assert isinstance(config.archive.storage, S3) @@ -107,7 +107,7 @@ def test_parser_index(fs): ) fs.create_dir("/path/to") fs.create_dir("/path/to/backup") - config = Config(path=Path("/sample.conf")) + config = Config(filename="/sample.conf") assert isinstance(config.index, IndexConfig) assert config.index.path == Path("/path/to/index.sqlite") @@ -153,7 +153,7 @@ def test_parser_config__sections_missing__raises_exception(fs): ) with pytest.raises(ValueError) as e: - Config(path=Path("/sample.conf")) + Config(filename="/sample.conf") assert str(e.value) == ( "Invalid config file; must contain source, archive and " f"index sections; instead found invalid" @@ -172,7 +172,7 @@ def test_parser_config__archive_section_missing__raises_exception(fs): ) with pytest.raises(ValueError) as e: - Config(path=Path("/sample.conf")) + Config(filename="/sample.conf") assert str(e.value) == ( "Invalid config file; must contain source, archive and " f"index sections; instead found source, index"