././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.866773 jenkinsapi-0.3.14/LICENSE0000644000000000000000000000205314766246232011701 0ustar00MIT License Copyright (c) 2023 PyContribs Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.866773 jenkinsapi-0.3.14/README.rst0000644000000000000000000001204214766246232012362 0ustar00Jenkinsapi ========== .. image:: https://badge.fury.io/py/jenkinsapi.png :target: http://badge.fury.io/py/jenkinsapi .. image:: https://codecov.io/gh/pycontribs/jenkinsapi/branch/master/graph/badge.svg :target: https://codecov.io/gh/pycontribs/jenkinsapi About this library ------------------- Jenkins is the market leading continuous integration system, originally created by Kohsuke Kawaguchi. Jenkins (and its predecessor Hudson) are useful projects for automating common development tasks (e.g. unit-testing, production batches) - but they are somewhat Java-centric. | Jenkinsapi makes scripting Jenkins tasks a breeze by wrapping the REST api into familiar python objects. | Here is a list of some of the most commonly used functionality * Add, remove, and query Jenkins jobs * Control pipeline execution * Query the results of a completed build * Block until jobs are complete or run jobs asyncronously * Get objects representing the latest builds of a job * Artifact management * Search for artifacts by simple criteria * Install artifacts to custom-specified directory structures * Search for builds by source code revision * Create, destroy, and monitor * Build nodes (Webstart and SSH slaves) * Views (including nested views using NestedViews Jenkins plugin) * Credentials (username/password and ssh key) * Authentication support for username and password * Manage jenkins and plugin installation Full library capabilities are outlined in the `Documentation `__ Known issues ------------ * Job deletion operations fail unless Cross-Site scripting protection is disabled. For other issues, please refer to the `support URL `_ Important Links --------------- * `Support and bug-reports `_ * `Source Code `_ * `Documentation `__ * `Releases `_ Installation ------------- Egg-files for this project are hosted on PyPi. Most Python users should be able to use pip or setuptools to automatically install this project. Using Pip or Setuptools ^^^^^^^^^^^^^^^^^^^^^^^ Most users can do the following: .. code-block:: bash pip install jenkinsapi Using your operating-system's package manager ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Ubuntu users can now use apt to install this package: .. code-block:: bash apt-get install python-jenkinsapi Beware that this technique will get a somewhat older version of Jenkinsapi. Example ------- JenkinsAPI is intended to map the objects in Jenkins (e.g. Builds, Views, Jobs) into easily managed Python objects: .. code-block:: python from jenkinsapi.jenkins import Jenkins J = Jenkins('http://localhost:8080') print(J.version) # 1.542 print(J.keys()) # foo, test_jenkinsapi print(J.get('test_jenkinsapi')) # print(J.get('test_jenkinsapi').get_last_good_build()) # More examples available on `Github `_ Testing ------- If you have installed the test dependencies on your system already, you can run the testsuite with the following command: .. code-block:: bash uv sync uv run pytest -sv --cov=jenkinsapi --cov-report=term-missing --cov-report=xml jenkinsapi_tests Otherwise using a virtualenv is recommended. Setuptools will automatically fetch missing test dependencies: .. code-block:: bash uv venv uv python install uv run pytest -sv --cov=jenkinsapi --cov-report=term-missing --cov-report=xml jenkinsapi_tests Development ----------- * Make sure that you have Java_ installed. Jenkins will be automatically downloaded and started during tests. * Create virtual environment for development * Install package in development mode .. code-block:: bash uv sync * Make your changes, write tests and check your code .. code-block:: bash uv run pytest -sv Python versions --------------- The project has been tested against Python versions: * 3.8 - 3.13 Jenkins versions ---------------- Project tested on both stable (LTS) and latest Jenkins versions. Project Contributors -------------------- * Aleksey Maksimov (ctpeko3a@gmail.com) * Salim Fadhley (sal@stodge.org) * Ramon van Alteren (ramon@vanalteren.nl) * Ruslan Lutsenko (ruslan.lutcenko@gmail.com) * Cleber J Santos (cleber@simplesconsultoria.com.br) * William Zhang (jollychang@douban.com) * Victor Garcia (bravejolie@gmail.com) * Bradley Harris (bradley@ninelb.com) * Kyle Rockman (kyle.rockman@mac.com) * Sascha Peilicke (saschpe@gmx.de) * David Johansen (david@makewhat.is) * Misha Behersky (bmwant@gmail.com) * Clinton Steiner (clintonsteiner@gmail.com) Please do not contact these contributors directly for support questions! Use the GitHub tracker instead. .. _Java: https://www.oracle.com/java/technologies/downloads/#java21 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.868773 jenkinsapi-0.3.14/jenkinsapi/.gitignore0000644000000000000000000000001514766246232015013 0ustar00/__pycache__ ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.868773 jenkinsapi-0.3.14/jenkinsapi/__init__.py0000644000000000000000000000425714766246232015150 0ustar00""" About this library ================== Jenkins is the market leading continuous integration system, originally created by Kohsuke Kawaguchi. This API makes Jenkins even easier to use by providing an easy to use conventional python interface. Jenkins (and It's predecessor Hudson) are fantastic projects - but they are somewhat Java-centric. Thankfully the designers have provided an excellent and complete REST interface. This library wraps up that interface as more conventional python objects in order to make most Jenkins oriented tasks simpler. This library can help you: * Query the test-results of a completed build * Get a objects representing the latest builds of a job * Search for artifacts by simple criteria * Block until jobs are complete * Install artifacts to custom-specified directory structures * username/password auth support for jenkins instances with auth turned on * Ability to search for builds by subversion revision * Ability to add/remove/query jenkins slaves Installing JenkinsAPI ===================== Egg-files for this project are hosted on PyPi. Most Python users should be able to use pip or distribute to automatically install this project. Most users can do the following: pip install jenkinsapi Project Authors =============== * Salim Fadhley (sal@stodge.org) * Ramon van Alteren (ramon@vanalteren.nl) * Ruslan Lutsenko (ruslan.lutcenko@gmail.com) * Aleksey Maksimov * Clinton Steiner Current code lives on github: https://github.com/pycontribs/jenkinsapi """ from importlib.metadata import version from jenkinsapi import ( # Modules command_line, utils, # Files api, artifact, build, config, constants, custom_exceptions, fingerprint, executors, executor, jenkins, jenkinsbase, job, node, result_set, result, view, ) __all__ = [ "command_line", "utils", "api", "artifact", "build", "config", "constants", "custom_exceptions", "executors", "executor", "fingerprint", "jenkins", "jenkinsbase", "job", "node", "result_set", "result", "view", ] __docformat__ = "epytext" __version__ = version("jenkinsapi") ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.868773 jenkinsapi-0.3.14/jenkinsapi/api.py0000644000000000000000000002264714766246232014165 0ustar00""" This module is a collection of helpful, high-level functions for automating common tasks. Many of these functions were designed to be exposed to the command-line, hence they have simple string arguments. """ import os import re import time import logging from typing import List, Dict from urllib.parse import urlparse from jenkinsapi import constants from jenkinsapi.artifact import Artifact from jenkinsapi.jenkins import Jenkins from jenkinsapi.view import View from jenkinsapi.job import Job from jenkinsapi.build import Build from jenkinsapi.custom_exceptions import ArtifactsMissing, TimeOut, BadURL from jenkinsapi.result_set import ResultSet log: logging.Logger = logging.getLogger(__name__) def get_latest_test_results( jenkinsurl: str, jobname: str, username: str = "", password: str = "", ssl_verify: bool = True, ) -> ResultSet: """ A convenience function to fetch down the very latest test results from a jenkins job. """ latestbuild: Build = get_latest_build( jenkinsurl, jobname, username=username, password=password, ssl_verify=ssl_verify, ) return latestbuild.get_resultset() def get_latest_build( jenkinsurl: str, jobname: str, username: str = "", password: str = "", ssl_verify: bool = True, ) -> Build: """ A convenience function to fetch down the very latest test results from a jenkins job. """ jenkinsci: Jenkins = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) job: Job = jenkinsci[jobname] return job.get_last_build() def get_latest_complete_build( jenkinsurl: str, jobname: str, username: str = "", password: str = "", ssl_verify: bool = True, ) -> Build: """ A convenience function to fetch down the very latest test results from a jenkins job. """ jenkinsci: Jenkins = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) job: Job = jenkinsci[jobname] return job.get_last_completed_build() def get_build( jenkinsurl: str, jobname: str, build_no: int, username: str = "", password: str = "", ssl_verify: bool = True, ) -> Build: """ A convenience function to fetch down the test results from a jenkins job by build number. """ jenkinsci = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) job = jenkinsci[jobname] return job.get_build(build_no) def get_artifacts( jenkinsurl: str, jobname: str, build_no: int, username: str = "", password: str = "", ssl_verify: bool = True, ): """ Find all the artifacts for the latest build of a job. """ jenkinsci = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) job = jenkinsci[jobname] if build_no: build = job.get_build(build_no) else: build = job.get_last_good_build() artifacts = build.get_artifact_dict() log.info( msg=f"Found {len(artifacts.keys())} \ artifacts in '{jobname}[{build_no}]" ) return artifacts def search_artifacts( jenkinsurl: str, jobname: str, artifact_ids=None, username: str = "", password: str = "", ssl_verify: bool = True, ): """ Search the entire history of a jenkins job for a list of artifact names. If same_build is true then ensure that all artifacts come from the same build of the job """ if not artifact_ids: return [] jenkinsci = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) job = jenkinsci[jobname] build_ids = job.get_build_ids() missing_artifacts = set() for build_id in build_ids: build = job.get_build(build_id) artifacts = build.get_artifact_dict() if set(artifact_ids).issubset(set(artifacts.keys())): return dict((a, artifacts[a]) for a in artifact_ids) missing_artifacts = set(artifact_ids) - set(artifacts.keys()) log.debug( msg="Artifacts %s missing from %s #%i" % (", ".join(missing_artifacts), jobname, build_id) ) raise ArtifactsMissing(missing_artifacts) def grab_artifact( jenkinsurl: str, jobname: str, artifactid, targetdir: str, username: str = "", password: str = "", strict_validation: bool = True, ssl_verify: bool = True, ) -> None: """ Convenience method to find the latest good version of an artifact and save it to a target directory. Directory is made automatically if not exists. """ artifacts = get_artifacts( jenkinsurl, jobname, artifactid, username=username, password=password, ssl_verify=ssl_verify, ) artifact = artifacts[artifactid] if not os.path.exists(targetdir): os.makedirs(targetdir) artifact.save_to_dir(targetdir, strict_validation) def block_until_complete( jenkinsurl: str, jobs: List[str], maxwait: int = 12000, interval: int = 30, raise_on_timeout: bool = True, username: str = "", password: str = "", ssl_verify: bool = True, ) -> None: """ Wait until all of the jobs in the list are complete. """ assert maxwait > 0 assert maxwait > interval assert interval > 0 report: str = "" obj_jenkins: Jenkins = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) obj_jobs: List[Job] = [obj_jenkins[jid] for jid in jobs] for time_left in range(maxwait, 0, -interval): still_running = [j for j in obj_jobs if j.is_queued_or_running()] if not still_running: return report = ", ".join('"%s"' % str(a) for a in still_running) log.warning( "Waiting for jobs %s to complete. Will wait another %is", report, time_left, ) time.sleep(interval) if raise_on_timeout: # noinspection PyUnboundLocalVariable raise TimeOut( "Waited too long for these jobs to complete: %s" % report ) def get_view_from_url( url: str, username: str = "", password: str = "", ssl_verify: bool = True ) -> View: """ Factory method """ matched = constants.RE_SPLIT_VIEW_URL.search(url) if not matched: raise BadURL("Cannot parse URL %s" % url) jenkinsurl, view_name = matched.groups() jenkinsci = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) return jenkinsci.views[view_name] def get_nested_view_from_url( url: str, username: str = "", password: str = "", ssl_verify: bool = True ) -> View: """ Returns View based on provided URL. Convenient for nested views. """ matched = constants.RE_SPLIT_VIEW_URL.search(url) if not matched: raise BadURL("Cannot parse URL %s" % url) jenkinsci = Jenkins( matched.group(0), username=username, password=password, ssl_verify=ssl_verify, ) return jenkinsci.get_view_by_url(url) def install_artifacts( artifacts, dirstruct: Dict[str, str], installdir: str, basestaticurl: str, strict_validation: bool = False, ): """ Install the artifacts. """ assert basestaticurl.endswith("/"), "Basestaticurl should end with /" installed = [] for reldir, artifactnames in dirstruct.items(): destdir = os.path.join(installdir, reldir) if not os.path.exists(destdir): log.warning("Making install directory %s", destdir) os.makedirs(destdir) else: assert os.path.isdir(destdir) for artifactname in artifactnames: destpath = os.path.abspath(os.path.join(destdir, artifactname)) if artifactname in artifacts.keys(): # The artifact must be loaded from jenkins theartifact = artifacts[artifactname] else: # It's probably a static file, # we can get it from the static collection staticurl = urlparse.urljoin(basestaticurl, artifactname) theartifact = Artifact(artifactname, staticurl, None) theartifact.save(destpath, strict_validation) installed.append(destpath) return installed def search_artifact_by_regexp( jenkinsurl: str, jobname: str, artifactRegExp: re.Pattern, username: str = "", password: str = "", ssl_verify: bool = True, ) -> Artifact: """ Search the entire history of a Jenkins job for a build which has an artifact whose name matches a supplied regular expression. Return only that artifact. @param jenkinsurl: The base URL of the jenkins server @param jobid: The name of the job we are to search through @param artifactRegExp: A compiled regular expression object (not a re-string) @param username: Jenkins login user name, optional @param password: Jenkins login password, optional """ job = Jenkins( jenkinsurl, username=username, password=password, ssl_verify=ssl_verify ) j = job[jobname] build_ids = j.get_build_ids() for build_id in build_ids: build = j.get_build(build_id) artifacts = build.get_artifact_dict() for name, art in artifacts.items(): md_match = artifactRegExp.search(name) if md_match: return art raise ArtifactsMissing() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.868773 jenkinsapi-0.3.14/jenkinsapi/artifact.py0000644000000000000000000001216414766246232015202 0ustar00""" Artifacts can be used to represent data created as a side-effect of running a Jenkins build. Artifacts are files which are associated with a single build. A build can have any number of artifacts associated with it. This module provides a class called Artifact which allows you to download objects from the server and also access them as a stream. """ from __future__ import annotations import os import logging import hashlib from typing import Any, Literal from jenkinsapi.fingerprint import Fingerprint from jenkinsapi.custom_exceptions import ArtifactBroken log = logging.getLogger(__name__) class Artifact(object): """ Represents a single Jenkins artifact, usually some kind of file generated as a by-product of executing a Jenkins build. """ def __init__( self, filename: str, url: str, build: "Build", relative_path: str | None = None, ) -> None: self.filename: str = filename self.url: str = url self.build: "Build" = build self.relative_path: str | None = relative_path def save(self, fspath: str, strict_validation: bool = False) -> str: """ Save the artifact to an explicit path. The containing directory must exist. Returns a reference to the file which has just been writen to. :param fspath: full pathname including the filename, str :return: filepath """ log.info(msg="Saving artifact @ %s to %s" % (self.url, fspath)) if not fspath.endswith(self.filename): log.warning( "Attempt to change the filename of artifact %s on save.", self.filename, ) if os.path.exists(fspath): if self.build: try: if self._verify_download(fspath, strict_validation): log.info( "Local copy of %s is already up to date.", self.filename, ) return fspath except ArtifactBroken: log.warning("Jenkins artifact could not be identified.") else: log.info( "This file did not originate from Jenkins, " "so cannot check." ) else: log.info("Local file is missing, downloading new.") filepath = self._do_download(fspath) self._verify_download(filepath, strict_validation) return fspath def get_jenkins_obj(self) -> Jenkins: return self.build.get_jenkins_obj() def get_data(self) -> Any: """ Grab the text of the artifact """ response = self.get_jenkins_obj().requester.get_and_confirm_status( self.url ) return response.content def _do_download(self, fspath: str) -> str: """ Download the the artifact to a path. """ data = self.get_jenkins_obj().requester.get_and_confirm_status( self.url, stream=True ) with open(fspath, "wb") as out: for chunk in data.iter_content(chunk_size=1024): out.write(chunk) return fspath def _verify_download(self, fspath, strict_validation) -> Literal[True]: """ Verify that a downloaded object has a valid fingerprint. Returns True if the fingerprint is valid, raises an exception if the fingerprint is invalid. """ local_md5 = self._md5sum(fspath) baseurl = self.build.job.jenkins.baseurl fp = Fingerprint(baseurl, local_md5, self.build.job.jenkins) valid = fp.validate_for_build( self.filename, self.build.job.get_full_name(), self.build.buildno ) if not valid or (fp.unknown and strict_validation): # strict = 404 as invalid raise ArtifactBroken( "Artifact %s seems to be broken, check %s" % (local_md5, baseurl) ) return True def _md5sum(self, fspath: str, chunksize: int = 2**20) -> str: """ A MD5 hashing function intended to produce the same results as that used by Jenkins. """ md5 = hashlib.md5() with open(fspath, "rb") as f: for chunk in iter(lambda: f.read(chunksize), ""): if chunk: md5.update(chunk) else: break return md5.hexdigest() def save_to_dir( self, dirpath: str, strict_validation: bool = False ) -> str: """ Save the artifact to a folder. The containing directory must exist, but use the artifact's default filename. """ assert os.path.exists(dirpath) assert os.path.isdir(dirpath) outputfilepath: str = os.path.join(dirpath, self.filename) return self.save(outputfilepath, strict_validation) def __repr__(self) -> str: """ Produce a handy repr-string. """ return """<%s.%s %s>""" % ( self.__class__.__module__, self.__class__.__name__, self.url, ) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/build.py0000644000000000000000000004574414766246232014516 0ustar00""" A Jenkins build represents a single execution of a Jenkins Job. Builds can be thought of as the second level of the Jenkins hierarchy beneath Jobs. Builds can have state, such as whether they are running or not. They can also have outcomes, such as whether they passed or failed. Build objects can be associated with Results and Artifacts. """ from __future__ import annotations import time import logging import warnings import datetime from time import sleep from typing import Iterator, List, Dict, Any import pytz from jenkinsapi import config from jenkinsapi.artifact import Artifact # from jenkinsapi.job import Job from jenkinsapi.result_set import ResultSet from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.constants import STATUS_SUCCESS from jenkinsapi.custom_exceptions import NoResults from jenkinsapi.custom_exceptions import JenkinsAPIException from urllib.parse import quote from requests import HTTPError log = logging.getLogger(__name__) class Build(JenkinsBase): """ Represents a Jenkins build, executed in context of a job. """ STR_TOTALCOUNT = "totalCount" STR_TPL_NOTESTS_ERR = ( "%s has status %s, and does not have " "any test results" ) def __init__( self, url: str, buildno: int, job: "Job", depth: int = 1 ) -> None: """ depth=1 is for backward compatibility consideration About depth, the deeper it is, the more build data you get back. If depth=0 is sufficient for you, don't go up to 1. For more information, see https://www.jenkins.io/doc/book/using/remote-access-api/#RemoteaccessAPI-Depthcontrol """ self.buildno: int = buildno self.job: "Job" = job self.depth = depth JenkinsBase.__init__(self, url) def _poll(self, tree=None): # For builds we need more information for downstream and # upstream builds so we override the poll to get at the extra # data for build objects url = self.python_api_url(self.baseurl) return self.get_data(url, params={"depth": self.depth}, tree=tree) def __str__(self) -> str: return self._data["fullDisplayName"] @property def name(self): return str(self) def get_description(self) -> str: return self._data["description"] def get_number(self) -> int: return self._data["number"] def get_status(self) -> str: return self._data["result"] def get_slave(self) -> str: return self._data["builtOn"] def get_revision(self) -> str: return getattr(self, f"_get_{self._get_vcs()}_rev", lambda: "")() def get_revision_branch(self) -> str: return getattr( self, f"_get_{self._get_vcs()}_rev_branch", lambda: "" )() def get_repo_url(self) -> str: return getattr(self, f"_get_{self._get_vcs()}_repo_url", lambda: "")() def get_params(self) -> dict[str, str]: """ Return a dictionary of params names and their values, or an empty dictionary if no parameters are returned. """ # This is what a parameter action looks like: # {'_class': 'hudson.model.ParametersAction', 'parameters': [ # {'_class': 'hudson.model.StringParameterValue', # 'value': '12', # 'name': 'FOO_BAR_BAZ'}]} actions = self._data.get("actions") if actions: parameters = {} for elem in actions: if elem.get("_class") == "hudson.model.ParametersAction": parameters = elem.get("parameters", {}) break return {pair["name"]: pair.get("value") for pair in parameters} return {} def get_changeset_items(self): """ Returns a list of changeSet items. Each item has structure as in following example: { "affectedPaths": [ "content/rcm/v00-rcm-xccdf.xml" ], "author" : { "absoluteUrl": "http://jenkins_url/user/username79", "fullName": "username" }, "commitId": "3097", "timestamp": 1414398423091, "date": "2014-10-27T08:27:03.091288Z", "msg": "commit message", "paths": [{ "editType": "edit", "file": "/some/path/of/changed_file" }], "revision": 3097, "user": "username" } """ if "changeSet" in self._data: if "items" in self._data["changeSet"]: return self._data["changeSet"]["items"] elif "changeSets" in self._data: if "items" in self._data["changeSets"]: return self._data["changeSets"]["items"] return [] def _get_vcs(self) -> str: """ Returns a string VCS. By default, 'git' will be used. """ vcs = "git" if "changeSet" in self._data and "kind" in self._data["changeSet"]: vcs = self._data["changeSet"]["kind"] or "git" elif "changeSets" in self._data and "kind" in self._data["changeSets"]: vcs = self._data["changeSets"]["kind"] or "git" return vcs def _get_git_rev(self) -> str | None: # Sometimes we have None as part of actions. Filter those actions # which have lastBuiltRevision in them _actions = [ x for x in self._data["actions"] if x and "lastBuiltRevision" in x ] if _actions: return _actions[0]["lastBuiltRevision"]["SHA1"] return None def _get_git_rev_branch(self) -> str: # Sometimes we have None as part of actions. Filter those actions # which have lastBuiltRevision in them _actions = [ x for x in self._data["actions"] if x and "lastBuiltRevision" in x ] return _actions[0]["lastBuiltRevision"]["branch"] def _get_git_repo_url(self) -> str: # Sometimes we have None as part of actions. Filter those actions # which have lastBuiltRevision in them _actions = [ x for x in self._data["actions"] if x and "lastBuiltRevision" in x ] # old Jenkins version have key remoteUrl v/s the new version # has a list remoteUrls result = _actions[0].get("remoteUrls", _actions[0].get("remoteUrl")) if isinstance(result, list): result = ",".join(result) return result def get_duration(self) -> datetime.timedelta: return datetime.timedelta(milliseconds=self._data["duration"]) def get_build_url(self) -> str: return self._data["url"] def get_artifacts(self) -> Iterator[Artifact]: data = self.poll(tree="artifacts[relativePath,fileName]") for afinfo in data["artifacts"]: url = "%s/artifact/%s" % ( self.baseurl, quote(afinfo["relativePath"]), ) af = Artifact( afinfo["fileName"], url, self, relative_path=afinfo["relativePath"], ) yield af def get_artifact_dict(self) -> dict[str, Artifact]: return {af.relative_path: af for af in self.get_artifacts()} def get_upstream_job_name(self) -> str | None: """ Get the upstream job name if it exist, None otherwise :return: String or None """ try: return self.get_actions()["causes"][0]["upstreamProject"] except KeyError: return None def get_upstream_job(self) -> Job | None: """ Get the upstream job object if it exist, None otherwise :return: Job or None """ if self.get_upstream_job_name(): return self.get_jenkins_obj().get_job(self.get_upstream_job_name()) return None def get_upstream_build_number(self) -> int | None: """ Get the upstream build number if it exist, None otherwise :return: int or None """ try: return int(self.get_actions()["causes"][0]["upstreamBuild"]) except KeyError: return None def get_upstream_build(self) -> "Build" | None: """ Get the upstream build if it exist, None otherwise :return Build or None """ upstream_job: "Job" = self.get_upstream_job() if upstream_job: return upstream_job.get_build(self.get_upstream_build_number()) return None def get_master_job_name(self) -> str | None: """ Get the master job name if it exist, None otherwise :return: String or None """ try: return self.get_actions()["parameters"][0]["value"] except KeyError: return None def get_master_job(self) -> Job | None: """ Get the master job object if it exist, None otherwise :return: Job or None """ if self.get_master_job_name(): return self.get_jenkins_obj().get_job(self.get_master_job_name()) return None def get_master_build_number(self) -> int | None: """ Get the master build number if it exist, None otherwise :return: int or None """ try: return int(self.get_actions()["parameters"][1]["value"]) except KeyError: return None def get_master_build(self) -> "Build" | None: """ Get the master build if it exist, None otherwise :return Build or None """ master_job: Job | None = self.get_master_job() if master_job: return master_job.get_build(self.get_master_build_number()) return None def get_downstream_jobs(self) -> List[Job]: """ Get the downstream jobs for this build :return List of jobs or None """ downstream_jobs: List[Job] = [] try: for job_name in self.get_downstream_job_names(): downstream_jobs.append( self.get_jenkins_obj().get_job(job_name) ) return downstream_jobs except (IndexError, KeyError): return [] def get_downstream_job_names(self) -> List[str]: """ Get the downstream job names for this build :return List of string or None """ downstream_job_names: List[str] = self.job.get_downstream_job_names() downstream_names: List[str] = [] try: fingerprints = self._data["fingerprint"] for fingerprint in fingerprints: for job_usage in fingerprint["usage"]: if job_usage["name"] in downstream_job_names: downstream_names.append(job_usage["name"]) return downstream_names except (IndexError, KeyError): return [] def get_downstream_builds(self) -> List["Build"]: """ Get the downstream builds for this build :return List of Build or None """ downstream_job_names: List[str] = self.get_downstream_job_names() downstream_builds: List[Build] = [] try: # pylint: disable=R1702 fingerprints = self._data["fingerprint"] for fingerprint in fingerprints: for job_usage in fingerprint["usage"]: if job_usage["name"] in downstream_job_names: job = self.get_jenkins_obj().get_job(job_usage["name"]) for job_range in job_usage["ranges"]["ranges"]: for build_id in range( job_range["start"], job_range["end"] ): downstream_builds.append( job.get_build(build_id) ) return downstream_builds except (IndexError, KeyError): return [] def get_matrix_runs(self) -> Iterator["Build"]: """ For a matrix job, get the individual builds for each matrix configuration :return: Generator of Build """ if "runs" in self._data: for rinfo in self._data["runs"]: number: int = rinfo["number"] if number == self._data["number"]: yield Build(rinfo["url"], number, self.job) def is_running(self) -> bool: """ Return a bool if running. """ data = self.poll(tree="building") return data.get("building", False) def block(self) -> None: while self.is_running(): time.sleep(1) def is_good(self) -> bool: """ Return a bool, true if the build was good. If the build is still running, return False. """ return (not self.is_running()) and self._data[ "result" ] == STATUS_SUCCESS def block_until_complete(self, delay: int = 15) -> None: count: int = 0 while self.is_running(): total_wait: int = delay * count log.info( msg="Waited %is for %s #%s to complete" % (total_wait, self.job.name, self.name) ) sleep(delay) count += 1 def get_jenkins_obj(self) -> "Jenkins": return self.job.get_jenkins_obj() def get_result_url(self) -> str: """ Return the URL for the object which provides the job's result summary. """ url_tpl: str = r"%stestReport/%s" return url_tpl % (self._data["url"], config.JENKINS_API) def get_resultset(self) -> ResultSet: """ Obtain detailed results for this build. Raises NoResults if the build has no results. :return: ResultSet """ result_url: str = self.get_result_url() if self.STR_TOTALCOUNT not in self.get_actions(): raise NoResults( "%s does not have any published results" % str(self) ) buildstatus: str = self.get_status() if not self.get_actions()[self.STR_TOTALCOUNT]: raise NoResults( self.STR_TPL_NOTESTS_ERR % (str(self), buildstatus) ) return ResultSet(result_url, build=self) def has_resultset(self) -> bool: """ Return a boolean, true if a result set is available. false if not. """ return self.STR_TOTALCOUNT in self.get_actions() def get_actions(self) -> Dict[str, Any]: all_actions: Dict[str, Any] = {} for dct_action in self._data["actions"]: if dct_action is None: continue all_actions.update(dct_action) return all_actions def get_causes(self) -> List[str]: """ Returns a list of causes. There can be multiple causes lists and some of the can be empty. For instance, when a build is manually aborted, Jenkins could add an empty causes list to the actions dict. Empty ones are ignored. """ all_causes: List[str] = [] for dct_action in self._data["actions"]: if dct_action is None: continue if "causes" in dct_action and dct_action["causes"]: all_causes.extend(dct_action["causes"]) return all_causes def get_timestamp(self) -> datetime.datetime: """ Returns build timestamp in UTC """ # Java timestamps are given in miliseconds since the epoch start! naive_timestamp = datetime.datetime( *time.gmtime(self._data["timestamp"] / 1000.0)[:6] ) return pytz.utc.localize(naive_timestamp) def get_console(self) -> str: """ Return the current state of the text console. """ url: str = "%s/consoleText" % self.baseurl resp = self.job.jenkins.requester.get_url(url) content: Any = resp.content # This check was made for Python 3.x # In this version content is a bytes string # By contract this function must return string if isinstance(content, str): return content elif isinstance(content, bytes): return content.decode(resp.encoding or "ISO-8859-1") else: raise JenkinsAPIException("Unknown content type for console") def stream_logs(self, interval=0) -> Iterator[str]: """ Return generator which streams parts of text console. """ url: str = "%s/logText/progressiveText" % self.baseurl size: int = 0 more_data: bool = True while more_data: resp = self.job.jenkins.requester.get_url( url, params={"start": size} ) content = resp.content if content: if isinstance(content, str): yield content elif isinstance(content, bytes): yield content.decode(resp.encoding or "ISO-8859-1") else: raise JenkinsAPIException( "Unknown content type for console" ) size = resp.headers["X-Text-Size"] more_data = resp.headers.get("X-More-Data") sleep(interval) def get_estimated_duration(self) -> int | None: """ Return the estimated build duration (in seconds) or none. """ try: eta_ms = self._data["estimatedDuration"] return max(0, eta_ms / 1000.0) except KeyError: return None def stop(self) -> bool: """ Stops the build execution if it's running :return: boolean True if succeeded False otherwis """ if self.is_running(): url: str = "%s/stop" % self.baseurl # Starting from Jenkins 2.7 stop function sometimes breaks # on redirect to job page. Call to stop works fine, and # we don't need to have job page here. self.job.jenkins.requester.post_and_confirm_status( url, data="", valid=[ 302, 200, 500, ], ) return True return False def get_env_vars(self) -> Dict[str, str]: """ Return the environment variables. This method is using the Environment Injector plugin: https://wiki.jenkins-ci.org/display/JENKINS/EnvInject+Plugin """ url: str = self.python_api_url("%s/injectedEnvVars" % self.baseurl) try: data = self.get_data(url, params={"depth": self.depth}) except HTTPError as ex: warnings.warn( "Make sure the Environment Injector plugin is installed." ) raise ex return data["envMap"] def toggle_keep(self) -> None: """ Toggle "keep this build forever" on and off """ url: str = "%s/toggleLogKeep" % self.baseurl self.get_jenkins_obj().requester.post_and_confirm_status(url, data={}) self._data = self._poll() def is_kept_forever(self) -> bool: return self._data["keepLog"] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/command_line/__init__.py0000644000000000000000000000005314766246232017563 0ustar00""" __init__,py for commandline module """ ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/command_line/jenkins_invoke.py0000644000000000000000000000600314766246232021041 0ustar00""" jenkinsapi class for invoking Jenkins """ import os import sys import logging import optparse from jenkinsapi import jenkins log = logging.getLogger(__name__) class JenkinsInvoke(object): """ JenkinsInvoke object implements class to call from command line """ @classmethod def mkparser(cls): parser = optparse.OptionParser() DEFAULT_BASEURL = os.environ.get( "JENKINS_URL", "http://localhost/jenkins" ) parser.help_text = ( "Execute a number of jenkins jobs on the server of your choice." + " Optionally block until the jobs are complete." ) parser.add_option( "-J", "--jenkinsbase", dest="baseurl", help="Base URL for the Jenkins server, default is %s" % DEFAULT_BASEURL, type="str", default=DEFAULT_BASEURL, ) parser.add_option( "--username", "-u", dest="username", help="Username for jenkins authentification", type="str", default=None, ) parser.add_option( "--password", "-p", dest="password", help="password for jenkins user auth", type="str", default=None, ) parser.add_option( "-b", "--block", dest="block", action="store_true", default=False, help="Block until each of the jobs is complete.", ) parser.add_option( "-t", "--token", dest="token", help="Optional security token.", default=None, ) return parser @classmethod def main(cls): parser = cls.mkparser() options, args = parser.parse_args() try: assert args, "Need to specify at least one job name" except AssertionError as err: log.critical(err.message) parser.print_help() sys.exit(1) invoker = cls(options, args) invoker() def __init__(self, options, jobs): self.options = options self.jobs = jobs self.api = self._get_api( baseurl=options.baseurl, username=options.username, password=options.password, ) def _get_api(self, baseurl, username, password): return jenkins.Jenkins(baseurl, username, password) def __call__(self): for job in self.jobs: self.invokejob( job, block=self.options.block, token=self.options.token ) def invokejob(self, jobname, block, token): assert isinstance(block, bool) assert isinstance(jobname, str) assert token is None or isinstance(token, str) job = self.api.get_job(jobname) job.invoke(securitytoken=token, block=block) def main(): logging.basicConfig() logging.getLogger("").setLevel(logging.INFO) JenkinsInvoke.main() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/command_line/jenkinsapi_version.py0000644000000000000000000000026414766246232021730 0ustar00""" jenkinsapi.command_line.jenkinsapi_version """ import jenkinsapi import sys def main(): sys.stdout.write(jenkinsapi.__version__) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/config.py0000644000000000000000000000011614766246232014644 0ustar00""" Jenkins configuration """ JENKINS_API = r"api/python" LOAD_TIMEOUT = 30 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/constants.py0000644000000000000000000000064014766246232015415 0ustar00""" Constants for jenkinsapi """ import re STATUS_FAIL = "FAIL" STATUS_ERROR = "ERROR" STATUS_ABORTED = "ABORTED" STATUS_REGRESSION = "REGRESSION" STATUS_SUCCESS = "SUCCESS" STATUS_FIXED = "FIXED" STATUS_PASSED = "PASSED" RESULTSTATUS_FAILURE = "FAILURE" RESULTSTATUS_FAILED = "FAILED" RESULTSTATUS_SKIPPED = "SKIPPED" STR_RE_SPLIT_VIEW = "(.*)/view/([^/]*)/?" RE_SPLIT_VIEW_URL = re.compile(STR_RE_SPLIT_VIEW) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/credential.py0000644000000000000000000003112714766246232015517 0ustar00""" Module for jenkinsapi Credential class """ import logging import xml.etree.cElementTree as ET log = logging.getLogger(__name__) class Credential(object): """ Base abstract class for credentials Credentials returned from Jenkins don't hold any sensitive information, so there is nothing useful can be done with existing credentials besides attaching them to Nodes or other objects. You can create concrete Credential instance: UsernamePasswordCredential or SSHKeyCredential by passing credential's description and credential dict. Each class expects specific credential dict, see below. """ # pylint: disable=unused-argument def __init__(self, cred_dict, jenkins_class=""): """ Create credential :param str description: as Jenkins doesn't allow human friendly names for credentials and makes "displayName" itself, there is no way to find credential later, this field is used to distinguish between credentials :param dict cred_dict: dict containing credential information """ self.credential_id = cred_dict.get("credential_id", "") self.description = cred_dict["description"] self.fullname = cred_dict.get("fullName", "") self.displayname = cred_dict.get("displayName", "") self.jenkins_class = jenkins_class def __str__(self): return self.description def get_attributes(self): pass def get_attributes_xml(self): pass def _get_attributes_xml(self, data): root = ET.Element(self.jenkins_class) for key in data: value = data[key] if isinstance(value, dict): node = ET.SubElement(root, key) if "stapler-class" in value: node.attrib["class"] = value["stapler-class"] for sub_key in value: ET.SubElement(node, sub_key).text = value[sub_key] else: ET.SubElement(root, key).text = data[key] return ET.tostring(root) class UsernamePasswordCredential(Credential): """ Username and password credential Constructor expects following dict: { 'credential_id': str, Automatically set by jenkinsapi 'displayName': str, Automatically set by Jenkins 'fullName': str, Automatically set by Jenkins 'typeName': str, Automatically set by Jenkins 'description': str, 'userName': str, 'password': str } When creating credential via jenkinsapi automatic fields not need to be in dict """ def __init__(self, cred_dict: dict) -> None: jenkins_class: str = ( "com.cloudbees.plugins.credentials.impl." "UsernamePasswordCredentialsImpl" ) super(UsernamePasswordCredential, self).__init__( cred_dict, jenkins_class ) if "typeName" in cred_dict: username: str = cred_dict["displayName"].split("/")[0] else: username: str = cred_dict["userName"] self.username: str = username self.password: str = cred_dict.get("password", "") def get_attributes(self): """ Used by Credentials object to create credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id return { "stapler-class": self.jenkins_class, "Submit": "OK", "json": { "": "1", "credentials": { "stapler-class": self.jenkins_class, "id": c_id, "username": self.username, "password": self.password, "description": self.description, }, }, } def get_attributes_xml(self): """ Used by Credentials object to update a credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id data = { "id": c_id, "username": self.username, "password": self.password, "description": self.description, } return super(UsernamePasswordCredential, self)._get_attributes_xml( data ) class SecretTextCredential(Credential): """ Secret text credential Constructor expects following dict: { 'credential_id': str, Automatically set by jenkinsapi 'displayName': str, Automatically set by Jenkins 'fullName': str, Automatically set by Jenkins 'typeName': str, Automatically set by Jenkins 'description': str, 'secret': str, } When creating credential via jenkinsapi automatic fields not need to be in dict """ def __init__(self, cred_dict): jenkins_class = ( "org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl" ) super(SecretTextCredential, self).__init__(cred_dict, jenkins_class) self.secret = cred_dict.get("secret", None) def get_attributes(self): """ Used by Credentials object to create credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id return { "stapler-class": self.jenkins_class, "Submit": "OK", "json": { "": "1", "credentials": { "stapler-class": self.jenkins_class, "$class": self.jenkins_class, "id": c_id, "secret": self.secret, "description": self.description, }, }, } def get_attributes_xml(self): """ Used by Credentials object to update a credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id data = { "id": c_id, "secret": self.secret, "description": self.description, } return super(SecretTextCredential, self)._get_attributes_xml(data) class SSHKeyCredential(Credential): """ SSH key credential Constructr expects following dict: { 'credential_id': str, Automatically set by jenkinsapi 'displayName': str, Automatically set by Jenkins 'fullName': str, Automatically set by Jenkins 'typeName': str, Automatically set by Jenkins 'description': str, 'userName': str, 'passphrase': str, SSH key passphrase, 'private_key': str Private SSH key } private_key value is parsed to find type of credential to create: private_key starts with - the value is private key itself These credential variations are no longer supported by SSH Credentials plugin. jenkinsapi will raise ValueError if they are used: private_key starts with / the value is a path to key private_key starts with ~ the value is a key from ~/.ssh When creating credential via jenkinsapi automatic fields not need to be in dict """ def __init__(self, cred_dict: dict) -> None: jenkins_class: str = ( "com.cloudbees.jenkins.plugins.sshcredentials.impl." "BasicSSHUserPrivateKey" ) super(SSHKeyCredential, self).__init__(cred_dict, jenkins_class) if "typeName" in cred_dict: username: str = cred_dict["displayName"].split(" ")[0] else: username: str = cred_dict["userName"] self.username: str = username self.passphrase: str = cred_dict.get("passphrase", "") if "private_key" not in cred_dict or cred_dict["private_key"] is None: self.key_type: int = -1 self.key_value: str = "" elif cred_dict["private_key"].startswith("-"): self.key_type: int = 0 self.key_value: str = cred_dict["private_key"] else: raise ValueError("Invalid private_key value") @property def attrs(self): if self.key_type == 0: c_class = self.jenkins_class + "$DirectEntryPrivateKeySource" elif self.key_type == 1: c_class = self.jenkins_class + "$FileOnMasterPrivateKeySource" elif self.key_type == 2: c_class = self.jenkins_class + "$UsersPrivateKeySource" else: c_class = None attrs = { "value": self.key_type, "privateKey": self.key_value, "stapler-class": c_class, } # We need one more attr when using the key file on master. if self.key_type == 1: attrs["privateKeyFile"] = self.key_value return attrs def get_attributes(self): """ Used by Credentials object to create credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id return { "stapler-class": self.attrs["stapler-class"], "Submit": "OK", "json": { "": "1", "credentials": { "scope": "GLOBAL", "id": c_id, "username": self.username, "description": self.description, "privateKeySource": self.attrs, "passphrase": self.passphrase, "stapler-class": self.jenkins_class, "$class": self.jenkins_class, }, }, } def get_attributes_xml(self): """ Used by Credentials object to update a credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id data = { "id": c_id, "username": self.username, "description": self.description, "privateKeySource": self.attrs, "passphrase": self.passphrase, } return super(SSHKeyCredential, self)._get_attributes_xml(data) class AmazonWebServicesCredentials(Credential): """ AWS credential using the CloudBees AWS Credentials Plugin See https://wiki.jenkins.io/display/JENKINS/CloudBees+AWS+Credentials+Plugin Constructor expects following dict: { 'credential_id': str, Automatically set by jenkinsapi 'displayName': str, Automatically set by Jenkins 'fullName': str, Automatically set by Jenkins 'description': str, 'accessKey': str, 'secretKey': str, 'iamRoleArn': str, 'iamMfaSerialNumber': str } When creating credential via jenkinsapi automatic fields not need to be in dict """ def __init__(self, cred_dict): jenkins_class = ( "com.cloudbees.jenkins.plugins.awscredentials.AWSCredentialsImpl" ) super(AmazonWebServicesCredentials, self).__init__( cred_dict, jenkins_class ) self.access_key = cred_dict["accessKey"] self.secret_key = cred_dict["secretKey"] self.iam_role_arn = cred_dict.get("iamRoleArn", "") self.iam_mfa_serial_number = cred_dict.get("iamMfaSerialNumber", "") def get_attributes(self): """ Used by Credentials object to create credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id return { "stapler-class": self.jenkins_class, "Submit": "OK", "json": { "": "1", "credentials": { "stapler-class": self.jenkins_class, "$class": self.jenkins_class, "id": c_id, "accessKey": self.access_key, "secretKey": self.secret_key, "iamRoleArn": self.iam_role_arn, "iamMfaSerialNumber": self.iam_mfa_serial_number, "description": self.description, }, }, } def get_attributes_xml(self): """ Used by Credentials object to update a credential in Jenkins """ c_id = "" if self.credential_id is None else self.credential_id data = { "id": c_id, "accessKey": self.access_key, "secretKey": self.secret_key, "iamRoleArn": self.iam_role_arn, "iamMfaSerialNumber": self.iam_mfa_serial_number, "description": self.description, } return super(AmazonWebServicesCredentials, self)._get_attributes_xml( data ) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/credentials.py0000644000000000000000000001644314766246232015706 0ustar00""" This module implements the Credentials class, which is intended to be a container-like interface for all of the Global credentials defined on a single Jenkins node. """ from __future__ import annotations from typing import Iterator import logging from urllib.parse import urlencode from jenkinsapi.credential import Credential from jenkinsapi.credential import UsernamePasswordCredential from jenkinsapi.credential import SecretTextCredential from jenkinsapi.credential import SSHKeyCredential from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import JenkinsAPIException log: logging.Logger = logging.getLogger(__name__) class Credentials(JenkinsBase): """ This class provides a container-like API which gives access to all global credentials on a Jenkins node. Returns a list of Credential Objects. """ def __init__(self, baseurl: str, jenkins_obj: "Jenkins"): self.baseurl: str = baseurl self.jenkins: "Jenkins" = jenkins_obj JenkinsBase.__init__(self, baseurl) self.credentials = self._data["credentials"] def _poll(self, tree=None): url: str = self.python_api_url(self.baseurl) + "?depth=2" data = self.get_data(url, tree=tree) credentials = data["credentials"] for cred_id, cred_dict in credentials.items(): cred_dict["credential_id"] = cred_id credentials[cred_id] = self._make_credential(cred_dict) return data def __str__(self) -> str: return "Global Credentials @ %s" % self.baseurl def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def __iter__(self) -> Iterator[Credential]: for cred in self.credentials.values(): yield cred.description def __contains__(self, description: str) -> bool: return description in self.keys() def iterkeys(self): return self.__iter__() def keys(self): return list(self.iterkeys()) def iteritems(self) -> Iterator[str, "Credential"]: for cred in self.credentials.values(): yield cred.description, cred def __getitem__(self, description: str) -> "Credential": for cred in self.credentials.values(): if cred.description == description: return cred raise KeyError( 'Credential with description "%s" not found' % description ) def __len__(self) -> int: return len(self.keys()) def __setitem__(self, description: str, credential: "Credential"): """ Creates Credential in Jenkins using username, password and description Description must be unique in Jenkins instance because it is used to find Credential later. If description already exists - this method is going to update existing Credential :param str description: Credential description :param tuple credential_tuple: (username, password, description) tuple. """ if description not in self: params = credential.get_attributes() url = "%s/createCredentials" % self.baseurl try: self.jenkins.requester.post_and_confirm_status( url, params={}, data=urlencode(params) ) except JenkinsAPIException as jae: raise JenkinsAPIException( "Latest version of Credentials " "plugin is required to be able " "to create credentials. " "Original exception: %s" % str(jae) ) else: cred_id = self[description].credential_id credential.credential_id = cred_id params = credential.get_attributes_xml() url = "%s/credential/%s/config.xml" % (self.baseurl, cred_id) try: self.jenkins.requester.post_xml_and_confirm_status( url, params={}, data=params ) except JenkinsAPIException as jae: raise JenkinsAPIException( "Latest version of Credentials " "plugin is required to be able " "to update credentials. " "Original exception: %s" % str(jae) ) self.poll() self.credentials = self._data["credentials"] if description not in self: raise JenkinsAPIException("Problem creating/updating credential.") def get(self, item, default): return self[item] if item in self else default def __delitem__(self, description: str): if description not in self: raise KeyError( 'Credential with description "%s" not found' % description ) params = {"Submit": "OK", "json": {}} url = "%s/credential/%s/doDelete" % ( self.baseurl, self[description].credential_id, ) try: self.jenkins.requester.post_and_confirm_status( url, params={}, data=urlencode(params) ) except JenkinsAPIException as jae: raise JenkinsAPIException( "Latest version of Credentials " "required to be able to create " "credentials. Original exception: %s" % str(jae) ) self.poll() self.credentials = self._data["credentials"] if description in self: raise JenkinsAPIException("Problem deleting credential.") def _make_credential(self, cred_dict): if cred_dict["typeName"] == "Username with password": cr = UsernamePasswordCredential(cred_dict) elif cred_dict["typeName"] == "SSH Username with private key": cr = SSHKeyCredential(cred_dict) elif cred_dict["typeName"] == "Secret text": cr = SecretTextCredential(cred_dict) else: cr = Credential(cred_dict) return cr class Credentials2x(Credentials): """ This class provides a container-like API which gives access to all global credentials on a Jenkins node. Returns a list of Credential Objects. """ def _poll(self, tree=None): url = self.python_api_url(self.baseurl) + "?depth=2" data = self.get_data(url, tree=tree) credentials = data["credentials"] new_creds = {} for cred_dict in credentials: cred_dict["credential_id"] = cred_dict["id"] new_creds[cred_dict["id"]] = self._make_credential(cred_dict) data["credentials"] = new_creds return data class CredentialsById(Credentials2x): """ This class provides a container-like API which gives access to all global credentials on a Jenkins node. Returns a list of Credential Objects. """ def __iter__(self): for cred in self.credentials.values(): yield cred.credential_id def __contains__(self, credential_id): return credential_id in self.keys() def iteritems(self): for cred in self.credentials.values(): yield cred.credential_id, cred def __getitem__(self, credential_id): for cred in self.credentials.values(): if cred.credential_id == credential_id: return cred raise KeyError( 'Credential with credential_id "%s" not found' % credential_id ) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/custom_exceptions.py0000644000000000000000000000455014766246232017160 0ustar00"""Module for custom_exceptions. Where possible we try to throw exceptions with non-generic, meaningful names. """ class JenkinsAPIException(Exception): """Base class for all errors""" pass class NotFound(JenkinsAPIException): """Resource cannot be found""" pass class ArtifactsMissing(NotFound): """Cannot find a build with all of the required artifacts.""" pass class UnknownJob(KeyError, NotFound): """Jenkins does not recognize the job requested.""" pass class UnknownView(KeyError, NotFound): """Jenkins does not recognize the view requested.""" pass class UnknownNode(KeyError, NotFound): """Jenkins does not recognize the node requested.""" pass class UnknownQueueItem(KeyError, NotFound): """Jenkins does not recognize the requested queue item""" pass class UnknownPlugin(KeyError, NotFound): """Jenkins does not recognize the plugin requested.""" pass class NoBuildData(NotFound): """A job has no build data.""" pass class NotBuiltYet(NotFound): """A job has no build data.""" pass class ArtifactBroken(JenkinsAPIException): """An artifact is broken, wrong""" pass class TimeOut(JenkinsAPIException): """Some jobs have taken too long to complete.""" pass class NoResults(JenkinsAPIException): """A build did not publish any results.""" pass class FailedNoResults(NoResults): """A build did not publish any results because it failed""" pass class BadURL(ValueError, JenkinsAPIException): """A URL appears to be broken""" pass class NotAuthorized(JenkinsAPIException): """Not Authorized to access resource""" # Usually thrown when we get a 403 returned pass class NotSupportSCM(JenkinsAPIException): """ It's a SCM that does not supported by current version of jenkinsapi """ pass class NotConfiguredSCM(JenkinsAPIException): """It's a job that doesn't have configured SCM""" pass class NotInQueue(JenkinsAPIException): """It's a job that is not in the queue""" pass class PostRequired(JenkinsAPIException): """Method requires POST and not GET""" pass class BadParams(JenkinsAPIException): """Invocation was given bad or inappropriate params""" pass class AlreadyExists(JenkinsAPIException): """ Method requires POST and not GET """ pass ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/executor.py0000644000000000000000000000354114766246232015242 0ustar00""" Module for jenkinsapi Executer class """ from __future__ import annotations from jenkinsapi.jenkinsbase import JenkinsBase import logging log = logging.getLogger(__name__) class Executor(JenkinsBase): """ Class to hold information on nodes that are attached as slaves to the master jenkins instance """ def __init__( self, baseurl: str, nodename: str, jenkins_obj: "Jenkins", number: int ) -> None: """ Init a node object by providing all relevant pointers to it :param baseurl: basic url for querying information on a node :param nodename: hostname of the node :param jenkins_obj: ref to the jenkins obj :return: Node obj """ self.nodename: str = nodename self.number: int = number self.jenkins: "Jenkins" = jenkins_obj self.baseurl: str = baseurl JenkinsBase.__init__(self, baseurl) def __str__(self) -> str: return f"{self.nodename} {self.number}" def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def get_progress(self) -> str: """Returns percentage""" return self.poll(tree="progress")["progress"] def get_number(self) -> int: """ Get Executor number. """ return self.poll(tree="number")["number"] def is_idle(self) -> bool: """ Returns Boolean: whether Executor is idle or not. """ return self.poll(tree="idle")["idle"] def likely_stuck(self) -> bool: """ Returns Boolean: whether Executor is likely stuck or not. """ return self.poll(tree="likelyStuck")["likelyStuck"] def get_current_executable(self) -> str: """ Returns the current Queue.Task this executor is running. """ return self.poll(tree="currentExecutable")["currentExecutable"] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/executors.py0000644000000000000000000000231014766246232015416 0ustar00""" This module implements the Executors class, which is intended to be a container-like interface for all of the executors defined on a single Jenkins node. """ from __future__ import annotations import logging from typing import Iterator from jenkinsapi.executor import Executor from jenkinsapi.jenkinsbase import JenkinsBase log: logging.Logger = logging.getLogger(__name__) class Executors(JenkinsBase): """ This class provides a container-like API which gives access to all executors on a Jenkins node. Returns a list of Executor Objects. """ def __init__( self, baseurl: str, nodename: str, jenkins: "Jenkins" ) -> None: self.nodename: str = nodename self.jenkins: str = jenkins JenkinsBase.__init__(self, baseurl) self.count: int = self._data["numExecutors"] def __str__(self) -> str: return f"Executors @ {self.baseurl}" def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def __iter__(self) -> Iterator[Executor]: for index in range(self.count): executor_url = "%s/executors/%s" % (self.baseurl, index) yield Executor(executor_url, self.nodename, self.jenkins, index) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/fingerprint.py0000644000000000000000000001047014766246232015732 0ustar00""" Module for jenkinsapi Fingerprint """ from __future__ import annotations import re import logging from typing import Any import requests from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import ArtifactBroken log: logging.Logger = logging.getLogger(__name__) class Fingerprint(JenkinsBase): """ Represents a jenkins fingerprint on a single artifact file ?? """ RE_MD5 = re.compile("^([0-9a-z]{32})$") def __init__(self, baseurl: str, id_: str, jenkins_obj: "Jenkins") -> None: self.jenkins_obj: "Jenkins" = jenkins_obj assert self.RE_MD5.search(id_), ( "%s does not look like " "a valid id" % id_ ) url: str = f"{baseurl}/fingerprint/{id_}/" JenkinsBase.__init__(self, url, poll=False) self.id_: str = id_ self.unknown: bool = False # Previously uninitialized in ctor def get_jenkins_obj(self) -> "Jenkins": return self.jenkins_obj def __str__(self) -> str: return self.id_ def valid(self) -> bool: """ Return True / False if valid. If returns True, self.unknown is set to either True or False, and can be checked if we have positive validity (fingerprint known at server) or negative validity (fingerprint not known at server, but not really an error). """ try: self.poll() self.unknown = False except requests.exceptions.HTTPError as err: # We can't really say anything about the validity of # fingerprints not found -- but the artifact can still # exist, so it is not possible to definitely say they are # valid or not. # The response object is of type: requests.models.Response # extract the status code from it response_obj: Any = err.response if response_obj.status_code == 404: logging.warning( "MD5 cannot be checked if fingerprints are not enabled" ) self.unknown = True return True return False return True def validate_for_build(self, filename: str, job: str, build: int) -> bool: if not self.valid(): log.info("Fingerprint is not known to jenkins.") return False if self.unknown: # not request error, but unknown to jenkins return True if self._data["original"] is not None: if self._data["original"]["name"] == job: if self._data["original"]["number"] == build: return True if self._data["fileName"] != filename: log.info( msg="Filename from jenkins (%s) did not match provided (%s)" % (self._data["fileName"], filename) ) return False for usage_item in self._data["usage"]: if usage_item["name"] == job: for range_ in usage_item["ranges"]["ranges"]: if range_["start"] <= build <= range_["end"]: msg = ( "This artifact was generated by %s " "between build %i and %i" % ( job, range_["start"], range_["end"], ) ) log.info(msg=msg) return True return False def validate(self) -> bool: try: assert self.valid() except AssertionError as ae: raise ArtifactBroken( "Artifact %s seems to be broken, check %s" % (self.id_, self.baseurl) ) from ae except requests.exceptions.HTTPError: raise ArtifactBroken( "Unable to validate artifact id %s using %s" % (self.id_, self.baseurl) ) return True def get_info(self): """ Returns a tuple of build-name, build# and artifact filename for a good build. """ self.poll() return ( self._data["original"]["name"], self._data["original"]["number"], self._data["fileName"], ) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/jenkins.py0000644000000000000000000006372714766246232015061 0ustar00""" Module for jenkinsapi Jenkins object """ from __future__ import annotations import time import logging import warnings from urllib.parse import urlparse from urllib.request import Request, HTTPRedirectHandler, build_opener from urllib.parse import quote as urlquote from urllib.parse import urlencode from requests import HTTPError, ConnectionError from jenkinsapi import config from jenkinsapi.credentials import Credentials from jenkinsapi.credentials import Credentials2x from jenkinsapi.credentials import CredentialsById from jenkinsapi.executors import Executors from jenkinsapi.jobs import Jobs from jenkinsapi.job import Job from jenkinsapi.view import View from jenkinsapi.label import Label from jenkinsapi.node import Node from jenkinsapi.nodes import Nodes from jenkinsapi.plugins import Plugins from jenkinsapi.plugin import Plugin from jenkinsapi.utils.requester import Requester from jenkinsapi.views import Views from jenkinsapi.queue import Queue from jenkinsapi.fingerprint import Fingerprint from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import JenkinsAPIException from jenkinsapi.utils.crumb_requester import CrumbRequester log = logging.getLogger(__name__) class Jenkins(JenkinsBase): """ Represents a jenkins environment. """ # pylint: disable=too-many-arguments def __init__( self, baseurl: str, username: str = "", password: str = "", requester=None, lazy: bool = False, ssl_verify: bool = True, cert=None, timeout: int = 10, use_crumb: bool = True, max_retries=None, ) -> None: """ :param baseurl: baseurl for jenkins instance including port, str :param username: username for jenkins auth, str :param password: password for jenkins auth, str :return: a Jenkins obj """ self.username = username self.password = password if requester is None: if use_crumb: requester = CrumbRequester else: requester = Requester self.requester = requester( username, password, baseurl=baseurl, ssl_verify=ssl_verify, cert=cert, timeout=timeout, max_retries=max_retries, ) else: self.requester = requester self.requester.timeout = timeout self.lazy = lazy self.jobs_container = None JenkinsBase.__init__(self, baseurl, poll=not lazy) def _poll(self, tree=None): url = self.python_api_url(self.baseurl) return self.get_data( url, tree="jobs[name,color,url]" if not tree else tree ) def _poll_if_needed(self): if self.lazy and self._data is None: self.poll() def _clone(self): return Jenkins( self.baseurl, username=self.username, password=self.password, requester=self.requester, ) def base_server_url(self): if config.JENKINS_API in self.baseurl: return self.baseurl[: -(len(config.JENKINS_API))] return self.baseurl def validate_fingerprint(self, id_): obj_fingerprint = Fingerprint(self.baseurl, id_, jenkins_obj=self) obj_fingerprint.validate() log.info(msg="Jenkins says %s is valid" % id_) def get_artifact_data(self, id_): obj_fingerprint = Fingerprint(self.baseurl, id_, jenkins_obj=self) obj_fingerprint.validate() return obj_fingerprint.get_info() def validate_fingerprint_for_build(self, digest, filename, job, build): obj_fingerprint = Fingerprint(self.baseurl, digest, jenkins_obj=self) return obj_fingerprint.validate_for_build(filename, job, build) def get_jenkins_obj(self): return self def get_jenkins_obj_from_url(self, url: str): return Jenkins(url, self.username, self.password, self.requester) def get_create_url(self) -> str: # This only ever needs to work on the base object return "%s/createItem" % self.baseurl def get_nodes_url(self) -> str: # This only ever needs to work on the base object return self.nodes.baseurl @property def jobs(self): if self.jobs_container is None: self.jobs_container = Jobs(self) return self.jobs_container def get_jobs(self): """ Fetch all the build-names on this Jenkins server. """ return self.jobs.iteritems() def get_jobs_info(self): """ Get the jobs information :return url, name """ for name, job in self.jobs.iteritems(): yield job.url, name def get_job(self, jobname: str) -> Job: """ Get a job by name :param jobname: name of the job, str :return: Job obj """ return self.jobs[jobname] def get_job_by_url(self, url: str, job_name: str) -> Job: """ Get a job by url :param url: jobs' url :param jobname: name of the job, str :return: Job obj """ return Job(url, job_name, self) def has_job(self, jobname: str) -> bool: """ Does a job by the name specified exist :param jobname: string :return: boolean """ return jobname in self.jobs def create_job(self, jobname: str, xml) -> Job: """ Create a job alternatively you can create job using Jobs object: self.jobs['job_name'] = config :param jobname: name of new job, str :param config: configuration of new job, xml :return: new Job obj """ return self.jobs.create(jobname, xml) def create_multibranch_pipeline_job( self, jobname, xml, block=True, delay=60 ): """ :return: list of new Job objects """ return self.jobs.create_multibranch_pipeline( jobname, xml, block, delay ) def copy_job(self, jobname: str, newjobname: str) -> Job: return self.jobs.copy(jobname, newjobname) def build_job(self, jobname: str, params=None) -> None: """ Invoke a build by job name :param jobname: name of exist job, str :param params: the job params, dict :return: none """ self[jobname].invoke(build_params=params or {}) def delete_job(self, jobname: str) -> None: """ Delete a job by name :param jobname: name of a exist job, str :return: new jenkins_obj """ del self.jobs[jobname] def rename_job(self, jobname: str, newjobname: str) -> Job: """ Rename a job :param jobname: name of a exist job, str :param newjobname: name of new job, str :return: new Job obj """ return self.jobs.rename(jobname, newjobname) def items(self): """ :param return: A list of pairs. Each pair will be (job name, Job object) """ return list(self.iteritems()) def get_jobs_list(self): return self.jobs.keys() def iterkeys(self): return self.jobs.iterkeys() def iteritems(self): return self.jobs.iteritems() def keys(self): return self.jobs.keys() def __str__(self) -> str: return "Jenkins server at %s" % self.baseurl @property def views(self): return Views(self) def get_view_by_url(self, view_url: str): # for nested view view_name = view_url.split("/view/")[-1].replace("/", "") return View(view_url, view_name, jenkins_obj=self) def delete_view_by_url(self, viewurl: str): url = f"{viewurl}/doDelete" self.requester.post_and_confirm_status(url, data="") self.poll() return self def get_label(self, label_name: str) -> Label: label_url = "%s/label/%s" % (self.baseurl, label_name) return Label(label_url, label_name, jenkins_obj=self) def __getitem__(self, jobname: str) -> Job: """ Get a job by name :param jobname: name of job, str :return: Job obj """ return self.jobs[jobname] def __len__(self) -> int: return len(self.jobs) def __contains__(self, jobname: str) -> bool: """ Does a job by the name specified exist :param jobname: string :return: boolean """ return jobname in self.jobs def __delitem__(self, job_name: str) -> None: del self.jobs[job_name] def get_node(self, nodename: str) -> Node: """Get a node object for a specific node""" return self.nodes[nodename] def get_node_url(self, nodename: str = "") -> str: """Return the url for nodes""" url = urlparse.urljoin( self.base_server_url(), "computer/%s" % urlquote(nodename) ) return url def get_queue_url(self): url = f"{self.base_server_url()}/queue" return url def get_queue(self) -> Queue: queue_url = self.get_queue_url() return Queue(queue_url, self) def get_nodes(self) -> Nodes: return Nodes(self.baseurl, self) @property def nodes(self): return self.get_nodes() def has_node(self, nodename: str) -> bool: """ Does a node by the name specified exist :param nodename: string, hostname :return: boolean """ self.poll() return nodename in self.nodes def delete_node(self, nodename: str) -> None: """ Remove a node from the managed slave list Please note that you cannot remove the master node :param nodename: string holding a hostname :return: None """ del self.nodes[nodename] def create_node( self, name: str, num_executors: int = 2, node_description: str = "", remote_fs: str = "/var/lib/jenkins", labels=None, exclusive: bool = False, ) -> Node: """ Create a new JNLP slave node by name. To create SSH node, please see description in Node class :param name: fqdn of slave, str :param num_executors: number of executors, int :param node_description: a freetext field describing the node :param remote_fs: jenkins path, str :param labels: labels to associate with slave, str :param exclusive: tied to specific job, boolean :return: node obj """ node_dict = { "num_executors": num_executors, "node_description": node_description, "remote_fs": remote_fs, "labels": labels, "exclusive": exclusive, } return self.nodes.create_node(name, node_dict) def create_node_with_config(self, name: str, config) -> Node | None: """ Create a new slave node with specific configuration. Config should be resemble the output of node.get_node_attributes() :param str name: name of slave :param dict config: Node attributes for Jenkins API request to create node (See function output Node.get_node_attributes()) :return: node obj """ return self.nodes.create_node_with_config(name=name, config=config) def get_plugins_url(self, depth): # This only ever needs to work on the base object return f"{self.baseurl}/pluginManager/api/python?depth={depth}" def install_plugin( self, plugin: str | Plugin, restart: bool = True, force_restart: bool = False, wait_for_reboot: bool = True, no_reboot_warning: bool = False, ): """ Install a plugin and optionally restart jenkins. @param plugin: Plugin (string or Plugin object) to be installed @param restart: Boolean, restart Jenkins when required by plugin @param force_restart: Boolean, force Jenkins to restart, ignoring plugin preferences @param no_warning: Don't show warning when restart is needed and restart parameters are set to False """ if not isinstance(plugin, Plugin): plugin = Plugin(plugin) self.plugins[plugin.shortName] = plugin if force_restart or (restart and self.plugins.restart_required): self.safe_restart(wait_for_reboot=wait_for_reboot) elif self.plugins.restart_required and not no_reboot_warning: warnings.warn( "System reboot is required, but automatic reboot is disabled. " "Please reboot manually." ) def install_plugins( self, plugin_list, restart: bool = True, force_restart: bool = False, wait_for_reboot: bool = True, no_reboot_warning: bool = False, ) -> None: """ Install a list of plugins and optionally restart jenkins. @param plugin_list: List of plugins (strings, Plugin objects or a mix of the two) to be installed @param restart: Boolean, restart Jenkins when required by plugin @param force_restart: Boolean, force Jenkins to restart, ignoring plugin preferences """ plugins = [ p if isinstance(p, Plugin) else Plugin(p) for p in plugin_list ] for plugin in plugins: self.install_plugin(plugin, restart=False, no_reboot_warning=True) if force_restart or (restart and self.plugins.restart_required): self.safe_restart(wait_for_reboot=wait_for_reboot) elif self.plugins.restart_required and not no_reboot_warning: warnings.warn( "System reboot is required, but automatic reboot is disabled. " "Please reboot manually." ) def delete_plugin( self, plugin: str | Plugin, restart: bool = True, force_restart: bool = False, wait_for_reboot: bool = True, no_reboot_warning: bool = False, ) -> None: """ Delete a plugin and optionally restart jenkins. Will not delete dependencies. @param plugin: Plugin (string or Plugin object) to be deleted @param restart: Boolean, restart Jenkins when required by plugin @param force_restart: Boolean, force Jenkins to restart, ignoring plugin preferences """ if isinstance(plugin, Plugin): plugin = plugin.shortName del self.plugins[plugin] if force_restart or (restart and self.plugins.restart_required): self.safe_restart(wait_for_reboot=wait_for_reboot) elif self.plugins.restart_required and not no_reboot_warning: warnings.warn( "System reboot is required, but automatic reboot is disabled. " "Please reboot manually." ) def delete_plugins( self, plugin_list, restart: bool = True, force_restart: bool = False, wait_for_reboot: bool = True, no_reboot_warning: bool = False, ): """ Delete a list of plugins and optionally restart jenkins. Will not delete dependencies. @param plugin_list: List of plugins (strings, Plugin objects or a mix of the two) to be deleted @param restart: Boolean, restart Jenkins when required by plugin @param force_restart: Boolean, force Jenkins to restart, ignoring plugin preferences """ for plugin in plugin_list: self.delete_plugin(plugin, restart=False, no_reboot_warning=True) if force_restart or (restart and self.plugins.restart_required): self.safe_restart(wait_for_reboot=wait_for_reboot) elif self.plugins.restart_required and not no_reboot_warning: warnings.warn( "System reboot is required, but automatic reboot is disabled. " "Please reboot manually." ) def safe_restart(self, wait_for_reboot: bool = True): """restarts jenkins when no jobs are running""" # NB: unlike other methods, the value of resp.status_code # here can be 503 even when everything is normal url = "%s/safeRestart" % (self.baseurl,) valid = self.requester.VALID_STATUS_CODES + [503, 500] resp = self.requester.post_and_confirm_status( url, data="", valid=valid ) if wait_for_reboot: self._wait_for_reboot() return resp def _wait_for_reboot(self) -> None: # We need to make sure all jobs have finished, # and that jenkins is actually restarting. # One way to be sure is to make sure jenkins is really down. wait = 5 count = 0 max_count = 30 self.__jenkins_is_unavailable() # Blocks until jenkins is restarting while count < max_count: time.sleep(wait) try: self.poll() len(self.plugins) # Make sure jenkins is fully started return # By this time jenkins is back online except (HTTPError, ConnectionError): msg = ( "Jenkins has not restarted yet! (This is" " try {0} of {1}, waited {2} seconds so far)" " Sleeping and trying again.." ) msg = msg.format(count, max_count, count * wait) log.debug(msg) count += 1 msg = ( "Jenkins did not come back from safe restart! " "Waited %s seconds altogether. This " "failure may cause other failures." ) log.critical(msg, count * wait) def __jenkins_is_unavailable(self): while True: try: res = self.requester.get_and_confirm_status( self.baseurl, valid=[503, 500, 200] ) # If there is a running job in Jenkins, the system message will # pop up but the Jenkins instance will return 200 if ( res.status_code == 200 and "Jenkins is going to shut down" in str(res.content, encoding="utf-8") ): time.sleep(1) continue return True except ConnectionError: # This is also a possibility while Jenkins is restarting return True except HTTPError: # This is a return code that is not 503, # so Jenkins is likely available time.sleep(1) def safe_exit(self, wait_for_exit: bool = True, max_wait: int = 360): """ Restarts jenkins when no jobs are running, except for pipeline jobs """ # NB: unlike other methods, the value of resp.status_code # here can be 503 even when everything is normal url = f"{self.baseurl}/safeExit" valid = self.requester.VALID_STATUS_CODES + [503, 500] resp = self.requester.post_and_confirm_status( url, data="", valid=valid ) if wait_for_exit: self._wait_for_exit(max_wait=max_wait) return resp def _wait_for_exit(self, max_wait: int = 360) -> None: # We need to make sure all non pipeline jobs have finished, # and that jenkins is unavailable self.__jenkins_is_unresponsive(max_wait=max_wait) def __jenkins_is_unresponsive(self, max_wait: int = 360): # Blocks until jenkins returns ConnectionError or JenkinsAPIException # Default wait is one hour is_alive = True wait = 0 while is_alive and wait < max_wait: try: self.requester.get_and_confirm_status( self.baseurl, valid=[200] ) time.sleep(1) wait += 1 is_alive = True except (ConnectionError, JenkinsAPIException): # Jenkins is finally down is_alive = False return True except HTTPError: # This is a return code that is not 503, # so Jenkins is likely available, and we need to wait time.sleep(1) wait += 1 is_alive = True def quiet_down(self): """ Put Jenkins in a Quiet mode, preparation for restart. No new builds started """ # NB: unlike other methods, the value of resp.status_code # here can be 503 even when everything is normal url = "%s/quietDown" % (self.baseurl,) valid = self.requester.VALID_STATUS_CODES + [503, 500] resp = self.requester.post_and_confirm_status( url, data="", valid=valid ) return resp def cancel_quiet_down(self): """Cancel the effect of the quiet-down command""" # NB: unlike other methods, the value of resp.status_code # here can be 503 even when everything is normal url = "%s/cancelQuietDown" % (self.baseurl,) valid = self.requester.VALID_STATUS_CODES + [503, 500] resp = self.requester.post_and_confirm_status( url, data="", valid=valid ) return resp @property def plugins(self): return self.get_plugins() def get_plugins(self, depth: int = 1) -> Plugins: url = self.get_plugins_url(depth=depth) return Plugins(url, self) def has_plugin(self, plugin_name: str) -> bool: return plugin_name in self.plugins def get_executors(self, nodename: str) -> Executors: url = f"{self.baseurl}/computer/{nodename}" return Executors(url, nodename, self) def get_master_data(self): url = f"{self.baseurl}/computer/api/python" return self.get_data(url) @property def version(self) -> str: """ Return version number of Jenkins """ response = self.requester.get_and_confirm_status(self.baseurl) version_key = "X-Jenkins" return response.headers.get(version_key, "0.0") def get_credentials(self, cred_class=Credentials2x): """ Return credentials """ if "credentials" not in self.plugins: raise JenkinsAPIException("Credentials plugin not installed") if self.plugins["credentials"].version.startswith("1."): url = f"{self.baseurl}/credential-store/domain/_/" return Credentials(url, self) url = f"{self.baseurl}/credentials/store/system/domain/_/" return cred_class(url, self) @property def credentials(self): return self.get_credentials(Credentials2x) @property def credentials_by_id(self): return self.get_credentials(CredentialsById) @property def is_quieting_down(self) -> bool: url = "%s/api/python?tree=quietingDown" % (self.baseurl,) data = self.get_data(url=url) return data.get("quietingDown", False) def shutdown(self) -> None: url = "%s/exit" % self.baseurl self.requester.post_and_confirm_status(url, data="") def generate_new_api_token( self, new_token_name: str = "Token By jenkinsapi python" ): subUrl = ( "/me/descriptorByName/jenkins.security." "ApiTokenProperty/generateNewToken" ) url = "%s%s" % (self.baseurl, subUrl) data = urlencode({"newTokenName": new_token_name}) response = self.requester.post_and_confirm_status(url, data=data) token = response.json()["data"]["tokenValue"] return token def run_groovy_script(self, script: str) -> str: """ Runs the requested groovy script on the Jenkins server returning the result as text. Raises a JenkinsAPIException if the returned HTTP response code from the POST request is not 200 OK. Example: server = Jenkins(...) script = 'println "Hello world!"' result = server.run_groovy_script(script) print(result) # will print "Hello world!" """ url = f"{self.baseurl}/scriptText" data = urlencode({"script": script}) response = self.requester.post_and_confirm_status(url, data=data) if response.status_code != 200: raise JenkinsAPIException( "Unexpected response %d." % response.status_code ) return response.text def use_auth_cookie(self) -> None: assert self.username and self.baseurl, ( "Please provide jenkins url, username " "and password to get the session ID cookie." ) login_url = "j_acegi_security_check" jenkins_url = "{0}/{1}".format(self.baseurl, login_url) data = urlencode( {"j_username": self.username, "j_password": self.password} ).encode("utf-8") class SmartRedirectHandler(HTTPRedirectHandler): def extract_cookie(self, setcookie): # Extracts the last cookie. # Example of set-cookie value for python2 # ('set-cookie', 'JSESSIONID.30blah=blahblahblah;Path=/; # HttpOnly, JSESSIONID.30ablah=blahblah;Path=/;HttpOnly'), return setcookie.split(",")[-1].split(";")[0].strip("\n\r ") def http_error_302(self, req, fp, code, msg, headers): # Jenkins can send several Set-Cookie values sometimes # The valid one is the last one for header, value in headers.items(): if header.lower() == "set-cookie": cookie = self.extract_cookie(value) req.headers["Cookie"] = cookie result = HTTPRedirectHandler.http_error_302( self, req, fp, code, msg, headers ) result.orig_status = code result.orig_headers = headers result.cookie = cookie return result request = Request(jenkins_url, data) opener = build_opener(SmartRedirectHandler()) res = opener.open(request) Requester.AUTH_COOKIE = res.cookie ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/jenkinsbase.py0000644000000000000000000000754514766246232015710 0ustar00""" Module for JenkinsBase class """ from __future__ import annotations import ast import pprint import logging from urllib.parse import quote from jenkinsapi import config from jenkinsapi.custom_exceptions import JenkinsAPIException logger = logging.getLogger(__name__) class JenkinsBase(object): """ This appears to be the base object that all other jenkins objects are inherited from """ def __repr__(self): return """<%s.%s %s>""" % ( self.__class__.__module__, self.__class__.__name__, str(self), ) def __str__(self): raise NotImplementedError def __init__(self, baseurl: str, poll: bool = True): """ Initialize a jenkins connection """ self._data = None self.baseurl = self.strip_trailing_slash(baseurl) if poll: self.poll() def get_jenkins_obj(self): raise NotImplementedError( "Please implement this method on %s" % self.__class__.__name__ ) def __eq__(self, other) -> bool: """ Return true if the other object represents a connection to the same server """ if not isinstance(other, self.__class__): return False return other.baseurl == self.baseurl @classmethod def strip_trailing_slash(cls, url: str) -> str: while url.endswith("/"): url = url[:-1] return url def poll(self, tree=None): data = self._poll(tree=tree) if "jobs" in data: data["jobs"] = self.resolve_job_folders(data["jobs"]) if not tree: self._data = data return data def _poll(self, tree=None): url = self.python_api_url(self.baseurl) return self.get_data(url, tree=tree) def get_data(self, url, params=None, tree=None): requester = self.get_jenkins_obj().requester if tree: if not params: params = {"tree": tree} else: params.update({"tree": tree}) response = requester.get_url(url, params) if response.status_code != 200: logger.error( "Failed request at %s with params: %s %s", url, params, tree if tree else "", ) response.raise_for_status() try: return ast.literal_eval(response.text) except Exception: logger.exception("Inappropriate content found at %s", url) raise JenkinsAPIException("Cannot parse %s" % response.content) def pprint(self): """ Print out all the data in this object for debugging. """ pprint.pprint(self._data) def resolve_job_folders(self, jobs): for job in list(jobs): if "color" not in job.keys(): jobs.remove(job) jobs += self.process_job_folder(job, self.baseurl) return jobs def process_job_folder(self, folder, folder_path): logger.debug("Processing folder %s in %s", folder["name"], folder_path) folder_path += "/job/%s" % quote(folder["name"]) data = self.get_data( self.python_api_url(folder_path), tree="jobs[name,color]" ) result = [] for job in data.get("jobs", []): if "color" not in job.keys(): result += self.process_job_folder(job, folder_path) else: job["url"] = "%s/job/%s" % (folder_path, quote(job["name"])) result.append(job) return result @classmethod def python_api_url(cls, url: str) -> str: if url.endswith(config.JENKINS_API): return url else: if url.endswith(r"/"): fmt = "%s%s" else: fmt = "%s/%s" return fmt % (url, config.JENKINS_API) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/job.py0000644000000000000000000006504514766246232014165 0ustar00""" Module for jenkinsapi Job """ from __future__ import annotations import json import logging import xml.etree.ElementTree as ET import urllib.parse as urlparse from collections import defaultdict from jenkinsapi.build import Build from jenkinsapi.custom_exceptions import ( NoBuildData, NotConfiguredSCM, NotFound, NotInQueue, NotSupportSCM, UnknownQueueItem, BadParams, ) from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.mutable_jenkins_thing import MutableJenkinsThing from jenkinsapi.queue import QueueItem SVN_URL = "./scm/locations/hudson.scm.SubversionSCM_-ModuleLocation/remote" GIT_URL = "./scm/userRemoteConfigs/hudson.plugins.git.UserRemoteConfig/url" HG_URL = "./scm/source" GIT_BRANCH = "./scm/branches/hudson.plugins.git.BranchSpec/name" HG_BRANCH = "./scm/branch" DEFAULT_HG_BRANCH_NAME = "default" log = logging.getLogger(__name__) class Job(JenkinsBase, MutableJenkinsThing): """ Represents a jenkins job A job can hold N builds which are the actual execution environments """ def __init__(self, url: str, name: str, jenkins_obj: "Jenkins") -> None: self.name: str = name self.jenkins: "Jenkins" = jenkins_obj self._revmap = None self._config = None self._element_tree = None self._scm_prefix = "" self._scm_map = { "hudson.scm.SubversionSCM": "svn", "hudson.plugins.git.GitSCM": "git", "hudson.plugins.mercurial.MercurialSCM": "hg", "hudson.scm.NullSCM": "NullSCM", } self._scmurlmap = { "svn": lambda element_tree: list(element_tree.findall(SVN_URL)), "git": lambda element_tree: list( element_tree.findall(self._scm_prefix + GIT_URL) ), "hg": lambda element_tree: list(element_tree.findall(HG_URL)), None: lambda element_tree: [], } self._scmbranchmap = { "svn": lambda element_tree: [], "git": lambda element_tree: list( element_tree.findall(self._scm_prefix + GIT_BRANCH) ), "hg": self._get_hg_branch, None: lambda element_tree: [], } self.url: str = url JenkinsBase.__init__(self, self.url) def __str__(self) -> str: return self.name def get_description(self) -> str: return self._data["description"] def get_jenkins_obj(self) -> "Jenkins": return self.jenkins # When the name of the hg branch used in the job is default hg branch (i.e. # default), Mercurial plugin doesn't store default branch name in # config XML file of the job. Create XML node corresponding to # default branch def _get_hg_branch(self, element_tree): branches = element_tree.findall(HG_BRANCH) if not branches: hg_default_branch = ET.Element("branch") hg_default_branch.text = DEFAULT_HG_BRANCH_NAME branches.append(hg_default_branch) return branches def poll(self, tree=None): data = super(Job, self).poll(tree=tree) if not tree and not self.jenkins.lazy: self._data = self._add_missing_builds(self._data) return data # pylint: disable=E1123 # Unexpected keyword arg 'params' def _add_missing_builds(self, data): """ Query Jenkins to get all builds of the job in the data object. Jenkins API loads the first 100 builds and thus may not contain all builds information. This method checks if all builds are loaded in the data object and updates it with the missing builds if needed. """ if not data.get("builds"): return data # do not call _buildid_for_type here: it would poll and do an infinite # loop oldest_loaded_build_number = data["builds"][-1]["number"] if "firstBuild" not in self._data or not self._data["firstBuild"]: first_build_number = oldest_loaded_build_number else: first_build_number = self._data["firstBuild"]["number"] all_builds_loaded = oldest_loaded_build_number == first_build_number if all_builds_loaded: return data response = self.poll(tree="allBuilds[number,url]") data["builds"] = response["allBuilds"] return data def _get_config_element_tree(self): """ The ElementTree objects creation is unnecessary, it can be a singleton per job """ if self._config is None: self.load_config() if self._element_tree is None: self._element_tree = ET.fromstring(self._config) return self._element_tree def get_build_triggerurl(self) -> str: if not self.has_params(): return "%s/build" % self.baseurl return "%s/buildWithParameters" % self.baseurl @staticmethod def _mk_json_from_build_parameters(build_params, file_params=None): """ Build parameters must be submitted in a particular format Key-Value pairs would be far too simple, no no! Watch and read on and behold! """ if not isinstance(build_params, dict): raise ValueError("Build parameters must be a dict") build_p = [ {"name": k, "value": v} for k, v in sorted(build_params.items()) ] out = {"parameter": build_p} if file_params: file_p = [{"name": k, "file": k} for k in file_params.keys()] out["parameter"].extend(file_p) if len(out["parameter"]) == 1: out["parameter"] = out["parameter"][0] return out @staticmethod def mk_json_from_build_parameters(build_params, file_params=None): json_structure = Job._mk_json_from_build_parameters( build_params, file_params ) json_structure["statusCode"] = "303" json_structure["redirectTo"] = "." return json.dumps(json_structure) def invoke( self, securitytoken=None, block: bool = False, build_params=None, cause=None, files=None, delay: int = 5, quiet_period=None, ) -> QueueItem: assert isinstance(block, bool) if build_params and (not self.has_params()): raise BadParams("This job does not support parameters") params = {} # Via Get string if securitytoken: params["token"] = securitytoken # Either copy the params dict or make a new one. build_params = ( dict(build_params.items()) if build_params else {} ) # Via POSTed JSON url = self.get_build_triggerurl() # If quiet period is set, the build will have {quiet_period} seconds # quiet peroid before start. if quiet_period is not None: url += "?delay={0}sec".format(quiet_period) if cause: build_params["cause"] = cause # Build require params as form fields # and as Json. data = { "json": self.mk_json_from_build_parameters(build_params, files) } data.update(build_params) response = self.jenkins.requester.post_and_confirm_status( url, data=data, params=params, files=files, valid=[200, 201, 303], allow_redirects=False, ) redirect_url = response.headers["location"] # # Enterprise Jenkins implementations such as CloudBees locate their # queue REST API base https://server.domain.com/jenkins/queue/api/ # above the team-specific REST API base # https://server.domain.com/jenkins/job/my_team/api/ # queue_baseurl_candidates = [self.jenkins.baseurl] scheme, netloc, path, _, query, frag = urlparse.urlparse( self.jenkins.baseurl ) while path: path = "/".join(path.rstrip("/").split("/")[:-1]) queue_baseurl_candidates.append( urlparse.urlunsplit([scheme, netloc, path, query, frag]) ) redirect_url_valid = False for queue_baseurl_candidate in queue_baseurl_candidates: redirect_url_valid = redirect_url.startswith( "%s/queue/item" % queue_baseurl_candidate ) if redirect_url_valid: break if not redirect_url_valid: raise ValueError("Not a Queue URL: %s" % redirect_url) qi = QueueItem(redirect_url, self.jenkins) if block: qi.block_until_complete(delay=delay) return qi def _buildid_for_type(self, buildtype): """ Gets a buildid for a given type of build """ KNOWNBUILDTYPES = [ "lastStableBuild", "lastSuccessfulBuild", "lastBuild", "lastCompletedBuild", "firstBuild", "lastFailedBuild", ] assert buildtype in KNOWNBUILDTYPES, ( "Unknown build info type: %s" % buildtype ) data = self.poll(tree="%s[number]" % buildtype) if not data.get(buildtype): raise NoBuildData(buildtype) return data[buildtype]["number"] def get_first_buildnumber(self): """ Get the numerical ID of the first build. """ return self._buildid_for_type("firstBuild") def get_last_stable_buildnumber(self): """ Get the numerical ID of the last stable build. """ return self._buildid_for_type("lastStableBuild") def get_last_good_buildnumber(self): """ Get the numerical ID of the last good build. """ return self._buildid_for_type("lastSuccessfulBuild") def get_last_failed_buildnumber(self): """ Get the numerical ID of the last failed build. """ return self._buildid_for_type(buildtype="lastFailedBuild") def get_last_buildnumber(self): """ Get the numerical ID of the last build. """ return self._buildid_for_type("lastBuild") def get_last_completed_buildnumber(self): """ Get the numerical ID of the last complete build. """ return self._buildid_for_type("lastCompletedBuild") def get_build_dict(self): builds = self.poll(tree="builds[number,url]") if not builds: raise NoBuildData(repr(self)) builds = self._add_missing_builds(builds) builds = builds["builds"] last_build = self.poll(tree="lastBuild[number,url]")["lastBuild"] if ( builds and last_build and builds[0]["number"] != last_build["number"] ): builds = [last_build] + builds # FIXME SO how is this supposed to work if build is false-y? # I don't think that builds *can* be false here, so I don't # understand the test above. return dict((build["number"], build["url"]) for build in builds) def get_build_by_params(self, build_params, order=1): first_build_number = self.get_first_buildnumber() last_build_number = self.get_last_buildnumber() if order != 1 and order != -1: raise ValueError( "Direction should be ascending or descending (1/-1)" ) for number in range(first_build_number, last_build_number + 1)[ ::order ]: build = self.get_build(number) if build.get_params() == build_params: return build raise NoBuildData( "No build with such params {params}".format(params=build_params) ) def get_revision_dict(self): """ Get dictionary of all revisions with a list of buildnumbers (int) that used that particular revision """ revs = defaultdict(list) if "builds" not in self._data: raise NoBuildData(repr(self)) for buildnumber in self.get_build_ids(): revs[self.get_build(buildnumber).get_revision()].append( buildnumber ) return revs def get_build_ids(self): """ Return a sorted list of all good builds as ints. """ return reversed(sorted(self.get_build_dict().keys())) def get_next_build_number(self): """ Return the next build number that Jenkins will assign. """ return self._data.get("nextBuildNumber", 0) def get_last_stable_build(self): """ Get the last stable build """ bn = self.get_last_stable_buildnumber() return self.get_build(bn) def get_last_good_build(self): """ Get the last good build """ bn = self.get_last_good_buildnumber() return self.get_build(bn) def get_last_build(self): """ Get the last build """ bn = self.get_last_buildnumber() return self.get_build(bn) def get_first_build(self): bn = self.get_first_buildnumber() return self.get_build(bn) def get_last_build_or_none(self): """ Get the last build or None if there is no builds """ try: return self.get_last_build() except NoBuildData: return None def get_last_completed_build(self): """ Get the last build regardless of status """ bn = self.get_last_completed_buildnumber() return self.get_build(bn) def get_buildnumber_for_revision(self, revision, refresh=False): """ :param revision: subversion revision to look for, int :param refresh: boolean, whether or not to refresh the revision -> buildnumber map :return: list of buildnumbers, [int] """ if self.get_scm_type() == "svn" and not isinstance(revision, int): revision = int(revision) if self._revmap is None or refresh: self._revmap = self.get_revision_dict() try: return self._revmap[revision] except KeyError: raise NotFound("Couldn't find a build with that revision") def get_build(self, buildnumber): assert isinstance(buildnumber, int) try: url = self.get_build_dict()[buildnumber] return Build(url, buildnumber, job=self) except KeyError: raise NotFound("Build #%s not found" % buildnumber) def delete_build(self, build_number): """ Remove build :param int build_number: Build number :raises NotFound: When build is not found """ try: url = self.get_build_dict()[build_number] url = "%s/doDelete" % url self.jenkins.requester.post_and_confirm_status(url, data="") self.jenkins.poll() except KeyError: raise NotFound("Build #%s not found" % build_number) def get_build_metadata(self, buildnumber): """ Get the build metadata for a given build number. For large builds with tons of tests, this method is faster than get_build by returning less data. """ if not isinstance(buildnumber, int): raise ValueError('Parameter "buildNumber" must be int') try: url = self.get_build_dict()[buildnumber] return Build(url, buildnumber, job=self, depth=0) except KeyError: raise NotFound("Build #%s not found" % buildnumber) def __delitem__(self, build_number): self.delete_build(build_number) def __getitem__(self, buildnumber): return self.get_build(buildnumber) def __len__(self): return len(self.get_build_dict()) def is_queued_or_running(self): return self.is_queued() or self.is_running() def is_queued(self): data = self.poll(tree="inQueue") return data.get("inQueue", False) def get_queue_item(self): """ Return a QueueItem if this object is in a queue, otherwise raise an exception """ if not self.is_queued(): raise UnknownQueueItem() q_item = self.poll(tree="queueItem[url]") qi_url = urlparse.urljoin( self.jenkins.baseurl, q_item["queueItem"]["url"] ) return QueueItem(qi_url, self.jenkins) def is_running(self): # self.poll() try: build = self.get_last_build_or_none() if build is not None: return build.is_running() except NoBuildData: log.info( "No build info available for %s, assuming not running.", str(self), ) return False def get_config(self): """ Returns the config.xml from the job """ response = self.jenkins.requester.get_and_confirm_status( "%(baseurl)s/config.xml" % self.__dict__ ) return response.text def load_config(self): self._config = self.get_config() def get_scm_type(self): element_tree = self._get_config_element_tree() scm_element = element_tree.find("scm") if not scm_element: multibranch_scm_prefix = "properties/org.jenkinsci.plugins.\ workflow.multibranch.BranchJobProperty/branch/" multibranch_path = multibranch_scm_prefix + "scm" scm_element = element_tree.find(multibranch_path) if scm_element: # multibranch pipeline. self._scm_prefix = multibranch_scm_prefix scm_class = scm_element.get("class") if scm_element else None scm = self._scm_map.get(scm_class) if not scm: raise NotSupportSCM( 'SCM class "%s" not supported by API for job "%s"' % (scm_class, self.name) ) if scm == "NullSCM": raise NotConfiguredSCM( 'SCM is not configured for job "%s"' % self.name ) return scm def get_scm_url(self): """ Get list of project SCM urls For some SCM's jenkins allow to configure and use number of SCM url's : return: list of SCM urls """ element_tree = self._get_config_element_tree() scm = self.get_scm_type() scm_url_list = [ scm_url.text for scm_url in self._scmurlmap[scm](element_tree) ] return scm_url_list def get_scm_branch(self): """ Get list of SCM branches : return: list of SCM branches """ element_tree = self._get_config_element_tree() scm = self.get_scm_type() return [ scm_branch.text for scm_branch in self._scmbranchmap[scm](element_tree) ] def modify_scm_branch(self, new_branch, old_branch=None): """ Modify SCM ("Source Code Management") branch name for configured job. :param new_branch : new repository branch name to set. If job has multiple branches configured and "old_branch" not provided - method will allways modify first url. :param old_branch (optional): exact value of branch name to be replaced. For some SCM's jenkins allow set multiple branches per job this parameter intended to indicate which branch need to be modified """ element_tree = self._get_config_element_tree() scm = self.get_scm_type() scm_branch_list = self._scmbranchmap[scm](element_tree) if scm_branch_list and not old_branch: scm_branch_list[0].text = new_branch self.update_config(ET.tostring(element_tree)) else: for scm_branch in scm_branch_list: if scm_branch.text == old_branch: scm_branch.text = new_branch self.update_config(ET.tostring(element_tree)) def modify_scm_url(self, new_source_url, old_source_url=None): """ Modify SCM ("Source Code Management") url for configured job. :param new_source_url : new repository url to set. If job has multiple repositories configured and "old_source_url" not provided - method will allways modify first url. :param old_source_url (optional): for some SCM's jenkins allows settting multiple repositories per job this parameter intended to indicate which repository need to be modified """ element_tree = self._get_config_element_tree() scm = self.get_scm_type() scm_url_list = self._scmurlmap[scm](element_tree) if scm_url_list and not old_source_url: scm_url_list[0].text = new_source_url self.update_config(ET.tostring(element_tree)) else: for scm_url in scm_url_list: if scm_url.text == old_source_url: scm_url.text = new_source_url self.update_config(ET.tostring(element_tree)) def get_config_xml_url(self): return "%s/config.xml" % self.baseurl def update_config(self, config, full_response=False, encoding="utf-8"): """ Update the config.xml to the job Also refresh the ElementTree object since the config has changed :param full_response (optional): if True, it will return the full response object instead of just the response text. Useful for debugging and validation workflows. """ url = self.get_config_xml_url() config = str(config) # cast unicode in case of Python 2 response = self.jenkins.requester.post_url( url, params={}, data=config.encode(encoding) ) self._element_tree = ET.fromstring(config) if full_response: return response return response.text def get_downstream_jobs(self): """ Get all the possible downstream jobs :return List of Job """ downstream_jobs = [] try: for j in self._data["downstreamProjects"]: downstream_jobs.append(self.get_jenkins_obj()[j["name"]]) except KeyError: return [] return downstream_jobs def get_downstream_job_names(self): """ Get all the possible downstream job names :return List of String """ downstream_jobs = [] try: for j in self._data["downstreamProjects"]: downstream_jobs.append(j["name"]) except KeyError: return [] return downstream_jobs def get_upstream_job_names(self): """ Get all the possible upstream job names :return List of String """ upstream_jobs = [] try: for j in self._data["upstreamProjects"]: upstream_jobs.append(j["name"]) except KeyError: return [] return upstream_jobs def get_upstream_jobs(self): """ Get all the possible upstream jobs :return List of Job """ upstream_jobs = [] try: for j in self._data["upstreamProjects"]: upstream_jobs.append(self.get_jenkins_obj().get_job(j["name"])) except KeyError: return [] return upstream_jobs def is_enabled(self): data = self.poll(tree="color") return "disabled" not in data.get("color", "") def disable(self): """ Disable job """ url = "%s/disable" % self.baseurl return self.get_jenkins_obj().requester.post_url(url, data="") def enable(self): """ Enable job """ url = "%s/enable" % self.baseurl return self.get_jenkins_obj().requester.post_url(url, data="") def delete_from_queue(self): """ Delete a job from the queue only if it's enqueued :raise NotInQueue if the job is not in the queue """ if not self.is_queued(): raise NotInQueue() queue_id = self._data["queueItem"]["id"] url = urlparse.urljoin( self.get_jenkins_obj().get_queue().baseurl, "queue/cancelItem?id=%s" % queue_id, ) self.get_jenkins_obj().requester.post_and_confirm_status(url, data="") return True def get_params(self): """ Get the parameters for this job. Format varies by parameter type. Here is an example string parameter: { 'type': 'StringParameterDefinition', 'description': 'Parameter description', 'defaultParameterValue': {'value': 'default value'}, 'name': 'FOO_BAR' } """ places = ["actions", "property"] found_definitions = False for place in places: if found_definitions: return actions = (x for x in self._data[place] if x is not None) for action in actions: try: for param in action["parameterDefinitions"]: found_definitions = True yield param except KeyError: continue def get_params_list(self): """ Gets the list of parameter names for this job. """ return [param["name"] for param in self.get_params()] def has_params(self): """ If job has parameters, returns True, else False """ if any( "parameterDefinitions" in a for a in (self._data["actions"]) if a ): return True if any( "parameterDefinitions" in a for a in (self._data["property"]) if a ): return True return False def has_queued_build(self, build_params): """ Returns True if a build with build_params is currently queued. """ queue = self.jenkins.get_queue() queued_builds = queue.get_queue_items_for_job(self.name) for build in queued_builds: if build.get_parameters() == build_params: return True return False @staticmethod def get_full_name_from_url_and_baseurl(url, baseurl): """ Get the full name for a job (including parent folders) from the job URL. """ path = url.replace(baseurl, "") split = path.split("/") split = [urlparse.unquote(part) for part in split[::2] if part] return "/".join(split) def get_full_name(self): """ Get the full name for a job (including parent folders) from the job URL. """ return Job.get_full_name_from_url_and_baseurl( self.url, self.jenkins.baseurl ) def toggle_keep_build(self, build_number): self.get_build(build_number).toggle_keep() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/jobs.py0000644000000000000000000002161214766246232014340 0ustar00""" This module implements the Jobs class, which is intended to be a container-like interface for all of the jobs defined on a single Jenkins server. """ from __future__ import annotations from typing import Iterator import logging import time from jenkinsapi.job import Job from jenkinsapi.custom_exceptions import JenkinsAPIException, UnknownJob log = logging.getLogger(__name__) class Jobs(object): """ This class provides a container-like API which gives access to all jobs defined on the Jenkins server. It behaves like a dict in which keys are Job-names and values are actual jenkinsapi.Job objects. """ def __init__(self, jenkins: "Jenkins") -> None: self.jenkins = jenkins self._data = [] def _del_data(self, job_name: str) -> None: if not self._data: return for num, job_data in enumerate(self._data): if job_data["name"] == job_name: del self._data[num] return def __len__(self) -> int: return len(self.keys()) def poll(self, tree="jobs[name,color,url]"): return self.jenkins.poll(tree=tree) def __delitem__(self, job_name: str) -> None: """ Delete a job by name :param str job_name: name of a existing job :raises JenkinsAPIException: When job is not deleted """ if job_name in self: try: delete_job_url = self[job_name].get_delete_url() self.jenkins.requester.post_and_confirm_status( delete_job_url, data="some random bytes..." ) self._del_data(job_name) except JenkinsAPIException: # Sometimes jenkins throws NPE when removing job # It removes job ok, but it is good to be sure # so we re-try if job was not deleted if job_name in self: delete_job_url = self[job_name].get_delete_url() self.jenkins.requester.post_and_confirm_status( delete_job_url, data="some random bytes..." ) self._del_data(job_name) def __setitem__(self, key: str, value: str) -> "Job": """ Create Job :param str key: Job name :param str value: XML configuration of the job .. code-block:: python api = Jenkins('http://localhost:8080/') new_job = api.jobs['my_new_job'] = config_xml """ return self.create(key, value) def __getitem__(self, job_name: str) -> "Job": if job_name in self: job_data = [ job_row for job_row in self._data if job_row["name"] == job_name or Job.get_full_name_from_url_and_baseurl( job_row["url"], self.jenkins.baseurl ) == job_name ][0] return Job(job_data["url"], job_data["name"], self.jenkins) else: raise UnknownJob(job_name) def iteritems(self) -> Iterator[str, "Job"]: """ Iterate over the names & objects for all jobs """ for job in self.itervalues(): if job.name != job.get_full_name(): yield job.get_full_name(), job else: yield job.name, job def __contains__(self, job_name: str) -> bool: """ True if job_name exists in Jenkins """ return job_name in self.keys() def iterkeys(self) -> Iterator[str]: """ Iterate over the names of all available jobs """ if not self._data: self._data = self.poll().get("jobs", []) for row in self._data: if row["name"] != Job.get_full_name_from_url_and_baseurl( row["url"], self.jenkins.baseurl ): yield Job.get_full_name_from_url_and_baseurl( row["url"], self.jenkins.baseurl ) else: yield row["name"] def itervalues(self) -> Iterator["Job"]: """ Iterate over all available jobs """ if not self._data: self._data = self.poll().get("jobs", []) for row in self._data: yield Job(row["url"], row["name"], self.jenkins) def keys(self) -> list[str]: """ Return a list of the names of all jobs """ return list(self.iterkeys()) def create(self, job_name: str, config: str | bytes) -> "Job": """ Create a job :param str jobname: Name of new job :param str config: XML configuration of new job :returns Job: new Job object """ if job_name in self: return self[job_name] if not config: raise JenkinsAPIException("Job XML config cannot be empty") params = {"name": job_name} if isinstance(config, bytes): config = config.decode("utf-8") self.jenkins.requester.post_xml_and_confirm_status( self.jenkins.get_create_url(), data=config, params=params ) # Reset to get it refreshed from Jenkins self._data = [] return self[job_name] def create_multibranch_pipeline( self, job_name: str, config: str, block: bool = True, delay: int = 60 ) -> list["Job"]: """ Create a multibranch pipeline job :param str jobname: Name of new job :param str config: XML configuration of new job :param block: block until scan is finished? :param delay: max delay to wait for scan to finish (seconds) :returns list of new Jobs after scan """ if not config: raise JenkinsAPIException("Job XML config cannot be empty") params = {"name": job_name} if isinstance(config, bytes): config = config.decode("utf-8") self.jenkins.requester.post_xml_and_confirm_status( self.jenkins.get_create_url(), data=config, params=params ) # Reset to get it refreshed from Jenkins self._data = [] # Launch a first scan / indexing to discover the branches... self.jenkins.requester.post_and_confirm_status( "{}/job/{}/build".format(self.jenkins.baseurl, job_name), data="", valid=[200, 302], # expect 302 without redirects allow_redirects=False, ) start_time = time.time() # redirect-url does not work with indexing; # so the only workaround found is to parse the console output # until scan has finished. scan_finished = False while not scan_finished and block and time.time() < start_time + delay: indexing_console_text = self.jenkins.requester.get_url( "{}/job/{}/indexing/consoleText".format( self.jenkins.baseurl, job_name ) ) if ( indexing_console_text.text.strip() .split("\n")[-1] .startswith("Finished:") ): scan_finished = True time.sleep(1) # now search for all jobs created; those who start with job_name + '/' jobs = [] for name in self.jenkins.get_jobs_list(): if name.startswith(job_name + "/"): jobs.append(self[name]) return jobs def copy(self, job_name: str, new_job_name: str) -> "Job": """ Copy a job :param str job_name: Name of an existing job :param new_job_name: Name of new job :returns Job: new Job object """ params = {"name": new_job_name, "mode": "copy", "from": job_name} self.jenkins.requester.post_and_confirm_status( self.jenkins.get_create_url(), params=params, data="" ) self._data = [] return self[new_job_name] def rename(self, job_name: str, new_job_name: str) -> "Job": """ Rename a job :param str job_name: Name of an existing job :param str new_job_name: Name of new job :returns Job: new Job object """ params = {"newName": new_job_name} rename_job_url = self[job_name].get_rename_url() self.jenkins.requester.post_and_confirm_status( rename_job_url, params=params, data="" ) self._data = [] return self[new_job_name] def build(self, job_name: str, params=None, **kwargs) -> "QueueItem": """ Executes build of a job :param str job_name: Job name :param dict params: Job parameters :param kwargs: Parameters for Job.invoke() function :returns QueueItem: Object to track build progress """ if params: assert isinstance(params, dict) return self[job_name].invoke(build_params=params, **kwargs) return self[job_name].invoke(**kwargs) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/label.py0000644000000000000000000000250114766246232014456 0ustar00""" Module for jenkinsapi labels """ from jenkinsapi.jenkinsbase import JenkinsBase import logging log = logging.getLogger(__name__) class Label(JenkinsBase): """ Class to hold information on labels that tied to a collection of jobs """ def __init__(self, baseurl, labelname, jenkins_obj): """ Init a label object by providing all relevant pointers to it :param baseurl: basic url for querying information on a node :param labelname: name of the label :param jenkins_obj: ref to the jenkins obj :return: Label obj """ self.labelname = labelname self.jenkins = jenkins_obj self.baseurl = baseurl JenkinsBase.__init__(self, baseurl) def __str__(self): return "%s" % (self.labelname) def get_jenkins_obj(self): return self.jenkins def is_online(self): return not self.poll(tree="offline")["offline"] def get_tied_jobs(self): """ Get a list of jobs. """ if self.get_tied_job_names(): for job in self.get_tied_job_names(): yield self.get_jenkins_obj().get_job(job["name"]) def get_tied_job_names(self): """ Get a list of the name of tied jobs. """ return self.poll(tree="tiedJobs[name]")["tiedJobs"] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1742294169.869773 jenkinsapi-0.3.14/jenkinsapi/mutable_jenkins_thing.py0000644000000000000000000000050614766246232017745 0ustar00""" Module for MutableJenkinsThing """ class MutableJenkinsThing(object): """ A mixin for certain mutable objects which can be renamed and deleted. """ def get_delete_url(self) -> str: return f"{self.baseurl}/doDelete" def get_rename_url(self) -> str: return f"{self.baseurl}/doRename" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/node.py0000644000000000000000000004605714766246232014342 0ustar00""" Module for jenkinsapi Node class """ from __future__ import annotations import json import logging import xml.etree.ElementTree as ET import time from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import PostRequired, TimeOut from jenkinsapi.custom_exceptions import JenkinsAPIException from urllib.parse import quote as urlquote log = logging.getLogger(__name__) class Node(JenkinsBase): """ Class to hold information on nodes that are attached as slaves to the master jenkins instance """ def __init__( self, jenkins_obj: "Jenkins", baseurl: str, nodename: str, node_dict, poll: bool = True, ) -> None: """ Init a node object by providing all relevant pointers to it :param jenkins_obj: ref to the jenkins obj :param baseurl: basic url for querying information on a node If url is not set - object will construct it itself. This is useful when node is being created and not exists in Jenkins yet :param nodename: hostname of the node :param dict node_dict: Dict with node parameters as described below :param bool poll: set to False if node does not exist or automatic refresh from Jenkins is not required. Default is True. If baseurl parameter is set to None - poll parameter will be set to False JNLP Node: { 'num_executors': int, 'node_description': str, 'remote_fs': str, 'labels': str, 'exclusive': bool } SSH Node: { 'num_executors': int, 'node_description': str, 'remote_fs': str, 'labels': str, 'exclusive': bool, 'host': str, 'port': int 'credential_description': str, 'jvm_options': str, 'java_path': str, 'prefix_start_slave_cmd': str, 'suffix_start_slave_cmd': str 'max_num_retries': int, 'retry_wait_time': int, 'retention': str ('Always' or 'OnDemand') 'ondemand_delay': int (only for OnDemand retention) 'ondemand_idle_delay': int (only for OnDemand retention) 'env': [ { 'key':'TEST', 'value':'VALUE' }, { 'key':'TEST2', 'value':'value2' } ], 'tool_location': [ { "key": "hudson.tasks.Maven$MavenInstallation$DescriptorImpl@Maven 3.0.5", # noqa "home": "/home/apache-maven-3.0.5/" }, { "key": "hudson.plugins.git.GitTool$DescriptorImpl@Default", "home": "/home/git-3.0.5/" }, ] } :return: None :return: Node obj """ self.name: str = nodename self.jenkins: "Jenkins" = jenkins_obj if not baseurl: poll = False baseurl = f"{self.jenkins.baseurl}/computer/{self.name}" JenkinsBase.__init__(self, baseurl, poll=poll) self.node_attributes: dict = node_dict self._element_tree = None self._config = None def get_node_attributes(self) -> dict: """ Gets node attributes as dict Used by Nodes object when node is created :return: Node attributes dict formatted for Jenkins API request to create node """ na: dict = self.node_attributes if not na.get("credential_description", False): # If credentials description is not present - we will create # JNLP node launcher = {"stapler-class": "hudson.slaves.JNLPLauncher"} else: try: credential = self.jenkins.credentials[ na["credential_description"] ] except KeyError: raise JenkinsAPIException( 'Credential with description "%s"' " not found" % na["credential_description"] ) retries: int = ( na["max_num_retries"] if "max_num_retries" in na else 0 ) re_wait: int = ( na["retry_wait_time"] if "retry_wait_time" in na else 0 ) launcher = { "stapler-class": "hudson.plugins.sshslaves.SSHLauncher", "$class": "hudson.plugins.sshslaves.SSHLauncher", "host": na["host"], "port": na["port"], "credentialsId": credential.credential_id, "jvmOptions": na["jvm_options"], "javaPath": na["java_path"], "prefixStartSlaveCmd": na["prefix_start_slave_cmd"], "suffixStartSlaveCmd": na["suffix_start_slave_cmd"], "maxNumRetries": retries, "retryWaitTime": re_wait, } retention = { "stapler-class": "hudson.slaves.RetentionStrategy$Always", "$class": "hudson.slaves.RetentionStrategy$Always", } if "retention" in na and na["retention"].lower() == "ondemand": retention = { "stapler-class": "hudson.slaves.RetentionStrategy$Demand", "$class": "hudson.slaves.RetentionStrategy$Demand", "inDemandDelay": na["ondemand_delay"], "idleDelay": na["ondemand_idle_delay"], } node_props: dict = {"stapler-class-bag": "true"} if "env" in na: node_props.update( { "hudson-slaves-EnvironmentVariablesNodeProperty": { "env": na["env"] } } ) if "tool_location" in na: node_props.update( { "hudson-tools-ToolLocationNodeProperty": { "locations": na["tool_location"] } } ) params = { "name": self.name, "type": "hudson.slaves.DumbSlave$DescriptorImpl", "json": json.dumps( { "name": self.name, "nodeDescription": na.get("node_description", ""), "numExecutors": na["num_executors"], "remoteFS": na["remote_fs"], "labelString": na["labels"], "mode": "EXCLUSIVE" if na["exclusive"] else "NORMAL", "retentionStrategy": retention, "type": "hudson.slaves.DumbSlave", "nodeProperties": node_props, "launcher": launcher, } ), } return params def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def __str__(self) -> str: return self.name def is_online(self) -> bool: return not self.poll(tree="offline")["offline"] def is_temporarily_offline(self) -> bool: return self.poll(tree="temporarilyOffline")["temporarilyOffline"] def is_jnlpagent(self) -> bool: return self._data["jnlpAgent"] def is_idle(self) -> bool: return self.poll(tree="idle")["idle"] def set_online(self) -> None: """ Set node online. Before change state verify client state: if node set 'offline' but 'temporarilyOffline' is not set - client has connection problems and AssertionError raised. If after run node state has not been changed raise AssertionError. """ self.poll() # Before change state check if client is connected if self._data["offline"] and not self._data["temporarilyOffline"]: raise AssertionError( "Node is offline and not marked as " "temporarilyOffline, check client " "connection: offline = %s, " "temporarilyOffline = %s" % (self._data["offline"], self._data["temporarilyOffline"]) ) if self._data["offline"] and self._data["temporarilyOffline"]: self.toggle_temporarily_offline() if self._data["offline"]: raise AssertionError( "The node state is still offline, " "check client connection:" " offline = %s, " "temporarilyOffline = %s" % (self._data["offline"], self._data["temporarilyOffline"]) ) def set_offline(self, message="requested from jenkinsapi") -> None: """ Set node offline. If after run node state has not been changed raise AssertionError. : param message: optional string explain why you are taking this node offline """ if not self._data["offline"]: self.toggle_temporarily_offline(message) data = self.poll(tree="offline,temporarilyOffline") if not data["offline"]: raise AssertionError( "The node state is still online:" + "offline = %s , temporarilyOffline = %s" % (data["offline"], data["temporarilyOffline"]) ) def launch(self) -> None: """ Tries to launch a connection with the slave if it is currently disconnected. Because launching a connection with the slave does not mean it is online (a slave can be launched, but set offline), this function does not check if the launch was successful. """ if not self._data["launchSupported"]: raise AssertionError("The node does not support manually launch.") if not self._data["manualLaunchAllowed"]: raise AssertionError( "It is not allowed to manually launch this node." ) url = self.baseurl + "/launchSlaveAgent" html_result = self.jenkins.requester.post_and_confirm_status( url, data={} ) log.debug(html_result) def toggle_temporarily_offline( self, message="requested from jenkinsapi" ) -> None: """ Switches state of connected node (online/offline) and set 'temporarilyOffline' property (True/False) Calling the same method again will bring node status back. :param message: optional string can be used to explain why you are taking this node offline """ initial_state = self.is_temporarily_offline() url = ( self.baseurl + "/toggleOffline?offlineMessage=" + urlquote(message) ) try: html_result = self.jenkins.requester.get_and_confirm_status(url) except PostRequired: html_result = self.jenkins.requester.post_and_confirm_status( url, data={} ) self.poll() log.debug(html_result) state = self.is_temporarily_offline() if initial_state == state: raise AssertionError( "The node state has not changed: temporarilyOffline = %s" % state ) def update_offline_reason(self, reason: str) -> None: """ Update offline reason on a temporary offline clsuter """ if self.is_temporarily_offline(): url = ( self.baseurl + "/changeOfflineCause?offlineMessage=" + urlquote(reason) ) self.jenkins.requester.post_and_confirm_status(url, data={}) def offline_reason(self) -> str: return self._data["offlineCauseReason"] @property def _et(self): return self._get_config_element_tree() def _get_config_element_tree(self) -> ET.Element: """ Returns an xml element tree for the node's config.xml. The resulting tree is cached for quick lookup. """ if self._config is None: self.load_config() if self._element_tree is None: self._element_tree = ET.fromstring(self._config) return self._element_tree def get_config(self) -> str: """ Returns the config.xml from the node. """ response = self.jenkins.requester.get_and_confirm_status( "%(baseurl)s/config.xml" % self.__dict__ ) return response.text def load_config(self) -> None: """ Loads the config.xml for the node allowing it to be re-queried without generating new requests. """ if self.name == "Built-In Node": raise JenkinsAPIException("Built-In node does not have config.xml") self._config = self.get_config() self._get_config_element_tree() def upload_config(self, config_xml: str) -> None: """ Uploads config_xml to the config.xml for the node. """ if self.name == "Built-In Node": raise JenkinsAPIException("Built-In node does not have config.xml") self.jenkins.requester.post_and_confirm_status( "%(baseurl)s/config.xml" % self.__dict__, data=config_xml ) def get_labels(self) -> str | None: """ Returns the labels for a slave as a string with each label separated by the ' ' character. """ return self.get_config_element("label") def get_num_executors(self) -> str: try: return self.get_config_element("numExecutors") except JenkinsAPIException: return self._data["numExecutors"] def set_num_executors(self, value: int | str) -> None: """ Sets number of executors for node Warning! Setting number of executors on master node will erase all other settings """ set_value = value if isinstance(value, str) else str(value) if self.name == "Built-In Node": # master node doesn't have config.xml, so we're going to submit # form here data = "json=%s" % urlquote( json.dumps( { "numExecutors": set_value, "nodeProperties": {"stapler-class-bag": "true"}, } ) ) url = self.baseurl + "/configSubmit" self.jenkins.requester.post_and_confirm_status(url, data=data) else: self.set_config_element("numExecutors", set_value) self.poll() def get_config_element(self, el_name: str) -> str: """ Returns simple config element. Better not to be used to return "nodeProperties" or "launcher" """ return self._et.find(el_name).text def set_config_element(self, el_name: str, value: str) -> None: """ Sets simple config element """ self._et.find(el_name).text = value xml_str = ET.tostring(self._et) self.upload_config(xml_str) def get_monitor(self, monitor_name: str, poll_monitor=True) -> str: """ Polls the node returning one of the monitors in the monitorData branch of the returned node api tree. """ monitor_data_key = "monitorData" if poll_monitor: # polling as monitors like response time can be updated monitor_data = self.poll(tree=monitor_data_key)[monitor_data_key] else: monitor_data = self._data[monitor_data_key] full_monitor_name = "hudson.node_monitors.{0}".format(monitor_name) if full_monitor_name not in monitor_data: raise AssertionError("Node monitor %s not found" % monitor_name) return monitor_data[full_monitor_name] def get_available_physical_memory(self) -> int: """ Returns the node's available physical memory in bytes. """ monitor_data = self.get_monitor("SwapSpaceMonitor") return monitor_data["availablePhysicalMemory"] def get_available_swap_space(self) -> int: """ Returns the node's available swap space in bytes. """ monitor_data = self.get_monitor("SwapSpaceMonitor") return monitor_data["availableSwapSpace"] def get_total_physical_memory(self) -> int: """ Returns the node's total physical memory in bytes. """ monitor_data = self.get_monitor("SwapSpaceMonitor") return monitor_data["totalPhysicalMemory"] def get_total_swap_space(self) -> int: """ Returns the node's total swap space in bytes. """ monitor_data = self.get_monitor("SwapSpaceMonitor") return monitor_data["totalSwapSpace"] def get_workspace_path(self) -> str: """ Returns the local path to the node's Jenkins workspace directory. """ monitor_data = self.get_monitor("DiskSpaceMonitor") return monitor_data["path"] def get_workspace_size(self) -> int: """ Returns the size in bytes of the node's Jenkins workspace directory. """ monitor_data = self.get_monitor("DiskSpaceMonitor") return monitor_data["size"] def get_temp_path(self) -> str: """ Returns the local path to the node's temp directory. """ monitor_data = self.get_monitor("TemporarySpaceMonitor") return monitor_data["path"] def get_temp_size(self) -> int: """ Returns the size in bytes of the node's temp directory. """ monitor_data = self.get_monitor("TemporarySpaceMonitor") return monitor_data["size"] def get_architecture(self) -> str: """ Returns the system architecture of the node eg. "Linux (amd64)". """ # no need to poll as the architecture will never change return self.get_monitor("ArchitectureMonitor", poll_monitor=False) def block_until_idle(self, timeout: int, poll_time: int = 5) -> None: """ Blocks until the node become idle. :param timeout: Time in second when the wait is aborted. :param poll_time: Interval in seconds between each check. :@raise TimeOut """ start_time = time.time() while not self.is_idle() and (time.time() - start_time) < timeout: log.debug( "Waiting for the node to become idle. Elapsed time: %s", (time.time() - start_time), ) time.sleep(poll_time) if not self.is_idle(): raise TimeOut( "The node has not become idle after {} minutes.".format( timeout / 60 ) ) def get_response_time(self) -> int: """ Returns the node's average response time. """ monitor_data = self.get_monitor("ResponseTimeMonitor") return monitor_data["average"] def get_clock_difference(self) -> int: """ Returns the difference between the node's clock and the master Jenkins clock. Used to detect out of sync clocks. """ monitor_data = self.get_monitor("ClockMonitor") return monitor_data["diff"] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/nodes.py0000644000000000000000000001413714766246232014517 0ustar00""" Module for jenkinsapi nodes """ from __future__ import annotations from typing import Iterator import logging from urllib.parse import urlencode from jenkinsapi.node import Node from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import JenkinsAPIException from jenkinsapi.custom_exceptions import UnknownNode from jenkinsapi.custom_exceptions import PostRequired log: logging.Logger = logging.getLogger(__name__) class Nodes(JenkinsBase): """ Class to hold information on a collection of nodes """ def __init__(self, baseurl: str, jenkins_obj: "Jenkins") -> None: """ Handy access to all of the nodes on your Jenkins server """ self.jenkins = jenkins_obj JenkinsBase.__init__( self, ( baseurl.rstrip("/") if "/computer" in baseurl else baseurl.rstrip("/") + "/computer" ), ) def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def __str__(self) -> str: return "Nodes @ %s" % self.baseurl def __contains__(self, node_name: str) -> bool: return node_name in self.keys() def iterkeys(self) -> Iterator[str]: """ Return an iterator over the container's node names. Using iterkeys() while creating nodes may raise a RuntimeError or fail to iterate over all entries. """ for item in self._data["computer"]: yield item["displayName"] def keys(self) -> list[str]: """ Return a copy of the container's list of node names. """ return list(self.iterkeys()) def _make_node(self, nodename) -> Node: """ Creates an instance of Node for the given nodename. This function assumes the returned node exists. """ if nodename.lower() == "built-in node": nodeurl = "%s/(%s)" % (self.baseurl, "built-in") else: nodeurl = "%s/%s" % (self.baseurl, nodename) return Node(self.jenkins, nodeurl, nodename, node_dict={}) def iteritems(self) -> Iterator[tuple[str, Node]]: """ Return an iterator over the container's (name, node) pairs. Using iteritems() while creating nodes may raise a RuntimeError or fail to iterate over all entries. """ for item in self._data["computer"]: nodename = item["displayName"] try: yield nodename, self._make_node(nodename) except Exception: raise JenkinsAPIException("Unable to iterate nodes") def items(self) -> list[tuple[str, Node]]: """ Return a copy of the container's list of (name, node) pairs. """ return list(self.iteritems()) def itervalues(self) -> Iterator[Node]: """ Return an iterator over the container's nodes. Using itervalues() while creating nodes may raise a RuntimeError or fail to iterate over all entries. """ for item in self._data["computer"]: try: yield self._make_node(item["displayName"]) except Exception: raise JenkinsAPIException("Unable to iterate nodes") def values(self) -> list[Node]: """ Return a copy of the container's list of nodes. """ return list(self.itervalues()) def __getitem__(self, nodename: str) -> Node: if nodename in self: return self._make_node(nodename) raise UnknownNode(nodename) def __len__(self) -> int: return len(self.keys()) def __delitem__(self, item: str) -> None: if item in self and item != "Built-In Node": url = "%s/doDelete" % self[item].baseurl try: self.jenkins.requester.get_and_confirm_status(url) except PostRequired: # Latest Jenkins requires POST here. GET kept for compatibility self.jenkins.requester.post_and_confirm_status(url, data={}) self.poll() else: if item != "Built-In Node": raise UnknownNode("Node %s does not exist" % item) log.info("Requests to remove built-in node ignored") def __setitem__(self, name: str, node_dict: dict): if not isinstance(node_dict, dict): raise ValueError('"node_dict" parameter must be a Node dict') if name not in self: self.create_node(name, node_dict) self.poll() def create_node(self, name: str, node_dict: dict) -> Node: """ Create a new slave node :param str name: name of slave :param dict node_dict: node dict (See Node class) :return: node obj """ if name in self: return self[name] node = Node( jenkins_obj=self.jenkins, baseurl="", nodename=name, node_dict=node_dict, poll=False, ) url = "%s/computer/doCreateItem?%s" % ( self.jenkins.baseurl, urlencode(node.get_node_attributes()), ) data = {"json": urlencode(node.get_node_attributes())} self.jenkins.requester.post_and_confirm_status(url, data=data) self.poll() return self[name] def create_node_with_config(self, name: str, config: dict) -> Node | None: """ Create a new slave node with specific configuration. Config should be resemble the output of node.get_node_attributes() :param str name: name of slave :param dict config: Node attributes for Jenkins API request to create node (See function output Node.get_node_attributes()) :return: node obj """ if name in self: return self[name] if not isinstance(config, dict): return None url = "%s/computer/doCreateItem?%s" % ( self.jenkins.baseurl, urlencode(config), ) data = {"json": urlencode(config)} self.jenkins.requester.post_and_confirm_status(url, data=data) self.poll() return self[name] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/plugin.py0000644000000000000000000000467714766246232014715 0ustar00""" Module for jenkinsapi Plugin """ from __future__ import annotations from typing import Union class Plugin(object): """ Plugin class """ def __init__(self, plugin_dict: Union[dict, str]) -> None: if isinstance(plugin_dict, dict): self.__dict__ = plugin_dict else: self.__dict__ = self.to_plugin(plugin_dict) self.shortName: str = self.__dict__["shortName"] self.version: str = self.__dict__.get("version", "Unknown") def to_plugin(self, plugin_string: str) -> dict: plugin_string = str(plugin_string) if "@" not in plugin_string or len(plugin_string.split("@")) != 2: usage_err: str = ( "plugin specification must be a string like " '"plugin-name@version", not "{0}"' ) usage_err = usage_err.format(plugin_string) raise ValueError(usage_err) shortName, version = plugin_string.split("@") return {"shortName": shortName, "version": version} def __eq__(self, other) -> bool: return self.__dict__ == other.__dict__ def __str__(self) -> str: return self.shortName def __repr__(self) -> str: return "<%s.%s %s>" % ( self.__class__.__module__, self.__class__.__name__, str(self), ) def get_attributes(self) -> str: """ Used by Plugins object to install plugins in Jenkins """ return ' ' % ( self.shortName, self.version, ) def is_latest(self, update_center_dict: dict) -> bool: """ Used by Plugins object to determine if plugin can be installed through the update center (when plugin version is latest version), or must be installed by uploading the plugin hpi file. """ if self.version == "latest": return True center_plugin = update_center_dict["plugins"][self.shortName] return center_plugin["version"] == self.version def get_download_link(self, update_center_dict) -> str: latest_version = update_center_dict["plugins"][self.shortName][ "version" ] latest_url = update_center_dict["plugins"][self.shortName]["url"] return latest_url.replace( "/".join((self.shortName, latest_version)), "/".join((self.shortName, self.version)), ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/plugins.py0000644000000000000000000002540314766246232015066 0ustar00""" jenkinsapi plugins """ from __future__ import annotations from typing import Generator import logging import time import re from io import BytesIO from urllib.parse import urlencode import json import requests from jenkinsapi.plugin import Plugin from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import UnknownPlugin from jenkinsapi.custom_exceptions import JenkinsAPIException from jenkinsapi.utils.jsonp_to_json import jsonp_to_json from jenkinsapi.utils.manifest import Manifest, read_manifest log: logging.Logger = logging.getLogger(__name__) class Plugins(JenkinsBase): """ Plugins class for jenkinsapi """ def __init__(self, url: str, jenkins_obj: "Jenkins") -> None: self.jenkins_obj: "Jenkins" = jenkins_obj JenkinsBase.__init__(self, url) def get_jenkins_obj(self) -> "Jenkins": return self.jenkins_obj def check_updates_server(self) -> None: url: str = ( f"{self.jenkins_obj.baseurl}/pluginManager/checkUpdatesServer" ) self.jenkins_obj.requester.post_and_confirm_status( url, params={}, data={} ) @property def update_center_dict(self): update_center = "https://updates.jenkins.io/update-center.json" jsonp = requests.get(update_center).content.decode("utf-8") return json.loads(jsonp_to_json(jsonp)) def _poll(self, tree=None): return self.get_data(self.baseurl, tree=tree) def keys(self) -> list[str]: return self.get_plugins_dict().keys() __iter__ = keys def iteritems(self) -> Generator[str, "Plugin"]: return self._get_plugins() def values(self) -> list["Plugin"]: return [a[1] for a in self.iteritems()] def _get_plugins(self) -> Generator[str, "Plugin"]: if "plugins" in self._data: for p_dict in self._data["plugins"]: yield p_dict["shortName"], Plugin(p_dict) def get_plugins_dict(self) -> dict[str, "Plugin"]: return dict(self._get_plugins()) def __len__(self) -> int: return len(self.get_plugins_dict().keys()) def __getitem__(self, plugin_name: str) -> Plugin: try: return self.get_plugins_dict()[plugin_name] except KeyError: raise UnknownPlugin(plugin_name) def __setitem__(self, shortName, plugin: "Plugin") -> None: """ Installs plugin in Jenkins. If plugin already exists - this method is going to uninstall the existing plugin and install the specified version if it is not already installed. :param shortName: Plugin ID :param plugin a Plugin object to be installed. """ if self.plugin_version_already_installed(plugin): return if plugin.is_latest(self.update_center_dict): self._install_plugin_from_updatecenter(plugin) else: self._install_specific_version(plugin) self._wait_until_plugin_installed(plugin) def _install_plugin_from_updatecenter(self, plugin: "Plugin") -> None: """ Latest versions of plugins can be installed from the update center (and don't need a restart.) """ xml_str: str = plugin.get_attributes() url: str = ( "%s/pluginManager/installNecessaryPlugins" % self.jenkins_obj.baseurl ) self.jenkins_obj.requester.post_xml_and_confirm_status( url, data=xml_str ) @property def update_center_install_status(self): """ Jenkins 2.x specific """ url: str = "%s/updateCenter/installStatus" % self.jenkins_obj.baseurl status = self.jenkins_obj.requester.get_url(url) if status.status_code == 404: raise JenkinsAPIException( "update_center_install_status not available for Jenkins 1.X" ) return status.json() @property def restart_required(self): """ Call after plugin installation to check if Jenkins requires a restart """ try: jobs = self.update_center_install_status["data"]["jobs"] except JenkinsAPIException: return True # Jenkins 1.X has no update_center return any([job for job in jobs if job["requiresRestart"] == "true"]) def _install_specific_version(self, plugin: "Plugin") -> None: """ Plugins that are not the latest version have to be uploaded. """ download_link: str = plugin.get_download_link( update_center_dict=self.update_center_dict ) downloaded_plugin: BytesIO = self._download_plugin(download_link) plugin_dependencies = self._get_plugin_dependencies(downloaded_plugin) log.debug("Installing dependencies for plugin '%s'", plugin.shortName) self.jenkins_obj.install_plugins(plugin_dependencies) url = "%s/pluginManager/uploadPlugin" % self.jenkins_obj.baseurl requester = self.jenkins_obj.requester downloaded_plugin.seek(0) requester.post_and_confirm_status( url, files={"file": ("plugin.hpi", downloaded_plugin)}, data={}, params={}, ) def _get_plugin_dependencies( self, downloaded_plugin: BytesIO ) -> list["Plugin"]: """ Returns a list of all dependencies for a downloaded plugin """ plugin_dependencies = [] manifest: Manifest = read_manifest(downloaded_plugin) manifest_dependencies = manifest.main_section.get( "Plugin-Dependencies" ) if manifest_dependencies: dependencies = manifest_dependencies.split(",") for dep in dependencies: # split plugin:version;resolution:optional entries components = dep.split(";") dep_plugin = components[0] name = dep_plugin.split(":")[0] # install latest dependency, avoids multiple # versions of the same dep plugin_dependencies.append( Plugin({"shortName": name, "version": "latest"}) ) return plugin_dependencies def _download_plugin(self, download_link): downloaded_plugin = BytesIO() downloaded_plugin.write(requests.get(download_link).content) return downloaded_plugin def _plugin_has_finished_installation(self, plugin) -> bool: """ Return True if installation is marked as 'Success' or 'SuccessButRequiresRestart' in Jenkins' update_center, else return False. """ try: jobs = self.update_center_install_status["data"]["jobs"] for job in jobs: if job["name"] == plugin.shortName and job[ "installStatus" ] in [ "Success", "SuccessButRequiresRestart", ]: return True return False except JenkinsAPIException: return False # lack of update_center in Jenkins 1.X def plugin_version_is_being_installed(self, plugin) -> bool: """ Return true if plugin is currently being installed. """ try: jobs = self.update_center_install_status["data"]["jobs"] except JenkinsAPIException: return False # lack of update_center in Jenkins 1.X return any( [ job for job in jobs if job["name"] == plugin.shortName and job["version"] == plugin.version ] ) def plugin_version_already_installed(self, plugin) -> bool: """ Check if plugin version is already installed """ if plugin.shortName not in self: if self.plugin_version_is_being_installed(plugin): return True return False installed_plugin = self[plugin.shortName] if plugin.version == installed_plugin.version: return True elif plugin.version == "latest": # we don't have an exact version, we first check if Jenkins # knows about an update if ( hasattr(installed_plugin, "hasUpdates") and installed_plugin.hasUpdates ): return False # Jenkins may not have an up-to-date catalogue, # so check update-center directly latest_version = self.update_center_dict["plugins"][ plugin.shortName ]["version"] return installed_plugin.version == latest_version return False def __delitem__(self, shortName): if re.match(".*@.*", shortName): real_shortName = re.compile("(.*)@(.*)").search(shortName).group(1) raise ValueError( ("Plugin shortName can't contain version. '%s' should be '%s'") % (shortName, real_shortName) ) if shortName not in self: raise KeyError( 'Plugin with ID "%s" not found, cannot uninstall' % shortName ) if self[shortName].deleted: raise JenkinsAPIException( 'Plugin "%s" already marked for uninstall. ' "Restart jenkins for uninstall to complete." ) params = {"Submit": "OK", "json": {}} url = "%s/pluginManager/plugin/%s/doUninstall" % ( self.jenkins_obj.baseurl, shortName, ) self.jenkins_obj.requester.post_and_confirm_status( url, params={}, data=urlencode(params) ) self.poll() if not self[shortName].deleted: raise JenkinsAPIException( "Problem uninstalling plugin '%s'." % shortName ) def _wait_until_plugin_installed(self, plugin, maxwait=120, interval=1): for _ in range(maxwait, 0, -interval): self.poll() if self._plugin_has_finished_installation(plugin): return True if plugin.shortName in self: return True # for Jenkins 1.X time.sleep(interval) if self.jenkins_obj.version.startswith("2"): raise JenkinsAPIException( "Problem installing plugin '%s'." % plugin.shortName ) log.warning( "Plugin '%s' not found in loaded plugins." "You may need to restart Jenkins.", plugin.shortName, ) return False def __contains__(self, plugin_name): """ True if plugin_name is the name of a defined plugin """ return plugin_name in self.keys() def __str__(self): plugins = [ plugin["shortName"] for plugin in self._data.get("plugins", []) ] return str(sorted(plugins)) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/queue.py0000644000000000000000000001302414766246232014525 0ustar00""" Queue module for jenkinsapi """ from __future__ import annotations from typing import Iterator, Tuple import logging import time from requests import HTTPError from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.custom_exceptions import UnknownQueueItem, NotBuiltYet log: logging.Logger = logging.getLogger(__name__) class Queue(JenkinsBase): """ Class that represents the Jenkins queue """ def __init__(self, baseurl: str, jenkins_obj: "Jenkins") -> None: """ Init the Jenkins queue object :param baseurl: basic url for the queue :param jenkins_obj: ref to the jenkins obj """ self.jenkins: "Jenkins" = jenkins_obj JenkinsBase.__init__(self, baseurl) def __str__(self) -> str: return self.baseurl def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def iteritems(self) -> Iterator[Tuple[str, "QueueItem"]]: for item in self._data["items"]: queue_id = item["id"] item_baseurl = "%s/item/%i" % (self.baseurl, queue_id) yield item["id"], QueueItem( baseurl=item_baseurl, jenkins_obj=self.jenkins ) def iterkeys(self) -> Iterator[str]: for item in self._data["items"]: yield item["id"] def itervalues(self) -> Iterator["QueueItem"]: for item in self._data["items"]: yield QueueItem(self.jenkins, **item) def keys(self) -> list[str]: return list(self.iterkeys()) def values(self) -> list["QueueItem"]: return list(self.itervalues()) def __len__(self) -> int: return len(self._data["items"]) def __getitem__(self, item_id: str) -> "QueueItem": self_as_dict = dict(self.iteritems()) if item_id in self_as_dict: return self_as_dict[item_id] else: raise UnknownQueueItem(item_id) def _get_queue_items_for_job(self, job_name: str) -> Iterator["QueueItem"]: for item in self._data["items"]: if "name" in item["task"] and item["task"]["name"] == job_name: yield QueueItem( self.get_queue_item_url(item), jenkins_obj=self.jenkins ) def get_queue_items_for_job(self, job_name: str): return list(self._get_queue_items_for_job(job_name)) def get_queue_item_url(self, item: str) -> str: return "%s/item/%i" % (self.baseurl, item["id"]) def delete_item(self, queue_item: "QueueItem"): self.delete_item_by_id(queue_item.queue_id) def delete_item_by_id(self, item_id: str): deleteurl: str = "%s/cancelItem?id=%s" % (self.baseurl, item_id) self.get_jenkins_obj().requester.post_url(deleteurl) class QueueItem(JenkinsBase): """An individual item in the queue""" def __init__(self, baseurl: str, jenkins_obj: "Jenkins") -> None: self.jenkins: "Jenkins" = jenkins_obj JenkinsBase.__init__(self, baseurl) @property def queue_id(self): return self._data["id"] @property def name(self): return self._data["task"]["name"] @property def why(self): return self._data.get("why") def get_jenkins_obj(self) -> "Jenkins": return self.jenkins def get_job(self) -> "Job": """ Return the job associated with this queue item """ return self.jenkins.get_job_by_url( self._data["task"]["url"], self._data["task"]["name"], ) def get_parameters(self): """returns parameters of queue item""" actions = self._data.get("actions", []) for action in actions: if isinstance(action, dict) and "parameters" in action: parameters = action["parameters"] return dict( [(x["name"], x.get("value", None)) for x in parameters] ) return [] def __repr__(self) -> str: return "<%s.%s %s>" % ( self.__class__.__module__, self.__class__.__name__, str(self), ) def __str__(self) -> str: return "%s Queue #%i" % (self.name, self.queue_id) def get_build(self) -> "Build": build_number = self.get_build_number() job = self.get_job() return job[build_number] def block_until_complete(self, delay=5): build = self.block_until_building(delay) return build.block_until_complete(delay=delay) def block_until_building(self, delay=5): while True: try: self.poll() return self.get_build() except NotBuiltYet: time.sleep(delay) continue except HTTPError as http_error: log.debug(str(http_error)) time.sleep(delay) continue def is_running(self) -> bool: """Return True if this queued item is running.""" try: return self.get_build().is_running() except NotBuiltYet: return False def is_queued(self) -> bool: """Return True if this queued item is queued.""" try: self.get_build() except NotBuiltYet: return True else: return False def get_build_number(self) -> int: try: return self._data["executable"]["number"] except (KeyError, TypeError): raise NotBuiltYet() def get_job_name(self) -> str: try: return self._data["task"]["name"] except KeyError: raise NotBuiltYet() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/result.py0000644000000000000000000000115114766246232014715 0ustar00""" Module for jenkinsapi Result """ class Result(object): """ Result class """ def __init__(self, **kwargs): self.__dict__.update(kwargs) def __str__(self): return f"{self.className} {self.name} {self.status}" def __repr__(self) -> str: module_name = self.__class__.__module__ class_name = self.__class__.__name__ self_str = str(self) return "<%s.%s %s>" % (module_name, class_name, self_str) def identifier(self) -> str: """ Calculate an ID for this object. """ return f"{self.className}.{self.name}" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/result_set.py0000644000000000000000000000307614766246232015600 0ustar00""" Module for jenkinsapi ResultSet """ from __future__ import annotations from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.result import Result class ResultSet(JenkinsBase): """ Represents a result from a completed Jenkins run. """ def __init__(self, url: str, build: "Build") -> None: """ Init a resultset :param url: url for a build, str :param build: build obj """ self.build: "Build" = build JenkinsBase.__init__(self, url) def get_jenkins_obj(self) -> "Jenkins": return self.build.job.get_jenkins_obj() def __str__(self) -> str: return "Test Result for %s" % str(self.build) @property def name(self): return str(self) def keys(self) -> list[str]: return [a[0] for a in self.iteritems()] def items(self): return [a for a in self.iteritems()] def iteritems(self): for suite in self._data.get("suites", []): for case in suite["cases"]: result = Result(**case) yield result.identifier(), result for report_set in self._data.get("childReports", []): if report_set["result"]: for suite in report_set["result"]["suites"]: for case in suite["cases"]: result = Result(**case) yield result.identifier(), result def __len__(self): return len(self.items()) def __getitem__(self, key): self_as_dict = dict(self.iteritems()) return self_as_dict[key] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/utils/__init__.py0000644000000000000000000000004214766246232016274 0ustar00""" Module __init__ for utils """ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/utils/crumb_requester.py0000644000000000000000000000526114766246232017754 0ustar00# Code from https://github.com/ros-infrastructure/ros_buildfarm # (c) Open Source Robotics Foundation import ast import logging from jenkinsapi.utils.requester import Requester logger = logging.getLogger(__name__) class CrumbRequester(Requester): """Adapter for Requester inserting the crumb in every request.""" def __init__(self, *args, **kwargs): super(CrumbRequester, self).__init__(*args, **kwargs) self._baseurl = kwargs["baseurl"] self._last_crumb_data = None def post_url( self, url, params=None, data=None, files=None, headers=None, allow_redirects=True, **kwargs, ): if self._last_crumb_data: # first try request with previous crumb if available response = self._post_url_with_crumb( self._last_crumb_data, url, params, data, files, headers, allow_redirects, **kwargs, ) # code 403 might indicate that the crumb is not valid anymore if response.status_code != 403: return response # fetch new crumb (if server has crumbs enabled) if self._last_crumb_data is not False: self._last_crumb_data = self._get_crumb_data() return self._post_url_with_crumb( self._last_crumb_data, url, params, data, files, headers, allow_redirects, **kwargs, ) def _get_crumb_data(self): response = self.get_url(self._baseurl + "/crumbIssuer/api/python") if response.status_code in [404]: logger.warning("The Jenkins master does not require a crumb") return False if response.status_code not in [200]: raise RuntimeError("Failed to fetch crumb: %s" % response.text) crumb_issuer_response = ast.literal_eval(response.text) crumb_request_field = crumb_issuer_response["crumbRequestField"] crumb = crumb_issuer_response["crumb"] logger.debug("Fetched crumb: %s", crumb) return {crumb_request_field: crumb} def _post_url_with_crumb( self, crumb_data, url, params, data, files, headers, allow_redirects, **kwargs, ): if crumb_data: if headers is None: headers = crumb_data else: headers.update(crumb_data) return super(CrumbRequester, self).post_url( url, params, data, files, headers, allow_redirects, **kwargs ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/utils/jenkins_launcher.py0000644000000000000000000002327614766246232020075 0ustar00import os import time import shutil import logging import datetime import tempfile import posixpath import requests import queue import threading import tarfile import subprocess from urllib3 import Retry from urllib.parse import urlparse from requests.adapters import HTTPAdapter from jenkinsapi.jenkins import Jenkins from jenkinsapi.custom_exceptions import JenkinsAPIException log = logging.getLogger(__name__) class FailedToStart(Exception): pass class TimeOut(Exception): pass class StreamThread(threading.Thread): def __init__(self, name, q, stream, fn_log): threading.Thread.__init__(self) self.name = name self.queue = q self.stream = stream self.fn_log = fn_log self._stop = threading.Event() def stop(self): self._stop.set() def stopped(self): return self._stop.isSet() def run(self): log.info("Starting %s", self.name) while True: if self._stop.is_set(): break line = self.stream.readline() if line: self.fn_log(line.rstrip()) self.queue.put((self.name, line)) else: break self.queue.put((self.name, None)) class JenkinsLancher(object): """ Launch jenkins """ JENKINS_WEEKLY_WAR_URL = "http://get.jenkins.io/war/latest/jenkins.war" JENKINS_LTS_WAR_URL = ( "https://get.jenkins.io/war-stable/latest/jenkins.war" ) def __init__( self, local_orig_dir, systests_dir, war_name, plugin_urls=None, jenkins_url=None, ): if jenkins_url is not None: self.jenkins_url = jenkins_url self.http_port = urlparse(jenkins_url).port self.start_new_instance = False else: import socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind(("", 0)) sock.listen(1) port = sock.getsockname()[1] sock.close() self.http_port = port self.jenkins_url = "http://localhost:%s" % self.http_port self.start_new_instance = True self.threads = [] self.war_path = os.path.join(local_orig_dir, war_name) self.local_orig_dir = local_orig_dir self.systests_dir = systests_dir self.war_filename = war_name if "JENKINS_HOME" not in os.environ: self.jenkins_home = tempfile.mkdtemp(prefix="jenkins-home-") os.environ["JENKINS_HOME"] = self.jenkins_home else: self.jenkins_home = os.environ["JENKINS_HOME"] self.jenkins_process = None self.queue = queue.Queue() self.plugin_urls = plugin_urls or [] if os.environ.get("JENKINS_VERSION", "stable") == "stable": self.JENKINS_WAR_URL = self.JENKINS_LTS_WAR_URL else: self.JENKINS_WAR_URL = self.JENKINS_WEEKLY_WAR_URL def update_war(self): os.chdir(self.systests_dir) if os.path.exists(self.war_path): log.info( "War file already present, delete it to redownload and" " update jenkins" ) else: log.info("Downloading Jenkins War") script_dir = os.path.join(self.systests_dir, "get-jenkins-war.sh") subprocess.check_call( [ script_dir, self.JENKINS_WAR_URL, self.local_orig_dir, self.war_filename, ] ) def update_config(self): from jenkinsapi_tests import systests file = os.path.join( os.path.dirname(systests.__file__), "jenkins_home.tar.gz" ) with open(file, "rb") as f: with tarfile.open(fileobj=f, mode="r:gz") as tarball: tarball.extractall(path=self.jenkins_home) def install_plugins(self): plugin_dest_dir = os.path.join(self.jenkins_home, "plugins") log.info("Plugins will be installed in '%s'", plugin_dest_dir) if not os.path.exists(plugin_dest_dir): os.mkdir(plugin_dest_dir) for url in self.plugin_urls: self.install_plugin(url, plugin_dest_dir) def install_plugin(self, hpi_url, plugin_dest_dir): sess = requests.Session() adapter = HTTPAdapter( max_retries=Retry(total=5, backoff_factor=1, allowed_methods=None) ) sess.mount("http://", adapter) sess.mount("https://", adapter) path = urlparse(hpi_url).path filename = posixpath.basename(path) plugin_orig_dir = os.path.join(self.local_orig_dir, "plugins") if not os.path.exists(plugin_orig_dir): os.mkdir(plugin_orig_dir) plugin_orig_path = os.path.join(plugin_orig_dir, filename) plugin_dest_path = os.path.join(plugin_dest_dir, filename) if os.path.exists(plugin_orig_path): log.info( "%s already locally present, delete the file to redownload" " and update", filename, ) else: log.info("Downloading %s from %s", filename, hpi_url) with sess.get(hpi_url, stream=True) as hget: hget.raise_for_status() with open(plugin_orig_path, "wb") as hpi: for chunk in hget.iter_content(chunk_size=8192): hpi.write(chunk) log.info("Installing %s", filename) shutil.copy(plugin_orig_path, plugin_dest_path) # Create an empty .pinned file, so that the downloaded plugin # will be used, instead of the version bundled in jenkins.war # See https://wiki.jenkins-ci.org/display/JENKINS/Pinned+Plugins open(plugin_dest_path + ".pinned", "a").close() def stop(self): if self.start_new_instance: log.info("Shutting down jenkins.") # Start the threads for thread in self.threads: thread.stop() Jenkins(self.jenkins_url).shutdown() # self.jenkins_process.terminate() # self.jenkins_process.wait() # Do not remove jenkins home if JENKINS_URL is set if "JENKINS_URL" not in os.environ: shutil.rmtree(self.jenkins_home, ignore_errors=True) log.info("Jenkins stopped.") def block_until_jenkins_ready(self, timeout): start_time = datetime.datetime.now() timeout_time = start_time + datetime.timedelta(seconds=timeout) while True: try: Jenkins(self.jenkins_url) log.info("Jenkins is finally ready for use.") except JenkinsAPIException: log.info("Jenkins is not yet ready...") if datetime.datetime.now() > timeout_time: raise TimeOut("Took too long for Jenkins to become ready...") time.sleep(5) def start(self, timeout=60): if self.start_new_instance: self.jenkins_home = os.environ.get( "JENKINS_HOME", self.jenkins_home ) self.update_war() self.update_config() self.install_plugins() os.chdir(self.local_orig_dir) jenkins_command = [ "java", "-Djenkins.install.runSetupWizard=false", "-Dhudson.DNSMultiCast.disabled=true", "-jar", self.war_filename, "--httpPort=%d" % self.http_port, ] log.info("About to start Jenkins...") log.info("%s> %s", os.getcwd(), " ".join(jenkins_command)) self.jenkins_process = subprocess.Popen( jenkins_command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) self.threads = [ StreamThread( "out", self.queue, self.jenkins_process.stdout, log.info ), StreamThread( "err", self.queue, self.jenkins_process.stderr, log.warning ), ] # Start the threads for thread in self.threads: thread.start() while True: try: streamName, line = self.queue.get( block=True, timeout=timeout ) # Python 3.x if isinstance(line, bytes): line = line.decode("UTF-8") except queue.Empty: log.warning("Input ended unexpectedly") break else: if line: if "Failed to initialize Jenkins" in line: raise FailedToStart(line) if "Invalid or corrupt jarfile" in line: raise FailedToStart(line) if "is fully up and running" in line: log.info(line) return else: log.warning("Stream %s has terminated", streamName) self.block_until_jenkins_ready(timeout) if __name__ == "__main__": logging.basicConfig() logging.getLogger("").setLevel(logging.INFO) log.info("Hello!") jl = JenkinsLancher( "/home/aleksey/src/jenkinsapi_lechat/jenkinsapi_tests" "/systests/localinstance_files", "/home/aleksey/src/jenkinsapi_lechat/jenkinsapi_tests/systests", "jenkins.war", ) jl.start() log.info("Jenkins was launched...") time.sleep(10) log.info("...now to shut it down!") jl.stop() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/utils/jsonp_to_json.py0000644000000000000000000000051714766246232017430 0ustar00""" Module for converting jsonp to json. """ from __future__ import print_function def jsonp_to_json(jsonp): try: l_index = jsonp.index("(") + 1 r_index = jsonp.rindex(")") except ValueError: print("Input is not in jsonp format.") return None res = jsonp[l_index:r_index] return res ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/utils/krb_requester.py0000644000000000000000000000250314766246232017416 0ustar00""" Kerberos aware Requester """ from jenkinsapi.utils.requester import Requester from requests_kerberos import HTTPKerberosAuth, OPTIONAL # pylint: disable=W0222 class KrbRequester(Requester): """ A class which carries out HTTP requests with Kerberos/GSSAPI authentication. """ def __init__(self, *args, **kwargs): """ :param ssl_verify: flag indicating if server certificate in HTTPS requests should be verified :param baseurl: Jenkins' base URL :param mutual_auth: type of mutual authentication, use one of REQUIRED, OPTIONAL or DISABLED from requests_kerberos package """ super(KrbRequester, self).__init__(*args, **kwargs) self.mutual_auth = ( kwargs["mutual_auth"] if "mutual_auth" in kwargs else OPTIONAL ) def get_request_dict( self, params=None, data=None, files=None, headers=None, **kwargs ): req_dict = super(KrbRequester, self).get_request_dict( params=params, data=data, files=files, headers=headers, **kwargs ) if self.mutual_auth: auth = HTTPKerberosAuth(self.mutual_auth) else: auth = HTTPKerberosAuth() req_dict["auth"] = auth return req_dict ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/utils/manifest.py0000644000000000000000000000636514766246232016361 0ustar00""" This module enables Manifest file parsing. Copied from https://chromium.googlesource.com/external/googleappengine/python/+/master /google/appengine/tools/jarfile.py """ import zipfile _MANIFEST_NAME = "META-INF/MANIFEST.MF" class InvalidJarError(Exception): """ InvalidJar exception class """ pass class Manifest(object): """The parsed manifest from a jar file. Attributes: main_section: a dict representing the main (first) section of the manifest. Each key is a string that is an attribute, such as 'Manifest-Version', and the corresponding value is a string that is the value of the attribute, such as '1.0'. sections: a dict representing the other sections of the manifest. Each key is a string that is the value of the 'Name' attribute for the section, and the corresponding value is a dict like the main_section one, for the other attributes. """ def __init__(self, main_section, sections): self.main_section = main_section self.sections = sections def read_manifest(jar_file_name): """Read and parse the manifest out of the given jar. Args: jar_file_name: the name of the jar from which the manifest is to be read. Returns: A parsed Manifest object, or None if the jar has no manifest. Raises: IOError: if the jar does not exist or cannot be read. """ with zipfile.ZipFile(jar_file_name) as jar: try: manifest_string = jar.read(_MANIFEST_NAME).decode("UTF-8") except KeyError: return None return _parse_manifest(manifest_string) def _parse_manifest(manifest_string): """Parse a Manifest object out of the given string. Args: manifest_string: a str or unicode that is the manifest contents. Returns: A Manifest object parsed out of the string. Raises: InvalidJarError: if the manifest is not well-formed. """ manifest_string = "\n".join(manifest_string.splitlines()).rstrip("\n") section_strings = manifest_string.split("\n\n") parsed_sections = [_parse_manifest_section(s) for s in section_strings] main_section = parsed_sections[0] sections = dict() try: for entry in parsed_sections[1:]: sections[entry["Name"]] = entry except KeyError: raise InvalidJarError( "Manifest entry has no Name attribute: %s" % entry ) return Manifest(main_section, sections) def _parse_manifest_section(section): """Parse a dict out of the given manifest section string. Args: section: a str or unicode that is the manifest section. It looks something like this (without the >): > Name: section-name > Some-Attribute: some value > Another-Attribute: another value Returns: A dict where the keys are the attributes (here, 'Name', 'Some-Attribute', 'Another-Attribute'), and the values are the corresponding attribute values. Raises: InvalidJarError: if the manifest section is not well-formed. """ section = section.replace("\n ", "") try: return dict(line.split(": ", 1) for line in section.split("\n")) except ValueError: raise InvalidJarError("Invalid manifest %r" % section) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/utils/requester.py0000644000000000000000000001724614766246232016572 0ustar00""" Module for jenkinsapi requester (which is a wrapper around python-requests) """ import requests import urllib.parse as urlparse from jenkinsapi.custom_exceptions import JenkinsAPIException, PostRequired # import logging # these two lines enable debugging at httplib level # (requests->urllib3->httplib) # you will see the REQUEST, including HEADERS and DATA, and RESPONSE # with HEADERS but without DATA. # the only thing missing will be the response.body which is not logged. # import httplib # httplib.HTTPConnection.debuglevel = 1 # you need to initialize logging, otherwise you will not see anything # from requests # logging.basicConfig() # logging.getLogger().setLevel(logging.DEBUG) # requests_log = logging.getLogger("requests.packages.urllib3") # requests_log.setLevel(logging.DEBUG) # requests_log.propagate = True requests.adapters.DEFAULT_RETRIES = 5 class Requester(object): """ A class which carries out HTTP requests. You can replace this class with one of your own implementation if you require some other way to access Jenkins. This default class can handle simple authentication only. """ VALID_STATUS_CODES = [ 200, ] AUTH_COOKIE = None def __init__(self, *args, **kwargs): username = None password = None ssl_verify = True cert = None baseurl = None timeout = 10 if len(args) == 1: (username,) = args elif len(args) == 2: username, password = args elif len(args) == 3: username, password, ssl_verify = args elif len(args) == 4: username, password, ssl_verify, cert = args elif len(args) == 5: username, password, ssl_verify, cert, baseurl = args elif len(args) == 6: username, password, ssl_verify, cert, baseurl, timeout = args elif len(args) > 6: raise ValueError("To much positional arguments given!") baseurl = kwargs.get("baseurl", baseurl) self.base_scheme = ( urlparse.urlsplit(baseurl).scheme if baseurl else None ) self.username = kwargs.get("username", username) self.password = kwargs.get("password", password) if self.username: assert self.password, ( "Please provide both username and password " "or don't provide them at all" ) if self.password: assert self.username, ( "Please provide both username and password " "or don't provide them at all" ) self.ssl_verify = kwargs.get("ssl_verify", ssl_verify) self.cert = kwargs.get("cert", cert) self.timeout = kwargs.get("timeout", timeout) self.session = requests.Session() self.max_retries = kwargs.get("max_retries") if self.max_retries is not None: retry_adapter = requests.adapters.HTTPAdapter( max_retries=self.max_retries ) self.session.mount("http://", retry_adapter) self.session.mount("https://", retry_adapter) def get_request_dict( self, params=None, data=None, files=None, headers=None, **kwargs ): requestKwargs = kwargs if self.username: requestKwargs["auth"] = (self.username, self.password) if params: assert isinstance( params, dict ), "Params must be a dict, got %s" % repr(params) requestKwargs["params"] = params if headers: assert isinstance( headers, dict ), "headers must be a dict, got %s" % repr(headers) requestKwargs["headers"] = headers if self.AUTH_COOKIE: currentheaders = requestKwargs.get("headers", {}) currentheaders.update({"Cookie": self.AUTH_COOKIE}) requestKwargs["headers"] = currentheaders requestKwargs["verify"] = self.ssl_verify requestKwargs["cert"] = self.cert if data: # It may seem odd, but some Jenkins operations require posting # an empty string. requestKwargs["data"] = data if files: requestKwargs["files"] = files requestKwargs["timeout"] = self.timeout return requestKwargs def _update_url_scheme(self, url): """ Updates scheme of given url to the one used in Jenkins baseurl. """ if self.base_scheme and not url.startswith("%s://" % self.base_scheme): url_split = urlparse.urlsplit(url) url = urlparse.urlunsplit( [ self.base_scheme, url_split.netloc, url_split.path, url_split.query, url_split.fragment, ] ) return url def get_url( self, url, params=None, headers=None, allow_redirects=True, stream=False, ): requestKwargs = self.get_request_dict( params=params, headers=headers, allow_redirects=allow_redirects, stream=stream, ) return self.session.get(self._update_url_scheme(url), **requestKwargs) def post_url( self, url, params=None, data=None, files=None, headers=None, allow_redirects=True, **kwargs, ): requestKwargs = self.get_request_dict( params=params, data=data, files=files, headers=headers, allow_redirects=allow_redirects, **kwargs, ) return self.session.post(self._update_url_scheme(url), **requestKwargs) def post_xml_and_confirm_status( self, url, params=None, data=None, valid=None ): headers = {"Content-Type": "text/xml"} return self.post_and_confirm_status( url, params=params, data=data, headers=headers, valid=valid ) def post_and_confirm_status( self, url, params=None, data=None, files=None, headers=None, valid=None, allow_redirects=True, ): valid = valid or self.VALID_STATUS_CODES if not headers and not files: headers = {"Content-Type": "application/x-www-form-urlencoded"} assert data is not None, "Post messages must have data" response = self.post_url( url, params, data, files, headers, allow_redirects ) if response.status_code not in valid: raise JenkinsAPIException( "Operation failed. url={0}, data={1}, headers={2}, " "status={3}, text={4}".format( response.url, data, headers, response.status_code, response.text.encode("UTF-8"), ) ) return response def get_and_confirm_status( self, url, params=None, headers=None, valid=None, stream=False ): valid = valid or self.VALID_STATUS_CODES response = self.get_url(url, params, headers, stream=stream) if response.status_code not in valid: if response.status_code == 405: # POST required raise PostRequired("POST required for url {0}".format(url)) raise JenkinsAPIException( "Operation failed. url={0}, headers={1}, status={2}, " "text={3}".format( response.url, headers, response.status_code, response.text.encode("UTF-8"), ) ) return response ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/utils/simple_post_logger.py0000755000000000000000000000200414766246232020435 0ustar00#!/usr/bin/env python from __future__ import print_function try: from SimpleHTTPServer import SimpleHTTPRequestHandler except ImportError: from http.server import SimpleHTTPRequestHandler try: import SocketServer as socketserver except ImportError: import socketserver import logging import cgi PORT = 8080 class ServerHandler(SimpleHTTPRequestHandler): def do_GET(self): logging.error(self.headers) super().do_GET() def do_POST(self): logging.error(self.headers) form = cgi.FieldStorage( fp=self.rfile, headers=self.headers, environ={ "REQUEST_METHOD": "POST", "CONTENT_TYPE": self.headers["Content-Type"], }, ) for item in form.list: logging.error(item) super().do_GET() if __name__ == "__main__": Handler = ServerHandler httpd = socketserver.TCPServer(("", PORT), Handler) print("serving at port", PORT) httpd.serve_forever() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/view.py0000644000000000000000000001374214766246232014362 0ustar00""" Module for jenkinsapi views """ from __future__ import annotations from typing import Iterator, Tuple import logging from jenkinsapi.jenkinsbase import JenkinsBase from jenkinsapi.job import Job from jenkinsapi.custom_exceptions import NotFound log: logging.Logger = logging.getLogger(__name__) class View(JenkinsBase): """ View class """ def __init__(self, url: str, name: str, jenkins_obj: "Jenkins") -> None: self.name: str = name self.jenkins_obj: "Jenkins" = jenkins_obj JenkinsBase.__init__(self, url) self.deleted: bool = False def __len__(self) -> int: return len(self.get_job_dict().keys()) def __str__(self) -> str: return self.name def __repr__(self) -> str: return self.name def __getitem__(self, job_name) -> Job: assert isinstance(job_name, str) api_url = self.python_api_url(self.get_job_url(job_name)) return Job(api_url, job_name, self.jenkins_obj) def __contains__(self, job_name: str) -> bool: """ True if view_name is the name of a defined view """ return job_name in self.keys() def delete(self) -> None: """ Remove this view object """ url: str = f"{self.baseurl}/doDelete" self.jenkins_obj.requester.post_and_confirm_status(url, data="") self.jenkins_obj.poll() self.deleted = True def keys(self) -> list[str]: return self.get_job_dict().keys() def iteritems(self) -> Iterator[Tuple[str, Job]]: it = self.get_job_dict().items() for name, url in it: yield name, Job(url, name, self.jenkins_obj) def values(self) -> list[Job]: return [a[1] for a in self.iteritems()] def items(self): return [a for a in self.iteritems()] def _get_jobs(self) -> Iterator[Tuple[str, str]]: if "jobs" in self._data: for viewdict in self._data["jobs"]: yield viewdict["name"], viewdict["url"] def get_job_dict(self) -> dict: return dict(self._get_jobs()) def get_job_url(self, str_job_name: str) -> str: if str_job_name in self: return self.get_job_dict()[str_job_name] else: # noinspection PyUnboundLocalVariable views_jobs = ", ".join(self.get_job_dict().keys()) raise NotFound( "Job %s is not known, available jobs" " in view are: %s" % (str_job_name, views_jobs) ) def get_jenkins_obj(self) -> "Jenkins": return self.jenkins_obj def add_job(self, job_name: str, job=None) -> bool: """ Add job to a view :param job_name: name of the job to be added :param job: Job object to be added :return: True if job has been added, False if job already exists or job not known to Jenkins """ if not job: if job_name in self.get_job_dict(): log.warning( "Job %s is already in the view %s", job_name, self.name ) return False else: # Since this call can be made from nested view, # which doesn't have any jobs, we can miss existing job # Thus let's create top level Jenkins and ask him # http://jenkins:8080/view/CRT/view/CRT-FB/view/CRT-SCRT-1301/ top_jenkins = self.get_jenkins_obj().get_jenkins_obj_from_url( self.baseurl.split("view/")[0] ) if not top_jenkins.has_job(job_name): log.error( msg='Job "%s" is not known to Jenkins' % job_name ) return False else: job = top_jenkins.get_job(job_name) log.info(msg="Creating job %s in view %s" % (job_name, self.name)) url = "%s/addJobToView" % self.baseurl params = {"name": job_name} self.get_jenkins_obj().requester.post_and_confirm_status( url, data={}, params=params ) self.poll() log.debug( msg='Job "%s" has been added to a view "%s"' % (job.name, self.name) ) return True def remove_job(self, job_name: str) -> bool: """ Remove job from a view :param job_name: name of the job to be removed :return: True if job has been removed, False if job not assigned to this view """ if job_name not in self: return False url = "%s/removeJobFromView" % self.baseurl params = {"name": job_name} self.get_jenkins_obj().requester.post_and_confirm_status( url, data={}, params=params ) self.poll() log.debug( msg='Job "%s" has been added to a view "%s"' % (job_name, self.name) ) return True def _get_nested_views(self) -> Iterator[Tuple[str, str]]: for viewdict in self._data.get("views", []): yield viewdict["name"], viewdict["url"] def get_nested_view_dict(self) -> dict: return dict(self._get_nested_views()) def get_config_xml_url(self) -> str: return "%s/config.xml" % self.baseurl def get_config(self) -> str: """ Return the config.xml from the view """ url = self.get_config_xml_url() response = self.get_jenkins_obj().requester.get_and_confirm_status(url) return response.text def update_config(self, config: str) -> str: """ Update the config.xml to the view """ url = self.get_config_xml_url() config = str(config) # cast unicode in case of Python 2 response = self.get_jenkins_obj().requester.post_url( url, params={}, data=config ) return response.text @property def views(self): return ( self.get_jenkins_obj().get_jenkins_obj_from_url(self.baseurl).views ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8707728 jenkinsapi-0.3.14/jenkinsapi/views.py0000644000000000000000000001007014766246232014534 0ustar00""" Module for jenkinsapi Views """ import logging import json from jenkinsapi.view import View from jenkinsapi.custom_exceptions import JenkinsAPIException log = logging.getLogger(__name__) class Views(object): """ An abstraction on a Jenkins object's views """ LIST_VIEW = "hudson.model.ListView" NESTED_VIEW = "hudson.plugins.nested_view.NestedView" CATEGORIZED_VIEW = ( "org.jenkinsci.plugins.categorizedview.CategorizedJobsView" ) MY_VIEW = "hudson.model.MyView" DASHBOARD_VIEW = "hudson.plugins.view.dashboard.Dashboard" PIPELINE_VIEW = ( "au.com.centrumsystems.hudson." "plugin.buildpipeline.BuildPipelineView" ) def __init__(self, jenkins): self.jenkins = jenkins self._data = None def poll(self, tree=None): self._data = self.jenkins.poll( tree="views[name,url]" if tree is None else tree ) def __len__(self): return len(self.keys()) def __delitem__(self, view_name): if view_name == "All": raise ValueError("Cannot delete this view: %s" % view_name) if view_name in self: self[view_name].delete() self.poll() def __setitem__(self, view_name, job_names_list): new_view = self.create(view_name) if isinstance(job_names_list, str): job_names_list = [job_names_list] for job_name in job_names_list: if not new_view.add_job(job_name): # Something wrong - delete view del self[new_view] raise TypeError("Job %s does not exist in Jenkins" % job_name) def __getitem__(self, view_name): self.poll() for row in self._data.get("views", []): if row["name"] == view_name: return View(row["url"], row["name"], self.jenkins) raise KeyError("View %s not found" % view_name) def iteritems(self): """ Get the names & objects for all views """ self.poll() for row in self._data.get("views", []): name = row["name"] url = row["url"] yield name, View(url, name, self.jenkins) def __contains__(self, view_name): """ True if view_name is the name of a defined view """ return view_name in self.keys() def iterkeys(self): """ Get the names of all available views """ self.poll() for row in self._data.get("views", []): yield row["name"] def keys(self): """ Return a list of the names of all views """ return list(self.iterkeys()) def create(self, view_name, view_type=LIST_VIEW, config=None): """ Create a view :param view_name: name of new view, str :param view_type: type of the view, one of the constants in Views, str :param config: XML configuration of the new view :return: new View obj or None if view was not created """ log.info('Creating "%s" view "%s"', view_type, view_name) if view_name in self: log.warning('View "%s" already exists', view_name) return self[view_name] url = "%s/createView" % self.jenkins.baseurl if view_type == self.CATEGORIZED_VIEW: if not config: raise JenkinsAPIException( "Job XML config cannot be empty for CATEGORIZED_VIEW" ) params = {"name": view_name} self.jenkins.requester.post_xml_and_confirm_status( url, data=config, params=params ) else: headers = {"Content-Type": "application/x-www-form-urlencoded"} data = { "name": view_name, "mode": view_type, "Submit": "OK", "json": json.dumps({"name": view_name, "mode": view_type}), } self.jenkins.requester.post_and_confirm_status( url, data=data, headers=headers ) self.poll() return self[view_name] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1742294169.8737729 jenkinsapi-0.3.14/pyproject.toml0000644000000000000000000000521214766246232013610 0ustar00[build-system] requires = ["flit_core >=3.2,<4"] build-backend = "flit_core.buildapi" [project] name = "jenkinsapi" version = "0.3.14" authors = [ {name = "Salim Fadhley", email = "salimfadhley@gmail.com"}, {name = "Aleksey Maksimov", email = "ctpeko3a@gmail.com"}, {name = "Clinton Steiner", email = "clintonsteiner@gmail.com"}, ] maintainers = [ {name = "Aleksey Maksimov", email = "ctpeko3a@gmail.com"}, {name = "Clinton Steiner", email = "clintonsteiner@gmail.com"}, ] description = "A Python API for accessing resources on a Jenkins continuous-integration server." readme = "README.rst" license = {text = "MIT license"} classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Testing", "Topic :: Utilities", ] requires-python = ">=3.8" dependencies = [ "pytz>=2014.4", "requests>=2.3.0", "six>=1.10.0", ] [tool.setuptools] packages = ["jenkinsapi", "jenkinsapi_utils", "jenkinsapi_tests"] include-package-data = false [tool.pbr] warnerrors = "True" [project.scripts] jenkins_invoke = "jenkinsapi.command_line.jenkins_invoke:main" jenkinsapi_version = "jenkinsapi.command_line.jenkinsapi_version:main" [tool.build_sphinx] source-dir = "doc/source" build-dir = "doc/build" all_files = "1" [tool.upload_sphinx] upload-dir = "doc/build/html" [tool.distutils.bdist_wheel] universal = 1 [tool.pycodestyle] exclude = ".tox,doc/source/conf.py,build,.venv,.eggs" max-line-length = "99" [dependency-groups] dev = [ "pytest-mock>=3.14.0", "pytest>=8.3.4", "pytest-cov>=4.0.0", "pycodestyle>=2.3.1", "astroid>=1.4.8", "pylint>=1.7.1", "tox>=2.3.1", "mock>=5.1.0", "codecov>=2.1.13", "requests-kerberos>=0.15.0", "ruff>=0.9.6", ] docs = [ "docutils>=0.20.1", "furo>=2024.8.6", "myst-parser>=3.0.1", "pygments>=2.19.1", "sphinx>=7.1.2", ] [tool.ruff] line-length = 79 [tool.ruff.lint] select = ["E9", "F63", "F7", "F82"] # Equivalent to flake8’s default rules ignore = ["F821"] #, "W503", "W504" jenkinsapi-0.3.14/PKG-INFO0000644000000000000000000001465000000000000011724 0ustar00Metadata-Version: 2.4 Name: jenkinsapi Version: 0.3.14 Summary: A Python API for accessing resources on a Jenkins continuous-integration server. Author-email: Salim Fadhley , Aleksey Maksimov , Clinton Steiner Maintainer-email: Aleksey Maksimov , Clinton Steiner Requires-Python: >=3.8 Description-Content-Type: text/x-rst Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Console Classifier: Intended Audience :: Developers Classifier: Intended Audience :: Information Technology Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: MIT License Classifier: Natural Language :: English Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Topic :: Software Development :: Testing Classifier: Topic :: Utilities License-File: LICENSE Requires-Dist: pytz>=2014.4 Requires-Dist: requests>=2.3.0 Requires-Dist: six>=1.10.0 Jenkinsapi ========== .. image:: https://badge.fury.io/py/jenkinsapi.png :target: http://badge.fury.io/py/jenkinsapi .. image:: https://codecov.io/gh/pycontribs/jenkinsapi/branch/master/graph/badge.svg :target: https://codecov.io/gh/pycontribs/jenkinsapi About this library ------------------- Jenkins is the market leading continuous integration system, originally created by Kohsuke Kawaguchi. Jenkins (and its predecessor Hudson) are useful projects for automating common development tasks (e.g. unit-testing, production batches) - but they are somewhat Java-centric. | Jenkinsapi makes scripting Jenkins tasks a breeze by wrapping the REST api into familiar python objects. | Here is a list of some of the most commonly used functionality * Add, remove, and query Jenkins jobs * Control pipeline execution * Query the results of a completed build * Block until jobs are complete or run jobs asyncronously * Get objects representing the latest builds of a job * Artifact management * Search for artifacts by simple criteria * Install artifacts to custom-specified directory structures * Search for builds by source code revision * Create, destroy, and monitor * Build nodes (Webstart and SSH slaves) * Views (including nested views using NestedViews Jenkins plugin) * Credentials (username/password and ssh key) * Authentication support for username and password * Manage jenkins and plugin installation Full library capabilities are outlined in the `Documentation `__ Known issues ------------ * Job deletion operations fail unless Cross-Site scripting protection is disabled. For other issues, please refer to the `support URL `_ Important Links --------------- * `Support and bug-reports `_ * `Source Code `_ * `Documentation `__ * `Releases `_ Installation ------------- Egg-files for this project are hosted on PyPi. Most Python users should be able to use pip or setuptools to automatically install this project. Using Pip or Setuptools ^^^^^^^^^^^^^^^^^^^^^^^ Most users can do the following: .. code-block:: bash pip install jenkinsapi Using your operating-system's package manager ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Ubuntu users can now use apt to install this package: .. code-block:: bash apt-get install python-jenkinsapi Beware that this technique will get a somewhat older version of Jenkinsapi. Example ------- JenkinsAPI is intended to map the objects in Jenkins (e.g. Builds, Views, Jobs) into easily managed Python objects: .. code-block:: python from jenkinsapi.jenkins import Jenkins J = Jenkins('http://localhost:8080') print(J.version) # 1.542 print(J.keys()) # foo, test_jenkinsapi print(J.get('test_jenkinsapi')) # print(J.get('test_jenkinsapi').get_last_good_build()) # More examples available on `Github `_ Testing ------- If you have installed the test dependencies on your system already, you can run the testsuite with the following command: .. code-block:: bash uv sync uv run pytest -sv --cov=jenkinsapi --cov-report=term-missing --cov-report=xml jenkinsapi_tests Otherwise using a virtualenv is recommended. Setuptools will automatically fetch missing test dependencies: .. code-block:: bash uv venv uv python install uv run pytest -sv --cov=jenkinsapi --cov-report=term-missing --cov-report=xml jenkinsapi_tests Development ----------- * Make sure that you have Java_ installed. Jenkins will be automatically downloaded and started during tests. * Create virtual environment for development * Install package in development mode .. code-block:: bash uv sync * Make your changes, write tests and check your code .. code-block:: bash uv run pytest -sv Python versions --------------- The project has been tested against Python versions: * 3.8 - 3.13 Jenkins versions ---------------- Project tested on both stable (LTS) and latest Jenkins versions. Project Contributors -------------------- * Aleksey Maksimov (ctpeko3a@gmail.com) * Salim Fadhley (sal@stodge.org) * Ramon van Alteren (ramon@vanalteren.nl) * Ruslan Lutsenko (ruslan.lutcenko@gmail.com) * Cleber J Santos (cleber@simplesconsultoria.com.br) * William Zhang (jollychang@douban.com) * Victor Garcia (bravejolie@gmail.com) * Bradley Harris (bradley@ninelb.com) * Kyle Rockman (kyle.rockman@mac.com) * Sascha Peilicke (saschpe@gmx.de) * David Johansen (david@makewhat.is) * Misha Behersky (bmwant@gmail.com) * Clinton Steiner (clintonsteiner@gmail.com) Please do not contact these contributors directly for support questions! Use the GitHub tracker instead. .. _Java: https://www.oracle.com/java/technologies/downloads/#java21