├── tests
├── __init__.py
├── unit
│ ├── __init__.py
│ ├── tests_data
│ │ ├── jenkins
│ │ │ ├── crumb.json
│ │ │ └── jenkins.json
│ │ ├── credential
│ │ │ ├── user_psw.json
│ │ │ ├── UserPasswordCredential.xml
│ │ │ ├── domains.json
│ │ │ └── credentials.json
│ │ ├── queue
│ │ │ ├── buildableitem.json
│ │ │ ├── leftitem.json
│ │ │ ├── waitingitem.json
│ │ │ ├── blockeditem.json
│ │ │ └── queue.json
│ │ ├── view
│ │ │ └── allview.json
│ │ ├── report
│ │ │ ├── coverage_result.json
│ │ │ ├── coverage_report.json
│ │ │ ├── test_report.json
│ │ │ ├── coverage_trends.json
│ │ │ └── coverage_report_trend.json
│ │ ├── run
│ │ │ ├── freestylebuild.json
│ │ │ └── workflowrun.json
│ │ ├── job
│ │ │ ├── folder.json
│ │ │ ├── mbranch.json
│ │ │ └── pipeline.json
│ │ └── plugin
│ │ │ ├── installStatus.json
│ │ │ ├── installStatus_done.json
│ │ │ └── plugin.json
│ ├── test_plugin.py
│ ├── test_artifact.py
│ ├── test_node.py
│ ├── test_view.py
│ ├── test_report.py
│ ├── test_system.py
│ ├── test_credential.py
│ ├── test_input.py
│ ├── test_queue.py
│ ├── test_job.py
│ ├── conftest.py
│ └── test_build.py
└── integration
│ ├── __init__.py
│ ├── tests_data
│ ├── folder.xml
│ ├── job.xml
│ ├── credential.xml
│ ├── domain.xml
│ ├── args_job.xml
│ └── view.xml
│ ├── test_05_build.py
│ ├── test_04_view.py
│ ├── test_08_plugin.py
│ ├── test_06_system.py
│ ├── test_03_credential.py
│ ├── test_07_node.py
│ ├── test_02_job.py
│ └── conftest.py
├── docs
├── requirements.txt
├── Makefile
├── make.bat
└── source
│ ├── user
│ ├── install.rst
│ └── api.rst
│ ├── conf.py
│ └── index.rst
├── .readthedocs.yaml
├── api4jenkins
├── __version__.py
├── exceptions.py
├── artifact.py
├── http.py
├── user.py
├── input.py
├── system.py
├── credential.py
├── view.py
├── mix.py
├── report.py
├── queue.py
├── item.py
├── plugin.py
├── node.py
└── build.py
├── .github
├── codecov.yml
└── workflows
│ ├── publish.yml
│ ├── unittest.yml
│ ├── integration.yml
│ └── codeql.yml
├── tox.ini
├── setup.py
├── .gitignore
├── HISTORY.md
└── README.md
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/unit/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | httpx
2 | sphinx
--------------------------------------------------------------------------------
/tests/integration/__init__.py:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/jenkins/crumb.json:
--------------------------------------------------------------------------------
1 | {
2 | "crumbRequestField": "mock-crumb-id",
3 | "crumb": "mock-crumb"
4 | }
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | build:
4 | os: "ubuntu-22.04"
5 | tools:
6 | python: "3.11"
7 |
8 | formats:
9 | - pdf
10 | - epub
11 |
12 | python:
13 | install:
14 | - requirements: docs/requirements.txt
15 |
--------------------------------------------------------------------------------
/tests/integration/tests_data/folder.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/tests/integration/tests_data/job.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | true
6 |
7 | false
8 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/credential/user_psw.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "com.cloudbees.plugins.credentials.CredentialsStoreAction$CredentialsWrapper",
3 | "description": "for testing",
4 | "displayName": "user1/****** (for testing)",
5 | "fingerprint": null,
6 | "fullName": "system/_/test-user",
7 | "id": "test-user",
8 | "typeName": "Username with password"
9 | }
--------------------------------------------------------------------------------
/tests/integration/tests_data/credential.xml:
--------------------------------------------------------------------------------
1 |
2 | GLOBAL
3 | user-id
4 | user-name
5 | user-password
6 | user id for testing
7 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/credential/UserPasswordCredential.xml:
--------------------------------------------------------------------------------
1 |
2 | GLOBAL
3 | user-id
4 | user-name
5 | user-password
6 | user id for testing
7 |
--------------------------------------------------------------------------------
/tests/unit/test_plugin.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 |
3 | import pytest
4 | from api4jenkins.plugin import Plugin
5 |
6 |
7 | class TestPlugins:
8 |
9 | @pytest.mark.parametrize('name, obj',
10 | [('not exist', type(None)), ('git', Plugin)])
11 | def test_get(self, jenkins, name, obj):
12 | assert isinstance(jenkins.plugins.get(name), obj)
13 |
--------------------------------------------------------------------------------
/api4jenkins/__version__.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | __version__ = '2.1.0'
3 | __title__ = 'api4jenkins'
4 | __description__ = 'Jenkins Python Client'
5 | __url__ = 'https://github.com/joelee2012/api4jenkins'
6 | __author__ = 'Joe Lee'
7 | __author_email__ = 'lj_2005@163.com'
8 | __license__ = 'Apache 2.0'
9 | __copyright__ = 'Copyright 2025 Joe Lee'
10 | __documentation__ = 'https://api4jenkins.readthedocs.io'
11 |
--------------------------------------------------------------------------------
/.github/codecov.yml:
--------------------------------------------------------------------------------
1 | flag_management:
2 | individual_flags:
3 | - name: unittest
4 | paths:
5 | - api4jenkins
6 | - tests/unit/
7 | statuses:
8 | - type: project
9 | target: 80%
10 | threshold: 1%
11 | - name: integration
12 | paths:
13 | - api4jenkins
14 | - tests/integration/
15 | statuses:
16 | - type: project
17 | target: auto
18 | threshold: 1%
19 |
--------------------------------------------------------------------------------
/api4jenkins/exceptions.py:
--------------------------------------------------------------------------------
1 |
2 | class JenkinsAPIException(Exception):
3 | pass
4 |
5 |
6 | class ItemNotFoundError(JenkinsAPIException):
7 | pass
8 |
9 |
10 | class AuthenticationError(JenkinsAPIException):
11 | pass
12 |
13 |
14 | class ItemExistsError(JenkinsAPIException):
15 | pass
16 |
17 |
18 | class UnsafeCharacterError(JenkinsAPIException):
19 | pass
20 |
21 |
22 | class BadRequestError(JenkinsAPIException):
23 | pass
24 |
25 |
26 | class ServerError(JenkinsAPIException):
27 | pass
28 |
--------------------------------------------------------------------------------
/tests/integration/tests_data/domain.xml:
--------------------------------------------------------------------------------
1 |
2 | testing
3 | Credentials for use against the *.test.example.com hosts
4 |
5 |
6 | *.test.example.com
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/queue/buildableitem.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "hudson.model.Queue$BuildableItem",
3 | "actions": [],
4 | "blocked": false,
5 | "buildable": true,
6 | "id": 668,
7 | "inQueueSince": 1561364099799,
8 | "params": "",
9 | "stuck": true,
10 | "task": {
11 | "_class": "org.jenkinsci.plugins.workflow.support.steps.ExecutorStepExecution$PlaceholderTask"
12 | },
13 | "url": "queue/item/668/",
14 | "why": "There are no nodes with the label ‘xxxx’",
15 | "buildableStartMilliseconds": 1561364099799,
16 | "pending": false
17 | }
--------------------------------------------------------------------------------
/tests/integration/test_05_build.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 |
3 |
4 | class TestBuild:
5 | def test_get_job(self, job):
6 | assert job == job[1].project
7 |
8 | def test_console_text(self, job):
9 | assert 'true' in ''.join(job[1].console_text())
10 |
11 | def test_get_next_build(self, job):
12 | assert job[1].get_next_build().number == 2
13 | assert job[2].get_next_build() is None
14 |
15 | def test_get_previous_build(self, job):
16 | assert job[2].get_previous_build().number == 1
17 | assert job[1].get_previous_build() is None
18 |
19 | def test_coverage_report(self, job):
20 | pass
21 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/view/allview.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "hudson.model.AllView",
3 | "description": null,
4 | "jobs": [
5 | {
6 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowJob",
7 | "name": "folder/job/pipeline",
8 | "url": "http://0.0.0.0:8080/job/folder/job/pipeline/",
9 | "color": "red"
10 | },
11 | {
12 | "_class": "com.cloudbees.hudson.plugins.folder.Folder",
13 | "name": "folder",
14 | "url": "http://0.0.0.0:8080/job/folder/"
15 | }
16 | ],
17 | "name": "all",
18 | "property": [],
19 | "url": "http://http://0.0.0.0:8080/"
20 | }
--------------------------------------------------------------------------------
/tests/unit/tests_data/credential/domains.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "com.cloudbees.hudson.plugins.folder.properties.FolderCredentialsProvider$FolderCredentialsProperty$CredentialsStoreActionImpl",
3 | "domains": {
4 | "_": {
5 | "_class": "com.cloudbees.plugins.credentials.CredentialsStoreAction$DomainWrapper",
6 | "urlName": "_"
7 | },
8 | "production": {
9 | "_class": "com.cloudbees.plugins.credentials.CredentialsStoreAction$DomainWrapper",
10 | "urlName": "production"
11 | },
12 | "testing": {
13 | "_class": "com.cloudbees.plugins.credentials.CredentialsStoreAction$DomainWrapper",
14 | "urlName": "testing"
15 | }
16 | }
17 | }
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = source
9 | BUILDDIR = build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Release Python Package
2 |
3 | on:
4 | release:
5 | types: [created]
6 |
7 | jobs:
8 | deploy:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Check out code
12 | uses: actions/checkout@v3
13 |
14 | - name: Set up Python
15 | uses: actions/setup-python@v4
16 | with:
17 | python-version: '3.x'
18 |
19 | - name: Install dependencies
20 | run: |
21 | python -m pip install --upgrade pip
22 | pip install setuptools wheel twine
23 |
24 | - name: Build and publish
25 | env:
26 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
27 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
28 | run: |
29 | python setup.py sdist bdist_wheel
30 | twine upload dist/*
31 |
--------------------------------------------------------------------------------
/tests/integration/test_04_view.py:
--------------------------------------------------------------------------------
1 | class TestGlobalView:
2 | def test_get(self, jenkins):
3 | v = jenkins.views.get('global-view')
4 | assert v.name == 'global-view'
5 | assert jenkins.views.get('not exist') is None
6 |
7 | def test_iter(self, jenkins):
8 | assert len(list(jenkins.views)) == 2
9 |
10 | def test_in_exclude(self, jenkins):
11 | v = jenkins.views.get('global-view')
12 | assert v['folder'] is None
13 | v.include('folder')
14 | assert v['folder']
15 | v.exclude('folder')
16 | assert v['folder'] is None
17 |
18 |
19 | class TestFolderView:
20 | def test_get(self, folder):
21 | v = folder.views.get('folder-view')
22 | assert v.name == 'folder-view'
23 | assert folder.views.get('not exist') is None
24 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/report/coverage_result.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "io.jenkins.plugins.coverage.targets.CoverageResult",
3 | "results": {
4 | "children": [
5 | {}
6 | ],
7 | "elements": [
8 | {
9 | "denominator": 1,
10 | "name": "Report",
11 | "numerator": 1,
12 | "ratio": 100
13 | },
14 | {
15 | "denominator": 1,
16 | "name": "Directory",
17 | "numerator": 1,
18 | "ratio": 100
19 | },
20 | {
21 | "denominator": 19,
22 | "name": "File",
23 | "numerator": 18,
24 | "ratio": 94.73684
25 | },
26 | {
27 | "denominator": 866,
28 | "name": "Line",
29 | "numerator": 726,
30 | "ratio": 83.83372
31 | }
32 | ],
33 | "name": "All reports"
34 | }
35 | }
--------------------------------------------------------------------------------
/tests/integration/tests_data/args_job.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | false
5 |
6 |
7 |
8 |
9 | ARG1
10 | false
11 |
12 |
13 |
14 |
15 |
16 |
17 | true
18 |
19 |
20 | false
21 |
--------------------------------------------------------------------------------
/tests/integration/tests_data/view.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | new text
4 | false
5 | false
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | false
22 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=source
11 | set BUILDDIR=build
12 |
13 | if "%1" == "" goto help
14 |
15 | %SPHINXBUILD% >NUL 2>NUL
16 | if errorlevel 9009 (
17 | echo.
18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
19 | echo.installed, then set the SPHINXBUILD environment variable to point
20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
21 | echo.may add the Sphinx directory to PATH.
22 | echo.
23 | echo.If you don't have Sphinx installed, grab it from
24 | echo.http://sphinx-doc.org/
25 | exit /b 1
26 | )
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/docs/source/user/install.rst:
--------------------------------------------------------------------------------
1 | .. _install:
2 |
3 | Installation
4 | =============
5 |
6 | From pypi
7 | ----------
8 |
9 | The easiest (and best) way to install is through pip::
10 |
11 | $ python -m pip install api4jenkins
12 |
13 | From source
14 | -------------------
15 |
16 | Optional you can clone the public repository to install::
17 |
18 | $ git clone https://github.com/joelee2012/api4jenkins
19 | $ cd api4jenkins
20 | $ python -m pip install .
21 |
22 | Prerequisites
23 | ------------------
24 | Install following plugins for Jenkins to enable full functionality for api4jenkins:
25 | - `folder `_
26 | - `pipeline `_
27 | - `credentials `_
28 | - `next-build-number `_
29 |
30 |
31 | .. include:: ../../../HISTORY.md
--------------------------------------------------------------------------------
/tests/unit/tests_data/queue/leftitem.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "hudson.model.Queue$LeftItem",
3 | "actions": [{
4 | "_class": "hudson.model.CauseAction",
5 | "causes": [{
6 | "_class": "hudson.model.Cause$UserIdCause",
7 | "shortDescription": "Started by user joelee",
8 | "userId": "admin",
9 | "userName": "joelee"
10 | }
11 | ]
12 | }
13 | ],
14 | "blocked": false,
15 | "buildable": false,
16 | "id": 599,
17 | "inQueueSince": 1559112231788,
18 | "params": "",
19 | "stuck": false,
20 | "task": {
21 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowJob",
22 | "name": "folder/job/pipeline",
23 | "url": "http://0.0.0.0:8080/job/folder/job/pipeline/",
24 | "color": "aborted_anime"
25 | },
26 | "url": "queue/item/599/",
27 | "why": null,
28 | "cancelled": false,
29 | "executable": {
30 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
31 | "number": 54,
32 | "url": "http://0.0.0.0:8080/job/folder/job/pipeline/54/"
33 | }
34 | }
--------------------------------------------------------------------------------
/tests/unit/test_artifact.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from api4jenkins.artifact import Artifact
3 |
4 |
5 | @pytest.fixture
6 | def artifact(jenkins):
7 | raw = {
8 | "id": "n3",
9 | "name": "hello2.jar",
10 | "path": "target/hello2.jar",
11 | "url": "/job/test/1/artifact/target/hello2.jar",
12 | "size": 6
13 | }
14 | return Artifact(jenkins, raw)
15 |
16 |
17 | class TestArtifact:
18 |
19 | def test_attrs(self, artifact, jenkins):
20 | assert artifact.id == "n3"
21 | assert artifact.name == "hello2.jar"
22 | assert artifact.path == "target/hello2.jar"
23 | assert artifact.url == f'{jenkins.url}job/test/1/artifact/target/hello2.jar'
24 | assert artifact.size == 6
25 |
26 | def test_save(self, artifact, respx_mock, tmp_path):
27 | respx_mock.get(artifact.url).respond(content='abcd')
28 | filename = tmp_path / artifact.name
29 | artifact.save(filename)
30 | assert filename.exists()
31 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/queue/waitingitem.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "hudson.model.Queue$WaitingItem",
3 | "actions": [
4 | {
5 | "_class": "hudson.model.CauseAction",
6 | "causes": [
7 | {
8 | "_class": "com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritCause",
9 | "shortDescription": "Triggered by"
10 | }
11 | ]
12 | },
13 | {},
14 | {},
15 | {},
16 | {
17 | "_class": "hudson.model.ParametersAction",
18 | "parameters": []
19 | }
20 | ],
21 | "blocked": false,
22 | "buildable": false,
23 | "id": 668,
24 | "inQueueSince": 1566553108796,
25 | "params": "",
26 | "stuck": false,
27 | "task": {
28 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowJob",
29 | "name": "folder/job/pipeline",
30 | "url": "http://0.0.0.0:8080/job/folder/job/pipeline/",
31 | "color": "red"
32 | },
33 | "url": "queue/item/700/",
34 | "why": "In the quiet period. Expires in 4.6 sec",
35 | "timestamp": 1566553113796
36 | }
--------------------------------------------------------------------------------
/tests/unit/tests_data/queue/blockeditem.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "hudson.model.Queue$BlockedItem",
3 | "actions": [{
4 | "_class": "hudson.model.CauseAction",
5 | "causes": [{
6 | "_class": "hudson.model.Cause$UserIdCause",
7 | "shortDescription": "Started by user joelee",
8 | "userId": "admin",
9 | "userName": "joelee"
10 | }
11 | ]
12 | }
13 | ],
14 | "blocked": true,
15 | "buildable": false,
16 | "id": 669,
17 | "inQueueSince": 1561364267921,
18 | "params": "",
19 | "stuck": false,
20 | "task": {
21 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowJob",
22 | "name": "folder/job/pipeline",
23 | "url": "http://0.0.0.0:8080/job/folder/job/pipeline/",
24 | "color": "red_anime"
25 | },
26 | "url": "queue/item/669/",
27 | "why": "Build #168 is already in progress (ETA:N/A)",
28 | "buildableStartMilliseconds": 1561364277513
29 | }
--------------------------------------------------------------------------------
/tests/integration/test_08_plugin.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 |
4 | @pytest.fixture
5 | def plugin(jenkins):
6 | jenkins.plugins.install('nodejs', block=True)
7 | yield jenkins.plugins.get('nodejs')
8 | jenkins.plugins.uninstall('nodejs')
9 |
10 |
11 | @pytest.fixture
12 | async def async_plugin(async_jenkins):
13 | await async_jenkins.plugins.install('nodejs', block=True)
14 | yield await async_jenkins.plugins.get('nodejs')
15 | await async_jenkins.plugins.uninstall('nodejs')
16 |
17 |
18 | class TestPlugin:
19 | def test_get(self, jenkins):
20 | assert jenkins.plugins.get('git')
21 | assert jenkins.plugins.get('notxist') is None
22 |
23 | def test_install(self, plugin):
24 | assert plugin.short_name == 'nodejs'
25 |
26 |
27 | class TestAsyncPlugin:
28 | async def test_get(self, async_jenkins):
29 | assert await async_jenkins.plugins.get('git')
30 | assert await async_jenkins.plugins.get('notxist') is None
31 |
32 | async def test_install(self, async_plugin):
33 | assert await async_plugin.short_name == 'nodejs'
34 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/credential/credentials.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "com.cloudbees.plugins.credentials.CredentialsStoreAction$DomainWrapper",
3 | "credentials": [
4 | {
5 | "description": "ssh key for connect slave",
6 | "displayName": "root (ssh key for connect slave)",
7 | "fingerprint": null,
8 | "fullName": "system/_/79aa8598-912d-4475-a962-748365cc81d6",
9 | "id": "79aa8598-912d-4475-a962-748365cc81d6",
10 | "typeName": "SSH Username with private key"
11 | },
12 | {
13 | "description": "for testing",
14 | "displayName": "user1/****** (for testing)",
15 | "fingerprint": null,
16 | "fullName": "system/_/test-user",
17 | "id": "test-user",
18 | "typeName": "Username with password"
19 | }
20 | ],
21 | "description": "Credentials that should be available irrespective of domain specification to requirements matching.",
22 | "displayName": "Global credentials (unrestricted)",
23 | "fullDisplayName": "System » Global credentials (unrestricted)",
24 | "fullName": "system/_",
25 | "global": true,
26 | "urlName": "_"
27 | }
--------------------------------------------------------------------------------
/tests/unit/test_node.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 |
3 |
4 | class TestNodes:
5 |
6 | def test_get(self, jenkins):
7 | node = jenkins.nodes.get('master')
8 | assert node.url == 'http://0.0.0.0:8080/computer/(master)/'
9 | assert jenkins.nodes.get('notexist') is None
10 |
11 | def test_iter(self, jenkins):
12 | nodes = list(jenkins.nodes)
13 | assert len(nodes) == 2
14 | assert nodes[0].display_name == 'master'
15 |
16 | def test_iter_builds(self, jenkins):
17 | builds = list(jenkins.nodes.iter_builds())
18 | assert len(builds) == 2
19 | assert builds[0].url == 'http://0.0.0.0:8080/job/freestylejob/14/'
20 | assert builds[1].url == 'http://0.0.0.0:8080/job/folder/job/pipeline/53/'
21 |
22 |
23 | class TestNode:
24 |
25 | def test_iter_builds(self, jenkins):
26 | node = jenkins.nodes.get('master')
27 | builds = list(node)
28 | assert len(builds) == 2
29 | assert builds[0].url == 'http://0.0.0.0:8080/job/freestylejob/14/'
30 | assert builds[1].url == 'http://0.0.0.0:8080/job/folder/job/pipeline/53/'
31 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | skip_missing_interpreters = True
3 | envlist = style, lint, py3
4 |
5 |
6 | [testenv]
7 | deps = respx
8 | pytest-cov
9 | pytest-asyncio
10 | commands =
11 | pytest -v --cov=api4jenkins tests/unit \
12 | -o junit_family=xunit2 \
13 | --asyncio-mode=auto \
14 | --cov-report term \
15 | --cov-report html:tests/unit/htmlcov \
16 | --cov-report xml:tests/unit/coverage.xml
17 |
18 | [testenv:lint]
19 | deps = ruff
20 | commands =
21 | ruff check api4jenkins
22 |
23 | [testenv:style]
24 | deps = pycodestyle
25 | commands =
26 | pycodestyle --show-source --show-pep8 \
27 | --ignore=E501,F401,E128,E402,E731,F821 api4jenkins
28 |
29 | [testenv:integration]
30 | deps = pytest-cov
31 | pytest-asyncio
32 | pyyaml
33 | passenv = JENKINS_*
34 | commands =
35 | pytest -v --cov=api4jenkins tests/integration \
36 | --asyncio-mode=auto \
37 | --cov-report term \
38 | --cov-report html:tests/integration/htmlcov \
39 | --cov-report xml:tests/integration/coverage.xml
40 | [pytest]
41 | asyncio_default_fixture_loop_scope = session
42 | asyncio_default_test_loop_scope = session
--------------------------------------------------------------------------------
/tests/unit/tests_data/report/coverage_report.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "hudson.plugins.jacoco.report.CoverageReport",
3 | "branchCoverage": {
4 | "covered": 244,
5 | "missed": 1588,
6 | "percentage": 13,
7 | "percentageFloat": 13.318777,
8 | "total": 1832
9 | },
10 | "classCoverage": {
11 | "covered": 38,
12 | "missed": 21,
13 | "percentage": 64,
14 | "percentageFloat": 64.406784,
15 | "total": 59
16 | },
17 | "complexityScore": {
18 | "covered": 398,
19 | "missed": 1218,
20 | "percentage": 25,
21 | "percentageFloat": 24.628712,
22 | "total": 1616
23 | },
24 | "instructionCoverage": {
25 | "covered": 4205,
26 | "missed": 9220,
27 | "percentage": 31,
28 | "percentageFloat": 31.32216,
29 | "total": 13425
30 | },
31 | "lineCoverage": {
32 | "covered": 712,
33 | "missed": 562,
34 | "percentage": 56,
35 | "percentageFloat": 55.88697,
36 | "total": 1274
37 | },
38 | "methodCoverage": {
39 | "covered": 320,
40 | "missed": 380,
41 | "percentage": 46,
42 | "percentageFloat": 45.714287,
43 | "total": 700
44 | },
45 | "previousResult": {
46 | "_class": "hudson.plugins.jacoco.report.CoverageReport"
47 | }
48 | }
--------------------------------------------------------------------------------
/tests/integration/test_06_system.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | import yaml
3 |
4 | conf = '''unclassified:
5 | timestamper:
6 | allPipelines: true
7 | '''
8 |
9 |
10 | class TestSystem:
11 | def test_export_jcasc(self, jenkins):
12 | data = yaml.safe_load(jenkins.system.export_jcasc())
13 | assert data['unclassified']['timestamper']['allPipelines'] == False
14 |
15 | @pytest.mark.xfail
16 | def test_apply_jcasc(self, jenkins):
17 | jenkins.system.apply_jcasc(conf)
18 | # jenkins.system.reload_jcasc()
19 | data = yaml.safe_load(jenkins.system.export_jcasc())
20 | assert data['unclassified']['timestamper']['allPipelines']
21 |
22 |
23 | class TestAsyncSystem:
24 | async def test_export_jcasc(self, async_jenkins):
25 | data = yaml.safe_load(await async_jenkins.system.export_jcasc())
26 | assert data['unclassified']['timestamper']['allPipelines'] == False
27 |
28 | @pytest.mark.xfail
29 | async def test_apply_jcasc(self, async_jenkins):
30 | await async_jenkins.system.apply_jcasc(conf)
31 | await async_jenkins.system.reload_jcasc()
32 | data = yaml.safe_load(await async_jenkins.system.export_jcasc())
33 | assert data['unclassified']['timestamper']['allPipelines']
34 |
--------------------------------------------------------------------------------
/.github/workflows/unittest.yml:
--------------------------------------------------------------------------------
1 | name: Unit Test
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | unit-tests:
7 | runs-on: ${{ matrix.os }}
8 |
9 | strategy:
10 | fail-fast: false
11 | matrix:
12 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
13 | # os: [ubuntu-latest, macOS-latest, windows-latest]
14 | os: [ubuntu-latest]
15 |
16 | steps:
17 | - name: Check out code
18 | uses: actions/checkout@v3
19 |
20 | - name: Set up Python ${{ matrix.python-version }}
21 | uses: actions/setup-python@v4
22 | with:
23 | python-version: ${{ matrix.python-version }}
24 |
25 | - name: Install dependencies
26 | run: pip install --upgrade keyring==21.4.0 setuptools wheel twine tox
27 |
28 | - name: Run pylint & codestyle & unit tests
29 | run: tox
30 |
31 | - name: Upload coverage to Codecov
32 | if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
33 | uses: codecov/codecov-action@v3
34 | with:
35 | directory: tests/unit/
36 | files: coverage.xml
37 | flags: unittest
38 | verbose: true
39 |
40 | - name: Run twine check
41 | run: |
42 | python setup.py sdist bdist_wheel
43 | twine check dist/*
44 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/report/test_report.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "hudson.tasks.junit.TestResult",
3 | "testActions": [],
4 | "duration": 74.678,
5 | "empty": false,
6 | "failCount": 2,
7 | "passCount": 37,
8 | "skipCount": 0,
9 | "suites": [
10 | {
11 | "cases": [
12 | {
13 | "testActions": [],
14 | "age": 0,
15 | "className": "tests.integration.test_jenkins.TestJenkins",
16 | "duration": 0.645,
17 | "errorDetails": null,
18 | "errorStackTrace": null,
19 | "failedSince": 0,
20 | "name": "test_exists",
21 | "skipped": false,
22 | "skippedMessage": null,
23 | "status": "PASSED",
24 | "stderr": null,
25 | "stdout": null
26 | }
27 | ],
28 | "duration": 74.678,
29 | "enclosingBlockNames": [],
30 | "enclosingBlocks": [],
31 | "id": null,
32 | "name": "pytest",
33 | "nodeId": null,
34 | "stderr": null,
35 | "stdout": null,
36 | "timestamp": "2021-02-22T17:33:40.266004"
37 | }
38 | ]
39 | }
--------------------------------------------------------------------------------
/tests/unit/tests_data/run/freestylebuild.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "hudson.model.FreeStyleBuild",
3 | "actions": [
4 | {
5 | "_class": "hudson.model.ParametersAction",
6 | "parameters": [
7 | {
8 | "_class": "hudson.model.StringParameterValue",
9 | "name": "arg",
10 | "value": "value1"
11 | }
12 | ]
13 | },
14 | {
15 | "_class": "hudson.model.CauseAction",
16 | "causes": [
17 | {
18 | "_class": "hudson.model.Cause$UserIdCause",
19 | "shortDescription": "Started by user admin",
20 | "userId": "admin",
21 | "userName": "admin"
22 | }
23 | ]
24 | },
25 | {},
26 | {},
27 | {}
28 | ],
29 | "artifacts": [],
30 | "building": false,
31 | "description": null,
32 | "displayName": "#14",
33 | "duration": 599334,
34 | "estimatedDuration": 599335,
35 | "executor": null,
36 | "fullDisplayName": "freestylejob #14",
37 | "id": "14",
38 | "keepLog": false,
39 | "number": 14,
40 | "queueId": 151,
41 | "result": "SUCCESS",
42 | "timestamp": 1619167515987,
43 | "url": "http://0.0.0.0:8080/job/freestylejob/14/",
44 | "builtOn": "",
45 | "changeSet": {
46 | "_class": "hudson.scm.EmptyChangeLogSet",
47 | "items": [],
48 | "kind": null
49 | },
50 | "culprits": []
51 | }
--------------------------------------------------------------------------------
/tests/integration/test_03_credential.py:
--------------------------------------------------------------------------------
1 | class TestCredentials:
2 | def test_credentials_iter(self, jenkins):
3 | assert len(list(jenkins.credentials)) == 2
4 |
5 | def test_credential_get(self, jenkins):
6 | assert jenkins.credentials.global_domain is not None
7 | assert jenkins.credentials.get('not exists') is None
8 |
9 | def test_domain_get(self, jenkins):
10 | c = jenkins.credentials.global_domain['user-id']
11 | assert c.id == 'user-id'
12 | assert jenkins.credentials.global_domain['not exists'] is None
13 |
14 | def test_domain_iter(self, folder):
15 | assert len(list(folder.credentials.global_domain)) == 1
16 |
17 |
18 | class TestAsyncCredential:
19 | async def test_credentials_iter(self, async_jenkins):
20 | assert len([c async for c in async_jenkins.credentials]) == 2
21 |
22 | async def test_credential_get(self, async_jenkins):
23 | domain = await async_jenkins.credentials.global_domain
24 | assert domain is not None
25 | domain = await async_jenkins.credentials.get('not exists')
26 | assert domain is None
27 |
28 | async def test_domain_get(self, async_jenkins):
29 | domain = await async_jenkins.credentials.global_domain
30 | c = await domain['user-id']
31 | assert await c.id == 'user-id'
32 | c = await domain['not exists']
33 | assert c is None
34 |
35 | async def test_domain_iter(self, async_folder):
36 | assert len([c async for c in await async_folder.credentials.global_domain]) == 1
37 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/job/folder.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "com.cloudbees.hudson.plugins.folder.Folder",
3 | "actions": [
4 | {},
5 | {},
6 | {
7 | "_class": "com.cloudbees.plugins.credentials.ViewCredentialsAction"
8 | }
9 | ],
10 | "description": "",
11 | "displayName": "folder",
12 | "displayNameOrNull": null,
13 | "fullDisplayName": "folder",
14 | "fullName": "folder",
15 | "name": "folder",
16 | "url": "http://0.0.0.0:8080/job/folder/",
17 | "jobs": [
18 | {
19 | "_class": "hudson.model.FreeStyleProject",
20 | "name": "Level2_FreeStyleProject",
21 | "url": "http://0.0.0.0:8080/job/folder/job/Level2_FreeStyleProject/",
22 | "color": "blue"
23 | },
24 | {
25 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowJob",
26 | "name": "pipeline",
27 | "url": "http://0.0.0.0:8080/job/folder/job/pipeline/",
28 | "color": "red"
29 | },
30 | {
31 | "_class": "hudson.matrix.MatrixProject",
32 | "name": "Level2_MatrixProject",
33 | "url": "http://0.0.0.0:8080/job/folder/job/Level2_MatrixProject/",
34 | "color": "blue"
35 | },
36 | {
37 | "_class": "com.cloudbees.hudson.plugins.folder.Folder",
38 | "name": "folder2",
39 | "url": "http://0.0.0.0:8080/job/folder/job/folder2/"
40 | }
41 | ],
42 | "primaryView": {
43 | "_class": "hudson.model.AllView",
44 | "name": "All",
45 | "url": "http://0.0.0.0:8080/job/folder/"
46 | },
47 | "views": [
48 | {
49 | "_class": "hudson.model.AllView",
50 | "name": "All",
51 | "url": "http://0.0.0.0:8080/job/folder/"
52 | }
53 | ]
54 | }
--------------------------------------------------------------------------------
/api4jenkins/artifact.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | import anyio
3 |
4 | from .item import AsyncItem, Item
5 | from .mix import RawJsonMixIn
6 |
7 |
8 | class Artifact(RawJsonMixIn, Item):
9 |
10 | def __init__(self, jenkins, raw):
11 | super().__init__(
12 | jenkins, f"{jenkins.url}{raw['url'][1:]}")
13 | # remove trailing slash
14 | self.url = self.url[:-1]
15 | self.raw = raw
16 | self.raw['_class'] = 'Artifact'
17 |
18 | def save(self, filename=None):
19 | if not filename:
20 | filename = self.name
21 | with self.handle_stream('GET', '') as resp:
22 | save_response_to(resp, filename)
23 |
24 |
25 | def save_response_to(response, filename):
26 | with open(filename, 'wb') as fd:
27 | for chunk in response.iter_bytes(chunk_size=128):
28 | fd.write(chunk)
29 |
30 |
31 | class AsyncArtifact(RawJsonMixIn, AsyncItem):
32 |
33 | def __init__(self, jenkins, raw):
34 | super().__init__(
35 | jenkins, f"{jenkins.url}{raw['url'][1:]}")
36 | # remove trailing slash
37 | self.url = self.url[:-1]
38 | self.raw = raw
39 | self.raw['_class'] = 'Artifact'
40 |
41 | async def save(self, filename=None):
42 | if not filename:
43 | filename = self.name
44 | async with self.handle_stream('GET', '') as resp:
45 | await save_response_to(resp, filename)
46 |
47 |
48 | async def async_save_response_to(response, filename):
49 | async with anyio.wrap_file(open(filename, 'wb')) as fd:
50 | async for chunk in response.aiter_bytes(chunk_size=128):
51 | await fd.write(chunk)
52 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/run/workflowrun.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
3 | "actions": [
4 | {
5 | "_class": "hudson.model.CauseAction",
6 | "causes": [
7 | {
8 | "_class": "hudson.model.Cause$UserIdCause",
9 | "shortDescription": "Started by user admin",
10 | "userId": "admin",
11 | "userName": "admin"
12 | },
13 | {
14 | "_class": "org.jenkinsci.plugins.workflow.cps.replay.ReplayCause",
15 | "shortDescription": "Replayed #1"
16 | }
17 | ]
18 | },
19 | {
20 | "_class": "hudson.model.ParametersAction",
21 | "parameters": [
22 | {
23 | "_class": "hudson.model.StringParameterValue",
24 | "name": "parameter1",
25 | "value": "value1"
26 | },
27 | {
28 | "_class": "hudson.model.StringParameterValue",
29 | "name": "parameter2",
30 | "value": "value2"
31 | }
32 | ]
33 | }
34 | ],
35 | "artifacts": [],
36 | "building": false,
37 | "description": null,
38 | "displayName": "#52",
39 | "duration": 324447,
40 | "estimatedDuration": 197743,
41 | "executor": null,
42 | "fullDisplayName": "folder/job/pipeline #52",
43 | "id": "52",
44 | "keepLog": false,
45 | "number": 52,
46 | "queueId": 668,
47 | "result": "FAILURE",
48 | "timestamp": 1559281453240,
49 | "url": "http://0.0.0.0:8080/job/folder/job/pipeline/52/",
50 | "changeSets": [],
51 | "culprits": [],
52 | "nextBuild": null,
53 | "previousBuild": {
54 | "number": 51,
55 | "url": "http://0.0.0.0:8080/job/folder/job/pipeline/51/"
56 | }
57 | }
--------------------------------------------------------------------------------
/tests/unit/tests_data/queue/queue.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "hudson.model.Queue",
3 | "discoverableItems": [],
4 | "items": [{
5 | "_class": "hudson.model.Queue$BlockedItem",
6 | "actions": [{
7 | "_class": "hudson.model.CauseAction",
8 | "causes": [{
9 | "_class": "hudson.model.Cause$UserIdCause",
10 | "shortDescription": "Started by user joelee",
11 | "userId": "admin",
12 | "userName": "joelee"
13 | }
14 | ]
15 | }
16 | ],
17 | "blocked": true,
18 | "buildable": false,
19 | "id": 669,
20 | "inQueueSince": 1561364267921,
21 | "params": "",
22 | "stuck": false,
23 | "task": {
24 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowJob",
25 | "name": "folder/job/pipeline",
26 | "url": "http://0.0.0.0:8080/job/folder/job/pipeline/",
27 | "color": "red_anime"
28 | },
29 | "url": "queue/item/669/",
30 | "why": "Build #168 is already in progress (ETA:N/A)",
31 | "buildableStartMilliseconds": 1561364277513
32 | }, {
33 | "_class": "hudson.model.Queue$BuildableItem",
34 | "actions": [],
35 | "blocked": false,
36 | "buildable": true,
37 | "id": 668,
38 | "inQueueSince": 1561364099799,
39 | "params": "",
40 | "stuck": true,
41 | "task": {
42 | "_class": "org.jenkinsci.plugins.workflow.support.steps.ExecutorStepExecution$PlaceholderTask"
43 | },
44 | "url": "queue/item/668/",
45 | "why": "There are no nodes with the label ‘xxxx’",
46 | "buildableStartMilliseconds": 1561364099799,
47 | "pending": false
48 | }
49 | ]
50 | }
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | import os
4 |
5 | from setuptools import setup
6 |
7 | here = os.path.abspath(os.path.dirname(__file__))
8 | about = {}
9 | with open(os.path.join(here, 'api4jenkins', '__version__.py')) as f:
10 | exec(f.read(), about)
11 |
12 | with open('README.md') as f:
13 | readme = f.read()
14 |
15 | requires = [
16 | 'httpx >= 0.24.1'
17 | ]
18 |
19 | setup(
20 | name=about['__title__'],
21 | version=about['__version__'],
22 | description=about['__description__'],
23 | long_description=readme,
24 | long_description_content_type='text/markdown',
25 | url=about['__url__'],
26 | author=about['__author__'],
27 | author_email=about['__author_email__'],
28 | packages=['api4jenkins'],
29 | package_data={'': ['LICENSE']},
30 | python_requires='>=3.8',
31 | install_requires=requires,
32 | license=about['__license__'],
33 | keywords=["RESTAPI", "Jenkins"],
34 | classifiers=[
35 | 'Development Status :: 5 - Production/Stable',
36 | 'Intended Audience :: Developers',
37 | 'Intended Audience :: System Administrators',
38 | 'License :: OSI Approved :: Apache Software License',
39 | 'Operating System :: OS Independent',
40 | 'Programming Language :: Python',
41 | 'Programming Language :: Python :: 3',
42 | 'Programming Language :: Python :: 3.8',
43 | 'Programming Language :: Python :: 3.9',
44 | 'Programming Language :: Python :: 3.10',
45 | 'Programming Language :: Python :: 3.11',
46 | 'Programming Language :: Python :: 3.12',
47 | 'Topic :: Utilities',
48 | ],
49 | project_urls={
50 | 'Documentation': about['__documentation__'],
51 | 'Source': about['__url__'],
52 | },
53 | )
54 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/report/coverage_trends.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "io.jenkins.plugins.coverage.targets.CoverageTrendTree",
3 | "children": [
4 | {
5 | "children": [
6 | {}
7 | ],
8 | "name": "istanbul: cov.xml",
9 | "trends": [
10 | {},
11 | {}
12 | ]
13 | }
14 | ],
15 | "name": "All reports",
16 | "trends": [
17 | {
18 | "buildName": "#5",
19 | "elements": [
20 | {
21 | "denominator": 1,
22 | "name": "Report",
23 | "numerator": 1,
24 | "ratio": 100
25 | },
26 | {
27 | "denominator": 1,
28 | "name": "Directory",
29 | "numerator": 1,
30 | "ratio": 100
31 | },
32 | {
33 | "denominator": 19,
34 | "name": "File",
35 | "numerator": 18,
36 | "ratio": 94.73684
37 | },
38 | {
39 | "denominator": 866,
40 | "name": "Line",
41 | "numerator": 726,
42 | "ratio": 83.83372
43 | }
44 | ]
45 | },
46 | {
47 | "buildName": "#3",
48 | "elements": [
49 | {
50 | "denominator": 1,
51 | "name": "Report",
52 | "numerator": 1,
53 | "ratio": 100
54 | },
55 | {
56 | "denominator": 1,
57 | "name": "Directory",
58 | "numerator": 1,
59 | "ratio": 100
60 | },
61 | {
62 | "denominator": 19,
63 | "name": "File",
64 | "numerator": 18,
65 | "ratio": 94.73684
66 | },
67 | {
68 | "denominator": 866,
69 | "name": "Line",
70 | "numerator": 726,
71 | "ratio": 83.83372
72 | }
73 | ]
74 | }
75 | ]
76 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 | MANIFEST
27 | docs/build/
28 | docs/source/_build/
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | .hypothesis/
50 | .pytest_cache/
51 |
52 | # Translations
53 | *.mo
54 | *.pot
55 |
56 | # Django stuff:
57 | *.log
58 | local_settings.py
59 | db.sqlite3
60 |
61 | # Flask stuff:
62 | instance/
63 | .webassets-cache
64 |
65 | # Scrapy stuff:
66 | .scrapy
67 |
68 | # Sphinx documentation
69 | docs/_build/
70 |
71 | # PyBuilder
72 | target/
73 |
74 | # Jupyter Notebook
75 | .ipynb_checkpoints
76 |
77 | # pyenv
78 | .python-version
79 |
80 | # celery beat schedule file
81 | celerybeat-schedule
82 |
83 | # SageMath parsed files
84 | *.sage.py
85 |
86 | # Environments
87 | .env
88 | .venv
89 | env/
90 | venv/
91 | ENV/
92 | env.bak/
93 | venv.bak/
94 |
95 | # Spyder project settings
96 | .spyderproject
97 | .spyproject
98 |
99 | # Rope project settings
100 | .ropeproject
101 |
102 | # mkdocs documentation
103 | /site
104 |
105 | # mypy
106 | .mypy_cache/
107 |
108 | # mac os related
109 | .DS_Store
--------------------------------------------------------------------------------
/docs/source/user/api.rst:
--------------------------------------------------------------------------------
1 | :title: API reference
2 |
3 | API reference
4 | =============
5 |
6 | .. automodule:: api4jenkins
7 | :members:
8 | :undoc-members:
9 | :inherited-members:
10 |
11 | .. automodule:: api4jenkins.job
12 | :members:
13 | :undoc-members:
14 | :inherited-members:
15 |
16 | .. automodule:: api4jenkins.build
17 | :members:
18 | :undoc-members:
19 | :inherited-members:
20 |
21 | .. automodule:: api4jenkins.input
22 | :members:
23 | :undoc-members:
24 | :inherited-members:
25 |
26 | .. automodule:: api4jenkins.queue
27 | :members:
28 | :undoc-members:
29 | :inherited-members:
30 |
31 | .. automodule:: api4jenkins.credential
32 | :members:
33 | :undoc-members:
34 | :inherited-members:
35 |
36 | .. automodule:: api4jenkins.node
37 | :members:
38 | :undoc-members:
39 | :inherited-members:
40 |
41 | .. automodule:: api4jenkins.plugin
42 | :members:
43 | :undoc-members:
44 | :inherited-members:
45 |
46 | .. automodule:: api4jenkins.view
47 | :members:
48 | :undoc-members:
49 | :inherited-members:
50 |
51 | .. automodule:: api4jenkins.system
52 | :members:
53 | :undoc-members:
54 | :inherited-members:
55 |
56 | .. automodule:: api4jenkins.user
57 | :members:
58 | :undoc-members:
59 | :inherited-members:
60 |
61 | .. automodule:: api4jenkins.report
62 | :members:
63 | :undoc-members:
64 | :inherited-members:
65 |
66 | .. automodule:: api4jenkins.item
67 | :members:
68 | :undoc-members:
69 | :inherited-members:
70 |
71 | .. automodule:: api4jenkins.mix
72 | :members:
73 | :undoc-members:
74 | :inherited-members:
75 |
76 | .. automodule:: api4jenkins.http
77 | :members:
78 | :undoc-members:
79 | :inherited-members:
80 |
81 | .. automodule:: api4jenkins.exceptions
82 | :members:
83 | :undoc-members:
84 | :inherited-members:
--------------------------------------------------------------------------------
/tests/integration/test_07_node.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 |
4 | @pytest.fixture
5 | def node(jenkins):
6 | return jenkins.nodes.get('Built-In Node')
7 |
8 |
9 | @pytest.fixture
10 | async def anode(async_jenkins):
11 | return await async_jenkins.nodes.get('Built-In Node')
12 |
13 |
14 | class TestNode:
15 | def test_get(self, node):
16 | assert node.display_name == 'Built-In Node'
17 |
18 | def test_iter(self, jenkins):
19 | assert len(list(jenkins.nodes)) == 1
20 |
21 | def test_filter_node_by_label(self, jenkins):
22 | assert len(list(jenkins.nodes.filter_node_by_label('built-in'))) == 1
23 |
24 | def test_filter_node_by_status(self, jenkins):
25 | assert len(list(jenkins.nodes.filter_node_by_status(online=True))) == 1
26 |
27 | def test_enable_disable(self, node):
28 | assert node.offline == False
29 | node.disable()
30 | assert node.offline
31 | node.enable()
32 |
33 | def test_iter_build_on_node(self, node):
34 | assert not list(node)
35 |
36 |
37 | class TestAsyncNode:
38 | async def test_get(self, anode):
39 | assert await anode.display_name == 'Built-In Node'
40 |
41 | async def test_iter(self, async_jenkins):
42 | assert len([n async for n in async_jenkins.nodes]) == 1
43 |
44 | async def test_filter_node_by_label(self, async_jenkins):
45 | assert len([n async for n in async_jenkins.nodes.filter_node_by_label('built-in')]) == 1
46 |
47 | async def test_filter_node_by_status(self, async_jenkins):
48 | assert len([n async for n in async_jenkins.nodes.filter_node_by_status(online=True)]) == 1
49 |
50 | async def test_enable_disable(self, anode):
51 | assert await anode.offline == False
52 | await anode.disable()
53 | assert await anode.offline
54 | await anode.enable()
55 |
56 | async def test_iter_build_on_node(self, anode):
57 | assert not [b async for b in anode]
58 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/jenkins/jenkins.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "hudson.model.Hudson",
3 | "assignedLabels": [
4 | {
5 | "name": "master"
6 | }
7 | ],
8 | "mode": "EXCLUSIVE",
9 | "nodeDescription": "the master Jenkins node",
10 | "nodeName": "",
11 | "numExecutors": 1,
12 | "description": "Jenkins configured automatically by Jenkins Configuration as Code Plugin\n\n",
13 | "jobs": [
14 | {
15 | "_class": "com.cloudbees.hudson.plugins.folder.Folder",
16 | "name": "folder",
17 | "url": "http://0.0.0.0:8080/job/folder/",
18 | "jobs": [
19 | {
20 | "_class": "hudson.model.FreeStyleProject",
21 | "name": "freestylejob",
22 | "url": "http://0.0.0.0:8080/job/folder/job/freestylejob/",
23 | "color": "blue"
24 | },
25 | {
26 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowJob",
27 | "name": "pipeline",
28 | "url": "http://0.0.0.0:8080/job/folder/job/pipeline/",
29 | "color": "red"
30 | },
31 | {
32 | "_class": "hudson.matrix.MatrixProject",
33 | "name": "maxtrixjob",
34 | "url": "http://0.0.0.0:8080/job/folder/job/maxtrixjob/",
35 | "color": "blue"
36 | },
37 | {
38 | "_class": "com.cloudbees.hudson.plugins.folder.Folder",
39 | "name": "folder2",
40 | "url": "http://0.0.0.0:8080/job/folder/job/folder2/"
41 | }
42 | ]
43 | }
44 | ],
45 | "overallLoad": {},
46 | "primaryView": {
47 | "_class": "hudson.model.AllView",
48 | "name": "all",
49 | "url": "http://0.0.0.0:8080/"
50 | },
51 | "quietingDown": false,
52 | "slaveAgentPort": 50000,
53 | "unlabeledLoad": {
54 | "_class": "jenkins.model.UnlabeledLoadStatistics"
55 | },
56 | "useCrumbs": false,
57 | "useSecurity": true,
58 | "views": [
59 | {
60 | "_class": "hudson.model.AllView",
61 | "name": "all",
62 | "url": "http://0.0.0.0:8080/"
63 | }
64 | ]
65 | }
--------------------------------------------------------------------------------
/.github/workflows/integration.yml:
--------------------------------------------------------------------------------
1 | name: Integration Tests
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | integration-tests:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - name: Check out code
10 | uses: actions/checkout@v3
11 |
12 | - name: Set up Python
13 | uses: actions/setup-python@v4
14 | with:
15 | python-version: "3.x"
16 |
17 | - name: Install dependencies
18 | run: pip install --upgrade keyring==21.4.0 setuptools wheel twine coveralls tox
19 |
20 | - name: Start Jenkins
21 | id: start-jenkins
22 | shell: bash
23 | run: |
24 | set -e
25 | sudo docker run -dt --rm --name jenkins-master joelee2012/standalone-jenkins:latest
26 | echo 'Waiting for Jenkins to start...'
27 | until sudo docker logs jenkins-master | grep -q 'Jenkins is fully up and running'; do
28 | sleep 1
29 | done
30 | ip=$(sudo docker inspect --format='{{.NetworkSettings.IPAddress}}' jenkins-master)
31 | password=$(sudo docker exec jenkins-master cat /var/jenkins_home/secrets/initialAdminPassword)
32 | version=$(sudo docker exec jenkins-master sh -c 'echo "$JENKINS_VERSION"')
33 | port=8080
34 | echo "url=http://${ip}:${port}/" >> $GITHUB_OUTPUT
35 | echo "user=admin" >> $GITHUB_OUTPUT
36 | echo "password=${password}" >> $GITHUB_OUTPUT
37 | echo "version=${version}" >> $GITHUB_OUTPUT
38 |
39 | - name: Run integration tests
40 | env:
41 | JENKINS_URL: ${{ steps.start-jenkins.outputs.url }}
42 | JENKINS_USER: ${{ steps.start-jenkins.outputs.user }}
43 | JENKINS_PASSWORD: ${{ steps.start-jenkins.outputs.password }}
44 | JENKINS_VERSION: ${{ steps.start-jenkins.outputs.version }}
45 | run: tox -e integration
46 |
47 | - name: Upload coverage to Codecov
48 | uses: codecov/codecov-action@v3
49 | with:
50 | directory: tests/integration/
51 | files: coverage.xml
52 | flags: integration
53 | verbose: true
54 |
--------------------------------------------------------------------------------
/api4jenkins/http.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | import inspect
3 | import logging
4 |
5 | from httpx import (AsyncClient, AsyncHTTPTransport, Client, HTTPTransport,
6 | Request, Response)
7 |
8 | from .exceptions import AuthenticationError, BadRequestError, ItemNotFoundError
9 |
10 | logger = logging.getLogger(__name__)
11 |
12 |
13 | def log_request(request: Request) -> None:
14 | logger.debug(
15 | f"Send Request: {request.method} {request.url} - Waiting for response")
16 |
17 |
18 | def check_response(response: Response) -> None:
19 | if response.is_success or response.is_redirect:
20 | return
21 | if response.status_code == 404:
22 | raise ItemNotFoundError(f'404 Not found {response.url}')
23 | if response.status_code == 401:
24 | raise AuthenticationError(
25 | f'401 Invalid authorization for {response.url}')
26 | if response.status_code == 403:
27 | raise PermissionError(f'403 No permission to access {response.url}')
28 | if response.status_code == 400:
29 | raise BadRequestError(f'400 {response.headers["X-Error"]}')
30 | response.raise_for_status()
31 |
32 |
33 | def _new_transport(obj, kwargs):
34 | init_args = {
35 | arg: kwargs.pop(arg)
36 | for arg in inspect.getfullargspec(obj).args
37 | if arg in kwargs
38 | }
39 | return obj(**init_args)
40 |
41 |
42 | def new_http_client(**kwargs) -> Client:
43 | trans = _new_transport(HTTPTransport, kwargs)
44 | return Client(
45 | transport=trans,
46 | **kwargs,
47 | event_hooks={'request': [log_request], 'response': [check_response]}
48 | )
49 |
50 |
51 | async def async_log_request(request: Request) -> None:
52 | logger.debug(
53 | f"Send Request: {request.method} {request.url} - Waiting for response")
54 |
55 |
56 | async def async_check_response(response: Response) -> None:
57 | check_response(response)
58 |
59 |
60 | def new_async_http_client(**kwargs) -> AsyncClient:
61 | trans = _new_transport(AsyncHTTPTransport, kwargs)
62 | return AsyncClient(
63 | transport=trans,
64 | **kwargs,
65 | event_hooks={'request': [async_log_request],
66 | 'response': [async_check_response]}
67 | )
68 |
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # This file only contains a selection of the most common options. For a full
4 | # list see the documentation:
5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
6 |
7 | # -- Path setup --------------------------------------------------------------
8 |
9 | # If extensions (or modules to document with autodoc) are in another directory,
10 | # add these directories to sys.path here. If the directory is relative to the
11 | # documentation root, use os.path.abspath to make it absolute, like shown here.
12 | #
13 |
14 | import sys
15 | from pathlib import Path
16 |
17 | repo_root = Path(__file__).parent.parent.parent
18 | sys.path.insert(0, str(repo_root))
19 | # -- Project information -----------------------------------------------------
20 |
21 | about = {}
22 | with open(repo_root.joinpath('api4jenkins', '__version__.py')) as f:
23 | exec(f.read(), about)
24 |
25 | project = about['__title__']
26 | copyright = about['__copyright__']
27 | author = about['__author__']
28 |
29 | # The full version, including alpha/beta/rc tags
30 | release = about['__version__']
31 |
32 | version = about['__version__']
33 | # -- General configuration ---------------------------------------------------
34 |
35 | # Add any Sphinx extension module names here, as strings. They can be
36 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
37 | # ones.
38 | extensions = ["sphinx.ext.autodoc",
39 | "sphinx.ext.intersphinx",
40 | "sphinx.ext.todo",
41 | "sphinx.ext.viewcode",]
42 |
43 | # Add any paths that contain templates here, relative to this directory.
44 | templates_path = ['_templates']
45 |
46 | # List of patterns, relative to source directory, that match files and
47 | # directories to ignore when looking for source files.
48 | # This pattern also affects html_static_path and html_extra_path.
49 | exclude_patterns = []
50 |
51 |
52 | # -- Options for HTML output -------------------------------------------------
53 |
54 | # The theme to use for HTML and HTML Help pages. See the documentation for
55 | # a list of builtin themes.
56 | #
57 | html_theme = 'alabaster'
58 |
59 | # Add any paths that contain custom static files (such as style sheets) here,
60 | # relative to this directory. They are copied after the builtin static files,
61 | # so a file named "default.css" will overwrite the builtin "default.css".
62 | # html_static_path = ['_static']
63 |
64 | master_doc = 'index'
65 |
--------------------------------------------------------------------------------
/api4jenkins/user.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | from collections import namedtuple
3 |
4 | from .item import AsyncItem, Item
5 | from .mix import (AsyncDeletionMixIn, AsyncDescriptionMixIn,
6 | DeletionMixIn, DescriptionMixIn)
7 |
8 | user_tree = 'users[user[id,absoluteUrl,fullName]]'
9 | new_token_url = 'descriptorByName/jenkins.security.ApiTokenProperty/generateNewToken'
10 | revoke_token_url = 'descriptorByName/jenkins.security.ApiTokenProperty/revoke'
11 |
12 |
13 | class Users(Item):
14 |
15 | def __iter__(self):
16 | for user in self.api_json(tree=user_tree)['users']:
17 | yield User(self.jenkins, user['user']['absoluteUrl'])
18 |
19 | def get(self, name):
20 | for user in self.api_json(tree=user_tree)['users']:
21 | if name in [user['user']['id'], user['user']['fullName']]:
22 | return User(self.jenkins, user['user']['absoluteUrl'])
23 | return None
24 |
25 |
26 | ApiToken = namedtuple('ApiToken', ['name', 'uuid', 'value'])
27 |
28 |
29 | class User(Item, DeletionMixIn, DescriptionMixIn):
30 |
31 | def generate_token(self, name=''):
32 | data = self.handle_req('POST', new_token_url,
33 | params={'newTokenName': name}).json()['data']
34 | return ApiToken(data['tokenName'], data['tokenUuid'], data['tokenValue'])
35 |
36 | def revoke_token(self, uuid):
37 | return self.handle_req('POST', revoke_token_url, params={'tokenUuid': uuid})
38 |
39 | # async class
40 |
41 |
42 | class AsyncUsers(AsyncItem):
43 |
44 | async def __aiter__(self):
45 | for user in (await self.api_json(tree=user_tree))['users']:
46 | yield AsyncUser(self.jenkins, user['user']['absoluteUrl'])
47 |
48 | async def get(self, name):
49 | for user in (await self.api_json(tree=user_tree))['users']:
50 | if name in [user['user']['id'], user['user']['fullName']]:
51 | return AsyncUser(self.jenkins, user['user']['absoluteUrl'])
52 | return None
53 |
54 |
55 | class AsyncUser(AsyncItem, AsyncDeletionMixIn, AsyncDescriptionMixIn):
56 |
57 | async def generate_token(self, name=''):
58 | data = (await self.handle_req('POST', new_token_url,
59 | params={'newTokenName': name})).json()['data']
60 | return ApiToken(data['tokenName'], data['tokenUuid'], data['tokenValue'])
61 |
62 | async def revoke_token(self, uuid):
63 | return await self.handle_req('POST', revoke_token_url, params={'tokenUuid': uuid})
64 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/job/mbranch.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "org.jenkinsci.plugins.workflow.multibranch.WorkflowMultiBranchProject",
3 | "actions": [
4 | {},
5 | {},
6 | {},
7 | {
8 | "_class": "org.jenkinsci.plugins.github_branch_source.GitHubRepoMetadataAction"
9 | },
10 | {
11 | "_class": "jenkins.scm.api.metadata.ObjectMetadataAction"
12 | },
13 | {},
14 | {},
15 | {},
16 | {},
17 | {},
18 | {},
19 | {
20 | "_class": "com.cloudbees.plugins.credentials.ViewCredentialsAction"
21 | }
22 | ],
23 | "description": null,
24 | "displayName": "Level1_multibranchjob",
25 | "displayNameOrNull": null,
26 | "fullDisplayName": "Level1_multibranchjob",
27 | "fullName": "Level1_multibranchjob",
28 | "name": "Level1_multibranchjob",
29 | "url": "http://0.0.0.0:8080/job/Level1_multibranchjob/",
30 | "healthReport": [
31 | {
32 | "description": "Worst health: Level1_multibranchjob » feature/github-commands: Build stability: All recent builds failed.",
33 | "iconClassName": "icon-health-00to19",
34 | "iconUrl": "health-00to19.png",
35 | "score": 0
36 | }
37 | ],
38 | "jobs": [
39 | {
40 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowJob",
41 | "name": "feature%2Fgithub-commands",
42 | "url": "http://0.0.0.0:8080/job/Level1_multibranchjob/job/branch1/",
43 | "color": "red"
44 | },
45 | {
46 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowJob",
47 | "name": "master",
48 | "url": "http://0.0.0.0:8080/job/Level1_multibranchjob/job/master/",
49 | "color": "red"
50 | }
51 | ],
52 | "primaryView": {
53 | "_class": "jenkins.branch.MultiBranchProjectViewHolder$ViewImpl",
54 | "name": "default",
55 | "url": "http://0.0.0.0:8080/job/Level1_multibranchjob/"
56 | },
57 | "views": [
58 | {
59 | "_class": "jenkins.branch.MultiBranchProjectViewHolder$ViewImpl",
60 | "name": "change-requests",
61 | "url": "http://0.0.0.0:8080/job/Level1_multibranchjob/view/change-requests/"
62 | },
63 | {
64 | "_class": "jenkins.branch.MultiBranchProjectViewHolder$ViewImpl",
65 | "name": "default",
66 | "url": "http://0.0.0.0:8080/job/Level1_multibranchjob/"
67 | }
68 | ],
69 | "sources": [
70 | {}
71 | ]
72 | }
--------------------------------------------------------------------------------
/tests/unit/test_view.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | import pytest
3 | from api4jenkins.view import AllView, AsyncAllView
4 |
5 |
6 | class TestViews:
7 |
8 | def test_get(self, jenkins):
9 | view = jenkins.views.get('all')
10 | assert isinstance(view, AllView)
11 | assert view.url == jenkins.url + 'view/all/'
12 | assert jenkins.views.get('not exist') is None
13 |
14 | def test_views(self, jenkins):
15 | assert len(list(jenkins.views)) == 1
16 |
17 |
18 | class TestView:
19 |
20 | def test_get_job(self, view):
21 | job_in_view = view['folder']
22 | job = view.jenkins.get_job('folder')
23 | assert job_in_view == job
24 | assert view['not exist'] is None
25 |
26 | def test_iter(self, view):
27 | assert len(list(view)) == 2
28 |
29 | @pytest.mark.parametrize('action, entry', [('include', 'addJobToView'),
30 | ('exclude', 'removeJobFromView')])
31 | def test_include_exclude(self, view, respx_mock, action, entry):
32 | req_url = f'{view.url}{entry}?name=folder1'
33 | respx_mock.post(req_url).respond(json={'name': 'folder1'})
34 | getattr(view, action)('folder1')
35 | assert respx_mock.calls[0].request.url == req_url
36 |
37 |
38 | class TestAsyncViews:
39 |
40 | async def test_get(self, async_jenkins):
41 | view = await async_jenkins.views['all']
42 | assert isinstance(view, AsyncAllView)
43 | assert view.url == f'{async_jenkins.url}view/all/'
44 | assert await async_jenkins.views['not exist'] is None
45 |
46 | async def test_views(self, async_jenkins):
47 | assert len([view async for view in async_jenkins.views]) == 1
48 |
49 |
50 | class TestAsyncView:
51 |
52 | async def test_get_job(self, async_view, async_folder):
53 | job_in_view = await async_view['folder']
54 | assert job_in_view == async_folder
55 | assert await async_view['not exist'] is None
56 |
57 | async def test_iter(self, async_view):
58 | assert len([job async for job in async_view]) == 2
59 |
60 | @pytest.mark.parametrize('action, entry', [('include', 'addJobToView'),
61 | ('exclude', 'removeJobFromView')])
62 | async def test_include_exclude(self, async_view, respx_mock, action, entry):
63 | req_url = f'{async_view.url}{entry}?name=folder1'
64 | respx_mock.post(req_url).respond(json={'name': 'folder1'})
65 | await getattr(async_view, action)('folder1')
66 | assert respx_mock.calls[0].request.url == req_url
67 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [master, main, 1.x]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [master, main, 1.x]
20 | schedule:
21 | - cron: '0 23 * * 0'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 |
28 | strategy:
29 | fail-fast: false
30 | matrix:
31 | language: ['python']
32 | # CodeQL supports [ $supported-codeql-languages ]
33 | # Learn more:
34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
35 |
36 | steps:
37 | - name: Checkout repository
38 | uses: actions/checkout@v2
39 |
40 | # Initializes the CodeQL tools for scanning.
41 | - name: Initialize CodeQL
42 | uses: github/codeql-action/init@v1
43 | with:
44 | languages: ${{ matrix.language }}
45 | # If you wish to specify custom queries, you can do so here or in a config file.
46 | # By default, queries listed here will override any specified in a config file.
47 | # Prefix the list here with "+" to use these queries and those in the config file.
48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
49 |
50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
51 | # If this step fails, then you should remove it and run the build manually (see below)
52 | - name: Autobuild
53 | uses: github/codeql-action/autobuild@v1
54 |
55 | # ℹ️ Command-line programs to run using the OS shell.
56 | # 📚 https://git.io/JvXDl
57 |
58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
59 | # and modify them (or add more) to build your code if your project
60 | # uses a compiled language
61 |
62 | #- run: |
63 | # make bootstrap
64 | # make release
65 |
66 | - name: Perform CodeQL Analysis
67 | uses: github/codeql-action/analyze@v1
68 |
--------------------------------------------------------------------------------
/api4jenkins/input.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | import json
3 |
4 | from .item import AsyncItem, Item
5 | from .mix import AsyncRawJsonMixIn, RawJsonMixIn
6 |
7 |
8 | class PendingInputAction(RawJsonMixIn, Item):
9 | ''' this class implement functionality to process
10 | `input step `_
11 | '''
12 |
13 | def __init__(self, jenkins, raw):
14 | super().__init__(
15 | jenkins, f"{jenkins.url}{raw['abortUrl'].rstrip('abort').lstrip('/')}")
16 | self.raw = raw
17 | self.raw['_class'] = 'PendingInputAction'
18 |
19 | def abort(self):
20 | '''submit `input step `_'''
21 | return self.handle_req('POST', 'abort')
22 |
23 | def submit(self, **params):
24 | '''submit `input step `_
25 |
26 | - if input requires parameters:
27 | - if submit without parameters, it will use default value of parameters
28 | - if submit with wrong parameters, exception raised
29 | - if input does not requires parameters, but submit with parameters, exception raised
30 | '''
31 | if params:
32 | data = _make_input_params(self.raw, **params)
33 | return self.handle_req('POST', 'submit', data=data)
34 | return self.handle_req('POST', 'proceedEmpty')
35 |
36 |
37 | def _make_input_params(api_json, **params):
38 | input_args = [input['name'] for input in api_json['inputs']]
39 | params_keys = list(params.keys())
40 | if not input_args:
41 | raise TypeError(f'input takes 0 argument, but got {params_keys}')
42 | if any(k not in input_args for k in params_keys):
43 | raise TypeError(
44 | f'input takes arguments: {input_args}, but got {params_keys}')
45 | params = [{'name': k, 'value': v} for k, v in params.items()]
46 | return {'proceed': api_json['proceedText'],
47 | 'json': json.dumps({'parameter': params})}
48 |
49 |
50 | class AsyncPendingInputAction(AsyncRawJsonMixIn, AsyncItem):
51 | def __init__(self, jenkins, raw):
52 | super().__init__(
53 | jenkins, f"{jenkins.url}{raw['abortUrl'].rstrip('abort').lstrip('/')}")
54 | self.raw = raw
55 | self.raw['_class'] = 'AsyncPendingInputAction'
56 |
57 | async def abort(self):
58 | return await self.handle_req('POST', 'abort')
59 |
60 | async def submit(self, **params):
61 | if params:
62 | data = _make_input_params(self.raw, **params)
63 | return await self.handle_req('POST', 'submit', data=data)
64 | return await self.handle_req('POST', 'proceedEmpty')
65 |
--------------------------------------------------------------------------------
/api4jenkins/system.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | import json
3 | from functools import partial
4 |
5 | from .item import AsyncItem, Item, snake
6 | from .mix import AsyncRunScriptMixIn, RunScriptMixIn
7 |
8 |
9 | class System(Item, RunScriptMixIn):
10 |
11 | def __init__(self, jenkins, url):
12 | '''
13 | see: https://support.cloudbees.com/hc/en-us/articles/216118748-How-to-Start-Stop-or-Restart-your-Instance-
14 | '''
15 | super().__init__(jenkins, url)
16 |
17 | def _post(entry):
18 | return self.handle_req('POST', entry)
19 |
20 | for entry in ['restart', 'safeRestart', 'exit',
21 | 'safeExit', 'quietDown', 'cancelQuietDown']:
22 | setattr(self, snake(entry), partial(_post, entry))
23 |
24 | def reload_jcasc(self):
25 | return self.handle_req('POST', 'configuration-as-code/reload')
26 |
27 | def export_jcasc(self):
28 | return self.handle_req('POST', 'configuration-as-code/export').text
29 |
30 | def apply_jcasc(self, content):
31 | params = {"newSource": content}
32 | resp = self.handle_req(
33 | 'POST', 'configuration-as-code/checkNewSource', params=params)
34 | if resp.text.startswith('
'):
35 | raise ValueError(resp.text)
36 | data = {'json': json.dumps(params),
37 | 'replace': 'Apply new configuration'}
38 | return self.handle_req('POST', 'configuration-as-code/replace', data=data)
39 |
40 | def decrypt_secret(self, text):
41 | cmd = f'println(hudson.util.Secret.decrypt("{text}"))'
42 | return self.run_script(cmd)
43 |
44 | # async class
45 |
46 |
47 | class AsyncSystem(AsyncItem, AsyncRunScriptMixIn):
48 |
49 | def __init__(self, jenkins, url):
50 | '''
51 | see: https://support.cloudbees.com/hc/en-us/articles/216118748-How-to-Start-Stop-or-Restart-your-Instance-
52 | '''
53 | super().__init__(jenkins, url)
54 |
55 | async def _post(entry):
56 | return await self.handle_req('POST', entry)
57 |
58 | for entry in ['restart', 'safeRestart', 'exit',
59 | 'safeExit', 'quietDown', 'cancelQuietDown']:
60 | setattr(self, snake(entry), partial(_post, entry))
61 |
62 | async def reload_jcasc(self):
63 | return await self.handle_req('POST', 'configuration-as-code/reload')
64 |
65 | async def export_jcasc(self):
66 | data = await self.handle_req('POST', 'configuration-as-code/export')
67 | return data.text
68 |
69 | async def apply_jcasc(self, content):
70 | params = {"newSource": content}
71 | resp = await self.handle_req(
72 | 'POST', 'configuration-as-code/checkNewSource', params=params)
73 | if resp.text.startswith('
'):
74 | raise ValueError(resp.text)
75 | data = {'json': json.dumps(params),
76 | 'replace': 'Apply new configuration'}
77 | return await self.handle_req('POST', 'configuration-as-code/replace', data=data)
78 |
79 | async def decrypt_secret(self, text):
80 | cmd = f'println(hudson.util.Secret.decrypt("{text}"))'
81 | return await self.run_script(cmd)
82 |
--------------------------------------------------------------------------------
/api4jenkins/credential.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 |
3 |
4 | from .item import AsyncItem, Item
5 | from .mix import (AsyncConfigurationMixIn, AsyncDeletionMixIn,
6 | ConfigurationMixIn, DeletionMixIn)
7 |
8 |
9 | class Credentials(Item):
10 |
11 | def get(self, name):
12 | for key in self.api_json(tree='domains[urlName]')['domains'].keys():
13 | if key == name:
14 | return Domain(self.jenkins, f'{self.url}domain/{key}/')
15 | return None
16 |
17 | def create(self, xml):
18 | self.handle_req('POST', 'createDomain',
19 | headers=self.headers, content=xml)
20 |
21 | def iter(self):
22 | for key in self.api_json(tree='domains[urlName]')['domains'].keys():
23 | yield Domain(self.jenkins, f'{self.url}domain/{key}/')
24 |
25 | @property
26 | def global_domain(self):
27 | return self['_']
28 |
29 |
30 | class Domain(Item, ConfigurationMixIn, DeletionMixIn):
31 |
32 | def get(self, id):
33 | for item in self.api_json(tree='credentials[id]')['credentials']:
34 | if item['id'] == id:
35 | return Credential(self.jenkins, f'{self.url}credential/{id}/')
36 | return None
37 |
38 | def create(self, xml):
39 | self.handle_req('POST', 'createCredentials',
40 | headers=self.headers, content=xml)
41 |
42 | def iter(self):
43 | for item in self.api_json(tree='credentials[id]')['credentials']:
44 | yield Credential(self.jenkins, f'{self.url}credential/{item["id"]}/')
45 |
46 |
47 | class Credential(Item, ConfigurationMixIn, DeletionMixIn):
48 | pass
49 |
50 |
51 | # async class
52 | class AsyncCredentials(AsyncItem):
53 |
54 | async def get(self, name):
55 | data = await self.api_json(tree='domains[urlName]')
56 | for key in data['domains'].keys():
57 | if key == name:
58 | return AsyncDomain(self.jenkins, f'{self.url}domain/{key}/')
59 | return None
60 |
61 | async def create(self, xml):
62 | await self.handle_req('POST', 'createDomain',
63 | headers=self.headers, content=xml)
64 |
65 | async def aiter(self):
66 | data = await self.api_json(tree='domains[urlName]')
67 | for key in data['domains'].keys():
68 | yield Domain(self.jenkins, f'{self.url}domain/{key}/')
69 |
70 | @property
71 | async def global_domain(self):
72 | return await self['_']
73 |
74 |
75 | class AsyncDomain(AsyncItem, AsyncConfigurationMixIn, AsyncDeletionMixIn):
76 |
77 | async def get(self, id):
78 | data = await self.api_json(tree='credentials[id]')
79 | for item in data['credentials']:
80 | if item['id'] == id:
81 | return AsyncCredential(self.jenkins, f'{self.url}credential/{id}/')
82 | return None
83 |
84 | async def create(self, xml):
85 | await self.handle_req('POST', 'createCredentials',
86 | headers=self.headers, content=xml)
87 |
88 | async def aiter(self):
89 | data = await self.api_json(tree='credentials[id]')
90 | for item in data['credentials']:
91 | yield AsyncCredential(self.jenkins, f'{self.url}credential/{item["id"]}/')
92 |
93 |
94 | class AsyncCredential(AsyncItem, AsyncConfigurationMixIn, AsyncDeletionMixIn):
95 | pass
96 |
--------------------------------------------------------------------------------
/HISTORY.md:
--------------------------------------------------------------------------------
1 | Release History
2 | ===============
3 | 2.0.2 (2023-11-06)
4 | ------------------
5 | - Fix certificates verification after migration from `requtests` to `httpx`
6 |
7 | 2.0.1 (2023-10-31)
8 | ------------------
9 | - Update doc and requires python 3.8 (#94)
10 |
11 | 2.0.0 (2023-10-19)
12 | -------------------
13 | - Add Async Client
14 |
15 | 1.15.0 (2023-08-30)
16 | -------------------
17 | - Support to manage domains for credential
18 |
19 | 1.14.1 (2023-07-24)
20 | -------------------
21 | - Support additional session headers
22 |
23 | 1.14 (2022-11-28)
24 | -----------------
25 | - Add AnkaCloudComputer (#70)
26 | - Remove 3.6 support (#71)
27 |
28 | 1.13 (2022-10-01)
29 | -----------------
30 | - fix for approving the pending input when jenkins runs on subpath (#66)
31 | - support get build by display name for job (#63)
32 |
33 | 1.12 (2022-08-04)
34 | -----------------
35 | - Add EC2Computer as Node child class (#58)
36 |
37 | 1.11 (2022-07-08)
38 | -----------------
39 | - encode request data with utf-8
40 | - add parameter "recursive" for `Job.duplicate`
41 | - fix issue for `Jenkins.crate_job`
42 |
43 | 1.10 (2022-05-17)
44 | -----------------
45 | - recursively create parent of job if it does not exist
46 | - add buildable property for multibranchproject
47 | - set dependency version
48 |
49 | 1.9.1 (2022-03-29)
50 | -------------------
51 | - change OrganizationFolder to inherit from WorkflowMultiBranchProject
52 | - `Jenkins.get_job` return consistent result
53 |
54 | 1.9 (2022-03-12)
55 | -----------------
56 | - Resolve `name`, `full_name`, `full_display_name` from url for `Job`
57 | - Add new methods for `Nodes`, `Project`
58 | - Support `SectionedView` and `OrganizationFolder`
59 |
60 | 1.8 (2021-12-27)
61 | -----------------
62 | - Rename built-in node as https://www.jenkins.io/doc/book/managing/built-in-node-migration/
63 | - Get parameters for job
64 |
65 | 1.7 (2021-10-09)
66 | -----------------
67 | - `Jenkins.get_job` can accept job url now
68 | - Support to retrieve coverage which was generated by [JaCoCo](https://plugins.jenkins.io/jacoco/) and [Code Coverage API](https://plugins.jenkins.io/code-coverage-api/)
69 |
70 | 1.6 (2021-08-01)
71 | -----------------
72 | - Support NestedView
73 | - Support decrypt credential in Jenkins
74 | - Update doc
75 | - bugfix for `queue.get_build`
76 |
77 | 1.5.1 (2021-05-11)
78 | -------------------
79 | - Bugfix for nodes.iter_builds
80 |
81 | 1.5 (2021-04-29)
82 | -----------------
83 | - Add methods to get parameters and causes for `Build` and `QueueItem`
84 | - Add methods to manage jcasc
85 | - Add help functions
86 |
87 | 1.4 (2021-03-31)
88 | -----------------
89 | - Support to retrieve test report for build
90 | - Support to validate Jenkinsfile
91 |
92 | 1.3 (2021-02-28)
93 | -----------------
94 | - Add capability to get/save artifacts for `WorkflowRun`.
95 | - Make `Jenkins` and `Folder` is subscribed and can be iterated with depth.
96 | - Refactor some code.
97 |
98 | 1.2 (2021-01-31)
99 | ----------------
100 | - Support to enable, disable, scan, get_scan_log for `WorkflowMultiBranchProject`
101 | - Call `Jenkins.get_job` for getting parent of `Job`
102 | - Support process input step for `WorkflowRun`, see `WorkflowRun.get_pending_input()`
103 | - Support user management
104 |
105 | 1.1 (2020-12-31)
106 | -----------------
107 | - Rewrite documentation and add more examples
108 | - `Jenkins.build_job()` and `Project.build()` accept key word argments instead of dict
109 | - Support to access attribute with None type value in json
110 | - Fix typo in `Folder.__iter__()`
111 |
112 | 1.0 (2020-11-15)
113 | ------------------
114 | - First release
115 |
116 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/report/coverage_report_trend.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "hudson.plugins.jacoco.report.CoverageReport",
3 | "branchCoverage": {
4 | "covered": 244,
5 | "missed": 1588,
6 | "percentage": 13,
7 | "percentageFloat": 13.318777,
8 | "total": 1832
9 | },
10 | "classCoverage": {
11 | "covered": 38,
12 | "missed": 21,
13 | "percentage": 64,
14 | "percentageFloat": 64.406784,
15 | "total": 59
16 | },
17 | "complexityScore": {
18 | "covered": 398,
19 | "missed": 1218,
20 | "percentage": 25,
21 | "percentageFloat": 24.628712,
22 | "total": 1616
23 | },
24 | "instructionCoverage": {
25 | "covered": 4205,
26 | "missed": 9220,
27 | "percentage": 31,
28 | "percentageFloat": 31.32216,
29 | "total": 13425
30 | },
31 | "lineCoverage": {
32 | "covered": 712,
33 | "missed": 562,
34 | "percentage": 56,
35 | "percentageFloat": 55.88697,
36 | "total": 1274
37 | },
38 | "methodCoverage": {
39 | "covered": 320,
40 | "missed": 380,
41 | "percentage": 46,
42 | "percentageFloat": 45.714287,
43 | "total": 700
44 | },
45 | "previousResult": {
46 | "_class": "hudson.plugins.jacoco.report.CoverageReport",
47 | "branchCoverage": {
48 | "covered": 244,
49 | "missed": 1588,
50 | "percentage": 13,
51 | "percentageFloat": 13.318777,
52 | "total": 1832
53 | },
54 | "classCoverage": {
55 | "covered": 38,
56 | "missed": 21,
57 | "percentage": 64,
58 | "percentageFloat": 64.406784,
59 | "total": 59
60 | },
61 | "complexityScore": {
62 | "covered": 398,
63 | "missed": 1218,
64 | "percentage": 25,
65 | "percentageFloat": 24.628712,
66 | "total": 1616
67 | },
68 | "instructionCoverage": {
69 | "covered": 4205,
70 | "missed": 9220,
71 | "percentage": 31,
72 | "percentageFloat": 31.32216,
73 | "total": 13425
74 | },
75 | "lineCoverage": {
76 | "covered": 712,
77 | "missed": 562,
78 | "percentage": 56,
79 | "percentageFloat": 55.88697,
80 | "total": 1274
81 | },
82 | "methodCoverage": {
83 | "covered": 320,
84 | "missed": 380,
85 | "percentage": 46,
86 | "percentageFloat": 45.714287,
87 | "total": 700
88 | },
89 | "previousResult": {
90 | "_class": "hudson.plugins.jacoco.report.CoverageReport",
91 | "branchCoverage": {
92 | "covered": 0,
93 | "missed": 1820,
94 | "percentage": 0,
95 | "percentageFloat": 0,
96 | "total": 1820
97 | },
98 | "classCoverage": {
99 | "covered": 0,
100 | "missed": 59,
101 | "percentage": 0,
102 | "percentageFloat": 0,
103 | "total": 59
104 | },
105 | "complexityScore": {
106 | "covered": 0,
107 | "missed": 1605,
108 | "percentage": 0,
109 | "percentageFloat": 0,
110 | "total": 1605
111 | },
112 | "instructionCoverage": {
113 | "covered": 0,
114 | "missed": 13339,
115 | "percentage": 0,
116 | "percentageFloat": 0,
117 | "total": 13339
118 | },
119 | "lineCoverage": {
120 | "covered": 0,
121 | "missed": 1256,
122 | "percentage": 0,
123 | "percentageFloat": 0,
124 | "total": 1256
125 | },
126 | "methodCoverage": {
127 | "covered": 0,
128 | "missed": 695,
129 | "percentage": 0,
130 | "percentageFloat": 0,
131 | "total": 695
132 | },
133 | "previousResult": null
134 | }
135 | }
136 | }
--------------------------------------------------------------------------------
/tests/unit/test_report.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 |
4 | @pytest.fixture
5 | def suite(build):
6 | return build.get_test_report().get('pytest')
7 |
8 |
9 | @pytest.fixture
10 | def case(suite):
11 | return suite.get('test_exists')
12 |
13 |
14 | @pytest.fixture
15 | def coverage_report(build):
16 | return build.get_coverage_report()
17 |
18 |
19 | class TestTestReport:
20 |
21 | def test_attributes(self, test_report):
22 | assert test_report.fail_count == 2
23 | assert test_report.pass_count == 37
24 |
25 | def test_iterate(self, test_report):
26 | assert len(list(test_report)) == 1
27 | assert len(list(test_report.suites)) == 1
28 |
29 |
30 | class TestTestSuite:
31 |
32 | def test_attributes(self, suite):
33 | assert suite.name == 'pytest'
34 | assert suite.duration == 74.678
35 |
36 | def test_get_case(self, suite):
37 | case = suite.get('test_exists')
38 | assert case.name == 'test_exists'
39 | assert case.status == 'PASSED'
40 | assert suite.get('notexist') is None
41 |
42 | def test_iterate(self, suite):
43 | assert len(list(suite)) == 1
44 | assert len(list(suite.cases)) == 1
45 |
46 |
47 | class TestTestCase:
48 |
49 | def test_attributes(self, case):
50 | assert case.name == 'test_exists'
51 | assert case.status == 'PASSED'
52 |
53 |
54 | class TestCoverageReport:
55 |
56 | def test_attributes(self, coverage_report):
57 | assert coverage_report.branch_coverage.covered == 244
58 | assert coverage_report.method_coverage.covered == 320
59 |
60 | def test_get(self, coverage_report):
61 | assert coverage_report.get('branchCoverage').covered == 244
62 | assert coverage_report.get('methodCoverage').covered == 320
63 |
64 | def test_wrong_attribute(self, coverage_report):
65 | with pytest.raises(AttributeError):
66 | coverage_report.xxxxx
67 |
68 |
69 | class TestCoverageResult:
70 |
71 | def test_get(self, build):
72 | cr = build.get_coverage_result()
73 | assert cr.get('Report').ratio == 100
74 | assert cr.get('Line').ratio == 83.83372
75 |
76 |
77 | @pytest.fixture
78 | async def async_suite(async_test_report):
79 | return await async_test_report.get('pytest')
80 |
81 |
82 | @pytest.fixture
83 | async def async_case(async_suite):
84 | return await async_suite.get('test_exists')
85 |
86 |
87 | @pytest.fixture
88 | async def async_coverage_report(async_build):
89 | return await async_build.get_coverage_report()
90 |
91 |
92 | @pytest.fixture
93 | async def async_test_report(async_build):
94 | return await async_build.get_test_report()
95 |
96 |
97 | class TestAsyncTestReport:
98 |
99 | async def test_attributes(self, async_test_report):
100 | assert await async_test_report.fail_count == 2
101 | assert await async_test_report.pass_count == 37
102 |
103 | async def test_iterate(self, async_test_report):
104 | assert len([s async for s in async_test_report]) == 1
105 | assert len([s async for s in async_test_report.suites]) == 1
106 |
107 |
108 | class TestAsyncCoverageReport:
109 |
110 | async def test_attributes(self, async_coverage_report):
111 | assert (await async_coverage_report.branch_coverage).covered == 244
112 | assert (await async_coverage_report.method_coverage).covered == 320
113 |
114 | async def test_get(self, async_coverage_report):
115 | assert (await async_coverage_report.get('branchCoverage')).covered == 244
116 | assert (await async_coverage_report.get('methodCoverage')).covered == 320
117 |
118 | async def test_wrong_attribute(self, async_coverage_report):
119 | with pytest.raises(AttributeError):
120 | await async_coverage_report.xxxxx
121 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/joelee2012/api4jenkins/actions/workflows/unittest.yml)
2 | [](https://github.com/joelee2012/api4jenkins/actions/workflows/integration.yml)
3 | 
4 | [](https://codecov.io/gh/joelee2012/api4jenkins)
5 | 
6 | 
7 | 
8 | [](https://api4jenkins.readthedocs.io/en/latest/?badge=latest)
9 | 
10 |
11 |
12 | # Jenkins Python Client
13 |
14 | [Python3](https://www.python.org/) client library for [Jenkins API](https://www.jenkins.io/doc/book/using/remote-access-api/) which provides sync and async APIs.
15 |
16 | # Features
17 |
18 | - Provides sync and async APIs
19 | - Object oriented, each Jenkins item has corresponding class, easy to use and extend
20 | - Base on `api/json`, easy to query/filter attribute of item
21 | - Setup relationship between class just like Jenkins item
22 | - Support api for almost every Jenkins item
23 | - Pythonic
24 | - Test with latest Jenkins LTS
25 |
26 | # Installation
27 |
28 | ```bash
29 | python3 -m pip install api4jenkins
30 | ```
31 |
32 | # Quick start
33 |
34 | Sync example:
35 |
36 | ```python
37 | >>> from api4jenkins import Jenkins
38 | >>> client = Jenkins('http://127.0.0.1:8080/', auth=('admin', 'admin'))
39 | >>> client.version
40 | '2.176.2'
41 | >>> xml = """
42 | ...
43 | ...
44 | ...
45 | ... echo $JENKINS_VERSION
46 | ...
47 | ...
48 | ... """
49 | >>> client.create_job('path/to/job', xml)
50 | >>> import time
51 | >>> item = client.build_job('path/to/job')
52 | >>> while not item.get_build():
53 | ... time.sleep(1)
54 | >>> build = item.get_build()
55 | >>> for line in build.progressive_output():
56 | ... print(line)
57 | ...
58 | Started by user admin
59 | Running as SYSTEM
60 | Building in workspace /var/jenkins_home/workspace/freestylejob
61 | [freestylejob] $ /bin/sh -xe /tmp/jenkins2989549474028065940.sh
62 | + echo $JENKINS_VERSION
63 | 2.176.2
64 | Finished: SUCCESS
65 | >>> build.building
66 | False
67 | >>> build.result
68 | 'SUCCESS'
69 | ```
70 |
71 | Async example
72 |
73 | ```python
74 | import asyncio
75 | import time
76 | from api4jenkins import AsyncJenkins
77 |
78 | async main():
79 | client = AsyncJenkins('http://127.0.0.1:8080/', auth=('admin', 'admin'))
80 | print(await client.version)
81 | xml = """
82 |
83 |
84 |
85 | echo $JENKINS_VERSION
86 |
87 |
88 | """
89 | await client.create_job('job', xml)
90 | item = await client.build_job('job')
91 | while not await item.get_build():
92 | time.sleep(1)
93 | build = await item.get_build()
94 | async for line in build.progressive_output():
95 | print(line)
96 |
97 | print(await build.building)
98 | print(await build.result)
99 |
100 | asyncio.run(main())
101 | ```
102 |
103 | # Documentation
104 | User Guide and API Reference is available on [Read the Docs](https://api4jenkins.readthedocs.io/)
105 |
106 |
--------------------------------------------------------------------------------
/tests/unit/test_system.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from api4jenkins.item import snake
3 |
4 |
5 | class TestSystem:
6 |
7 | @pytest.mark.parametrize('action', ['restart', 'safeRestart', 'quietDown',
8 | 'cancelQuietDown', 'exit', 'safeExit'])
9 | def test_enable_disable(self, jenkins, respx_mock, action):
10 | req_url = f'{jenkins.system.url}{action}'
11 | respx_mock.post(req_url)
12 | getattr(jenkins.system, snake(action))()
13 | assert respx_mock.calls[0].request.url == req_url
14 |
15 | def test_reload_jcasc(self, jenkins, respx_mock):
16 | req_url = f'{jenkins.system.url}configuration-as-code/reload'
17 | respx_mock.post(req_url)
18 | jenkins.system.reload_jcasc()
19 | assert respx_mock.calls[0].request.url == req_url
20 |
21 | def test_export_jcasc(self, jenkins, respx_mock):
22 | req_url = f'{jenkins.system.url}configuration-as-code/export'
23 | respx_mock.post(req_url)
24 | jenkins.system.export_jcasc()
25 | assert respx_mock.calls[0].request.url == req_url
26 |
27 | def test_apply_jcasc(self, jenkins, respx_mock):
28 | check_url = f'{jenkins.system.url}configuration-as-code/checkNewSource?newSource=yaml'
29 | respx_mock.post(check_url)
30 | req_url = f'{jenkins.system.url}configuration-as-code/replace'
31 | respx_mock.post(req_url)
32 | jenkins.system.apply_jcasc('yaml')
33 | assert respx_mock.calls[0].request.url == check_url
34 | assert respx_mock.calls[1].request.url == req_url
35 |
36 | def test_apply_jcasc_fail(self, jenkins, respx_mock):
37 | check_url = f'{jenkins.system.url}configuration-as-code/checkNewSource?newSource=yaml'
38 | respx_mock.post(check_url).respond(text='
')
39 | with pytest.raises(ValueError):
40 | jenkins.system.apply_jcasc('yaml')
41 | assert respx_mock.calls[0].request.url == check_url
42 |
43 |
44 | class TestAsyncSystem:
45 |
46 | @pytest.mark.parametrize('action', ['restart', 'safeRestart', 'quietDown',
47 | 'cancelQuietDown', 'exit', 'safeExit'])
48 | async def test_enable_disable(self, async_jenkins, respx_mock, action):
49 | req_url = f'{async_jenkins.system.url}{action}'
50 | respx_mock.post(req_url)
51 | await getattr(async_jenkins.system, snake(action))()
52 | assert respx_mock.calls[0].request.url == req_url
53 |
54 | async def test_reload_jcasc(self, async_jenkins, respx_mock):
55 | req_url = f'{async_jenkins.system.url}configuration-as-code/reload'
56 | respx_mock.post(req_url)
57 | await async_jenkins.system.reload_jcasc()
58 | assert respx_mock.calls[0].request.url == req_url
59 |
60 | async def test_export_jcasc(self, async_jenkins, respx_mock):
61 | req_url = f'{async_jenkins.system.url}configuration-as-code/export'
62 | respx_mock.post(req_url)
63 | await async_jenkins.system.export_jcasc()
64 | assert respx_mock.calls[0].request.url == req_url
65 |
66 | async def test_apply_jcasc(self, async_jenkins, respx_mock):
67 | check_url = f'{async_jenkins.system.url}configuration-as-code/checkNewSource?newSource=yaml'
68 | respx_mock.post(check_url)
69 | req_url = f'{async_jenkins.system.url}configuration-as-code/replace'
70 | respx_mock.post(req_url)
71 | await async_jenkins.system.apply_jcasc('yaml')
72 | assert respx_mock.calls[0].request.url == check_url
73 | assert respx_mock.calls[1].request.url == req_url
74 |
75 | async def test_apply_jcasc_fail(self, async_jenkins, respx_mock):
76 | check_url = f'{async_jenkins.system.url}configuration-as-code/checkNewSource?newSource=yaml'
77 | respx_mock.post(check_url).respond(text='
')
78 | with pytest.raises(ValueError):
79 | await async_jenkins.system.apply_jcasc('yaml')
80 | assert respx_mock.calls[0].request.url == check_url
81 |
--------------------------------------------------------------------------------
/tests/unit/test_credential.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 |
3 | import pytest
4 | from api4jenkins.credential import AsyncCredential, AsyncDomain, Credential, Domain
5 |
6 |
7 | @pytest.fixture
8 | def global_domain(jenkins):
9 | return jenkins.credentials.global_domain
10 |
11 |
12 | @pytest.fixture
13 | async def async_global_domain(async_jenkins):
14 | return await async_jenkins.credentials.global_domain
15 |
16 |
17 | class TestCredentials:
18 |
19 | def test_get(self, jenkins):
20 | assert isinstance(jenkins.credentials['_'], Domain)
21 | assert jenkins.credentials['x'] is None
22 |
23 | def test_create(self, jenkins, respx_mock):
24 | req_url = f'{jenkins.credentials.url}createDomain'
25 | respx_mock.post(req_url)
26 | jenkins.credentials.create('xml')
27 | assert respx_mock.calls[0].request.url == req_url
28 |
29 | def test_iter(self, jenkins):
30 | assert len(list(jenkins.credentials)) == 3
31 |
32 |
33 | class TestDomain:
34 |
35 | @pytest.mark.parametrize('id_, obj', [('not exist', type(None)), ('test-user', Credential)])
36 | def test_get(self, global_domain, id_, obj):
37 | assert isinstance(global_domain[id_], obj)
38 |
39 | def test_create(self, global_domain, respx_mock):
40 | req_url = f'{global_domain.url}createCredentials'
41 | respx_mock.post(req_url)
42 | global_domain.create('xml')
43 | assert respx_mock.calls[0].request.url == req_url
44 |
45 | def test_iter(self, global_domain):
46 | creds = list(global_domain)
47 | assert len(creds) == 2
48 | assert all(isinstance(c, Credential) for c in creds)
49 |
50 |
51 | class TestCredential:
52 |
53 | def test_delete(self, credential, respx_mock):
54 | req_url = f'{credential.url}doDelete'
55 | respx_mock.post(req_url)
56 | credential.delete()
57 | assert respx_mock.calls[0].response.status_code == 200
58 | assert respx_mock.calls[0].request.url == req_url
59 |
60 | @pytest.mark.parametrize('req, xml, body',
61 | [('GET', None, '
'), ('POST', '
', '')],
62 | ids=['get', 'set'])
63 | def test_configure(self, credential, respx_mock, req, xml, body):
64 | req_url = f'{credential.url}config.xml'
65 | respx_mock.route(method=req, url=req_url).respond(content=body)
66 | text = credential.configure(xml)
67 | assert respx_mock.calls[0].request.url == req_url
68 |
69 |
70 | class TestAsyncCredentials:
71 |
72 | async def test_get(self, async_jenkins):
73 | assert isinstance(await async_jenkins.credentials['_'], AsyncDomain)
74 | assert await async_jenkins.credentials['x'] is None
75 |
76 | async def test_create(self, async_jenkins, respx_mock):
77 | req_url = f'{async_jenkins.credentials.url}createDomain'
78 | respx_mock.post(req_url)
79 | await async_jenkins.credentials.create('xml')
80 | assert respx_mock.calls[0].request.url == req_url
81 |
82 | async def test_iter(self, async_jenkins):
83 | assert len([c async for c in async_jenkins.credentials]) == 3
84 |
85 |
86 | class TestAsyncDomain:
87 |
88 | @pytest.mark.parametrize('id_, obj', [('not exist', type(None)), ('test-user', AsyncCredential)])
89 | async def test_get(self, async_global_domain, id_, obj):
90 | assert isinstance(await async_global_domain[id_], obj)
91 |
92 | async def test_create(self, async_global_domain, respx_mock):
93 | req_url = f'{async_global_domain.url}createCredentials'
94 | respx_mock.post(req_url)
95 | await async_global_domain.create('xml')
96 | assert respx_mock.calls[0].request.url == req_url
97 |
98 | async def test_iter(self, async_global_domain):
99 | creds = [c async for c in async_global_domain]
100 | assert len(creds) == 2
101 | assert all(isinstance(c, AsyncCredential) for c in creds)
102 |
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | .. api4jenkins documentation master file, created by
2 | sphinx-quickstart on Mon Dec 16 20:16:12 2019.
3 | You can adapt this file completely to your liking, but it should at least
4 | contain the root `toctree` directive.
5 |
6 | Jenkins Python Client
7 | =================================================
8 | .. image:: https://img.shields.io/github/license/joelee2012/api4jenkins
9 | :target: https://pypi.org/project/api4jenkins/
10 |
11 | .. image:: https://img.shields.io/pypi/wheel/api4jenkins
12 | :target: https://pypi.org/project/api4jenkins/
13 |
14 | .. image:: https://img.shields.io/pypi/v/api4jenkins
15 | :target: https://pypi.org/project/api4jenkins/
16 |
17 | .. image:: https://img.shields.io/pypi/pyversions/api4jenkins
18 | :target: https://pypi.org/project/api4jenkins/
19 |
20 |
21 |
22 | `Python3
`_ client library for
23 | `Jenkins API
`_.
24 |
25 |
26 | Features
27 | --------
28 |
29 | - Provides ``sync`` and ``async`` APIs
30 | - Object oriented, each Jenkins item has corresponding class, easy to use and extend
31 | - Base on ``api/json``, easy to query/filter attribute of item
32 | - Setup relationship between class just like Jenkins item
33 | - Support api for almost every Jenkins item
34 | - Pythonic
35 | - Test with latest `Jenkins LTS `_
36 |
37 |
38 | Quick start
39 | ----------------------------------------
40 |
41 | Here is an example to create and build job, then monitor progressive output
42 | until it's done.
43 |
44 | Sync example::
45 |
46 | >>> from api4jenkins import Jenkins
47 | >>> client = Jenkins('http://127.0.0.1:8080/', auth=('admin', 'admin'))
48 | >>> client.version
49 | '2.176.2'
50 | >>> xml = """
51 | ...
52 | ...
53 | ...
54 | ... echo $JENKINS_VERSION
55 | ...
56 | ...
57 | ... """
58 | >>> client.create_job('path/to/job', xml)
59 | >>> import time
60 | >>> item = client.build_job('path/to/job')
61 | >>> while not item.get_build():
62 | ... time.sleep(1)
63 | >>> build = item.get_build()
64 | >>> for line in build.progressive_output():
65 | ... print(line)
66 | ...
67 | Started by user admin
68 | Running as SYSTEM
69 | Building in workspace /var/jenkins_home/workspace/freestylejob
70 | [freestylejob] $ /bin/sh -xe /tmp/jenkins2989549474028065940.sh
71 | + echo $JENKINS_VERSION
72 | 2.176.2
73 | Finished: SUCCESS
74 | >>> build.building
75 | False
76 | >>> build.result
77 | 'SUCCESS'
78 |
79 |
80 | Async example::
81 |
82 | import asyncio
83 | import time
84 | from api4jenkins import AsyncJenkins
85 |
86 | async main():
87 | client = AsyncJenkins('http://127.0.0.1:8080/', auth=('admin', 'admin'))
88 | print(await client.version)
89 | xml = """
90 |
91 |
92 |
93 | echo $JENKINS_VERSION
94 |
95 |
96 | """
97 | await client.create_job('job', xml)
98 | item = await client.build_job('job')
99 | while not await item.get_build():
100 | time.sleep(1)
101 | build = await item.get_build()
102 | async for line in build.progressive_output():
103 | print(line)
104 |
105 | print(await build.building)
106 | print(await build.result)
107 |
108 | asyncio.run(main())
109 |
110 |
111 | .. toctree::
112 | :maxdepth: 2
113 | :caption: Contents:
114 |
115 | user/install
116 | user/example
117 | user/api
118 |
119 |
120 | Indices and tables
121 | ==================
122 |
123 | * :ref:`genindex`
124 | * :ref:`modindex`
125 | * :ref:`search`
126 |
--------------------------------------------------------------------------------
/tests/integration/test_02_job.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 |
3 | import pytest
4 |
5 |
6 | class TestFolder:
7 | def test_parent(self, jenkins, folder, job):
8 | assert folder == job.parent
9 | assert jenkins == folder.parent
10 |
11 | def test_iter_jobs(self, folder):
12 | assert len(list(folder.iter(2))) == 5
13 | assert len(list(folder(2))) == 5
14 | assert len(list(folder)) == 4
15 | assert folder['job']
16 |
17 |
18 | class TestProject:
19 | def test_name(self, job):
20 | assert job.name == 'job'
21 | assert job.full_name == 'folder/job'
22 | assert job.full_display_name == 'folder » job'
23 |
24 | def test_get_parameters(self, args_job):
25 | assert args_job.get_parameters()[0]['name'] == 'ARG1'
26 |
27 | def test_get_build(self, job):
28 | assert job[0] is None
29 | assert job[1]
30 |
31 | def test_get_special_build(self, job):
32 | assert job.get_first_build()
33 | assert job.get_last_failed_build() is None
34 |
35 | def test_iter_build(self, job):
36 | assert len(list(job)) == 2
37 | assert len(list(job.iter())) == 2
38 |
39 | def test_iter_all_builds(self, job):
40 | assert len(list(job.iter_all_builds())) == 2
41 |
42 | def test_building(self, job):
43 | assert job.building == False
44 |
45 | def test_set_next_build_number(self, job):
46 | job.set_next_build_number(10)
47 | assert job.next_build_number == 10
48 |
49 | def test_filter_builds_by_result(self, job):
50 | assert len(list(job.filter_builds_by_result(result='SUCCESS'))) == 2
51 | assert not list(job.filter_builds_by_result(result='ABORTED'))
52 | with pytest.raises(ValueError):
53 | assert list(job.filter_builds_by_result(result='not a status')) == 'x'
54 |
55 |
56 | class TestAsyncFolder:
57 | async def test_parent(self, async_jenkins, async_folder, async_job):
58 | assert async_folder == await async_job.parent
59 | assert async_jenkins == await async_folder.parent
60 |
61 | async def test_iter_jobs(self, async_folder):
62 | assert len([j async for j in async_folder(2)]) == 5
63 | assert len([j async for j in async_folder]) == 4
64 | assert await async_folder['job']
65 |
66 |
67 | class TestAsyncProject:
68 | async def test_name(self, async_job):
69 | assert async_job.name == 'job'
70 | assert async_job.full_name == 'async_folder/job'
71 | assert async_job.full_display_name == 'async_folder » job'
72 |
73 | async def test_get_parameters(self, async_args_job):
74 | assert (await async_args_job.get_parameters())[0]['name'] == 'ARG1'
75 |
76 | async def test_get_build(self, async_job):
77 | assert await async_job.get(0) is None
78 | assert await async_job[1]
79 |
80 | async def test_get_special_build(self, async_job):
81 | assert await async_job.get_first_build()
82 | assert await async_job.get_last_failed_build() is None
83 |
84 | async def test_iter_build(self, async_job):
85 | assert len([b async for b in async_job]) == 2
86 | assert len([b async for b in async_job.aiter()]) == 2
87 |
88 | async def test_iter_all_builds(self, async_job):
89 | assert len([b async for b in async_job.iter_all_builds()]) == 2
90 |
91 | async def test_building(self, async_job):
92 | assert await async_job.building == False
93 |
94 | async def test_set_next_build_number(self, async_job):
95 | await async_job.set_next_build_number(10)
96 | assert await async_job.next_build_number == 10
97 |
98 | async def test_filter_builds_by_result(self, async_job):
99 | assert len([b async for b in async_job.filter_builds_by_result(result='SUCCESS')]) == 2
100 | assert not [b async for b in async_job.filter_builds_by_result(result='ABORTED')]
101 | with pytest.raises(ValueError):
102 | assert [b async for b in async_job.filter_builds_by_result(result='not a status')] == 'x'
103 |
--------------------------------------------------------------------------------
/tests/unit/test_input.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from api4jenkins.input import AsyncPendingInputAction, PendingInputAction, _make_input_params
3 |
4 |
5 | raw = {
6 | "id": "3eaa25d43fac6e39a12c3936942b72c8",
7 | "proceedText": "Proceed",
8 | "message": "Is the build okay?",
9 | "inputs": [],
10 | "proceedUrl": "/job/input-pipeline/47/wfapi/inputSubmit?inputId=3eaa25d43fac6e39a12c3936942b72c8",
11 | "abortUrl": "/job/input-pipeline/47/input/3eaa25d43fac6e39a12c3936942b72c8/abort",
12 | "redirectApprovalUrl": "/job/input-pipeline/47/input/"
13 | }
14 |
15 |
16 | @pytest.fixture
17 | def pending_input(jenkins):
18 | return PendingInputAction(jenkins, raw)
19 |
20 |
21 | @pytest.fixture
22 | def async_pending_input(async_jenkins):
23 | return AsyncPendingInputAction(async_jenkins, raw)
24 | # @pytest.mark.skip
25 |
26 |
27 | class TestPendingInput:
28 |
29 | def test_access_attrs(self, pending_input):
30 | assert isinstance(pending_input, PendingInputAction)
31 | assert pending_input.message == "Is the build okay?"
32 | assert pending_input.id == "3eaa25d43fac6e39a12c3936942b72c8"
33 | assert pending_input.proceed_url == "/job/input-pipeline/47/wfapi/inputSubmit?inputId=3eaa25d43fac6e39a12c3936942b72c8"
34 | assert pending_input.abort_url == "/job/input-pipeline/47/input/3eaa25d43fac6e39a12c3936942b72c8/abort"
35 |
36 | def test_abort(self, pending_input, respx_mock):
37 | respx_mock.post(f'{pending_input.url}abort')
38 | pending_input.abort()
39 | assert respx_mock.calls[0].request.url == f'{pending_input.url}abort'
40 |
41 | def test_submit_empty(self, pending_input, respx_mock):
42 | respx_mock.post(f'{pending_input.url}proceedEmpty')
43 | pending_input.submit()
44 | assert respx_mock.calls[0].request.url == f'{pending_input.url}proceedEmpty'
45 |
46 | def test_submit_arg(self, pending_input, respx_mock):
47 | pending_input.raw['inputs'] = [{'name': 'arg1'}]
48 | respx_mock.post(f'{pending_input.url}submit').respond(
49 | json={'arg1': 'x'})
50 | pending_input.submit(arg1='x')
51 | assert respx_mock.calls[0].request.url == f'{pending_input.url}submit'
52 |
53 | def test_submit_empty_with_arg(self, pending_input):
54 | pending_input.raw['inputs'] = []
55 | with pytest.raises(TypeError):
56 | pending_input.submit(arg1='x')
57 |
58 | def test_submit_wrong_arg(self, pending_input):
59 | pending_input.raw['inputs'] = [{'name': 'arg1'}]
60 | with pytest.raises(TypeError):
61 | pending_input.submit(arg2='x')
62 |
63 |
64 | class TestAsyncPendingInput:
65 |
66 | async def test_access_attrs(self, async_pending_input):
67 | assert isinstance(async_pending_input, AsyncPendingInputAction)
68 | assert await async_pending_input.message == "Is the build okay?"
69 | assert await async_pending_input.id == "3eaa25d43fac6e39a12c3936942b72c8"
70 | assert await async_pending_input.proceed_url == "/job/input-pipeline/47/wfapi/inputSubmit?inputId=3eaa25d43fac6e39a12c3936942b72c8"
71 | assert await async_pending_input.abort_url == "/job/input-pipeline/47/input/3eaa25d43fac6e39a12c3936942b72c8/abort"
72 |
73 | async def test_abort(self, async_pending_input, respx_mock):
74 | url = f'{async_pending_input.url}abort'
75 | respx_mock.post(url)
76 | await async_pending_input.abort()
77 | assert respx_mock.calls[0].request.url == url
78 |
79 | async def test_submit_empty(self, async_pending_input, respx_mock):
80 | url = f'{async_pending_input.url}proceedEmpty'
81 | respx_mock.post(url)
82 | await async_pending_input.submit()
83 | assert respx_mock.calls[0].request.url == url
84 |
85 | async def test_submit_arg(self, async_pending_input, respx_mock):
86 | async_pending_input.raw['inputs'] = [{'name': 'arg1'}]
87 | url = f'{async_pending_input.url}submit'
88 | respx_mock.post(url)
89 | await async_pending_input.submit(arg1='x')
90 | assert respx_mock.calls[0].request.url == url
91 |
92 | async def test_submit_empty_with_arg(self, async_pending_input):
93 | async_pending_input.raw['inputs'] = []
94 | with pytest.raises(TypeError):
95 | await async_pending_input.submit(arg1='x')
96 |
97 | async def test_submit_wrong_arg(self, async_pending_input):
98 | async_pending_input.raw['inputs'] = [{'name': 'arg1'}]
99 | with pytest.raises(TypeError):
100 | await async_pending_input.submit(arg2='x')
101 |
--------------------------------------------------------------------------------
/api4jenkins/view.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 |
3 | from .item import AsyncItem, Item
4 | from .mix import (AsyncConfigurationMixIn, AsyncDeletionMixIn,
5 | AsyncDescriptionMixIn, ConfigurationMixIn,
6 | DeletionMixIn, DescriptionMixIn)
7 |
8 |
9 | class Views(Item):
10 | '''
11 | classdocs
12 | '''
13 |
14 | def __init__(self, owner):
15 | '''
16 | Constructor
17 | '''
18 | self.owner = owner
19 | super().__init__(owner.jenkins, owner.url)
20 |
21 | def get(self, name):
22 | for item in self.api_json(tree='views[name,url]')['views']:
23 | if name == item['name']:
24 | return self._new_item(__name__, item)
25 | return None
26 |
27 | def create(self, name, xml):
28 | self.handle_req('POST', 'createView', params={'name': name},
29 | headers=self.headers, content=xml)
30 |
31 | def __iter__(self):
32 | for item in self.api_json(tree='views[name,url]')['views']:
33 | yield self._new_item(__name__, item)
34 |
35 |
36 | class View(Item, ConfigurationMixIn, DescriptionMixIn, DeletionMixIn):
37 |
38 | def get(self, name):
39 | for item in self.api_json(tree='jobs[name,url]')['jobs']:
40 | if name == item['name']:
41 | return self._new_item('api4jenkins.job', item)
42 | return None
43 |
44 | def __iter__(self):
45 | for item in self.api_json(tree='jobs[name,url]')['jobs']:
46 | yield self._new_item('api4jenkins.job', item)
47 |
48 | def include(self, name):
49 | self.handle_req('POST', 'addJobToView', params={'name': name})
50 |
51 | def exclude(self, name):
52 | self.handle_req('POST', 'removeJobFromView', params={'name': name})
53 |
54 |
55 | class AllView(View):
56 | def __init__(self, jenkins, url):
57 | # name of all view for jenkins is 'all', but for folder is 'All'
58 | name = 'view/all' if jenkins.url == url else 'view/All'
59 | super().__init__(jenkins, url + name)
60 |
61 |
62 | class MyView(View):
63 | pass
64 |
65 |
66 | class ListView(View):
67 | pass
68 |
69 |
70 | class Dashboard(View):
71 | pass
72 |
73 |
74 | class NestedView(View):
75 |
76 | @property
77 | def views(self):
78 | return Views(self)
79 |
80 |
81 | class SectionedView(View):
82 | pass
83 |
84 |
85 | class AsyncViews(AsyncItem):
86 | '''
87 | classdocs
88 | '''
89 |
90 | def __init__(self, owner):
91 | '''
92 | Constructor
93 | '''
94 | self.owner = owner
95 | super().__init__(owner.jenkins, owner.url)
96 |
97 | async def get(self, name):
98 | data = await self.api_json(tree='views[name,url]')
99 | for item in data['views']:
100 | if name == item['name']:
101 | return self._new_item(__name__, item)
102 | return None
103 |
104 | async def create(self, name, xml):
105 | await self.handle_req('POST', 'createView', params={'name': name},
106 | headers=self.headers, content=xml)
107 |
108 | async def __aiter__(self):
109 | data = await self.api_json(tree='views[name,url]')
110 | for item in data['views']:
111 | yield self._new_item(__name__, item)
112 |
113 |
114 | class AsyncView(AsyncItem, AsyncConfigurationMixIn, AsyncDescriptionMixIn, AsyncDeletionMixIn):
115 |
116 | async def get(self, name):
117 | data = await self.api_json(tree='jobs[name,url]')
118 | for item in data['jobs']:
119 | if name == item['name']:
120 | return self._new_item('api4jenkins.job', item)
121 | return None
122 |
123 | async def __aiter__(self):
124 | data = await self.api_json(tree='jobs[name,url]')
125 | for item in data['jobs']:
126 | yield self._new_item('api4jenkins.job', item)
127 |
128 | async def include(self, name):
129 | await self.handle_req('POST', 'addJobToView', params={'name': name})
130 |
131 | async def exclude(self, name):
132 | await self.handle_req('POST', 'removeJobFromView', params={'name': name})
133 |
134 |
135 | class AsyncAllView(AsyncView):
136 | def __init__(self, jenkins, url):
137 | # name of all view for jenkins is 'all', but for folder is 'All'
138 | name = 'view/all' if jenkins.url == url else 'view/All'
139 | super().__init__(jenkins, url + name)
140 |
141 |
142 | class AsyncMyView(AsyncView):
143 | pass
144 |
145 |
146 | class AsyncListView(AsyncView):
147 | pass
148 |
149 |
150 | class AsyncDashboard(AsyncView):
151 | pass
152 |
153 |
154 | class AsyncNestedView(AsyncView):
155 |
156 | @property
157 | def views(self):
158 | return AsyncViews(self)
159 |
160 |
161 | class AsyncSectionedView(AsyncView):
162 | pass
163 |
--------------------------------------------------------------------------------
/tests/integration/conftest.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import os
3 | import time
4 | from pathlib import Path
5 |
6 | import pytest
7 |
8 | from api4jenkins import EMPTY_FOLDER_XML, AsyncFolder, AsyncJenkins, Folder, Jenkins
9 | from api4jenkins.job import AsyncWorkflowJob, WorkflowJob
10 |
11 | TEST_DATA_DIR = Path(__file__).with_name('tests_data')
12 |
13 |
14 | def load_xml(name):
15 | with open(TEST_DATA_DIR.joinpath(name)) as f:
16 | return f.read()
17 |
18 |
19 | @pytest.fixture(scope='session')
20 | def jenkins():
21 | yield Jenkins(os.environ['JENKINS_URL'], auth=(os.environ['JENKINS_USER'], os.environ['JENKINS_PASSWORD']))
22 |
23 |
24 | @pytest.fixture(scope='session')
25 | def async_jenkins():
26 | yield AsyncJenkins(os.environ['JENKINS_URL'], auth=(os.environ['JENKINS_USER'], os.environ['JENKINS_PASSWORD']))
27 |
28 |
29 | @pytest.fixture(scope='session')
30 | def folder_xml():
31 | return EMPTY_FOLDER_XML
32 |
33 |
34 | # @pytest.fixture(scope='session')
35 | # def job_xml():
36 | # return load_xml('job.xml')
37 |
38 |
39 | # @pytest.fixture(scope='session')
40 | # def job_with_args_xml():
41 | # return load_xml('job_params.xml')
42 |
43 |
44 | @pytest.fixture(scope='session')
45 | def credential_xml():
46 | return load_xml('credential.xml')
47 |
48 |
49 | @pytest.fixture(scope='session')
50 | def view_xml():
51 | return load_xml('view.xml')
52 |
53 |
54 | @pytest.fixture(scope='session')
55 | def folder(jenkins: Jenkins):
56 | return Folder(jenkins, jenkins._name2url('folder'))
57 |
58 |
59 | @pytest.fixture(scope='session')
60 | def async_folder(async_jenkins: AsyncJenkins):
61 | return AsyncFolder(async_jenkins, async_jenkins._name2url('async_folder'))
62 |
63 |
64 | @pytest.fixture(scope='session')
65 | def job(jenkins: Jenkins):
66 | return WorkflowJob(jenkins, jenkins._name2url('folder/job'))
67 |
68 |
69 | @pytest.fixture(scope='session')
70 | def async_job(async_jenkins: AsyncJenkins):
71 | return AsyncWorkflowJob(async_jenkins, async_jenkins._name2url('async_folder/job'))
72 |
73 |
74 | @pytest.fixture(scope='session')
75 | def args_job(jenkins: Jenkins):
76 | return WorkflowJob(jenkins, jenkins._name2url('folder/args_job'))
77 |
78 |
79 | @pytest.fixture(scope='session')
80 | def async_args_job(async_jenkins: AsyncJenkins):
81 | return AsyncWorkflowJob(async_jenkins, async_jenkins._name2url('async_folder/args_job'))
82 |
83 |
84 | @pytest.fixture(scope='session', autouse=True)
85 | def setup(jenkins, credential_xml, view_xml):
86 | try:
87 | for name in [
88 | 'folder/folder',
89 | 'folder/for_rename',
90 | 'folder/for_move',
91 | 'async_folder/folder',
92 | 'async_folder/for_rename',
93 | 'async_folder/for_move',
94 | ]:
95 | jenkins.create_job(name, EMPTY_FOLDER_XML, True)
96 |
97 | for name in ['folder/job', 'async_folder/job']:
98 | jenkins.create_job(name, load_xml('job.xml'))
99 |
100 | for name in ['folder/args_job', 'async_folder/args_job']:
101 | jenkins.create_job(name, load_xml('args_job.xml'))
102 |
103 | jenkins.credentials.global_domain.create(credential_xml)
104 | jenkins.credentials.create(load_xml('domain.xml'))
105 | jenkins.views.create('global-view', view_xml)
106 | jenkins['folder'].credentials.global_domain.create(credential_xml)
107 | jenkins['async_folder'].credentials.global_domain.create(credential_xml)
108 | jenkins['folder'].views.create('folder-view', view_xml)
109 | jenkins['async_folder'].views.create('folder-view', view_xml)
110 |
111 | yield
112 | finally:
113 | jenkins.delete_job('folder')
114 | jenkins.delete_job('async_folder')
115 | jenkins.credentials.global_domain.get('user-id').delete()
116 | jenkins.credentials.get('testing').delete()
117 | jenkins.views.get('global-view').delete()
118 |
119 |
120 | @pytest.fixture(scope='session')
121 | def retrive_build_and_output():
122 | def _retrive(item):
123 | for _ in range(10):
124 | if item.get_build():
125 | break
126 | time.sleep(1)
127 | else:
128 | raise TimeoutError('unable to get build in 10 seconds!!')
129 | build = item.get_build()
130 | output = []
131 | for line in build.progressive_output():
132 | output.append(str(line))
133 | return build, output
134 |
135 | return _retrive
136 |
137 |
138 | @pytest.fixture(scope='session')
139 | async def async_retrive_build_and_output():
140 | async def _retrive(item):
141 | for _ in range(10):
142 | if await item.get_build():
143 | break
144 | await asyncio.sleep(1)
145 | else:
146 | raise TimeoutError('unable to get build in 10 seconds!!')
147 | build = await item.get_build()
148 | output = []
149 | async for line in build.progressive_output():
150 | output.append(str(line))
151 | return build, output
152 |
153 | return _retrive
154 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/job/pipeline.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowJob",
3 | "actions": [
4 | {},
5 | {},
6 | {},
7 | {},
8 | {},
9 | {},
10 | {
11 | "_class": "hudson.plugins.jobConfigHistory.JobConfigHistoryProjectAction"
12 | },
13 | {},
14 | {},
15 | {},
16 | {},
17 | {},
18 | {
19 | "_class": "org.jenkinsci.plugins.testresultsanalyzer.TestResultsAnalyzerAction"
20 | },
21 | {},
22 | {},
23 | {
24 | "_class": "com.cloudbees.plugins.credentials.ViewCredentialsAction"
25 | }
26 | ],
27 | "description": "",
28 | "displayName": "Level1_WorkflowJob",
29 | "displayNameOrNull": null,
30 | "fullDisplayName": "Level1_WorkflowJob",
31 | "fullName": "Level1_WorkflowJob",
32 | "name": "Level1_WorkflowJob",
33 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/",
34 | "buildable": true,
35 | "builds": [
36 | {
37 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
38 | "number": 52,
39 | "displayName": "#52",
40 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/52/",
41 | "building": false
42 | },
43 | {
44 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
45 | "number": 51,
46 | "displayName": "#51",
47 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/51/",
48 | "building": false
49 | },
50 | {
51 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
52 | "number": 50,
53 | "displayName": "#50",
54 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/50/",
55 | "building": false
56 | },
57 | {
58 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
59 | "number": 49,
60 | "displayName": "#49",
61 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/49/",
62 | "building": false
63 | },
64 | {
65 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
66 | "number": 48,
67 | "displayName": "#48",
68 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/48/",
69 | "building": false
70 | },
71 | {
72 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
73 | "number": 47,
74 | "displayName": "#47",
75 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/47/",
76 | "building": false
77 | },
78 | {
79 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
80 | "number": 46,
81 | "displayName": "#46",
82 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/46/",
83 | "building": false
84 | },
85 | {
86 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
87 | "number": 45,
88 | "displayName": "#45",
89 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/45/",
90 | "building": false
91 | }
92 | ],
93 | "color": "red",
94 | "firstBuild": {
95 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
96 | "number": 45,
97 | "displayName": "#45",
98 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/45/"
99 | },
100 | "healthReport": [
101 | {
102 | "description": "Build stability: 4 out of the last 5 builds failed.",
103 | "iconClassName": "icon-health-00to19",
104 | "iconUrl": "health-00to19.png",
105 | "score": 20
106 | }
107 | ],
108 | "inQueue": false,
109 | "keepDependencies": false,
110 | "lastBuild": {
111 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
112 | "number": 52,
113 | "displayName": "#52",
114 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/52/"
115 | },
116 | "lastCompletedBuild": {
117 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
118 | "number": 52,
119 | "displayName": "#52",
120 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/52/"
121 | },
122 | "lastFailedBuild": {
123 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
124 | "number": 52,
125 | "displayName": "#52",
126 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/52/"
127 | },
128 | "lastStableBuild": {
129 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
130 | "number": 49,
131 | "displayName": "#49",
132 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/49/"
133 | },
134 | "lastSuccessfulBuild": {
135 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
136 | "number": 49,
137 | "displayName": "#49",
138 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/49/"
139 | },
140 | "lastUnstableBuild": {
141 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
142 | "number": 52,
143 | "displayName": "#52",
144 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/52/"
145 | },
146 | "lastUnsuccessfulBuild": {
147 | "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun",
148 | "number": 52,
149 | "displayName": "#52",
150 | "url": "http://0.0.0.0:8080/job/Level1_WorkflowJob/52/"
151 | },
152 | "nextBuildNumber": 53,
153 | "property": [],
154 | "queueItem": null,
155 | "concurrentBuild": true,
156 | "resumeBlocked": false
157 | }
--------------------------------------------------------------------------------
/api4jenkins/mix.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 |
3 | # pylint: disable=no-member
4 | # type: ignore
5 | from collections import namedtuple
6 | from pathlib import PurePosixPath
7 |
8 |
9 | class UrlMixIn:
10 | __slots__ = ()
11 |
12 | def _url2name(self, url):
13 | if not url.startswith(self.url):
14 | raise ValueError(f'{url} is not in {self.url}')
15 | return url.replace(self.url, '/').replace('/job/', '/').strip('/')
16 |
17 | def _name2url(self, full_name):
18 | if not full_name:
19 | return self.url
20 | full_name = full_name.strip('/').replace('/', '/job/')
21 | return f'{self.url}job/{full_name}/'
22 |
23 | def _parse_name(self, full_name):
24 | if full_name.startswith(('http://', 'https://')):
25 | full_name = self._url2name(full_name)
26 | path = PurePosixPath(full_name)
27 | parent = str(path.parent) if path.parent.name else ''
28 | return parent, path.name
29 |
30 |
31 | class DeletionMixIn:
32 | __slots__ = ()
33 |
34 | def delete(self):
35 | self.handle_req('POST', 'doDelete')
36 |
37 |
38 | class ConfigurationMixIn:
39 | __slots__ = ()
40 |
41 | def configure(self, xml=None):
42 | if not xml:
43 | return self.handle_req('GET', 'config.xml').text
44 | return self.handle_req('POST', 'config.xml',
45 | headers=self.headers, content=xml)
46 |
47 | @property
48 | def name(self):
49 | return self.url.split('/')[-2]
50 |
51 |
52 | class DescriptionMixIn:
53 | __slots__ = ()
54 |
55 | def set_description(self, text):
56 | self.handle_req('POST', 'submitDescription',
57 | params={'description': text})
58 |
59 |
60 | class RunScriptMixIn:
61 | __slots__ = ()
62 |
63 | def run_script(self, script):
64 | return self.handle_req('POST', 'scriptText',
65 | data={'script': script}).text
66 |
67 |
68 | class EnableMixIn:
69 | __slots__ = ()
70 |
71 | def enable(self):
72 | return self.handle_req('POST', 'enable')
73 |
74 | def disable(self):
75 | return self.handle_req('POST', 'disable')
76 |
77 |
78 | class RawJsonMixIn:
79 | __slots__ = ()
80 |
81 | def api_json(self, tree='', depth=0):
82 | return self.raw
83 |
84 |
85 | Parameter = namedtuple('Parameter', ['class_name', 'name', 'value'])
86 |
87 |
88 | class ActionsMixIn:
89 | __slots__ = ()
90 |
91 | def get_parameters(self):
92 | parameters = []
93 | for action in self.api_json()['actions']:
94 | if 'parameters' in action:
95 | parameters.extend(Parameter(raw['_class'], raw['name'], raw.get(
96 | 'value', '')) for raw in action['parameters'])
97 | break
98 | return parameters
99 |
100 | def get_causes(self):
101 | return next((action['causes'] for action in self.api_json()['actions'] if 'causes' in action), [])
102 |
103 |
104 | # async classes
105 |
106 |
107 | class AsyncDeletionMixIn:
108 | __slots__ = ()
109 |
110 | async def delete(self):
111 | await self.handle_req('POST', 'doDelete')
112 |
113 |
114 | class AsyncConfigurationMixIn:
115 | __slots__ = ()
116 |
117 | async def configure(self, xml=None):
118 | if xml:
119 | return await self.handle_req('POST', 'config.xml',
120 | headers=self.headers, content=xml)
121 | return (await self.handle_req('GET', 'config.xml')).text
122 |
123 | @property
124 | def name(self):
125 | return self.url.split('/')[-2]
126 |
127 |
128 | class AsyncDescriptionMixIn:
129 | __slots__ = ()
130 |
131 | async def set_description(self, text):
132 | await self.handle_req('POST', 'submitDescription',
133 | params={'description': text})
134 |
135 |
136 | class AsyncRunScriptMixIn:
137 | __slots__ = ()
138 |
139 | async def run_script(self, script):
140 | return (await self.handle_req('POST', 'scriptText', data={'script': script})).text
141 |
142 |
143 | class AsyncEnableMixIn:
144 | __slots__ = ()
145 |
146 | async def enable(self):
147 | return await self.handle_req('POST', 'enable')
148 |
149 | async def disable(self):
150 | return await self.handle_req('POST', 'disable')
151 |
152 |
153 | class AsyncRawJsonMixIn:
154 | __slots__ = ()
155 |
156 | async def api_json(self, tree='', depth=0):
157 | return self.raw
158 |
159 |
160 | class AsyncActionsMixIn:
161 | __slots__ = ()
162 |
163 | async def get_parameters(self):
164 | parameters = []
165 | data = await self.api_json()
166 | for action in data['actions']:
167 | if 'parameters' in action:
168 | parameters.extend(Parameter(raw['_class'], raw['name'], raw.get(
169 | 'value', '')) for raw in action['parameters'])
170 | break
171 | return parameters
172 |
173 | async def get_causes(self):
174 | data = await self.api_json()
175 | return next((action['causes'] for action in data['actions'] if 'causes' in action), [])
176 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/plugin/installStatus.json:
--------------------------------------------------------------------------------
1 | {
2 | "status": "ok",
3 | "data": {
4 | "jobs": [{
5 | "name": "nant",
6 | "version": "1.4.3",
7 | "title": "NAnt",
8 | "installStatus": "Success",
9 | "requiresRestart": "false"
10 | }, {
11 | "name": "cloudbees-folder",
12 | "version": "6.9",
13 | "title": "Folders",
14 | "installStatus": "SuccessButRequiresRestart",
15 | "requiresRestart": "true"
16 | }, {
17 | "name": "scm-api",
18 | "version": "2.6.3",
19 | "title": "SCM API",
20 | "installStatus": "SuccessButRequiresRestart",
21 | "requiresRestart": "true"
22 | }, {
23 | "name": "structs",
24 | "version": "1.19",
25 | "title": "Structs",
26 | "installStatus": "SuccessButRequiresRestart",
27 | "requiresRestart": "true"
28 | }, {
29 | "name": "script-security",
30 | "version": "1.61",
31 | "title": "Script Security",
32 | "installStatus": "SuccessButRequiresRestart",
33 | "requiresRestart": "true"
34 | }, {
35 | "name": "workflow-step-api",
36 | "version": "2.20",
37 | "title": "Pipeline: Step API",
38 | "installStatus": "SuccessButRequiresRestart",
39 | "requiresRestart": "true"
40 | }, {
41 | "name": "workflow-api",
42 | "version": "2.35",
43 | "title": "Pipeline: API",
44 | "installStatus": "SuccessButRequiresRestart",
45 | "requiresRestart": "true"
46 | }, {
47 | "name": "workflow-support",
48 | "version": "3.3",
49 | "title": "Pipeline: Supporting APIs",
50 | "installStatus": "SuccessButRequiresRestart",
51 | "requiresRestart": "true"
52 | }, {
53 | "name": "workflow-cps",
54 | "version": "2.72",
55 | "title": "Pipeline: Groovy",
56 | "installStatus": "SuccessButRequiresRestart",
57 | "requiresRestart": "true"
58 | }, {
59 | "name": "config-file-provider",
60 | "version": "3.6.2",
61 | "title": "Config File Provider",
62 | "installStatus": "SuccessButRequiresRestart",
63 | "requiresRestart": "true"
64 | }, {
65 | "name": "workflow-job",
66 | "version": "2.33",
67 | "title": "Pipeline: Job",
68 | "installStatus": "SuccessButRequiresRestart",
69 | "requiresRestart": "true"
70 | }, {
71 | "name": "branch-api",
72 | "version": "2.5.3",
73 | "title": "Branch API",
74 | "installStatus": "SuccessButRequiresRestart",
75 | "requiresRestart": "true"
76 | }, {
77 | "name": "workflow-multibranch",
78 | "version": "2.21",
79 | "title": "Pipeline: Multibranch",
80 | "installStatus": "SuccessButRequiresRestart",
81 | "requiresRestart": "true"
82 | }, {
83 | "name": "pipeline-multibranch-defaults",
84 | "version": "2.0",
85 | "title": "Pipeline: Multibranch with defaults",
86 | "installStatus": "Failure",
87 | "requiresRestart": "false"
88 | }, {
89 | "name": "ownership",
90 | "version": "0.12.1",
91 | "title": "Job and Node ownership",
92 | "installStatus": "Success",
93 | "requiresRestart": "false"
94 | }, {
95 | "name": "nomad",
96 | "version": "0.6.5",
97 | "title": "Nomad",
98 | "installStatus": "Success",
99 | "requiresRestart": "false"
100 | }, {
101 | "name": "pipeline-multibranch-defaults",
102 | "version": "2.0",
103 | "title": "Pipeline: Multibranch with defaults",
104 | "installStatus": "Failure",
105 | "requiresRestart": "false"
106 | }, {
107 | "name": "multi-slave-config-plugin",
108 | "version": "1.2.0",
109 | "title": "Multi slave config",
110 | "installStatus": "Success",
111 | "requiresRestart": "false"
112 | }, {
113 | "name": "mock-slave",
114 | "version": "1.13",
115 | "title": "Mock Agent",
116 | "installStatus": "Success",
117 | "requiresRestart": "false"
118 | }, {
119 | "name": "jquery",
120 | "version": "1.12.4-0",
121 | "title": "jQuery",
122 | "installStatus": "Success",
123 | "requiresRestart": "false"
124 | }, {
125 | "name": "nodelabelparameter",
126 | "version": "1.7.2",
127 | "title": "Node and Label parameter",
128 | "installStatus": "Success",
129 | "requiresRestart": "false"
130 | }, {
131 | "name": "cloud-stats",
132 | "version": "0.24",
133 | "title": "Cloud Statistics",
134 | "installStatus": "Success",
135 | "requiresRestart": "false"
136 | }, {
137 | "name": "credentials",
138 | "version": "2.2.0",
139 | "title": "Credentials",
140 | "installStatus": "Pending",
141 | "requiresRestart": "false"
142 | }, {
143 | "name": "ssh-credentials",
144 | "version": "1.17.1",
145 | "title": "SSH Credentials",
146 | "installStatus": "Pending",
147 | "requiresRestart": "false"
148 | }, {
149 | "name": "openstack-cloud",
150 | "version": "2.47",
151 | "title": "OpenStack Cloud",
152 | "installStatus": "Pending",
153 | "requiresRestart": "false"
154 | }
155 | ],
156 | "state": "RESTART"
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/tests/unit/test_queue.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 |
3 | import pytest
4 |
5 | from api4jenkins.build import AsyncWorkflowRun, WorkflowRun
6 | from api4jenkins.job import AsyncWorkflowJob, WorkflowJob
7 | from api4jenkins.queue import AsyncQueueItem, QueueItem
8 |
9 | from .conftest import load_json
10 |
11 |
12 | class TestQueue:
13 |
14 | @pytest.mark.parametrize('id_, obj',
15 | [(1, type(None)), (669, QueueItem)])
16 | def test_get(self, jenkins, id_, obj):
17 | assert isinstance(jenkins.queue.get(id_), obj)
18 |
19 | def test_iter(self, jenkins):
20 | items = list(jenkins.queue)
21 | assert len(items) == 2
22 |
23 | def test_cancel(self, jenkins, respx_mock):
24 | req_url = f'{jenkins.queue.url}cancelItem?id=0'
25 | respx_mock.post(req_url)
26 | jenkins.queue.cancel(0)
27 | assert respx_mock.calls[0].request.url == req_url
28 |
29 |
30 | class TestQueueItem:
31 |
32 | @pytest.mark.parametrize('id_, type_',
33 | [(669, 'blockeditem'),
34 | (599, 'leftitem'),
35 | (700, 'waitingitem'),
36 | (668, 'buildableitem')])
37 | def test_get_job(self, jenkins, job, id_, type_, monkeypatch):
38 | item = QueueItem(jenkins, f'{jenkins.url}queue/item/{id_}/')
39 |
40 | def _api_json(tree='', depth=0):
41 | return load_json(f'queue/{type_}.json')
42 |
43 | monkeypatch.setattr(item, 'api_json', _api_json)
44 | assert job == item.get_job()
45 |
46 | @pytest.mark.parametrize('id_, type_, obj',
47 | [(669, 'blockeditem', type(None)),
48 | (599, 'leftitem', WorkflowRun),
49 | (668, 'waitingitem', WorkflowRun),
50 | (668, 'buildableitem', WorkflowRun)])
51 | def test_get_build(self, jenkins, id_, type_, obj, monkeypatch):
52 | item = QueueItem(jenkins, f'{jenkins.url}queue/item/{id_}/')
53 |
54 | def _api_json(tree='', depth=0):
55 | return load_json(f'queue/{type_}.json')
56 |
57 | monkeypatch.setattr(item, 'api_json', _api_json)
58 | build = item.get_build()
59 | assert isinstance(build, obj)
60 |
61 | def test_get_parameters(self, jenkins):
62 | item = QueueItem(jenkins, f'{jenkins.url}queue/item/668/')
63 | params = item.get_parameters()
64 | assert len(params) == 0
65 |
66 | def test_get_causes(self, jenkins):
67 | item = QueueItem(jenkins, f'{jenkins.url}queue/item/668/')
68 | causes = item.get_causes()
69 | assert causes[0]['shortDescription'] == 'Triggered by'
70 |
71 |
72 | class TestAsyncQueue:
73 |
74 | @pytest.mark.parametrize('id_, obj',
75 | [(1, type(None)), (669, AsyncQueueItem)])
76 | async def test_get(self, async_jenkins, id_, obj):
77 | assert isinstance(await async_jenkins.queue.get(id_), obj)
78 |
79 | async def test_iter(self, async_jenkins):
80 | items = [i async for i in async_jenkins.queue]
81 | assert len(items) == 2
82 |
83 | async def test_cancel(self, async_jenkins, respx_mock):
84 | req_url = f'{async_jenkins.queue.url}cancelItem?id=0'
85 | respx_mock.post(req_url)
86 | await async_jenkins.queue.cancel(0)
87 | assert respx_mock.calls[0].request.url == req_url
88 |
89 |
90 | class TestAsyncQueueItem:
91 |
92 | @pytest.mark.parametrize('id_, type_',
93 | [(669, 'blockeditem'),
94 | (599, 'leftitem'),
95 | (700, 'waitingitem'),
96 | (668, 'buildableitem')])
97 | async def test_get_job(self, async_jenkins, async_job, id_, type_, monkeypatch):
98 | item = AsyncQueueItem(
99 | async_jenkins, f'{async_jenkins.url}queue/item/{id_}/')
100 |
101 | async def _api_json(tree='', depth=0):
102 | return load_json(f'queue/{type_}.json')
103 |
104 | monkeypatch.setattr(item, 'api_json', _api_json)
105 | job = await item.get_job()
106 | assert isinstance(job, AsyncWorkflowJob)
107 | assert job == job
108 |
109 | @pytest.mark.parametrize('id_, type_, obj',
110 | [(669, 'blockeditem', type(None)),
111 | (599, 'leftitem', AsyncWorkflowRun),
112 | (668, 'waitingitem', AsyncWorkflowRun),
113 | (668, 'buildableitem', AsyncWorkflowRun)])
114 | async def test_get_build(self, async_jenkins, id_, type_, obj, monkeypatch):
115 | item = AsyncQueueItem(
116 | async_jenkins, f'{async_jenkins.url}queue/item/{id_}/')
117 |
118 | async def _api_json(tree='', depth=0):
119 | return load_json(f'queue/{type_}.json')
120 |
121 | monkeypatch.setattr(item, 'api_json', _api_json)
122 | assert isinstance(await item.get_build(), obj)
123 |
124 | async def test_get_parameters(self, async_jenkins):
125 | item = AsyncQueueItem(
126 | async_jenkins, f'{async_jenkins.url}queue/item/668/')
127 | params = await item.get_parameters()
128 | assert len(params) == 0
129 |
130 | async def test_get_causes(self, async_jenkins):
131 | item = AsyncQueueItem(
132 | async_jenkins, f'{async_jenkins.url}queue/item/668/')
133 | causes = await item.get_causes()
134 | assert causes[0]['shortDescription'] == 'Triggered by'
135 |
--------------------------------------------------------------------------------
/api4jenkins/report.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | from .item import AsyncItem, Item, camel, snake
3 |
4 |
5 | class GetMixIn:
6 |
7 | def get(self, name):
8 | return next((item for item in self if item.name == name), None)
9 |
10 |
11 | class ResultBase:
12 |
13 | def __init__(self, raw):
14 | self.raw = raw
15 |
16 | def __getattr__(self, name):
17 | camel_name = camel(name)
18 | if camel_name in self.raw:
19 | return self.raw[camel_name]
20 | return super().__getattribute__(name)
21 |
22 | def __str__(self):
23 | return f'<{type(self).__name__}: {self.name}>'
24 |
25 | def __dir__(self):
26 | return super().__dir__() + [snake(k) for k in self.raw]
27 |
28 |
29 | class TestReport(Item, GetMixIn):
30 |
31 | def __iter__(self):
32 | for suite in self.api_json()['suites']:
33 | yield TestSuite(suite)
34 |
35 | @property
36 | def suites(self):
37 | yield from self
38 |
39 |
40 | class TestSuite(ResultBase, GetMixIn):
41 |
42 | def __iter__(self):
43 | for case in self.raw['cases']:
44 | yield TestCase(case)
45 |
46 | @property
47 | def cases(self):
48 | yield from self
49 |
50 |
51 | class TestCase(ResultBase):
52 | pass
53 |
54 |
55 | class CoverageReport(Item, GetMixIn):
56 | '''Access coverage report generated by `JaCoCo `_'''
57 |
58 | report_types = ['branchCoverage', 'classCoverage', 'complexityScore',
59 | 'instructionCoverage', 'lineCoverage', 'methodCoverage']
60 |
61 | def __getattr__(self, name):
62 | attr = camel(name)
63 | if attr not in self.report_types:
64 | raise AttributeError(
65 | f"'CoverageReport' object has no attribute '{name}'")
66 | return self.get(attr)
67 |
68 | def __iter__(self):
69 | for k, v in self.api_json().items():
70 | if k not in ['_class', 'previousResult']:
71 | v['name'] = k
72 | yield Coverage(v)
73 |
74 | def trends(self, count=2):
75 | def _resolve(data):
76 | if data['previousResult']:
77 | yield from _resolve(data['previousResult'])
78 | for k, v in data.items():
79 | if k not in ['_class', 'previousResult']:
80 | v['name'] = k
81 | yield Coverage(v)
82 |
83 | yield from _resolve(self.api_json(depth=count))
84 |
85 |
86 | class Coverage(ResultBase):
87 | pass
88 |
89 |
90 | class CoverageResult(Item, GetMixIn):
91 | '''Access coverage result generated by `Code Coverage API `_'''
92 |
93 | def __iter__(self):
94 | for element in self.api_json(depth=1)['results']['elements']:
95 | yield CoverageElement(element)
96 |
97 |
98 | class CoverageElement(ResultBase):
99 | pass
100 |
101 |
102 | class CoverageTrends(Item, GetMixIn):
103 | def __iter__(self):
104 | for trend in self.api_json(depth=1)['trends']:
105 | trend['name'] = trend['buildName']
106 | yield CoverageTrend(trend)
107 |
108 |
109 | class CoverageTrend(ResultBase):
110 |
111 | def __iter__(self):
112 | for element in self.raw['elements']:
113 | yield CoverageElement(element)
114 |
115 |
116 | # async class
117 |
118 | class AsyncGetMixIn:
119 |
120 | async def get(self, name):
121 | async for item in self:
122 | if item.name == name:
123 | return item
124 |
125 |
126 | class AsyncTestReport(AsyncItem, AsyncGetMixIn):
127 |
128 | async def __aiter__(self):
129 | data = await self.api_json()
130 | for suite in data['suites']:
131 | yield TestSuite(suite)
132 |
133 | @property
134 | async def suites(self):
135 | async for suite in self:
136 | yield suite
137 |
138 |
139 | class AsyncCoverageReport(AsyncItem, AsyncGetMixIn):
140 | '''Access coverage report generated by `JaCoCo `_'''
141 |
142 | report_types = ['branchCoverage', 'classCoverage', 'complexityScore',
143 | 'instructionCoverage', 'lineCoverage', 'methodCoverage']
144 |
145 | async def __getattr__(self, name):
146 | attr = camel(name)
147 | if attr not in self.report_types:
148 | raise AttributeError(
149 | f"'CoverageReport' object has no attribute '{name}'")
150 | return await self.get(attr)
151 |
152 | async def __aiter__(self):
153 | data = await self.api_json()
154 | for k, v in data.items():
155 | if k not in ['_class', 'previousResult']:
156 | v['name'] = k
157 | yield Coverage(v)
158 |
159 | async def trends(self, count=2):
160 | def _resolve(data):
161 | if data['previousResult']:
162 | yield from _resolve(data['previousResult'])
163 | for k, v in data.items():
164 | if k not in ['_class', 'previousResult']:
165 | v['name'] = k
166 | yield Coverage(v)
167 | data = await self.api_json(depth=count)
168 | for c in _resolve(data):
169 | yield c
170 |
171 |
172 | class AsyncCoverageResult(AsyncItem, AsyncGetMixIn):
173 | '''Access coverage result generated by `Code Coverage API `_'''
174 |
175 | async def __aiter__(self):
176 | data = await self.api_json(depth=1)
177 | for element in data['results']['elements']:
178 | yield CoverageElement(element)
179 |
180 |
181 | class AsyncCoverageTrends(AsyncItem, AsyncGetMixIn):
182 | async def __aiter__(self):
183 | data = await self.api_json(depth=1)
184 | for trend in data['trends']:
185 | trend['name'] = trend['buildName']
186 | yield CoverageTrend(trend)
187 |
--------------------------------------------------------------------------------
/api4jenkins/queue.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | import re
3 |
4 | from .item import AsyncItem, Item
5 | from .mix import (ActionsMixIn, AsyncActionsMixIn)
6 |
7 |
8 | class Queue(Item):
9 | def get(self, id):
10 | for item in self.api_json(tree='items[id,url]')['items']:
11 | if item['id'] == int(id):
12 | return QueueItem(self.jenkins,
13 | f"{self.jenkins.url}{item['url']}")
14 | return None
15 |
16 | def cancel(self, id):
17 | self.handle_req('POST', 'cancelItem', params={'id': id})
18 |
19 | def __iter__(self):
20 | for item in self.api_json(tree='items[url]')['items']:
21 | yield QueueItem(self.jenkins, f"{self.jenkins.url}{item['url']}")
22 |
23 | # https://javadoc.jenkins.io/hudson/model/Queue.html#buildables
24 | # (enter) --> waitingList --+--> blockedProjects
25 | # | ^
26 | # | |
27 | # | v
28 | # +--> buildables ---> pending ---> left
29 | # ^ |
30 | # | |
31 | # +---(rarely)---+
32 |
33 |
34 | class QueueItem(Item, ActionsMixIn):
35 |
36 | def __init__(self, jenkins, url):
37 | if not url.startswith('https') and jenkins.url.startswith('https'):
38 | url = re.sub(r'^http[s]', 'https', url)
39 | super().__init__(jenkins, url)
40 | self.id = int(self.url.split('/')[-2])
41 | self._build = None
42 |
43 | def get_job(self):
44 | if self._class.endswith('$BuildableItem'):
45 | return self.get_build().project
46 | task = self.api_json(tree='task[url]')['task']
47 | return self._new_item('api4jenkins.job', task)
48 |
49 | def get_build(self):
50 | if not self._build:
51 | _class = self._class
52 | # BlockedItem does not have build
53 | if _class.endswith('$LeftItem'):
54 | executable = self.api_json('executable[url]')['executable']
55 | self._build = self._new_item(
56 | 'api4jenkins.build', executable)
57 | elif _class.endswith(('$BuildableItem', '$WaitingItem')):
58 | for build in self.jenkins.nodes.iter_builds():
59 | # https://javadoc.jenkins.io/hudson/model/Run.html#getQueueId--
60 | # https://javadoc.jenkins.io/hudson/model/Queue.Item.html#getId--
61 | # ensure build exists, see https://github.com/joelee2012/api4jenkins/issues/49
62 | if build.exists() and int(build.queue_id) == self.id:
63 | self._build = build
64 | break
65 | return self._build
66 |
67 | def cancel(self):
68 | self.jenkins.queue.cancel(self.id)
69 |
70 | # due to item type is dynamic
71 |
72 |
73 | class BuildableItem(QueueItem):
74 | pass
75 |
76 |
77 | class BlockedItem(QueueItem):
78 | pass
79 |
80 |
81 | class LeftItem(QueueItem):
82 | pass
83 |
84 |
85 | class WaitingItem(QueueItem):
86 | pass
87 |
88 | # async class
89 |
90 |
91 | class AsyncQueue(AsyncItem):
92 |
93 | async def get(self, id):
94 | for item in (await self.api_json(tree='items[id,url]'))['items']:
95 | if item['id'] == int(id):
96 | return AsyncQueueItem(self.jenkins,
97 | f"{self.jenkins.url}{item['url']}")
98 | return None
99 |
100 | async def cancel(self, id):
101 | await self.handle_req('POST', 'cancelItem', params={'id': id})
102 |
103 | async def __aiter__(self):
104 | for item in (await self.api_json(tree='items[url]'))['items']:
105 | yield AsyncQueueItem(self.jenkins, f"{self.jenkins.url}{item['url']}")
106 |
107 |
108 | class AsyncQueueItem(AsyncItem, AsyncActionsMixIn):
109 |
110 | def __init__(self, jenkins, url):
111 | if not url.startswith('https') and jenkins.url.startswith('https'):
112 | url = re.sub(r'^http[s]', 'https', url)
113 | super().__init__(jenkins, url)
114 | self.id = int(self.url.split('/')[-2])
115 | self._build = None
116 |
117 | async def get_job(self):
118 | _class = await self._class
119 | if _class.endswith('$BuildableItem'):
120 | build = await self.get_build()
121 | return await build.project
122 | data = await self.api_json(tree='task[url]')
123 | return self._new_item('api4jenkins.job', data['task'])
124 |
125 | async def get_build(self):
126 | if not self._build:
127 | _class = await self._class
128 | # BlockedItem does not have build
129 | if _class.endswith('$LeftItem'):
130 | data = await self.api_json('executable[url]')
131 | self._build = self._new_item(
132 | 'api4jenkins.build', data['executable'])
133 | elif _class.endswith(('$BuildableItem', '$WaitingItem')):
134 | async for build in self.jenkins.nodes.iter_builds():
135 | # https://javadoc.jenkins.io/hudson/model/Run.html#getQueueId--
136 | # https://javadoc.jenkins.io/hudson/model/Queue.Item.html#getId--
137 | # ensure build exists, see https://github.com/joelee2012/api4jenkins/issues/49
138 | if await build.exists() and int(await build.queue_id) == self.id:
139 | self._build = build
140 | break
141 | return self._build
142 |
143 | async def cancel(self):
144 | await self.jenkins.queue.cancel(self.id)
145 |
146 |
147 | class AsyncBuildableItem(AsyncQueueItem):
148 | pass
149 |
150 |
151 | class AsyncBlockedItem(AsyncQueueItem):
152 | pass
153 |
154 |
155 | class AsyncLeftItem(AsyncQueueItem):
156 | pass
157 |
158 |
159 | class AsyncWaitingItem(AsyncQueueItem):
160 | pass
161 |
--------------------------------------------------------------------------------
/api4jenkins/item.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 |
3 | import contextlib
4 | import re
5 | from importlib import import_module
6 |
7 | import api4jenkins
8 |
9 | from .exceptions import ItemNotFoundError
10 |
11 |
12 | def camel(s):
13 | if s[0] == '_':
14 | return s
15 | first, *other = s.split('_')
16 | return first.lower() + ''.join(x.title() for x in other)
17 |
18 |
19 | def _snake():
20 | pattern = re.compile(r'(?'
74 |
75 | def _add_crumb(self, crumb, kwargs):
76 | if crumb:
77 | headers = kwargs.get('headers', {})
78 | headers.update(crumb)
79 | kwargs['headers'] = headers
80 |
81 | @classmethod
82 | def _get_attr_names(cls, api_json):
83 | types = (int, str, bool, type(None))
84 | cls._attr_names = [snake(k)
85 | for k in api_json if isinstance(api_json[k], types)]
86 |
87 |
88 | class Item(BaseItem):
89 |
90 | def api_json(self, tree='', depth=0):
91 | params = {'depth': depth}
92 | if tree:
93 | params['tree'] = tree
94 | return self.handle_req('GET', 'api/json', params=params).json()
95 |
96 | def handle_req(self, method, entry, **kwargs):
97 | self._add_crumb(self.jenkins.crumb, kwargs)
98 | return self._request(method, self.url + entry, **kwargs)
99 |
100 | @contextlib.contextmanager
101 | def handle_stream(self, method, entry, **kwargs):
102 | self._add_crumb(self.jenkins.crumb, kwargs)
103 | with self._stream(method, self.url + entry, **kwargs) as response:
104 | yield response
105 |
106 | def exists(self):
107 | try:
108 | self.api_json(tree='_class')
109 | return True
110 | except ItemNotFoundError:
111 | return False
112 |
113 | @property
114 | def dynamic_attrs(self):
115 | if not self._attr_names:
116 | self._get_attr_names(self.api_json())
117 | return self._attr_names
118 |
119 | def __getattr__(self, name):
120 | if name in self.dynamic_attrs:
121 | attr = camel(name)
122 | return self.api_json(tree=attr)[attr]
123 | return super().__getattribute__(name)
124 |
125 | def __getitem__(self, name):
126 | if hasattr(self, 'get'):
127 | return self.get(name)
128 | raise TypeError(f"'{type(self).__name__}' object is not subscriptable")
129 |
130 | def iter(self):
131 | raise TypeError(f"'{type(self).__name__}' object is not iterable")
132 |
133 | def __iter__(self):
134 | yield from self.iter()
135 |
136 |
137 | class AsyncItem(BaseItem):
138 |
139 | async def api_json(self, tree='', depth=0):
140 | params = {'depth': depth}
141 | if tree:
142 | params['tree'] = tree
143 | return (await self.handle_req('GET', 'api/json', params=params)).json()
144 |
145 | async def handle_req(self, method, entry, **kwargs):
146 | self._add_crumb(await self.jenkins.crumb, kwargs)
147 | return await self._request(method, self.url + entry, **kwargs)
148 |
149 | @contextlib.asynccontextmanager
150 | async def handle_stream(self, method, entry, **kwargs):
151 | self._add_crumb(await self.jenkins.crumb, kwargs)
152 | async with self._stream(method, self.url + entry, **kwargs) as response:
153 | yield response
154 |
155 | async def exists(self):
156 | try:
157 | await self.api_json(tree='_class')
158 | return True
159 | except ItemNotFoundError:
160 | return False
161 |
162 | @property
163 | async def dynamic_attrs(self):
164 | if not self._attr_names:
165 | self._get_attr_names(await self.api_json())
166 | return self._attr_names
167 |
168 | async def __getattr__(self, name):
169 | if name in (await self.dynamic_attrs):
170 | attr = camel(name)
171 | return (await self.api_json(tree=attr))[attr]
172 | return super().__getattribute__(name)
173 |
174 | async def __getitem__(self, name):
175 | if hasattr(self, 'get'):
176 | return await self.get(name)
177 | raise TypeError(f"'{type(self).__name__}' object is not subscriptable")
178 |
179 | async def aiter(self):
180 | raise TypeError(f"'{type(self).__name__}' object is not iterable")
181 |
182 | async def __aiter__(self):
183 | async for item in self.aiter():
184 | yield item
185 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/plugin/installStatus_done.json:
--------------------------------------------------------------------------------
1 | {
2 | "status": "ok",
3 | "data": {
4 | "jobs": [{
5 | "name": "nant",
6 | "version": "1.4.3",
7 | "title": "NAnt",
8 | "installStatus": "Success",
9 | "requiresRestart": "false"
10 | }, {
11 | "name": "cloudbees-folder",
12 | "version": "6.9",
13 | "title": "Folders",
14 | "installStatus": "SuccessButRequiresRestart",
15 | "requiresRestart": "true"
16 | }, {
17 | "name": "scm-api",
18 | "version": "2.6.3",
19 | "title": "SCM API",
20 | "installStatus": "SuccessButRequiresRestart",
21 | "requiresRestart": "true"
22 | }, {
23 | "name": "structs",
24 | "version": "1.19",
25 | "title": "Structs",
26 | "installStatus": "SuccessButRequiresRestart",
27 | "requiresRestart": "true"
28 | }, {
29 | "name": "script-security",
30 | "version": "1.61",
31 | "title": "Script Security",
32 | "installStatus": "SuccessButRequiresRestart",
33 | "requiresRestart": "true"
34 | }, {
35 | "name": "workflow-step-api",
36 | "version": "2.20",
37 | "title": "Pipeline: Step API",
38 | "installStatus": "SuccessButRequiresRestart",
39 | "requiresRestart": "true"
40 | }, {
41 | "name": "workflow-api",
42 | "version": "2.35",
43 | "title": "Pipeline: API",
44 | "installStatus": "SuccessButRequiresRestart",
45 | "requiresRestart": "true"
46 | }, {
47 | "name": "workflow-support",
48 | "version": "3.3",
49 | "title": "Pipeline: Supporting APIs",
50 | "installStatus": "SuccessButRequiresRestart",
51 | "requiresRestart": "true"
52 | }, {
53 | "name": "workflow-cps",
54 | "version": "2.72",
55 | "title": "Pipeline: Groovy",
56 | "installStatus": "SuccessButRequiresRestart",
57 | "requiresRestart": "true"
58 | }, {
59 | "name": "config-file-provider",
60 | "version": "3.6.2",
61 | "title": "Config File Provider",
62 | "installStatus": "SuccessButRequiresRestart",
63 | "requiresRestart": "true"
64 | }, {
65 | "name": "workflow-job",
66 | "version": "2.33",
67 | "title": "Pipeline: Job",
68 | "installStatus": "SuccessButRequiresRestart",
69 | "requiresRestart": "true"
70 | }, {
71 | "name": "branch-api",
72 | "version": "2.5.3",
73 | "title": "Branch API",
74 | "installStatus": "SuccessButRequiresRestart",
75 | "requiresRestart": "true"
76 | }, {
77 | "name": "workflow-multibranch",
78 | "version": "2.21",
79 | "title": "Pipeline: Multibranch",
80 | "installStatus": "SuccessButRequiresRestart",
81 | "requiresRestart": "true"
82 | }, {
83 | "name": "pipeline-multibranch-defaults",
84 | "version": "2.0",
85 | "title": "Pipeline: Multibranch with defaults",
86 | "installStatus": "Failure",
87 | "requiresRestart": "false"
88 | }, {
89 | "name": "ownership",
90 | "version": "0.12.1",
91 | "title": "Job and Node ownership",
92 | "installStatus": "Success",
93 | "requiresRestart": "false"
94 | }, {
95 | "name": "nomad",
96 | "version": "0.6.5",
97 | "title": "Nomad",
98 | "installStatus": "Success",
99 | "requiresRestart": "false"
100 | }, {
101 | "name": "pipeline-multibranch-defaults",
102 | "version": "2.0",
103 | "title": "Pipeline: Multibranch with defaults",
104 | "installStatus": "Failure",
105 | "requiresRestart": "false"
106 | }, {
107 | "name": "multi-slave-config-plugin",
108 | "version": "1.2.0",
109 | "title": "Multi slave config",
110 | "installStatus": "Success",
111 | "requiresRestart": "false"
112 | }, {
113 | "name": "mock-slave",
114 | "version": "1.13",
115 | "title": "Mock Agent",
116 | "installStatus": "Success",
117 | "requiresRestart": "false"
118 | }, {
119 | "name": "jquery",
120 | "version": "1.12.4-0",
121 | "title": "jQuery",
122 | "installStatus": "Success",
123 | "requiresRestart": "false"
124 | }, {
125 | "name": "nodelabelparameter",
126 | "version": "1.7.2",
127 | "title": "Node and Label parameter",
128 | "installStatus": "Success",
129 | "requiresRestart": "false"
130 | }, {
131 | "name": "cloud-stats",
132 | "version": "0.24",
133 | "title": "Cloud Statistics",
134 | "installStatus": "Success",
135 | "requiresRestart": "false"
136 | }, {
137 | "name": "credentials",
138 | "version": "2.2.0",
139 | "title": "Credentials",
140 | "installStatus": "SuccessButRequiresRestart",
141 | "requiresRestart": "true"
142 | }, {
143 | "name": "ssh-credentials",
144 | "version": "1.17.1",
145 | "title": "SSH Credentials",
146 | "installStatus": "SuccessButRequiresRestart",
147 | "requiresRestart": "true"
148 | }, {
149 | "name": "openstack-cloud",
150 | "version": "2.47",
151 | "title": "OpenStack Cloud",
152 | "installStatus": "Failure",
153 | "requiresRestart": "false"
154 | }, {
155 | "name": "vs-code-metrics",
156 | "version": "1.7",
157 | "title": "Visual Studio Code Metrics",
158 | "installStatus": "Success",
159 | "requiresRestart": "false"
160 | }, {
161 | "name": "vs-code-metrics",
162 | "version": "1.7",
163 | "title": "Visual Studio Code Metrics",
164 | "installStatus": "Success",
165 | "requiresRestart": "false"
166 | }, {
167 | "name": "vstestrunner",
168 | "version": "1.0.8",
169 | "title": "VSTest Runner",
170 | "installStatus": "Success",
171 | "requiresRestart": "false"
172 | }, {
173 | "name": "mstest",
174 | "version": "1.0.0",
175 | "title": "MSTest",
176 | "installStatus": "Success",
177 | "requiresRestart": "false"
178 | }
179 | ],
180 | "state": "RESTART"
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/tests/unit/test_job.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | import pytest
3 |
4 | from api4jenkins.build import AsyncWorkflowRun, WorkflowRun
5 | from api4jenkins.item import snake
6 |
7 |
8 | class TestFolder:
9 |
10 | def test_parent(self, folder, jenkins, job):
11 | assert job.parent == folder
12 | assert folder.parent == jenkins
13 |
14 | @pytest.mark.parametrize('req, xml, body',
15 | [('GET', None, ''), ('POST', '', '')],
16 | ids=['get', 'set'])
17 | def test_configure(self, folder, respx_mock, req, xml, body):
18 | req_url = f'{folder.url}config.xml'
19 | respx_mock.route(method=req, url=req_url).respond(content=body)
20 | text = folder.configure(xml)
21 | assert respx_mock.calls[0].request.url == req_url
22 |
23 |
24 | class TestWorkflowMultiBranchProject:
25 |
26 | def test_scan(self, multi_job, respx_mock):
27 | req_url = f'{multi_job.url}build?delay=0'
28 | respx_mock.post(req_url)
29 | multi_job.scan()
30 | assert respx_mock.calls[0].request.url == req_url
31 |
32 | def test_get_scan_log(self, multi_job, respx_mock):
33 | body = 'a\nb'
34 | respx_mock.get(
35 | f'{multi_job.url}indexing/consoleText').respond(content=body)
36 | assert list(multi_job.get_scan_log()) == body.split('\n')
37 |
38 |
39 | class TestOrganizationFolder:
40 | def test_get_scan_log(self, org_job, respx_mock):
41 | body = 'a\nb'
42 | respx_mock.get(
43 | f'{org_job.url}computation/consoleText').respond(content=body)
44 | assert list(org_job.get_scan_log()) == body.split('\n')
45 |
46 |
47 | class TestProject:
48 |
49 | @pytest.mark.parametrize('number, obj',
50 | [(52, WorkflowRun), (100, type(None))],
51 | ids=['exist', 'not exist'])
52 | def test_get_build(self, job, number, obj):
53 | build = job[number]
54 | assert isinstance(build, obj)
55 | build = job[f'#{number}']
56 | assert isinstance(build, obj)
57 |
58 | @pytest.mark.parametrize('key', ['firstBuild', 'lastBuild', 'lastCompletedBuild',
59 | 'lastFailedBuild', 'lastStableBuild', 'lastUnstableBuild',
60 | 'lastSuccessfulBuild', 'lastUnsuccessfulBuild'])
61 | def test_get_(self, job, key):
62 | build = getattr(job, snake(f'get_{key}'))()
63 | assert isinstance(build, WorkflowRun)
64 | assert build.url == job.api_json()[key]['url']
65 |
66 | def test_iter_builds(self, job):
67 | builds = list(job)
68 | assert len(builds) == 8
69 |
70 | @pytest.mark.parametrize('action', ['enable', 'disable'])
71 | def test_enable_disable(self, job, respx_mock, action):
72 | req_url = f'{job.url}{action}'
73 | respx_mock.post(req_url)
74 | getattr(job, action)()
75 | assert respx_mock.calls[0].request.url == req_url
76 |
77 | def test_building(self, job):
78 | assert job.building is False
79 |
80 |
81 | class TestAsyncFolder:
82 |
83 | async def test_parent(self, async_folder, async_jenkins, async_job):
84 | assert await async_folder.parent == async_jenkins
85 | assert await async_job.parent == async_folder
86 |
87 | @pytest.mark.parametrize('req, xml, body',
88 | [('GET', None, ''), ('POST', '', '')],
89 | ids=['get', 'set'])
90 | async def test_configure(self, async_folder, req, xml, body, respx_mock):
91 | req_url = f'{async_folder.url}config.xml'
92 | respx_mock.route(method=req, url=req_url).respond(content=body)
93 | text = await async_folder.configure(xml)
94 | assert respx_mock.calls[0].request.url == req_url
95 |
96 |
97 | class TestAsyncWorkflowMultiBranchProject:
98 |
99 | async def test_scan(self, async_multi_job, respx_mock):
100 | req_url = f'{async_multi_job.url}build?delay=0'
101 | respx_mock.post(req_url)
102 | await async_multi_job.scan()
103 | assert respx_mock.calls[0].request.url == req_url
104 |
105 | async def test_get_scan_log(self, async_multi_job, respx_mock):
106 | body = 'a\nb'
107 | respx_mock.get(
108 | f'{async_multi_job.url}indexing/consoleText').respond(content=body)
109 | assert [line async for line in async_multi_job.get_scan_log()] == body.split('\n')
110 |
111 |
112 | class TestAsyncOrganizationFolder:
113 | async def test_get_scan_log(self, async_org_job, respx_mock):
114 | body = 'a\nb'
115 | respx_mock.get(
116 | f'{async_org_job.url}computation/consoleText').respond(content=body)
117 | assert [line async for line in async_org_job.get_scan_log()] == body.split('\n')
118 |
119 |
120 | class TestAsyncProject:
121 |
122 | @pytest.mark.parametrize('number, obj',
123 | [(52, AsyncWorkflowRun), (100, type(None))],
124 | ids=['exist', 'not exist'])
125 | async def test_get_build(self, async_job, number, obj):
126 | build = await async_job[number]
127 | assert isinstance(build, obj)
128 | build = await async_job[f'#{number}']
129 | assert isinstance(build, obj)
130 |
131 | @pytest.mark.parametrize('key', ['firstBuild', 'lastBuild', 'lastCompletedBuild',
132 | 'lastFailedBuild', 'lastStableBuild', 'lastUnstableBuild',
133 | 'lastSuccessfulBuild', 'lastUnsuccessfulBuild'])
134 | async def test_get_(self, async_job, key):
135 | build = await getattr(async_job, snake(f'get_{key}'))()
136 | job_json = await async_job.api_json()
137 | assert isinstance(build, AsyncWorkflowRun)
138 | assert build.url == job_json[key]['url']
139 |
140 | async def test_iter_builds(self, async_job):
141 | builds = [b async for b in async_job]
142 | assert len(builds) == 8
143 |
144 | @pytest.mark.parametrize('action', ['enable', 'disable'])
145 | async def test_enable_disable(self, async_job, respx_mock, action):
146 | req_url = f'{async_job.url}{action}'
147 | respx_mock.post(req_url)
148 | await getattr(async_job, action)()
149 | assert respx_mock.calls[0].request.url == req_url
150 |
151 | async def test_building(self, async_job):
152 | assert await async_job.building is False
153 |
--------------------------------------------------------------------------------
/api4jenkins/plugin.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | import json
3 | import time
4 | import xml.etree.ElementTree as ET
5 |
6 | from .item import AsyncItem, Item
7 |
8 |
9 | class PluginsManager(Item):
10 |
11 | def get(self, name):
12 | for plugin in self.api_json(tree='plugins[shortName]')['plugins']:
13 | if plugin['shortName'] == name:
14 | return Plugin(self.jenkins, f'{self.url}plugin/{name}/')
15 | return None
16 |
17 | def install(self, *names, block=False):
18 | plugin_xml = ET.Element('jenkins')
19 | for name in names:
20 | if '@' not in name:
21 | name += '@latest'
22 | ET.SubElement(plugin_xml, 'install', {'plugin': name})
23 | self.handle_req('POST', 'installNecessaryPlugins',
24 | headers=self.headers,
25 | content=ET.tostring(plugin_xml))
26 |
27 | while block and not self.installation_done:
28 | time.sleep(2)
29 |
30 | def uninstall(self, *names):
31 | for name in names:
32 | self.handle_req('POST', f'plugin/{name}/doUninstall')
33 |
34 | def set_site(self, url):
35 | self.handle_req('POST', 'siteConfigure', params={'site': url})
36 | self.check_updates_server()
37 |
38 | def check_updates_server(self):
39 | self.handle_req('POST', 'checkUpdatesServer')
40 |
41 | @property
42 | def update_center(self):
43 | return UpdateCenter(self.jenkins, f'{self.jenkins.url}updateCenter/')
44 |
45 | @property
46 | def site(self):
47 | return self.update_center.site
48 |
49 | @property
50 | def restart_required(self):
51 | return self.update_center.restart_required
52 |
53 | @property
54 | def installation_done(self):
55 | return self.update_center.installation_done
56 |
57 | def set_proxy(self, name, port, *, username='',
58 | password='', no_proxy='', test_url=''):
59 | data = {'name': name, 'port': port, 'userName': username,
60 | 'password': password, 'noProxyHost': no_proxy,
61 | 'testUrl': test_url}
62 | self.handle_req('POST', 'proxyConfigure', data={
63 | 'json': json.dumps(data)})
64 |
65 | def __iter__(self):
66 | for plugin in self.api_json(tree='plugins[shortName]')['plugins']:
67 | yield Plugin(self.jenkins,
68 | f'{self.url}plugin/{plugin["shortName"]}/')
69 |
70 |
71 | class Plugin(Item):
72 | def uninstall(self):
73 | self.handle_req('POST', 'doUninstall')
74 |
75 |
76 | class UpdateCenter(Item):
77 |
78 | @property
79 | def installation_done(self):
80 | resp = self.handle_req('GET', 'installStatus')
81 | return all(job['installStatus'] != 'Pending'
82 | for job in resp.json()['data']['jobs'])
83 |
84 | @property
85 | def restart_required(self):
86 | return self.api_json(tree='restartRequiredForCompletion').get(
87 | 'restartRequiredForCompletion')
88 |
89 | @property
90 | def site(self):
91 | return self.api_json(tree='sites[url]')['sites'][0].get('url')
92 |
93 |
94 | # async class
95 |
96 | class AsyncPluginsManager(AsyncItem):
97 |
98 | async def get(self, name):
99 | data = await self.api_json(tree='plugins[shortName]')
100 | for plugin in data['plugins']:
101 | if plugin['shortName'] == name:
102 | return AsyncPlugin(self.jenkins, f'{self.url}plugin/{name}/')
103 | return None
104 |
105 | async def install(self, *names, block=False):
106 | plugin_xml = ET.Element('jenkins')
107 | for name in names:
108 | if '@' not in name:
109 | name += '@latest'
110 | ET.SubElement(plugin_xml, 'install', {'plugin': name})
111 | await self.handle_req('POST', 'installNecessaryPlugins',
112 | headers=self.headers,
113 | content=ET.tostring(plugin_xml))
114 |
115 | while block and not await self.installation_done:
116 | time.sleep(2)
117 |
118 | async def uninstall(self, *names):
119 | for name in names:
120 | await self.handle_req('POST', f'plugin/{name}/doUninstall')
121 |
122 | async def set_site(self, url):
123 | await self.handle_req('POST', 'siteConfigure', params={'site': url})
124 | await self.check_updates_server()
125 |
126 | async def check_updates_server(self):
127 | await self.handle_req('POST', 'checkUpdatesServer')
128 |
129 | @property
130 | def update_center(self):
131 | return AsyncUpdateCenter(self.jenkins, f'{self.jenkins.url}updateCenter/')
132 |
133 | @property
134 | def site(self):
135 | return self.update_center.site
136 |
137 | @property
138 | def restart_required(self):
139 | return self.update_center.restart_required
140 |
141 | @property
142 | def installation_done(self):
143 | return self.update_center.installation_done
144 |
145 | async def set_proxy(self, name, port, *, username='',
146 | password='', no_proxy='', test_url=''):
147 | data = {'name': name, 'port': port, 'userName': username,
148 | 'password': password, 'noProxyHost': no_proxy,
149 | 'testUrl': test_url}
150 | await self.handle_req('POST', 'proxyConfigure', data={
151 | 'json': json.dumps(data)})
152 |
153 | async def __aiter__(self):
154 | data = await self.api_json(tree='plugins[shortName]')
155 | for plugin in data['plugins']:
156 | yield AsyncPlugin(self.jenkins,
157 | f'{self.url}plugin/{plugin["shortName"]}/')
158 |
159 |
160 | class AsyncPlugin(AsyncItem):
161 | async def uninstall(self):
162 | await self.handle_req('POST', 'doUninstall')
163 |
164 |
165 | class AsyncUpdateCenter(AsyncItem):
166 |
167 | @property
168 | async def installation_done(self):
169 | resp = await self.handle_req('GET', 'installStatus')
170 | return all(job['installStatus'] != 'Pending'
171 | for job in resp.json()['data']['jobs'])
172 |
173 | @property
174 | async def restart_required(self):
175 | data = await self.api_json(tree='restartRequiredForCompletion')
176 | return data.get('restartRequiredForCompletion')
177 |
178 | @property
179 | async def site(self):
180 | data = await self.api_json(tree='sites[url]')
181 | return data['sites'][0].get('url')
182 |
--------------------------------------------------------------------------------
/tests/unit/tests_data/plugin/plugin.json:
--------------------------------------------------------------------------------
1 | {
2 | "_class": "hudson.LocalPluginManager",
3 | "plugins": [
4 | {
5 | "shortName": "jquery-detached"
6 | },
7 | {
8 | "shortName": "bouncycastle-api"
9 | },
10 | {
11 | "shortName": "htmlpublisher"
12 | },
13 | {
14 | "shortName": "plain-credentials"
15 | },
16 | {
17 | "shortName": "pam-auth"
18 | },
19 | {
20 | "shortName": "resource-disposer"
21 | },
22 | {
23 | "shortName": "timestamper"
24 | },
25 | {
26 | "shortName": "docker-plugin"
27 | },
28 | {
29 | "shortName": "pipeline-model-extensions"
30 | },
31 | {
32 | "shortName": "cvs"
33 | },
34 | {
35 | "shortName": "pipeline-build-step"
36 | },
37 | {
38 | "shortName": "pipeline-model-api"
39 | },
40 | {
41 | "shortName": "workflow-cps-global-lib"
42 | },
43 | {
44 | "shortName": "workflow-job"
45 | },
46 | {
47 | "shortName": "analysis-core"
48 | },
49 | {
50 | "shortName": "ws-cleanup"
51 | },
52 | {
53 | "shortName": "branch-api"
54 | },
55 | {
56 | "shortName": "maven-plugin"
57 | },
58 | {
59 | "shortName": "matrix-auth"
60 | },
61 | {
62 | "shortName": "git-server"
63 | },
64 | {
65 | "shortName": "github-api"
66 | },
67 | {
68 | "shortName": "antisamy-markup-formatter"
69 | },
70 | {
71 | "shortName": "ownership"
72 | },
73 | {
74 | "shortName": "build-view-column"
75 | },
76 | {
77 | "shortName": "command-launcher"
78 | },
79 | {
80 | "shortName": "handlebars"
81 | },
82 | {
83 | "shortName": "git"
84 | },
85 | {
86 | "shortName": "gradle"
87 | },
88 | {
89 | "shortName": "jsch"
90 | },
91 | {
92 | "shortName": "workflow-scm-step"
93 | },
94 | {
95 | "shortName": "vstestrunner"
96 | },
97 | {
98 | "shortName": "apache-httpcomponents-client-4-api"
99 | },
100 | {
101 | "shortName": "pipeline-input-step"
102 | },
103 | {
104 | "shortName": "pipeline-multibranch-defaults"
105 | },
106 | {
107 | "shortName": "jdk-tool"
108 | },
109 | {
110 | "shortName": "external-monitor-job"
111 | },
112 | {
113 | "shortName": "mailer"
114 | },
115 | {
116 | "shortName": "ant"
117 | },
118 | {
119 | "shortName": "nant"
120 | },
121 | {
122 | "shortName": "workflow-api"
123 | },
124 | {
125 | "shortName": "pipeline-model-declarative-agent"
126 | },
127 | {
128 | "shortName": "github-organization-folder"
129 | },
130 | {
131 | "shortName": "pipeline-github-lib"
132 | },
133 | {
134 | "shortName": "token-macro"
135 | },
136 | {
137 | "shortName": "git-client"
138 | },
139 | {
140 | "shortName": "jackson2-api"
141 | },
142 | {
143 | "shortName": "ssh-credentials"
144 | },
145 | {
146 | "shortName": "cloudbees-folder"
147 | },
148 | {
149 | "shortName": "job-restrictions"
150 | },
151 | {
152 | "shortName": "gerrit-trigger"
153 | },
154 | {
155 | "shortName": "build-timeout"
156 | },
157 | {
158 | "shortName": "email-ext"
159 | },
160 | {
161 | "shortName": "workflow-basic-steps"
162 | },
163 | {
164 | "shortName": "mapdb-api"
165 | },
166 | {
167 | "shortName": "mock-slave"
168 | },
169 | {
170 | "shortName": "matrix-project"
171 | },
172 | {
173 | "shortName": "durable-task"
174 | },
175 | {
176 | "shortName": "subversion"
177 | },
178 | {
179 | "shortName": "mstest"
180 | },
181 | {
182 | "shortName": "javadoc"
183 | },
184 | {
185 | "shortName": "pipeline-stage-step"
186 | },
187 | {
188 | "shortName": "display-url-api"
189 | },
190 | {
191 | "shortName": "junit"
192 | },
193 | {
194 | "shortName": "cloud-stats"
195 | },
196 | {
197 | "shortName": "pipeline-milestone-step"
198 | },
199 | {
200 | "shortName": "synopsys-coverity"
201 | },
202 | {
203 | "shortName": "workflow-aggregator"
204 | },
205 | {
206 | "shortName": "ldap"
207 | },
208 | {
209 | "shortName": "momentjs"
210 | },
211 | {
212 | "shortName": "nodelabelparameter"
213 | },
214 | {
215 | "shortName": "jquery"
216 | },
217 | {
218 | "shortName": "authentication-tokens"
219 | },
220 | {
221 | "shortName": "workflow-multibranch"
222 | },
223 | {
224 | "shortName": "workflow-durable-task-step"
225 | },
226 | {
227 | "shortName": "script-security"
228 | },
229 | {
230 | "shortName": "ace-editor"
231 | },
232 | {
233 | "shortName": "docker-java-api"
234 | },
235 | {
236 | "shortName": "groovy-label-assignment"
237 | },
238 | {
239 | "shortName": "github-branch-source"
240 | },
241 | {
242 | "shortName": "pipeline-stage-tags-metadata"
243 | },
244 | {
245 | "shortName": "workflow-cps"
246 | },
247 | {
248 | "shortName": "config-file-provider"
249 | },
250 | {
251 | "shortName": "pipeline-graph-analysis"
252 | },
253 | {
254 | "shortName": "lockable-resources"
255 | },
256 | {
257 | "shortName": "workflow-support"
258 | },
259 | {
260 | "shortName": "vs-code-metrics"
261 | },
262 | {
263 | "shortName": "pipeline-rest-api"
264 | },
265 | {
266 | "shortName": "docker-commons"
267 | },
268 | {
269 | "shortName": "ssh-slaves"
270 | },
271 | {
272 | "shortName": "pipeline-model-definition"
273 | },
274 | {
275 | "shortName": "github"
276 | },
277 | {
278 | "shortName": "scm-api"
279 | },
280 | {
281 | "shortName": "docker-workflow"
282 | },
283 | {
284 | "shortName": "credentials"
285 | },
286 | {
287 | "shortName": "windows-slaves"
288 | },
289 | {
290 | "shortName": "pipeline-stage-view"
291 | },
292 | {
293 | "shortName": "credentials-binding"
294 | },
295 | {
296 | "shortName": "workflow-step-api"
297 | },
298 | {
299 | "shortName": "structs"
300 | },
301 | {
302 | "shortName": "openstack-cloud"
303 | },
304 | {
305 | "shortName": "multi-slave-config-plugin"
306 | }
307 | ]
308 | }
--------------------------------------------------------------------------------
/tests/unit/conftest.py:
--------------------------------------------------------------------------------
1 | import json
2 | from pathlib import Path
3 |
4 | import pytest
5 |
6 | from api4jenkins import AsyncJenkins, Jenkins
7 | from api4jenkins.build import (AsyncFreeStyleBuild, AsyncWorkflowRun,
8 | FreeStyleBuild, WorkflowRun)
9 | from api4jenkins.credential import (AsyncCredential, AsyncCredentials,
10 | AsyncDomain, Credential, Credentials,
11 | Domain)
12 | from api4jenkins.item import AsyncItem, Item
13 | from api4jenkins.job import (AsyncFolder, AsyncOrganizationFolder,
14 | AsyncWorkflowJob, AsyncWorkflowMultiBranchProject,
15 | Folder, OrganizationFolder, WorkflowJob,
16 | WorkflowMultiBranchProject)
17 | from api4jenkins.node import AsyncNode, AsyncNodes, Node, Nodes
18 | from api4jenkins.plugin import AsyncPluginsManager, PluginsManager
19 | from api4jenkins.queue import AsyncQueue, AsyncQueueItem, Queue, QueueItem
20 | from api4jenkins.report import (AsyncCoverageReport, AsyncCoverageResult,
21 | AsyncTestReport, CoverageReport,
22 | CoverageResult, TestReport)
23 | from api4jenkins.view import AllView, AsyncAllView
24 |
25 | DATA = Path(__file__).with_name('tests_data')
26 |
27 |
28 | def _api_json(self, tree='', depth=0):
29 | if self.url == self.jenkins.url:
30 | return load_json('jenkins/jenkins.json')
31 | elif isinstance(self, (Folder, AsyncFolder)):
32 | return load_json('job/folder.json')
33 | elif isinstance(self, (WorkflowJob, AsyncWorkflowJob)):
34 | return load_json('job/pipeline.json')
35 | elif isinstance(self, (WorkflowRun, AsyncWorkflowRun)):
36 | return load_json('run/workflowrun.json')
37 | elif isinstance(self, (FreeStyleBuild, AsyncFreeStyleBuild)):
38 | return load_json('run/freestylebuild.json')
39 | elif isinstance(self, (Credentials, AsyncCredentials)):
40 | return load_json('credential/domains.json')
41 | elif isinstance(self, (Domain, AsyncDomain)):
42 | return load_json('credential/credentials.json')
43 | elif isinstance(self, (Credential, AsyncCredential)):
44 | return load_json('credential/user_psw.json')
45 | elif isinstance(self, (PluginsManager, AsyncPluginsManager)):
46 | return load_json('plugin/plugin.json')
47 | elif isinstance(self, (Queue, AsyncQueue)):
48 | return load_json('queue/queue.json')
49 | elif isinstance(self, (Nodes, AsyncNodes)):
50 | return load_json('node/nodes.json')
51 | elif isinstance(self, (Node, AsyncNode)):
52 | return load_json('node/node.json')
53 | elif isinstance(self, (AllView, AsyncAllView)):
54 | return load_json('view/allview.json')
55 | elif isinstance(self, (TestReport, AsyncTestReport)):
56 | return load_json('report/test_report.json')
57 | elif isinstance(self, (CoverageReport, AsyncCoverageReport)):
58 | return load_json('report/coverage_report.json')
59 | elif isinstance(self, (CoverageResult, AsyncCoverageResult)):
60 | return load_json('report/coverage_result.json')
61 | elif isinstance(self, (QueueItem, AsyncQueueItem)):
62 | return load_json('queue/waitingitem.json')
63 | raise TypeError(f'unknown item: {type(self)}')
64 |
65 |
66 | async def _async_api_json(self, tree='', depth=0):
67 | return _api_json(self, tree, depth)
68 |
69 |
70 | @pytest.fixture(autouse=True)
71 | def mock_api_json(monkeypatch):
72 | monkeypatch.setattr(Item, 'api_json', _api_json)
73 | monkeypatch.setattr(AsyncItem, 'api_json', _async_api_json)
74 |
75 |
76 | def load_json(file_):
77 | with open(DATA.joinpath(file_), 'rb') as f:
78 | return json.load(f)
79 |
80 |
81 | @pytest.fixture(scope='module')
82 | def url():
83 | return 'http://0.0.0.0:8080/'
84 |
85 |
86 | @pytest.fixture(scope='module')
87 | def jenkins(url):
88 | j = Jenkins(url, auth=('admin', 'password'))
89 | j._crumb = load_json('jenkins/crumb.json')
90 | return j
91 |
92 |
93 | @pytest.fixture(scope='module')
94 | def async_jenkins(url):
95 | j = AsyncJenkins(url, auth=('admin', 'password'))
96 | j._crumb = load_json('jenkins/crumb.json')
97 | return j
98 |
99 |
100 | @pytest.fixture()
101 | def folder(jenkins):
102 | return Folder(jenkins, f'{jenkins.url}job/folder/')
103 |
104 |
105 | @pytest.fixture()
106 | def async_folder(async_jenkins):
107 | return AsyncFolder(async_jenkins, f'{async_jenkins.url}job/folder/')
108 |
109 |
110 | @pytest.fixture(scope='module')
111 | def job(jenkins):
112 | return WorkflowJob(jenkins, f'{jenkins.url}job/folder/job/pipeline/')
113 |
114 |
115 | @pytest.fixture(scope='module')
116 | def async_job(async_jenkins):
117 | return AsyncWorkflowJob(async_jenkins, f'{async_jenkins.url}job/folder/job/pipeline/')
118 |
119 |
120 | @pytest.fixture(scope='module')
121 | def build(jenkins):
122 | return WorkflowRun(jenkins, f'{jenkins.url}job/folder/job/pipeline/2/')
123 |
124 |
125 | @pytest.fixture(scope='module')
126 | def async_build(async_jenkins):
127 | return AsyncWorkflowRun(async_jenkins, f'{async_jenkins.url}job/folder/job/pipeline/2/')
128 |
129 |
130 | @pytest.fixture(scope='module')
131 | def multi_job(jenkins):
132 | return WorkflowMultiBranchProject(jenkins, f'{jenkins.url}job/folder/multi-pipe/')
133 |
134 |
135 | @pytest.fixture(scope='module')
136 | def org_job(jenkins):
137 | return OrganizationFolder(jenkins, f'{jenkins.url}job/folder/org-pipe/')
138 |
139 |
140 | @pytest.fixture(scope='module')
141 | def async_multi_job(async_jenkins):
142 | return AsyncWorkflowMultiBranchProject(async_jenkins, f'{async_jenkins.url}job/folder/multi-pipe/')
143 |
144 |
145 | @pytest.fixture(scope='module')
146 | def async_org_job(async_jenkins):
147 | return AsyncOrganizationFolder(async_jenkins, f'{async_jenkins.url}job/folder/org-pipe/')
148 |
149 |
150 | @pytest.fixture(scope='module')
151 | def credential(jenkins):
152 | return Credential(jenkins, f'{jenkins.url}credentials/store/system/domain/_/test-user/')
153 |
154 |
155 | @pytest.fixture(scope='module')
156 | def async_credential(async_jenkins):
157 | return AsyncCredential(async_jenkins, f'{async_jenkins.url}credentials/store/system/domain/_/test-user/')
158 |
159 |
160 | @pytest.fixture(scope='module')
161 | def view(jenkins):
162 | return AllView(jenkins, jenkins.url)
163 |
164 |
165 | @pytest.fixture(scope='module')
166 | def async_view(async_jenkins):
167 | return AsyncAllView(async_jenkins, async_jenkins.url)
168 |
169 | # @pytest.fixture
170 | # def mock_resp():
171 | # with respx.mock() as respx_mock:
172 | # yield respx_mock
173 |
174 |
175 | @pytest.fixture
176 | def test_report(jenkins, build):
177 | return TestReport(jenkins, f'{build.url}testReport')
178 |
179 | # @pytest.fixture
180 | # def coverage_report(jenkins, workflow):
181 | # return workflow.get
182 |
--------------------------------------------------------------------------------
/api4jenkins/node.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 |
3 | import json
4 |
5 | from .item import AsyncItem, Item, new_item
6 | from .mix import (AsyncConfigurationMixIn, AsyncDeletionMixIn,
7 | AsyncRunScriptMixIn, ConfigurationMixIn, DeletionMixIn,
8 | RunScriptMixIn)
9 |
10 | # query builds from 'executors', 'oneOffExecutors' in computer(s),
11 | # cause freestylebuild is in executors, and workflowbuild has different _class in
12 | # executors(..PlaceholderTask$PlaceholderExecutable) and oneOffExecutors(org.jenkinsci.plugins.workflow.job.WorkflowRun)
13 | _nodes_tree = ('computer[executors[currentExecutable[url]],'
14 | 'oneOffExecutors[currentExecutable[url]]]')
15 |
16 | _node_tree = ('executors[currentExecutable[url]],'
17 | 'oneOffExecutors[currentExecutable[url]]')
18 |
19 |
20 | def _make_node_setting(name, **kwargs):
21 | node_setting = {
22 | 'nodeDescription': '',
23 | 'numExecutors': 1,
24 | 'remoteFS': '/home/jenkins',
25 | 'labelString': '',
26 | 'mode': 'NORMAL',
27 | 'retentionStrategy': {
28 | 'stapler-class': 'hudson.slaves.RetentionStrategy$Always'
29 | },
30 | 'nodeProperties': {'stapler-class-bag': 'true'},
31 | 'launcher': {'stapler-class': 'hudson.slaves.JNLPLauncher'}
32 | }
33 | node_setting.update(kwargs)
34 | return {
35 | 'name': name,
36 | 'type': 'hudson.slaves.DumbSlave$DescriptorImpl',
37 | 'json': json.dumps(node_setting)
38 | }
39 |
40 |
41 | def _new_builds(jenkins, api_json):
42 | for computer in api_json['computer']:
43 | for item in _parse_builds(computer):
44 | yield new_item(jenkins, 'api4jenkins.build', item)
45 |
46 |
47 | def _parse_builds(data):
48 | for kind in ['executors', 'oneOffExecutors']:
49 | for executor in data.get(kind):
50 | # in case of issue:
51 | # https://github.com/joelee2012/api4jenkins/issues/16
52 | execable = executor['currentExecutable']
53 | if execable and not execable['_class'].endswith('PlaceholderExecutable'):
54 | yield {'url': execable['url'], '_class': execable['_class']}
55 |
56 |
57 | def _iter_node(jenkins, api_json):
58 | for item in api_json['computer']:
59 | item['url'] = f"{jenkins.url}computer/{item['displayName']}/"
60 | yield new_item(jenkins, __name__, item)
61 |
62 |
63 | def _get_node(jenkins, api_json, name):
64 | for item in api_json['computer']:
65 | if name == item['displayName']:
66 | item['url'] = f"{jenkins.url}computer/{item['displayName']}/"
67 | return new_item(jenkins, __name__, item)
68 | return None
69 |
70 |
71 | class IterBuildingBuildsMixIn:
72 | # pylint: disable=no-member
73 | def iter_building_builds(self):
74 | yield from filter(lambda build: build.building, self.iter_builds())
75 |
76 |
77 | class Nodes(Item, IterBuildingBuildsMixIn):
78 | '''
79 | classdocs
80 | '''
81 |
82 | def create(self, name, **kwargs):
83 | self.handle_req('POST', 'doCreateItem',
84 | data=_make_node_setting(name, **kwargs))
85 |
86 | def get(self, name):
87 | return _get_node(self.jenkins, self.api_json(tree='computer[displayName]'), name)
88 |
89 | def iter_builds(self):
90 | yield from _new_builds(self.jenkins, self.api_json(_nodes_tree, 2))
91 |
92 | def iter(self):
93 | yield from _iter_node(self.jenkins, self.api_json(tree='computer[displayName]'))
94 |
95 | def filter_node_by_label(self, *labels):
96 | for node in self:
97 | for label in node.api_json()['assignedLabels']:
98 | if label['name'] in labels:
99 | yield node
100 |
101 | def filter_node_by_status(self, *, online):
102 | yield from filter(lambda node: online != node.offline, self)
103 |
104 | # following two functions should be used in this module only
105 |
106 |
107 | class Node(Item, ConfigurationMixIn, DeletionMixIn, RunScriptMixIn, IterBuildingBuildsMixIn):
108 |
109 | def enable(self):
110 | if self.offline:
111 | self.handle_req('POST', 'toggleOffline',
112 | params={'offlineMessage': ''})
113 |
114 | def disable(self, msg=''):
115 | if not self.offline:
116 | self.handle_req('POST', 'toggleOffline',
117 | params={'offlineMessage': msg})
118 |
119 | def iter_builds(self):
120 | for item in _parse_builds(self.api_json(_node_tree, 2)):
121 | yield new_item(self.jenkins, 'api4jenkins.build', item)
122 |
123 | def __iter__(self):
124 | yield from self.iter_builds()
125 |
126 |
127 | class MasterComputerMixIn:
128 | def __init__(self, jenkins, url):
129 | # rename built-in node: https://www.jenkins.io/doc/upgrade-guide/2.319/
130 | name = 'master' if url.endswith('/master/') else 'built-in'
131 | super().__init__(jenkins, f'{jenkins.url}computer/({name})/')
132 |
133 |
134 | class MasterComputer(MasterComputerMixIn, Node):
135 | pass
136 |
137 |
138 | class SlaveComputer(Node):
139 | pass
140 |
141 |
142 | class KubernetesComputer(Node):
143 | pass
144 |
145 |
146 | class DockerComputer(Node):
147 | pass
148 |
149 |
150 | class EC2Computer(Node):
151 | pass
152 |
153 |
154 | class AsyncIterBuildingBuildsMixIn:
155 | # pylint: disable=no-member
156 | async def iter_building_builds(self):
157 | async for build in self.iter_builds():
158 | if await build.building:
159 | yield build
160 |
161 |
162 | class AsyncNodes(AsyncItem, AsyncIterBuildingBuildsMixIn):
163 | async def create(self, name, **kwargs):
164 | await self.handle_req('POST', 'doCreateItem', data=_make_node_setting(name, **kwargs))
165 |
166 | async def get(self, name):
167 | return _get_node(self.jenkins, await self.api_json(tree='computer[displayName]'), name)
168 |
169 | async def iter_builds(self):
170 | for build in _new_builds(self.jenkins, await self.api_json(_nodes_tree, 2)):
171 | yield build
172 |
173 | async def aiter(self):
174 | data = await self.api_json(tree='computer[displayName]')
175 | for node in _iter_node(self.jenkins, data):
176 | yield node
177 |
178 | async def filter_node_by_label(self, *labels):
179 | async for node in self:
180 | data = await node.api_json()
181 | for label in data['assignedLabels']:
182 | if label['name'] in labels:
183 | yield node
184 |
185 | async def filter_node_by_status(self, *, online):
186 | async for node in self:
187 | if online != await node.offline:
188 | yield node
189 |
190 |
191 | class AsyncNode(AsyncItem, AsyncConfigurationMixIn, AsyncDeletionMixIn, AsyncRunScriptMixIn, AsyncIterBuildingBuildsMixIn):
192 |
193 | async def enable(self):
194 | if await self.offline:
195 | await self.handle_req('POST', 'toggleOffline',
196 | params={'offlineMessage': ''})
197 |
198 | async def disable(self, msg=''):
199 | if not await self.offline:
200 | await self.handle_req('POST', 'toggleOffline',
201 | params={'offlineMessage': msg})
202 |
203 | async def iter_builds(self):
204 | for item in _parse_builds(await self.api_json(_node_tree, 2)):
205 | yield new_item(self.jenkins, 'api4jenkins.build', item)
206 |
207 | async def __aiter__(self):
208 | async for build in self.iter_builds():
209 | yield build
210 |
211 |
212 | class AsyncMasterComputer(MasterComputerMixIn, AsyncNode):
213 | pass
214 |
215 |
216 | class AsyncSlaveComputer(AsyncNode):
217 | pass
218 |
219 |
220 | class AsyncKubernetesComputer(AsyncNode):
221 | pass
222 |
223 |
224 | class AsyncDockerComputer(AsyncNode):
225 | pass
226 |
227 |
228 | class AsyncEC2Computer(AsyncNode):
229 | pass
230 |
--------------------------------------------------------------------------------
/api4jenkins/build.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 |
3 | import asyncio
4 | import re
5 | import time
6 |
7 | from .artifact import Artifact, async_save_response_to, save_response_to
8 | from .input import PendingInputAction
9 | from .item import AsyncItem, Item
10 | from .mix import (ActionsMixIn, AsyncActionsMixIn, AsyncDeletionMixIn,
11 | AsyncDescriptionMixIn, DeletionMixIn, DescriptionMixIn)
12 | from .report import (AsyncCoverageReport, AsyncCoverageResult,
13 | AsyncCoverageTrends, AsyncTestReport, CoverageReport,
14 | CoverageResult, CoverageTrends, TestReport)
15 |
16 |
17 | class Build(Item, DescriptionMixIn, DeletionMixIn, ActionsMixIn):
18 |
19 | def console_text(self):
20 | with self.handle_stream('GET', 'consoleText') as resp:
21 | yield from resp.iter_lines()
22 |
23 | def progressive_output(self, html=False):
24 | url = 'logText/progressiveHtml' if html else 'logText/progressiveText'
25 | start = 0
26 | while True:
27 | resp = self.handle_req('GET', url, params={'start': start})
28 | time.sleep(1)
29 | if start == resp.headers.get('X-Text-Size'):
30 | continue
31 | yield from resp.iter_lines()
32 | if not resp.headers.get('X-More-Data'):
33 | break
34 | start = resp.headers['X-Text-Size']
35 |
36 | def stop(self):
37 | return self.handle_req('POST', 'stop')
38 |
39 | def term(self):
40 | return self.handle_req('POST', 'term')
41 |
42 | def kill(self):
43 | return self.handle_req('POST', 'kill')
44 |
45 | def get_next_build(self):
46 | if item := self.api_json(tree='nextBuild[url]')['nextBuild']:
47 | return self.__class__(self.jenkins, item['url'])
48 | return None
49 |
50 | def get_previous_build(self):
51 | if item := self.api_json(tree='previousBuild[url]')['previousBuild']:
52 | return self.__class__(self.jenkins, item['url'])
53 | return None
54 |
55 | @property
56 | def project(self):
57 | job_name = self.jenkins._url2name(re.sub(r'\w+[/]?$', '', self.url))
58 | return self.jenkins.get_job(job_name)
59 |
60 | def get_test_report(self):
61 | tr = TestReport(self.jenkins, f'{self.url}testReport/')
62 | return tr if tr.exists() else None
63 |
64 | def get_coverage_report(self):
65 | '''Access coverage report generated by `JaCoCo `_'''
66 | cr = CoverageReport(self.jenkins, f'{self.url}jacoco/')
67 | return cr if cr.exists() else None
68 |
69 | def get_coverage_result(self):
70 | '''Access coverage result generated by `Code Coverage API `_'''
71 | cr = CoverageResult(self.jenkins, f'{self.url}coverage/result/')
72 | return cr if cr.exists() else None
73 |
74 | def get_coverage_trends(self):
75 | ct = CoverageTrends(self.jenkins, f'{self.url}coverage/trend/')
76 | return ct if ct.exists() else None
77 |
78 |
79 | class WorkflowRun(Build):
80 |
81 | def get_pending_input(self):
82 | '''get current pending input step'''
83 | data = self.handle_req('GET', 'wfapi/describe').json()
84 | if not data['_links'].get('pendingInputActions'):
85 | return None
86 | action = self.handle_req('GET', 'wfapi/pendingInputActions').json()[0]
87 | action["abortUrl"] = action["abortUrl"][action["abortUrl"].index(
88 | "/job/"):]
89 | return PendingInputAction(self.jenkins, action)
90 |
91 | @property
92 | def artifacts(self):
93 | artifacts = self.handle_req('GET', 'wfapi/artifacts').json()
94 | return [Artifact(self.jenkins, art) for art in artifacts]
95 |
96 | def save_artifacts(self, filename='archive.zip'):
97 | with self.handle_stream('GET', 'artifact/*zip*/archive.zip') as resp:
98 | save_response_to(resp, filename)
99 |
100 |
101 | class FreeStyleBuild(Build):
102 | pass
103 |
104 |
105 | class MatrixBuild(Build):
106 | pass
107 |
108 | # async class
109 |
110 |
111 | class AsyncBuild(AsyncItem, AsyncDescriptionMixIn, AsyncDeletionMixIn, AsyncActionsMixIn):
112 |
113 | async def console_text(self):
114 | async with self.handle_stream('GET', 'consoleText') as resp:
115 | async for line in resp.aiter_lines():
116 | yield line
117 |
118 | async def progressive_output(self, html=False):
119 | url = 'logText/progressiveHtml' if html else 'logText/progressiveText'
120 | start = 0
121 | while True:
122 | resp = await self.handle_req('GET', url, params={'start': start})
123 | await asyncio.sleep(1)
124 | if start == resp.headers.get('X-Text-Size'):
125 | continue
126 | async for line in resp.aiter_lines():
127 | yield line
128 | if not resp.headers.get('X-More-Data'):
129 | break
130 | start = resp.headers['X-Text-Size']
131 |
132 | async def stop(self):
133 | return await self.handle_req('POST', 'stop')
134 |
135 | async def term(self):
136 | return await self.handle_req('POST', 'term')
137 |
138 | async def kill(self):
139 | return await self.handle_req('POST', 'kill')
140 |
141 | async def get_next_build(self):
142 | data = await self.api_json(tree='nextBuild[url]')
143 | return self.__class__(self.jenkins, data['nextBuild']['url']) if data['nextBuild'] else None
144 |
145 | async def get_previous_build(self):
146 | data = await self.api_json(tree='previousBuild[url]')
147 | return self.__class__(self.jenkins, data['previousBuild']['url']) if data['previousBuild'] else None
148 |
149 | @property
150 | async def project(self):
151 | job_name = self.jenkins._url2name(re.sub(r'\w+[/]?$', '', self.url))
152 | return await self.jenkins.get_job(job_name)
153 |
154 | async def get_test_report(self):
155 | tr = AsyncTestReport(self.jenkins, f'{self.url}testReport/')
156 | return tr if await tr.exists() else None
157 |
158 | async def get_coverage_report(self):
159 | '''Access coverage report generated by `JaCoCo `_'''
160 | cr = AsyncCoverageReport(self.jenkins, f'{self.url}jacoco/')
161 | return cr if await cr.exists() else None
162 |
163 | async def get_coverage_result(self):
164 | '''Access coverage result generated by `Code Coverage API `_'''
165 | cr = AsyncCoverageResult(self.jenkins, f'{self.url}coverage/result/')
166 | return cr if await cr.exists() else None
167 |
168 | async def get_coverage_trends(self):
169 | ct = AsyncCoverageTrends(self.jenkins, f'{self.url}coverage/trend/')
170 | return ct if await ct.exists() else None
171 |
172 |
173 | class AsyncWorkflowRun(AsyncBuild):
174 |
175 | async def get_pending_input(self):
176 | '''get current pending input step'''
177 | data = (await self.handle_req('GET', 'wfapi/describe')).json()
178 | if not data['_links'].get('pendingInputActions'):
179 | return None
180 | action = (await self.handle_req('GET', 'wfapi/pendingInputActions')).json()[0]
181 | action["abortUrl"] = action["abortUrl"][action["abortUrl"].index(
182 | "/job/"):]
183 | return PendingInputAction(self.jenkins, action)
184 |
185 | @property
186 | async def artifacts(self):
187 | artifacts = (await self.handle_req('GET', 'wfapi/artifacts')).json()
188 | return [Artifact(self.jenkins, art) for art in artifacts]
189 |
190 | async def save_artifacts(self, filename='archive.zip'):
191 | async with self.handle_stream('GET', 'artifact/*zip*/archive.zip') as resp:
192 | await async_save_response_to(resp, filename)
193 |
194 |
195 | class AsyncFreeStyleBuild(AsyncBuild):
196 | pass
197 |
198 |
199 | class AsyncMatrixBuild(AsyncBuild):
200 | pass
201 |
--------------------------------------------------------------------------------
/tests/unit/test_build.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | import pytest
3 | from respx import MockResponse
4 |
5 | from api4jenkins.build import AsyncWorkflowRun, WorkflowRun
6 | from api4jenkins.input import PendingInputAction
7 |
8 |
9 | class TestBuild:
10 | def test_console_text(self, build, respx_mock):
11 | body = 'a\nb'
12 | respx_mock.get(f'{build.url}consoleText').respond(content=body)
13 | assert list(build.console_text()) == body.split('\n')
14 |
15 | def test_progressive_output(self, build, respx_mock):
16 | body = ['a', 'b']
17 | headers = {'X-More-Data': 'True', 'X-Text-Size': '1'}
18 | req_url = f'{build.url}logText/progressiveText'
19 | respx_mock.get(req_url).mock(
20 | side_effect=[MockResponse(headers=headers, content=body[0], status_code=200), MockResponse(content=body[1], status_code=200)])
21 | assert list(build.progressive_output()) == body
22 |
23 | def test_get_next_build(self, build):
24 | assert build.get_next_build() is None
25 |
26 | def test_get_previous_build(self, build):
27 | assert isinstance(build.get_previous_build(), WorkflowRun)
28 |
29 | def test_get_job(self, build, job):
30 | assert job == build.project
31 |
32 | @pytest.mark.parametrize('action', ['stop', 'term', 'kill'])
33 | def test_stop_term_kill(self, build, respx_mock, action):
34 | req_url = f'{build.url}{action}'
35 | respx_mock.post(req_url)
36 | getattr(build, action)()
37 | assert respx_mock.calls[0].request.url == req_url
38 |
39 | def test_get_parameters(self, build):
40 | params = build.get_parameters()
41 | assert params[0].name == 'parameter1'
42 | assert params[0].value == 'value1'
43 | assert params[1].name == 'parameter2'
44 | assert params[1].value == 'value2'
45 |
46 | def test_get_causes(self, build):
47 | causes = build.get_causes()
48 | assert causes[0]['shortDescription'] == 'Started by user admin'
49 | assert causes[1]['shortDescription'] == 'Replayed #1'
50 |
51 |
52 | class TestWorkflowRun:
53 |
54 | @pytest.mark.parametrize('data, obj', [({"_links": {}}, type(None)),
55 | ({"_links": {"pendingInputActions": 't1'}},
56 | PendingInputAction),
57 | ({"_links": {"pendingInputActions": 't2'}}, PendingInputAction)])
58 | def test_get_pending_input(self, build, respx_mock, data, obj):
59 | respx_mock.get(f'{build.url}wfapi/describe').respond(json=data)
60 | if data['_links'] and 'pendingInputActions' in data['_links']:
61 | if data['_links']['pendingInputActions'] == "t1":
62 | respx_mock.get(f'{build.url}wfapi/pendingInputActions').respond(
63 | json=[{'abortUrl': '/job/Test%20Workflow/11/input/Ef95dd500ae6ed3b27b89fb852296d12/abort'}])
64 | elif data['_links']['pendingInputActions'] == "t2":
65 | respx_mock.get(f'{build.url}wfapi/pendingInputActions').respond(
66 | json=[{'abortUrl': '/jenkins/job/Test%20Workflow/11/input/Ef95dd500ae6ed3b27b89fb852296d12/abort'}])
67 |
68 | assert isinstance(build.get_pending_input(), obj)
69 |
70 | @pytest.mark.parametrize('data, count', [([], 0),
71 | ([{"url": 'abcd'}], 1)],
72 | ids=["empty", "no empty"])
73 | def test_get_artifacts(self, build, respx_mock, data, count):
74 | respx_mock.get(f'{build.url}wfapi/artifacts').respond(json=data)
75 | assert len(build.artifacts) == count
76 |
77 | def test_save_artifacts(self, build, respx_mock, tmp_path):
78 | respx_mock.get(
79 | f'{build.url}artifact/*zip*/archive.zip').respond(content='abc')
80 | filename = tmp_path / 'my_archive.zip'
81 | build.save_artifacts(filename)
82 | assert filename.exists()
83 |
84 |
85 | class TestAsyncBuild:
86 | async def test_console_text(self, async_build, respx_mock):
87 | body = 'a\nb'
88 | respx_mock.get(f'{async_build.url}consoleText').respond(content=body)
89 | output = [line async for line in async_build.console_text()]
90 | assert output == body.split('\n')
91 |
92 | async def test_progressive_output(self, async_build, respx_mock):
93 | body = ['a', 'b']
94 | headers = {'X-More-Data': 'True', 'X-Text-Size': '1'}
95 | req_url = f'{async_build.url}logText/progressiveText'
96 | respx_mock.get(req_url).mock(
97 | side_effect=[MockResponse(headers=headers, content=body[0], status_code=200), MockResponse(content=body[1], status_code=200)])
98 |
99 | assert [line async for line in async_build.progressive_output()] == body
100 |
101 | async def test_get_next_build(self, async_build):
102 | assert await async_build.get_next_build() is None
103 |
104 | async def test_get_previous_build(self, async_build):
105 | assert isinstance(await async_build.get_previous_build(), AsyncWorkflowRun)
106 |
107 | async def test_get_job(self, async_build, async_job):
108 | assert async_job == await async_build.project
109 |
110 | @pytest.mark.parametrize('action', ['stop', 'term', 'kill'])
111 | async def test_stop_term_kill(self, async_build, respx_mock, action):
112 | req_url = f'{async_build.url}{action}'
113 | respx_mock.post(req_url)
114 | await getattr(async_build, action)()
115 | assert respx_mock.calls[0].request.url == req_url
116 |
117 | async def test_get_parameters(self, async_build):
118 | params = await async_build.get_parameters()
119 | assert params[0].name == 'parameter1'
120 | assert params[0].value == 'value1'
121 | assert params[1].name == 'parameter2'
122 | assert params[1].value == 'value2'
123 |
124 | async def test_get_causes(self, async_build):
125 | causes = await async_build.get_causes()
126 | assert causes[0]['shortDescription'] == 'Started by user admin'
127 | assert causes[1]['shortDescription'] == 'Replayed #1'
128 |
129 |
130 | class TestAsyncWorkflowRun:
131 |
132 | @pytest.mark.parametrize('data, obj', [({"_links": {}}, type(None)),
133 | ({"_links": {"pendingInputActions": 't1'}},
134 | PendingInputAction),
135 | ({"_links": {"pendingInputActions": 't2'}}, PendingInputAction)])
136 | async def test_get_pending_input(self, async_build, respx_mock, data, obj):
137 | respx_mock.get(f'{async_build.url}wfapi/describe').respond(json=data)
138 | if data['_links'] and 'pendingInputActions' in data['_links']:
139 | if data['_links']['pendingInputActions'] == "t1":
140 | respx_mock.get(f'{async_build.url}wfapi/pendingInputActions').respond(
141 | json=[{'abortUrl': '/job/Test%20Workflow/11/input/Ef95dd500ae6ed3b27b89fb852296d12/abort'}])
142 | elif data['_links']['pendingInputActions'] == "t2":
143 | respx_mock.get(f'{async_build.url}wfapi/pendingInputActions').respond(
144 | json=[{'abortUrl': '/jenkins/job/Test%20Workflow/11/input/Ef95dd500ae6ed3b27b89fb852296d12/abort'}])
145 |
146 | assert isinstance(await async_build.get_pending_input(), obj)
147 |
148 | @pytest.mark.parametrize('data, count', [([], 0),
149 | ([{"url": 'abcd'}], 1)],
150 | ids=["empty", "no empty"])
151 | async def test_get_artifacts(self, async_build, respx_mock, data, count):
152 | respx_mock.get(f'{async_build.url}wfapi/artifacts').respond(json=data)
153 | artifacts = await async_build.artifacts
154 | assert len(artifacts) == count
155 |
156 | async def test_save_artifacts(self, async_build, respx_mock, tmp_path):
157 | respx_mock.get(
158 | f'{async_build.url}artifact/*zip*/archive.zip').respond(content='abc')
159 | filename = tmp_path / 'my_archive.zip'
160 | await async_build.save_artifacts(filename)
161 | assert filename.exists()
162 |
--------------------------------------------------------------------------------