From f74c07af9aec29840fb9a635cd43da710f78f02c Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Mon, 13 Jan 2025 22:54:25 +0100 Subject: [PATCH] add cli app and fix relationship typos --- python/cli/.gitignore | 105 +++++++++ python/cli/CHANGELOG.md | 5 + python/cli/Dockerfile | 10 + python/cli/LICENSE.md | 1 + python/cli/MANIFEST.in | 5 + python/cli/Makefile | 31 +++ python/cli/README.md | 69 ++++++ python/cli/config/kontor.yml.example | 46 ++++ .../__init__.py => python/cli/docs/.gitkeep | 0 python/cli/kontor/__init__.py | 0 python/cli/kontor/controllers/__init__.py | 0 python/cli/kontor/controllers/clibase.py | 60 +++++ python/cli/kontor/core/__init__.py | 0 python/cli/kontor/core/exc.py | 4 + python/cli/kontor/core/version.py | 7 + .../cli/kontor}/database/__init__.py | 6 +- {qt => python/cli/kontor}/database/base.py | 0 {qt => python/cli/kontor}/database/comic.py | 2 +- python/cli/kontor/database/media.py | 25 +++ python/cli/kontor/database/metadata.py | 49 ++++ {qt => python/cli/kontor}/database/tysc.py | 2 +- python/cli/kontor/ext/__init__.py | 0 python/cli/kontor/main.py | 111 +++++++++ python/cli/kontor/plugins/__init__.py | 0 python/cli/kontor/templates/__init__.py | 0 python/cli/kontor/templates/command1.jinja2 | 4 + python/cli/requirements-dev.txt | 8 + python/cli/requirements.txt | 6 + python/cli/setup.cfg | 0 python/cli/setup.py | 28 +++ python/cli/tests/conftest.py | 16 ++ python/cli/tests/test_kontor.py | 36 +++ python/main.py | 16 ++ {qt => python/qt}/.gitignore | 0 python/qt/database/__init__.py | 212 ++++++++++++++++++ python/qt/database/base.py | 18 ++ python/qt/database/comic.py | 130 +++++++++++ {qt => python/qt}/database/media.py | 0 {qt => python/qt}/database/metadata.py | 0 python/qt/database/tysc.py | 131 +++++++++++ python/qt/gui/__init__.py | 0 {qt => python/qt}/gui/data_view.py | 0 {qt => python/qt}/gui/data_view_model.py | 0 {qt => python/qt}/gui/dialogs.py | 0 {qt => python/qt}/gui/main_window.py | 0 {qt => python/qt}/gui/model_config.py | 0 {qt => python/qt}/gui/table_model.py | 0 {qt => python/qt}/kontor.py | 0 {qt => python/qt}/pysidedeploy.spec | 0 {qt => python/qt}/res/application-export.png | Bin {qt => python/qt}/res/application-import.png | Bin {qt => python/qt}/res/arrow-circle-double.png | Bin {qt => python/qt}/res/cross.png | Bin {qt => python/qt}/res/tick.png | Bin 54 files changed, 1138 insertions(+), 5 deletions(-) create mode 100644 python/cli/.gitignore create mode 100644 python/cli/CHANGELOG.md create mode 100644 python/cli/Dockerfile create mode 100644 python/cli/LICENSE.md create mode 100644 python/cli/MANIFEST.in create mode 100644 python/cli/Makefile create mode 100644 python/cli/README.md create mode 100644 python/cli/config/kontor.yml.example rename qt/gui/__init__.py => python/cli/docs/.gitkeep (100%) create mode 100644 python/cli/kontor/__init__.py create mode 100644 python/cli/kontor/controllers/__init__.py create mode 100644 python/cli/kontor/controllers/clibase.py create mode 100644 python/cli/kontor/core/__init__.py create mode 100644 python/cli/kontor/core/exc.py create mode 100644 python/cli/kontor/core/version.py rename {qt => python/cli/kontor}/database/__init__.py (98%) rename {qt => python/cli/kontor}/database/base.py (100%) rename {qt => python/cli/kontor}/database/comic.py (99%) create mode 100644 python/cli/kontor/database/media.py create mode 100644 python/cli/kontor/database/metadata.py rename {qt => python/cli/kontor}/database/tysc.py (99%) create mode 100644 python/cli/kontor/ext/__init__.py create mode 100644 python/cli/kontor/main.py create mode 100644 python/cli/kontor/plugins/__init__.py create mode 100644 python/cli/kontor/templates/__init__.py create mode 100644 python/cli/kontor/templates/command1.jinja2 create mode 100644 python/cli/requirements-dev.txt create mode 100644 python/cli/requirements.txt create mode 100644 python/cli/setup.cfg create mode 100644 python/cli/setup.py create mode 100644 python/cli/tests/conftest.py create mode 100644 python/cli/tests/test_kontor.py create mode 100644 python/main.py rename {qt => python/qt}/.gitignore (100%) create mode 100644 python/qt/database/__init__.py create mode 100644 python/qt/database/base.py create mode 100644 python/qt/database/comic.py rename {qt => python/qt}/database/media.py (100%) rename {qt => python/qt}/database/metadata.py (100%) create mode 100644 python/qt/database/tysc.py create mode 100644 python/qt/gui/__init__.py rename {qt => python/qt}/gui/data_view.py (100%) rename {qt => python/qt}/gui/data_view_model.py (100%) rename {qt => python/qt}/gui/dialogs.py (100%) rename {qt => python/qt}/gui/main_window.py (100%) rename {qt => python/qt}/gui/model_config.py (100%) rename {qt => python/qt}/gui/table_model.py (100%) rename {qt => python/qt}/kontor.py (100%) rename {qt => python/qt}/pysidedeploy.spec (100%) rename {qt => python/qt}/res/application-export.png (100%) rename {qt => python/qt}/res/application-import.png (100%) rename {qt => python/qt}/res/arrow-circle-double.png (100%) rename {qt => python/qt}/res/cross.png (100%) rename {qt => python/qt}/res/tick.png (100%) diff --git a/python/cli/.gitignore b/python/cli/.gitignore new file mode 100644 index 0000000..a74b246 --- /dev/null +++ b/python/cli/.gitignore @@ -0,0 +1,105 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +coverage-report/ +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ diff --git a/python/cli/CHANGELOG.md b/python/cli/CHANGELOG.md new file mode 100644 index 0000000..6c95d97 --- /dev/null +++ b/python/cli/CHANGELOG.md @@ -0,0 +1,5 @@ +# Kontor Change History + +## 0.0.1 + +Initial release. diff --git a/python/cli/Dockerfile b/python/cli/Dockerfile new file mode 100644 index 0000000..d32cf5c --- /dev/null +++ b/python/cli/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.9-alpine +LABEL MAINTAINER="Thomas Peetz " +ENV PS1="\[\e[0;33m\]|> kontor <| \[\e[1;35m\]\W\[\e[0m\] \[\e[0m\]# " + +WORKDIR /src +COPY . /src +RUN pip install --no-cache-dir -r requirements.txt \ + && python setup.py install +WORKDIR / +ENTRYPOINT ["kontor"] diff --git a/python/cli/LICENSE.md b/python/cli/LICENSE.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/python/cli/LICENSE.md @@ -0,0 +1 @@ + diff --git a/python/cli/MANIFEST.in b/python/cli/MANIFEST.in new file mode 100644 index 0000000..1160952 --- /dev/null +++ b/python/cli/MANIFEST.in @@ -0,0 +1,5 @@ +recursive-include *.py +include setup.cfg +include README.md CHANGELOG.md LICENSE.md +include *.txt +recursive-include kontor/templates * diff --git a/python/cli/Makefile b/python/cli/Makefile new file mode 100644 index 0000000..b016c3c --- /dev/null +++ b/python/cli/Makefile @@ -0,0 +1,31 @@ +.PHONY: clean virtualenv test docker dist dist-upload + +clean: + find . -name '*.py[co]' -delete + +virtualenv: + virtualenv --prompt '|> kontor <| ' env + env/bin/pip install -r requirements-dev.txt + env/bin/python setup.py develop + @echo + @echo "VirtualENV Setup Complete. Now run: source env/bin/activate" + @echo + +test: + python -m pytest \ + -v \ + --cov=kontor \ + --cov-report=term \ + --cov-report=html:coverage-report \ + tests/ + +docker: clean + docker build -t kontor:latest . + +dist: clean + rm -rf dist/* + python setup.py sdist + python setup.py bdist_wheel + +dist-upload: + twine upload dist/* diff --git a/python/cli/README.md b/python/cli/README.md new file mode 100644 index 0000000..6afe593 --- /dev/null +++ b/python/cli/README.md @@ -0,0 +1,69 @@ +# Kontor CLI Tool + +## Installation + +``` +$ pip install -r requirements.txt + +$ python setup.py install +``` + +## Development + +This project includes a number of helpers in the `Makefile` to streamline common development tasks. + +### Environment Setup + +The following demonstrates setting up and working with a development environment: + +``` +### create a virtualenv for development + +$ make virtualenv + +$ source env/bin/activate + + +### run kontor cli application + +$ kontor --help + + +### run pytest / coverage + +$ make test +``` + + +### Releasing to PyPi + +Before releasing to PyPi, you must configure your login credentials: + +**~/.pypirc**: + +``` +[pypi] +username = YOUR_USERNAME +password = YOUR_PASSWORD +``` + +Then use the included helper function via the `Makefile`: + +``` +$ make dist + +$ make dist-upload +``` + +## Deployments + +### Docker + +Included is a basic `Dockerfile` for building and distributing `Kontor`, +and can be built with the included `make` helper: + +``` +$ make docker + +$ docker run -it kontor --help +``` diff --git a/python/cli/config/kontor.yml.example b/python/cli/config/kontor.yml.example new file mode 100644 index 0000000..ba6507c --- /dev/null +++ b/python/cli/config/kontor.yml.example @@ -0,0 +1,46 @@ +### Kontor Configuration Settings +--- + +kontor: + +### Toggle application level debug (does not toggle framework debugging) +# debug: false + +### Where external (third-party) plugins are loaded from +# plugin_dir: /var/lib/kontor/plugins/ + +### Where all plugin configurations are loaded from +# plugin_config_dir: /etc/kontor/plugins.d/ + +### Where external templates are loaded from +# template_dir: /var/lib/kontor/templates/ + +### The log handler label +# log_handler: colorlog + +### The output handler label +# output_handler: jinja2 + +### sample foo option +# foo: bar + + +log.colorlog: + +### Where the log file lives (no log file by default) +# file: null + +### The level for which to log. One of: info, warning, error, fatal, debug +# level: info + +### Whether or not to log to console +# to_console: true + +### Whether or not to rotate the log file when it reaches `max_bytes` +# rotate: false + +### Max size in bytes that a log file can grow until it is rotated. +# max_bytes: 512000 + +### The maximum number of log files to maintain when rotating +# max_files: 4 diff --git a/qt/gui/__init__.py b/python/cli/docs/.gitkeep similarity index 100% rename from qt/gui/__init__.py rename to python/cli/docs/.gitkeep diff --git a/python/cli/kontor/__init__.py b/python/cli/kontor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/kontor/controllers/__init__.py b/python/cli/kontor/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/kontor/controllers/clibase.py b/python/cli/kontor/controllers/clibase.py new file mode 100644 index 0000000..8d69fb2 --- /dev/null +++ b/python/cli/kontor/controllers/clibase.py @@ -0,0 +1,60 @@ + +from cement import Controller, ex +from cement.utils.version import get_version_banner +from ..core.version import get_version + +VERSION_BANNER = """ +Kontor CLI Tool %s +%s +""" % (get_version(), get_version_banner()) + + +class CliBase(Controller): + class Meta: + label = 'base' + + # text displayed at the top of --help output + description = 'Kontor CLI Tool' + + # text displayed at the bottom of --help output + epilog = 'Usage: kontor command1 --foo bar' + + # controller level arguments. ex: 'kontor --version' + arguments = [ + ### add a version banner + ( [ '-v', '--version' ], + { 'action' : 'version', + 'version' : VERSION_BANNER } ), + ] + + + def _default(self): + """Default action if no sub-command is passed.""" + + self.app.args.print_help() + + + @ex( + help='example sub command1', + + # sub-command level arguments. ex: 'kontor command1 --foo bar' + arguments=[ + ### add a sample foo option under subcommand namespace + ( [ '-f', '--foo' ], + { 'help' : 'notorious foo option', + 'action' : 'store', + 'dest' : 'foo' } ), + ], + ) + def command1(self): + """Example sub-command.""" + + data = { + 'foo' : 'bar', + } + + ### do something with arguments + if self.app.pargs.foo is not None: + data['foo'] = self.app.pargs.foo + + self.app.render(data, 'command1.jinja2') diff --git a/python/cli/kontor/core/__init__.py b/python/cli/kontor/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/kontor/core/exc.py b/python/cli/kontor/core/exc.py new file mode 100644 index 0000000..aaeb159 --- /dev/null +++ b/python/cli/kontor/core/exc.py @@ -0,0 +1,4 @@ + +class KontorError(Exception): + """Generic errors.""" + pass diff --git a/python/cli/kontor/core/version.py b/python/cli/kontor/core/version.py new file mode 100644 index 0000000..d130f85 --- /dev/null +++ b/python/cli/kontor/core/version.py @@ -0,0 +1,7 @@ + +from cement.utils.version import get_version as cement_get_version + +VERSION = (0, 0, 1, 'alpha', 0) + +def get_version(version=VERSION): + return cement_get_version(version) diff --git a/qt/database/__init__.py b/python/cli/kontor/database/__init__.py similarity index 98% rename from qt/database/__init__.py rename to python/cli/kontor/database/__init__.py index d74e927..95ce1d8 100644 --- a/qt/database/__init__.py +++ b/python/cli/kontor/database/__init__.py @@ -6,9 +6,9 @@ import mariadb from sqlalchemy import create_engine, select, text, MetaData, join from sqlalchemy.orm import DeclarativeBase, relationship, sessionmaker -from database.base import Base -from database.comic import Comic -from database.metadata import MetaDataTable, MetaDataColumn +from .base import Base +from .comic import Comic +from .metadata import MetaDataTable, MetaDataColumn class KontorDB: diff --git a/qt/database/base.py b/python/cli/kontor/database/base.py similarity index 100% rename from qt/database/base.py rename to python/cli/kontor/database/base.py diff --git a/qt/database/comic.py b/python/cli/kontor/database/comic.py similarity index 99% rename from qt/database/comic.py rename to python/cli/kontor/database/comic.py index 1dc31b4..49d222f 100644 --- a/qt/database/comic.py +++ b/python/cli/kontor/database/comic.py @@ -2,7 +2,7 @@ from sqlalchemy import Column, DateTime, ForeignKey, Integer, String from sqlalchemy.dialects.mysql import BIT from sqlalchemy.orm import relationship -from database.base import Base +from .base import Base class Publisher(Base): diff --git a/python/cli/kontor/database/media.py b/python/cli/kontor/database/media.py new file mode 100644 index 0000000..0129c03 --- /dev/null +++ b/python/cli/kontor/database/media.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, DateTime, Integer, String +from sqlalchemy.dialects.mysql import BIT + +from .base import Base + + +class MediaFile(Base): + __tablename__ = 'media_file' + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + cloud_link = Column(String(255)) + file_name = Column(String(255)) + path = Column(String(255)) + review = Column(BIT(1)) + title = Column(String(255)) + url = Column(String(255)) + should_download = Column(BIT(1)) + + def __repr__(self): + return f'MediaFile({self.id} {self.title} {self.title})' + + def __str__(self): + return f'{self.title}({self.id})' diff --git a/python/cli/kontor/database/metadata.py b/python/cli/kontor/database/metadata.py new file mode 100644 index 0000000..2975e10 --- /dev/null +++ b/python/cli/kontor/database/metadata.py @@ -0,0 +1,49 @@ +from sqlalchemy import Column, String, ForeignKey, DateTime, Integer, Boolean +from sqlalchemy.dialects.mysql import BIT +from sqlalchemy.orm import relationship + +from .base import Base + + +class MetaDataTable(Base): + __tablename__ = 'meta_data_table' + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + table_name = Column(String(255), unique=True) + table_columns = relationship("MetaDataColumn") + + def __repr__(self): + return f'MetaDataTable({self.id} {self.table_name})' + + def __str__(self): + return f'{self.table_name}({self.id})' + +class MetaDataColumn(Base): + __tablename__ = 'meta_data_column' + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + column_modifier = Column(String(255), nullable=True) + column_name = Column(String(255)) + column_order = Column(Integer) + column_sync_name = Column(String(255)) + column_type = Column(String(255)) + table_id = Column(String, ForeignKey('meta_data_table.id')) + table = relationship("MetaDataTable", back_populates="table_columns") + column_label = Column(String(255)) + filter_label = Column(String(255)) + is_shown = Column(BIT(1)) + show_filter = Column(BIT(1)) + ref_column = Column(String, nullable=True) + + def __repr__(self): + if self.column_name is None: + return f'MetaDataColumn({self.id} {self.table.table_name}.__)' + else: + return f'MetaDataColumn({self.id} {self.table.table_name}.{self.column_name})' + + def __str__(self): + return f'{self.column_name}({self.id})' diff --git a/qt/database/tysc.py b/python/cli/kontor/database/tysc.py similarity index 99% rename from qt/database/tysc.py rename to python/cli/kontor/database/tysc.py index 0e77165..54cacea 100644 --- a/qt/database/tysc.py +++ b/python/cli/kontor/database/tysc.py @@ -2,7 +2,7 @@ from sqlalchemy import Column, DateTime, Integer, String, ForeignKey, UniqueCons from sqlalchemy.dialects.mysql import BIT from sqlalchemy.orm import relationship -from database.base import Base +from .base import Base class Sport(Base): diff --git a/python/cli/kontor/ext/__init__.py b/python/cli/kontor/ext/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/kontor/main.py b/python/cli/kontor/main.py new file mode 100644 index 0000000..4c71aab --- /dev/null +++ b/python/cli/kontor/main.py @@ -0,0 +1,111 @@ + +from cement import App, TestApp, init_defaults +from cement.core.exc import CaughtSignal +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from .core.exc import KontorError +from .database.base import Base +from .controllers.clibase import CliBase + +# configuration defaults +CONFIG = init_defaults('kontor', 'mariadb') +CONFIG['kontor']['foo'] = 'bar' +CONFIG['mariadb']['user'] = 'kontor' +CONFIG['mariadb']['password'] = 'kontor' +CONFIG['mariadb']['host'] = '127.0.0.1' +CONFIG['mariadb']['port'] = '3306' +CONFIG['mariadb']['database'] = 'kontor' + +def extend_sqlalchemy(app): + app.log.info('extending kontor application with sqlalchemy') + connect_string = ('mariadb+mariadbconnector://{}:{}@{}:{}/{}'.format( + app.config.get('mariadb', 'user'), + app.config.get('mariadb', 'password'), + app.config.get('mariadb', 'host'), + app.config.get('mariadb', 'port'), + app.config.get('mariadb', 'database') + )) + # engine = create_engine(connect_string, echo=True) + engine = create_engine(connect_string) + Base.metadata.create_all(bind=engine) + __session__ = sessionmaker(bind=engine) + app.extend('session', __session__()) + + +class Kontor(App): + """Kontor primary application.""" + + class Meta: + label = 'kontor' + + # configuration defaults + config_defaults = CONFIG + + # call sys.exit() on close + exit_on_close = True + + # load additional framework extensions + extensions = [ + 'yaml', + 'colorlog', + 'jinja2', + ] + + # configuration handler + config_handler = 'yaml' + + # configuration file suffix + config_file_suffix = '.yml' + + # set the log handler + log_handler = 'colorlog' + + # set the output handler + output_handler = 'jinja2' + + hooks = [ + ('post_setup', extend_sqlalchemy), + ] + # register handlers + handlers = [ + CliBase + ] + + +class KontorTest(TestApp,Kontor): + """A sub-class of Kontor that is better suited for testing.""" + + class Meta: + label = 'kontor' + + +def main(): + with Kontor() as app: + try: + app.run() + + except AssertionError as e: + print('AssertionError > %s' % e.args[0]) + app.exit_code = 1 + + if app.debug is True: + import traceback + traceback.print_exc() + + except KontorError as e: + print('KontorError > %s' % e.args[0]) + app.exit_code = 1 + + if app.debug is True: + import traceback + traceback.print_exc() + + except CaughtSignal as e: + # Default Cement signals are SIGINT and SIGTERM, exit 0 (non-error) + print('\n%s' % e) + app.exit_code = 0 + + +if __name__ == '__main__': + main() diff --git a/python/cli/kontor/plugins/__init__.py b/python/cli/kontor/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/kontor/templates/__init__.py b/python/cli/kontor/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/kontor/templates/command1.jinja2 b/python/cli/kontor/templates/command1.jinja2 new file mode 100644 index 0000000..2435e4d --- /dev/null +++ b/python/cli/kontor/templates/command1.jinja2 @@ -0,0 +1,4 @@ + +Example Template (templates/command1.jinja2) + +Foo => {{ foo }} diff --git a/python/cli/requirements-dev.txt b/python/cli/requirements-dev.txt new file mode 100644 index 0000000..f20606e --- /dev/null +++ b/python/cli/requirements-dev.txt @@ -0,0 +1,8 @@ +-r requirements.txt + +pytest +pytest-cov +coverage +twine>=1.11.0 +setuptools>=38.6.0 +wheel>=0.31.0 diff --git a/python/cli/requirements.txt b/python/cli/requirements.txt new file mode 100644 index 0000000..bee2df1 --- /dev/null +++ b/python/cli/requirements.txt @@ -0,0 +1,6 @@ +cement==3.0.12 +cement[jinja2] +cement[yaml] +cement[colorlog] +mariadb +sqlalchemy diff --git a/python/cli/setup.cfg b/python/cli/setup.cfg new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/setup.py b/python/cli/setup.py new file mode 100644 index 0000000..0b92a43 --- /dev/null +++ b/python/cli/setup.py @@ -0,0 +1,28 @@ + +from setuptools import setup, find_packages +from kontor.core.version import get_version + +VERSION = get_version() + +f = open('README.md', 'r') +LONG_DESCRIPTION = f.read() +f.close() + +setup( + name='kontor', + version=VERSION, + description='Kontor CLI Tool', + long_description=LONG_DESCRIPTION, + long_description_content_type='text/markdown', + author='Thomas Peetz', + author_email='thomas.peetz@thpeetz.de', + url='https://gitlab.com/tpeetz/kontor', + license='MIT', + packages=find_packages(exclude=['ez_setup', 'tests*']), + package_data={'kontor': ['templates/*']}, + include_package_data=True, + entry_points=""" + [console_scripts] + kontor = kontor.main:main + """, +) diff --git a/python/cli/tests/conftest.py b/python/cli/tests/conftest.py new file mode 100644 index 0000000..5124e2e --- /dev/null +++ b/python/cli/tests/conftest.py @@ -0,0 +1,16 @@ +""" +PyTest Fixtures. +""" + +import pytest +from cement import fs + +@pytest.fixture(scope="function") +def tmp(request): + """ + Create a `tmp` object that geneates a unique temporary directory, and file + for each test function that requires it. + """ + t = fs.Tmp() + yield t + t.remove() diff --git a/python/cli/tests/test_kontor.py b/python/cli/tests/test_kontor.py new file mode 100644 index 0000000..3c1bd67 --- /dev/null +++ b/python/cli/tests/test_kontor.py @@ -0,0 +1,36 @@ + +from pytest import raises +from kontor.main import KontorTest + +def test_kontor(): + # test kontor without any subcommands or arguments + with KontorTest() as app: + app.run() + assert app.exit_code == 0 + + +def test_kontor_debug(): + # test that debug mode is functional + argv = ['--debug'] + with KontorTest(argv=argv) as app: + app.run() + assert app.debug is True + + +def test_command1(): + # test command1 without arguments + argv = ['command1'] + with KontorTest(argv=argv) as app: + app.run() + data,output = app.last_rendered + assert data['foo'] == 'bar' + assert output.find('Foo => bar') + + + # test command1 with arguments + argv = ['command1', '--foo', 'not-bar'] + with KontorTest(argv=argv) as app: + app.run() + data,output = app.last_rendered + assert data['foo'] == 'not-bar' + assert output.find('Foo => not-bar') diff --git a/python/main.py b/python/main.py new file mode 100644 index 0000000..76d0e8c --- /dev/null +++ b/python/main.py @@ -0,0 +1,16 @@ +# This is a sample Python script. + +# Press Umschalt+F10 to execute it or replace it with your code. +# Press Double Shift to search everywhere for classes, files, tool windows, actions, and settings. + + +def print_hi(name): + # Use a breakpoint in the code line below to debug your script. + print(f'Hi, {name}') # Press Strg+F8 to toggle the breakpoint. + + +# Press the green button in the gutter to run the script. +if __name__ == '__main__': + print_hi('PyCharm') + +# See PyCharm help at https://www.jetbrains.com/help/pycharm/ diff --git a/qt/.gitignore b/python/qt/.gitignore similarity index 100% rename from qt/.gitignore rename to python/qt/.gitignore diff --git a/python/qt/database/__init__.py b/python/qt/database/__init__.py new file mode 100644 index 0000000..47f5706 --- /dev/null +++ b/python/qt/database/__init__.py @@ -0,0 +1,212 @@ +import json +from datetime import datetime +from pathlib import Path + +import mariadb +from sqlalchemy import create_engine, select, text, MetaData, join +from sqlalchemy.orm import DeclarativeBase, relationship, sessionmaker + +from .base import Base +from .comic import Comic, Artist, Publisher, ComicWork, WorkType, StoryArc, Volume, Issue, TradePaperback +from .tysc import Sport, Team, Card, CardSet, Vendor, Rooster, Player, FieldPosition +from .media import MediaFile +from .metadata import MetaDataTable, MetaDataColumn + + +class KontorDB: + + def __init__(self, db_config): + self.db_conn = mariadb.connect( + host=db_config['mariadb']['host'], + port=db_config['mariadb']['port'], + user=db_config['mariadb']['user'], + password=db_config['mariadb']['password'], + database=db_config['mariadb']['database'] + ) + connect_string = ('mariadb+mariadbconnector://{}:{}@{}:{}/{}' + .format( + db_config['mariadb']['user'], + db_config['mariadb']['password'], + db_config['mariadb']['host'], + db_config['mariadb']['port'], + db_config['mariadb']['database'])) + # engine = create_engine(connect_string, echo=True) + engine = create_engine(connect_string) + Base.metadata.create_all(bind=engine) + __session__ = sessionmaker(bind=engine) + self.session = __session__() + self.registry = {} + self.init_registry() + + def init_registry(self): + self.registry['card'] = Card + self.registry['card_set'] = CardSet + self.registry['sport'] = Sport + self.registry['team'] = Team + self.registry['field_position'] = FieldPosition + self.registry['rooster'] = Rooster + self.registry['player'] = Player + self.registry['vendor'] = Vendor + self.registry['artist'] = Artist + self.registry['publisher'] = Publisher + self.registry['comic'] = Comic + self.registry['issue'] = Issue + self.registry['story_arc'] = StoryArc + self.registry['trade_paperback'] = TradePaperback + self.registry['volume'] = Volume + self.registry['comic_work'] = ComicWork + self.registry['worktype'] = WorkType + self.registry['media_file'] = MediaFile + + + def get_table_names(self) -> list: + tables = self.session.query(MetaDataTable).all() + result = [table.table_name for table in tables] + return result + + def get_column_meta_data(self, table_name: str, view_only=True) -> dict: + meta_data = {} + order = 0 + if view_only: + for (_, column) in (self.session.query(MetaDataTable, MetaDataColumn). + filter(MetaDataTable.id == MetaDataColumn.table_id). + filter(MetaDataTable.table_name == table_name). + filter(MetaDataColumn.is_shown == 1).all()): + meta_data[order] = {'column': column.column_name, 'label': column.column_label, + 'order': column.column_order, 'ref_column': column.ref_column} + order += 1 + else: + for (_, column) in (self.session.query(MetaDataTable, MetaDataColumn). + filter(MetaDataTable.id == MetaDataColumn.table_id). + filter(MetaDataTable.table_name == table_name).all()): + meta_data[order] = { + 'column': column.column_name, + 'order': column.column_order, + 'ref_column': column.ref_column + } + order += 1 + return meta_data + + def get_filters(self, table_name): + _filter_map = {} + for (_, column) in (self.session.query(MetaDataTable, MetaDataColumn). + filter(MetaDataTable.id == MetaDataColumn.table_id). + filter(MetaDataTable.table_name == table_name). + filter(MetaDataColumn.show_filter == 1).all()): + _filter_map[column.column_name] = {'label': column.filter_label, 'widget': None} + print(f"retrieved {len(_filter_map)} filters: {_filter_map}") + return _filter_map + + def data(self, table, columns: dict, filters) -> list: + data = [] + entries = [] + if len(filters) == 0: + entries = self.session.query(table).all() + else: + entries = self.session.query(table).filter_by(**filters) + for entry in entries: + row = [] + for order in columns.keys(): + column_name = columns[order]['column'] + if str(column_name).endswith("_id"): + ref_table = column_name[:-3] + # print(f"{ref_table=}") + ref = getattr(entry, ref_table) + value = getattr(ref, "name") + # print(f"{value=}") + row.append(value) + else: + row.append(getattr(entry, column_name)) + # print(repr(row)) + data.append(row) + return data + + def get_data(self, table_name: str, columns: dict, where_clause: str) -> list: + data = [] + cursor = self.db_conn.cursor() + cursor.execute(self.get_statement(table_name, columns, where_clause)) + rows = cursor.fetchall() + print(len(rows)) + for row in rows: + # print(f"KontorDB.get_data: {row}") + data.append(list(row)) + cursor.close() + # print(f"KontorDB.getData: return {len(data)}") + if table_name == 'comic' and len(where_clause) == 0: + data.clear() + comics = self.session.query(Comic).all() + for item in comics: + # print(item) + row = [] + for order in columns.keys(): + column_name = columns[order]['column'] + if str(column_name).endswith("_id"): + ref_table = column_name[:-3] + # print(f"{ref_table=}") + ref = getattr(item, ref_table) + value = getattr(ref, "name") + # print(f"{value=}") + row.append(value) + else: + row.append(getattr(item, column_name)) + # print(repr(row)) + data.append(row) + return data + + def get_statement(self, table: str, header: dict, where_clause): + columns = "" + for index, column in header.items(): + if index > 0: + columns += ", " + columns += column['column'] + if len(columns) == 0: + columns = "*" + statement = f"SELECT {columns} FROM {table} {where_clause}" + print(f"{statement=}") + return statement + + def export_db(self, export_type: str, export_file_name: str, export_table_list: list): + print(f"export DB to {export_file_name} as {export_type}") + db = {} + for table in export_table_list: + columns = self.get_column_meta_data(table, view_only=False) + if table in self.registry: + model = self.registry[table] + else: + print(f"table {table} is not registered") + continue + rows = self.session.query(model).all() + entries = [] + print(f"found {len(rows)} entries") + print(f"found {len(columns)} columns") + for row in rows: + # print(row) + entry = {} + for order in columns: + # print(columns[order]) + column_name = columns[order]['column'] + # print(f"get value {column_name} from {row} of table {table}") + try: + value = getattr(row, column_name) + if isinstance(value, datetime): + entry[column_name] = str(value) + else: + entry[column_name] = value + except AttributeError as error: + print("could not get value") + entries.append(entry) + db[table] = entries + export_file = Path(export_file_name) + match export_type: + case "JSON": + json_dump = json.dumps(db, indent=4) + with open(export_file_name, "w") as dump_file: + dump_file.write(json_dump) + case "YAML": + export_file = Path(export_file_name) + case "SQLite": + export_file = Path(export_file_name) + case _: + print("unknown export type") + if export_file.exists(): + print(f"{export_file} exists") diff --git a/python/qt/database/base.py b/python/qt/database/base.py new file mode 100644 index 0000000..97d2990 --- /dev/null +++ b/python/qt/database/base.py @@ -0,0 +1,18 @@ +from sqlalchemy.orm import DeclarativeBase, relationship, sessionmaker, declarative_base + + +class Base(DeclarativeBase): + pass + +# class BaseModel: +# +# @classmethod +# def model_lookup_by_table_name(cls, table_name): +# registry_instance = getattr(cls, "registry") +# for mapper_ in registry_instance.mappers: +# model = mapper_.class_ +# model_class_name = model.__tablename__ +# if model_class_name == table_name: +# return model + +# Base = declarative_base(cls=BaseModel) \ No newline at end of file diff --git a/python/qt/database/comic.py b/python/qt/database/comic.py new file mode 100644 index 0000000..49d222f --- /dev/null +++ b/python/qt/database/comic.py @@ -0,0 +1,130 @@ +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.mysql import BIT +from sqlalchemy.orm import relationship + +from .base import Base + + +class Publisher(Base): + __tablename__ = "publisher" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(length=255), unique=True) + comics = relationship("Comic") + + def __repr__(self): + return f'Publisher({self.id} {self.name})' + + def __str__(self): + return self.__repr__() + + +class Comic(Base): + __tablename__ = 'comic' + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + title = Column(String(length=255), unique=True) + publisher_id = Column(String, ForeignKey('publisher.id'), nullable=False) + publisher = relationship("Publisher", back_populates="comics") + current_order = Column(BIT(1)) + completed = Column(BIT(1)) + issues = relationship("Issue") + story_arcs = relationship("StoryArc") + trade_paperbacks = relationship("TradePaperback") + volumes = relationship("Volume") + comic_works = relationship("ComicWork") + + def __repr__(self): + return f'Comic({self.id} {self.version} {self.title} {self.publisher.name})' + + def __str__(self): + return f'{self.title}({self.id})' + + +class Volume(Base): + __tablename__ = "volume" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(length=255), nullable=False) + comic_id = Column(String, ForeignKey("comic.id"), nullable=False) + comic = relationship("Comic", back_populates="volumes") + issues = relationship("Issue") + + +class TradePaperback(Base): + __tablename__ = "trade_paperback" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(length=255), nullable=False) + issue_start = Column(Integer) + issue_end = Column(Integer) + comic_id = Column(String, ForeignKey("comic.id"), nullable=False) + comic = relationship("Comic", back_populates="trade_paperbacks") + + +class StoryArc(Base): + __tablename__ = "story_arc" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(length=255), nullable=False) + comic_id = Column(String, ForeignKey("comic.id"), nullable=False) + comic = relationship("Comic", back_populates="story_arcs") + + +class Issue(Base): + __tablename__ = "issue" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + issue_number = Column(String(255)) + in_stock = Column(BIT(1)) + is_read = Column(BIT(1)) + comic_id = Column(String, ForeignKey("comic.id"), nullable=False) + comic = relationship("Comic", back_populates="issues") + volume_id = Column(String, ForeignKey("volume.id"), nullable=True) + volume = relationship("Volume", back_populates="issues") + + +class Artist(Base): + __tablename__ = "artist" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(length=255), nullable=False) + comic_works = relationship("ComicWork") + + +class WorkType(Base): + __tablename__ = "worktype" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(length=255), nullable=False, unique=True) + comic_works = relationship("ComicWork") + + +class ComicWork(Base): + __tablename__ = "comic_work" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + comic_id = Column(String, ForeignKey("comic.id"), nullable=False) + comic = relationship("Comic", back_populates="comic_works") + artist_id = Column(String, ForeignKey("artist.id"), nullable=False) + artist = relationship("Artist", back_populates="comic_works") + work_type_id = Column(String, ForeignKey("worktype.id"), nullable=False) + work_type = relationship("WorkType", back_populates="comic_works") diff --git a/qt/database/media.py b/python/qt/database/media.py similarity index 100% rename from qt/database/media.py rename to python/qt/database/media.py diff --git a/qt/database/metadata.py b/python/qt/database/metadata.py similarity index 100% rename from qt/database/metadata.py rename to python/qt/database/metadata.py diff --git a/python/qt/database/tysc.py b/python/qt/database/tysc.py new file mode 100644 index 0000000..733d7ed --- /dev/null +++ b/python/qt/database/tysc.py @@ -0,0 +1,131 @@ +from sqlalchemy import Column, DateTime, Integer, String, ForeignKey, UniqueConstraint +from sqlalchemy.dialects.mysql import BIT +from sqlalchemy.orm import relationship + +from .base import Base + + +class Sport(Base): + __tablename__ = "sport" + __table_args__ = ( + UniqueConstraint("name"), + ) + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(255), nullable=False, index=True, unique=True) + teams = relationship("Team") + positions = relationship("FieldPosition") + + +class Team(Base): + __tablename__ = "team" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(255), nullable=False, index=True, unique=True) + short_name = Column(String(255), nullable=False, ) + sport_id = Column(String, ForeignKey("sport.id"), nullable=False) + sport = relationship("Sport", back_populates="teams") + roosters = relationship("Rooster") + + +class FieldPosition(Base): + __tablename__ = "field_position" + __table_args__ = ( + UniqueConstraint("name", "sport_id"), + UniqueConstraint("short_name", "sport_id"), + ) + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(255), nullable=False, index=True) + short_name = Column(String(255), nullable=False) + sport_id = Column(String, ForeignKey("sport.id"), nullable=False, index=True) + sport = relationship("Sport", back_populates="positions") + roosters = relationship("Rooster") + + +class Player(Base): + __tablename__ = "player" + __table_args__ = ( + UniqueConstraint("first_name", "last_name"), + ) + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + first_name = Column(String(255), nullable=False, index=True) + last_name = Column(String(255), nullable=False, index=True) + roosters = relationship("Rooster") + + def get_full_name(self) -> str: + return f"{self.last_name}, {self.first_name}" + + +class Rooster(Base): + __tablename__ = "rooster" + __table_args__ = ( + UniqueConstraint("year", "team_id", "player_id", "position_id"), + ) + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + year = Column(Integer) + team_id = Column(String, ForeignKey("team.id"), nullable=False, index=True) + team = relationship("Team", back_populates="roosters") + player_id = Column(String, ForeignKey("player.id"), nullable=False, index=True) + player = relationship("Player", back_populates="roosters") + position_id = Column(String, ForeignKey("field_position.id"), nullable=False, index=True) + position = relationship("FieldPosition", back_populates="roosters") + cards = relationship("Card") + +class Vendor(Base): + __tablename__ = "vendor" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(255), nullable=False, unique=True, index=True) + card_sets = relationship("CardSet") + cards = relationship("Card") + + +class CardSet(Base): + __tablename__ = "card_set" + __table_args__ = ( + UniqueConstraint("name", "vendor_id"), + ) + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(255), index=True) + parallel_set = Column(BIT(1)) + insert_set = Column(BIT(1)) + vendor_id = Column(String, ForeignKey("vendor.id"), nullable=False, index=True) + vendor = relationship("Vendor", back_populates="card_sets") + cards = relationship("Card") + + +class Card(Base): + __tablename__ = "card" + __table_args__ = ( + UniqueConstraint("card_number", "year", "vendor_id", "card_set_id"), + ) + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + card_number = Column(Integer, index=True) + year = Column(Integer, index=True) + card_set_id = Column(String, ForeignKey("card_set.id"), nullable=False) + card_set = relationship("CardSet", back_populates="cards") + rooster_id = Column(String, ForeignKey("rooster.id"), nullable=False) + rooster = relationship("Rooster", back_populates="cards") + vendor_id = Column(String, ForeignKey("vendor.id"), nullable=False) + vendor = relationship("Vendor", back_populates="cards") diff --git a/python/qt/gui/__init__.py b/python/qt/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qt/gui/data_view.py b/python/qt/gui/data_view.py similarity index 100% rename from qt/gui/data_view.py rename to python/qt/gui/data_view.py diff --git a/qt/gui/data_view_model.py b/python/qt/gui/data_view_model.py similarity index 100% rename from qt/gui/data_view_model.py rename to python/qt/gui/data_view_model.py diff --git a/qt/gui/dialogs.py b/python/qt/gui/dialogs.py similarity index 100% rename from qt/gui/dialogs.py rename to python/qt/gui/dialogs.py diff --git a/qt/gui/main_window.py b/python/qt/gui/main_window.py similarity index 100% rename from qt/gui/main_window.py rename to python/qt/gui/main_window.py diff --git a/qt/gui/model_config.py b/python/qt/gui/model_config.py similarity index 100% rename from qt/gui/model_config.py rename to python/qt/gui/model_config.py diff --git a/qt/gui/table_model.py b/python/qt/gui/table_model.py similarity index 100% rename from qt/gui/table_model.py rename to python/qt/gui/table_model.py diff --git a/qt/kontor.py b/python/qt/kontor.py similarity index 100% rename from qt/kontor.py rename to python/qt/kontor.py diff --git a/qt/pysidedeploy.spec b/python/qt/pysidedeploy.spec similarity index 100% rename from qt/pysidedeploy.spec rename to python/qt/pysidedeploy.spec diff --git a/qt/res/application-export.png b/python/qt/res/application-export.png similarity index 100% rename from qt/res/application-export.png rename to python/qt/res/application-export.png diff --git a/qt/res/application-import.png b/python/qt/res/application-import.png similarity index 100% rename from qt/res/application-import.png rename to python/qt/res/application-import.png diff --git a/qt/res/arrow-circle-double.png b/python/qt/res/arrow-circle-double.png similarity index 100% rename from qt/res/arrow-circle-double.png rename to python/qt/res/arrow-circle-double.png diff --git a/qt/res/cross.png b/python/qt/res/cross.png similarity index 100% rename from qt/res/cross.png rename to python/qt/res/cross.png diff --git a/qt/res/tick.png b/python/qt/res/tick.png similarity index 100% rename from qt/res/tick.png rename to python/qt/res/tick.png