Repository: cityjson/cjdb (language: python)

Issue:
Version 1.2.0



Discussion / hints:

Fix patch:
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..d0566c8
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,2 @@
+@GinaStavropoulou
+@balazsdukai
\ No newline at end of file
diff --git a/README.md b/README.md
index 4765e18..78cdbd8 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +1,41 @@
 # cjdb
 [![MIT badge](https://img.shields.io/pypi/l/cjdb)](LICENSE) &nbsp; [![PyPI](https://img.shields.io/pypi/v/cjdb)](https://pypi.org/project/cjdb)
 
-CJDB is a tool for enabling CityJSON integration with a PostgreSQL database.
+`cjdb` is a Python based importer of CityJSONL files to a PostgreSQL database. It requires the [PostGIS](https://postgis.net/) extension.
 
 Authors: Cynthia Cai, Lan Yan, Yitong Xia, Chris Poon, Siebren Meines, Leon Powalka
 
 Maintainer: Gina Stavropoulou
 
 ## Table of Contents  
-### [1. Data model](#model)
-### [2. Installation & running](#install)
-### [3. Local development](#local)
-### [4. Running tests](#tests)
+### [1.Data model](#1-data-model)
+### [2.Installation](#2-installation)
+- [Using pip](#using-pip)
+- [Using Docker](#using-docker)
+### [3.Usage](#3-usage)
+- [CLI](#cli)
+- [Quickstart](#quickstart)
+- [Basic Queries](#basic-queries)
+### [4.Local development](#4-local-development)
+- [Install and Build](#install-and-build)
+- [Testing](#testing)
+### [5.Explanation](#5-explanation)
+ - [Model assumptions](#model-assumptions)
+ - [What is a City Model?](#what-is-a-city-model)
+ - [Types of input](#types-of-input)
+ - [Coordinate Reference Systems](#coordinate-reference-systems)
+ - [3D reprojections](#3d-reprojections)
+ - [CityJSON Extensions](#cityjson-extensions)
+ - [CityJSON GeometryTemplate](#cityjson-geometrytemplate)
+ - [Data validation](#data-validation)
+ - [Repeated object IDs](#repeated-object-ids)
 ---
-## 1. Data model <a name="model"></a>
+## 1. Data model
 For the underlying data model see [cjdb/model/README.md](cjdb/model/README.md)
 
 
-## 2. Installation & running <a name="install"></a>
+## 2. Installation
 ### Using pip
-
 ```bash
 pip install cjdb
 ```
@@ -40,21 +56,183 @@ To import some files, the `-v` option is needed to mount our local file director
 ```bash
 docker run -v {MYDIRECTORY}:/data --rm -it --network=host cjdb cjdb -H localhost -U postgres -d postgres -W postgres /data/5870_ext.jsonl 
 ```
+## 3. Usage <a name="usage"></a>
+
+### CLI <a name="cli"></a>
 
-## 3. Local development <a name="local"></a>
-Make sure `poetry` is installed. Then, to create a local environment with all the necessary dependencies, run from the repository root:
 ```bash
-poetry install
+cj2pgsql [-h] [-H DB_HOST] [-p DB_PORT] -U DB_USER [-W DB_PASSWORD] -d DB_NAME [-s DB_SCHEMA] [-I TARGET_SRID][-x INDEXED_ATTRIBUTES] [-px PARTIAL_INDEXED_ATTRIBUTES] [-g] [-a | -o] [-e | -u] [file_or_directory]
+```
+#### Positional Arguments
+file_or_directory
+Source CityJSONL file or a directory with CityJSONL files. STDIN if not specified. If specifying a directory, all the *.jsonl files inside of it will be imported.
+
+Default: “stdin”
+
+#### Named Arguments
+`-I, --srid`
+Target coordinate system SRID. All 3D and 2D geometries will be reprojected.
+
+`-x, --attr-index`
+CityObject attribute to be indexed using a btree index. Can be specified multiple times, for each attribute once.
+
+Default: []
+
+`-px, --partial-attr-index`
+CityObject attribute to be indexed using a btree partial index. Can be specified multiple times, for each attribute once. This index indexes on a condition ‘where {
+                {ATTR_NAME
+                }
+        } is not null’. This means that it saves space and improves query performance when the attribute is not present for all imported CityObjects.
+
+Default: []
+
+`-g, --ignore-repeated-file`
+Ignore repeated file names warning when importing. By default, the importer will send out warnings if a specific file has already been imported.
+
+Default: False
+
+`-a, --append`
+Run in append mode (as opposed to default create mode). This assumes the database structure exists already and new data is to be appended.
+
+Default: False
+
+`-o, --overwrite`
+Overwrite the data that is currently in the database schema. Warning: this causes the loss of what was imported before to the database schema.
+
+Default: False
+
+`-u, --update-existing`
+Check if the object with given ID exists before inserting, and update it if it does. The old object will be updated with the new object’s properties.
+
+Default: False
+
+#### Database connection arguments
+`-H, --host`
+PostgreSQL database host
+
+Default: “localhost”
+
+`-p, --port`
+PostgreSQL database port
+
+Default: 5432
+
+`-U, --user`
+PostgreSQL database user name
+
+`-W, --password`
+PostgreSQL database user password
+
+`-d, --database`
+PostgreSQL database name
+
+`-s, --schema`
+Target database schema
+
+Default: “public”
+
+### Quickstart
+
+Sample CityJSON data can be downloaded from [3DBAG download service](https://3dbag.nl/nl/download?tid=901). Then, having the CityJSON file, a combination of [cjio](https://github.com/cityjson/cjio) (external CityJSON processing library) and cjdb is needed to import it to a specified schema in a database. 
+
+1. Convert CityJSON to CityJSONL
+
+```bash
+cjio --suppress_msg tile_901.json export jsonl tile_901.jsonl 
 ```
 
-To build the wheel run:
+2. Create a new database
+
+  - [how to create a new database](https://postgis.net/workshops/postgis-intro/creating_db.html)
+
+3. Import CityJSONL to the database
 ```bash
-poetry build
+PGPASSWORD=postgres cjdb -H localhost -U postgres -d postgres -s cjdb -o tile_901.jsonl   
 ```
 
-Then install the .whl file with pip:
+**Alternatively steps 1 and 2 in a single command:**
+
 ```bash
-pip3 install dist/*.whl --force-reinstall
+cjio --suppress_msg tile_901.json export jsonl stdout | cjdb -H localhost -U postgres -d postgres -s cjdb -o
+```
+
+The metadata and the objects can then be found in the tables in the specified schema (`cjdb` in this example).
+
+
+Password can be specified in the `PGPASSWORD` environment variable. If not specified, the app will prompt for the password.
+
+
+### Basic Queries
+
+- Query an object with a specific id:
+```SQL
+SELECT * FROM cjdb.cj_object
+WHERE object_id = 'NL.IMBAG.Pand.0503100000000334';
+```
+
+- Query a building with a specific child
+```SQL
+SELECT o.* FROM cjdb.family f
+INNER JOIN cjdb.cj_object o ON o.object_id = f.parent_id
+WHERE f.child_id = 'NL.IMBAG.Pand.0503100000000334-0'
+```
+
+- Query all buildings within a bounding box
+```SQL
+SELECT * FROM cjdb.cj_object
+WHERE type = 'Building'
+AND ST_Contains(ST_MakeEnvelope(81900.00, 446850.00, 81930.00, 446900.00, 7415), ground_geometry)
+ORDER BY id ASC;
+```
+
+- Query the building intersecting with a point
+```SQL
+SELECT * FROM cjdb.cj_object
+WHERE ground_geometry && ST_MakePoint(81915.00, 446850.00)
+AND type = 'Building'
+ORDER BY object_id ASC;
+```
+
+- Query all objects with a slanted roof
+```SQL
+SELECT * FROM cjdb.cj_object
+WHERE (attributes->'dak_type')::varchar = '"slanted"'
+ORDER BY id ASC;
+```
+
+- Query all the buildings made after 2000:
+```SQL
+SELECT * FROM cjdb.cj_object
+WHERE (attributes->'oorspronkelijkbouwjaar')::int > 2000
+AND type = 'Building'
+ORDER BY id ASC;
+```
+
+- Query all objects with LOD 1.2
+
+```SQL
+SELECT * FROM cjdb.cj_object
+WHERE geometry::jsonb @> '[{"lod": 1.2}]'::jsonb
+```
+
+## 4. Local development
+
+### Install and Build
+Make sure [poetry](https://python-poetry.org/docs/) is installed and the [creation of virtual environments within the project is allowed](
+https://python-poetry.org/docs/configuration/#virtualenvsin-project):
+
+```
+poetry config virtualenvs.in-project true
+```
+
+Then, to create a local environment with all the necessary dependencies, run from the repository root:
+```bash
+poetry install
+```
+
+To activate the env:
+```bash
+source .venv/bin/activate 
 ```
 
 Then you can run the CLI command:
@@ -62,16 +240,110 @@ Then you can run the CLI command:
 cjdb --help
 ```
 
-## 4. Running tests <a name="tests"></a>
----
-Modify the arguments for the database connection to your own settings in:
-- /tests/cli_test.py
+Every time you make some changed to the package you can run `poetry install` to reinstall.
+
+
+### Testing
+In onder to run the tests you need to have [PostgreSQL](https://www.postgresql.org/download/) installed. Then you can run:
 
-Then run:
 ```bash
 pytest -v
 ```
 
+## 5. Explanation
+---
+### Model assumptions
+The `cjdb` importer loads the data in accordance with a specific data model.
+
+Model documentation:
+ [model/README](model/README.md)
+
+#### Indexes
+Some indexes are created by default (refer to [model/README](model/README.md)).
+
+Additionally, the user can specify which CityObject attributes are to be indexed with the `-x/--attr-index` or `-px/--partial-attr-index` flag, we recommend doing this if several queries are made on specific attributes. 
+The second option uses a partial index with a `not null` condition on the attribute. This saves disk space when indexing an attribute that is not present among all the imported CityObjects. 
+This is often the case with CityJSON, because in a single dataset there can be different object types, with different attributes.
+
+
+### Structuring the database and its schemas
+
+It is recommended to group together semantically coherent objects, by importing them to the same database schema.
+One database can have different schemas.
+
+While the current data model supports the import of any type of CityJSON objects together (`Building` and `SolitaryVegetationObject`), the data becomes harder to manage for the user. 
+Example of this would be having different attributes for the same CityObject type (which should be consistent for data coming from the same source).
+
+
+### Input == CityJSONFeature
+The importer works only on [*CityJSONL* files](https://www.cityjson.org/specs/#text-sequences-and-streaming-with-cityjsonfeature), that is where a CityJSON file is decomposed into its *features* (`CityJSONFeature`).
+
+The easiest way to create these from a CityJSON file is with [cjio](https://github.com/cityjson/cjio), and to follow [those instructions](https://github.com/cityjson/cjio#stdin-and-stdout).
+
+The importer supports 3 kinds of input:
+  1. a single CityJSONL file (only those as the output of cjio currently work)
+  1. a directory of CityJSONL files (all files with *jsonl* extensions are located and imported)
+  1. STDIN using the pipe operator: `cat file.jsonl | cjdb ...`
+
+
+
+### Coordinate Reference Systems
+The `cjdb` importer does not allow inconsistent CRSs (coordinate reference systems) within the same database schema. For storing data in separate CRSs, you have to use different schemas.
+
+The data needs to be either harmonized beforehand, or the `-I/--srid` flag can be used upon import, to reproject all the geometries to the one specified CRS. 
+Specifying a 2D CRS (instead of a 3D one) will cause the Z-coordinates to remain unchanged.
+
+**Note:** reprojections slow down the import significantly.
+
+**Note:** Source data with missing `"metadata"/"referenceSystem"` cannot be reprojected due to unknown source reference system.
+
+
+### 3D reprojections
+[`pyproj`](https://pyproj4.github.io/pyproj/stable/) is used for CRS reprojections. 
+While it supports 3D CRS transformations between different systems, sometimes downloading additional [grids](https://pyproj4.github.io/pyproj/stable/transformation_grids.html) is required. 
+The importer will attempt to download the grids needed for the reprojection, with the following message:
+
+```
+Attempting to download additional grids required for CRS transformation.
+This can also be done manually, and the files should be put in this folder:
+        {pyproj_directory}
+```
+
+If that fails, the user will have to download the required grids and put them in the printed `{pyproj_directory}` themselves. 
+
+
+### CityJSON Extensions
+If [CityJSON Extensions](https://www.cityjson.org/extensions/) are present in the imported files, they can be found listed in the `extensions` column in the `import_meta` table.
+
+The [CityJSON specifications](https://www.cityjson.org/specs/#extensions) mention 3 different extendable features, and the `cjdb` importer deals with them as follows:
+
+1. Complex attributes
+
+No action is taken. These attributes end up in the `attributes` JSONB column.
+
+2. Additional root properties
+
+Additional root properties are placed in the `extra properties` JSONB column in the `import_meta` table.
+
+3. Additional CityObject type
+
+Additional CityObject types are appended to the list of allowed CityJSON objects.
+
+### CityJSON GeometryTemplate
+[Geometry templates](https://www.cityjson.org/specs/1.1.2/#geometry-templates)
+are resolved for each object geometry, so that the object in the table ends up with its real-world coordinates (instead of vertex references or relative template coordinates).
+
+### Data validation
+The importer does not validate the structure of the file. It is assumed that the input file is schema-valid ([CityJSON validator](https://validator.cityjson.org/)).
+It sends out warnings when:
+- there appear CityObject types defined neither in the main CityJSON specification nor any of the supplied extensions. 
+- the specified target CRS does not have the Z-axis defined
+- the source dataset does not have a CRS defined at all
+
+### Repeated object IDs
+By default, the importer does not check if an object with a given ID exists already in the database. This is because such an operation for every inserted object results in a performance penalty.
+
+The user can choose to run the import with either the `-e/--skip-existing` option to skip existing objects or `-u, --update-existing` to update existing objects. This will slow down the import, but it will also ensure that repeated object cases are handled.
 
 
 
diff --git a/changelog.md b/changelog.md
index 9dc9209..df28e0e 100644
--- a/changelog.md
+++ b/changelog.md
@@ -17,6 +17,19 @@ for soon-to-be removed features.
 
 `Security` in case of vulnerabilities.
 
+## [1.2.0] - 2023-04-12
+Version 1.2.0
+
+`Added`
+- cjio
+- tests independent of postgres 
+- logger
+- '--version' flag
+- some exceptions
+
+`Changed`
+- merged README
+- default schema to 'cjdb'
 
 ## [1.1.0] - 2023-04-06
 Version 1.1.0
diff --git a/cjdb/README.md b/cjdb/README.md
deleted file mode 100644
index 78811bf..0000000
--- a/cjdb/README.md
+++ /dev/null
@@ -1,216 +0,0 @@
-# cjdb
-
-[![MIT badge](https://img.shields.io/pypi/l/cjdb)](../LICENSE) &nbsp; [![PyPI](https://img.shields.io/pypi/v/cjdb)](https://pypi.org/project/cjdb)
-
-`cjdb` is a Python based importer of CityJSONL files to a PostgreSQL database. It requires [PostGIS](https://postgis.net/) extension for geometry types.
-
-## Table of Contents  
-### [1. CLI Usage](#usage)
-- [CLI instructions](#instructions)
-- [Quickstart example](#quickstart)
-
-### [2. cjdb explanations](#explanation)
- - [Model assumptions](#model)
- - [What is a City Model?](#citymodel)
- - [Types of input](#input)
- - [Coordinate Reference Systems](#crs)
- - [3D reprojections](#3d)
- - [CityJSON Extensions](#extensions)
- - [CityJSON GeometryTemplate](#geomtemplate)
- - [Data validation](#validation)
- - [Repeated object IDs](#repeated)
-
----------------------------------
-
-## 1. CLI usage <a name="usage"></a>
----
-### CLI instructions: <a name="instructions"></a>
-
-usage: 
-
-```bash
-cj2pgsql [-h] [-H DB_HOST] [-p DB_PORT] -U DB_USER [-W DB_PASSWORD] -d DB_NAME [-s DB_SCHEMA] [-I TARGET_SRID][-x INDEXED_ATTRIBUTES] [-px PARTIAL_INDEXED_ATTRIBUTES] [-g] [-a | -o] [-e | -u] [file_or_directory]
-```
-#### Positional Arguments
-file_or_directory
-Source CityJSONL file or a directory with CityJSONL files. STDIN if not specified. If specifying a directory, all the *.jsonl files inside of it will be imported.
-
-Default: “stdin”
-
-#### Named Arguments
--I, --srid
-Target coordinate system SRID. All 3D and 2D geometries will be reprojected.
-
--x, --attr-index
-CityObject attribute to be indexed using a btree index. Can be specified multiple times, for each attribute once.
-
-Default: []
-
--px, --partial-attr-index
-CityObject attribute to be indexed using a btree partial index. Can be specified multiple times, for each attribute once. This index indexes on a condition ‘where {
-                {ATTR_NAME
-                }
-        } is not null’. This means that it saves space and improves query performance when the attribute is not present for all imported CityObjects.
-
-Default: []
-
--g, --ignore-repeated-file
-Ignore repeated file names warning when importing. By default, the importer will send out warnings if a specific file has already been imported.
-
-Default: False
-
--a, --append
-Run in append mode (as opposed to default create mode). This assumes the database structure exists already and new data is to be appended.
-
-Default: False
-
--o, --overwrite
-Overwrite the data that is currently in the database schema. Warning: this causes the loss of what was imported before to the database schema.
-
-Default: False
-
--u, --update-existing
-Check if the object with given ID exists before inserting, and update it if it does. The old object will be updated with the new object’s properties.
-
-Default: False
-
-#### Database connection arguments
--H, --host
-PostgreSQL database host
-
-Default: “localhost”
-
--p, --port
-PostgreSQL database port
-
-Default: 5432
-
--U, --user
-PostgreSQL database user name
-
--W, --password
-PostgreSQL database user password
-
--d, --database
-PostgreSQL database name
-
--s, --schema
-Target database schema
-
-Default: “public”
-
-### Quickstart example <a name="quickstart"></a>
-Sample CityJSON data can be downloaded from [3DBAG download service](https://3dbag.nl/nl/download?tid=901).
-
-Then, having the CityJSON file, a combination of [cjio](https://github.com/cityjson/cjio) (external CityJSON processing library) and cjdb is needed to import it to a specified schema in a database.
-
-1. Convert CityJSON to CityJSONL
-
-```
-cjio --suppress_msg tile_901.json export jsonl stdout > tile_901.jsonl 
-```
-
-2. Import CityJSONL to the database
-```
-PGPASSWORD=postgres cjdb -H localhost -U postgres -d postgres -s cjdb -o tile_901.jsonl   
-```
-
-**Alternatively steps 1 and 2 in a single command:**
-
-```
-cjio --suppress_msg tile_901.json export jsonl stdout | cjdb -H localhost -U postgres -d postgres -s cjdb -o
-```
-
-The metadata and the objects can then be found in the tables in the specified schema (`cjdb` in this example).
-
-
-Password can be specified in the `PGPASSWORD` environment variable. If not specified, the app will prompt for the password.
-
-
-## 2. cjdb explanations <a name="explanation"></a>
----
-### Model assumptions <a name="model"></a>
-The `cjdb` importer loads the data in accordance with a specific data model.
-
-Model documentation:
- [model/README](model/README.md)
-
-#### Indexes
-Some indexes are created by default (refer to [model/README](model/README.md)).
-
-Additionally, the user can specify which CityObject attributes are to be indexed with the `-x/--attr-index` or `-px/--partial-attr-index` flag. The second option uses a partial index with a `not null` condition on the attribute. This saves disk space when indexing an attribute that is not present among all the imported CityObjects. This is often the case with CityJSON, because in a single dataset there can be different object types, with different attributes.
-
-### What is a City Model? How to organize CityJSON data from various sources? <a name="citymodel"></a>
-
-The definition and scope of the City Model are for the user to decide. It is recommended to group together semantically coherent objects, by importing them to the same database schema.
-
-While the static table structure (columns don't change) does support loading any type of CityJSON objects together, the data becomes harder to manage for the user. Example of this would be having different attributes for the same CityObject type (which should be consistent for data coming from the same source).
-
-### Types of input <a name="input"></a>
-The importer works only on *CityJSONL* files.
-Instructions on how to obtain such a file from a *CityJSON* file: https://cjio.readthedocs.io/en/latest/includeme.html#stdin-and-stdout
-
-
-The importer supports 3 kinds of input:
-- a single CityJSONL file
-- a directory of CityJSONL files (all files with *jsonl* extensions are located and imported)
-- STDIN using the pipe operator:
-```
-cat file.jsonl | cjdb ...
-```
-
-### Coordinate Reference Systems <a name="crs"></a>
-The `cjdb` importer does not allow inconsistent CRS (coordinate reference systems) within the same database schema. For storing data in separate CRS using multiple schemas is required.
-
-The data needs to be either harmonized beforehand, or the `-I/--srid` flag can be used upon import, to reproject all the geometries to the one specified CRS. Specifying a 2D CRS (instead of a 3D one) will cause the Z-coordinates to remain unchanged.
-
-**Note:** reprojections slow down the import significantly.
-
-**Note:** Source data with missing `"metadata"/"referenceSystem"` cannot be reprojected due to unknown source reference system.
-
-### 3D reprojections <a name="3d"></a>
-`Pyproj` is used for CRS reprojections. It supports 3D CRS transformations between different systems. However, sometimes downloading additional [grids](https://pyproj4.github.io/pyproj/stable/transformation_grids.html) is required. The importer will attempt to download the grids needed for the reprojection, with the following message:
-```
-Attempting to download additional grids required for CRS transformation.
-This can also be done manually, and the files should be put in this folder:
-        {pyproj_directory}
-```
-
-If that fails, the user will have to download the required grids and put them in the printed `{pyproj_directory}` themselves. 
-
-
-### CityJSON Extensions <a name="extensions"></a>
-If [CityJSON Extensions](https://www.cityjson.org/extensions/) were present in the imported file, they can be found listed in the `extensions` column in the `import_meta` table.
-
-The [CityJSON specifications](https://www.cityjson.org/specs/1.1.2/#extensions) mention 3 different extendable features, and the `cjdb` importer deals with them as follows:
-
-1. Complex attributes
-
-No action is taken. These attributes end up in the `attributes` JSONB column.
-
-2. Additional root properties
-
-Additional root properties are placed in the `extra properties` JSONB column in the `import_meta` table.
-
-3. Additional CityObject type
-
-Additional CityObject types are appended to the list of allowed CityJSON objects.
-
-### CityJSON GeometryTemplates <a name="geomtemplate"></a>
-[Geometry templates](https://www.cityjson.org/specs/1.1.2/#geometry-templates)
-are resolved for each object geometry, so that the object in the table ends up with its real-world coordinates (instead of vertex references or relative template coordinates).
-
-### Data validation <a name="validation"></a>
-The importer does not validate the structure of the file. It is assumed that the input file is schema-valid ([CityJSON validator](https://validator.cityjson.org/)).
-It sends out warnings when:
-- there appear CityObject types defined neither in the main CityJSON specification nor any of the supplied extensions. 
-- the specified target CRS does not have the Z-axis defined
-- the source dataset does not have a CRS defined at all
-
-### Repeated object IDs <a name="repeated"></a>
-By default, the importer does not check if an object with a given ID exists already in the database. This is because such an operation for every inserted object results in a performance penalty.
-
-The user can choose to run the import with either the `-e/--skip-existing` option to skip existing objects or `-u, --update-existing` to update existing objects. This will slow down the import, but it will also ensure that repeated object cases are handled.
-
-
-
diff --git a/cjdb/__init__.py b/cjdb/__init__.py
index e69de29..8c1f573 100644
--- a/cjdb/__init__.py
+++ b/cjdb/__init__.py
@@ -0,0 +1,6 @@
+try:
+    import importlib.metadata as importlib_metadata
+except ModuleNotFoundError:
+    import importlib_metadata
+
+__version__ = importlib_metadata.version(__name__)
diff --git a/cjdb/logger.py b/cjdb/logger.py
new file mode 100644
index 0000000..8f52520
--- /dev/null
+++ b/cjdb/logger.py
@@ -0,0 +1,10 @@
+import logging
+
+logging.basicConfig(
+    format=" %(asctime)s %(levelname)s: %(message)s",
+    level=logging.INFO,
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.INFO)
diff --git a/cjdb/main.py b/cjdb/main.py
index f9d68e6..514ad47 100644
--- a/cjdb/main.py
+++ b/cjdb/main.py
@@ -1,12 +1,13 @@
 from cjdb.modules.arg_parser import Parser, validate_args
 from cjdb.modules.importer import Importer
+from cjdb.modules.utils import get_db_engine
 
 
 def get_args():
     parser = Parser()
     args = parser.parse_args()
     validation_result, validation_msg = validate_args(args)
-    
+
     if validation_result:
         return args
     else:
@@ -15,13 +16,15 @@ def get_args():
 
 # organized like this to be able to call it in tests and also as CLI
 def run(args):
-    with Importer(args) as imp:
+    engine = get_db_engine(args)
+    with Importer(engine, args) as imp:
         imp.run_import()
 
+
 def main():
     args = get_args()
     run(args)
 
 
 if __name__ == "__main__":
-    main()
\ No newline at end of file
+    main()
diff --git a/cjdb/model/README.md b/cjdb/model/README.md
index 792abe1..cca413b 100644
--- a/cjdb/model/README.md
+++ b/cjdb/model/README.md
@@ -40,12 +40,12 @@ The import_meta table stores information from imported files, e.g. name or metad
 <**id**: import_meta record's index within the database.<br/>
 **source_file**: name of the source file.<br/>
 **version**: cityJSON version used.<br/>
-**metadata**: [cityJSON metadata object](https://www.cityjson.org/specs/1.1.2/#metadata), a JSON object describing the creator, dataset extent or coordinate reference system used, etc.<br/>
-**transform**:[cityJSON transform object](https://www.cityjson.org/specs/1.1.2/#transform-object), a JSON object describing how to decompress the integer coordinates of the geometries to obtain real-world coordinates.<br/>
-**geometry_templates**: [cityJSON geometry-templates object](https://www.cityjson.org/specs/1.1.2/#geometry-templates), a JSON object containing the templates that can be reused by different City Objects (usually for trees).<br/>
+**metadata**: [cityJSON metadata object](https://www.cityjson.org/specs/#metadata), a JSON object describing the creator, dataset extent or coordinate reference system used, etc.<br/>
+**transform**:[cityJSON transform object](https://www.cityjson.org/specs/#transform-object), a JSON object describing how to decompress the integer coordinates of the geometries to obtain real-world coordinates.<br/>
+**geometry_templates**: [cityJSON geometry-templates object](https://www.cityjson.org/specs/#geometry-templates), a JSON object containing the templates that can be reused by different City Objects (usually for trees).<br/>
 **srid**: Coordinate reference system (CRS) of the imported city objects in the database. If not specified when importing, the CRS will be the same with the source file's CRS. If specified when importing, the CRS will be the specified CRS.<br/>
-**extensions**: [cityJSON Extensions](https://www.cityjson.org/specs/1.1.2/#extensions), a JSON file that documents how the core data model of CityJSON is extended.<br/>
-**extra_properties**: [extraRootProperties](https://www.cityjson.org/specs/1.1.2/#case-2-adding-new-properties-at-the-root-of-a-document), a JSON object with added new properties at the root of the imported document.<br/>
+**extensions**: [cityJSON Extensions](https://www.cityjson.org/specs/#extensions), a JSON file that documents how the core data model of CityJSON is extended.<br/>
+**extra_properties**: [extraRootProperties](https://www.cityjson.org/specs/#case-2-adding-new-properties-at-the-root-of-a-document), a JSON object with added new properties at the root of the imported document.<br/>
 **started_at**: importing start time.<br/>
 **finished_at**: importing finish time. `null` if not finished.<br/>
 **Bounding box**: bounding box is taken from the `geographicExtent` object from the `metadata` section
@@ -58,8 +58,8 @@ The cj_object model stores individual city objects, for instance buildings, road
 **import_meta_id**: the source file id of the city object, foriegn key to the id column of import_meta table.<br/>
 **object_id**: the identification string of the city object (e.g. NL.IMBAG.Pand.0503100000000033-0).<br/>
 **type**: type of the city object (e.g. building, buildingparts, etc.).<br/>
-**attributes**:[cityJSON attributes](https://www.cityjson.org/specs/1.1.2/#attributes-for-all-city-objects), a JSON object that describes attributes of the city object (e.g. roof type, area, etc.).<br/>
-**geometry**: [cityJSON geometry](https://www.cityjson.org/specs/1.1.2/#geometry-objects), a JSON object that describes the geometry of the city object.<br/>
+**attributes**:[cityJSON attributes](https://www.cityjson.org/specs/#attributes-for-all-city-objects), a JSON object that describes attributes of the city object (e.g. roof type, area, etc.).<br/>
+**geometry**: [cityJSON geometry](https://www.cityjson.org/specs/#geometry-objects), a JSON object that describes the geometry of the city object.<br/>
 **ground_geometry**: ground geometry of the city object, in geometry type.<br/>
 
 ### 3. Family <a name="family"></a>
diff --git a/cjdb/model/sqlalchemy_models/__init__.py b/cjdb/model/sqlalchemy_models/__init__.py
index cbcf08b..31eef04 100644
--- a/cjdb/model/sqlalchemy_models/__init__.py
+++ b/cjdb/model/sqlalchemy_models/__init__.py
@@ -1,11 +1,16 @@
-from sqlalchemy import Column, ForeignKey, \
-    String, func, Integer, UniqueConstraint
-from sqlalchemy.orm import relationship, declarative_base
-from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP
+import sys
+
 from geoalchemy2 import Geometry
+from sqlalchemy import (Column, ForeignKey, Integer, String, UniqueConstraint,
+                        func)
+from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP
+from sqlalchemy.orm import declarative_base, relationship
+
+from cjdb.logger import logger
 
 Base = declarative_base()
 
+
 class BaseModel(Base):
     __abstract__ = True
     id = Column(Integer, primary_key=True)
@@ -16,8 +21,8 @@ def NullableJSONB():
 
 
 class ImportMetaModel(BaseModel):
-    __tablename__ = 'import_meta'
-    __table_args__ = {'schema':'cjdb'}
+    __tablename__ = "import_meta"
+    __table_args__ = {"schema": "cjdb"}
     source_file = Column(String)
     version = Column(String(10), nullable=False)
     meta = Column(JSONB, name="metadata")
@@ -28,71 +33,90 @@ class ImportMetaModel(BaseModel):
     extra_properties = Column(NullableJSONB())
     started_at = Column(TIMESTAMP, default=func.now())
     finished_at = Column(TIMESTAMP)
-    bbox = Column(Geometry('Polygon'))
+    bbox = Column(Geometry("Polygon"))
 
-    def compare_existing(self, session, ignore_repeated_file):
+    def compare_existing(self, session, ignore_repeated_file, update_existing):
         result_ok = True
 
         # check if the file was already imported
-        if self.source_file.lower() != 'stdin' and not ignore_repeated_file:
-            same_source_import = session.query(ImportMetaModel)\
-                .filter_by(source_file=self.source_file)\
-                .filter(ImportMetaModel.finished_at.isnot(None))\
-                .order_by(ImportMetaModel.finished_at.desc())\
+        if self.source_file.lower() != "stdin" and \
+           not ignore_repeated_file and \
+           not update_existing:
+            same_source_import = (
+                session.query(ImportMetaModel)
+                .filter_by(source_file=self.source_file)
+                .filter(ImportMetaModel.finished_at.isnot(None))
+                .order_by(ImportMetaModel.finished_at.desc())
                 .first()
+            )
             if same_source_import:
-                print(f"\nFile '{self.source_file}' was previously imported at {same_source_import.finished_at}.\n" \
-                        "Use the -g flag to suppress this warning")
-                user_answer = input("Should the import continue? " +
-                                    "Already imported City Objects will " +
-                                    "be skipped. If you want to update " +
-                                    "use the flag --update-existing. \n" +
-                                    " [y / n]\n")
+                logger.warning(
+                    "File %s was previously imported at %s. "
+                    "Use the -g flag to suppress this warning.",
+                    self.source_file,  same_source_import.finished_at
+                )
+
+                user_answer = input(
+                    "Should the import continue? "
+                    "Already imported City Objects will be skipped. "
+                    "If you want to update them instead "
+                    "use the flag --update-existing. \n"
+                    " [y / n]\n"
+                )
                 if user_answer.lower() != "y":
-                    return False
-    
+                    logger.info("Import process terminated by user.")
+                    sys.exit(1)
+
         # check if the CRS is consistent with other imports
-        different_srid_meta = session.query(ImportMetaModel)\
-                            .filter(ImportMetaModel.srid != self.srid)\
-                            .filter(ImportMetaModel.finished_at.isnot(None))\
-                            .order_by(ImportMetaModel.finished_at.desc())\
-                            .first()
+        different_srid_meta = (
+            session.query(ImportMetaModel)
+            .filter(ImportMetaModel.srid != self.srid)
+            .filter(ImportMetaModel.finished_at.isnot(None))
+            .order_by(ImportMetaModel.finished_at.desc())
+            .first()
+        )
 
         if different_srid_meta:
-            print("Inconsistent Coordinate Reference Systems detected")
-            print(f"Currently imported SRID: {self.srid}")
-            print(f"Recently imported SRID: {different_srid_meta.srid}")
-            print("Use the '-I/--srid' flag to reproject everything to a single specified CRS",
-                    "or modify source data.")
+            logger.error("Inconsistent Coordinate Reference Systems detected."
+                         "\nCurrently imported SRID: %s \n"
+                         "Recently imported SRID: %s "
+                         "\nUse the '-I/--srid' flag to reproject everything "
+                         "to a single specified CRS or modify source data.",
+                         self.srid, different_srid_meta.srid
+                         )
             return False
 
         return result_ok
 
 
 class CjObjectModel(BaseModel):
-    __tablename__ = 'cj_object'
-    __table_args__ = {'schema':'cjdb'}
+    __tablename__ = "cj_object"
+    __table_args__ = {"schema": "cjdb"}
     import_meta_id = Column(Integer, ForeignKey(ImportMetaModel.id))
     object_id = Column(String, nullable=False, unique=True)
     type = Column(String, nullable=False)
     attributes = Column(NullableJSONB())
     geometry = Column(NullableJSONB())
-    ground_geometry=Column(Geometry('MultiPolygon'))
+    ground_geometry = Column(Geometry("MultiPolygon"))
     import_meta = relationship(ImportMetaModel)
 
     @classmethod
     def get_attributes_and_types(cls, session):
         # sample attributes for each object type
-        # this is needed to create proper indexes and also to use those indexes when querying
-        sampled_objects = session.query(cls)\
-            .distinct(cls.type)\
-            .filter(cls.attributes.isnot(None))\
-            .order_by(cls.type, cls.id.desc())\
+        # this is needed to create proper indexes and also
+        # to use those indexes when querying
+        sampled_objects = (
+            session.query(cls)
+            .distinct(cls.type)
+            .filter(cls.attributes.isnot(None))
+            .order_by(cls.type, cls.id.desc())
             .all()
+        )
 
-        # create type mapping for each attribute appearing across all object types
-        # this considers that different object types have different attributes
-        # if same object types have different attributes, then this will not work correctly
+        # create type mapping for each attribute appearing
+        # across all object types this considers that different
+        # object types have different attributes if same object
+        # types have different attributes, then this will not work correctly
         type_mapping = {}
         if sampled_objects:
             for cj_obj in sampled_objects:
@@ -103,12 +127,16 @@ def get_attributes_and_types(cls, session):
 
 
 class FamilyModel(BaseModel):
-    __tablename__ = 'family'
-    __table_args__ = {'schema':'cjdb'}
+    __tablename__ = "family"
+    __table_args__ = {"schema": "cjdb"}
     parent_id = Column(String, ForeignKey(CjObjectModel.object_id))
     child_id = Column(String, ForeignKey(CjObjectModel.object_id))
 
-    parent = relationship(CjObjectModel, foreign_keys=[parent_id], post_update=True)
-    child = relationship(CjObjectModel, foreign_keys=[child_id], post_update=True)
+    parent = relationship(CjObjectModel,
+                          foreign_keys=[parent_id],
+                          post_update=True)
+    child = relationship(CjObjectModel,
+                         foreign_keys=[child_id],
+                         post_update=True)
 
     parent_child_unique = UniqueConstraint(parent_id, child_id)
diff --git a/cjdb/modules/arg_parser.py b/cjdb/modules/arg_parser.py
index 06e0cd3..d63058f 100644
--- a/cjdb/modules/arg_parser.py
+++ b/cjdb/modules/arg_parser.py
@@ -2,6 +2,7 @@
 import os
 from getpass import getpass
 
+from cjdb import __version__
 from cjdb.resources import strings as s
 
 
@@ -20,6 +21,13 @@ def Parser():
         help=s.filepath_help,
     )
 
+    parser.add_argument(
+        "--version",
+        action="version",
+        version='%(prog)s ' + __version__,
+        help=s.version_help,
+    )
+
     db = parser.add_argument_group(title="Database connection arguments")
 
     db.add_argument(
@@ -30,6 +38,7 @@ def Parser():
         help=s.host_help,
         dest="db_host"
     )
+
     db.add_argument(
         "-p",
         "--port",
@@ -38,6 +47,7 @@ def Parser():
         help=s.port_help,
         dest="db_port"
     )
+
     db.add_argument(
         "-U",
         "--user",
@@ -46,6 +56,7 @@ def Parser():
         help=s.user_help,
         dest="db_user"
     )
+
     db.add_argument(
         "-W",
         "--password",
@@ -54,6 +65,7 @@ def Parser():
         help=s.password_help,
         dest="db_password",
     )
+
     db.add_argument(
         "-d",
         "--database",
@@ -62,14 +74,16 @@ def Parser():
         help=s.database_help,
         dest="db_name",
     )
+
     db.add_argument(
         "-s",
         "--schema",
         type=str,
-        default="public",
+        default="cjdb",
         help=s.schema_help,
         dest="db_schema",
     )
+
     parser.add_argument(
         "-I",
         "--srid",
@@ -78,6 +92,7 @@ def Parser():
         help=s.srid_help,
         dest="target_srid"
     )
+
     parser.add_argument(
         "-x",
         "--attr-index",
@@ -87,6 +102,7 @@ def Parser():
         help=s.index_help,
         dest="indexed_attributes",
     )
+
     parser.add_argument(
         "-px",
         "--partial-attr-index",
@@ -108,6 +124,7 @@ def Parser():
     )
 
     mode = parser.add_mutually_exclusive_group()
+
     mode.add_argument(
         "-a",
         "--append",
@@ -117,6 +134,7 @@ def Parser():
         help=s.append_help,
         dest="append_mode",
     )
+
     mode.add_argument(
         "-o",
         "--overwrite",
@@ -145,6 +163,8 @@ def validate_args(args):
     result = True
     msg = ""
     if not args.db_password:
-        args.db_password = getpass(prompt=f'Password for user "{args.db_user}": ') # noqa
+        args.db_password = getpass(
+            prompt=f'Password for user "{args.db_user}": '
+        )  # noqa
 
     return result, msg
diff --git a/cjdb/modules/checks.py b/cjdb/modules/checks.py
index e4b93e8..b3f4863 100644
--- a/cjdb/modules/checks.py
+++ b/cjdb/modules/checks.py
@@ -1,14 +1,16 @@
 from pyproj import CRS, datadir
 from pyproj.transformer import TransformerGroup
 
+from cjdb.logger import logger
+
 
 def check_root_properties(found_extra_properties, defined_extra_properties):
     if found_extra_properties:
-        base_msg = """Warning: An extended CityJSON root property: '{}'\
+        base_msg = """An extended CityJSON root property: '{}'\
                       was not defined by any of the extensions."""
         for prop_name in found_extra_properties:
             if prop_name not in defined_extra_properties:
-                print(base_msg.format(prop_name))
+                logger.warning(base_msg.format(prop_name))
 
 
 def check_object_type(checked_type, allowed_types, extended_types):
@@ -17,43 +19,42 @@ def check_object_type(checked_type, allowed_types, extended_types):
     all_types = allowed_types + extended_types
     if checked_type not in all_types:
         check_result = False
-        message = f"""Warning: CityJSON object type '{checked_type}'\
+        message = f"""CityJSON object type '{checked_type}'\
                       not allowed by main schema nor extensions."""
 
     return check_result, message
 
 
 # check if reprojection possible
-# prints warnings, but doesn't stop the import
+# logs warnings, but doesn't stop the import
 def check_reprojection(source_srid, target_srid):
     source_proj = CRS.from_epsg(source_srid)
     target_proj = CRS.from_epsg(target_srid)
 
     if len(target_proj.axis_info) < 3:
-        print(
-            f"Warning: The specified target SRID({target_srid}) "
-            + "lacks information about the Z-axis."
-            + " The Z vertex values will remain unchanged."
+        logger.warning(
+            "The specified target SRID(%s) "
+            "lacks information about the Z-axis. "
+            "The Z vertex values will remain unchanged.",
+            target_srid
         )
 
     group = TransformerGroup(source_proj, target_proj)
-    # this prints a warning if there are some grids missing for the reprojection # noqa
-    # more about this https://pyproj4.github.io/pyproj/stable/transformation_grids.html # noqa
+    # this logs a warning if there are some grids missing for the reprojection
+    # more about this:
+    # https://pyproj4.github.io/pyproj/stable/transformation_grids.html
 
     # attempt to download missing grids
     if not group.best_available:
-        print(
-            """Attempting to download additional grids\
-               required for CRS transformation."""
-        )
-        print(
-            f"""This can also be done manually, and the grid\
-            should be put in this folder:\n\t{datadir.get_data_dir()}"""
-        )
+        logger.warning("""Attempting to download additional grids\
+               required for CRS transformation.""")
+        logger.warning(
+            "This can also be done manually, and the grid should be put"
+            "in this folder:\n %s", datadir.get_data_dir())
 
         try:
             group.download_grids(datadir.get_data_dir())
-        except (...):
-            print("Failed to download the missing grids.")
+        except ...:
+            logger.warning("Failed to download the missing grids.")
         else:
-            print("Download successful.")
+            logger.info("Download successful.")
diff --git a/cjdb/modules/exceptions.py b/cjdb/modules/exceptions.py
new file mode 100644
index 0000000..818a5a4
--- /dev/null
+++ b/cjdb/modules/exceptions.py
@@ -0,0 +1,28 @@
+class InvalidMetadataException(Exception):
+    def __init__(self, *args):
+        if args:
+            self.msg = args[0]
+        else:
+            self.msg = None
+
+    def __str__(self):
+        if self.msg:
+            return f"InvalidMetadataException: {self.msg}"
+        else:
+            return """InvalidMetadataException: the CityJSON object
+                      has invalid 'metadata'."""
+
+
+class InvalidCityJSONObjectException(Exception):
+    def __init__(self, *args):
+        if args:
+            self.msg = args[0]
+        else:
+            self.msg = None
+
+    def __str__(self):
+        if self.msg:
+            return f"InvalidCityJSONObjectException: {self.msg}"
+        else:
+            return """InvalidCityJSONObjectException: the first object of the
+                      file should be a valid CityJSON object."""
diff --git a/cjdb/modules/extensions.py b/cjdb/modules/extensions.py
index 2ce74d1..2e1f4ad 100644
--- a/cjdb/modules/extensions.py
+++ b/cjdb/modules/extensions.py
@@ -2,6 +2,8 @@
 
 import requests
 
+from cjdb.logger import logger
+
 
 class ExtensionHandler:
     def __init__(self, extensions):
@@ -9,7 +11,7 @@ def __init__(self, extensions):
         self.extra_root_properties = []
         self.extra_attributes = {}
         self.extra_city_objects = []
-        
+
         if extensions:
             self.get_extensions(extensions)
 
@@ -27,7 +29,10 @@ def get_extensions(self, extensions):
                         ext_definition = json.loads(resp.text)
                         self.full_definitions[ext_name] = ext_definition
                     except ValueError as e:
-                        print(f"Extension url: {url} did not provide a correct json schema")
+                        logger.error(
+                            "Extension url: %s did not provide a correct json"
+                            " schema", url
+                        )
                         # raise
                         # throw this exception or ignore it?
                         return
@@ -35,7 +40,9 @@ def get_extensions(self, extensions):
                     for prop_name in ext_definition["extraRootProperties"]:
                         self.extra_root_properties.append(prop_name)
 
-                    for obj_type, extra_attributes in ext_definition["extraAttributes"].items():
+                    for obj_type, extra_attributes in ext_definition[
+                        "extraAttributes"
+                    ].items():
                         if obj_type not in self.extra_attributes:
                             self.extra_attributes[obj_type] = []
                         for attr_name in extra_attributes:
@@ -47,7 +54,3 @@ def get_extensions(self, extensions):
                     msg = f"Extension url: {url} did not return a correct response"
                     # raise Exception(msg)
                     return
-
-    
-
-
diff --git a/cjdb/modules/geometric.py b/cjdb/modules/geometric.py
index edae2e9..50a8f1b 100644
--- a/cjdb/modules/geometric.py
+++ b/cjdb/modules/geometric.py
@@ -1,11 +1,11 @@
 import copy
-from collections import OrderedDict
 
 import numpy as np
 from pyproj import CRS, Transformer
-from pyproj.transformer import TransformerGroup
-from shapely.geometry import MultiPolygon, Point, Polygon, box
-from shapely.validation import explain_validity, make_valid
+from shapely.geometry import MultiPolygon, Point, Polygon
+from shapely.validation import explain_validity
+
+from cjdb.logger import logger
 
 
 # get srid from a CRS string definition
@@ -40,7 +40,9 @@ def reproject_vertex_list(vertices, srid_from, srid_to):
     target_proj = CRS.from_epsg(srid_to)
 
     # prepare transformer from crs to crs
-    transformer = Transformer.from_crs(source_proj, target_proj, always_xy=True)
+    transformer = Transformer.from_crs(source_proj,
+                                       target_proj,
+                                       always_xy=True)
 
     # transform all the coordinates
     reprojected_xyz = transformer.transform(*zip(*vertices))
@@ -55,7 +57,7 @@ def resolve(lod_level, vertices, inplace=True):
     else:
         resolvable = copy.deepcopy(lod_level)
 
-    for boundary in resolvable['boundaries']:
+    for boundary in resolvable["boundaries"]:
         for i, shell in enumerate(boundary):
             if type(shell[0]) is list:
                 for j, ring in enumerate(shell):
@@ -73,41 +75,50 @@ def resolve(lod_level, vertices, inplace=True):
 
     return resolvable
 
-def resolve_template(lod_level, vertices, geometry_templates, source_target_srid):
+
+def resolve_template(lod_level,
+                     vertices,
+                     geometry_templates,
+                     source_target_srid):
     # get anchor point
     vertex_id = lod_level["boundaries"][0]
     anchor = vertices[vertex_id]
 
     # apply transformation matrix to the template vertices
-    template_vertices = [transform_with_rotation(v, lod_level["transformationMatrix"]) 
-            for v in geometry_templates["vertices-templates"]]
+    template_vertices = [
+        transform_with_rotation(v, lod_level["transformationMatrix"])
+        for v in geometry_templates["vertices-templates"]
+    ]
 
     # add anchor point to the vertices
     template_vertices = [list(np.array(v) + anchor) for v in template_vertices]
 
     # reproject vertices if needed
     if source_target_srid:
-        template_vertices = reproject_vertex_list(template_vertices, *source_target_srid)
+        template_vertices = reproject_vertex_list(
+            template_vertices, *source_target_srid
+        )
 
     # dereference template vertices
     template_id = lod_level["template"]
     template = geometry_templates["templates"][template_id]
-    
-    # inplace=False, because the template can be resolved differently for some other cityobject
+
+    # inplace=False, because the template can be resolved differently
+    # for some other cityobject
     resolved_template = resolve(template, template_vertices, inplace=False)
     return resolved_template
 
 
-def resolve_geometry_vertices(geometry, vertices, 
-                                geometry_templates, source_target_srid):
-
-    # use ready vertices to resolve coordinate values for the geometry (or geometry template)
+def resolve_geometry_vertices(
+    geometry, vertices, geometry_templates, source_target_srid
+):
+    # use ready vertices to resolve coordinate values
+    # for the geometry (or geometry template)
     for i, lod_level in enumerate(geometry):
         if lod_level["type"] == "GeometryInstance":
-            resolved_template = resolve_template(lod_level, 
-                                                vertices,
-                                                geometry_templates,
-                                                source_target_srid)
+            resolved_template = resolve_template(
+                lod_level, vertices, geometry_templates, source_target_srid
+            )
             geometry[i] = resolved_template
         else:
             # resolve without geometry template
@@ -119,74 +130,78 @@ def resolve_geometry_vertices(geometry, vertices,
 def get_ground_geometry(geometry, obj_id):
     # returns a shapely multipolygon (see shapely.geometry.MultiPolygon)
     # the MultiPolygon should be a 2D geometry (Z coordinate is omitted)
-    # this geometry should be obtained by parsing the "geometry" object from cityjson -> the argument of this function
-    # the geometry is a multipolygon of all the ground surfaces in the lowest available LOD
+    # this geometry should be obtained by parsing the "geometry" object
+    # from cityjson -> the argument of this function
+    # the geometry is a multipolygon of all the ground
+    # surfaces in the lowest available LOD
 
-    planes=dict()
-    z_min=0
+    planes = dict()
+    z_min = 0
     for boundary in geometry[0]["boundaries"]:
         for i, shell in enumerate(boundary):
             if type(shell[0]) is list:
                 if type(shell[0][0]) is list:
                     for ring in shell:
-                        Point_list=[]
-                        z_tot=0
-                        z_count=0
-                        for x,y,z in ring:
-                            z_tot=z+z_tot
-                            z_count=z_count+1
-                            p=Point(x,y,z)
+                        Point_list = []
+                        z_tot = 0
+                        z_count = 0
+                        for x, y, z in ring:
+                            z_tot = z + z_tot
+                            z_count = z_count + 1
+                            p = Point(x, y, z)
                             Point_list.append(p)
-                        z_avg=round(z_tot/z_count,6)
-                        z_min=z_avg
-                        planes[str(z_avg)]=Point_list                
+                        z_avg = round(z_tot / z_count, 6)
+                        z_min = z_avg
+                        planes[str(z_avg)] = Point_list
                 else:
-                    Point_list=[]                   
-                    z_tot=0
-                    z_count=0
-                    for x,y,z in shell:
-                        z_tot=z+z_tot
-                        z_count=z_count+1
-                        p=Point(x,y,z)
+                    Point_list = []
+                    z_tot = 0
+                    z_count = 0
+                    for x, y, z in shell:
+                        z_tot = z + z_tot
+                        z_count = z_count + 1
+                        p = Point(x, y, z)
                         Point_list.append(p)
-                    z_avg=round(z_tot/z_count,6)
-                    z_min=z_avg
-                    planes[str(z_avg)]=Point_list
-    
+                    z_avg = round(z_tot / z_count, 6)
+                    z_min = z_avg
+                    planes[str(z_avg)] = Point_list
+
     for key in planes:
-        z_num=float(key)
-        if(z_num<z_min):
-            z_min=z_num
+        z_num = float(key)
+        if z_num < z_min:
+            z_min = z_num
 
-    ground_points=[]
-    ground_points_dic={}
+    ground_points = []
+    ground_points_dic = {}
 
     for key in planes:
-        if(abs(float(key)-z_min)<0.3):
-            poly = Polygon([[p.x, p.y] for p in planes[key]])
+        if abs(float(key) - z_min) < 0.3:
             for p in planes[key]:
-                str_p=str(p.x)+" "+str(p.y)
-                if((str_p in ground_points_dic.keys())is False):
-                    ground_points_dic[str_p]=0
-    
+                str_p = str(p.x) + " " + str(p.y)
+                if (str_p in ground_points_dic.keys()) is False:
+                    ground_points_dic[str_p] = 0
+
     for key in ground_points_dic:
-        p_x=key.split()[0]
-        p_y=key.split()[1]
-        p=Point(float(p_x),float(p_y))
-        ground_points.append(p) 
+        p_x = key.split()[0]
+        p_y = key.split()[1]
+        p = Point(float(p_x), float(p_y))
+        ground_points.append(p)
 
-    if(len(ground_points)>=3):
-        ground_polygon=Polygon([[p.x, p.y] for p in ground_points])
+    if len(ground_points) >= 3:
+        ground_polygon = Polygon([[p.x, p.y] for p in ground_points])
     else:
-        print(f"Warning: Ground geometry for object ID=({obj_id}) could not be calculated.")
+        logger.warning(
+            f"Ground geometry for object ID=({obj_id}) could not be"
+            " calculated."
+        )
         return None
-    
-    if(ground_polygon.is_valid==False):
-        ground_polygon=ground_polygon.buffer(0)
-        if(ground_polygon.is_valid==False):
-            print(explain_validity(ground_polygon))
 
-    if (type(ground_polygon) is Polygon):
-        ground_polygon=MultiPolygon([ground_polygon])
+    if ground_polygon.is_valid is False:
+        ground_polygon = ground_polygon.buffer(0)
+        if ground_polygon.is_valid is False:
+            logger.info(explain_validity(ground_polygon))
+
+    if type(ground_polygon) is Polygon:
+        ground_polygon = MultiPolygon([ground_polygon])
 
-    return ground_polygon
\ No newline at end of file
+    return ground_polygon
diff --git a/cjdb/modules/importer.py b/cjdb/modules/importer.py
index 29f5442..b1555b0 100644
--- a/cjdb/modules/importer.py
+++ b/cjdb/modules/importer.py
@@ -2,36 +2,27 @@
 import os
 import sys
 from pathlib import Path
+from typing import Optional, Tuple
 
+from shapely.geometry.base import BaseGeometry
 from sqlalchemy import func, text
 from sqlalchemy.dialects.postgresql import insert
 from sqlalchemy.orm import Session
 
-from cjdb.modules.checks import (
-    check_object_type,
-    check_reprojection,
-    check_root_properties,
-)
+from cjdb.logger import logger
+from cjdb.model.sqlalchemy_models import (BaseModel, CjObjectModel,
+                                          FamilyModel, ImportMetaModel)
+from cjdb.modules.checks import (check_object_type, check_reprojection,
+                                 check_root_properties)
+from cjdb.modules.exceptions import (InvalidCityJSONObjectException,
+                                     InvalidMetadataException)
 from cjdb.modules.extensions import ExtensionHandler
-from cjdb.modules.geometric import (
-    get_ground_geometry,
-    get_srid,
-    reproject_vertex_list,
-    resolve_geometry_vertices,
-    transform_vertex,
-)
-from cjdb.modules.utils import (
-    find_extra_properties,
-    get_cj_object_types,
-    get_db_engine,
-    to_dict,
-)
-from cjdb.model.sqlalchemy_models import (
-    BaseModel,
-    CjObjectModel,
-    FamilyModel,
-    ImportMetaModel,
-)
+from cjdb.modules.geometric import (get_ground_geometry, get_srid,
+                                    reproject_vertex_list,
+                                    resolve_geometry_vertices,
+                                    transform_vertex)
+from cjdb.modules.utils import (find_extra_properties, get_cj_object_types,
+                                is_cityjson_object, to_dict)
 
 
 # class to store variables per file import - for clarity
@@ -50,7 +41,8 @@ def __init__(self, file="stdin"):
 
 # importer class called once per whole import
 class Importer:
-    def __init__(self, args):
+    def __init__(self, engine, args):
+        self.engine = engine
         self.args = args
         # get allowed types for validation
         self.cj_object_types = get_cj_object_types()
@@ -60,14 +52,13 @@ def __init__(self, args):
             table.schema = self.args.db_schema
 
     def __enter__(self):
-        self.engine = get_db_engine(self.args)
         self.session = Session(self.engine)
         return self
 
     def __exit__(self, exc_type, exc_value, traceback):
         self.session.close()
 
-    def run_import(self):
+    def run_import(self) -> None:
         # create model if in create mode, else append data
         if not self.args.append_mode:
             self.prepare_database()
@@ -78,24 +69,26 @@ def run_import(self):
             self.post_import()
         self.current.import_meta.finished_at = func.now()
         self.session.commit()
-        print(f"Imported from {self.args.filepath} successfully")
+        logger.info(f"Imported from {self.args.filepath} successfully")
 
-    def prepare_database(self):
+    def prepare_database(self) -> None:
+        """Adds the postgis extension and creates
+        the schema and the tables."""
         with self.engine.connect() as conn:
+            conn.execute(text("""CREATE EXTENSION IF NOT EXISTS postgis"""))
             if self.args.overwrite:
-                conn.execute(
-                    text(f"""DROP SCHEMA
+                conn.execute(text(f"""DROP SCHEMA
                              IF EXISTS {self.args.db_schema}
-                             CASCADE""")
-                )
+                             CASCADE"""))
             conn.execute(text(f"""CREATE SCHEMA IF NOT EXISTS
                                   {self.args.db_schema}"""))
-
+            conn.commit()
         # create all tables defined as SqlAlchemy models
         for table in BaseModel.metadata.tables.values():
             table.create(self.engine, checkfirst=True)
 
-    def parse_cityjson(self):
+    def parse_cityjson(self) -> None:
+        """Parses the input path."""
         source_path = self.args.filepath
 
         if os.path.isfile(source_path) or source_path.lower() == "stdin":
@@ -107,8 +100,9 @@ def parse_cityjson(self):
         else:
             raise Exception(f"Path: '{source_path}' not found")
 
-    def post_import(self):
-        # post import operations like clustering, indexing...
+    def post_import(self) -> None:
+        """Perform post import operation on the schema,
+           like clustering and indexing"""
 
         cur_path = Path(__file__).parent
         sql_path = os.path.join(cur_path.parent, "resources/post_import.sql")
@@ -119,219 +113,226 @@ def post_import(self):
             conn.execute(text(cmd))
         self.index_attributes()
 
-    def process_line(self, line):
-        line_json = json.loads(line)
-        if "metadata" in line_json:
-            extra_root_properties = find_extra_properties(line_json)
-            self.current.source_srid = get_srid(
-                line_json["metadata"].get("referenceSystem")
-            )
-            if not self.current.source_srid:
-                print(
-                    """Warning: No Coordinate Reference System
-                       specified for the dataset."""
-                )
+    def extract_import_metadata(self, line_json):
+        if "metadata" not in line_json:
+            raise InvalidMetadataException("The file should contain a member"
+                                           "'metadata', in the first object")
 
-            # use specified target SRID for all the geometries
-            # If not specified use same as source.
-            if self.args.target_srid and self.current.source_srid:
-                self.current.target_srid = self.args.target_srid
-                check_reprojection(self.current.source_srid, self.current.target_srid) # noqa
-            elif self.args.target_srid:
-                self.current.target_srid = self.args.target_srid
-            else:
-                self.current.target_srid = self.current.source_srid
-
-            # calculate dataset bbox based on geographicalExtent
-            bbox = None
-            if "geographicalExtent" in line_json["metadata"]:
-                bbox_coords = line_json["metadata"]["geographicalExtent"]
-                bbox_vertices = [bbox_coords[:3], bbox_coords[3:]]
-
-                if (
-                    self.current.source_srid
-                    and self.current.target_srid != self.current.source_srid
-                ):
-                    bbox_vertices = reproject_vertex_list(
-                        bbox_vertices,
-                        self.current.source_srid,
-                        self.current.target_srid,
-                    )
+        extra_root_properties = find_extra_properties(line_json)
+        self.current.source_srid = get_srid(
+            line_json["metadata"].get("referenceSystem")
+        )
+        if not self.current.source_srid:
+            logger.warning("No Coordinate Reference System"
+                           " specified for the dataset.")
+
+        # use specified target SRID for all the geometries
+        # If not specified use same as source.
+        if self.args.target_srid and self.current.source_srid:
+            self.current.target_srid = self.args.target_srid
+            check_reprojection(self.current.source_srid,
+                               self.current.target_srid)
+        elif self.args.target_srid:
+            self.current.target_srid = self.args.target_srid
+        else:
+            self.current.target_srid = self.current.source_srid
 
-                bbox = func.st_makeenvelope(
-                    bbox_vertices[0][0],
-                    bbox_vertices[0][1],
-                    bbox_vertices[1][0],
-                    bbox_vertices[1][1],
+        # calculate dataset bbox based on geographicalExtent
+        bbox = None
+        if "geographicalExtent" in line_json["metadata"]:
+            bbox_coords = line_json["metadata"]["geographicalExtent"]
+            bbox_vertices = [bbox_coords[:3], bbox_coords[3:]]
+
+            if (
+                self.current.source_srid
+                and self.current.target_srid != self.current.source_srid
+            ):
+                bbox_vertices = reproject_vertex_list(
+                    bbox_vertices,
+                    self.current.source_srid,
                     self.current.target_srid,
                 )
 
-            # store extensions data - extra root properties, extra city objects
-            self.current.extension_handler = ExtensionHandler(
-                line_json.get("extensions")
+            bbox = func.st_makeenvelope(
+                bbox_vertices[0][0],
+                bbox_vertices[0][1],
+                bbox_vertices[1][0],
+                bbox_vertices[1][1],
+                self.current.target_srid,
             )
 
-            # prepare extra properties coming from extensions
-            # they will be placed in the extra_properties jsonb column
-            extra_properties_obj = {}
-            for prop_name in extra_root_properties:
-                extra_properties_obj[prop_name] = line_json[prop_name]
-
-            # check the occurring properties against
-            # the extension defined extra properties
-            check_root_properties(
-                extra_root_properties,
-                self.current.extension_handler.extra_root_properties,
-            )
+        # store extensions data - extra root properties, extra city objects
+        self.current.extension_handler = ExtensionHandler(
+            line_json.get("extensions")
+        )
 
-            # "or None" is added to change empty json "{}" to database null
-            import_meta = ImportMetaModel(
-                source_file=os.path.basename(self.current.file),
-                version=line_json["version"],
-                meta=line_json.get("metadata") or None,
-                transform=line_json.get("transform") or None,
-                geometry_templates=line_json.get("geometry-templates") or None,
-                srid=self.current.target_srid,
-                extensions=line_json.get("extensions") or None,
-                extra_properties=extra_properties_obj or None,
-                bbox=bbox,
-            )
+        # prepare extra properties coming from extensions
+        # they will be placed in the extra_properties jsonb column
+        extra_properties_obj = {}
+        for prop_name in extra_root_properties:
+            extra_properties_obj[prop_name] = line_json[prop_name]
+
+        # check the occurring properties against
+        # the extension defined extra properties
+        check_root_properties(
+            extra_root_properties,
+            self.current.extension_handler.extra_root_properties,
+        )
 
-            # compare to existing import metas
-            # for example to detect inconsistent CRS from different files
-            result_ok = import_meta.compare_existing(
-                self.session, self.args.ignore_repeated_file
-            )
-            if not result_ok:
-                print("Cancelling import")
-                sys.exit(1)
+        # "or None" is added to change empty json "{}" to database null
+        import_meta = ImportMetaModel(
+            source_file=os.path.basename(self.current.file),
+            version=line_json["version"],
+            meta=line_json.get("metadata") or None,
+            transform=line_json.get("transform") or None,
+            geometry_templates=line_json.get("geometry-templates") or None,
+            srid=self.current.target_srid,
+            extensions=line_json.get("extensions") or None,
+            extra_properties=extra_properties_obj or None,
+            bbox=bbox,
+        )
 
-            # add metadata to the database
-            import_meta.__table__.schema = self.args.db_schema
-            self.current.import_meta = import_meta
-            self.session.add(import_meta)
-            self.session.commit()
+        # compare to existing import metas
+        # for example to detect inconsistent CRS from different files
+        result_ok = import_meta.compare_existing(
+            self.session,
+            self.args.ignore_repeated_file,
+            self.args.update_existing
+        )
+        if not result_ok:
+            raise InvalidMetadataException()
 
-        else:
-            # unpack vertices for the cityobjects based on
-            # the CityJSON transform
-            # this is done once for the CityJSONFeature
-            vertices = [
-                transform_vertex(v, self.current.import_meta.transform)
-                for v in line_json["vertices"]
-            ]
-
-            # reproject if needed
-            source_target_srid = None
-            if (
-                self.current.source_srid
-                and self.current.target_srid != self.current.source_srid
-            ):
-                source_target_srid = (
-                    self.current.source_srid,
-                    self.current.target_srid,
-                )
-                vertices = reproject_vertex_list(vertices, *source_target_srid)
-
-            # list of relationships for the CityJSONFeature
-            family_ties = []
-            # objects for the CityJSONFeature
-            cj_feature_objects = {}
-
-            # create CityJSONObjects
-            for obj_id, cityobj in line_json["CityObjects"].items():
-                obj_to_update = None
-
-                # optionally check if the object exists - 
-                # to skip it or update it
-                if self.args.update_existing:
-                    existing = (
-                        self.session.query(CjObjectModel)
-                        .filter_by(object_id=obj_id)
-                        .first()
-                    )
+        # add metadata to the database
+        import_meta.__table__.schema = self.args.db_schema
+        self.current.import_meta = import_meta
+        self.session.add(import_meta)
+        self.session.commit()
 
-                    if existing:
-                        # TODO: proper logging
-                        print(f"CityObject (id:{obj_id}) already exists. Updating.") # noqa
-                        obj_to_update = existing
+    def process_line(self, line_json) -> None:
+        # unpack vertices for the cityobjects based on
+        # the CityJSON transform
+        # this is done once for the CityJSONFeature
+        vertices = [
+            transform_vertex(v, self.current.import_meta.transform)
+            for v in line_json["vertices"]
+        ]
 
-                # get 3D geom, ground geom and bbox
-                geometry, ground_geometry = self.get_geometries(
-                    obj_id, cityobj, vertices, source_target_srid
+        # reproject if needed
+        source_target_srid = None
+        if (
+            self.current.source_srid
+            and self.current.target_srid != self.current.source_srid
+        ):
+            source_target_srid = (
+                self.current.source_srid,
+                self.current.target_srid,
+            )
+            vertices = reproject_vertex_list(vertices, *source_target_srid)
+
+        # list of relationships for the CityJSONFeature
+        family_ties = []
+        # objects for the CityJSONFeature
+        cj_feature_objects = {}
+
+        # create CityJSONObjects
+        for obj_id, cityobj in line_json["CityObjects"].items():
+            obj_to_update = None
+
+            # optionally check if the object exists -
+            # to skip it or update it
+            if self.args.update_existing:
+                existing = (
+                    self.session.query(CjObjectModel)
+                    .filter_by(object_id=obj_id)
+                    .first()
                 )
 
-                # check if the object type is allowed by the official
-                # spec or extension
-                check_result, message = check_object_type(
-                    cityobj.get("type"),
-                    self.cj_object_types,
-                    self.current.extension_handler.extra_city_objects,
+                if existing:
+                    logger.warning(f"CityObject (id:{obj_id}) already exists. Updating.")  # noqa
+                    obj_to_update = existing
+
+            # get 3D geom, ground geom and bbox
+            geometry, ground_geometry = self.get_geometries(
+                obj_id, cityobj, vertices, source_target_srid
+            )
+
+            # check if the object type is allowed by the official
+            # spec or extension
+            check_result, message = check_object_type(
+                cityobj.get("type"),
+                self.cj_object_types,
+                self.current.extension_handler.extra_city_objects,
+            )
+            if not check_result:
+                logger.info(message)
+
+            # update or insert the object
+            # 'or None' is added to change empty json "{}" to database null
+            if obj_to_update:
+                cj_object = obj_to_update
+                cj_object.type = cityobj.get("type")
+                cj_object.attributes = cityobj.get("attributes") or None
+                cj_object.geometry = geometry
+                cj_object.ground_geometry = ground_geometry
+                cj_object.import_meta = self.current.import_meta
+            else:
+                cj_object = CjObjectModel(
+                    object_id=obj_id,
+                    type=cityobj.get("type"),
+                    attributes=cityobj.get("attributes") or None,
+                    geometry=geometry,
+                    ground_geometry=ground_geometry,
                 )
-                if not check_result:
-                    print(message)
 
-                # update or insert the object
-                # 'or None' is added to change empty json "{}" to database null
+                # add CityJson object to the database
+                cj_object.__table__.schema = self.args.db_schema
+                cj_object.import_meta_id = self.current.import_meta.id
+                self.current.cj_objects.append(to_dict(cj_object))
+
+            cj_feature_objects[obj_id] = cj_object
+
+            # save children-parent links
+            for child_id in cityobj.get("children", []):
+                family_ties.append((obj_id, child_id))
+
+                # delete previous ties if updating object
                 if obj_to_update:
-                    cj_object = obj_to_update
-                    cj_object.type = cityobj.get("type")
-                    cj_object.attributes = cityobj.get("attributes") or None
-                    cj_object.geometry = geometry
-                    cj_object.ground_geometry = ground_geometry
-                    cj_object.import_meta = self.current.import_meta
-                else:
-                    cj_object = CjObjectModel(
-                        object_id=obj_id,
-                        type=cityobj.get("type"),
-                        attributes=cityobj.get("attributes") or None,
-                        geometry=geometry,
-                        ground_geometry=ground_geometry,
+                    children = self.session.query(FamilyModel).filter_by(
+                        child_id=child_id
                     )
+                    children.delete()
 
-                    # add CityJson object to the database
-                    cj_object.__table__.schema = self.args.db_schema
-                    cj_object.import_meta_id = self.current.import_meta.id
-                    self.current.cj_objects.append(to_dict(cj_object))
-
-                cj_feature_objects[obj_id] = cj_object
-
-                # save children-parent links
-                for child_id in cityobj.get("children", []):
-                    family_ties.append((obj_id, child_id))
-
-                    # delete previous ties if updating object
-                    if obj_to_update:
-                        children = self.session.query(FamilyModel).filter_by(
-                            child_id=child_id
-                        )
-                        children.delete()
-
-            # create children-parent links after all objects
-            # from the CityJSONFeature already exist
-            for parent_id, child_id in family_ties:
-                self.current.families.append(
-                    {"parent_id": parent_id, "child_id": child_id}
-                )
+        # create children-parent links after all objects
+        # from the CityJSONFeature already exist
+        for parent_id, child_id in family_ties:
+            self.current.families.append({"parent_id": parent_id,
+                                          "child_id": child_id})
 
-    def process_file(self, filepath):
+    def process_file(self, filepath) -> None:
+        """Process a single cityJSON file"""
         self.current = SingleFileImport(filepath)
-        print("Running import for file: ", filepath)
+        logger.info("Running import for file: %s", filepath)
 
         if filepath.lower() == "stdin":
-            for line in sys.stdin:
-                self.process_line(line.rstrip("\n"))
+            f = sys.stdin
         else:
-            with open(filepath) as f:
-                for line in f.readlines():
-                    self.process_line(line)
+            f = open(filepath, "rt")
+
+        first_line = f.readline()
+        first_line_json = json.loads(first_line.rstrip("\n"))
+        if not is_cityjson_object(first_line_json):
+            raise InvalidCityJSONObjectException()
+        self.extract_import_metadata(first_line_json)
+        for line in f.readlines():
+            line_json = json.loads(line.rstrip("\n"))
+            self.process_line(line_json)
 
         if self.current.cj_objects:
             obj_insert = (
                 insert(CjObjectModel)
                 .values(self.current.cj_objects)
                 .on_conflict_do_nothing()
-            )  # noqa
+            )
             self.session.execute(obj_insert)
 
         if self.current.families:
@@ -344,8 +345,9 @@ def process_file(self, filepath):
         self.current.import_meta.finished_at = func.now()
         self.session.commit()
 
-    def process_directory(self, dir_path):
-        print("Running import for directory: ", dir_path)
+    def process_directory(self, dir_path) -> None:
+        """Process all files in a directory."""
+        logger.info("Running import for directory: %s", dir_path)
         ext = ".jsonl"
         for f in os.scandir(dir_path):
             if f.path.endswith(ext):
@@ -371,7 +373,9 @@ def index_attributes(self):
         )
 
         # prepare partial and non partial indexes in one list
-        attributes = [(a, True) for a in self.args.partial_indexed_attributes] + [ #noqa
+        attributes = [
+            (a, True) for a in self.args.partial_indexed_attributes
+        ] + [  # noqa
             (a, False) for a in self.args.indexed_attributes
         ]
 
@@ -380,7 +384,7 @@ def index_attributes(self):
             msg = f"Indexing CityObject attribute: '{attr_name}'"
             if is_partial:
                 msg += " with partial index"
-            print(msg)
+            logger.info(msg)
 
             # get proper postgres type
             if attr_name in type_mapping:
@@ -389,7 +393,7 @@ def index_attributes(self):
                 # prepare and run sql command
                 if is_partial:
                     cmd = cmd_base
-                    + " WHERE attributes->>'{attr_name}' IS NOT NULL"
+                    +" WHERE attributes->>'{attr_name}' IS NOT NULL"
                 else:
                     cmd = cmd_base
 
@@ -403,11 +407,13 @@ def index_attributes(self):
                     conn.execute(text(cmd))
 
             else:
-                print(
-                    f"Specified attribute to be indexed: '{attr_name}' does not exist" # noqa
+                logger.warning(
+                    f"Specified attribute to be indexed: '{attr_name}' does not exist"  # noqa
                 )
 
-    def get_geometries(self, obj_id, cityobj, vertices, source_target_srid):
+    def get_geometries(
+        self, obj_id, cityobj, vertices, source_target_srid
+    ) -> Tuple[Optional[BaseGeometry], Optional[BaseGeometry]]:
         if "geometry" not in cityobj:
             return None, None
 
diff --git a/cjdb/modules/utils.py b/cjdb/modules/utils.py
index 14f2ed3..bcfe3f0 100644
--- a/cjdb/modules/utils.py
+++ b/cjdb/modules/utils.py
@@ -1,5 +1,4 @@
-import os
-from pathlib import Path
+from typing import Any, Dict
 
 import psycopg2
 from psycopg2.extras import RealDictCursor
@@ -9,8 +8,10 @@
 
 
 def get_db_engine(args, echo=False):
-    conn_string = f"postgresql://{args.db_user}:{args.db_password}"\
+    conn_string = (
+        f"postgresql://{args.db_user}:{args.db_password}"
         f"@{args.db_host}:{args.db_port}/{args.db_name}"
+    )
 
     engine = create_engine(conn_string, echo=echo)
 
@@ -18,12 +19,14 @@ def get_db_engine(args, echo=False):
 
 
 def open_connection(args):
-    conn = psycopg2.connect(database=args.db_name,
-                            host=args.db_host,
-                            user=args.db_user,
-                            port=args.db_port,
-                            cursor_factory=RealDictCursor)
-    
+    conn = psycopg2.connect(
+        database=args.db_name,
+        host=args.db_host,
+        user=args.db_user,
+        port=args.db_port,
+        cursor_factory=RealDictCursor,
+    )
+
     return conn
 
 
@@ -41,6 +44,18 @@ def get_cj_object_types():
     return sorted(type_list)
 
 
+def is_cityjson_object(json: Dict[str, Any]) -> bool:
+    """Check if the json is a cityjson object"""
+    if (
+        "version" in json
+        and "transform" in json
+        and "type" in json
+        and json["type"] == "CityJSON"
+    ):
+        return True
+    return False
+
+
 # find extended properties
 def find_extra_properties(json_obj):
     property_names = []
@@ -50,8 +65,9 @@ def find_extra_properties(json_obj):
 
     return property_names
 
+
 # Sqlalchemy model as dict
 def to_dict(model):
     d = dict(model.__dict__)
-    d.pop('_sa_instance_state', None)
-    return d
\ No newline at end of file
+    d.pop("_sa_instance_state", None)
+    return d
diff --git a/cjdb/resources/object_types.py b/cjdb/resources/object_types.py
index 4dde7f1..14f8503 100644
--- a/cjdb/resources/object_types.py
+++ b/cjdb/resources/object_types.py
@@ -1,23 +1,37 @@
 types = {
-    'Bridge':
-        ['BridgePart', 'BridgeInstallation', 
-        'BridgeConstructiveElement', 'BridgeRoom', 'BridgeFurniture'], 
-    'Building': 
-        ['BuildingPart', 'BuildingInstallation', 'BuildingConstructiveElement', 
-        'BuildingFurniture', 'BuildingStorey', 'BuildingRoom', 'BuildingUnit'], 
-    'CityFurniture': None, 
-    'CityObjectGroup': None, 
-    'LandUse': None, 
-    'OtherConstruction': None, 
-    'PlantCover': None, 
-    'SolitaryVegetationObject': None, 
-    'TINRelief': None, 
-    'TransportationSquare': None, 
-    'Railway': None, 
-    'Road': None, 
-    'Tunnel': 
-        ['TunnelPart', 'TunnelInstallation', 'TunnelConstructiveElement', 
-        'TunnelHollowSpace', 'TunnelFurniture'], 
-    'WaterBody': None, 
-    'Waterway': None
-}
\ No newline at end of file
+    "Bridge": [
+        "BridgePart",
+        "BridgeInstallation",
+        "BridgeConstructiveElement",
+        "BridgeRoom",
+        "BridgeFurniture",
+    ],
+    "Building": [
+        "BuildingPart",
+        "BuildingInstallation",
+        "BuildingConstructiveElement",
+        "BuildingFurniture",
+        "BuildingStorey",
+        "BuildingRoom",
+        "BuildingUnit",
+    ],
+    "CityFurniture": None,
+    "CityObjectGroup": None,
+    "LandUse": None,
+    "OtherConstruction": None,
+    "PlantCover": None,
+    "SolitaryVegetationObject": None,
+    "TINRelief": None,
+    "TransportationSquare": None,
+    "Railway": None,
+    "Road": None,
+    "Tunnel": [
+        "TunnelPart",
+        "TunnelInstallation",
+        "TunnelConstructiveElement",
+        "TunnelHollowSpace",
+        "TunnelFurniture",
+    ],
+    "WaterBody": None,
+    "Waterway": None,
+}
diff --git a/cjdb/resources/post_import.sql b/cjdb/resources/post_import.sql
index 79bf593..2f0ef14 100644
--- a/cjdb/resources/post_import.sql
+++ b/cjdb/resources/post_import.sql
@@ -1,16 +1,21 @@
 -- import_meta indexes
-create index if not exists import_meta_gix on {schema}.import_meta using gist(bbox);
-create index if not exists import_meta_source_file_idx on {schema}.import_meta using hash(source_file);
+CREATE INDEX IF NOT EXISTS import_meta_gix ON {schema}.import_meta USING gist(bbox);
+
+CREATE INDEX IF NOT EXISTS import_meta_source_file_idx ON {schema}.import_meta USING hash(source_file);
 
 -- cj_object indexes
-create index if not exists cj_object_type_idx on {schema}.cj_object using btree("type");
-create index if not exists cj_object_ground_gix on {schema}.cj_object using gist(ground_geometry);
-create index lod on {schema}.cj_object using gin (geometry);
+CREATE INDEX IF NOT EXISTS cj_object_type_idx ON {schema}.cj_object USING btree("type");
+
+CREATE INDEX IF NOT EXISTS cj_object_ground_gix ON {schema}.cj_object USING gist(ground_geometry);
+
+CREATE INDEX lod ON {schema}.cj_object USING gin (geometry);
 
 -- family indexes
-create index if not exists family_parent_idx on {schema}.family using btree(parent_id);
-create index if not exists family_child_idx on {schema}.family using btree(child_id);
+CREATE INDEX IF NOT EXISTS family_parent_idx ON {schema}.family USING btree(parent_id);
+
+CREATE INDEX IF NOT EXISTS family_child_idx ON {schema}.family USING btree(child_id);
 
 -- clustering
-cluster {schema}.import_meta using import_meta_gix;
-cluster {schema}.cj_object using cj_object_ground_gix;
+cluster {schema}.import_meta USING import_meta_gix;
+
+cluster {schema}.cj_object USING cj_object_ground_gix;
\ No newline at end of file
diff --git a/cjdb/resources/strings.py b/cjdb/resources/strings.py
index b0659e8..c40b168 100644
--- a/cjdb/resources/strings.py
+++ b/cjdb/resources/strings.py
@@ -1,6 +1,9 @@
-filepath_help = "Source CityJSONL file or a directory with CityJSONL files. " \
-    + "STDIN if not specified. " \
-    + "If specifying a directory, all the *.jsonl files inside of it will be imported."
+filepath_help = (
+    "Source a CityJSONL file or a directory with CityJSONL files. "
+    "If a path is not specified, STDIN will be used."
+)
+
+version_help = "Get the current version"
 
 host_help = "PostgreSQL database host"
 
@@ -14,30 +17,52 @@
 
 schema_help = "Target database schema"
 
-srid_help = "Target coordinate system SRID. " +\
+srid_help = (
+    "Target coordinate system SRID. "
     "All 3D and 2D geometries will be reprojected."
+)
 
-index_help = "CityObject attribute to be indexed using a btree index. " +\
+index_help = (
+    "CityObject attribute to be indexed using a btree index. "
     "Can be specified multiple times, for each attribute once."
+)
 
-partial_index_help = "CityObject attribute to be indexed using a btree partial index. " +\
-    "Can be specified multiple times, for each attribute once. " + \
-    "This index indexes on a condition 'where {{ATTR_NAME}} is not null'. " + \
-    "This means that it saves space and improves query performance when the attribute " +\
-    "is not present for all imported CityObjects."
+partial_index_help = (
+    "CityObject attribute to be indexed using a btree partial index. "
+    "Can be specified multiple times, for each attribute once. "
+    "This index indexes on a condition 'where {{ATTR_NAME}} is not null'. "
+    "This means that it saves space and improves query performance when the "
+    "attribute is not present for all imported CityObjects."
+)
 
-append_help = "Run in append mode (as opposed to default create mode). " +\
-            "This assumes the database structure exists already and new data is to be appended."
+append_help = (
+    "Run in append mode (as opposed to default create mode). "
+    "This assumes the database structure exists already and "
+    "new data is to be appended."
+)
 
-overwrite_help = "Overwrite the data that is currently in the database schema. " + \
-                "Warning: this causes the loss of what was imported before to the database schema."
+overwrite_help = (
+    "Overwrite the data that is currently in the database schema. "
+    "Warning: this causes the loss of what was imported before "
+    "to the database schema."
+)
 
-ignore_file_help = "Ignore repeated file names warning when importing. " +\
-    "By default, the importer will send out warnings if a specific file has already been imported."
+ignore_file_help = (
+    "Ignore repeated file names warning when importing. "
+    "By default, the importer will send out warnings if a specific "
+    "file has already been imported."
+)
 
-skip_existing = "Check if the object with given ID exists before inserting, and skip it if it does. " +\
-    "By default, the importer does not check existence for performance reasons, " +\
-    "which means that importing the same object twice will result in an error."
+skip_existing = (
+    "Check if the object with given ID exists before inserting, and "
+    "skip it if it does. "
+    "By default, the importer does not check existence for "
+    "performance reasons, which means that importing the same object "
+    "twice will result in an error."
+)
 
-update_existing = "Check if the object with given ID exists before inserting, and update it if it does. " +\
+update_existing = (
+    "Check if the object with given ID exists before inserting, and "
+    "update it if it does. "
     "The old object will be updated with the new object's properties."
+)
diff --git a/poetry.lock b/poetry.lock
index 64426ae..068366a 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,24 +1,5 @@
 # This file is automatically @generated by Poetry and should not be changed by hand.
 
-[[package]]
-name = "attrs"
-version = "22.2.0"
-description = "Classes Without Boilerplate"
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"},
-    {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"},
-]
-
-[package.extras]
-cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"]
-dev = ["attrs[docs,tests]"]
-docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"]
-tests = ["attrs[tests-no-zope]", "zope.interface"]
-tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"]
-
 [[package]]
 name = "black"
 version = "23.3.0"
@@ -164,11 +145,33 @@ files = [
     {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"},
 ]
 
+[[package]]
+name = "cjio"
+version = "0.8.1"
+description = "CLI to process and manipulate CityJSON files"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "cjio-0.8.1-py2.py3-none-any.whl", hash = "sha256:49889e1565580764e154558d4eafe46305e7b4a249f7d320d1169638be95be63"},
+    {file = "cjio-0.8.1.tar.gz", hash = "sha256:d07f51b5ccc4cd05f045642a13cb8a79959fc41d3f54ab89cf46c3ac0fc0d4bf"},
+]
+
+[package.dependencies]
+Click = ">=8.1.0"
+numpy = "*"
+
+[package.extras]
+develop = ["bump2version", "pytest"]
+export = ["mapbox-earcut", "pandas", "triangle"]
+reproject = ["pyproj (>=3.0.0)"]
+validate = ["cjvalpy (>=0.3.0)"]
+
 [[package]]
 name = "click"
 version = "8.1.3"
 description = "Composable command line interface toolkit"
-category = "dev"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -183,7 +186,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""}
 name = "colorama"
 version = "0.4.6"
 description = "Cross-platform colored terminal text."
-category = "dev"
+category = "main"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 files = [
@@ -210,14 +213,14 @@ pyflakes = ">=3.0.0,<3.1.0"
 
 [[package]]
 name = "geoalchemy2"
-version = "0.13.1"
+version = "0.13.2"
 description = "Using SQLAlchemy with Spatial Databases"
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "GeoAlchemy2-0.13.1-py3-none-any.whl", hash = "sha256:7ffd577d6b032cce4ea12dbd3e4f85940b958867b166845a9d6b62f18b0ea2ee"},
-    {file = "GeoAlchemy2-0.13.1.tar.gz", hash = "sha256:57246d44aea90b4c52f84c006f6758d0e1951eb9018f10409a87141f02159b13"},
+    {file = "GeoAlchemy2-0.13.2-py3-none-any.whl", hash = "sha256:5723f302c54a22ae9b2fd37eed4d26bb7f0be96b2d4d3dd8ec488ecd5d7dfa6e"},
+    {file = "GeoAlchemy2-0.13.2.tar.gz", hash = "sha256:164c17ec4e566e4751ec1e8906e1c10316203a0535e6c5f80f7d7252791fd1f5"},
 ]
 
 [package.dependencies]
@@ -352,40 +355,55 @@ files = [
     {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
 ]
 
+[[package]]
+name = "mirakuru"
+version = "2.5.1"
+description = "Process executor (not only) for tests."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "mirakuru-2.5.1-py3-none-any.whl", hash = "sha256:0a16f897841741f8cd784f790e54d74e61456ba36be9cb9de731b49e2e7a45dc"},
+    {file = "mirakuru-2.5.1.tar.gz", hash = "sha256:5a60d641fa92c8bfcd383f6e52f7a0bf3f081da0467fc6e3e6a3f6b3e3e47a7b"},
+]
+
+[package.dependencies]
+psutil = {version = ">=4.0.0", markers = "sys_platform != \"cygwin\""}
+
 [[package]]
 name = "mypy"
-version = "1.1.1"
+version = "1.2.0"
 description = "Optional static typing for Python"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "mypy-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39c7119335be05630611ee798cc982623b9e8f0cff04a0b48dfc26100e0b97af"},
-    {file = "mypy-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61bf08362e93b6b12fad3eab68c4ea903a077b87c90ac06c11e3d7a09b56b9c1"},
-    {file = "mypy-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbb19c9f662e41e474e0cff502b7064a7edc6764f5262b6cd91d698163196799"},
-    {file = "mypy-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:315ac73cc1cce4771c27d426b7ea558fb4e2836f89cb0296cbe056894e3a1f78"},
-    {file = "mypy-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5cb14ff9919b7df3538590fc4d4c49a0f84392237cbf5f7a816b4161c061829e"},
-    {file = "mypy-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:26cdd6a22b9b40b2fd71881a8a4f34b4d7914c679f154f43385ca878a8297389"},
-    {file = "mypy-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5b5f81b40d94c785f288948c16e1f2da37203c6006546c5d947aab6f90aefef2"},
-    {file = "mypy-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b437be1c02712a605591e1ed1d858aba681757a1e55fe678a15c2244cd68a5"},
-    {file = "mypy-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d809f88734f44a0d44959d795b1e6f64b2bbe0ea4d9cc4776aa588bb4229fc1c"},
-    {file = "mypy-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:a380c041db500e1410bb5b16b3c1c35e61e773a5c3517926b81dfdab7582be54"},
-    {file = "mypy-1.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b7c7b708fe9a871a96626d61912e3f4ddd365bf7f39128362bc50cbd74a634d5"},
-    {file = "mypy-1.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c10fa12df1232c936830839e2e935d090fc9ee315744ac33b8a32216b93707"},
-    {file = "mypy-1.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0a28a76785bf57655a8ea5eb0540a15b0e781c807b5aa798bd463779988fa1d5"},
-    {file = "mypy-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ef6a01e563ec6a4940784c574d33f6ac1943864634517984471642908b30b6f7"},
-    {file = "mypy-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d64c28e03ce40d5303450f547e07418c64c241669ab20610f273c9e6290b4b0b"},
-    {file = "mypy-1.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:64cc3afb3e9e71a79d06e3ed24bb508a6d66f782aff7e56f628bf35ba2e0ba51"},
-    {file = "mypy-1.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce61663faf7a8e5ec6f456857bfbcec2901fbdb3ad958b778403f63b9e606a1b"},
-    {file = "mypy-1.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2b0c373d071593deefbcdd87ec8db91ea13bd8f1328d44947e88beae21e8d5e9"},
-    {file = "mypy-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:2888ce4fe5aae5a673386fa232473014056967f3904f5abfcf6367b5af1f612a"},
-    {file = "mypy-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:19ba15f9627a5723e522d007fe708007bae52b93faab00f95d72f03e1afa9598"},
-    {file = "mypy-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:59bbd71e5c58eed2e992ce6523180e03c221dcd92b52f0e792f291d67b15a71c"},
-    {file = "mypy-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9401e33814cec6aec8c03a9548e9385e0e228fc1b8b0a37b9ea21038e64cdd8a"},
-    {file = "mypy-1.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b398d8b1f4fba0e3c6463e02f8ad3346f71956b92287af22c9b12c3ec965a9f"},
-    {file = "mypy-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:69b35d1dcb5707382810765ed34da9db47e7f95b3528334a3c999b0c90fe523f"},
-    {file = "mypy-1.1.1-py3-none-any.whl", hash = "sha256:4e4e8b362cdf99ba00c2b218036002bdcdf1e0de085cdb296a49df03fb31dfc4"},
-    {file = "mypy-1.1.1.tar.gz", hash = "sha256:ae9ceae0f5b9059f33dbc62dea087e942c0ccab4b7a003719cb70f9b8abfa32f"},
+    {file = "mypy-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:701189408b460a2ff42b984e6bd45c3f41f0ac9f5f58b8873bbedc511900086d"},
+    {file = "mypy-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fe91be1c51c90e2afe6827601ca14353bbf3953f343c2129fa1e247d55fd95ba"},
+    {file = "mypy-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d26b513225ffd3eacece727f4387bdce6469192ef029ca9dd469940158bc89e"},
+    {file = "mypy-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3a2d219775a120581a0ae8ca392b31f238d452729adbcb6892fa89688cb8306a"},
+    {file = "mypy-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:2e93a8a553e0394b26c4ca683923b85a69f7ccdc0139e6acd1354cc884fe0128"},
+    {file = "mypy-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3efde4af6f2d3ccf58ae825495dbb8d74abd6d176ee686ce2ab19bd025273f41"},
+    {file = "mypy-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:695c45cea7e8abb6f088a34a6034b1d273122e5530aeebb9c09626cea6dca4cb"},
+    {file = "mypy-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0e9464a0af6715852267bf29c9553e4555b61f5904a4fc538547a4d67617937"},
+    {file = "mypy-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8293a216e902ac12779eb7a08f2bc39ec6c878d7c6025aa59464e0c4c16f7eb9"},
+    {file = "mypy-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:f46af8d162f3d470d8ffc997aaf7a269996d205f9d746124a179d3abe05ac602"},
+    {file = "mypy-1.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:031fc69c9a7e12bcc5660b74122ed84b3f1c505e762cc4296884096c6d8ee140"},
+    {file = "mypy-1.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:390bc685ec209ada4e9d35068ac6988c60160b2b703072d2850457b62499e336"},
+    {file = "mypy-1.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4b41412df69ec06ab141808d12e0bf2823717b1c363bd77b4c0820feaa37249e"},
+    {file = "mypy-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4e4a682b3f2489d218751981639cffc4e281d548f9d517addfd5a2917ac78119"},
+    {file = "mypy-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a197ad3a774f8e74f21e428f0de7f60ad26a8d23437b69638aac2764d1e06a6a"},
+    {file = "mypy-1.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c9a084bce1061e55cdc0493a2ad890375af359c766b8ac311ac8120d3a472950"},
+    {file = "mypy-1.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaeaa0888b7f3ccb7bcd40b50497ca30923dba14f385bde4af78fac713d6d6f6"},
+    {file = "mypy-1.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bea55fc25b96c53affab852ad94bf111a3083bc1d8b0c76a61dd101d8a388cf5"},
+    {file = "mypy-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:4c8d8c6b80aa4a1689f2a179d31d86ae1367ea4a12855cc13aa3ba24bb36b2d8"},
+    {file = "mypy-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:70894c5345bea98321a2fe84df35f43ee7bb0feec117a71420c60459fc3e1eed"},
+    {file = "mypy-1.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4a99fe1768925e4a139aace8f3fb66db3576ee1c30b9c0f70f744ead7e329c9f"},
+    {file = "mypy-1.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023fe9e618182ca6317ae89833ba422c411469156b690fde6a315ad10695a521"},
+    {file = "mypy-1.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4d19f1a239d59f10fdc31263d48b7937c585810288376671eaf75380b074f238"},
+    {file = "mypy-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:2de7babe398cb7a85ac7f1fd5c42f396c215ab3eff731b4d761d68d0f6a80f48"},
+    {file = "mypy-1.2.0-py3-none-any.whl", hash = "sha256:d8e9187bfcd5ffedbe87403195e1fc340189a68463903c39e2b63307c9fa0394"},
+    {file = "mypy-1.2.0.tar.gz", hash = "sha256:f70a40410d774ae23fcb4afbbeca652905a04de7948eaf0b1789c8d1426b72d1"},
 ]
 
 [package.dependencies]
@@ -450,14 +468,14 @@ files = [
 
 [[package]]
 name = "packaging"
-version = "23.0"
+version = "23.1"
 description = "Core utilities for Python packages"
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"},
-    {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"},
+    {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"},
+    {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"},
 ]
 
 [[package]]
@@ -504,6 +522,69 @@ files = [
 dev = ["pre-commit", "tox"]
 testing = ["pytest", "pytest-benchmark"]
 
+[[package]]
+name = "port-for"
+version = "0.6.3"
+description = "Utility that helps with local TCP ports management. It can find an unused TCP localhost port and remember the association."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "port-for-0.6.3.tar.gz", hash = "sha256:232bd999015b7fbdf19f90f3a9298cc742252d67650108123940bfc75c6f4d4e"},
+    {file = "port_for-0.6.3-py3-none-any.whl", hash = "sha256:31860afde6cb552e1830c927def3288350c8fbbe9aea8aed8150ed9d1aa0de81"},
+]
+
+[[package]]
+name = "psutil"
+version = "5.9.5"
+description = "Cross-platform lib for process and system monitoring in Python."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+    {file = "psutil-5.9.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:be8929ce4313f9f8146caad4272f6abb8bf99fc6cf59344a3167ecd74f4f203f"},
+    {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ab8ed1a1d77c95453db1ae00a3f9c50227ebd955437bcf2a574ba8adbf6a74d5"},
+    {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:4aef137f3345082a3d3232187aeb4ac4ef959ba3d7c10c33dd73763fbc063da4"},
+    {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ea8518d152174e1249c4f2a1c89e3e6065941df2fa13a1ab45327716a23c2b48"},
+    {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:acf2aef9391710afded549ff602b5887d7a2349831ae4c26be7c807c0a39fac4"},
+    {file = "psutil-5.9.5-cp27-none-win32.whl", hash = "sha256:5b9b8cb93f507e8dbaf22af6a2fd0ccbe8244bf30b1baad6b3954e935157ae3f"},
+    {file = "psutil-5.9.5-cp27-none-win_amd64.whl", hash = "sha256:8c5f7c5a052d1d567db4ddd231a9d27a74e8e4a9c3f44b1032762bd7b9fdcd42"},
+    {file = "psutil-5.9.5-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3c6f686f4225553615612f6d9bc21f1c0e305f75d7d8454f9b46e901778e7217"},
+    {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a7dd9997128a0d928ed4fb2c2d57e5102bb6089027939f3b722f3a210f9a8da"},
+    {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89518112647f1276b03ca97b65cc7f64ca587b1eb0278383017c2a0dcc26cbe4"},
+    {file = "psutil-5.9.5-cp36-abi3-win32.whl", hash = "sha256:104a5cc0e31baa2bcf67900be36acde157756b9c44017b86b2c049f11957887d"},
+    {file = "psutil-5.9.5-cp36-abi3-win_amd64.whl", hash = "sha256:b258c0c1c9d145a1d5ceffab1134441c4c5113b2417fafff7315a917a026c3c9"},
+    {file = "psutil-5.9.5-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:c607bb3b57dc779d55e1554846352b4e358c10fff3abf3514a7a6601beebdb30"},
+    {file = "psutil-5.9.5.tar.gz", hash = "sha256:5410638e4df39c54d957fc51ce03048acd8e6d60abc0f5107af51e5fb566eb3c"},
+]
+
+[package.extras]
+test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"]
+
+[[package]]
+name = "psycopg"
+version = "3.1.8"
+description = "PostgreSQL database adapter for Python"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "psycopg-3.1.8-py3-none-any.whl", hash = "sha256:b1500c42063abaa01d30b056f0b300826b8dd8d586900586029a294ce74af327"},
+    {file = "psycopg-3.1.8.tar.gz", hash = "sha256:59b4a71536b146925513c0234dfd1dc42b81e65d56ce5335dff4813434dbc113"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.1"
+tzdata = {version = "*", markers = "sys_platform == \"win32\""}
+
+[package.extras]
+binary = ["psycopg-binary (>=3.1.6,<=3.1.8)"]
+c = ["psycopg-c (>=3.1.6,<=3.1.8)"]
+dev = ["black (>=22.3.0)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=0.990)", "types-setuptools (>=57.4)", "wheel (>=0.37)"]
+docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"]
+pool = ["psycopg-pool"]
+test = ["mypy (>=0.990)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-asyncio (>=0.17)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"]
+
 [[package]]
 name = "psycopg2-binary"
 version = "2.9.6"
@@ -650,25 +731,45 @@ certifi = "*"
 
 [[package]]
 name = "pytest"
-version = "7.2.2"
+version = "7.3.1"
 description = "pytest: simple powerful testing with Python"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "pytest-7.2.2-py3-none-any.whl", hash = "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e"},
-    {file = "pytest-7.2.2.tar.gz", hash = "sha256:c99ab0c73aceb050f68929bc93af19ab6db0558791c6a0715723abe9d0ade9d4"},
+    {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"},
+    {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"},
 ]
 
 [package.dependencies]
-attrs = ">=19.2.0"
 colorama = {version = "*", markers = "sys_platform == \"win32\""}
 iniconfig = "*"
 packaging = "*"
 pluggy = ">=0.12,<2.0"
 
 [package.extras]
-testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
+testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
+
+[[package]]
+name = "pytest-postgresql"
+version = "4.1.1"
+description = "Postgresql fixtures and fixture factories for Pytest."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pytest-postgresql-4.1.1.tar.gz", hash = "sha256:144d6af4000641decb1f0e8025d9bfdd4a0572f418c5fec7ef409b51b991295d"},
+    {file = "pytest_postgresql-4.1.1-py3-none-any.whl", hash = "sha256:e4fca93189ce7e4f306ed5974cd5fdbb988f6b18ea51d12465dd301fcace933f"},
+]
+
+[package.dependencies]
+mirakuru = ">=2.3.0"
+port-for = "*"
+pytest = ">=6.2.0"
+setuptools = "*"
+
+[package.extras]
+tests = ["pytest-cov", "pytest-xdist"]
 
 [[package]]
 name = "requests"
@@ -692,6 +793,23 @@ urllib3 = ">=1.21.1,<1.27"
 socks = ["PySocks (>=1.5.6,!=1.5.7)"]
 use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 
+[[package]]
+name = "setuptools"
+version = "67.7.1"
+description = "Easily download, build, install, upgrade, and uninstall Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "setuptools-67.7.1-py3-none-any.whl", hash = "sha256:6f0839fbdb7e3cfef1fc38d7954f5c1c26bf4eebb155a55c9bf8faf997b9fb67"},
+    {file = "setuptools-67.7.1.tar.gz", hash = "sha256:bb16732e8eb928922eabaa022f881ae2b7cdcfaf9993ef1f5e841a96d32b8e0c"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
+testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
+
 [[package]]
 name = "shapely"
 version = "2.0.1"
@@ -837,6 +955,18 @@ files = [
     {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"},
 ]
 
+[[package]]
+name = "tzdata"
+version = "2023.3"
+description = "Provider of IANA time zone data"
+category = "dev"
+optional = false
+python-versions = ">=2"
+files = [
+    {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"},
+    {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"},
+]
+
 [[package]]
 name = "urllib3"
 version = "1.26.15"
@@ -857,4 +987,4 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.11"
-content-hash = "215acc83ec0a9b2700a980ffcd97f63050e4c60c16efe22c41d6b347d1b96b3b"
+content-hash = "021091dac083a9a2e7eb3db0f406aa63d2eaa607b69c5cbe5381ef583864b52a"
diff --git a/pyproject.toml b/pyproject.toml
index 142f3a0..4963a22 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "cjdb"
-version = "1.1.0"
+version = "1.2.0"
 description = "CJDB is a tool that enables CityJSON integration with a PostgreSQL database"
 authors = ["Cynthia Cai", "Lan Yan", "Yitong Xia", "Chris Poon", "Siebren Meines", "Leon Powalka"]
 maintainers = ["Gina Stavropoulou <g.stavropoulou@tudelft.nl>"]
@@ -20,6 +20,7 @@ pyproj = "^3.5.0"
 requests = "^2.28.2"
 psycopg2-binary = "^2.9.6"
 geoalchemy2 = "^0.13.1"
+cjio = "^0.8.1"
 
 [tool.poetry.group.dev.dependencies]
 mypy = "^1.1.1"
@@ -27,6 +28,8 @@ isort = "^5.12.0"
 black = "^23.3.0"
 flake8 = "^6.0.0"
 pytest = "^7.2.2"
+pytest-postgresql = "^4.1.1"
+psycopg = "^3.1.8"
 
 [build-system]
 requires = ["poetry-core>=1.3.2"]


Test patch:
diff --git a/tests/cjdb_test.py b/tests/cjdb_test.py
new file mode 100644
index 0000000..b4e4aab
--- /dev/null
+++ b/tests/cjdb_test.py
@@ -0,0 +1,348 @@
+import io
+from argparse import Namespace
+
+import pytest
+from pytest_postgresql.janitor import DatabaseJanitor
+from sqlalchemy import MetaData, Table, create_engine, inspect, select
+from sqlalchemy.orm import Session
+
+from cjdb.modules.exceptions import (InvalidCityJSONObjectException,
+                                     InvalidMetadataException)
+from cjdb.modules.importer import Importer
+
+
+@pytest.fixture(scope="session")
+def engine_postgresql(postgresql_proc):
+    with DatabaseJanitor(
+        postgresql_proc.user,
+        postgresql_proc.host,
+        postgresql_proc.port,
+        postgresql_proc.dbname,
+        postgresql_proc.version,
+        password=postgresql_proc.password,
+    ):
+        yield create_engine(
+            f"postgresql+psycopg2://{postgresql_proc.user}:"
+            f"{postgresql_proc.password}@{postgresql_proc.host}:"
+            f"{postgresql_proc.port}/{postgresql_proc.dbname}"
+        )
+
+
+def test_single_import(engine_postgresql):
+    args = Namespace(
+        filepath="./tests/files/vienna.jsonl",
+        db_schema="cjdb",
+        target_srid=None,
+        indexed_attributes=[],
+        partial_indexed_attributes=[],
+        ignore_repeated_file=False,
+        append_mode=False,
+        overwrite=False,
+        update_existing=False,
+    )
+    with Importer(engine=engine_postgresql, args=args) as imp:
+        imp.run_import()
+
+
+def test_repeated_file_with_overwrite(engine_postgresql):
+    args = Namespace(
+        filepath="./tests/files/vienna.jsonl",
+        db_schema="cjdb",
+        target_srid=None,
+        indexed_attributes=[],
+        partial_indexed_attributes=[],
+        ignore_repeated_file=False,
+        append_mode=False,
+        overwrite=True,
+        update_existing=False,
+    )
+    with Importer(engine=engine_postgresql, args=args) as imp:
+        imp.run_import()
+
+
+def test_repeated_file_with_ignore_repeated_file(engine_postgresql):
+    args = Namespace(
+        filepath="./tests/files/vienna.jsonl",
+        db_schema="cjdb",
+        target_srid=None,
+        indexed_attributes=[],
+        partial_indexed_attributes=[],
+        ignore_repeated_file=True,
+        append_mode=False,
+        overwrite=False,
+        update_existing=False,
+    )
+    with Importer(engine=engine_postgresql, args=args) as imp:
+        imp.run_import()
+
+
+def test_repeated_file_with_update_existing(engine_postgresql):
+    args = Namespace(
+        filepath="./tests/files/vienna.jsonl",
+        db_schema="cjdb",
+        target_srid=None,
+        indexed_attributes=[],
+        partial_indexed_attributes=[],
+        ignore_repeated_file=False,
+        append_mode=False,
+        overwrite=False,
+        update_existing=True,
+    )
+    with Importer(engine=engine_postgresql, args=args) as imp:
+        imp.run_import()
+
+
+def test_repeated_file_with_prompt_to_skip(engine_postgresql, monkeypatch):
+    monkeypatch.setattr("sys.stdin", io.StringIO("y"))
+    args = Namespace(
+        filepath="./tests/files/vienna.jsonl",
+        db_schema="cjdb",
+        target_srid=None,
+        indexed_attributes=[],
+        partial_indexed_attributes=[],
+        ignore_repeated_file=False,
+        append_mode=False,
+        overwrite=False,
+        update_existing=False,
+    )
+    with Importer(engine=engine_postgresql, args=args) as imp:
+        imp.run_import()
+
+
+def test_repeated_file_with_prompt_to_exit(engine_postgresql, monkeypatch):
+    monkeypatch.setattr("sys.stdin", io.StringIO("n"))
+    args = Namespace(
+        filepath="./tests/files/vienna.jsonl",
+        db_schema="cjdb",
+        target_srid=None,
+        indexed_attributes=[],
+        partial_indexed_attributes=[],
+        ignore_repeated_file=False,
+        append_mode=False,
+        overwrite=False,
+        update_existing=False,
+    )
+    with pytest.raises(SystemExit):
+        with Importer(engine=engine_postgresql, args=args) as imp:
+            imp.run_import()
+
+
+def test_directory_import(engine_postgresql, monkeypatch):
+    monkeypatch.setattr("sys.stdin", io.StringIO("y"))
+    args = Namespace(
+        filepath="./tests/files/cjfiles",
+        db_schema="cjdb",
+        target_srid=None,
+        indexed_attributes=[],
+        partial_indexed_attributes=[],
+        ignore_repeated_file=False,
+        append_mode=False,
+        overwrite=False,
+        update_existing=False,
+    )
+    with Importer(engine=engine_postgresql, args=args) as imp:
+        imp.run_import()
+
+
+def test_db_model(engine_postgresql):
+    insp = inspect(engine_postgresql)
+    assert insp.has_schema("cjdb")
+    assert insp.has_table("cj_object", schema="cjdb")
+    assert insp.has_table("import_meta", schema="cjdb")
+    assert insp.has_table("family", schema="cjdb")
+
+    cj_object = Table(
+        "cj_object",
+        MetaData(),
+        schema="cjdb",
+        autoload_with=engine_postgresql
+    )
+
+    import_meta = Table(
+        "import_meta",
+        MetaData(),
+        schema="cjdb",
+        autoload_with=engine_postgresql
+    )
+
+    query_cj_object = select(cj_object).where(
+        cj_object.c.object_id == "UUID_LOD2_011491-3cd51f89-4727-44e6-b12e_6"
+    )
+    query_import_meta = select(import_meta).where(
+        import_meta.c.source_file == "vienna.jsonl")
+
+    with Session(engine_postgresql) as session:
+        row = session.execute(query_cj_object).first()
+        assert row.object_id == "UUID_LOD2_011491-3cd51f89-4727-44e6-b12e_6"
+        assert row.attributes["roofType"] == "FLACHDACH"
+        assert row.type == "BuildingPart"
+
+        row = session.execute(query_import_meta).first()
+        assert row.source_file == "vienna.jsonl"
+        assert row.version == "1.1"
+        assert row.transform["scale"] == [0.001, 0.001, 0.001]
+        assert row.transform["translate"] == [983.16, 340433.878, 27.861]
+        assert row.metadata["geographicalExtent"] == [
+            983.16,
+            340433.878,
+            27.861,
+            1510.432,
+            341048.5,
+            84.987,
+        ]
+
+
+def test_single_import_with_extensions(engine_postgresql, monkeypatch):
+    monkeypatch.setattr("sys.stdin", io.StringIO("y"))
+    args = Namespace(
+        filepath="./tests/files/extension.city.jsonl",
+        db_schema="cjdb",
+        target_srid=None,
+        indexed_attributes=[],
+        partial_indexed_attributes=[],
+        ignore_repeated_file=False,
+        append_mode=False,
+        overwrite=False,
+        update_existing=False,
+    )
+    with Importer(engine=engine_postgresql, args=args) as imp:
+        imp.run_import()
+
+    import_meta = Table(
+        "import_meta", MetaData(),
+        schema="cjdb",
+        autoload_with=engine_postgresql
+    )
+
+    query_import_meta = (
+        select(import_meta)
+        .where(import_meta.c.extensions.isnot(None))
+        .where(import_meta.c.source_file == "extension.city.jsonl")
+    )
+
+    with Session(engine_postgresql) as session:
+        row = session.execute(query_import_meta).first()
+        assert (
+            row.extensions["Noise"]["url"]
+            == "https://www.cityjson.org/tutorials/files/noise.ext.json"
+        )
+        assert row.extensions["Noise"]["version"] == "1.1.0"
+
+
+def test_single_import_with_geometry_template(engine_postgresql, monkeypatch):
+    monkeypatch.setattr("sys.stdin", io.StringIO("y"))
+    args = Namespace(
+        filepath="./tests/files/geomtemplate.city.jsonl",
+        db_schema="cjdb",
+        target_srid=None,
+        indexed_attributes=[],
+        partial_indexed_attributes=[],
+        ignore_repeated_file=False,
+        append_mode=False,
+        overwrite=False,
+        update_existing=False,
+    )
+    with Importer(engine=engine_postgresql, args=args) as imp:
+        imp.run_import()
+
+    import_meta = Table(
+        "import_meta", MetaData(),
+        schema="cjdb",
+        autoload_with=engine_postgresql
+    )
+
+    query_import_meta = (
+        select(import_meta)
+        .where(import_meta.c.geometry_templates.isnot(None))
+        .where(import_meta.c.source_file == "geomtemplate.city.jsonl")
+    )
+
+    with Session(engine_postgresql) as session:
+        row = session.execute(query_import_meta).first()
+
+        assert row.geometry_templates["templates"][0]["lod"] == "1"
+        assert row.geometry_templates["templates"][0]["type"] == "MultiSurface"
+
+        assert row.geometry_templates["vertices-templates"] == [
+            [0, 0, 5],
+            [10, 0, 5],
+            [10, 10, 5],
+            [0, 10, 5],
+            [0, 0, 15],
+            [10, 0, 15],
+            [10, 10, 15],
+            [0, 10, 15],
+        ]
+
+
+def test_single_import_without_srid(engine_postgresql, monkeypatch):
+    monkeypatch.setattr("sys.stdin", io.StringIO("y"))
+    args = Namespace(
+        filepath="./tests/files/vienna.jsonl",
+        db_schema="cjdb",
+        target_srid=None,
+        indexed_attributes=[],
+        partial_indexed_attributes=[],
+        ignore_repeated_file=False,
+        append_mode=False,
+        overwrite=False,
+        update_existing=False,
+    )
+    with pytest.raises(InvalidMetadataException):
+        with Importer(engine=engine_postgresql, args=args) as imp:
+            imp.run_import()
+
+
+def test_single_import_with_target_srid(engine_postgresql, monkeypatch):
+    monkeypatch.setattr("sys.stdin", io.StringIO("y"))
+    args = Namespace(
+        filepath="./tests/files/vienna.jsonl",
+        db_schema="cjdb",
+        target_srid="7415",
+        indexed_attributes=[],
+        partial_indexed_attributes=[],
+        ignore_repeated_file=False,
+        append_mode=False,
+        overwrite=False,
+        update_existing=False,
+    )
+    with Importer(engine=engine_postgresql, args=args) as imp:
+        imp.run_import()
+
+
+def test_single_import_without_metadata(engine_postgresql, monkeypatch):
+    monkeypatch.setattr("sys.stdin", io.StringIO("y"))
+    args = Namespace(
+        filepath="./tests/files/no_metadata.city.jsonl",
+        db_schema="cjdb",
+        target_srid=None,
+        indexed_attributes=[],
+        partial_indexed_attributes=[],
+        ignore_repeated_file=False,
+        append_mode=False,
+        overwrite=False,
+        update_existing=False,
+    )
+    with pytest.raises(InvalidMetadataException):
+        with Importer(engine=engine_postgresql, args=args) as imp:
+            imp.run_import()
+
+
+def test_single_import_without_cityjson_obj_in_first_line(
+    engine_postgresql, monkeypatch
+):
+    monkeypatch.setattr("sys.stdin", io.StringIO("y"))
+    args = Namespace(
+        filepath="./tests/files/no_cityjson_obj.city.jsonl",
+        db_schema="cjdb",
+        target_srid=None,
+        indexed_attributes=[],
+        partial_indexed_attributes=[],
+        ignore_repeated_file=False,
+        append_mode=False,
+        overwrite=False,
+        update_existing=False,
+    )
+    with pytest.raises(InvalidCityJSONObjectException):
+        with Importer(engine=engine_postgresql, args=args) as imp:
+            imp.run_import()
diff --git a/tests/cli_test.py b/tests/cli_test.py
deleted file mode 100644
index f5eb3dc..0000000
--- a/tests/cli_test.py
+++ /dev/null
@@ -1,45 +0,0 @@
-from cjdb.main import run
-from cjdb.modules.arg_parser import Parser
-import pytest
-import io
-
-arguments = ['-H', 'localhost', '-U', 'postgres', '-d', 'postgres']
-
-
-def test_single_import(monkeypatch):
-    monkeypatch.setattr('sys.stdin', io.StringIO('y'))
-    current_arguments = arguments.copy()
-    current_arguments.append("./tests/files/extension2.jsonl")
-    parser = Parser()
-    args = parser.parse_args(current_arguments)
-    print("Args: ", " ".join(current_arguments))
-    run(args)
-
-
-def test_single_import_withour_srid(monkeypatch):
-    monkeypatch.setattr('sys.stdin', io.StringIO('y'))
-    current_arguments = arguments.copy()
-    current_arguments.append("./tests/files/vienna.jsonl")
-    parser = Parser()
-    args = parser.parse_args(current_arguments)
-    print("Args: ", " ".join(current_arguments))
-    with pytest.raises(SystemExit) as excinfo:
-        run(args)
-
-    assert excinfo.value.code == 1
-
-
-def test_single_import_with_target_srid(monkeypatch):
-    monkeypatch.setattr('sys.stdin', io.StringIO('y'))
-    current_arguments = arguments.copy()
-    current_arguments.extend(["./tests/files/vienna.jsonl", "-I", "7415"])
-    parser = Parser()
-    args = parser.parse_args(current_arguments)
-    print("Args: ", " ".join(current_arguments))
-    run(args)
-    
-    # TODO: Add tests for other flags.
-    # TODO: Add tests for directory import.
-    # TODO: Add data tests post import
-    # TODO: Add test to postgres with testing.postgres
-
diff --git a/tests/files/extension2.jsonl b/tests/files/extension.city.jsonl
similarity index 100%
rename from tests/files/extension2.jsonl
rename to tests/files/extension.city.jsonl
diff --git a/tests/files/no_cityjson_obj.city.jsonl b/tests/files/no_cityjson_obj.city.jsonl
new file mode 100644
index 0000000..c603b1e
--- /dev/null
+++ b/tests/files/no_cityjson_obj.city.jsonl
@@ -0,0 +1,1 @@
+{"type":"CityJSONFeature","CityObjects":{"id-1":{"geometry":[{"boundaries":[[[[[0,1,2,3]],[[4,5,6,7]],[[0,3,5,4]],[[3,2,6,5]],[[2,1,7,6]],[[1,0,4,7]]]],[[[[8,9,10,11]],[[12,13,14,15]],[[8,11,13,12]],[[11,10,14,13]],[[10,9,15,14]],[[9,8,12,15]]]]],"type":"MultiSolid","lod":"1"}],"type":"+GenericCityObject"}},"vertices":[[0,0,0],[0,1000,0],[1000,1000,0],[1000,0,0],[0,0,1000],[1000,0,1000],[1000,1000,1000],[0,1000,1000],[0,0,2000],[0,1000,2000],[1000,1000,2000],[1000,0,2000],[0,0,3000],[1000,0,3000],[1000,1000,3000],[0,1000,3000]],"id":"id-1"}
diff --git a/tests/files/no_metadata.city.jsonl b/tests/files/no_metadata.city.jsonl
new file mode 100644
index 0000000..15c65ba
--- /dev/null
+++ b/tests/files/no_metadata.city.jsonl
@@ -0,0 +1,2 @@
+{"type":"CityJSON","version":"1.1","CityObjects":{},"vertices":[],"transform":{"scale":[0.001,0.001,0.001],"translate":[0.0,0.0,0.0]},"extensions":{"Generic":{"url":"https://cityjson.org/extensions/download/generic.ext.json","version":"1.0"}}}
+{"type":"CityJSONFeature","CityObjects":{"id-1":{"geometry":[{"boundaries":[[[[[0,1,2,3]],[[4,5,6,7]],[[0,3,5,4]],[[3,2,6,5]],[[2,1,7,6]],[[1,0,4,7]]]],[[[[8,9,10,11]],[[12,13,14,15]],[[8,11,13,12]],[[11,10,14,13]],[[10,9,15,14]],[[9,8,12,15]]]]],"type":"MultiSolid","lod":"1"}],"type":"+GenericCityObject"}},"vertices":[[0,0,0],[0,1000,0],[1000,1000,0],[1000,0,0],[0,0,1000],[1000,0,1000],[1000,1000,1000],[0,1000,1000],[0,0,2000],[0,1000,2000],[1000,1000,2000],[1000,0,2000],[0,0,3000],[1000,0,3000],[1000,1000,3000],[0,1000,3000]],"id":"id-1"}