python-jcli 0.1.0__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.
@@ -0,0 +1,128 @@
1
+ """Job XML template generation for parameterized job creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def generate_freestyle_xml(
7
+ git_url: str = "",
8
+ git_branch: str = "main",
9
+ shell_script: str = "",
10
+ description: str = "",
11
+ cron_schedule: str = "",
12
+ ) -> str:
13
+ """Generate XML for a Freestyle project."""
14
+ triggers_xml = ""
15
+ if cron_schedule:
16
+ triggers_xml = f"""
17
+ <triggers>
18
+ <hudson.triggers.TimerTrigger>
19
+ <spec>{cron_schedule}</spec>
20
+ </hudson.triggers.TimerTrigger>
21
+ </triggers>"""
22
+
23
+ scm_xml = ""
24
+ if git_url:
25
+ scm_xml = f"""
26
+ <scm class="hudson.plugins.git.GitSCM" plugin="git">
27
+ <configVersion>2</configVersion>
28
+ <userRemoteConfigs>
29
+ <hudson.plugins.git.UserRemoteConfig>
30
+ <url>{git_url}</url>
31
+ </hudson.plugins.git.UserRemoteConfig>
32
+ </userRemoteConfigs>
33
+ <branches>
34
+ <hudson.plugins.git.BranchSpec>
35
+ <name>*/{git_branch}</name>
36
+ </hudson.plugins.git.BranchSpec>
37
+ </branches>
38
+ <doGenerateSubmoduleConfigurations>false</doGenerateSubmoduleConfigurations>
39
+ </scm>"""
40
+
41
+ builders_xml = ""
42
+ if shell_script:
43
+ builders_xml = f"""
44
+ <builders>
45
+ <hudson.tasks.Shell>
46
+ <command>{_escape_xml(shell_script)}</command>
47
+ <configuredLocalRules/>
48
+ </hudson.tasks.Shell>
49
+ </builders>"""
50
+
51
+ return f"""<?xml version='1.1' encoding='UTF-8'?>
52
+ <project>
53
+ <description>{_escape_xml(description)}</description>
54
+ <keepDependencies>false</keepDependencies>
55
+ <properties/>
56
+ {scm_xml}
57
+ <canRoam>true</canRoam>
58
+ <disabled>false</disabled>
59
+ <blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>
60
+ <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
61
+ {triggers_xml}
62
+ <concurrentBuild>false</concurrentBuild>
63
+ {builders_xml}
64
+ <publishers/>
65
+ <buildWrappers/>
66
+ </project>"""
67
+
68
+
69
+ def generate_pipeline_xml(
70
+ git_url: str = "",
71
+ git_branch: str = "main",
72
+ jenkinsfile_path: str = "Jenkinsfile",
73
+ description: str = "",
74
+ script: str = "",
75
+ ) -> str:
76
+ """Generate XML for a Pipeline project.
77
+
78
+ If ``script`` is provided the definition uses ``CpsFlowDefinition``
79
+ (inline Pipeline script). Otherwise when ``git_url`` is given the
80
+ definition is ``CpsScmFlowDefinition`` (Pipeline script from SCM).
81
+ """
82
+ definition_xml: str
83
+ if script:
84
+ # Inline script mode (CpsFlowDefinition)
85
+ definition_xml = f"""
86
+ <definition class="org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition" plugin="workflow-cps">
87
+ <script>{_escape_xml(script)}</script>
88
+ <sandbox>true</sandbox>
89
+ </definition>"""
90
+ elif git_url:
91
+ # SCM-based mode (CpsScmFlowDefinition)
92
+ definition_xml = f"""
93
+ <definition class="org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition" plugin="workflow-cps">
94
+ <scm class="hudson.plugins.git.GitSCM" plugin="git">
95
+ <configVersion>2</configVersion>
96
+ <userRemoteConfigs>
97
+ <hudson.plugins.git.UserRemoteConfig>
98
+ <url>{git_url}</url>
99
+ </hudson.plugins.git.UserRemoteConfig>
100
+ </userRemoteConfigs>
101
+ <branches>
102
+ <hudson.plugins.git.BranchSpec>
103
+ <name>*/{git_branch}</name>
104
+ </hudson.plugins.git.BranchSpec>
105
+ </branches>
106
+ <doGenerateSubmoduleConfigurations>false</doGenerateSubmoduleConfigurations>
107
+ </scm>
108
+ <scriptPath>{jenkinsfile_path}</scriptPath>
109
+ <lightweight>true</lightweight>
110
+ </definition>"""
111
+ else:
112
+ # Bare minimum (no SCM, no script)
113
+ definition_xml = ""
114
+
115
+ return f"""<?xml version='1.1' encoding='UTF-8'?>
116
+ <flow-definition plugin="workflow-job">
117
+ <description>{_escape_xml(description)}</description>
118
+ <keepDependencies>false</keepDependencies>
119
+ <properties/>
120
+ {definition_xml}
121
+ <triggers/>
122
+ <disabled>false</disabled>
123
+ </flow-definition>"""
124
+
125
+
126
+ def _escape_xml(text: str) -> str:
127
+ """Escape special XML characters."""
128
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
jcli/sdk/node.py ADDED
@@ -0,0 +1,139 @@
1
+ """Jenkins Node (Agent) SDK functions.
2
+
3
+ All functions take a :class:`~jcli.sdk.client.JenkinsClient` as the first argument.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from typing import TYPE_CHECKING, Any
10
+ from urllib.parse import quote
11
+
12
+ if TYPE_CHECKING:
13
+ from jcli.sdk.client import JenkinsClient
14
+
15
+ # ------------------------------------------------------------------
16
+ # Public API
17
+ # ------------------------------------------------------------------
18
+
19
+
20
+ def list_nodes(client: "JenkinsClient") -> list[dict[str, Any]]:
21
+ """List all Jenkins nodes (built-in node + agents).
22
+
23
+ Calls ``GET /computer/api/json``.
24
+
25
+ Args:
26
+ client: Authenticated Jenkins API client.
27
+
28
+ Returns:
29
+ List of node dictionaries, each containing ``displayName``,
30
+ ``offline``, ``temporarilyOffline``, and other fields.
31
+ """
32
+ data = client.get_json("/computer/api/json")
33
+ return data.get("computer", [])
34
+
35
+
36
+ def get_node(client: "JenkinsClient", name: str) -> dict[str, Any]:
37
+ """Get details for a specific node.
38
+
39
+ Calls ``GET /computer/{name}/api/json``.
40
+
41
+ Args:
42
+ client: Authenticated Jenkins API client.
43
+ name: Node display name (e.g. ``"built-in"``, ``"agent-1"``).
44
+
45
+ Returns:
46
+ Node detail dictionary.
47
+ """
48
+ return client.get_json(f"/computer/{quote(name, safe='')}/api/json")
49
+
50
+
51
+ def delete_node(client: "JenkinsClient", name: str) -> bool:
52
+ """Delete a node (agent) from Jenkins.
53
+
54
+ Calls ``POST /computer/{name}/doDelete``. The built-in node
55
+ cannot be deleted.
56
+
57
+ Args:
58
+ client: Authenticated Jenkins API client.
59
+ name: Node display name to delete.
60
+
61
+ Returns:
62
+ ``True`` on success (HTTP 200/302).
63
+ """
64
+ resp = client.request("POST", f"/computer/{quote(name, safe='')}/doDelete")
65
+ return resp.ok
66
+
67
+
68
+ def toggle_offline(
69
+ client: "JenkinsClient",
70
+ name: str,
71
+ msg: str = "",
72
+ ) -> bool:
73
+ """Toggle a node's offline/online state.
74
+
75
+ If the node is currently online, this marks it temporarily offline.
76
+ If already temporarily offline, this brings it back online.
77
+ Calls ``POST /computer/{name}/toggleOffline``.
78
+
79
+ Args:
80
+ client: Authenticated Jenkins API client.
81
+ name: Node display name.
82
+ msg: Optional offline message (shown when marking offline).
83
+
84
+ Returns:
85
+ ``True`` on success (HTTP 200/302).
86
+ """
87
+ data: dict[str, Any] = {}
88
+ if msg:
89
+ data["offlineMessage"] = msg
90
+ resp = client.post_data(f"/computer/{quote(name, safe='')}/toggleOffline", data=data)
91
+ return resp.ok
92
+
93
+
94
+ def create_node(
95
+ client: "JenkinsClient",
96
+ name: str,
97
+ num_executors: int = 1,
98
+ remote_fs: str = "/tmp",
99
+ labels: str = "",
100
+ launch_method: str = "hudson.slaves.JNLPLauncher",
101
+ ) -> Any:
102
+ """Create a new Jenkins agent node.
103
+
104
+ Uses form submission to create a DumbSlave node via
105
+ ``POST /computer/doCreateItem``.
106
+
107
+ Args:
108
+ client: Authenticated Jenkins API client.
109
+ name: Node display name.
110
+ num_executors: Number of executors (default: 1).
111
+ remote_fs: Remote filesystem root (default: ``/tmp``).
112
+ labels: Node labels string.
113
+ launch_method: Stapler launch method class name.
114
+
115
+ Returns:
116
+ ``requests.Response`` from the creation POST.
117
+ """
118
+ data = {
119
+ "name": name,
120
+ "type": "hudson.slaves.DumbSlave",
121
+ "json": json.dumps({
122
+ "name": name,
123
+ "nodeDescription": "",
124
+ "numExecutors": str(num_executors),
125
+ "remoteFS": remote_fs,
126
+ "labelString": labels,
127
+ "mode": "NORMAL",
128
+ "type": "hudson.slaves.DumbSlave",
129
+ "retentionStrategy": {"stapler-class": "hudson.slaves.RetentionStrategy$Always"},
130
+ "nodeProperties": {"stapler-class-bag": "true"},
131
+ "launcher": {"stapler-class": launch_method},
132
+ }),
133
+ }
134
+ return client.request(
135
+ "POST",
136
+ "/computer/doCreateItem",
137
+ params={"name": name, "type": "hudson.slaves.DumbSlave"},
138
+ data=data,
139
+ )
@@ -0,0 +1,3 @@
1
+ from .formatter import OutputFormatter
2
+
3
+ __all__ = ["OutputFormatter"]
@@ -0,0 +1,131 @@
1
+ import json
2
+ from typing import Any, Dict, List, Optional, Union
3
+
4
+ import yaml
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+
9
+ class OutputFormatter:
10
+ """Output formatter supporting table, JSON, and YAML formats using Rich library."""
11
+
12
+ def __init__(self, format_type: str = "table"):
13
+ """Initialize formatter.
14
+
15
+ Args:
16
+ format_type: Output format - "table", "json", or "yaml"
17
+ """
18
+ if format_type not in ("table", "json", "yaml"):
19
+ raise ValueError(f"format_type must be 'table', 'json', or 'yaml', got '{format_type}'")
20
+ self.format_type = format_type
21
+ self.console = Console()
22
+ self.error_console = Console(stderr=True)
23
+
24
+ def print_table(
25
+ self,
26
+ headers: List[str],
27
+ rows: List[List[Any]],
28
+ title: Optional[str] = None,
29
+ ) -> None:
30
+ """Print data as a formatted table using Rich.
31
+
32
+ Args:
33
+ headers: Column headers
34
+ rows: List of row data
35
+ title: Optional table title
36
+ """
37
+ if self.format_type == "json":
38
+ self.print_json([dict(zip(headers, row)) for row in rows])
39
+ return
40
+
41
+ if self.format_type == "yaml":
42
+ self.print_yaml([dict(zip(headers, row)) for row in rows])
43
+ return
44
+
45
+ table = Table(
46
+ title=title,
47
+ show_header=True,
48
+ header_style="bold cyan",
49
+ box=None,
50
+ show_lines=False,
51
+ expand=False,
52
+ )
53
+
54
+ # Add columns with auto-width
55
+ for header in headers:
56
+ table.add_column(header, no_wrap=False)
57
+
58
+ # Add rows with alternating colors
59
+ for idx, row in enumerate(rows):
60
+ style = "dim" if idx % 2 == 1 else None
61
+ table.add_row(*[str(cell) for cell in row], style=style)
62
+
63
+ self.console.print(table)
64
+
65
+ def print_json(self, data: Union[Dict, List, Any]) -> None:
66
+ """Print data as formatted JSON.
67
+
68
+ When ``self.format_type`` is ``"yaml"``, delegates to
69
+ :meth:`print_yaml` instead.
70
+
71
+ Args:
72
+ data: Data to format as JSON
73
+ """
74
+ if self.format_type == "yaml":
75
+ self.print_yaml(data)
76
+ return
77
+
78
+ output = json.dumps(data, indent=2, ensure_ascii=False)
79
+ self.console.print(output)
80
+
81
+ def print_yaml(self, data: Union[Dict, List, Any]) -> None:
82
+ """Print data as formatted YAML.
83
+
84
+ Args:
85
+ data: Data to format as YAML. If already a string it is
86
+ printed as-is; otherwise :func:`yaml.dump` is used.
87
+ """
88
+ if isinstance(data, str):
89
+ print(data)
90
+ else:
91
+ output = yaml.dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False)
92
+ print(output, end="")
93
+
94
+ def print_error(self, message: str) -> None:
95
+ """Print error message in red to stderr.
96
+
97
+ Args:
98
+ message: Error message
99
+ """
100
+ self.error_console.print(f"[red]Error: {message}[/red]")
101
+
102
+ def print_success(self, message: str) -> None:
103
+ """Print success message in green.
104
+
105
+ Args:
106
+ message: Success message
107
+ """
108
+ self.console.print(f"[green]{message}[/green]")
109
+
110
+ def print_info(self, message: str) -> None:
111
+ """Print info message in blue.
112
+
113
+ Args:
114
+ message: Info message
115
+ """
116
+ self.console.print(f"[blue]{message}[/blue]")
117
+
118
+
119
+ def get_formatter(ctx) -> OutputFormatter:
120
+ """Create an OutputFormatter from Click context.
121
+
122
+ Reads ``ctx.obj["format"]`` (default ``"table"``).
123
+
124
+ Args:
125
+ ctx: Click context with ``ctx.obj`` dict.
126
+
127
+ Returns:
128
+ Configured ``OutputFormatter`` instance.
129
+ """
130
+ format_type = ctx.obj.get("format", "table") if ctx.obj else "table"
131
+ return OutputFormatter(format_type=format_type)
jcli/sdk/pipeline.py ADDED
@@ -0,0 +1,121 @@
1
+ """Jenkins Pipeline SDK functions.
2
+
3
+ Provides functions for querying Pipeline stages, step logs,
4
+ validating Jenkinsfiles, and checking pending input actions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from jcli.sdk.client import JenkinsClient
12
+
13
+
14
+ def get_pipeline_stages(
15
+ client: JenkinsClient,
16
+ job_name: str,
17
+ build_number: int | str,
18
+ ) -> Any:
19
+ """Get Pipeline stage information for a specific build.
20
+
21
+ Uses the Blue Ocean wfapi describe endpoint to retrieve stage
22
+ and step topology for declarative or scripted Pipelines.
23
+
24
+ Args:
25
+ client: Configured JenkinsClient instance.
26
+ job_name: Job (Pipeline) name.
27
+ build_number: Build number to query.
28
+
29
+ Returns:
30
+ Parsed JSON response containing stages, status, and duration info.
31
+
32
+ Raises:
33
+ JenkinsNotFoundError: If job or build doesn't exist.
34
+ JenkinsAuthError: If authentication fails.
35
+ JenkinsAPIError: For other API errors.
36
+ """
37
+ path = f"job/{job_name}/{build_number}/wfapi/describe"
38
+ return client.get_json(path)
39
+
40
+
41
+ def get_pipeline_log(
42
+ client: JenkinsClient,
43
+ job_name: str,
44
+ build_number: int | str,
45
+ node_id: str | int,
46
+ ) -> Any:
47
+ """Get log output for a specific Pipeline step (node).
48
+
49
+ Uses the Blue Ocean wfapi log endpoint to retrieve console
50
+ log text for an individual flow node (stage or parallel branch).
51
+
52
+ Args:
53
+ client: Configured JenkinsClient instance.
54
+ job_name: Job (Pipeline) name.
55
+ build_number: Build number to query.
56
+ node_id: Flow node ID (from stage/step info in describe).
57
+
58
+ Returns:
59
+ Parsed JSON response containing log text and metadata.
60
+
61
+ Raises:
62
+ JenkinsNotFoundError: If job, build, or node doesn't exist.
63
+ JenkinsAuthError: If authentication fails.
64
+ JenkinsAPIError: For other API errors.
65
+ """
66
+ path = f"job/{job_name}/{build_number}/execution/node/{node_id}/wfapi/log"
67
+ return client.get_json(path)
68
+
69
+
70
+ def validate_jenkinsfile(
71
+ client: JenkinsClient,
72
+ content: str,
73
+ ) -> Any:
74
+ """Validate a Jenkinsfile (declarative Pipeline) against the Jenkins server.
75
+
76
+ Sends the Jenkinsfile content to the pipeline-model-converter endpoint
77
+ and returns the validation result.
78
+
79
+ Args:
80
+ client: Configured JenkinsClient instance.
81
+ content: Jenkinsfile content as a string.
82
+
83
+ Returns:
84
+ Parsed JSON response with validation results (errors, warnings).
85
+
86
+ Raises:
87
+ JenkinsAuthError: If authentication fails.
88
+ JenkinsConnectionError: If server is unreachable.
89
+ """
90
+ resp = client.post_data(
91
+ "/pipeline-model-converter/validate",
92
+ data={"jenkinsfile": content},
93
+ )
94
+ return resp.json()
95
+
96
+
97
+ def get_pending_input(
98
+ client: JenkinsClient,
99
+ job_name: str,
100
+ build_number: int | str,
101
+ ) -> Any:
102
+ """Get pending input actions for a Pipeline build.
103
+
104
+ Retrieves input steps that are waiting for user interaction
105
+ (e.g., approval gate, parameter input).
106
+
107
+ Args:
108
+ client: Configured JenkinsClient instance.
109
+ job_name: Job (Pipeline) name.
110
+ build_number: Build number to query.
111
+
112
+ Returns:
113
+ Parsed JSON response containing pending input actions.
114
+
115
+ Raises:
116
+ JenkinsNotFoundError: If job or build doesn't exist.
117
+ JenkinsAuthError: If authentication fails.
118
+ JenkinsAPIError: For other API errors.
119
+ """
120
+ path = f"job/{job_name}/{build_number}/wfapi/pendingInputActions"
121
+ return client.get_json(path)
jcli/sdk/plugin.py ADDED
@@ -0,0 +1,155 @@
1
+ """Jenkins Plugin management SDK functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from jcli.sdk.client import JenkinsClient
9
+ from jcli.sdk.exceptions import JenkinsAPIError, JenkinsNotFoundError
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Jenkins plugin manager API paths
14
+ PLUGIN_MANAGER_API = "/pluginManager/api/json"
15
+ INSTALL_PLUGINS_PATH = "/pluginManager/installNecessaryPlugins"
16
+
17
+ # Tree filter for listing plugins (essential fields only)
18
+ PLUGIN_LIST_TREE = "plugins[shortName,version,active,hasUpdate]"
19
+
20
+ # Tree filter for plugin details (richer fields)
21
+ PLUGIN_DETAIL_TREE = (
22
+ "plugins[shortName,version,active,hasUpdate,longName,url,"
23
+ "requiredCoreVersion,hasRequireRestart]"
24
+ )
25
+
26
+
27
+ def _build_install_xml(plugins_dict: dict[str, str | None]) -> str:
28
+ """Build XML body for plugin installation request.
29
+
30
+ Args:
31
+ plugins_dict: Mapping of plugin shortName to optional version.
32
+ e.g. {"git": "5.0.0", "workflow-aggregator": None}
33
+
34
+ Returns:
35
+ XML string for POST to /pluginManager/installNecessaryPlugins.
36
+ """
37
+ lines = ["<jenkins>"]
38
+ for name, version in plugins_dict.items():
39
+ if version:
40
+ lines.append(f' <install plugin="{name}@{version}" />')
41
+ else:
42
+ lines.append(f' <install plugin="{name}" />')
43
+ lines.append("</jenkins>")
44
+ return "\n".join(lines)
45
+
46
+
47
+ def list_plugins(client: JenkinsClient) -> list[dict[str, Any]]:
48
+ """Return a list of installed plugins with summary fields.
49
+
50
+ URI: GET /pluginManager/api/json?tree=plugins[shortName,version,active,hasUpdate]
51
+
52
+ Returns:
53
+ List of plugin dicts with keys: shortName, version, active, hasUpdate.
54
+ """
55
+ data = client.get_json(
56
+ PLUGIN_MANAGER_API,
57
+ tree=PLUGIN_LIST_TREE,
58
+ )
59
+ logger.debug("list_plugins returned %d entries", len(data.get("plugins", [])))
60
+ return data.get("plugins", [])
61
+
62
+
63
+ def get_plugin(client: JenkinsClient, short_name: str) -> dict[str, Any] | None:
64
+ """Return details for a single plugin by shortName.
65
+
66
+ Uses the full API response and filters locally to avoid additional round-trips.
67
+
68
+ Args:
69
+ client: Authenticated Jenkins client.
70
+ short_name: Plugin short name (e.g. "git", "workflow-aggregator").
71
+
72
+ Returns:
73
+ Plugin detail dict, or None if not found.
74
+
75
+ Raises:
76
+ JenkinsNotFoundError: if no plugin matching short_name is found.
77
+ """
78
+ data = client.get_json(
79
+ PLUGIN_MANAGER_API,
80
+ tree=PLUGIN_DETAIL_TREE,
81
+ )
82
+ plugins: list[dict[str, Any]] = data.get("plugins", [])
83
+
84
+ for p in plugins:
85
+ if p.get("shortName") == short_name:
86
+ return p
87
+
88
+ raise JenkinsNotFoundError(
89
+ f"Plugin '{short_name}' not found on server"
90
+ )
91
+
92
+
93
+ def install_plugins(
94
+ client: JenkinsClient,
95
+ plugins_dict: dict[str, str | None],
96
+ ) -> None:
97
+ """Install one or more plugins (optionally with specific versions).
98
+
99
+ URI: POST /pluginManager/installNecessaryPlugins
100
+
101
+ The server will download and install the requested plugins.
102
+ Restart may be required afterward.
103
+
104
+ Args:
105
+ client: Authenticated Jenkins client.
106
+ plugins_dict: Mapping of {plugin_shortName: version_or_None}.
107
+ If version is None, the latest version is installed.
108
+
109
+ Raises:
110
+ JenkinsAPIError: if the request fails.
111
+ """
112
+ if not plugins_dict:
113
+ logger.warning("install_plugins called with empty dict, nothing to do")
114
+ return
115
+
116
+ xml_body = _build_install_xml(plugins_dict)
117
+ logger.info("Installing plugins: %s", list(plugins_dict.keys()))
118
+ logger.debug("Install XML: %s", xml_body)
119
+
120
+ client.post_xml(INSTALL_PLUGINS_PATH, xml_body)
121
+
122
+
123
+ def check_plugin_updates(client: JenkinsClient) -> Any:
124
+ """Check for plugin updates.
125
+
126
+ Triggers Jenkins to check for available plugin updates.
127
+
128
+ Args:
129
+ client: Jenkins API client.
130
+
131
+ Returns:
132
+ Parsed JSON response with update information.
133
+ """
134
+ return client.post_data("/pluginManager/checkUpdates", data={}).json()
135
+
136
+
137
+ def uninstall_plugin(client: JenkinsClient, short_name: str) -> None:
138
+ """Trigger uninstall of a plugin by its short name.
139
+
140
+ URI: POST /pluginManager/plugin/{shortName}/doUninstall
141
+
142
+ The server schedules the plugin for uninstall. A restart is typically required
143
+ to complete the removal.
144
+
145
+ Args:
146
+ client: Authenticated Jenkins client.
147
+ short_name: Plugin short name to uninstall.
148
+
149
+ Raises:
150
+ JenkinsAPIError: if the request fails.
151
+ """
152
+ path = f"/pluginManager/plugin/{short_name}/doUninstall"
153
+ logger.info("Uninstalling plugin: %s", short_name)
154
+
155
+ client.request("POST", path)