diff --git a/.github/workflows/test_linux.yml b/.github/workflows/test_linux.yml index 3ce1f59..6214f25 100644 --- a/.github/workflows/test_linux.yml +++ b/.github/workflows/test_linux.yml @@ -1,13 +1,20 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python +# This workflow will install Python dependencies, run tests and lint with a +# variety of Python versions. For more information see: +# https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python +--- name: Python package on: push: - branches: [ "main", "feature-output-dir", "feature-bash-completion" ] + branches: [ + "main", + "feature-bash-completion", + "feature-latex-packages", + "feature-output-dir", + ] pull_request: - branches: [ "main" ] + branches: ["main"] jobs: build: @@ -19,32 +26,32 @@ jobs: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 pytest - python -m pip install hatch - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Install TeX Live - uses: zauguin/install-texlive@v3 - with: - packages: > - latex-bin latexmk scheme-basic makeindex imakeidx xkeyval - cache_version: 1 - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with hatch run pytest - run: | - # TEMPDIR is needed tor tex compilation based tests - export TMPDIR=$(mktemp -d -p .) - hatch run pytest -v - rm -fr ${TMPDIR} + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 pytest + python -m pip install hatch + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Install TeX Live + uses: zauguin/install-texlive@v3 + with: + packages: > + latex-bin latexmk scheme-basic makeindex imakeidx xkeyval float + cache_version: 1 + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with hatch run pytest + run: | + # TEMPDIR is needed tor tex compilation based tests + export TMPDIR=$(mktemp -d -p .) + hatch run pytest -v + rm -fr ${TMPDIR} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8adaa1d..3c3a275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [Unreleased] + +### Added + +- Option "-p/--packages": Save a `TeXPackages.json` file, listing system and + local latex packages used, inside output tarball. +- Rich formatting for printed messages for completion options. + ## [0.4.1] 2024-02-29 ### Fixed diff --git a/src/tartex/_completion.py b/src/tartex/_completion.py index 80ca916..07f3f3a 100644 --- a/src/tartex/_completion.py +++ b/src/tartex/_completion.py @@ -3,10 +3,14 @@ Module to help users with completion syntax for tartex """ +import contextlib +import os from pathlib import Path from shutil import copy2 -import os -from tartex.__about__ import __appname__ as APPNAME + +from rich import print as richprint + +from tartex.__about__ import __appname__ as APPNAME # noqa COMPFILE = { "bash": Path(f"bash-completion/completions/{APPNAME}"), @@ -19,35 +23,30 @@ class Completion: """Methods for helping users print or install shell completion""" - def __init__(self, shell, filename): + def __init__(self, shell_name, filename): """Initialise""" - self.shell = shell + self.shell = shell_name self.completion_file = Path(__file__).parent.joinpath("data", filename) self.data = self.completion_file.read_text(encoding="utf-8") install_root = Path( - os.getenv("XDG_DATA_HOME") or Path.home().joinpath(".local", "share") + os.getenv("XDG_DATA_HOME") + or Path.home().joinpath(".local", "share") ) self.install_dir = install_root.joinpath(COMPFILE[self.shell]).parent self.install_filename = COMPFILE[self.shell].name - def print(self): - """Print completion to stdout""" - print(self.data, end="") - def install(self, install_dir=None): """Install completion to path""" path = Path(install_dir or self.install_dir) - try: + with contextlib.suppress(FileExistsError): os.makedirs(path) - except FileExistsError: - pass inst_path = Path( copy2(self.completion_file, path.joinpath(self.install_filename)) ) - print( - f"Completion file for {self.shell} shell installed to" - f" {inst_path.parent}" + richprint( + f"✓ Completion file for [bold]{self.shell}[/] shell installed to" + f" [link={inst_path.parent.as_uri()}]{inst_path.parent}[/]" ) @@ -58,7 +57,7 @@ class BashCompletion(Completion): def __init__(self): """Initialise""" Completion.__init__( - self, shell="bash", filename="tartex-completion.bash" + self, shell_name="bash", filename="tartex-completion.bash" ) @@ -68,7 +67,9 @@ class ZshCompletion(Completion): def __init__(self): """Initialise""" - Completion.__init__(self, shell="zsh", filename="tartex-completion.zsh") + Completion.__init__( + self, shell_name="zsh", filename="tartex-completion.zsh" + ) class FishCompletion(Completion): @@ -78,5 +79,5 @@ class FishCompletion(Completion): def __init__(self): """Initialise""" Completion.__init__( - self, shell="fish", filename="tartex-completion.fish" + self, shell_name="fish", filename="tartex-completion.fish" ) diff --git a/src/tartex/_latex.py b/src/tartex/_latex.py index 6a12d3c..8f3831a 100644 --- a/src/tartex/_latex.py +++ b/src/tartex/_latex.py @@ -20,6 +20,9 @@ # Match only lines beginning with INPUT INPUT_RE = re.compile(r"^INPUT") +INPUT_STY = re.compile(r"^INPUT\s.*.(cls|def|sty)") +INPUT_FONTS = re.compile(r"^INPUT\s.*.(pfb|tfm)") +FONT_PUBLIC = re.compile(r"/public/.*/") def run_latexmk(filename, mode, compdir): @@ -69,10 +72,11 @@ def run_latexmk(filename, mode, compdir): return fls_path -def fls_input_files(f, lof_excl, skip_files): +def fls_input_files(fls_fileobj, lof_excl, skip_files, *, sty_files=False): """Helper function to return list on files marked as 'INPUT' in fls file""" - deps = [] - for line in f: + deps = set() + pkgs = {"System": set(), "Local": set()} + for line in fls_fileobj: if INPUT_RE.match(line): p = Path(line.split()[-1]) if ( @@ -81,7 +85,28 @@ def fls_input_files(f, lof_excl, skip_files): and (p.as_posix() not in lof_excl) and (p.suffix not in skip_files) ): - deps.append(p.as_posix()) + deps.add(p.as_posix()) log.info("Add file: %s", p.as_posix()) - return deps + if sty_files: + if INPUT_STY.match(line): + p = Path(line.split()[-1]) + if p.is_absolute(): + # Base is not a (La)TeX package; it is installed with even + # the most basic TeXlive/MikTeX installation + if (pdir := p.parent.name) != "base": + pkgs["System"].add(pdir) + else: + pkgs["Local"].add(p.stem) + elif INPUT_FONTS.match(line): + p = Path(line.split()[-1]) + if p.is_absolute(): + try: + fontdir = ( + FONT_PUBLIC.search(str(p)).group(0).split("/")[2] + ) + except AttributeError: + fontdir = p.parent.name + pkgs["System"].add(fontdir) + + return list(deps), pkgs diff --git a/src/tartex/_parse_args.py b/src/tartex/_parse_args.py index 84e56e2..e401b1a 100644 --- a/src/tartex/_parse_args.py +++ b/src/tartex/_parse_args.py @@ -10,9 +10,14 @@ import argparse from pathlib import Path -from textwrap import fill, wrap +from textwrap import wrap -from tartex.__about__ import __appname__ as APPNAME, __version__ +from rich import print as richprint +from rich.markdown import Markdown +from rich.syntax import Syntax + +from tartex.__about__ import __appname__ as APPNAME # noqa: N812 +from tartex.__about__ import __version__ from tartex._completion import ( COMPFILE, BashCompletion, @@ -23,8 +28,8 @@ # Latexmk allowed compilers LATEXMK_TEX = [ "dvi", - "luatex", "lualatex", + "luatex", "pdf", "pdflua", "ps", @@ -33,6 +38,51 @@ ] +BASH_COMP_PATH = BashCompletion().install_dir.joinpath(f"{APPNAME}") +COMPLETIONS_GUIDE = f""" +Completions are currently supported for bash, fish, and zsh shells. +Please consider [contributing](https://github.com/badshah400/tartex) if you +would like completion for any other shell. + +__Note__: XDG_DATA_HOME defaults to `~/.local/share`. + +## Bash ## +The option `--bash-completion` will install bash completions for {APPNAME} to +the directory: $XDG_DATA_HOME/{COMPFILE["bash"]}. + +Bash automatically searches this dir for completions, so completion for +tartex should work immediately after starting a new terminal session. If it +does not, you may have to add the following lines to your +'~/.bashrc' file: + +```bash +# Source {APPNAME} completion +source ~/{BASH_COMP_PATH.relative_to(Path.home())} +``` + +## Zsh ## +The option `--zsh-completion` will install a zsh completions file for {APPNAME} +to the directory: $XDG_DATA_HOME/{COMPFILE['zsh'].parent!s}. It will also +print what to add to your .zshrc file to enable these completions. + +## Fish ## +The option `--fish-completion` will install a fish completions file for +{APPNAME} to the directory: $XDG_DATA_HOME/{COMPFILE['fish'].parent!s}. + +No further configuration should be needed. Simply start a new fish terminal et +voila! +""" + +ZSH_GUIDE = f"""# Update fpath to include completions dir +# Note: Must be done before initialising compinit +fpath=(~/{ZshCompletion().install_dir.relative_to(Path.home())} $fpath) + +# If the following two lines already appear in your .zshrc do not add them +# again, but move the fpath line above the 'autoload compinit' line +autoload -U compinit +compinit""" + + class CompletionPrintAction(argparse.Action): """ @@ -45,7 +95,7 @@ def __init__( option_strings, dest=argparse.SUPPRESS, default=argparse.SUPPRESS, - help=None, + help=None, # noqa: A002 ): """Initialise Action class""" super().__init__( @@ -56,81 +106,10 @@ def __init__( help=help, ) - # TODO: This is a mess of print calls; see if it can be simplified - def __call__(self, parser, namespace, values, option_string=None): - fill_width = 80 - print( - "Completion is currently supported for bash, fish, and zsh shells." - ) - print( - "Please consider contributing if you would like completion for" - " any other shell.\n" - ) - - print( - "----\n" # do not join - "Bash\n" # do not join - "----\n" # do not join - + fill( - "The option `--bash-completion` will install bash completions" - f" for {APPNAME} to the directory:", - width=fill_width, - ) - + f"\n${{XDG_DATA_HOME}}/{COMPFILE['bash'].parent!s}\n" - ) - print( - fill( - "Bash automatically searches this dir for completions, so" - f" completion for {APPNAME} should work immediately after" - " starting a new terminal session." - " If it does not, you may have to add the following lines" - " to your .bashrc:", - replace_whitespace=False, - width=fill_width, - ) - ) - bash_comp_path = BashCompletion().install_dir.joinpath(f"{APPNAME}") - print( - f"\n# Source {APPNAME} completion\n" - f"source ~/{bash_comp_path.relative_to(Path.home())}", - ) - print( - "\n" # do not join - "---\n" # do not join - "Zsh\n" # do not join - "---\n" # do not join - + fill( - "The option `--zsh-completion` will install a zsh completion" - f" file for {APPNAME} to the directory:", - width=fill_width, - ) - + "\n" - f"${{XDG_DATA_HOME}}/{COMPFILE['zsh'].parent!s}/\n" - "\n" - + fill( - "It will also print what to add to your .zshrc file to enable" - " these completions", - width=fill_width, - ) - ) - print( - "\n" # do not join - "----\n" # do not join - "Fish\n" # do not join - "----\n" # do not join - + fill( - "The option `--fish-completion` will install a fish completion" - f" file for {APPNAME} to the directory:", - width=fill_width, - ) - + f"\n${{XDG_DATA_HOME}}/{COMPFILE['fish'].parent!s}/\n" - ) - print( - fill( - "No further configuration should be needed. Simply start a" - " new fish terminal et voila!" - ) - ) + # Note that correct __call__ signature requires all positional args even if + # they are not used in this method itself + def __call__(self, parser, nsp, vals, opt_str=None): # noqa: ARG002 + richprint(Markdown(COMPLETIONS_GUIDE)) parser.exit() @@ -146,7 +125,7 @@ def __init__( option_strings, dest=argparse.SUPPRESS, default=argparse.SUPPRESS, - help=None, + help=None, # noqa: A002 ): """Initialise Action class""" super().__init__( @@ -157,7 +136,9 @@ def __init__( help=help, ) - def __call__(self, parser, namespace, values, option_string=None): + # Note that correct __call__ signature requires all positional args even if + # they are not used in this method itself + def __call__(self, parser, namespace, values, option_string=None): # noqa parser.exit() @@ -187,24 +168,11 @@ class ZshCompletionInstall(CompletionInstall): def __call__(self, parser, namespace, values, option_strings=None): ZshCompletion().install() - print( - "\nAdd the following to your .zshrc if not already present:" - "\n-----------------------------------------------------------" - ) - print( - "# Update fpath to include completions dir\n" - "# Note: Must be done before initialising compinit\n" - "fpath" - f"=(~/{ZshCompletion().install_dir.relative_to(Path.home())}" - " $fpath)\n" + richprint( "\n" - "# If the following two lines already appear in your .zshrc\n" - "# do not add them again, but move the fpath line above the\n" - "# 'autoload compinit' line\n" - "autoload -U compinit\n" - "compinit" - "\n-----------------------------------------------------------\n" + "Add the following to your [bold].zshrc[/] if not already present:" ) + richprint(Syntax(ZSH_GUIDE, "zsh")) super().__call__(parser, namespace, values, option_strings) parser.exit() @@ -254,7 +222,7 @@ def _format_action_invocation(self, action): return ", ".join(parts) - def _split_lines(self, text, width): + def _split_lines(self, text, width): # noqa: ARG002 return wrap(text, width=52, break_on_hyphens=False) @@ -314,8 +282,17 @@ def parse_args(args): "--output", metavar="NAME[.SUF]", type=Path, - help="output tar file name; tar compression mode will be inferred from" - " .SUF, if possible (default 'gz')", + help=( + "output tar file name; tar compression mode will be inferred from" + " .SUF, if possible (default 'gz')" + ), + ) + + parser.add_argument( + "-p", + "--packages", + action="store_true", + help="add names of used (La)TeX packages as a json file", ) parser.add_argument( diff --git a/src/tartex/data/tartex-completion.fish b/src/tartex/data/tartex-completion.fish index 468dcd4..54544f0 100644 --- a/src/tartex/data/tartex-completion.fish +++ b/src/tartex/data/tartex-completion.fish @@ -18,6 +18,7 @@ complete -c tartex -s V -l Version -d "display tartex version and exit" complete -c tartex -f -s b -l bib -d "include bibliography (.bib) file in tar" complete -c tartex -f -s F -l force-recompile -d "force recompilation even if .fls exists" complete -c tartex -f -s l -l list -d "print a list of files to include and quit" +complete -c tartex -f -s p -l packages -d "add names of used (La)TeX packages as a json file" complete -c tartex -f -s s -l summary -d "print a summary at the end" complete -c tartex -f -s v -l verbose -d "increase verbosity (-v, -vv, etc.)" diff --git a/src/tartex/data/tartex-completion.zsh b/src/tartex/data/tartex-completion.zsh index 54f7d2e..8897636 100644 --- a/src/tartex/data/tartex-completion.zsh +++ b/src/tartex/data/tartex-completion.zsh @@ -43,6 +43,7 @@ _arguments -s -S \ '(- : *)'{-V,--version}'[print tartex version and exit]' \ '(completions -a --add)'{-a,--add=}'[include additional file names matching glob-style PATTERNS]:PATTERNS:_files' \ '(completions -b --bib)'{-b,--bib}'[find and add bib file to tarball]' \ + '(completions -p --packages)'{-p,--packages}'[add names of used (La)TeX packages as a json file]' \ '(completions -s --summary)'{-s,--summary}'[print a summary at the end]' \ '*'{-v,--verbose}'[increase verbosity (-v, -vv, etc.)]' \ '(completions -x --excl)'{-x,--excl=}'[exclude file names matching PATTERNS]:PATTERNS:_files' \ diff --git a/src/tartex/tartex.py b/src/tartex/tartex.py index 9a600d9..dd7bb77 100644 --- a/src/tartex/tartex.py +++ b/src/tartex/tartex.py @@ -6,6 +6,7 @@ """tartex module""" import fnmatch +import json import logging as log import math import os @@ -100,6 +101,8 @@ class TarTeX: # pylint: disable=too-many-instance-attributes + pkglist_name = "TeXPackages.json" + def __init__(self, args): self.args = parse_args(args) log.basicConfig( @@ -189,6 +192,8 @@ def __init__(self, args): self.force_tex, ) + self.pkglist = None + def add_user_files(self): """ Return list of additional user specified/globbed file paths, @@ -251,7 +256,21 @@ def input_files(self): ) with open(fls_path, encoding="utf-8") as f: - deps = _latex.fls_input_files(f, self.excl_files, AUXFILES) + deps, pkgs = _latex.fls_input_files( + f, + self.excl_files, + AUXFILES, + sty_files=self.args.packages, + ) + if self.args.packages: + log.info( + "System TeX/LaTeX packages used: %s", + ", ".join(sorted(pkgs["System"])) + ) + + self.pkglist = json.dumps(pkgs, cls=SetEncoder).encode( + "utf8" + ) for ext in SUPP_REQ: if app := self._missing_supp( @@ -263,10 +282,14 @@ def input_files(self): else: # If .fls exists, this assumes that all INPUT files recorded in it # are also included in source dir - with open( - self.main_file.with_suffix(".fls"), encoding="utf-8" - ) as f: - deps = _latex.fls_input_files(f, self.excl_files, AUXFILES) + with open(self.main_file.with_suffix(".fls"), encoding="utf8") as f: + deps, pkgs = _latex.fls_input_files( + f, self.excl_files, AUXFILES, sty_files=self.args.packages + ) + if self.args.packages: + self.pkglist = json.dumps(pkgs, cls=SetEncoder).encode( + "utf8" + ) if self.args.bib and (bib := self.bib_file()): deps.append(bib.as_posix()) @@ -420,9 +443,9 @@ def _do_tar(self, tar_obj): dep, ) - for fpath, byt in self.req_supfiles.items(): - tinfo = tar_obj.tarinfo(fpath.name) - tinfo.size = len(byt) + def _tar_add_bytesio(obj, name): + tinfo = tar_obj.tarinfo(name) + tinfo.size = len(obj) tinfo.mtime = int(time.time()) # Copy user/group names from main.tex file @@ -432,8 +455,18 @@ def _do_tar(self, tar_obj): tinfo.gname = tar_obj.getmember( self.main_file.with_suffix(".tex").name ).gname + tar_obj.addfile(tinfo, BytesIO(obj)) + + if self.args.packages: + log.info( + "Adding list of packages as BytesIO object: %s", + self.pkglist_name, + ) + _tar_add_bytesio(self.pkglist, self.pkglist_name) - tar_obj.addfile(tinfo, BytesIO(byt)) + for fpath, byt in self.req_supfiles.items(): + log.info("Adding %s as BytesIO object", fpath.name) + _tar_add_bytesio(byt, fpath.name) def _proc_output_path(self): """ @@ -467,11 +500,15 @@ def _print_list(self, ls): richprint(f"{i+1:{idx_width}}. {f}") for r in self.req_supfiles: richprint(f"{'*':>{idx_width + 1}} {r.name}") + if self.args.packages: + richprint(f"{'*':>{idx_width + 1}} {self.pkglist_name}") if self.args.summary: - _summary_msg(len(ls) + len(self.req_supfiles)) + _summary_msg( + len(ls) + len(self.req_supfiles) + (1 if self.pkglist else 0) + ) def _missing_supp(self, fpath, tmpdir, deps): - """Handle missing supplemetary file from orig dir, if req""" + """Handle missing supplementary file from orig dir, if req""" if ( fpath not in deps # bbl file not in source dir and (Path(tmpdir) / fpath.name).exists() # Implies it's req @@ -493,5 +530,18 @@ def make_tar(): t.tar_files() +class SetEncoder(json.JSONEncoder): + """A class to allow JSONEncoder to interpret a set as a list""" + + def default(self, o): + """ + Convert o (a set) into a sorted list + + :o: set + :return: list + """ + return sorted(o) + + if __name__ == "__main__": make_tar() diff --git a/tests/test_bash_completion.py b/tests/test_bash_completion.py index 265cc97..ea427a7 100644 --- a/tests/test_bash_completion.py +++ b/tests/test_bash_completion.py @@ -4,11 +4,13 @@ """ from pathlib import Path + import pytest -from tartex._completion import BashCompletion, APPNAME +from tartex._completion import APPNAME, BashCompletion from tartex.tartex import TarTeX + def test_print(capsys): """Test output of print is not empty""" with pytest.raises(SystemExit) as exc: @@ -17,6 +19,7 @@ def test_print(capsys): assert "bash" in capsys.readouterr().out assert exc.value.code == 0 + def test_install(capsys, monkeypatch, tmpdir): """Test installed completions file""" monkeypatch.setenv("HOME", str(tmpdir)) @@ -25,8 +28,9 @@ def test_install(capsys, monkeypatch, tmpdir): assert exc.value.code == 0 bc = BashCompletion() - bc.print() compl_file = Path.home() / bc.install_dir / APPNAME - assert str(compl_file.parent) in capsys.readouterr().out + # For overtly long paths, capsys output may end up introducing "\n" inside + # the path, make sure we get rid of those when comparing + assert str(compl_file.parent) in capsys.readouterr().out.replace("\n", "") assert compl_file.exists() assert compl_file.read_text(encoding="utf-8") == bc.data diff --git a/tests/test_packages.py b/tests/test_packages.py new file mode 100644 index 0000000..944c9d9 --- /dev/null +++ b/tests/test_packages.py @@ -0,0 +1,34 @@ +# vim:set et sw=4 ts=4 tw=79: +""" +Tests for package list inclusion +""" + +import json +import tarfile as tar + +from tartex.tartex import TAR_DEFAULT_COMP, TarTeX + + +def test_float_pkg(datadir, tmpdir, capsys): + """ + Test 'packages.json' for float package included from test_packages + """ + t = TarTeX( + [ + str(datadir / "main.tex"), + "-s", + "-p", + "-o", + f"{tmpdir!s}/packagelist", + ] + ) + t.tar_files() + assert "2 files" in capsys.readouterr().out + with tar.open( + f"{tmpdir!s}/packagelist.tar.{TAR_DEFAULT_COMP}", mode="r" + ) as f: + assert "TeXPackages.json" in f.getnames() + + pkgjson = json.loads(t.pkglist) + assert "float" in pkgjson["System"] + assert pkgjson["Local"] == [] diff --git a/tests/test_packages/main.tex b/tests/test_packages/main.tex new file mode 100644 index 0000000..9f313da --- /dev/null +++ b/tests/test_packages/main.tex @@ -0,0 +1,7 @@ +\documentclass[11pt]{article} +\usepackage{float} +\begin{document} + +A basic document to test inclusion of latex packages. + +\end{document}