diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml index cc61f8c1..f7bd77e3 100644 --- a/.github/workflows/code-style.yml +++ b/.github/workflows/code-style.yml @@ -42,7 +42,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 + pip install flake8==5.0.4 cd packages/${{ matrix.package }} pip install -r requirements.txt make local-deps @@ -94,7 +94,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install mypy + pip install mypy==0.991 cd packages/${{ matrix.package }} pip install -r requirements.txt make local-deps @@ -107,4 +107,4 @@ jobs: - name: Typecheck with mypy run: | cd packages/${{ matrix.package }} - mypy --install-types --ignore-missing-imports --non-interactive dsw + mypy --install-types --ignore-missing-imports --check-untyped-defs --non-interactive dsw diff --git a/packages/dsw-command-queue/CHANGELOG.md b/packages/dsw-command-queue/CHANGELOG.md index 42fe790b..7cd644e6 100644 --- a/packages/dsw-command-queue/CHANGELOG.md +++ b/packages/dsw-command-queue/CHANGELOG.md @@ -8,6 +8,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [3.18.0] + +Released for version consistency with other DSW tools. + ## [3.17.0] ### Changed @@ -54,3 +58,4 @@ Released for version consistency with other DSW tools. [3.15.3]: /../../tree/v3.15.3 [3.16.0]: /../../tree/v3.16.0 [3.17.0]: /../../tree/v3.17.0 +[3.18.0]: /../../tree/v3.18.0 diff --git a/packages/dsw-command-queue/dsw/command_queue/command_queue.py b/packages/dsw-command-queue/dsw/command_queue/command_queue.py index d2be1dd4..1468f931 100644 --- a/packages/dsw-command-queue/dsw/command_queue/command_queue.py +++ b/packages/dsw-command-queue/dsw/command_queue/command_queue.py @@ -67,7 +67,7 @@ def run(self): queue_conn.listening = True LOG.info('Listening for jobs in command queue') - notifications = list() + notifications = list() # type: list[str] timeout = self.db.cfg.queue_timout LOG.info('Entering working cycle, waiting for notifications') diff --git a/packages/dsw-command-queue/pyproject.toml b/packages/dsw-command-queue/pyproject.toml index 26d682ad..1aea0e45 100644 --- a/packages/dsw-command-queue/pyproject.toml +++ b/packages/dsw-command-queue/pyproject.toml @@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta' [project] name = 'dsw-command-queue' -version = '3.17.0' +version = '3.18.0' description = 'Library for working with command queue and persistent commands' readme = 'README.md' keywords = ['dsw', 'subscriber', 'publisher', 'database', 'queue', 'processing'] @@ -27,7 +27,7 @@ classifiers = [ requires-python = '>=3.7, <4' dependencies = [ # DSW - 'dsw-database==3.17.0', + 'dsw-database==3.18.0', ] [project.urls] diff --git a/packages/dsw-config/CHANGELOG.md b/packages/dsw-config/CHANGELOG.md index c0298e27..e5ab402a 100644 --- a/packages/dsw-config/CHANGELOG.md +++ b/packages/dsw-config/CHANGELOG.md @@ -8,6 +8,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [3.18.0] + +Released for version consistency with other DSW tools. + ## [3.17.0] Released for version consistency with other DSW tools. @@ -52,3 +56,4 @@ Released for version consistency with other DSW tools. [3.15.3]: /../../tree/v3.15.3 [3.16.0]: /../../tree/v3.16.0 [3.17.0]: /../../tree/v3.17.0 +[3.18.0]: /../../tree/v3.18.0 diff --git a/packages/dsw-config/dsw/config/parser.py b/packages/dsw-config/dsw/config/parser.py index c7822874..aa2f6675 100644 --- a/packages/dsw-config/dsw/config/parser.py +++ b/packages/dsw-config/dsw/config/parser.py @@ -1,6 +1,6 @@ import yaml -from typing import List +from typing import List, Any from .model import GeneralConfig, SentryConfig, S3Config, \ DatabaseConfig, LoggingConfig, CloudConfig, MailConfig @@ -98,13 +98,13 @@ def has(self, *path): return True def _get_default(self, *path): - x = self.DEFAULTS + x = self.DEFAULTS # type: Any for p in path: x = x[p] return x def get_or_default(self, *path): - x = self.cfg + x = self.cfg # type: Any for p in path: if not hasattr(x, 'keys') or p not in x.keys(): return self._get_default(*path) diff --git a/packages/dsw-config/pyproject.toml b/packages/dsw-config/pyproject.toml index 08949cee..dc3e9ec3 100644 --- a/packages/dsw-config/pyproject.toml +++ b/packages/dsw-config/pyproject.toml @@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta' [project] name = 'dsw-config' -version = '3.17.0' +version = '3.18.0' description = 'Library for DSW config manipulation' readme = 'README.md' keywords = ['dsw', 'config', 'yaml', 'parser'] diff --git a/packages/dsw-data-seeder/CHANGELOG.md b/packages/dsw-data-seeder/CHANGELOG.md index 58aa3e36..5bb03138 100644 --- a/packages/dsw-data-seeder/CHANGELOG.md +++ b/packages/dsw-data-seeder/CHANGELOG.md @@ -8,6 +8,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [3.18.0] + +Released for version consistency with other DSW tools. + ## [3.17.0] ### Changed @@ -95,3 +99,4 @@ Released for version consistency with other DSW tools. [3.15.3]: /../../tree/v3.15.3 [3.16.0]: /../../tree/v3.16.0 [3.17.0]: /../../tree/v3.17.0 +[3.18.0]: /../../tree/v3.18.0 diff --git a/packages/dsw-data-seeder/dsw/data_seeder/consts.py b/packages/dsw-data-seeder/dsw/data_seeder/consts.py index cf7caae3..c932a02e 100644 --- a/packages/dsw-data-seeder/dsw/data_seeder/consts.py +++ b/packages/dsw-data-seeder/dsw/data_seeder/consts.py @@ -1,5 +1,5 @@ PROG_NAME = 'dsw-seeder' -VERSION = '3.17.0' +VERSION = '3.18.0' NULL_UUID = '00000000-0000-0000-0000-000000000000' LOGGER_NAME = 'DSW_DATA_SEEDER' diff --git a/packages/dsw-data-seeder/dsw/data_seeder/seeder.py b/packages/dsw-data-seeder/dsw/data_seeder/seeder.py index 882a3ff3..cfcf4b9c 100644 --- a/packages/dsw-data-seeder/dsw/data_seeder/seeder.py +++ b/packages/dsw-data-seeder/dsw/data_seeder/seeder.py @@ -65,9 +65,10 @@ def _load_s3_object_names(self): self.s3_objects[s3_object_path] = target_object_name def _prepare_uuids(self): - for i in range(self.uuids_count): - key = self.uuids_placeholder.replace('[n]', f'[{i}]') - self.uuids_replacement[key] = str(uuid.uuid4()) + if self.uuids_placeholder is not None: + for i in range(self.uuids_count): + key = self.uuids_placeholder.replace('[n]', f'[{i}]') + self.uuids_replacement[key] = str(uuid.uuid4()) def prepare(self): if self.prepared: diff --git a/packages/dsw-data-seeder/pyproject.toml b/packages/dsw-data-seeder/pyproject.toml index 385011ed..55299b45 100644 --- a/packages/dsw-data-seeder/pyproject.toml +++ b/packages/dsw-data-seeder/pyproject.toml @@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta' [project] name = 'dsw-data-seeder' -version = '3.17.0' +version = '3.18.0rc1' description = 'Worker for seeding DSW data' readme = 'README.md' keywords = ['data', 'database', 'seed', 'storage'] @@ -27,10 +27,10 @@ dependencies = [ 'click', 'tenacity', # DSW - 'dsw-command-queue==3.17.0', - 'dsw-config==3.17.0', - 'dsw-database==3.17.0', - 'dsw-storage==3.17.0', + 'dsw-command-queue==3.18.0', + 'dsw-config==3.18.0', + 'dsw-database==3.18.0', + 'dsw-storage==3.18.0', ] [project.urls] diff --git a/packages/dsw-database/CHANGELOG.md b/packages/dsw-database/CHANGELOG.md index 4a113a46..1446a083 100644 --- a/packages/dsw-database/CHANGELOG.md +++ b/packages/dsw-database/CHANGELOG.md @@ -8,6 +8,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [3.18.0] + +Released for version consistency with other DSW tools. + ## [3.17.0] ### Added @@ -60,3 +64,4 @@ Released for version consistency with other DSW tools. [3.15.3]: /../../tree/v3.15.3 [3.16.0]: /../../tree/v3.16.0 [3.17.0]: /../../tree/v3.17.0 +[3.18.0]: /../../tree/v3.18.0 diff --git a/packages/dsw-database/dsw/database/database.py b/packages/dsw-database/dsw/database/database.py index 155b2dff..c17e00e3 100644 --- a/packages/dsw-database/dsw/database/database.py +++ b/packages/dsw-database/dsw/database/database.py @@ -344,7 +344,7 @@ def __init__(self, name: str, dsn: str, timeout=30000, autocommit=False): connect_timeout=timeout, ) self.autocommit = autocommit - self._connection = None + self._connection = None # type: Optional[psycopg.Connection] @tenacity.retry( reraise=True, @@ -356,10 +356,14 @@ def __init__(self, name: str, dsn: str, timeout=30000, autocommit=False): def _connect_db(self): LOG.info(f'Creating connection to PostgreSQL database "{self.name}"') connection = psycopg.connect(conninfo=self.dsn, autocommit=self.autocommit) + if connection is None: + raise RuntimeError('Failed to init DB connection') # test connection cursor = connection.cursor() cursor.execute(query='SELECT 1;') result = cursor.fetchone() + if result is None: + raise RuntimeError('Failed to verify DB connection') LOG.debug(f'DB connection verified (result={result[0]})') cursor.close() connection.commit() diff --git a/packages/dsw-database/pyproject.toml b/packages/dsw-database/pyproject.toml index e54259e3..b512a594 100644 --- a/packages/dsw-database/pyproject.toml +++ b/packages/dsw-database/pyproject.toml @@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta' [project] name = 'dsw-database' -version = '3.17.0' +version = '3.18.0' description = 'Library for managing DSW database' readme = 'README.md' keywords = ['dsw', 'database'] @@ -28,7 +28,7 @@ dependencies = [ 'psycopg[binary]', 'tenacity', # DSW - 'dsw-config==3.17.0', + 'dsw-config==3.18.0', ] [project.urls] diff --git a/packages/dsw-document-worker/CHANGELOG.md b/packages/dsw-document-worker/CHANGELOG.md index 2fa9ec2f..175070f9 100644 --- a/packages/dsw-document-worker/CHANGELOG.md +++ b/packages/dsw-document-worker/CHANGELOG.md @@ -8,6 +8,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [3.18.0] + +### Fixed + +- Fix Excel step for datetime + ## [3.17.0] ### Added @@ -67,3 +73,4 @@ Released for version consistency with other DSW tools. [3.15.3]: /../../tree/v3.15.3 [3.16.0]: /../../tree/v3.16.0 [3.17.0]: /../../tree/v3.17.0 +[3.18.0]: /../../tree/v3.18.0 diff --git a/packages/dsw-document-worker/dsw/document_worker/config.py b/packages/dsw-document-worker/dsw/document_worker/config.py index 7c1440df..af87bcea 100644 --- a/packages/dsw-document-worker/dsw/document_worker/config.py +++ b/packages/dsw-document-worker/dsw/document_worker/config.py @@ -20,7 +20,7 @@ def __str__(self): class ExperimentalConfig: - def __init__(self, pdf_only: bool, job_timeout: Optional[float], + def __init__(self, pdf_only: bool, job_timeout: Optional[int], max_doc_size: Optional[float], pdf_watermark: str, pdf_watermark_top: bool): self.pdf_only = pdf_only diff --git a/packages/dsw-document-worker/dsw/document_worker/consts.py b/packages/dsw-document-worker/dsw/document_worker/consts.py index 68dd85ca..65137a23 100644 --- a/packages/dsw-document-worker/dsw/document_worker/consts.py +++ b/packages/dsw-document-worker/dsw/document_worker/consts.py @@ -1,6 +1,6 @@ DEFAULT_ENCODING = 'utf-8' EXIT_SUCCESS = 0 -VERSION = '3.17.0' +VERSION = '3.18.0' PROG_NAME = 'docworker' LOGGER_NAME = 'docworker' CURRENT_METAMODEL = 10 diff --git a/packages/dsw-document-worker/dsw/document_worker/model/context.py b/packages/dsw-document-worker/dsw/document_worker/model/context.py index 270b1a1c..e6258a54 100644 --- a/packages/dsw-document-worker/dsw/document_worker/model/context.py +++ b/packages/dsw-document-worker/dsw/document_worker/model/context.py @@ -378,7 +378,11 @@ def _resolve_links_parent(self, ctx): question_uuid = self.fragments[-1] if question_uuid in ctx.e.questions.keys(): self.question = ctx.e.questions[question_uuid] - self.question.replies[self.path] = self + if self.question is not None: + self.question.replies[self.path] = self + + def _resolve_links(self, ctx): + pass class AnswerReply(Reply): @@ -657,6 +661,9 @@ def _resolve_links_parent(self, ctx): self.required_phase = ctx.e.phases.get(self.required_phase_uuid, PHASE_NEVER) self.is_required = ctx.current_phase.order >= self.required_phase.order + def _resolve_links(self, ctx): + pass + @property def url_references(self) -> list[URLReference]: return [r for r in self.references if isinstance(r, URLReference)] @@ -789,7 +796,7 @@ def _resolve_links(self, ctx): for key in self.choice_uuids if key in ctx.e.choices.keys()] for choice in self.choices: - choice.question = self + choice.parent = self @staticmethod def load(data: dict, **options): @@ -1300,7 +1307,8 @@ def _resolve_links(self, ctx): m._resolve_links(ctx) if self.chapter_uuid is not None and self.chapter_uuid in ctx.e.chapters.keys(): self.chapter = ctx.e.chapters[self.chapter_uuid] - self.chapter.reports.append(self) + if self.chapter is not None: + self.chapter.reports.append(self) @staticmethod def load(data: dict, **options): diff --git a/packages/dsw-document-worker/dsw/document_worker/templates/__init__.py b/packages/dsw-document-worker/dsw/document_worker/templates/__init__.py index 76f9c0a0..7ac62337 100644 --- a/packages/dsw-document-worker/dsw/document_worker/templates/__init__.py +++ b/packages/dsw-document-worker/dsw/document_worker/templates/__init__.py @@ -1,3 +1,4 @@ from .templates import TemplateRegistry, Template +from .formats import Format -__all__ = ['TemplateRegistry', 'Template'] +__all__ = ['TemplateRegistry', 'Template', 'Format'] diff --git a/packages/dsw-document-worker/dsw/document_worker/templates/formats.py b/packages/dsw-document-worker/dsw/document_worker/templates/formats.py index 69d27c8f..ce1d22b2 100644 --- a/packages/dsw-document-worker/dsw/document_worker/templates/formats.py +++ b/packages/dsw-document-worker/dsw/document_worker/templates/formats.py @@ -66,7 +66,7 @@ def execute(self, context: dict) -> DocumentFile: result = self.steps[0].execute_first(context) for step in self.steps[1:]: if result is not None: - result = step.execute_follow(result) + result = step.execute_follow(result, context) else: break return result diff --git a/packages/dsw-document-worker/dsw/document_worker/templates/steps/__init__.py b/packages/dsw-document-worker/dsw/document_worker/templates/steps/__init__.py index 8da9f657..6e59dc48 100644 --- a/packages/dsw-document-worker/dsw/document_worker/templates/steps/__init__.py +++ b/packages/dsw-document-worker/dsw/document_worker/templates/steps/__init__.py @@ -3,6 +3,7 @@ from .conversion import WkHtmlToPdfStep, PandocStep, RdfLibConvertStep from .excel import ExcelStep from .template import JSONStep, Jinja2Step +from .word import EnrichDocxStep __all__ = [ 'create_step', 'Step', 'FormatStepException', @@ -10,4 +11,5 @@ 'PandocStep', 'RdfLibConvertStep', 'WkHtmlToPdfStep', 'ExcelStep', 'JSONStep', 'Jinja2Step', + 'EnrichDocxStep', ] diff --git a/packages/dsw-document-worker/dsw/document_worker/templates/steps/archive.py b/packages/dsw-document-worker/dsw/document_worker/templates/steps/archive.py index 5564edef..40e8a08a 100644 --- a/packages/dsw-document-worker/dsw/document_worker/templates/steps/archive.py +++ b/packages/dsw-document-worker/dsw/document_worker/templates/steps/archive.py @@ -130,7 +130,7 @@ def _create_tar(self) -> DocumentFile: content=data, ) - def execute_follow(self, document: DocumentFile) -> DocumentFile: + def execute_follow(self, document: DocumentFile, context: dict) -> DocumentFile: # (future) allow multiple files to be archived self.input_file_src = document.filename('document') if self.input_file_dst == '': diff --git a/packages/dsw-document-worker/dsw/document_worker/templates/steps/base.py b/packages/dsw-document-worker/dsw/document_worker/templates/steps/base.py index e5abb8a4..72a78d3a 100644 --- a/packages/dsw-document-worker/dsw/document_worker/templates/steps/base.py +++ b/packages/dsw-document-worker/dsw/document_worker/templates/steps/base.py @@ -31,7 +31,7 @@ def requires_via_extras(self, requirement: str) -> bool: def execute_first(self, context: dict) -> DocumentFile: return self.raise_exc('Called execute_follow on Step class') - def execute_follow(self, document: DocumentFile) -> DocumentFile: + def execute_follow(self, document: DocumentFile, context: dict) -> DocumentFile: return self.raise_exc('Called execute_follow on Step class') def raise_exc(self, message: str): diff --git a/packages/dsw-document-worker/dsw/document_worker/templates/steps/conversion.py b/packages/dsw-document-worker/dsw/document_worker/templates/steps/conversion.py index 2b22dc71..032b7287 100644 --- a/packages/dsw-document-worker/dsw/document_worker/templates/steps/conversion.py +++ b/packages/dsw-document-worker/dsw/document_worker/templates/steps/conversion.py @@ -16,7 +16,7 @@ def __init__(self, template, options: dict): def execute_first(self, context: dict) -> DocumentFile: return self.raise_exc(f'Step "{self.NAME}" cannot be first') - def execute_follow(self, document: DocumentFile) -> DocumentFile: + def execute_follow(self, document: DocumentFile, context: dict) -> DocumentFile: if document.file_format != FileFormats.HTML: self.raise_exc(f'WkHtmlToPdf does not support {document.file_format.name} format as input') data = self.wkhtmltopdf( @@ -71,7 +71,7 @@ def __init__(self, template, options: dict): def execute_first(self, context: dict) -> DocumentFile: return self.raise_exc(f'Step "{self.NAME}" cannot be first') - def execute_follow(self, document: DocumentFile) -> DocumentFile: + def execute_follow(self, document: DocumentFile, context: dict) -> DocumentFile: if document.file_format != self.input_format: self.raise_exc(f'Unexpected input {document.file_format.name} as input for pandoc') data = self.pandoc( @@ -114,7 +114,7 @@ def __init__(self, template, options: dict): def execute_first(self, context: dict) -> DocumentFile: return self.raise_exc(f'Step "{self.NAME}" cannot be first') - def execute_follow(self, document: DocumentFile) -> DocumentFile: + def execute_follow(self, document: DocumentFile, context: dict) -> DocumentFile: if document.file_format != self.input_format: self.raise_exc(f'Unexpected input {document.file_format.name} ' f'as input for rdflib-convert ' diff --git a/packages/dsw-document-worker/dsw/document_worker/templates/steps/excel.py b/packages/dsw-document-worker/dsw/document_worker/templates/steps/excel.py index 41ceec97..63105dbf 100644 --- a/packages/dsw-document-worker/dsw/document_worker/templates/steps/excel.py +++ b/packages/dsw-document-worker/dsw/document_worker/templates/steps/excel.py @@ -59,7 +59,7 @@ def _cell_writer_datetime(worksheet: Worksheet, pos_args, item, cell_format): value = datetime.datetime.utcnow() worksheet.write_datetime( *pos_args, - datetime=value, + date=value, cell_format=cell_format, ) @@ -920,7 +920,7 @@ def _get_data(self, document: DocumentFile) -> dict: self.raise_exc(f'Failed to parse JSON for Excel: {str(e)}') return data - def execute_follow(self, document: DocumentFile) -> DocumentFile: + def execute_follow(self, document: DocumentFile, context: dict) -> DocumentFile: input_data = self._get_data(document) is_xlsm = WorkbookBuilder.is_xlsm(input_data) file_format = FileFormats.XLSX diff --git a/packages/dsw-document-worker/dsw/document_worker/templates/steps/template.py b/packages/dsw-document-worker/dsw/document_worker/templates/steps/template.py index 9ed9bd58..4000e64c 100644 --- a/packages/dsw-document-worker/dsw/document_worker/templates/steps/template.py +++ b/packages/dsw-document-worker/dsw/document_worker/templates/steps/template.py @@ -2,6 +2,8 @@ import jinja2.exceptions import json +from typing import Any + from ...consts import DEFAULT_ENCODING from ...context import Context from ...documents import DocumentFile, FileFormat, FileFormats @@ -19,7 +21,7 @@ def execute_first(self, context: dict) -> DocumentFile: DEFAULT_ENCODING ) - def execute_follow(self, document: DocumentFile) -> DocumentFile: + def execute_follow(self, document: DocumentFile, context: dict) -> DocumentFile: return self.raise_exc(f'Step "{self.NAME}" cannot process other files') @@ -69,7 +71,7 @@ def _add_j2_enhancements(self): self.template.template_id, ) if template_cfg is not None: - global_vars = {'secrets': template_cfg.secrets} + global_vars = {'secrets': template_cfg.secrets} # type: dict[str, Any] if template_cfg.requests.enabled: global_vars['requests'] = RequestsWrapper( template_cfg=template_cfg, @@ -96,7 +98,7 @@ def asset_path(file_name): f'- {str(e)}') return DocumentFile(self.output_format, content, DEFAULT_ENCODING) - def execute_follow(self, document: DocumentFile) -> DocumentFile: + def execute_follow(self, document: DocumentFile, context: dict) -> DocumentFile: return self.raise_exc(f'Step "{self.NAME}" cannot process other files') diff --git a/packages/dsw-document-worker/dsw/document_worker/templates/steps/word.py b/packages/dsw-document-worker/dsw/document_worker/templates/steps/word.py new file mode 100644 index 00000000..aee85029 --- /dev/null +++ b/packages/dsw-document-worker/dsw/document_worker/templates/steps/word.py @@ -0,0 +1,125 @@ +import pathlib +import jinja2 +import shutil +import zipfile + +from typing import Any + +from ...consts import DEFAULT_ENCODING +from ...context import Context +from ...documents import DocumentFile, FileFormats +from .base import Step, register_step, TMP_DIR + + +class EnrichDocxStep(Step): + NAME = 'enrich-docx' + INPUT_FORMAT = FileFormats.DOCX + OUTPUT_FORMAT = FileFormats.DOCX + + def _jinja_exception_msg(self, e: jinja2.exceptions.TemplateSyntaxError): + lines = [ + 'Failed loading Jinja2 template due to syntax error:', + f'- {e.message}', + f'- Filename: {e.name}', + f'- Line number: {e.lineno}', + ] + return '\n'.join(lines) + + def __init__(self, template, options: dict): + super().__init__(template, options) + self.rewrites = {k[8:]: v + for k, v in options.items() + if k.startswith('rewrite:')} + # TODO: shared part with Jinja2Step + try: + self.j2_env = jinja2.Environment( + loader=jinja2.FileSystemLoader(searchpath=template.template_dir), + extensions=['jinja2.ext.do'], + ) + self._add_j2_enhancements() + except jinja2.exceptions.TemplateSyntaxError as e: + self.raise_exc(self._jinja_exception_msg(e)) + except Exception as e: + self.raise_exc(f'Failed loading Jinja2 template: {e}') + + def _add_j2_enhancements(self): + # TODO: shared part with Jinja2Step + from ..filters import filters + from ..tests import tests + from ...model.http import RequestsWrapper + self.j2_env.filters.update(filters) + self.j2_env.tests.update(tests) + template_cfg = Context.get().app.cfg.templates.get_config( + self.template.template_id, + ) + if template_cfg is not None: + global_vars = {'secrets': template_cfg.secrets} # type: dict[str, Any] + if template_cfg.requests.enabled: + global_vars['requests'] = RequestsWrapper( + template_cfg=template_cfg, + ) + self.j2_env.globals.update(global_vars) + + def _render_rewrite(self, rewrite_template: str, context: dict) -> str: + try: + j2_template = self.j2_env.get_template(rewrite_template) + return j2_template.render(ctx=context) + except jinja2.exceptions.TemplateSyntaxError as e: + self.raise_exc(self._jinja_exception_msg(e)) + except Exception as e: + self.raise_exc(f'Failed loading Jinja2 template: {e}') + return '' + + def _static_rewrite(self, rewrite_file: str) -> str: + try: + path = self.template.template_dir / rewrite_file # type: pathlib.Path + return path.read_text(encoding=DEFAULT_ENCODING) + except Exception as e: + self.raise_exc(f'Failed loading Jinja2 template: {e}') + return '' + + def _get_rewrite(self, rewrite: str, context: dict) -> str: + if rewrite.startswith('static:'): + return self._static_rewrite(rewrite[7:]) + elif rewrite.startswith('render:'): + return self._render_rewrite(rewrite[7:], context) + return '' + + def execute_first(self, context: dict) -> DocumentFile: + return self.raise_exc(f'Step "{self.NAME}" cannot be first') + + def execute_follow(self, document: DocumentFile, context: dict) -> DocumentFile: + if document.file_format != self.INPUT_FORMAT: + self.raise_exc(f'Step "{self.NAME}" requires DOCX input') + + docx_file = TMP_DIR / 'original_file.docx' + docx_file.write_bytes(document.content) + new_docx_file = TMP_DIR / 'enriched_file.docx' + docx_dir = TMP_DIR / 'enriched_file_docx' + + with zipfile.ZipFile(docx_file, mode='r') as source_docx: + source_docx.extractall(docx_dir) + + for target_file, rewrite in self.rewrites.items(): + content = self._get_rewrite(rewrite, context) + target = docx_dir / target_file + target.write_text(content, encoding=DEFAULT_ENCODING) + + with zipfile.ZipFile(new_docx_file, mode='w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as target_docx: + for path in docx_dir.rglob('*'): + if path.is_file(): + target_docx.write(path, path.relative_to(docx_dir)) + + new_content = new_docx_file.read_bytes() + + docx_file.unlink(missing_ok=True) + new_docx_file.unlink(missing_ok=True) + shutil.rmtree(docx_dir, ignore_errors=True) + + return DocumentFile( + file_format=self.OUTPUT_FORMAT, + content=new_content, + ) + + +register_step(EnrichDocxStep.NAME, EnrichDocxStep) diff --git a/packages/dsw-document-worker/dsw/document_worker/worker.py b/packages/dsw-document-worker/dsw/document_worker/worker.py index f827cb35..3f85328c 100644 --- a/packages/dsw-document-worker/dsw/document_worker/worker.py +++ b/packages/dsw-document-worker/dsw/document_worker/worker.py @@ -20,7 +20,7 @@ from .exceptions import create_job_exception, JobException from .limits import LimitsEnforcer from .logging import DocWorkerLogger, DocWorkerLogFilter -from .templates import TemplateRegistry +from .templates import TemplateRegistry, Template, Format from .utils import timeout, JobTimeoutError,\ PdfWaterMarker, byte_size_format @@ -49,8 +49,8 @@ class Job: def __init__(self, command: PersistentCommand): self.ctx = Context.get() self.log = Context.logger - self.template = None - self.format = None + self.template = None # type: Optional[Template] + self.format = None # type: Optional[Format] self.app_uuid = command.app_uuid # type: str self.doc_uuid = command.body['uuid'] # type: str self.doc_context = command.body # type: dict @@ -59,6 +59,30 @@ def __init__(self, command: PersistentCommand): self.app_config = None # type: Optional[DBAppConfig] self.app_limits = None # type: Optional[DBAppLimits] + @property + def safe_doc(self) -> DBDocument: + if self.doc is None: + raise RuntimeError('Document is not set but it should') + return self.doc + + @property + def safe_final_file(self) -> DocumentFile: + if self.final_file is None: + raise RuntimeError('Final file is not set but it should') + return self.final_file + + @property + def safe_template(self) -> Template: + if self.template is None: + raise RuntimeError('Template is not set but it should') + return self.template + + @property + def safe_format(self) -> Format: + if self.format is None: + raise RuntimeError('Format is not set but it should') + return self.format + @handle_job_step('Failed to get document from DB') def get_document(self): SentryReporter.set_context('template', '') @@ -93,97 +117,105 @@ def get_document(self): @handle_job_step('Failed to prepare template') def prepare_template(self): - template_id = self.doc.template_id - format_uuid = self.doc.format_uuid + template_id = self.safe_doc.template_id + format_uuid = self.safe_doc.format_uuid self.log.info(f'Document uses template {template_id} with format {format_uuid}') # update Sentry info SentryReporter.set_context('template', template_id) SentryReporter.set_context('format', format_uuid) SentryReporter.set_context('document', self.doc_uuid) # prepare template - self.template = TemplateRegistry.get().prepare_template( + template = TemplateRegistry.get().prepare_template( app_uuid=self.app_uuid, template_id=template_id, ) # prepare format - self.template.prepare_format(format_uuid) - self.format = self.template.formats.get(format_uuid) + template.prepare_format(format_uuid) + self.format = template.formats.get(format_uuid) # check limits (PDF-only) self.app_config = self.ctx.app.db.fetch_app_config(app_uuid=self.app_uuid) self.app_limits = self.ctx.app.db.fetch_app_limits(app_uuid=self.app_uuid) LimitsEnforcer.check_format( job_id=self.doc_uuid, - doc_format=self.format, + doc_format=self.safe_format, app_config=self.app_config, ) + # finalize + self.template = template @handle_job_step('Failed to build final document') def build_document(self): self.log.info('Building document by rendering template with context') + doc = self.safe_doc # enrich context context = self.doc_context - if self.format.requires_via_extras('submissions'): + if self.safe_format.requires_via_extras('submissions'): submissions = self.ctx.app.db.fetch_questionnaire_submissions( - questionnaire_uuid=self.doc.questionnaire_uuid, + questionnaire_uuid=doc.questionnaire_uuid, app_uuid=self.app_uuid, ) context['extras'] = { 'submissions': [s.to_dict() for s in submissions], } # render document - self.final_file = self.template.render( - format_uuid=self.doc.format_uuid, + final_file = self.safe_template.render( + format_uuid=doc.format_uuid, context=context, ) # check limits LimitsEnforcer.check_doc_size( job_id=self.doc_uuid, - doc_size=self.final_file.byte_size, + doc_size=final_file.byte_size, ) limit_size = None if self.app_limits is None else self.app_limits.storage used_size = self.ctx.app.db.get_currently_used_size(app_uuid=self.app_uuid) LimitsEnforcer.check_size_usage( job_id=self.doc_uuid, - doc_size=self.final_file.byte_size, + doc_size=final_file.byte_size, used_size=used_size, limit_size=limit_size, ) # watermark - if self.format.is_pdf: - self.final_file.content = LimitsEnforcer.make_watermark( - doc_pdf=self.final_file.content, + if self.safe_format.is_pdf: + final_file.content = LimitsEnforcer.make_watermark( + doc_pdf=final_file.content, app_config=self.app_config, ) + # finalize + self.final_file = final_file @handle_job_step('Failed to store document in S3') def store_document(self): s3_id = self.ctx.app.s3.identification + final_file = self.safe_final_file self.log.info(f'Preparing S3 bucket {s3_id}') self.ctx.app.s3.ensure_bucket() self.log.info(f'Storing document to S3 bucket {s3_id}') self.ctx.app.s3.store_document( app_uuid=self.app_uuid, file_name=self.doc_uuid, - content_type=self.final_file.content_type, - data=self.final_file.content, + content_type=final_file.content_type, + data=final_file.content, ) self.log.info(f'Document {self.doc_uuid} stored in S3 bucket {s3_id}') @handle_job_step('Failed to finalize document generation') def finalize(self): - file_name = DocumentNameGiver.name_document(self.doc, self.final_file) - self.doc.finished_at = datetime.datetime.now() - self.doc.file_name = file_name - self.doc.content_type = self.final_file.content_type - self.doc.file_size = self.final_file.byte_size + doc = self.safe_doc + final_file = self.safe_final_file + file_name = DocumentNameGiver.name_document(doc, final_file) + doc.finished_at = datetime.datetime.now() + doc.file_name = file_name + doc.content_type = final_file.content_type + doc.file_size = final_file.byte_size self.ctx.app.db.update_document_finished( - finished_at=self.doc.finished_at, - file_name=self.doc.file_name, - content_type=self.doc.content_type, - file_size=self.doc.file_size, + finished_at=doc.finished_at, + file_name=doc.file_name, + content_type=doc.content_type, + file_size=doc.file_size, worker_log=( f'Document "{file_name}" generated successfully ' - f'({byte_size_format(self.doc.file_size)}).' + f'({byte_size_format(doc.file_size)}).' ), document_uuid=self.doc_uuid, ) diff --git a/packages/dsw-document-worker/pyproject.toml b/packages/dsw-document-worker/pyproject.toml index 9ff61551..cbd17a5a 100644 --- a/packages/dsw-document-worker/pyproject.toml +++ b/packages/dsw-document-worker/pyproject.toml @@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta' [project] name = 'dsw-document-worker' -version = '3.17.0' +version = '3.18.0' description = 'Worker for assembling and transforming documents' readme = 'README.md' keywords = ['documents', 'generation', 'jinja2', 'pandoc', 'worker'] @@ -39,10 +39,10 @@ dependencies = [ 'tenacity', 'XlsxWriter', # DSW - 'dsw-command-queue==3.17.0', - 'dsw-config==3.17.0', - 'dsw-database==3.17.0', - 'dsw-storage==3.17.0', + 'dsw-command-queue==3.18.0', + 'dsw-config==3.18.0', + 'dsw-database==3.18.0', + 'dsw-storage==3.18.0', ] [project.urls] diff --git a/packages/dsw-mailer/CHANGELOG.md b/packages/dsw-mailer/CHANGELOG.md index 53a9fc96..05945d34 100644 --- a/packages/dsw-mailer/CHANGELOG.md +++ b/packages/dsw-mailer/CHANGELOG.md @@ -8,6 +8,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [3.18.0] + +Released for version consistency with other DSW tools. + ## [3.17.0] ### Changed @@ -61,3 +65,4 @@ Released for version consistency with other DSW tools. [3.15.3]: /../../tree/v3.15.3 [3.16.0]: /../../tree/v3.16.0 [3.17.0]: /../../tree/v3.17.0 +[3.18.0]: /../../tree/v3.18.0 diff --git a/packages/dsw-mailer/dsw/mailer/consts.py b/packages/dsw-mailer/dsw/mailer/consts.py index d1a94564..104c3f6e 100644 --- a/packages/dsw-mailer/dsw/mailer/consts.py +++ b/packages/dsw-mailer/dsw/mailer/consts.py @@ -1,5 +1,5 @@ PROG_NAME = 'dsw-mailer' -VERSION = '3.17.0' +VERSION = '3.18.0' LOGGER_NAME = 'mailer' diff --git a/packages/dsw-mailer/dsw/mailer/mailer.py b/packages/dsw-mailer/dsw/mailer/mailer.py index ac4856ea..09a4f318 100644 --- a/packages/dsw-mailer/dsw/mailer/mailer.py +++ b/packages/dsw-mailer/dsw/mailer/mailer.py @@ -1,3 +1,4 @@ +import abc import datetime import math import pathlib @@ -166,14 +167,16 @@ def _app_config_to_context(app_config: Optional[DBAppConfig]) -> dict: } -class MailerCommand: +class MailerCommand(abc.ABC): FUNCTION_NAME = 'unknown' TEMPLATE_NAME = '' + @abc.abstractmethod def to_context(self) -> dict: pass + @abc.abstractmethod def to_request(self, msg_id: str, trigger: str) -> MessageRequest: pass @@ -185,6 +188,7 @@ def corresponds(cls, cmd: PersistentCommand) -> bool: return cls.FUNCTION_NAME == cmd.function @staticmethod + @abc.abstractmethod def create_from(cmd: PersistentCommand) -> 'MailerCommand': pass diff --git a/packages/dsw-mailer/pyproject.toml b/packages/dsw-mailer/pyproject.toml index c76bc67b..040ec60e 100644 --- a/packages/dsw-mailer/pyproject.toml +++ b/packages/dsw-mailer/pyproject.toml @@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta' [project] name = 'dsw-mailer' -version = '3.17.0' +version = '3.18.0' description = 'Worker for sending email notifications' readme = 'README.md' keywords = ['email', 'jinja2', 'notification', 'template'] @@ -29,9 +29,9 @@ dependencies = [ 'sentry-sdk', 'tenacity', # DSW - 'dsw-command-queue==3.17.0', - 'dsw-config==3.17.0', - 'dsw-database==3.17.0', + 'dsw-command-queue==3.18.0', + 'dsw-config==3.18.0', + 'dsw-database==3.18.0', ] [project.urls] diff --git a/packages/dsw-storage/CHANGELOG.md b/packages/dsw-storage/CHANGELOG.md index b8e11046..0112c33c 100644 --- a/packages/dsw-storage/CHANGELOG.md +++ b/packages/dsw-storage/CHANGELOG.md @@ -8,6 +8,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [3.18.0] + +Released for version consistency with other DSW tools. + ## [3.17.0] Released for version consistency with other DSW tools. @@ -52,3 +56,4 @@ Released for version consistency with other DSW tools. [3.15.3]: /../../tree/v3.15.3 [3.16.0]: /../../tree/v3.16.0 [3.17.0]: /../../tree/v3.17.0 +[3.18.0]: /../../tree/v3.18.0 diff --git a/packages/dsw-storage/pyproject.toml b/packages/dsw-storage/pyproject.toml index 1dfec2df..59ea28fb 100644 --- a/packages/dsw-storage/pyproject.toml +++ b/packages/dsw-storage/pyproject.toml @@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta' [project] name = 'dsw-storage' -version = '3.17.0' +version = '3.18.0' description = 'Library for managing DSW S3 storage' readme = 'README.md' keywords = ['dsw', 's3', 'bucket', 'storage'] @@ -28,7 +28,7 @@ dependencies = [ 'minio', 'tenacity', # DSW - 'dsw-config==3.17.0', + 'dsw-config==3.18.0', ] [project.urls] diff --git a/packages/dsw-tdk/CHANGELOG.md b/packages/dsw-tdk/CHANGELOG.md index c801ceda..d495e150 100644 --- a/packages/dsw-tdk/CHANGELOG.md +++ b/packages/dsw-tdk/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.18.0] + +Released for version consistency with other DSW tools. + ## [3.17.0] Released for version consistency with other DSW tools. @@ -238,3 +242,4 @@ Initial DSW Template Development Kit (versioned as part of the [DSW platform](ht [3.15.3]: /../../tree/v3.15.3 [3.16.0]: /../../tree/v3.16.0 [3.17.0]: /../../tree/v3.17.0 +[3.18.0]: /../../tree/v3.18.0 diff --git a/packages/dsw-tdk/dsw/tdk/cli.py b/packages/dsw-tdk/dsw/tdk/cli.py index c801a377..66d3834f 100644 --- a/packages/dsw-tdk/dsw/tdk/cli.py +++ b/packages/dsw-tdk/dsw/tdk/cli.py @@ -248,7 +248,7 @@ def new_template(ctx, template_dir, force): ClickPrinter.failure('Exited...') exit(1) tdk = TDKCore(template=builder.build(), logger=ctx.obj.logger) - template_dir = template_dir or dir_from_id(tdk.template.id) + template_dir = template_dir or dir_from_id(tdk.safe_template.id) tdk.prepare_local(template_dir=template_dir) try: tdk.store_local(force=force) @@ -278,12 +278,12 @@ async def main_routine(): try: await tdk.init_client(api_url=api_server, username=username, password=password) await tdk.load_remote(template_id=template_id) - await tdk.client.close() + await tdk.safe_client.close() except DSWCommunicationError as e: ClickPrinter.error('Could not get template:', bold=True) ClickPrinter.error(f'> {e.reason}\n> {e.message}') exit(1) - await tdk.client.safe_close() + await tdk.safe_client.safe_close() tdk.prepare_local(template_dir=template_dir) try: tdk.store_local(force=force) @@ -314,7 +314,11 @@ def put_template(ctx, api_server, template_dir, username, password, force, watch async def watch_callback(changes): changes = list(changes) for change in changes: - ClickPrinter.watch_change(*change, root=tdk.project.template_dir) + ClickPrinter.watch_change( + change_type=change[0], + filepath=change[1], + root=tdk.safe_project.template_dir, + ) if len(changes) > 0: await tdk.process_changes(changes, force=force) @@ -323,17 +327,18 @@ async def main_routine(): try: await tdk.init_client(api_server, username, password) await tdk.store_remote(force=force) - ClickPrinter.success(f'Template {tdk.project.template.id} uploaded to {api_server}') + ClickPrinter.success(f'Template {tdk.safe_project.safe_template.id} ' + f'uploaded to {api_server}') if watch: ClickPrinter.watch('Entering watch mode... (press Ctrl+C to abort)') await tdk.watch_project(watch_callback) - await tdk.client.close() + await tdk.safe_client.close() except TDKProcessingError as e: ClickPrinter.failure('Could not upload template') ClickPrinter.error(f'> {e.message}\n> {e.hint}') - await tdk.client.safe_close() + await tdk.safe_client.safe_close() exit(1) except DSWCommunicationError as e: ClickPrinter.failure('Could not upload template') @@ -341,7 +346,7 @@ async def main_routine(): ClickPrinter.error('> Probably incorrect API URL, metamodel version, ' 'or template already exists...') ClickPrinter.error('> Check if you are using the matching version') - await tdk.client.safe_close() + await tdk.safe_client.safe_close() exit(1) loop = asyncio.get_event_loop() @@ -389,7 +394,7 @@ async def main_routine(): ClickPrinter.failure('Failed to get list of templates') ClickPrinter.error(f'> {e.reason}\n> {e.message}') exit(1) - await tdk.client.safe_close() + await tdk.safe_client.safe_close() loop = asyncio.get_event_loop() loop.run_until_complete(main_routine()) @@ -404,7 +409,7 @@ def verify_template(ctx, template_dir): errors = tdk.verify() if len(errors) == 0: ClickPrinter.success('The template is valid!') - print_template_info(template=tdk.project.template) + print_template_info(template=tdk.safe_project.safe_template) else: ClickPrinter.failure('The template is invalid!') click.echo('Found violations:') diff --git a/packages/dsw-tdk/dsw/tdk/consts.py b/packages/dsw-tdk/dsw/tdk/consts.py index 0fae1306..6e2c8883 100644 --- a/packages/dsw-tdk/dsw/tdk/consts.py +++ b/packages/dsw-tdk/dsw/tdk/consts.py @@ -1,8 +1,9 @@ +import pathlib import pathspec # type: ignore import re APP = 'dsw-tdk' -VERSION = '3.17.0' +VERSION = '3.18.0' METAMODEL_VERSION = 10 REGEX_SEMVER = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+$') @@ -13,7 +14,7 @@ DEFAULT_LIST_FORMAT = '{template.id:<40} {template.name:<30}' DEFAULT_ENCODING = 'utf-8' -DEFAULT_README = 'README.md' +DEFAULT_README = pathlib.Path('README.md') TEMPLATE_FILE = 'template.json' PATHSPEC_FACTORY = pathspec.patterns.GitWildMatchPattern diff --git a/packages/dsw-tdk/dsw/tdk/core.py b/packages/dsw-tdk/dsw/tdk/core.py index db9ef984..f6b02660 100644 --- a/packages/dsw-tdk/dsw/tdk/core.py +++ b/packages/dsw-tdk/dsw/tdk/core.py @@ -109,7 +109,7 @@ def prepare_local(self, template_dir): def load_local(self, template_dir): self.prepare_local(template_dir=template_dir) self.logger.info('Loading local template project') - self.project.load() + self.safe_project.load() async def load_remote(self, template_id: str): self.logger.info(f'Retrieving template {template_id}') @@ -210,7 +210,7 @@ async def _create_template_file(self, tfile: TemplateFile, project_update: bool self.logger.error(f'Failed to store remote {tfile.remote_type.value} {tfile.filename.as_posix()}: {e}') async def store_remote_files(self): - for tfile in self.safe_project.template.files.values(): + for tfile in self.safe_project.safe_template.files.values(): tfile.remote_id = None tfile.remote_type = TemplateFileType.file if tfile.is_text else TemplateFileType.asset await self._create_template_file(tfile=tfile, project_update=True) @@ -262,18 +262,24 @@ async def watch_project(self, callback): async def _update_descriptor(self): try: - template_exists = await self.client.check_template_exists(template_id=self.safe_project.template.id) + template_exists = await self.safe_client.check_template_exists( + template_id=self.safe_project.safe_template.id, + ) if template_exists: - self.logger.info(f'Updating existing remote template {self.project.template.id}') - await self.client.put_template(template=self.project.template) + self.logger.info(f'Updating existing remote template' + f' {self.safe_project.safe_template.id}') + await self.safe_client.put_template(template=self.safe_project.safe_template) else: # TODO: optimization - reload full template and send it, skip all other changes - self.logger.info(f'Template {self.safe_project.template.id} does not exist on remote - full sync') + self.logger.info(f'Template {self.safe_project.safe_template.id} ' + f'does not exist on remote - full sync') await self.store_remote(force=False) except DSWCommunicationError as e: - self.logger.error(f'Failed to update template {self.safe_project.safe_template.id}: {e.message}') + self.logger.error(f'Failed to update template' + f' {self.safe_project.safe_template.id}: {e.message}') except Exception as e: - self.logger.error(f'Failed to update template {self.safe_project.safe_template.id}: {e}') + self.logger.error(f'Failed to update template' + f' {self.safe_project.safe_template.id}: {e}') async def _delete_file(self, filepath: pathlib.Path): try: diff --git a/packages/dsw-tdk/dsw/tdk/model.py b/packages/dsw-tdk/dsw/tdk/model.py index 3aa4214c..5f0acea1 100644 --- a/packages/dsw-tdk/dsw/tdk/model.py +++ b/packages/dsw-tdk/dsw/tdk/model.py @@ -109,7 +109,8 @@ class TDKConfig: def __init__(self, *, version=None, readme_file=None, files=None): self.version = version or VERSION # type: str - self.readme_file = readme_file or self.DEFAULT_README # type: Optional[pathlib.Path] + readme_file_str = readme_file or self.DEFAULT_README # type: str + self.readme_file = pathlib.Path(readme_file_str) # type: pathlib.Path self.files = files or [] # type: List[str] @classmethod @@ -138,7 +139,6 @@ def __init__(self, *, remote_id=None, remote_type=None, filename=None, self.remote_id = remote_id # type: Optional[str] self.filename = filename # type: pathlib.Path self.content = content # type: bytes - self.remote_type = remote_type self.content_type = content_type or self.guess_type() # type: str self.remote_type = remote_type or self.guess_tfile_type() # type: TemplateFileType diff --git a/packages/dsw-tdk/pyproject.toml b/packages/dsw-tdk/pyproject.toml index 65868ca6..d6c6604f 100644 --- a/packages/dsw-tdk/pyproject.toml +++ b/packages/dsw-tdk/pyproject.toml @@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta' [project] name = 'dsw-tdk' -version = '3.17.0' +version = '3.18.0' description = 'Data Stewardship Wizard Template Development Toolkit' readme = 'README.md' keywords = ['documents', 'dsw', 'jinja2', 'template', 'toolkit']