Repository: pemistahl/grex (language: python)

Issue:
Create Python bindings for the library
With the help of [PyO3](https://github.com/PyO3/pyo3) and [Maturin](https://github.com/PyO3/maturin), let's create Python bindings for the library so that it can be used within Python software, too.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml
new file mode 100644
index 0000000..4006e98
--- /dev/null
+++ b/.github/workflows/python-build.yml
@@ -0,0 +1,79 @@
+#
+# Copyright © 2019-today Peter M. Stahl pemistahl@gmail.com
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either expressed or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: Python Build
+
+on:
+  push:
+    branches:
+      - main
+    paths:
+      - 'Cargo.lock'
+      - 'Cargo.toml'
+      - 'src/**'
+      - 'tests/**'
+      - '**.yml'
+  pull_request:
+    branches:
+      - main
+    paths:
+      - 'Cargo.lock'
+      - 'Cargo.toml'
+      - 'src/**'
+      - 'tests/**'
+      - '**.yml'
+
+jobs:
+  python-build:
+      name: Python ${{ matrix.python-version }} on ${{ matrix.name }}
+
+      runs-on: ${{ matrix.os }}
+
+      strategy:
+        fail-fast: false
+        matrix:
+          os: [ ubuntu-latest, macos-latest, windows-latest ]
+          python-version: [ '3.8', '3.9', '3.10', '3.11' ]
+          include:
+            - os: ubuntu-latest
+              name: Linux 64-Bit
+
+            - os: macos-latest
+              name: MacOS 64-Bit
+
+            - os: windows-latest
+              name: Windows 64-Bit
+
+      steps:
+        - name: Check out repository
+          uses: actions/checkout@v3
+
+        - name: Set up Python
+          uses: actions/setup-python@v4
+          with:
+            python-version: ${{ matrix.python-version }}
+            cache: 'pip'
+
+        - name: Install maturin and pytest
+          run: pip install -r requirements.txt
+
+        - name: Build Python extension
+          run: maturin build
+
+        - name: Install Python extension
+          run: pip install --find-links=target/wheels grex
+
+        - name: Run Python unit tests
+          run: pytest tests/python/test_grex.py
diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml
new file mode 100644
index 0000000..4090aa5
--- /dev/null
+++ b/.github/workflows/python-release.yml
@@ -0,0 +1,165 @@
+#
+# Copyright © 2019-today Peter M. Stahl pemistahl@gmail.com
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either expressed or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: Python Release
+
+on:
+  push:
+    tags:
+      - v1.*
+
+jobs:
+  linux:
+    name: Python ${{ matrix.python-version }} on target ${{ matrix.target }}
+
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        target: [x86_64, x86, aarch64, armv7, s390x, ppc64le]
+        python-version: [ '3.8', '3.9', '3.10', '3.11' ]
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@v3
+
+      - name: Set up Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+          cache: 'pip'
+
+      - name: Build wheels
+        uses: PyO3/maturin-action@v1
+        with:
+          target: ${{ matrix.target }}
+          args: --release --out dist --find-interpreter
+          sccache: 'true'
+          manylinux: auto
+
+      - name: Upload wheels
+        uses: actions/upload-artifact@v3
+        with:
+          name: wheels
+          path: dist
+
+  windows:
+    name: Python ${{ matrix.python-version }} on target ${{ matrix.target }}
+
+    runs-on: windows-latest
+
+    strategy:
+      matrix:
+        target: [x64, x86]
+        python-version: [ '3.8', '3.9', '3.10', '3.11' ]
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@v3
+
+      - name: Set up Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+          architecture: ${{ matrix.target }}
+          cache: 'pip'
+
+      - name: Build wheels
+        uses: PyO3/maturin-action@v1
+        with:
+          target: ${{ matrix.target }}
+          args: --release --out dist --find-interpreter
+          sccache: 'true'
+
+      - name: Upload wheels
+        uses: actions/upload-artifact@v3
+        with:
+          name: wheels
+          path: dist
+
+  macos:
+    name: Python ${{ matrix.python-version }} on target ${{ matrix.target }}
+
+    runs-on: macos-latest
+
+    strategy:
+      matrix:
+        target: [x86_64, aarch64]
+        python-version: [ '3.8', '3.9', '3.10', '3.11' ]
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@v3
+
+      - name: Set up Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+          cache: 'pip'
+
+      - name: Build wheels
+        uses: PyO3/maturin-action@v1
+        with:
+          target: ${{ matrix.target }}
+          args: --release --out dist --find-interpreter
+          sccache: 'true'
+
+      - name: Upload wheels
+        uses: actions/upload-artifact@v3
+        with:
+          name: wheels
+          path: dist
+
+  sdist:
+    name: Source distribution
+
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@v3
+
+      - name: Build source distribution
+        uses: PyO3/maturin-action@v1
+        with:
+          command: sdist
+          args: --out dist
+
+      - name: Upload source distribution
+        uses: actions/upload-artifact@v3
+        with:
+          name: wheels
+          path: dist
+
+  release:
+    name: Publish wheels to PyPI
+
+    runs-on: ubuntu-latest
+
+    needs: [linux, windows, macos, sdist]
+
+    steps:
+      - name: Download wheels from previous jobs
+        uses: actions/download-artifact@v3
+        with:
+          name: wheels
+
+      - name: Upload to PyPI
+        uses: PyO3/maturin-action@v1
+        env:
+          MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
+        with:
+          command: upload
+          args: --skip-existing *
diff --git a/.github/workflows/build.yml b/.github/workflows/rust-build.yml
similarity index 87%
rename from .github/workflows/build.yml
rename to .github/workflows/rust-build.yml
index c1d3257..62bd96d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/rust-build.yml
@@ -13,7 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-name: build
+name: Rust Build
 
 on:
   push:
@@ -36,8 +36,8 @@ on:
       - '**.yml'
 
 jobs:
-  build-and-test:
-    name: ${{ matrix.name }}
+  rust-build:
+    name: Rust on ${{ matrix.name }}
 
     runs-on: ${{ matrix.os }}
 
@@ -47,31 +47,26 @@ jobs:
         os: [ubuntu-latest, macos-latest, windows-latest]
         include:
           - os: ubuntu-latest
-            name: Linux Binary 64-Bit
+            name: Linux 64-Bit
             target: x86_64-unknown-linux-musl
 
           - os: macos-latest
-            name: MacOS Binary 64-Bit
+            name: MacOS 64-Bit
             target: x86_64-apple-darwin
-            target2: aarch64-apple-darwin
             env:
               MACOSX_DEPLOYMENT_TARGET: 10.7
 
           - os: windows-latest
-            name: Windows Binary 64-Bit
+            name: Windows 64-Bit
             target: x86_64-pc-windows-msvc
 
     steps:
       - name: Check out repository
         uses: actions/checkout@v3
 
-      - name: Add rustup default target
+      - name: Add rustup target
         run: rustup target add ${{ matrix.target }}
 
-      - name: Add rustup Apple ARM64 target
-        if: ${{ matrix.os == 'macos-latest' }}
-        run: rustup target add ${{ matrix.target2 }}
-
       - name: Install apt packages
         if: ${{ matrix.os == 'ubuntu-latest' }}
         run: sudo apt-get install musl-tools libssl-dev
@@ -103,13 +98,9 @@ jobs:
             target/
           key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
 
-      - name: Build default target in debug mode
+      - name: Build target in debug mode
         run: cargo build --target ${{ matrix.target }} --locked
 
-      - name: Build Apple ARM64 target in debug mode
-        if: ${{ matrix.os == 'macos-latest' }}
-        run: cargo build --target ${{ matrix.target2 }} --locked
-
       - name: Test default target in debug mode
         run: cargo test --target ${{ matrix.target }}
 
diff --git a/.github/workflows/release.yml b/.github/workflows/rust-release.yml
similarity index 99%
rename from .github/workflows/release.yml
rename to .github/workflows/rust-release.yml
index fb58fef..1aef05a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/rust-release.yml
@@ -13,7 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-name: release
+name: Rust Release
 
 on:
   push:
@@ -21,7 +21,7 @@ on:
       - v1.*
 
 jobs:
-  create-release:
+  rust-release:
     name: ${{ matrix.name }}
 
     runs-on: ${{ matrix.os }}
diff --git a/.gitignore b/.gitignore
index 65c8a6f..95f0b51 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,7 @@
 *.launch
 .settings/
 .metadata/
+.venv
 *.sublime-workspace
 bin/
 tmp/
diff --git a/Cargo.lock b/Cargo.lock
index f32c7a6..5db0c02 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -215,7 +215,7 @@ dependencies = [
  "heck",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.27",
 ]
 
 [[package]]
@@ -447,7 +447,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.27",
 ]
 
 [[package]]
@@ -505,18 +505,19 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
 
 [[package]]
 name = "grex"
-version = "1.4.2"
+version = "1.4.3"
 dependencies = [
  "assert_cmd",
  "clap",
  "criterion",
- "indoc",
+ "indoc 2.0.3",
  "itertools 0.11.0",
  "lazy_static",
  "ndarray",
  "petgraph",
  "predicates",
  "proptest",
+ "pyo3",
  "regex",
  "rstest",
  "tempfile",
@@ -561,6 +562,12 @@ dependencies = [
  "hashbrown",
 ]
 
+[[package]]
+name = "indoc"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306"
+
 [[package]]
 name = "indoc"
 version = "2.0.3"
@@ -652,6 +659,16 @@ version = "0.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0"
 
+[[package]]
+name = "lock_api"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
 [[package]]
 name = "log"
 version = "0.4.19"
@@ -759,6 +776,29 @@ version = "11.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0ab1bc2a289d34bd04a330323ac98a1b4bc82c9d9fcb1e66b63caa84da26b575"
 
+[[package]]
+name = "parking_lot"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-targets",
+]
+
 [[package]]
 name = "petgraph"
 version = "0.6.4"
@@ -875,6 +915,66 @@ dependencies = [
  "unarray",
 ]
 
+[[package]]
+name = "pyo3"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e681a6cfdc4adcc93b4d3cf993749a4552018ee0a9b65fc0ccfad74352c72a38"
+dependencies = [
+ "cfg-if",
+ "indoc 1.0.9",
+ "libc",
+ "memoffset",
+ "parking_lot",
+ "pyo3-build-config",
+ "pyo3-ffi",
+ "pyo3-macros",
+ "unindent",
+]
+
+[[package]]
+name = "pyo3-build-config"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "076c73d0bc438f7a4ef6fdd0c3bb4732149136abd952b110ac93e4edb13a6ba5"
+dependencies = [
+ "once_cell",
+ "target-lexicon",
+]
+
+[[package]]
+name = "pyo3-ffi"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e53cee42e77ebe256066ba8aa77eff722b3bb91f3419177cf4cd0f304d3284d9"
+dependencies = [
+ "libc",
+ "pyo3-build-config",
+]
+
+[[package]]
+name = "pyo3-macros"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfeb4c99597e136528c6dd7d5e3de5434d1ceaf487436a3f03b2d56b6fc9efd1"
+dependencies = [
+ "proc-macro2",
+ "pyo3-macros-backend",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "pyo3-macros-backend"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "947dc12175c254889edc0c02e399476c2f652b4b9ebd123aa655c224de259536"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
 [[package]]
 name = "quick-error"
 version = "1.2.3"
@@ -1032,7 +1132,7 @@ dependencies = [
  "regex",
  "relative-path",
  "rustc_version",
- "syn",
+ "syn 2.0.27",
  "unicode-ident",
 ]
 
@@ -1134,7 +1234,7 @@ checksum = "6e5c3a298c7f978e53536f95a63bdc4c4a64550582f31a0359a9afda6aede62e"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.27",
 ]
 
 [[package]]
@@ -1157,12 +1257,29 @@ dependencies = [
  "autocfg",
 ]
 
+[[package]]
+name = "smallvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9"
+
 [[package]]
 name = "strsim"
 version = "0.10.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
 
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
 [[package]]
 name = "syn"
 version = "2.0.27"
@@ -1174,6 +1291,12 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "target-lexicon"
+version = "0.12.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d0e916b1148c8e263850e1ebcbd046f333e0683c724876bb0da63ea4373dc8a"
+
 [[package]]
 name = "tempfile"
 version = "3.8.0"
@@ -1273,6 +1396,12 @@ version = "1.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
 
+[[package]]
+name = "unindent"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c"
+
 [[package]]
 name = "utf8parse"
 version = "0.2.1"
@@ -1325,7 +1454,7 @@ dependencies = [
  "once_cell",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.27",
  "wasm-bindgen-shared",
 ]
 
@@ -1359,7 +1488,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.27",
  "wasm-bindgen-backend",
  "wasm-bindgen-shared",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index dc07d79..173597c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,7 +14,7 @@
 
 [package]
 name = "grex"
-version = "1.4.2"
+version = "1.4.3"
 authors = ["Peter M. Stahl <pemistahl@gmail.com>"]
 description = """
 grex generates regular expressions from user-provided test cases.
@@ -36,6 +36,7 @@ itertools = "0.11.0"
 lazy_static = "1.4.0"
 ndarray = "0.15.6"
 petgraph = {version = "0.6.4", default-features = false, features = ["stable_graph"]}
+pyo3 = { version = "0.19.2", optional = true }
 regex = "1.9.3"
 unic-char-range = "0.9.0"
 unic-ucd-category = "0.9.0"
@@ -64,6 +65,7 @@ wasm-bindgen-test = "0.3.37"
 [features]
 default = ["cli"]
 cli = ["clap"]
+python = ["pyo3"]
 
 [[bench]]
 name = "benchmark"
diff --git a/README.md b/README.md
index 864f209..0f7127a 100644
--- a/README.md
+++ b/README.md
@@ -7,18 +7,20 @@
   [![build status](https://github.com/pemistahl/grex/actions/workflows/build.yml/badge.svg)](https://github.com/pemistahl/grex/actions/workflows/build.yml)
   [![docs.rs](https://docs.rs/grex/badge.svg)](https://docs.rs/grex)
   [![codecov](https://codecov.io/gh/pemistahl/grex/branch/main/graph/badge.svg)](https://codecov.io/gh/pemistahl/grex)
-  [![dependency status](https://deps.rs/crate/grex/1.4.2/status.svg)](https://deps.rs/crate/grex/1.4.2)
+  [![dependency status](https://deps.rs/crate/grex/1.4.3/status.svg)](https://deps.rs/crate/grex/1.4.3)
   [![demo](https://img.shields.io/badge/-Demo%20Website-orange?logo=HTML5&labelColor=white)](https://pemistahl.github.io/grex-js/)
   
   [![downloads](https://img.shields.io/crates/d/grex.svg)](https://crates.io/crates/grex)
   [![crates.io](https://img.shields.io/crates/v/grex.svg)](https://crates.io/crates/grex)
-  [![lib.rs](https://img.shields.io/badge/lib.rs-v1.4.2-blue)](https://lib.rs/crates/grex)
+  [![lib.rs](https://img.shields.io/badge/lib.rs-v1.4.3-blue)](https://lib.rs/crates/grex)
+  ![supported Python versions](https://img.shields.io/badge/Python-%3E%3D%203.8-blue?logo=Python&logoColor=yellow)
+  [![pypi](https://img.shields.io/badge/PYPI-v1.0.0-blue?logo=PyPI&logoColor=yellow)](https://pypi.org/project/grex)
   [![license](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
 
-  [![Linux Download](https://img.shields.io/badge/Linux%20Download-v1.4.2-blue?logo=Linux)](https://github.com/pemistahl/grex/releases/download/v1.4.2/grex-v1.4.2-x86_64-unknown-linux-musl.tar.gz)
-  [![MacOS Download](https://img.shields.io/badge/macOS%20x86%20Download-v1.4.2-blue?logo=Apple)](https://github.com/pemistahl/grex/releases/download/v1.4.2/grex-v1.4.2-x86_64-apple-darwin.tar.gz)
-  [![MacOS ARM Download](https://img.shields.io/badge/macOS%20ARM%20Download-v1.4.2-blue?logo=Apple)](https://github.com/pemistahl/grex/releases/download/v1.4.2/grex-v1.4.2-aarch64-apple-darwin.tar.gz)
-  [![Windows Download](https://img.shields.io/badge/Windows%20Download-v1.4.2-blue?logo=Windows)](https://github.com/pemistahl/grex/releases/download/v1.4.2/grex-v1.4.2-x86_64-pc-windows-msvc.zip)
+  [![Linux Download](https://img.shields.io/badge/Linux%20Download-v1.4.3-blue?logo=Linux)](https://github.com/pemistahl/grex/releases/download/v1.4.3/grex-v1.4.3-x86_64-unknown-linux-musl.tar.gz)
+  [![MacOS Download](https://img.shields.io/badge/macOS%20x86%20Download-v1.4.3-blue?logo=Apple)](https://github.com/pemistahl/grex/releases/download/v1.4.3/grex-v1.4.3-x86_64-apple-darwin.tar.gz)
+  [![MacOS ARM Download](https://img.shields.io/badge/macOS%20ARM%20Download-v1.4.3-blue?logo=Apple)](https://github.com/pemistahl/grex/releases/download/v1.4.3/grex-v1.4.3-aarch64-apple-darwin.tar.gz)
+  [![Windows Download](https://img.shields.io/badge/Windows%20Download-v1.4.3-blue?logo=Windows)](https://github.com/pemistahl/grex/releases/download/v1.4.3/grex-v1.4.3-x86_64-pc-windows-msvc.zip)
 </div>
 
 <br>
@@ -112,7 +114,7 @@ In order to use *grex* as a library, simply add it as a dependency to your `Carg
 
 ```toml
 [dependencies]
-grex = { version = "1.4.2", default-features = false }
+grex = { version = "1.4.3", default-features = false }
 ```
 
 The dependency *clap* is only needed for the command-line tool.
@@ -133,7 +135,7 @@ The following table shows all available flags and options:
 ```
 $ grex -h
 
-grex 1.4.2
+grex 1.4.3
 © 2019-today Peter M. Stahl <pemistahl@gmail.com>
 Licensed under the Apache License, Version 2.0
 Downloadable from https://crates.io/crates/grex
@@ -197,8 +199,8 @@ Miscellaneous Options:
 
 #### 5.2.1 Default settings
 
-Test cases are passed either from a collection via [`RegExpBuilder::from()`](https://docs.rs/grex/1.4.2/grex/struct.RegExpBuilder.html#method.from) 
-or from a file via [`RegExpBuilder::from_file()`](https://docs.rs/grex/1.4.2/grex/struct.RegExpBuilder.html#method.from_file).
+Test cases are passed either from a collection via [`RegExpBuilder::from()`](https://docs.rs/grex/1.4.3/grex/struct.RegExpBuilder.html#method.from) 
+or from a file via [`RegExpBuilder::from_file()`](https://docs.rs/grex/1.4.3/grex/struct.RegExpBuilder.html#method.from_file).
 If read from a file, each test case must be on a separate line. Lines may be ended with either a newline `\n` or a carriage
 return with a line feed `\r\n`.
 
@@ -496,7 +498,35 @@ Benchmarks measuring the performance of several settings can be run with:
 cargo bench
 ```
 
-## 7. WebAssembly support
+## 7. Python extension module
+
+With the help of [PyO3](https://github.com/PyO3/pyo3) and
+[Maturin](https://github.com/PyO3/maturin), the library has been compiled to a
+Python extension module so that it can be used within any Python software as well.
+It is available in the [Python Package Index](https://pypi.org/project/grex) and can 
+be installed with:
+
+```shell
+pip install grex
+```
+
+To build the Python extension module yourself, create a virtual environment and install 
+[Maturin](https://github.com/PyO3/maturin).
+
+```shell
+python -m venv /path/to/virtual/environment
+source /path/to/virtual/environment/bin/activate
+pip install maturin
+maturin build
+```
+
+The Python library contains a single class named `RegExpBuilder` that can be imported like so:
+
+```python
+from grex import RegExpBuilder
+```
+
+## 8. WebAssembly support
 
 This library can be compiled to [WebAssembly (WASM)](https://webassembly.org) which allows to use *grex*
 in any JavaScript-based project, be it in the browser or in the back end running on [Node.js](https://nodejs.org).
@@ -537,7 +567,7 @@ There is a [demo website](https://pemistahl.github.io/grex-js/) available where
 
 ![demo website](https://raw.githubusercontent.com/pemistahl/grex/main/website.jpg)
 
-## 8. How does it work?
+## 9. How does it work?
 
 1. A [deterministic finite automaton](https://en.wikipedia.org/wiki/Deterministic_finite_automaton) (DFA) 
 is created from the input strings.
@@ -549,11 +579,11 @@ is created from the input strings.
 [Brzozowski's algebraic method](http://cs.stackexchange.com/questions/2016/how-to-convert-finite-automata-to-regular-expressions#2392), 
 resulting in the final regular expression.
 
-## 9. What's next for version 1.5.0?
+## 10. What's next for version 1.5.0?
 
 Take a look at the [planned issues](https://github.com/pemistahl/grex/milestone/5).
 
-## 10. Contributions
+## 11. Contributions
 
 In case you want to contribute something to *grex*, I encourage you to do so.
 Do you have ideas for cool features? Or have you found any bugs so far? 
diff --git a/README_PYPI.md b/README_PYPI.md
new file mode 100644
index 0000000..0fa08c4
--- /dev/null
+++ b/README_PYPI.md
@@ -0,0 +1,270 @@
+<div align="center">
+
+![grex](https://raw.githubusercontent.com/pemistahl/grex/main/logo.png)
+
+<br>
+
+[![build status](https://github.com/pemistahl/grex/actions/workflows/build.yml/badge.svg)](https://github.com/pemistahl/grex/actions/workflows/build.yml)
+[![codecov](https://codecov.io/gh/pemistahl/grex/branch/main/graph/badge.svg)](https://codecov.io/gh/pemistahl/grex)
+[![demo](https://img.shields.io/badge/-Demo%20Website-orange?logo=HTML5&labelColor=white)](https://pemistahl.github.io/grex-js/)
+![supported Python versions](https://img.shields.io/badge/Python-%3E%3D%203.8-blue?logo=Python&logoColor=yellow)
+[![pypi](https://img.shields.io/badge/PYPI-v1.0.0-blue?logo=PyPI&logoColor=yellow)](https://pypi.org/project/grex)
+[![license](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
+</div>
+
+<br>
+
+## 1. What does this library do?
+
+*grex* is a library that is meant to simplify the often complicated and tedious
+task of creating regular expressions. It does so by automatically generating a
+single regular expression from user-provided test cases. The resulting
+expression is guaranteed to match the test cases which it was generated from.
+
+This project has started as a [Rust port](https://github.com/pemistahl/grex) of
+the JavaScript tool [*regexgen*](https://github.com/devongovett/regexgen)
+written by [Devon Govett](https://github.com/devongovett). Although a lot of
+further useful features could be added to it, its development was apparently
+ceased several years ago. The Rust library offers new features and extended
+Unicode support. With the help of [PyO3](https://github.com/PyO3/pyo3) and 
+[Maturin](https://github.com/PyO3/maturin), the library has been compiled to a 
+Python extension module so that it can be used within any Python software as well.
+
+The philosophy of this project is to generate the most specific regular expression
+possible by default which exactly matches the given input only and nothing else.
+With the use of preprocessing methods, more generalized expressions can be created.
+
+The produced expressions are [Perl-compatible regular expressions](https://www.pcre.org) which are also
+compatible with the [regular expression module](https://docs.python.org/3/library/re.html) in Python's 
+standard library.
+
+There is a [demo website](https://pemistahl.github.io/grex-js/) available where you can give grex a try.
+
+![demo website](https://raw.githubusercontent.com/pemistahl/grex/main/website.jpg)
+
+## 2. Do I still need to learn to write regexes then?
+
+**Definitely, yes!** Using the standard settings, *grex* produces a regular expression that is guaranteed
+to match only the test cases given as input and nothing else. However, if the conversion to shorthand
+character classes such as `\w` is enabled, the resulting regex matches a much wider scope of test cases.
+Knowledge about the consequences of this conversion is essential for finding a correct regular expression
+for your business domain.
+
+*grex* uses an algorithm that tries to find the shortest possible regex for the given test cases.
+Very often though, the resulting expression is still longer or more complex than it needs to be.
+In such cases, a more compact or elegant regex can be created only by hand.
+Also, every regular expression engine has different built-in optimizations. *grex* does not know anything
+about those and therefore cannot optimize its regexes for a specific engine.
+
+**So, please learn how to write regular expressions!** The currently best use case for *grex* is to find
+an initial correct regex which should be inspected by hand if further optimizations are possible.
+
+## 3. Current Features
+
+- literals
+- character classes
+- detection of common prefixes and suffixes
+- detection of repeated substrings and conversion to `{min,max}` quantifier notation
+- alternation using `|` operator
+- optionality using `?` quantifier
+- escaping of non-ascii characters, with optional conversion of astral code points to surrogate pairs
+- case-sensitive or case-insensitive matching
+- capturing or non-capturing groups
+- optional anchors `^` and `$`
+- fully compliant to [Unicode Standard 15.0](https://unicode.org/versions/Unicode15.0.0)
+- correctly handles graphemes consisting of multiple Unicode symbols
+- produces more readable expressions indented on multiple using optional verbose mode
+- optional syntax highlighting for nicer output in supported terminals
+
+## 4. How to install?
+
+*grex* is available in the [Python Package Index](https://pypi.org/project/grex) and can be installed with:
+
+```
+pip install grex
+```
+
+The current version 1.0.0 corresponds to the latest version 1.4.3 of the Rust
+library and command-line tool.
+
+## 5. How to use?
+
+This library contains a single class named `RegExpBuilder` that can be imported like so:
+
+```python
+from grex import RegExpBuilder
+```
+
+### 5.1 Default settings
+
+```python
+pattern = RegExpBuilder.from_test_cases(["a", "aa", "aaa"]).build()
+assert pattern == "^a(?:aa?)?$"
+```
+
+### 5.2 Convert to character classes
+
+```python
+pattern = (RegExpBuilder.from_test_cases(["a", "aa", "123"])
+    .with_conversion_of_digits()
+    .with_conversion_of_words()
+    .build())
+assert pattern == "^(?:\\d\\d\\d|\\w(?:\\w)?)$"
+```
+
+### 5.3 Convert repeated substrings
+
+```python
+pattern = (RegExpBuilder.from_test_cases(["aa", "bcbc", "defdefdef"])
+    .with_conversion_of_repetitions()
+    .build())
+assert pattern == "^(?:a{2}|(?:bc){2}|(?:def){3})$"
+```
+
+By default, *grex* converts each substring this way which is at least a single character long
+and which is subsequently repeated at least once. You can customize these two parameters if you like.
+
+In the following example, the test case `aa` is not converted to `a{2}` because the repeated substring
+`a` has a length of 1, but the minimum substring length has been set to 2.
+
+```python
+pattern = (RegExpBuilder.from_test_cases(["aa", "bcbc", "defdefdef"])
+    .with_conversion_of_repetitions()
+    .with_minimum_substring_length(2)
+    .build())
+assert pattern == "^(?:aa|(?:bc){2}|(?:def){3})$"
+```
+
+Setting a minimum number of 2 repetitions in the next example, only the test case `defdefdef` will be
+converted because it is the only one that is repeated twice.
+
+```python
+pattern = (RegExpBuilder.from_test_cases(["aa", "bcbc", "defdefdef"])
+    .with_conversion_of_repetitions()
+    .with_minimum_repetitions(2)
+    .build())
+assert pattern == "^(?:bcbc|aa|(?:def){3})$"
+```
+
+### 5.4 Escape non-ascii characters
+
+```python
+pattern = (RegExpBuilder.from_test_cases(["You smell like 💩."])
+    .with_escaping_of_non_ascii_chars(use_surrogate_pairs=False)
+    .build())
+assert pattern == "^You smell like \\U0001f4a9\\.$"
+```
+
+Old versions of JavaScript do not support unicode escape sequences for the astral code planes
+(range `U+010000` to `U+10FFFF`). In order to support these symbols in JavaScript regular
+expressions, the conversion to surrogate pairs is necessary. More information on that matter
+can be found [here](https://mathiasbynens.be/notes/javascript-unicode).
+
+```python
+pattern = (RegExpBuilder.from_test_cases(["You smell like 💩."])
+    .with_escaping_of_non_ascii_chars(use_surrogate_pairs=True)
+    .build())
+assert pattern == "^You smell like \\ud83d\\udca9\\.$"
+```
+
+### 5.5 Case-insensitive matching
+
+The regular expressions that *grex* generates are case-sensitive by default.
+Case-insensitive matching can be enabled like so:
+
+```python
+pattern = (RegExpBuilder.from_test_cases(["big", "BIGGER"])
+    .with_case_insensitive_matching()
+    .build())
+assert pattern == "(?i)^big(?:ger)?$"
+```
+
+### 5.6 Capturing Groups
+
+Non-capturing groups are used by default.
+Extending the previous example, you can switch to capturing groups instead.
+
+```python
+pattern = (RegExpBuilder.from_test_cases(["big", "BIGGER"])
+    .with_case_insensitive_matching()
+    .with_capturing_groups()
+    .build())
+assert pattern == "(?i)^big(ger)?$"
+```
+
+### 5.7 Verbose mode
+
+If you find the generated regular expression hard to read, you can enable verbose mode.
+The expression is then put on multiple lines and indented to make it more pleasant to the eyes.
+
+```python
+import inspect
+
+pattern = (RegExpBuilder.from_test_cases(["a", "b", "bcd"])
+    .with_verbose_mode()
+    .build())
+
+assert pattern == inspect.cleandoc("""
+    (?x)
+    ^
+      (?:
+        b
+        (?:
+          cd
+        )?
+        |
+        a
+      )
+    $
+    """
+)
+```
+
+### 5.8 Disable anchors
+
+By default, the anchors `^` and `$` are put around every generated regular expression in order
+to ensure that it matches only the test cases given as input. Often enough, however, it is
+desired to use the generated pattern as part of a larger one. For this purpose, the anchors
+can be disabled, either separately or both of them.
+
+```python
+pattern = (RegExpBuilder.from_test_cases(["a", "aa", "aaa"])
+    .without_anchors()
+    .build())
+assert pattern == "a(?:aa?)?"
+```
+
+## 6. How to build?
+
+In order to build the source code yourself, you need the
+[stable Rust toolchain](https://www.rust-lang.org/tools/install) installed on your machine
+so that [*cargo*](https://doc.rust-lang.org/cargo/), the Rust package manager is available.
+
+```shell
+git clone https://github.com/pemistahl/grex.git
+cd grex
+cargo build
+```
+
+To build the Python extension module, create a virtual environment and install [Maturin](https://github.com/PyO3/maturin).
+
+```shell
+python -m venv /path/to/virtual/environment
+source /path/to/virtual/environment/bin/activate
+pip install maturin
+maturin build
+```
+
+The Rust source code is accompanied by an extensive test suite consisting of unit tests, integration
+tests and property tests. For running them, simply say:
+
+```shell
+cargo test
+```
+
+Additional Python tests can be run after installing pytest which is an optional dependency:
+
+```shell
+maturin develop --extras=test
+pytest tests/python/test_grex.py
+```
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..e9d6f77
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,39 @@
+[project]
+name = "grex"
+version = "1.0.0"
+authors = [{name = "Peter M. Stahl", email = "pemistahl@gmail.com"}]
+description = "grex generates regular expressions from user-provided test cases."
+readme = "README_PYPI.md"
+requires-python = ">=3.8"
+license = {file = "LICENSE"}
+keywords = ["pattern", "regex", "regexp"]
+classifiers = [
+    "Development Status :: 5 - Production/Stable",
+    "Intended Audience :: Developers",
+    "Intended Audience :: Information Technology",
+    "Intended Audience :: Science/Research",
+    "License :: OSI Approved :: Apache Software License",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Rust",
+    "Topic :: Software Development :: Libraries :: Python Modules",
+    "Topic :: Text Processing"
+]
+
+[project.urls]
+homepage = "https://github.com/pemistahl/grex"
+repository = "https://github.com/pemistahl/grex"
+
+[project.optional-dependencies]
+test = ["pytest == 7.4.0"]
+
+[tool.maturin]
+no-default-features = true
+features = ["pyo3/extension-module", "python"]
+
+[build-system]
+requires = ["maturin>=1.1,<2.0"]
+build-backend = "maturin"
+
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..9fac072
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+maturin == 1.2.3
+pytest == 7.4.0
diff --git a/src/builder.rs b/src/builder.rs
index 1a7a3b9..5d80d4b 100644
--- a/src/builder.rs
+++ b/src/builder.rs
@@ -31,8 +31,9 @@ pub(crate) const MINIMUM_SUBSTRING_LENGTH_MESSAGE: &str =
 
 /// This struct builds regular expressions from user-provided test cases.
 #[derive(Clone)]
+#[cfg_attr(feature = "python", pyo3::prelude::pyclass)]
 pub struct RegExpBuilder {
-    test_cases: Vec<String>,
+    pub(crate) test_cases: Vec<String>,
     pub(crate) config: RegExpConfig,
 }
 
diff --git a/src/lib.rs b/src/lib.rs
index d562ef6..b58f047 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -277,6 +277,9 @@ mod regexp;
 mod substring;
 mod unicode_tables;
 
+#[cfg(feature = "python")]
+mod python;
+
 #[cfg(target_family = "wasm")]
 mod wasm;
 
diff --git a/src/python.rs b/src/python.rs
new file mode 100644
index 0000000..2dbcd70
--- /dev/null
+++ b/src/python.rs
@@ -0,0 +1,275 @@
+/*
+ * Copyright © 2019-today Peter M. Stahl pemistahl@gmail.com
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either expressed or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+use crate::builder::{
+    RegExpBuilder, MINIMUM_REPETITIONS_MESSAGE, MINIMUM_SUBSTRING_LENGTH_MESSAGE,
+    MISSING_TEST_CASES_MESSAGE,
+};
+use crate::config::RegExpConfig;
+use lazy_static::lazy_static;
+use pyo3::exceptions::PyValueError;
+use pyo3::prelude::*;
+use pyo3::types::PyType;
+use regex::{Captures, Regex};
+
+#[pymodule]
+fn grex(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
+    m.add_class::<RegExpBuilder>()?;
+    Ok(())
+}
+
+#[pymethods]
+impl RegExpBuilder {
+    #[new]
+    fn new(test_cases: Vec<String>) -> PyResult<Self> {
+        if test_cases.is_empty() {
+            Err(PyValueError::new_err(MISSING_TEST_CASES_MESSAGE))
+        } else {
+            Ok(Self {
+                test_cases,
+                config: RegExpConfig::new(),
+            })
+        }
+    }
+
+    /// Specify the test cases to build the regular expression from.
+    ///
+    /// The test cases need not be sorted because `RegExpBuilder` sorts them internally.
+    ///
+    /// Args:
+    ///     test_cases (list[str]): The list of test cases
+    ///
+    /// Raises:
+    ///     ValueError: if `test_cases` is empty
+    #[classmethod]
+    fn from_test_cases(_cls: &PyType, test_cases: Vec<String>) -> PyResult<Self> {
+        Self::new(test_cases)
+    }
+
+    /// Convert any Unicode decimal digit to character class `\d`.
+    ///
+    /// This method takes precedence over `with_conversion_of_words` if both are set.
+    /// Decimal digits are converted to `\d`, the remaining word characters to `\w`.
+    ///
+    /// This method takes precedence over `with_conversion_of_non_whitespace` if both are set.
+    /// Decimal digits are converted to `\d`, the remaining non-whitespace characters to `\S`.
+    #[pyo3(name = "with_conversion_of_digits")]
+    fn py_with_conversion_of_digits(mut self_: PyRefMut<Self>) -> PyRefMut<Self> {
+        self_.config.is_digit_converted = true;
+        self_
+    }
+
+    /// Convert any character which is not a Unicode decimal digit to character class `\D`.
+    ///
+    /// This method takes precedence over `with_conversion_of_non_words` if both are set.
+    /// Non-digits which are also non-word characters are converted to `\D`.
+    ///
+    /// This method takes precedence over `with_conversion_of_non_whitespace` if both are set.
+    /// Non-digits which are also non-space characters are converted to `\D`.
+    #[pyo3(name = "with_conversion_of_non_digits")]
+    fn py_with_conversion_of_non_digits(mut self_: PyRefMut<Self>) -> PyRefMut<Self> {
+        self_.config.is_non_digit_converted = true;
+        self_
+    }
+
+    /// Convert any Unicode whitespace character to character class `\s`.
+    ///
+    /// This method takes precedence over `with_conversion_of_non_digits` if both are set.
+    /// Whitespace characters are converted to `\s`, the remaining non-digit characters to `\D`.
+    ///
+    /// This method takes precedence over `with_conversion_of_non_words` if both are set.
+    /// Whitespace characters are converted to `\s`, the remaining non-word characters to `\W`.
+    #[pyo3(name = "with_conversion_of_whitespace")]
+    fn py_with_conversion_of_whitespace(mut self_: PyRefMut<Self>) -> PyRefMut<Self> {
+        self_.config.is_space_converted = true;
+        self_
+    }
+
+    /// Convert any character which is not a Unicode whitespace character to character class `\S`.
+    #[pyo3(name = "with_conversion_of_non_whitespace")]
+    fn py_with_conversion_of_non_whitespace(mut self_: PyRefMut<Self>) -> PyRefMut<Self> {
+        self_.config.is_non_space_converted = true;
+        self_
+    }
+
+    /// Convert any Unicode word character to character class `\w`.
+    ///
+    /// This method takes precedence over `with_conversion_of_non_digits` if both are set.
+    /// Word characters are converted to `\w`, the remaining non-digit characters to `\D`.
+    ///
+    /// This method takes precedence over `with_conversion_of_non_whitespace` if both are set.
+    /// Word characters are converted to `\w`, the remaining non-space characters to `\S`.
+    #[pyo3(name = "with_conversion_of_words")]
+    fn py_with_conversion_of_words(mut self_: PyRefMut<Self>) -> PyRefMut<Self> {
+        self_.config.is_word_converted = true;
+        self_
+    }
+
+    /// Convert any character which is not a Unicode word character to character class `\W`.
+    ///
+    /// This method takes precedence over `with_conversion_of_non_whitespace` if both are set.
+    /// Non-words which are also non-space characters are converted to `\W`.
+    #[pyo3(name = "with_conversion_of_non_words")]
+    fn py_with_conversion_of_non_words(mut self_: PyRefMut<Self>) -> PyRefMut<Self> {
+        self_.config.is_non_word_converted = true;
+        self_
+    }
+
+    /// Detect repeated non-overlapping substrings and convert them to `{min,max}` quantifier notation.
+    #[pyo3(name = "with_conversion_of_repetitions")]
+    fn py_with_conversion_of_repetitions(mut self_: PyRefMut<Self>) -> PyRefMut<Self> {
+        self_.config.is_repetition_converted = true;
+        self_
+    }
+
+    /// Enable case-insensitive matching of test cases so that letters match both upper and lower case.
+    #[pyo3(name = "with_case_insensitive_matching")]
+    fn py_with_case_insensitive_matching(mut self_: PyRefMut<Self>) -> PyRefMut<Self> {
+        self_.config.is_case_insensitive_matching = true;
+        self_
+    }
+
+    /// Replace non-capturing groups by capturing ones.
+    #[pyo3(name = "with_capturing_groups")]
+    fn py_with_capturing_groups(mut self_: PyRefMut<Self>) -> PyRefMut<Self> {
+        self_.config.is_capturing_group_enabled = true;
+        self_
+    }
+
+    /// Specify the minimum quantity of substring repetitions to be converted if `with_conversion_of_repetitions` is set.
+    ///
+    /// If the quantity is not explicitly set with this method, a default value of 1 will be used.
+    ///
+    /// Args:
+    ///     quantity (int): The minimum quantity of substring repetitions
+    ///
+    /// Raises:
+    ///     ValueError: if `quantity` is zero
+    #[pyo3(name = "with_minimum_repetitions")]
+    fn py_with_minimum_repetitions(
+        mut self_: PyRefMut<Self>,
+        quantity: i32,
+    ) -> PyResult<PyRefMut<Self>> {
+        if quantity <= 0 {
+            Err(PyValueError::new_err(MINIMUM_REPETITIONS_MESSAGE))
+        } else {
+            self_.config.minimum_repetitions = quantity as u32;
+            Ok(self_)
+        }
+    }
+
+    /// Specify the minimum length a repeated substring must have in order to be converted if `with_conversion_of_repetitions` is set.
+    ///
+    /// If the length is not explicitly set with this method, a default value of 1 will be used.
+    ///
+    /// Args:
+    ///     length (int): The minimum substring length
+    ///
+    /// Raises:
+    ///     ValueError: if `length` is zero
+    #[pyo3(name = "with_minimum_substring_length")]
+    fn py_with_minimum_substring_length(
+        mut self_: PyRefMut<Self>,
+        length: i32,
+    ) -> PyResult<PyRefMut<Self>> {
+        if length <= 0 {
+            Err(PyValueError::new_err(MINIMUM_SUBSTRING_LENGTH_MESSAGE))
+        } else {
+            self_.config.minimum_substring_length = length as u32;
+            Ok(self_)
+        }
+    }
+
+    /// Convert non-ASCII characters to unicode escape sequences.
+    ///
+    /// The parameter `use_surrogate_pairs` specifies whether to convert astral code planes
+    /// (range `U+010000` to `U+10FFFF`) to surrogate pairs.
+    ///
+    /// Args:
+    ///     use_surrogate_pairs (bool): Whether to convert astral code planes to surrogate pairs
+    #[pyo3(name = "with_escaping_of_non_ascii_chars")]
+    fn py_with_escaping_of_non_ascii_chars(
+        mut self_: PyRefMut<Self>,
+        use_surrogate_pairs: bool,
+    ) -> PyRefMut<Self> {
+        self_.config.is_non_ascii_char_escaped = true;
+        self_.config.is_astral_code_point_converted_to_surrogate = use_surrogate_pairs;
+        self_
+    }
+
+    /// Produce a nicer looking regular expression in verbose mode.
+    #[pyo3(name = "with_verbose_mode")]
+    fn py_with_verbose_mode(mut self_: PyRefMut<Self>) -> PyRefMut<Self> {
+        self_.config.is_verbose_mode_enabled = true;
+        self_
+    }
+
+    /// Remove the caret anchor '^' from the resulting regular expression, thereby allowing to
+    /// match the test cases also when they do not occur at the start of a string.
+    #[pyo3(name = "without_start_anchor")]
+    fn py_without_start_anchor(mut self_: PyRefMut<Self>) -> PyRefMut<Self> {
+        self_.config.is_start_anchor_disabled = true;
+        self_
+    }
+
+    /// Remove the dollar sign anchor '$' from the resulting regular expression, thereby allowing
+    /// to match the test cases also when they do not occur at the end of a string.
+    #[pyo3(name = "without_end_anchor")]
+    fn py_without_end_anchor(mut self_: PyRefMut<Self>) -> PyRefMut<Self> {
+        self_.config.is_end_anchor_disabled = true;
+        self_
+    }
+
+    /// Remove the caret and dollar sign anchors from the resulting regular expression, thereby
+    /// allowing to match the test cases also when they occur within a larger string that contains
+    /// other content as well.
+    #[pyo3(name = "without_anchors")]
+    fn py_without_anchors(mut self_: PyRefMut<Self>) -> PyRefMut<Self> {
+        self_.config.is_start_anchor_disabled = true;
+        self_.config.is_end_anchor_disabled = true;
+        self_
+    }
+
+    /// Build the actual regular expression using the previously given settings.
+    #[pyo3(name = "build")]
+    fn py_build(&mut self) -> String {
+        let regexp = self.build();
+        if self.config.is_non_ascii_char_escaped {
+            replace_unicode_escape_sequences(regexp)
+        } else {
+            regexp
+        }
+    }
+}
+
+/// Replaces Rust Unicode escape sequences to Python Unicode escape sequences.
+fn replace_unicode_escape_sequences(regexp: String) -> String {
+    lazy_static! {
+        static ref FOUR_CHARS_ESCAPE_SEQUENCE: Regex = Regex::new(r"\\u\{([0-9a-f]{4})\}").unwrap();
+        static ref FIVE_CHARS_ESCAPE_SEQUENCE: Regex = Regex::new(r"\\u\{([0-9a-f]{5})\}").unwrap();
+    }
+    let mut replacement = FOUR_CHARS_ESCAPE_SEQUENCE
+        .replace_all(&regexp, |caps: &Captures| format!("\\u{}", &caps[1]))
+        .to_string();
+
+    replacement = FIVE_CHARS_ESCAPE_SEQUENCE
+        .replace_all(&replacement, |caps: &Captures| {
+            format!("\\U000{}", &caps[1])
+        })
+        .to_string();
+
+    replacement
+}


Test patch:
diff --git a/tests/python/test_grex.py b/tests/python/test_grex.py
new file mode 100644
index 0000000..7a4699b
--- /dev/null
+++ b/tests/python/test_grex.py
@@ -0,0 +1,340 @@
+#
+# Copyright © 2019-today Peter M. Stahl pemistahl@gmail.com
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either expressed or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import inspect
+import pytest
+import re
+
+from grex import RegExpBuilder
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["abc", "abd", "abe"], "^ab[c-e]$"),
+    ]
+)
+def test_default_settings(test_cases, expected_pattern):
+    pattern = RegExpBuilder.from_test_cases(test_cases).build()
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["My ♥ and 💩 is yours."], "^My \\u2665 and \\U0001f4a9 is yours\\.$"),
+    ]
+)
+def test_escaping(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_escaping_of_non_ascii_chars(use_surrogate_pairs=False)
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["My ♥ and 💩 is yours."], "^My \\u2665 and \\ud83d\\udca9 is yours\\.$"),
+    ]
+)
+def test_escaping_with_surrogate_pairs(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_escaping_of_non_ascii_chars(use_surrogate_pairs=True)
+               .build())
+    assert pattern == expected_pattern
+    # module re does not support matching surrogate pairs
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["efgh", "abcxy", "abcw"], "^(abc(xy|w)|efgh)$"),
+    ]
+)
+def test_capturing_groups(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_capturing_groups()
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["efgh", "abcxy", "abcw"], "(?:abc(?:xy|w)|efgh)"),
+    ]
+)
+def test_without_anchors(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .without_anchors()
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["ABC", "zBC", "abc", "AbC", "aBc"], "(?i)^[az]bc$"),
+    ]
+)
+def test_case_insensitive_matching(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_case_insensitive_matching()
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(
+            ["[a-z]", "(d,e,f)"],
+            inspect.cleandoc("""
+                (?x)
+                ^
+                  (?:
+                    \\(d,e,f\\)
+                    |
+                    \\[a\\-z\\]
+                  )
+                $
+                """)
+        ),
+    ]
+)
+def test_verbose_mode(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_verbose_mode()
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(
+            ["Ä@Ö€Ü", "ä@ö€ü", "Ä@ö€Ü", "ä@Ö€ü"],
+            inspect.cleandoc("""
+                (?ix)
+                ^
+                  ä@ö€ü
+                $
+                """)
+        )
+    ]
+)
+def test_case_insensitive_matching_and_verbose_mode(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_case_insensitive_matching()
+               .with_verbose_mode()
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["a", "b\nx\nx", "c"], "^(?:b(?:\\nx){2}|[ac])$"),
+    ]
+)
+def test_conversion_of_repetitions(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_conversion_of_repetitions()
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["My ♥♥♥ and 💩💩 is yours."], "^My \\u2665{3} and \\U0001f4a9{2} is yours\\.$"),
+    ]
+)
+def test_escaping_and_conversion_of_repetitions(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_escaping_of_non_ascii_chars(use_surrogate_pairs=False)
+               .with_conversion_of_repetitions()
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["a1b2c3"], "^a\\db\\dc\\d$"),
+    ]
+)
+def test_conversion_of_digits(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_conversion_of_digits()
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["a1b2c3"], "^\\D1\\D2\\D3$"),
+    ]
+)
+def test_conversion_of_non_digits(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_conversion_of_non_digits()
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["\n\t", "\r"], "^\\s(?:\\s)?$"),
+    ]
+)
+def test_conversion_of_whitespace(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_conversion_of_whitespace()
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["a1 b2 c3"], "^\\S\\S \\S\\S \\S\\S$"),
+    ]
+)
+def test_conversion_of_non_whitespace(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_conversion_of_non_whitespace()
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["abc", "1234"], "^\\w\\w\\w(?:\\w)?$"),
+    ]
+)
+def test_conversion_of_words(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_conversion_of_words()
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["abc 1234"], "^abc\\W1234$"),
+    ]
+)
+def test_conversion_of_non_words(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_conversion_of_non_words()
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["aababab"], "^aababab$"),
+        pytest.param(["aabababab"], "^a(?:ab){4}$")
+    ]
+)
+def test_minimum_repetitions(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_conversion_of_repetitions()
+               .with_minimum_repetitions(3)
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+@pytest.mark.parametrize(
+    "test_cases,expected_pattern",
+    [
+        pytest.param(["ababab"], "^ababab$"),
+        pytest.param(["abcabcabc"], "^(?:abc){3}$")
+    ]
+)
+def test_minimum_substring_length(test_cases, expected_pattern):
+    pattern = (RegExpBuilder.from_test_cases(test_cases)
+               .with_conversion_of_repetitions()
+               .with_minimum_substring_length(3)
+               .build())
+    assert pattern == expected_pattern
+    for test_case in test_cases:
+        assert re.match(pattern, test_case)
+
+
+def test_error_for_empty_test_cases():
+    with pytest.raises(ValueError) as exception_info:
+        RegExpBuilder.from_test_cases([])
+    assert (
+        exception_info.value.args[0] ==
+        "No test cases have been provided for regular expression generation"
+    )
+
+
+def test_error_for_invalid_minimum_repetitions():
+    with pytest.raises(ValueError) as exception_info:
+        RegExpBuilder.from_test_cases(["abcd"]).with_minimum_repetitions(-4)
+    assert (
+        exception_info.value.args[0] ==
+        "Quantity of minimum repetitions must be greater than zero"
+    )
+
+
+def test_error_for_invalid_minimum_substring_length():
+    with pytest.raises(ValueError) as exception_info:
+        RegExpBuilder.from_test_cases(["abcd"]).with_minimum_substring_length(-2)
+    assert (
+        exception_info.value.args[0] ==
+        "Minimum substring length must be greater than zero"
+    )