Observed since 21 Oct, 10:21 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
aic-utils
AIC API wrapper and GitLab integration framework for pipeline management
aic_utils is a Python toolkit for managing JD Power AIC (Analytics Insights Console) pipelines and synchronizing them with GitLab under a strict, audit-friendly Git flow. It also bundles a few supporting utilities (dataset placeholder conversion, Slack logging, repository bootstrapping).
2.0.2⚠️ The PyPI release may lag this repository. This README documents the current source tree. If a method described here is not present in the version you
pip install-ed, install from source (pip install git+https://github.com/dylandoyle11/aic_utils) or pin to the version that matches.
AIC — AIC REST API wrapperGitLabManager — Git ↔ AIC sync engineBranchWorkspaceMapper — branch ↔ workspace mappingGitLabRepositoryInitializer — bootstrap a new repoDatasetConverter — placeholder rewritingSlackLogger — Slack-backed Python loggerpip install aic_utils
Or pin to the latest source:
pip install git+https://github.com/dylandoyle11/aic_utils.git
Runtime dependencies (from setup.py):
requests >= 2.25.0PyYAML >= 5.4.0pandas >= 1.3.0slack_sdk >= 3.0.0Python: 3.8+
The
GitLabManager._send_emailhelper importspyspark_utilslazily; that package is optional and only required whenemail_recipientsis configured.
from aic_utils import (
AIC, # AIC REST wrapper
GitLabManager, # GitLab sync / Git-flow enforcement
GitLabRepositoryInitializer, # One-time repo bootstrap
DatasetConverter, # Rewrite SQL/JSON placeholders → dataset:table refs
SlackLogger, # Pipe Python logging / print() to Slack
)
The package is organized so that one AIC instance + one GitLabManager instance together represent one workspace ↔ one branch at a time. Each environment (Prod / QA / Dev / sandbox) uses its own pair.
┌──────────────┐ ┌────────────────────┐ ┌──────────────┐
│ AIC workspace│ ←──── │ AIC (REST wrapper)│ ────→ │ pipeline JSON│
│ (cloud) │ └─────────┬──────────┘ │ in memory │
└──────────────┘ │ └──────┬───────┘
▼ ▼
┌────────────────────┐ ┌──────────────────┐
│ GitLabManager │ ────→ │ GitLab repo │
│ (push / deploy / │ │ config/*.json │
│ branch / MR) │ │ code/<pipe>/* │
└─────────┬──────────┘ └──────────────────┘
│
▼
┌────────────────────┐
│ BranchWorkspace- │ ← branch_to_workspace.yaml
│ Mapper │
└────────────────────┘
AIC — AIC REST API wrapperaic_utils/aic.py — class AIC
A thin REST client around the AIC core API (/apis/core/v1). Resolves project + workspace, lists pipelines, fetches/upserts pipeline configs, manages datasets and backups.
AIC(api_key: str,
project: str,
workspace: str,
pipelines: list[str] = [],
qa: bool = False)
| Arg | Description |
|---|---|
api_key | AIC API key (User Profile → Manage API Keys in AIC UI). |
project | Exact project name (matched against name from GET /projects). |
workspace | Exact workspace name within the project. |
pipelines | List of pipeline names to load configs for. ['*'] loads all pipelines in the workspace. Names not found are added as dummy configs with empty stages — useful for creating new pipelines on first deploy. |
qa | If True, points to https://aic.qa.jdpower.com/apis/core/v1; otherwise prod (https://prod.jdpower.ai/apis/core/v1). |
Resolved attributes
| Attribute | Type | Meaning |
|---|---|---|
base_url | str | Endpoint root, prod or QA. |
headers | dict | accept, api-key, Content-Type. |
project_id | str | Resolved via get_project_id. |
workspace | str | Original workspace name. |
workspace_id | str | Resolved via get_workspace_id. |
pipelines | list[{'name','id'}] | All pipelines in the workspace (populated by pop_pipelines). |
pipeline_configs | list[{'name','jobConfig','id'}] | Configs for the requested subset (populated by pop_pipeline_config, parallelized via ThreadPoolExecutor). |
timestamp | class attr | YYYYMMDD of instantiation date. |
| Method | Returns | Notes |
|---|---|---|
get_project_id(project) | str | Raises if project not found. |
get_workspace_id(workspace) | str | Raises if workspace not found. |
pop_pipelines() | list[{'name','id'}] | GET /jobs; refreshes self.pipelines. |
get_pipelines(workspace_id) | list[{'name','id'}] | Same shape as above, but for an arbitrary workspace ID. |
pop_pipeline_config(pipelines) | list[dict] | Parallel fetch. Pass ['*'] for all. Missing names produce dummy configs. |
fetch_pipeline_config(pipeline, direct=False) | dict | pipeline is a {'name', 'id'/'$id'} dict. direct=True skips name lookup. |
get_config() | list[dict] | Returns self.pipeline_configs. |
| Method | Effect |
|---|---|
write_config_to_pipeline(config) | POST /jobs upsert. If config['id'] is set it’s included as $id to update; otherwise creates. |
create_or_update_pipeline(workspace_id, pipeline_config) | Same idea but for an arbitrary workspace; on 409 Conflict falls through to update_pipeline. |
update_pipeline(workspace_id, pipeline_id, pipeline_config) | PUT /jobs/{id}. |
create_pipeline_branch(config) | PUT /interactive-pipeline/{jobId}/branches with auto-generated name PUSH_<YYYYMMDDHHMMSS>. |
copy_pipelines(target_workspace_name, pipelines_to_copy) | Copies the named pipelines from the current workspace into another (resolving target workspace by name). |
config shape:
{
'name': '<pipeline title>',
'jobConfig': { 'stages': [...], 'variables': [...], 'sourceStage': {...}, 'sinkStage': {...}, ... },
'id': '<existing $id, or None for create>',
}
| Method | Effect |
|---|---|
get_datasets() | GET /datasets. |
get_tables(dataset_id) | GET /datasets/{id}/tables. |
delete_branches(job_names) | Deletes every interactive branch on each named job. |
backup_pipelines(pipelines, base_folder='.', drive_name='backups') | Creates a YYYYMMDD/ folder on the named drive and uploads each pipeline’s config JSON via POST /upload-file. pipelines may be names or full configs. |
get_drive_id_by_name(drive_name) | Resolves drive by name (case-insensitive). Returns None if absent. |
pop_pipeline_config uses ThreadPoolExecutor.print() for visibility (you can redirect to a logger via SlackLogger).GitLabManager — Git ↔ AIC sync engineaic_utils/gitlabmanager.py — class GitLabManager
Wraps the GitLab v4 REST API and binds it to an AIC instance. Enforces a strict 1:1 branch ↔ workspace contract, drives push/deploy passes, manages feature/release/hotfix Git flow, and can bootstrap a brand-new repo.
Class-level constants control which branches play which Git-flow role:
GitLabManager.branch_roles = {
"production": "main",
"staging": "staging",
"development": "dev",
}
If your repo uses different names (e.g. develop instead of dev), set this before instantiating any managers:
GitLabManager.branch_roles = {"production": "main", "staging": "qa", "development": "develop"}
The defaults —
main/staging/dev— are whatGitLabRepositoryInitializerwill scaffold.
GitLabManager(
aic_instance, # AIC
gitlab_token, # str
gitlab_namespace, # e.g. "pin/pin-analytics"
repo_folder, # e.g. "pin-fusion-2.0"
gitlab_base_url: str = "https://git.autodatacorp.org/api/v4",
use_hash_comparison: bool = True,
email_recipients: list = None,
email_sender: str = None,
mapping_file: str = "branch_to_workspace.yaml",
)
What __init__ does, in order
Private-Token).{namespace}/{repo_folder}); raises a clear RuntimeError on 404.default_branch.branch_to_workspace.yaml from the default branch into a BranchWorkspaceMapper.aic.workspace. If no mapping exists, slugs the workspace name (PIN FUSION 2.0 → pin-fusion-2-0).ensure_branch_exists on that branch (off default_branch)._allowed_push = {slug, mapped_branch} plus _hotfix_prefixes = ['hotfix/'].check_token_expiration() (warns if ≤ 30 days).After construction, __setattr__ raises if you try to mutate an attribute. The only sanctioned way to change _branch is via:
create_feature_branch(name)create_release_branch(name)create_hotfix_branch(name)…all of which use the internal _set_branch() helper. mgr.branch is exposed as a read-only property.
Practical implication: don’t reuse a
GitLabManageracross workspaces. Build a new one per environment.
| Method | Effect |
|---|---|
repository_exists(repo_path) | GET /projects/{path} → 200? |
create_repository(repo_name) | POST /projects under get_subgroup_id(), visibility private. |
get_subgroup_id() | Resolves namespace path to a numeric group id. |
ensure_branch_exists(branch_name=None, ref=None) | Creates branch_name off ref if missing. Idempotent. |
_slugify(text) | URL-safe slug ([^a-z0-9]+ → -). |
get_token_expiration_date() | Hits /personal_access_tokens, returns the soonest-expiring active token's datetime. |
check_token_expiration(warning_threshold_days=30) | Logs OK / warning / expired. Called automatically at init. |
generate_hash(content) | MD5 of newline-normalized content (used by deploy_pipelines). |
| Method | Effect |
|---|---|
get_existing_file_content(repo_name, file_name) | Tries current branch then default; base64-decodes. |
fetch_pipeline_file(full_path, ref=None) | Raw content via /raw?ref=.... Falls back current → default. Raises FileNotFoundError. |
list_pipeline_files(subpath=None) | Paginated .json list under subpath on current branch. Empty on 404. |
list_all_blobs(subpath) | Recursive list of every file path under subpath on current branch. |
delete_file(file_name, branch=None) | DELETE /repository/files/.... |
_commit_deletion_actions(paths, branch, msg_prefix) | Batches up to 50 deletions per commit via POST /repository/commits. |
_push_file(proj_encoded, file_name, file_content, branch) | Tries POST (create); on 400/409 falls back to PUT (update). |
mgr.push_to_git()
default_branch, prompts (y/n) before continuing._allowed_push or start with hotfix/.config/{name}.json for every entry in aic.pipeline_configs._extract_and_push_code to:
PYSPARK task scripts to code/{pipeline}/{task_id}.py,select|insert|update|delete|with|create|drop|alter|merge) and write each as code/{pipeline}/{task_id}_{n}.sql,SQL task query/script/sql text to code/{pipeline}/{task_id}.sql.email_recipients is set, sends an HTML push summary via pyspark_utils.send_email.mgr.deploy_pipelines()
aic.workspace.aic.pipeline_configs:
config/{name}.json from the current branch.use_hash_comparison=True); skips unchanged.aic.write_config_to_pipeline(...) for changed pipelines.The earlier
force_sync=Trueflag is no longer present — current behavior is hash-compare or always-write controlled byuse_hash_comparisonat construction time.
| Method | Source branch | Target | Notes |
|---|---|---|---|
create_feature_branch(feature_name) | branch_roles["development"] | feature/{name} | Refuses if not currently on dev. Switches _branch to the feature. |
create_release_branch(branch_name) | branch_roles["staging"] | release/{name} | After cut, prunes any config/*.json and code/<pipeline>/... files for pipelines NOT in aic.pipeline_configs. |
create_hotfix_branch(branch_name) | production (resolved via mapper, falls back to default_branch) | hotfix/{name} | Same prune behavior as release. |
All three slugify the input (replace anything outside [A-Za-z0-9\-_/] with -) and switch the manager's branch on success.
mgr.create_merge_request(
title: str,
description: str,
target_branch: str | None = None, # auto-detected if omitted
assignee_ids: list | None = None,
)
feature/, release/, or hotfix/.target_branch is omitted:
feature/* → stagingrelease/*, hotfix/*, or staging → mainmainiid is logged.Note: the auto-target string literals are hardcoded to
"main"/"staging"— if you customizebranch_roles, passtarget_branchexplicitly.
GitLabManager.initialize_repository(
gitlab_token, gitlab_namespace, repo_folder,
gitlab_base_url="https://git.autodatacorp.org/api/v4",
)
Builds a minimally-initialized manager via _setup_for_initialization and hands off to GitLabRepositoryInitializer (see §6). Returns True/False.
GitLabManager.wipe_repository(
gitlab_token, gitlab_namespace, repo_folder,
gitlab_base_url="https://git.autodatacorp.org/api/v4",
keep_branches=None, # default ['main']
)
Destructive. Prompts (y/n) first, then:
DELETE.main/master defensively, removes branch protections first)._send_email(subject, body, cc=None, bcc=None, attachments=None, testing=True)
_format_push_summary(branch, results) → (subject, html_body)
_format_deploy_summary(branch, results) → (subject, html_body)
results is a list of (pipeline_name, status, message) tuples, rendered as an HTML table.
BranchWorkspaceMapper — branch ↔ workspace mappingDefined in gitlabmanager.py. Constructed automatically by GitLabManager.__init__ from branch_to_workspace.yaml on the default branch.
YAML format:
mapping:
main: PIN FUSION 2.0
staging: PIN FUSION 2.0 QA
dev: PIN FUSION 2.0 DEV
intg/*: PIN FUSION 2.0 INTG # wildcard prefix
API:
| Method | Behavior |
|---|---|
lookup(branch) | Exact match → prefix match (keys ending in /*) → built-ins (feature/* → develop mapping; release/*, hotfix/* → main mapping). Raises KeyError if nothing matches. |
workspace_to_branch(workspace) | Reverse lookup; falls back to the workspace name slugified to lowercase-with-dashes. |
lookuplooks up the mapping keys literally — its built-infeature/*fallback is keyed offdevelop(notdev). If you renamed the dev branch, add an explicit mapping for the dev branch and rely on exact/prefix rules rather than the built-in.
GitLabRepositoryInitializer — bootstrap a new repoaic_utils/gitlab_init.py — class GitLabRepositoryInitializer
Usually invoked indirectly via GitLabManager.initialize_repository(...). It performs a one-time setup of a freshly-created empty GitLab project.
initialize_repository() runs, in order:
_enforce_main_branch() — if the default branch isn’t branch_roles["production"] (default main), creates it from the existing default, sets it as default, protects it (push/merge level 30 = maintainer), and deletes the original._create_initial_branches() — creates staging and dev (or whatever branch_roles specifies) off the default branch._create_initial_files() — pushes onto the default branch:
branch_to_workspace.yaml with placeholder workspace names,SECURITY.md documenting JDP scanners and remediation timelines,config/.gitkeep,code/.gitkeep._setup_security_scanning() — writes a .gitlab-ci.yml that includes the corporate templates for the matching group (alg / pin / ucg → otherwise corp) and wires SAST, dependency scanning, secret detection, code quality, plus a manual deploy_pipelines job._enable_cicd_features() — PUT /projects/{id} to enable builds/MRs/security & compliance and disable wiki/snippets/registry._setup_branch_protection() — protects production, staging, and development (push/merge level 30)._generate_readme() — derives project + company names from the namespace and writes a tailored README.md (the same one you’re reading describes how to use it).All steps are idempotent enough to re-run safely (existing branches, files, and protections return 409 and are logged as warnings).
DatasetConverter — placeholder rewritingaic_utils/dataset_converter.py — class DatasetConverter
Purpose: rewrite bare table names in pipeline configs into AIC dataset-aware placeholders (${DATASET.TABLE}), then push the rewritten config to a new interactive AIC branch named CONVERTED_<YYYYMMDDHHMMSS>. Intended for one-shot migration of legacy pipelines that hard-coded table names.
DatasetConverter(api_key, project, workspace,
target_workspace=None, qa=False)
AIC.target_workspace is supplied, the table catalog (used as the lookup dictionary) is built from that workspace; otherwise from the source workspace.self.tables as a pandas.DataFrame of {dataset_name, dataset_id, table_name} (parallel fetch via ThreadPoolExecutor).| Method | Behavior |
|---|---|
pop_tables(workspace_id) | Populates self.tables. |
get_datasets(workspace_id) | GET /datasets. |
get_tables(dataset, workspace_id) | GET /datasets/{id}/tables. |
get_pipelines() | List of {name,id} for the source workspace. |
fetch_pipeline_config(pipeline_name) | Returns {name, jobConfig, id} or None. |
convert(pipelines) | For each name: fetch → replace_placeholders → replace_source_stages_with_documentation → create_pipeline_branch. |
replace_placeholders(config) | Recursively walks the config; if any string equals a known table name, rewrites it as ${DATASET.TABLE} and appends a markdown bullet to self.audit_log. |
replace_source_stages_with_documentation(config) | Strips out DATASET-typed source tasks and inserts a DOCUMENTATION task containing the audit log markdown. |
create_pipeline_branch(config) | Pushes the rewritten jobConfig to interactive-pipeline/{job_id}/branches as CONVERTED_<timestamp>. |
SlackLogger — Slack-backed Python loggeraic_utils/slack_logger.py — class SlackLogger
Bridges Python’s logging (and optionally print) to a Slack channel via slack_sdk. Includes a re-entrancy guard so messages emitted while sending don’t loop.
SlackLogger(token: str, channel: str)
.send_message(text) # post a single message
.SlackLoggerHandler # nested logging.Handler
SlackLogger.redirect_print_to_logger(logger) # monkey-patches builtins.print
SlackLogger.create_logger(slack_token,
slack_channel='C07DYFK5SE8',
redirect_print=True) # returns a configured logging.Logger
create_logger:
SlackLogger.SlackLoggerHandler wired to it.logging.Logger named 'SlackLogger' at INFO level with format %(asctime)s - %(levelname)s - %(message)s.redirect_print=True, re-points builtins.print at logger.info.The default channel ID 'C07DYFK5SE8' is JDP-internal — pass your own.
from aic_utils import SlackLogger
logger = SlackLogger.create_logger(
slack_token="xoxb-…",
slack_channel="C0123456789",
redirect_print=True, # any subsequent print() lands in Slack
)
logger.info("Pipeline run started")
The branch role names below assume the defaults production="main", staging="staging", development="dev". Substitute as needed.
from aic_utils import GitLabManager
GitLabManager.initialize_repository(
gitlab_token="<GIT_TOKEN>",
gitlab_namespace="pin/pin-analytics",
repo_folder="pin-fusion-2.0",
)
# Then edit branch_to_workspace.yaml with real workspace names.
from aic_utils import AIC, GitLabManager
aic_prod = AIC(
api_key="<API_KEY>",
project="dylan.doyle@jdpa.com",
workspace="PIN FUSION 2.0",
pipelines=["*"], # or a curated list
)
mgr = GitLabManager(
aic_instance=aic_prod,
gitlab_token="<GIT_TOKEN>",
gitlab_namespace="pin/pin-analytics",
repo_folder="pin-fusion-2.0",
)
aic_dev = AIC(api_key=..., project=..., workspace="PIN FUSION 2.0 DEV", pipelines=[...])
mgr_dev = GitLabManager(aic_instance=aic_dev, ...)
mgr_dev.push_to_git()
Restrictions:
push_to_git on main requires interactive y confirmation._allowed_push (slug + mapped branch) and isn't a hotfix/*.mgr_dev.create_feature_branch("PINOPS-1831") # cuts feature/PINOPS-1831 from dev
mgr_dev.create_merge_request(
title="Demographics module",
description="QA push",
target_branch="staging", # pass explicitly if you customized roles
)
# After the MR is merged, deploy on the QA workspace:
aic_qa = AIC(api_key=..., workspace="PIN FUSION 2.0 QA", pipelines=[...])
mgr_qa = GitLabManager(aic_instance=aic_qa, ...)
mgr_qa.deploy_pipelines()
mgr_qa.push_to_git() # capture latest QA state
mgr_qa.create_release_branch("v2.1.0") # cuts release/v2.1.0 from staging,
# prunes pipelines not in aic.pipeline_configs
mgr_qa.create_merge_request(
title="Release v2.1.0",
description="QA-approved",
target_branch="main",
)
# After merge:
mgr_prod = GitLabManager(aic_instance=aic_prod, ...)
mgr_prod.deploy_pipelines()
The order is inverted from feature/release: the fix is applied in AIC prod first, then captured in Git.
# 1. Apply the fix manually in AIC prod workspace.
# 2. Build a fresh prod AIC + manager (so it sees the new config).
aic_prod = AIC(api_key=..., workspace="PIN FUSION 2.0", pipelines=["BROKEN_PIPELINE"])
mgr_prod = GitLabManager(aic_instance=aic_prod, ...)
# 3. Cut hotfix off main and push the AIC state into the hotfix branch.
mgr_prod.create_hotfix_branch("PINOPS-1831")
mgr_prod.push_to_git()
# 4. Open MR back into main for the audit trail.
mgr_prod.create_merge_request(
title="Hotfix PINOPS-1831",
description="Emergency fix for streaming timeout",
target_branch="main",
)
from aic_utils import DatasetConverter
dc = DatasetConverter(
api_key="<API_KEY>",
project="dylan.doyle@jdpa.com",
workspace="LEGACY",
target_workspace="PIN FUSION 2.0", # use this workspace's catalog as the lookup
)
dc.convert(["LEGACY_PIPELINE_A", "LEGACY_PIPELINE_B"])
# Each pipeline now has a CONVERTED_<timestamp> AIC branch with rewritten placeholders.
from aic_utils import SlackLogger
SlackLogger.create_logger("<SLACK_TOKEN>", "<CHANNEL_ID>", redirect_print=True)
print("from now on, this lands in Slack")
Operating principles
main / staging / dev should be protected; only feature/*, release/*, hotfix/* cut from them.use_hash_comparison=False if you want every deploy to write regardless of diff.branch_to_workspace.yaml truthful. The mapper reads it from the default branch only.branch_roles is class-level. Setting it after construction does not retroactively rebuild allowed-push sets.Common errors
| Symptom | Likely cause | Fix |
|---|---|---|
RuntimeError: ❌ GitLab project ... not found | gitlab_namespace or repo_folder typo | Verify in GitLab UI. |
❌ Cannot modify attribute 'X' after initialization | Trying to mutate manager state directly | Use create_*_branch helpers; build a new manager for a new workspace. |
❌ Cannot push to branch '...' | Current branch isn’t in _allowed_push and isn’t hotfix/* | You probably checked out a feature branch — push, then MR; don’t push features directly. |
❌ Branch '...' maps to workspace '...', not '...' (on deploy) | Mismatched branch ↔ AIC workspace | Use the correct AIC workspace for the branch you’re deploying. |
FileNotFoundError: 'branch_to_workspace.yaml' not found ... | Missing/renamed mapping file on default branch | Add it on main (or whatever your default branch is). |
pyspark_utils import error | email_recipients was set but the package isn’t installed | Either pip install pyspark_utils or unset email_recipients. |
| Token nearing expiry warning at init | <30 days remaining | Rotate the GitLab token. |
Contact
Download counts and popularity will appear here as the collector receives them.
| Package / target | Requirement | Relationship |
|---|---|---|
requestsEnvironment{
"extras": "",
"marker": null,
"requires_python": ">=3.8",
"evaluation": "NOT_EVALUATED"
} | requests>=2.25.0 | runtime |
PyYAMLEnvironment{
"extras": "",
"marker": null,
"requires_python": ">=3.8",
"evaluation": "NOT_EVALUATED"
} | PyYAML>=5.4.0 | runtime |
pandasEnvironment{
"extras": "",
"marker": null,
"requires_python": ">=3.8",
"evaluation": "NOT_EVALUATED"
} | pandas>=1.3.0 | runtime |
slack_sdkEnvironment{
"extras": "",
"marker": null,
"requires_python": ">=3.8",
"evaluation": "NOT_EVALUATED"
} | slack_sdk>=3.0.0 | runtime |
bdist_wheel · 46.7 KB · Python py3
sdist · 54.12 KB · Python source
Observed since 21 Oct, 10:21 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package_version:3211246fb29f6b8fde39214cb446ccdf73a90d7d56a8e7cd60acd523af75d0cf