initial commit
This commit is contained in:
136
tap-personio/.gitignore
vendored
Normal file
136
tap-personio/.gitignore
vendored
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Secrets and internal config files
|
||||||
|
**/.secrets/*
|
||||||
|
|
||||||
|
# Ignore meltano internal cache and sqlite systemdb
|
||||||
|
|
||||||
|
.meltano/
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
47
tap-personio/.pre-commit-config.yaml
Normal file
47
tap-personio/.pre-commit-config.yaml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
ci:
|
||||||
|
autofix_prs: true
|
||||||
|
autoupdate_schedule: weekly
|
||||||
|
autoupdate_commit_msg: 'chore: pre-commit autoupdate'
|
||||||
|
skip:
|
||||||
|
- uv-lock
|
||||||
|
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v5.0.0
|
||||||
|
hooks:
|
||||||
|
- id: check-json
|
||||||
|
exclude: |
|
||||||
|
(?x)^(
|
||||||
|
\.vscode/.*\.json
|
||||||
|
)$
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-yaml
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: trailing-whitespace
|
||||||
|
|
||||||
|
- repo: https://github.com/python-jsonschema/check-jsonschema
|
||||||
|
rev: 0.33.0
|
||||||
|
hooks:
|
||||||
|
- id: check-dependabot
|
||||||
|
- id: check-github-workflows
|
||||||
|
- id: check-meltano
|
||||||
|
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.11.11
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix, --exit-non-zero-on-fix, --show-fixes]
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.15.0
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
additional_dependencies:
|
||||||
|
- types-requests
|
||||||
|
|
||||||
|
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||||
|
rev: 0.7.8
|
||||||
|
hooks:
|
||||||
|
- id: uv-lock
|
||||||
|
- id: uv-sync
|
||||||
10
tap-personio/.secrets/.gitignore
vendored
Normal file
10
tap-personio/.secrets/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# IMPORTANT! This folder is hidden from git - if you need to store config files or other secrets,
|
||||||
|
# make sure those are never staged for commit into your git repo. You can store them here or another
|
||||||
|
# secure location.
|
||||||
|
#
|
||||||
|
# Note: This may be redundant with the global .gitignore for, and is provided
|
||||||
|
# for redundancy. If the `.secrets` folder is not needed, you may delete it
|
||||||
|
# from the project.
|
||||||
|
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
20
tap-personio/.vscode/launch.json
vendored
Normal file
20
tap-personio/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "tap-personio",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"program": "tap_personio",
|
||||||
|
"justMyCode": false,
|
||||||
|
"args": [
|
||||||
|
"--config",
|
||||||
|
".secrets/config.json",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
136
tap-personio/README.md
Normal file
136
tap-personio/README.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# tap-personio
|
||||||
|
|
||||||
|
`tap-personio` is a Singer tap for Personio.
|
||||||
|
|
||||||
|
Built with the [Meltano Tap SDK](https://sdk.meltano.com) for Singer Taps.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
|
||||||
|
Developer TODO: Update the below as needed to correctly describe the install procedure. For instance, if you do not have a PyPI repo, or if you want users to directly install from your git repo, you can modify this step as appropriate.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install from PyPI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pipx install tap-personio
|
||||||
|
```
|
||||||
|
|
||||||
|
Install from GitHub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pipx install git+https://github.com/ORG_NAME/tap-personio.git@main
|
||||||
|
```
|
||||||
|
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Accepted Config Options
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Developer TODO: Provide a list of config options accepted by the tap.
|
||||||
|
|
||||||
|
This section can be created by copy-pasting the CLI output from:
|
||||||
|
|
||||||
|
```
|
||||||
|
tap-personio --about --format=markdown
|
||||||
|
```
|
||||||
|
-->
|
||||||
|
|
||||||
|
A full list of supported settings and capabilities for this
|
||||||
|
tap is available by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tap-personio --about
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configure using environment variables
|
||||||
|
|
||||||
|
This Singer tap will automatically import any environment variables within the working directory's
|
||||||
|
`.env` if the `--config=ENV` is provided, such that config values will be considered if a matching
|
||||||
|
environment variable is set either in the terminal context or in the `.env` file.
|
||||||
|
|
||||||
|
### Source Authentication and Authorization
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Developer TODO: If your tap requires special access on the source system, or any special authentication requirements, provide those here.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
You can easily run `tap-personio` by itself or in a pipeline using [Meltano](https://meltano.com/).
|
||||||
|
|
||||||
|
### Executing the Tap Directly
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tap-personio --version
|
||||||
|
tap-personio --help
|
||||||
|
tap-personio --config CONFIG --discover > ./catalog.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developer Resources
|
||||||
|
|
||||||
|
Follow these instructions to contribute to this project.
|
||||||
|
|
||||||
|
### Initialize your Development Environment
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
|
||||||
|
- Python 3.9+
|
||||||
|
- [uv](https://docs.astral.sh/uv/)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create and Run Tests
|
||||||
|
|
||||||
|
Create tests within the `tests` subfolder and
|
||||||
|
then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also test the `tap-personio` CLI interface directly using `uv run`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tap-personio --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing with [Meltano](https://www.meltano.com)
|
||||||
|
|
||||||
|
_**Note:** This tap will work in any Singer environment and does not require Meltano.
|
||||||
|
Examples here are for convenience and to streamline end-to-end orchestration scenarios._
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Developer TODO:
|
||||||
|
Your project comes with a custom `meltano.yml` project file already created. Open the `meltano.yml` and follow any "TODO" items listed in
|
||||||
|
the file.
|
||||||
|
-->
|
||||||
|
|
||||||
|
Next, install Meltano (if you haven't already) and any needed plugins:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install meltano
|
||||||
|
pipx install meltano
|
||||||
|
# Initialize meltano within this directory
|
||||||
|
cd tap-personio
|
||||||
|
meltano install
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can test and orchestrate using Meltano:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test invocation:
|
||||||
|
meltano invoke tap-personio --version
|
||||||
|
|
||||||
|
# OR run a test ELT pipeline:
|
||||||
|
meltano run tap-personio target-jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
### SDK Dev Guide
|
||||||
|
|
||||||
|
See the [dev guide](https://sdk.meltano.com/en/latest/dev_guide.html) for more instructions on how to use the SDK to
|
||||||
|
develop your own taps and targets.
|
||||||
49
tap-personio/meltano.yml
Normal file
49
tap-personio/meltano.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
version: 1
|
||||||
|
send_anonymous_usage_stats: true
|
||||||
|
project_id: "tap-personio"
|
||||||
|
default_environment: test
|
||||||
|
venv:
|
||||||
|
backend: uv
|
||||||
|
environments:
|
||||||
|
- name: test
|
||||||
|
plugins:
|
||||||
|
extractors:
|
||||||
|
- name: "tap-personio"
|
||||||
|
namespace: "tap_personio"
|
||||||
|
pip_url: -e .
|
||||||
|
capabilities:
|
||||||
|
- state
|
||||||
|
- catalog
|
||||||
|
- discover
|
||||||
|
- about
|
||||||
|
- stream-maps
|
||||||
|
|
||||||
|
# TODO: Declare settings and their types here:
|
||||||
|
settings:
|
||||||
|
- name: username
|
||||||
|
label: Username
|
||||||
|
description: The username to use for authentication
|
||||||
|
|
||||||
|
- name: password
|
||||||
|
kind: password
|
||||||
|
label: Password
|
||||||
|
description: The password to use for authentication
|
||||||
|
sensitive: true
|
||||||
|
|
||||||
|
- name: start_date
|
||||||
|
kind: date_iso8601
|
||||||
|
label: Start Date
|
||||||
|
description: Initial date to start extracting data from
|
||||||
|
|
||||||
|
# TODO: Declare required settings here:
|
||||||
|
settings_group_validation:
|
||||||
|
- [username, password]
|
||||||
|
|
||||||
|
# TODO: Declare default configuration values here:
|
||||||
|
config:
|
||||||
|
start_date: '2010-01-01T00:00:00Z'
|
||||||
|
|
||||||
|
loaders:
|
||||||
|
- name: target-jsonl
|
||||||
|
variant: andyh1203
|
||||||
|
pip_url: target-jsonl
|
||||||
4
tap-personio/output/.gitignore
vendored
Normal file
4
tap-personio/output/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# This directory is used as a target by target-jsonl, so ignore all files
|
||||||
|
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
72
tap-personio/pyproject.toml
Normal file
72
tap-personio/pyproject.toml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
[project]
|
||||||
|
name = "tap-personio"
|
||||||
|
version = "0.0.1"
|
||||||
|
description = "Singer tap for Personio, built with the Meltano Singer SDK."
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [{ name = "Jeroen Vandensteen", email = "jeroen@hrlakehouse.com" }]
|
||||||
|
keywords = [
|
||||||
|
"ELT",
|
||||||
|
"Personio",
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
]
|
||||||
|
license-files = [ "LICENSE" ]
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
dependencies = [
|
||||||
|
"singer-sdk~=0.46.4",
|
||||||
|
"requests~=2.32.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
s3 = [
|
||||||
|
"s3fs~=2025.5.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
# CLI declaration
|
||||||
|
tap-personio = 'tap_personio.tap:TapPersonio.cli'
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
{ include-group = "test" },
|
||||||
|
]
|
||||||
|
test = [
|
||||||
|
"pytest>=8",
|
||||||
|
"singer-sdk[testing]",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
addopts = [
|
||||||
|
"--durations=10",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
warn_unused_configs = true
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py39"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
ignore = [
|
||||||
|
"COM812", # missing-trailing-comma
|
||||||
|
]
|
||||||
|
select = ["ALL"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-annotations]
|
||||||
|
allow-star-arg-any = true
|
||||||
|
|
||||||
|
[tool.ruff.lint.pydocstyle]
|
||||||
|
convention = "google"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = [
|
||||||
|
"hatchling>=1,<2",
|
||||||
|
]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
1
tap-personio/tap_personio/__init__.py
Normal file
1
tap-personio/tap_personio/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Tap for Personio."""
|
||||||
7
tap-personio/tap_personio/__main__.py
Normal file
7
tap-personio/tap_personio/__main__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""Personio entry point."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tap_personio.tap import TapPersonio
|
||||||
|
|
||||||
|
TapPersonio.cli()
|
||||||
44
tap-personio/tap_personio/auth.py
Normal file
44
tap-personio/tap_personio/auth.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Personio Authentication."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from singer_sdk.authenticators import OAuthAuthenticator, SingletonMeta
|
||||||
|
|
||||||
|
|
||||||
|
# The SingletonMeta metaclass makes your streams reuse the same authenticator instance.
|
||||||
|
# If this behaviour interferes with your use-case, you can remove the metaclass.
|
||||||
|
class PersonioAuthenticator(OAuthAuthenticator, metaclass=SingletonMeta):
|
||||||
|
"""Authenticator class for Personio."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def oauth_request_body(self) -> dict:
|
||||||
|
"""Define the OAuth request body for the AutomaticTestTap API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with the request body
|
||||||
|
"""
|
||||||
|
# TODO: Define the request body needed for the API.
|
||||||
|
return {
|
||||||
|
"resource": "https://analysis.windows.net/powerbi/api",
|
||||||
|
"scope": self.oauth_scopes,
|
||||||
|
"client_id": self.config["client_id"],
|
||||||
|
"username": self.config["username"],
|
||||||
|
"password": self.config["password"],
|
||||||
|
"grant_type": "password",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_for_stream(cls, stream) -> PersonioAuthenticator: # noqa: ANN001
|
||||||
|
"""Instantiate an authenticator for a specific Singer stream.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stream: The Singer stream instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A new authenticator.
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
stream=stream,
|
||||||
|
auth_endpoint="TODO: OAuth Endpoint URL",
|
||||||
|
oauth_scopes="TODO: OAuth Scopes",
|
||||||
|
)
|
||||||
174
tap-personio/tap_personio/client.py
Normal file
174
tap-personio/tap_personio/client.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""REST client handling, including PersonioStream base class."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import decimal
|
||||||
|
import typing as t
|
||||||
|
from functools import cached_property
|
||||||
|
from importlib import resources
|
||||||
|
|
||||||
|
from singer_sdk.helpers.jsonpath import extract_jsonpath
|
||||||
|
from singer_sdk.pagination import BaseAPIPaginator # noqa: TC002
|
||||||
|
from singer_sdk.streams import RESTStream
|
||||||
|
|
||||||
|
from tap_personio.auth import PersonioAuthenticator
|
||||||
|
|
||||||
|
from singer_sdk.pagination import BaseHATEOASPaginator, first
|
||||||
|
|
||||||
|
|
||||||
|
if t.TYPE_CHECKING:
|
||||||
|
import requests
|
||||||
|
from singer_sdk.helpers.types import Auth, Context
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Delete this is if not using json files for schema definition
|
||||||
|
SCHEMAS_DIR = resources.files(__package__) / "schemas"
|
||||||
|
|
||||||
|
|
||||||
|
class MyPaginator(BaseHATEOASPaginator):
|
||||||
|
def get_next_url(self, response):
|
||||||
|
|
||||||
|
try:
|
||||||
|
return first(
|
||||||
|
extract_jsonpath("$._meta.links.next.href", response.json())
|
||||||
|
)
|
||||||
|
except StopIteration:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonioStream(RESTStream):
|
||||||
|
"""Personio stream class."""
|
||||||
|
|
||||||
|
# Limit the number of results per page
|
||||||
|
# Max 50 according to Personio API documentation.
|
||||||
|
RESULTS_PER_PAGE = 50
|
||||||
|
|
||||||
|
NEXT_PAGE_JSONPATH = "$.cursor"
|
||||||
|
|
||||||
|
# Update this value if necessary or override `parse_response`.
|
||||||
|
records_jsonpath = "$._data[*]"
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url_base(self) -> str:
|
||||||
|
"""Return the API URL root, configurable via tap settings."""
|
||||||
|
# TODO: hardcode a value here, or retrieve it from self.config
|
||||||
|
return "https://api.personio.de/v1"
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def authenticator(self) -> Auth:
|
||||||
|
"""Return a new authenticator object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An authenticator instance.
|
||||||
|
"""
|
||||||
|
return PersonioAuthenticator.create_for_stream(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def http_headers(self) -> dict:
|
||||||
|
"""Return the http headers needed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary of HTTP headers.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'X-Personio-Partner-ID': self.config.get("partner_id", ""),
|
||||||
|
'X-Personio-App-ID': self.config.get("app_id", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_new_paginator(self) -> BaseAPIPaginator:
|
||||||
|
"""Create a new pagination helper instance.
|
||||||
|
|
||||||
|
If the source API can make use of the `next_page_token_jsonpath`
|
||||||
|
attribute, or it contains a `X-Next-Page` header in the response
|
||||||
|
then you can remove this method.
|
||||||
|
|
||||||
|
If you need custom pagination that uses page numbers, "next" links, or
|
||||||
|
other approaches, please read the guide: https://sdk.meltano.com/en/v0.25.0/guides/pagination-classes.html.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A pagination helper instance.
|
||||||
|
"""
|
||||||
|
return MyPaginator()
|
||||||
|
|
||||||
|
def get_url_params(
|
||||||
|
self,
|
||||||
|
context: Context | None, # noqa: ARG002
|
||||||
|
next_page_token: t.Any | None, # noqa: ANN401
|
||||||
|
) -> dict[str, t.Any]:
|
||||||
|
"""Return a dictionary of values to be used in URL parameterization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: The stream context.
|
||||||
|
next_page_token: The next page index or value.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary of URL query parameters.
|
||||||
|
"""
|
||||||
|
params: dict = {}
|
||||||
|
|
||||||
|
# Next page token is a URL, so we can to parse it to extract the query string
|
||||||
|
if next_page_token:
|
||||||
|
params.update(parse_qsl(next_page_token.query))
|
||||||
|
|
||||||
|
# Set the results limit
|
||||||
|
params["limit"] = self.RESULTS_PER_PAGE
|
||||||
|
|
||||||
|
# No sorting support for Personio API, so commented this out.
|
||||||
|
#if self.replication_key:
|
||||||
|
# params["sort"] = "asc"
|
||||||
|
# params["order_by"] = self.replication_key
|
||||||
|
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
def prepare_request_payload(
|
||||||
|
self,
|
||||||
|
context: Context | None, # noqa: ARG002
|
||||||
|
next_page_token: t.Any | None, # noqa: ARG002, ANN401
|
||||||
|
) -> dict | None:
|
||||||
|
"""Prepare the data payload for the REST API request.
|
||||||
|
|
||||||
|
By default, no payload will be sent (return None).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: The stream context.
|
||||||
|
next_page_token: The next page index or value.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary with the JSON body for a POST requests.
|
||||||
|
"""
|
||||||
|
# TODO: Delete this method if no payload is required. (Most REST APIs.)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_response(self, response: requests.Response) -> t.Iterable[dict]:
|
||||||
|
"""Parse the response and return an iterator of result records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response: The HTTP ``requests.Response`` object.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Each record from the source.
|
||||||
|
"""
|
||||||
|
# TODO: Parse response body and return a set of records.
|
||||||
|
yield from extract_jsonpath(
|
||||||
|
self.records_jsonpath,
|
||||||
|
input=response.json(parse_float=decimal.Decimal),
|
||||||
|
)
|
||||||
|
|
||||||
|
def post_process(
|
||||||
|
self,
|
||||||
|
row: dict,
|
||||||
|
context: Context | None = None, # noqa: ARG002
|
||||||
|
) -> dict | None:
|
||||||
|
"""As needed, append or transform raw data to match expected structure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
row: An individual record from the stream.
|
||||||
|
context: The stream context.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated record dictionary, or ``None`` to skip the record.
|
||||||
|
"""
|
||||||
|
# TODO: Delete this method if not needed.
|
||||||
|
return row
|
||||||
1
tap-personio/tap_personio/schemas/__init__.py
Normal file
1
tap-personio/tap_personio/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""JSON schema files for the REST API."""
|
||||||
156
tap-personio/tap_personio/schemas/employments.json
Normal file
156
tap-personio/tap_personio/schemas/employments.json
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"enum": ["ACTIVE", "INACTIVE", "ONBOARDING", "LEAVE", "UNSPECIFIED"]
|
||||||
|
},
|
||||||
|
"weekly_working_hours": {
|
||||||
|
"type": ["integer", "null"]
|
||||||
|
},
|
||||||
|
"full_time_weekly_working_hours": {
|
||||||
|
"type": ["integer", "null"]
|
||||||
|
},
|
||||||
|
"probation_end_date": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"employment_start_date": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"employment_end_date": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"enum": ["INTERNAL", "EXTERNAL", "UNSPECIFIED"]
|
||||||
|
},
|
||||||
|
"contract_end_date": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"position": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"supervisor": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"office": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"legal_entity": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"description": "Identifier of the legal entity"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["id"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"org_units": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cost_centers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"weight": {
|
||||||
|
"type": ["integer", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"person": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"termination": {
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"properties": {
|
||||||
|
"termination_date": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"last_working_date": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"terminated_at": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"enum": [
|
||||||
|
"UNSPECIFIED",
|
||||||
|
"EMPLOYEE",
|
||||||
|
"FIRED",
|
||||||
|
"DEATH",
|
||||||
|
"CONTRACT_EXPIRED",
|
||||||
|
"AGREEMENT",
|
||||||
|
"SUB_COMPANY_SWITCH",
|
||||||
|
"IRREVOCABLE_SUSPENSION",
|
||||||
|
"CANCELLATION",
|
||||||
|
"COLLECTIVE_AGREEMENT",
|
||||||
|
"SETTLEMENT_AGREEMENT",
|
||||||
|
"RETIREMENT",
|
||||||
|
"COURT_SETTLEMENT",
|
||||||
|
"QUIT"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
203
tap-personio/tap_personio/schemas/legal-entities.json
Normal file
203
tap-personio/tap_personio/schemas/legal-entities.json
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["ACTIVE", "INACTIVE", "SUSPENDED", "DELETED"]
|
||||||
|
},
|
||||||
|
"is_main": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"valid_from": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date",
|
||||||
|
"description": "Date from which this record is valid"
|
||||||
|
},
|
||||||
|
"assigned_employees": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"active": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Number of active employees"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Total number of employees"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["active", "total"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Z]{2}$",
|
||||||
|
"description": "Country code in ISO 3166-1 alpha-2 format"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Legal name of the company"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["GMBH", "AG", "UG", "KG", "OHG", "EV", "VV"],
|
||||||
|
"description": "Legal form of the company"
|
||||||
|
},
|
||||||
|
"registration_number": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Official registration number of the company"
|
||||||
|
},
|
||||||
|
"industry_sector": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"COMPUTER_SOFTWARE",
|
||||||
|
"MANUFACTURING",
|
||||||
|
"FINANCIAL_SERVICES",
|
||||||
|
"HEALTHCARE",
|
||||||
|
"RETAIL",
|
||||||
|
"TELECOMMUNICATIONS",
|
||||||
|
"CONSTRUCTION",
|
||||||
|
"EDUCATION"
|
||||||
|
],
|
||||||
|
"description": "Primary industry sector of the company"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email",
|
||||||
|
"description": "Primary email address of the company"
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Primary phone number of the company"
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"street_name",
|
||||||
|
"house_number",
|
||||||
|
"postal_code",
|
||||||
|
"city"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"street_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the street"
|
||||||
|
},
|
||||||
|
"house_number": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "House/building number"
|
||||||
|
},
|
||||||
|
"postal_code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Postal/ZIP code"
|
||||||
|
},
|
||||||
|
"city": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "City name"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "State or region code"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"contact_person": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"salutation": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["MR", "MRS", "MS", "DR", "PROF"],
|
||||||
|
"description": "Salutation of the contact person"
|
||||||
|
},
|
||||||
|
"full_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Full name of the contact person"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email",
|
||||||
|
"description": "Email address of the contact person"
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Phone number of the contact person"
|
||||||
|
},
|
||||||
|
"fax": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Fax number of the contact person"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["full_name"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"bank_details": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"iban": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Z]{2}[0-9]{2}[a-zA-Z0-9]{1,30}$",
|
||||||
|
"description": "International Bank Account Number"
|
||||||
|
},
|
||||||
|
"bic": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$",
|
||||||
|
"description": "Bank Identifier Code"
|
||||||
|
},
|
||||||
|
"account_holder": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the account holder"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["iban", "bic", "account_holder"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"mailing_address": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"street_name",
|
||||||
|
"house_number",
|
||||||
|
"postal_code",
|
||||||
|
"city",
|
||||||
|
"address_type"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"street_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the street"
|
||||||
|
},
|
||||||
|
"house_number": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "House/building number"
|
||||||
|
},
|
||||||
|
"postal_code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Postal/ZIP code"
|
||||||
|
},
|
||||||
|
"city": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "City name"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name for this address (e.g., 'Headquarters')"
|
||||||
|
},
|
||||||
|
"additional_info": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Additional address information"
|
||||||
|
},
|
||||||
|
"address_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["REGULAR_ADDRESS", "PO_BOX", "PACKSTATION"],
|
||||||
|
"description": "Type of mailing address"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
141
tap-personio/tap_personio/schemas/org-units.json
Normal file
141
tap-personio/tap_personio/schemas/org-units.json
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"enum": ["ACTIVE", "OUT_OF_BUSINESS"]
|
||||||
|
},
|
||||||
|
"is_main": {
|
||||||
|
"type": ["boolean", "null"]
|
||||||
|
},
|
||||||
|
"valid_from": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"assigned_employees": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"active": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"pattern": "^[A-Z]{2}$"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"registration_number": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"industry_sector": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"enum": ["ACCOUNTING_AND_AUDITING_SERVICES", "ADVERTISING_AND_PR_SERVICES", "AEROSPACE", "AGRICULTURE", "ARCHITECTURAL", "AUTOMOTIVE", "AUTOMOTIVE_PARTS", "BANKING", "BIOTECHNOLOGY", "BROADCASTING", "BUSINESS_SERVICES", "CHEMICALS", "CLOTHING", "COMPUTER_HARDWARE", "COMPUTER_SERVICES", "COMPUTER_SOFTWARE", "CONSTRUCTION", "CONSTRUCTION_RESIDENTIAL", "EDUCATION", "ELECTRONICS", "ENERGY", "ENGINEERING_SERVICES", "ENTERTAINMENT_VENUES", "FINANCIAL_SERVICES", "FOOD_AND_BEVERAGE", "GOVERNMENT", "HEALTHCARE_SERVICES", "HOTELS", "INSURANCE", "INTERNET", "LEGAL_SERVICES", "MANAGEMENT", "MANUFACTURING", "MARINE_MFG", "MEDICAL_DEVICES", "METALS", "NONPROFIT", "OTHER", "PERFORMING", "PERSONAL", "PERSONAL_CARE", "PRINTING", "REAL_ESTATE", "RENTAL_SERVICES", "RESTAURANT", "RETAIL", "SECURITY", "SPORTS", "STAFFING", "TELECOMMUNICATIONS", "TRADE_EXPORT", "TRANSPORT", "TRAVEL", "WASTE_MANAGEMENT"]
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"street_name": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"house_number": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"postal_code": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"city": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contact_person": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"salutation": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"enum": ["MR", "MRS", "MS", "DR", "PROF"]
|
||||||
|
},
|
||||||
|
"full_name": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"fax": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bank_details": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"iban": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"bic": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"account_holder": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mailing_address": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"street_name": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"house_number": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"postal_code": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"city": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"additional_info": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"address_type": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"enum": ["REGULAR_ADDRESS", "PO_BOX", "EPOST", "FOREIGN_ADDRESS"]
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"po_box_number": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
tap-personio/tap_personio/schemas/persons.json
Normal file
55
tap-personio/tap_personio/schemas/persons.json
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"first_name": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"last_name": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"preferred_name": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"gender": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"enum": ["UNSPECIFIED", "MALE", "FEMALE", "DIVERSE"]
|
||||||
|
},
|
||||||
|
"profile_picture": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"enum": ["ACTIVE", "INACTIVE", "UNSPECIFIED"]
|
||||||
|
},
|
||||||
|
"employments": {
|
||||||
|
"type": ["array", "null"],
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
120
tap-personio/tap_personio/streams.py
Normal file
120
tap-personio/tap_personio/streams.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""Stream type classes for tap-personio."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typing as t
|
||||||
|
from importlib import resources
|
||||||
|
|
||||||
|
from singer_sdk import typing as th # JSON Schema typing helpers
|
||||||
|
|
||||||
|
from tap_personio.client import PersonioStream
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
SCHEMAS_DIR = resources.files(__package__) / "schemas"
|
||||||
|
|
||||||
|
class PersonsStream(PersonioStream):
|
||||||
|
name = "persons"
|
||||||
|
path = "/v2/persons"
|
||||||
|
primary_keys = ["id"]
|
||||||
|
replication_key = "updated_at"
|
||||||
|
is_sorted = False
|
||||||
|
schema_filepath = SCHEMAS_DIR / "persons.json"
|
||||||
|
|
||||||
|
|
||||||
|
def get_child_context(self, record: dict, context: Optional[dict]) -> dict:
|
||||||
|
"""Return a context dictionary for child streams."""
|
||||||
|
return {
|
||||||
|
"person_id": record["id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmploymentsStream(PersonioStream):
|
||||||
|
name = "employments"
|
||||||
|
path = "/v2/persons/{person_id}/employments"
|
||||||
|
primary_keys = ["id"]
|
||||||
|
replication_key = "updated_at"
|
||||||
|
is_sorted = False
|
||||||
|
schema_filepath = SCHEMAS_DIR / "employments.json"
|
||||||
|
|
||||||
|
# EmploymentsStream should be invoked once per parent Person
|
||||||
|
parent_stream_type = PersonsStream
|
||||||
|
|
||||||
|
# Assume employments don't have "updated_at" incremented when employments are changed
|
||||||
|
ignore_parent_replication_keys = True
|
||||||
|
|
||||||
|
|
||||||
|
# Getting the Org Units, based on the IDs received from the EmploymentsStream
|
||||||
|
# Also fetches the parent chain for each org unit, to ensure we have the full hierarchy
|
||||||
|
class OrgUnitStream(PersonioStream):
|
||||||
|
name = "org-units"
|
||||||
|
path = "/v2/org-units/{id}"
|
||||||
|
schema_filepath = SCHEMAS_DIR / "org-units.json"
|
||||||
|
primary_keys = ["id"]
|
||||||
|
|
||||||
|
def get_url_params(self, context, next_page_token):
|
||||||
|
"""Add the include_parent_chain parameter to all requests"""
|
||||||
|
params = super().get_url_params(context, next_page_token)
|
||||||
|
params["include_parent_chain"] = "true"
|
||||||
|
return params
|
||||||
|
|
||||||
|
def __init__(self, tap):
|
||||||
|
super().__init__(tap)
|
||||||
|
self._org_unit_ids = None
|
||||||
|
|
||||||
|
def get_records(self, context):
|
||||||
|
initial_org_unit_ids = self._get_required_org_unit_ids()
|
||||||
|
processed_ids = set()
|
||||||
|
|
||||||
|
for org_unit_id in initial_org_unit_ids:
|
||||||
|
try:
|
||||||
|
response_data = self._fetch_org_unit(org_unit_id)
|
||||||
|
|
||||||
|
# Extract and yield all org units from this response
|
||||||
|
yield from self._extract_org_units(response_data, processed_ids)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to fetch org unit {org_unit_id}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _extract_org_units(self, response_data, processed_ids):
|
||||||
|
"""Extract all org units from a single API response"""
|
||||||
|
all_org_units = []
|
||||||
|
|
||||||
|
# Add the main org unit
|
||||||
|
main_org_unit = {k: v for k, v in response_data.items() if k != "parent_chain"}
|
||||||
|
all_org_units.append(main_org_unit)
|
||||||
|
|
||||||
|
# Add all parent org units
|
||||||
|
all_org_units.extend(response_data.get("parent_chain", []))
|
||||||
|
|
||||||
|
# Yield only unique org units
|
||||||
|
for org_unit in all_org_units:
|
||||||
|
org_unit_id = org_unit["id"]
|
||||||
|
if org_unit_id not in processed_ids:
|
||||||
|
processed_ids.add(org_unit_id)
|
||||||
|
yield org_unit
|
||||||
|
|
||||||
|
def _fetch_org_unit(self, org_unit_id):
|
||||||
|
url = self.get_url({"id": org_unit_id})
|
||||||
|
response = self.request_decorator(self._request)(url)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def _get_required_org_unit_ids(self):
|
||||||
|
# This could read from tap state, a file, or re-scan employment data
|
||||||
|
employment_stream = EmploymentsStream(self._tap)
|
||||||
|
org_unit_ids = set()
|
||||||
|
|
||||||
|
for record in employment_stream.get_records(None):
|
||||||
|
if record.get('org_unit_id'):
|
||||||
|
org_unit_ids.add(record['org_unit_id'])
|
||||||
|
|
||||||
|
return org_unit_ids
|
||||||
|
|
||||||
|
|
||||||
|
class LegalEntitiesStream(PersonioStream):
|
||||||
|
name = "legal-entities"
|
||||||
|
path = "/v2/legal-entities"
|
||||||
|
primary_keys = ["id"]
|
||||||
|
replication_key = "updated_at"
|
||||||
|
is_sorted = False
|
||||||
|
schema_filepath = SCHEMAS_DIR / "legal-entities.json"
|
||||||
65
tap-personio/tap_personio/tap.py
Normal file
65
tap-personio/tap_personio/tap.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Personio tap class."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from singer_sdk import Tap
|
||||||
|
from singer_sdk import typing as th # JSON schema typing helpers
|
||||||
|
|
||||||
|
# TODO: Import your custom stream types here:
|
||||||
|
from tap_personio import streams
|
||||||
|
|
||||||
|
|
||||||
|
class TapPersonio(Tap):
|
||||||
|
"""Personio tap class."""
|
||||||
|
|
||||||
|
name = "tap-personio"
|
||||||
|
|
||||||
|
config_jsonschema = th.PropertiesList(
|
||||||
|
th.Property(
|
||||||
|
"client_id",
|
||||||
|
th.StringType,
|
||||||
|
required=True,
|
||||||
|
secret=True,
|
||||||
|
description="The client id to authenticate against the Personio API",
|
||||||
|
),
|
||||||
|
th.Property(
|
||||||
|
"client_secret",
|
||||||
|
th.StringType,
|
||||||
|
required=True,
|
||||||
|
secret=True,
|
||||||
|
description="The client secret to authenticate against the Personio API",
|
||||||
|
),
|
||||||
|
th.Property(
|
||||||
|
"start_date",
|
||||||
|
th.DateTimeType,
|
||||||
|
description="The earliest record date to sync",
|
||||||
|
),
|
||||||
|
th.Property(
|
||||||
|
"partner_id",
|
||||||
|
th.StringType,
|
||||||
|
required=False,
|
||||||
|
default="",
|
||||||
|
description="The partner ID for the Personio API, if applicable",
|
||||||
|
),
|
||||||
|
th.Property(
|
||||||
|
"app_id",
|
||||||
|
th.StringType,
|
||||||
|
required=True,
|
||||||
|
default="",
|
||||||
|
description="The app ID for the Personio API",
|
||||||
|
),
|
||||||
|
).to_dict()
|
||||||
|
|
||||||
|
def discover_streams(self) -> list[streams.PersonioStream]:
|
||||||
|
"""Return a list of discovered streams.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of discovered streams.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
streams.EmployeesStream(self),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
TapPersonio.cli()
|
||||||
1
tap-personio/tests/__init__.py
Normal file
1
tap-personio/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Test suite for tap-personio."""
|
||||||
22
tap-personio/tests/test_core.py
Normal file
22
tap-personio/tests/test_core.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""Tests standard tap features using the built-in SDK tests library."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from singer_sdk.testing import get_tap_test_class
|
||||||
|
|
||||||
|
from tap_personio.tap import TapPersonio
|
||||||
|
|
||||||
|
SAMPLE_CONFIG = {
|
||||||
|
"start_date": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d"),
|
||||||
|
# TODO: Initialize minimal tap config
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Run standard built-in tap tests from the SDK:
|
||||||
|
TestTapPersonio = get_tap_test_class(
|
||||||
|
tap_class=TapPersonio,
|
||||||
|
config=SAMPLE_CONFIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Create additional tests as appropriate for your tap.
|
||||||
15
tap-personio/tox.ini
Normal file
15
tap-personio/tox.ini
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# This file can be used to customize tox tests as well as other test frameworks like flake8 and mypy
|
||||||
|
|
||||||
|
[tox]
|
||||||
|
envlist = py3{9,10,11,12,13}
|
||||||
|
minversion = 4.22
|
||||||
|
requires =
|
||||||
|
tox>=4.22
|
||||||
|
|
||||||
|
[testenv]
|
||||||
|
pass_env =
|
||||||
|
TAP_PERSONIO_*
|
||||||
|
dependency_groups =
|
||||||
|
test
|
||||||
|
commands =
|
||||||
|
pytest {posargs}
|
||||||
1684
tap-personio/uv.lock
generated
Normal file
1684
tap-personio/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user