├── setup.cfg ├── tests ├── pytest.ini ├── attach.jpg ├── test_result_fields.py ├── test_priorities.py ├── test_case_types.py ├── test_template.py ├── test_roles.py ├── test_reports.py ├── test_case_fields.py ├── test_tests.py ├── test_statuses.py ├── test_suites.py ├── test_configurations.py ├── test_users.py ├── test_groups.py ├── test_projects.py ├── test_variables.py ├── test_shared_steps.py ├── test_sections.py ├── conftest.py ├── test_runs.py ├── test_milestone.py ├── test_datasets.py ├── test_plans.py ├── test_results.py ├── test_other.py ├── test_cases.py └── test_attachments.py ├── testrail_api ├── _enums.py ├── _exception.py ├── __init__.py ├── _testrail_api.py ├── _session.py └── _category.py ├── Pipfile ├── .editorconfig ├── LICENSE ├── .github └── workflows │ ├── python-publish.yml │ └── python-package.yml ├── setup.py ├── .gitignore ├── ruff.toml └── README.md /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | omit = testrail_api/__version__.py 3 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | -l 4 | --cov=testrail_api tests/ 5 | -------------------------------------------------------------------------------- /tests/attach.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tolstislon/testrail-api/HEAD/tests/attach.jpg -------------------------------------------------------------------------------- /testrail_api/_enums.py: -------------------------------------------------------------------------------- 1 | """Enums.""" 2 | 3 | from enum import Enum 4 | 5 | 6 | class METHODS(Enum): 7 | """HTTP methods.""" 8 | 9 | GET = "GET" 10 | POST = "POST" 11 | -------------------------------------------------------------------------------- /testrail_api/_exception.py: -------------------------------------------------------------------------------- 1 | """Exceptions.""" 2 | 3 | 4 | class TestRailError(Exception): 5 | """Base Exception.""" 6 | 7 | 8 | class TestRailAPIError(TestRailError): 9 | """Base API Exception.""" 10 | 11 | 12 | class StatusCodeError(TestRailAPIError): 13 | """Status code Exception.""" 14 | -------------------------------------------------------------------------------- /tests/test_result_fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | 5 | 6 | def test_get_result_fields(api, mock, url): 7 | mock.add_callback( 8 | responses.GET, url("get_result_fields"), lambda _: (200, {}, json.dumps([{"id": 1, "configs": []}])) 9 | ) 10 | resp = api.result_fields.get_result_fields() 11 | assert resp[0]["id"] == 1 12 | -------------------------------------------------------------------------------- /tests/test_priorities.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | 5 | 6 | def test_get_priorities(api, mock, url): 7 | mock.add_callback( 8 | responses.GET, 9 | url("get_priorities"), 10 | lambda _: (200, {}, json.dumps([{"id": 1, "priority": 1}, {"id": 4, "priority": 4}])), 11 | ) 12 | 13 | resp = api.priorities.get_priorities() 14 | assert resp[0]["id"] == 1 15 | assert resp[1]["priority"] == 4 16 | -------------------------------------------------------------------------------- /tests/test_case_types.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | 5 | 6 | def test_get_case_types(api, mock, url): 7 | mock.add_callback( 8 | responses.GET, 9 | url("get_case_types"), 10 | lambda _: (200, {}, json.dumps([{"id": 1, "name": "Automated"}, {"id": 6, "name": "Other"}])), 11 | ) 12 | resp = api.case_types.get_case_types() 13 | assert resp[0]["id"] == 1 14 | assert resp[1]["name"] == "Other" 15 | -------------------------------------------------------------------------------- /tests/test_template.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | 5 | 6 | def test_get_templates(api, mock, url): 7 | mock.add_callback( 8 | responses.GET, 9 | url("get_templates/1"), 10 | lambda _: ( 11 | 200, 12 | {}, 13 | json.dumps([{"id": 1, "name": "Test Case (Text)"}, {"id": 2, "name": "Test Case (Steps)"}]), 14 | ), 15 | ) 16 | resp = api.templates.get_templates(1) 17 | assert resp[0]["id"] == 1 18 | assert resp[1]["name"] == "Test Case (Steps)" 19 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | pytest = "==8.4.0" 8 | pytest-cov = "==6.2.1" 9 | responses = "==0.21.0" 10 | ruff = "==0.11.8" 11 | pytest-xdist = "==3.7.0" 12 | 13 | [packages] 14 | requests = "==2.32.4" 15 | testrail-api = { editable = true, path = "." } 16 | 17 | [requires] 18 | python_version = "3.12" 19 | 20 | [pipenv] 21 | allow_prereleases = true 22 | 23 | [scripts] 24 | tests = "pytest -n auto ./tests" 25 | format = "ruff format" 26 | check = "ruff check --fix" 27 | -------------------------------------------------------------------------------- /tests/test_roles.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | 5 | 6 | def get_roles(_): 7 | return ( 8 | 200, 9 | {}, 10 | json.dumps( 11 | { 12 | "offset": 0, 13 | "limit": 250, 14 | "size": 1, 15 | "roles": [ 16 | {"id": 3, "name": "Tester", "is_default": False, "is_project_admin": False}, 17 | {"id": 1, "name": "Lead", "is_default": True, "is_project_admin": False}, 18 | ], 19 | } 20 | ), 21 | ) 22 | 23 | 24 | def test_get_roles(api, mock, url): 25 | mock.add_callback(responses.GET, url("get_roles"), get_roles) 26 | resp = api.roles.get_roles() 27 | assert resp["roles"][1]["name"] == "Lead" 28 | -------------------------------------------------------------------------------- /tests/test_reports.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | 5 | 6 | def test_get_reports(api, mock, url): 7 | project_id = 1 8 | mock.add_callback( 9 | responses.GET, 10 | url(f"get_reports/{project_id}"), 11 | lambda _: (200, {}, json.dumps([{"id": 1, "name": "Activity Summary"}])), 12 | ) 13 | response = api.reports.get_reports(project_id) 14 | assert response[0]["name"] == "Activity Summary" 15 | 16 | 17 | def test_run_report(api, mock, url): 18 | report_url, report_template_id = "https://...383", 1 19 | mock.add_callback( 20 | responses.GET, 21 | url(f"run_report/{report_template_id}"), 22 | lambda _: (200, {}, json.dumps({"report_url": report_url})), 23 | ) 24 | response = api.reports.run_report(report_template_id) 25 | assert response["report_url"] == report_url 26 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | end_of_line = lf 9 | indent_size = 4 10 | 11 | # Python files 12 | [*.py] 13 | max_line_length = 120 14 | ij_python_optimize_imports_always_split_from_imports = false 15 | ij_python_optimize_imports_case_insensitive_order = false 16 | ij_python_optimize_imports_join_from_imports_with_same_source = true 17 | ij_python_optimize_imports_sort_by_type_first = true 18 | ij_python_optimize_imports_sort_imports = true 19 | ij_python_optimize_imports_sort_names_in_from_imports = true 20 | ij_python_from_import_trailing_comma_if_multiline = true 21 | ij_python_new_line_after_colon_multi_clause = true 22 | ij_python_from_import_new_line_after_left_parenthesis = true 23 | ij_python_from_import_new_line_before_right_parenthesis = true 24 | ij_python_from_import_parentheses_force_if_multiline = true 25 | ij_wrap_on_typing = false 26 | ij_python_blank_lines_around_class = 1 27 | ij_python_blank_lines_after_imports = 2 28 | ij_python_blank_lines_before_first_method = 1 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 tostislon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /tests/test_case_fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | 5 | 6 | def add_case_field(r): 7 | data = json.loads(r.body.decode()) 8 | return 200, {}, json.dumps({"id": 33, "type_id": 12, "label": data["label"], "description": data["description"]}) 9 | 10 | 11 | def test_get_case_fields(api, mock, url): 12 | mock.add_callback( 13 | responses.GET, 14 | url("get_case_fields"), 15 | lambda _: (200, {}, json.dumps([{"id": 1, "description": "The preconditions of this test case"}])), 16 | ) 17 | resp = api.case_fields.get_case_fields() 18 | assert resp[0]["id"] == 1 19 | 20 | 21 | def test_add_case_field(api, mock, url): 22 | mock.add_callback(responses.POST, url("add_case_field"), add_case_field) 23 | resp = api.case_fields.add_case_field( 24 | "Integer", 25 | "My field", 26 | "label", 27 | description="New field", 28 | configs=[ 29 | { 30 | "context": {"is_global": True, "project_ids": []}, 31 | "options": {"is_required": True, "default_value": "1", "items": "1, First\n2, Second"}, 32 | } 33 | ], 34 | ) 35 | assert resp["label"] == "label" 36 | assert resp["description"] == "New field" 37 | -------------------------------------------------------------------------------- /tests/test_tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import responses 5 | 6 | 7 | def get_tests(r): 8 | resp = [{"id": c, "status_id": int(i)} for c, i in enumerate(r.params["status_id"].split(","), 1)] 9 | return 200, {}, json.dumps({"offset": 0, "limit": 250, "size": len(resp), "tests": resp}) 10 | 11 | 12 | def test_get_test(api, mock, url): 13 | mock.add_callback( 14 | responses.GET, url("get_test/2"), lambda _: (200, {}, json.dumps({"case_id": 1, "id": 2, "run_id": 2})) 15 | ) 16 | resp = api.tests.get_test(2) 17 | assert resp["case_id"] == 1 18 | 19 | 20 | @pytest.mark.parametrize("status_id", ("1,5", [1, 5])) 21 | def test_get_tests(api, mock, url, status_id): 22 | mock.add_callback(responses.GET, url("get_tests/2"), get_tests) 23 | resp = api.tests.get_tests(2, status_id=status_id).get("tests") 24 | assert resp[0]["status_id"] == 1 25 | assert resp[1]["status_id"] == 5 26 | 27 | 28 | @pytest.mark.parametrize("status_id", ("1,5", [1, 5])) 29 | def test_get_tests_bulk(api, mock, url, status_id): 30 | mock.add_callback(responses.GET, url("get_tests/2"), get_tests) 31 | resp = api.tests.get_tests_bulk(2, status_id=status_id) 32 | assert resp[0]["status_id"] == 1 33 | assert resp[1]["status_id"] == 5 34 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Build Pypi 10 | 11 | on: 12 | release: 13 | types: [ published ] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: "3.12" 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Tests 5 | 6 | on: [ pull_request ] 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | python -m pip install pytest pytest-cov pytest-xdist responses==0.21.0 ruff==0.11.8 27 | python -m pip install -e . 28 | - name: Format check 29 | run: | 30 | ruff format --check 31 | - name: Lints 32 | run: | 33 | ruff check 34 | - name: Test with pytest 35 | run: | 36 | pytest -n auto ./tests 37 | -------------------------------------------------------------------------------- /tests/test_statuses.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | 5 | 6 | def test_get_statuses(api, mock, url): 7 | mock.add_callback( 8 | responses.GET, 9 | url("get_statuses"), 10 | lambda _: (200, {}, json.dumps([{"id": 1, "label": "Passed"}, {"id": 5, "label": "Failed"}])), 11 | ) 12 | resp = api.statuses.get_statuses() 13 | assert resp[0]["id"] == 1 14 | assert resp[1]["label"] == "Failed" 15 | 16 | 17 | def test_get_case_statuses(api, mock, url): 18 | mock.add_callback( 19 | responses.GET, 20 | url("get_case_statuses"), 21 | lambda _: ( 22 | 200, 23 | {}, 24 | json.dumps( 25 | [ 26 | { 27 | "case_status_id": 1, 28 | "name": "Approved", 29 | "abbreviation": None, 30 | "is_default": False, 31 | "is_approved": True, 32 | }, 33 | { 34 | "case_status_id": 2, 35 | "name": "Draft", 36 | "abbreviation": None, 37 | "is_default": True, 38 | "is_approved": True, 39 | }, 40 | ] 41 | ), 42 | ), 43 | ) 44 | resp = api.statuses.get_case_statuses() 45 | assert resp[0]["case_status_id"] == 1 46 | assert resp[1]["name"] == "Draft" 47 | -------------------------------------------------------------------------------- /testrail_api/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python wrapper of the TestRail API. 3 | 4 | ------------------ 5 | from datetime import datetime 6 | 7 | from testrail_api import TestRailAPI 8 | 9 | api = TestRailAPI("https://example.testrail.com/", "example@mail.com", "password") 10 | 11 | # If you use environment variables. 12 | # api = TestRailAPI() 13 | 14 | 15 | new_milestone = api.milestones.add_milestone( 16 | project_id=1, 17 | name="New milestone", 18 | start_on=int(datetime.now().timestamp()) 19 | ) 20 | 21 | my_test_run = api.runs.add_run( 22 | project_id=1, 23 | suite_id=2, 24 | name="My test run", 25 | include_all=True, 26 | milestone_id=new_milestone["id"] 27 | ) 28 | 29 | result = api.results.add_result_for_case( 30 | run_id=my_test_run["id"], 31 | case_id=5, 32 | status_id=1, 33 | comment="Pass", 34 | version="1" 35 | ) 36 | attach = "attach.jpg" 37 | api.attachments.add_attachment_to_result(result["id"], attach) 38 | 39 | api.runs.close_run(my_test_run["id"]) 40 | api.milestones.update_milestone(new_milestone["id"], is_completed=True) 41 | ------------------ 42 | """ 43 | 44 | try: 45 | from .__version__ import version as __version__ 46 | except ImportError: # pragma: no cover 47 | __version__ = "unknown" 48 | 49 | import logging 50 | 51 | from ._exception import StatusCodeError 52 | from ._testrail_api import TestRailAPI 53 | 54 | logging.getLogger(__package__).addHandler(logging.NullHandler()) 55 | 56 | __all__ = ["StatusCodeError", "TestRailAPI", "__version__"] 57 | -------------------------------------------------------------------------------- /testrail_api/_testrail_api.py: -------------------------------------------------------------------------------- 1 | """TestRail API Categories.""" 2 | 3 | from . import _category 4 | from ._session import Session 5 | 6 | 7 | class TestRailAPI(Session): 8 | """API Categories.""" 9 | 10 | attachments: _category.Attachments = _category.Attachments() 11 | cases: _category.Cases = _category.Cases() 12 | case_fields: _category.CaseFields = _category.CaseFields() 13 | case_types: _category.CaseTypes = _category.CaseTypes() 14 | configurations: _category.Configurations = _category.Configurations() 15 | milestones: _category.Milestones = _category.Milestones() 16 | plans: _category.Plans = _category.Plans() 17 | priorities: _category.Priorities = _category.Priorities() 18 | projects: _category.Projects = _category.Projects() 19 | reports: _category.Reports = _category.Reports() 20 | results: _category.Results = _category.Results() 21 | result_fields: _category.ResultFields = _category.ResultFields() 22 | runs: _category.Runs = _category.Runs() 23 | sections: _category.Sections = _category.Sections() 24 | shared_steps: _category.SharedSteps = _category.SharedSteps() 25 | statuses: _category.Statuses = _category.Statuses() 26 | suites: _category.Suites = _category.Suites() 27 | templates: _category.Template = _category.Template() 28 | tests: _category.Tests = _category.Tests() 29 | users: _category.Users = _category.Users() 30 | roles: _category.Roles = _category.Roles() 31 | groups: _category.Groups = _category.Groups() 32 | variables: _category.Variables = _category.Variables() 33 | datasets: _category.Datasets = _category.Datasets() 34 | -------------------------------------------------------------------------------- /tests/test_suites.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | 5 | 6 | def add_suite(r): 7 | data = json.loads(r.body.decode()) 8 | return 200, {}, json.dumps({"id": 1, "name": data["name"], "description": data["description"]}) 9 | 10 | 11 | def test_get_suite(api, mock, url): 12 | mock.add_callback( 13 | responses.GET, url("get_suite/4"), lambda _: (200, {}, json.dumps({"id": 4, "description": "My suite"})) 14 | ) 15 | resp = api.suites.get_suite(4) 16 | assert resp["id"] == 4 17 | 18 | 19 | def test_get_suites(api, mock, url): 20 | mock.add_callback( 21 | responses.GET, 22 | url("get_suites/5"), 23 | lambda _: (200, {}, json.dumps([{"id": 1, "description": "Suite1"}, {"id": 2, "description": "Suite2"}])), 24 | ) 25 | resp = api.suites.get_suites(5) 26 | assert resp[0]["id"] == 1 27 | assert resp[1]["description"] == "Suite2" 28 | 29 | 30 | def test_add_suite(api, mock, url): 31 | mock.add_callback(responses.POST, url("add_suite/7"), add_suite) 32 | resp = api.suites.add_suite(7, "New suite", description="My new suite") 33 | assert resp["name"] == "New suite" 34 | assert resp["description"] == "My new suite" 35 | 36 | 37 | def test_update_suite(api, mock, url): 38 | mock.add_callback(responses.POST, url("update_suite/4"), add_suite) 39 | resp = api.suites.update_suite(4, name="new name", description="new description") 40 | assert resp["name"] == "new name" 41 | assert resp["description"] == "new description" 42 | 43 | 44 | def test_delete_suite(api, mock, url): 45 | mock.add_callback(responses.POST, url("delete_suite/4"), lambda _: (200, {}, "")) 46 | resp = api.suites.delete_suite(4) 47 | assert resp is None 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup file.""" 2 | 3 | from pathlib import Path 4 | 5 | from setuptools import find_packages, setup 6 | 7 | readme = Path(".", "README.md").absolute() 8 | with readme.open("r", encoding="utf-8") as file: 9 | long_description = file.read() 10 | 11 | setup( 12 | name="testrail_api", 13 | packages=find_packages(exclude=("tests", "dev_tools")), 14 | url="https://github.com/tolstislon/testrail-api", 15 | license="MIT License", 16 | author="tolstislon", 17 | author_email="tolstislon@gmail.com", 18 | description="Python wrapper of the TestRail API", 19 | long_description=long_description, 20 | long_description_content_type="text/markdown", 21 | use_scm_version={"write_to": "testrail_api/__version__.py"}, 22 | setup_requires=["setuptools_scm"], 23 | install_requires=["requests>=2.32.4"], 24 | python_requires=">=3.9", 25 | include_package_data=True, 26 | keywords=[ 27 | "testrail", 28 | "api", 29 | "client", 30 | "api-client", 31 | "library", 32 | "testrail_api", 33 | "testrail-api", 34 | ], 35 | classifiers=[ 36 | "Development Status :: 5 - Production/Stable", 37 | "Intended Audience :: Developers", 38 | "License :: OSI Approved :: MIT License", 39 | "Natural Language :: English", 40 | "Operating System :: OS Independent", 41 | "Programming Language :: Python", 42 | "Programming Language :: Python :: 3.9", 43 | "Programming Language :: Python :: 3.10", 44 | "Programming Language :: Python :: 3.11", 45 | "Programming Language :: Python :: 3.12", 46 | "Programming Language :: Python :: 3.13", 47 | "Programming Language :: Python :: Implementation :: CPython", 48 | "Programming Language :: Python :: Implementation :: PyPy", 49 | "Topic :: Software Development :: Libraries :: Python Modules", 50 | ], 51 | ) 52 | -------------------------------------------------------------------------------- /.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 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # Pycharm 107 | .idea/ 108 | 109 | # Other 110 | Pipfile.lock 111 | TODO.txt 112 | pyproject.toml 113 | build.sh 114 | testrail_api/__version__.py 115 | -------------------------------------------------------------------------------- /tests/test_configurations.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | 5 | 6 | def post_config(r): 7 | data = json.loads(r.body.decode()) 8 | return 200, {}, json.dumps({"id": 2, "name": data["name"]}) 9 | 10 | 11 | def test_get_configs(api, mock, url): 12 | mock.add_callback( 13 | responses.GET, 14 | url("get_configs/1"), 15 | lambda _: (200, {}, json.dumps([{"id": 1, "name": "Browsers", "configs": []}])), 16 | ) 17 | resp = api.configurations.get_configs(1) 18 | assert resp[0]["name"] == "Browsers" 19 | 20 | 21 | def test_add_config_group(api, mock, url): # no response example 22 | mock.add_callback(responses.POST, url("add_config_group/5"), post_config) 23 | resp = api.configurations.add_config_group(5, name="Python") 24 | assert resp["name"] == "Python" 25 | 26 | 27 | def test_add_config(api, mock, url): 28 | mock.add_callback(responses.POST, url("add_config/1"), post_config) 29 | resp = api.configurations.add_config(1, "TestRail") 30 | assert resp["name"] == "TestRail" 31 | 32 | 33 | def test_update_config_group(api, mock, url): 34 | mock.add_callback(responses.POST, url("update_config_group/3"), post_config) 35 | resp = api.configurations.update_config_group(3, "New Name") 36 | assert resp["name"] == "New Name" 37 | 38 | 39 | def test_update_config(api, mock, url): 40 | mock.add_callback(responses.POST, url("update_config/4"), post_config) 41 | resp = api.configurations.update_config(4, "New config name") 42 | assert resp["name"] == "New config name" 43 | 44 | 45 | def test_delete_config_group(api, mock, url): 46 | mock.add_callback(responses.POST, url("delete_config_group/234"), lambda _: (200, {}, "")) 47 | resp = api.configurations.delete_config_group(234) 48 | assert resp is None 49 | 50 | 51 | def test_delete_config(api, mock, url): 52 | mock.add_callback(responses.POST, url("delete_config/54"), lambda _: (200, {}, "")) 53 | resp = api.configurations.delete_config(54) 54 | assert resp is None 55 | -------------------------------------------------------------------------------- /tests/test_users.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | 5 | 6 | def test_get_user(api, mock, url): 7 | mock.add_callback( 8 | responses.GET, 9 | url("get_user/1"), 10 | lambda _: (200, {}, json.dumps({"email": "testrail@ff.com", "id": 1, "name": "John Smith", "is_active": True})), 11 | ) 12 | response = api.users.get_user(1) 13 | assert response["name"] == "John Smith" 14 | 15 | 16 | def test_get_user_by_email(api, mock, url): 17 | mock.add_callback( 18 | responses.GET, 19 | url("get_user_by_email"), 20 | lambda x: (200, {}, json.dumps({"email": x.params["email"], "id": 1, "name": "John Smith", "is_active": True})), 21 | ) 22 | email = "testrail@cc.cc" 23 | response = api.users.get_user_by_email(email) 24 | assert response["email"] == email 25 | 26 | 27 | def test_get_users(api, mock, url): 28 | mock.add_callback( 29 | responses.GET, 30 | url("get_users/15"), 31 | lambda _: ( 32 | 200, 33 | {}, 34 | json.dumps([{"email": "testrail@ff.com", "id": 1, "name": "John Smith", "is_active": True}]), 35 | ), 36 | ) 37 | response = api.users.get_users(15) 38 | assert response[0]["name"] == "John Smith" 39 | 40 | 41 | def test_get_users_no_project_id(api, mock, url): 42 | mock.add_callback( 43 | responses.GET, 44 | url("get_users"), 45 | lambda _: ( 46 | 200, 47 | {}, 48 | json.dumps([{"email": "testrail@ff.com", "id": 1, "name": "John Smith", "is_active": True}]), 49 | ), 50 | ) 51 | response = api.users.get_users() 52 | assert response[0]["name"] == "John Smith" 53 | 54 | 55 | def test_get_current_user(api, mock, url): 56 | mock.add_callback( 57 | responses.GET, 58 | url("get_current_user/1"), 59 | lambda _: (200, {}, json.dumps({"email": "testrail@ff.com", "id": 1, "name": "John Smith", "is_active": True})), 60 | ) 61 | response = api.users.get_current_user(1) 62 | assert response["name"] == "John Smith" 63 | -------------------------------------------------------------------------------- /tests/test_groups.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import responses 5 | 6 | 7 | def get_group(_): 8 | return 200, {}, json.dumps({"id": 1, "name": "New group", "user_ids": [1, 2, 3, 4, 5]}) 9 | 10 | 11 | def get_groups(_): 12 | return ( 13 | 200, 14 | {}, 15 | json.dumps( 16 | { 17 | "offset": 0, 18 | "limit": 250, 19 | "size": 0, 20 | "_links": { 21 | "next": None, 22 | "prev": None, 23 | }, 24 | "groups": [{"id": 1, "name": "New group", "user_ids": [1, 2, 3, 4, 5]}], 25 | } 26 | ), 27 | ) 28 | 29 | 30 | def add_group(r): 31 | req = json.loads(r.body) 32 | req["id"] = 1 33 | return 200, {}, json.dumps(req) 34 | 35 | 36 | def test_get_group(api, mock, url): 37 | mock.add_callback(responses.GET, url("get_group/1"), get_group, content_type="application/json") 38 | resp = api.groups.get_group(1) 39 | assert resp["id"] == 1 40 | 41 | 42 | def test_get_groups(api, mock, url): 43 | mock.add_callback(responses.GET, url("get_groups"), get_groups, content_type="application/json") 44 | resp = api.groups.get_groups() 45 | assert resp["groups"][0]["id"] == 1 46 | 47 | 48 | def test_add_group(api, mock, url): 49 | mock.add_callback(responses.POST, url("add_group"), add_group, content_type="application/json") 50 | resp = api.groups.add_group("New group", [1, 2, 3, 4]) 51 | assert resp["id"] == 1 52 | 53 | 54 | @pytest.mark.parametrize("data", ({"name": "qwe"}, {"user_ids": [1, 2]}, {"name": "q", "user_ids": [1, 3]})) 55 | def test_update_group(api, mock, url, data): 56 | mock.add_callback(responses.POST, url("update_group/1"), add_group, content_type="application/json") 57 | resp = api.groups.update_group(1, **data) 58 | for key in data: 59 | assert resp[key] == data[key] 60 | 61 | 62 | def test_delete_group(api, mock, url): 63 | mock.add_callback(responses.POST, url("delete_group/1"), lambda _: (200, {}, ""), content_type="application/json") 64 | response = api.groups.delete_group(1) 65 | assert response is None 66 | -------------------------------------------------------------------------------- /tests/test_projects.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import responses 5 | 6 | 7 | def get_projects(r): 8 | assert r.params["is_completed"] == "0" 9 | return 200, {}, json.dumps([{"id": 1, "name": "Datahub"}]) 10 | 11 | 12 | def add_project(r): 13 | data = json.loads(r.body.decode()) 14 | return 200, {}, json.dumps({"id": 1, "name": data["name"]}) 15 | 16 | 17 | def update_project(r): 18 | data = json.loads(r.body.decode()) 19 | return 200, {}, json.dumps({"id": 1, "name": "Datahub", "is_completed": data["is_completed"]}) 20 | 21 | 22 | def test_get_project(api, mock, url): 23 | mock.add_callback( 24 | responses.GET, url("get_project/1"), lambda _: (200, {}, json.dumps({"id": 1, "name": "Datahub"})) 25 | ) 26 | resp = api.projects.get_project(1) 27 | assert resp["name"] == "Datahub" 28 | 29 | 30 | @pytest.mark.parametrize("is_completed", (0, False)) 31 | def test_get_projects(api, mock, url, is_completed): 32 | mock.add_callback( 33 | responses.GET, 34 | url("get_projects"), 35 | get_projects, 36 | ) 37 | resp = api.projects.get_projects(is_completed=is_completed) 38 | assert resp[0]["name"] == "Datahub" 39 | 40 | 41 | def test_add_project(api, mock, url): 42 | mock.add_callback( 43 | responses.POST, 44 | url("add_project"), 45 | add_project, 46 | ) 47 | resp = api.projects.add_project("My project", announcement="description", show_announcement=True, suite_mode=1) 48 | assert resp["name"] == "My project" 49 | 50 | 51 | @pytest.mark.parametrize("is_completed", (True, False)) 52 | def test_update_project(api, mock, url, is_completed): 53 | mock.add_callback( 54 | responses.POST, 55 | url("update_project/1"), 56 | update_project, 57 | ) 58 | resp = api.projects.update_project(1, is_completed=is_completed) 59 | assert resp["is_completed"] is is_completed 60 | 61 | 62 | def test_delete_project(api, mock, url): 63 | mock.add_callback( 64 | responses.POST, 65 | url("delete_project/1"), 66 | lambda _: (200, {}, ""), 67 | ) 68 | resp = api.projects.delete_project(1) 69 | assert resp is None 70 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 120 2 | target-version = "py39" 3 | exclude = [".git", ".pytest_cache", ".idea", ".github", "build"] 4 | 5 | [lint] 6 | select = [ 7 | "A", # flake8-builtins 8 | "ANN", # flake8-annotations 9 | "B", # flake8-bugbear 10 | "C4", # flake8-comprehensions 11 | "COM", # flake8-commas 12 | "D", # pydocstyle 13 | "E", # pycodestyle 14 | "F", # Pyflakes 15 | "FURB", # refurb 16 | "I", # isort 17 | "ICN", # flake8-import-conventions 18 | "ISC", # flake8-implicit-str-concat 19 | "N", # pep8-naming 20 | "PERF", # Perflint 21 | "PGH", # pygrep-hooks 22 | "PTH", # flake8-use-pathlib 23 | "RUF", # Ruff-specific rules 24 | "S", # flake8-bandit 25 | "T20", # flake8-print 26 | "UP", # pyupgrade 27 | "PIE", # flake8-pie 28 | "PT", # flake8-pytest-style 29 | "Q", # flake8-quotes 30 | "RET", # flake8-return 31 | "SIM", # flake8-simplify 32 | "ARG", # flake8-unused-arguments 33 | "FLY", # flynt 34 | "PL", # Pylint 35 | "RSE", # flake8-raise 36 | ] 37 | ignore = [ 38 | "S311", # Standard pseudo-random generators are not suitable for cryptographic purposesStandard pseudo-random generators are not suitable for cryptographic purposes 39 | "D212", # multi-line-summary-first-line (conflicts rules) 40 | "D203", # one-blank-line-before-class (conflicts rules) 41 | "COM812", # conflicts rules 42 | "ISC001", # conflicts rules 43 | "N818", # Exception name `{exception}` should be named with an Error suffix 44 | "ANN003", # Missing type annotation for `**kwargs` 45 | "ANN002", # Missing type annotation for `*args` 46 | "D107", # Missing docstring in `__init__` 47 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed` 48 | ] 49 | 50 | [lint.per-file-ignores] 51 | "test_*.py" = ["S101", "D103", "ANN201", "D100", "S605", "S607", "ANN001", "PLR2004", "PT018", "ARG002", "D102", "ANN204", "A004", "PT012"] 52 | "conftest.py" = ["ANN001", "PLR0913", "D101", "D100"] 53 | "_category.py" = ["D401", "A002"] 54 | 55 | [lint.flake8-pytest-style] 56 | parametrize-values-row-type = "tuple" 57 | parametrize-values-type = "tuple" 58 | fixture-parentheses = false 59 | mark-parentheses = false 60 | 61 | [format] 62 | line-ending = "lf" 63 | -------------------------------------------------------------------------------- /tests/test_variables.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | import uuid 4 | 5 | import responses 6 | from requests import PreparedRequest 7 | 8 | 9 | def _add_variables(r: PreparedRequest) -> tuple[int, dict, str]: 10 | req = json.loads(r.body) 11 | assert "id" in req and "name" in req 12 | return 200, {}, json.dumps(req) 13 | 14 | 15 | def _update_variable(r: PreparedRequest) -> tuple[int, dict, str]: 16 | req = json.loads(r.body) 17 | v = r.url.split("/")[-1] 18 | return 200, {}, json.dumps({"id": int(v), "name": req["name"]}) 19 | 20 | 21 | def test_get_variables(api, mock, url): 22 | project_id = random.randint(1, 10000) 23 | mock.add_callback( 24 | responses.GET, 25 | url(f"get_variables/{project_id}"), 26 | lambda _: ( 27 | 200, 28 | {}, 29 | json.dumps( 30 | { 31 | "offset": 0, 32 | "limit": 250, 33 | "size": 2, 34 | "_links": {"next": None, "prev": None}, 35 | "variables": [{"id": 611, "name": "d"}, {"id": 612, "name": "e"}], 36 | } 37 | ), 38 | ), 39 | ) 40 | response = api.variables.get_variables(project_id) 41 | assert response["size"] == 2 42 | for variable in response["variables"]: 43 | assert tuple(variable) == ("id", "name") 44 | 45 | 46 | def test_add_variables(api, mock, url): 47 | project_id = random.randint(1, 10000) 48 | _id, _name = random.randint(1, 10000), uuid.uuid4().hex 49 | mock.add_callback(responses.POST, url(f"add_variable/{project_id}"), _add_variables) 50 | response = api.variables.add_variable(project_id, _id, _name) 51 | assert response["id"] == _id 52 | assert response["name"] == _name 53 | 54 | 55 | def test_update_variable(api, mock, url): 56 | variable_id = random.randint(1, 10000) 57 | _name = uuid.uuid4().hex 58 | mock.add_callback(responses.POST, url(f"update_variable/{variable_id}"), _update_variable) 59 | response = api.variables.update_variable(variable_id=variable_id, name=_name) 60 | assert response["id"] == variable_id 61 | assert response["name"] == _name 62 | 63 | 64 | def test_delete_variable(api, mock, url): 65 | variable_id = random.randint(1, 10000) 66 | mock.add_callback(responses.POST, url(f"delete_variable/{variable_id}"), lambda _: (200, {}, None)) 67 | response = api.variables.delete_variable(variable_id) 68 | assert response is None 69 | -------------------------------------------------------------------------------- /tests/test_shared_steps.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | 5 | 6 | def add_shared_step(r): 7 | req = json.loads(r.body.decode()) 8 | req["id"] = 1 9 | return 200, {}, json.dumps(req) 10 | 11 | 12 | def test_get_shared_step(api, mock, url): 13 | mock.add_callback( 14 | responses.GET, url("get_shared_step/3"), lambda _: (200, {}, json.dumps({"id": 1, "title": "My step"})) 15 | ) 16 | resp = api.shared_steps.get_shared_step(3) 17 | assert resp["id"] == 1 18 | assert resp["title"] == "My step" 19 | 20 | 21 | def test_get_shared_steps(api, mock, url): 22 | mock.add_callback( 23 | responses.GET, 24 | url("get_shared_steps/1"), 25 | lambda _: ( 26 | 200, 27 | {}, 28 | json.dumps( 29 | { 30 | "offset": 0, 31 | "limit": 250, 32 | "size": 1, 33 | "shared_steps": [{"id": 1, "title": "My step"}], 34 | } 35 | ), 36 | ), 37 | ) 38 | resp = api.shared_steps.get_shared_steps(project_id=1).get("shared_steps") 39 | assert resp[0]["id"] == 1 40 | assert resp[0]["title"] == "My step" 41 | 42 | 43 | def test_add_shared_step(api, mock, url): 44 | mock.add_callback(responses.POST, url("add_shared_step/1"), add_shared_step) 45 | resp = api.shared_steps.add_shared_step(project_id=1, title="My step", custom_steps_separated=[{"a": 1}, {"b": 2}]) 46 | assert resp["id"] == 1 47 | assert resp["title"] == "My step" 48 | 49 | 50 | def test_update_shared_step(api, mock, url): 51 | mock.add_callback(responses.POST, url("update_shared_step/34"), add_shared_step) 52 | resp = api.shared_steps.update_shared_step(shared_update_id=34, title="New shared step") 53 | assert resp["title"] == "New shared step" 54 | 55 | 56 | def test_delete_shared_step(api, mock, url): 57 | mock.add_callback(responses.POST, url("delete_shared_step/34"), lambda _: (200, {}, "")) 58 | api.shared_steps.delete_shared_step(shared_update_id=34) 59 | 60 | 61 | def test_get_shared_steps_bulk(api, mock, url): 62 | mock.add_callback( 63 | responses.GET, 64 | url("get_shared_steps/1"), 65 | lambda _: ( 66 | 200, 67 | {}, 68 | json.dumps( 69 | { 70 | "offset": 0, 71 | "limit": 250, 72 | "size": 1, 73 | "shared_steps": [{"id": 1, "title": "My step"}], 74 | } 75 | ), 76 | ), 77 | ) 78 | resp = api.shared_steps.get_shared_steps_bulk(project_id=1) 79 | assert resp[0]["id"] == 1 80 | assert resp[0]["title"] == "My step" 81 | -------------------------------------------------------------------------------- /tests/test_sections.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | 5 | 6 | def add_section(r): 7 | data = json.loads(r.body.decode()) 8 | return 200, {}, json.dumps({"id": 2, "name": data["name"], "description": data["description"]}) 9 | 10 | 11 | def test_get_section(api, mock, url): 12 | mock.add_callback( 13 | responses.GET, url("get_section/3"), lambda _: (200, {}, json.dumps({"depth": 1, "description": "My section"})) 14 | ) 15 | resp = api.sections.get_section(3) 16 | assert resp["depth"] == 1 17 | 18 | 19 | def test_get_sections(api, mock, url): 20 | mock.add_callback( 21 | responses.GET, 22 | url("get_sections/5"), 23 | lambda _: ( 24 | 200, 25 | {}, 26 | json.dumps( 27 | { 28 | "offset": 0, 29 | "limit": 250, 30 | "size": 1, 31 | "sections": [{"depth": 1, "description": "My section"}], 32 | } 33 | ), 34 | ), 35 | ) 36 | resp = api.sections.get_sections(5, suite_id=2).get("sections") 37 | assert resp[0]["depth"] == 1 38 | 39 | 40 | def test_add_section(api, mock, url): 41 | mock.add_callback(responses.POST, url("add_section/4"), add_section) 42 | resp = api.sections.add_section(4, "new section", suite_id=2, description="Description") 43 | assert resp["name"] == "new section" 44 | assert resp["description"] == "Description" 45 | 46 | 47 | def test_update_section(api, mock, url): 48 | mock.add_callback(responses.POST, url("update_section/2"), add_section) 49 | resp = api.sections.update_section(2, name="new_name", description="new_description") 50 | assert resp["name"] == "new_name" 51 | assert resp["description"] == "new_description" 52 | 53 | 54 | def test_delete_section(api, mock, url): 55 | mock.add_callback(responses.POST, url("delete_section/2"), lambda _: (200, {}, "")) 56 | resp = api.sections.delete_section(2) 57 | assert resp is None 58 | 59 | 60 | def test_move_section(api, mock, url): 61 | mock.add_callback(responses.POST, url("move_section/2"), lambda x: (200, {}, x.body)) 62 | resp = api.sections.move_section(2, parent_id=3, after_id=5) 63 | assert resp["parent_id"] == 3 64 | assert resp["after_id"] == 5 65 | 66 | 67 | def test_get_sections_bulk(api, mock, url): 68 | mock.add_callback( 69 | responses.GET, 70 | url("get_sections/5"), 71 | lambda _: ( 72 | 200, 73 | {}, 74 | json.dumps( 75 | { 76 | "offset": 0, 77 | "limit": 250, 78 | "size": 1, 79 | "sections": [{"depth": 1, "description": "My section"}], 80 | } 81 | ), 82 | ), 83 | ) 84 | resp = api.sections.get_sections_bulk(5, suite_id=2) 85 | assert resp[0]["depth"] == 1 86 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from collections.abc import Iterator 3 | from pathlib import Path 4 | from typing import Callable 5 | 6 | import pytest 7 | import responses 8 | 9 | try: 10 | from responses import FalseBool 11 | 12 | false_bool = FalseBool() 13 | except ImportError: 14 | false_bool = False 15 | 16 | from testrail_api import TestRailAPI 17 | 18 | BASE_HOST = "https://example.testrail.com/index.php?/api/v2/" 19 | 20 | 21 | class CallbackResponse(responses.CallbackResponse): 22 | def _url_matches(self, url: str, other: str, match_querystring=false_bool) -> bool: # noqa: ARG002 23 | base_url = url.replace(BASE_HOST, "") 24 | other = other.replace(BASE_HOST, "") 25 | base_other = other.split("&", 1)[0] 26 | return base_url == base_other 27 | 28 | 29 | class RequestsMock(responses.RequestsMock): 30 | def add_callback( 31 | self, 32 | method, 33 | url, 34 | callback, 35 | match_querystring=false_bool, 36 | content_type="text/plain", 37 | match=(), 38 | ) -> None: 39 | """Request callback method.""" 40 | self._registry.add( 41 | CallbackResponse( 42 | url=url, 43 | method=method, 44 | callback=callback, 45 | content_type=content_type, 46 | match_querystring=match_querystring, 47 | match=match, 48 | ) 49 | ) 50 | 51 | 52 | @pytest.fixture(scope="session") 53 | def host() -> str: 54 | """Test host.""" 55 | return "https://example.testrail.com/" 56 | 57 | 58 | @pytest.fixture(scope="session") 59 | def url(host: str) -> Callable[[str], str]: 60 | """Compiled url.""" 61 | 62 | def _wrap(endpoint: str) -> str: 63 | return f"{host}index.php?/api/v2/{endpoint}" 64 | 65 | return _wrap 66 | 67 | 68 | @pytest.fixture(scope="session") 69 | def base_path() -> str: 70 | """Root path.""" 71 | path = Path(__file__).absolute().parent 72 | return str(path) 73 | 74 | 75 | @pytest.fixture(scope="session") 76 | def auth_data(host: str) -> tuple[str, str, str]: 77 | """Test data for authorization.""" 78 | return host, "example@mail.com", "password" 79 | 80 | 81 | @pytest.fixture 82 | def mock() -> Iterator[RequestsMock]: 83 | """Mock request.""" 84 | with RequestsMock() as resp: 85 | yield resp 86 | 87 | 88 | @pytest.fixture 89 | def api(auth_data: tuple[str, str, str]) -> TestRailAPI: 90 | """TestRailAPI object.""" 91 | return TestRailAPI(*auth_data) 92 | 93 | 94 | @pytest.fixture 95 | def environ(auth_data: tuple[str, str, str]) -> Iterator[None]: 96 | """Set envs.""" 97 | os.environ["TESTRAIL_URL"] = auth_data[0] 98 | os.environ["TESTRAIL_EMAIL"] = auth_data[1] 99 | os.environ["TESTRAIL_PASSWORD"] = auth_data[2] 100 | yield 101 | del os.environ["TESTRAIL_URL"] 102 | del os.environ["TESTRAIL_EMAIL"] 103 | del os.environ["TESTRAIL_PASSWORD"] 104 | -------------------------------------------------------------------------------- /tests/test_runs.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from datetime import datetime 4 | 5 | import pytest 6 | import responses 7 | 8 | 9 | def get_runs(r): 10 | assert r.params["is_completed"] == "1" 11 | for key in "created_after", "created_before": 12 | assert re.match(r"^\d+$", r.params[key]) 13 | return ( 14 | 200, 15 | {}, 16 | json.dumps( 17 | { 18 | "offset": 0, 19 | "limit": 250, 20 | "size": 1, 21 | "runs": [{"id": 1, "name": "My run", "is_completed": r.params["is_completed"]}], 22 | } 23 | ), 24 | ) 25 | 26 | 27 | def add_run(r): 28 | data = json.loads(r.body.decode()) 29 | return ( 30 | 200, 31 | {}, 32 | json.dumps( 33 | {"id": 25, "suite_id": data["suite_id"], "name": data["name"], "milestone_id": data["milestone_id"]} 34 | ), 35 | ) 36 | 37 | 38 | def test_get_run(api, mock, url): 39 | mock.add_callback(responses.GET, url("get_run/1"), lambda _: (200, {}, json.dumps({"id": 1, "name": "My run"}))) 40 | resp = api.runs.get_run(1) 41 | assert resp["id"] == 1 42 | 43 | 44 | @pytest.mark.parametrize("is_completed", (1, True)) 45 | def test_get_runs(api, mock, url, is_completed): 46 | mock.add_callback(responses.GET, url("get_runs/12"), get_runs) 47 | resp = api.runs.get_runs( 48 | 12, is_completed=is_completed, created_after=datetime.now(), created_before=datetime.now() 49 | ).get("runs") 50 | assert resp[0]["is_completed"] == "1" 51 | 52 | 53 | def test_add_run(api, mock, url): 54 | mock.add_callback(responses.POST, url("add_run/12"), add_run) 55 | resp = api.runs.add_run(12, suite_id=1, name="New Run", milestone_id=1) 56 | assert resp["suite_id"] == 1 57 | assert resp["name"] == "New Run" 58 | assert resp["milestone_id"] == 1 59 | 60 | 61 | def test_update_run(api, mock, url): 62 | mock.add_callback(responses.POST, url("update_run/15"), add_run) 63 | resp = api.runs.update_run(15, suite_id=1, name="New Run", milestone_id=1) 64 | assert resp["suite_id"] == 1 65 | assert resp["name"] == "New Run" 66 | assert resp["milestone_id"] == 1 67 | 68 | 69 | def test_close_run(api, mock, url): 70 | mock.add_callback( 71 | responses.POST, url("close_run/3"), lambda _: (200, {}, json.dumps({"id": 3, "is_completed": True})) 72 | ) 73 | resp = api.runs.close_run(3) 74 | assert resp["is_completed"] is True 75 | 76 | 77 | def test_delete_run(api, mock, url): 78 | mock.add_callback(responses.POST, url("delete_run/2"), lambda _: (200, {}, "")) 79 | resp = api.runs.delete_run(2) 80 | assert resp is None 81 | 82 | 83 | @pytest.mark.parametrize("is_completed", (1, True)) 84 | def test_get_runs_bulk(api, mock, url, is_completed): 85 | mock.add_callback(responses.GET, url("get_runs/12"), get_runs) 86 | resp = api.runs.get_runs_bulk( 87 | 12, is_completed=is_completed, created_after=datetime.now(), created_before=datetime.now() 88 | ) 89 | assert resp[0]["is_completed"] == "1" 90 | -------------------------------------------------------------------------------- /tests/test_milestone.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | 4 | import pytest 5 | import responses 6 | 7 | 8 | def add_milestone(r): 9 | req = json.loads(r.body.decode()) 10 | req["id"] = 1 11 | assert isinstance(req["start_on"], int) 12 | assert isinstance(req["due_on"], int) 13 | return 200, {}, json.dumps(req) 14 | 15 | 16 | def get_milestones(r): 17 | req = r.params 18 | assert req["is_started"] == "1" 19 | return ( 20 | 200, 21 | {}, 22 | json.dumps( 23 | { 24 | "offset": 250, 25 | "limit": 250, 26 | "size": 1, 27 | "milestones": [{"id": 1, "name": "Milestone 1", "description": "My new milestone"}], 28 | } 29 | ), 30 | ) 31 | 32 | 33 | def update_milestone(r): 34 | req = json.loads(r.body.decode()) 35 | req["id"] = 1 36 | assert isinstance(req["start_on"], int) 37 | return 200, {}, json.dumps(req) 38 | 39 | 40 | def test_get_milestone(api, mock, url): 41 | mock.add_callback( 42 | responses.GET, 43 | url("get_milestone/1"), 44 | lambda _: (200, {}, json.dumps({"id": 1, "name": "Milestone 1", "description": "My new milestone"})), 45 | content_type="application/json", 46 | ) 47 | response = api.milestones.get_milestone(1) 48 | assert response["name"] == "Milestone 1" 49 | assert response["description"] == "My new milestone" 50 | 51 | 52 | @pytest.mark.parametrize("is_started", (1, True)) 53 | def test_get_milestones(api, mock, url, is_started): 54 | mock.add_callback(responses.GET, url("get_milestones/1"), get_milestones, content_type="application/json") 55 | response = api.milestones.get_milestones(project_id=1, is_started=is_started).get("milestones") 56 | assert response[0]["name"] == "Milestone 1" 57 | assert response[0]["description"] == "My new milestone" 58 | 59 | 60 | def test_add_milestone(api, mock, url): 61 | mock.add_callback(responses.POST, url("add_milestone/1"), add_milestone, content_type="application/json") 62 | response = api.milestones.add_milestone( 63 | project_id=1, 64 | name="New milestone", 65 | start_on=datetime.now(), 66 | due_on=int(datetime.now().timestamp()), 67 | description="My new milestone", 68 | ) 69 | assert response["name"] == "New milestone" 70 | assert response["description"] == "My new milestone" 71 | 72 | 73 | def test_update_milestone(api, mock, url): 74 | mock.add_callback(responses.POST, url("update_milestone/1"), update_milestone, content_type="application/json") 75 | response = api.milestones.update_milestone(1, is_completed=True, parent_id=23, start_on=datetime.now()) 76 | assert response["is_completed"] is True 77 | assert response["parent_id"] == 23 78 | 79 | 80 | def test_delete_milestone(api, mock, url): 81 | mock.add_callback( 82 | responses.POST, url("delete_milestone/1"), lambda _: (200, {}, ""), content_type="application/json" 83 | ) 84 | response = api.milestones.delete_milestone(1) 85 | assert response is None 86 | 87 | 88 | def test_get_milestones_bulk(api, mock, url): 89 | mock.add_callback(responses.GET, url("get_milestones/1"), get_milestones, content_type="application/json") 90 | response = api.milestones.get_milestones_bulk(1, is_started=1) 91 | assert response[0]["name"] == "Milestone 1" 92 | assert response[0]["description"] == "My new milestone" 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Testrail Api 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/testrail-api?color=%2301a001&label=pypi&logo=version)](https://pypi.org/project/testrail-api/) 4 | [![Downloads](https://pepy.tech/badge/testrail-api)](https://pepy.tech/project/testrail-api) 5 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/testrail-api.svg)](https://pypi.org/project/testrail-api/) 6 | [![PyPI - Implementation](https://img.shields.io/pypi/implementation/testrail-api)](https://pypi.org/project/testrail-api/) 7 | [![Build Pypi](https://github.com/tolstislon/testrail-api/actions/workflows/python-publish.yml/badge.svg)](https://github.com/tolstislon/testrail-api) 8 | 9 | This is a Python wrapper of the TestRail API according 10 | to [the official documentation](https://www.gurock.com/testrail/docs/api) 11 | 12 | 13 | Install 14 | ---- 15 | Install using pip with 16 | 17 | ```bash 18 | pip install testrail-api 19 | ``` 20 | 21 | ##### Support environment variables 22 | 23 | ```dotenv 24 | TESTRAIL_URL=https://example.testrail.com/ 25 | TESTRAIL_EMAIL=example@mail.com 26 | TESTRAIL_PASSWORD=password 27 | ``` 28 | 29 | Example 30 | ---- 31 | 32 | ```python 33 | from datetime import datetime 34 | 35 | from testrail_api import TestRailAPI 36 | 37 | api = TestRailAPI("https://example.testrail.com/", "example@mail.com", "password") 38 | 39 | # if use environment variables 40 | # api = TestRailAPI() 41 | 42 | 43 | new_milestone = api.milestones.add_milestone( 44 | project_id=1, 45 | name="New milestone", 46 | start_on=datetime.now() 47 | ) 48 | 49 | my_test_run = api.runs.add_run( 50 | project_id=1, 51 | suite_id=2, 52 | name="My test run", 53 | include_all=True, 54 | milestone_id=new_milestone["id"] 55 | ) 56 | 57 | result = api.results.add_result_for_case( 58 | run_id=my_test_run["id"], 59 | case_id=5, 60 | status_id=1, 61 | comment="Pass", 62 | version="1" 63 | ) 64 | attach = "screenshots/attach.jpg" 65 | api.attachments.add_attachment_to_result(result["id"], attach) 66 | 67 | api.runs.close_run(my_test_run["id"]) 68 | api.milestones.update_milestone(new_milestone["id"], is_completed=True) 69 | ``` 70 | 71 | #### Custom response handler 72 | 73 | ```python 74 | from datetime import datetime 75 | import simplejson 76 | 77 | from testrail_api import TestRailAPI 78 | 79 | 80 | def my_handler(response): 81 | if response.ok: 82 | return simplejson.loads(response.text) 83 | return 'Error' 84 | 85 | 86 | api = TestRailAPI("https://example.testrail.com/", "example@mail.com", "password", response_handler=my_handler) 87 | new_milestone = api.milestones.add_milestone( 88 | project_id=1, 89 | name="New milestone", 90 | start_on=datetime.now() 91 | ) 92 | 93 | ``` 94 | 95 | Contributing 96 | ---- 97 | Contributions are very welcome. 98 | 99 | ###### Getting started 100 | 101 | * python 3.11 102 | * pipenv 2022.12.19+ 103 | 104 | 1. Clone the repository 105 | ```bash 106 | git clone https://github.com/tolstislon/testrail-api.git 107 | cd testrail-api 108 | ``` 109 | 2. Install dev dependencies 110 | ```bash 111 | pipenv install --dev 112 | pipenv shell 113 | ``` 114 | 3. Run the black 115 | ```bash 116 | pipenv run black 117 | ``` 118 | 4. Run the flake8 119 | ```bash 120 | pipenv run flake8 121 | ``` 122 | 5. Run the tests 123 | ```bash 124 | pipenv run tests 125 | ``` 126 | -------------------------------------------------------------------------------- /tests/test_datasets.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | import uuid 4 | from typing import Optional 5 | 6 | import responses 7 | from requests import PreparedRequest 8 | 9 | 10 | def _dataset(variables_count: int = 5, dataset_id: Optional[int] = None) -> dict: 11 | dataset_id = dataset_id or random.randint(1, 1000) 12 | return { 13 | "id": dataset_id, 14 | "name": uuid.uuid4().hex, 15 | "variables": [ 16 | {"id": i, "name": uuid.uuid4().hex, "value": uuid.uuid4().hex} for i in range(1, variables_count + 1) 17 | ], 18 | } 19 | 20 | 21 | def _get_dataset(r: PreparedRequest) -> tuple[int, dict, str]: 22 | dataset_id = int(r.url.split("/")[-1]) 23 | return 200, {}, json.dumps(_dataset(5, dataset_id)) 24 | 25 | 26 | def _get_datasets(_) -> tuple[int, dict, str]: 27 | datasets = [_dataset(variables_count=random.randint(1, 5)) for _ in range(random.randint(1, 10))] 28 | return ( 29 | 200, 30 | {}, 31 | json.dumps( 32 | { 33 | "offset": 0, 34 | "limit": 250, 35 | "size": len(datasets), 36 | "_links": {"next": None, "prev": None}, 37 | "datasets": datasets, 38 | } 39 | ), 40 | ) 41 | 42 | 43 | def _add_dataset(r: PreparedRequest) -> tuple[int, dict, str]: 44 | req = json.loads(r.body) 45 | variables = [{"id": i, **v} for i, v in enumerate(req["variables"], 1)] 46 | req["variables"] = variables 47 | return 200, {}, json.dumps(req) 48 | 49 | 50 | def _update_dataset(r: PreparedRequest) -> tuple[int, dict, str]: 51 | req = json.loads(r.body) 52 | dataset_id = int(r.url.split("/")[-1]) 53 | return ( 54 | 200, 55 | {}, 56 | json.dumps( 57 | { 58 | "id": dataset_id, 59 | "name": req["name"], 60 | "variables": [{"id": i, **v} for i, v in enumerate(req["variables"], 1)], 61 | } 62 | ), 63 | ) 64 | 65 | 66 | def test_get_dataset(api, mock): 67 | dataset_id = random.randint(1, 10000) 68 | mock.add_callback(responses.GET, f"get_dataset/{dataset_id}", _get_dataset) 69 | resp = api.datasets.get_dataset(dataset_id) 70 | assert resp["id"] == dataset_id 71 | for variable in resp["variables"]: 72 | assert tuple(variable) == ("id", "name", "value") 73 | 74 | 75 | def test_get_datasets(api, mock): 76 | project_id = random.randint(1, 1000) 77 | mock.add_callback(responses.GET, f"get_datasets/{project_id}", _get_datasets) 78 | resp = api.datasets.get_datasets(project_id) 79 | assert resp["size"] == len(resp["datasets"]) 80 | for dataset in resp["datasets"]: 81 | assert tuple(dataset) == ("id", "name", "variables") 82 | 83 | 84 | def test_add_dataset(api, mock): 85 | dataset_id, project_id = random.randint(1, 1000), random.randint(1, 1000) 86 | name = uuid.uuid4().hex 87 | variables = [{"name": uuid.uuid4().hex, "value": uuid.uuid4().hex} for _ in range(random.randint(1, 10))] 88 | mock.add_callback(responses.POST, f"add_dataset/{project_id}", _add_dataset) 89 | resp = api.datasets.add_dataset(project_id=project_id, id=dataset_id, name=name, variables=variables) 90 | assert resp["id"] == dataset_id 91 | assert resp["name"] == name 92 | assert len(resp["variables"]) == len(variables) 93 | 94 | 95 | def test_update_dataset(api, mock): 96 | dataset_id = random.randint(1, 10000) 97 | mock.add_callback(responses.POST, f"update_dataset/{dataset_id}", _update_dataset) 98 | new_name = uuid.uuid4().hex 99 | variables = [{"name": uuid.uuid4().hex, "value": uuid.uuid4().hex} for _ in range(random.randint(1, 10))] 100 | resp = api.datasets.update_dataset(dataset_id, name=new_name, variables=variables) 101 | assert resp["id"] == dataset_id 102 | assert resp["name"] == new_name 103 | assert len(resp["variables"]) == len(variables) 104 | 105 | 106 | def test_delete_dataset(api, mock): 107 | dataset_id = random.randint(1, 10000) 108 | mock.add_callback(responses.POST, f"delete_dataset/{dataset_id}", lambda _: (200, {}, None)) 109 | resp = api.datasets.delete_dataset(dataset_id) 110 | assert resp is None 111 | -------------------------------------------------------------------------------- /tests/test_plans.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from datetime import datetime 4 | 5 | import pytest 6 | import responses 7 | 8 | 9 | def get_plans(r): 10 | assert r.params["is_completed"] == "1" 11 | for key in "created_after", "created_before": 12 | assert re.match(r"^\d+$", r.params[key]) 13 | return 200, {}, json.dumps({"offset": 0, "limit": 250, "size": 1, "plans": [{"id": 5, "name": "System test"}]}) 14 | 15 | 16 | def add_plan(r): 17 | data = json.loads(r.body.decode()) 18 | return 200, {}, json.dumps({"id": 96, "name": data["name"], "milestone_id": data["milestone_id"]}) 19 | 20 | 21 | def add_run_to_plan_entry(r): 22 | data = json.loads(r.body.decode()) 23 | assert data["config_ids"] == [1, 2] 24 | return 200, {}, "" 25 | 26 | 27 | def update_run_in_plan_entry(r): 28 | data = json.loads(r.body.decode()) 29 | assert data["description"] == "Test" 30 | return 200, {}, "" 31 | 32 | 33 | def add_plan_entry(r): 34 | data = json.loads(r.body.decode()) 35 | assert data["include_all"] is True 36 | assert data["config_ids"] == [1, 2, 3] 37 | return 200, {}, json.dumps({"id": 5, "name": "System test"}) 38 | 39 | 40 | def update_plan_entry(r): 41 | data = json.loads(r.body.decode()) 42 | assert data["case_ids"] == [2, 3] 43 | return 200, {}, json.dumps({"id": 7, "name": data["name"]}) 44 | 45 | 46 | def test_get_plan(api, mock, url): 47 | mock.add_callback( 48 | responses.GET, url("get_plan/5"), lambda _: (200, {}, json.dumps({"id": 5, "name": "System test"})) 49 | ) 50 | resp = api.plans.get_plan(5) 51 | assert resp["id"] == 5 52 | 53 | 54 | @pytest.mark.parametrize("is_completed", (1, True)) 55 | def test_get_plans(api, mock, url, is_completed): 56 | mock.add_callback(responses.GET, url("get_plans/7"), get_plans) 57 | resp = api.plans.get_plans( 58 | 7, is_completed=is_completed, created_after=datetime.now(), created_before=datetime.now() 59 | ).get("plans") 60 | assert resp[0]["id"] == 5 61 | 62 | 63 | def test_add_plan(api, mock, url): 64 | mock.add_callback(responses.POST, url("add_plan/5"), add_plan) 65 | resp = api.plans.add_plan(5, name="new plan", milestone_id=4) 66 | assert resp["name"] == "new plan" 67 | assert resp["milestone_id"] == 4 68 | 69 | 70 | def test_add_plan_entry(api, mock, url): 71 | mock.add_callback(responses.POST, url("add_plan_entry/7"), add_plan_entry) 72 | resp = api.plans.add_plan_entry(7, 3, include_all=True, config_ids=[1, 2, 3]) 73 | assert resp["id"] == 5 74 | 75 | 76 | def test_update_plan(api, mock, url): 77 | mock.add_callback(responses.POST, url("update_plan/12"), add_plan) 78 | resp = api.plans.update_plan(12, name="update", milestone_id=1) 79 | assert resp["name"] == "update" 80 | assert resp["milestone_id"] == 1 81 | 82 | 83 | def test_update_plan_entry(api, mock, url): 84 | mock.add_callback(responses.POST, url("update_plan_entry/7/1"), update_plan_entry) 85 | resp = api.plans.update_plan_entry(7, 1, name="Update name", case_ids=[2, 3]) 86 | assert resp["name"] == "Update name" 87 | 88 | 89 | def test_close_plan(api, mock, url): 90 | mock.add_callback( 91 | responses.POST, url("close_plan/7"), lambda _: (200, {}, json.dumps({"id": 7, "name": "System test"})) 92 | ) 93 | resp = api.plans.close_plan(7) 94 | assert resp["id"] == 7 95 | 96 | 97 | def test_delete_plan(api, mock, url): 98 | mock.add_callback(responses.POST, url("delete_plan/11"), lambda _: (200, {}, "")) 99 | resp = api.plans.delete_plan(11) 100 | assert resp is None 101 | 102 | 103 | def test_delete_plan_entry(api, mock, url): 104 | mock.add_callback(responses.POST, url("delete_plan_entry/12/2"), lambda _: (200, {}, "")) 105 | resp = api.plans.delete_plan_entry(12, 2) 106 | assert resp is None 107 | 108 | 109 | def test_add_run_to_plan_entry(api, mock, url): 110 | mock.add_callback(responses.POST, url("add_run_to_plan_entry/12/2"), add_run_to_plan_entry) 111 | api.plans.add_run_to_plan_entry(12, 2, [1, 2]) 112 | 113 | 114 | def test_update_run_in_plan_entry(api, mock, url): 115 | mock.add_callback(responses.POST, url("update_run_in_plan_entry/2"), update_run_in_plan_entry) 116 | api.plans.update_run_in_plan_entry(2, description="Test") 117 | 118 | 119 | def test_delete_run_from_plan_entry(api, mock, url): 120 | mock.add_callback(responses.POST, url("delete_run_from_plan_entry/2"), lambda _: (200, {}, "")) 121 | api.plans.delete_run_from_plan_entry(2) 122 | 123 | 124 | def test_get_plans_bulk(api, mock, url): 125 | mock.add_callback( 126 | responses.GET, 127 | url("get_plans/1"), 128 | get_plans, 129 | ) 130 | resp = api.plans.get_plans_bulk( 131 | 1, 132 | is_completed=True, 133 | created_after=datetime.now(), 134 | created_before=datetime.now(), 135 | ) 136 | assert resp[0]["id"] == 5 137 | -------------------------------------------------------------------------------- /tests/test_results.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | import re 4 | from datetime import datetime 5 | 6 | import pytest 7 | import responses 8 | 9 | 10 | def get_results(r, limit="3"): 11 | assert r.params["limit"] == limit 12 | assert r.params["status_id"] == "1,2,3" 13 | return ( 14 | 200, 15 | {}, 16 | json.dumps({"offset": 0, "limit": 250, "size": 1, "results": [{"id": 1, "status_id": 2, "test_id": 1}]}), 17 | ) 18 | 19 | 20 | def get_results_for_run(r, limit="3"): 21 | assert r.params["limit"] == limit 22 | assert r.params["status_id"] == "1,2,3" 23 | for key in "created_after", "created_before": 24 | assert re.match(r"^\d+$", r.params[key]) 25 | return ( 26 | 200, 27 | {}, 28 | json.dumps({"offset": 0, "limit": 250, "size": 1, "results": [{"id": 1, "status_id": 2, "test_id": 1}]}), 29 | ) 30 | 31 | 32 | def add_result(r): 33 | data = json.loads(r.body.decode()) 34 | return ( 35 | 200, 36 | {}, 37 | json.dumps( 38 | { 39 | "id": 1, 40 | "status_id": data["status_id"], 41 | "test_id": 15, 42 | "assignedto_id": data["assignedto_id"], 43 | "comment": data["comment"], 44 | } 45 | ), 46 | ) 47 | 48 | 49 | def add_results(r): 50 | data = json.loads(r.body.decode()) 51 | return 200, {}, json.dumps(data["results"]) 52 | 53 | 54 | @pytest.mark.parametrize("status_id", ("1,2,3", [1, 2, 3])) 55 | def test_get_results(api, mock, url, status_id): 56 | mock.add_callback(responses.GET, url("get_results/221"), get_results) 57 | resp = api.results.get_results(221, limit=3, status_id=status_id).get("results") 58 | assert resp[0]["status_id"] == 2 59 | 60 | 61 | @pytest.mark.parametrize("status_id", ("1,2,3", [1, 2, 3])) 62 | def test_get_results_for_case(api, mock, url, status_id): 63 | mock.add_callback(responses.GET, url("get_results_for_case/23/2567"), get_results) 64 | resp = api.results.get_results_for_case(23, 2567, limit=3, status_id=status_id).get("results") 65 | assert resp[0]["status_id"] == 2 66 | 67 | 68 | @pytest.mark.parametrize("status_id", ("1,2,3", [1, 2, 3])) 69 | def test_get_results_for_run(api, mock, url, status_id): 70 | mock.add_callback(responses.GET, url("get_results_for_run/12"), get_results_for_run) 71 | resp = api.results.get_results_for_run( 72 | 12, limit=3, status_id=status_id, created_after=datetime.now(), created_before=datetime.now() 73 | ).get("results") 74 | assert resp[0]["status_id"] == 2 75 | 76 | 77 | def test_add_result(api, mock, url): 78 | mock.add_callback(responses.POST, url("add_result/15"), add_result) 79 | resp = api.results.add_result(15, status_id=5, comment="Fail", assignedto_id=1) 80 | assert resp["status_id"] == 5 81 | assert resp["comment"] == "Fail" 82 | assert resp["assignedto_id"] == 1 83 | 84 | 85 | def test_add_result_for_case(api, mock, url): 86 | mock.add_callback(responses.POST, url("add_result_for_case/3/34"), add_result) 87 | resp = api.results.add_result_for_case(3, 34, status_id=1, comment="Passed", assignedto_id=1) 88 | assert resp["status_id"] == 1 89 | assert resp["comment"] == "Passed" 90 | assert resp["assignedto_id"] == 1 91 | 92 | 93 | def test_add_results(api, mock, url): 94 | mock.add_callback(responses.POST, url("add_results/15"), add_results) 95 | results = [{"test_id": 1, "status_id": 5}, {"test_id": 2, "status_id": 1}] 96 | resp = api.results.add_results(15, results=results) 97 | assert resp == results 98 | 99 | 100 | def test_add_results_for_cases(api, mock, url): 101 | mock.add_callback(responses.POST, url("add_results_for_cases/18"), add_results) 102 | results = [{"case_id": 1, "status_id": 5}, {"case_id": 2, "status_id": 1}] 103 | resp = api.results.add_results_for_cases(18, results) 104 | assert resp == results 105 | 106 | 107 | @pytest.mark.parametrize("status_id", ("1,2,3", [1, 2, 3])) 108 | def test_get_results_bulk(api, mock, url, status_id): 109 | get_results_bulk = functools.partial(get_results, limit="250") 110 | mock.add_callback(responses.GET, url("get_results/221"), get_results_bulk) 111 | resp = api.results.get_results_bulk(221, status_id=status_id) 112 | assert resp[0]["status_id"] == 2 113 | 114 | 115 | @pytest.mark.parametrize("status_id", ("1,2,3", [1, 2, 3])) 116 | def test_get_results_for_case_bulk(api, mock, url, status_id): 117 | get_results_for_case_bulk = functools.partial(get_results, limit="250") 118 | mock.add_callback( 119 | responses.GET, 120 | url("get_results_for_case/23/2567"), 121 | get_results_for_case_bulk, 122 | ) 123 | resp = api.results.get_results_for_case_bulk(23, 2567, status_id=status_id) 124 | assert resp[0]["status_id"] == 2 125 | 126 | 127 | @pytest.mark.parametrize("status_id", ("1,2,3", [1, 2, 3])) 128 | def test_get_results_for_run_bulk(api, mock, url, status_id): 129 | get_results_for_run_bulk = functools.partial(get_results_for_run, limit="250") 130 | mock.add_callback(responses.GET, url("get_results_for_run/12"), get_results_for_run_bulk) 131 | resp = api.results.get_results_for_run_bulk( 132 | 12, status_id=status_id, created_after=datetime.now(), created_before=datetime.now() 133 | ) 134 | assert resp[0]["status_id"] == 2 135 | -------------------------------------------------------------------------------- /tests/test_other.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | from unittest import mock 4 | 5 | import pytest 6 | import responses 7 | from requests import Session 8 | from requests.exceptions import ConnectionError 9 | 10 | from testrail_api import StatusCodeError 11 | from testrail_api import TestRailAPI as TRApi 12 | from testrail_api._category import _bulk_api_method 13 | from testrail_api._exception import TestRailError as TRError 14 | 15 | 16 | class RateLimit: 17 | """Rate limit test class.""" 18 | 19 | def __init__(self): 20 | self.last = 0 21 | self.count = 0 22 | 23 | def __call__(self, r) -> tuple[int, dict, str]: 24 | self.count += 1 25 | now = time.time() 26 | if self.last == 0 or now - self.last < 3: 27 | self.last = now 28 | return 429, {}, "" 29 | return 200, {}, json.dumps({"count": self.count}) 30 | 31 | 32 | class CustomException(Exception): 33 | """Base custom exception.""" 34 | 35 | 36 | class CustomExceptionRetry: 37 | """Exception retry.""" 38 | 39 | def __init__(self, exception=CustomException, fail=False) -> None: 40 | self.count = 0 41 | self.exception = exception 42 | self.fail = fail 43 | 44 | def __call__(self, *args, **kwargs) -> tuple[int, dict, str]: 45 | self.count += 1 46 | if self.count < 3 or self.fail: 47 | raise self.exception("fail") 48 | return 200, {}, json.dumps({"count": self.count}) 49 | 50 | 51 | class CustomSession(Session): 52 | """Custom session object.""" 53 | 54 | def request(*args, **kwargs) -> None: 55 | """Session request.""" 56 | raise ValueError("CustomSession") 57 | 58 | 59 | def test_rate_limit(api, mock, url): 60 | mock.add_callback( 61 | responses.GET, 62 | url("get_case/1"), 63 | RateLimit(), 64 | ) 65 | resp = api.cases.get_case(1) 66 | assert resp["count"] == 2 67 | 68 | 69 | def test_raise_rate_limit(api, mock, url): 70 | mock.add_callback( 71 | responses.GET, 72 | url("get_case/1"), 73 | lambda _: (429, {}, ""), 74 | ) 75 | with pytest.raises(StatusCodeError): 76 | api.cases.get_case(1) 77 | 78 | 79 | def test_exc_raise_rate_limit(auth_data, mock, url): 80 | api = TRApi(*auth_data, exc=True) 81 | mock.add_callback( 82 | responses.GET, 83 | url("get_case/1"), 84 | lambda _: (429, {}, ""), 85 | ) 86 | resp = api.cases.get_case(1) 87 | assert resp is None 88 | 89 | 90 | def test_exc_raise(auth_data, mock, url): 91 | api = TRApi(*auth_data, exc=True) 92 | mock.add_callback( 93 | responses.GET, 94 | url("get_case/1"), 95 | lambda _: (400, {}, ""), 96 | ) 97 | resp = api.cases.get_case(1) 98 | assert resp is None 99 | 100 | 101 | def test_raise(auth_data, mock, url): 102 | api = TRApi(*auth_data, exc=False) 103 | mock.add_callback( 104 | responses.GET, 105 | url("get_case/1"), 106 | lambda _: (400, {}, ""), 107 | ) 108 | with pytest.raises(StatusCodeError): 109 | api.cases.get_case(1) 110 | 111 | 112 | def test_custom_exception_fails(auth_data, mock, url): 113 | api = TRApi(*auth_data, exc=True, retry_exceptions=(CustomException,)) 114 | mock.add_callback(responses.GET, url("get_case/1"), CustomExceptionRetry(fail=True)) 115 | with pytest.raises(CustomException): 116 | api.cases.get_case(1) 117 | 118 | 119 | def test_custom_exception_succeeds(auth_data, mock, url): 120 | api = TRApi(*auth_data, exc=True, retry_exceptions=(CustomException,)) 121 | mock.add_callback(responses.GET, url("get_case/1"), CustomExceptionRetry(fail=False)) 122 | response = api.cases.get_case(1) 123 | assert response.get("count") == 3 124 | 125 | 126 | def test_custom_exception_fails_different_exception(auth_data, mock, url): 127 | api = TRApi(*auth_data, exc=True, retry_exceptions=(KeyboardInterrupt,)) 128 | mock.add_callback(responses.GET, url("get_case/1"), CustomExceptionRetry(fail=True)) 129 | with pytest.raises(CustomException): 130 | api.cases.get_case(1) 131 | 132 | 133 | def test_no_response_raise(): 134 | api = TRApi("https://asdadadsa.cd", "asd@asd.com", "asdasda", exc=False) 135 | with pytest.raises(ConnectionError): 136 | api.cases.get_case(1) 137 | 138 | 139 | def test_get_email(): 140 | email = "asd@asd.com" 141 | api = TRApi("https://asdadadsa.cd", "asd@asd.com", "asdasda", exc=False) 142 | assert api.user_email == email 143 | 144 | 145 | @pytest.mark.parametrize("field", ("url", "email", "password")) 146 | def test_raise_no_arg(field): 147 | data = {"url": "https://asdadadsa.cd", "email": "asd@asd.com", "password": "asdasda"} 148 | del data[field] 149 | with pytest.raises(TRError): 150 | TRApi(**data) 151 | 152 | 153 | @pytest.mark.usefixtures("environ") 154 | def test_environment_variables(mock, url): 155 | api = TRApi() 156 | mock.add_callback( 157 | responses.GET, 158 | url("get_case/1"), 159 | lambda _: (200, {}, json.dumps({"id": 1})), 160 | ) 161 | resp = api.cases.get_case(1) 162 | assert resp["id"] == 1 163 | 164 | 165 | def test_http_warn(): 166 | with pytest.warns(UserWarning): 167 | TRApi("http://asdadadsa.cd", "asd@asd.com", "asdasda", exc=False) 168 | 169 | 170 | @pytest.mark.filterwarnings("error") 171 | def test_http_no_warn(): 172 | TRApi("http://asdadadsa.cd", "asd@asd.com", "asdasda", warn_ignore=True) 173 | 174 | 175 | def test_response_handler(auth_data, mock, url): 176 | def hook(_) -> str: 177 | return "my hook response" 178 | 179 | api = TRApi(*auth_data, response_handler=hook) 180 | mock.add_callback(responses.GET, url("get_case/1"), lambda _: (200, {}, json.dumps({"a": 1, "b": 2}))) 181 | response = api.cases.get_case(1) 182 | assert response == "my hook response" 183 | 184 | 185 | def test_bulk_endpoint_helper(): 186 | mock_func = mock.Mock() 187 | mock_func.side_effect = [ 188 | {"offset": 0, "limit": 250, "size": 250, "data": [{"id": _} for _ in range(250)]}, 189 | {"offset": 250, "limit": 250, "size": 1, "data": [{"id": 101}]}, 190 | ] 191 | resp = _bulk_api_method(mock_func, "data") 192 | assert len(resp) == 251 193 | assert all("id" in _ for _ in resp) 194 | 195 | 196 | def test_add_custom_session(auth_data): 197 | api = TRApi(*auth_data, session=CustomSession()) 198 | with pytest.raises(ValueError, match="CustomSession"): 199 | api.users.get_users() 200 | -------------------------------------------------------------------------------- /tests/test_cases.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from datetime import datetime 4 | from functools import partial 5 | 6 | import responses 7 | 8 | 9 | def get_cases(r): 10 | assert r.params["suite_id"] 11 | assert r.params["section_id"] 12 | assert r.params["limit"] 13 | assert r.params["offset"] 14 | for key in "created_after", "created_before", "updated_after", "updated_before": 15 | assert re.match(r"^\d+$", r.params[key]) 16 | return ( 17 | 200, 18 | {}, 19 | json.dumps({"limit": 250, "offset": 250, "size": 1, "cases": [{"id": 1, "type_id": 1, "title": "My case"}]}), 20 | ) 21 | 22 | 23 | def add_case(r): 24 | data = json.loads(r.body.decode()) 25 | return ( 26 | 200, 27 | {}, 28 | json.dumps({"id": 1, "title": data["title"], "priority_id": data["priority_id"]}), 29 | ) 30 | 31 | 32 | def update_case(r): 33 | data = json.loads(r.body.decode()) 34 | return 200, {}, json.dumps({"id": 1, "title": data["title"]}) 35 | 36 | 37 | def update_cases(r): 38 | suite_id = r.url.split("/")[-1] 39 | data = json.loads(r.body.decode()) 40 | return ( 41 | 200, 42 | {}, 43 | json.dumps( 44 | {"updated": [{"id": _, "suite_id": int(suite_id), "title": data["title"]} for _ in data["case_ids"]]} 45 | ), 46 | ) 47 | 48 | 49 | def delete_cases(r, project_id=None, case_ids=None, suite_id=None, soft=0): 50 | assert int(r.params["soft"]) == soft 51 | assert int(r.params["project_id"]) == project_id 52 | if suite_id: 53 | assert f"delete_cases/{suite_id}&" in r.url 54 | else: 55 | assert "delete_cases&" in r.url 56 | assert json.loads(r.body.decode()) == {"case_ids": case_ids} 57 | return 200, {}, "" 58 | 59 | 60 | # def copy_cases_to_section(r): 61 | # body = json.loads(r.body.decode()) 62 | # assert body['case_ids'] == '1,2,3' 63 | # return 200, {}, '' 64 | 65 | 66 | def test_get_case(api, mock, url): 67 | mock.add_callback( 68 | responses.GET, 69 | url("get_case/1"), 70 | lambda _: (200, {}, json.dumps({"id": 1, "type_id": 1, "title": "My case"})), 71 | ) 72 | resp = api.cases.get_case(1) 73 | assert resp["id"] == 1 74 | 75 | 76 | def test_get_cases(api, mock, url): 77 | mock.add_callback( 78 | responses.GET, 79 | url("get_cases/1"), 80 | get_cases, 81 | ) 82 | now = datetime.now() 83 | 84 | resp = api.cases.get_cases( 85 | 1, 86 | suite_id=2, 87 | section_id=3, 88 | limit=5, 89 | offset=10, 90 | created_after=now, 91 | created_before=round(now.timestamp()), 92 | updated_after=now, 93 | updated_before=now, 94 | ) 95 | assert resp.get("cases")[0]["id"] == 1 96 | 97 | 98 | def test_add_case(api, mock, url): 99 | mock.add_callback( 100 | responses.POST, 101 | url("add_case/2"), 102 | add_case, 103 | ) 104 | resp = api.cases.add_case(2, "New case", priority_id=1) 105 | assert resp["title"] == "New case" 106 | assert resp["priority_id"] == 1 107 | 108 | 109 | def test_update_case(api, mock, url): 110 | mock.add_callback( 111 | responses.POST, 112 | url("update_case/1"), 113 | update_case, 114 | ) 115 | resp = api.cases.update_case(1, title="New case title") 116 | assert resp["title"] == "New case title" 117 | 118 | 119 | def test_delete_case(api, mock, url): 120 | mock.add_callback( 121 | responses.POST, 122 | url("delete_case/5"), 123 | lambda _: (200, {}, ""), 124 | ) 125 | resp = api.cases.delete_case(5) 126 | assert resp is None 127 | 128 | 129 | def test_get_history_for_case(api, mock, url): 130 | mock.add_callback( 131 | responses.GET, 132 | url("get_history_for_case/7"), 133 | lambda _: (200, {}, ""), 134 | ) 135 | api.cases.get_history_for_case(7) 136 | 137 | 138 | def test_update_cases(api, mock, url): 139 | mock.add_callback( 140 | responses.POST, 141 | url("update_cases/1"), 142 | update_cases, 143 | ) 144 | body = {"title": "New title", "estimate": "5m"} 145 | resp = api.cases.update_cases([1, 2, 3], 1, **body) 146 | assert resp["updated"][0]["title"] == "New title" 147 | assert resp["updated"][0]["suite_id"] == 1 148 | # verify [1,2,3] are the case_ids 149 | assert all(resp["updated"][_]["id"] in [1, 2, 3] for _ in range(len(resp["updated"]))) 150 | 151 | 152 | def test_delete_cases_no_suite_id(api, mock, url): 153 | mock.add_callback( 154 | responses.POST, 155 | url("delete_cases"), 156 | partial(delete_cases, project_id=1, case_ids=[5, 6]), 157 | ) 158 | api.cases.delete_cases(1, [5, 6]) 159 | 160 | 161 | def test_delete_cases_suite_id(api, mock, url): 162 | mock.add_callback( 163 | responses.POST, 164 | url("delete_cases/1"), 165 | partial(delete_cases, project_id=1, suite_id=1, case_ids=[5, 6]), 166 | ) 167 | api.cases.delete_cases(1, [5, 6], 1) 168 | 169 | 170 | def test_delete_cases_suite_id_soft(api, mock, url): 171 | mock.add_callback( 172 | responses.POST, 173 | url("delete_cases/1"), 174 | partial(delete_cases, project_id=1, suite_id=1, soft=1, case_ids=[5, 6]), 175 | ) 176 | api.cases.delete_cases(1, [5, 6], 1, 1) 177 | 178 | 179 | def test_copy_cases_to_section(api, mock, url): 180 | mock.add_callback(responses.POST, url("copy_cases_to_section/2"), lambda x: (200, {}, x.body)) 181 | resp = api.cases.copy_cases_to_section(section_id=2, case_ids=[1, 2, 3]) 182 | assert resp["case_ids"] == "1,2,3" 183 | 184 | 185 | def test_move_cases_to_section(api, mock, url): 186 | mock.add_callback(responses.POST, url("move_cases_to_section/5"), lambda x: (200, {}, x.body)) 187 | resp = api.cases.move_cases_to_section(5, 6, case_ids=[1, 2, 3]) 188 | assert resp["case_ids"] == "1,2,3" 189 | assert resp["suite_id"] == 6 190 | 191 | 192 | def test_get_cases_bulk(api, mock, url): 193 | mock.add_callback( 194 | responses.GET, 195 | url("get_cases/1"), 196 | get_cases, 197 | ) 198 | now = datetime.now() 199 | 200 | resp = api.cases.get_cases_bulk( 201 | 1, 202 | suite_id=2, 203 | section_id=3, 204 | limit=5, 205 | offset=10, 206 | created_after=now, 207 | created_before=round(now.timestamp()), 208 | updated_after=now, 209 | updated_before=now, 210 | ) 211 | assert resp[0]["id"] == 1 212 | -------------------------------------------------------------------------------- /tests/test_attachments.py: -------------------------------------------------------------------------------- 1 | import json 2 | from functools import partial 3 | from pathlib import Path 4 | 5 | import pytest 6 | import responses 7 | 8 | from testrail_api import StatusCodeError 9 | 10 | 11 | def add_attachment(r): 12 | assert "multipart/form-data" in r.headers["Content-Type"] 13 | assert r.headers["User-Agent"].startswith("Python TestRail API v:") 14 | assert r.body 15 | return 200, {}, json.dumps({"attachment_id": 433}) 16 | 17 | 18 | def get_attachment(_, path): 19 | file = Path(path, "attach.jpg") 20 | with file.open("rb") as f: 21 | return 200, {}, f 22 | 23 | 24 | def test_add_attachment_to_plan(api, mock, url, base_path): 25 | mock.add_callback(responses.POST, url("add_attachment_to_plan/3"), add_attachment) 26 | file = Path(base_path, "attach.jpg") 27 | resp = api.attachments.add_attachment_to_plan(3, file) 28 | assert resp["attachment_id"] == 433 29 | 30 | 31 | def test_add_attachment_to_plan_entry(api, mock, url, base_path): 32 | mock.add_callback(responses.POST, url("add_attachment_to_plan_entry/3/4"), add_attachment) 33 | file = Path(base_path, "attach.jpg") 34 | resp = api.attachments.add_attachment_to_plan_entry(3, 4, file) 35 | assert resp["attachment_id"] == 433 36 | 37 | 38 | def test_add_attachment_to_result_pathlib(api, mock, url, base_path): 39 | mock.add_callback(responses.POST, url("add_attachment_to_result/2"), add_attachment) 40 | file = Path(base_path, "attach.jpg") 41 | resp = api.attachments.add_attachment_to_result(2, file) 42 | assert resp["attachment_id"] == 433 43 | 44 | 45 | def test_add_attachment_to_result_str(api, mock, url, base_path): 46 | mock.add_callback(responses.POST, url("add_attachment_to_result/2"), add_attachment) 47 | file = Path(base_path, "attach.jpg") 48 | resp = api.attachments.add_attachment_to_result(2, str(file)) 49 | assert resp["attachment_id"] == 433 50 | 51 | 52 | def test_add_attachment_to_run(api, mock, url, base_path): 53 | mock.add_callback(method=responses.POST, url=url("add_attachment_to_run/2"), callback=add_attachment) 54 | file = Path(base_path, "attach.jpg") 55 | resp = api.attachments.add_attachment_to_run(2, file) 56 | assert resp["attachment_id"] == 433 57 | 58 | 59 | def test_add_attachment_to_case_str(api, mock, url, base_path): 60 | mock.add_callback(responses.POST, url("add_attachment_to_case/2"), add_attachment) 61 | file = Path(base_path, "attach.jpg") 62 | resp = api.attachments.add_attachment_to_case(2, str(file)) 63 | assert resp["attachment_id"] == 433 64 | 65 | 66 | def test_add_attachment_to_case(api, mock, url, base_path): 67 | mock.add_callback(method=responses.POST, url=url("add_attachment_to_case/2"), callback=add_attachment) 68 | file = Path(base_path, "attach.jpg") 69 | resp = api.attachments.add_attachment_to_case(2, file) 70 | assert resp["attachment_id"] == 433 71 | 72 | 73 | def test_get_attachments_for_case(api, mock, url): 74 | mock.add_callback( 75 | responses.GET, 76 | url("get_attachments_for_case/2"), 77 | lambda _: ( 78 | 200, 79 | {}, 80 | json.dumps({"limit": 250, "offset": 250, "size": 1, "attachments": [{"id": 1, "filename": "444.jpg"}]}), 81 | ), 82 | ) 83 | resp = api.attachments.get_attachments_for_case(2) 84 | assert resp.get("attachments")[0]["filename"] == "444.jpg" 85 | 86 | 87 | def test_get_attachments_for_plan(api, mock, url): 88 | mock.add_callback( 89 | responses.GET, 90 | url("get_attachments_for_plan/2"), 91 | lambda _: ( 92 | 200, 93 | {}, 94 | json.dumps({"limit": 250, "offset": 250, "size": 1, "attachments": [{"id": 1, "filename": "444.jpg"}]}), 95 | ), 96 | ) 97 | resp = api.attachments.get_attachments_for_plan(2) 98 | assert resp.get("attachments")[0]["filename"] == "444.jpg" 99 | 100 | 101 | def test_get_attachments_for_plan_entry(api, mock, url): 102 | mock.add_callback( 103 | responses.GET, 104 | url("get_attachments_for_plan_entry/2/1"), 105 | lambda _: ( 106 | 200, 107 | {}, 108 | json.dumps({"limit": 250, "offset": 250, "size": 1, "attachments": [{"id": 1, "filename": "444.jpg"}]}), 109 | ), 110 | ) 111 | resp = api.attachments.get_attachments_for_plan_entry(2, 1) 112 | assert resp.get("attachments")[0]["filename"] == "444.jpg" 113 | 114 | 115 | def test_get_attachments_for_run(api, mock, url): 116 | mock.add_callback( 117 | responses.GET, 118 | url("get_attachments_for_run/2"), 119 | lambda _: ( 120 | 200, 121 | {}, 122 | json.dumps({"limit": 250, "offset": 250, "size": 1, "attachments": [{"id": 1, "filename": "444.jpg"}]}), 123 | ), 124 | ) 125 | resp = api.attachments.get_attachments_for_run(2) 126 | assert resp.get("attachments")[0]["filename"] == "444.jpg" 127 | 128 | 129 | def test_get_attachments_for_test(api, mock, url): 130 | mock.add_callback( 131 | responses.GET, 132 | url("get_attachments_for_test/12"), 133 | lambda _: ( 134 | 200, 135 | {}, 136 | json.dumps({"limit": 250, "offset": 250, "size": 1, "attachments": [{"id": 1, "filename": "444.jpg"}]}), 137 | ), 138 | ) 139 | resp = api.attachments.get_attachments_for_test(12) 140 | assert resp.get("attachments")[0]["filename"] == "444.jpg" 141 | 142 | 143 | def test_get_attachment(api, mock, url, base_path): 144 | mock.add_callback(responses.GET, url("get_attachment/433"), partial(get_attachment, path=base_path)) 145 | file = Path(base_path, "new_attach.jpg") 146 | new_file = api.attachments.get_attachment(433, file) 147 | assert new_file.exists() 148 | new_file.unlink() 149 | 150 | 151 | def test_get_attachment_str(api, mock, url, base_path): 152 | mock.add_callback(responses.GET, url("get_attachment/433"), partial(get_attachment, path=base_path)) 153 | file = Path(base_path, "new_attach_str.jpg") 154 | new_file = api.attachments.get_attachment(433, str(file)) 155 | assert new_file.exists() 156 | new_file.unlink() 157 | 158 | 159 | def test_get_attachment_error(api, mock, url, base_path): 160 | mock.add_callback(responses.GET, url("get_attachment/433"), lambda _: (400, {}, "")) 161 | file = Path(base_path, "new_attach_str.jpg") 162 | with pytest.raises(StatusCodeError): 163 | new_file = api.attachments.get_attachment(433, str(file)) 164 | assert new_file is None 165 | 166 | 167 | def test_delete_attachment(api, mock, url): 168 | mock.add_callback(responses.POST, url("delete_attachment/433"), lambda _: (200, {}, "")) 169 | resp = api.attachments.delete_attachment(433) 170 | assert resp is None 171 | 172 | 173 | def test_get_attachments_for_case_bulk(api, mock, url): 174 | mock.add_callback( 175 | responses.GET, 176 | url("get_attachments_for_case/2"), 177 | lambda _: ( 178 | 200, 179 | {}, 180 | json.dumps({"limit": 250, "offset": 250, "size": 1, "attachments": [{"id": 1, "filename": "444.jpg"}]}), 181 | ), 182 | ) 183 | resp = api.attachments.get_attachments_for_case_bulk(2) 184 | assert resp[0]["filename"] == "444.jpg" 185 | 186 | 187 | def test_get_attachments_for_plan_bulk(api, mock, url): 188 | mock.add_callback( 189 | responses.GET, 190 | url("get_attachments_for_plan/2"), 191 | lambda _: ( 192 | 200, 193 | {}, 194 | json.dumps({"limit": 250, "offset": 250, "size": 1, "attachments": [{"id": 1, "filename": "444.jpg"}]}), 195 | ), 196 | ) 197 | resp = api.attachments.get_attachments_for_plan_bulk(2) 198 | assert resp[0]["filename"] == "444.jpg" 199 | 200 | 201 | def test_get_attachments_for_run_bulk(api, mock, url): 202 | mock.add_callback( 203 | responses.GET, 204 | url("get_attachments_for_run/2"), 205 | lambda _: ( 206 | 200, 207 | {}, 208 | json.dumps({"limit": 250, "offset": 250, "size": 1, "attachments": [{"id": 1, "filename": "444.jpg"}]}), 209 | ), 210 | ) 211 | resp = api.attachments.get_attachments_for_run_bulk(2) 212 | assert resp[0]["filename"] == "444.jpg" 213 | 214 | 215 | def test_get_attachments_for_plan_entry_bulk(api, mock, url): 216 | mock.add_callback( 217 | responses.GET, 218 | url("get_attachments_for_plan_entry/2/1"), 219 | lambda _: ( 220 | 200, 221 | {}, 222 | json.dumps({"limit": 250, "offset": 250, "size": 1, "attachments": [{"id": 1, "filename": "444.jpg"}]}), 223 | ), 224 | ) 225 | resp = api.attachments.get_attachments_for_plan_entry_bulk(2, 1) 226 | assert resp[0]["filename"] == "444.jpg" 227 | 228 | 229 | def test_get_attachments_for_test_bulk(api, mock, url): 230 | mock.add_callback( 231 | responses.GET, 232 | url("get_attachments_for_test/2"), 233 | lambda _: ( 234 | 200, 235 | {}, 236 | json.dumps({"limit": 250, "offset": 250, "size": 1, "attachments": [{"id": 1, "filename": "444.jpg"}]}), 237 | ), 238 | ) 239 | resp = api.attachments.get_attachments_for_test_bulk(2) 240 | assert resp[0]["filename"] == "444.jpg" 241 | -------------------------------------------------------------------------------- /testrail_api/_session.py: -------------------------------------------------------------------------------- 1 | """Base session.""" 2 | 3 | import logging 4 | import time 5 | import warnings 6 | from datetime import datetime 7 | from json.decoder import JSONDecodeError 8 | from os import environ 9 | from pathlib import Path 10 | from typing import Any, Callable, Final, Optional, Union 11 | 12 | import requests 13 | 14 | from . import __version__ 15 | from ._enums import METHODS 16 | from ._exception import StatusCodeError, TestRailError 17 | 18 | logger = logging.getLogger(__package__) 19 | 20 | RATE_LIMIT_STATUS_CODE: Final[int] = 429 21 | 22 | 23 | class Environ: 24 | URL: str = "TESTRAIL_URL" 25 | EMAIL: str = "TESTRAIL_EMAIL" 26 | PASSWORD: str = "TESTRAIL_PASSWORD" # noqa: S105 27 | 28 | 29 | class Session: 30 | """Base Session.""" 31 | 32 | _user_agent = f"Python TestRail API v: {__version__}" 33 | 34 | def __init__( # noqa: PLR0913 35 | self, 36 | url: Optional[str] = None, 37 | email: Optional[str] = None, 38 | password: Optional[str] = None, 39 | *, 40 | exc: bool = False, 41 | rate_limit: bool = True, 42 | warn_ignore: bool = False, 43 | retry_exceptions: tuple[type[BaseException], ...] = (), 44 | response_handler: Optional[Callable[[requests.Response], Any]] = None, 45 | session: Optional[requests.Session] = None, 46 | **kwargs, 47 | ) -> None: 48 | """ 49 | Session constructor. 50 | 51 | :param url: 52 | TestRail address. 53 | :param email: 54 | Email for the account on the TestRail. 55 | :param password: 56 | Password for the account on the TestRail or token. 57 | :param session: 58 | A Given session will be used instead of new one. 59 | :param exc: 60 | Catching exceptions. 61 | :param rate_limit: 62 | Check the response header for the rate limit and retry the request. 63 | :param warn_ignore: 64 | Ignore warning when not using HTTPS. 65 | :param retry_exceptions: 66 | Set of exceptions to retry the request. 67 | :param response_hook: 68 | Override default response handling. 69 | :param kwargs: 70 | :key timeout: int (default: 30) 71 | How many seconds to wait for the server to send data. 72 | :key verify: bool (default: True) 73 | Controls whether we verify the server's certificate. 74 | :key headers: dict 75 | Dictionary of HTTP Headers to send. 76 | :key retry: int (default 3) 77 | Delay in receiving code 429. 78 | :key exc_iterations: int (default 3) 79 | """ 80 | _url = self.__get_url(url=url, warn_ignore=warn_ignore) 81 | _email = self.__get_email(email=email) 82 | _password = self.__get_password(password=password) 83 | self.__base_url = f"{_url}/index.php?/api/v2/" 84 | self.__timeout = kwargs.get("timeout", 30) 85 | self.__session = session or requests.Session() 86 | self.__session.headers["User-Agent"] = self._user_agent 87 | self.__session.headers.update(kwargs.get("headers", {})) 88 | self.__session.verify = kwargs.get("verify", True) 89 | self.__retry = kwargs.get("retry", 3) 90 | self.__user_email = _email 91 | self.__session.auth = (self.__user_email, _password) 92 | self.__exc = exc 93 | self.__retry_exceptions = (KeyError, *retry_exceptions) 94 | self.__exc_iterations = kwargs.get("exc_iterations", 3) 95 | self.__response_handler = response_handler or self.__default_response_handler 96 | self._rate_limit = rate_limit 97 | logger.info( 98 | "Create Session{url: %s, user: %s, timeout: %s, headers: %s, verify: " 99 | "%s, exception: %s, exc_iterations: %s, retry: %s}", 100 | url, 101 | self.__user_email, 102 | self.__timeout, 103 | self.__session.headers, 104 | self.__session.verify, 105 | self.__exc, 106 | self.__exc_iterations, 107 | self.__retry, 108 | ) 109 | 110 | @property 111 | def user_email(self) -> str: 112 | """Get user email.""" 113 | return self.__user_email 114 | 115 | @staticmethod 116 | def __get_url(url: str, warn_ignore: bool) -> str: 117 | """Read URL.""" 118 | if not (_url := url or environ.get(Environ.URL)): 119 | raise TestRailError(f"Url is not set. Use argument url or env {Environ.URL}") 120 | _url = _url.rstrip("/") 121 | if _url.startswith("http://") and not warn_ignore: 122 | warnings.warn( 123 | "Using HTTP and not HTTPS may cause writeable API requests to return 404 errors", stacklevel=2 124 | ) 125 | return _url 126 | 127 | @staticmethod 128 | def __get_email(email: Optional[str]) -> str: 129 | """Read email.""" 130 | if not (_email := email or environ.get(Environ.EMAIL)): 131 | raise TestRailError(f"Email is not set. Use argument email or env {Environ.EMAIL}") 132 | return _email 133 | 134 | @staticmethod 135 | def __get_password(password: str) -> str: 136 | """Read password.""" 137 | if not (_password := password or environ.get(Environ.PASSWORD)): 138 | raise TestRailError(f"Password is not set. Use argument password or env {Environ.PASSWORD}") 139 | return _password 140 | 141 | def __default_response_handler(self, response: requests.Response) -> Any: 142 | """Deserialization json or return None.""" 143 | if not response.ok: 144 | logger.error( 145 | "Code: %s, reason: %s url: %s, content: %s", 146 | response.status_code, 147 | response.reason, 148 | response.url, 149 | response.content, 150 | ) 151 | if not self.__exc: 152 | raise StatusCodeError( 153 | response.status_code, 154 | response.reason, 155 | response.url, 156 | response.content, 157 | ) 158 | logger.debug("Response body: %s", response.text) 159 | try: 160 | return response.json() 161 | except (JSONDecodeError, ValueError): 162 | return response.text or None 163 | 164 | @staticmethod 165 | def __get_converter(params: dict) -> None: 166 | """Convert GET parameters.""" 167 | for key, value in params.items(): 168 | if isinstance(value, (list, tuple, set)): 169 | # Converting a list to a string '1,2,3' 170 | params[key] = ",".join(str(i) for i in value) 171 | elif isinstance(value, bool): 172 | # Converting a boolean value to integer 173 | params[key] = int(value) 174 | elif isinstance(value, datetime): 175 | # Converting a datetime value to integer (UNIX timestamp) 176 | params[key] = round(value.timestamp()) 177 | 178 | @staticmethod 179 | def __post_converter(json: dict) -> None: 180 | """Convert POST parameters.""" 181 | for key, value in json.items(): 182 | if isinstance(value, datetime): 183 | # Converting a datetime value to integer (UNIX timestamp) 184 | json[key] = round(value.timestamp()) 185 | 186 | def get(self, endpoint: str, params: Optional[dict[Any, Any]] = None) -> Any: 187 | """GET method.""" 188 | return self.request( 189 | method=METHODS.GET, 190 | endpoint=endpoint, 191 | params=params or {}, 192 | ) 193 | 194 | def post( 195 | self, 196 | endpoint: str, 197 | params: Optional[dict[Any, Any]] = None, 198 | json: Optional[dict[Any, Any]] = None, 199 | ) -> Any: 200 | """POST method.""" 201 | return self.request( 202 | method=METHODS.POST, 203 | endpoint=endpoint, 204 | params=params or {}, 205 | json=json or {}, 206 | ) 207 | 208 | def request(self, method: METHODS, endpoint: str, raw: bool = False, **kwargs) -> Any: 209 | """Send request method.""" 210 | url = f"{self.__base_url}{endpoint}" 211 | if not endpoint.startswith("add_attachment"): 212 | headers = kwargs.setdefault("headers", {}) 213 | headers.update({"Content-Type": "application/json"}) 214 | 215 | self.__get_converter(kwargs.get("params", {})) 216 | self.__post_converter(kwargs.get("json", {})) 217 | 218 | for count in range(self.__exc_iterations): # noqa: RET503 219 | try: 220 | response = self.__session.request(method=str(method.value), url=url, timeout=self.__timeout, **kwargs) 221 | except self.__retry_exceptions as exc: 222 | if count < self.__exc_iterations - 1: 223 | logger.warning("%s, retrying %s/%s", exc, count + 1, self.__exc_iterations) 224 | continue 225 | raise 226 | except Exception as err: 227 | logger.error("%s", err, exc_info=True) 228 | raise err 229 | if ( 230 | self._rate_limit 231 | and response.status_code == RATE_LIMIT_STATUS_CODE 232 | and count < self.__exc_iterations - 1 233 | ): 234 | time.sleep(int(response.headers.get("retry-after", self.__retry))) 235 | continue 236 | logger.debug("Response header: %s", response.headers) 237 | return response if raw else self.__response_handler(response) 238 | 239 | @staticmethod 240 | def _path(path: Union[Path, str]) -> Path: 241 | return path if isinstance(path, Path) else Path(path) 242 | 243 | def attachment_request(self, method: METHODS, src: str, file: Union[Path, str], **kwargs) -> dict: 244 | """Send attach.""" 245 | file = self._path(file) 246 | with file.open("rb") as attachment: 247 | return self.request(method, src, files={"attachment": attachment}, **kwargs) 248 | 249 | def get_attachment(self, method: METHODS, src: str, file: Union[Path, str], **kwargs) -> Path: 250 | """Download attach.""" 251 | file = self._path(file) 252 | response = self.request(method, src, raw=True, **kwargs) 253 | if response.ok: 254 | with file.open("wb") as attachment: 255 | attachment.write(response.content) 256 | return file 257 | return self.__default_response_handler(response) 258 | -------------------------------------------------------------------------------- /testrail_api/_category.py: -------------------------------------------------------------------------------- 1 | """TestRail API categories.""" 2 | 3 | import itertools 4 | from pathlib import Path 5 | from typing import Any, Callable, Optional, Union 6 | 7 | from ._enums import METHODS 8 | from ._session import Session 9 | 10 | OFFSET_MAX = 250 11 | LIMIT_MAX = 250 12 | 13 | 14 | def _bulk_api_method(func: Callable, resp_key: str, *args, **kwargs) -> list: 15 | """ 16 | Get the objects handling the pagination via offset. 17 | 18 | If the size returned from the API is less than the offset value. 19 | """ 20 | _response_objects = [] 21 | for offset in itertools.count(0, OFFSET_MAX): 22 | kwargs.update({"offset": offset, "limit": LIMIT_MAX}) 23 | _resp = func(*args, **kwargs) 24 | _response_objects.extend(_resp.get(resp_key)) 25 | if _resp.get("size") < LIMIT_MAX: 26 | break 27 | return _response_objects 28 | 29 | 30 | class _MetaCategory: 31 | """Meta Category.""" 32 | 33 | @property 34 | def s(self) -> Session: 35 | return self._session 36 | 37 | def __call__(self, session: Session): # noqa: ANN204 (3.9 and 3.10 no Self) 38 | self._session = session 39 | return self 40 | 41 | def __get__(self, instance: Session, owner: type[Session]): # noqa: ANN204 (3.9 and 3.10 no Self) 42 | return self(instance) 43 | 44 | 45 | class Attachments(_MetaCategory): 46 | """https://www.gurock.com/testrail/docs/api/reference/attachments.""" 47 | 48 | def add_attachment_to_plan(self, plan_id: int, path: Union[str, Path]) -> dict: 49 | """ 50 | Adds an attachment to a test plan. 51 | 52 | The maximum allowable upload size is set to 256 mb. 53 | Requires TestRail 6.3 or later. 54 | 55 | :param plan_id: 56 | The ID of the test plan the attachment should be added to 57 | :param path: 58 | The path to the file 59 | :return: dict 60 | ex: {"attachment_id": 443} 61 | """ 62 | return self.s.attachment_request(METHODS.POST, f"add_attachment_to_plan/{plan_id}", path) 63 | 64 | def add_attachment_to_plan_entry(self, plan_id: int, entry_id: int, path: Union[str, Path]) -> dict: 65 | """ 66 | Adds an attachment to a test plan entry. 67 | 68 | The maximum allowable upload size is set to 256 mb. 69 | Requires TestRail 6.3 or later. 70 | 71 | :param plan_id: 72 | The ID of the test plan containing the entry 73 | :param entry_id: 74 | The ID of the test plan entry the attachment should be added to 75 | :param path: 76 | The path to the file 77 | :return: dict 78 | ex: {"attachment_id": 443} 79 | """ 80 | return self.s.attachment_request( 81 | METHODS.POST, 82 | f"add_attachment_to_plan_entry/{plan_id}/{entry_id}", 83 | path, 84 | ) 85 | 86 | def add_attachment_to_result(self, result_id: int, path: Union[str, Path]) -> dict: 87 | """ 88 | Adds attachment to a result based on the result ID. 89 | 90 | The maximum allowable upload size is set to 256 mb. 91 | Requires TestRail 5.7 or later. 92 | 93 | :param result_id: 94 | The ID of the result the attachment should be added to 95 | :param path: 96 | The path to the file 97 | :return: dict 98 | ex: {"attachment_id": 443} 99 | """ 100 | return self.s.attachment_request(METHODS.POST, f"add_attachment_to_result/{result_id}", path) 101 | 102 | def add_attachment_to_run(self, run_id: int, path: Union[str, Path]) -> dict: 103 | """ 104 | Adds attachment to test run. 105 | 106 | The maximum allowable upload size is set to 256 mb. 107 | Requires TestRail 6.3 or later. 108 | 109 | :param run_id: 110 | The ID of the test run the attachment should be added to 111 | :param path: 112 | The path to the file 113 | :return: dict 114 | ex: {"attachment_id": 443} 115 | """ 116 | return self.s.attachment_request(METHODS.POST, f"add_attachment_to_run/{run_id}", path) 117 | 118 | def add_attachment_to_case(self, case_id: int, path: Union[str, Path]) -> dict: 119 | """ 120 | Adds attachment to a case based on the case ID. 121 | 122 | The maximum allowable upload size is set to 256 mb. 123 | Requires TestRail 6.5.2 or later. 124 | 125 | :param case_id: 126 | The ID of the case the attachment should be added to 127 | :param path: 128 | The path to the file 129 | :return: dict 130 | ex: {"attachment_id": 443} 131 | """ 132 | return self.s.attachment_request(METHODS.POST, f"add_attachment_to_case/{case_id}", path) 133 | 134 | def get_attachments_for_case(self, case_id: int, limit: int = 250, offset: int = 0) -> dict: 135 | """ 136 | Returns a list of attachments for a test case. 137 | 138 | Requires TestRail 5.7 or later 139 | 140 | :param case_id: int 141 | The ID of the test case 142 | :param limit: int 143 | The number of attachments the response should return 144 | (The response size is 250 by default) (requires TestRail 6.7 or later) 145 | :param offset: int 146 | Where to start counting the attachments from (the offset) 147 | (requires TestRail 6.7 or later) 148 | :return: response 149 | """ 150 | return self.s.get( 151 | endpoint=f"get_attachments_for_case/{case_id}", 152 | params={"limit": limit, "offset": offset}, 153 | ) 154 | 155 | def get_attachments_for_plan(self, plan_id: int, limit: int = 250, offset: int = 0) -> list[dict]: 156 | """ 157 | Returns a list of attachments for a test plan. 158 | 159 | Requires TestRail 6.3 or later. 160 | 161 | :param plan_id: 162 | The ID of the test plan to retrieve attachments from 163 | :param limit: 164 | The number of attachments the response should return 165 | (The response size is 250 by default) (requires TestRail 6.7 or later) 166 | :param offset: 167 | Where to start counting the attachments from (the offset) 168 | (requires TestRail 6.7 or later) 169 | :return: response 170 | """ 171 | return self.s.get( 172 | endpoint=f"get_attachments_for_plan/{plan_id}", 173 | params={"limit": limit, "offset": offset}, 174 | ) 175 | 176 | def get_attachments_for_plan_entry(self, plan_id: int, entry_id: int, **kwargs) -> list[dict]: 177 | """ 178 | Returns a list of attachments for a test plan entry. 179 | 180 | Requires TestRail 6.3 or later. 181 | 182 | :param plan_id: 183 | The ID of the test plan containing the entry 184 | :param entry_id: 185 | The ID of the test plan entry to retrieve attachments from 186 | :param kwargs: 187 | :key limit/offset: int 188 | The number of attachments the response should return 189 | :return: response 190 | """ 191 | return self.s.get( 192 | endpoint=f"get_attachments_for_plan_entry/{plan_id}/{entry_id}", 193 | params=kwargs, 194 | ) 195 | 196 | def get_attachments_for_run(self, run_id: int, limit: int = 250, offset: int = 0) -> list[dict]: 197 | """ 198 | Returns a list of attachments for a test run. 199 | 200 | Requires TestRail 6.3 or later. 201 | 202 | :param run_id: 203 | The ID of the test run to retrieve attachments from 204 | :param limit: 205 | The number of attachments the response should return 206 | (The response size is 250 by default) (requires TestRail 6.7 or later) 207 | :param offset: 208 | Where to start counting the attachments from (the offset) 209 | (requires TestRail 6.7 or later) 210 | :return: response 211 | """ 212 | return self.s.get( 213 | endpoint=f"get_attachments_for_run/{run_id}", 214 | params={"limit": limit, "offset": offset}, 215 | ) 216 | 217 | def get_attachments_for_test(self, test_id: int, **kwargs) -> list[dict]: 218 | """ 219 | Returns a list of attachments for test results. 220 | 221 | Requires TestRail 5.7 or later. 222 | 223 | :param test_id: 224 | The ID of the test 225 | :param kwargs: 226 | :key limit/offset: int 227 | The number of attachments the response should return 228 | :return: response 229 | """ 230 | return self.s.get(endpoint=f"get_attachments_for_test/{test_id}", params=kwargs) 231 | 232 | def get_attachment(self, attachment_id: int, path: Union[str, Path]) -> Path: 233 | """ 234 | Returns the requested attachment identified by attachment_id. 235 | 236 | Requires TestRail 5.7 or later. 237 | 238 | :param attachment_id: 239 | The ID of the test to retrieve attachments from 240 | :param path: Path 241 | :return: Path 242 | """ 243 | return self._session.get_attachment(METHODS.GET, f"get_attachment/{attachment_id}", path) 244 | 245 | def delete_attachment(self, attachment_id: int) -> None: 246 | """ 247 | Deletes the specified attachment identified by attachment_id. 248 | 249 | Requires TestRail 5.7 or later. 250 | 251 | :param attachment_id: 252 | The ID of the attachment to to delete 253 | :return: None 254 | """ 255 | return self.s.post(endpoint=f"delete_attachment/{attachment_id}") 256 | 257 | def get_attachments_for_case_bulk(self, case_id: int) -> list[dict]: 258 | """ 259 | Return the list of attachments from the case handling pagination. 260 | 261 | Requires TestRail 5.7 or later 262 | 263 | :param case_id: 264 | The ID of the test case to retrieve attachments from 265 | :return: List of attachments 266 | :returns: list[dict] 267 | """ 268 | return _bulk_api_method(self.get_attachments_for_case, "attachments", case_id) 269 | 270 | def get_attachments_for_plan_bulk(self, plan_id: int) -> list[dict]: 271 | """ 272 | Return the list of attachments from the plan handling pagination. 273 | 274 | Requires TestRail 6.3 or later 275 | 276 | :param plan_id: 277 | The ID of the test plan to retrieve attachments from 278 | :return: List of attachments 279 | :returns: list[dict] 280 | """ 281 | return _bulk_api_method(self.get_attachments_for_plan, "attachments", plan_id) 282 | 283 | def get_attachments_for_run_bulk(self, run_id: int) -> list[dict]: 284 | """ 285 | Return the list of attachments from the run handling pagination. 286 | 287 | Requires TestRail 6.3 or later 288 | 289 | :param run_id: 290 | The ID of the test run to retrieve attachments from 291 | :return: List of attachments 292 | :returns: list[dict] 293 | """ 294 | return _bulk_api_method(self.get_attachments_for_run, "attachments", run_id) 295 | 296 | def get_attachments_for_plan_entry_bulk(self, plan_id: int, entry_id: int) -> list[dict]: 297 | """ 298 | Returns the list of attachments for the plan entry handling pagination. 299 | 300 | Requires TestRail 6.3 or later 301 | 302 | :param plan_id: 303 | The ID of the test plan containing the entry 304 | :param entry_id: 305 | The ID of the test plan entry to retrieve attachments from 306 | :return: List of attachments 307 | :returns: list[dict] 308 | """ 309 | return _bulk_api_method(self.get_attachments_for_plan_entry, "attachments", plan_id, entry_id) 310 | 311 | def get_attachments_for_test_bulk(self, test_id: int) -> list[dict]: 312 | """ 313 | Return the list of attachments from the test handling pagination. 314 | 315 | Requires TestRail 6.3 or later 316 | 317 | :param test_id: 318 | The ID of the test to retrieve attachments from 319 | :return: List of attachments 320 | :returns: list[dict] 321 | """ 322 | return _bulk_api_method(self.get_attachments_for_test, "attachments", test_id) 323 | 324 | 325 | class Cases(_MetaCategory): 326 | """https://www.gurock.com/testrail/docs/api/reference/cases.""" 327 | 328 | def get_case(self, case_id: int) -> dict: 329 | """ 330 | Returns an existing test case. 331 | 332 | :param case_id: 333 | The ID of the test case 334 | :return: response 335 | """ 336 | return self.s.get(endpoint=f"get_case/{case_id}") 337 | 338 | def get_cases(self, project_id: int, **kwargs) -> dict: 339 | """ 340 | Returns a list of test cases for a project or specific test suite (if the project has multiple suites enabled). 341 | 342 | :param project_id: 343 | The ID of the project 344 | :param kwargs: 345 | :key suite_id: int 346 | The ID of the test suite (optional if the project is operating in 347 | single suite mode) 348 | :key created_after: int/datetime 349 | Only return test cases created after this date (as UNIX timestamp). 350 | :key created_before: int/datetime 351 | Only return test cases created before this date (as UNIX timestamp). 352 | :key created_by: list[int] or comma-separated string 353 | A comma-separated list of creators (user IDs) to filter by. 354 | :key filter: str 355 | Only return cases with matching filter string in the case title 356 | :key limit: int 357 | The number of test cases the response should return 358 | (The response size is 250 by default) (requires TestRail 6.7 or later) 359 | :key milestone_id: list[int] or comma-separated string 360 | A comma-separated list of milestone IDs to filter by (not available 361 | if the milestone field is disabled for the project). 362 | :key offset: int 363 | Where to start counting the tests cases from (the offset) 364 | (requires TestRail 6.7 or later) 365 | :key priority_id: list[int] or comma-separated string 366 | A comma-separated list of priority IDs to filter by. 367 | :key refs: str 368 | A single Reference ID (e.g. TR-1, 4291, etc.) 369 | (requires TestRail 6.5.2 or later) 370 | :key section_id: int 371 | The ID of a test case section 372 | :key template_id: list[int] or comma-separated string 373 | A comma-separated list of template IDs to filter by 374 | (requires TestRail 5.2 or later) 375 | :key type_id: list[int] or comma-separated string 376 | A comma-separated list of case type IDs to filter by. 377 | :key updated_after: int/datetime 378 | Only return test cases updated after this date (as UNIX timestamp). 379 | :key updated_before: int/datetime 380 | Only return test cases updated before this date (as UNIX timestamp). 381 | :key updated_by: list[int] or comma-separated string 382 | A comma-separated list of user IDs who updated test cases to filter by. 383 | :return: response 384 | """ 385 | return self.s.get(endpoint=f"get_cases/{project_id}", params=kwargs) 386 | 387 | def get_history_for_case(self, case_id: int, limit: int = 250, offset: int = 0) -> list[dict]: 388 | """ 389 | Returns the edit history for a test case_id. 390 | 391 | Requires TestRail 6.5.4 or later. 392 | 393 | :param case_id int 394 | The ID of the test case 395 | :param limit int 396 | The number of test cases the response should return 397 | (The response size is 250 by default) (requires TestRail 6.7 or later) 398 | :param offset int 399 | Where to start counting the tests cases from (the offset) 400 | (requires TestRail 6.7 or later) 401 | """ 402 | return self.s.get( 403 | endpoint=f"get_history_for_case/{case_id}", 404 | params={"limit": limit, "offset": offset}, 405 | ) 406 | 407 | def add_case(self, section_id: int, title: str, **kwargs) -> dict: 408 | """ 409 | Creates a new test case. 410 | 411 | :param section_id: 412 | The ID of the section the test case should be added to 413 | :param title: 414 | The title of the test case (required) 415 | :param kwargs: 416 | :key template_id: int 417 | The ID of the template (field layout) (requires TestRail 5.2 or later) 418 | :key type_id: int 419 | The ID of the case type 420 | :key priority_id: int 421 | The ID of the case priority 422 | :key estimate: str 423 | The estimate, e.g. "30s" or "1m 45s" 424 | :key milestone_id: int 425 | The ID of the milestone to link to the test case 426 | :key refs: str 427 | A comma-separated list of references/requirements 428 | 429 | Custom fields are supported as well and must be submitted with their 430 | system name, prefixed with 'custom_', e.g.: 431 | { 432 | .. 433 | "custom_preconds": "These are the preconditions for a test case" 434 | .. 435 | } 436 | The following custom field types are supported: 437 | Checkbox: bool 438 | True for checked and false otherwise 439 | Date: str 440 | A date in the same format as configured for TestRail and API user 441 | (e.g. "07/08/2013") 442 | Dropdown: int 443 | The ID of a dropdown value as configured in the field configuration 444 | Integer: int 445 | A valid integer 446 | Milestone: int 447 | The ID of a milestone for the custom field 448 | Multi-select: list 449 | An array of IDs as configured in the field configuration 450 | Steps: list 451 | An array of objects specifying the steps. Also see the example below. 452 | String: str 453 | A valid string with a maximum length of 250 characters 454 | Text: str 455 | A string without a maximum length 456 | URL: str 457 | A string with matches the syntax of a URL 458 | User: int 459 | The ID of a user for the custom field 460 | 461 | :return: response 462 | """ 463 | return self.s.post(endpoint=f"add_case/{section_id}", json=dict(title=title, **kwargs)) 464 | 465 | def update_case(self, case_id: int, **kwargs) -> dict: 466 | """ 467 | Updates an existing test case. 468 | 469 | (partial updates are supported, i.e. you can submit and update specific fields only). 470 | 471 | :param case_id: T 472 | he ID of the test case 473 | :param kwargs: 474 | :key title: str 475 | The title of the test case 476 | :key section_id: int 477 | The ID of the section (requires TestRail 6.5.2 or later) 478 | :key template_id: int 479 | The ID of the template (requires TestRail 5.2 or later) 480 | :key type_id: int 481 | The ID of the case type 482 | :key priority_id: int 483 | The ID of the case priority 484 | :key estimate: str 485 | The estimate, e.g. "30s" or "1m 45s" 486 | :key milestone_id: int 487 | The ID of the milestone to link to the test case 488 | :key refs: str 489 | A comma-separated list of references/requirements 490 | :return: response 491 | """ 492 | return self.s.post(endpoint=f"update_case/{case_id}", json=kwargs) 493 | 494 | def update_cases(self, case_ids: list[int], suite_id: int, **kwargs) -> dict: 495 | """ 496 | Updates multiple test cases with the same values, such as setting a set of test cases to High priority. 497 | 498 | This does not support updating multiple test cases with different values per test case. 499 | 500 | Note: The online documentation is wrong. The suite_id is required in 501 | single suite mode as well. 502 | 503 | :param suite_id: 504 | The ID of the suite 505 | :param case_ids: list[int] 506 | The IDs of the test cases to update with the kwargs 507 | :param kwargs: 508 | :key title: str 509 | The title of the test case 510 | :key section_id: int 511 | The ID of the section (requires TestRail 6.5.2 or later) 512 | :key template_id: int 513 | The ID of the template (requires TestRail 5.2 or later) 514 | :key type_id: int 515 | The ID of the case type 516 | :key priority_id: int 517 | The ID of the case priority 518 | :key estimate: str 519 | The estimate, e.g. "30s" or "1m 45s" 520 | :key milestone_id: int 521 | The ID of the milestone to link to the test case 522 | :key refs: str 523 | A comma-separated list of references/requirements 524 | """ 525 | kwargs.update({"case_ids": case_ids}) 526 | return self.s.post(endpoint=f"update_cases/{suite_id}", json=kwargs) 527 | 528 | def delete_case(self, case_id: int, soft: int = 0) -> Optional[dict]: 529 | """ 530 | Deletes an existing test case. 531 | 532 | :param case_id: 533 | The ID of the test case 534 | :param soft: 535 | If soft=1, this will return data on the number of affected tests. 536 | Including soft=1 will not actually delete the entity. 537 | Omitting the soft parameter, or submitting soft=0 will delete the test case. 538 | :return: response 539 | """ 540 | return self.s.post(endpoint=f"delete_case/{case_id}", params={"soft": soft}) 541 | 542 | def delete_cases( 543 | self, 544 | project_id: int, 545 | case_ids: list[int], 546 | suite_id: Optional[int] = None, 547 | soft: int = 0, 548 | ) -> None: 549 | """ 550 | Deletes multiple test cases from a project or test suite. 551 | 552 | :param project_id: 553 | The ID of the project 554 | :param case_ids: 555 | The case ids to be deleted 556 | :param suite_id: 557 | The ID of the suite (Only required if project is in multi-suite mode) 558 | :param soft: 559 | Optional parameter 560 | If soft=1, this will return data on the number of affected tests. 561 | Including soft=1 will not actually delete the entity. 562 | Omitting the soft parameter, or submitting soft=0 will delete the test case. 563 | """ 564 | return self.s.post( 565 | endpoint=f"delete_cases/{suite_id}" if suite_id else "delete_cases", 566 | params={"soft": soft, "project_id": project_id}, 567 | json={"case_ids": case_ids}, 568 | ) 569 | 570 | def copy_cases_to_section(self, section_id: int, case_ids: list[int]) -> dict: 571 | """ 572 | Copies the list of cases to another suite/section. 573 | 574 | :param section_id: int 575 | The ID of the section the test case should be copied to 576 | :param case_ids: 577 | List of case IDs. 578 | """ 579 | return self.s.post( 580 | endpoint=f"copy_cases_to_section/{section_id}", 581 | json={"case_ids": ",".join(map(str, case_ids))}, 582 | ) 583 | 584 | def move_cases_to_section(self, section_id: int, suite_id: int, case_ids: list[str]) -> dict: 585 | """ 586 | Moves cases to another suite or section. 587 | 588 | :param section_id: int 589 | The ID of the section the cases will be moved to. 590 | :param suite_id: int 591 | The ID of the suite for the section the cases will be moved to. 592 | :param case_ids: 593 | List of case IDs. 594 | """ 595 | return self.s.post( 596 | endpoint=f"move_cases_to_section/{section_id}", 597 | json={ 598 | "case_ids": ",".join(map(str, case_ids)), 599 | "suite_id": suite_id, 600 | }, 601 | ) 602 | 603 | def get_cases_bulk(self, project_id: int, **kwargs) -> list[dict]: 604 | """ 605 | Return a list of test cases for a project or specific test suite with pagination. 606 | 607 | :param project_id: 608 | The ID of the project 609 | :param kwargs: 610 | :key suite_id: int 611 | The ID of the test suite (optional if the project is operating in 612 | single suite mode) 613 | :key created_after: int/datetime 614 | Only return test cases created after this date (as UNIX timestamp). 615 | :key created_before: int/datetime 616 | Only return test cases created before this date (as UNIX timestamp). 617 | :key created_by: list[int] or comma-separated string 618 | A comma-separated list of creators (user IDs) to filter by. 619 | :key filter: str 620 | Only return cases with matching filter string in the case title 621 | :key limit: int 622 | The number of test cases the response should return 623 | (The response size is 250 by default) (requires TestRail 6.7 or later) 624 | :key milestone_id: list[int] or comma-separated string 625 | A comma-separated list of milestone IDs to filter by (not available 626 | if the milestone field is disabled for the project). 627 | :key offset: int 628 | Where to start counting the tests cases from (the offset) 629 | (requires TestRail 6.7 or later) 630 | :key priority_id: list[int] or comma-separated string 631 | A comma-separated list of priority IDs to filter by. 632 | :key refs: str 633 | A single Reference ID (e.g. TR-1, 4291, etc.) 634 | (requires TestRail 6.5.2 or later) 635 | :key section_id: int 636 | The ID of a test case section 637 | :key template_id: list[int] or comma-separated string 638 | A comma-separated list of template IDs to filter by 639 | (requires TestRail 5.2 or later) 640 | :key type_id: list[int] or comma-separated string 641 | A comma-separated list of case type IDs to filter by. 642 | :key updated_after: int/datetime 643 | Only return test cases updated after this date (as UNIX timestamp). 644 | :key updated_before: int/datetime 645 | Only return test cases updated before this date (as UNIX timestamp). 646 | :key updated_by: list[int] or comma-separated string 647 | A comma-separated list of user IDs who updated test cases to filter by. 648 | :return: List of test cases 649 | :returns: list[dict] 650 | """ 651 | return _bulk_api_method(self.get_cases, "cases", project_id, **kwargs) 652 | 653 | 654 | class CaseFields(_MetaCategory): 655 | """https://www.gurock.com/testrail/docs/api/reference/case-fields.""" 656 | 657 | def get_case_fields(self) -> list[dict]: 658 | """ 659 | Returns a list of available test case custom fields. 660 | 661 | A custom field can have different configurations and options per project which 662 | is indicated by the configs field. To check if a custom field is applicable to 663 | a specific project (and to find out the field options for this project), 664 | the context of the field configuration must either be global (is_global) or 665 | include the ID of the project in project_ids. 666 | 667 | Also, the following list shows the available custom field types (type_id field): 668 | 1 - String 669 | 2 - Integer 670 | 3 - Text 671 | 4 - URL 672 | 5 - Checkbox 673 | 6 - Dropdown 674 | 7 - User 675 | 8 - Date 676 | 9 - Milestone 677 | 10 - Steps 678 | 12 - Multi-select 679 | :return: response 680 | """ 681 | return self.s.get(endpoint="get_case_fields") 682 | 683 | def add_case_field(self, type: str, name: str, label: str, configs: list[dict], **kwargs) -> dict: 684 | """ 685 | Creates a new test case custom field. 686 | 687 | :param type: str 688 | The type identifier for the new custom field (required). 689 | The following types are supported: 690 | String, Integer, Text, URL, Checkbox, Dropdown, User, Date, 691 | Milestone, Steps, Multiselect 692 | You can pass the number of the type as well as the word, e.g. "5", 693 | "string", "String", "Dropdown", "12". 694 | The numbers must be sent as a string e.g {type: "5"} not {type: 5}, 695 | otherwise you will get a 400 (Bad Request) response. 696 | :param name: str 697 | The name for new the custom field (required) 698 | :param label: str 699 | The label for the new custom field (required) 700 | :param configs: 701 | An object wrapped in an array with two default keys, 702 | 'context' and 'options' (required) 703 | :param kwargs: 704 | :key description: str 705 | The description for the new custom field 706 | :key include_all: bool 707 | Set flag to true if you want the new custom field included for 708 | all templates. Otherwise (false) specify the ID's of templates to be 709 | included as the next parameter (template_ids) 710 | :key template_ids: list 711 | ID's of templates new custom field will apply to if include_all is 712 | set to false 713 | :return: response 714 | """ 715 | return self.s.post( 716 | endpoint="add_case_field", 717 | json=dict(type=type, name=name, label=label, configs=configs, **kwargs), 718 | ) 719 | 720 | 721 | class CaseTypes(_MetaCategory): 722 | """https://www.gurock.com/testrail/docs/api/reference/case-types.""" 723 | 724 | def get_case_types(self) -> list[dict]: 725 | """ 726 | Returns a list of available case types. 727 | 728 | The response includes an array of test case types. 729 | Each case type has a unique ID and a name. 730 | The is_default field is true for the default case type and false otherwise. 731 | 732 | :return: response 733 | """ 734 | return self.s.get(endpoint="get_case_types") 735 | 736 | 737 | class Configurations(_MetaCategory): 738 | """https://www.gurock.com/testrail/docs/api/reference/configurations.""" 739 | 740 | def get_configs(self, project_id: int) -> list[dict]: 741 | """ 742 | Returns a list of available configurations, grouped by configuration groups. 743 | 744 | :param project_id: 745 | The ID of the project 746 | :return: response 747 | """ 748 | return self.s.get(endpoint=f"get_configs/{project_id}") 749 | 750 | def add_config_group(self, project_id: int, name: str) -> dict: 751 | """ 752 | Creates a new configuration group (requires TestRail 5.2 or later). 753 | 754 | :param project_id: 755 | The ID of the project the configuration group should be added to 756 | :param name: 757 | The name of the configuration group (required) 758 | :return: response 759 | """ 760 | return self.s.post(endpoint=f"add_config_group/{project_id}", json={"name": name}) 761 | 762 | def add_config(self, config_group_id: int, name: str) -> dict: 763 | """ 764 | Creates a new configuration (requires TestRail 5.2 or later). 765 | 766 | :param config_group_id: 767 | The ID of the configuration group the configuration should be added to 768 | :param name: 769 | The name of the configuration (required) 770 | :return: response 771 | """ 772 | return self.s.post(endpoint=f"add_config/{config_group_id}", json={"name": name}) 773 | 774 | def update_config_group(self, config_group_id: int, name: str) -> dict: 775 | """ 776 | Updates an existing configuration group (requires TestRail 5.2 or later). 777 | 778 | :param config_group_id: 779 | The ID of the configuration group 780 | :param name: 781 | The name of the configuration group 782 | :return: response 783 | """ 784 | return self.s.post( 785 | endpoint=f"update_config_group/{config_group_id}", 786 | json={"name": name}, 787 | ) 788 | 789 | def update_config(self, config_id: int, name: str) -> dict: 790 | """ 791 | Updates an existing configuration (requires TestRail 5.2 or later). 792 | 793 | :param config_id: 794 | The ID of the configuration 795 | :param name: 796 | The name of the configuration 797 | :return: response 798 | """ 799 | return self.s.post(endpoint=f"update_config/{config_id}", json={"name": name}) 800 | 801 | def delete_config_group(self, config_group_id: int) -> None: 802 | """ 803 | Deletes an existing configuration group and its configurations (requires TestRail 5.2 or later). 804 | 805 | :param config_group_id: 806 | The ID of the configuration group 807 | :return: response 808 | """ 809 | return self.s.post(endpoint=f"delete_config_group/{config_group_id}") 810 | 811 | def delete_config(self, config_id: int) -> None: 812 | """ 813 | Deletes an existing configuration (requires TestRail 5.2 or later). 814 | 815 | :param config_id: 816 | The ID of the configuration 817 | :return: response 818 | """ 819 | return self.s.post(endpoint=f"delete_config/{config_id}") 820 | 821 | 822 | class Milestones(_MetaCategory): 823 | """https://www.gurock.com/testrail/docs/api/reference/milestones.""" 824 | 825 | def get_milestone(self, milestone_id: int) -> dict: 826 | """ 827 | Returns an existing milestone. 828 | 829 | :param milestone_id: 830 | The ID of the milestone 831 | :return: response 832 | """ 833 | return self.s.get(endpoint=f"get_milestone/{milestone_id}") 834 | 835 | def get_milestones(self, project_id: int, limit: int = 250, offset: int = 0, **kwargs) -> dict: 836 | """ 837 | Returns the list of milestones for a project. 838 | 839 | :param project_id: 840 | The ID of the project 841 | :param limit: 842 | The number of milestones the response should return 843 | (The response size is 250 by default) (requires TestRail 6.7 or later) 844 | :param offset: 845 | Where to start counting the milestones from (the offset) 846 | (requires TestRail 6.7 or later) 847 | :param kwargs: 848 | :key is_completed: int/bool 849 | 1/True to return completed milestones only. 850 | 0/False to return open (active/upcoming) milestones only 851 | (available since TestRail 4.0). 852 | :key is_started: int/bool 853 | 1/True to return started milestones only. 854 | 0/False to return upcoming milestones only 855 | (available since TestRail 5.3). 856 | :return: response 857 | """ 858 | return self.s.get( 859 | endpoint=f"get_milestones/{project_id}", 860 | params=dict(limit=limit, offset=offset, **kwargs), 861 | ) 862 | 863 | def add_milestone(self, project_id: int, name: str, **kwargs) -> dict: 864 | """ 865 | Creates a new milestone. 866 | 867 | :param project_id: 868 | The ID of the project the milestone should be added to 869 | :param name: str 870 | The name of the milestone (required) 871 | :param kwargs: 872 | :key description: str 873 | The description of the milestone 874 | :key due_on: int/datetime 875 | The due date of the milestone (as UNIX timestamp) 876 | :key parent_id: int 877 | The ID of the parent milestone, if any (for sub-milestones) 878 | (available since TestRail 5.3) 879 | :key refs: str 880 | A comma-separated list of references/requirements 881 | (available since TestRail 6.4) 882 | :key start_on: int/datetime 883 | The scheduled start date of the milestone (as UNIX timestamp) 884 | (available since TestRail 5.3) 885 | :return: response 886 | """ 887 | return self.s.post(endpoint=f"add_milestone/{project_id}", json=dict(name=name, **kwargs)) 888 | 889 | def update_milestone(self, milestone_id: int, **kwargs) -> dict: 890 | """ 891 | Updates an existing milestone. 892 | 893 | (partial updates are supported, i.e. you can submit and update specific fields only). 894 | 895 | :param milestone_id: 896 | The ID of the milestone 897 | :param kwargs: 898 | :key is_completed: bool 899 | True if a milestone is considered completed and false otherwise 900 | :key is_started: bool 901 | True if a milestone is considered started and false otherwise 902 | :key parent_id: int 903 | The ID of the parent milestone, if any (for sub-milestones) 904 | (available since TestRail 5.3) 905 | :key start_on: int/datetime 906 | The scheduled start date of the milestone (as UNIX timestamp) 907 | (available since TestRail 5.3) 908 | :return: response 909 | """ 910 | return self.s.post(endpoint=f"update_milestone/{milestone_id}", json=kwargs) 911 | 912 | def delete_milestone(self, milestone_id: int) -> None: 913 | """ 914 | Deletes an existing milestone. 915 | 916 | :param milestone_id: 917 | The ID of the milestone 918 | :return: response 919 | """ 920 | return self.s.post(endpoint=f"delete_milestone/{milestone_id}") 921 | 922 | def get_milestones_bulk(self, project_id: int, **kwargs) -> list[dict]: 923 | """ 924 | Return a list of milestones for a project handling pagination. 925 | 926 | :param project_id: 927 | The ID of the project 928 | :param kwargs: 929 | :key is_completed: int/bool 930 | 1/True to return completed milestones only. 931 | 0/False to return open (active/upcoming) milestones only 932 | (available since TestRail 4.0). 933 | :key is_started: int/bool 934 | 1/True to return started milestones only. 935 | 0/False to return upcoming milestones only 936 | (available since TestRail 5.3). 937 | :return: response 938 | """ 939 | return _bulk_api_method(self.get_milestones, "milestones", project_id, **kwargs) 940 | 941 | 942 | class Plans(_MetaCategory): 943 | """https://www.gurock.com/testrail/docs/api/reference/plans.""" 944 | 945 | def get_plan(self, plan_id: int) -> dict: 946 | """ 947 | Returns an existing test plan. 948 | 949 | :param plan_id: 950 | The ID of the test plan 951 | :return: response 952 | """ 953 | return self.s.get(endpoint=f"get_plan/{plan_id}") 954 | 955 | def get_plans(self, project_id: int, **kwargs) -> dict: 956 | """ 957 | Returns a list of test plans for a project. 958 | 959 | This method will return up to 250 entries in the response array. 960 | To retrieve additional entries, you can make additional requests 961 | using the offset filter described in the Request filters section below. 962 | 963 | :param project_id: 964 | The ID of the project 965 | :param kwargs: filters 966 | :key created_after: int/datetime 967 | Only return test plans created after this date (as UNIX timestamp). 968 | :key created_before: int/datetime 969 | Only return test plans created before this date (as UNIX timestamp). 970 | :key created_by: list[int] or comma-separated string 971 | A comma-separated list of creators (user IDs) to filter by. 972 | :key is_completed: int/bool 973 | 1/True to return completed test plans only. 974 | 0/False to return active test plans only. 975 | :key limit/offset: int 976 | Limit the result to :limit test plans. Use :offset to skip records. 977 | :key milestone_id: list[int] or comma-separated string 978 | A comma-separated list of milestone IDs to filter by. 979 | :return: response 980 | """ 981 | return self.s.get(endpoint=f"get_plans/{project_id}", params=kwargs) 982 | 983 | def add_plan(self, project_id: int, name: str, **kwargs) -> dict: 984 | """ 985 | Creates a new test plan. 986 | 987 | :param project_id: 988 | The ID of the project the test plan should be added to 989 | :param name: 990 | The name of the test plan (required) 991 | :param kwargs: 992 | :key description: str 993 | The description of the test plan 994 | :key milestone_id: int 995 | The ID of the milestone to link to the test plan 996 | :key entries: list 997 | An array of objects describing the test runs of the plan, 998 | see the example below and add_plan_entry 999 | :return: response 1000 | """ 1001 | return self.s.post(endpoint=f"add_plan/{project_id}", json=dict(name=name, **kwargs)) 1002 | 1003 | def add_plan_entry(self, plan_id: int, suite_id: int, **kwargs) -> dict: 1004 | """ 1005 | Adds one or more new test runs to a test plan. 1006 | 1007 | :param plan_id: 1008 | The ID of the plan the test runs should be added to 1009 | :param suite_id: 1010 | The ID of the test suite for the test run(s) (required) 1011 | :param kwargs: 1012 | :key name: str 1013 | The name of the test run(s) 1014 | :key description: str 1015 | The description of the test run(s) (requires TestRail 5.2 or later) 1016 | :key assignedto_id: int 1017 | The ID of the user the test run(s) should be assigned to 1018 | :key include_all: bool 1019 | True for including all test cases of the test suite and false for a 1020 | custom case selection (default: true) 1021 | :key case_ids: list 1022 | An array of case IDs for the custom case selection 1023 | :key config_ids: list 1024 | An array of configuration IDs used for the test runs of the test 1025 | plan entry 1026 | :key refs: str 1027 | A string of external requirement IDs, separated by commas. 1028 | (requires TestRail 6.3 or later) 1029 | :key runs: list 1030 | An array of test runs with configurations, 1031 | please see the example below for details 1032 | :return: response 1033 | """ 1034 | return self.s.post(endpoint=f"add_plan_entry/{plan_id}", json=dict(suite_id=suite_id, **kwargs)) 1035 | 1036 | def add_run_to_plan_entry(self, plan_id: int, entry_id: int, config_ids: list[int], **kwargs) -> dict: 1037 | """ 1038 | Adds a new test run to a test plan entry (using configurations). 1039 | 1040 | Requires TestRail 6.4 or later. 1041 | 1042 | :param plan_id: 1043 | The ID of the plan the test runs should be added to 1044 | :param entry_id: 1045 | The ID of the test plan entry 1046 | :param config_ids: 1047 | An array of configuration IDs used for the test run of the 1048 | test plan entry (Required) 1049 | :param kwargs: 1050 | :key description: str 1051 | The description of the test run 1052 | :key assignedto_id: int 1053 | The ID of the user the test run should be assigned to 1054 | :key include_all: bool 1055 | True for including all test cases of the test suite and false for 1056 | a custom case selection 1057 | :key case_ids: list[int] 1058 | An array of case IDs for the custom case selection 1059 | (Required if include_all is false) 1060 | :key refs: str 1061 | A comma-separated list of references/requirements 1062 | :return: response 1063 | """ 1064 | return self.s.post( 1065 | endpoint=f"add_run_to_plan_entry/{plan_id}/{entry_id}", 1066 | json=dict(config_ids=config_ids, **kwargs), 1067 | ) 1068 | 1069 | def update_plan(self, plan_id: int, **kwargs) -> dict: 1070 | """ 1071 | Updates an existing test plan. 1072 | 1073 | (partial updates are supported, i.e. you can submit and update specific fields only). 1074 | 1075 | :param plan_id: 1076 | The ID of the test plan 1077 | :param kwargs: 1078 | :key name: str 1079 | The name of the test plan 1080 | :key description: str 1081 | The description of the test plan 1082 | :key milestone_id: int 1083 | The ID of the milestone to link to the test plan 1084 | :key entries: list 1085 | An array of objects describing the test runs of the plan, see the 1086 | example below and add_plan_entry 1087 | :return: response 1088 | """ 1089 | return self.s.post(endpoint=f"update_plan/{plan_id}", json=kwargs) 1090 | 1091 | def update_plan_entry(self, plan_id: int, entry_id: int, **kwargs) -> dict: 1092 | """ 1093 | Updates one or more existing test runs in a plan. 1094 | 1095 | (partial updates are supported, i.e. you can submit and update specific fields only). 1096 | 1097 | :param plan_id: 1098 | The ID of the test plan 1099 | :param entry_id: 1100 | The ID of the test plan entry (note: not the test run ID) 1101 | :param kwargs: 1102 | :key name: str 1103 | The name of the test run(s) 1104 | :key description: str 1105 | The description of the test run(s) (requires TestRail 5.2 or later) 1106 | :key assignedto_id: int 1107 | The ID of the user the test run(s) should be assigned to 1108 | :key include_all: bool 1109 | True for including all test cases of the test suite and false for a 1110 | custom case selection (default: true) 1111 | :key case_ids: list 1112 | An array of case IDs for the custom case selection 1113 | :key refs: str 1114 | A string of external requirement IDs, separated by commas. 1115 | (requires TestRail 6.3 or later) 1116 | :return: response 1117 | """ 1118 | return self.s.post(endpoint=f"update_plan_entry/{plan_id}/{entry_id}", json=kwargs) 1119 | 1120 | def update_run_in_plan_entry(self, run_id: int, **kwargs) -> dict: 1121 | """ 1122 | Updates a run inside a plan entry which uses configurations requires TestRail 6.4 or later. 1123 | 1124 | :param run_id: 1125 | The ID of the test run 1126 | :param kwargs: 1127 | :key description: str 1128 | The description of the test run 1129 | :key assignedto_id: int 1130 | The ID of the user the test run should be assigned to 1131 | :key include_all: bool 1132 | True for including all test cases of the test suite and false for 1133 | a custom case selection 1134 | :key case_ids: list[int] 1135 | An array of case IDs for the custom case selection. 1136 | (Required if include_all is false) 1137 | :key refs: str 1138 | A comma-separated list of references/requirements 1139 | :return: response 1140 | """ 1141 | return self.s.post(endpoint=f"update_run_in_plan_entry/{run_id}", json=kwargs) 1142 | 1143 | def close_plan(self, plan_id: int) -> dict: 1144 | """ 1145 | Closes an existing test plan and archives its test runs & results. 1146 | 1147 | :param plan_id: 1148 | The ID of the test plan 1149 | :return: response 1150 | """ 1151 | return self.s.post(endpoint=f"close_plan/{plan_id}") 1152 | 1153 | def delete_plan(self, plan_id: int) -> None: 1154 | """ 1155 | Deletes an existing test plan. 1156 | 1157 | :param plan_id: 1158 | The ID of the test plan 1159 | :return: response 1160 | """ 1161 | return self.s.post(endpoint=f"delete_plan/{plan_id}") 1162 | 1163 | def delete_plan_entry(self, plan_id: int, entry_id: int) -> None: 1164 | """ 1165 | Deletes one or more existing test runs from a plan. 1166 | 1167 | :param plan_id: 1168 | The ID of the test plan 1169 | :param entry_id: 1170 | The ID of the test plan entry (note: not the test run ID) 1171 | :return: response 1172 | """ 1173 | return self.s.post(endpoint=f"delete_plan_entry/{plan_id}/{entry_id}") 1174 | 1175 | def delete_run_from_plan_entry(self, run_id: int) -> dict: 1176 | """ 1177 | Deletes a test run from a test plan entry. 1178 | 1179 | :param run_id: 1180 | The ID of the test run 1181 | :return: response 1182 | """ 1183 | return self.s.post(endpoint=f"delete_run_from_plan_entry/{run_id}") 1184 | 1185 | def get_plans_bulk(self, project_id: int, **kwargs) -> list[dict]: 1186 | """ 1187 | Return a list of test plans for a project handling pagination. 1188 | 1189 | :param project_id: 1190 | The ID of the project 1191 | :param kwargs: 1192 | :key is_completed: int 1193 | True for returning completed test plans and false for uncompleted 1194 | test plans 1195 | :return: response 1196 | """ 1197 | return _bulk_api_method(self.get_plans, "plans", project_id, **kwargs) 1198 | 1199 | 1200 | class Priorities(_MetaCategory): 1201 | """https://www.gurock.com/testrail/docs/api/reference/priorities.""" 1202 | 1203 | def get_priorities(self) -> list[dict]: 1204 | """ 1205 | Returns a list of available priorities. 1206 | 1207 | :return: response 1208 | """ 1209 | return self.s.get(endpoint="get_priorities") 1210 | 1211 | 1212 | class Projects(_MetaCategory): 1213 | """https://www.gurock.com/testrail/docs/api/reference/projects.""" 1214 | 1215 | def get_project(self, project_id: int) -> dict: 1216 | """ 1217 | Returns an existing project. 1218 | 1219 | :param project_id: 1220 | The ID of the project 1221 | :return: response 1222 | """ 1223 | return self.s.get(endpoint=f"get_project/{project_id}") 1224 | 1225 | def get_projects(self, limit: int = 250, offset: int = 0, **kwargs) -> dict: 1226 | """ 1227 | Returns the list of available projects. 1228 | 1229 | :param limit: 1230 | The number of projects the response should return 1231 | (The response size is 250 by default) (requires TestRail 6.7 or later) 1232 | :param offset: 1233 | Where to start counting the projects from (the offset) 1234 | (requires TestRail 6.7 or later) 1235 | :param kwargs: filter 1236 | :key is_completed: int/bool 1237 | 1/True to return completed projects only. 1238 | 0/False to return active projects only. 1239 | :return: response 1240 | """ 1241 | return self.s.get(endpoint="get_projects", params=dict(limit=limit, offset=offset, **kwargs)) 1242 | 1243 | def add_project(self, name: str, **kwargs) -> dict: 1244 | """ 1245 | Creates a new project (admin status required). 1246 | 1247 | :param name: 1248 | The name of the project (required) 1249 | :param kwargs: 1250 | :key announcement: str 1251 | The description of the project 1252 | :key show_announcement: bool 1253 | True if the announcement should be displayed on the project's overview 1254 | page and false otherwise 1255 | :key suite_mode: int 1256 | The suite mode of the project 1257 | 1 for single suite mode, 1258 | 2 for single suite + baselines, 1259 | 3 for multiple suite 1260 | :return: response 1261 | """ 1262 | return self.s.post(endpoint="add_project", json=dict(name=name, **kwargs)) 1263 | 1264 | def update_project(self, project_id: int, **kwargs) -> dict: 1265 | """ 1266 | Updates an existing project. 1267 | 1268 | (admin status required; partial updates are supported, i.e. you can submit and update specific fields only). 1269 | 1270 | :param project_id: 1271 | The ID of the project 1272 | :param kwargs: 1273 | :key name: str 1274 | The name of the project 1275 | :key announcement: str 1276 | The description of the project 1277 | :key show_annoucement: bool 1278 | True if the annoucnement should be displayed on the project's 1279 | overview page and false otherwise 1280 | :key is_completed: bool 1281 | Specifies whether a project is considered completed or not 1282 | :return: response 1283 | """ 1284 | return self.s.post(endpoint=f"update_project/{project_id}", json=kwargs) 1285 | 1286 | def delete_project(self, project_id: int) -> None: 1287 | """ 1288 | Deletes an existing project (admin status required). 1289 | 1290 | :param project_id: 1291 | The ID of the project 1292 | :return: response 1293 | """ 1294 | return self.s.post(endpoint=f"delete_project/{project_id}") 1295 | 1296 | 1297 | class Reports(_MetaCategory): 1298 | """https://www.gurock.com/testrail/docs/api/reference/reports.""" 1299 | 1300 | def get_reports(self, project_id: int) -> list[dict]: 1301 | """ 1302 | Returns a list of API available reports by project. 1303 | 1304 | Requires TestRail 5.7 or later. 1305 | 1306 | :param project_id: 1307 | The ID of the project for which you want a list of API accessible reports 1308 | :return: response 1309 | """ 1310 | return self.s.get(endpoint=f"get_reports/{project_id}") 1311 | 1312 | def run_report(self, report_template_id: int) -> dict: 1313 | """ 1314 | Executes the report identified using the report_id parameter and returns URL's for accessing the report. 1315 | 1316 | Requires TestRail 5.7 or later. 1317 | 1318 | :param report_template_id: 1319 | :return: response 1320 | """ 1321 | return self.s.get(endpoint=f"run_report/{report_template_id}") 1322 | 1323 | 1324 | class Results(_MetaCategory): 1325 | """https://www.gurock.com/testrail/docs/api/reference/results.""" 1326 | 1327 | def get_results(self, test_id: int, limit: int = 250, offset: int = 0, **kwargs) -> dict: 1328 | """ 1329 | Returns a list of test results for a test. 1330 | 1331 | :param test_id: 1332 | The ID of the test 1333 | :param limit: 1334 | Number that sets the limit of test results to be shown on the response 1335 | (Optional parameter. The response size limit is 250 by default) 1336 | (requires TestRail 6.7 or later) 1337 | :param offset: 1338 | Number that sets the position where the response should start from 1339 | (Optional parameter) (requires TestRail 6.7 or later) 1340 | :param kwargs: filters 1341 | :key defects_filter: str 1342 | A single Defect ID (e.g. TR-1, 4291, etc.) 1343 | :key status_id: list[int] or comma-separated string 1344 | A comma-separated list of status IDs to filter by. 1345 | :return: response 1346 | """ 1347 | return self.s.get( 1348 | endpoint=f"get_results/{test_id}", 1349 | params=dict(limit=limit, offset=offset, **kwargs), 1350 | ) 1351 | 1352 | def get_results_for_case(self, run_id: int, case_id: int, limit: int = 250, offset: int = 0, **kwargs) -> dict: 1353 | """ 1354 | Returns a list of test results for a test run and case combination. 1355 | 1356 | The difference to get_results is that this method expects a test run + 1357 | test case instead of a test. In TestRail, tests are part of a test run and 1358 | the test cases are part of the related test suite. So, when you create a new 1359 | test run, TestRail creates a test for each test case found in the test suite 1360 | of the run. You can therefore think of a test as an “instance” of a test case 1361 | which can have test results, comments and a test status. 1362 | Please also see TestRail's getting started guide for more details about the 1363 | differences between test cases and tests. 1364 | 1365 | :param run_id: 1366 | The ID of the test run 1367 | :param case_id: 1368 | The ID of the test case 1369 | :param limit: 1370 | The number of test results the response should return 1371 | (The response size is 250 by default) (requires TestRail 6.7 or later) 1372 | :param offset: 1373 | Where to start counting the tests results from (the offset) 1374 | (requires TestRail 6.7 or later) 1375 | :param kwargs: filters 1376 | :key defects_filter: str 1377 | A single Defect ID (e.g. TR-1, 4291, etc.) 1378 | :key status_id: list[int] or comma-separated string 1379 | A comma-separated list of status IDs to filter by. 1380 | :return: response 1381 | """ 1382 | return self.s.get( 1383 | endpoint=f"get_results_for_case/{run_id}/{case_id}", 1384 | params=dict(limit=limit, offset=offset, **kwargs), 1385 | ) 1386 | 1387 | def get_results_for_run(self, run_id: int, limit: int = 250, offset: int = 0, **kwargs) -> dict: 1388 | """ 1389 | Returns a list of test results for a test run. 1390 | 1391 | This method will return up to 250 entries in the response array. 1392 | To retrieve additional entries, you can make additional requests using 1393 | the offset filter described in the Request filters section below. 1394 | 1395 | :param run_id: 1396 | The ID of the test run 1397 | :param limit: 1398 | Number that sets the limit of results to be shown on the response 1399 | (Optional parameter. The response size limit is 250 by default) 1400 | (requires TestRail 6.7 or later) 1401 | :param offset: 1402 | Number that sets the position where the response should start from 1403 | (Optional parameter) (requires TestRail 6.7 or later) 1404 | :param kwargs: filters 1405 | :key created_after: int/datetime 1406 | Only return test results created after this date. 1407 | :key created_before: int/datetime 1408 | Only return test results created before this date. 1409 | :key created_by: list[int] or comma-separated string 1410 | A comma-separated list of creators (user IDs) to filter by. 1411 | :key defects_filter: str 1412 | A single Defect ID (e.g. TR-1, 4291, etc.) 1413 | :key status_id: list[int] or comma-separated string 1414 | A comma-separated list of status IDs to filter by. 1415 | :return: response 1416 | """ 1417 | return self.s.get( 1418 | endpoint=f"get_results_for_run/{run_id}", 1419 | params=dict(limit=limit, offset=offset, **kwargs), 1420 | ) 1421 | 1422 | def add_result(self, test_id: int, **kwargs) -> list[dict]: 1423 | """ 1424 | Adds a new test result, comment or assigns a test. 1425 | 1426 | It's recommended to use add_results instead if you plan to add results for multiple tests. 1427 | 1428 | :param test_id: 1429 | The ID of the test the result should be added to 1430 | :param kwargs: 1431 | :key status_id: int 1432 | The ID of the test status. The built-in system 1433 | statuses have the following IDs: 1434 | 1 - Passed 1435 | 2 - Blocked 1436 | 3 - Untested (not allowed when adding a result) 1437 | 4 - Retest 1438 | 5 - Failed 1439 | You can get a full list of system and custom statuses via get_statuses. 1440 | :key comment: str 1441 | The comment / description for the test result 1442 | :key version: str 1443 | The version or build you tested against 1444 | :key elapsed: str 1445 | The time it took to execute the test, e.g. "30s" or "1m 45s" 1446 | :key defects: str 1447 | A comma-separated list of defects to link to the test result 1448 | :key assignedto_id: int 1449 | The ID of a user the test should be assigned to 1450 | 1451 | Custom fields are supported as well and must be submitted with their 1452 | system name, prefixed with 'custom_', e.g.: 1453 | { 1454 | ... 1455 | "custom_comment": "This is a custom comment" 1456 | ... 1457 | } 1458 | :return: response 1459 | """ 1460 | return self.s.post(endpoint=f"add_result/{test_id}", json=kwargs) 1461 | 1462 | def add_result_for_case(self, run_id: int, case_id: int, **kwargs) -> dict: 1463 | """ 1464 | Adds a new test result, comment or assigns a test (for a test run and case combination). 1465 | 1466 | It's recommended to use add_results_for_cases instead if you 1467 | plan to add results for multiple test cases. 1468 | 1469 | The difference to add_result is that this method expects a test run + 1470 | test case instead of a test. In TestRail, tests are part of a test run and 1471 | the test cases are part of the related test suite. 1472 | So, when you create a new test run, TestRail creates a test for each test case 1473 | found in the test suite of the run. 1474 | You can therefore think of a test as an “instance” of a test case which can 1475 | have test results, comments and a test status. 1476 | Please also see TestRail's getting started guide for more details about the 1477 | differences between test cases and tests. 1478 | 1479 | :param run_id: 1480 | The ID of the test run 1481 | :param case_id: 1482 | The ID of the test case 1483 | :param kwargs: 1484 | :key status_id: int 1485 | The ID of the test status. The built-in system 1486 | statuses have the following IDs: 1487 | 1 - Passed 1488 | 2 - Blocked 1489 | 3 - Untested (not allowed when adding a result) 1490 | 4 - Retest 1491 | 5 - Failed 1492 | You can get a full list of system and custom statuses via get_statuses. 1493 | :key comment: str 1494 | The comment / description for the test result 1495 | :key version: str 1496 | The version or build you tested against 1497 | :key elapsed: str 1498 | The time it took to execute the test, e.g. "30s" or "1m 45s" 1499 | :key defects: str 1500 | A comma-separated list of defects to link to the test result 1501 | :key assignedto_id: int 1502 | The ID of a user the test should be assigned to 1503 | 1504 | Custom fields are supported as well and must be submitted with their 1505 | system name, prefixed with 'custom_', e.g.: 1506 | { 1507 | ... 1508 | "custom_comment": "This is a custom comment" 1509 | ... 1510 | } 1511 | :return: response 1512 | """ 1513 | return self.s.post( 1514 | endpoint=f"add_result_for_case/{run_id}/{case_id}", 1515 | json=kwargs, 1516 | ) 1517 | 1518 | def add_results(self, run_id: int, results: list[dict]) -> list[dict]: 1519 | """ 1520 | Method expects an array of test results (via the 'results' field, please see below). 1521 | 1522 | Each test result must specify the test ID and can pass in the same fields as add_result, 1523 | namely all test related system and custom fields. 1524 | 1525 | Please note that all referenced tests must belong to the same test run. 1526 | 1527 | :param run_id: 1528 | The ID of the test run the results should be added to 1529 | :param results: list[dict] 1530 | This method expects an array of test results (via the 'results' field, 1531 | please see below). 1532 | Each test result must specify the test ID and can pass in the same fields 1533 | as add_result, namely all test related system and custom fields. 1534 | 1535 | Please note that all referenced tests must belong to the same test run. 1536 | :return: response 1537 | """ 1538 | return self.s.post(endpoint=f"add_results/{run_id}", json={"results": results}) 1539 | 1540 | def add_results_for_cases(self, run_id: int, results: list[dict]) -> list[dict]: 1541 | """ 1542 | Adds one or more new test results, comments or assigns one or more tests (using the case IDs). 1543 | 1544 | Ideal for test automation to bulk-add multiple test results in one step. 1545 | Requires TestRail 3.1 or later. 1546 | 1547 | :param run_id: 1548 | The ID of the test run the results should be added to 1549 | :param results: list[dict] 1550 | This method expects an array of test results (via the 'results' field, 1551 | please see below). Each test result must specify the test case ID and 1552 | can pass in the same fields as add_result, namely all test related 1553 | system and custom fields. 1554 | 1555 | The difference to add_results is that this method expects test case IDs 1556 | instead of test IDs. Please see add_result_for_case for details. 1557 | 1558 | Please note that all referenced tests must belong to the same test run. 1559 | :return: response 1560 | """ 1561 | return self.s.post( 1562 | endpoint=f"add_results_for_cases/{run_id}", 1563 | json={"results": results}, 1564 | ) 1565 | 1566 | def get_results_bulk(self, test_id: int, **kwargs) -> list[dict]: 1567 | """ 1568 | Return a list of test results for a test run handling pagination. 1569 | 1570 | :param test_id: 1571 | The ID of the test run 1572 | :param kwargs: filters 1573 | :key defects_filter: str 1574 | A single Defect ID (e.g. TR-1, 4291, etc.) 1575 | :key status_id: list[int] or comma-separated string 1576 | A comma-separated list of status IDs to filter by. 1577 | :return: List of results 1578 | :returns: list[dict] 1579 | """ 1580 | return _bulk_api_method(self.get_results, "results", test_id, **kwargs) 1581 | 1582 | def get_results_for_case_bulk(self, run_id: int, case_id: int, **kwargs) -> list[dict]: 1583 | """ 1584 | Return a list of test results for a case in a test run handling pagination. 1585 | 1586 | For the difference between get_results vs get_results_for_case, please see 1587 | the documentation for get_results_for_case. 1588 | 1589 | :param run_id: 1590 | The ID of the test run 1591 | :param case_id: 1592 | The ID of the test case 1593 | :param kwargs: filters 1594 | :key defects_filter: str 1595 | A single Defect ID (e.g. TR-1, 4291, etc.) 1596 | :key status_id: list[int] or comma-separated string 1597 | A comma-separated list of status IDs to filter by. 1598 | :return: response 1599 | :returns: list[dict] 1600 | """ 1601 | return _bulk_api_method(self.get_results_for_case, "results", run_id, case_id, **kwargs) 1602 | 1603 | def get_results_for_run_bulk(self, run_id: int, **kwargs) -> list[dict]: 1604 | """ 1605 | Returns a list of test results for a test run handling pagination. 1606 | 1607 | :param run_id: 1608 | The ID of the test run 1609 | :param kwargs: filters 1610 | :key created_after: int/datetime 1611 | Only return test results created after this date. 1612 | :key created_before: int/datetime 1613 | Only return test results created before this date. 1614 | :key created_by: list[int] or comma-separated string 1615 | A comma-separated list of creators (user IDs) to filter by. 1616 | :key defects_filter: str 1617 | A single Defect ID (e.g. TR-1, 4291, etc.) 1618 | :key status_id: list[int] or comma-separated string 1619 | A comma-separated list of status IDs to filter by. 1620 | :return: response 1621 | :returns: list[dict] 1622 | """ 1623 | return _bulk_api_method(self.get_results_for_run, "results", run_id, **kwargs) 1624 | 1625 | 1626 | class ResultFields(_MetaCategory): 1627 | """https://www.gurock.com/testrail/docs/api/reference/result-fields.""" 1628 | 1629 | def get_result_fields(self) -> list[dict]: 1630 | """ 1631 | Returns a list of available test result custom fields. 1632 | 1633 | :return: response 1634 | """ 1635 | return self.s.get(endpoint="get_result_fields") 1636 | 1637 | 1638 | class Runs(_MetaCategory): 1639 | """https://www.gurock.com/testrail/docs/api/reference/runs.""" 1640 | 1641 | def get_run(self, run_id: int) -> dict: 1642 | """ 1643 | Returns an existing test run. Please see get_tests for the list of included tests in this run. 1644 | 1645 | :param run_id: 1646 | The ID of the test run 1647 | :return: response 1648 | """ 1649 | return self.s.get(endpoint=f"get_run/{run_id}") 1650 | 1651 | def get_runs(self, project_id: int, **kwargs) -> dict: 1652 | """ 1653 | Returns a list of test runs for a project. 1654 | 1655 | Only returns those test runs that are not part of a test plan (please see get_plans/get_plan for this). 1656 | 1657 | :param project_id: int 1658 | The ID of the project 1659 | :param kwargs: filters 1660 | :key created_after: int/datetime 1661 | Only return test runs created after this date (as UNIX timestamp). 1662 | :key created_before: int/datetime 1663 | Only return test runs created before this date (as UNIX timestamp). 1664 | :key created_by: list[int] or comma-separated string 1665 | A comma-separated list of creators (user IDs) to filter by. 1666 | :key is_completed: int/bool 1667 | 1/True to return completed test runs only. 1668 | 0/False to return active test runs only. 1669 | :key limit/offset: int 1670 | Limit the result to :limit test runs. Use :offset to skip records. 1671 | :key milestone_id: list[int] or comma-separated string 1672 | A comma-separated list of milestone IDs to filter by. 1673 | :key refs_filter: str 1674 | A single Reference ID (e.g. TR-a, 4291, etc.) 1675 | :key suite_id: list[int] or comma-separated string 1676 | A comma-separated list of test suite IDs to filter by. 1677 | :return: response 1678 | """ 1679 | return self.s.get(endpoint=f"get_runs/{project_id}", params=kwargs) 1680 | 1681 | def add_run(self, project_id: int, **kwargs) -> dict: 1682 | """ 1683 | Creates a new test run. 1684 | 1685 | :param project_id: 1686 | The ID of the project the test run should be added to 1687 | :param kwargs: 1688 | :key suite_id: int 1689 | The ID of the test suite for the test run (optional if the project is 1690 | operating in single suite mode, required otherwise) 1691 | :key name: str 1692 | The name of the test run 1693 | :key description: str 1694 | The description of the test run 1695 | :key milestone_id: int 1696 | The ID of the milestone to link to the test run 1697 | :key assignedto_id: int 1698 | The ID of the user the test run should be assigned to 1699 | :key include_all: bool 1700 | True for including all test cases of the test suite and false for a 1701 | custom case selection (default: true) 1702 | :key case_ids: list 1703 | An array of case IDs for the custom case selection 1704 | :key refs: str 1705 | A comma-separated list of references/requirements 1706 | (Requires TestRail 6.1 or later) 1707 | :return: response 1708 | """ 1709 | return self.s.post(endpoint=f"add_run/{project_id}", json=kwargs) 1710 | 1711 | def update_run(self, run_id: int, **kwargs) -> dict: 1712 | """ 1713 | Updates an existing test run. 1714 | 1715 | (partial updates are supported, i.e. you can submit and update specific fields only). 1716 | 1717 | :param run_id: 1718 | The ID of the test run 1719 | :param kwargs: 1720 | :key name: str 1721 | The name of the test run 1722 | :key description: str 1723 | The description of the test run 1724 | :key milestone_id: int 1725 | The ID of the milestone to link to the test run 1726 | :key include_all: bool 1727 | True for including all test cases of the test suite and false for a 1728 | custom case selection (default: true) 1729 | :key case_ids: list 1730 | An array of case IDs for the custom case selection 1731 | :key refs: str 1732 | A comma-separated list of references/requirements 1733 | (Requires TestRail 6.1 or later) 1734 | :return: response 1735 | """ 1736 | return self.s.post(endpoint=f"update_run/{run_id}", json=kwargs) 1737 | 1738 | def close_run(self, run_id: int) -> Optional[dict]: 1739 | """ 1740 | Closes an existing test run and archives its tests & results. 1741 | 1742 | Closing a test run cannot be undone. 1743 | 1744 | :param run_id: 1745 | The ID of the test run 1746 | :return: response 1747 | """ 1748 | return self.s.post(endpoint=f"close_run/{run_id}") 1749 | 1750 | def delete_run(self, run_id: int, soft: int = 0) -> Optional[dict]: 1751 | """ 1752 | Deletes an existing test run. 1753 | 1754 | Deleting a test run cannot be undone and also permanently deletes all 1755 | tests & results of the test run. 1756 | 1757 | :param run_id: 1758 | The ID of the test run 1759 | :param soft: 1760 | Deleting a test run cannot be undone and also permanently deletes 1761 | all tests & results of the test run. 1762 | Omitting the soft parameter, or submitting soft=0 will delete the test 1763 | run and its tests. 1764 | :return: response 1765 | """ 1766 | return self.s.post(endpoint=f"delete_run/{run_id}", params={"soft": soft}) 1767 | 1768 | def get_runs_bulk(self, project_id: int, **kwargs) -> list[dict]: 1769 | """ 1770 | Returns a list of test runs for a project. 1771 | 1772 | Only returns those test runs that are not part of a test plan (please see get_plans/get_plan for this). 1773 | 1774 | :param project_id: int 1775 | The ID of the project 1776 | :param kwargs: filters 1777 | :key created_after: int/datetime 1778 | Only return test runs created after this date (as UNIX timestamp). 1779 | :key created_before: int/datetime 1780 | Only return test runs created before this date (as UNIX timestamp). 1781 | :key created_by: list[int] or comma-separated string 1782 | A comma-separated list of creators (user IDs) to filter by. 1783 | :key is_completed: int/bool 1784 | 1/True to return completed test runs only. 1785 | 0/False to return active test runs only. 1786 | :key milestone_id: list[int] or comma-separated string 1787 | A comma-separated list of milestone IDs to filter by. 1788 | :key refs_filter: str 1789 | A single Reference ID (e.g. TR-a, 4291, etc.) 1790 | :key suite_id: list[int] or comma-separated string 1791 | A comma-separated list of test suite IDs to filter by. 1792 | :return: List of runs 1793 | :returns: list[dict] 1794 | """ 1795 | return _bulk_api_method(self.get_runs, "runs", project_id, **kwargs) 1796 | 1797 | 1798 | class Sections(_MetaCategory): 1799 | """https://www.gurock.com/testrail/docs/api/reference/sections.""" 1800 | 1801 | def get_section(self, section_id: int) -> dict: 1802 | """ 1803 | Returns an existing section. 1804 | 1805 | :param section_id: 1806 | The ID of the section 1807 | :return: response 1808 | """ 1809 | return self.s.get(endpoint=f"get_section/{section_id}") 1810 | 1811 | def get_sections(self, project_id: int, limit: int = 250, offset: int = 0, **kwargs) -> dict: 1812 | """ 1813 | Returns a list of sections for a project and test suite. 1814 | 1815 | :param project_id: 1816 | The ID of the project 1817 | :param limit: int 1818 | The number of sections the response should return 1819 | (The response size is 250 by default) (requires TestRail 6.7 or later) 1820 | :param offset: int 1821 | Where to start counting the sections from (the offset) 1822 | (requires TestRail 6.7 or later) 1823 | :param kwargs: 1824 | :key suite_id: 1825 | The ID of the test suite (optional if the project is operating in 1826 | single suite mode) 1827 | :return: response 1828 | """ 1829 | return self.s.get( 1830 | endpoint=f"get_sections/{project_id}", 1831 | params=dict(limit=limit, offset=offset, **kwargs), 1832 | ) 1833 | 1834 | def add_section(self, project_id: int, name: str, **kwargs) -> dict: 1835 | """ 1836 | Creates a new section. 1837 | 1838 | :param project_id: 1839 | The ID of the project 1840 | :param name: 1841 | The name of the section (required) 1842 | :param kwargs: 1843 | :key description: str 1844 | The description of the section 1845 | :key suite_id: int 1846 | The ID of the test suite (ignored if the project is 1847 | operating in single suite mode, required otherwise) 1848 | :key parent_id: int 1849 | The ID of the parent section (to build section hierarchies) 1850 | :return: response 1851 | """ 1852 | return self.s.post(endpoint=f"add_section/{project_id}", json=dict(name=name, **kwargs)) 1853 | 1854 | def move_section(self, section_id: int, parent_id: int = 0, after_id: Optional[int] = None) -> dict: 1855 | """ 1856 | Moves a section to another suite or section (Requires TestRail 6.5.2 or later). 1857 | 1858 | :param section_id: 1859 | The ID of the section. 1860 | :param parent_id: int 1861 | The ID of the parent section 1862 | (it can be null if it should be moved to the root). 1863 | Must be in the same project and suite. 1864 | May not be direct child of the section being moved. 1865 | :param after_id: int 1866 | The section ID after which the section should be put (can be null) 1867 | """ 1868 | return self.s.post( 1869 | endpoint=f"move_section/{section_id}", 1870 | json={"parent_id": parent_id, "after_id": after_id}, 1871 | ) 1872 | 1873 | def update_section(self, section_id: int, **kwargs) -> dict: 1874 | """ 1875 | Updates an existing section. 1876 | 1877 | (partial updates are supported, i.e. you can submit and update specific fields only). 1878 | 1879 | :param section_id: 1880 | The ID of the section 1881 | :param kwargs: 1882 | :key name: str 1883 | The name of the section 1884 | :key description: str 1885 | The description of the section 1886 | :return: response 1887 | """ 1888 | return self.s.post(endpoint=f"update_section/{section_id}", json=kwargs) 1889 | 1890 | def delete_section(self, section_id: int, soft: int = 0) -> None: 1891 | """ 1892 | Deletes an existing section. 1893 | 1894 | :param section_id: 1895 | The ID of the section 1896 | :param soft: 1897 | Deleting a section cannot be undone and also deletes all related test 1898 | cases as well as active tests & results, i.e. tests & results that 1899 | weren't closed (archived) yet. 1900 | Omitting the soft parameter, or submitting soft=0 will delete the 1901 | section and its test cases 1902 | :return: response 1903 | """ 1904 | return self.s.post(endpoint=f"delete_section/{section_id}", params={"soft": soft}) 1905 | 1906 | def get_sections_bulk(self, project_id: int, **kwargs) -> list[dict]: 1907 | """ 1908 | Returns a list of sections for a project and/or test suite handling pagination. 1909 | 1910 | :param project_id: 1911 | The ID of the project 1912 | :param kwargs: 1913 | :key suite_id: 1914 | The ID of the test suite (optional if the project is operating in 1915 | single suite mode) 1916 | :return: List of sections 1917 | :returns: list[dict] 1918 | """ 1919 | return _bulk_api_method(self.get_sections, "sections", project_id, **kwargs) 1920 | 1921 | 1922 | class Statuses(_MetaCategory): 1923 | """https://www.gurock.com/testrail/docs/api/reference/statuses.""" 1924 | 1925 | def get_statuses(self) -> list[dict]: 1926 | """ 1927 | Returns a list of available test statuses. 1928 | 1929 | :return: response 1930 | """ 1931 | return self.s.get(endpoint="get_statuses") 1932 | 1933 | def get_case_statuses(self) -> list[dict]: 1934 | """ 1935 | Returns a list of available test case statuses. 1936 | 1937 | :return: response 1938 | """ 1939 | return self.s.get(endpoint="get_case_statuses") 1940 | 1941 | 1942 | class Suites(_MetaCategory): 1943 | """https://www.gurock.com/testrail/docs/api/reference/suites.""" 1944 | 1945 | def get_suite(self, suite_id: int) -> dict: 1946 | """ 1947 | Returns an existing test suite. 1948 | 1949 | :param suite_id: 1950 | The ID of the test suite 1951 | :return: response 1952 | """ 1953 | return self.s.get(endpoint=f"get_suite/{suite_id}") 1954 | 1955 | def get_suites(self, project_id: int) -> list[dict]: 1956 | """ 1957 | Returns a list of test suites for a project. 1958 | 1959 | :param project_id: 1960 | The ID of the project 1961 | :return: response 1962 | """ 1963 | return self.s.get(endpoint=f"get_suites/{project_id}") 1964 | 1965 | def add_suite(self, project_id: int, name: str, **kwargs) -> dict: 1966 | """ 1967 | Creates a new test suite. 1968 | 1969 | :param project_id: 1970 | The ID of the project the test suite should be added to 1971 | :param name: 1972 | The name of the test suite (required) 1973 | :param kwargs: 1974 | :key description: str 1975 | The description of the test suite 1976 | :return: response 1977 | """ 1978 | return self.s.post(endpoint=f"add_suite/{project_id}", json=dict(name=name, **kwargs)) 1979 | 1980 | def update_suite(self, suite_id: int, **kwargs) -> dict: 1981 | """ 1982 | Updates an existing test suite. 1983 | 1984 | (partial updates are supported, i.e. you can submit and update specific fields only). 1985 | 1986 | :param suite_id: 1987 | The ID of the test suite 1988 | :param kwargs: 1989 | :key name: str 1990 | The name of the test suite 1991 | :key description: str 1992 | The description of the test suite 1993 | :return: response 1994 | """ 1995 | return self.s.post(endpoint=f"update_suite/{suite_id}", json=kwargs) 1996 | 1997 | def delete_suite(self, suite_id: int, soft: int = 0) -> None: 1998 | """ 1999 | Deletes an existing test suite. 2000 | 2001 | :param suite_id: 2002 | The ID of the test suite 2003 | :param soft: 2004 | Deleting a test suite cannot be undone and also deletes all active 2005 | test runs & results, i.e. test runs & results that 2006 | weren't closed (archived) yet. 2007 | Omitting the soft parameter, or submitting soft=0 will delete the 2008 | test suite and its test cases 2009 | :return: response 2010 | """ 2011 | return self.s.post(endpoint=f"delete_suite/{suite_id}", params={"soft": soft}) 2012 | 2013 | 2014 | class Template(_MetaCategory): 2015 | """https://www.gurock.com/testrail/docs/api/reference/templates.""" 2016 | 2017 | def get_templates(self, project_id: int) -> list[dict]: 2018 | """ 2019 | Returns a list of available templates (requires TestRail 5.2 or later). 2020 | 2021 | :param project_id: 2022 | The ID of the project 2023 | :return: response 2024 | """ 2025 | return self.s.get(endpoint=f"get_templates/{project_id}") 2026 | 2027 | 2028 | class Tests(_MetaCategory): 2029 | """https://www.gurock.com/testrail/docs/api/reference/tests.""" 2030 | 2031 | def get_test(self, test_id: int, **kwargs) -> dict: 2032 | """ 2033 | Returns an existing test. 2034 | 2035 | If you interested in the test results rather than the tests, please see 2036 | get_results instead. 2037 | 2038 | :param test_id: 2039 | The ID of the test 2040 | :param kwargs: 2041 | :key with_data: 2042 | The parameter to get data (This is optional) 2043 | :return: response 2044 | """ 2045 | return self.s.get(endpoint=f"get_test/{test_id}", params=kwargs) 2046 | 2047 | def get_tests(self, run_id: int, limit: int = 250, offset: int = 0, **kwargs) -> dict: 2048 | """ 2049 | Returns a list of tests for a test run. 2050 | 2051 | :param run_id: 2052 | The ID of the test run 2053 | :param limit: int 2054 | Number that sets the limit of tests to be shown on the response 2055 | (Optional parameter. The response size limit is 250 by default) 2056 | (requires TestRail 6.7 or later) 2057 | :param offset: int 2058 | Number that sets the position where the response should start from 2059 | (Optional parameter) (requires TestRail 6.7 or later) 2060 | :param kwargs: filters 2061 | :key status_id: list[str] or comma-separated string 2062 | A comma-separated list of status IDs to filter by. 2063 | :return: response 2064 | """ 2065 | return self.s.get( 2066 | endpoint=f"get_tests/{run_id}", 2067 | params=dict(limit=limit, offset=offset, **kwargs), 2068 | ) 2069 | 2070 | def get_tests_bulk(self, run_id: int, **kwargs) -> list[dict]: 2071 | """ 2072 | Returns a list of tests for a test run handling pagination. 2073 | 2074 | :param run_id: 2075 | The ID of the test run 2076 | :param kwargs: 2077 | :key status_id: list[str] or comma-separated string 2078 | A comma-separated list of status IDs to filter by. 2079 | :return: List of tests 2080 | :returns: list[dict] 2081 | """ 2082 | return _bulk_api_method(self.get_tests, "tests", run_id, **kwargs) 2083 | 2084 | 2085 | class Users(_MetaCategory): 2086 | """https://www.gurock.com/testrail/docs/api/reference/users.""" 2087 | 2088 | def get_user(self, user_id: int) -> dict: 2089 | """ 2090 | Returns an existing user. 2091 | 2092 | :param user_id: 2093 | The ID of the user 2094 | :return: response 2095 | """ 2096 | return self.s.get(endpoint=f"get_user/{user_id}") 2097 | 2098 | def get_current_user(self, user_id: int) -> dict: 2099 | """ 2100 | Returns user details for the TestRail user making the API request (Requires TestRail 6.6 or later). 2101 | 2102 | :param user_id: 2103 | The ID of the user 2104 | :return: response 2105 | """ 2106 | return self.s.get(endpoint=f"get_current_user/{user_id}") 2107 | 2108 | def get_user_by_email(self, email: str) -> dict: 2109 | """ 2110 | Returns an existing user by his/her email address. 2111 | 2112 | :param email: 2113 | The email address to get the user for 2114 | :return: response 2115 | """ 2116 | return self.s.get(endpoint="get_user_by_email", params={"email": email}) 2117 | 2118 | def get_users(self, project_id: Optional[int] = None) -> list[dict]: 2119 | """ 2120 | Returns a list of users. 2121 | 2122 | :param project_id: 2123 | The ID of the project for which you would like to retrieve user information. 2124 | (Required for non-administrators. Requires TestRail 6.6 or later.) 2125 | :return: response 2126 | """ 2127 | return self.s.get(endpoint=f"get_users/{project_id}" if project_id else "get_users") 2128 | 2129 | 2130 | class SharedSteps(_MetaCategory): 2131 | """https://www.gurock.com/testrail/docs/api/reference/api-shared-steps.""" 2132 | 2133 | def get_shared_step(self, shared_step_id: int) -> dict: 2134 | """ 2135 | Returns an existing set of shared steps. 2136 | 2137 | :param shared_step_id: int 2138 | The ID of the set of shared steps. 2139 | """ 2140 | return self.s.get(endpoint=f"get_shared_step/{shared_step_id}") 2141 | 2142 | def get_shared_steps(self, project_id: int, **kwargs) -> dict: 2143 | """ 2144 | Returns a list of shared steps for a project. 2145 | 2146 | :param project_id: int 2147 | The ID of the project. 2148 | :param kwargs: 2149 | :key created_after: int or datetime 2150 | Only return shared steps created after this date 2151 | :key created_before: int or datetime 2152 | Only return shared steps created before this date 2153 | :key created_by: list[int] or A comma-separated str 2154 | A comma-separated list of creators (user IDs) to filter by. 2155 | :key limit/offset: int 2156 | Limit the result to :limit test runs. Use :offset to skip records. 2157 | :key updated_after: int or datetime 2158 | Only return shared steps updated after this date 2159 | :key updated_before: int or datetime 2160 | Only return shared steps updated before this date 2161 | :key refs: str 2162 | A single Reference ID (e.g. TR-a, 4291, etc.) 2163 | """ 2164 | return self.s.get(endpoint=f"get_shared_steps/{project_id}", params=kwargs) 2165 | 2166 | def add_shared_step(self, project_id: int, title: str, custom_steps_separated: list[dict]) -> dict: 2167 | """ 2168 | Creates a new set of shared steps. Requires permission to add test cases withing the project. 2169 | 2170 | :param project_id: int 2171 | The ID of the project. 2172 | :param title: int 2173 | The title for the set of steps. (Required) 2174 | :param custom_steps_separated: list 2175 | An array of objects. Each object contains the details for 2176 | an individual step. 2177 | See the table below for more details. 2178 | 2179 | custom_steps_separated fields: 2180 | additional_info: str: The text contents of the "Additional Info" field. 2181 | content: str: The text contents of the "Step" field. 2182 | expected: str: The text contents of the "Expected Result" field. 2183 | refs: str: Reference information for the "References" field. 2184 | """ 2185 | return self.s.post( 2186 | endpoint=f"add_shared_step/{project_id}", 2187 | json={"title": title, "custom_steps_separated": custom_steps_separated}, 2188 | ) 2189 | 2190 | def update_shared_step(self, shared_update_id: int, **kwargs) -> dict: 2191 | """ 2192 | Updates an existing set of shared steps. 2193 | 2194 | (partial updates are supported, i.e. you can submit and update specific fields only). 2195 | 2196 | Requires permission to edit test cases within the project. 2197 | 2198 | :param shared_update_id: int 2199 | The ID of the set of shared steps. 2200 | :param kwargs: 2201 | :key title: int 2202 | The title for the set of steps. 2203 | :key custom_steps_separated: list 2204 | An array of objects. Each object contains the details for 2205 | an individual step. See the table below for more details. 2206 | """ 2207 | return self.s.post(endpoint=f"update_shared_step/{shared_update_id}", json=kwargs) 2208 | 2209 | def delete_shared_step(self, shared_update_id: int, keep_in_cases: int = 1) -> dict: 2210 | """ 2211 | Deletes an existing shared step entity. 2212 | 2213 | Requires permission to delete test cases within the project. 2214 | 2215 | :param shared_update_id: int 2216 | The ID of the set of shared steps. 2217 | :param keep_in_cases: int 2218 | Default is 1 (true). Submit keep_in_cases=0 to delete the shared steps from 2219 | all test cases as well as the shared step repository. 2220 | """ 2221 | return self.s.post( 2222 | endpoint=f"delete_shared_step/{shared_update_id}", 2223 | json={"keep_in_cases": keep_in_cases}, 2224 | ) 2225 | 2226 | def get_shared_steps_bulk(self, project_id: int, **kwargs) -> list[dict]: 2227 | """ 2228 | Returns a list of shared steps for a project. 2229 | 2230 | :param project_id: int 2231 | The ID of the project. 2232 | :param kwargs: 2233 | :key created_after: int or datetime 2234 | Only return shared steps created after this date 2235 | :key created_before: int or datetime 2236 | Only return shared steps created before this date 2237 | :key created_by: list[int] or A comma-separated str 2238 | A comma-separated list of creators (user IDs) to filter by. 2239 | :key updated_after: int or datetime 2240 | Only return shared steps updated after this date 2241 | :key updated_before: int or datetime 2242 | Only return shared steps updated before this date 2243 | :key refs: str 2244 | A single Reference ID (e.g. TR-a, 4291, etc.) 2245 | :return: List of shared steps 2246 | :returns: list[dict] 2247 | """ 2248 | return _bulk_api_method(self.get_shared_steps, "shared_steps", project_id, **kwargs) 2249 | 2250 | 2251 | class Roles(_MetaCategory): 2252 | """https://support.testrail.com/hc/en-us/articles/7077853258772-Roles.""" 2253 | 2254 | def get_roles(self) -> dict[str, Any]: 2255 | """Returns a list of available roles.""" 2256 | return self.s.get(endpoint="get_roles") 2257 | 2258 | 2259 | class Groups(_MetaCategory): 2260 | """https://support.testrail.com/hc/en-us/articles/7077338821012-Groups.""" 2261 | 2262 | def get_group(self, group_id: int) -> dict: 2263 | """ 2264 | Returns an existing group. 2265 | 2266 | :param group_id: int 2267 | The ID of the group 2268 | """ 2269 | return self.s.get(f"get_group/{group_id}") 2270 | 2271 | def get_groups(self) -> dict[str, Any]: 2272 | """Returns the list of available groups.""" 2273 | return self.s.get("get_groups") 2274 | 2275 | def add_group(self, name: str, user_ids: list[int]) -> dict: 2276 | """ 2277 | Creates a new group. 2278 | 2279 | :param name: str 2280 | The name of the group 2281 | :param user_ids: list[int] 2282 | An array of user IDs. Each ID is a user belonging to this group 2283 | """ 2284 | return self.s.post("add_group", json={"name": name, "user_ids": user_ids}) 2285 | 2286 | def update_group(self, group_id: int, **kwargs) -> dict: 2287 | """ 2288 | Updates an existing group. 2289 | 2290 | :param group_id: int 2291 | The ID of the group 2292 | :param kwargs: 2293 | :key name: str 2294 | The name of the group 2295 | :key user_ids: list[int] 2296 | An array of user IDs. Each ID is a user belonging to this group 2297 | """ 2298 | return self.s.post(f"update_group/{group_id}", json=kwargs) 2299 | 2300 | def delete_group(self, group_id: int) -> None: 2301 | """ 2302 | Deletes an existing group. 2303 | 2304 | :param group_id: int 2305 | The ID of the group 2306 | """ 2307 | return self.s.post(f"delete_group/{group_id}") 2308 | 2309 | 2310 | class Variables(_MetaCategory): 2311 | """https://support.testrail.com/hc/en-us/articles/7077979742868-Variables.""" 2312 | 2313 | def get_variables(self, project_id: int) -> dict: 2314 | """ 2315 | Retrieves the requested variables. 2316 | 2317 | :param project_id: int 2318 | The ID of the project from which to retrieve variables. 2319 | """ 2320 | return self.s.get(endpoint=f"get_variables/{project_id}") 2321 | 2322 | def add_variable(self, project_id: int, id: int, name: str) -> dict: 2323 | """ 2324 | Creates a new variable. 2325 | 2326 | :param project_id: int 2327 | The ID of the project to which the variable should be added. 2328 | :param id: int 2329 | The ID of the newly added variable 2330 | :param name: str 2331 | Name of the newly added variable 2332 | """ 2333 | return self.s.post(endpoint=f"add_variable/{project_id}", json={"name": name, "id": id}) 2334 | 2335 | def update_variable(self, variable_id: int, name: str) -> dict: 2336 | """ 2337 | Updates an existing variable. 2338 | 2339 | :param variable_id: int 2340 | The ID of the variable to update. 2341 | :param name: str 2342 | Name of the variable to update 2343 | """ 2344 | return self.s.post(endpoint=f"update_variable/{variable_id}", json={"name": name}) 2345 | 2346 | def delete_variable(self, variable_id: int) -> None: 2347 | """ 2348 | Deletes an existing variable. 2349 | 2350 | :param variable_id: str 2351 | The ID of the variable to be deleted. 2352 | """ 2353 | return self.s.post(endpoint=f"delete_variable/{variable_id}") 2354 | 2355 | 2356 | class Datasets(_MetaCategory): 2357 | """https://support.testrail.com/hc/en-us/articles/7077300491540-Datasets.""" 2358 | 2359 | def get_dataset(self, dataset_id: int) -> dict: 2360 | """ 2361 | Retrieves the requested dataset parameter. 2362 | 2363 | :param dataset_id: int 2364 | The ID of the dataset to retrieve 2365 | """ 2366 | return self.s.get(endpoint=f"get_dataset/{dataset_id}") 2367 | 2368 | def get_datasets(self, project_id: int) -> dict: 2369 | """ 2370 | Retrieves the requested list of datasets. 2371 | 2372 | :param project_id: int 2373 | The ID of the project from which to retrieve datasets 2374 | """ 2375 | return self.s.get(endpoint=f"get_datasets/{project_id}") 2376 | 2377 | def add_dataset(self, project_id: int, id: int, name: str, variables: list[dict]) -> dict: 2378 | """ 2379 | Creates a new dataset. 2380 | 2381 | :param project_id: int 2382 | The ID of the project to which the dataset should be added 2383 | :param id: int 2384 | The database ID of the dataset 2385 | :param name: str 2386 | Name of the dataset as provided 2387 | :param variables: list[dict] 2388 | Key/Value pairs. Key should be the variable name. Value should be the value to be included in the dataset. 2389 | """ 2390 | return self.s.post(endpoint=f"add_dataset/{project_id}", json={"name": name, "variables": variables, "id": id}) 2391 | 2392 | def update_dataset(self, dataset_id: int, **kwargs) -> dict: 2393 | """ 2394 | Updates an existing dataset. 2395 | 2396 | :param dataset_id: int 2397 | The ID of the project to which the dataset should be updated 2398 | :param kwargs: 2399 | :key name: str 2400 | Name of the dataset as provided 2401 | :key variables: list[dict] 2402 | Key/Value pairs. Key should be the variable name. 2403 | """ 2404 | return self.s.post(endpoint=f"update_dataset/{dataset_id}", json=kwargs) 2405 | 2406 | def delete_dataset(self, dataset_id: int) -> None: 2407 | """ 2408 | Deletes an existing dataset.Parameter. 2409 | 2410 | :param dataset_id: int 2411 | The ID of the dataset to be deleted 2412 | """ 2413 | return self.s.post(endpoint=f"delete_dataset/{dataset_id}") 2414 | --------------------------------------------------------------------------------