Merge branch 'latex-packages'

This commit is contained in:
Maximilian Keßler 2021-10-29 09:39:39 +02:00
commit 357144e9f0
18 changed files with 640 additions and 34 deletions

View File

@ -1,9 +1,7 @@
from .config import LICENSE, DEFAULT_AUTHOR, PACKAGE_INFO_TEXT, PYTEX_INFO_TEXT
from .enums import Attributes, Args
__all__ = [
'LICENSE',
'DEFAULT_AUTHOR',
'PACKAGE_INFO_TEXT',
'PYTEX_INFO_TEXT',
'Args',

7
build/__init__.py Normal file
View File

@ -0,0 +1,7 @@
from .build import build
from .build_parser import parse_and_build
__all__ = [
'build',
'parse_and_build'
]

117
build/build.py Normal file
View File

@ -0,0 +1,117 @@
import json
from pathlib import Path
from typing import Optional
import git
from PyTeX.config.constants import BUILD_INFO_FILENAME
from .utils import BuildInfo, pytex_msg, TexFileToFormat
def build(
src_dir: Optional[Path] = None,
build_dir: Optional[Path] = None,
input_file: Optional[Path] = None,
author: Optional[str] = None,
latex_name: str = 'prepend-author', # name handling
recursive: bool = False, # input control
include_timestamp: bool = False, # header
include_pytex_version: bool = False, # header
include_license: bool = False, # header
include_git_version: bool = False, # header
include_pytex_info_text: bool = False, # header
extra_header: Optional[Path] = None,
allow_dirty: bool = False, # versioning
overwrite_existing_files: bool = False, # output control
build_all: bool = False, # output control / versioning
write_build_information: bool = True, # meta
):
pytex_msg('Getting git repository information...')
if extra_header:
if extra_header.exists():
with open(extra_header, 'r') as f:
text = f.readlines()
extra_header = [line.rstrip() for line in text]
else:
raise FileNotFoundError('Path to extra header content is invalid.')
current_build_info = BuildInfo(
include_timestamp=include_timestamp,
include_pytex_version=include_pytex_version,
include_license=include_license,
include_git_version=include_git_version,
include_pytex_info_text=include_pytex_info_text,
extra_header=extra_header,
author=author,
pytex_repo=git.Repo(__file__, search_parent_directories=True),
packages_repo=git.Repo(src_dir, search_parent_directories=True)
)
input_dir = src_dir if src_dir else input_file.parent
output_dir = build_dir if build_dir else input_file.parent
last_build_info_file = output_dir / BUILD_INFO_FILENAME
if last_build_info_file.exists():
with open(output_dir / 'build_info.json', 'r') as f:
last_build_info = json.load(f)
else:
last_build_info = None
files = []
if input_file:
files.append(input_file)
if src_dir:
if recursive:
for file in src_dir.rglob('*.pysty'):
files.append(file)
for file in src_dir.rglob('*.pycls'):
files.append(file)
else:
for file in src_dir.glob('*.pysty'):
files.append(file)
for file in src_dir.glob('*.pycls'):
files.append(file)
sources_to_build = []
for file in files:
if last_build_info:
last_build_info_for_this_file = next(
(info for info in last_build_info['tex_sources'] if info['source file'] == file.name), {})
else:
last_build_info_for_this_file = None
sources_to_build.append(
TexFileToFormat(
src_path=file,
build_dir=output_dir / file.parent.relative_to(input_dir),
latex_name=latex_name,
current_build_info=current_build_info,
last_build_info=last_build_info_for_this_file,
allow_dirty=allow_dirty,
overwrite_existing_files=overwrite_existing_files,
build_all=build_all
))
info_dict = {
'build_time': '',
'source files': {
'version': current_build_info.packages_version,
'commit': current_build_info.packages_hash,
'dirty': current_build_info.package_repo.is_dirty(untracked_files=True)
},
'pytex': {
'version': current_build_info.pytex_version,
'commit': current_build_info.pytex_hash,
'dirty': current_build_info.pytex_repo.is_dirty(untracked_files=True)
},
'tex_sources': [
]
}
for source in sources_to_build:
info = source.format()
info_dict['tex_sources'].append(info)
if write_build_information:
with open(output_dir / 'build_info.json', 'w') as f:
json.dump(info_dict, f, indent=4)
pytex_msg('Build done')

111
build/build_parser.py Normal file
View File

@ -0,0 +1,111 @@
import argparse
import pathlib
from PyTeX.config import FILENAME_TYPE_PREPEND_AUTHOR, FILENAME_TYPE_RAW_NAME
from .build import build
def parse_and_build(arglist: [str]):
parser = argparse.ArgumentParser(description='Incrementally build LatexPackages with PyTeX')
input_group = parser.add_mutually_exclusive_group(required=True)
input_group.add_argument(
'-s', '--source-dir',
metavar='SRC_DIR',
help='Relative or absolute path to source directory of .pysty or .pycls files',
type=pathlib.Path,
nargs='?',
default='./src',
dest='src_dir'
)
parser.add_argument(
'-b', '--build-dir',
metavar='BUILD_DIR',
help='Relativ or absolute path to output directory for processed packages and classes',
type=pathlib.Path,
nargs='?',
default='./build',
dest='build_dir'
)
parser.add_argument(
'-r', '--recursive',
help='Recursively search subdirectories for files. Default: false',
action='store_true',
dest='recursive'
)
input_group.add_argument(
'-i', '--input-file',
metavar='FILE',
help='Filename to be built. Can be in valid .pysty or .pycls format',
type=pathlib.Path,
dest='input_file'
)
parser.add_argument(
'-n', '--name',
help='Name of the package / class to be formatted.',
type=str,
choices=[FILENAME_TYPE_RAW_NAME, FILENAME_TYPE_PREPEND_AUTHOR],
default=FILENAME_TYPE_PREPEND_AUTHOR,
dest='latex_name'
)
parser.add_argument(
'-g', '--git-version',
help='Insert git version information into build. This assumes your input'
'files are located in a git repository. Default: false',
action='store_true',
dest='include_git_version'
)
parser.add_argument(
'-d', '--allow-dirty',
help='If git flag is set, allow building of a dirty repo. Default: false',
action='store_true',
dest='allow_dirty'
)
parser.add_argument(
'-p',
'--pytex-version',
help='Write PyTeX version information into built LaTeX files',
action='store_true',
dest='include_pytex_version'
)
parser.add_argument(
'-t', '--build-time',
help='Insert build time into built LaTeX files',
action='store_true',
dest='include_timestamp'
)
parser.add_argument(
'-l', '--license',
help='Insert MIT license into package header',
action='store_true',
dest='include_license'
)
parser.add_argument(
'-a', '--author',
help='Set author of packages',
type=str,
dest='author'
)
parser.add_argument(
'-f', '--force',
help='Overwrite unknown existing files without confirmation',
action='store_true',
dest='overwrite_existing_files'
)
parser.add_argument(
'--pytex-info-text',
help='Include a PyTeX info text into headers',
action='store_true',
dest='include_pytex_info_text'
)
parser.add_argument(
'-e', '--extra-header',
help='Path to file containing extra text for header of each package',
type=pathlib.Path,
dest='extra_header'
)
args = vars(parser.parse_args(arglist))
for arg in args.keys():
if type(args[arg]) == pathlib.PosixPath:
args[arg] = args[arg].resolve()
build(**args)

View File

@ -0,0 +1,9 @@
from .git_version import git_describe, get_history, get_latest_commit
from .recent import is_recent
__all__ = [
'git_describe',
'get_history',
'get_latest_commit',
'is_recent'
]

View File

@ -0,0 +1,53 @@
import git
from typing import Dict
def get_latest_commit(repo):
if repo.head.is_detached:
return repo.head.commit
else:
return repo.head.ref.commit
def get_history(commit: git.objects.commit.Commit, priority=0, depth=0) -> Dict:
commit_history = {commit.hexsha: {
'priority': priority,
'depth': depth
}}
try:
if len(commit.parents) > 0:
commit_history.update(get_history(commit.parents[0], priority, depth + 1))
for parent in commit.parents[1:]:
commit_history.update(get_history(parent, priority + 1, depth + 1))
except ValueError:
pass
return commit_history
def git_describe(commit: git.objects.commit.Commit):
commit_history = get_history(commit)
latest_tag = None
for tag in commit.repo.tags:
sha = tag.commit.hexsha
if sha in commit_history.keys():
if latest_tag is None:
latest_tag = tag
elif commit_history[sha]['priority'] < commit_history[latest_tag.commit.hexsha]['priority']:
latest_tag = tag
elif commit_history[sha]['priority'] > commit_history[latest_tag.commit.hexsha]['priority']:
pass # move on
elif commit_history[sha]['depth'] < commit_history[latest_tag.commit.hexsha]['depth']:
latest_tag = tag
elif commit_history[sha]['depth'] > commit_history[latest_tag.commit.hexsha]['depth']:
pass # move on
elif tag.object.tagged_date > latest_tag.object.tagged_date:
latest_tag = tag
if latest_tag is None:
return "No tags found - cannot describe anything."
else:
msg = latest_tag.name
if commit_history[latest_tag.commit.hexsha]['depth'] != 0:
msg += "-{depth}".format(depth=commit_history[latest_tag.commit.hexsha]['depth'])
if commit.repo.is_dirty(untracked_files=True):
msg += '-*'
return msg

42
build/git_hook/recent.py Normal file
View File

@ -0,0 +1,42 @@
import git
from .git_version import get_latest_commit
from typing import Union, Optional, List
from pathlib import Path
def is_recent(file: Path, repo: git.Repo, compare: Optional[Union[git.Commit, List[git.Commit]]] = None) -> Optional[bool]:
"""
:param file: file to check
:param repo: repo that the file belongs to
:param compare: commit or list of commits to compare to. None stands for 'working tree'
:return: Whether the given file is currently the same as in compared commit
If compare is a commit, checks if the file has changed since given commit, compared to the most recent commit
of the repository
For a list of commits, checks the same, but for all of these commits. In particular, only returns true if the file
is the same in all of the specified commits (and in the most recent of the repository)
If compare is None, compares the file against the corking tree, i.e. if the file has been modified since the last
commit on the repo. This also involves staged files, i.e. modified and staged files will be considered as
'not recent' since changes are not committed
"""
newly_committed_files = []
if type(compare) == git.Commit:
newly_committed_files = [item.a_path for item in get_latest_commit(repo).diff(compare)]
elif compare is None:
com = get_latest_commit(repo)
newly_committed_files = [item.a_path for item in com.diff(None)]
pass
elif type(compare) == list:
for commit in compare:
for item in get_latest_commit(repo).diff(commit):
newly_committed_files.append(item.a_path)
else:
print("Invalid argument type for compare")
return None
if str(file.relative_to(repo.working_dir)) in newly_committed_files:
return False
else:
return True

9
build/utils/__init__.py Normal file
View File

@ -0,0 +1,9 @@
from .build_information import BuildInfo
from .pytex_file import TexFileToFormat
from .pytex_msg import pytex_msg
__all__ = [
'BuildInfo',
'TexFileToFormat',
'pytex_msg'
]

View File

@ -0,0 +1,135 @@
import git
import datetime
from typing import Optional, List
from PyTeX.build.git_hook import git_describe, get_latest_commit
from PyTeX.config.header_parts import *
class BuildInfo:
def __init__(
self,
include_timestamp: bool = False,
include_pytex_version: bool = False,
include_license: bool = False,
include_git_version: bool = False,
include_pytex_info_text: bool = False,
extra_header: Optional[List[str]] = None,
author: Optional[str] = None,
pytex_repo: Optional[git.Repo] = None,
packages_repo: Optional[git.Repo] = None):
self.author = author
self.build_time = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
self._pytex_repo = pytex_repo
self._packages_repo = packages_repo
self._pytex_repo_commit = None
self._packages_repo_commit = None
self._pytex_repo_version = None
self._packages_repo_version = None
self._header = None
self.get_repo_commits()
self.get_repo_version()
self.create_header(
include_license=include_license,
include_pytex_info_text=include_pytex_info_text,
include_timestamp=include_timestamp,
include_git_version=include_git_version,
include_pytex_version=include_pytex_version,
extra_header=extra_header
)
@property
def header(self):
return self._header
@property
def pytex_version(self):
return self._pytex_repo_version
@property
def packages_version(self):
return self._packages_repo_version
@property
def pytex_hash(self):
return self._pytex_repo_commit.hexsha
@property
def packages_hash(self):
return self._packages_repo_commit.hexsha
@property
def package_repo(self):
return self._packages_repo
@property
def pytex_repo(self):
return self._pytex_repo
def get_repo_commits(self):
if self._packages_repo:
self._packages_repo_commit = get_latest_commit(self._packages_repo)
if self._pytex_repo:
self._pytex_repo_commit = get_latest_commit(self._pytex_repo)
def get_repo_version(self):
if self._packages_repo_commit:
self._packages_repo_version = git_describe(self._packages_repo_commit)
if self._pytex_repo_commit:
self._pytex_repo_version = git_describe(self._pytex_repo_commit)
def create_header(
self,
include_timestamp: bool = False,
include_pytex_version: bool = False,
include_license: bool = False,
include_git_version: bool = False,
include_pytex_info_text: bool = False,
extra_header: Optional[List[str]] = None
):
if not (include_license
or include_pytex_info_text
or include_timestamp
or include_pytex_version
or include_git_version):
self._header = None
return
else:
self._header = []
if include_license:
self._header += LICENSE + ['']
if include_pytex_info_text:
self._header += PYTEX_INFO_TEXT + ['']
if include_timestamp or include_pytex_version or include_git_version:
self._header += BUILD_DETAILS
if include_timestamp:
self._header += BUILD_TIME
if include_pytex_version:
self._header += PYTEX_VERSION
if include_git_version:
self._header += SOURCE_CODE_VERSION
self._header += ['']
if extra_header:
self._header += extra_header + ['']
if self._header[-1] == '':
self._header.pop()
formatted_header = []
for line in self._header:
formatted_header.append(line.format(
year=datetime.datetime.now().strftime('%Y'),
copyright_holders=self.author,
source_file='{source_file}',
latex_file_type='{latex_file_type}',
pytex_version=self.pytex_version,
pytex_commit_hash=self.pytex_hash[:7],
packages_version=self.packages_version,
packages_commit_hash=self.packages_hash[:7]
))
self._header = formatted_header

104
build/utils/pytex_file.py Normal file
View File

@ -0,0 +1,104 @@
from pathlib import Path
from typing import Optional, List
from PyTeX.build.git_hook import is_recent, get_latest_commit
from PyTeX import PackageFormatter, ClassFormatter
from .pytex_msg import pytex_msg
from .build_information import BuildInfo
class TexFileToFormat:
def __init__(
self,
src_path: Path,
build_dir: Path,
latex_name: str,
current_build_info: BuildInfo,
last_build_info: Optional[dict],
allow_dirty: bool = False,
overwrite_existing_files: bool = False,
build_all: bool = False):
self.src_path = src_path
self.build_path = build_dir
self.tex_name = latex_name # Still an identifier on how to name the package when being formatted
self.current_build_info = current_build_info
self.last_build_info = last_build_info
self.allow_dirty = allow_dirty
self.overwrite_existing_files: overwrite_existing_files
self.build_all = build_all
self._header: Optional[List[str]] = None
self.__format_header()
self.dirty = not is_recent(self.src_path, self.current_build_info.package_repo, compare=None)
self.pytex_dirty: bool = self.current_build_info.pytex_repo.is_dirty(
working_tree=True,
untracked_files=True
)
if self.last_build_info:
self.recent: bool = is_recent(
file=self.src_path,
repo=self.current_build_info.package_repo,
compare=self.current_build_info.package_repo.commit(self.last_build_info['source commit hash'])
)
self.pytex_recent: bool = get_latest_commit(
self.current_build_info.pytex_repo
).hexsha == self.last_build_info['pytex commit hash']
else:
self.recent = False
self.pytex_recent = False
def format(self) -> dict:
if self.dirty or self.pytex_dirty:
if not self.allow_dirty:
raise Exception(
'{file} is dirty, but writing dirty files not allowed.'.format(
file=self.src_path.name if self.dirty else 'Submodule PyTeX')
)
# TODO: add this to the header...?
return self.__format() # Dirty files are always built, since we have no information about them
elif self.build_all:
return self.__format() # Build file since we build all of them
elif not self.pytex_recent or not self.recent:
return self.__format() # Build file since either pytex or package repo is not recent
elif self.last_build_info and self.last_build_info['dirty']:
return self.__format() # Build file since we do not know in what state it is
else:
return self.last_build_info
def __format_header(self):
new_header = []
for line in self.current_build_info.header:
new_header.append(line.format(
source_file=self.src_path.name,
latex_file_type='package' if '.pysty' in self.src_path.name else 'class'
))
self._header = new_header
def __format(self) -> dict:
if '.pysty' in self.src_path.name:
formatter = PackageFormatter(
package_name=self.src_path.with_suffix('').name,
author=self.current_build_info.author,
extra_header=self._header)
elif '.pycls' in self.src_path.name:
formatter = ClassFormatter(
class_name=self.src_path.with_suffix('').name,
author=self.current_build_info.author,
extra_header=self._header)
else:
raise Exception('Programming error. Please contact the developer.')
pytex_msg('Writing file {}'.format(formatter.file_name))
formatter.make_default_macros()
formatter.format_file(self.src_path, self.build_path)
info = {
'name': formatter.file_name,
'source file': self.src_path.name,
'build time': self.current_build_info.build_time,
'source version': self.current_build_info.packages_version,
'source commit hash': self.current_build_info.packages_hash,
'pytex version': self.current_build_info.pytex_version,
'pytex commit hash': self.current_build_info.pytex_hash,
'dirty': self.dirty
}
return info

2
build/utils/pytex_msg.py Normal file
View File

@ -0,0 +1,2 @@
def pytex_msg(msg: str):
print('[PyTeX] ' + msg)

12
config/__init__.py Normal file
View File

@ -0,0 +1,12 @@
from .constants import FILENAME_TYPE_PREPEND_AUTHOR, FILENAME_TYPE_RAW_NAME, DATE_FORMAT, BUILD_INFO_FILENAME
from .header_parts import LICENSE, PYTEX_INFO_TEXT, BUILD_DETAILS
__all__ = [
'FILENAME_TYPE_PREPEND_AUTHOR',
'FILENAME_TYPE_RAW_NAME',
'DATE_FORMAT',
'BUILD_INFO_FILENAME',
'LICENSE',
'PYTEX_INFO_TEXT',
'BUILD_DETAILS'
]

4
config/constants.py Normal file
View File

@ -0,0 +1,4 @@
FILENAME_TYPE_PREPEND_AUTHOR = 'prepend-author'
FILENAME_TYPE_RAW_NAME = 'raw'
DATE_FORMAT = '%Y/%m/%d %H:%M'
BUILD_INFO_FILENAME = 'build_info.json'

View File

@ -1,5 +1,3 @@
DEFAULT_AUTHOR = 'Maximilian Keßler'
LICENSE = [
'Copyright © {year} {copyright_holders}',
'',
@ -21,16 +19,6 @@ LICENSE = [
'SOFTWARE.'
]
PACKAGE_INFO_TEXT = [
"This LaTeX {latex_file_type} is free software and distributed under the MIT License. You",
"may use it freely for your purposes. The latest version of the {latex_file_type} can be",
"obtained via GitHub under",
" https://github.com/kesslermaximilian/LatexPackages",
"For further information see the url above.",
"Reportings of bugs, suggestions and improvements are welcome, see the README",
"at the Git repository for further information."
]
PYTEX_INFO_TEXT = [
"This {latex_file_type} has been generated by PyTeX, available at",
" https://github.com/kesslermaximilian/PyTeX",
@ -39,3 +27,19 @@ PYTEX_INFO_TEXT = [
"changes will not be versioned by Git and overwritten by the next build. Always",
"edit the source file and build the {latex_file_type} again."
]
BUILD_DETAILS = [
"Build details:"
]
BUILD_TIME = [
" Build time: {build_time}"
]
PYTEX_VERSION = [
" PyTeX version: {pytex_version} (commit {pytex_commit_hash})"
]
SOURCE_CODE_VERSION = [
" Source code version: {packages_version} (commit {packages_commit_hash})"
]

View File

@ -4,7 +4,7 @@ import PyTeX.macros
class ClassFormatter(PyTeX.formatter.TexFormatter):
def __init__(self, class_name: str, author: str = PyTeX.base.DEFAULT_AUTHOR, extra_header: [str] = []):
def __init__(self, class_name: str, author: str, extra_header: [str] = []):
PyTeX.formatter.TexFormatter.__init__(self, class_name, author, extra_header, '.cls')
def make_default_macros(self):

View File

@ -4,8 +4,8 @@ import PyTeX.macros
class PackageFormatter(PyTeX.formatter.TexFormatter):
def __init__(self, package_name: str, author: str = PyTeX.base.DEFAULT_AUTHOR, extra_header: [str] = []):
PyTeX.formatter.TexFormatter.__init__(self, package_name, author, extra_header, '.cls')
def __init__(self, package_name: str, author: str, extra_header: [str] = []):
PyTeX.formatter.TexFormatter.__init__(self, package_name, author, extra_header, '.sty')
def make_default_macros(self):
PyTeX.macros.make_default_macros(self, 'package')

View File

@ -1,15 +1,15 @@
import datetime
import re
from pathlib import Path
from typing import Dict
from typing import Dict, Optional, List
from datetime import *
from PyTeX.base import Attributes, Args
class TexFormatter:
def __init__(self, name: str, author: str, extra_header: [str], file_extension: str):
self.extra_header = extra_header
def __init__(self, name: str, author: str, header: Optional[List[str]], file_extension: str):
self.header = header
self.name_raw = name
self.author = author
author_parts = self.author.lower().replace('ß', 'ss').split(' ')
@ -28,6 +28,10 @@ class TexFormatter:
def __command_name2keyword(keyword: str):
return '__' + keyword.upper().strip().replace(' ', '_') + '__'
@property
def filename(self):
return self.file_name
def __parse_replacement_args(self, match_groups, *user_args, **user_kwargs):
new_args = []
for arg in user_args:
@ -93,7 +97,12 @@ class TexFormatter:
self.source_file_name = str(input_path.name)
input_file = input_path.open()
lines = input_file.readlines()
newlines = []
if self.header:
newlines = '%' * 80 + '\n' \
+ '\n'.join(map(lambda line: '% ' + line, self.header)) \
+ '\n' + '%' * 80 + '\n\n'
else:
newlines = []
for line in lines:
newlines += self.__format_string_with_arg(self.__format_string(line))
if output_dir is None:

View File

@ -1,28 +1,18 @@
import PyTeX.formatter
import PyTeX.base
import PyTeX.config
def make_default_macros(formatter: PyTeX.formatter.TexFormatter, latex_file_type: str):
header = '%' * 80 + '\n' \
+ '\n'.join(map(lambda line: '% ' + line,
PyTeX.base.LICENSE + [''] + PyTeX.base.PACKAGE_INFO_TEXT + [
''] + PyTeX.base.PYTEX_INFO_TEXT
+ [''] + formatter.extra_header)
) \
+ '\n' + '%' * 80 + '\n\n' \
+ '\\NeedsTeXFormat{{LaTeX2e}}\n' \
'\\Provides{Type}{{{name_lowercase}}}[{date} - {description}]\n\n'
header = '\\NeedsTeXFormat{{LaTeX2e}}\n' \
'\\Provides{Type}{{{name_lowercase}}}[{date} - {description}]\n\n'
formatter.add_arg_replacement(
1, 'header',
header,
name_lowercase=PyTeX.base.Attributes.name_lowercase,
date=PyTeX.base.Attributes.date,
description=PyTeX.base.Args.one,
year=PyTeX.base.Attributes.year,
copyright_holders=PyTeX.base.Attributes.author,
source_file=PyTeX.base.Attributes.source_file_name,
Type=latex_file_type.capitalize(),
latex_file_type=latex_file_type
)
formatter.add_replacement('{Type} name'.format(Type=latex_file_type), '{}', PyTeX.base.Attributes.name_lowercase)
formatter.add_replacement('{Type} prefix'.format(Type=latex_file_type), '{}', PyTeX.base.Attributes.prefix)