# SPDX-License-Identifier: GPL-3.0-or-later
# Definitions of helper commands to use with setuptools
#
# This file is part of Hydrilla
#
# Copyright (C) 2021 Wojtek Kosior
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
#
# I, Wojtek Kosior, thereby promise not to sue for violation of this file's
# license. Although I request that you do not make use this code in a
# proprietary program, I am not going to enforce this in court.
from setuptools import Command
from setuptools.command.build_py import build_py
import sys
from pathlib import Path
import subprocess
import re
import os
import json
import importlib
def mypath(path_or_string):
return Path(path_or_string).resolve()
class Helper:
def __init__(self, project_root, app_package_name, locales_dir,
locales=['en', 'pl'], default_locale='en', locale_domain=None,
packages_root=None, debian_dir=None, config_path=None):
self.project_root = mypath(project_root)
self.app_package_name = app_package_name
self.locales_dir = mypath(locales_dir)
self.locales = locales
self.default_locale = default_locale
self.locale_domain = locale_domain or app_package_name
self.packages_root = mypath(packages_root or project_root / 'src')
self.app_package_dir = self.packages_root / app_package_name
self.debian_dir = mypath(debian_dir or project_root / 'debian')
self.config_path = config_path and mypath(config_path)
self.locale_files_list = None
def run_command(self, command, verbose, runner=subprocess.run, **kwargs):
cwd = kwargs.get('cwd')
if cwd:
cwd = mypath(cwd)
where = f'from {cwd} '
else:
cwd = Path.cwd().resolve()
where = ''
str_command = [str(command[0])]
for arg in command[1:]:
if isinstance(arg, Path):
try:
arg = str(arg.relative_to(cwd))
except ValueError:
arg = str(arg)
str_command.append(arg)
if verbose:
print(f'{where}executing {" ".join(str_command)}')
runner(str_command, **kwargs)
def create_mo_files(self, dry_run=False, verbose=False):
self.locale_files_list = []
for locale in self.locales:
messages_dir = self.locales_dir / locale / 'LC_MESSAGES'
for po_path in messages_dir.glob('*.po'):
mo_path = po_path.with_suffix('.mo')
if not dry_run:
command = ['msgfmt', po_path, '-o', mo_path]
self.run_command(command, verbose=verbose, check=True)
self.locale_files_list.extend([po_path, mo_path])
def locale_files(self):
if self.locale_files_list is None:
self.create_mo_files(dry_run=True)
return self.locale_files_list
def locale_files_relative(self, to=None):
if to is None:
to = self.app_package_dir
return [file.relative_to(to) for file in self.locale_files()]
def flask_run(self, locale=None):
for var, val in (('ENV', 'development'), ('DEBUG', 'True')):
os.environ[f'FLASK_{var}'] = os.environ.get(f'FLASK_{var}', val)
config = {'lang': locale or self.default_locale}
sys.path.insert(0, str(self.packages_root))
package = importlib.import_module(self.app_package_name)
# make relative paths in json config resolve from project's directory
os.chdir(self.project_root)
kwargs = {'config_path': self.config_path} if self.config_path else {}
package.create_app(flask_config=config, **kwargs).run()
def update_po_files(self, verbose=False):
pot_path = self.locales_dir / f'{self.locale_domain}.pot'
rglob = self.app_package_dir.rglob
command = ['xgettext', '-d', self.locale_domain, '--language=Python',
'-o', pot_path, *rglob('*.py'), *rglob('*.html')]
self.run_command(command, verbose=verbose, check=True,
cwd=self.app_package_dir)
for locale in self.locales:
messages_dir = self.locales_dir / locale / 'LC_MESSAGES'
for po_path in messages_dir.glob('*.po'):
if po_path.stem != self.app_package_name:
continue;
if po_path.exists():
command = ['msgmerge', '--update', po_path, pot_path]
else:
command = ['cp', po_path, pot_path]
self.run_command(command, verbose=verbose, check=True)
if (verbose):
print('removing generated .pot file')
pot_path.unlink()
# we exclude these from the source archive we produce
bad_file_regex = re.compile(r'^\..*|build|debian|dist')
changelog_line_regex = re.compile(r'''
^ # match from the beginning of each line
\s* # skip initial whitespace (if any)
(?P # capture name
[^\s(]+
)
\s* # again skip whitespace (if any)
\(
(?P # capture version which is enclosed in parantheses
[^)]+
)
-
(?P # capture debrel part of version separately
[0-9]+
)
\)
''', re.VERBOSE)
def make_tarballs(self, verbose=False):
changelog_path = self.project_root / 'debian' / 'changelog'
with open(changelog_path, 'rt') as file_handle:
for line in file_handle.readlines():
match = changelog_line_regex.match(line)
if match:
break
if not match:
raise ValueError("Couldn't extract version from debian/changelog.")
name, ver, debrel = \
[match.group(gn) for gn in ('source_name', 'version', 'debrel')]
source_dirname = f'{name}-{ver}'
source_tarball_name = f'{name}_{ver}.orig.tar.gz'
debian_tarball_name = f'{name}_{ver}-{debrel}.debian.tar.gz'
source_args = [f'--prefix={source_dirname}/', '-o',
self.project_root.parent / source_tarball_name, 'HEAD']
for filepath in self.project_root.iterdir():
if not self.bad_file_regex.search(filepath.parts[-1]):
source_args.append(filepath)
debian_args = ['-o', self.project_root.parent / debian_tarball_name,
'HEAD', self.debian_dir]
for args in [source_args, debian_args]:
command = ['git', 'archive', '--format=tar.gz', *args]
self.run_command(command, verbose=verbose, check=True)
def commands(self):
helper = self
class MsgfmtCommand(Command):
'''A custom command to run msgfmt on all .po files below '{}'.'''
description = 'use msgfmt to generate .mo files from .po files'
user_options = []
def initialize_options(self):
pass
def finalize_options(self):
pass
def run(self):
helper.create_mo_files(verbose=self.verbose)
MsgfmtCommand.__doc__ = MsgfmtCommand.__doc__.format(helper.locales_dir)
class RunCommand(Command):
'''
A custom command to run the app using flask.
This is similar in effect to:
PYTHONPATH='{packages_root}' FLASK_APP={app_package_name} \\
FLASK_ENV=development flask run
'''
description = 'run the Flask app from source directory'
user_options = [
('locale=', 'l',
"app locale (one of: %s; default: '%s')" %
(', '.join([f"'{l}'" for l in helper.locales]),
helper.default_locale))
]
def initialize_options(self):
self.locale = helper.default_locale
def finalize_options(self):
if self.locale not in helper.locales:
raise ValueError("Locale '%s' not supported" % self.lang)
def run(self):
helper.flask_run(locale=self.locale)
RunCommand.__doc__ = RunCommand.__doc__.format(
packages_root=self.packages_root,
app_package_name=self.app_package_name
)
class MsgmergeCommand(Command):
'''
A custom command to run xgettext and msgmerge to update project's
.po files below '{}'.
'''
description = 'use xgettext and msgmerge to update (or generate) .po files for this project'
user_options = []
def initialize_options(self):
pass
def finalize_options(self):
pass
def run(self):
helper.update_po_files(verbose=self.verbose)
MsgmergeCommand.__doc__ = \
MsgmergeCommand.__doc__.format(helper.locales_dir)
class TarballsCommand(Command):
'''
A custom command to run git archive to create debian tarballs of
this project.
'''
description = 'use git archive to create .orig.tar.gz and .debian.tar.gz files for this project'
user_options = []
def initialize_options(self):
pass
def finalize_options(self):
pass
def run(self):
helper.make_tarballs(verbose=self.verbose)
class BuildCommand(build_py):
'''
The build command but runs the custom msgfmt command before build.
'''
def run(self, *args, **kwargs):
self.run_command('msgfmt')
super().run(*args, **kwargs)
return {
'msgfmt': MsgfmtCommand,
'run': RunCommand,
'msgmerge': MsgmergeCommand,
'tarballs': TarballsCommand,
'build_py': BuildCommand
}