pyboomi-cli 0.2.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyboomi_cli/__init__.py +55 -0
- pyboomi_cli/cache.py +111 -0
- pyboomi_cli/cli.py +59 -0
- pyboomi_cli/config.py +48 -0
- pyboomi_cli/utils.py +565 -0
- pyboomi_cli-0.2.5.dist-info/METADATA +210 -0
- pyboomi_cli-0.2.5.dist-info/RECORD +11 -0
- pyboomi_cli-0.2.5.dist-info/WHEEL +5 -0
- pyboomi_cli-0.2.5.dist-info/entry_points.txt +3 -0
- pyboomi_cli-0.2.5.dist-info/licenses/LICENSE +201 -0
- pyboomi_cli-0.2.5.dist-info/top_level.txt +1 -0
pyboomi_cli/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# PyBoomi CLI - Main Package
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2025 Robert Little
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
#
|
|
17
|
+
# Development Notes:
|
|
18
|
+
# Contents of this file were produced with the help of code generation tools
|
|
19
|
+
# and subsequently reviewed and edited by the author. While some code was
|
|
20
|
+
# created with AI assistance, manual adjustments have been made to ensure
|
|
21
|
+
# correctness, readability, functionality, and compliance with coding
|
|
22
|
+
# standards. Any future modifications should preserve these manual changes.
|
|
23
|
+
#
|
|
24
|
+
# Author: Robert Little
|
|
25
|
+
# Created: 2025-07-27
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
PyBoomi CLI - Command-line interface for the Boomi Platform API.
|
|
29
|
+
|
|
30
|
+
This package provides a comprehensive command-line tool for interacting with
|
|
31
|
+
the Boomi Platform API. It enables developers and system administrators to
|
|
32
|
+
manage Boomi integrations, processes, folders, and other platform resources
|
|
33
|
+
directly from the terminal.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
__version__ = version("pyboomi-cli")
|
|
40
|
+
except PackageNotFoundError:
|
|
41
|
+
__version__ = "0.0.0.dev0"
|
|
42
|
+
|
|
43
|
+
__author__ = "Robert Little"
|
|
44
|
+
__copyright__ = "Copyright 2025, Robert Little"
|
|
45
|
+
__license__ = "Apache 2.0"
|
|
46
|
+
|
|
47
|
+
# Import main CLI function for easy access
|
|
48
|
+
from .cli import main
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"__version__",
|
|
52
|
+
"__author__",
|
|
53
|
+
"__license__",
|
|
54
|
+
"main",
|
|
55
|
+
]
|
pyboomi_cli/cache.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# PyBoomi CLI - Cache Management
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2025 Robert Little
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
|
|
17
|
+
"""Cache management for PyBoomi CLI components."""
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import os
|
|
21
|
+
import xml.etree.ElementTree as ET
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional, Tuple
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_cache_dir() -> Path:
|
|
27
|
+
"""Get the cache directory path, cross-platform compatible."""
|
|
28
|
+
from pyboomi_cli.config import get_config
|
|
29
|
+
|
|
30
|
+
config = get_config()
|
|
31
|
+
|
|
32
|
+
if config and "cache_dir" in config:
|
|
33
|
+
return Path(config["cache_dir"]).expanduser()
|
|
34
|
+
|
|
35
|
+
# Default to .boomicache in user home directory
|
|
36
|
+
home = Path.home()
|
|
37
|
+
return home / ".boomicache"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_cache_key(component_id: str, branch_id: str, version: str) -> str:
|
|
41
|
+
"""Generate a cache key for a component. Requires both branch_id and version."""
|
|
42
|
+
key_string = f"{component_id}|branch:{branch_id}|version:{version}"
|
|
43
|
+
return hashlib.sha256(key_string.encode()).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def extract_component_info(component_xml: str) -> Tuple[Optional[str], Optional[str]]:
|
|
47
|
+
"""Extract branch ID and version from component XML."""
|
|
48
|
+
try:
|
|
49
|
+
root = ET.fromstring(component_xml)
|
|
50
|
+
# Look for common attributes that contain branch and version info
|
|
51
|
+
branch_id = root.get("branchId") or root.get("branch-id")
|
|
52
|
+
version = root.get("version") or root.get("revision")
|
|
53
|
+
return branch_id, version
|
|
54
|
+
except Exception:
|
|
55
|
+
return None, None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_cached_component(
|
|
59
|
+
component_id: str, branch_id: Optional[str] = None, version: Optional[str] = None
|
|
60
|
+
) -> Optional[str]:
|
|
61
|
+
"""
|
|
62
|
+
Retrieve a component from cache if it exists.
|
|
63
|
+
|
|
64
|
+
Only works with specific branch_id and version.
|
|
65
|
+
"""
|
|
66
|
+
if not branch_id or not version:
|
|
67
|
+
return None # Don't cache without specific branch and version
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
cache_dir = get_cache_dir()
|
|
71
|
+
if not cache_dir.exists():
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
cache_key = get_cache_key(component_id, branch_id, version)
|
|
75
|
+
cache_file = cache_dir / f"{cache_key}.xml"
|
|
76
|
+
|
|
77
|
+
if cache_file.exists():
|
|
78
|
+
return cache_file.read_text(encoding="utf-8")
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cache_component(
|
|
86
|
+
component_data: str,
|
|
87
|
+
component_id: str,
|
|
88
|
+
branch_id: Optional[str] = None,
|
|
89
|
+
version: Optional[str] = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Store a component in the cache. Only caches with specific branch_id and version."""
|
|
92
|
+
# If branch_id or version not provided, try to extract from component XML
|
|
93
|
+
if not branch_id or not version:
|
|
94
|
+
extracted_branch, extracted_version = extract_component_info(component_data)
|
|
95
|
+
branch_id = branch_id or extracted_branch
|
|
96
|
+
version = version or extracted_version
|
|
97
|
+
|
|
98
|
+
# Only cache if we have both branch and version
|
|
99
|
+
if not branch_id or not version:
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
cache_dir = get_cache_dir()
|
|
104
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
|
|
106
|
+
cache_key = get_cache_key(component_id, branch_id, version)
|
|
107
|
+
cache_file = cache_dir / f"{cache_key}.xml"
|
|
108
|
+
|
|
109
|
+
cache_file.write_text(component_data, encoding="utf-8")
|
|
110
|
+
except Exception:
|
|
111
|
+
pass # Silently fail if caching doesn't work
|
pyboomi_cli/cli.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# PyBoomi CLI - CLI Main
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2025 Robert Little
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
#
|
|
17
|
+
# Development Notes:
|
|
18
|
+
# Contents of this file were produced with the help of code generation tools
|
|
19
|
+
# and subsequently reviewed and edited by the author. While some code was
|
|
20
|
+
# created with AI assistance, manual adjustments have been made to ensure
|
|
21
|
+
# correctness, readability, functionality, and compliance with coding
|
|
22
|
+
# standards. Any future modifications should preserve these manual changes.
|
|
23
|
+
#
|
|
24
|
+
# Author: Robert Little
|
|
25
|
+
# Created: 2025-12-12
|
|
26
|
+
|
|
27
|
+
"""CLI main module for PyBoomi CLI tool."""
|
|
28
|
+
|
|
29
|
+
__author__ = "Robert Little"
|
|
30
|
+
__copyright__ = "Copyright 2025, Robert Little"
|
|
31
|
+
__license__ = "Apache 2.0"
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
import click
|
|
35
|
+
|
|
36
|
+
from pyboomi_cli.commands.branch import command as branch_command
|
|
37
|
+
from pyboomi_cli.commands.component import command as component_command
|
|
38
|
+
from pyboomi_cli.commands.environment import command as environment_command
|
|
39
|
+
from pyboomi_cli.commands.folders import command as folders_command
|
|
40
|
+
from pyboomi_cli.commands.package import command as package_command
|
|
41
|
+
from pyboomi_cli.commands.processes import command as processes_command
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@click.group()
|
|
45
|
+
def main():
|
|
46
|
+
"""PyBoomi CLI — manage Boomi Platform API from the terminal."""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
main.add_command(folders_command)
|
|
51
|
+
# Plural alias for backward compatibility
|
|
52
|
+
main.add_command(folders_command, name="folders")
|
|
53
|
+
main.add_command(processes_command, name="processes")
|
|
54
|
+
main.add_command(processes_command, name="process")
|
|
55
|
+
main.add_command(component_command)
|
|
56
|
+
main.add_command(package_command)
|
|
57
|
+
main.add_command(branch_command)
|
|
58
|
+
main.add_command(environment_command)
|
|
59
|
+
main.add_command(environment_command, name="environments")
|
pyboomi_cli/config.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# PyBoomi CLI - Configuration Management
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2025 Robert Little
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
|
|
17
|
+
"""Configuration management for PyBoomi CLI."""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, Optional
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_config_file_path() -> Path:
|
|
26
|
+
"""Get the configuration file path."""
|
|
27
|
+
# Check for config file in current directory first
|
|
28
|
+
local_config = Path(".pyboomi.json")
|
|
29
|
+
if local_config.exists():
|
|
30
|
+
return local_config
|
|
31
|
+
|
|
32
|
+
# Check in user home directory
|
|
33
|
+
home_config = Path.home() / ".pyboomi.json"
|
|
34
|
+
return home_config
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_config() -> Optional[Dict[str, Any]]:
|
|
38
|
+
"""Load configuration from file if it exists."""
|
|
39
|
+
config_file = get_config_file_path()
|
|
40
|
+
|
|
41
|
+
if not config_file.exists():
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
46
|
+
return json.load(f)
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
pyboomi_cli/utils.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
# PyBoomi CLI - Utilities
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2025 Robert Little
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
#
|
|
17
|
+
# Development Notes:
|
|
18
|
+
# Contents of this file were produced with the help of code generation tools
|
|
19
|
+
# and subsequently reviewed and edited by the author. While some code was
|
|
20
|
+
# created with AI assistance, manual adjustments have been made to ensure
|
|
21
|
+
# correctness, readability, functionality, and compliance with coding
|
|
22
|
+
# standards. Any future modifications should preserve these manual changes.
|
|
23
|
+
#
|
|
24
|
+
# Author: Robert Little
|
|
25
|
+
# Created: 2025-01-27
|
|
26
|
+
|
|
27
|
+
"""Utility functions for PyBoomi CLI operations."""
|
|
28
|
+
|
|
29
|
+
__author__ = "Robert Little"
|
|
30
|
+
__copyright__ = "Copyright 2025, Robert Little"
|
|
31
|
+
__license__ = "Apache 2.0"
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Dict, List, Optional, Set
|
|
39
|
+
|
|
40
|
+
import click
|
|
41
|
+
from pyboomi_platform.auth import BoomiAuth
|
|
42
|
+
from pyboomi_platform.client import BoomiPlatformClient
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _apply_accept_header(client, accept: Optional[str]) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Best-effort application of an Accept header on the underlying client/session.
|
|
48
|
+
Supports multiple possible client shapes without failing hard.
|
|
49
|
+
"""
|
|
50
|
+
if not accept:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
applied = False
|
|
54
|
+
|
|
55
|
+
# Common requests-style session
|
|
56
|
+
session = getattr(client, "session", None)
|
|
57
|
+
if session and hasattr(session, "headers"):
|
|
58
|
+
try:
|
|
59
|
+
session.headers["Accept"] = accept
|
|
60
|
+
applied = True
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
# Some clients expose default_headers dict
|
|
65
|
+
default_headers = getattr(client, "default_headers", None)
|
|
66
|
+
if isinstance(default_headers, dict):
|
|
67
|
+
try:
|
|
68
|
+
default_headers["Accept"] = accept
|
|
69
|
+
applied = True
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
# Fallback attribute for downstream use if the client inspects it
|
|
74
|
+
if not applied:
|
|
75
|
+
try:
|
|
76
|
+
setattr(client, "accept", accept)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_client(account_id=None, username=None, api_token=None, accept: Optional[str] = None):
|
|
82
|
+
"""
|
|
83
|
+
Get a configured BoomiPlatformClient with authentication.
|
|
84
|
+
|
|
85
|
+
Priority for credentials:
|
|
86
|
+
- BOOMI_AUTH (base64 encoded Basic Auth string)
|
|
87
|
+
- Function arguments (username, api_token)
|
|
88
|
+
- Environment variables (BOOMI_USER, BOOMI_TOKEN)
|
|
89
|
+
|
|
90
|
+
Account ID can come from function argument or BOOMI_ACCT env var.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
account_id: Boomi account ID (optional, can use BOOMI_ACCT env var)
|
|
94
|
+
username: Boomi user email (optional, can use BOOMI_USER env var)
|
|
95
|
+
api_token: Boomi Platform API token (optional, can use BOOMI_TOKEN env var)
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
BoomiPlatformClient: Configured client instance with optional Accept header
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
click.UsageError: If required credentials are missing
|
|
102
|
+
"""
|
|
103
|
+
account_id = account_id or os.getenv("BOOMI_ACCT")
|
|
104
|
+
if not account_id:
|
|
105
|
+
raise click.UsageError("Boomi account ID must be provided via --account-id or BOOMI_ACCT environment variable.")
|
|
106
|
+
|
|
107
|
+
boomi_auth_header = os.getenv("BOOMI_AUTH")
|
|
108
|
+
|
|
109
|
+
if boomi_auth_header:
|
|
110
|
+
# BOOMI_AUTH is base64 encoded "BOOMI_TOKEN.email:uuid"
|
|
111
|
+
# Extract username and password for client initialization
|
|
112
|
+
try:
|
|
113
|
+
import base64
|
|
114
|
+
|
|
115
|
+
decoded = base64.b64decode(boomi_auth_header).decode("utf-8")
|
|
116
|
+
if ":" in decoded:
|
|
117
|
+
# Format is "BOOMI_TOKEN.email:uuid"
|
|
118
|
+
client_username, client_api_token = decoded.split(":", 1)
|
|
119
|
+
else:
|
|
120
|
+
# Fallback if no colon found
|
|
121
|
+
client_username = decoded
|
|
122
|
+
client_api_token = "token"
|
|
123
|
+
except Exception:
|
|
124
|
+
# If decoding fails, use placeholders
|
|
125
|
+
client_username = "user"
|
|
126
|
+
client_api_token = "token"
|
|
127
|
+
|
|
128
|
+
# Construct a fake BoomiAuth wrapper that returns the existing auth header
|
|
129
|
+
# The header is already base64 encoded, so we use it directly
|
|
130
|
+
class StaticBoomiAuth:
|
|
131
|
+
"""Wrapper for pre-encoded Basic Auth header."""
|
|
132
|
+
|
|
133
|
+
def get_auth_header(self):
|
|
134
|
+
"""Return pre-encoded authorization header."""
|
|
135
|
+
return {"Authorization": f"Basic {boomi_auth_header}"}
|
|
136
|
+
|
|
137
|
+
auth = StaticBoomiAuth()
|
|
138
|
+
else:
|
|
139
|
+
username = username or os.getenv("BOOMI_USER")
|
|
140
|
+
api_token = api_token or os.getenv("BOOMI_TOKEN")
|
|
141
|
+
|
|
142
|
+
if not username or not api_token:
|
|
143
|
+
raise click.UsageError(
|
|
144
|
+
"You must provide credentials using either:\n"
|
|
145
|
+
"- BOOMI_AUTH (base64 encoded Basic Auth string), or\n"
|
|
146
|
+
"- --username and --api-token (or BOOMI_USER and BOOMI_TOKEN env vars)"
|
|
147
|
+
)
|
|
148
|
+
auth = BoomiAuth(username, api_token)
|
|
149
|
+
client_username = username
|
|
150
|
+
client_api_token = api_token
|
|
151
|
+
|
|
152
|
+
# Inject the custom auth into the client
|
|
153
|
+
client = BoomiPlatformClient(account_id, username=client_username, api_token=client_api_token, base_url=None)
|
|
154
|
+
client.auth = auth
|
|
155
|
+
|
|
156
|
+
# Apply Accept header if requested (e.g., force XML responses)
|
|
157
|
+
_apply_accept_header(client, accept)
|
|
158
|
+
|
|
159
|
+
return client
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def sanitize_filename(name: str) -> str:
|
|
163
|
+
"""
|
|
164
|
+
Sanitize a filename by removing filesystem-unsafe characters.
|
|
165
|
+
|
|
166
|
+
Removes or replaces characters that are not safe for filesystem use:
|
|
167
|
+
/, \\, :, *, ?, ", <, >, |
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
name: Original filename string
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
str: Sanitized filename safe for filesystem use
|
|
174
|
+
"""
|
|
175
|
+
if not name:
|
|
176
|
+
return "unknown"
|
|
177
|
+
|
|
178
|
+
# Remove filesystem-unsafe characters
|
|
179
|
+
unsafe_chars = r'[/\\:*?"<>|]'
|
|
180
|
+
sanitized = re.sub(unsafe_chars, "", name)
|
|
181
|
+
|
|
182
|
+
# Replace spaces with underscores for better filesystem compatibility
|
|
183
|
+
sanitized = sanitized.replace(" ", "_")
|
|
184
|
+
|
|
185
|
+
# Remove leading/trailing dots and spaces (Windows issue)
|
|
186
|
+
sanitized = sanitized.strip(". ")
|
|
187
|
+
|
|
188
|
+
# If empty after sanitization, return a default
|
|
189
|
+
if not sanitized:
|
|
190
|
+
return "unknown"
|
|
191
|
+
|
|
192
|
+
return sanitized
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def format_output(data, output_format="json"):
|
|
196
|
+
"""
|
|
197
|
+
Format data for output in the specified format.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
data: Data to format (dict, list, etc.)
|
|
201
|
+
output_format: Output format ('json', 'yaml', or 'text')
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
str: Formatted output string
|
|
205
|
+
"""
|
|
206
|
+
if output_format == "json":
|
|
207
|
+
return json.dumps(data, indent=2)
|
|
208
|
+
elif output_format == "yaml":
|
|
209
|
+
try:
|
|
210
|
+
import yaml
|
|
211
|
+
|
|
212
|
+
return yaml.dump(data, default_flow_style=False, sort_keys=False)
|
|
213
|
+
except ImportError:
|
|
214
|
+
click.echo("Warning: PyYAML not installed. Falling back to JSON format.", err=True)
|
|
215
|
+
return json.dumps(data, indent=2)
|
|
216
|
+
elif output_format == "text":
|
|
217
|
+
# Simple text formatting for common component fields
|
|
218
|
+
if isinstance(data, dict):
|
|
219
|
+
lines = []
|
|
220
|
+
for key, value in data.items():
|
|
221
|
+
if isinstance(value, (dict, list)):
|
|
222
|
+
lines.append(f"{key}:")
|
|
223
|
+
lines.append(json.dumps(value, indent=2))
|
|
224
|
+
else:
|
|
225
|
+
lines.append(f"{key}: {value}")
|
|
226
|
+
return "\n".join(lines)
|
|
227
|
+
else:
|
|
228
|
+
return str(data)
|
|
229
|
+
elif output_format == "xml":
|
|
230
|
+
# For XML we expect a string or bytes payload; pass through as text
|
|
231
|
+
if isinstance(data, bytes):
|
|
232
|
+
return data.decode("utf-8", errors="replace")
|
|
233
|
+
return str(data)
|
|
234
|
+
else:
|
|
235
|
+
# Default to JSON
|
|
236
|
+
return json.dumps(data, indent=2)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def get_templates_directory() -> Path:
|
|
240
|
+
"""
|
|
241
|
+
Get the templates directory path.
|
|
242
|
+
|
|
243
|
+
Checks in order:
|
|
244
|
+
1. Environment variable PYBOOMI_TEMPLATES_DIR
|
|
245
|
+
2. Current working directory 'templates/'
|
|
246
|
+
3. User home directory '~/.pyboomi-cli/templates/'
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Path: Path to templates directory
|
|
250
|
+
"""
|
|
251
|
+
# Check environment variable first
|
|
252
|
+
env_path = os.getenv("PYBOOMI_TEMPLATES_DIR")
|
|
253
|
+
if env_path:
|
|
254
|
+
return Path(env_path).expanduser()
|
|
255
|
+
|
|
256
|
+
# Check current working directory
|
|
257
|
+
cwd_templates = Path.cwd() / "templates"
|
|
258
|
+
if cwd_templates.exists():
|
|
259
|
+
return cwd_templates
|
|
260
|
+
|
|
261
|
+
# Default to current working directory (will be created if needed)
|
|
262
|
+
return Path.cwd() / "templates"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def find_template_file(template_path: str, component_type: Optional[str] = None) -> Path:
|
|
266
|
+
"""
|
|
267
|
+
Find a template file by path.
|
|
268
|
+
|
|
269
|
+
The template_path can be:
|
|
270
|
+
- A relative path from templates directory (e.g., "processes/basic.xml")
|
|
271
|
+
- An absolute path
|
|
272
|
+
- A filename that will be searched in component_type subdirectory
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
template_path: Path to template file
|
|
276
|
+
component_type: Optional component type subdirectory (e.g., "processes")
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Path: Path to template file
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
click.UsageError: If template file is not found
|
|
283
|
+
"""
|
|
284
|
+
template_path_obj = Path(template_path)
|
|
285
|
+
|
|
286
|
+
# If it's an absolute path and exists, use it
|
|
287
|
+
if template_path_obj.is_absolute() and template_path_obj.exists():
|
|
288
|
+
return template_path_obj
|
|
289
|
+
|
|
290
|
+
# Get templates directory
|
|
291
|
+
templates_dir = get_templates_directory()
|
|
292
|
+
|
|
293
|
+
# If template_path contains a slash, treat as relative to templates_dir
|
|
294
|
+
if "/" in template_path or "\\" in template_path:
|
|
295
|
+
full_path = templates_dir / template_path
|
|
296
|
+
if full_path.exists():
|
|
297
|
+
return full_path
|
|
298
|
+
|
|
299
|
+
# Otherwise, search in component_type subdirectory
|
|
300
|
+
if component_type:
|
|
301
|
+
full_path = templates_dir / component_type / template_path
|
|
302
|
+
if full_path.exists():
|
|
303
|
+
return full_path
|
|
304
|
+
|
|
305
|
+
# Try just in templates_dir
|
|
306
|
+
full_path = templates_dir / template_path
|
|
307
|
+
if full_path.exists():
|
|
308
|
+
return full_path
|
|
309
|
+
|
|
310
|
+
# Not found
|
|
311
|
+
raise click.UsageError(
|
|
312
|
+
f"Template file not found: {template_path}\n"
|
|
313
|
+
f"Searched in: {templates_dir}" + (f"/{component_type}" if component_type else "")
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def scan_template_parameters(template_content: str) -> Dict[str, Optional[str]]:
|
|
318
|
+
"""
|
|
319
|
+
Scan template content for all unique template parameters.
|
|
320
|
+
|
|
321
|
+
Template parameters use {{parameterName}} or {{parameterName:default value}} syntax.
|
|
322
|
+
Default values can span multiple lines.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
template_content: Template file content as string
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Dict[str, Optional[str]]: Dictionary mapping parameter names to their default values
|
|
329
|
+
(None if no default provided)
|
|
330
|
+
"""
|
|
331
|
+
parameters = {}
|
|
332
|
+
|
|
333
|
+
# Pattern to match {{parameterName:default value}} (with default, supports multi-line)
|
|
334
|
+
# Uses non-greedy matching to stop at the first }} (closing braces)
|
|
335
|
+
# The (?s) flag makes . match newlines
|
|
336
|
+
pattern_with_default = r"\{\{(\w+):(.*?)\}\}"
|
|
337
|
+
matches_with_default = re.findall(pattern_with_default, template_content, re.DOTALL)
|
|
338
|
+
for param_name, default_value in matches_with_default:
|
|
339
|
+
# Strip leading/trailing whitespace but preserve internal structure
|
|
340
|
+
parameters[param_name] = default_value.strip()
|
|
341
|
+
|
|
342
|
+
# Pattern to match {{parameterName}} (without default)
|
|
343
|
+
# But exclude those that were already matched with defaults
|
|
344
|
+
pattern_no_default = r"\{\{(\w+)\}\}"
|
|
345
|
+
matches_no_default = re.findall(pattern_no_default, template_content)
|
|
346
|
+
for param_name in matches_no_default:
|
|
347
|
+
# Only add if not already found with a default value
|
|
348
|
+
if param_name not in parameters:
|
|
349
|
+
parameters[param_name] = None
|
|
350
|
+
|
|
351
|
+
return parameters
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def display_template_parameter_summary(
|
|
355
|
+
parameters: Dict[str, Optional[str]],
|
|
356
|
+
provided_values: Optional[Dict[str, str]] = None,
|
|
357
|
+
) -> None:
|
|
358
|
+
"""
|
|
359
|
+
Display a summary of template parameters to the user.
|
|
360
|
+
|
|
361
|
+
Shows all parameters, indicating which have defaults, which are already provided,
|
|
362
|
+
and which still need values.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
parameters: Dictionary mapping parameter names to their default values (None if no default)
|
|
366
|
+
provided_values: Optional dictionary of already-provided parameter values
|
|
367
|
+
"""
|
|
368
|
+
if not parameters:
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
if provided_values is None:
|
|
372
|
+
provided_values = {}
|
|
373
|
+
|
|
374
|
+
click.echo("\n" + "=" * 70)
|
|
375
|
+
click.echo("Template Parameter Summary")
|
|
376
|
+
click.echo("=" * 70)
|
|
377
|
+
|
|
378
|
+
# Separate parameters into categories
|
|
379
|
+
with_defaults = []
|
|
380
|
+
without_defaults = []
|
|
381
|
+
already_provided = []
|
|
382
|
+
|
|
383
|
+
for param_name in sorted(parameters.keys()):
|
|
384
|
+
if param_name in provided_values:
|
|
385
|
+
already_provided.append((param_name, provided_values[param_name]))
|
|
386
|
+
elif parameters[param_name] is not None:
|
|
387
|
+
with_defaults.append((param_name, parameters[param_name]))
|
|
388
|
+
else:
|
|
389
|
+
without_defaults.append(param_name)
|
|
390
|
+
|
|
391
|
+
# Display already provided parameters
|
|
392
|
+
if already_provided:
|
|
393
|
+
click.echo("\n✓ Already Provided:")
|
|
394
|
+
for param_name, value in already_provided:
|
|
395
|
+
# Truncate long values for display
|
|
396
|
+
display_value = value if len(value) <= 60 else value[:57] + "..."
|
|
397
|
+
if "\n" in display_value:
|
|
398
|
+
display_value = display_value.split("\n")[0] + " ... (multi-line)"
|
|
399
|
+
click.echo(f" • {param_name}: {display_value}")
|
|
400
|
+
|
|
401
|
+
# Display parameters with defaults
|
|
402
|
+
if with_defaults:
|
|
403
|
+
click.echo("\nParameters with Default Values:")
|
|
404
|
+
for param_name, default_value in with_defaults:
|
|
405
|
+
# Truncate long defaults for display
|
|
406
|
+
display_default = default_value if len(default_value) <= 60 else default_value[:57] + "..."
|
|
407
|
+
if "\n" in display_default:
|
|
408
|
+
display_default = display_default.split("\n")[0] + " ... (multi-line)"
|
|
409
|
+
click.echo(f" • {param_name}")
|
|
410
|
+
click.echo(f" Default: {display_default}")
|
|
411
|
+
|
|
412
|
+
# Display parameters without defaults
|
|
413
|
+
if without_defaults:
|
|
414
|
+
click.echo("\nParameters Requiring Values (no defaults):")
|
|
415
|
+
for param_name in without_defaults:
|
|
416
|
+
click.echo(f" • {param_name}")
|
|
417
|
+
|
|
418
|
+
click.echo("\n" + "=" * 70)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def prompt_for_template_parameters(
|
|
422
|
+
parameters: Dict[str, Optional[str]],
|
|
423
|
+
existing_values: Optional[Dict[str, str]] = None,
|
|
424
|
+
) -> Dict[str, str]:
|
|
425
|
+
"""
|
|
426
|
+
Prompt user for values for all template parameters.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
parameters: Dictionary mapping parameter names to their default values (None if no default)
|
|
430
|
+
existing_values: Optional dict of pre-filled values (won't prompt for these)
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
Dict[str, str]: Dictionary mapping parameter names to values
|
|
434
|
+
"""
|
|
435
|
+
if existing_values is None:
|
|
436
|
+
existing_values = {}
|
|
437
|
+
|
|
438
|
+
values = {}
|
|
439
|
+
sorted_params = sorted(parameters.keys())
|
|
440
|
+
|
|
441
|
+
for param in sorted_params:
|
|
442
|
+
if param in existing_values:
|
|
443
|
+
values[param] = existing_values[param]
|
|
444
|
+
click.echo(f"{param}: {existing_values[param]} (using provided value)")
|
|
445
|
+
else:
|
|
446
|
+
# Convert parameter name to a more readable prompt
|
|
447
|
+
prompt_text = param.replace("_", " ").replace("-", " ").title()
|
|
448
|
+
default_value = parameters.get(param)
|
|
449
|
+
|
|
450
|
+
if default_value is not None:
|
|
451
|
+
# Use default value in prompt
|
|
452
|
+
value = click.prompt(
|
|
453
|
+
f"Enter value for {prompt_text} ({param})",
|
|
454
|
+
default=default_value,
|
|
455
|
+
type=str,
|
|
456
|
+
)
|
|
457
|
+
else:
|
|
458
|
+
# No default, require input
|
|
459
|
+
value = click.prompt(f"Enter value for {prompt_text} ({param})", type=str)
|
|
460
|
+
values[param] = value
|
|
461
|
+
|
|
462
|
+
return values
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def replace_template_parameters(template_content: str, parameter_values: Dict[str, str]) -> str:
|
|
466
|
+
"""
|
|
467
|
+
Replace template parameters with provided values.
|
|
468
|
+
|
|
469
|
+
Handles both formats:
|
|
470
|
+
- {{parameterName}} (no default)
|
|
471
|
+
- {{parameterName:default value}} (with default, supports multi-line)
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
template_content: Template content with {{parameterName}} or {{parameterName:default}} placeholders
|
|
475
|
+
parameter_values: Dictionary mapping parameter names to values
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
str: Template content with parameters replaced
|
|
479
|
+
"""
|
|
480
|
+
result = template_content
|
|
481
|
+
for param_name, param_value in parameter_values.items():
|
|
482
|
+
# Replace {{paramName:default}} format first (more specific, supports multi-line)
|
|
483
|
+
# Use non-greedy matching with DOTALL flag to handle multi-line defaults
|
|
484
|
+
pattern_with_default = r"\{\{" + re.escape(param_name) + r":.*?\}\}"
|
|
485
|
+
result = re.sub(pattern_with_default, str(param_value), result, flags=re.DOTALL)
|
|
486
|
+
|
|
487
|
+
# Then replace {{paramName}} format (no default)
|
|
488
|
+
pattern_no_default = r"\{\{" + re.escape(param_name) + r"\}\}"
|
|
489
|
+
result = re.sub(pattern_no_default, str(param_value), result)
|
|
490
|
+
return result
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def load_and_process_template(
|
|
494
|
+
template_path: str,
|
|
495
|
+
component_type: Optional[str] = None,
|
|
496
|
+
parameter_values: Optional[Dict[str, str]] = None,
|
|
497
|
+
prompt_missing: bool = True,
|
|
498
|
+
) -> str:
|
|
499
|
+
"""
|
|
500
|
+
Load a template file, scan for parameters, prompt for values, and replace them.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
template_path: Path to template file
|
|
504
|
+
component_type: Optional component type subdirectory
|
|
505
|
+
parameter_values: Optional pre-filled parameter values
|
|
506
|
+
prompt_missing: Whether to prompt for missing parameters (default: True)
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
str: Processed template content with all parameters replaced
|
|
510
|
+
"""
|
|
511
|
+
# Find and load template file
|
|
512
|
+
template_file = find_template_file(template_path, component_type)
|
|
513
|
+
|
|
514
|
+
try:
|
|
515
|
+
with open(template_file, "r", encoding="utf-8") as f:
|
|
516
|
+
template_content = f.read()
|
|
517
|
+
except Exception as e:
|
|
518
|
+
raise click.UsageError(f"Error reading template file {template_file}: {e}")
|
|
519
|
+
|
|
520
|
+
# Scan for parameters (returns dict with parameter names and default values)
|
|
521
|
+
parameters = scan_template_parameters(template_content)
|
|
522
|
+
|
|
523
|
+
if not parameters:
|
|
524
|
+
# No parameters to replace, return as-is
|
|
525
|
+
return template_content
|
|
526
|
+
|
|
527
|
+
# Get parameter values
|
|
528
|
+
if parameter_values is None:
|
|
529
|
+
parameter_values = {}
|
|
530
|
+
|
|
531
|
+
# Prompt for missing parameters if requested
|
|
532
|
+
if prompt_missing:
|
|
533
|
+
missing_params = {k: v for k, v in parameters.items() if k not in parameter_values}
|
|
534
|
+
if missing_params:
|
|
535
|
+
# Display summary of all parameters (including those already provided)
|
|
536
|
+
display_template_parameter_summary(parameters, parameter_values)
|
|
537
|
+
|
|
538
|
+
# Ask user if they want to continue or abort
|
|
539
|
+
click.echo("\nYou can proceed to provide values for the missing parameters,")
|
|
540
|
+
click.echo("or abort now to gather the necessary information first.")
|
|
541
|
+
if not click.confirm("\nDo you want to continue with parameter entry?", default=True):
|
|
542
|
+
click.echo("\nOperation cancelled. Please gather the required information and try again.")
|
|
543
|
+
raise click.Abort()
|
|
544
|
+
|
|
545
|
+
click.echo(f"\nTemplate contains {len(missing_params)} parameter(s) that need values:")
|
|
546
|
+
prompted_values = prompt_for_template_parameters(missing_params, parameter_values)
|
|
547
|
+
parameter_values.update(prompted_values)
|
|
548
|
+
else:
|
|
549
|
+
# If not prompting, apply default values for parameters that don't have values yet
|
|
550
|
+
for param_name, default_value in parameters.items():
|
|
551
|
+
if param_name not in parameter_values and default_value is not None:
|
|
552
|
+
parameter_values[param_name] = default_value
|
|
553
|
+
|
|
554
|
+
# Check that all parameters have values
|
|
555
|
+
missing = set(parameters.keys()) - set(parameter_values.keys())
|
|
556
|
+
if missing:
|
|
557
|
+
raise click.UsageError(
|
|
558
|
+
f"Missing values for template parameters: {', '.join(sorted(missing))}\n"
|
|
559
|
+
f"Please provide values for all parameters."
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
# Replace parameters
|
|
563
|
+
processed_content = replace_template_parameters(template_content, parameter_values)
|
|
564
|
+
|
|
565
|
+
return processed_content
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyboomi-cli
|
|
3
|
+
Version: 0.2.5
|
|
4
|
+
Summary: Command-line interface for Boomi Platform API
|
|
5
|
+
Author-email: Robert Little <robert@iesoftwaredeveloper.com>
|
|
6
|
+
Maintainer-email: Robert Little <robert@iesoftwaredeveloper.com>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Project-URL: Homepage, https://github.com/iesoftwaredeveloper/pyboomi-cli
|
|
9
|
+
Project-URL: Documentation, https://github.com/iesoftwaredeveloper/pyboomi-cli#readme
|
|
10
|
+
Project-URL: Repository, https://github.com/iesoftwaredeveloper/pyboomi-cli.git
|
|
11
|
+
Project-URL: Bug Tracker, https://github.com/iesoftwaredeveloper/pyboomi-cli/issues
|
|
12
|
+
Project-URL: Changelog, https://github.com/iesoftwaredeveloper/pyboomi-cli/blob/main/CHANGELOG.md
|
|
13
|
+
Keywords: boomi,api,cli,integration,platform,etl,middleware
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: System Administrators
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Classifier: Topic :: System :: Systems Administration
|
|
27
|
+
Classifier: Topic :: Utilities
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.8
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Requires-Dist: pyboomi_platform>=0.1.0
|
|
33
|
+
Requires-Dist: click>=8.1.0
|
|
34
|
+
Requires-Dist: rich>=13.0.0
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
37
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
38
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
39
|
+
Requires-Dist: flake8>=6.0.0; extra == "dev"
|
|
40
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
41
|
+
Requires-Dist: isort>=5.12.0; extra == "dev"
|
|
42
|
+
Requires-Dist: pre-commit>=3.0.0; extra == "dev"
|
|
43
|
+
Requires-Dist: tox>=4.0.0; extra == "dev"
|
|
44
|
+
Requires-Dist: build>=0.10.0; extra == "dev"
|
|
45
|
+
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
46
|
+
Provides-Extra: test
|
|
47
|
+
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
48
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "test"
|
|
49
|
+
Provides-Extra: lint
|
|
50
|
+
Requires-Dist: black>=23.0.0; extra == "lint"
|
|
51
|
+
Requires-Dist: flake8>=6.0.0; extra == "lint"
|
|
52
|
+
Requires-Dist: mypy>=1.0.0; extra == "lint"
|
|
53
|
+
Requires-Dist: isort>=5.12.0; extra == "lint"
|
|
54
|
+
Provides-Extra: docs
|
|
55
|
+
Requires-Dist: sphinx>=5.0.0; extra == "docs"
|
|
56
|
+
Requires-Dist: sphinx-rtd-theme>=1.0.0; extra == "docs"
|
|
57
|
+
Requires-Dist: sphinx-autodoc-typehints>=1.19.0; extra == "docs"
|
|
58
|
+
Dynamic: license-file
|
|
59
|
+
|
|
60
|
+
# pyboomi-cli
|
|
61
|
+
|
|
62
|
+
Command-line interface for the Boomi Platform API.
|
|
63
|
+
|
|
64
|
+
## Installation
|
|
65
|
+
|
|
66
|
+
### For Development
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Clone the repository
|
|
70
|
+
git clone https://github.com/iesoftwaredeveloper/pyboomi-cli
|
|
71
|
+
cd pyboomi-cli
|
|
72
|
+
|
|
73
|
+
# Install with development dependencies
|
|
74
|
+
pip install -e .[dev]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### For Usage (when published)
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install pyboomi-cli
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### From Source
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
git clone https://github.com/iesoftwaredeveloper/pyboomi-cli
|
|
87
|
+
cd pyboomi-cli
|
|
88
|
+
pip install -e .
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Usage
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Get help (both entry points are available)
|
|
95
|
+
boomi --help
|
|
96
|
+
pyboomi --help
|
|
97
|
+
|
|
98
|
+
# Query folders (folders alias is supported)
|
|
99
|
+
boomi folder query --account-id YOUR_ACCOUNT_ID --username YOUR_USERNAME --api-token YOUR_API_TOKEN
|
|
100
|
+
# or
|
|
101
|
+
boomi folders query --account-id YOUR_ACCOUNT_ID --username YOUR_USERNAME --api-token YOUR_API_TOKEN
|
|
102
|
+
|
|
103
|
+
# Filter folders by name and print verbose request/response
|
|
104
|
+
boomi folder query --name "My Folder" --verbose
|
|
105
|
+
|
|
106
|
+
# Filter folders with parent constraints
|
|
107
|
+
boomi folder query --name "My Folder" --parent-id 0000-parent-id
|
|
108
|
+
boomi folder query --name "My Folder" --parent-name "Parent Folder"
|
|
109
|
+
boomi folder query --name "My Folder" --parent-path "/Root/Parent"
|
|
110
|
+
|
|
111
|
+
# Create a folder (parent can be provided by id or name/path)
|
|
112
|
+
boomi folder create --name "New Folder" --parent-id 0000-parent-id
|
|
113
|
+
boomi folder create --name "New Folder" --parent-name "Parent Folder"
|
|
114
|
+
boomi folder create --name "New Folder" --parent-name "Parent Folder" --parent-path "/Root/Parent"
|
|
115
|
+
|
|
116
|
+
# Using environment variables
|
|
117
|
+
export BOOMI_ACCT=your-account-id
|
|
118
|
+
export BOOMI_USER=your-username
|
|
119
|
+
export BOOMI_TOKEN=your-api-token
|
|
120
|
+
boomi folder query
|
|
121
|
+
|
|
122
|
+
# Get component XML (optionally include branch/version with ~)
|
|
123
|
+
boomi component get 1234-abcd
|
|
124
|
+
boomi component get 1234-abcd --branch-id 5678-branch --version 1.0
|
|
125
|
+
|
|
126
|
+
# Get component metadata for a branch (json|yaml|text)
|
|
127
|
+
boomi component metadata 1234-abcd --branch-id 5678-branch --output-format json
|
|
128
|
+
|
|
129
|
+
# Create a packaged component
|
|
130
|
+
boomi package create 1234-abcd --version 1.0.0 --notes "Initial package"
|
|
131
|
+
|
|
132
|
+
# Query packaged components by component ID or version (supports xml)
|
|
133
|
+
boomi package query --component-id 1234-abcd --package-version 1.0.0 --output-format xml
|
|
134
|
+
|
|
135
|
+
# Query branches (filter by name or parent)
|
|
136
|
+
boomi branch query --name "feature-x"
|
|
137
|
+
|
|
138
|
+
# Create a new branch under a parent branch
|
|
139
|
+
boomi branch create 1111-parent "feature-x"
|
|
140
|
+
|
|
141
|
+
# Update a branch
|
|
142
|
+
boomi branch update 2222-branch --name "feature-x-renamed" --ready
|
|
143
|
+
|
|
144
|
+
# Delete a branch
|
|
145
|
+
boomi branch delete 2222-branch
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Commands
|
|
149
|
+
|
|
150
|
+
- `folder` — Manage Boomi folders (group command, alias: `folders`)
|
|
151
|
+
- Subcommands:
|
|
152
|
+
- `folder query` — Query and list folders (`--name`, `--parent-id`, `--parent-name`, `--parent-path`, `--verbose`)
|
|
153
|
+
- `folder create` — Create a folder (`--name` required; `--parent-id` or `--parent-name` required, with optional `--parent-path` to disambiguate; `--output-format` `json|yaml|text`)
|
|
154
|
+
- Common options: `--account-id`, `--username`, `--api-token`
|
|
155
|
+
- `component` — Manage Boomi components (group command)
|
|
156
|
+
- Subcommands:
|
|
157
|
+
- `component get <component_id>` — Get component XML (`--branch-id`, `--version` optional; always returns XML)
|
|
158
|
+
- `component metadata <component_id>` — Get component metadata (`--branch-id` optional; `--output-format` `json|yaml|text`)
|
|
159
|
+
- Common options: `--account-id`, `--username`, `--api-token`
|
|
160
|
+
- `package` — Manage Boomi packages (group command)
|
|
161
|
+
- Subcommands:
|
|
162
|
+
- `package create <component_id> --version <version>` — Create packaged component (`--notes`, `--branch-name`, `--output-format` `json|yaml|text|xml`)
|
|
163
|
+
- `package get <packaged_component_id>` — Get packaged component (`--output-format` `json|yaml|text|xml`)
|
|
164
|
+
- `package query` — Query packaged components (`--component-id`, `--package-version`, `--output-format` `json|yaml|text|xml`)
|
|
165
|
+
- Common options: `--account-id`, `--username`, `--api-token`
|
|
166
|
+
- `branch` — Manage Boomi branches (group command)
|
|
167
|
+
- Subcommands:
|
|
168
|
+
- `branch get <branch_id>` — Get a branch
|
|
169
|
+
- `branch query` — Query branches (`--name`, `--parent-branch-id`, `--package-id`, `--ready`, `--output-format` `json|yaml|text`)
|
|
170
|
+
- `branch create <parent_branch_id> <branch_name>` — Create a branch (`--package-id`, `--output-format` `json|yaml|text`)
|
|
171
|
+
- `branch update <branch_id>` — Update a branch (`--name`, `--description`, `--ready/--not-ready`, `--output-format` `json|yaml|text`)
|
|
172
|
+
- `branch delete <branch_id>` — Delete a branch
|
|
173
|
+
- Common options: `--account-id`, `--username`, `--api-token`
|
|
174
|
+
- `process` — Manage Boomi processes (alias: `processes`) — placeholder
|
|
175
|
+
|
|
176
|
+
## Environment Variables
|
|
177
|
+
|
|
178
|
+
- `BOOMI_ACCT`: Your Boomi account ID
|
|
179
|
+
- `BOOMI_USER`: Your Boomi username (email)
|
|
180
|
+
- `BOOMI_TOKEN`: Your Boomi API token
|
|
181
|
+
- `BOOMI_AUTH`: Base64 encoded Basic Auth string (alternative to username/token)
|
|
182
|
+
|
|
183
|
+
## Output Formats
|
|
184
|
+
|
|
185
|
+
Many commands support `--output-format` with the following values:
|
|
186
|
+
|
|
187
|
+
- `json` (default)
|
|
188
|
+
- `yaml` (falls back to json if PyYAML is not installed)
|
|
189
|
+
- `text` (simple key/value text)
|
|
190
|
+
- `xml` (returns raw XML when requested; defaults to XML for `component get`)
|
|
191
|
+
|
|
192
|
+
## Templates
|
|
193
|
+
|
|
194
|
+
The `templates/` directory contains XML templates for various Boomi component types including:
|
|
195
|
+
|
|
196
|
+
- **Processes**: Unit test harnesses and process templates
|
|
197
|
+
- **Connectors**: Database, API, and file connectors
|
|
198
|
+
- **Profiles**: JSON, XML, flat file, and database profiles
|
|
199
|
+
- **Maps**: Data mapping templates and functions
|
|
200
|
+
- **APIs**: API services and proxy configurations
|
|
201
|
+
- **Scripts**: Process and map scripts
|
|
202
|
+
- **Certificates**: Security certificate templates
|
|
203
|
+
|
|
204
|
+
These templates provide standardized starting points for creating Boomi components. See [TEMPLATE_SYSTEM.md](TEMPLATE_SYSTEM.md) for detailed information about the template system and usage.
|
|
205
|
+
|
|
206
|
+
## Documentation
|
|
207
|
+
|
|
208
|
+
- [CHANGELOG.md](CHANGELOG.md) - Version history and release notes
|
|
209
|
+
- [TEMPLATE_SYSTEM.md](TEMPLATE_SYSTEM.md) - Template system documentation
|
|
210
|
+
- [TEMPLATES_STRUCTURE.md](TEMPLATES_STRUCTURE.md) - Template directory structure
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pyboomi_cli/__init__.py,sha256=DDPpf2yYk4-AwHdhfDZPDe8Nvi2-Ca-4aNSMtPdPvKc,1824
|
|
2
|
+
pyboomi_cli/cache.py,sha256=-FYcR1DeRos9bF3M9yEeOLYBlDsK1_RzD89TJNJhKLw,3677
|
|
3
|
+
pyboomi_cli/cli.py,sha256=tmd7MS83o0EOZS0M-jfilZOj1Yg6sgZ6ZVB1FGaeECs,2218
|
|
4
|
+
pyboomi_cli/config.py,sha256=JxlVQr43xrNWSwr18nLigKDVQ55Ughl0z0JD1Q3uDUQ,1438
|
|
5
|
+
pyboomi_cli/utils.py,sha256=37u0CE2UWnBrZrcHSSJv5zrvCe5jeZ7eVVdxYHKgCvA,20286
|
|
6
|
+
pyboomi_cli-0.2.5.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
7
|
+
pyboomi_cli-0.2.5.dist-info/METADATA,sha256=zApTduaWleJDFp8bg0n7VdTaNOcINIrZlpUSGT3Eb10,8657
|
|
8
|
+
pyboomi_cli-0.2.5.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
9
|
+
pyboomi_cli-0.2.5.dist-info/entry_points.txt,sha256=qigTo4ZwZ5MVK5OfkpB75_i9_2_q36dPZsyqa-grsK0,78
|
|
10
|
+
pyboomi_cli-0.2.5.dist-info/top_level.txt,sha256=hUdkMJxOXraWdexC9lBVRhSOs_XeN1q1ZGdFofQizF0,12
|
|
11
|
+
pyboomi_cli-0.2.5.dist-info/RECORD,,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyboomi_cli
|