Repository: OpenSemanticLab/osw-python (language: python)

Issue:
Add mkdoc
see also: https://realpython.com/python-project-documentation-with-mkdocs


Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 00000000..93f7360d
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,28 @@
+# .coveragerc to control coverage.py
+[run]
+branch = True
+source = osw
+# omit = bad_file.py
+
+[paths]
+source =
+    src/
+    */site-packages/
+
+[report]
+# Regexes for lines to exclude from consideration
+exclude_lines =
+    # Have to re-enable the standard pragma
+    pragma: no cover
+
+    # Don't complain about missing debug-only code:
+    def __repr__
+    if self\.debug
+
+    # Don't complain if tests don't hit defensive assertion code:
+    raise AssertionError
+    raise NotImplementedError
+
+    # Don't complain if non-runnable code isn't run:
+    if 0:
+    if __name__ == .__main__.:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..1724ce9e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,116 @@
+# GitHub Actions configuration **EXAMPLE**,
+# MODIFY IT ACCORDING TO YOUR NEEDS!
+# Reference: https://docs.github.com/en/actions
+
+name: tests
+
+on:
+  push:
+    # Avoid using all the resources/limits available by checking only
+    # relevant branches and tags. Other branches can be checked via PRs.
+    branches: [main]
+    tags: ['v[0-9]*', '[0-9]+.[0-9]+*']  # Match tags that resemble a version
+  pull_request:  # Run in every PR
+  workflow_dispatch:  # Allow manually triggering the workflow
+  schedule:
+    # Run roughly every 15 days at 00:00 UTC
+    # (useful to check if updates on dependencies break the package)
+    - cron: '0 0 1,16 * *'
+
+concurrency:
+  group: >-
+    ${{ github.workflow }}-${{ github.ref_type }}-
+    ${{ github.event.pull_request.number || github.sha }}
+  cancel-in-progress: true
+
+jobs:
+  prepare:
+    runs-on: ubuntu-latest
+    outputs:
+      wheel-distribution: ${{ steps.wheel-distribution.outputs.path }}
+    steps:
+      - uses: actions/checkout@v3
+        with: {fetch-depth: 0}  # deep clone for setuptools-scm
+      - uses: actions/setup-python@v4
+        with: {python-version: "3.10"}
+      - name: Run static analysis and format checkers
+        run: pipx run pre-commit run --all-files --show-diff-on-failure
+      - name: Build package distribution files
+        run: pipx run tox -e clean,build
+      - name: Record the path of wheel distribution
+        id: wheel-distribution
+        run: echo "::set-output name=path::$(ls dist/*.whl)"
+      - name: Store the distribution files for use in other stages
+        # `tests` and `publish` will use the same pre-built distributions,
+        # so we make sure to release the exact same package that was tested
+        uses: actions/upload-artifact@v3
+        with:
+          name: python-distribution-files
+          path: dist/
+          retention-days: 1
+
+  test:
+    needs: prepare
+    strategy:
+      matrix:
+        python:
+        - "3.8"  # oldest Python supported by PSF
+        - "3.10"  # newest Python that is stable
+        platform:
+        - ubuntu-latest
+        - macos-latest
+        - windows-latest
+    runs-on: ${{ matrix.platform }}
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python }}
+      - name: Retrieve pre-built distribution files
+        uses: actions/download-artifact@v3
+        with: {name: python-distribution-files, path: dist/}
+      - name: Run tests
+        run: >-
+          pipx run tox
+          --installpkg '${{ needs.prepare.outputs.wheel-distribution }}'
+          -- -rFEx --durations 10 --color yes
+      - name: Generate coverage report
+        run: pipx run coverage lcov -o coverage.lcov
+      - name: Upload partial coverage report
+        uses: coverallsapp/github-action@master
+        with:
+          path-to-lcov: coverage.lcov
+          github-token: ${{ secrets.github_token }}
+          flag-name: ${{ matrix.platform }} - py${{ matrix.python }}
+          parallel: true
+
+  finalize:
+    needs: test
+    runs-on: ubuntu-latest
+    steps:
+      - name: Finalize coverage report
+        uses: coverallsapp/github-action@master
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          parallel-finished: true
+
+  publish:
+    needs: finalize
+    if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/') }}
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-python@v4
+        with: {python-version: "3.10"}
+      - name: Retrieve pre-built distribution files
+        uses: actions/download-artifact@v3
+        with: {name: python-distribution-files, path: dist/}
+      - name: Publish Package
+        env:
+          # TODO: Set your PYPI_TOKEN as a secret using GitHub UI
+          # - https://pypi.org/help/#apitoken
+          # - https://docs.github.com/en/actions/security-guides/encrypted-secrets
+          TWINE_REPOSITORY: pypi
+          TWINE_USERNAME: __token__
+          TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
+        run: pipx run tox -e publish
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 00000000..4ac5bfce
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,31 @@
+# This is a basic workflow to help you get started with Actions
+
+name: Docs
+
+# Controls when the workflow will run
+on:
+  # Triggers the workflow on push or pull request events but only for the master branch
+  push:
+    branches: [ main ]
+
+  # Allows you to run this workflow manually from the Actions tab
+  workflow_dispatch:
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+  # This workflow contains a single job called "build"
+  build:
+    # The type of runner that the job will run on
+    runs-on: ubuntu-latest
+
+    # Steps represent a sequence of tasks that will be executed as part of the job
+    steps:
+      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
+      - uses: actions/checkout@v2
+        with:
+          fetch-depth: 0
+      - uses: actions/setup-python@v2
+      - run: pip install --upgrade pip && pip install mkdocs mkdocs-gen-files "mkdocstrings[python]" mkdocs-material
+      - run: git config user.name 'github-actions[bot]' && git config user.email 'github-actions[bot]@users.noreply.github.com'
+      - name: Publish docs
+        run: mkdocs gh-deploy
diff --git a/.gitignore b/.gitignore
index ca2da171..120617da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,1 +1,59 @@
-*.ipynb_checkpoints
\ No newline at end of file
+# Temporary and binary files
+*~
+*.py[cod]
+*.so
+*.cfg
+!.isort.cfg
+!setup.cfg
+*.orig
+*.log
+*.pot
+__pycache__/*
+.cache/*
+.*.swp
+*/.ipynb_checkpoints/*
+.DS_Store
+
+# Project files
+.ropeproject
+.project
+.pydevproject
+.settings
+.idea
+.vscode
+tags
+
+# Package files
+*.egg
+*.eggs/
+.installed.cfg
+*.egg-info
+
+# Unittest and coverage
+htmlcov/*
+.coverage
+.coverage.*
+.tox
+junit*.xml
+coverage.xml
+.pytest_cache/
+
+# Build and docs folder/files
+build/*
+dist/*
+sdist/*
+docs/api/*
+docs/_rst/*
+docs/_build/*
+cover/*
+MANIFEST
+site
+
+# Per-project virtualenvs
+.venv*/
+.conda*/
+.python-version
+
+# Credential files
+*pwd.txt
+*pwd.yaml
diff --git a/.isort.cfg b/.isort.cfg
new file mode 100644
index 00000000..90d20e39
--- /dev/null
+++ b/.isort.cfg
@@ -0,0 +1,3 @@
+[settings]
+profile = black
+known_first_party = osw
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 00000000..be968e01
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,54 @@
+exclude: '^docs/conf.py'
+
+repos:
+- repo: https://github.com/pre-commit/pre-commit-hooks
+  rev: v4.4.0
+  hooks:
+  - id: trailing-whitespace
+  - id: check-added-large-files
+  - id: check-ast
+  - id: check-json
+  - id: check-merge-conflict
+  - id: check-xml
+  - id: check-yaml
+  - id: debug-statements
+  - id: end-of-file-fixer
+  - id: requirements-txt-fixer
+  - id: mixed-line-ending
+    args: ['--fix=auto']  # replace 'auto' with 'lf' to enforce Linux/Mac line endings or 'crlf' for Windows
+
+## If you want to avoid flake8 errors due to unused vars or imports:
+# - repo: https://github.com/myint/autoflake
+#   rev: v1.4
+#   hooks:
+#   - id: autoflake
+#     args: [
+#       --in-place,
+#       --remove-all-unused-imports,
+#       --remove-unused-variables,
+#     ]
+
+- repo: https://github.com/pycqa/isort
+  rev: 5.11.4
+  hooks:
+  - id: isort
+
+- repo: https://github.com/psf/black
+  rev: 22.12.0
+  hooks:
+  - id: black
+    language_version: python3
+
+## If like to embrace black styles even in the docs:
+# - repo: https://github.com/asottile/blacken-docs
+#   rev: v1.12.0
+#   hooks:
+#   - id: blacken-docs
+#     additional_dependencies: [black]
+
+- repo: https://github.com/PyCQA/flake8
+  rev: 6.0.0
+  hooks:
+  - id: flake8
+  ## You can add flake8 plugins via `additional_dependencies`:
+  #  additional_dependencies: [flake8-bugbear]
diff --git a/.readthedocs.yml b/.readthedocs.yml
new file mode 100644
index 00000000..21b08145
--- /dev/null
+++ b/.readthedocs.yml
@@ -0,0 +1,23 @@
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+# Build documentation in the docs/ directory with Sphinx
+sphinx:
+  configuration: docs/conf.py
+
+# Build documentation with MkDocs
+#mkdocs:
+#  configuration: mkdocs.yml
+
+# Optionally build your docs in additional formats such as PDF
+formats:
+  - pdf
+
+python:
+  version: 3.8
+  install:
+    - requirements: docs/requirements.txt
+    - {path: ., method: pip}
diff --git a/AUTHORS.md b/AUTHORS.md
new file mode 100644
index 00000000..4cb1827e
--- /dev/null
+++ b/AUTHORS.md
@@ -0,0 +1,6 @@
+# Contributors
+
+* Simon Stier [simon.stier@isc.fraunhofer.de](mailto:simon.stier@isc.fraunhofer.de)
+* Lukas Gold
+* Andreas Räder
+* Matthias Popp
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..6928d7b8
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## Version 0.1 (development)
+
+- create package
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..c1d68b19
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,371 @@
+```{todo} THIS IS SUPPOSED TO BE AN EXAMPLE. MODIFY IT ACCORDING TO YOUR NEEDS!
+
+   The document assumes you are using a source repository service that promotes a
+   contribution model similar to [GitHub's fork and pull request workflow].
+   While this is true for the majority of services (like GitHub, GitLab,
+   BitBucket), it might not be the case for private repositories (e.g., when
+   using Gerrit).
+
+   Also notice that the code examples might refer to GitHub URLs or the text
+   might use GitHub specific terminology (e.g., *Pull Request* instead of *Merge
+   Request*).
+
+   Please make sure to check the document having these assumptions in mind
+   and update things accordingly.
+```
+
+```{todo} Provide the correct links/replacements at the bottom of the document.
+```
+
+```{todo} You might want to have a look on [PyScaffold's contributor's guide],
+
+   especially if your project is open source. The text should be very similar to
+   this template, but there are a few extra contents that you might decide to
+   also include, like mentioning labels of your issue tracker or automated
+   releases.
+```
+
+# Contributing
+
+Welcome to `osw` contributor's guide.
+
+This document focuses on getting any potential contributor familiarized with
+the development processes, but [other kinds of contributions] are also appreciated.
+
+If you are new to using [git] or have never collaborated in a project previously,
+please have a look at [contribution-guide.org]. Other resources are also
+listed in the excellent [guide created by FreeCodeCamp] [^contrib1].
+
+Please notice, all users and contributors are expected to be **open,
+considerate, reasonable, and respectful**. When in doubt,
+[Python Software Foundation's Code of Conduct] is a good reference in terms of
+behavior guidelines.
+
+## Issue Reports
+
+If you experience bugs or general issues with `osw`, please have a look
+on the [issue tracker].
+If you don't see anything useful there, please feel free to fire an issue report.
+
+:::{tip}
+Please don't forget to include the closed issues in your search.
+Sometimes a solution was already reported, and the problem is considered
+**solved**.
+:::
+
+New issue reports should include information about your programming environment
+(e.g., operating system, Python version) and steps to reproduce the problem.
+Please try also to simplify the reproduction steps to a very minimal example
+that still illustrates the problem you are facing. By removing other factors,
+you help us to identify the root cause of the issue.
+
+## Documentation Improvements
+
+You can help improve `osw` docs by making them more readable and coherent, or
+by adding missing information and correcting mistakes.
+
+`osw` documentation uses [Sphinx] as its main documentation compiler.
+This means that the docs are kept in the same repository as the project code, and
+that any documentation update is done in the same way was a code contribution.
+
+```{todo} Don't forget to mention which markup language you are using.
+
+    e.g.,  [reStructuredText] or [CommonMark] with [MyST] extensions.
+```
+
+```{todo} If your project is hosted on GitHub, you can also mention the following tip:
+
+   :::{tip}
+      Please notice that the [GitHub web interface] provides a quick way of
+      propose changes in `osw`'s files. While this mechanism can
+      be tricky for normal code contributions, it works perfectly fine for
+      contributing to the docs, and can be quite handy.
+
+      If you are interested in trying this method out, please navigate to
+      the `docs` folder in the source [repository], find which file you
+      would like to propose changes and click in the little pencil icon at the
+      top, to open [GitHub's code editor]. Once you finish editing the file,
+      please write a message in the form at the bottom of the page describing
+      which changes have you made and what are the motivations behind them and
+      submit your proposal.
+   :::
+```
+
+When working on documentation changes in your local machine, you can
+compile them using [tox] :
+
+```
+tox -e docs
+```
+
+and use Python's built-in web server for a preview in your web browser
+(`http://localhost:8000`):
+
+```
+python3 -m http.server --directory 'docs/_build/html'
+```
+
+## Code Contributions
+
+```{todo} Please include a reference or explanation about the internals of the project.
+
+   An architecture description, design principles or at least a summary of the
+   main concepts will make it easy for potential contributors to get started
+   quickly.
+```
+
+### Submit an issue
+
+Before you work on any non-trivial code contribution it's best to first create
+a report in the [issue tracker] to start a discussion on the subject.
+This often provides additional considerations and avoids unnecessary work.
+
+### Create an environment
+
+Before you start coding, we recommend creating an isolated [virtual environment]
+to avoid any problems with your installed Python packages.
+This can easily be done via either [virtualenv]:
+
+```
+virtualenv <PATH TO VENV>
+source <PATH TO VENV>/bin/activate
+```
+
+or [Miniconda]:
+
+```
+conda create -n osw python=3 six virtualenv pytest pytest-cov
+conda activate osw
+```
+
+### Clone the repository
+
+1. Create an user account on GitHub if you do not already have one.
+
+2. Fork the project [repository]: click on the *Fork* button near the top of the
+   page. This creates a copy of the code under your account on GitHub.
+
+3. Clone this copy to your local disk:
+
+   ```
+   git clone git@github.com:YourLogin/osw.git
+   cd osw
+   ```
+
+4. You should run:
+
+   ```
+   pip install -U pip setuptools -e .
+   ```
+
+   to be able to import the package under development in the Python REPL.
+
+   ```{todo} if you are not using pre-commit, please remove the following item:
+   ```
+
+5. Install [pre-commit]:
+
+   ```
+   pip install pre-commit
+   pre-commit install
+   ```
+
+   `osw` comes with a lot of hooks configured to automatically help the
+   developer to check the code being written.
+
+### Implement your changes
+
+1. Create a branch to hold your changes:
+
+   ```
+   git checkout -b my-feature
+   ```
+
+   and start making changes. Never work on the main branch!
+
+2. Start your work on this branch. Don't forget to add [docstrings] to new
+   functions, modules and classes, especially if they are part of public APIs.
+
+3. Add yourself to the list of contributors in `AUTHORS.rst`.
+
+4. When you’re done editing, do:
+
+   ```
+   git add <MODIFIED FILES>
+   git commit
+   ```
+
+   to record your changes in [git].
+
+   ```{todo} if you are not using pre-commit, please remove the following item:
+   ```
+
+   Please make sure to see the validation messages from [pre-commit] and fix
+   any eventual issues.
+   This should automatically use [flake8]/[black] to check/fix the code style
+   in a way that is compatible with the project.
+
+   :::{important}
+   Don't forget to add unit tests and documentation in case your
+   contribution adds an additional feature and is not just a bugfix.
+
+   Moreover, writing a [descriptive commit message] is highly recommended.
+   In case of doubt, you can check the commit history with:
+
+   ```
+   git log --graph --decorate --pretty=oneline --abbrev-commit --all
+   ```
+
+   to look for recurring communication patterns.
+   :::
+
+5. Please check that your changes don't break any unit tests with:
+
+   ```
+   tox
+   ```
+
+   (after having installed [tox] with `pip install tox` or `pipx`).
+
+   You can also use [tox] to run several other pre-configured tasks in the
+   repository. Try `tox -av` to see a list of the available checks.
+
+### Submit your contribution
+
+1. If everything works fine, push your local branch to the remote server with:
+
+   ```
+   git push -u origin my-feature
+   ```
+
+2. Go to the web page of your fork and click "Create pull request"
+   to send your changes for review.
+
+   ```{todo} if you are using GitHub, you can uncomment the following paragraph
+
+      Find more detailed information in [creating a PR]. You might also want to open
+      the PR as a draft first and mark it as ready for review after the feedbacks
+      from the continuous integration (CI) system or any required fixes.
+
+   ```
+
+### Troubleshooting
+
+The following tips can be used when facing problems to build or test the
+package:
+
+1. Make sure to fetch all the tags from the upstream [repository].
+   The command `git describe --abbrev=0 --tags` should return the version you
+   are expecting. If you are trying to run CI scripts in a fork repository,
+   make sure to push all the tags.
+   You can also try to remove all the egg files or the complete egg folder, i.e.,
+   `.eggs`, as well as the `*.egg-info` folders in the `src` folder or
+   potentially in the root of your project.
+
+2. Sometimes [tox] misses out when new dependencies are added, especially to
+   `setup.cfg` and `docs/requirements.txt`. If you find any problems with
+   missing dependencies when running a command with [tox], try to recreate the
+   `tox` environment using the `-r` flag. For example, instead of:
+
+   ```
+   tox -e docs
+   ```
+
+   Try running:
+
+   ```
+   tox -r -e docs
+   ```
+
+3. Make sure to have a reliable [tox] installation that uses the correct
+   Python version (e.g., 3.7+). When in doubt you can run:
+
+   ```
+   tox --version
+   # OR
+   which tox
+   ```
+
+   If you have trouble and are seeing weird errors upon running [tox], you can
+   also try to create a dedicated [virtual environment] with a [tox] binary
+   freshly installed. For example:
+
+   ```
+   virtualenv .venv
+   source .venv/bin/activate
+   .venv/bin/pip install tox
+   .venv/bin/tox -e all
+   ```
+
+4. [Pytest can drop you] in an interactive session in the case an error occurs.
+   In order to do that you need to pass a `--pdb` option (for example by
+   running `tox -- -k <NAME OF THE FALLING TEST> --pdb`).
+   You can also setup breakpoints manually instead of using the `--pdb` option.
+
+## Maintainer tasks
+
+### Releases
+
+```{todo} This section assumes you are using PyPI to publicly release your package.
+
+   If instead you are using a different/private package index, please update
+   the instructions accordingly.
+```
+
+If you are part of the group of maintainers and have correct user permissions
+on [PyPI], the following steps can be used to release a new version for
+`osw`:
+
+1. Make sure all unit tests are successful.
+2. Tag the current commit on the main branch with a release tag, e.g., `v1.2.3`.
+3. Push the new tag to the upstream [repository],
+   e.g., `git push upstream v1.2.3`
+4. Clean up the `dist` and `build` folders with `tox -e clean`
+   (or `rm -rf dist build`)
+   to avoid confusion with old builds and Sphinx docs.
+5. Run `tox -e build` and check that the files in `dist` have
+   the correct version (no `.dirty` or [git] hash) according to the [git] tag.
+   Also check the sizes of the distributions, if they are too big (e.g., >
+   500KB), unwanted clutter may have been accidentally included.
+6. Run `tox -e publish -- --repository pypi` and check that everything was
+   uploaded to [PyPI] correctly.
+
+[^contrib1]: Even though, these resources focus on open source projects and
+    communities, the general ideas behind collaborating with other developers
+    to collectively create software are general and can be applied to all sorts
+    of environments, including private companies and proprietary code bases.
+
+
+[black]: https://pypi.org/project/black/
+[commonmark]: https://commonmark.org/
+[contribution-guide.org]: http://www.contribution-guide.org/
+[creating a pr]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request
+[descriptive commit message]: https://chris.beams.io/posts/git-commit
+[docstrings]: https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html
+[first-contributions tutorial]: https://github.com/firstcontributions/first-contributions
+[flake8]: https://flake8.pycqa.org/en/stable/
+[git]: https://git-scm.com
+[github web interface]: https://docs.github.com/en/github/managing-files-in-a-repository/managing-files-on-github/editing-files-in-your-repository
+[github's code editor]: https://docs.github.com/en/github/managing-files-in-a-repository/managing-files-on-github/editing-files-in-your-repository
+[github's fork and pull request workflow]: https://guides.github.com/activities/forking/
+[guide created by freecodecamp]: https://github.com/freecodecamp/how-to-contribute-to-open-source
+[miniconda]: https://docs.conda.io/en/latest/miniconda.html
+[myst]: https://myst-parser.readthedocs.io/en/latest/syntax/syntax.html
+[other kinds of contributions]: https://opensource.guide/how-to-contribute
+[pre-commit]: https://pre-commit.com/
+[pypi]: https://pypi.org/
+[pyscaffold's contributor's guide]: https://pyscaffold.org/en/stable/contributing.html
+[pytest can drop you]: https://docs.pytest.org/en/stable/usage.html#dropping-to-pdb-python-debugger-at-the-start-of-a-test
+[python software foundation's code of conduct]: https://www.python.org/psf/conduct/
+[restructuredtext]: https://www.sphinx-doc.org/en/master/usage/restructuredtext/
+[sphinx]: https://www.sphinx-doc.org/en/master/
+[tox]: https://tox.readthedocs.io/en/stable/
+[virtual environment]: https://realpython.com/python-virtual-environments-a-primer/
+[virtualenv]: https://virtualenv.pypa.io/en/stable/
+
+
+```{todo} Please review and change the following definitions:
+```
+
+[repository]: https://github.com/<USERNAME>/osw
+[issue tracker]: https://github.com/<USERNAME>/osw/issues
diff --git a/LICENSE b/LICENSE.txt
similarity index 93%
rename from LICENSE
rename to LICENSE.txt
index 0ad25db4..ca9b0551 100644
--- a/LICENSE
+++ b/LICENSE.txt
@@ -617,45 +617,3 @@ Program, unless a warranty or assumption of liability accompanies a
 copy of the Program in return for a fee.
 
                      END OF TERMS AND CONDITIONS
-
-            How to Apply These Terms to Your New Programs
-
-  If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
-  To do so, attach the following notices to the program.  It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-    <one line to give the program's name and a brief idea of what it does.>
-    Copyright (C) <year>  <name of author>
-
-    This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero 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 Affero General Public License for more details.
-
-    You should have received a copy of the GNU Affero General Public License
-    along with this program.  If not, see <https://www.gnu.org/licenses/>.
-
-Also add information on how to contact you by electronic and paper mail.
-
-  If your software can interact with users remotely through a computer
-network, you should also make sure that it provides a way for users to
-get its source.  For example, if your program is a web application, its
-interface could display a "Source" link that leads users to an archive
-of the code.  There are many ways you could offer source, and different
-solutions will be better for different programs; see section 13 for the
-specific requirements.
-
-  You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU AGPL, see
-<https://www.gnu.org/licenses/>.
diff --git a/README.md b/README.md
index 1c4216a1..80310c29 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,30 @@
-# Tools
-python toolset for data processing, queries, wikicode generation and page manipulation
+<!-- These are examples of badges you might want to add to your README:
+     please update the URLs accordingly
+
+[![Built Status](https://api.cirrus-ci.com/github/<USER>/osw.svg?branch=main)](https://cirrus-ci.com/github/<USER>/osw)
+[![ReadTheDocs](https://readthedocs.org/projects/osw/badge/?version=latest)](https://osw.readthedocs.io/en/stable/)
+[![Coveralls](https://img.shields.io/coveralls/github/<USER>/osw/main.svg)](https://coveralls.io/r/<USER>/osw)
+[![PyPI-Server](https://img.shields.io/pypi/v/osw.svg)](https://pypi.org/project/osw/)
+[![Conda-Forge](https://img.shields.io/conda/vn/conda-forge/osw.svg)](https://anaconda.org/conda-forge/osw)
+[![Monthly Downloads](https://pepy.tech/badge/osw/month)](https://pepy.tech/project/osw)
+[![Twitter](https://img.shields.io/twitter/url/http/shields.io.svg?style=social&label=Twitter)](https://twitter.com/osw)
+-->
+
+[![PyPI-Server](https://img.shields.io/pypi/v/osw.svg)](https://pypi.org/project/osw/)
+[![Coveralls](https://img.shields.io/coveralls/github/OpenSemanticLab/osw-python/main.svg)](https://coveralls.io/r/<USER>/osw)
+[![Project generated with PyScaffold](https://img.shields.io/badge/-PyScaffold-005CA0?logo=pyscaffold)](https://pyscaffold.org/)
+
+# osw
+
+> Python toolset for data processing, queries, wikicode generation and page manipulation
+
+## Documentation
+
+https://opensemanticlab.github.io/osw-python/
+
+<!-- pyscaffold-notes -->
+
+## Note
+
+This project has been set up using PyScaffold 4.3.1. For details and usage
+information on PyScaffold see https://pyscaffold.org/.
diff --git a/__init__.py b/__init__.py
index 971fdc19..60890872 100644
--- a/__init__.py
+++ b/__init__.py
@@ -1,1 +1,1 @@
-# This file is required to make Python treat directories containing the file as packages.
\ No newline at end of file
+# This file is required to make Python treat directories containing the file as packages.
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 00000000..31655dd9
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,29 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS    ?=
+SPHINXBUILD   ?= sphinx-build
+SOURCEDIR     = .
+BUILDDIR      = _build
+AUTODOCDIR    = api
+
+# User-friendly check for sphinx-build
+ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $?), 1)
+$(error "The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://sphinx-doc.org/")
+endif
+
+.PHONY: help clean Makefile
+
+# Put it first so that "make" without argument is like "make help".
+help:
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+clean:
+	rm -rf $(BUILDDIR)/* $(AUTODOCDIR)
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/_static/.gitignore b/docs/_static/.gitignore
new file mode 100644
index 00000000..3c963632
--- /dev/null
+++ b/docs/_static/.gitignore
@@ -0,0 +1,1 @@
+# Empty directory
diff --git a/docs/authors.md b/docs/authors.md
new file mode 100644
index 00000000..ced47d0e
--- /dev/null
+++ b/docs/authors.md
@@ -0,0 +1,4 @@
+```{include} ../AUTHORS.md
+:relative-docs: docs/
+:relative-images:
+```
diff --git a/docs/changelog.md b/docs/changelog.md
new file mode 100644
index 00000000..6e2f0fb1
--- /dev/null
+++ b/docs/changelog.md
@@ -0,0 +1,4 @@
+```{include} ../CHANGELOG.md
+:relative-docs: docs/
+:relative-images:
+```
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 00000000..969f2c4c
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,304 @@
+# This file is execfile()d with the current directory set to its containing dir.
+#
+# This file only contains a selection of the most common options. For a full
+# list see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+import os
+import sys
+import shutil
+
+# -- Path setup --------------------------------------------------------------
+
+__location__ = os.path.dirname(__file__)
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+sys.path.insert(0, os.path.join(__location__, "../src"))
+
+# -- Run sphinx-apidoc -------------------------------------------------------
+# This hack is necessary since RTD does not issue `sphinx-apidoc` before running
+# `sphinx-build -b html . _build/html`. See Issue:
+# https://github.com/readthedocs/readthedocs.org/issues/1139
+# DON'T FORGET: Check the box "Install your project inside a virtualenv using
+# setup.py install" in the RTD Advanced Settings.
+# Additionally it helps us to avoid running apidoc manually
+
+try:  # for Sphinx >= 1.7
+    from sphinx.ext import apidoc
+except ImportError:
+    from sphinx import apidoc
+
+output_dir = os.path.join(__location__, "api")
+module_dir = os.path.join(__location__, "../src/osw")
+try:
+    shutil.rmtree(output_dir)
+except FileNotFoundError:
+    pass
+
+try:
+    import sphinx
+
+    cmd_line = f"sphinx-apidoc --implicit-namespaces -f -o {output_dir} {module_dir}"
+
+    args = cmd_line.split(" ")
+    if tuple(sphinx.__version__.split(".")) >= ("1", "7"):
+        # This is a rudimentary parse_version to avoid external dependencies
+        args = args[1:]
+
+    apidoc.main(args)
+except Exception as e:
+    print("Running `sphinx-apidoc` failed!\n{}".format(e))
+
+# -- General configuration ---------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+# needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be extensions
+# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
+extensions = [
+    "sphinx.ext.autodoc",
+    "sphinx.ext.intersphinx",
+    "sphinx.ext.todo",
+    "sphinx.ext.autosummary",
+    "sphinx.ext.viewcode",
+    "sphinx.ext.coverage",
+    "sphinx.ext.doctest",
+    "sphinx.ext.ifconfig",
+    "sphinx.ext.mathjax",
+    "sphinx.ext.napoleon",
+]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ["_templates"]
+
+
+# Enable markdown
+extensions.append("myst_parser")
+
+# Configure MyST-Parser
+myst_enable_extensions = [
+    "amsmath",
+    "colon_fence",
+    "deflist",
+    "dollarmath",
+    "html_image",
+    "linkify",
+    "replacements",
+    "smartquotes",
+    "substitution",
+    "tasklist",
+]
+
+# The suffix of source filenames.
+source_suffix = [".rst", ".md"]
+
+# The encoding of source files.
+# source_encoding = 'utf-8-sig'
+
+# The master toctree document.
+master_doc = "index"
+
+# General information about the project.
+project = "osw"
+copyright = "2023, SimonStier"
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# version: The short X.Y version.
+# release: The full version, including alpha/beta/rc tags.
+# If you don’t need the separation provided between version and release,
+# just set them both to the same value.
+try:
+    from osw import __version__ as version
+except ImportError:
+    version = ""
+
+if not version or version.lower() == "unknown":
+    version = os.getenv("READTHEDOCS_VERSION", "unknown")  # automatically set by RTD
+
+release = version
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+# language = None
+
+# There are two options for replacing |today|: either, you set today to some
+# non-false value, then it is used:
+# today = ''
+# Else, today_fmt is used as the format for a strftime call.
+# today_fmt = '%B %d, %Y'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", ".venv"]
+
+# The reST default role (used for this markup: `text`) to use for all documents.
+# default_role = None
+
+# If true, '()' will be appended to :func: etc. cross-reference text.
+# add_function_parentheses = True
+
+# If true, the current module name will be prepended to all description
+# unit titles (such as .. function::).
+# add_module_names = True
+
+# If true, sectionauthor and moduleauthor directives will be shown in the
+# output. They are ignored by default.
+# show_authors = False
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = "sphinx"
+
+# A list of ignored prefixes for module index sorting.
+# modindex_common_prefix = []
+
+# If true, keep warnings as "system message" paragraphs in the built documents.
+# keep_warnings = False
+
+# If this is True, todo emits a warning for each TODO entries. The default is False.
+todo_emit_warnings = True
+
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+html_theme = "alabaster"
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further.  For a list of options available for each theme, see the
+# documentation.
+html_theme_options = {
+    "sidebar_width": "300px",
+    "page_width": "1200px"
+}
+
+# Add any paths that contain custom themes here, relative to this directory.
+# html_theme_path = []
+
+# The name for this set of Sphinx documents.  If None, it defaults to
+# "<project> v<release> documentation".
+# html_title = None
+
+# A shorter title for the navigation bar.  Default is the same as html_title.
+# html_short_title = None
+
+# The name of an image file (relative to this directory) to place at the top
+# of the sidebar.
+# html_logo = ""
+
+# The name of an image file (within the static path) to use as favicon of the
+# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
+# pixels large.
+# html_favicon = None
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ["_static"]
+
+# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
+# using the given strftime format.
+# html_last_updated_fmt = '%b %d, %Y'
+
+# If true, SmartyPants will be used to convert quotes and dashes to
+# typographically correct entities.
+# html_use_smartypants = True
+
+# Custom sidebar templates, maps document names to template names.
+# html_sidebars = {}
+
+# Additional templates that should be rendered to pages, maps page names to
+# template names.
+# html_additional_pages = {}
+
+# If false, no module index is generated.
+# html_domain_indices = True
+
+# If false, no index is generated.
+# html_use_index = True
+
+# If true, the index is split into individual pages for each letter.
+# html_split_index = False
+
+# If true, links to the reST sources are added to the pages.
+# html_show_sourcelink = True
+
+# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
+# html_show_sphinx = True
+
+# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
+# html_show_copyright = True
+
+# If true, an OpenSearch description file will be output, and all pages will
+# contain a <link> tag referring to it.  The value of this option must be the
+# base URL from which the finished HTML is served.
+# html_use_opensearch = ''
+
+# This is the file name suffix for HTML files (e.g. ".xhtml").
+# html_file_suffix = None
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = "osw-doc"
+
+
+# -- Options for LaTeX output ------------------------------------------------
+
+latex_elements = {
+    # The paper size ("letterpaper" or "a4paper").
+    # "papersize": "letterpaper",
+    # The font size ("10pt", "11pt" or "12pt").
+    # "pointsize": "10pt",
+    # Additional stuff for the LaTeX preamble.
+    # "preamble": "",
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title, author, documentclass [howto/manual]).
+latex_documents = [
+    ("index", "user_guide.tex", "osw Documentation", "SimonStier", "manual")
+]
+
+# The name of an image file (relative to this directory) to place at the top of
+# the title page.
+# latex_logo = ""
+
+# For "manual" documents, if this is true, then toplevel headings are parts,
+# not chapters.
+# latex_use_parts = False
+
+# If true, show page references after internal links.
+# latex_show_pagerefs = False
+
+# If true, show URL addresses after external links.
+# latex_show_urls = False
+
+# Documents to append as an appendix to all manuals.
+# latex_appendices = []
+
+# If false, no module index is generated.
+# latex_domain_indices = True
+
+# -- External mapping --------------------------------------------------------
+python_version = ".".join(map(str, sys.version_info[0:2]))
+intersphinx_mapping = {
+    "sphinx": ("https://www.sphinx-doc.org/en/master", None),
+    "python": ("https://docs.python.org/" + python_version, None),
+    "matplotlib": ("https://matplotlib.org", None),
+    "numpy": ("https://numpy.org/doc/stable", None),
+    "sklearn": ("https://scikit-learn.org/stable", None),
+    "pandas": ("https://pandas.pydata.org/pandas-docs/stable", None),
+    "scipy": ("https://docs.scipy.org/doc/scipy/reference", None),
+    "setuptools": ("https://setuptools.pypa.io/en/stable/", None),
+    "pyscaffold": ("https://pyscaffold.org/en/stable", None),
+}
+
+print(f"loading configurations for {project} {version} ...", file=sys.stderr)
\ No newline at end of file
diff --git a/docs/contributing.md b/docs/contributing.md
new file mode 100644
index 00000000..fc1b2138
--- /dev/null
+++ b/docs/contributing.md
@@ -0,0 +1,4 @@
+```{include} ../CONTRIBUTING.md
+:relative-docs: docs/
+:relative-images:
+```
diff --git a/docs/dev.md b/docs/dev.md
new file mode 100644
index 00000000..9c5e020c
--- /dev/null
+++ b/docs/dev.md
@@ -0,0 +1,13 @@
+# Testing
+
+1. Create new test (file name test_*.py) under /tests
+
+2. Install pytest
+```
+pip install pytest
+```
+
+3. Run pytest in the project root dir
+```
+pytest
+```
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 00000000..132d1910
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,39 @@
+# osw
+
+Python toolset for data processing, queries, wikicode generation and page manipulation
+
+
+## Note
+
+> This is the main page of your project's [Sphinx] documentation. It is
+> formatted in [Markdown]. Add additional pages by creating md-files in
+> `docs` or rst-files (formatted in [reStructuredText]) and adding links to
+> them in the `Contents` section below.
+>
+> Please check [Sphinx] and [MyST] for more information
+> about how to document your project and how to configure your preferences.
+
+
+## Contents
+
+```{toctree}
+:maxdepth: 2
+
+Overview <readme>
+Contributions & Help <contributing>
+License <license>
+Authors <authors>
+Changelog <changelog>
+Module Reference <api/modules>
+```
+
+## Indices and tables
+
+* {ref}`genindex`
+* {ref}`modindex`
+* {ref}`search`
+
+[Sphinx]: http://www.sphinx-doc.org/
+[Markdown]: https://daringfireball.net/projects/markdown/
+[reStructuredText]: http://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html
+[MyST]: https://myst-parser.readthedocs.io/en/latest/
diff --git a/docs/license.md b/docs/license.md
new file mode 100644
index 00000000..b231201e
--- /dev/null
+++ b/docs/license.md
@@ -0,0 +1,5 @@
+# License
+
+```{literalinclude} ../LICENSE.txt
+:language: text
+```
diff --git a/docs/model.md b/docs/model.md
new file mode 100644
index 00000000..9b99ab07
--- /dev/null
+++ b/docs/model.md
@@ -0,0 +1,3 @@
+OpenSematicLab Model
+
+::: osw.model.entity.Entity
diff --git a/docs/osw.md b/docs/osw.md
new file mode 100644
index 00000000..20bdbd3f
--- /dev/null
+++ b/docs/osw.md
@@ -0,0 +1,7 @@
+OpenSematicWorld Class
+
+# OSW
+::: osw.core.OSW
+
+# OSW.SchemaRegistration
+::: osw.core.OSW.SchemaRegistration
diff --git a/docs/readme.md b/docs/readme.md
new file mode 100644
index 00000000..2cb706b3
--- /dev/null
+++ b/docs/readme.md
@@ -0,0 +1,4 @@
+```{include} ../README.md
+:relative-docs: docs/
+:relative-images:
+```
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 00000000..5c7a058b
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,11 @@
+
+mkdocs
+mkdocs-gen-files
+mkdocs-material
+mkdocstrings[python]
+# Requirements file for ReadTheDocs, check .readthedocs.yml.
+# To build the module reference correctly, make sure every external package
+# under `install_requires` in `setup.cfg` is also listed here!
+# sphinx_rtd_theme
+myst-parser[linkify]
+sphinx>=3.2.1
diff --git a/docs/tools.md b/docs/tools.md
new file mode 100644
index 00000000..ab8ceac5
--- /dev/null
+++ b/docs/tools.md
@@ -0,0 +1,3 @@
+Collection of helper functions
+
+::: osw.wiki_tools
diff --git a/examples/accounts.pwd.yaml b/examples/accounts.pwd.yaml
new file mode 100644
index 00000000..db370c6c
--- /dev/null
+++ b/examples/accounts.pwd.yaml
@@ -0,0 +1,3 @@
+wiki-dev.open-semantic-lab.org:
+  username: <YourUsername>
+  password: <YourPassword>
diff --git a/examples/controller_logic.py b/examples/controller_logic.py
new file mode 100644
index 00000000..b431fb72
--- /dev/null
+++ b/examples/controller_logic.py
@@ -0,0 +1,45 @@
+import os
+
+from typing_extensions import override
+
+from osw.controller.entity import Entity, Hardware
+from osw.core import OSW
+from osw.wtsite import WtSite
+
+# create/update the password file under examples/accounts.pwd.yaml
+pwd_file_path = os.path.join(
+    os.path.dirname(os.path.abspath(__file__)), "accounts.pwd.yaml"
+)
+# pwd_file_path = "./accounts.pwd.yaml"
+wtsite = WtSite.from_domain("wiki-dev.open-semantic-lab.org", pwd_file_path)
+osw = OSW(site=wtsite)
+
+title = "Item:OSW7d7193567ea14e4e89b74de88983b718"
+# title = "Item:OSWe02213b6c4664d04834355dc8eb08b99"
+entity = osw.load_entity(title).cast(Entity)
+print(entity.__class__)
+print(entity.label.text)  # we can access any attribute of model.Entity...
+entity.explain()  # ...and any methode of controller.Entity
+
+hardware_entity = entity.cast(Hardware)  # explicit cast to model.Hardware
+print(
+    hardware_entity.manufacturer
+)  # we can access now any attribute of model.Entity...
+hardware_entity.run()  # ...and any methode of controller.Hardware ...
+hardware_entity.explain()  # ...and any inherited methode of controller.Entity
+
+
+# you can also write your own controller
+class CustomHardware(Hardware):
+    @override
+    def run(self):
+        super().run()
+        print("...but with some custom stuff")
+
+    def a_new_methode(self):
+        print("New Stuff")
+
+
+my_hardware_entity = hardware_entity.cast(CustomHardware)
+my_hardware_entity.run()
+my_hardware_entity.a_new_methode()
diff --git a/examples/create_entity.py b/examples/create_entity.py
new file mode 100644
index 00000000..30c18c8f
--- /dev/null
+++ b/examples/create_entity.py
@@ -0,0 +1,9 @@
+from pprint import pprint
+
+import osw.model.entity as model
+
+my_entity = model.Item(
+    label=model.Label(text="MyItem"), statements=[model.Statement(predicate="IsA")]
+)
+pprint(my_entity.dict())
+print(my_entity.json())
diff --git a/examples/database_access.py b/examples/database_access.py
new file mode 100644
index 00000000..422c1a02
--- /dev/null
+++ b/examples/database_access.py
@@ -0,0 +1,64 @@
+import os
+
+# import osw.controller.entity as controller
+import osw.model.entity as model
+from osw.core import OSW
+from osw.wtsite import WtSite
+
+# create/update the password file under examples/accounts.pwd.yaml
+pwd_file_path = os.path.join(
+    os.path.dirname(os.path.abspath(__file__)), "accounts.pwd.yaml"
+)
+# pwd_file_path = "./accounts.pwd.yaml"
+wtsite = WtSite.from_domain("wiki-dev.open-semantic-lab.org", pwd_file_path)
+osw = OSW(site=wtsite)
+
+wtsite.enable_cache()  # skip downloads on second request
+
+# osw.fetch_schema() #this will load the current entity schema from the OSW instance. You may have to re-run the script to get the updated schema extension. Requires 'pip install datamodel-code-generator'
+if False:
+    osw.fetch_schema(
+        OSW.FetchSchemaParam(
+            schema_title="Category:OSW02590972aeba46d7864ed492c0c11384", mode="replace"
+        )
+    )
+    osw.fetch_schema(
+        OSW.FetchSchemaParam(
+            schema_title="Category:OSWacdb001c926c46b998af3e645d36b13f", mode="append"
+        )
+    )
+    osw.fetch_schema(
+        OSW.FetchSchemaParam(
+            schema_title="Category:OSW51ad0d1716254c77a2b7a03217f23b43", mode="append"
+        )
+    )
+
+# #explicit load host and server
+# host = osw.load_entity("Item:OSWb8625aedf4074b72a4fa9bcb11694e4d")
+# server = osw.load_entity("Item:OSW536e1417a0eb427887aeb267f47442fb")
+
+db = osw.load_entity("Item:OSW837099bdc67d4b7cb5ffcce713a7648b")
+db = db.cast(model.Database)
+
+# #load host and server from references
+# server = osw.load_entity(db.db_server)
+# server = server.cast(model.DatabaseServer)
+# host = osw.load_entity(server.host)
+# host = host.cast(model.Host)
+
+# #load host and server from semantic queries
+server_title = wtsite.semantic_search(
+    f"[[-HasDbServer::Item:{osw.get_osw_id(db.uuid)}]]"
+)
+server = osw.load_entity(server_title[0])
+
+host_title = wtsite.semantic_search(f"[[-HasHost::Item:{osw.get_osw_id(server.uuid)}]]")
+host = osw.load_entity(host_title[0])
+
+
+connect_str = f"{host.network_domain[0]}:{server.network_port[0]}/{db.db_name}"
+print(connect_str)
+
+# cast into controller and execute function
+# db = db.cast(controller.DbController)
+# print(db.create_table())
diff --git a/examples/entity_manipulation.py b/examples/entity_manipulation.py
new file mode 100644
index 00000000..10c93c85
--- /dev/null
+++ b/examples/entity_manipulation.py
@@ -0,0 +1,95 @@
+import os
+from importlib import reload
+from pprint import pprint
+
+import osw.model.entity as model
+from osw.core import OSW
+from osw.wtsite import WtSite
+
+# import src.controller.DeviceType as crtl
+# from src.controller.DeviceType import DeviceInstance
+
+
+# create/update the password file under examples/wiki-admin.pwd
+pwd_file_path = os.path.join(
+    os.path.dirname(os.path.abspath(__file__)), "accounts.pwd.yaml"
+)
+wtsite = WtSite.from_domain("wiki-dev.open-semantic-lab.org", pwd_file_path)
+osw = OSW(site=wtsite)
+
+do_fetch = True
+do_fetch = False
+
+if do_fetch:
+    # osw.fetch_schema() #this will load the current entity schema from the OSW instance. You may have to re-run the script to get the updated schema extension. Requires 'pip install datamodel-code-generator'
+    osw.fetch_schema(
+        OSW.FetchSchemaParam(schema_title="JsonSchema:LIMS/Device/Type", mode="replace")
+    )
+    osw.fetch_schema(
+        OSW.FetchSchemaParam(
+            schema_title="JsonSchema:LIMS/Device/Instance", mode="append"
+        )
+    )
+    reload(model)  # only for modules
+
+# check if the schema extension was loaded
+try:
+    model.Device
+except AttributeError:
+    print("[Ex] Device not defined")
+else:
+    print("[Ex] Device defined")
+
+# device = model.Device(manufacturer="Test")
+# event = model.Event(start=date.fromisoformat("2022-01-01"))
+# entity = model.Entity(label="TestE", extensions=[
+#     model.Device(
+#        manufacturer = "TestM"
+#    ),
+#    event
+# ])
+
+# pprint(entity)
+# osw.store_entity(entity = entity, entity_title="Term:OSW9c64c51bd5fb4162bc1fa9e60468a09d" )
+
+entity2 = osw.load_entity("Term:OSW9c64c51bd5fb4162bc1fa9e60468a09e")
+# pprint(entity2)
+print(type(entity2))
+# ctrl = crtl.DeviceController(dev = entity2)
+# ctrl.print()
+
+entity2.print()
+entity2.getPlot()
+di = model.DeviceInstance(label="test")
+di.print()
+print(type(di))
+
+# create custom model
+# @model._basemodel_decorator
+# class MyModel(model.BaseModel, metaclass=OswClassMetaclass, osw_template="MyTemplate", osw_footer_template="MyFooterTemplate"):
+#    my_property_2: Optional[str]
+#    my_property: Optional[int] =  Field(None, title="My Property")
+
+# print(MyModel.schema_json(indent=4))
+# myModel = model.Entity(label="MyModel", extensions=[MyModel(my_property=1)])
+# pprint(myModel)
+# osw.register_schema(OSW.SchemaRegistration(model_cls=MyModel, schema_name="MyModel"))
+
+# import src.model.LIMS.Device.Type as model2
+dt = model.DeviceType(label=model.Label(label_text="Test"))
+# pprint(dt)
+# pprint(dt.json())
+
+se = model.DeviceInstance(
+    label="PCB",
+    statements=[
+        model.Statement(predicate="Contains", object="Gold"),
+        model.Statement(
+            predicate="Contains",
+            object="Lead",
+            substatements=[model.Statement(predicate="HasConcentration", object="10%")],
+        ),
+    ],
+)
+pprint(se.dict())
+osw.store_entity(entity=se, entity_title="Term:OSW9c64c51bd5fb4162bc1fa9e60468a09e")
diff --git a/examples/load_entity.py b/examples/load_entity.py
new file mode 100644
index 00000000..d2d5d029
--- /dev/null
+++ b/examples/load_entity.py
@@ -0,0 +1,24 @@
+import os
+
+import osw.model.entity as model
+from osw.core import OSW
+from osw.wtsite import WtSite
+
+# create/update the password file under examples/accounts.pwd.yaml
+pwd_file_path = os.path.join(
+    os.path.dirname(os.path.abspath(__file__)), "accounts.pwd.yaml"
+)
+# pwd_file_path = "./accounts.pwd.yaml"
+wtsite = WtSite.from_domain("wiki-dev.open-semantic-lab.org", pwd_file_path)
+osw = OSW(site=wtsite)
+
+title = "Item:OSW7d7193567ea14e4e89b74de88983b718"
+# title = "Item:OSWe02213b6c4664d04834355dc8eb08b99"
+entity = osw.load_entity(title)
+print(entity.__class__)
+print(entity.label.text)  # we can access any attribute of model.Entity
+
+hardware_entity = entity.cast(model.Hardware)  # explicit cast to model.Hardware
+print(hardware_entity.manufacturer)  # we can access now any attribute of model.Entity
+
+print(entity.manufacturer)
diff --git a/examples/page_manipulation.py b/examples/page_manipulation.py
new file mode 100644
index 00000000..c60381eb
--- /dev/null
+++ b/examples/page_manipulation.py
@@ -0,0 +1,222 @@
+import json
+import os
+from datetime import datetime
+from pprint import pprint
+
+import osw.wiki_tools as wt
+from osw.wtsite import WtPage, WtSite
+
+pwd_file_path = os.path.join(
+    os.path.dirname(os.path.abspath(__file__)), "accounts.pwd.yaml"
+)
+
+
+def basic_text_manipulation():
+    wikitext_org = """{{OslTemplate:LIMS/Device/Type
+    |timestamp=2022-10-10T00:00:00.000Z
+    |creator=C1;C2
+    |display_name=Test Term
+    |label=Device Test Type with Term
+    |label_lang_code=en
+    |description=Some description
+    with line break
+    |category=Category:OSWa444b0eeb79140d58a836a7fc6fc940a
+    |relations={{OslTemplate:KB/Relation
+    |property=IsRelatedTo
+    |value=Term:OSW6b663c61c12d42e8be37d735dd2a869c
+    }}SomeText{{OslTemplate:KB/Relation
+    |property=IsRelatedTo
+    |value=Term:OSW6b663c61c12d42e8be37d735dd2a869c
+    }}
+    }}
+    =Details=
+    some text
+
+    {{Some/Template
+    |p1=v1
+    }}
+
+    <br />
+    {{OslTemplate:LIMS/Device/Type/Footer
+    }}"""
+
+    content_dict_1 = wt.create_flat_content_structure_from_wikitext(wikitext_org)
+    pprint(content_dict_1)
+
+    wikitext_2 = wt.get_wikitext_from_flat_content_structure(content_dict_1)
+    print(wikitext_2)
+    if wikitext_2 == wikitext_org:
+        print("wikitext_2 == wikitext_org")
+    else:
+        print("wikitext_2 != wikitext_org")
+
+    content_dict_3 = wt.create_flat_content_structure_from_wikitext(wikitext_2)
+    pprint(content_dict_3)
+    wikitext_3 = wt.get_wikitext_from_flat_content_structure(content_dict_3)
+    print(wikitext_3)
+    if wikitext_3 == wikitext_2:
+        print("wikitext_3 == wikitext_2")
+    else:
+        print("wikitext_3 != wikitext_2")
+
+    content_dict_3[0]["OslTemplate:LIMS/Device/Type"]["display_name"] = "NEW VALUE"
+    print(wt.get_wikitext_from_flat_content_structure(content_dict_3))
+
+
+def mass_page_edit():
+    wtsite = WtSite.from_domain("wiki-dev.open-semantic-lab.org", pwd_file_path)
+    # wtpage = wtsite.get_WtPage("LabNote:220601-sist-0001-ni")
+    # wtpage = wtsite.get_WtPage("testesfesefsef")
+    # wtpage.append_template("TestTemplate", {"p1": "v1"})
+    # wtpage.append_text("Some text",)
+    # wtpage.append_template("TestTemplate", {"p1": "v2"})
+
+    # pprint(wtpage._dict)
+
+    # res = wtpage.get_value("*.TestTemplate.p1")
+    # pprint(res)
+    # d = wtpage.set_value("*.TestTemplate.p1", "v3")
+
+    # local_id = wtpage.title.split(":")[1]
+    # wtpage.set_value("*.'OslTemplate:ELN/Entry/Header'", {"local_id" : [local_id]})
+
+    # wtpage.update_content()
+    # pprint(wtpage._dict)
+    # wtpage.edit()
+    # print(wtpage.changed)
+
+    wtsite.modify_search_results(
+        "semantic",
+        "[[Category:labNote]]",
+        lambda wtpage:
+        # print("Lambda")#wtpage.title)
+        wtpage.set_value(
+            "*.'OslTemplate:ELN/Entry/Header'", {"id": [wtpage.title.split(":")[1]]}
+        ).update_content(),
+        limit=1,
+        comment="[bot] set id from title",
+        log=True,
+        dryrun=True,
+    )
+
+
+def schema_renaming():
+    wtsite = WtSite.from_domain("wiki-dev.open-semantic-lab.org", pwd_file_path)
+
+    def modify(wtpage: WtPage):
+        wtpage.content_replace("_osl_template", "osl_template")
+        wtpage.content_replace("_osl_footer", "osl_footer")
+
+    wtsite.modify_search_results(
+        "prefix",
+        "JsonSchema:",
+        modify,
+        limit=20,
+        comment="rename keywords _osl* to osl*",
+        log=True,
+        dryrun=False,
+    )
+
+
+def slot_default_values():
+    wtsite = WtSite.from_domain("wiki-dev.open-semantic-lab.org", pwd_file_path)
+
+    def modify(wtpage: WtPage):
+        edit = True
+        # print(wtpage.get_last_changed_time())
+        # if wtpage.get_last_changed_time() > datetime.fromisoformat('2023-01-15T12:00:00+00:00'):
+        #    print("new")
+        # else:
+        #    print("old")
+        #    edit = False
+        if edit:
+            if wtpage.get_slot_content("header") != "{{#invoke:Entity|header}}":
+                wtpage.set_slot_content("header", "{{#invoke:Entity|header}}")
+            if wtpage.get_slot_content("footer") != "{{#invoke:Entity|footer}}":
+                wtpage.set_slot_content("footer", "{{#invoke:Entity|footer}}")
+
+    wtsite.modify_search_results(
+        "prefix",
+        "Item:",
+        # "semantic",
+        # "[[IsA::Category:Category]]",
+        modify,
+        limit=300,
+        comment="set default content of slot header and footer",
+        log=True,
+        dryrun=False,
+    )
+
+
+def text_replace():
+    wtsite = WtSite.from_domain("wiki-dev.open-semantic-lab.org", pwd_file_path)
+
+    def modify(wtpage: WtPage):
+        edit = True
+        if "#" in wtpage.title:
+            edit = False
+        if wtpage._page.redirect:
+            edit = False
+        if edit:
+            for slot in wtpage._slots:
+                content = wtpage.get_slot_content(slot)
+                if isinstance(content, dict):
+                    content = json.loads(json.dumps(content).replace("OSL", "OSW"))
+                else:
+                    content = content.replace("OSL", "OSW")
+                wtpage.set_slot_content(slot, content)
+
+    wtsite.modify_search_results(
+        "semantic",
+        # "[[IsA::Category:Category]]",
+        "[[Item:+]]",
+        # "prefix",
+        # "Item:OSW",
+        modify,
+        limit=100,
+        comment="replace OSL with OSW",
+        log=True,
+        dryrun=False,
+    )
+
+
+def move_page():
+    wtsite = WtSite.from_domain("wiki-dev.open-semantic-lab.org", pwd_file_path)
+
+    def modify(wtpage: WtPage):
+        edit = True
+        # print(wtpage.get_last_changed_time())
+        if wtpage.get_last_changed_time() > datetime.fromisoformat(
+            "2022-12-24T12:00:00+00:00"
+        ):
+            print("new")
+        else:
+            print("old")
+            edit = False
+        if "#" in wtpage.title:
+            edit = False
+        if wtpage._page.redirect:
+            edit = False
+        if edit:
+            new_title = wtpage.title.replace("OSL", "OSW")
+            wtpage.move(new_title=new_title, comment="replace OSL with OSW")
+
+    wtsite.modify_search_results(
+        "semantic",
+        # "[[IsA::Category:Category]]",
+        "[[~*File:OSL*]]",
+        # "prefix",
+        # "File:OSL",
+        modify,
+        limit=1000,
+        comment="replace OSL with OSW",
+        log=False,
+        dryrun=True,
+    )
+
+
+# mass_page_edit()
+# schema_renaming()
+# slot_default_values()
+# text_replace()
+move_page()
diff --git a/examples/register_model.py b/examples/register_model.py
new file mode 100644
index 00000000..a4901273
--- /dev/null
+++ b/examples/register_model.py
@@ -0,0 +1,48 @@
+import os
+from importlib import reload
+from pprint import pprint
+from typing import ClassVar
+from uuid import uuid4
+
+from pydantic import BaseModel
+
+import osw.model.entity as model
+from osw.core import OSW
+from osw.wtsite import WtSite
+
+# create/update the password file under examples/accounts.pwd.yaml
+pwd_file_path = os.path.join(
+    os.path.dirname(os.path.abspath(__file__)), "accounts.pwd.yaml"
+)
+wtsite = WtSite.from_domain("wiki-dev.open-semantic-lab.org", pwd_file_path)
+osw = OSW(site=wtsite)
+
+
+# todo: does dataclass export only most specific class jsonschema
+class MyPythonClass(
+    BaseModel
+):  # we don't inherit from model.Item here because this will trigger a full schema export
+    __uuid__: ClassVar[uuid4] = "23e4356e-b726-4c5b-b63f-620b301eb836"
+    my_value: str
+
+
+osw.register_schema(
+    osw.SchemaRegistration(
+        model_cls=MyPythonClass,
+        schema_uuid=MyPythonClass.__uuid__,
+        schema_name="MyPythonClass",
+        schema_bases=["Category:Item"],
+    )
+)
+
+osw.fetch_schema(
+    osw.FetchSchemaParam(
+        schema_title="Category:" + OSW.get_osw_id(MyPythonClass.__uuid__), mode="append"
+    )
+)
+reload(model)
+
+my_instance = model.MyPythonClass(
+    label=model.Label(text="My Instance"), my_value="test"
+)
+pprint(my_instance)
diff --git a/examples/store_entity.py b/examples/store_entity.py
new file mode 100644
index 00000000..6e4100cd
--- /dev/null
+++ b/examples/store_entity.py
@@ -0,0 +1,25 @@
+import os
+from pprint import pprint
+
+import osw.model.entity as model
+from osw.core import OSW
+from osw.wtsite import WtSite
+
+# create/update the password file under examples/accounts.pwd.yaml
+pwd_file_path = os.path.join(
+    os.path.dirname(os.path.abspath(__file__)), "accounts.pwd.yaml"
+)
+wtsite = WtSite.from_domain("wiki-dev.open-semantic-lab.org", pwd_file_path)
+osw = OSW(site=wtsite)
+
+my_entity = model.Item(
+    label=model.Label(text="MyItem"), statements=[model.Statement(predicate="IsA")]
+)
+pprint(my_entity)
+
+osw.store_entity(my_entity)
+
+my_entity2 = osw.load_entity("Item:" + OSW.get_osw_id(my_entity.uuid))
+pprint(my_entity)
+
+osw.delete_entity(my_entity)
diff --git a/examples/update_local_model.py b/examples/update_local_model.py
new file mode 100644
index 00000000..6d184ad2
--- /dev/null
+++ b/examples/update_local_model.py
@@ -0,0 +1,23 @@
+import os
+from importlib import reload
+
+import osw.model.entity as model
+from osw.core import OSW
+from osw.wtsite import WtSite
+
+# create/update the password file under examples/accounts.pwd.yaml
+pwd_file_path = os.path.join(
+    os.path.dirname(os.path.abspath(__file__)), "accounts.pwd.yaml"
+)
+# pwd_file_path = "./accounts.pwd.yaml"
+wtsite = WtSite.from_domain("wiki-dev.open-semantic-lab.org", pwd_file_path)
+osw = OSW(site=wtsite)
+
+# osw.fetch_schema() #this will load the current entity schema from the OSW instance. You may have to re-run the script to get the updated schema extension. Requires 'pip install datamodel-code-generator'
+osw.fetch_schema(OSW.FetchSchemaParam(schema_title="Category:Hardware", mode="replace"))
+osw.fetch_schema(
+    OSW.FetchSchemaParam(
+        schema_title="Category:OSWf9742b744112486ea2a5d8afee3e2538", mode="append"
+    )
+)
+reload(model)
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 00000000..29c3c1df
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,22 @@
+site_name: OpenSemanticWorld Python Package (osw)
+site_url: https://OpenSemanticLab.github.io/Tools/
+
+plugins:
+- search
+- autorefs
+- mkdocstrings:
+    handlers:
+      python:
+        paths: [src]  # search packages in the src folder
+        options:
+          docstring_style: numpy
+
+nav:
+    - Home: index.md
+    - tools.md
+    - osw.md
+    - model.md
+    - Development: dev.md
+
+theme:
+  name: "material"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..89a5bedf
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,9 @@
+[build-system]
+# AVOID CHANGING REQUIRES: IT WILL BE UPDATED BY PYSCAFFOLD!
+requires = ["setuptools>=46.1.0", "setuptools_scm[toml]>=5"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools_scm]
+# For smarter version schemes and other configuration options,
+# check out https://github.com/pypa/setuptools_scm
+version_scheme = "no-guess-dev"
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 00000000..4b42f3ac
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,143 @@
+# This file is used to configure your project.
+# Read more about the various options under:
+# https://setuptools.pypa.io/en/latest/userguide/declarative_config.html
+# https://setuptools.pypa.io/en/latest/references/keywords.html
+
+[metadata]
+name = osw
+description = Python toolset for data processing, queries, wikicode generation and page manipulation
+author = "Simon Stier"
+author_email = simon.stier@isc.fraunhofer.de
+license = AGPL-3.0-or-later
+license_files = LICENSE.txt
+long_description = file: README.md
+long_description_content_type = text/markdown; charset=UTF-8; variant=GFM
+url = https://github.com/OpenSemanticLab/osw-python
+# Add here related links, for example:
+project_urls =
+    Documentation = https://opensemanticlab.github.io/osw-python/
+    Source = https://github.com/OpenSemanticLab/osw-python
+    Changelog = https://github.com/OpenSemanticLab/osw-python/blob/main/CHANGELOG.md
+#    Tracker = https://github.com/pyscaffold/pyscaffold/issues
+#    Conda-Forge = https://anaconda.org/conda-forge/pyscaffold
+    Download = https://pypi.org/project/osw/#files
+#    Twitter = https://twitter.com/PyScaffold
+
+# Change if running only on Windows, Mac or Linux (comma-separated)
+platforms = any
+
+# Add here all kinds of additional classifiers as defined under
+# https://pypi.org/classifiers/
+classifiers =
+    Development Status :: 3 - Alpha
+    Programming Language :: Python
+
+
+[options]
+zip_safe = False
+packages = find_namespace:
+include_package_data = True
+package_dir =
+    =src
+
+# Require a min/specific Python version (comma-separated conditions)
+# python_requires = >=3.8
+
+# Add here dependencies of your project (line-separated), e.g. requests>=2.2,<3.0.
+# Version specifiers like >=2.2,<3.0 avoid problems due to API changes in
+# new major versions. This works if the required packages follow Semantic Versioning.
+# For more information, check out https://semver.org/.
+install_requires =
+    pydantic
+    datamodel-code-generator
+    mwclient
+    mwparserfromhell
+    SPARQLWrapper
+    jsonpath-ng
+    numpy
+    pyyaml
+
+
+[options.packages.find]
+where = src
+exclude =
+    tests
+
+[options.extras_require]
+# Add here additional requirements for extra features, to install with:
+# `pip install osw[PDF]` like:
+# PDF = ReportLab; RXP
+
+# Add here test requirements (semicolon/line-separated)
+testing =
+    setuptools
+    pytest
+    pytest-cov
+
+[options.entry_points]
+# Add here console scripts like:
+# console_scripts =
+#     script_name = osw.module:function
+# For example:
+# console_scripts =
+#     fibonacci = osw.skeleton:run
+# And any other entry points, for example:
+# pyscaffold.cli =
+#     awesome = pyscaffoldext.awesome.extension:AwesomeExtension
+
+[tool:pytest]
+# Specify command line options as you would do when invoking pytest directly.
+# e.g. --cov-report html (or xml) for html/xml output or --junitxml junit.xml
+# in order to write a coverage file that can be read by Jenkins.
+# CAUTION: --cov flags may prohibit setting breakpoints while debugging.
+#          Comment those flags to avoid this pytest issue.
+addopts =
+    --cov osw --cov-report term-missing
+    --verbose
+norecursedirs =
+    dist
+    build
+    .tox
+testpaths = tests
+# Use pytest markers to select/deselect specific tests
+# markers =
+#     slow: mark tests as slow (deselect with '-m "not slow"')
+#     system: mark end-to-end system tests
+
+[devpi:upload]
+# Options for the devpi: PyPI server and packaging tool
+# VCS export must be deactivated since we are using setuptools-scm
+no_vcs = 1
+formats = bdist_wheel
+
+[tool.black]
+line-length = 88
+
+[flake8]
+# Some sane defaults for the code style checker flake8
+# 79 characters is the default line length according to PEP8
+# 88 is the option recommended by black
+max_line_length = 88
+select = C,E,F,W,B,B950
+extend_ignore = E203, E501, W503
+# ^  Black-compatible
+#    E203 and W503 have edge cases handled by black
+# E501 allows a line length of 79 only
+exclude =
+    .tox
+    build
+    dist
+    .eggs
+    docs/conf.py
+    src/osw/model/entity.py #autogenerated
+
+[pyscaffold]
+# PyScaffold's parameters when the project was created.
+# This will be used when updating. Do not change!
+version = 4.3.1
+package = osw
+extensions =
+    github_actions
+    markdown
+    no_skeleton
+    pre_commit
diff --git a/setup.py b/setup.py
new file mode 100644
index 00000000..219ce8ee
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,21 @@
+"""
+    Setup file for osw.
+    Use setup.cfg to configure your project.
+
+    This file was generated with PyScaffold 4.3.1.
+    PyScaffold helps you to put up the scaffold of your new Python project.
+    Learn more under: https://pyscaffold.org/
+"""
+from setuptools import setup
+
+if __name__ == "__main__":
+    try:
+        setup(use_scm_version={"version_scheme": "no-guess-dev"})
+    except:  # noqa
+        print(
+            "\n\nAn error occurred while building the project, "
+            "please ensure you have the most updated version of setuptools, "
+            "setuptools_scm and wheel with:\n"
+            "   pip install -U setuptools setuptools_scm wheel\n\n"
+        )
+        raise
diff --git a/src/osw/__init__.py b/src/osw/__init__.py
new file mode 100644
index 00000000..9f7cc634
--- /dev/null
+++ b/src/osw/__init__.py
@@ -0,0 +1,10 @@
+from importlib.metadata import PackageNotFoundError, version  # pragma: no cover
+
+try:
+    # Change here if project is renamed and does not equal the package name
+    dist_name = __name__
+    __version__ = version(dist_name)
+except PackageNotFoundError:  # pragma: no cover
+    __version__ = "unknown"
+finally:
+    del version, PackageNotFoundError
diff --git a/src/__init__.py b/src/osw/controller/__init__.py
similarity index 71%
rename from src/__init__.py
rename to src/osw/controller/__init__.py
index 971fdc19..60890872 100644
--- a/src/__init__.py
+++ b/src/osw/controller/__init__.py
@@ -1,1 +1,1 @@
-# This file is required to make Python treat directories containing the file as packages.
\ No newline at end of file
+# This file is required to make Python treat directories containing the file as packages.
diff --git a/src/osw/controller/entity.py b/src/osw/controller/entity.py
new file mode 100644
index 00000000..142d0f90
--- /dev/null
+++ b/src/osw/controller/entity.py
@@ -0,0 +1,24 @@
+import osw.model.entity as model
+
+
+class Entity(model.Entity):
+    def explain(self):
+        print(f"Entity with label '{str(self.label.text)}'")
+
+
+# model.Entity = Entity
+
+
+class Hardware(model.Hardware, Entity):
+    def run(self):
+        self.explain()
+        print(" is running")
+
+
+# model.Hardware = Hardware
+
+
+class DbController(model.Database):
+    def create_table(self):
+        connection_str = self.db_server
+        return connection_str
diff --git a/src/osw/core.py b/src/osw/core.py
new file mode 100644
index 00000000..18e514fe
--- /dev/null
+++ b/src/osw/core.py
@@ -0,0 +1,428 @@
+from __future__ import annotations
+
+import importlib
+import json
+import os
+import platform
+import re
+import sys
+from enum import Enum
+from typing import List, Optional
+from uuid import UUID
+
+from jsonpath_ng.ext import parse
+from pydantic import BaseModel, Field, create_model
+from pydantic.main import ModelMetaclass
+
+import osw.model.entity as model
+from osw.wtsite import WtSite
+
+
+class OswClassMetaclass(ModelMetaclass):
+    def __new__(cls, name, bases, dic, osl_template, osl_footer_template):
+        base_footer_cls = type(
+            dic["__qualname__"] + "Footer",
+            (BaseModel,),
+            {
+                "__annotations__": {"osl_template": str},
+                "osl_template": Field(
+                    default=osl_footer_template,
+                    title=dic["__qualname__"] + "FooterTemplate",
+                ),
+            },
+        )
+        if "__annotations__" not in dic:
+            dic["__annotations__"] = {}
+        dic["__annotations__"]["osl_template"] = str
+        dic["osl_template"] = Field(
+            default=osl_template, title=dic["__qualname__"] + "Template"
+        )
+        dic["__annotations__"]["osl_footer"] = base_footer_cls
+        dic["osl_footer"] = Field(
+            default={"osl_template": osl_footer_template},
+            title=dic["__qualname__"] + "Footer",
+        )
+        new_cls = super().__new__(cls, name, bases, dic)
+        return new_cls
+
+
+class OSW(BaseModel):
+    """OSW Class"""
+
+    uuid: str = "2ea5b605-c91f-4e5a-9559-3dff79fdd4a5"
+    _protected_keywords = (
+        "_osl_template",
+        "_osl_footer",
+    )  # private properties included in model export
+
+    class Config:
+        arbitrary_types_allowed = True  # neccessary to allow e.g. np.array as type
+
+    site: WtSite
+
+    @staticmethod
+    def get_osw_id(uuid: uuid) -> str:
+        return "OSW" + str(uuid).replace("-", "")
+
+    @staticmethod
+    def get_uuid(osw_id) -> uuid:
+        return UUID(osw_id.replace("OSW", ""))
+
+    @model._basemodel_decorator
+    class SchemaRegistration(BaseModel):
+        """
+        dataclass param of register_schema()
+
+        Attributes
+        ----------
+        model_cls:
+            the model class
+        schema_name:
+            the name of the schema
+        schema_bases:
+            list of base schemas (referenced by allOf)
+        """
+
+        class Config:
+            arbitrary_types_allowed = True  # allow any class as type
+
+        model_cls: ModelMetaclass
+        schema_uuid = str  # Optional[str] = model_cls.__uuid__
+        schema_name: str  # Optional[str] = model_cls.__name__
+        schema_bases: List[str] = ["Category:Item"]
+
+    def register_schema(self, schema_registration: SchemaRegistration):
+        """registers a new or updated schema in OSW
+
+        Parameters
+        ----------
+        schema_registration
+            see SchemaRegistration
+        """
+        entity = schema_registration.model_cls
+
+        jsondata = {}
+        jsondata["uuid"] = schema_registration.schema_uuid
+        jsondata["label"] = {"text": schema_registration.schema_name, "lang": "en"}
+        jsondata["subclass_of"] = schema_registration.schema_bases
+
+        if issubclass(entity, BaseModel):
+            entity_title = "Category:" + OSW.get_osw_id(schema_registration.schema_uuid)
+            page = self.site.get_WtPage(entity_title)
+
+            page.set_slot_content("jsondata", jsondata)
+
+            # entity = ModelMetaclass(entity.__name__, (BaseModel,), dict(entity.__dict__)) #strips base classes but fiels are already importet
+            schema = json.loads(
+                entity.schema_json(indent=4).replace("$ref", "dollarref")
+            )
+
+            jsonpath_expr = parse("$..allOf")
+            # replace local definitions (#/definitions/...) with embedded definitions to prevent resolve errors in json-editor
+            for match in jsonpath_expr.find(schema):
+                result_array = []
+                for subschema in match.value:
+                    # pprint(subschema)
+                    value = subschema["dollarref"]
+                    if value.startswith("#"):
+                        definition_jsonpath_expr = parse(
+                            value.replace("#", "$").replace("/", ".")
+                        )
+                        for def_match in definition_jsonpath_expr.find(schema):
+                            # pprint(def_match.value)
+                            result_array.append(def_match.value)
+                    else:
+                        result_array.append(subschema)
+                match.full_path.update_or_create(schema, result_array)
+            if "definitions" in schema:
+                del schema["definitions"]
+
+            if "allOf" not in schema:
+                schema["allOf"] = []
+            for base in schema_registration.schema_bases:
+                schema["allOf"].append(
+                    {"$ref": f"/wiki/{base}?action=raw&slot=jsonschema"}
+                )
+
+            page.set_slot_content("jsonschema", schema)
+        else:
+            print("Error: Unsupported entity type")
+            return
+
+        page.edit()
+        print("Entity stored at " + page.get_url())
+
+    @model._basemodel_decorator
+    class SchemaUnregistration(BaseModel):
+        """
+        dataclass param of register_schema()
+
+        Attributes
+        ----------
+        model_cls:
+            the model class
+        schema_name:
+            the name of the schema
+        schema_bases:
+            list of base schemas (referenced by allOf)
+        """
+
+        class Config:
+            arbitrary_types_allowed = True  # allow any class as type
+
+        model_cls: Optional[ModelMetaclass]
+        model_uuid: Optional[str]
+        comment: Optional[str]
+
+    def unregister_schema(self, schema_unregistration: SchemaUnregistration):
+        uuid = ""
+        if schema_unregistration.model_uuid:
+            uuid = schema_unregistration.model_uuid
+        elif (
+            not uuid
+            and schema_unregistration.model_cls
+            and issubclass(schema_unregistration.model_cls, BaseModel)
+        ):
+            uuid = schema_unregistration.model_cls.__uuid__
+        else:
+            print("Error: Neither model nor model id provided")
+
+        entity_title = "Category:" + OSW.get_osw_id(uuid)
+        page = self.site.get_WtPage(entity_title)
+        page.delete(schema_unregistration.comment)
+
+    class FetchSchemaMode(Enum):
+        append = "append"  # append to the current model
+        replace = "replace"  # replace the current model
+
+    @model._basemodel_decorator
+    class FetchSchemaParam(BaseModel):
+        schema_title: Optional[str] = "JsonSchema:Entity"
+        root: Optional[bool] = True
+        mode: Optional[
+            str
+        ] = "replace"  # type 'FetchSchemaMode' requires: 'from __future__ import annotations'
+
+    def fetch_schema(self, fetchSchemaParam: FetchSchemaParam = None):
+        site_cache_state = self.site.get_cache_enabled()
+        self.site.enable_cache()
+        if fetchSchemaParam is None:
+            fetchSchemaParam = OSW.FetchSchemaParam()
+        schema_title = fetchSchemaParam.schema_title
+        root = fetchSchemaParam.root
+        schema_name = schema_title.split(":")[-1]
+        page = self.site.get_WtPage(schema_title)
+        if schema_title.startswith("Category:"):
+            schema_str = json.dumps(page.get_slot_content("jsonschema"))
+        else:
+            schema_str = page.get_content()
+        schema = json.loads(
+            schema_str.replace("$ref", "dollarref")
+        )  # '$' is a special char for root object in jsonpath
+        print(f"Fetch {schema_title}")
+
+        jsonpath_expr = parse("$..dollarref")
+        for match in jsonpath_expr.find(schema):
+            # value = "https://" + self.site._site.host + match.value
+            if match.value.startswith("#"):
+                continue  # skip self references
+            ref_schema_title = match.value.replace("/wiki/", "").split("?")[0]
+            ref_schema_name = ref_schema_title.split(":")[-1] + ".json"
+            value = ""
+            for i in range(0, schema_name.count("/")):
+                value += "../"  # created relative path to top-level schema dir
+            value += ref_schema_name  # create a reference to a local file
+            match.full_path.update_or_create(schema, value)
+            # print(f"replace {match.value} with {value}")
+            if (
+                ref_schema_title != schema_title
+            ):  # prevent recursion in case of self references
+                self.fetch_schema(
+                    OSW.FetchSchemaParam(schema_title=ref_schema_title, root=False)
+                )  # resolve references recursive
+
+        model_dir_path = os.path.join(
+            os.path.dirname(os.path.abspath(__file__)), "model"
+        )  # src/model
+        schema_path = os.path.join(model_dir_path, schema_name + ".json")
+        os.makedirs(os.path.dirname(schema_path), exist_ok=True)
+        with open(schema_path, "w", encoding="utf-8") as f:
+            schema_str = json.dumps(schema, ensure_ascii=False, indent=4).replace(
+                "dollarref", "$ref"
+            )
+            # print(schema_str)
+            f.write(schema_str)
+
+        # result_model_path = schema_path.replace(".json", ".py")
+        result_model_path = os.path.join(model_dir_path, "entity.py")
+        temp_model_path = os.path.join(model_dir_path, "temp.py")
+        if root:
+            exec_name = "datamodel-codegen"
+            if platform.system() == "Windows":
+                exec_name += ".exe"
+            exec_path = os.path.join(
+                os.path.dirname(os.path.abspath(sys.executable)), exec_name
+            )
+            if not os.path.isfile(exec_path):
+                exec_path = os.path.join(
+                    os.path.dirname(os.path.abspath(sys.executable)),
+                    "Scripts",
+                    exec_name,
+                )
+            if not os.path.isfile(exec_path):
+                print("Error: datamodel-codegen not found")
+                return
+            os.system(
+                f"{exec_path}  --input {schema_path} --input-file-type jsonschema --output {temp_model_path} \
+                --base-class OswBaseModel \
+                --use-default \
+                --enum-field-as-literal one \
+                --use-title-as-name \
+                --use-schema-description \
+                --use-field-description \
+            "
+            )
+            # see https://koxudaxi.github.io/datamodel-code-generator/
+            # --base-class OswBaseModel: use a custom base class
+            # --custom-template-dir src/model/template_data/
+            # --extra-template-data src/model/template_data/extra.json
+            # --use-default: Use default value even if a field is required
+            # --enum-field-as-literal one: for static properties like osl_template
+            # --use-schema-description: Use schema description to populate class docstring
+            # --use-field-description: Use schema description to populate field docstring
+            # --use-title-as-name: use titles as class names of models, e. g. for the footer templates
+
+            # this is dirty, but required for autocompletion: https://stackoverflow.com/questions/62884543/pydantic-autocompletion-in-vs-code
+            # idealy solved by custom templates in the future: https://github.com/koxudaxi/datamodel-code-generator/issues/860
+
+            content = ""
+            with open(temp_model_path, "r") as f:
+                content = f.read()
+            os.remove(temp_model_path)
+
+            content = re.sub(
+                r"(import OswBaseModel)", "from pydantic import BaseModel", content, 1
+            )  # remove import statement
+
+            if fetchSchemaParam.mode == "replace":
+
+                header = (
+                    "from uuid import uuid4\n"
+                    "from typing import TYPE_CHECKING, Type, TypeVar\n"
+                    "\n"
+                    "if TYPE_CHECKING:\n"
+                    "    from dataclasses import dataclass as _basemodel_decorator\n"
+                    "else:\n"
+                    "    _basemodel_decorator = lambda x: x   # noqa: E731\n"
+                    "\n"
+                )
+                header += (
+                    "\nT = TypeVar('T', bound=BaseModel)\n"
+                    "\nclass OswBaseModel(BaseModel):\n"
+                    "    def full_dict(self, **kwargs): #extent BaseClass export function\n"
+                    "        d = super().dict(**kwargs)\n"
+                    "        for key in " + str(self._protected_keywords) + ":\n"
+                    "            if hasattr(self, key): d[key] = getattr(self, key) #include selected private properites. note: private properties are not considered as discriminator \n"
+                    "        return d\n\n"
+                    "    def cast(self, cls: Type[T]) -> T:\n"
+                    "       return cls(**self.dict())\n"
+                )
+
+                content = re.sub(
+                    r"(class\s*\S*\s*\(\s*OswBaseModel\s*\)\s*:.*\n)",
+                    header + r"\n\n\n\1",
+                    content,
+                    1,
+                )  # replace first match
+                content = re.sub(
+                    r"(class\s*\S*\s*\(\s*OswBaseModel\s*\)\s*:.*\n)",
+                    r"@_basemodel_decorator\n\1",
+                    content,
+                )
+                content = re.sub(
+                    r"(UUID = Field\(...)",
+                    r"UUID = Field(default_factory=uuid4",
+                    content,
+                )  # enable default value for uuid
+                with open(result_model_path, "w") as f:
+                    f.write(content)
+
+            if fetchSchemaParam.mode == "append":
+                org_content = ""
+                with open(result_model_path, "r") as f:
+                    org_content = f.read()
+
+                pattern = re.compile(
+                    r"class\s*([\S]*)\s*\(\s*\S*\s*\)\s*:.*\n"
+                )  # match class definition [\s\S]*(?:[^\S\n]*\n){2,}
+                for cls in re.findall(pattern, org_content):
+                    print(cls)
+                    content = re.sub(
+                        r"(class\s*"
+                        + cls
+                        + r"\s*\(\s*\S*\s*\)\s*:.*\n[\s\S]*?(?:[^\S\n]*\n){2,})",
+                        "",
+                        content,
+                        count=1,
+                    )  # replace duplicated classes
+
+                content = re.sub(
+                    r"(from __future__ import annotations)", "", content, 1
+                )  # remove import statement
+                # print(content)
+                with open(result_model_path, "a") as f:
+                    f.write(content)
+
+            importlib.reload(model)  # reload the updated module
+            if not site_cache_state:
+                self.site.disable_cache()  # restore original state
+
+    def load_entity(self, entity_title) -> model.Entity:
+        entity = None
+        schemas = []
+        page = self.site.get_WtPage(entity_title)
+        jsondata = page.get_slot_content("jsondata")
+        if jsondata:
+            for category in jsondata["type"]:
+                schema = self.site.get_WtPage(category).get_slot_content("jsonschema")
+                schemas.append(schema)
+
+        if len(schemas) == 0:
+            print("Error: no schema defined")
+
+        elif len(schemas) == 1:
+            cls = schemas[0]["title"]
+            entity = eval(f"model.{cls}(**jsondata)")
+
+        else:
+            bases = []
+            for schema in schemas:
+                bases.append(eval("model." + schema["title"]))
+            cls = create_model("Test", __base__=tuple(bases))
+            entity = cls(**jsondata)
+
+        return entity
+
+    def store_entity(self, entity) -> None:
+        if isinstance(entity, model.Item):
+            entity_title = "Item:" + OSW.get_osw_id(entity.uuid)
+            page = self.site.get_WtPage(entity_title)
+            jsondata = json.loads(entity.json())  # use pydantic serialization
+            page.set_slot_content("jsondata", jsondata)
+        else:
+            print("Error: Unsupported entity type")
+            return
+
+        page.edit()
+        print("Entity stored at " + page.get_url())
+
+    def delete_entity(self, entity, comment: str = None):
+        if isinstance(entity, model.Item):
+            entity_title = "Item:" + OSW.get_osw_id(entity.uuid)
+            page = self.site.get_WtPage(entity_title)
+        else:
+            print("Error: Unsupported entity type")
+            return
+
+        page.delete(comment)
+        print("Entity deleted: " + page.get_url())
diff --git a/src/osw/model/__init__.py b/src/osw/model/__init__.py
new file mode 100644
index 00000000..60890872
--- /dev/null
+++ b/src/osw/model/__init__.py
@@ -0,0 +1,1 @@
+# This file is required to make Python treat directories containing the file as packages.
diff --git a/src/osw/model/entity.py b/src/osw/model/entity.py
new file mode 100644
index 00000000..c4d5d147
--- /dev/null
+++ b/src/osw/model/entity.py
@@ -0,0 +1,98 @@
+# generated by datamodel-codegen:
+#   filename:  Item.json
+#   timestamp: 2023-01-23T06:27:29+00:00
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Any, List, Optional
+from uuid import UUID
+
+from pydantic import BaseModel, Field
+from typing_extensions import Literal
+
+
+class LangCode(Enum):
+    en = "en"
+    de = "de"
+
+
+from typing import TYPE_CHECKING, Type, TypeVar
+from uuid import uuid4
+
+if TYPE_CHECKING:
+    from dataclasses import dataclass as _basemodel_decorator
+else:
+    _basemodel_decorator = lambda x: x  # noqa: E731
+
+
+T = TypeVar("T", bound=BaseModel)
+
+
+class OswBaseModel(BaseModel):
+    def full_dict(self, **kwargs):  # extent BaseClass export function
+        d = super().dict(**kwargs)
+        for key in ("_osl_template", "_osl_footer"):
+            if hasattr(self, key):
+                d[key] = getattr(
+                    self, key
+                )  # include selected private properites. note: private properties are not considered as discriminator
+        return d
+
+    def cast(self, cls: Type[T]) -> T:
+        return cls(**self.dict())
+
+
+@_basemodel_decorator
+class Label(OswBaseModel):
+    text: str = Field(..., title="Text")
+    lang: Optional[LangCode] = Field("en", title="Lang code")
+
+
+class LangCode1(Enum):
+    en = "en"
+    de = "de"
+
+
+@_basemodel_decorator
+class Description(OswBaseModel):
+    text: str = Field(..., title="Text")
+    lang: Optional[LangCode1] = Field("en", title="Lang code")
+
+
+@_basemodel_decorator
+class Statement(OswBaseModel):
+    osl_template: Optional[Literal["OslTemplate:Statement"]] = "OslTemplate:Statement"
+    label: Optional[Label] = Field(None, title="Label")
+    """
+    Human readable name
+    """
+    uuid: UUID = Field(default_factory=uuid4, title="UUID")
+    subject: Optional[str] = None
+    predicate: str
+    object: Optional[str] = None
+    substatements: Optional[List[Statement]] = Field(None, title="Substatements")
+
+
+@_basemodel_decorator
+class Entity(OswBaseModel):
+    uuid: UUID = Field(default_factory=uuid4, title="UUID")
+    label: Label = Field(..., title="Label")
+    """
+    Human readable name
+    """
+    query_label: Optional[str] = Field(None, title="Label")
+    additional_labels: Optional[List[Label]] = Field(None, title="Additional Labels")
+    description: Optional[List[Description]] = Field(None, title="Description")
+    image: Optional[str] = Field(None, title="Image")
+    statements: Optional[List[Statement]] = Field(None, title="Statements")
+    extensions: Optional[List[Any]] = Field(None, title="Extensions")
+
+
+class Item(Entity):
+    type: Optional[List[str]] = Field(
+        ["Category:Item"], min_length=1, title="Types/Categories"
+    )
+
+
+Statement.update_forward_refs()
diff --git a/src/osw/sparql_client_smw.py b/src/osw/sparql_client_smw.py
new file mode 100644
index 00000000..c9e5f8dc
--- /dev/null
+++ b/src/osw/sparql_client_smw.py
@@ -0,0 +1,238 @@
+# from quantities
+import getpass
+
+from SPARQLWrapper import BASIC, JSON, POST, SPARQLWrapper
+
+
+class SmwSparqlClient:
+    def __init__(self, endpoint, domain, auth="none", user="", password=""):
+
+        self.create_sparql_client(endpoint, auth, user, password)
+
+        self.prefix_dict = {
+            "dc": "http://purl.org/dc/elements/1.1/",
+            "property": "https://{domain}/id/Property-3A",
+            "file": "https://{domain}/id/File-3A",
+            "category": "https://{domain}/id/Category-3A",
+            "material": "https://{domain}/id/Material-3A",
+            "wiki": "https://{domain}/id/",
+            "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
+            "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
+            "owl": "http://www.w3.org/2002/07/owl#",
+            "swivt": "http://semantic-mediawiki.org/swivt/1.0#",
+            "xsd": "http://www.w3.org/2001/XMLSchema#",
+        }
+        self.wiki_prefixes = []
+        self.prefixes = ""
+        for prefix in self.prefix_dict:
+            if "{domain}" in prefix:
+                self.wiki_prefixes.append[prefix]
+            self.prefix_dict[prefix] = self.prefix_dict[prefix].replace(
+                "{domain}", domain
+            )
+            self.prefixes += f"PREFIX {prefix}: <{self.prefix_dict[prefix]}>\n".replace(
+                "{domain}", domain
+            )
+
+        # from https://github.com/SemanticMediaWiki/SemanticMediaWiki/blob/e04b78ddbc0a1b2181b12f31a51d7f91e723336b/src/Exporter/Escaper.php#L61
+        self.smw_encoding_lists = {
+            "in": [
+                "*",
+                ",",
+                ";",
+                "<",
+                ">",
+                "(",
+                ")",
+                "[",
+                "]",
+                "{",
+                "}",
+                "\\",
+                "$",
+                "^",
+                ":",
+                '"',
+                "#",
+                "&",
+                "'",
+                "+",
+                "!",
+                "%",
+            ],
+            "out": [
+                "-2A",
+                "-2C",
+                "-3B",
+                "-3C",
+                "-3E",
+                "-28",
+                "-29",
+                "-5B",
+                "-5D",
+                "-7B",
+                "-7D",
+                "-5C",
+                "-24",
+                "-5E",
+                "-3A",
+                "-22",
+                "-23",
+                "-26",
+                "-27",
+                "-2B",
+                "-21",
+                "-",
+            ],
+        }
+        self.encoding_dict = dict(
+            zip(self.smw_encoding_lists["in"], self.smw_encoding_lists["out"])
+        )
+        self.encoding_dict = {}
+        self.encoding_dict_inv = dict(
+            (self.encoding_dict[k], k) for k in self.encoding_dict
+        )
+
+    def create_sparql_client(self, endpoint, auth="none", user="", password=""):
+        self.sparql = SPARQLWrapper(endpoint)
+        if auth == "basic":
+            self.sparql.setHTTPAuth(BASIC)
+            if user == "":
+                user = input("Enter username")
+            if password == "":
+                password = getpass.getpass("Enter password")
+            self.sparql.setCredentials(user, password)
+        self.sparql.setMethod(POST)
+
+    def sparqlQuery(self, query):
+        # print(query)
+        self.sparql.setQuery(query)
+        self.sparql.setReturnFormat(JSON)
+        # print(self.sparql.query())
+        return self.sparql.query().convert()
+
+    def get_sparql_triplets(
+        self,
+        where_statement,
+        select_statement="",
+        inner_where_statement="?subject ?predicate ?object",
+        tsubject="",
+        tpredicate="",
+        tobject="",
+        limit=999999999,
+        prefixes="",
+        debug=False,
+    ):
+        select_statement = ""
+        if tsubject == "":
+            select_statement += " ?subject"
+        if tpredicate == "":
+            select_statement += " ?predicate"
+        if tobject == "":
+            select_statement += " ?object"
+        if tsubject != "":
+            inner_where_statement = inner_where_statement.replace(
+                "?subject", "<" + tsubject + ">"
+            )
+        if tpredicate != "":
+            inner_where_statement = inner_where_statement.replace(
+                "?predicate", "<" + tpredicate + ">"
+            )
+        if tobject != "":
+            inner_where_statement = inner_where_statement.replace(
+                "?object", "<" + tobject + ">"
+            )
+        if prefixes == "":
+            prefixes = self.prefixes
+        query = (
+            """
+            {{{prefixes}}}
+            SELECT {{{select_statement}}}
+            WHERE {
+              {{{where_statement}}}
+                {
+                SELECT {{{select_statement}}}
+                WHERE {
+                    {{{inner_where_statement}}}
+                }
+              }
+            }
+            LIMIT {{{limit}}}
+        """.replace(
+                "{{{select_statement}}}", select_statement
+            )
+            .replace("{{{where_statement}}}", where_statement)
+            .replace("{{{inner_where_statement}}}", inner_where_statement)
+            .replace("{{{limit}}}", str(limit))
+            .replace("{{{prefixes}}}", prefixes)
+        )
+        for key in self.encoding_dict.keys():
+            query = query.replace(key, self.encoding_dict[key])
+        if debug:
+            print(query)
+        # select distinct ?p ?o where {<http://dbpedia.org/resource/Amount_of_substance> ?p ?o} LIMIT 100
+        triples = self.sparqlQuery(query)
+        # print(triples)
+        tdict = {}
+        if debug:
+            print("{} triplets found".format(len(triples["results"]["bindings"])))
+        for t in triples["results"]["bindings"]:
+            if tsubject == "":
+                s = t["subject"]["value"]
+            else:
+                s = tsubject
+            if tpredicate == "":
+                p = t["predicate"]["value"]
+            else:
+                p = tpredicate
+            if tobject == "":
+                o = t["object"]["value"]
+            else:
+                o = tobject
+            if "xml:lang" in t["object"]:
+                o += "@" + t["object"]["xml:lang"]
+            # print("{} {} {}".format(s,p,o))
+            for prefix in self.prefix_dict:
+                s = s.replace(self.prefix_dict[prefix], prefix + ":")
+                p = p.replace(self.prefix_dict[prefix], prefix + ":")
+                o = o.replace(self.prefix_dict[prefix], prefix + ":")
+            for key in self.encoding_dict_inv.keys():
+                s = s.replace(key, self.encoding_dict_inv[key])
+                p = p.replace(key, self.encoding_dict_inv[key])
+                o = o.replace(key, self.encoding_dict_inv[key])
+            self.dict_append_tripl(tdict, s, p, o)
+        # print(tdict)
+        return tdict
+
+    def spo_result_to_dict(self, triples, tsubject="", tpredicate="", tobject=""):
+        tdict = {}
+        for t in triples["results"]["bindings"]:
+            if tsubject == "":
+                s = t["subject"]["value"]
+            else:
+                s = tsubject
+            if tpredicate == "":
+                p = t["predicate"]["value"]
+            else:
+                p = tpredicate
+            if tobject == "":
+                o = t["object"]["value"]
+            else:
+                o = tobject
+            if "xml:lang" in t["object"]:
+                o += "@" + t["object"]["xml:lang"]
+            # print("{} {} {}".format(s,p,o))
+            for prefix in self.prefix_dict:
+                s = s.replace(self.prefix_dict[prefix], prefix + ":")
+                p = p.replace(self.prefix_dict[prefix], prefix + ":")
+                o = o.replace(self.prefix_dict[prefix], prefix + ":")
+            self.dict_append_tripl(tdict, s, p, o)
+        # print(tdict)
+        return tdict
+
+    def dict_append_tripl(self, d, s, p, o):
+        if s not in d:
+            d[s] = {}
+        if p not in d[s]:
+            d[s][p] = []
+        d[s][p].append(o)
diff --git a/src/osw/wiki_tools.py b/src/osw/wiki_tools.py
new file mode 100644
index 00000000..f1aaf1f8
--- /dev/null
+++ b/src/osw/wiki_tools.py
@@ -0,0 +1,1007 @@
+import copy
+import getpass
+
+import mwclient
+import mwparserfromhell
+import numpy as np
+import yaml
+from jsonpath_ng.ext import parse
+
+
+def create_site_object(domain, password_file=""):
+    """
+
+    Parameters
+    ----------
+    domain : str
+    password_file : str (path to file with <username>\n<password>)
+
+    Returns
+    -------
+    site : mwclient.client.Site
+        Site object from mwclient lib
+    """
+    domain_dict = {"wiki-dev": {"Address": "wiki-dev.open-semantic-lab.org"}}
+    if domain in domain_dict.keys():
+        domain = domain_dict[domain]["Address"]
+
+    site = mwclient.Site(domain, path="/w/")
+
+    if password_file != "":
+        with open(password_file, "r") as stream:
+            try:
+                accounts = yaml.safe_load(stream)
+                for domain in accounts:
+                    if domain == domain:
+                        user = accounts[domain]["username"]
+                        password = accounts[domain]["password"]
+            except yaml.YAMLError as exc:
+                print(exc)
+    else:
+        user = input("Enter bot username (username@botname)")
+        password = getpass.getpass("Enter bot password")
+
+    site.login(user, password)
+    del user
+    del password
+    return site
+
+
+# Standard Query
+# api.php?action=query&list=prefixsearch&pssearch=Star Wars
+def prefix_search(site, text):
+    """
+
+    Parameters
+    ----------
+    site : mwclient.client.Site
+        Site object from mwclient lib
+    text : str
+
+    Returns
+    -------
+    page_list : list
+    """
+    page_list = []
+    result = site.api(
+        "query", list="prefixsearch", pssearch=text, pslimit=1000, format="json"
+    )
+    if len(result["query"]["prefixsearch"]) == 0:
+        print("No results")
+    else:
+        for page in result["query"]["prefixsearch"]:
+            title = page["title"]
+            print(title)
+            page_list.append(title)
+    return page_list
+
+
+# Semantic Query
+def semantic_search(site, query):
+    """
+
+    Parameters
+    ----------
+    site : mwclient.client.Site
+        Site object from mwclient lib
+    query
+
+    Returns
+    -------
+    page_list : list
+    """
+    page_list = []
+    query += "|limit=1000"
+    result = site.api("ask", query=query, format="json")
+    if len(result["query"]["results"]) == 0:
+        print("Query '{}' returned no results".format(query))
+    else:
+        print(
+            "Query '{}' returned {} results".format(
+                query, len(result["query"]["results"])
+            )
+        )
+        for page in result["query"]["results"].values():
+            # why do we do the following?
+            if "printouts" in page:
+                title = page["fulltext"]
+                if "#" not in title:
+                    print(title)
+                    # original position of "page_list.append(title)" line
+            page_list.append(title)
+    return page_list
+
+
+# Page search wrapper
+def search_wiki_page(title, site):
+    """Adds exact match functionality with ignore-case on top of the prefix_search()'s functionality
+
+    Parameters
+    ----------
+    title : str
+        Title of the wiki page, e. g. User:Someone1234
+    site : mwclient.client.Site
+        Site object from mwclient lib
+
+    Returns
+    -------
+    result_dict : dict
+    """
+    result = site.api(
+        "query", list="prefixsearch", pssearch=title, pslimit=1000, format="json"
+    )
+    if len(result["query"]["prefixsearch"]) == 0:
+        return {"Result": False, "List": list()}
+    else:
+        exact_match = False
+        page_title_list = list()
+        for page in result["query"]["prefixsearch"]:
+            page_title = page["title"]
+            page_title_list.append(page_title)
+            if page_title.lower() == title.lower():
+                exact_match = True
+        result_dict = {
+            "Result": True,
+            "List": page_title_list,
+            "Exact match": exact_match,
+        }
+        return result_dict
+
+
+def search_redirection_sources(site, target_title, debug=False):
+    """Returns a list of pages redirecting to the page with target_title per #REDIRECT [[target]] syntax
+
+    Parameters
+    ----------
+    site : mwclient.client.Site
+        Site object from mwclient lib
+    target_title : str
+        Title of the target wiki page
+
+    Returns
+    -------
+    page_list : list of pages redirecting to the page with target_title
+    """
+    page_list = []
+    result = site.api("query", titles=target_title, prop="redirects", format="json")
+    if len(result["query"]["pages"]) == 0:
+        if debug:
+            print("No results")
+    else:
+        for page in result["query"]["pages"]:
+            if "redirects" not in result["query"]["pages"][page]:
+                if debug:
+                    print("No results")
+            else:
+                for redirecting_source in result["query"]["pages"][page]["redirects"]:
+                    title = redirecting_source["title"]
+                    page_list.append(title)
+    return page_list
+
+
+def update_template_within_wikitext(
+    text,
+    template_text,
+    delete=False,
+    remove_empty_lines=False,
+    overwrite_with_empty=False,
+):
+    """Updates the template parameters in an existing wiki <text> with a provided new <template_text>
+
+    Parameters
+    ----------
+    text : str
+        The existing text
+    template_text : str
+        The template text with new data
+    delete : bool
+        If true, params not defined in <template_text> get removed from <text>
+    remove_empty_lines : bool
+        If true, function will cleanup empty lines within the template code created by the underlying mwparserfromhell
+        lib (wanted), but also within the wiki text around it (unwanted)
+    overwrite_with_empty : bool
+        If true, parameters in the existing tempalte will be overwritten even if the parameter value in the
+        template_text is empty
+
+    Returns
+    -------
+    new_text : str
+    """
+    if template_text == text:
+        return template_text
+    if template_text == "":
+        return text
+    if text == "":
+        return template_text
+    new_code = mwparserfromhell.parse(template_text)
+    new_template = new_code.filter_templates()[0]
+    existing_code = mwparserfromhell.parse(text)
+    template_names_matched = False
+    for template in existing_code.filter_templates(recursive=True):
+        if template.name.matches(new_template.name):
+            existing_template = template
+            template_names_matched = True
+            break
+    if template_names_matched:
+        for p in new_template.params:
+            if existing_template.has(p.name):
+                if p.value != "" or overwrite_with_empty:
+                    existing_template.get(p.name).value = p.value
+            else:
+                existing_template.add(p.name, p.value)
+        if delete:
+            for p in existing_template.params:
+                if not new_template.has(p.name):
+                    existing_template.remove(p)
+    else:  # the original text did not contain a matching template
+        # options: 1) include the new template
+        # 2) replace the existing template - but what if multiple tempaltes exist on that page?
+        pass
+    # print("Tmerged: \n" + str(existing_template))
+    # print("Text merged: " + str(existing_code))
+    new_text = str(existing_code)
+    # this will cleanup empty lines within the template code (wanted), but also within the wiki text around it (unwanted)
+    if remove_empty_lines:
+        new_text = "\n".join(
+            [ll.rstrip() for ll in str(new_text).splitlines() if ll.strip()]
+        )
+    return new_text
+
+
+def merge_wiki_page_text(
+    text1, text2, template_name, subtemplate_param="", subtemplate_name=""
+):
+    """Not fully tested function!
+
+    Parameters
+    ----------
+    text1
+    text2
+    template_name
+    subtemplate_param
+    subtemplate_name
+
+    Returns
+    -------
+    non_empty_lines : str
+    """
+
+    if text1 == text2:
+        return text1
+    if text1 == "":
+        return text2
+    if text2 == "":
+        return text1
+    code1 = mwparserfromhell.parse(text1)
+    code2 = mwparserfromhell.parse(text2)
+    r1 = []
+    r2 = []
+    for template in code1.filter_templates(recursive=True):
+        if template.name.matches(template_name):
+            t1 = template
+        if template.name.matches(subtemplate_name):
+            r1.append(template)
+    for template in code2.filter_templates(recursive=True):
+        if template.name.matches(template_name):
+            t2 = template
+        if template.name.matches(subtemplate_name):
+            r2.append(template)
+    # print("T1: " + str(t1))
+    # print("T2: " + str(t2))
+    for p in t2.params:
+        if not t1.has(p.name):
+            t1.add(p.name, p.value)
+    if subtemplate_param != "":
+        t1.remove(subtemplate_param)
+        for rel2 in r2:
+            exists = False
+            for rel1 in r1:
+                all_params_equal = True
+                for p in rel2.params:
+                    if rel1.has(p.name):
+                        if not rel1.get(p.name).value.matches(rel2.get(p.name).value):
+                            all_params_equal = False
+                        # print("Compare {}:{} with :{} -> res={}".format(p.name, rel1.get(p.name).value,rel2.get(p.name).value,
+                        #                                                rel1.get(p.name).value.matches(rel2.get(p.name).value)))
+                    else:
+                        all_params_equal = False
+                if all_params_equal:
+                    exists = True
+            if not exists:
+                r1.append(rel2)
+        r1string = ""
+        for rel1 in r1:
+            r1string += "\r\n   " + str(rel1)
+        t1.add(subtemplate_param, r1string)
+    # print("Tmerged: " + str(t1))
+    non_empty_lines = "\n".join(
+        [ll.rstrip() for ll in str(t1).splitlines() if ll.strip()]
+    )
+    return non_empty_lines
+
+
+def edit_wiki_page_with_content_merge(title, new_content, site, template_name):
+    """Edits an existing wiki page, while merging the passed new content with the content of the existing page
+
+    Parameters
+    ----------
+    title : str
+        Title of the wiki page, e. g. User:Someone1234
+    new_content : str
+    site : mwclient.client.Site
+        Site object from mwclient lib
+    template_name : str
+
+    Returns
+    -------
+    success : bool
+    """
+    search_result = search_wiki_page(title, site)
+    if search_result["Result"]:
+        source_page = site.pages[title]
+        source_page_content = source_page.text()
+        # todo: test function
+        new_content = merge_wiki_page_text(
+            new_content, source_page_content, template_name=template_name
+        )
+        target_page = site.pages[title]
+        target_page.edit(new_content, "[bot] update of page content")
+        success = True
+    else:
+        success = False
+    return success
+
+
+def create_flat_content_structure_from_wikitext(text: str, array_mode: str = "force"):
+    """Create a flat python dict (aka 'flat_content_structure' = 'wikiJson') representing the content of the page
+
+    Parameters
+    ----------
+    text : str
+        the wiki source text
+    array_mode : str
+        defines how to parse template params
+        array_mode / param     value   value;  value;value     comment
+        'force':               array   array   array           always create an array
+        'only_multiple':       literal literal array           create only when more than one value is given
+        'delimiter_present':   literal array   array           create array if at least one separator char is present
+
+    Returns
+    -------
+    res : dict
+        (aka 'flat_content_structure' = 'wikiJson')
+    """
+
+    res = []
+    text = text.strip()
+    existing_code = mwparserfromhell.parse(text)
+    t_count = 0
+    for i in range(0, len(existing_code.nodes)):
+        n = existing_code.nodes.pop(
+            0
+        )  # returns first layer of nodes. filter() returns also template args
+        if type(n) is mwparserfromhell.nodes.template.Template:
+            t_count += 1
+            wt = {}
+            # print(f"Template: {t.name} = {t}")
+            wt[str(n.name).strip()] = {}
+            for p in n.params:
+                # print(f"  Param: {p.name} = {p.value} ({type(p.value)})")
+                wt[str(n.name).strip()][
+                    str(p.name)
+                ] = create_flat_content_structure_from_wikitext(
+                    str(p.value), array_mode
+                )
+            res.append(wt)
+        else:
+            if len(res) == 0 or type(res[-1]) is dict:
+                res.append("")
+            res[-1] = res[-1] + str(n)  # append to previos string if exists
+    # for i, x in enumerate(res):
+    #    if type(x) is str: res[i] = x.strip() #remove whitespace
+    res = [x for x in res if x and not (type(x) is str and x.isspace())]
+    if t_count == 0:
+        res = text
+        values = str(text).strip().split(";")
+        if array_mode == "force":
+            res = values
+        elif array_mode == "only_multiple" and len(values) > 1:
+            res = values
+        elif array_mode == "separator_present" and ";" in text:
+            res = values
+    return res
+
+
+def get_wikitext_from_flat_content_dict(d: dict):
+    """Create wiki source text from a flat python dict representing a wiki template
+
+    Parameters
+    ----------
+    d : dict
+        flat python dict
+        e.g.: {"HeaderTemplate": {"param": "value"}}
+
+    Returns
+    -------
+    wt : str
+        wiki text
+    """
+    wt = ""
+    for key, value in d.items():
+        # print("key: {}, valuetype: {}, value: {}".format(key, type(value), ""))
+        if isinstance(value, dict):
+            # print("dict")
+            wt += "{{" + key
+            wt += get_wikitext_from_flat_content_dict(value)
+            wt += "\n}}"
+        elif isinstance(value, list):
+            # print("list")
+            wt += "\n|{}=".format(key)
+            string_index = 0
+            for index, element in enumerate(value):
+                if isinstance(element, dict):
+                    wt += get_wikitext_from_flat_content_dict(element)
+                    # wt += "\n{{" + element
+                    # wt += get_wikitext_from_dict(element)
+                    # wt += "\n}}"
+                else:
+                    if string_index != index:
+                        print(
+                            f"Warning: template param '{key}' has mixed template/string values: {value}"
+                        )
+                    if string_index > 0 and element and not element.strip().isspace():
+                        wt += ";"
+                    wt += element
+                    string_index += 1
+        else:
+            # print("literal")
+            wt += "\n|{}={}".format(key, value)
+    return wt
+
+
+def get_wikitext_from_flat_content_structure(content):
+    """Create wiki source text from the content list (aka 'flat_content_structure' = 'wikiJson') of the page
+
+    Parameters
+    ----------
+    content : list
+        content list, mixed objects (templates) and free text (aka 'flat_content_structure' = 'wikiJson')
+        e.g.: [{"HeaderTemplate": {"param": "value"}}, "freetext", {"FooterTemplate": {"param2": "value"}}]
+
+    Returns
+    -------
+    wt : wiki text
+    """
+    wt = ""
+    for content_element in content:
+        if isinstance(content_element, dict):
+            wt += get_wikitext_from_flat_content_dict(content_element)
+        elif isinstance(content_element, str):
+            wt += content_element  # "\n" + content_element
+        else:
+            print(
+                "Error: content element is not dict or string: {}".format(
+                    content_element
+                )
+            )
+    return wt
+
+
+def wikiJson2SchemaJson(schema, wikiJson):
+    """Create osl schema-compatible json from the content representation of a page (aka 'flat_content_structure' = 'wikiJson')
+
+    Parameters
+    ----------
+    wikiJson : list
+        content list, mixed objects (templates) and free text (aka 'flat_content_structure' = 'wikiJson')
+        e.g.: [{"HeaderTemplate": {"param": "value"}}, "freetext", {"FooterTemplate": {"param2": "value"}}]
+
+    Returns
+    -------
+    schemaJson : dict
+        schema-compatible json
+        e.g.: {osl_template: "HeaderTemplate", "param": "value", "osl_wikitext": "freetext", "osl_footer": {"osl_template": "FooterTemplate", "param2": "value2"}}
+    """
+    schemaJson = {}
+    if (
+        not isinstance(wikiJson[0], dict)
+        or not isinstance(wikiJson[1], str)
+        or not isinstance(wikiJson[2], dict)
+    ):
+        print("Error: Invalid wikiJson:", wikiJson)
+        return schemaJson
+
+    schemaJson = wikiJson2SchemaJsonRecursion(schema, wikiJson[0], wikiJson[2])
+    schemaJson["osl_wikitext"] = wikiJson[1]
+    return schemaJson
+
+
+def wikiJson2SchemaJsonRecursion(schema: dict, wikiJson: dict, footerWikiJson=None):
+    """internal recursion function of wikiJson2SchemaJson()
+
+    Parameters
+    ----------
+    wikiJson : list
+        wiki template representation
+        e.g.: {"HeaderTemplate": {"param": "value"}}
+
+    footerWikiJson : list
+        wiki footer template representation
+        e.g.: {"FooterTemplate": {"param2": "value2"}}
+
+    Returns
+    -------
+    schemaJson : dict
+        schema-compatible json
+        e.g.: {osl_template: "HeaderTemplate", "param": "value", "osl_footer": {"osl_template": "FooterTemplate", "param2": "value2"}}
+    """
+    schemaJson = {}
+    if footerWikiJson is not None:
+        schemaJson["osl_footer"] = wikiJson2SchemaJsonRecursion(schema, footerWikiJson)
+
+    for key in wikiJson:
+        value = wikiJson[key]
+        if isinstance(value, list):
+            schemaJson[key] = []
+            for index, element in enumerate(value):
+                element = value[index]
+                if isinstance(element, dict):
+                    if key == "extensions":
+                        if (
+                            footerWikiJson is not None
+                        ):  # we asume that every extension provides also a footer template
+                            nextFooter = footerWikiJson[
+                                schemaJson["osl_footer"]["osl_template"]
+                            ]["extensions"][index]
+                            schemaJson[key].append(
+                                wikiJson2SchemaJsonRecursion(
+                                    schema, element, nextFooter
+                                )
+                            )
+                    else:
+                        schemaJson[key].append(
+                            wikiJson2SchemaJsonRecursion(schema, element)
+                        )
+                else:
+                    schemaJson[key].append(element)
+
+        elif isinstance(value, dict):
+            schemaJson = wikiJson2SchemaJsonRecursion(schema, value, footerWikiJson)
+            schemaJson["osl_template"] = key
+        else:
+            schemaJson[key] = value
+
+    if "osl_template" in schemaJson:
+        jsonpath_expr = parse(
+            "$..properties"
+        )  # [?osl_template.default = "' + schemaJson['osl_template'] + '"]')
+        schema_def = {}
+        # print("Search for ", schemaJson['osl_template'])
+        for match in jsonpath_expr.find(schema):
+            value = match.value
+            if (
+                "osl_template" in value
+                and "osl_template" in schemaJson
+                and value["osl_template"]["default"] == schemaJson["osl_template"]
+                or "osl_schema" in value
+                and "osl_schema" in schemaJson
+                and value["osl_schema"]["default"] == schemaJson["osl_schema"]
+            ):
+                schema_def = match.value  # ToDo: Resolve allOf...
+                # print(schema_def)
+
+        for key in list(schemaJson.keys()):
+            if key in schema_def:
+                if "type" in schema_def[key]:  # use schema to resolve types
+                    if schema_def[key]["type"] == "integer":
+                        schemaJson[key] = int(schemaJson[key])
+                    elif schema_def[key]["type"] == "float":
+                        schemaJson[key] = float(schemaJson[key])
+                    elif schema_def[key]["type"] == "number":
+                        schemaJson[key] = float(schemaJson[key])
+                    elif schema_def[key]["type"] == "string":
+                        schemaJson[key] = str(schemaJson[key])
+                    elif schema_def[key]["type"] == "array":
+                        if not isinstance(schemaJson[key], list):
+                            del schemaJson[key]
+                        elif len(schemaJson[key]) == 0:
+                            del schemaJson[key]
+                else:  # assume type==object
+                    if isinstance(schemaJson[key], list) and len(schemaJson[key]) == 1:
+                        schemaJson[key] = schemaJson[key][0]
+            else:  # fall back
+                if schemaJson[key] == "" and key == "extensions":
+                    del schemaJson[key]
+                elif isinstance(
+                    schemaJson[key], list
+                ):  # wikiJson defaults are lists, even for single or empty values
+                    if len(schemaJson[key]) == 0:
+                        del schemaJson[key]
+                    # elif len(schemaJson[key]) == 1: schemaJson[key] = schemaJson[key][0]
+
+    return schemaJson
+
+
+def schemaJson2WikiJson(schemaJson, isRoot=True):
+    """Create content representation of a page (aka 'flat_content_structure' = 'wikiJson')  from the osl schema-compatible json
+
+    Parameters
+    ----------
+    schemaJson : dict
+        schema-compatible json
+        e.g.: {osl_template: "HeaderTemplate", "param": "value", "osl_wikitext": "freetext", "osl_footer": {"osl_template": "FooterTemplate", "param2": "value2"}}
+
+    isRoot: boolean
+        indicates first call in recursion
+
+    Returns
+    -------
+    wikiJson : list
+        content list, mixed objects (templates) and free text (aka 'flat_content_structure' = 'wikiJson')
+        e.g.: [{"HeaderTemplate": {"param": "value"}}, "freetext", {"FooterTemplate": {"param2": "value"}}]
+    """
+    wikiJson = [{}, "", {}]
+    # header, freetext, footer
+    template = ""
+    footer_template = ""
+    if "osl_template" in schemaJson:
+        template = schemaJson["osl_template"]
+        wikiJson[0][template] = {}
+    else:
+        print(
+            "Error: Mandatory property 'osl_template' not found in schemaJson",
+            schemaJson,
+        )
+        return
+
+    if "osl_wikitext" in schemaJson:
+        wikiJson[1] = schemaJson["osl_wikitext"]
+    if "osl_footer" in schemaJson:
+        wikiJson[2] = schemaJson2WikiJson(schemaJson["osl_footer"], False)[0]
+        footer_template = schemaJson["osl_footer"]["osl_template"]
+        wikiJson[2][footer_template]["extensions"] = []
+
+    for key in schemaJson:
+        if (
+            key.startswith("_")
+            or key.startswith("osl_template")
+            or key.startswith("osl_wikitext")
+            or key.startswith("osl_footer")
+        ):
+            continue  # exclude private and reserved keywords
+        if schemaJson[key] is None:
+            continue
+        if isinstance(schemaJson[key], list):
+            wikiJson[0][template][key] = []
+            for subSchemaJson in schemaJson[key]:
+                subWikiJson = schemaJson2WikiJson(subSchemaJson, False)
+                wikiJson[0][template][key].append(subWikiJson[0])
+                if key == "extensions":
+                    wikiJson[2][footer_template]["extensions"].append(subWikiJson[2])
+
+        elif isinstance(schemaJson[key], dict):
+            subWikiJson = schemaJson2WikiJson(schemaJson[key], False)
+            wikiJson[0][template][key] = [subWikiJson[0]]
+        else:
+            wikiJson[0][template][key] = schemaJson[key]
+
+    return wikiJson
+
+
+def create_or_overwrite_wiki_page(title, content, site):
+    """Creates a page with the passed title and content. If the page already exists, the prior content is replaced with
+    the passed content.
+
+    Parameters
+    ----------
+    title : str
+        Title of the wiki page, e. g. User:Someone1234
+    content : str
+    site : mwclient.client.Site
+        Site object from mwclient lib
+
+    Returns
+    -------
+    success : bool
+    """
+    target_page = site.pages[title]
+    target_page.edit(content, "[bot] create page")
+    success = True
+    return success
+
+
+def delete_wiki_page(title, site, reason):
+    """Deletes the wiki page with the passed title, if it was found (exact match!), otherwise returns False
+
+    Parameters
+    ----------
+    title : str
+        Title of the wiki page, e. g. User:Someone1234
+    site : mwclient.client.Site
+        Site object from mwclient lib
+    reason : str
+
+    Returns
+    -------
+    success : bool
+    """
+    try:
+        page = site.pages[title]
+        page.delete(reason=reason, watch=False, unwatch=True, oldimage=False)
+        success = True
+    except mwclient.errors.APIError:
+        success = False
+    return success
+
+
+def create_or_update_wiki_page_with_template(
+    title, content, site, overwrite_with_empty=False
+):
+    """Creates a wiki page with a template included in the content. If the page does already exist, the parameters
+    within the template are update
+
+    Parameters
+    ----------
+    title : str
+        Title of the wiki page, e. g. User:Someone1234
+    content : str
+    site : mwclient.client.Site
+        Site object from mwclient lib
+    overwrite_with_empty : bool
+        Decided whether a template parameter's value in an preexisting page is overwritten with an empty value
+
+    Returns
+    -------
+    success : bool
+    """
+    search_result = search_wiki_page(title, site)
+    if search_result["Result"]:
+        existing_page = site.pages[title]
+        existing_text = existing_page.text()
+        # update the page's content (template only)
+        updated_content = update_template_within_wikitext(
+            text=existing_text,
+            template_text=content,
+            overwrite_with_empty=overwrite_with_empty,
+        )
+        success = create_or_overwrite_wiki_page(title, updated_content, site)
+    else:
+        # just create the page
+        success = create_or_overwrite_wiki_page(title, content, site)
+    return success
+
+
+def find_dependencies(wikitext, debug=False):
+    """Finds templates, properties and categories within wikitext
+
+    Parameters
+    ----------
+    wikitext : str
+    debug : bool
+        Whether to print debugging messages
+
+    Returns
+    -------
+    filtered_dependencies : list
+    """
+    dependencies = []
+    code = mwparserfromhell.parse(wikitext)
+    for template in code.filter_templates(recursive=True):
+        if template.name.split(":")[0].isupper():
+            if debug:
+                print("MagicWord: {}".format(template.name))
+        elif template.name[0] == "#":
+            if debug:
+                print("ParserFunction: {}".format(template.name))
+            if "#set:" in template.name or "#declare:" in template.name:
+                if (
+                    "=" in template.name.split(":")[1]
+                ):  # in case of '{{#set:HasIdPostfix={{{id_postfix}}} }}'
+                    property_ = "Property:" + template.name.split(":")[1].split("=")[0]
+                    dependencies.append(property_)
+                    if debug:
+                        print("=> {}".format(property_))
+                for param in template.params:
+                    property_ = "Property:" + param.split("=")[0]
+                    dependencies.append(property_)
+                    if debug:
+                        print("=> {}".format(property_))
+        else:
+            if debug:
+                print("Template: {}".format(template.name))
+            template_name = str(template.name)
+            if ":" not in template.name:
+                template_name = "Template:" + template_name
+            dependencies.append(template_name)
+            if debug:
+                print("=> {}".format(template_name))
+    # for tag in code.filter_tags(recursive=True):
+    #    if (debug): print("Tag: {}".format(tag))
+    for link in code.filter_wikilinks(recursive=True):
+        if "::" in link:
+            if debug:
+                print("Annotation: {}".format(link))
+            property_ = "Property:" + link.split("::")[0].split("[[")[-1]
+            dependencies.append(property_)
+            if debug:
+                print("=> {}".format(property_))
+        if "Category:" in link:
+            if debug:
+                print("Category: {}".format(link))
+            category = link.replace("[[", "").replace("]]", "")
+            dependencies.append(str(category))
+            if debug:
+                print("=> {}".format(category))
+        else:
+            if debug:
+                print("Link: {}".format(link))
+    dependencies = np.unique(dependencies).tolist()  # remove duplicates
+    filtered_dependencies = []  # do not manipulate the iterated object
+    for dependency in dependencies:
+        # ensure no leading or trailing white spaces
+        dependency = dependency.strip()
+        # very frew page titles listed here contain invalid characters "\n"
+        dependency = dependency.replace("\n", "")
+        # see https://www.semantic-mediawiki.org/wiki/Help:Special_properties
+        if "Property:" in dependency and (" " in dependency or "_" in dependency):
+            if debug:
+                print(
+                    "Info: Remove presumptive built-in property {}".format(dependency)
+                )
+        else:
+            filtered_dependencies.append(dependency)
+    return filtered_dependencies
+
+
+def find_dependencies_recursively(title, site, dependencies=[], debug=False):
+    """Finds all included templates, properties and categories within a wiki page
+
+    Parameters
+    ----------
+    title : str
+        Title of the wiki page, e. g. User:Someone1234
+    site : mwclient.client.Site
+        Site object from mwclient lib
+    dependencies
+        List of already found dependent titles
+    debug : bool
+        Whether to print debugging messages
+
+    Returns
+    -------
+    dependencies : list
+    """
+    title = title.replace("{", "").replace("}", "")
+    page = site.pages[title]
+    content = page.text()
+    new_dependencies = find_dependencies(content, debug)
+    for dependency in new_dependencies:
+        if dependency not in dependencies:  # for circular dependencies
+            dependencies.append(dependency)
+            if debug:
+                print("Scan nested {}".format(dependency))
+            find_dependencies_recursively(
+                dependency, site, dependencies=dependencies, debug=debug
+            )
+    return dependencies
+
+
+def extend_page_list_with_recursive_dependencies(page_list, site):
+    """Finds all included templates, properties and categories for a list of wiki pages and appends them to the list
+
+    Parameters
+    ----------
+    page_list : list or iterable
+        List of pages
+    site : mwclient.client.Site
+        Site object from mwclient lib
+
+    Returns
+    -------
+    page_list : list
+    """
+    page_list_iter = copy.deepcopy(page_list)
+    for page in page_list_iter:
+        # we provide the updates page list to prevent a revisit of the pages
+        dependencies = find_dependencies_recursively(
+            page, site, dependencies=page_list, debug=False
+        )
+        page_list.extend(dependencies)
+    page_list = np.unique(page_list)
+    return page_list
+
+
+def copy_wiki_page(title0, title1, site0, site1, overwrite=True):
+    """
+
+    Parameters
+    ----------
+    title0 : str
+        Title of the source page
+    title1 : str
+        Title of the target page
+    site0 : mwclient.client.Site
+        Source site object from mwclient lib
+    site1 : mwclient.client.Site
+        Target site object from mwclient lib
+    overwrite : bool
+        Whether to overwrite existing pages at target site
+
+    Returns
+    -------
+    success: bool
+
+    """
+    if title0.lower() == title1.lower() and site0 == site1:
+        # copy on it self = no action necessary
+        success = True
+    else:
+        page0 = site0.pages[title0]
+        content = page0.text()
+        if overwrite:
+            success = create_or_overwrite_wiki_page(title1, content, site1)
+        else:
+            search_result = search_wiki_page(title1, site1)
+            if (
+                search_result["Result"] and search_result["Exact match"]
+            ):  # page already exists
+                success = False
+            else:  # search_result["Result"] == True/False, search_result["Exact match"] == False
+                success = create_or_overwrite_wiki_page(title1, content, site1)
+    return success
+
+
+def copy_list_of_wiki_pages(title_list, site0, site1, overwrite, callback=None):
+    """
+
+    Parameters
+    ----------
+    title_list : list
+    site0 : mwclient.client.Site
+        Source site object from mwclient lib
+    site1 : mwclient.client.Site
+        Target site object from mwclient lib
+    overwrite : bool
+        Whether to overwrite existing pages at target site
+    callback : NoneType or function
+        Function passed over, to perform operation on the titles of the source pages and to be passed as title of the
+        target pages. See examples below.
+        Example functions:
+            capitalize = lambda x: x.capitalize()
+            def change_namespace(title, namespace):
+                if ":" in namespace:
+                    namespace = namespace.split(":")[0]
+                if ":" in title:
+                    splits = title.split(":")
+                    old_name = splits[1].capitalize()
+                    new_title = namespace + ":" + old_name
+                else:
+                    new_title = namespace + ":" + title.capitalize()
+                return new_title
+        Examples of passing a function as parameter:
+            callback = capitalize
+            callback = lambda x: x.lower()
+
+    Returns
+    -------
+    results_dict : dict
+        Dictionary, containing the results of the copying operations
+    """
+
+    success_list = list()
+    fail_list = list()
+    for title0 in title_list:
+        if callback is None:
+            title1 = title0
+        else:
+            title1 = callback(title0)
+        success = copy_wiki_page(title0, title1, site0, site1, overwrite)
+        if success:
+            success_list.append(title1)
+        else:
+            fail_list.append(title1)
+    results_dict = {
+        "Successfully copied pages": success_list,
+        "Pages failed to copy": fail_list,
+    }
+    return results_dict
diff --git a/src/osw/wtsite.py b/src/osw/wtsite.py
new file mode 100644
index 00000000..501a5a93
--- /dev/null
+++ b/src/osw/wtsite.py
@@ -0,0 +1,249 @@
+# extents mwclient.site
+
+import json
+from datetime import datetime
+from pprint import pprint
+
+import mwclient
+from jsonpath_ng.ext import parse
+
+import osw.wiki_tools as wt
+
+
+class WtSite:
+    def __init__(self, site: mwclient.Site = None):
+        if site:
+            self._site = site
+        else:
+            raise ValueError("Parameter 'site' is None")
+        self._page_cache = {}
+        self._cache_enabled = False
+
+    @classmethod
+    def from_domain(cls, domain: str = None, password_file: str = None):
+        site = wt.create_site_object(domain, password_file)
+        return cls(site)
+
+    def get_WtPage(self, title: str = None):
+        if self._cache_enabled and title in self._page_cache:
+            return self._page_cache[title]
+        else:
+            wtpage = WtPage(self, title)
+            if self._cache_enabled:
+                self._page_cache[title] = wtpage
+        return wtpage
+
+    def enable_cache(self):
+        self._cache_enabled = True
+
+    def disable_cache(self):
+        self._cache_enabled = False
+
+    def get_cache_enabled(self):
+        return self._cache_enabled
+
+    def clear_cache(self):
+        del self._page_cache
+        self._page_cache = {}
+
+    def prefix_search(self, text):
+        return wt.prefix_search(self._site, text)
+
+    def semantic_search(self, query):
+        return wt.semantic_search(self._site, query)
+
+    def modify_search_results(
+        self,
+        mode: str,
+        query: str,
+        modify_page,
+        limit=None,
+        comment=None,
+        log=False,
+        dryrun=False,
+    ):
+        titles = []
+        if mode == "prefix":
+            titles = wt.prefix_search(self._site, query)
+        elif mode == "semantic":
+            titles = wt.semantic_search(self._site, query)
+        if limit:
+            titles = titles[0:limit]
+        if log:
+            print(f"Found: {titles}")
+        for title in titles:
+            wtpage = self.get_WtPage(title)
+            modify_page(wtpage)
+            if log:
+                print(f"\n======= {title} =======")
+                print(wtpage._content)
+                for slot in wtpage._slots:
+                    content = wtpage.get_slot_content(slot)
+                    # if isinstance(content, dict): content = json.dumps(content)
+                    print(f"   ==== {title}:{slot} ====   ")
+                    pprint(content)
+                    print("\n")
+            if not dryrun:
+                wtpage.edit(comment)
+
+
+class WtPage:
+    def __init__(self, wtSite: WtSite = None, title: str = None):
+        self.wtSite = wtSite
+        self.title = title
+
+        self._page = wtSite._site.pages[self.title]
+        self.exists = self._page.exists
+        self._original_content = ""
+        self._content = ""
+        self.changed = False
+        self._dict = []
+        self._slots = {"main": ""}
+        self._slots_changed = {"main": False}
+        self._content_model = {"main": "wikitext"}
+
+        if self.exists:
+            self._original_content = self._page.text()
+            self._content = self._original_content
+            self._dict = wt.create_flat_content_structure_from_wikitext(
+                self._content, array_mode="only_multiple"
+            )
+            # multi content revisions
+            rev = wtSite._site.api(
+                "query",
+                prop="revisions",
+                titles=title,
+                rvprop="ids|timestamp|flags|comment|user|content|contentmodel|roles|slotsize",
+                rvslots="*",
+                rvlimit="1",
+                format="json",
+            )
+            for page_id in rev["query"]["pages"]:
+                page = rev["query"]["pages"][page_id]
+                if page["title"] == title:
+                    for revision in page["revisions"]:
+                        self._current_revision = revision
+                        for slot_key in revision["slots"]:
+                            self._slots[slot_key] = revision["slots"][slot_key]["*"]
+                            self._content_model[slot_key] = revision["slots"][slot_key][
+                                "contentmodel"
+                            ]
+                            self._slots_changed[slot_key] = False
+                            if self._content_model[slot_key] == "json":
+                                self._slots[slot_key] = json.loads(
+                                    self._slots[slot_key]
+                                )
+
+    def create_slot(self, slot_key, content_model):
+        self._slots[slot_key] = None
+        self._slots_changed[slot_key] = False
+        self._content_model[slot_key] = content_model
+
+    def get_content(self):
+        return self._content
+
+    def get_slot_content(self, slot_key):
+        if slot_key not in self._slots:
+            return None
+        return self._slots[slot_key]
+
+    def set_content(self, content):
+        self._content = content
+        self.changed = True
+
+    def set_slot_content(self, slot_key, content):
+        if slot_key not in self._slots:
+            content_model = "json"
+            if type(content) == str:
+                content_model = "wikitext"
+            self.create_slot(slot_key, content_model)
+        if content != self._slots[slot_key]:
+            self._slots_changed[slot_key] = True
+        self._slots[slot_key] = content
+
+    def get_url(self) -> str:
+        return "https://" + self.wtSite._site.host + "/wiki/" + self.title
+
+    def append_template(self, template_name: str = None, template_params: dict = None):
+        self._dict.append({template_name: template_params})
+        return self
+
+    def append_text(self, text):
+        self._dict.append(text)
+        return self
+
+    def get_value(self, jsonpath):
+        jsonpath_expr = parse(jsonpath)
+        res = []
+        d = dict(
+            zip(range(len(self._dict)), self._dict)
+        )  # convert list to dict with index
+        for match in jsonpath_expr.find(d):
+            res.append(match.value)
+        return res
+
+    def update_dict(combined: dict, update: dict) -> None:
+        for k, v in update.items():
+            if isinstance(v, dict):
+                WtPage.combine_into(v, combined.setdefault(k, {}))
+            else:
+                combined[k] = v
+
+    def set_value(self, jsonpath_match, value, replace=False):
+        jsonpath_expr = parse(jsonpath_match)
+        d = dict(
+            zip(range(len(self._dict)), self._dict)
+        )  # convert list to dict with index
+        # if create: jsonpath_expr.update_or_create(d, value)
+        # else: jsonpath_expr.update(d, value)
+        matches = jsonpath_expr.find(d)
+        for match in matches:
+            print(match.full_path)
+            # pprint(value)
+            if not replace:
+                WtPage.update_dict(match.value, value)
+                value = match.value
+            # pprint(value)
+            match.full_path.update_or_create(d, value)
+        self._dict = list(d.values())  # convert dict with index to list
+        return self
+
+    def update_content(self):
+        self._content = wt.get_wikitext_from_flat_content_structure(self._dict)
+        self.changed = self._original_content != self._content
+        return self
+
+    def edit(self, comment: str = None):
+        if not comment:
+            comment = "[bot] update of page content"
+        if self.changed:
+            self._page.edit(self._content, comment)
+        for slot_key in self._slots:
+            if self._slots_changed[slot_key]:
+                content = self._slots[slot_key]
+                if self._content_model[slot_key] == "json":
+                    content = json.dumps(content)
+                self.wtSite._site.api(
+                    "editslot",
+                    token=self.wtSite._site.get_token("csrf"),
+                    title=self.title,
+                    slot=slot_key,
+                    text=content,
+                    summary=comment,
+                )
+
+    def delete(self, comment: str = None):
+        self._page.delete(comment)
+
+    def move(self, new_title: str, comment: str = None, redirect=True):
+        if new_title != self.title:
+            print(f"move '{self.title}' to '{new_title}'")
+            self._page.move(
+                new_title=new_title, reason=comment, no_redirect=not redirect
+            )
+            self.title = new_title
+
+    def get_last_changed_time(self):
+        return datetime.fromisoformat(
+            self._current_revision["timestamp"].replace("Z", "+00:00")
+        )
diff --git a/src/sparql_client_smw.py b/src/sparql_client_smw.py
deleted file mode 100644
index 198375b0..00000000
--- a/src/sparql_client_smw.py
+++ /dev/null
@@ -1,133 +0,0 @@
-#from quantities
-from SPARQLWrapper import SPARQLWrapper, POST, BASIC, JSON
-import json
-from pprint import pprint
-import getpass
-
-class SmwSparqlClient:
-    
-    def __init__(self, endpoint, domain, auth='none', user='', password=''):
-        
-        self.create_sparql_client(endpoint, auth, user, password)
-        
-        self.prefix_dict = {
-            'dc': "http://purl.org/dc/elements/1.1/",
-            'property': "https://{domain}/id/Property-3A",
-            'file': "https://{domain}/id/File-3A",
-            'category': "https://{domain}/id/Category-3A",
-            'material': "https://{domain}/id/Material-3A",
-            'wiki': "https://{domain}/id/",
-            'rdf': "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
-            'rdfs': "http://www.w3.org/2000/01/rdf-schema#",
-            'owl': "http://www.w3.org/2002/07/owl#",
-            'swivt': "http://semantic-mediawiki.org/swivt/1.0#",
-            'xsd': "http://www.w3.org/2001/XMLSchema#"  
-        }
-        self.wiki_prefixes = []
-        self.prefixes = ""
-        for prefix in self.prefix_dict:
-            if "{domain}" in prefix: self.wiki_prefixes.append[prefix]
-            self.prefix_dict[prefix] = self.prefix_dict[prefix].replace("{domain}", domain)
-            self.prefixes += f"PREFIX {prefix}: <{self.prefix_dict[prefix]}>\n".replace("{domain}", domain)
-            
-        #from https://github.com/SemanticMediaWiki/SemanticMediaWiki/blob/e04b78ddbc0a1b2181b12f31a51d7f91e723336b/src/Exporter/Escaper.php#L61
-        self.smw_encoding_lists = {
-            'in': [ '*', ',' , ';', '<', '>', '(', ')', '[', ']', '{', '}', '\\', '$', '^', ':', '"', '#', '&', "'", '+', '!', '%' ],
-            'out': [ '-2A', '-2C', '-3B', '-3C', '-3E', '-28', '-29', '-5B', '-5D', '-7B', '-7D', '-5C', '-24', '-5E', '-3A', '-22', '-23', '-26', '-27', '-2B', '-21', '-' ]
-        };
-        self.encoding_dict = dict(zip(self.smw_encoding_lists['in'], self.smw_encoding_lists['out']))
-        self.encoding_dict = {}
-        self.encoding_dict_inv = dict((self.encoding_dict[k], k) for k in self.encoding_dict)
-    
-    def create_sparql_client(self, endpoint, auth='none', user='', password=''):
-        self.sparql = SPARQLWrapper(endpoint)
-        if (auth == 'basic'):
-            self.sparql.setHTTPAuth(BASIC)
-            if user == '': user = input("Enter username")
-            if password == '': password = getpass.getpass("Enter password")
-            self.sparql.setCredentials(user, password)
-        self.sparql.setMethod(POST)
-
-    def sparqlQuery(self, query):
-        #print(query)
-        self.sparql.setQuery(query)
-        self.sparql.setReturnFormat(JSON)
-        #print(self.sparql.query())
-        return self.sparql.query().convert()
-
-    def get_sparql_triplets(self, where_statement, select_statement = "", inner_where_statement="?subject ?predicate ?object", tsubject="", tpredicate="", tobject="",limit=999999999, prefixes="", debug=False):
-        select_statement = ""
-        if (tsubject == ""): select_statement += " ?subject"
-        if (tpredicate == ""): select_statement += " ?predicate"
-        if (tobject == ""): select_statement += " ?object"
-        if (tsubject != ""): inner_where_statement = inner_where_statement.replace("?subject", "<" + tsubject + ">")
-        if (tpredicate != ""): inner_where_statement = inner_where_statement.replace("?predicate", "<" + tpredicate + ">")
-        if (tobject != ""): inner_where_statement = inner_where_statement.replace("?object", "<" + tobject + ">")
-        if prefixes == "": prefixes = self.prefixes
-        query = """
-            {{{prefixes}}}
-            SELECT {{{select_statement}}}
-            WHERE {
-              {{{where_statement}}}
-                {  
-                SELECT {{{select_statement}}}
-                WHERE {
-                    {{{inner_where_statement}}}
-                }
-              }
-            }
-            LIMIT {{{limit}}}
-        """.replace("{{{select_statement}}}", select_statement).replace("{{{where_statement}}}", where_statement).replace("{{{inner_where_statement}}}", inner_where_statement).replace("{{{limit}}}", str(limit)).replace("{{{prefixes}}}", prefixes)
-        for key in self.encoding_dict.keys():
-            query = query.replace(key, self.encoding_dict[key])
-        if (debug): print(query)
-        #select distinct ?p ?o where {<http://dbpedia.org/resource/Amount_of_substance> ?p ?o} LIMIT 100
-        triples = self.sparqlQuery(query)
-        #print(triples)
-        tdict = {}
-        if (debug): print("{} triplets found".format(len(triples['results']['bindings'])))
-        for t in triples['results']['bindings']:
-            if (tsubject == ""): s = t['subject']['value']
-            else: s = tsubject
-            if (tpredicate == ""): p = t['predicate']['value']
-            else: p = tpredicate
-            if (tobject == ""): o = t['object']['value']
-            else: o = tobject
-            if 'xml:lang' in t['object']: o += "@" + t['object']['xml:lang']
-            #print("{} {} {}".format(s,p,o))
-            for prefix in self.prefix_dict:
-                s = s.replace(self.prefix_dict[prefix], prefix + ":")
-                p = p.replace(self.prefix_dict[prefix], prefix + ":")
-                o = o.replace(self.prefix_dict[prefix], prefix + ":")
-            for key in self.encoding_dict_inv.keys(): 
-                s = s.replace(key, self.encoding_dict_inv[key])
-                p = p.replace(key, self.encoding_dict_inv[key])
-                o = o.replace(key, self.encoding_dict_inv[key])
-            self.dict_append_tripl(tdict, s, p, o)
-        #print(tdict)
-        return tdict
-
-    def spo_result_to_dict(self, triples, tsubject="", tpredicate="", tobject=""):
-        tdict = {}
-        for t in triples['results']['bindings']:
-            if (tsubject == ""): s = t['subject']['value']
-            else: s = tsubject
-            if (tpredicate == ""): p = t['predicate']['value']
-            else: p = tpredicate
-            if (tobject == ""): o = t['object']['value']
-            else: o = tobject
-            if 'xml:lang' in t['object']: o += "@" + t['object']['xml:lang']
-            #print("{} {} {}".format(s,p,o))
-            for prefix in self.prefix_dict:
-                s = s.replace(self.prefix_dict[prefix], prefix + ":")
-                p = p.replace(self.prefix_dict[prefix], prefix + ":")
-                o = o.replace(self.prefix_dict[prefix], prefix + ":")
-            self.dict_append_tripl(tdict, s, p, o)
-        #print(tdict)
-        return tdict
-
-    def dict_append_tripl(self, d, s, p, o):
-        if not s in d: d[s] = {}
-        if not p in d[s]: d[s][p] = []
-        d[s][p].append(o)
-    
\ No newline at end of file
diff --git a/src/wiki_tools.py b/src/wiki_tools.py
deleted file mode 100644
index a3351604..00000000
--- a/src/wiki_tools.py
+++ /dev/null
@@ -1,653 +0,0 @@
-import mwclient
-import getpass
-import mwparserfromhell
-import numpy as np
-import copy
-
-
-def create_site_object(domain):
-    """
-
-    Parameters
-    ----------
-    domain : str
-
-    Returns
-    -------
-    site : mwclient.client.Site
-        Site object from mwclient lib
-    """
-    domain_dict = {
-        "wiki-dev": {"Address": "wiki-dev.open-semantic-lab.org"}
-    }
-    if domain in domain_dict.keys():
-        domain = domain_dict[domain]["Address"]
-
-    site = mwclient.Site(domain, path='/w/')
-    user = input("Enter bot username (username@botname)")
-    password = getpass.getpass("Enter bot password")
-    site.login(user, password)
-    del user
-    del password
-    return site
-
-
-# Standard Query
-# api.php?action=query&list=prefixsearch&pssearch=Star Wars
-def prefix_search(site, text):
-    """
-
-    Parameters
-    ----------
-    site : mwclient.client.Site
-        Site object from mwclient lib
-    text : str
-
-    Returns
-    -------
-    page_list : list
-    """
-    page_list = []
-    result = site.api('query', list="prefixsearch", pssearch=text, pslimit=1000, format='json')
-    if len(result['query']['prefixsearch']) == 0:
-        print('No results')
-    else:
-        for page in result['query']['prefixsearch']:
-            title = page['title']
-            print(title)
-            page_list.append(title)
-    return page_list
-
-
-# Semantic Query
-def semantic_search(site, query):
-    """
-
-    Parameters
-    ----------
-    site : mwclient.client.Site
-        Site object from mwclient lib
-    query
-
-    Returns
-    -------
-    page_list : list
-    """
-    page_list = []
-    query += "|limit=1000"
-    result = site.api('ask', query=query, format='json')
-    if len(result['query']['results']) == 0:
-        print("Query '{}' returned no results".format(query))
-    else:
-        print("Query '{}' returned {} results".format(query, len(result['query']['results'])))
-        for page in result['query']['results'].values():
-            # why do we do the following?
-            if 'printouts' in page:
-                title = page['fulltext']
-                if '#' not in title:
-                    print(title)
-                    # original position of "page_list.append(title)" line
-            page_list.append(title)
-    return page_list
-
-
-# Page search wrapper
-def search_wiki_page(title, site):
-    """Adds exact match functionality with ignore-case on top of the prefix_search()'s functionality
-
-    Parameters
-    ----------
-    title : str
-        Title of the wiki page, e. g. User:Someone1234
-    site : mwclient.client.Site
-        Site object from mwclient lib
-
-    Returns
-    -------
-    result_dict : dict
-    """
-    result = site.api('query', list="prefixsearch", pssearch=title, pslimit=1000, format='json')
-    if len(result['query']['prefixsearch']) == 0:
-        return {"Result": False, "List": list()}
-    else:
-        exact_match = False
-        page_title_list = list()
-        for page in result['query']['prefixsearch']:
-            page_title = page['title']
-            page_title_list.append(page_title)
-            if page_title.lower() == title.lower():
-                exact_match = True
-        result_dict = {"Result": True, "List": page_title_list, "Exact match": exact_match}
-        return result_dict
-    
-def search_redirection_sources(site, target_title, debug = False):
-    """Returns a list of pages redirecting to the page with target_title per #REDIRECT [[target]] syntax
-
-    Parameters
-    ----------
-    site : mwclient.client.Site
-        Site object from mwclient lib
-    target_title : str
-        Title of the target wiki page
-
-    Returns
-    -------
-    page_list : list of pages redirecting to the page with target_title
-    """
-    page_list = []
-    result = site.api('query', titles=target_title, prop='redirects', format='json')
-    if len(result['query']['pages'])==0:
-        if (debug): print('No results')
-    else:
-        for page in result['query']['pages']:
-            if not 'redirects' in result['query']['pages'][page]: 
-                if (debug): print('No results')
-            else:
-                for redirecting_source in result['query']['pages'][page]['redirects']:
-                    title = redirecting_source['title']
-                    page_list.append(title)
-    return page_list
-
-def update_template_within_wikitext(text, template_text, delete=False, remove_empty_lines=False, 
-                                    overwrite_with_empty=False):
-    """Updates the template parameters in an existing wiki <text> with a provided new <template_text>
-
-    Parameters
-    ----------
-    text : str
-        The existing text
-    template_text : str
-        The template text with new data
-    delete : bool
-        If true, params not defined in <template_text> get removed from <text>
-    remove_empty_lines : bool
-        If true, function will cleanup empty lines within the template code created by the underlying mwparserfromhell
-        lib (wanted), but also within the wiki text around it (unwanted)
-    overwrite_with_empty : bool
-        If true, parameters in the existing tempalte will be overwritten even if the parameter value in the 
-        template_text is empty
-
-    Returns
-    -------
-    new_text : str
-    """
-    if template_text == text: return template_text
-    if template_text == "": return text
-    if text == "": return template_text
-    new_code = mwparserfromhell.parse(template_text)
-    new_template = new_code.filter_templates()[0]
-    existing_code = mwparserfromhell.parse(text)
-    template_names_matched = False 
-    for template in existing_code.filter_templates(recursive=True):
-        if template.name.matches(new_template.name):
-            existing_template = template
-            template_names_matched = True
-            break
-    if template_names_matched:
-        for p in new_template.params:
-            if existing_template.has(p.name):
-                if p.value != "" or overwrite_with_empty:
-                    existing_template.get(p.name).value = p.value
-            else:
-                existing_template.add(p.name, p.value)
-        if delete:
-            for p in existing_template.params:
-                if not new_template.has(p.name):
-                    existing_template.remove(p)
-    else:  # the original text did not contain a matching template
-        # options: 1) include the new template
-        # 2) replace the existing template - but what if multiple tempaltes exist on that page?
-        pass
-    # print("Tmerged: \n" + str(existing_template))
-    # print("Text merged: " + str(existing_code))
-    new_text = str(existing_code)
-    # this will cleanup empty lines within the template code (wanted), but also within the wiki text around it (unwanted)
-    if remove_empty_lines: new_text = "\n".join([ll.rstrip() for ll in str(new_text).splitlines() if ll.strip()])
-    return new_text
-
-
-def merge_wiki_page_text(text1, text2, template_name, subtemplate_param="", subtemplate_name=""):
-    """Not fully tested function!
-
-    Parameters
-    ----------
-    text1
-    text2
-    template_name
-    subtemplate_param
-    subtemplate_name
-
-    Returns
-    -------
-    non_empty_lines : str
-    """
-
-    if text1 == text2: return text1
-    if text1 == "": return text2
-    if text2 == "": return text1
-    code1 = mwparserfromhell.parse(text1)
-    code2 = mwparserfromhell.parse(text2)
-    r1 = []
-    r2 = []
-    for template in code1.filter_templates(recursive=True):
-        if template.name.matches(template_name): t1 = template
-        if template.name.matches(subtemplate_name): r1.append(template)
-    for template in code2.filter_templates(recursive=True):
-        if template.name.matches(template_name): t2 = template
-        if template.name.matches(subtemplate_name): r2.append(template)
-    # print("T1: " + str(t1))
-    # print("T2: " + str(t2))
-    for p in t2.params:
-        if not t1.has(p.name):
-            t1.add(p.name, p.value)
-    if subtemplate_param != "":
-        t1.remove(subtemplate_param)
-        for rel2 in r2:
-            exists = False
-            for rel1 in r1:
-                all_params_equal = True
-                for p in rel2.params:
-                    if rel1.has(p.name):
-                        if not rel1.get(p.name).value.matches(rel2.get(p.name).value): all_params_equal = False
-                        # print("Compare {}:{} with :{} -> res={}".format(p.name, rel1.get(p.name).value,rel2.get(p.name).value,
-                        #                                                rel1.get(p.name).value.matches(rel2.get(p.name).value)))
-                    else:
-                        all_params_equal = False
-                if all_params_equal: exists = True
-            if not exists: r1.append(rel2)
-        r1string = ""
-        for rel1 in r1:
-            r1string += "\r\n   " + str(rel1)
-        t1.add(subtemplate_param, r1string)
-    # print("Tmerged: " + str(t1))
-    non_empty_lines = "\n".join([ll.rstrip() for ll in str(t1).splitlines() if ll.strip()])
-    return non_empty_lines
-
-
-def edit_wiki_page_with_content_merge(title, new_content, site, template_name):
-    """Edits an existing wiki page, while merging the passed new content with the content of the existing page
-
-    Parameters
-    ----------
-    title : str
-        Title of the wiki page, e. g. User:Someone1234
-    new_content : str
-    site : mwclient.client.Site
-        Site object from mwclient lib
-    template_name : str
-
-    Returns
-    -------
-    success : bool
-    """
-    search_result = search_wiki_page(title, site)
-    if search_result["Result"]:
-        source_page = site.pages[title]
-        source_page_content = source_page.text()
-        # todo: test function
-        new_content = merge_wiki_page_text(new_content, source_page_content, template_name=template_name)
-        target_page = site.pages[title]
-        target_page.edit(new_content, '[bot] update of page content')
-        success = True
-    else:
-        success = False
-    return success
-
-def create_flat_content_structure_from_wikitext(text):
-    """Create a flat python dict representing the content of the page
-
-    Parameters
-    ----------
-    text : str
-        the wiki source text
-
-    Returns
-    -------
-    res : dict
-    """
-    res = []
-    existing_code = mwparserfromhell.parse(text)
-    t_count = 0
-    for t in existing_code.filter_templates(recursive=False):
-        t_count += 1
-        wt = {}
-        #print(f"Template: {t.name} = {t}")
-        wt[str(t.name).strip()] = {}
-        for p in t.params:
-            #print(f"  Param: {p.name} = {p.value} ({type(p.value)})")
-            wt[str(t.name).strip()][str(p.name)] = create_flat_content_structure_from_wikitext(str(p.value))
-        res.append(wt)
-    if t_count == 0: res = str(text).strip().split(';')
-    return res
-
-def get_wikitext_from_flat_content_dict(d):
-    """Create wiki source text from a flat python dict representing the content of the page
-
-    Parameters
-    ----------
-    d : dict
-        flat python dict
-
-    Returns
-    -------
-    wt : wiki text
-    """
-    wt = ""
-    for key, value in d.items():
-        #print("key: {}, valuetype: {}, value: {}".format(key, type(value), "")) 
-        if isinstance(value,dict): 
-            #print("dict")
-            wt += "\n{{" + key
-            wt += get_wikitext_from_flat_content_dict(value)
-            wt += "\n}}"
-        elif isinstance(value,list): 
-            #print("list")
-            wt += "\n|{}=".format(key)
-            for index, element in enumerate(value):
-                if isinstance(element,dict): 
-                    wt += get_wikitext_from_flat_content_dict(element)
-                    #wt += "\n{{" + element
-                    #wt += get_wikitext_from_dict(element)
-                    #wt += "\n}}"  
-                else:
-                    if index > 0: wt += ";"
-                    wt += element
-        else: 
-            #print("literal")
-            wt += "\n|{}={}".format(key, value)
-    return wt
-
-def get_wikitext_from_flat_content_structure(content):
-    wt = ""
-    for content_element in content:
-        if isinstance(content_element,dict): 
-            wt += get_wikitext_from_flat_content_dict(content_element)
-        elif isinstance(content_element,str): wt += "\n" + content_element
-        else: print("Error: content element is not dict or string: {}".format(content_element))
-    return wt
-
-def create_or_overwrite_wiki_page(title, content, site):
-    """Creates a page with the passed title and content. If the page already exists, the prior content is replaced with
-    the passed content.
-
-    Parameters
-    ----------
-    title : str
-        Title of the wiki page, e. g. User:Someone1234
-    content : str
-    site : mwclient.client.Site
-        Site object from mwclient lib
-
-    Returns
-    -------
-    success : bool
-    """
-    target_page = site.pages[title]
-    target_page.edit(content, '[bot] create page')
-    success = True
-    return success
-
-
-def delete_wiki_page(title, site, reason):
-    """Deletes the wiki page with the passed title, if it was found (exact match!), otherwise returns False
-
-    Parameters
-    ----------
-    title : str
-        Title of the wiki page, e. g. User:Someone1234
-    site : mwclient.client.Site
-        Site object from mwclient lib
-    reason : str
-
-    Returns
-    -------
-    success : bool
-    """
-    try:
-        page = site.pages[title]
-        page.delete(reason=reason, watch=False, unwatch=True, oldimage=False)
-        success = True
-    except mwclient.errors.APIError:
-        success = False
-    return success
-
-
-def create_or_update_wiki_page_with_template(title, content, site, overwrite_with_empty=False):
-    """Creates a wiki page with a template included in the content. If the page does already exist, the parameters
-    within the template are update
-
-    Parameters
-    ----------
-    title : str
-        Title of the wiki page, e. g. User:Someone1234
-    content : str
-    site : mwclient.client.Site
-        Site object from mwclient lib
-    overwrite_with_empty : bool
-        Decided whether a template parameter's value in an preexisting page is overwritten with an empty value
-
-    Returns
-    -------
-    success : bool
-    """
-    search_result = search_wiki_page(title, site)
-    if search_result["Result"]:
-        existing_page = site.pages[title]
-        existing_text = existing_page.text()
-        # update the page's content (template only)
-        updated_content = update_template_within_wikitext(text=existing_text, template_text=content, overwrite_with_empty=overwrite_with_empty)
-        success = create_or_overwrite_wiki_page(title, updated_content, site)
-    else:
-        # just create the page
-        success = create_or_overwrite_wiki_page(title, content, site)
-    return success
-
-
-def find_dependencies(wikitext, debug=False):
-    """Finds templates, properties and categories within wikitext
-
-    Parameters
-    ----------
-    wikitext : str
-    debug : bool
-        Whether to print debugging messages
-
-    Returns
-    -------
-    filtered_dependencies : list
-    """
-    dependencies = []
-    code = mwparserfromhell.parse(wikitext)
-    for template in code.filter_templates(recursive=True):
-        if template.name.split(':')[0].isupper():
-            if debug: print("MagicWord: {}".format(template.name))
-        elif template.name[0] == "#":
-            if debug: print("ParserFunction: {}".format(template.name))
-            if "#set:" in template.name or "#declare:" in template.name:
-                if '=' in template.name.split(':')[1]:  # in case of '{{#set:HasIdPostfix={{{id_postfix}}} }}'
-                    property_ = "Property:" + template.name.split(':')[1].split('=')[0]
-                    dependencies.append(property_)
-                    if debug: print("=> {}".format(property_))
-                for param in template.params:
-                    property_ = "Property:" + param.split('=')[0]
-                    dependencies.append(property_)
-                    if debug: print("=> {}".format(property_))
-        else:
-            if debug: print("Template: {}".format(template.name))
-            template_name = str(template.name)
-            if ':' not in template.name:
-                template_name = "Template:" + template_name
-            dependencies.append(template_name)
-            if debug: print("=> {}".format(template_name))
-    # for tag in code.filter_tags(recursive=True):
-    #    if (debug): print("Tag: {}".format(tag))
-    for link in code.filter_wikilinks(recursive=True):
-        if '::' in link:
-            if debug: print("Annotation: {}".format(link))
-            property_ = "Property:" + link.split('::')[0].split('[[')[-1]
-            dependencies.append(property_)
-            if debug: print("=> {}".format(property_))
-        if 'Category:' in link:
-            if debug: print("Category: {}".format(link))
-            category = link.replace('[[', '').replace(']]', '')
-            dependencies.append(str(category))
-            if debug: print("=> {}".format(category))
-        else:
-            if debug: print("Link: {}".format(link))
-    dependencies = np.unique(dependencies).tolist()  # remove duplicates
-    filtered_dependencies = []  # do not manipulate the iterated object
-    for dependency in dependencies:
-        # ensure no leading or trailing white spaces 
-        dependency = dependency.strip()
-        # very frew page titles listed here contain invalid characters "\n"
-        dependency = dependency.replace('\n', '')        
-        # see https://www.semantic-mediawiki.org/wiki/Help:Special_properties
-        if 'Property:' in dependency and (' ' in dependency or '_' in dependency):
-            if debug: print("Info: Remove presumptive built-in property {}".format(dependency))
-        else:            
-            filtered_dependencies.append(dependency)
-    return filtered_dependencies
-
-
-def find_dependencies_recursively(title, site, dependencies=[], debug=False):
-    """Finds all included templates, properties and categories within a wiki page
-
-    Parameters
-    ----------    
-    title : str
-        Title of the wiki page, e. g. User:Someone1234
-    site : mwclient.client.Site
-        Site object from mwclient lib    
-    dependencies
-        List of already found dependent titles
-    debug : bool
-        Whether to print debugging messages
-
-    Returns
-    -------
-    dependencies : list
-    """
-    title = title.replace("{", "").replace("}", "")
-    page = site.pages[title]
-    content = page.text()
-    new_dependencies = find_dependencies(content, debug)
-    for dependency in new_dependencies:
-        if dependency not in dependencies:  # for circular dependencies
-            dependencies.append(dependency)
-            if debug: print("Scan nested {}".format(dependency))
-            find_dependencies_recursively(dependency, site, dependencies=dependencies, debug=debug)
-    return dependencies
-
-
-def extend_page_list_with_recursive_dependencies(page_list, site):
-    """Finds all included templates, properties and categories for a list of wiki pages and appends them to the list
-
-    Parameters
-    ----------    
-    page_list : list or iterable
-        List of pages
-    site : mwclient.client.Site
-        Site object from mwclient lib
-
-    Returns
-    -------
-    page_list : list
-    """
-    page_list_iter = copy.deepcopy(page_list)
-    for page in page_list_iter:
-        # we provide the updates page list to prevent a revisit of the pages
-        dependencies = find_dependencies_recursively(page, site, dependencies=page_list, debug=False)
-        page_list.extend(dependencies)
-    page_list = np.unique(page_list)
-    return page_list
-
-
-def copy_wiki_page(title0, title1, site0, site1, overwrite=True):
-    """
-
-    Parameters
-    ----------
-    title0 : str
-        Title of the source page
-    title1 : str
-        Title of the target page
-    site0 : mwclient.client.Site
-        Source site object from mwclient lib
-    site1 : mwclient.client.Site
-        Target site object from mwclient lib
-    overwrite : bool
-        Whether to overwrite existing pages at target site
-
-    Returns
-    -------
-    success: bool
-
-    """
-    if title0.lower() == title1.lower() and site0 == site1:
-        # copy on it self = no action necessary
-        success = True
-    else:
-        page0 = site0.pages[title0]
-        content = page0.text()
-        if overwrite:
-            success = create_or_overwrite_wiki_page(title1, content, site1)
-        else:
-            search_result = search_wiki_page(title1, site1)
-            if search_result["Result"] and search_result["Exact match"]:  # page already exists
-                success = False
-            else:  # search_result["Result"] == True/False, search_result["Exact match"] == False
-                success = create_or_overwrite_wiki_page(title1, content, site1)
-    return success
-
-
-def copy_list_of_wiki_pages(title_list, site0, site1, overwrite, callback=None):
-    """
-
-    Parameters
-    ----------
-    title_list : list
-    site0 : mwclient.client.Site
-        Source site object from mwclient lib
-    site1 : mwclient.client.Site
-        Target site object from mwclient lib
-    overwrite : bool
-        Whether to overwrite existing pages at target site
-    callback : NoneType or function
-        Function passed over, to perform operation on the titles of the source pages and to be passed as title of the
-        target pages. See examples below.
-        Example functions:
-            capitalize = lambda x: x.capitalize()
-            def change_namespace(title, namespace):
-                if ":" in namespace:
-                    namespace = namespace.split(":")[0]
-                if ":" in title:
-                    splits = title.split(":")
-                    old_name = splits[1].capitalize()
-                    new_title = namespace + ":" + old_name
-                else:
-                    new_title = namespace + ":" + title.capitalize()
-                return new_title
-        Examples of passing a function as parameter:
-            callback = capitalize
-            callback = lambda x: x.lower()
-
-    Returns
-    -------
-    results_dict : dict
-        Dictionary, containing the results of the copying operations
-    """
-
-    success_list = list()
-    fail_list = list()
-    for title0 in title_list:
-        if callback is None:
-            title1 = title0
-        else:
-            title1 = callback(title0)
-        success = copy_wiki_page(title0, title1, site0, site1, overwrite)
-        if success:
-            success_list.append(title1)
-        else:
-            fail_list.append(title1)
-    results_dict = {"Successfully copied pages": success_list, "Pages failed to copy": fail_list}
-    return results_dict
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 00000000..8d264321
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,104 @@
+# Tox configuration file
+# Read more under https://tox.wiki/
+# THIS SCRIPT IS SUPPOSED TO BE AN EXAMPLE. MODIFY IT ACCORDING TO YOUR NEEDS!
+
+[tox]
+minversion = 3.24
+envlist = default
+isolated_build = True
+
+
+[testenv]
+description = Invoke pytest to run automated tests
+setenv =
+    TOXINIDIR = {toxinidir}
+passenv =
+    HOME
+    SETUPTOOLS_*
+extras =
+    testing
+commands =
+    pytest {posargs}
+
+
+# # To run `tox -e lint` you need to make sure you have a
+# # `.pre-commit-config.yaml` file. See https://pre-commit.com
+# [testenv:lint]
+# description = Perform static analysis and style checks
+# skip_install = True
+# deps = pre-commit
+# passenv =
+#     HOMEPATH
+#     PROGRAMDATA
+#     SETUPTOOLS_*
+# commands =
+#     pre-commit run --all-files {posargs:--show-diff-on-failure}
+
+
+[testenv:{build,clean}]
+description =
+    build: Build the package in isolation according to PEP517, see https://github.com/pypa/build
+    clean: Remove old distribution files and temporary build artifacts (./build and ./dist)
+# https://setuptools.pypa.io/en/stable/build_meta.html#how-to-use-it
+skip_install = True
+changedir = {toxinidir}
+deps =
+    build: build[virtualenv]
+passenv =
+    SETUPTOOLS_*
+commands =
+    clean: python -c 'import shutil; [shutil.rmtree(p, True) for p in ("build", "dist", "docs/_build")]'
+    clean: python -c 'import pathlib, shutil; [shutil.rmtree(p, True) for p in pathlib.Path("src").glob("*.egg-info")]'
+    build: python -m build {posargs}
+
+
+[testenv:{docs,doctests,linkcheck}]
+description =
+    docs: Invoke sphinx-build to build the docs
+    doctests: Invoke sphinx-build to run doctests
+    linkcheck: Check for broken links in the documentation
+passenv =
+    SETUPTOOLS_*
+setenv =
+    DOCSDIR = {toxinidir}/docs
+    BUILDDIR = {toxinidir}/docs/_build
+    docs: BUILD = html
+    doctests: BUILD = doctest
+    linkcheck: BUILD = linkcheck
+deps =
+    -r {toxinidir}/docs/requirements.txt
+    # ^  requirements.txt shared with Read The Docs
+commands =
+    sphinx-build --color -b {env:BUILD} -d "{env:BUILDDIR}/doctrees" "{env:DOCSDIR}" "{env:BUILDDIR}/{env:BUILD}" {posargs}
+
+
+[testenv:{mkdocs}]
+description =
+    mkdocs -- build: Invoke mkdocs to build the docs
+    mkdocs -- serve: Invoke mkdocs to build the docs
+passenv =
+    SETUPTOOLS_*
+deps =
+    -r {toxinidir}/docs/requirements.txt
+    # ^  requirements.txt shared with Read The Docs
+commands =
+     mkdocs {posargs}
+
+
+[testenv:publish]
+description =
+    Publish the package you have been developing to a package index server.
+    By default, it uses testpypi. If you really want to publish your package
+    to be publicly accessible in PyPI, use the `-- --repository pypi` option.
+skip_install = True
+changedir = {toxinidir}
+passenv =
+    # See: https://twine.readthedocs.io/en/latest/
+    TWINE_USERNAME
+    TWINE_PASSWORD
+    TWINE_REPOSITORY
+    TWINE_REPOSITORY_URL
+deps = twine
+commands =
+    python -m twine check dist/*
+    python -m twine upload {posargs:--repository {env:TWINE_REPOSITORY:testpypi}} dist/*


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..022d6308
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,40 @@
+"""
+    Dummy conftest.py for osw.
+
+    If you don't know what this is for, just leave it empty.
+    Read more about conftest.py under:
+    - https://docs.pytest.org/en/stable/fixture.html
+    - https://docs.pytest.org/en/stable/writing_plugins.html
+"""
+
+import pytest
+
+
+def pytest_addoption(parser):
+    parser.addoption("--wiki_domain", action="store")
+    parser.addoption("--wiki_username", action="store")
+    parser.addoption("--wiki_password", action="store")
+
+
+@pytest.fixture(scope="session")
+def wiki_domain(request):
+    value = request.config.option.wiki_domain
+    if value is None:
+        pytest.skip()
+    return value
+
+
+@pytest.fixture(scope="session")
+def wiki_username(request):
+    value = request.config.option.wiki_username
+    if value is None:
+        pytest.skip()
+    return value
+
+
+@pytest.fixture(scope="session")
+def wiki_password(request):
+    value = request.config.option.wiki_password
+    if value is None:
+        pytest.skip()
+    return value
diff --git a/tests/controller_mixin.py b/tests/controller_mixin.py
new file mode 100644
index 00000000..f7df7807
--- /dev/null
+++ b/tests/controller_mixin.py
@@ -0,0 +1,46 @@
+from typing import Type
+
+from pydantic import create_model
+
+import osw.model.entity as model
+
+
+class ControllerTestController(model.Entity):
+    def testfunction(self):
+        print(f"Entity with label '{str(self.label.text)}'")
+
+
+def controller_mixin(cls) -> Type:
+    # import osw.controller.Entity as controller
+    prefix = ""  # "controller."
+    postfix = "Controller"
+    try:
+        eval(f"{prefix}{cls.__name__}{postfix}")
+    except NameError:
+        # print(f"controller.{cls.__name__} No defined")
+        return cls
+    else:
+        # the controller class
+        ctrl_cls = eval(f"{prefix}{cls.__name__}{postfix}")
+        # the patched class inheriting both from the data and the controller class
+        mixin_cls = create_model(cls.__name__, __base__=tuple([ctrl_cls, cls]))
+        return mixin_cls
+
+
+@model._basemodel_decorator
+class ControllerTest(model.Entity):
+    pass
+
+
+ControllerTest = controller_mixin(ControllerTest)
+
+
+@model._basemodel_decorator
+class ControllerTestSubclass(ControllerTest):
+    pass
+
+
+ControllerTestSubclass = controller_mixin(ControllerTestSubclass)
+
+test = ControllerTestSubclass(label=model.Label(text="MyTest"))
+test.testfunction()  # while this works, theres no support from static type checking
diff --git a/tests/integration/login_test.py b/tests/integration/login_test.py
new file mode 100644
index 00000000..ed1500a1
--- /dev/null
+++ b/tests/integration/login_test.py
@@ -0,0 +1,13 @@
+import mwclient
+
+# from osw.core import OSW
+# from osw.wtsite import WtSite
+
+# run with: tox -e test -- --wiki_domain domain --wiki_username user --wiki_password pass
+
+
+def test_login(wiki_domain, wiki_username, wiki_password):
+    site = mwclient.Site(host=wiki_domain)
+    site.login(username=wiki_username, password=wiki_password)
+    # wtsite = WtSite(site)
+    # osw = OSW(site=wtsite)
diff --git a/tests/integration/store_and_load_test.py b/tests/integration/store_and_load_test.py
new file mode 100644
index 00000000..5eaf54fd
--- /dev/null
+++ b/tests/integration/store_and_load_test.py
@@ -0,0 +1,24 @@
+import mwclient
+
+import osw.model.entity as model
+from osw.core import OSW
+from osw.wtsite import WtSite
+
+# run with: tox -e test -- --wiki_domain domain --wiki_username user --wiki_password pass
+
+
+def test_store_and_load(wiki_domain, wiki_username, wiki_password):
+    site = mwclient.Site(host=wiki_domain)
+    site.login(username=wiki_username, password=wiki_password)
+    wtsite = WtSite(site)
+    osw = OSW(site=wtsite)
+
+    my_entity = model.Item(label=model.Label(text="MyItem"))
+
+    osw.store_entity(my_entity)
+
+    my_entity2 = osw.load_entity("Item:" + OSW.get_osw_id(my_entity.uuid))
+
+    assert my_entity.label.text == my_entity2.label.text
+
+    osw.delete_entity(my_entity)
diff --git a/tests/sparql_client_test.py b/tests/sparql_client_test.py
new file mode 100644
index 00000000..d9e6ba23
--- /dev/null
+++ b/tests/sparql_client_test.py
@@ -0,0 +1,15 @@
+from osw.sparql_client_smw import SmwSparqlClient
+
+domain = "onterface.open-semantic-lab.org"
+
+
+def test_sparql_client():
+    sc = SmwSparqlClient(
+        f"https://graph.{domain}/blazegraph/namespace/kb/sparql", domain
+    )
+    result = sc.get_sparql_triplets(
+        where_statement=f"?subject swivt:page <https://{domain}/wiki/Main_Page>"
+    )
+    result_value = result["wiki:Main_Page"]["rdfs:isDefinedBy"][0]
+    expected_value = f"https://{domain}/wiki/Special:ExportRDF/Main_Page"
+    assert result_value == expected_value
diff --git a/tests/test_osl.py b/tests/test_osl.py
new file mode 100644
index 00000000..15ee410e
--- /dev/null
+++ b/tests/test_osl.py
@@ -0,0 +1,17 @@
+from uuid import UUID
+
+from osw.core import OSW
+
+
+def test_osw_id_to_uuid():
+    osw_id = "OSW2ea5b605c91f4e5a95593dff79fdd4a5"
+    expected = UUID("2ea5b605-c91f-4e5a-9559-3dff79fdd4a5")
+    result = OSW.get_uuid(osw_id)
+    assert result == expected
+
+
+def test_uuid_to_osw_id():
+    uuid = UUID("2ea5b605-c91f-4e5a-9559-3dff79fdd4a5")
+    expected = "OSW2ea5b605c91f4e5a95593dff79fdd4a5"
+    result = OSW.get_osw_id(uuid)
+    assert result == expected
diff --git a/tests/test_wiki_tools.py b/tests/test_wiki_tools.py
new file mode 100644
index 00000000..292b6886
--- /dev/null
+++ b/tests/test_wiki_tools.py
@@ -0,0 +1,8 @@
+import osw.wiki_tools as wt
+
+
+def test_create_flat_content_structure_from_wikitext():
+    wikitext = "{{Template" "|param=value" "}}"
+    expected = [{"Template": {"param": ["value"]}}]
+    result = wt.create_flat_content_structure_from_wikitext(wikitext)
+    assert result == expected