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

Issue:
Feat: add rdf generation
OSW.to_json allows to export JSON-LD, optionally as rdflib.Graph in order to run local SPARQL queries


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index c0d66284..b4acc1af 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,18 @@
 [![PyPI-Server](https://img.shields.io/pypi/v/osw.svg)](https://pypi.org/project/osw/)
 [![DOI](https://zenodo.org/badge/458130867.svg)](https://zenodo.org/badge/latestdoi/458130867)
-[![Coveralls](https://img.shields.io/coveralls/github/OpenSemanticLab/osw-python/main.svg)](https://coveralls.io/r/<USER>/osw)
+[![Coveralls](https://img.shields.io/coveralls/github/OpenSemanticLab/osw-python/main.svg)](https://coveralls.io/r/OpenSemanticLab/osw)
+[![docs](xx.xx)](https://opensemanticlab.github.io/osw-python/)
+![license](https://img.shields.io/github/license/OpenSemanticLab/osw-python.svg)
+<!-- [![Pydantic v2](https://img.shields.io/endpoint?url=https://raw.githubusercontent.
+com/pydantic/pydantic/main/docs/badge/v2.json)](https://pydantic.dev) -->
 [![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 within OpenSemanticLab.
-General features for object oriented interaction with knowledge graphs are planned to be moved to a standalone package: [oold-python](https://github.com/OpenSemanticWorld/oold-python)
+
+General features for object-oriented interaction with knowledge graphs are planned to be moved to a standalone package:
+[oold-python](https://github.com/OpenSemanticWorld/oold-python)
 
 ## Installation
 ```
@@ -47,6 +53,10 @@ os.environ["PATH"] += os.pathsep + "/home/jovyan/.local/bin"
 
 https://opensemanticlab.github.io/osw-python/
 
+### Tutorials
+
+Tutorials can be found at [tutorials](docs/tutorials/)
+- [Basics](docs/tutorials/basics.md) - a description of the data model of OpenSemanticLab and how to interact with it
 
 ## Development
 
diff --git a/docs/tutorials/basics.ipynb b/docs/tutorials/basics.ipynb
new file mode 100644
index 00000000..3fc32974
--- /dev/null
+++ b/docs/tutorials/basics.ipynb
@@ -0,0 +1,1476 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "47f7c6ba83e22f59",
+   "metadata": {},
+   "source": [
+    "# Tutorial: Basics\n",
+    "\n",
+    "This tutorial will cover the basics and prerequisites, required to use the osw-python library in Python and to \n",
+    "interact with an [Open Semantic Lab (OSL)](https://github.com/OpenSemanticLab) instance, like the [OpenSemanticWorld \n",
+    "Registry](https://opensemantic.world/). To do this tutorial interactively, jump to [Downloading the library](#Downloading-the-library-optional) and open this notebook in a Jupyter environment.\n",
+    "\n",
+    "- [OSL data model](#OSL-data-model)\n",
+    "- [Downloading the library (optional)](#Downloading-the-library-optional)\n",
+    "- [Installation](#Installation)\n",
+    "- [Connecting to an OSL instance](#Connecting-to-an-OSL-instance)\n",
+    "- [Downloading data model dependencies](#Downloading-data-model-dependencies)\n",
+    "- [Interacting with an entity](#Interacting-with-an-entity)\n",
+    "- [Interacting with files](#Interacting-with-files)\n",
+    "- [Interfacing data sources](#Interfacing-data-sources)\n",
+    "- [Querying data](#Querying-data)"
+   ]
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "## Optional: Install & run in online notebook\n",
+    "In case you want to run this tutorial in a throw-away environment, you can use an online notebook service, e.g.,\n",
+    "Google Colab.\n",
+    "- (If you haven't already) Open this tutorial in the notebook service\n",
+    "    - Select `Open from GitHub` and insert https://github.com/OpenSemanticLab/osw-python/blob/main/docs/tutorials/basics.ipynb\n",
+    "    - For Google Colab just click [Tutorial: Basics](https://colab.research.google.com/github/OpenSemanticLab/osw-python/blob/main/docs/tutorials/basics.ipynb)\n",
+    "- Execute the following cell"
+   ],
+   "id": "b5fac358813d0bd6"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# Optional: Clone the repository and install the package with the tutorial dependencies\n",
+    "!git clone https://github.com/OpenSemanticLab/osw-python.git\n",
+    "%cd osw-python\n",
+    "!pip install -e .[tutorial]"
+   ],
+   "id": "a805b5dcaf0beaf7"
+  },
+  {
+   "cell_type": "markdown",
+   "id": "6bef0569b5824ea0",
+   "metadata": {},
+   "source": [
+    "## OSL data model\n",
+    "\n",
+    "Open Semantic Lab provides an [extension](https://github.com/OpenSemanticLab/mediawiki-extensions-OpenSemanticLab) \n",
+    "for Semantic Mediawiki, delivering a machine-readable data structure based on industry standards, like JSON, JSON-LD,\n",
+    " JSON-Schema. It allows to import, reference and interface existing (OWL, RDF) ontologies and aims to facilitate the \n",
+    " implementation of [FAIR Data principles](https://www.go-fair.org/fair-principles/) out-of-the-box.\n",
+    "\n",
+    "<figure>\n",
+    "    <a href=\"https://opensemantic.world/wiki/File:OSW95a74be1e22d4b6e9e4f836127d5915a.drawio.svg\">\n",
+    "        <img src=\"./img/osw_intro_technology_stack.png\"\n",
+    "            width=\"400\"\n",
+    "            height=\"200\"\n",
+    "            alt=\"Components of the OSL extension for Semantic Mediawiki\">\n",
+    "    </a>\n",
+    "</figure>\n",
+    "\n",
+    "JSON serves as the central data storage element for structured data, including the definition of classes and forms\n",
+    " via JSON-Schema, linking JSON-Data to ontologies and building property graphs.\n",
+    "\n",
+    "### Namespaces\n",
+    "\n",
+    "As we are using Semantic Mediawiki, the data is stored in pages, which are organized in namespaces. Full page titles \n",
+    "follow this structure: `<namespace>:<page_title>`. While the `<page_title>` can contain `:`, it is rarely found. The \n",
+    "most important namespaces in OSL and stored entries are:\n",
+    "\n",
+    "- Category - Classes (instances of MetaClasses) and MetaClasses\n",
+    "- Item - Instances of classes\n",
+    "- Property - Semantic properties and reusable property schemas\n",
+    "- JsonSchema - Reusable JSON-Schema definitions\n",
+    "- Template - Templates for rendering pages or performing queries\n",
+    "\n",
+    "### Slots\n",
+    "\n",
+    "The data stored on a page in Semantic Mediawiki can be stored as plain text (main slot, content model: wikitext) or in\n",
+    " an arbitrary format in dedicated slots. In OSL, we go with nine slots, tailored to the needs of a data scientist, \n",
+    " around the JSON format. The most important slots are `jsondata` and `jsonschema`, which store the data and the schema:\n",
+    "\n",
+    "| Slot name       | Content model | Description                                                                                                         |\n",
+    "|-----------------|---------------|---------------------------------------------------------------------------------------------------------------------|\n",
+    "| main            | wikitext      | Default content slot, rendered between the header and footer of the page                                            |\n",
+    "| jsondata        | JSON          | Structured data, (partially) used to render the infobox on the page                                                 |\n",
+    "| jsonschema      | JSON          | stored within a category (=class) page, defining the schema for the jsondata slot of any category member (instance) |\n",
+    "| header          | wikitext      | Content to be placed at the top of the page, below the heading                                                      |\n",
+    "| footer          | wikitext      | Content to be placed at the bottom of the page, above the (Semantic Mediawiki) built-in elements                    |\n",
+    "| header_template | wikitext      | Stored within a category (=class) page, renders the page header of any category member (instance)                   |\n",
+    "| footer_template | wikitext      | stored within a category (=class) page, renders the page footer of any category member (instance)                   |\n",
+    "    \n",
+    "This data structure can be used to generate Python data classes, which can be used to interact with the data in a type-safe manner. The osw-python library includes a [code generator](https://github.com/koxudaxi/datamodel-code-generator/) to generate Python data classes from the JSON schema. \n",
+    "\n",
+    "At the same time, this data structure can be used to auto-generate form editors, create property graphs, and provide \n",
+    "data and interfaces for applications, such as Machine Learning and data processing.\n",
+    "\n",
+    "### Data Classes / Class Hierarchy\n",
+    "\n",
+    "Everything is considered an 'Entity', which is analogous to the 'object' in Python. 'Classes' are subclasses and \n",
+    "instances of 'Entity' or specific 'MetaClasses'. 'MetaClasses' define a JSON schema used to validate the structured \n",
+    "data stored in the jsondata slot of 'Classes', just as 'Classes' do for individual 'Instances' or 'Items'.\n",
+    "\n",
+    "<figure>\n",
+    "    <a href=\"https://opensemantic.world/wiki/File:OSW96280227805c4e4a8fcf615359b01672.drawio.svg\">\n",
+    "    <img src=\"./img/osw_intro_data_model.png\" \n",
+    "        width=\"600\" \n",
+    "        height=\"300\"\n",
+    "        alt=\"OSL data model\">\n",
+    "</figure>"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "6e259e4f34e709ea",
+   "metadata": {},
+   "source": [
+    "### JSON / JSON-Schema\n",
+    "\n",
+    "The JSON schema stored in the `jsonschema` slot of a Category (=class) defines the structure of the data stored in \n",
+    "the `jsondata` slot of members of this category (=items). The JSON schema is a JSON object that defines the \n",
+    "properties and their types, constraints, and relationships. The JSON schema can be generated from the data stored \n",
+    "in the `jsondata` slot of the category (=class) or can be created manually. We are using the \n",
+    "[JSON-Schema](https://json-schema.org/) standard to define the schema. \n",
+    "\n",
+    "Through their ensured consistency, JSON can be used to generate Python data classes and instances, which can be used \n",
+    "as parameter objects for functions and methods. The generated classes are based on Pydantic models, which provide validation and serialization capabilities.\n",
+    "\n",
+    "#### JSON-Schema to Python Data Classes\n",
+    "\n",
+    "**Category:MyCategory `jsonschema` slot:**\n",
+    "```json\n",
+    "{\n",
+    "    \"type\": \"object\",\n",
+    "    \"allOf\": \"/wiki/Category:Entity?action=raw&slot=jsonschema\",\n",
+    "    \"properties\": {\n",
+    "        \"text\": { \"type\": \"string\" },\n",
+    "        \"number\": { \"type\": \"number\" },\n",
+    "        \"array\": { \"type\": \"array\" }\n",
+    "    }\n",
+    "}\n",
+    "```\n",
+    "**Category:MySubCategory `jsonschema` slot:**\n",
+    "```json\n",
+    "{\n",
+    "    \"type\": \"object\",\n",
+    "    \"allOf\": \"/wiki/Category:MyCategory?action=raw&slot=jsonschema\",\n",
+    "    \"properties\": {\n",
+    "        \"additional_property\": { \"type\": \"string\" }\n",
+    "    }\n",
+    "}\n",
+    "```\n",
+    "**Generated Python data classes:**\n",
+    "```python\n",
+    "from osw.model.entity import Entity\n",
+    "\n",
+    "class MyClass(Entity):\n",
+    "    text: str\n",
+    "    number: float\n",
+    "    array: List[Any]\n",
+    "    \n",
+    "class MySubClass(MyClass):\n",
+    "    additional_property: str\n",
+    "```\n",
+    "\n",
+    "#### Python instance to JSON data\n",
+    "\n",
+    "```python\n",
+    "from osw.express import OswExpress\n",
+    "\n",
+    "osw_obj = OswExpress(domain=\"demo.open-semantic-lab.org\")\n",
+    "\n",
+    "my_instance = MySubClass(\n",
+    "    text=\"some text\",\n",
+    "    number=1.1,\n",
+    "    array=[1, \"two\", 3.0],\n",
+    "    additional_property = \"test2\",\n",
+    ")\n",
+    "my_instance.json()\n",
+    "my_instance = osw_obj.store_entity(my_instance)  # wiki upload\n",
+    "```\n",
+    "\n",
+    "### Object Oriented Linked Data (OO-LD)\n",
+    "\n",
+    "The example above [JSON / JSON Schema](#JSON--JSON-Schema) already showed the integration of Object-Oriented\n",
+    "Programming (OOP) into JSON and JSON Schema. Adding the linked data component of [JSON-LD](https://json-ld.org/) \n",
+    "enables the reusable annotation of datasets with well established vocabularies (ontologies), such as [schema.org] \n",
+    "(https://schema.org/). Annotation have to be made at Category (=class) level only, and are available on export of \n",
+    "instances. This makes the datasets machine-readable, allows for the integration of the data into the \n",
+    "[Semantic Web](https://en.wikipedia.org/wiki/Semantic_Web) and the creation of property graphs. \n",
+    "\n",
+    "#### A minimal example:\n",
+    "```json\n",
+    "{\n",
+    "  \"@context\": {\n",
+    "    \"schema\": \"https://schema.org/\",\n",
+    "    \"name\": \"schema:name\"\n",
+    "  },\n",
+    "  \"title\": \"Person\",\n",
+    "  \"type\": \"object\",\n",
+    "  \"properties\": {\n",
+    "    \"name\": {\n",
+    "      \"type\": \"string\",\n",
+    "      \"description\": \"First and Last name\"\n",
+    "    }\n",
+    "  }\n",
+    "}\n",
+    "```\n",
+    "\n",
+    "### Further reading\n",
+    "\n",
+    "- [OSW Introduction](https://opensemantic.world/wiki/Item:OSWdb485a954a88465287b341d2897a84d6)\n",
+    "- [OSW Python Package](https://opensemantic.world/wiki/Item:OSW659a81662ff44af1b2b6febeee7c3a25)\n",
+    "- [JSON Tutorial](https://opensemantic.world/wiki/Item:OSWf1df064239044b8fa3c968339fb93344)\n",
+    "- [JSON-Schema Tutorial](https://opensemantic.world/wiki/Item:OSWf4a9514baed04859a4c6c374a7312f10)\n",
+    "- [JSON-LD Tutorial](https://opensemantic.world/wiki/Item:OSW911488771ea449a6a34051f8213d7f2f)\n",
+    "- [OO-LD Tutorial](https://opensemantic.world/wiki/Item:OSWee501c0fa6a9407d99c058b5ff9d55b4)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "c089a8c6224ff1be",
+   "metadata": {},
+   "source": [
+    "\n",
+    "\n",
+    "## Installation\n",
+    "\n",
+    "In case you just want to try the package in a throw-away environment, refer to the section  [Optional: Install & run\n",
+    "in online notebook](#optional-install--run-in-online-notebook)) Jupyter notebook.\n",
+    "\n",
+    "### Downloading the library (optional)\n",
+    "The osw-python library is available as GitHub repository and can be downloaded as a ZIP file or via git:\n",
+    "\n",
+    "```bash\n",
+    "git clone https://github.com/OpenSemanticLab/osw-python.git <target_directory>\n",
+    "```\n",
+    "\n",
+    "### From PyPI\n",
+    "Preferably, you can install the library from the Python Package Index (PyPI) via pip, which is recommended for most users:\n",
+    "\n",
+    "```bash\n",
+    "conda activate <your_environment>  # optional, if you use conda to manage environments\n",
+    "pip install osw-python[tutorial]\n",
+    "```\n",
+    "The option `[tutorial]` installs the library with the dependencies required for data import, like `deepl`, `geopy`\n",
+    "and `openpyxl`.\n",
+    "\n",
+    "### From source\n",
+    "If you want to install the library from source, you can clone the repository and install it via pip. The option `-e`\n",
+    " installs the package in editable mode, which means that the source code is linked to the installed package. This is \n",
+    " useful for development and testing.\n",
+    "\n",
+    "```bash\n",
+    "git clone https://github.com/OpenSemanticLab/osw-python.git <target_directory>\n",
+    "cd <target_directory>\n",
+    "conda activate <your_environment>  # optional, if you use conda to manage environments\n",
+    "pip install [-e] .[tutorial]\n",
+    "```"
+   ]
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "## Connecting to an OSL instance\n",
+    "\n",
+    "To connect to an OSL instance, you need to provide your login credentials. You can either provide your username and \n",
+    "password directly or create a bot password. The bot is preferred because its edit rights can be restricted and at the \n",
+    "same time, edits made programmatically are traceable, being marked as bot edits.\n",
+    "\n",
+    "### Creating a bot password\n",
+    "\n",
+    "- Log in to your OSL instance\n",
+    "- Navigate to **Special:BotPasswords**, via **Toggle menu → Special pages → Bot passwords**,\n",
+    "    e.g., `https://<wiki_domain>/wiki/Special:BotPasswords`, \n",
+    "- You must log in again to verify your identity\n",
+    "- Create a new bot password by providing a `Bot name`, e.g., 'PythoBot' and click **Create**\n",
+    "- Save the `Username` and `Bot password` in a safe place, as the password will not be displayed again\n",
+    "\n",
+    "### (Optional) Creating a credentials file\n",
+    "\n",
+    "You can create a YAML file, e.g., 'credentials.pwd.yaml', with your login credentials, which can be used to connect to the OSL instance. The file must follow the structure below:\n",
+    "\n",
+    "```yaml\n",
+    " <wiki_domain>:\n",
+    "     username: <wiki_username>\n",
+    "     password: <wiki_password>\n",
+    "```\n",
+    "\n",
+    "### Connecting via osw-python\n",
+    "\n",
+    "It is recommended to use the `osw.express.OswExpress` class to connect to an OSL instance. The class provides a \n",
+    "number of convenience functions ontop of the underlying `osw.core.OSW`. \n",
+    "\n",
+    "On the first execution of the following cell you will be prompted to enter domain, username and password. The \n",
+    "credentials will be stored in a file named **credentials.pwd.yaml** in a subfolder **osw_files** of the current working \n",
+    "directory. In the current working directory, a **.gitignore** file will be created or updated to include the \n",
+    "credentials file. \n",
+    "\n",
+    "This step is required to download all dependencies (data models) of OswExpress from the OSL instance."
+   ],
+   "id": "137e9b3144a4db96"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "from osw.express import OswExpress\n",
+    "# Some modules that will be required several times\n",
+    "import osw.params as prm\n",
+    "from pprint import pprint\n",
+    "from pathlib import Path\n",
+    "import pandas as pd"
+   ],
+   "id": "509040ca218924ed",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# Define the wiki_domain for later reuse:\n",
+    "wiki_domain = \"demo.open-semantic-lab.org\"  # Replace with the domain of your OSL instance"
+   ],
+   "id": "7d0db4de67f448b9"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "#### Option 1: Reuse the credentials file created in the previous step\n",
+    "\n",
+    "If you are still running in the same CWD, OswExpress will automatically find the credentials file.\n",
+    "\n",
+    "Else you will be prompted to enter your username and password."
+   ],
+   "id": "5e7595bf60e9a247"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": "osw_obj = OswExpress(domain=wiki_domain)  ",
+   "id": "1519ec4f2ef7fde0"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "#### Option 2: Provide a credentials file (path)\n",
+    "\n",
+    "If the file does not exist or the domain is not in the file, you will be prompted to enter your username and password.\n",
+    "Unknown domains will be appended to the file."
+   ],
+   "id": "f9754e95cfa0d483"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": "osw_obj = OswExpress(domain=wiki_domain, cred_filepath=\"credentials.pwd.yaml\")",
+   "id": "a2be66e5d7ecc1cc"
+  },
+  {
+   "cell_type": "markdown",
+   "id": "537cdcc821e4803e",
+   "metadata": {},
+   "source": [
+    "## Downloading data model dependencies\n",
+    "\n",
+    "Loading entities from OSL fetches required data models by default. So if you just want to load, modify and upload one\n",
+    " (type of) entity, you can scip to [Downloading an entity](#Downloading-an-entity).\n",
+    "\n",
+    "Before we can upload entities or files, we need to download the required data models. The data models are stored in the \n",
+    "`jsonschema` slot of the respective categories (=classes) and are used to generate Python data classes. OswExpress \n",
+    "offers a convenience function to download all dependencies of a given category, that an item is an instance of. \n",
+    "\n",
+    "> [!NOTE]\n",
+    "> It is important to execute this notebook with the same environment, where the data models are installed to!"
+   ]
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "### Identify required data models\n",
+    "\n",
+    "All categories (=classes) are subcategories of the **Entity** category. The classes **Entity**, **Item** and concepts \n",
+    "required to provide typing for those classes are provided out-of-the-box within `osw.model.entity`, which imports \n",
+    "**OswBasemodel(pydantic.BaseModel)** from `osw.model.static`.\n",
+    "\n",
+    "To store structured information in an OSL instance, you need to find a fitting **Category**, to create pages \n",
+    "(in the **Item** or **Category** namespace) in. To explore the data model hierarchy, you can use the graph tool \n",
+    "provided under **Graph** on  every  page in the `Category` or `Item` namespace, following the `SubClassOf` property. \n",
+    "\n",
+    "A good alternativ is to consult the **Category tree** page and navigate through the collapsible tree. The page can \n",
+    "be found under \n",
+    "`https://<wiki_domain>/wiki/Special:CategoryTree?target=Category%3AEntity&mode=categories&namespaces=`. \n",
+    "\n",
+    "Save the `Machine compatible name` and `Full page title` of the category you want to work with in a dictionary. Note \n",
+    "that only the category, the farthest down a branch, with respect to the root category **Entity**, is required. All \n",
+    "other categories will be downloaded automatically.\n",
+    "\n",
+    "**Example category tree**:\n",
+    "```\n",
+    "Entity\n",
+    "├── Property\n",
+    "├── Statement\n",
+    "└── Item\n",
+    "    ├── Person\n",
+    "    |   └── User\n",
+    "    ├── Location\n",
+    "    |   ├── Site\n",
+    "    |   ├── Building\n",
+    "    |   ├── Floor\n",
+    "    |   └── Room\n",
+    "    ├── CreativeWork\n",
+    "    |   └── Article\n",
+    "    |       └── Tutorial\n",
+    "    └── OrganizationalUnit\n",
+    "        └── Organization\n",
+    "```\n",
+    "\n",
+    "> [!NOTE]\n",
+    "> If you find no category, ask your administrator to install page packages via the special page 'Special:Packages'. \n",
+    "> Page packages are maintained via [GitHub](https://github.com/OpenSemanticWorld-Packages/osw-package-maintenance)"
+   ],
+   "id": "1885ed16f44d97b1"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "dependencies = {\n",
+    "    \"Organization\": \"Category:OSW1969007d5acf40539642877659a02c23\",  # Will fetch: Organization, OrganizationalUnit\n",
+    "    \"Person\":       \"Category:OSWd9aa0bca9b0040d8af6f5c091bf9eec7\",  # Will fetch: Person\n",
+    "    \"Room\":         \"Category:OSWc5ed0ed1e33c4b31887c67af25a610c1\",  # Will fetch: Room, Location, but not: Site, Building, Floor\n",
+    "    \"Tutorial\":     \"Category:OSW494f660e6a714a1a9681c517bbb975da\",  # Will fetch: Tutorial, Article, CreativeWork\n",
+    "}"
+   ],
+   "id": "f94aec7bab90dfc4"
+  },
+  {
+   "cell_type": "markdown",
+   "id": "7a8684bb1b8779fd",
+   "metadata": {},
+   "source": [
+    "> [!NOTE]\n",
+    "> Keys in this dictionary will eventually be used in the import statements, should therefore fit the auto generated \n",
+    "> class names, which are the same as the category's `Machine compatible name`!"
+   ]
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "### Installing data models\n",
+    "\n",
+    "Data models (data classes generated in osw.model.entity) can not be imported in Python scripts and modules prior to \n",
+    "installation. Therefore, it is recommended to do this step either in a separate script, which is run before the main\n",
+    "script, or in the main script itself, before the import statements.\n",
+    "\n",
+    "#### Option 1: Installing dependencies before import from osw.model.entity\n",
+    "\n",
+    "This option is recommended to put in a separate script, which is run before the main script."
+   ],
+   "id": "75206bbf0a1e0dc9"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# Will run everytime the script is executed:\n",
+    "osw_obj.install_dependencies(dependencies)\n",
+    "\n",
+    "# Static code checker will note 'Module not found' before the installation:\n",
+    "from osw.model.entity import Description, Label, Organization, Person, Room, Tutorial"
+   ],
+   "id": "369bb36b7c805028"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "#### Option 2: Use OswExpress comfort function  for imports\n",
+    "\n",
+    "This option is recommended to put in the main script, before the first `from osw.model.entity import` statement."
+   ],
+   "id": "3b63af956676181b"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "from typing import TYPE_CHECKING\n",
+    "from osw.express import import_with_fallback\n",
+    "from osw.model.entity import Description, Label  # Available out of the box\n",
+    "\n",
+    "# Will fetch and install dependencies only if not already installed:\n",
+    "import_with_fallback(dependencies, globals(), domain=wiki_domain)\n",
+    "\n",
+    "# Otherwise static code checker will note 'Module not found' before the installation:\n",
+    "if TYPE_CHECKING:\n",
+    "    from osw.model.entity import Description, Label, Organization, Person, Room, Tutorial"
+   ],
+   "id": "30a3c01428c6120d"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "## Interacting with an entity\n",
+    "\n",
+    "Data classes created by the code generator are based on Pydantic models, which provide validation and serialization.\n",
+    "\n",
+    "### Creating an entity\n",
+    "\n",
+    "To create an entity, you need to create an instance of the respective data class. The `__init__` method of the data \n",
+    "class expects keyword arguments for all fields. As per usual for Pydantic models, positional arguments are not \n",
+    "permitted and the input data is validated during initialization."
+   ],
+   "id": "35b4a3429feaf2d8"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# Create a person\n",
+    "john = Person(\n",
+    "    label=[Label(text=\"John Doe\")],  # Label, will be used as a display title\n",
+    "    name=\"John Doe\",  # Not required: Machine compatible name\n",
+    "    # Machine compatible is auto-generated from the first label\n",
+    "    first_name=\"John\",\n",
+    "    last_name=\"Doe\",\n",
+    "    email=\"john.doe@example.com\"\n",
+    ")\n",
+    "# Should return two ValidationErrors:\n",
+    "# - surname: field required\n",
+    "# - email: value is not a valid set"
+   ],
+   "id": "459d6ac5804389a2"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "Lets breakdown what happened here\n",
+    "\n",
+    "- During initialization, the Pydantic model validates the input data. The validation errors are raised as exceptions.\n",
+    "- The `surname` field is required, but it was not provided.\n",
+    "- The extra field `last_name` was provided, but it was not expected. By default, Pydantic models disregard extra \n",
+    "fields without warning.\n",
+    "- The `email` field is expected to be a list of strings, but a string was provided. "
+   ],
+   "id": "f634abd07a3568a"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# Should run without validation errors\n",
+    "john = Person(\n",
+    "    label=[Label(text=\"John Doe\")],  # Label, will be used as a display title\n",
+    "    first_name=\"John\",\n",
+    "    surname=\"Doe\",\n",
+    "    email=[\"john.doe@example.com\"],\n",
+    ")"
+   ],
+   "id": "9dd23162dbe110ef"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "Before storing the entity in the OSL instance, lets check at which IRI (full page title) it will be stored.\n",
+    "The IRI is derived from the `uuid` and the `namespace` of the entity."
+   ],
+   "id": "fffbf93fcb44ff57"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "print(\"Page label:\", john.label[0].text)\n",
+    "print(\"Machine-readable page name:\", john.name)\n",
+    "print(\"Namespace:\", john.get_namespace())\n",
+    "print(\"UUID:\", john.uuid)\n",
+    "print(\"OSW-ID:\", john.get_osw_id())\n",
+    "print(\"IRI:\", john.get_iri())"
+   ],
+   "id": "89b4c364b9f365c2"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "### Storing an entity\n",
+    "\n",
+    "We can now store this entity in the OSL instance. The `store_entity` method uploads the entity to the OSL instance. "
+   ],
+   "id": "2c88920949485b7"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# Let's document all storing processes in a list\n",
+    "stored = []\n",
+    "\n",
+    "# Storing an entity requires only one line:\n",
+    "res = osw_obj.store_entity(john)\n",
+    "\n",
+    "# But to be able to access all storage event later on, we will append the result to the list\n",
+    "stored.append(res)"
+   ],
+   "id": "99cf8828bee651ee"
+  },
+  {
+   "cell_type": "markdown",
+   "id": "639db64348bbd905",
+   "metadata": {},
+   "source": [
+    "```python\n",
+    "# In this specific case equivalent to:\n",
+    "params = prm.StoreEntityParam(\n",
+    "    entities=[john],\n",
+    "    namespace=john.get_namespace(),\n",
+    "    parallel=False,\n",
+    "    overwrite=\"keep existing\",\n",
+    "    overwrite_per_class=None,\n",
+    ")  # All default values included\n",
+    "stored.append(osw_obj.store_entity(params))\n",
+    "```\n",
+    "Like most methods and functions in the osw-python library, the `store_entity` takes only a single argument. Usually\n",
+    "either a specific object type or a dedicated params object is accepted. If an object of type other than the params \n",
+    "object is passed, it is usually tested for compatibility and put inside a params object, filling the other parameters \n",
+    "with default values. \n",
+    "\n",
+    "This ensures that the method signature is as simple as possible and at the same time allows full typing and validation \n",
+    "of input parameters that the method is easy to use. "
+   ]
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "### Downloading an entity\n",
+    "\n",
+    "To download an entity, you need to provide the IRI of the entity. The `download_entity` method downloads the\n",
+    "entity from the OSL instance and returns an instance of the respective data class. If the respective data class is \n",
+    "not already part of `osw.model.entity`, the data class is generated on-the-fly by default."
+   ],
+   "id": "ae4da6692096e045"
+  },
+  {
+   "cell_type": "code",
+   "id": "19d1c6767b2098b",
+   "metadata": {},
+   "source": [
+    "john2 = osw_obj.load_entity(john.get_iri())\n",
+    "# Let's have a look at the attributes of the downloaded entity"
+   ],
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": "pprint(john2.dict())",
+   "id": "5e98d8915a3bf440"
+  },
+  {
+   "cell_type": "markdown",
+   "id": "d140b974b54155ce",
+   "metadata": {},
+   "source": [
+    "Besides the attributes that we set (first_name, surname, email), the downloaded entity has additional attributes, \n",
+    "that are generated by default, either when the entity is initialized (uuid, meta.wiki_page.title) or loaded (None \n",
+    "valued attributes). \n",
+    "\n",
+    "Loading an entity from the OSL instance downloads the full `jsondata` slot and pass it to the \n",
+    "`__init__` method of the respective data class. Thereby, attributes not present in the `jsondata` slot are set to the\n",
+    " default value of the data class."
+   ]
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "### Modifying an entity\n",
+    "\n",
+    "To modify an entity, you can change the attributes of the entity instance. The attributes can be accessed and modified \n",
+    "like any other attribute of a Python object."
+   ],
+   "id": "b8713c64622ea4fe"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# Adding a new attribute\n",
+    "john2.middle_name = {\"R.\"}\n",
+    "# Changing an existing attribute \n",
+    "john2.name = \"John R. Doe\"\n",
+    "john2.email = {\"john.doe@gmx.de\"}\n",
+    "\n",
+    "# Checking the made changes:\n",
+    "pprint(john2.dict())"
+   ],
+   "id": "b4425d8e6b1cbe62",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# Let's create an organization and link it to the person\n",
+    "org = Organization(\n",
+    "    label=[Label(text=\"Doe, Inc.\")],\n",
+    "    description=[Description(text=\"The John Doe Company\")],\n",
+    "    manager=[john2.get_iri()]\n",
+    ")\n",
+    "# And another one that is linked to both the person and the first organization\n",
+    "org2 = Organization(\n",
+    "    label=[Label(text=\"Doe Group, Inc.\")],\n",
+    "    description=[Description(text=\"The Doe Group\")],\n",
+    "    manager=[john2.get_iri()],\n",
+    "    member_ous=[org.get_iri()]\n",
+    ")"
+   ],
+   "id": "1247b70481054611"
+  },
+  {
+   "cell_type": "markdown",
+   "id": "f42c1600ed17a059",
+   "metadata": {},
+   "source": [
+    "Here we have created links between Entities for the first time. The `get_iri` method returns the full title of the to\n",
+    " be created page(s), which replaces the pointers Python use where ever you reference an object by variable. JSON has\n",
+    " to use resolvable IRIs to realize this reference.\n",
+    "\n",
+    "Note: We are already working on a feature to automatically resolve these references, so you can use the Python\n",
+    "pointers in the future!"
+   ]
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "### Storing an altered entity\n",
+    "\n",
+    "Here the same applies as for [Storing an entity](#Storing-an-entity). BUT: overwriting entities is not possible with \n",
+    "default settings \"keep existing\". Therefore, you need to call the method `store_entity` passing a `StoreEntityParam` with\n",
+    "the attribute `overwrite` set to \"overwrite\".\n"
+   ],
+   "id": "bd30cfbba5d56386"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# Define entities to be stored\n",
+    "entities2store = [john2, org, org2]"
+   ],
+   "id": "77b507f13be1235b",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# Option 1: Overwrite all entities\n",
+    "res = osw_obj.store_entity(prm.StoreEntityParam(entities=entities2store, overwrite=True))\n",
+    "stored.append(res)"
+   ],
+   "id": "19d4d48e95e0fe82",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# Option 2: Overwrite only entities of type Person\n",
+    "res = osw_obj.store_entity(prm.StoreEntityParam(\n",
+    "    entities=entities2store, overwrite_per_class=[prm.OverwriteClassParam(model=Person, overwrite=True)]))\n",
+    "stored.append(res)"
+   ],
+   "id": "4fc037a10f95356a",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# Option 3: Overwrite only the email attribute of entities of type Person\n",
+    "res = osw_obj.store_entity(prm.StoreEntityParam(\n",
+    "    entities=entities2store, overwrite_per_class=[\n",
+    "        prm.OverwriteClassParam(model=Person, overwrite=False,per_property={\"email\": True})]))\n",
+    "stored.append(res)"
+   ],
+   "id": "2019c0d5301ab1de"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "Here all three options will have the same result, but in many cases the result will differ, especially if you have \n",
+    "entities of different classes in the list of entities to store.\n",
+    "\n",
+    "The param `overwrite` is applied to all entities handed to the method regardless of type / class. It is also possible\n",
+    " to specify the overwrite behavior per class, by providing a list of `prm.OverwriteClassParam`s. Those can even be\n",
+    " specific down to the property level. \n",
+    "- Available options for `prm.StoreEntitParam.overwrite`, `prm.OverwriteClassParam.overwrite` (per class) and `OSW\n",
+    ".OverwriteClassParam.per_property` are: \n",
+    "    - `prm.OverwriteOptions.true`: True - overwrite the remote entity or property with the local one\n",
+    "    - `prm.OverwriteOptions.false`: False - do not overwrite the remote entity or property with the local one\n",
+    "    - `prm.OverwriteOptions.only_empty`: \"only empty\" - overwrite the remote entity or property with the local one,\n",
+    "      if the remote entity or property is empty\n",
+    "- Only available to `prm.StoreEntitParam.overwrite` and `prm.OverwriteClassParam.overwrite` (per class) are:\n",
+    "    - `prm.AddOverwriteClassOptions.replace_remote`: \"replace remote\" - replace the remote entity with the local one and\n",
+    "      removes all properties not present in the local entity\n",
+    "    - `prm.AddOverwriteClassOptions.keep_existing`: \"keep existing\" - keep the remote entity, if one exists under this\n",
+    "      OSW-ID"
+   ],
+   "id": "2107b3efcd22837f"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "## Exploring links and properties in the knowledge graph of the OSL instance\n",
+    "\n",
+    "The output of the code cell above should list pages where entities have been stored at. Follow one of the links\n",
+    "to the OSL instance you are connected to. You should end up on the page of an instance of either `Organization` or `Person`. Follow these steps to explore the knowledge graph of the OSL instance:\n",
+    "- Scroll down to the bottom of the page and un-collapse, by clicking, the `Graph` section. This will open a graph\n",
+    "representation of the entity. By default, only the property edges `HasSchema`/`HasType` and `SubClassOf` are shown.\n",
+    "- By right-clicking on a node, e.g., the person instance, the Person/Organization class, you have access to a list of properties that the node posses.\n",
+    "- By left-clicking on a property the nodes that are connected via this property will appear. By repeating this\n",
+    "process, you can follow property paths in this network and explore the graph.\n",
+    "\n",
+    "Later on in this tutorial, you can use the paths you have explored to create semantic queries.\n",
+    "\n",
+    "### Exercise\n",
+    "- If you are on the page `John Doe`\n",
+    "    - Explore the property `HasName`, `HasFirstName` and `HasSurName` - these are properties of type `string`,\n",
+    "    assigning a value to an entry.\n",
+    "    - Explore the property `-HasManager` to find organizations, which state John as their manager. Here you are looking into an inverse property relation, of type `@id` - it links two entries within the knowledge graph.\n",
+    "    - On the node `Doe Group, Inc.` explore the property `HasMember` to find the organization `Doe, Inc.`. This is also a property of type `@id`, but not an inverse relation.\n",
+    "- If you are on the page `Doe Group, Inc.`\n",
+    "    - Explore the property `HasMember` to find the organization `Doe, Inc.`.\n",
+    "    - Explore the property `HasManager` to find the person `John Doe`."
+   ],
+   "id": "2dc425de23d5e2ce"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "## Interacting with files\n",
+    "\n",
+    "### Downloading a file\n",
+    "\n",
+    "Let's say you have already uploaded a file to the instance of OSL you are connected to and have the URL to the file \n",
+    "available. (Execute the Upload a file section to upload a file to the OSL instance.) You can download the file with \n",
+    "just two lines:\n"
+   ],
+   "id": "8611c346fba59805"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "from osw.express import osw_download_file\n",
+    "local_file = osw_download_file(\n",
+    "    \"https://demo.open-semantic-lab.org/wiki/File:OSW98a720defe8349cab010ca1fc3528aee.pdf\",\n",
+    "    # , use_cached=True  # Can be used to download the file only once, e.g., when developing code\n",
+    "    # , overwrite=True  # Can be used to avoid overwriting an existing file\n",
+    ")"
+   ],
+   "id": "49d395dd958beff7"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "The object `local_file` is an instance of `OswExpress.DownloadFileResult` and contains the path to the downloaded file, \n",
+    "which is accessible via:"
+   ],
+   "id": "5215e1b89b4ba89f"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "The class `OswExpress.DownloadFileResult` implements all dunder methods required for a context manager. Therefore, it\n",
+    " can be used with the `with` statement to ensure the file is closed properly after use:"
+   ],
+   "id": "c4a6ed35e633a34c"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "with osw_download_file(\n",
+    "    \"https://demo.open-semantic-lab.org/wiki/File:OSW2c08f45151ee4b518485ed68cf73c5e5.txt\", overwrite=True\n",
+    ") as file:\n",
+    "    file.open(\"r\")\n",
+    "    print(file.read())"
+   ],
+   "id": "861724df8468d97"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "### Round-Robin\n",
+    "\n",
+    "Let's create a file, upload it to the OSL instance, download it and read and alter its content, before uploading it \n",
+    "again.\n"
+   ],
+   "id": "efcb9b2381ad388d"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "from pathlib import Path\n",
+    "from osw.express import osw_upload_file, osw_download_file\n",
+    "\n",
+    "# Create a file\n",
+    "fp = Path(\"example.txt\")\n",
+    "with open(fp, \"w\") as file:\n",
+    "    file.write(\"Hello, World!\")\n",
+    "# Upload a file to an OSW instance\n",
+    "wiki_file = osw_upload_file(fp, domain=wiki_domain)\n",
+    "stored.append(wiki_file)\n",
+    "# Delete the local file\n",
+    "fp.unlink()\n",
+    "\n",
+    "# Download the file\n",
+    "local_file = osw_download_file(wiki_file.url, mode=\"r+\")  # mode=\"r+\" to read and write\n",
+    "\n",
+    "with local_file as file:\n",
+    "    content = file.read()\n",
+    "    print(\"Original content:\")\n",
+    "    print(content)\n",
+    "    content = content.replace(\"World\", \"OSW\")\n",
+    "    print(\"\\nModified content:\")\n",
+    "    print(content)\n",
+    "    # Write the modified content back to the file\n",
+    "    file.write(content)\n",
+    "\n",
+    "# Upload the modified file\n",
+    "modified_wiki_file = osw_upload_file(local_file)\n",
+    "stored.append(modified_wiki_file)"
+   ],
+   "id": "9647b490f90d2a0d",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# Delete WikiFile from OSW instance after you are done with it\n",
+    "wiki_file.delete()\n",
+    "# Error: InsufficientPermission, if you do not have the required rights"
+   ],
+   "id": "ca65732aeed620f6"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "## Interfacing data sources\n",
+    "\n",
+    "### Tabular data: Excel, CSV and others\n",
+    "\n",
+    "Let's create a demo table and save it to an Excel file.   "
+   ],
+   "id": "e7e9cc49527fab6b"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "import pandas as pd\n",
+    "\n",
+    "df = pd.DataFrame(\n",
+    "    {\n",
+    "        \"FirstName\": [\"John\", \"Jane\", \"Alice\"],\n",
+    "        \"LastName\": [\"Doe\", \"Do\", \"Dont\"],\n",
+    "        \"Email\": [\"john.doe@example.com\", \"jane.do@example.com\", \"alice.dont@example.com\"],\n",
+    "    }\n",
+    ")\n",
+    "df.to_excel(\"demo.xlsx\", index=False)\n",
+    "del df"
+   ],
+   "id": "ed6cd2984e10baca",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# Let's read in our example Excel file\n",
+    "data_df = pd.read_excel(\"demo.xlsx\")\n",
+    "# Pandas dict representation is optimal for converting to JSON\n",
+    "# Let's have a look at the first row\n",
+    "john_dict = data_df.iloc[0].to_dict()\n",
+    "pprint(john_dict)"
+   ],
+   "id": "840449d1e08ea7f2",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# Let's convert the dict to a Person instance\n",
+    "from osw.model.entity import Person\n",
+    "john_xl = Person(**john_dict)\n",
+    "# This will cause ValidationError(s):\n",
+    "# - first_name: field required\n",
+    "# - surname: field required"
+   ],
+   "id": "cea026747cf19c54"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "Explanation: This is due to the dictionary unpacking operator `**`, which passes the dictionary keys as keyword\n",
+    "arguments to the `Person` class. The `first_name` and `surname` fields are required, but they are not present in the\n",
+    "dictionary. \n",
+    "\n",
+    "We have several options to resolve this issue:\n",
+    "- Replace the dictionary keys with fitting ones\n",
+    "    - By renaming the columns in the DataFrame before converting it to a dictionary\n",
+    "    - By providing a mapping dictionary and replacing the keys in the dictionary, using the following function:\n",
+    "      ```python\n",
+    "      def replace_keys(d, key_map):    \n",
+    "        return {key_map.get(k, k): v for k, v in d.items()}\n",
+    "      ```\n",
+    "- Create a HelperClass, which inherits from the target data class and `osw.data.import_utility.HelperModel`, and \n",
+    "  implements a transformation function to create the target data class instance from the dictionary "
+   ],
+   "id": "d98830f43fd2a405"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "##### Option 1: Rename columns in the DataFrame\n",
+    "\n",
+    "This option is very simple and will serve you well for cases of low complexity, where the datatypes in the DataFrame \n",
+    "columns already match the datatypes of the target data class."
+   ],
+   "id": "829bb56909333cb1"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# Let's print out the columns first\n",
+    "print(data_df.columns)"
+   ],
+   "id": "90f7efe362d1fd52",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# Let's create the mapping dictionary and rename the columns\n",
+    "mapping = {\n",
+    "    \"FirstName\": \"first_name\",\n",
+    "    \"LastName\": \"surname\",\n",
+    "    \"Email\": \"email\"\n",
+    "}\n",
+    "data_df.rename(columns=mapping, inplace=True)\n",
+    "# Auto create the name & label\n",
+    "data_df[\"name\"] = data_df[\"first_name\"] + \" \" + data_df[\"surname\"]\n",
+    "data_df[\"label\"] = data_df[\"name\"].apply(lambda x: [{\"text\": x}])\n",
+    "# Let's have a look at the first row\n",
+    "john_dict = data_df.iloc[0].to_dict()\n",
+    "print(john_dict)"
+   ],
+   "id": "469e3f2514a16e50",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# Let's construct an instance of the Person data model\n",
+    "john_xl = Person(**john_dict)\n",
+    "# This will cause a ValidationError:\n",
+    "# - email: value is not a valid set"
+   ],
+   "id": "9e5c3bc9ff17b2e4",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# Let's correct the email field\n",
+    "john_dict[\"email\"] = [john_dict[\"email\"]]\n",
+    "john_xl = Person(**john_dict)\n",
+    "pprint(john_xl.dict())"
+   ],
+   "id": "5d75c837ffc91987"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "##### Option 2: Create a HelperModel and a transformation function\n",
+    "\n",
+    "This approach will be able to treat even cases of high complexity, where the datatypes in the DataFrame columns do not \n",
+    "match the datatypes of the target data class or where references to other instances in a dataset have to be made. \n",
+    "\n",
+    "[!NOTE] Property and variable names in Python must not contain spaces, so the column names in the DataFrame have \n",
+    "to be transformed accordingly."
+   ],
+   "id": "f73419919649608d"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "from typing import Any\n",
+    "from osw.data.import_utility import HelperModel\n",
+    "from osw.utils.strings import pascal_case\n",
+    "\n",
+    "class PersonHelper(Person, HelperModel):\n",
+    "    # Attributes of the first base class are set to Optional[Any], default: None\n",
+    "    FirstName: Any\n",
+    "    LastName: Any\n",
+    "    Email: Any\n",
+    "    \n",
+    "    def transform_attributes(self, dd: dict = None) -> bool:\n",
+    "        super().transform_attributes()\n",
+    "        self.first_name = self.FirstName\n",
+    "        self.surname = self.LastName\n",
+    "        self.email = {self.Email}\n",
+    "        self.name = pascal_case(f\"{self.first_name} {self.surname}\")\n",
+    "        self.label = [Label(text=self.name)]\n",
+    "        if self.middle_name:\n",
+    "            self.name = pascal_case(\"{self.first_name} {self.middle_name} {self.surname}\")\n",
+    "        return True\n",
+    "\n",
+    "    def transform_references(self, dd: dict = None) -> bool:\n",
+    "        super().transform_references()\n",
+    "        if self.organization:\n",
+    "            self.organization = set([org_.get_iri() for org_ in self.organization])\n",
+    "        return True\n",
+    "\n",
+    "# Let's create a new instance of the PersonHelper class\n",
+    "data_df = pd.read_excel(\"demo.xlsx\")\n",
+    "john_dict = data_df.iloc[0].to_dict()\n",
+    "john_helper = PersonHelper(**john_dict)\n",
+    "print(\"Before transformation:\")\n",
+    "pprint(john_helper.dict())"
+   ],
+   "id": "872b9e65d606d544",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# Let's see the effect of the transformation of attributes\n",
+    "john_helper.transform_attributes()\n",
+    "print(\"After transformation:\")\n",
+    "pprint(john_helper.dict())"
+   ],
+   "id": "2ff76f2a76c097f6",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# Actually we access 'transformed' directly\n",
+    "# If the transformation operations had not been performed already,\n",
+    "#  accessing 'transformed' would trigger them\n",
+    "local_john = john_helper.transformed\n",
+    "print(\"After casting:\")\n",
+    "pprint(local_john.dict())"
+   ],
+   "id": "30e0620c9ef49fdf",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# We can do the same for all instances quite easily:\n",
+    "entities = []\n",
+    "for ii in data_df.index:\n",
+    "    entities.append(PersonHelper(**data_df.iloc[ii].to_dict()).transformed)"
+   ],
+   "id": "15f2596aff7ac2e0",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# And store the entities in the OSL instance\n",
+    "res = osw_obj.store_entity(entities)\n",
+    "stored.append(res)"
+   ],
+   "id": "53e1042422f2f0ed"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "## Querying data\n",
+    "\n",
+    "### Querying instances of a category\n",
+    "osw-python offers methods to query semantic properties, which are set in the triplestore of the Open Semantic Lab\n",
+    "instance, and which were defined in `@context` of the JSON-Schema documents. We can therefore query for instances of\n",
+    "a category, which point to the category with the properties `HasType` and `HasSchema`. The method `query_instances`\n",
+    "is a convenience method, basically a wrapper around `semantic_search()`, querying: `[[HasType::Category:<OSW-ID of\n",
+    "Category:Person>]]`. This query essentially queries all entries in the platform, which fulfill the condition."
+   ],
+   "id": "d4fe2e79455f2f04"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# query_instances() returns a list of the full page titles (IRIs) of instances of the given class\n",
+    "#  The method queries direct instances only, instances of subclasses are not returned\n",
+    "persons = osw_obj.query_instances(Person)\n",
+    "organizations = osw_obj.query_instances(Organization)\n",
+    "print(\"Number of persons stored in the OSL instance:\", len(persons))\n",
+    "print(\"Number of organizations stored in the OSL instance:\", len(organizations))"
+   ],
+   "id": "c47281593ceed2bf",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# Test if john is in the list of persons\n",
+    "john.get_iri() in persons"
+   ],
+   "id": "5bf7f397f2cb48c",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# To get the instances themselves, use load_entity() with the IRIs\n",
+    "print(\"Loading person with IRI:\", persons[0])\n",
+    "some_person = osw_obj.load_entity(persons[0])\n",
+    "pprint(some_person.dict())"
+   ],
+   "id": "1ae5a4da1418c1"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "### Querying semantic properties\n",
+    "We can also query for the entries, a semantic properties links to, starting from a specific entity. Here, we are\n",
+    "using the syntax defined in the Semantic Mediawiki [user manual](https://www.semantic-mediawiki.org/wiki/Help:Semantic_search).\n",
+    "\n",
+    "#### In the browser\n",
+    "It might be a good idea to start with a query in the browser, to get a feeling for the structure of the data. You\n",
+    "have explored the graph tool already, so you know properties (edges) link entities (nodes). The semantic search uses\n",
+    "a Semantic Mediawiki specific syntax.\n",
+    "\n",
+    "If you are connected to the OSL instance with the domain `demo.open-semantic-lab.org`, you can use the following link:\n",
+    "[Special:Ask](https://demo.open-semantic-lab.org/w/index.php?title=Special:Ask&#search).\n",
+    "\n",
+    "If you are connected to another OSL instance, use the following cell to create a link for you:"
+   ],
+   "id": "8f816e2fc97251d0"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# Creating a clickable link for you\n",
+    "from sys import stdout\n",
+    "stdout.write(f\"Interactive semantic search: 'https://{wiki_domain}/w/index.php?title=Special:Ask&#search'\")"
+   ],
+   "id": "39b2ce4f24ac5793"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "Let's explore some queries. Enter the following queries in the field `Condition` and click `Find results`:\n",
+    "- [[HasName::John Doe]] - Query for all entries that posses the property `HasName` with the value `John Doe`\n",
+    "- [[HasManager.HasName::John Doe]] - Query for all entries that specify person, named `John Doe` as manager\n",
+    "\n",
+    "The queries should return:\n",
+    "- A list of all `John Doe` named entries in the platform\n",
+    "- A list of organizations, where `John Doe` is the manager - there should be at least one, the `Doe Group, Inc.` and\n",
+    "`Doe, Inc.` we have created earlier"
+   ],
+   "id": "c814cd2e5bf56d96"
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "#### In Python\n",
+    "\n",
+    "The method `semantic_search` allows to do query for semantic properties in python. At the moment, the method is\n",
+    "only available for the `site` attribute of OSW / OswExpress."
+   ],
+   "id": "43df2da7095a1511"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "managed_by_john = f\"[[HasManager::{john.get_iri()}]]\"\n",
+    "# Queries can also be performed on inverse properties\n",
+    "members_of_doe_group = f\"[[-HasMember::{org2.get_iri()}]]\"\n",
+    "# Multiple query conditions can be applied in one query\n",
+    "members_of_managed_by_john = f\"[[HasManager::{john.get_iri()}]][[HasMember::+]]\"\n",
+    "# Query them\n",
+    "for query in [managed_by_john, members_of_doe_group, members_of_managed_by_john]:\n",
+    "    query_res = osw_obj.site.semantic_search(query)\n",
+    "    print(f\"Querying '{query}' returned:\", query_res)"
+   ],
+   "id": "be231b1bccee0b71",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# Now at the very end, lets query everything that we have uploaded\n",
+    "# The change_id can be recovered from the store_entity returns, as we have preserved them in `stored`\n",
+    "change_ids = [res.change_id for res in stored]\n",
+    "print(\"Change Ids:\", change_ids)"
+   ],
+   "id": "7a3a8d820b939523",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# Create queries for the change_ids\n",
+    "# We can query along a path of properties. Here each entry has a sub-object 'meta', which points to the change_id with\n",
+    "#  'HasChangeId'.\n",
+    "queries = [f\"[[HasMeta.HasChangeId::{change_id}]]\" for change_id in change_ids]\n",
+    "# We could also create a single query, that chains all queries with 'OR':\n",
+    "query = \"OR \".join(queries)\n",
+    "print(\"Alternative chained query:\", query)\n",
+    "search_param = prm.SearchParam(query=queries)\n",
+    "res_iris = osw_obj.site.semantic_search(search_param)\n",
+    "print(\"IRIs of the uploaded entities:\", res_iris)"
+   ],
+   "id": "167bd0a450faef66",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": [
+    "# Clean up - delete all uploaded entities (if you have permission to do so)\n",
+    "osw_obj.site.delete_page(res_iris)\n",
+    "# Will fail if you do not have the required rights - we will clean up for you :)"
+   ],
+   "id": "4fff01bc5e87752b"
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.12.4"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/tutorials/img/osw_intro_data_model.png b/docs/tutorials/img/osw_intro_data_model.png
new file mode 100644
index 00000000..81d52923
Binary files /dev/null and b/docs/tutorials/img/osw_intro_data_model.png differ
diff --git a/docs/tutorials/img/osw_intro_technology_stack.png b/docs/tutorials/img/osw_intro_technology_stack.png
new file mode 100644
index 00000000..d63fc178
Binary files /dev/null and b/docs/tutorials/img/osw_intro_technology_stack.png differ
diff --git a/examples/clean_demo_platform.py b/examples/clean_demo_platform.py
new file mode 100644
index 00000000..eda6899c
--- /dev/null
+++ b/examples/clean_demo_platform.py
@@ -0,0 +1,19 @@
+from pathlib import Path
+
+import osw.params as prm
+from osw.express import OswExpress
+
+osw_obj = OswExpress(
+    domain="demo.open-semantic-lab.org", cred_filepath=Path("accounts.pwd.yaml")
+)
+orgs = osw_obj.site.semantic_search(
+    prm.SearchParam(
+        query="[[HasType::Category:OSW1969007d5acf40539642877659a02c23]]", debug=False
+    )
+)
+persons = osw_obj.site.semantic_search(
+    prm.SearchParam(
+        query="[[HasType::Category:OSW44deaa5b806d41a2a88594f562b110e9]]", debug=False
+    )
+)
+osw_obj.site.delete_page(orgs + persons)
diff --git a/examples/use_express_functions.py b/examples/use_express_functions.py
index 9024b03f..92298cdb 100644
--- a/examples/use_express_functions.py
+++ b/examples/use_express_functions.py
@@ -37,7 +37,6 @@
     overwrite=True,  # Required if file already exists
 )
 local_file_path = local_file.path
-local_file.close()  # required to release the file lock
 
 # Open a file with context manager directly from an OSW instance
 with osw_download_file(
diff --git a/scripts/migration/file_page_migration.py b/scripts/migration/file_page_migration.py
index ce8b4fb9..b60e6212 100644
--- a/scripts/migration/file_page_migration.py
+++ b/scripts/migration/file_page_migration.py
@@ -12,8 +12,8 @@
 import osw.wiki_tools as wt
 from osw.core import OSW
 from osw.model.entity import Label, WikiFile
+from osw.utils.regex import match_first_regex_pattern, test_regex_pattern
 from osw.utils.regex_pattern import REGEX_PATTERN_LIB, REGEX_PATTERN_LIST
-from osw.utils.strings import match_first_regex_pattern, test_regex_pattern
 from osw.utils.util import parallelize
 from osw.wtsite import WtPage, WtSite
 
diff --git a/setup.cfg b/setup.cfg
index a9a70693..267a3b9f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -88,8 +88,11 @@ S3 =
 dataimport =
     geopy
     deepl
+    openpyxl
 UI =
     pysimplegui
+tutorial =
+    %(dataimport)s
 all =
     %(dataimport)s
     %(DB)s
diff --git a/src/osw/auth.py b/src/osw/auth.py
index a232fd5b..85b8aff1 100644
--- a/src/osw/auth.py
+++ b/src/osw/auth.py
@@ -7,8 +7,9 @@
 from warnings import warn
 
 import yaml
-from pydantic.v1 import FilePath, PrivateAttr
+from pydantic.v1 import PrivateAttr
 
+from osw.custom_types import PossibleFilePath
 from osw.model.static import OswBaseModel
 
 CREDENTIALS_FN_DEFAULT = "credentials.pwd.yaml"
@@ -17,9 +18,13 @@
 class CredentialManager(OswBaseModel):
     """Handles credentials"""
 
-    cred_filepath: Optional[Union[Union[str, FilePath], List[Union[str, FilePath]]]]
+    cred_filepath: Optional[
+        Union[Union[str, PossibleFilePath], List[Union[str, PossibleFilePath]]]
+    ]
     """Filepath to yaml file with credentials for osw and connected services"""
-    cert_filepath: Optional[Union[Union[str, FilePath], List[Union[str, FilePath]]]]
+    cert_filepath: Optional[
+        Union[Union[str, PossibleFilePath], List[Union[str, PossibleFilePath]]]
+    ]
     """Filepath to the certificates for osw and connected services"""
 
     _credentials: List[BaseCredential] = PrivateAttr([])
@@ -233,7 +238,7 @@ def iri_in_file(self, iri: str) -> bool:
 
     def save_credentials_to_file(
         self,
-        filepath: Union[str, FilePath] = None,
+        filepath: Union[str, PossibleFilePath] = None,
         set_cred_filepath: bool = False,
     ):
         """Saves the in memory credentials to a file
@@ -254,6 +259,7 @@ def save_credentials_to_file(
             if self.cred_filepath is None:
                 filepath_ = [Path.cwd() / CREDENTIALS_FN_DEFAULT]
         if set_cred_filepath:
+            # Creates error if file does not exist -> Using custom FilePath
             self.cred_filepath = filepath_
         for fp in filepath_:
             file = Path(fp)
diff --git a/src/osw/controller/file/remote.py b/src/osw/controller/file/remote.py
index 5497569a..11a592ea 100644
--- a/src/osw/controller/file/remote.py
+++ b/src/osw/controller/file/remote.py
@@ -4,7 +4,8 @@
 from osw.controller.file.base import FileController
 from osw.core import model
 
-# TODO: add addional remove file with https://docs.prefect.io/2.11.4/concepts/filesystems/
+# TODO: add additional remove file with
+#  https://docs.prefect.io/2.11.4/concepts/filesystems/
 
 
 # Note: the order of the base classes is important
diff --git a/src/osw/controller/page_package.py b/src/osw/controller/page_package.py
index edc4a95d..dfdcbbec 100644
--- a/src/osw/controller/page_package.py
+++ b/src/osw/controller/page_package.py
@@ -14,7 +14,7 @@
 from osw.model import page_package as package
 from osw.model.page_package import NAMESPACE_CONST_TO_NAMESPACE_MAPPING
 from osw.model.static import OswBaseModel
-from osw.utils.strings import RegExPatternExtended
+from osw.utils.regex import RegExPatternExtended
 from osw.wtsite import WtSite
 
 # Definition of constants
diff --git a/src/osw/core.py b/src/osw/core.py
index 21b5cc1e..982963ca 100644
--- a/src/osw/core.py
+++ b/src/osw/core.py
@@ -9,7 +9,7 @@
 from copy import deepcopy
 from enum import Enum
 from typing import Any, Dict, List, Optional, Type, Union
-from uuid import UUID
+from uuid import UUID, uuid4
 from warnings import warn
 
 import rdflib
@@ -31,6 +31,7 @@
     get_uuid,
     is_empty,
     namespace_from_full_title,
+    remove_empty,
     title_from_full_title,
 )
 from osw.wiki_tools import SearchParam
@@ -604,6 +605,9 @@ class LoadEntityParam(BaseModel):
         autofetch_schema: Optional[bool] = True
         """If true, load the corresponding schemas /
         categories ad-hoc if not already present"""
+        remove_empty: Optional[bool] = True
+        """If true, remove key with an empty string, list, dict or set as value
+        from the jsondata."""
         disable_cache: bool = False
         """If true, disable the cache for the loading process"""
 
@@ -659,6 +663,8 @@ def load_entity(
             schemas = []
             schemas_fetched = True
             jsondata = page.get_slot_content("jsondata")
+            if param.remove_empty:
+                remove_empty(jsondata)
             if jsondata:
                 for category in jsondata["type"]:
                     schema = (
@@ -668,19 +674,19 @@ def load_entity(
                     )
                     schemas.append(schema)
                     # generate model if not already exists
-                    cls = schema["title"]
-                    if not hasattr(model, cls):
+                    cls_name: str = schema["title"]
+                    if not hasattr(model, cls_name):
                         if param.autofetch_schema:
                             self.fetch_schema(
                                 OSW.FetchSchemaParam(
                                     schema_title=category, mode="append"
                                 )
                             )
-                    if not hasattr(model, cls):
+                    if not hasattr(model, cls_name):
                         schemas_fetched = False
                         print(
-                            f"Error: Model {cls} not found. Schema {category} needs to "
-                            f"be fetched first."
+                            f"Error: Model {cls_name} not found. Schema {category} "
+                            f"needs to be fetched first."
                         )
             if not schemas_fetched:
                 continue
@@ -689,7 +695,7 @@ def load_entity(
                 print("Error: no schema defined")
 
             elif len(schemas) == 1:
-                cls = getattr(model, schemas[0]["title"])
+                cls: Type[model.Entity] = getattr(model, schemas[0]["title"])
                 entity: model.Entity = cls(**jsondata)
 
             else:
@@ -700,7 +706,7 @@ def load_entity(
                 entity: model.Entity = cls(**jsondata)
 
             if entity is not None:
-                # make sure we do not override existing meta data
+                # make sure we do not override existing metadata
                 if not hasattr(entity, "meta") or entity.meta is None:
                     entity.meta = model.Meta()
                 if (
@@ -775,6 +781,7 @@ class _ApplyOverwriteParam(OswBaseModel):
         namespace: Optional[str]
         meta_category_title: Optional[str]
         meta_category_template_str: Optional[str]
+        remove_empty: Optional[bool] = True
         inplace: Optional[bool] = False
         debug: Optional[bool] = False
 
@@ -840,8 +847,10 @@ def set_content(content_to_set: dict) -> None:
                 page.set_slot_content(slot_, content_to_set[slot_])
 
         # Create a variable to hold the new content
-        new_content = {  # required for json parsing and header rendering
-            "header": "{{#invoke:Entity|header}}",  # required for footer rendering
+        new_content = {
+            # required for json parsing and header rendering
+            "header": "{{#invoke:Entity|header}}",
+            # required for footer rendering
             "footer": "{{#invoke:Entity|footer}}",
         }
         # Take the shortcut if
@@ -853,6 +862,8 @@ def set_content(content_to_set: dict) -> None:
         ):
             # Use pydantic serialization, skip none values:
             new_content["jsondata"] = json.loads(param.entity.json(exclude_none=True))
+            if param.remove_empty:
+                remove_empty(new_content["jsondata"])
             set_content(new_content)
             page.changed = True
             return page  # Guard clause --> exit function
@@ -880,9 +891,10 @@ def set_content(content_to_set: dict) -> None:
         remote_content = {}
         # Get the remote content
         for slot in ["jsondata", "header", "footer"]:  # SLOTS:
-            remote_content[slot] = page.get_slot_content(
-                slot
-            )  # Todo: remote content does not contain properties that are not set
+            remote_content[slot] = page.get_slot_content(slot)
+            # Todo: remote content does not contain properties that are not set
+        if param.remove_empty:
+            remove_empty(remote_content["jsondata"])
         if remote_content["header"]:  # not None or {} or ""
             new_content["header"] = remote_content["header"]
         if remote_content["footer"]:
@@ -893,6 +905,8 @@ def set_content(content_to_set: dict) -> None:
         # Properties that are not set in the local content will be set to None
         # We want those not to be listed as keys
         local_content["jsondata"] = json.loads(param.entity.json(exclude_none=True))
+        if param.remove_empty:
+            remove_empty(local_content["jsondata"])
         if param.debug:
             print(f"'local_content': {str(remote_content)}")
         # Apply the overwrite logic
@@ -968,6 +982,12 @@ class StoreEntityParam(OswBaseModel):
         """A list of OverwriteClassParam objects. If a class specific overwrite setting
         is set, this setting is used.
         """
+        remove_empty: Optional[bool] = True
+        """If true, remove key with an empty string value from the jsondata."""
+        change_id: Optional[str] = None
+        """ID to document the change. Entities within the same store_entity() call will
+        share the same change_id. This parameter can also be used to link multiple
+        store_entity() calls."""
         meta_category_title: Optional[str] = "Category:Category"
         debug: Optional[bool] = False
         _overwrite_per_class: Dict[str, Dict[str, OSW.OverwriteClassParam]] = (
@@ -980,6 +1000,15 @@ def __init__(self, **data):
             super().__init__(**data)
             if not isinstance(self.entities, list):
                 self.entities = [self.entities]
+            if self.change_id is None:
+                self.change_id = str(uuid4())
+            for entity in self.entities:
+                if getattr(entity, "meta", None) is None:
+                    entity.meta = model.Meta()
+                if entity.meta.change_id is None:
+                    entity.meta.change_id = []
+                if self.change_id not in entity.meta.change_id:
+                    entity.meta.change_id.append(self.change_id)
             if len(self.entities) > 5 and self.parallel is None:
                 self.parallel = True
             if self.parallel is None:
@@ -1005,9 +1034,15 @@ def __init__(self, **data):
                     self._overwrite_per_class["by name"][model_name] = param
                     self._overwrite_per_class["by type"][model_type] = param
 
+    class StoreEntityResult(OswBaseModel):
+        """Result of store_entity()"""
+
+        change_id: str
+        """The ID of the change"""
+
     def store_entity(
         self, param: Union[StoreEntityParam, OswBaseModel, List[OswBaseModel]]
-    ) -> None:
+    ) -> StoreEntityResult:
         """stores the given dataclass instance as OSW page by calling BaseModel.json()
 
         Parameters
@@ -1067,22 +1102,26 @@ def store_entity_(
                     namespace=namespace_,
                     policy=overwrite_class_param,
                     meta_category_template_str=meta_category_template_str,
+                    remove_empty=param.remove_empty,
                     debug=param.debug,
                 )
             )
             if meta_category_template:
                 try:
+                    jsondata = page.get_slot_content("jsondata")
+                    if param.remove_empty:
+                        remove_empty(jsondata)
                     schema_str = eval_compiled_handlebars_template(
                         meta_category_template,
-                        page.get_slot_content("jsondata"),
+                        jsondata,
                         {
-                            "_page_title": entity_title,  # legacy
+                            "_page_title": entity_title,  # Legacy
                             "_current_subject_": entity_title,
                         },
                     )
                     schema = json.loads(schema_str)
-                    # put generated schema in definitions section
-                    # currently only enabled for Characteristics
+                    # Put generated schema in definitions section,
+                    #  currently only enabled for Characteristics
                     if hasattr(model, "CharacteristicType") and isinstance(
                         entity_, model.CharacteristicType
                     ):
@@ -1170,6 +1209,7 @@ def handle_upload_object_(upload_object: UploadObject) -> None:
                 handle_upload_object_(upload_object)
                 for upload_object in upload_object_list
             ]
+        return OSW.StoreEntityResult(change_id=param.change_id)
 
     class DeleteEntityParam(OswBaseModel):
         entities: Union[OswBaseModel, List[OswBaseModel]]
@@ -1187,7 +1227,9 @@ def __init__(self, **data):
                 self.parallel = False
 
     def delete_entity(
-        self, entity: Union[OswBaseModel, DeleteEntityParam], comment: str = None
+        self,
+        entity: Union[OswBaseModel, List[OswBaseModel], DeleteEntityParam],
+        comment: str = None,
     ):
         """Deletes the given entity/entities from the OSW instance."""
         if not isinstance(entity, OSW.DeleteEntityParam):
@@ -1195,28 +1237,28 @@ def delete_entity(
         if comment is not None:
             entity.comment = comment
 
-        def delete_entity_(entity, comment_: str = None):
+        def delete_entity_(entity_, comment_: str = None):
             """Deletes the given entity from the OSW instance.
 
             Parameters
             ----------
-            entity:
+            entity_:
                 The dataclass instance to delete
             comment_:
                 Command for the change log, by default None
             """
             title_ = None
             namespace_ = None
-            if hasattr(entity, "meta"):
-                if entity.meta and entity.meta.wiki_page:
-                    if entity.meta.wiki_page.title:
-                        title_ = entity.meta.wiki_page.title
-                    if entity.meta.wiki_page.namespace:
-                        namespace_ = entity.meta.wiki_page.namespace
+            if hasattr(entity_, "meta"):
+                if entity_.meta and entity_.meta.wiki_page:
+                    if entity_.meta.wiki_page.title:
+                        title_ = entity_.meta.wiki_page.title
+                    if entity_.meta.wiki_page.namespace:
+                        namespace_ = entity_.meta.wiki_page.namespace
             if namespace_ is None:
-                namespace_ = get_namespace(entity)
+                namespace_ = get_namespace(entity_)
             if title_ is None:
-                title_ = OSW.get_osw_id(entity.uuid)
+                title_ = OSW.get_osw_id(entity_.uuid)
             if namespace_ is None or title_ is None:
                 print("Error: Unsupported entity type")
                 return
@@ -1242,7 +1284,9 @@ def delete_entity_(entity, comment_: str = None):
             _ = [delete_entity_(e, entity.comment) for e in entity.entities]
 
     class QueryInstancesParam(OswBaseModel):
-        categories: Union[Union[str, OswBaseModel], List[Union[str, OswBaseModel]]]
+        categories: Union[
+            Union[str, Type[OswBaseModel]], List[Union[str, Type[OswBaseModel]]]
+        ]
         parallel: Optional[bool] = None
         debug: Optional[bool] = False
         limit: Optional[int] = 1000
@@ -1251,20 +1295,20 @@ class QueryInstancesParam(OswBaseModel):
 
         @staticmethod
         def get_full_page_name_parts(
-            category_: Union[str, OswBaseModel]
+            category_: Union[str, Type[OswBaseModel]]
         ) -> Dict[str, str]:
             error_msg = (
                 f"Category must be a string like 'Category:<category name>' or a "
-                f"dataclass instance with a 'type' attribute. This error occurred on "
+                f"dataclass subclass with a 'type' attribute. This error occurred on "
                 f"'{str(category_)}'"
             )
             if isinstance(category_, str):
                 string_to_split = category_
-            elif isinstance(category_, OswBaseModel):
-                type_ = getattr(category_, "type", None)
-                if type_ is None:
+            elif issubclass(category_, OswBaseModel):
+                type_ = category_.__fields__.get("type")
+                if getattr(type_, "default", None) is None:
                     raise TypeError(error_msg)
-                string_to_split = type_[0]
+                string_to_split = type_.default[0]
             else:
                 raise TypeError(error_msg)
             if "Category:" not in string_to_split:
diff --git a/src/osw/custom_types.py b/src/osw/custom_types.py
new file mode 100644
index 00000000..f785bc80
--- /dev/null
+++ b/src/osw/custom_types.py
@@ -0,0 +1,23 @@
+import sys
+from pathlib import Path
+
+from pydantic.v1.types import FilePath as PydanticFilePath
+from pydantic.v1.validators import path_validator  # , path_exists_validator
+
+if sys.version_info < (3, 10):
+    NoneType = type(None)
+else:
+    from types import NoneType  # noqa: F401
+
+
+class PossibleFilePath(PydanticFilePath):
+    # Overwrite the Pydantic FilePath class to allow non-existing paths
+    @classmethod
+    def __get_validators__(cls):
+        yield path_validator
+        # yield path_exists_validator  # Remove this line to allow non-existing paths
+        yield cls.validate
+
+    @classmethod
+    def validate(cls, value: Path) -> Path:
+        return value
diff --git a/src/osw/data/import_utility.py b/src/osw/data/import_utility.py
index 37f936fa..9a0bb122 100644
--- a/src/osw/data/import_utility.py
+++ b/src/osw/data/import_utility.py
@@ -1,20 +1,22 @@
 import copy
+import inspect
 import re
 import uuid as uuid_module
 import warnings
 from pathlib import Path
-from typing import Any, Callable, Dict, List, Optional, Union
+from typing import Any, Callable, Dict, List, Optional, Type, Union
 
 import deepl
 import numpy as np
 from geopy import Nominatim
 from jsonpath_ng import ext as jp
+from pydantic.v1.fields import ModelField
 
-import osw.utils.strings as strutil
 from osw import wiki_tools as wt
 from osw.auth import CredentialManager
 from osw.core import OSW
 from osw.model import entity as model
+from osw.utils.regex import MatchResult, RegExPatternExtended
 from osw.utils.regex_pattern import REGEX_PATTERN_LIB, REGEX_PATTERN_LIST
 from osw.wtsite import WtSite
 
@@ -28,42 +30,117 @@
 
 # Classes
 class HelperModel(model.OswBaseModel):
+    """Helper class for model transformations. The first base of the inheriting class
+    should always be the target class and the second base should be this class.
+
+    Example
+    -------
+    >>> class Person(model.OswBaseModel):
+    >>>     first_name: str
+    >>>     surname: str
+    >>>     email: Set[str]
+    >>>
+    >>> john_dict = {"FirstName": "John", "LastName": "Doe", "Email": {
+    "john.doe@example.com"}}
+    >>>
+    >>> class PersonHelper(Person, HelperModel):
+    >>>     FirstName: Any
+    >>>     LastName: Any
+    >>>     Email: Any
+    >>>
+    >>>     def transform_attributes(self, dd: dict = None) -> bool:
+    >>>         super().transform_attributes(dd)
+    >>>         self.first_name = self.FirstName
+    >>>         self.surname = self.LastName
+    >>>         self.email = {self.Email}
+    >>>         return True
+    """
+
     # Custom attributes
     attributes_transformed: bool = False
     references_transformed: bool = False
     casted_instance: Any = None
     full_page_title: Optional[str]
 
-    def transform_attributes(self, dd: dict) -> bool:
+    class Config:
+        arbitrary_types_allowed = True
+
+    def __init_subclass__(cls, **kwargs):
+        """Will overwrite the annotations and fields of the inheriting class,
+        defined in the first base class with Optional[Any] annotations. This is
+        necessary to prevent errors when casting to the inheriting class."""
+        super().__init_subclass__(**kwargs)
+        first_base: Type[model.OswBaseModel] = cls.__bases__[0]
+        if not issubclass(first_base, model.OswBaseModel):
+            return None
+
+        model_fields = {}
+        constructor_kwargs = [
+            ele
+            for ele in list(inspect.signature(ModelField.__init__).parameters.keys())
+            if ele != "self"
+        ]
+        for field_name in first_base.__fields__:
+            model_field_kwargs = {}
+            for arg in constructor_kwargs:
+                arg_val = getattr(first_base.__fields__[field_name], arg)
+                if arg_val is None:
+                    # Keeps the default values of the ModelField class constructor
+                    continue
+                # Reuses values originally passed to construct the ModelField instance
+                model_field_kwargs[arg] = arg_val
+            # Overwrite the type and required model_field_kwargs
+            model_field_kwargs["type_"] = Optional[Any]
+            model_field_kwargs["required"] = False
+            # Create a new ModelField instance
+            model_fields[field_name] = ModelField(**model_field_kwargs)
+        for field_name in model_fields:
+            if field_name in cls.__fields__:  # Replace existing fields
+                cls.__fields__[field_name] = model_fields[field_name]
+            if field_name in cls.__annotations__:  # Replace existing annotations
+                cls.__annotations__[field_name] = Optional[Any]
+
+    def transform_attributes(self, dd: dict = None) -> bool:
         if not self.attributes_transformed:
-            uuid = uuid_module.uuid4()
-            if hasattr(self, "uuid"):
-                if self.uuid is not None:
-                    uuid = self.uuid
-            self.full_page_title = uuid_to_full_page_title(uuid=uuid)
+            if getattr(self, "uuid", None) is None:
+                if hasattr(self, "uuid"):
+                    self.uuid = uuid_module.uuid4()
+            self.full_page_title = self.get_iri()
             # All set successfully
             self.attributes_transformed = True
         return True
 
-    def transform_references(self, dd: dict) -> bool:
+    def transform_references(self, dd: dict = None) -> bool:
+        # Ensure that the attributes are transformed before transforming the references
         if not self.attributes_transformed:
             self.transform_attributes(dd)
         if not self.references_transformed:
             # Test casting
-            superclass = self.__class__.__bases__[0]
-            self.casted_instance = self.cast_none_to_default(cls=superclass)
+            # superclass = self.__class__.__bases__[0]
+            # Todo: this might cast with attributes not yet set
+            # self.casted_instance = self.cast_none_to_default(cls=superclass)
             # All set successfully
             self.references_transformed = True
         return True
 
-    def cast_to_superclass(self, dd):
+    def cast_to_superclass(
+        self, dd: dict = None, return_casted: bool = False
+    ) -> Union[bool, Type[model.OswBaseModel]]:
+        """Casts the instance to the superclass of the inheriting class. Assumes that
+        the first base of the inheriting class is the target class."""
         if not self.references_transformed:
             self.transform_references(dd)
-        else:
-            superclass = self.__class__.__bases__[0]
-            self.casted_instance = self.cast_none_to_default(cls=superclass)
+
+        superclass = self.__class__.__bases__[0]
+        self.casted_instance = self.cast_none_to_default(cls=superclass)
+        if return_casted:
+            return self.casted_instance
         return True
 
+    @property
+    def transformed(self):
+        return self.cast_to_superclass(return_casted=True)
+
 
 # Functions
 def transform_attributes_and_merge(
@@ -89,6 +166,7 @@ def transform_attributes_and_merge(
     if not inplace:
         ent = copy.deepcopy(ent)
         ent_as_dict = copy.deepcopy(ent_as_dict)
+    # Transform attributes
     ent, ent_as_dict = loop_and_call_method(
         entities=ent,
         method_name="transform_attributes",
@@ -162,6 +240,7 @@ def get_uuid_from_object_via_type(obj: Any) -> Union[uuid_module.UUID, None]:
 
 def get_lang_specific_label(label: list, lang: str) -> Union[str, None]:
     """Get the label in a specific language from a list of labels"""
+    # todo: rework to not break on missing LangCode
     for ele in label:
         if ele["lang"] == model.LangCode(lang):
             return ele["text"]
@@ -310,15 +389,15 @@ def jsonpath_search_and_return_list(
     -------
     Searching through entites of type HelperWikiFile within all entities (
     entities_as_dict) and returning the full_page_title of entities matching filename
-    by the attribute name. Afterwards the attribute image is set to the result if it any
-    >>> res = diu.jsonpath_search_and_return_list(
+    by the attribute name. Afterward the attribute image is set to the result if it any
+    >>> res = jsonpath_search_and_return_list(
     >>>     jp_str=f'*[?name = "{filename}"]',
     >>>     val_key="full_page_title",
     >>>     search_tar=entities_as_dict,
     >>>     class_to_match=HelperWikiFile,
     >>> )
     >>> if len(res) > 0:
-    >>>     self.image = res[0]
+    >>>     image = res[0]
 
     """
     jp_parse = jp.parse(path=jp_str)
@@ -423,8 +502,8 @@ def nan_empty_or_none(inp: Any) -> bool:
 
 
 def regex_match_list(
-    pattern: Union[str, strutil.RegExPatternExtended], list_of_strings: List[str]
-) -> List[Union[str, strutil.MatchResult]]:
+    pattern: Union[str, RegExPatternExtended], list_of_strings: List[str]
+) -> List[Union[str, MatchResult]]:
     """Returns a subset of the 'list_of_strings' that matched the regex 'pattern'.
 
     Parameters
@@ -443,7 +522,7 @@ def regex_match_list(
             if re.match(pattern=pattern, string=string):
                 matches.append(string)
         return matches
-    elif isinstance(pattern, strutil.RegExPatternExtended):
+    elif isinstance(pattern, RegExPatternExtended):
         matches = []
         for string in list_of_strings:
             match_result_obj = pattern.match(string)
diff --git a/src/osw/express.py b/src/osw/express.py
index d3e90349..e4bc6774 100644
--- a/src/osw/express.py
+++ b/src/osw/express.py
@@ -5,13 +5,17 @@
 
 import importlib.util
 import re
+from io import TextIOWrapper
 from pathlib import Path
+from uuid import uuid4
 from warnings import warn
 
 from typing_extensions import (
     IO,
     TYPE_CHECKING,
     Any,
+    AnyStr,
+    Buffer,
     Dict,
     List,
     Optional,
@@ -23,6 +27,7 @@
 from osw.auth import CREDENTIALS_FN_DEFAULT, CredentialManager
 from osw.core import OSW, OVERWRITE_CLASS_OPTIONS, OverwriteOptions
 from osw.model.static import OswBaseModel
+from osw.utils.wiki import namespace_from_full_title, title_from_full_title
 from osw.wtsite import WtSite
 
 # Definition of constants
@@ -31,8 +36,11 @@
 DOWNLOAD_DIR_DEFAULT = BASE_PATH / "osw_files" / "downloads"
 
 DEPENDENCIES = {
-    # "Entity": "Category:Entity",  # depends on nothing
+    # "Entity": "Category:Entity",  # depends on nothing#
+    "Category": "Category:Category",  # depends on Entity
+    "Property": "Category:Property",  # depends on Entity
     # "Item": "Category:Item",  # depends on Entity
+    "Characteristics": "Category:OSW93ccae36243542ceac6c951450a81d47",  # depends on Item
     # "Data": "Category:OSW2ac4493f8635481eaf1db961b63c8325", # depends on Item
     # "File": "Category:OSWff333fd349af4f65a69100405a9e60c7",  # depends on Data
     "LocalFile": "Category:OSW3e3f5dd4f71842fbb8f270e511af8031",  # depends on File
@@ -63,6 +71,8 @@ def __repr__(self):
     def __eq__(self, other):
         if isinstance(other, CredentialsFpDefault):
             return self.default == other.default
+        elif isinstance(other, DownloadDirDefault):
+            return self.default == other.default
         return False
 
     @property
@@ -178,19 +188,22 @@ def __init__(
         self.cred_filepath = cred_filepath
 
     def __enter__(self):
+        """Return self when entering the context manager."""
         return self
 
     def __exit__(self):
+        """Close the connection to the OSL instance when exiting the context manager."""
         self.close_connection()
 
     def close_connection(self):
+        """Close the connection to the OSL instance."""
         self.site._site.connection.close()
 
     def shut_down(self):
+        """Makes sure this OSL instance can't be reused after it was shut down,
+        as the connection can't be reopened except when initializing a new instance."""
         self.close_connection()
         del self
-        # Make sure this osw instance can't be reused after it was shut down (the
-        # connection can't be reopened except when initializing a new instance)
 
     def install_dependencies(
         self,
@@ -291,6 +304,7 @@ def upload_file(
         label: Optional[List[model.Label]] = None,
         name: Optional[str] = None,
         description: Optional[List[model.Description]] = None,
+        change_id: Optional[str] = None,
         **properties: Dict[str, Any],
     ) -> "UploadFileResult":
         """Upload a file to an OSL page.
@@ -318,6 +332,9 @@ def upload_file(
         description
             The description to set on the WikiFile data model prior to uploading it to the
             OSL instance.
+        change_id
+            The id of the change to use for the upload. If None, a new change id will be
+            generated.
         properties
             The properties to set on the WikiFile data model prior to uploading it to
             the OSL instance. Properties listed here, won't overwrite properties handed
@@ -334,8 +351,10 @@ def upload_file(
         data = {**locals(), **properties}
         # Clean data dict to avoid passing None values
         data = {key: value for key, value in data.items() if value is not None}
+        # Make sure self is passed as osw_express
+        data["osw_express"] = self
         # Initialize the UploadFileResult object
-        return UploadFileResult(source=source, osw_express=self, **data)
+        return UploadFileResult(source=source, **data)
 
 
 class DataModel(OswBaseModel):
@@ -350,7 +369,11 @@ class DataModel(OswBaseModel):
 
 
 def import_with_fallback(
-    to_import: List[DataModel], dependencies: Dict[str, str] = None, domain: str = None
+    to_import: Union[List[DataModel], Dict[str, str]],
+    caller_globals: dict,
+    module: str = None,
+    dependencies: Dict[str, str] = None,
+    domain: str = None,
 ):
     """Imports data models with a fallback to fetch the dependencies from an OSL
     instance if the data models are not available in the local osw.model.entity module.
@@ -358,7 +381,14 @@ def import_with_fallback(
     Parameters
     ----------
     to_import
-        List of DataModel objects to import.
+        List of DataModel objects or a dictionary, with (key: value) pairs (class_name:
+        osw_fpt) to import.
+    caller_globals
+        (Mandatory!) The globals dictionary as returned by globals() in the calling
+        script.
+    module
+        (Optional) The module to import the data models from. Used only if to_import
+        is of type List[Dict]. Defaults to 'osw.model.entity' if not specified.
     dependencies
         A dictionary with the keys being the names of the dependencies and the values
         being the full page name of the dependencies.
@@ -366,14 +396,41 @@ def import_with_fallback(
         The domain of the OSL instance to connect to, if the dependencies are not
         available in the local osw.model.entity module.
 
+    Examples
+    --------
+    >>> import_with_fallback(
+    >>>     [DataModel(module="osw.controller.file.base",class_name="FileController")],
+    >>>     globals(),
+    >>> )
+
     Returns
     -------
-
+    None
     """
+    if isinstance(to_import, dict):
+        # The arg 'to_import' should have the right structure to act as 'dependencies'
+        # Assume all DataModels are part of osw.model.entity
+        if module is None:
+            module = "osw.model.entity"
+        # A list of DataModels will be derived from the dict
+        to_import = [
+            DataModel(
+                module=module,
+                class_name=key,
+                osw_fpt=value,
+            )
+            for key, value in to_import.items()
+        ]
+    if to_import is None:
+        raise ValueError(
+            "Either the argument 'to_import' or 'dependencies' must be passed to the "
+            "function import_with_fallback()."
+        )
     try:
+        # Try to import the listed data models from the (listed) module(s)
         for ti in to_import:
             # Raises AttributeError if the target could not be found
-            globals()[ti.class_name] = getattr(
+            caller_globals[ti.class_name] = getattr(
                 importlib.import_module(ti.module), ti.class_name
             )
     except Exception as e:
@@ -381,24 +438,23 @@ def import_with_fallback(
             dependencies = {}
             warn(
                 "No 'dependencies' were passed to the function "
-                "import_with_fallback()!"
+                "import_with_fallback()! Trying to derive them from 'to_import'."
             )
         new_dependencies = {
-            f"{module.class_name}": module.osw_fpt
+            module.class_name: module.osw_fpt
             for module in to_import
             if module.osw_fpt is not None
         }
+        dependencies.update(new_dependencies)
         if not dependencies:
-            # If dependencies is an empty dict,
             raise AttributeError(
                 f"An exception occurred while loading the module dependencies: \n'{e}'"
                 "No 'dependencies' were passed to the function import_with_fallback() "
                 "and could not be derived from 'to_import'!"
             )
-        dependencies.update(new_dependencies)
         warn(
             f"An exception occurred while loading the module dependencies: \n'{e}'"
-            "You will be now have to connect to an OSW instance to fetch the "
+            "You will now have to connect to an OSW instance to fetch the "
             "dependencies from!"
         )
         if domain is None:
@@ -409,10 +465,10 @@ def import_with_fallback(
 
         osw_express.install_dependencies(dependencies, mode="append")
         osw_express.shut_down()  # Avoiding connection error
-        # Try again
+        # Try again to import the data models
         for ti in to_import:
             # Raises AttributeError if the target could not be found
-            globals()[ti.class_name] = getattr(
+            caller_globals[ti.class_name] = getattr(
                 importlib.import_module(ti.module), ti.class_name
             )
 
@@ -436,6 +492,7 @@ def import_with_fallback(
             class_name="WikiFileController",
         ),
     ],
+    caller_globals=globals(),
     dependencies=DEPENDENCIES,
 )
 
@@ -449,7 +506,7 @@ def import_with_fallback(
 class FileResult(OswBaseModel):
     url_or_title: Optional[str] = None
     """The URL or full page title of the WikiFile page."""
-    file: Optional[TextIO] = None
+    file_io: Optional[Union[TextIO, TextIOWrapper, None]] = None
     """The file object. They type depends on the file type."""
     mode: str = "r"
     """The mode to open the file in. Default is 'r'. Implements the built-in open."""
@@ -476,20 +533,45 @@ class FileResult(OswBaseModel):
     class Config:
         arbitrary_types_allowed = True
 
-    def open(self, mode: str = "r", **kwargs):
+    def open(self, mode: str = None, **kwargs) -> TextIO:
+        """Open the file, if not already opened using the 'mode' argument (priority) or
+        the 'mode' attribute."""
+        if mode is None:
+            mode = self.mode
         kwargs["mode"] = mode
-        return open(self.path, **kwargs)
+        if self.file_io is None or self.file_io.closed:
+            self.file_io = open(self.path, **kwargs)
+        return self.file_io
+
+    def close(self) -> None:
+        """Close the file, if not already closed."""
+        if self.file_io is None or self.file_io.closed:
+            warn("File already closed or not opened.")
+        else:
+            self.file_io.close()
 
-    def close(self):
-        self.file.close()
+    def read(self, n: int = -1) -> AnyStr:
+        """Read the file. If n is not specified, the entire file will be read.
+        If the file is not already opened, it will be opened."""
+        if self.file_io is None or self.file_io.closed:
+            self.file_io = self.open(mode="r")
+        return self.file_io.read(n)
 
-    def read(self, *args, **kwargs):
-        return self.file.read(*args, **kwargs)
+    def write(self, s: Union[Buffer, AnyStr]):
+        """Write to the file. If the file is not already opened, it will be opened."""
+        if self.file_io is None or self.file_io.closed:
+            self.file_io = self.open(mode="w")
+        return self.file_io.write(s)
 
     def __enter__(self):
+        """Open the file when entering the context manager."""
+        if self.file_io is None or self.file_io.closed:
+            self.open()
         return self
 
     def __exit__(self, exc_type, exc_value, traceback):
+        """Close the file when exiting the context manager, and deletes the file if
+        'delete_after_use' was set."""
         self.close()
         if self.delete_after_use and self.path.exists():
             self.path.unlink()
@@ -505,6 +587,14 @@ def process_init_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
             if data.get(key) is None:
                 data[key] = value
         # Do replacements
+        if (
+            data.get("label") == InMemoryController.__fields__["label"].default
+            or data.get("label") == LocalFileController.__fields__["label"].default
+            or data.get("label") == WikiFileController.__fields__["label"].default
+        ):
+            # Make sure that the label is not set to the default value, it will be
+            # set by the source file controller
+            del data["label"]
         if data.get("cred_filepath") is None:
             data["cred_filepath"] = cred_filepath_default.get_default()
         if not data.get("cred_filepath").parent.exists():
@@ -603,8 +693,7 @@ def __init__(self, url_or_title, **data):
             data = {key: value for key, value in data.items() if value is not None}
             super().__init__(**{**lf.dict(), **data})
             self.put_from(wf)
-        # Do open
-        self.file = self.open(mode=data.get("mode"))
+        # File is only opened at request to avoid locking the file
 
 
 def osw_download_file(
@@ -700,7 +789,7 @@ class UploadFileResult(FileResult, WikiFileController):
     overwrite: OVERWRITE_CLASS_OPTIONS = OverwriteOptions.true
     """If True, the file will be overwritten if it already exists. If False, the file
     will not be uploaded if it already exists. See osw.core for more information."""
-    change_id: Optional[List[str]] = None
+    change_id: Optional[str] = None
     """The change ID of the WikiFile page to upload the file to, stored in the meta
     property."""
 
@@ -732,7 +821,11 @@ def __init__(
             data["source_file_controller"] = LocalFileController(path=data.get("path"))
         elif isinstance(source, IO):
             data["source_file_controller"] = InMemoryController(stream=source)
-
+        else:
+            raise ValueError(
+                "The 'source' argument must be a LocalFileController, WikiFileController,"
+                " str, Path or IO object."
+            )
         # If url_or_title is given, it either
         # * contains a valid domain, which can be used in osw_express
         # * contains a full page title, which is the target page
@@ -777,19 +870,36 @@ def __init__(
                 )
         # Create an osw_express object if not given
         if data.get("osw_express") is None:
-            data["osw_express"] = OswExpress(
-                domain=data.get("domain"),
-                cred_mngr=data.get("cred_mngr"),
-            )
+            create_new = True
+            # Try to get the osw_express object from the source_file_controller
+            if data.get("source_file_controller") is not None:
+                if hasattr(data["source_file_controller"], "osw_express"):
+                    create_new = False
+                    data["osw_express"] = data["source_file_controller"].osw_express
+            # Otherwise create a new osw_express object
+            if create_new:
+                data["osw_express"] = OswExpress(
+                    domain=data.get("domain"),
+                    cred_mngr=data.get("cred_mngr"),
+                )
+        # If no change_id is given, generate a new one
+        if data.get("change_id") is None:
+            data["change_id"] = str(uuid4())
+        # Change_id will be returned in the UploadFileResult.change_id attribute
         # If given set titel and namespace
         if data.get("target_fpt") is not None:
-            namespace = data.get("target_fpt").split(":")[0]
-            title = data.get("target_fpt").split(":")[-1]
+            namespace = namespace_from_full_title(data.get("target_fpt"))
+            title = title_from_full_title(data.get("target_fpt"))
             wiki_page = model.WikiPage(namespace=namespace, title=title)
             data["meta"] = model.Meta(wiki_page=wiki_page)
-            if data.get("change_id") is not None:
-                data["meta"].change_id = data.get("change_id")
             data["title"] = title
+        # Set change_id to existing meta
+        for meta in [data.get("meta"), getattr(data["source_file_controller"], "meta")]:
+            if meta is not None:
+                if getattr(meta, "change_id", None) is None:
+                    meta.change_id = [data["change_id"]]
+                else:
+                    meta.change_id.append(data.get("change_id"))
         # Clean data dict
         data = {key: value for key, value in data.items() if value is not None}
         # Create the WikiFileController from the source_file_controller
@@ -797,15 +907,14 @@ def __init__(
             other=data.get("source_file_controller"),
             osw=data.get("osw_express"),
             **data,
-            # Passes arguments to the cast() method, e.g. overwrite the label
-            # cast method will call init
+            # Passes arguments to the cast() method, e.g., overwrite the label
+            #  cast method will call init
         )
         # Upload to the target OSW instance
         wfc.put_from(data.get("source_file_controller"), **data)
         data["url_or_title"] = wfc.url
-        super().__init__(
-            **{**wfc.dict(), **data}
-        )  # Don't open the local (uploaded) file
+        super().__init__(**{**wfc.dict(), **data})
+        # Don't open the local (uploaded) file
 
 
 def osw_upload_file(
@@ -820,6 +929,7 @@ def osw_upload_file(
     label: Optional[List[model.Label]] = None,
     name: Optional[str] = None,
     description: Optional[List[model.Description]] = None,
+    change_id: Optional[str] = None,
     **properties: Dict[str, Any],
 ) -> UploadFileResult:
     """Upload a file to an OSL page.
@@ -861,6 +971,9 @@ def osw_upload_file(
     description
         The description to set on the WikiFile data model prior to uploading it to the
         OSL instance.
+    change_id
+        The change ID of the WikiFile page to upload the file to, stored in the meta
+        property.
     properties
         The properties to set on the WikiFile data model prior to uploading it to
         the OSL instance. Properties listed here, won't overwrite properties handed
@@ -905,4 +1018,3 @@ def osw_upload_file(
 #      * Save a pandas.DataFrame to a WikiFile (as table, e.g. as csv, xlsx,
 #        json)
 #    * Save a wiki page as pdf
-#  * make upload function work with IO objects
diff --git a/src/osw/model/entity.py b/src/osw/model/entity.py
index 68cb8f7c..d2310b56 100644
--- a/src/osw/model/entity.py
+++ b/src/osw/model/entity.py
@@ -47,6 +47,7 @@ class WikiPage(OswBaseModel):
 
 
 class Meta(OswBaseModel):
+    uuid: UUID = Field(default_factory=uuid4, title="UUID")
     wiki_page: Optional[WikiPage] = Field(None, title="Wiki page")
     """
     The wiki page containing this entity
diff --git a/src/osw/model/static.py b/src/osw/model/static.py
index fbe73d18..efce2d7d 100644
--- a/src/osw/model/static.py
+++ b/src/osw/model/static.py
@@ -2,9 +2,13 @@
 This module is to be imported in the dynamically created and updated entity.py module.
 """
 
-from typing import TYPE_CHECKING, Type, TypeVar, Union
+from typing import TYPE_CHECKING, Literal, Optional, Type, TypeVar, Union
+from uuid import UUID
 
-from pydantic.v1 import BaseModel
+from pydantic.v1 import BaseModel, Field, constr
+
+from osw.custom_types import NoneType
+from osw.utils.strings import pascal_case
 
 T = TypeVar("T", bound=BaseModel)
 
@@ -20,8 +24,96 @@
     _basemodel_decorator = lambda x: x  # noqa: E731
 
 
+def custom_issubclass(obj: Union[type, T], class_name: str) -> bool:
+    """
+    Custom issubclass function that checks if the object is a subclass of a class
+    with the given name.
+
+    Parameters
+    ----------
+    obj : object
+        The object to check.
+    class_name : str
+        The name of the class to check against.
+
+    Returns
+    -------
+    bool
+        True if the object is a subclass of the class with the given name,
+        False otherwise.
+    """
+
+    def check_bases(cls, name):
+        if hasattr(cls, "__name__") and cls.__name__ == name:
+            return True
+        if not hasattr(cls, "__bases__"):
+            return False
+        for base in cls.__bases__:
+            if check_bases(base, name):
+                return True
+        return False
+
+    return check_bases(obj, class_name)
+
+
+def custom_isinstance(obj: Union[type, T], class_name: str) -> bool:
+    """
+    Custom isinstance function that checks if the object is an instance of a class with
+    the given name.
+
+    Parameters
+    ----------
+    obj : object
+        The object to check.
+    class_name : str
+        The name of the class to check against.
+
+    Returns
+    -------
+    bool
+        True if the object is an instance of the class with the given name,
+        False otherwise.
+    """
+    if not hasattr(obj, "__class__"):
+        return False
+
+    return custom_issubclass(obj.__class__, class_name)
+
+
 @_basemodel_decorator
 class OswBaseModel(BaseModel):
+
+    class Config:
+        #     strict = False
+        # Additional fields are allowed
+        validate_assignment = True
+        # Ensures that the assignment of a value to a field is validated
+
+    def __init__(self, **data):
+        if data.get("label"):
+            if not isinstance(data["label"], list):
+                raise ValueError(
+                    "label must be a list of Label objects",
+                )
+            labels = []
+            for label in data["label"]:
+                if isinstance(label, dict):
+                    labels.append(Label(**label))
+                else:
+                    # The list element should be a Label object
+                    labels.append(label)
+            data["label"] = labels
+            # Ensure that the label attribute is a list of Label objects, but use
+            #  custom_isinstance to avoid circular imports and ValidationError since
+            #  osw.model.entity defines its own Label class
+            if not all(custom_isinstance(label, "Label") for label in data["label"]):
+                raise ValueError(
+                    "label must be a list of Label objects",
+                )
+        if data.get("name") is None and "label" in data:
+            data["name"] = pascal_case(data["label"][0].text)
+        super().__init__(**data)
+
     def full_dict(self, **kwargs):  # extent BaseClass export function
         d = super().dict(**kwargs)
         for key in ("_osl_template", "_osl_footer"):
@@ -54,7 +146,12 @@ def cast_none_to_default(self, cls: Union[Type[T], type], **kwargs) -> T:
         includes None values, the attribute is not passed to the instance of the
         target class, which will then fall back to the default."""
 
-        def test_if_empty_list_or_none(obj) -> bool:
+        def test_if_empty_list_or_none(
+            obj: Union[
+                NoneType,
+                list,
+            ]
+        ) -> bool:
             if obj is None:
                 return True
             elif isinstance(obj, list):
@@ -68,9 +165,160 @@ def test_if_empty_list_or_none(obj) -> bool:
             k: v for k, v in self.dict().items() if not test_if_empty_list_or_none(v)
         }
         combined_args = {**self_args, **kwargs}
-        del combined_args["type"]
+        if "type" in combined_args:
+            del combined_args["type"]
         return cls(**combined_args)
 
+    def get_uuid(self) -> Union[str, UUID, NoneType]:
+        """Getter for the attribute 'uuid' of the entity
+
+        Returns
+        -------
+            The uuid as a string or None if the uuid could not be determined
+        """
+        return getattr(self, "uuid", None)
+
+    def get_osw_id(self) -> Union[str, NoneType]:
+        """Determines the OSW-ID based on the entity's uuid.
+
+        Returns
+        -------
+            The OSW-ID as a string or None if the OSW-ID could not be determined
+        """
+        return get_osw_id(self)
+
+    def get_namespace(self) -> Union[str, NoneType]:
+        """Determines the wiki namespace based on the entity's type/class
+
+        Returns
+        -------
+            The namespace as a string or None if the namespace could not be determined
+        """
+        return get_namespace(self)
+
+    def get_title(self) -> Union[str, NoneType]:
+        """Determines the wiki page title based on the entity's data
+
+        Returns
+        -------
+            The title as a string or None if the title could not be determined
+        """
+        return get_title(self)
+
+    def get_iri(self) -> Union[str, NoneType]:
+        """Determines the IRI / wiki full title (namespace:title) based on the entity's
+        data
+
+        Returns
+        -------
+            The full title as a string or None if the title could not be determined.
+        """
+        return get_full_title(self)
+
+
+def get_osw_id(entity: Union[OswBaseModel, Type[OswBaseModel]]) -> Union[str, NoneType]:
+    """Determines the OSW-ID based on the entity's data - either from the entity's
+    attribute 'osw_id' or 'uuid'.
+
+    Parameters
+    ----------
+    entity
+        The entity to determine the OSW-ID for
+
+    Returns
+    -------
+        The OSW-ID as a string or None if the OSW-ID could not be determined
+    """
+    osw_id = getattr(entity, "osw_id", None)
+    uuid = entity.get_uuid()
+    from_uuid = None if uuid is None else f"OSW{str(uuid).replace('-', '')}"
+    if osw_id is None:
+        return from_uuid
+    if osw_id != from_uuid:
+        raise ValueError(f"OSW-ID does not match UUID: {osw_id} != {from_uuid}")
+    return osw_id
+
+
+def get_namespace(
+    entity: Union[OswBaseModel, Type[OswBaseModel]]
+) -> Union[str, NoneType]:
+    """Determines the wiki namespace based on the entity's type/class
+
+    Parameters
+    ----------
+    entity
+        The entity to determine the namespace for
+
+    Returns
+    -------
+        The namespace as a string or None if the namespace could not be determined
+    """
+    namespace = None
+
+    if hasattr(entity, "meta") and entity.meta and entity.meta.wiki_page:
+        if entity.meta.wiki_page.namespace:
+            namespace = entity.meta.wiki_page.namespace
+
+    if namespace is None:
+        if custom_issubclass(entity, "Entity"):
+            namespace = "Category"
+        elif custom_isinstance(entity, "Category"):
+            namespace = "Category"
+        elif custom_issubclass(entity, "Characteristic"):
+            namespace = "Category"
+        elif custom_isinstance(entity, "Item"):
+            namespace = "Item"
+        elif custom_isinstance(entity, "Property"):
+            namespace = "Property"
+        elif custom_isinstance(entity, "WikiFile"):
+            namespace = "File"
+
+    return namespace
+
+
+def get_title(entity: OswBaseModel) -> Union[str, NoneType]:
+    """Determines the wiki page title based on the entity's data
+
+    Parameters
+    ----------
+    entity
+        the entity to determine the title for
+
+    Returns
+    -------
+        the title as a string or None if the title could not be determined
+    """
+    title = None
+
+    if hasattr(entity, "meta") and entity.meta and entity.meta.wiki_page:
+        if entity.meta.wiki_page.title:
+            title = entity.meta.wiki_page.title
+
+    if title is None:
+        title = get_osw_id(entity)
+
+    return title
+
+
+def get_full_title(entity: OswBaseModel) -> Union[str, NoneType]:
+    """determines the wiki full title (namespace:title) based on the entity's data
+
+    Parameters
+    ----------
+    entity
+        the entity to determine the full title for
+
+    Returns
+    -------
+        the full title as a string or None if the title could not be determined
+    """
+    namespace = get_namespace(entity)
+    title = get_title(entity)
+    if namespace is not None and title is not None:
+        return namespace + ":" + title
+    elif title is not None:
+        return title
+
 
 class Ontology(OswBaseModel):
     iri: str
@@ -78,3 +326,8 @@ class Ontology(OswBaseModel):
     name: str
     prefix_name: str
     link: str
+
+
+class Label(OswBaseModel):
+    text: constr(min_length=1) = Field(..., title="Text")
+    lang: Optional[Literal["en", "de"]] = Field("en", title="Lang code")
diff --git a/src/osw/ontology.py b/src/osw/ontology.py
index 34b55535..699601d6 100644
--- a/src/osw/ontology.py
+++ b/src/osw/ontology.py
@@ -738,7 +738,7 @@ def _store_ontology(self, param: StoreOntologyParam):
                 if not hasattr(e, "subclass_of"):
                     e.subclass_of = []
                 if len(e.subclass_of) == 0:
-                    e.subclass_of = self.import_config.meta_class_title
+                    e.subclass_of.append(self.import_config.meta_class_title)
             elif namespace == "Property":
                 smw_import_type = "Type:" + e.cast(model.Property).property_type
             else:
diff --git a/src/osw/params.py b/src/osw/params.py
new file mode 100644
index 00000000..4130734b
--- /dev/null
+++ b/src/osw/params.py
@@ -0,0 +1,122 @@
+from osw.auth import CredentialManager  # noqa: F401
+from osw.core import (  # noqa: F401
+    OSW,
+    OVERWRITE_CLASS_OPTIONS,
+    AddOverwriteClassOptions,
+    OverwriteOptions,
+)
+from osw.express import (  # noqa: F401
+    CredentialsFpDefault,
+    DataModel,
+    DownloadDirDefault,
+    FilePathDefault,
+    OswExpress,
+)
+from osw.wiki_tools import SearchParam  # noqa: F401
+from osw.wtsite import WtPage, WtSite  # noqa: F401
+
+
+# From osw.auth.CredentialManager:
+class UserPwdCredential(CredentialManager.UserPwdCredential):
+    pass
+
+
+# Enum
+# class CredentialFallback(CredentialManager.CredentialFallback):
+#     pass
+
+
+class CredentialConfig(CredentialManager.CredentialConfig):
+    pass
+
+
+# From osw.core.OSW:
+class SchemaUnregistration(OSW.SchemaUnregistration):
+    pass
+
+
+class SchemaRegistration(OSW.SchemaRegistration):
+    pass
+
+
+# Enum
+# class FetchSchemaMode(OSW.FetchSchemaMode):
+#     pass
+
+
+class FetchSchemaParam(OSW.FetchSchemaParam):
+    pass
+
+
+class LoadEntityParam(OSW.LoadEntityParam):
+    pass
+
+
+class OverwriteClassParam(OSW.OverwriteClassParam):
+    pass
+
+
+class StoreEntityParam(OSW.StoreEntityParam):
+    pass
+
+
+class DeleteEntityParam(OSW.DeleteEntityParam):
+    pass
+
+
+class QueryInstancesParam(OSW.QueryInstancesParam):
+    pass
+
+
+# From osw.wtsite.WtSite:
+class WtSiteConfig(WtSite.WtSiteConfig):
+    pass
+
+
+class GetPageParam(WtSite.GetPageParam):
+    pass
+
+
+class ModifySearchResultsParam(WtSite.ModifySearchResultsParam):
+    pass
+
+
+class UploadPageParam(WtSite.UploadPageParam):
+    pass
+
+
+class CopyPagesParam(WtSite.CopyPagesParam):
+    pass
+
+
+class CreatePagePackageParam(WtSite.CreatePagePackageParam):
+    pass
+
+
+class ReadPagePackageParam(WtSite.ReadPagePackageParam):
+    pass
+
+
+class UploadPagePackageParam(WtSite.UploadPagePackageParam):
+    pass
+
+
+class DeletePageParam(WtSite.DeletePageParam):
+    pass
+
+
+# From osw.wtsite.WtPage:
+class CopyPageConfig(WtPage.CopyPageConfig):
+    pass
+
+
+class PageDumpConfig(WtPage.PageDumpConfig):
+    pass
+
+
+class ExportConfig(WtPage.ExportConfig):
+    pass
+
+
+class ImportConfig(WtPage.ImportConfig):
+    pass
diff --git a/src/osw/utils/regex.py b/src/osw/utils/regex.py
new file mode 100644
index 00000000..3012d2d3
--- /dev/null
+++ b/src/osw/utils/regex.py
@@ -0,0 +1,357 @@
+import re
+from typing import Dict, List, Optional, Union
+
+from pydantic.v1 import validator
+
+from osw.model.static import OswBaseModel
+
+
+# Classes
+class RegExPatternExtended(OswBaseModel):
+    """A class that extends the re.Pattern class with additional attributes.
+
+    Example
+    -------
+    Definition
+    >>> pattern = RegExPatternExtended(
+    >>>     description="File page full page title (new format) to label",
+    >>>     pattern=r"File:(OS[WL]{1}[[a-f0-9]{32})"
+    >>>     "(\\.[a-zA-Z0-9]*[a-zA-Z]+[a-zA-Z0-9]*)",
+    >>>     group_keys=["Label", "Suffix(es)"],  # ["OSW-ID", "Suffix(es)"]
+    >>>     example_str="File:OSW11a53cdfbdc24524bf8ac435cbf65d9d.svg",
+    >>>     expected_groups=["OSW11a53cdfbdc24524bf8ac435cbf65d9d", ".svg"],
+    >>> )
+    Usage
+    >>> some_str = pattern.example_str
+    >>> match = pattern.match(some_str)
+    >>> label = match.groups["label"]
+    >>> suffix = match.groups["Suffix(es)"]
+    Testing
+    >>> pattern.test_pattern()
+
+
+    Resources
+    ---------
+    https://regex101.com/
+    """
+
+    pattern: Union[str, re.Pattern]
+    """The regular expression pattern. Can be a string or a compiled pattern."""
+    description: str
+    """A (unique!) description of the pattern. This is used as a key in a dictionary
+    containing all patterns and acting as a library."""
+    group_keys: List[str]
+    """A list of keys that can be used to access the match groups in the match
+    result. The number of keys must match the number of match groups in the pattern.
+    So the first key (list index 0) will be used to access the first match group
+    (index 1!)."""
+    example_str: Optional[str]
+    """An example string that can be used to test the pattern."""
+    expected_groups: Optional[List[str]]
+    """A list strings that are expected in the example_match.groups.values(). This is
+    used to test the pattern by asserting list(self.example_match.groups.values()) ==
+    self.expected_groups"""
+
+    class Config:
+        arbitrary_types_allowed = True
+
+    def __init__(self, **data):
+        super().__init__(**data)
+        if isinstance(self.pattern, str):
+            self.pattern = re.compile(self.pattern)
+
+    def match(self, string: str) -> "MatchResult":
+        """Return the match result of the given string and the pattern."""
+        return MatchResult(match=re.match(self.pattern, string), pattern=self)
+
+    def search(self, string: str) -> "SearchResult":
+        """Return the search result of the given string and the pattern."""
+        return SearchResult(match=re.search(self.pattern, string), pattern=self)
+
+    def finditer(self, string: str) -> List["MatchResult"]:
+        """Return a list of all match results of the given string and the pattern."""
+        return [
+            MatchResult(match=match, pattern=self)
+            for match in re.finditer(self.pattern, string)
+        ]
+
+    def findall(self, string: str) -> Dict[str, List[str]]:
+        """Return a list of all match groups of the given string and the pattern."""
+        result = {}
+        for grp_key in self.group_keys:
+            result[grp_key] = [mr.groups[grp_key] for mr in self.finditer(string)]
+        return result
+
+    def findall_by_group_key(self, string: str, group_key: str) -> List[str]:
+        """Return a list of all match groups of the given string and the pattern by
+        group key."""
+        if group_key not in self.group_keys:
+            raise ValueError(f"Group key '{group_key}' not found in pattern.")
+        return self.findall(string)[group_key]
+
+    @property
+    def example_match(self) -> "MatchResult":
+        """Return the match result of the example string and the pattern."""
+        return self.match(string=self.example_str)
+
+    @validator("group_keys")
+    def validate_group_keys(cls, group_keys, values) -> List[str]:
+        """Validate that the number of group keys matches the number of match groups,
+        defined in the pattern."""
+        pattern = values.get("pattern")
+        group_count = count_match_groups(pattern)
+        # Subtract non-capturing group count
+        if len(group_keys) != group_count:
+            raise ValueError(
+                f"The number of group keys ({len(group_keys)}) does not match "
+                f"the number of match groups ({group_count})."
+            )
+        return group_keys
+
+    def test_pattern(self) -> bool:
+        """Test the pattern by asserting self.example_match.groups.values()) ==
+        self.expected_groups"""
+        if list(self.example_match.groups.values()) == self.expected_groups:
+            return True
+        else:
+            return False
+
+
+class MatchResult(OswBaseModel):
+    match: Union[re.Match, None]
+    pattern: Union[RegExPatternExtended, None]
+
+    class Config:
+        arbitrary_types_allowed = True
+
+    @property
+    def groups(self):
+        """Return a dictionary representation of the object, enabling accessing the
+        match groups by key (as defined in RegExPatternExtended)."""
+        if self.match is None or self.pattern is None:
+            return None  # []
+        keys = self.pattern.group_keys
+        return {
+            key: get_match_group_by_key(match_result=self, group_key=key)
+            for key in keys
+        }
+
+
+class SearchResult(MatchResult):
+    pass
+
+
+# Functions
+def count_match_groups(pattern: Union[str, re.Pattern]):
+    """Counts the number of match groups in a regular expression pattern.
+
+    Parameters
+    ----------
+    pattern:
+        The regular expression pattern.
+
+    Returns
+    -------
+    group_count:
+        The number of match groups in the pattern.
+
+    Examples
+    --------
+    >>> pat = r"(\\d+)(\\w+)\\((\\d+)\\)"
+    >>> grp_cnt = count_match_groups(pattern)
+    >>> print(f"Number of match groups: {group_count}")
+    """
+    if isinstance(pattern, re.Pattern):
+        pattern = pattern.pattern
+    # Remove escaped parentheses from the pattern
+    pattern = re.sub(r"\\[()]", "", pattern)
+
+    # Find all opening and closing parentheses
+    open_brackets = re.findall(r"\(", pattern)
+    close_brackets = re.findall(r"\)", pattern)
+
+    # Count the number of valid match groups
+    group_count = 0
+    unmatched_brackets = 0
+
+    for _ in open_brackets:
+        unmatched_brackets += 1
+
+    for _ in close_brackets:
+        if unmatched_brackets > 0:
+            group_count += 1
+            unmatched_brackets -= 1
+
+    corrected_group_count = group_count - pattern.count("(?:")
+    return corrected_group_count
+
+
+def match_regex_patterns(
+    patterns: Union[RegExPatternExtended, List[RegExPatternExtended]],
+    strings: Union[str, List[str]],
+    stop_at_first_match: bool = False,
+) -> Dict[str, List[MatchResult]]:
+    """Match a list of strings against a list of regular expression patterns. If
+    stop_at_first_match is True, the function will stop matching a string as soon as it
+    finds a match."""
+    if isinstance(patterns, RegExPatternExtended):
+        patterns = [patterns]
+    if isinstance(strings, str):
+        strings = [strings]
+    matching_results = dict()
+    for _k, string in enumerate(strings):
+        string_matched = False
+        matching_results[string] = list()
+        for _i, pattern in enumerate(patterns):
+            match = re.match(pattern.pattern, string)
+            if match is not None:
+                string_matched = True
+                matching_results[string].append(
+                    MatchResult(match=match, pattern=pattern)
+                )
+                if stop_at_first_match is True:
+                    break
+        if string_matched is False:
+            matching_results[string].append(MatchResult(match=None, pattern=None))
+    return matching_results
+
+
+def match_first_regex_pattern(
+    patterns: Union[RegExPatternExtended, List[RegExPatternExtended]],
+    strings: Union[str, List[str]],
+) -> List[MatchResult]:
+    """Match a list of strings against a list of regular expression patterns. Return
+    the first match for each string."""
+    if isinstance(patterns, RegExPatternExtended):
+        patterns = [patterns]
+    if isinstance(strings, str):
+        strings = [strings]
+    matching_results = match_regex_patterns(patterns, strings, stop_at_first_match=True)
+    return [matching_results[string][0] for string in strings]
+
+
+def get_match_group_by_key(
+    match_result: MatchResult, group_key: str
+) -> Union[str, None]:
+    """Get the value of a named group in a match result."""
+    if match_result.match is None:
+        return None
+    if group_key not in match_result.pattern.group_keys:
+        raise ValueError(f"Group '{group_key}' not found in pattern.")
+    # +1 is very important here, because the first group is the whole match
+    group_index = match_result.pattern.group_keys.index(group_key) + 1
+    return match_result.match.group(group_index)
+
+
+def test_regex_pattern(
+    patterns: Union[
+        str, List[str], dict, RegExPatternExtended, List[RegExPatternExtended]
+    ],
+    strings: List[str],
+) -> dict:
+    """Tests one or a list of regular expression patterns against a list of strings.
+    Returns a dictionary with the results. The first pattern is evaluated first,
+    and every following pattern is evaluated regarding the additional matches found.
+
+    Parameters
+    ----------
+    patterns:
+        A regular expression pattern string, a list of such strings or a dictionary
+        as in osw.data_import_utility.REGEX_PATTERNS
+    strings:
+        A list of strings to test the patterns against.
+
+    Returns
+    -------
+
+    """
+    if isinstance(patterns, str):
+        patterns = [
+            RegExPatternExtended(
+                pattern=patterns,
+                description="No description",
+                group_keys=[],
+            )
+        ]
+    if isinstance(patterns, list):
+        if isinstance(patterns[0], str):
+            patterns = [
+                RegExPatternExtended(
+                    pattern=pattern,
+                    description="No description",
+                    group_keys=[],
+                )
+                for pattern in patterns
+            ]
+    if isinstance(patterns, dict):
+        patterns = [
+            RegExPatternExtended(
+                pattern=val["Pattern"],
+                description=key,
+                group_keys=val["Groups"],
+                example_str=val.get("Example string", None),
+            )
+            for key, val in patterns.items()
+        ]
+    matching_results = list()
+    for _i, pattern in enumerate(patterns):
+        result = {
+            "Pattern": pattern.pattern,
+            "Matching strings": [],
+            "Match objects": [],
+            "No match": [],
+            "Pattern description": pattern.description,
+            "Group keys": pattern.group_keys,
+        }
+        for string in strings:
+            match = re.match(pattern.pattern, string)
+            match_result = MatchResult(match=match, pattern=pattern)
+            if match is None:
+                result["No match"].append(string)
+            else:
+                result["Matching strings"].append(string)
+                result["Match objects"].append(match_result)
+        result["Coverage per pattern"] = len(result["Matching strings"]) / len(strings)
+        matching_results.append(result)
+
+    coverage = dict()
+    former_matches = list()
+    for i, pattern in enumerate(patterns):
+        if pattern.description != "No description":
+            key = pattern.description
+        else:
+            key = i
+        previously_unmatched = list(
+            set(matching_results[i]["Matching strings"]) - set(former_matches)
+        )
+        coverage[key] = {
+            "Pattern": pattern,
+            "Pattern description": pattern.description,
+            "Group keys": pattern.group_keys,
+            "Matching strings": matching_results[i]["Matching strings"],
+            "Matches, groups": [
+                [group for group in match_result.match.groups()]
+                for match_result in matching_results[i]["Match objects"]
+            ],
+            "Additional matches": previously_unmatched,
+            "Additional matches, groups": [
+                [group for group in match_result.match.groups()]
+                for match_result in matching_results[i]["Match objects"]
+                if match_result.match.string in previously_unmatched
+            ],
+            "Additional coverage": len(previously_unmatched) / len(strings),
+            "Coverage per pattern": matching_results[i]["Coverage per pattern"],
+            "Cumulative coverage": (len(former_matches) + len(previously_unmatched))
+            / len(strings),
+        }
+        former_matches.extend(previously_unmatched)
+
+    coverage["Accumulative coverage"] = sum(
+        [len(result["Matching strings"]) for result in matching_results]
+    ) / len(strings)
+    total_coverage = len(former_matches) / len(strings)
+    coverage["Total coverage"] = total_coverage
+    coverage["Not covered"] = 1 - total_coverage
+    coverage["Unmatched strings"] = list(set(strings) - set(former_matches))
+
+    return {"Results of regex matching": matching_results, "Coverage": coverage}
diff --git a/src/osw/utils/regex_pattern.py b/src/osw/utils/regex_pattern.py
index 37f5afb5..7aeae351 100644
--- a/src/osw/utils/regex_pattern.py
+++ b/src/osw/utils/regex_pattern.py
@@ -1,4 +1,4 @@
-from osw.utils.strings import RegExPatternExtended
+from osw.utils.regex import RegExPatternExtended
 
 REGEX_PATTERN_LIST = [
     RegExPatternExtended(
diff --git a/src/osw/utils/strings.py b/src/osw/utils/strings.py
index 7b49de92..835b3e9d 100644
--- a/src/osw/utils/strings.py
+++ b/src/osw/utils/strings.py
@@ -1,362 +1,4 @@
-import re
-from typing import Dict, List, Optional, Union
-
-from pydantic.v1 import validator
-
-from osw.model.static import OswBaseModel
-
-
-# Classes
-class RegExPatternExtended(OswBaseModel):
-    """A class that extends the re.Pattern class with additional attributes.
-
-    Example
-    -------
-    Definition
-    >>> pattern = RegExPatternExtended(
-    >>>     description="File page full page title (new format) to label",
-    >>>     pattern=r"File:(OS[WL]{1}[[a-f0-9]{32})"
-    >>>     "(\\.[a-zA-Z0-9]*[a-zA-Z]+[a-zA-Z0-9]*)",
-    >>>     group_keys=["Label", "Suffix(es)"],  # ["OSW-ID", "Suffix(es)"]
-    >>>     example_str="File:OSW11a53cdfbdc24524bf8ac435cbf65d9d.svg",
-    >>>     expected_groups=["OSW11a53cdfbdc24524bf8ac435cbf65d9d", ".svg"],
-    >>> )
-    Usage
-    >>> some_str = pattern.example_str
-    >>> match = pattern.match(some_str)
-    >>> label = match.groups["label"]
-    >>> suffix = match.groups["Suffix(es)"]
-    Testing
-    >>> pattern.test_pattern()
-
-
-    Resources
-    ---------
-    https://regex101.com/
-    """
-
-    pattern: Union[str, re.Pattern]
-    """The regular expression pattern. Can be a string or a compiled pattern."""
-    description: str
-    """A (unique!) description of the pattern. This is used as a key in a dictionary
-    containing all patterns and acting as a library."""
-    group_keys: List[str]
-    """A list of keys that can be used to access the match groups in the match
-    result. The number of keys must match the number of match groups in the pattern.
-    So the first key (list index 0) will be used to access the first match group
-    (index 1!)."""
-    example_str: Optional[str]
-    """An example string that can be used to test the pattern."""
-    expected_groups: Optional[List[str]]
-    """A list strings that are expected in the example_match.groups.values(). This is
-    used to test the pattern by asserting list(self.example_match.groups.values()) ==
-    self.expected_groups"""
-
-    def __init__(self, **data):
-        super().__init__(**data)
-        if isinstance(self.pattern, str):
-            self.pattern = re.compile(self.pattern)
-
-    def match(self, string: str) -> "MatchResult":
-        """Return the match result of the given string and the pattern."""
-        return MatchResult(match=re.match(self.pattern, string), pattern=self)
-
-    def search(self, string: str) -> "SearchResult":
-        """Return the search result of the given string and the pattern."""
-        return SearchResult(match=re.search(self.pattern, string), pattern=self)
-
-    def finditer(self, string: str) -> List["MatchResult"]:
-        """Return a list of all match results of the given string and the pattern."""
-        return [
-            MatchResult(match=match, pattern=self)
-            for match in re.finditer(self.pattern, string)
-        ]
-
-    def findall(self, string: str) -> Dict[str, List[str]]:
-        """Return a list of all match groups of the given string and the pattern."""
-        result = {}
-        for grp_key in self.group_keys:
-            result[grp_key] = [mr.groups[grp_key] for mr in self.finditer(string)]
-        return result
-
-    def findall_by_group_key(self, string: str, group_key: str) -> List[str]:
-        """Return a list of all match groups of the given string and the pattern by
-        group key."""
-        if group_key not in self.group_keys:
-            raise ValueError(f"Group key '{group_key}' not found in pattern.")
-        return self.findall(string)[group_key]
-
-    @property
-    def example_match(self) -> "MatchResult":
-        """Return the match result of the example string and the pattern."""
-        return self.match(string=self.example_str)
-
-    @validator("group_keys")
-    def validate_group_keys(cls, group_keys, values) -> List[str]:
-        """Validate that the number of group keys matches the number of match groups,
-        defined in the pattern."""
-        pattern = values.get("pattern")
-        group_count = count_match_groups(pattern)
-        # Subtract non-capturing group count
-        if len(group_keys) != group_count:
-            raise ValueError(
-                f"The number of group keys ({len(group_keys)}) does not match "
-                f"the number of match groups ({group_count})."
-            )
-        return group_keys
-
-    def test_pattern(self) -> bool:
-        """Test the pattern by asserting self.example_match.groups.values()) ==
-        self.expected_groups"""
-        if list(self.example_match.groups.values()) == self.expected_groups:
-            return True
-        else:
-            return False
-
-    class Config:
-        arbitrary_types_allowed = True
-
-
-class MatchResult(OswBaseModel):
-    match: Union[re.Match, None]
-    pattern: Union[RegExPatternExtended, None]
-
-    @property
-    def groups(self):
-        """Return a dictionary representation of the object, enabling accessing the
-        match groups by key (as defined in RegExPatternExtended)."""
-        if self.match is None or self.pattern is None:
-            return None  # []
-        keys = self.pattern.group_keys
-        return {
-            key: get_match_group_by_key(match_result=self, group_key=key)
-            for key in keys
-        }
-
-    class Config:
-        arbitrary_types_allowed = True
-
-
-class SearchResult(MatchResult):
-    pass
-
-
 # Functions
-def count_match_groups(pattern: Union[str, re.Pattern]):
-    """Counts the number of match groups in a regular expression pattern.
-
-    Parameters
-    ----------
-    pattern:
-        The regular expression pattern.
-
-    Returns
-    -------
-    group_count:
-        The number of match groups in the pattern.
-
-    Examples
-    --------
-    >>> pat = r"(\\d+)(\\w+)\\((\\d+)\\)"
-    >>> grp_cnt = count_match_groups(pattern)
-    >>> print(f"Number of match groups: {group_count}")
-    """
-    if isinstance(pattern, re.Pattern):
-        pattern = pattern.pattern
-    # Remove escaped parentheses from the pattern
-    pattern = re.sub(r"\\[()]", "", pattern)
-
-    # Find all opening and closing parentheses
-    open_brackets = re.findall(r"\(", pattern)
-    close_brackets = re.findall(r"\)", pattern)
-
-    # Count the number of valid match groups
-    group_count = 0
-    unmatched_brackets = 0
-
-    for _ in open_brackets:
-        unmatched_brackets += 1
-
-    for _ in close_brackets:
-        if unmatched_brackets > 0:
-            group_count += 1
-            unmatched_brackets -= 1
-
-    corrected_group_count = group_count - pattern.count("(?:")
-    return corrected_group_count
-
-
-def match_regex_patterns(
-    patterns: Union[RegExPatternExtended, List[RegExPatternExtended]],
-    strings: Union[str, List[str]],
-    stop_at_first_match: bool = False,
-) -> Dict[str, List[MatchResult]]:
-    """Match a list of strings against a list of regular expression patterns. If
-    stop_at_first_match is True, the function will stop matching a string as soon as it
-    finds a match."""
-    if isinstance(patterns, RegExPatternExtended):
-        patterns = [patterns]
-    if isinstance(strings, str):
-        strings = [strings]
-    matching_results = dict()
-    for _k, string in enumerate(strings):
-        string_matched = False
-        matching_results[string] = list()
-        for _i, pattern in enumerate(patterns):
-            match = re.match(pattern.pattern, string)
-            if match is not None:
-                string_matched = True
-                matching_results[string].append(
-                    MatchResult(match=match, pattern=pattern)
-                )
-                if stop_at_first_match is True:
-                    break
-        if string_matched is False:
-            matching_results[string].append(MatchResult(match=None, pattern=None))
-    return matching_results
-
-
-def match_first_regex_pattern(
-    patterns: Union[RegExPatternExtended, List[RegExPatternExtended]],
-    strings: Union[str, List[str]],
-) -> List[MatchResult]:
-    """Match a list of strings against a list of regular expression patterns. Return
-    the first match for each string."""
-    if isinstance(patterns, RegExPatternExtended):
-        patterns = [patterns]
-    if isinstance(strings, str):
-        strings = [strings]
-    matching_results = match_regex_patterns(patterns, strings, stop_at_first_match=True)
-    return [matching_results[string][0] for string in strings]
-
-
-def get_match_group_by_key(
-    match_result: MatchResult, group_key: str
-) -> Union[str, None]:
-    """Get the value of a named group in a match result."""
-    if match_result.match is None:
-        return None
-    if group_key not in match_result.pattern.group_keys:
-        raise ValueError(f"Group '{group_key}' not found in pattern.")
-    # +1 is very important here, because the first group is the whole match
-    group_index = match_result.pattern.group_keys.index(group_key) + 1
-    return match_result.match.group(group_index)
-
-
-def test_regex_pattern(
-    patterns: Union[
-        str, List[str], dict, RegExPatternExtended, List[RegExPatternExtended]
-    ],
-    strings: List[str],
-) -> dict:
-    """Tests one or a list of regular expression patterns against a list of strings.
-    Returns a dictionary with the results. The first pattern is evaluated first,
-    and every following pattern is evaluated regarding the additional matches found.
-
-    Parameters
-    ----------
-    patterns:
-        A regular expression pattern string, a list of such strings or a dictionary
-        as in osw.data_import_utility.REGEX_PATTERNS
-    strings:
-        A list of strings to test the patterns against.
-
-    Returns
-    -------
-
-    """
-    if isinstance(patterns, str):
-        patterns = [
-            RegExPatternExtended(
-                pattern=patterns,
-                description="No description",
-                group_keys=[],
-            )
-        ]
-    if isinstance(patterns, list):
-        if isinstance(patterns[0], str):
-            patterns = [
-                RegExPatternExtended(
-                    pattern=pattern,
-                    description="No description",
-                    group_keys=[],
-                )
-                for pattern in patterns
-            ]
-    if isinstance(patterns, dict):
-        patterns = [
-            RegExPatternExtended(
-                pattern=val["Pattern"],
-                description=key,
-                group_keys=val["Groups"],
-                example_str=val.get("Example string", None),
-            )
-            for key, val in patterns.items()
-        ]
-    matching_results = list()
-    for _i, pattern in enumerate(patterns):
-        result = {
-            "Pattern": pattern.pattern,
-            "Matching strings": [],
-            "Match objects": [],
-            "No match": [],
-            "Pattern description": pattern.description,
-            "Group keys": pattern.group_keys,
-        }
-        for string in strings:
-            match = re.match(pattern.pattern, string)
-            match_result = MatchResult(match=match, pattern=pattern)
-            if match is None:
-                result["No match"].append(string)
-            else:
-                result["Matching strings"].append(string)
-                result["Match objects"].append(match_result)
-        result["Coverage per pattern"] = len(result["Matching strings"]) / len(strings)
-        matching_results.append(result)
-
-    coverage = dict()
-    former_matches = list()
-    for i, pattern in enumerate(patterns):
-        if pattern.description != "No description":
-            key = pattern.description
-        else:
-            key = i
-        previously_unmatched = list(
-            set(matching_results[i]["Matching strings"]) - set(former_matches)
-        )
-        coverage[key] = {
-            "Pattern": pattern,
-            "Pattern description": pattern.description,
-            "Group keys": pattern.group_keys,
-            "Matching strings": matching_results[i]["Matching strings"],
-            "Matches, groups": [
-                [group for group in match_result.match.groups()]
-                for match_result in matching_results[i]["Match objects"]
-            ],
-            "Additional matches": previously_unmatched,
-            "Additional matches, groups": [
-                [group for group in match_result.match.groups()]
-                for match_result in matching_results[i]["Match objects"]
-                if match_result.match.string in previously_unmatched
-            ],
-            "Additional coverage": len(previously_unmatched) / len(strings),
-            "Coverage per pattern": matching_results[i]["Coverage per pattern"],
-            "Cumulative coverage": (len(former_matches) + len(previously_unmatched))
-            / len(strings),
-        }
-        former_matches.extend(previously_unmatched)
-
-    coverage["Accumulative coverage"] = sum(
-        [len(result["Matching strings"]) for result in matching_results]
-    ) / len(strings)
-    total_coverage = len(former_matches) / len(strings)
-    coverage["Total coverage"] = total_coverage
-    coverage["Not covered"] = 1 - total_coverage
-    coverage["Unmatched strings"] = list(set(strings) - set(former_matches))
-
-    return {"Results of regex matching": matching_results, "Coverage": coverage}
-
-
 def pascal_case(st: str) -> str:
     """converts a string to PascalCase
 
diff --git a/src/osw/utils/wiki.py b/src/osw/utils/wiki.py
index 7d137d8b..866df235 100644
--- a/src/osw/utils/wiki.py
+++ b/src/osw/utils/wiki.py
@@ -1,8 +1,8 @@
-from typing import Type, Union
+from copy import deepcopy
 from uuid import UUID
 
-import osw.model.entity as model
-from osw.model.static import OswBaseModel
+# Legacy imports:
+from osw.model.static import get_full_title, get_namespace, get_title  # noqa: F401
 
 
 def get_osw_id(uuid: UUID) -> str:
@@ -12,11 +12,11 @@ def get_osw_id(uuid: UUID) -> str:
     Parameters
     ----------
     uuid
-        uuid object, e. g. UUID("2ea5b605-c91f-4e5a-9559-3dff79fdd4a5")
+        An UUID object, e.g., UUID("2ea5b605-c91f-4e5a-9559-3dff79fdd4a5")
 
     Returns
     -------
-        OSW-ID string, e. g. OSW2ea5b605c91f4e5a95593dff79fdd4a5
+        OSW-ID string, e.g., OSW2ea5b605c91f4e5a95593dff79fdd4a5
     """
     return "OSW" + str(uuid).replace("-", "")
 
@@ -27,122 +27,15 @@ def get_uuid(osw_id) -> UUID:
     Parameters
     ----------
     osw_id
-        OSW-ID string, e. g. OSW2ea5b605c91f4e5a95593dff79fdd4a5
+        OSW-ID string, e.g., OSW2ea5b605c91f4e5a95593dff79fdd4a5
 
     Returns
     -------
-        uuid object, e. g. UUID("2ea5b605-c91f-4e5a-9559-3dff79fdd4a5")
+        uuid object, e.g., UUID("2ea5b605-c91f-4e5a-9559-3dff79fdd4a5")
     """
     return UUID(osw_id.replace("OSW", ""))
 
 
-def get_namespace(entity: Union[OswBaseModel, Type[OswBaseModel]]) -> Union[str, None]:
-    """determines the wiki namespace based on the entity's type/class
-
-    Parameters
-    ----------
-    entity
-        the entity to determine the namespace for
-
-    Returns
-    -------
-        the namespace as a string or None if the namespace could not be determined
-    """
-    namespace = None
-
-    if hasattr(entity, "meta") and entity.meta and entity.meta.wiki_page:
-        if entity.meta.wiki_page.namespace:
-            namespace = entity.meta.wiki_page.namespace
-
-    #  (model classes may not exist => try except)
-    #  note: this may not work properly with dynamic reloaded model module
-    #  note: some of these lines lead to AssertationError in coverage.py
-    #        and are therefore excluded from coverage
-    #        (see also https://github.com/OpenSemanticLab/osw-python/issues/74)
-    if namespace is None:
-        try:
-            if issubclass(entity, model.Entity):
-                namespace = "Category"
-        except (TypeError, AttributeError):
-            pass
-    if namespace is None:
-        try:
-            if isinstance(entity, model.Category):  # pragma: no cover
-                namespace = "Category"
-        except AttributeError:
-            pass
-    if namespace is None:
-        try:
-            if issubclass(entity, model.Characteristic):  # pragma: no cover
-                namespace = "Category"
-        except (TypeError, AttributeError):
-            pass
-    if namespace is None:
-        try:
-            if isinstance(entity, model.Item):
-                namespace = "Item"
-        except AttributeError:
-            pass
-    if namespace is None:
-        try:
-            if isinstance(entity, model.Property):  # pragma: no cover
-                namespace = "Property"
-        except AttributeError:
-            pass
-    if namespace is None:
-        try:
-            if isinstance(entity, model.WikiFile):  # pragma: no cover
-                namespace = "File"
-        except AttributeError:
-            pass
-
-    return namespace
-
-
-def get_title(entity: model.Entity) -> Union[str, None]:
-    """determines the wiki page title based on the entity's data
-
-    Parameters
-    ----------
-    entity
-        the entity to determine the title for
-
-    Returns
-    -------
-        the title as a string or None if the title could not be determined
-    """
-    title = None
-
-    if hasattr(entity, "meta") and entity.meta and entity.meta.wiki_page:
-        if entity.meta.wiki_page.title:
-            title = entity.meta.wiki_page.title
-
-    if title is None:
-        title = get_osw_id(entity.uuid)
-
-    return title
-
-
-def get_full_title(entity: model.Entity) -> Union[str, None]:
-    """determines the wiki full title (namespace:title) based on the entity's data
-
-    Parameters
-    ----------
-    entity
-        the entity to determine the full title for
-
-    Returns
-    -------
-        the full title as a string or None if the title could not be determined
-    """
-    namespace = get_namespace(entity)
-    title = get_title(entity)
-    if namespace is not None and title is not None:
-        return namespace + ":" + title
-    else:
-        return title
-
-
 def namespace_from_full_title(full_title: str) -> str:
     """extracts the namespace from a full title (namespace:title)
 
@@ -170,7 +63,8 @@ def title_from_full_title(full_title: str) -> str:
     -------
         the title as a string
     """
-    return full_title.split(":")[-1]
+    namespace = full_title.split(":")[0]
+    return full_title.split(f"{namespace}:")[-1]
 
 
 def is_empty(val):
@@ -180,3 +74,67 @@ def is_empty(val):
     elif isinstance(val, list) or isinstance(val, str) or isinstance(val, dict):
         return len(val) == 0
     return False
+
+
+def remove_empty_strings(d: dict, inplace: bool = True) -> dict:
+    """Iterates through the dictionary structure and removes key-value pairs
+    where the value is an empty string
+
+    Parameters
+    ----------
+    d:
+        The dictionary to perform the operation on
+    inplace:
+        Whether to perform the operation in place or return a new dictionary
+
+    Returns
+    -------
+    result:
+        The modified dictionary
+    """
+    if not inplace:
+        d = deepcopy(d)
+    keys = list(d.keys())
+    for key in keys:
+        value = d[key]
+        if isinstance(value, dict):
+            remove_empty_strings(value)
+        elif isinstance(value, list):
+            for item in value:
+                if isinstance(item, dict):
+                    remove_empty_strings(item)
+        if value == "":
+            del d[key]
+    return d
+
+
+def remove_empty(d: dict, inplace: bool = True) -> dict:
+    """Iterates through the dictionary structure and removes key-value pairs
+    where the value is an empty string, list, set or dictionary
+
+    Parameters
+    ----------
+    d:
+        The dictionary to perform the operation on
+    inplace:
+        Whether to perform the operation in place or return a new dictionary
+
+    Returns
+    -------
+    result:
+        The modified dictionary
+    """
+    if not inplace:
+        d = deepcopy(d)
+    keys = list(d.keys())
+    for key in keys:
+        value = d[key]
+        if isinstance(value, dict):
+            remove_empty(value)
+        elif isinstance(value, list):
+            for item in value:
+                if isinstance(item, dict):
+                    remove_empty(item)
+        if value in ["", [], {}, set()]:
+            del d[key]
+    return d
diff --git a/src/osw/wtsite.py b/src/osw/wtsite.py
index 92edd1f9..5ae94206 100644
--- a/src/osw/wtsite.py
+++ b/src/osw/wtsite.py
@@ -385,7 +385,10 @@ def _clear_cookies(self):
                     cookie.domain, cookie.path, cookie.name
                 )
 
-    def prefix_search(self, text: Union[str, wt.SearchParam]):
+    class SearchParam(wt.SearchParam):
+        pass
+
+    def prefix_search(self, text: Union[str, SearchParam]):
         """Send a prefix search request to the site.
 
         Parameters
@@ -399,7 +402,7 @@ def prefix_search(self, text: Union[str, wt.SearchParam]):
         """
         return wt.prefix_search(self._site, text)
 
-    def semantic_search(self, query: Union[str, wt.SearchParam]):
+    def semantic_search(self, query: Union[str, SearchParam]):
         """Send a swm ask query to the site.
 
         Parameters
@@ -597,6 +600,66 @@ def copy_single_page(content_dict: dict):
         else:
             return [copy_single_page(content) for content in content_list]
 
+    class DeletePageParam(OswBaseModel):
+        page: Union["WtPage", List["WtPage"], str, List[str]]
+        comment: Optional[str] = None
+        debug: Optional[bool] = True
+        """If True, debug messages will be printed."""
+        parallel: Optional[bool] = None
+        """If true, processes the pages in parallel."""
+
+        class Config:
+            arbitrary_types_allowed = True
+
+        def __init__(self, **data):
+            super().__init__(**data)
+            if not isinstance(self.page, list):
+                self.page = [self.page]
+            if not self.comment:
+                self.comment = "Deleted via osw-python"
+            if len(self.page) > 5 and self.parallel is None:
+                self.parallel = True
+
+    def delete_page(
+        self,
+        param: Union["WtPage", List["WtPage"], str, List[str], DeletePageParam],
+        comment: str = None,
+    ):
+        """Deletes a page or a list of pages from the site.
+
+        Parameters
+        ----------
+        param:
+            DeletePageParam object
+        comment:
+            The comment to leave when deleting the page
+        """
+        if not isinstance(param, WtSite.DeletePageParam):
+            param = WtSite.DeletePageParam(page=param)
+        if comment:
+            param.comment = comment
+        pages = []
+        print("Getting pages to delete ...")
+        for page in param.page:
+            if isinstance(page, str):
+                pages.append(WtPage(self, title=page, do_init=True))
+            else:
+                pages.append(page)
+        param.page = pages
+
+        def delete_single_page(page_: "WtPage", comment: str):
+            return page_.delete(comment=comment)
+
+        if param.parallel:
+            return ut.parallelize(
+                delete_single_page,
+                param.page,
+                comment=param.comment,
+                flush_at_end=param.debug,
+            )
+        else:
+            return [delete_single_page(page, param.comment) for page in param.page]
+
     class CreatePagePackageParam(OswBaseModel):
         """Parameter object for create_page_package method."""
 
@@ -922,7 +985,7 @@ def get_file_pages(self, limit: int = 1000000) -> List[str]:
 
     def get_file_info_and_usage(
         self,
-        page_titles: Union[str, List[str], wt.SearchParam],
+        page_titles: Union[str, List[str], SearchParam],
     ) -> list:
         """Get the file info and usage for one or more file pages.
 
@@ -1533,7 +1596,7 @@ def delete(self, comment: str = None):
         """
         self._page.delete(comment)
 
-    def move(self, new_title: str, comment: str = None, redirect=True):
+    def move(self, new_title: str, comment: str = None, redirect: bool = True):
         """Moves (=renames) the page to a new title
 
         Parameters
@@ -1995,3 +2058,4 @@ def import_xml(self, config: ImportConfig) -> ImportResult:
 WtSite.CreatePagePackageParam.update_forward_refs()
 WtSite.UploadPagePackageParam.update_forward_refs()
 WtSite.ReadPagePackageResult.update_forward_refs()
+WtSite.DeletePageParam.update_forward_refs()


Test patch:
diff --git a/tests/integration/store_and_load_test.py b/tests/integration/store_and_load_test.py
index e8d54ee4..9b9543a6 100644
--- a/tests/integration/store_and_load_test.py
+++ b/tests/integration/store_and_load_test.py
@@ -5,7 +5,8 @@
 from osw.auth import CredentialManager
 from osw.core import OSW, AddOverwriteClassOptions, OverwriteOptions
 from osw.utils.wiki import get_full_title
-from osw.wiki_tools import SearchParam
+
+# from osw.wiki_tools import SearchParam
 from osw.wtsite import WtSite
 
 # run with: tox -e test -- --wiki_domain domain --wiki_username user --wiki_password pass
@@ -141,36 +142,36 @@ def test_query_instances(wiki_domain, wiki_username, wiki_password):
     osw.delete_entity(my_item)
 
 
-def test_statement_creation(wiki_domain, wiki_username, wiki_password):
-    cm = CredentialManager()
-    cm.add_credential(
-        CredentialManager.UserPwdCredential(
-            iri=wiki_domain, username=wiki_username, password=wiki_password
-        )
-    )
-    wtsite = WtSite(WtSite.WtSiteConfig(iri=wiki_domain, cred_mngr=cm))
-    osw = OSW(site=wtsite)
-
-    my_entity = model.Item(
-        label=[model.Label(text="MyItem")],
-        statements=[
-            model.DataStatement(property="Property:TestProperty", value="TestValue")
-        ],
-    )
-
-    osw.store_entity(my_entity)
-
-    search_param = SearchParam(query="[[TestProperty::TestValue]]")
-    full_page_titles = osw.site.semantic_search(search_param)
-    assert f"Item:{OSW.get_osw_id(my_entity.uuid)}" in full_page_titles
-
-    search_param = SearchParam(
-        query="[[HasStatement.HasProperty::Property:TestProperty]]"
-    )
-    full_page_titles = osw.site.semantic_search(search_param)
-    assert f"Item:{OSW.get_osw_id(my_entity.uuid)}" in full_page_titles
-
-    osw.delete_entity(my_entity)
+# def test_statement_creation(wiki_domain, wiki_username, wiki_password):
+#     cm = CredentialManager()
+#     cm.add_credential(
+#         CredentialManager.UserPwdCredential(
+#             iri=wiki_domain, username=wiki_username, password=wiki_password
+#         )
+#     )
+#     wtsite = WtSite(WtSite.WtSiteConfig(iri=wiki_domain, cred_mngr=cm))
+#     osw = OSW(site=wtsite)
+#
+#     my_entity = model.Item(
+#         label=[model.Label(text="MyItem")],
+#         statements=[
+#             model.DataStatement(property="Property:TestProperty", value="TestValue")
+#         ],
+#     )
+#
+#     osw.store_entity(my_entity)
+#
+#     search_param = SearchParam(query="[[TestProperty::TestValue]]")
+#     full_page_titles = osw.site.semantic_search(search_param)
+#     assert f"Item:{OSW.get_osw_id(my_entity.uuid)}" in full_page_titles
+#
+#     search_param = SearchParam(
+#         query="[[HasStatement.HasProperty::Property:TestProperty]]"
+#     )
+#     full_page_titles = osw.site.semantic_search(search_param)
+#     assert f"Item:{OSW.get_osw_id(my_entity.uuid)}" in full_page_titles
+#
+#     osw.delete_entity(my_entity)
 
 
 def test_characteristic_creation(wiki_domain, wiki_username, wiki_password):
diff --git a/tests/test_osl.py b/tests/test_osl.py
index 8aa6bed3..af33cb2e 100644
--- a/tests/test_osl.py
+++ b/tests/test_osl.py
@@ -4,6 +4,7 @@
 
 import osw.model.entity as model
 from osw.core import OSW, AddOverwriteClassOptions, OverwriteOptions
+from osw.utils.wiki import remove_empty
 from osw.wtsite import WtPage
 
 
@@ -55,9 +56,10 @@ def check_false(original: model.Entity, altered: model.Entity, stored: model.Ent
     but add those additionally present in the altered entity."""
     assert stored.label[0].text == original.label[0].text
     assert stored.name == original.name
-    assert stored.iri == original.iri
-    if len(original.description) == 0:
-        assert stored.description == original.description
+    # empty string property is removed on store and load:
+    assert stored.iri == altered.iri
+    if original.description is None or len(original.description) == 0:
+        assert stored.description == altered.description
     else:
         assert stored.description[0].text == original.description[0].text
     assert stored.query_label == altered.query_label  # value == None -->
@@ -74,6 +76,7 @@ def check_only_empty(
     entity in the OSW that are empty, but are not empty in the altered entity."""
     assert stored.label[0].text == original.label[0].text
     assert stored.name == original.name
+    # empty string property is removed on store and load:
     assert stored.iri == altered.iri
     assert stored.description[0].text == altered.description[0].text
     assert stored.query_label == altered.query_label
@@ -102,8 +105,13 @@ def check_keep_existing(
     'keep existing', which is supposed to keep the existing entity in the OSW."""
     assert stored.label[0].text == original.label[0].text
     assert stored.name == original.name
-    assert stored.iri == original.iri
-    assert stored.description == original.description  # empty list
+    # assert stored.iri == original.iri
+    # empty string property is removed on store and load:
+    assert getattr(stored, "iri", None) is None
+    if original.description is None or len(original.description) == 0:  # empty list
+        assert stored.description is None
+    else:
+        assert stored.description[0].text == original.description[0].text
     assert stored.query_label == original.query_label
     assert stored.image == original.image
     assert getattr(stored, "attachments", None) is None
@@ -135,7 +143,7 @@ def test_apply_overwrite_policy():
 
     for check in checks:
         # Create a new item with some properties
-        original_item = model.Item(
+        original_item_local = model.Item(
             label=[model.Label(text="My Item")],
             name="MyItem",
             iri="",  # Empty string property
@@ -146,11 +154,19 @@ def test_apply_overwrite_policy():
         original_page = OfflineWtPage(
             # wtSite=OSW.wt_site,  # todo: missing
             title="Item:"
-            + OSW.get_osw_id(original_item.uuid)
-        )
-        original_page.set_slot_content(
-            "jsondata", json.loads(original_item.json(exclude_none=True))
+            + OSW.get_osw_id(original_item_local.uuid)
         )
+        jsondata = json.loads(original_item_local.json(exclude_none=True))
+        # Emulate the default setting for StoreEntityParam.remove_empty,
+        #  which is True and applied on store_entity
+        remove_empty(jsondata)
+
+        original_page.set_slot_content("jsondata", jsondata)
+        # To reproduce what happens in the OSL instance, we need to get the content
+        #  from the page again
+        original_content = original_page.get_slot_content("jsondata")
+        # And create the original item from that content
+        original_item = model.Item(**original_content)
         # Alter some of the property values
         altered_props = {
             "label": [model.Label(text="My Item Duplicate")],
diff --git a/tests/utils/utils_test.py b/tests/utils/utils_test.py
index e07953cc..c1b2824e 100644
--- a/tests/utils/utils_test.py
+++ b/tests/utils/utils_test.py
@@ -1,7 +1,8 @@
 import uuid
 
 import osw.model.entity as model
-from osw.utils.strings import count_match_groups, pascal_case
+from osw.utils.regex import count_match_groups
+from osw.utils.strings import pascal_case
 from osw.utils.wiki import (
     get_full_title,
     get_namespace,