diff --git a/poetry.lock b/poetry.lock index 6ce18e6..ccdee07 100644 --- a/poetry.lock +++ b/poetry.lock @@ -137,6 +137,24 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "filelock" +version = "3.12.3" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.12.3-py3-none-any.whl", hash = "sha256:f067e40ccc40f2b48395a80fcbd4728262fab54e232e090a4063ab804179efeb"}, + {file = "filelock-3.12.3.tar.gz", hash = "sha256:0ecc1dd2ec4672a10c8550a8182f1bd0c0a5088470ecd5a125e45f49472fac3d"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.7.1", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3)", "diff-cover (>=7.7)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-timeout (>=2.1)"] + [[package]] name = "idna" version = "3.4" @@ -268,4 +286,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" python-versions = "^3.10.4" -content-hash = "94d19787d862d95613c125368347112edf9d0d96432993884af68dc357603446" +content-hash = "cd7f95639b0867a577285e2d8bf6f2fdd1d8f670c7341af453224a2c1824fecb" diff --git a/pyproject.toml b/pyproject.toml index 95498bc..3656605 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ include = ["generate_completions.sh", "tldr-man.1"] python = "^3.10.4" click = "^8.1.7" click-help-colors = "^0.9.2" +filelock = "^3.12.3" requests = "^2.28.1" [tool.poetry.group.dev.dependencies] diff --git a/src/tldr_man/languages.py b/src/tldr_man/languages.py index e135478..3a049d1 100644 --- a/src/tldr_man/languages.py +++ b/src/tldr_man/languages.py @@ -21,14 +21,16 @@ from tldr_man.color import style_input from tldr_man.errors import Fail -from tldr_man.pages import CACHE_DIR, language_directory_to_code, iter_dirs +from tldr_man.pages import CACHE_DIR, cache_dir_lock, language_directory_to_code, iter_dirs +@cache_dir_lock def all_languages() -> Iterator[str]: """Returns an iterator of all languages directory names.""" return map(get_language_directory, all_language_codes()) +@cache_dir_lock def all_language_codes() -> Iterator[str]: """Returns an iterator of all language codes, based on all language directories.""" return ( @@ -76,6 +78,7 @@ def _language_code_as_parts(language_code: str) -> tuple[str, str]: return language, region +@cache_dir_lock def get_language_directory(language_code: str) -> str: """Get the name of the directory for a language code.""" language, region = _language_code_as_parts(language_code) @@ -89,6 +92,7 @@ def get_language_directory(language_code: str) -> str: return f'pages.{language}' +@cache_dir_lock def get_locales(ctx: Context) -> list[str]: """Return an ordered list of the languages that the user specifies.""" language = ctx.params.get('language') diff --git a/src/tldr_man/main.py b/src/tldr_man/main.py index 6882584..badb1ae 100755 --- a/src/tldr_man/main.py +++ b/src/tldr_man/main.py @@ -38,6 +38,7 @@ from tldr_man import pages from tldr_man.color import HELP_COLORS +from tldr_man.pages import cache_dir_lock from tldr_man.shell_completion import page_shell_complete, language_shell_complete from tldr_man.languages import get_locales from tldr_man.platforms import get_page_sections, TLDR_PLATFORMS @@ -82,6 +83,7 @@ def require_tldr_cache(func: Callable[Concatenate[list[str], list[str], P], T]) func(locales, platforms, ...) --> func(...) """ @wraps(func) + @cache_dir_lock def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: pages.verify_tldr_cache_exists() @@ -163,8 +165,11 @@ def subcommand_manpath(locales: list[str], page_sections: list[str]) -> None: def cli(locales: list[str], page_sections: list[str], page: list[str]) -> None: """TLDR client that displays tldr-pages as manpages""" page_name = '-'.join(page).strip().lower() - page_path = pages.find_page(page_name, locales, page_sections) - pages.display_page(page_path) + page_file = pages.find_page(page_name, locales, page_sections) + with temp_file(page_file.name) as temp_page_file: + temp_page_file.write_bytes(page_file.read_bytes()) + cache_dir_lock.release(force=True) + pages.display_page(temp_page_file) if __name__ == '__main__': diff --git a/src/tldr_man/pages.py b/src/tldr_man/pages.py index 92da7f5..340ccc6 100644 --- a/src/tldr_man/pages.py +++ b/src/tldr_man/pages.py @@ -28,6 +28,7 @@ import requests from click import style, echo, secho, progressbar, format_filename +from filelock import FileLock from tldr_man.color import style_command, style_path, style_url from tldr_man.errors import Fail, NoPageCache, ExternalCommandNotFound, PageNotFound, eprint @@ -79,6 +80,7 @@ def get_cache_dir() -> Path: CACHE_DIR: Path = get_cache_dir() +cache_dir_lock = FileLock(CACHE_DIR.parent / f'.{CACHE_DIR.name}.lock', timeout=2) def download_archive(location: Path, url: str = ZIP_ARCHIVE_URL) -> None: @@ -172,12 +174,13 @@ def to_manpage(tldr_page: zipfile.Path) -> tuple[str, str]: res_file.write_text(manpage) # Log whether the file was created, updated, or unchanged: - if original_dir is None or not (original_file := original_dir / filename).exists(): - created += 1 - elif original_file.read_text() != manpage: - updated += 1 - else: - unchanged += 1 + with cache_dir_lock: + if original_dir is None or not (original_file := original_dir / filename).exists(): + created += 1 + elif original_file.read_text() != manpage: + updated += 1 + else: + unchanged += 1 except: # If an exception occurs, such as a KeyboardInterrupt or an actual Exception, # shutdown the pool *without* waiting for any remaining futures to finish. This will prevent the @@ -190,12 +193,13 @@ def to_manpage(tldr_page: zipfile.Path) -> tuple[str, str]: # Now that the updated cache has been generated, remove the old cache, make sure the parent directory exists, # and move the new cache into the correct directory from the temporary directory. - ensure_cache_dir_update_safety() - with suppress(FileNotFoundError): - rmtree(CACHE_DIR) + with cache_dir_lock: + ensure_cache_dir_update_safety() + with suppress(FileNotFoundError): + rmtree(CACHE_DIR) - makedirs(CACHE_DIR.parent, exist_ok=True) - move(temp_cache_dir, CACHE_DIR) + makedirs(CACHE_DIR.parent, exist_ok=True) + move(temp_cache_dir, CACHE_DIR) # Display the details for the cache update: echo(', '.join([ @@ -209,6 +213,7 @@ def to_manpage(tldr_page: zipfile.Path) -> tuple[str, str]: EXPECTED_CACHE_CONTENT_PATTERN = re.compile(r'^pages(?:\.\w{2}(?:_\w{2})?)?$') +@cache_dir_lock def ensure_cache_dir_update_safety() -> None: """Make sure not to overwrite directories with user data in them.""" @@ -280,6 +285,7 @@ def pandoc_exists() -> bool: return False +@cache_dir_lock def verify_tldr_cache_exists() -> None: """Display a specific message if the tldr manpage cache doesn't exist yet, and then exit.""" if not CACHE_DIR.exists(): @@ -296,6 +302,7 @@ def display_page(page: Path) -> None: raise +@cache_dir_lock def find_page(page_name: str, /, locales: Iterable[str], page_sections: Iterable[str]) -> Path: for search_dir in get_dir_search_order(locales, page_sections): page = search_dir / (page_name + '.' + MANPAGE_SECTION) @@ -316,6 +323,7 @@ def unique(items: Iterable[T]) -> Iterator[T]: yield item +@cache_dir_lock def get_dir_search_order(locales: Iterable[str], page_sections: Iterable[str]) -> Iterator[Path]: return unique( CACHE_DIR / locale / section / ('man' + MANPAGE_SECTION) diff --git a/src/tldr_man/shell_completion.py b/src/tldr_man/shell_completion.py index 6b829ed..e6feb6c 100644 --- a/src/tldr_man/shell_completion.py +++ b/src/tldr_man/shell_completion.py @@ -17,11 +17,12 @@ from click import Context, Parameter from click.shell_completion import CompletionItem -from tldr_man.pages import CACHE_DIR, get_dir_search_order +from tldr_man.pages import CACHE_DIR, cache_dir_lock, get_dir_search_order from tldr_man.languages import get_locales, all_language_codes from tldr_man.platforms import get_page_sections +@cache_dir_lock def page_shell_complete(ctx: Context, param: Parameter, incomplete: str) -> list[CompletionItem]: if not CACHE_DIR.exists() or param.name is None: # the `param.name is None` check makes the type checker happy return [] @@ -40,6 +41,7 @@ def page_shell_complete(ctx: Context, param: Parameter, incomplete: str) -> list ] +@cache_dir_lock def language_shell_complete(_ctx: Context, _param: Parameter, _incomplete: str) -> list[CompletionItem]: if not CACHE_DIR.exists(): return []