-
Notifications
You must be signed in to change notification settings - Fork 0
Development
This guide covers local setup, testing, code quality, and contributing to the OneLogin Migration Wizard.
- Python 3.10 or higher (< 3.14)
- pip and virtualenv
- Git for version control
- Clone the repository:
git clone <repository-url>
cd Okta_to_Onelogin- Create and activate virtual environment:
python3 -m venv .venv
source .venv/bin/activate # macOS/Linux
# or
.venv\Scripts\activate # Windows- Install packages:
This project uses a monorepo structure with three packages. Use the provided script to install all packages:
# Install all packages in development mode (recommended)
./scripts/dev-install.shOr install packages individually:
# Core package (required)
pip install -e packages/core
# CLI package
pip install -e packages/cli
# GUI package
pip install -e packages/gui
# With test dependencies
pip install -e "packages/core[test]"
pip install -e "packages/cli[test]"
pip install -e "packages/gui[test]"Note: Editable mode (-e) allows code changes to take effect immediately without reinstalling.
# Check command is available
onelogin-migration --help
# Or via GUI
onelogin-migration-gui
# Verify module imports
python -c "from onelogin_migration_core import manager; print('Core OK')"
python -c "from onelogin_migration_cli import app; print('CLI OK')"
python -c "from onelogin_migration_gui import main; print('GUI OK')"
# Run tests
./scripts/test-all.shOkta_to_Onelogin/ # Monorepo root
├── packages/ # Independent packages
│ ├── core/ # Core migration library
│ │ ├── src/onelogin_migration_core/
│ │ │ ├── __init__.py
│ │ │ ├── clients.py # API clients
│ │ │ ├── config.py # Configuration
│ │ │ ├── config_parser.py # YAML parser
│ │ │ ├── credentials.py # Credential management
│ │ │ ├── secure_settings.py # Settings manager
│ │ │ ├── manager.py # Migration orchestration
│ │ │ ├── exporters.py # Source data export
│ │ │ ├── importers.py # OneLogin import
│ │ │ ├── transformers.py # Field mapping
│ │ │ ├── custom_attributes.py # Attribute provisioning
│ │ │ ├── csv_generator.py # CSV generation
│ │ │ ├── state_manager.py # State persistence
│ │ │ ├── progress.py # Progress tracking
│ │ │ ├── constants.py # Constants
│ │ │ └── db/ # Database management
│ │ │ ├── database_manager.py
│ │ │ ├── connector_db.py # Connector catalog
│ │ │ ├── connector_refresh.py
│ │ │ ├── telemetry.py # Analytics
│ │ │ └── encryption.py
│ │ ├── tests/ # Core tests
│ │ ├── pyproject.toml
│ │ └── README.md
│ ├── cli/ # Command-line interface
│ │ ├── src/onelogin_migration_cli/
│ │ │ ├── __init__.py
│ │ │ ├── app.py # Main CLI
│ │ │ ├── credentials.py # Credentials commands
│ │ │ ├── database.py # Database commands
│ │ │ └── telemetry.py # Telemetry commands
│ │ ├── tests/ # CLI tests
│ │ ├── pyproject.toml
│ │ └── README.md
│ └── gui/ # Graphical interface
│ ├── src/onelogin_migration_gui/
│ │ ├── __init__.py
│ │ ├── main.py # GUI entry point
│ │ ├── components.py # UI components
│ │ ├── theme_manager.py # Theme support
│ │ ├── helpers.py
│ │ ├── steps/ # Wizard steps
│ │ │ ├── base.py
│ │ │ ├── welcome.py
│ │ │ ├── source.py
│ │ │ ├── target.py
│ │ │ ├── options.py
│ │ │ ├── objects.py
│ │ │ ├── analysis.py # Analysis step (NEW)
│ │ │ ├── summary.py
│ │ │ └── progress.py
│ │ ├── dialogs/
│ │ │ └── analysis_detail/ # Analysis dialogs (NEW)
│ │ │ ├── dialog.py
│ │ │ ├── export/ # CSV/XLSX exporters
│ │ │ ├── tables/ # Table managers
│ │ │ └── utils/ # Formatters, validators
│ │ └── styles/ # UI theming
│ ├── tests/ # GUI tests
│ ├── pyproject.toml
│ └── README.md
├── scripts/ # Development scripts
│ ├── dev-install.sh # Install all packages
│ ├── test-all.sh # Run all tests
│ ├── lint-all.sh # Lint all packages
│ ├── format-all.sh # Format all packages
│ └── build_catalog_db.py # Database utilities
├── config/
│ ├── migration.template.yaml # YAML template
│ └── migration.yaml # User config (gitignored)
├── templates/
│ └── user-upload-template.csv
├── tools/ # Build tools
│ ├── build_app.sh # macOS app build
│ └── build_app.bat # Windows app build
├── dist/ # Built applications
├── artifacts/ # Generated exports (gitignored)
└── README.md # Main documentation
# Run tests for all packages
./scripts/test-all.sh
# Run all tests with pytest directly
pytest
# With verbose output
pytest -v
# With coverage report for all packages
pytest --cov=onelogin_migration_core --cov=onelogin_migration_cli --cov=onelogin_migration_gui
# Generate HTML coverage report
pytest --cov=onelogin_migration_core --cov-report=html
# Open htmlcov/index.html in browser# Core package tests
cd packages/core && pytest tests/ -v
# CLI package tests
cd packages/cli && pytest tests/ -v
# GUI package tests
cd packages/gui && pytest tests/ -v# Run single test file
pytest packages/core/tests/test_config.py
# Run specific test function
pytest packages/core/tests/test_config.py::test_load_settings
# Run tests matching pattern
pytest -k "test_cli"
# Run tests for specific package
pytest packages/core/Core Package Tests (packages/core/tests/):
| Test File | Coverage |
|---|---|
test_credentials.py |
Secure credential storage, keyring, vault |
test_secure_settings.py |
Non-sensitive settings manager |
test_config_parser.py |
YAML parsing, credential extraction |
test_clients.py |
API client behavior, rate limiting, pagination |
test_config.py |
Configuration dataclasses and validation |
test_migration.py |
Migration orchestration, state management |
test_provider_abstraction.py |
Multi-provider architecture, SourceClient protocol, deprecation warnings |
test_field_mapper.py |
FieldMapper protocol, OktaFieldMapper, manager delegation |
test_progress.py |
Progress calculation and snapshot updates |
test_database_manager.py |
Database operations and schema |
test_load_connectors.py |
Connector database loading |
CLI Package Tests (packages/cli/tests/):
| Test File | Coverage |
|---|---|
test_cli_entrypoint.py |
CLI command registration |
test_credentials_cli.py |
Credential management commands |
test_database_cli.py |
Database management commands |
test_telemetry_cli.py |
Telemetry commands |
GUI Package Tests (packages/gui/tests/):
| Test File | Coverage |
|---|---|
test_gui_main.py |
GUI initialization and wizard |
test_gui_credentials.py |
Credential save/prefill, multi-provider ProviderSettingsPage
|
test_analysis_step.py |
Analysis step functionality |
test_theme_manager.py |
Theme switching |
Use pytest fixtures and mocking:
import pytest
from unittest.mock import Mock, patch
def test_example():
# Arrange
settings = MigrationSettings(...)
# Act
result = function_under_test(settings)
# Assert
assert result.expected_value == "expected"# Lint all packages
./scripts/lint-all.sh
# Format all packages
./scripts/format-all.sh# Check all packages
ruff check packages/
# Auto-fix issues
ruff check packages/ --fix
# Check specific package
ruff check packages/core/src/# Format all code
black packages/
# Check formatting without changes
black packages/ --check
# Format specific package
black packages/core/src/# Install mypy
pip install mypy
# Run type checking on all packages
mypy packages/core/src/
mypy packages/cli/src/
mypy packages/gui/src/- Create feature branch:
git checkout -b feature/my-new-feature-
Make changes to code in appropriate package:
- Core logic:
packages/core/src/onelogin_migration_core/ - CLI commands:
packages/cli/src/onelogin_migration_cli/ - GUI features:
packages/gui/src/onelogin_migration_gui/
- Core logic:
-
Add tests in corresponding
tests/directory -
Run tests:
pytest- Format and lint:
./scripts/format-all.sh
./scripts/lint-all.sh-
Update documentation in wiki if needed
-
Commit changes:
git add .
git commit -m "Add feature: description"- Push and create PR:
git push origin feature/my-new-featureAdd new source provider:
- Implement
FieldMapperprotocol inpackages/core/src/onelogin_migration_core/field_mapper.pywith provider-specific field transforms - Implement
SourceClientprotocol inpackages/core/src/onelogin_migration_core/clients.py, attach the mapper viafield_mapperproperty, and register in_PROVIDER_REGISTRY - Add provider entry to
SOURCE_PROVIDERSinpackages/gui/src/onelogin_migration_gui/steps/source.py - Add tests covering the new
FieldMapper,SourceClient, andbuild_source_client()dispatch - Update Architecture wiki page with new provider details
Add new CLI command:
- Add command function in
packages/cli/src/onelogin_migration_cli/app.py - Or create new subcommand module (e.g., for
db,telemetry) - Add tests in
packages/cli/tests/ - Update Command-Line wiki page
Add credential management feature:
- Add command to
packages/cli/src/onelogin_migration_cli/credentials.py - Implement logic using credential manager from core package
- Add tests in
packages/cli/tests/test_credentials_cli.py - Update Command-Line wiki page
Add new configuration option:
- For non-sensitive settings: Add field to
NonSensitiveSettingsinpackages/core/src/onelogin_migration_core/secure_settings.py - For credentials: Use
CredentialManagerinpackages/core/src/onelogin_migration_core/credentials.py - Update migration from YAML in
packages/core/src/onelogin_migration_core/config_parser.py - Add tests in
packages/core/tests/ - Update Configuration wiki page
Extend field mapping:
- Modify mapping logic in
packages/core/src/onelogin_migration_core/transformers.py - Update constants in
packages/core/src/onelogin_migration_core/constants.py - Add tests with example data
- Update README field mapping section
Add GUI wizard step:
- Create new step file in
packages/gui/src/onelogin_migration_gui/steps/ - Inherit from
BaseStep - Implement
initUI()andisComplete()methods - Register in wizard navigation in
main.py - Add tests in
packages/gui/tests/ - Update GUI wiki page
Add GUI analysis feature:
- Add table manager in
packages/gui/src/onelogin_migration_gui/dialogs/analysis_detail/tables/ - Add export logic in
packages/gui/src/onelogin_migration_gui/dialogs/analysis_detail/export/ - Update analysis dialog to include new feature
- Add tests for data processing
- Update GUI wiki page
Modify migration workflow:
- Update
packages/core/src/onelogin_migration_core/manager.pyfor orchestration changes - Modify exporters, importers, or transformers in core package
- Update state manager if state tracking changes
- Add comprehensive tests in
packages/core/tests/ - Update Architecture wiki page
Add database feature:
- Add database logic in
packages/core/src/onelogin_migration_core/db/ - Add CLI commands in
packages/cli/src/onelogin_migration_cli/database.py - Update schema if needed
- Add tests in both core and CLI packages
- Update Command-Line and Architecture wiki pages
Each package can be built independently:
# Install build tools
pip install build
# Build core package
cd packages/core && python -m build
# Build CLI package
cd packages/cli && python -m build
# Build GUI package
cd packages/gui && python -m build# macOS application
./tools/build_app.sh
# Windows application
./tools/build_app.bat
# Output in dist/# Install from built wheels
pip install packages/core/dist/onelogin_migration_core-0.2.0-py3-none-any.whl
pip install packages/cli/dist/onelogin_migration_cli-0.2.0-py3-none-any.whl
pip install packages/gui/dist/onelogin_migration_gui-0.2.0-py3-none-any.whlUpdate version in each package's pyproject.toml:
-
Core package (
packages/core/pyproject.toml):
[project]
version = "0.2.1" # Increment version-
CLI package (
packages/cli/pyproject.toml):
[project]
version = "0.2.1"
dependencies = [
"onelogin-migration-core>=0.2.1,<0.3.0", # Update dependency
# ...
]-
GUI package (
packages/gui/pyproject.toml):
[project]
version = "0.2.1"
dependencies = [
"onelogin-migration-core>=0.2.1,<0.3.0", # Update dependency
# ...
]-
Update version-specific documentation:
- Add release notes to CHANGELOG
- Update version references in README
- Update wiki pages with version indicators
-
Rebuild all packages:
cd packages/core && python -m build && cd ../..
cd packages/cli && python -m build && cd ../..
cd packages/gui && python -m build && cd ../..Version history:
- v0.2.0 - Monorepo structure, analysis features, database management
- v0.1.x - Legacy single-package structure
The wiki is maintained in this repository at:
<repository-root>/docs/wiki/
Current wiki pages should be mirrored to GitHub Wiki.
When adding features, update relevant pages:
- Home.md - Overview and quick start
- Architecture.md - System design changes
- Configuration.md - New config options
- Command-Line.md - CLI command changes
- GUI.md - GUI workflow updates
- Development.md - Dev process changes
- README.md - Comprehensive reference for all features
- Wiki - Organized guides by topic
- Keep both in sync for major features
# Enable verbose logging
onelogin-migration-tool migrate --config config/migration.yaml -v
# Run with Python debugger
python -m pdb -m onelogin_migration_tool migrate --config config/migration.yaml# Run GUI with verbose terminal logging
onelogin-migration-tool gui -v
# Check Qt environment
python -c "from PySide6 import QtCore; print(QtCore.__version__)"Import errors after code changes:
# Reinstall in editable mode
pip install -e .Tests fail after dependency update:
# Recreate virtual environment
deactivate
rm -rf .venv
python3 -m venv .venv
source .venv/bin/activate
pip install -e .[gui,test]GUI doesn't show changes:
- Check if PySide6 cached compiled UI files
- Restart GUI application
- Verify changes saved to correct file
- Follow PEP 8 (enforced by Black)
- Use type hints for function signatures
- Write docstrings for public functions
- Keep functions focused and testable
- Use imperative mood: "Add feature" not "Added feature"
- First line: brief summary (50 chars)
- Blank line, then detailed explanation if needed
- Include tests for new functionality
- Update documentation
- Ensure CI passes (tests, linting)
- Reference related issues
- Automated tests run on PR
- Code review by maintainers
- Address feedback
- Merge when approved
- Python packaging: https://packaging.python.org/
- pytest documentation: https://docs.pytest.org/
- Typer CLI: https://typer.tiangolo.com/
- PySide6 docs: https://doc.qt.io/qtforpython/
- Ruff linter: https://beta.ruff.rs/docs/
The full pipeline — export, attribute discovery, user/group/app transforms — is entirely delegated through two protocols: FieldMapper and SourceClient. No wizard logic, manager code, or GUI screens need to change.
Create a new class in packages/core/src/onelogin_migration_core/field_mapper.py that satisfies the FieldMapper protocol. All nine methods must be implemented.
# field_mapper.py
from .transformers import FieldTransformer
from .constants import KNOWN_STANDARD_FIELDS
class AzureADFieldMapper:
"""FieldMapper for the Microsoft Azure AD source provider.
Azure AD Graph API returns flat user objects with displayName, userPrincipalName, etc.
"""
def transform_user(self, raw_user: dict) -> dict | None:
upn = raw_user.get("userPrincipalName") or raw_user.get("mail")
if not upn:
return None
return {
"firstname": raw_user.get("givenName"),
"lastname": raw_user.get("surname"),
"email": raw_user.get("mail") or upn,
"username": upn,
"state": 0 if raw_user.get("accountEnabled") is False else 1,
"external_id": raw_user.get("id", ""),
"phone": raw_user.get("businessPhones", [None])[0],
"department": raw_user.get("department"),
"title": raw_user.get("jobTitle"),
"company": raw_user.get("companyName"),
}
def transform_group(self, raw_group: dict) -> dict | None:
name = raw_group.get("displayName")
return {"name": name} if name else None
def transform_application(self, raw_app: dict, connector_lookup: dict) -> dict | None:
labels = self.extract_app_labels(raw_app)
sign_on = self.extract_app_signon_mode(raw_app)
for label in labels:
connectors = connector_lookup.get(label, {})
connector_id = connectors.get(sign_on) or connectors.get(None)
if connector_id:
return self.build_app_payload(raw_app, connector_id)
return None
def discover_custom_attributes(self, raw_users: list[dict]) -> set[str]:
STANDARD = {"id", "userPrincipalName", "mail", "givenName", "surname",
"displayName", "accountEnabled", "businessPhones", "department",
"jobTitle", "companyName", "mobilePhone", "officeLocation"}
attrs: set[str] = set()
for user in raw_users:
for key, value in user.items():
if key in STANDARD or value is None:
continue
if isinstance(value, (dict, list)):
continue
if isinstance(value, str) and not value.strip():
continue
normalized = self.normalize_custom_attribute_name(key)
if normalized:
attrs.add(normalized)
return attrs
def normalize_custom_attribute_name(self, source_key: str) -> str:
return FieldTransformer.normalize_custom_attribute_name(source_key)
def extract_app_labels(self, raw_app: dict) -> list[str]:
labels = []
for key in ("displayName", "appDisplayName", "name"):
candidate = FieldTransformer.normalize_app_label(raw_app.get(key))
if candidate:
labels.append(candidate)
return labels
def extract_app_signon_mode(self, raw_app: dict) -> str | None:
# Azure AD uses "signInAudience" and "web"/"spa"/"publicClient" to indicate type
# Map to a normalized OneLogin sign-on mode string
sign_in = raw_app.get("signInAudience", "")
if "AzureADMyOrg" in sign_in or "AzureADMultipleOrgs" in sign_in:
return "saml_2_0"
return None
def build_app_payload(self, raw_app: dict, connector_id: int) -> dict | None:
name = raw_app.get("displayName") or raw_app.get("appDisplayName")
if not name:
return None
return FieldTransformer.clean_payload({
"name": name,
"connector_id": connector_id,
"description": raw_app.get("description"),
"visible": True,
"configuration": {},
})
def build_app_parameters(self, raw_app: dict, sign_on_mode: str | None,
allows_new_parameters: bool) -> dict:
if not allows_new_parameters:
return {}
params = {}
web = raw_app.get("web") or {}
redirect_uris = web.get("redirectUris")
if isinstance(redirect_uris, list) and redirect_uris:
params["redirect_uris"] = redirect_uris
return paramsSee OktaFieldMapper in field_mapper.py for a complete production reference — it covers SAML/OIDC parameter extraction and Okta-specific field filtering in detail.
Add a new client class in packages/core/src/onelogin_migration_core/clients.py:
# clients.py
class AzureADSourceClient:
"""SourceClient for the Microsoft Azure AD Graph API."""
def __init__(self, settings: SourceApiSettings) -> None:
self._settings = settings
self._mapper = AzureADFieldMapper()
@property
def field_mapper(self) -> AzureADFieldMapper:
return self._mapper
@property
def settings(self) -> SourceApiSettings:
return self._settings
def test_connection(self) -> tuple[bool, str]:
# TODO: implement actual auth check
return True, "ok"
def list_users(self) -> list[dict]:
# Call Microsoft Graph API: GET /v1.0/users
...
def list_groups(self) -> list[dict]:
# Call Microsoft Graph API: GET /v1.0/groups
...
def list_group_memberships(self, groups=None) -> list[dict]:
...
def list_applications(self) -> list[dict]:
# Call Microsoft Graph API: GET /v1.0/applications
...
def list_policies(self) -> list[dict]:
return []
def export_all(self, categories=None) -> dict:
return {
"users": self.list_users(),
"groups": self.list_groups(),
"memberships": self.list_group_memberships(),
"applications": self.list_applications(),
}
# Register so build_source_client() can find it
_PROVIDER_REGISTRY["azure_ad"] = AzureADSourceClientThe SourceApiSettings model accepts arbitrary extra fields — tenant_id, client_id, etc. — through its extra dict, so no model changes are needed.
In packages/gui/src/onelogin_migration_gui/steps/source.py, add your provider to SOURCE_PROVIDERS:
SOURCE_PROVIDERS = {
"Okta": {
"domain": {"label": "Okta Domain", "placeholder": "company.okta.com"},
"token": {"label": "API Token", "secret": True},
},
"Azure AD": {
"tenant_id": {"label": "Tenant ID", "placeholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"},
"client_id": {"label": "Client ID", "placeholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"},
"client_secret": {"label": "Client Secret", "secret": True},
},
}The ProviderSettingsPage widget reads this dict and rebuilds its form fields automatically. No other GUI code changes.
source:
provider: azure_ad # must match the key in _PROVIDER_REGISTRY
tenant_id: your-tenant-id
client_id: your-client-id
# client_secret comes from keyring — never put it in the YAMLAdd a test file packages/core/tests/test_azure_ad_field_mapper.py following the same pattern as test_field_mapper.py:
from onelogin_migration_core.field_mapper import AzureADFieldMapper
class TestAzureADFieldMapperUsers:
def test_transforms_active_user(self):
mapper = AzureADFieldMapper()
raw = {
"id": "aad-001",
"userPrincipalName": "alice@corp.com",
"givenName": "Alice",
"surname": "Smith",
"mail": "alice@corp.com",
"accountEnabled": True,
}
result = mapper.transform_user(raw)
assert result["firstname"] == "Alice"
assert result["email"] == "alice@corp.com"
assert result["state"] == 1
assert result["external_id"] == "aad-001"
def test_disabled_user_maps_state_zero(self):
mapper = AzureADFieldMapper()
raw = {"userPrincipalName": "bob@corp.com", "accountEnabled": False, "id": "aad-002"}
assert mapper.transform_user(raw)["state"] == 0
def test_returns_none_when_no_upn(self):
mapper = AzureADFieldMapper()
assert mapper.transform_user({"givenName": "Ghost"}) is NoneRun your new tests the same way as the core suite:
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONPATH=packages/core/src \
.venv/bin/python -m pytest packages/core/tests/test_azure_ad_field_mapper.py -vAlso add a protocol conformance check to confirm your mapper satisfies isinstance(AzureADFieldMapper(), FieldMapper).
build_source_client() raises ValueError: unknown provider
The provider string in your YAML must exactly match the key passed to _PROVIDER_REGISTRY. Check spelling and case.
GUI combo shows provider but form is empty
The provider key in SOURCE_PROVIDERS (GUI dict) must match the display name you select — they can differ from the registry key but the dict must have an entry.
isinstance(mapper, FieldMapper) returns False
A required method is missing. Run python -c "from onelogin_migration_core.field_mapper import FieldMapper, AzureADFieldMapper; import inspect; print([m for m in dir(FieldMapper) if not m.startswith('_') and m not in dir(AzureADFieldMapper)])" to see which ones are absent.