├── tests ├── __init__.py ├── api │ ├── __init__.py │ ├── exception_test.py │ ├── encoding_test.py │ ├── wear_test.py │ ├── reporter_test.py │ └── configuration_test.py ├── conftest.py ├── data │ └── foo.py ├── api_test.py ├── integration_test.py ├── git_test.py └── cli_test.py ├── example ├── __init__.py ├── runtests.py ├── project.py └── example.json ├── nonunicode ├── __init__.py ├── malformed.py └── nonunicode.py ├── .github ├── CODEOWNERS ├── renovate.json5 ├── autoapproval.yml ├── workflows │ ├── dependencies.yaml │ ├── autoapproval.yml │ ├── test.yml │ └── build.yml ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── settings.yml ├── MANIFEST.in ├── coveralls ├── __main__.py ├── __init__.py ├── exception.py ├── cli.py ├── git.py ├── reporter.py └── api.py ├── docs ├── authors.rst ├── troubleshooting.rst ├── conf.py ├── index.rst ├── tips │ ├── nosetests.rst │ └── coveragerc.rst ├── usage │ ├── index.rst │ ├── vcsconfig.rst │ ├── multilang.rst │ ├── tox.rst │ └── configuration.rst └── release.rst ├── .readthedocs.yml ├── Dockerfile ├── docker-update-readme.js ├── tox.ini ├── .gitignore ├── LICENSE.rst ├── pyproject.toml ├── README.rst ├── .circleci └── config.yml ├── .pre-commit-config.yaml └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nonunicode/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @TheKevJames 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md LICENSE.txt README.rst 2 | -------------------------------------------------------------------------------- /nonunicode/malformed.py: -------------------------------------------------------------------------------- 1 | # -*- cоding: utf-8 -*- 2 | 3 | def hello(): 4 | return 1 5 | -------------------------------------------------------------------------------- /coveralls/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import main 2 | 3 | 4 | if __name__ == '__main__': 5 | main() 6 | -------------------------------------------------------------------------------- /nonunicode/nonunicode.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheKevJames/coveralls-python/HEAD/nonunicode/nonunicode.py -------------------------------------------------------------------------------- /coveralls/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | from .api import Coveralls 4 | 5 | 6 | __version__ = importlib.metadata.version('coveralls') 7 | __all__ = ['Coveralls'] 8 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>thekevjames/tools:personal", 4 | "github>thekevjames/tools//renovate/version-as-lib.json5", 5 | ], 6 | } 7 | -------------------------------------------------------------------------------- /example/runtests.py: -------------------------------------------------------------------------------- 1 | from project import branch 2 | from project import hello 3 | 4 | if __name__ == '__main__': 5 | hello() 6 | branch(False, True) 7 | branch(True, True) 8 | -------------------------------------------------------------------------------- /.github/autoapproval.yml: -------------------------------------------------------------------------------- 1 | from_owner: 2 | - pre-commit-ci[bot] 3 | - renovate[bot] 4 | 5 | required_labels: [] 6 | apply_labels: [] 7 | blacklisted_labels: 8 | - blocked 9 | - in-progress 10 | 11 | auto_squash_merge_labels: 12 | - automerge 13 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture(scope='session', autouse=True) 7 | def nuke_coverage(): 8 | for folder in ('.', './example', './nonunicode'): 9 | try: 10 | os.remove(f'{folder}/.coverage') 11 | except FileNotFoundError: 12 | pass 13 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | Coveralls is written and maintained by various contributors, without whom none of this would be possible. For a full list, see `GitHub`_. 5 | 6 | Special thanks goes to the original maintainer, Ilya Baryshev. 7 | 8 | .. _GitHub: https://github.com/TheKevJames/coveralls-python/graphs/contributors 9 | -------------------------------------------------------------------------------- /example/project.py: -------------------------------------------------------------------------------- 1 | def hello(): 2 | print('world') 3 | 4 | 5 | class Foo: 6 | """ Bar """ 7 | 8 | 9 | def baz(): 10 | print('this is not tested') 11 | 12 | def branch(cond1, cond2): 13 | if cond1: 14 | print('condition tested both ways') 15 | if cond2: 16 | print('condition not tested both ways') 17 | -------------------------------------------------------------------------------- /tests/data/foo.py: -------------------------------------------------------------------------------- 1 | def test_func(max_val): 2 | for idx in range(0, max_val): 3 | if idx == -1: 4 | print('Miss 1', idx) 5 | elif idx == 4: 6 | print('Hit 1', idx) 7 | elif idx == 6: 8 | print('Hit 2', idx) 9 | elif idx == 12: 10 | print('Miss 2', idx) 11 | else: 12 | print('Other', idx) 13 | -------------------------------------------------------------------------------- /.github/workflows/dependencies.yaml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | on: [pull_request] 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | dependency-review: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: 'Checkout Repository' 12 | uses: actions/checkout@v6.0.1 13 | - name: 'Dependency Review' 14 | uses: actions/dependency-review-action@v4.8.2 15 | -------------------------------------------------------------------------------- /coveralls/exception.py: -------------------------------------------------------------------------------- 1 | class CoverallsException(Exception): 2 | # TODO: do we really need this? 3 | def __eq__(self, other): 4 | if isinstance(other, self.__class__): 5 | return str(self) == str(other) 6 | return False 7 | 8 | def __ne__(self, other): 9 | return not self.__eq__(other) 10 | 11 | def __hash__(self): 12 | return hash(str(self)) 13 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /.github/workflows/autoapproval.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | types: [opened, reopened, labeled] 4 | pull_request_review: 5 | types: [dismissed] 6 | 7 | permissions: 8 | contents: read 9 | pull-requests: write 10 | 11 | jobs: 12 | autoapproval: 13 | runs-on: ubuntu-latest 14 | name: Auto-Approval Bot 15 | steps: 16 | - uses: dkhmelenko/autoapproval@v1.0 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /docs/troubleshooting.rst: -------------------------------------------------------------------------------- 1 | Troubleshooting 2 | =============== 3 | 4 | If you are having difficulties submitting your coverage to coveralls.io, debug mode may help you figure out the problem:: 5 | 6 | $ coveralls debug 7 | 8 | Debug mode doesn't send anything, it just outputs prepared json and reported files list to stdout. 9 | 10 | We also have an `issue tracker`_ on GitHub. 11 | 12 | .. _issue tracker: https://github.com/TheKevJames/coveralls-python/issues 13 | -------------------------------------------------------------------------------- /tests/api_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from unittest import mock 4 | 5 | from coveralls import Coveralls 6 | 7 | 8 | @mock.patch.dict(os.environ, {}, clear=True) 9 | def test_output_to_file(tmpdir): 10 | """Check we can write coveralls report into the file.""" 11 | test_log = tmpdir.join('test.log') 12 | Coveralls(repo_token='xxx').save_report(test_log.strpath) 13 | report = test_log.read() 14 | 15 | assert json.loads(report)['repo_token'] == 'xxx' 16 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-lts-latest 5 | tools: 6 | python: "3.12" 7 | jobs: 8 | post_create_environment: 9 | - pip install poetry 10 | post_install: 11 | # VIRTUAL_ENV needs to be set manually for now. 12 | # See https://github.com/readthedocs/readthedocs.org/pull/11152/ 13 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs 14 | 15 | sphinx: 16 | configuration: docs/conf.py 17 | fail_on_warning: true 18 | 19 | formats: 20 | - pdf 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | # renovate: datasource=pypi depName=coveralls 4 | ARG COVERALLS_VERSION=4.0.2 5 | # renovate: datasource=repology depName=alpine_3_22/git versioning=loose 6 | ARG GIT_VERSION=2.49.1-r0 7 | 8 | 9 | FROM python:3.14-alpine3.22 10 | 11 | ARG GIT_VERSION 12 | RUN --mount=type=cache,target=/var/cache/apk \ 13 | apk --update add \ 14 | "git=${GIT_VERSION}" 15 | 16 | ARG COVERALLS_VERSION 17 | RUN --mount=type=cache,target=/root/.cache/pip \ 18 | python3 -m pip install \ 19 | "coveralls==${COVERALLS_VERSION}" 20 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from coveralls import __version__ 2 | 3 | 4 | master_doc = 'index' 5 | source_suffix = '.rst' 6 | pygments_style = 'sphinx' 7 | 8 | templates_path = ['_templates'] 9 | exclude_patterns = [] 10 | 11 | todo_include_todos = True 12 | extensions = [ 13 | 'sphinx.ext.autodoc', 14 | 'sphinx.ext.githubpages', 15 | 'sphinx.ext.imgmath', 16 | 'sphinx.ext.todo', 17 | 'sphinx.ext.viewcode', 18 | ] 19 | 20 | project = 'coveralls-python' 21 | globals()['copyright'] = '2013, TheKevJames' 22 | author = 'TheKevJames' 23 | language = 'en' 24 | 25 | version = __version__ 26 | release = __version__ 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] 11 | 12 | steps: 13 | - uses: actions/checkout@v6.0.1 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: python -m pip install --upgrade pip tox tox-gh-actions 19 | - run: tox 20 | - run: tox -e upload 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /docker-update-readme.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | var dockerHubAPI = require('docker-hub-api'); 4 | dockerHubAPI.login(process.env.DOCKER_USER, 5 | process.env.DOCKER_PASS) 6 | .then((x) => dockerHubAPI.setLoginToken(x.token)) 7 | .then(() => { 8 | const readme = fs.readFileSync(process.argv[4] || '/tmp/README.md', 9 | {encoding: 'utf-8'}); 10 | dockerHubAPI.setRepositoryDescription(process.argv[2], 11 | process.argv[3], 12 | {full: readme}); 13 | }); 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | tags: ['*'] 6 | 7 | jobs: 8 | publish: 9 | name: upload to PyPI 10 | runs-on: ubuntu-latest 11 | environment: release 12 | permissions: 13 | id-token: write 14 | steps: 15 | - uses: actions/checkout@v6.0.1 16 | - uses: actions/setup-python@v5 17 | with: 18 | python-version: '3.12' 19 | - uses: snok/install-poetry@v1.4.1 20 | with: 21 | # TODO: renovate 22 | version: 1.8.2 23 | - run: poetry build 24 | - name: Publish package distributions to PyPI 25 | uses: pypa/gh-action-pypi-publish@release/v1 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature, in-review 6 | assignees: TheKevJames 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{310,311,312,313,314,py3}-cov{5,6,7}-{default,pyyaml} 3 | 4 | [gh-actions] 5 | python = 6 | 3.10: py310,upload 7 | 3.11: py311,upload 8 | 3.12: py312,upload 9 | 3.13: py313,upload 10 | 3.14: py314,upload 11 | 12 | [testenv] 13 | passenv = * 14 | usedevelop = true 15 | deps = 16 | responses==0.25.0 17 | pytest==8.1.1 18 | pyyaml: PyYAML>=3.10,<7.0 19 | cov5: coverage[toml]>=5.0,<6.0 20 | cov6: coverage[toml]>=6.0,<7.0 21 | cov7: coverage[toml]>=7.0,<8.0 22 | commands = 23 | coverage run --branch --source=coveralls -m pytest tests/ 24 | coverage report -m 25 | 26 | [testenv:upload] 27 | deps = 28 | coverage[toml]>=7.0,<8.0 29 | commands = 30 | coveralls --verbose 31 | -------------------------------------------------------------------------------- /example/example.json: -------------------------------------------------------------------------------- 1 | {"source_files": [{"name": "coveralls-python/example/project.py", "source": "def hello():\n print('world')\n\n\nclass Foo:\n \"\"\" Bar \"\"\"\n\n\ndef baz():\n print('this is not tested')\n\ndef branch(cond1, cond2):\n if cond1:\n print('condition tested both ways')\n if cond2:\n print('condition not tested both ways')\n", "coverage": [1, 1, null, null, 1, null, null, null, 1, 0, null, 1, 1, 1, 1, 1]}, {"name": "coveralls-python/example/runtests.py", "source": "from project import branch\nfrom project import hello\n\nif __name__ == '__main__':\n hello()\n branch(False, True)\n branch(True, True)\n", "coverage": [1, 1, null, 1, 1, 1, 1]}], "service_name": "coveralls-python", "config_file": ".coveragerc", "base_dir": null} -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report about a potential bug in the project 4 | title: '' 5 | labels: bug, in-review 6 | assignees: TheKevJames 7 | --- 8 | 9 | **Describe the Bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 1. With a project that looks like this... 15 | 2. Including these versions of the relevant dependencies... 16 | 3. Running these commands... 17 | 4. I get this result 18 | 19 | **Expected Behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Package Versions** 23 | - coveralls [e.g. 3.3.1] 24 | - coverage [e.g. 7.4.7] 25 | - ... 26 | 27 | **Trace Logs** 28 | Provide coverage logs w/ DEBUG logging enabled (run `coverage debug ...`) 29 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | coveralls-python 2 | ================ 3 | 4 | `coveralls.io`_ is a service for publishing your coverage stats online. This package provides seamless integration with `coverage.py`_ (and thus ``py.test``, ``nosetests``, etc...) in your Python projects. 5 | 6 | Getting Started 7 | --------------- 8 | .. toctree:: 9 | :maxdepth: 2 10 | 11 | usage/index 12 | usage/configuration 13 | usage/vcsconfig 14 | usage/tox 15 | usage/multilang 16 | 17 | tips/coveragerc 18 | tips/nosetests 19 | 20 | troubleshooting 21 | 22 | About 23 | ----- 24 | .. toctree:: 25 | :maxdepth: 2 26 | 27 | authors 28 | 29 | Administration 30 | -------------- 31 | .. toctree:: 32 | :maxdepth: 2 33 | 34 | release 35 | 36 | .. _coveralls.io: https://coveralls.io/ 37 | .. _coverage.py: https://coverage.readthedocs.io/en/latest/ 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *$py.class 2 | *.cover 3 | *.egg 4 | *.egg-info/ 5 | *.log 6 | *.manifest 7 | *.mo 8 | *.pot 9 | *.py[cod] 10 | *.sage.py 11 | *.so 12 | *.spec 13 | .Python 14 | .cache 15 | .coverage 16 | .coverage.* 17 | .eggs/ 18 | .env 19 | .hypothesis/ 20 | .installed.cfg 21 | .ipynb_checkpoints 22 | .mypy_cache/ 23 | .pytest_cache/ 24 | .python-version 25 | .ropeproject 26 | .scrapy 27 | .spyderproject 28 | .spyproject 29 | .tox/ 30 | .venv 31 | .webassets-cache 32 | /site 33 | ENV/ 34 | MANIFEST 35 | __pycache__/ 36 | build/ 37 | celerybeat-schedule 38 | coverage.xml 39 | db.sqlite3 40 | develop-eggs/ 41 | dist/ 42 | docs/_build/ 43 | downloads/ 44 | eggs/ 45 | env.bak/ 46 | env/ 47 | htmlcov/ 48 | instance/ 49 | lib/ 50 | lib64/ 51 | local_settings.py 52 | nosetests.xml 53 | parts/ 54 | pip-delete-this-directory.txt 55 | pip-log.txt 56 | sdist/ 57 | target/ 58 | var/ 59 | venv.bak/ 60 | venv/ 61 | wheels/ 62 | -------------------------------------------------------------------------------- /docs/tips/nosetests.rst: -------------------------------------------------------------------------------- 1 | Nosetests 2 | ========= 3 | 4 | `Nosetests`_ provide a plugin for coverage measurement of your code:: 5 | 6 | $ nosetests --with-coverage --cover-package= 7 | 8 | However, nosetests gathers coverage for all executed code, ignoring the ``source`` config option in ``.coveragerc``. 9 | 10 | This well make ``coveralls`` report unnecessary files, which can be inconvenient. To workaround this issue, you can use the ``omit`` option in your ``.coveragerc`` to specify a list of filename patterns to leave out of reporting. 11 | 12 | For example:: 13 | 14 | [report] 15 | omit = 16 | */venv/* 17 | */my_project/ignorable_file.py 18 | */test_script.py 19 | 20 | Note, that native ``coverage.py`` and ``py.test`` are not affected by this problem and do not require this workaround. 21 | 22 | .. _Nosetests: http://nose.readthedocs.org/en/latest/plugins/cover.html 23 | -------------------------------------------------------------------------------- /docs/usage/index.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | This package works with any CI environment. Special handling has been included for some CI service providers, but coveralls-python can run anywhere. 5 | 6 | To get started with coveralls-python, make sure to `add your repo`_ on the coveralls.io website. If you will be using coveralls-python on TravisCI, you're done here -- otherwise, take note of the "repo token" in the coveralls.io dashboard. 7 | 8 | After that, its as simple as installing coveralls-python, collecting coverage results, and sending them to coveralls.io. 9 | 10 | For example:: 11 | 12 | pip install coveralls 13 | coverage run --source=my_package setup.py test 14 | COVERALLS_REPO_TOKEN=tGSdG5Qcd2dcQa2oQN9GlJkL50wFZPv1j coveralls 15 | 16 | coveralls-python can be configured with several environment variables, as seen above. See :ref:`configuration` for more details. 17 | 18 | .. _add your repo: https://coveralls.io/repos/new 19 | -------------------------------------------------------------------------------- /docs/usage/vcsconfig.rst: -------------------------------------------------------------------------------- 1 | .. _vcsconfig: 2 | 3 | VCS Configuration 4 | ================= 5 | 6 | ``coveralls-python`` supports ``git`` by default and will run the necessary ``git`` commands to collect the required information without any intervention. 7 | 8 | As describe in `the coveralls docs`_, you may also configure these values by setting environment variables. These will be used in the fallback case, eg. if ``git`` is not available or your project is not a ``git`` repository. 9 | 10 | As described in the linked documentation, you can also use this method to support non- ``git`` projects:: 11 | 12 | GIT_ID=$(hg tip --template '{node}\n') 13 | GIT_AUTHOR_NAME=$(hg tip --template '{author|person}\n') 14 | GIT_AUTHOR_EMAIL=$(hg tip --template '{author|email}\n') 15 | GIT_COMMITTER_NAME=$(hg tip --template '{author|person}\n') 16 | GIT_COMMITTER_EMAIL=$(hg tip --template '{author|email}\n') 17 | GIT_MESSAGE=$(hg tip --template '{desc}\n') 18 | GIT_BRANCH=$(hg branch) 19 | 20 | .. _the coveralls docs: https://docs.coveralls.io/mercurial-support 21 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | MIT License 2 | =========== 3 | 4 | Copyright (c) 2017 Kevin James 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /tests/api/exception_test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | 4 | import pytest 5 | 6 | from coveralls.exception import CoverallsException 7 | 8 | 9 | class CoverallsExceptionTest(unittest.TestCase): 10 | 11 | _caplog = None 12 | 13 | @pytest.fixture(autouse=True) 14 | def inject_fixtures(self, caplog): 15 | self._caplog = caplog 16 | 17 | def test_log(self): 18 | self._caplog.set_level(logging.INFO) 19 | exc_value = '' 20 | try: 21 | raise CoverallsException('Some exception') 22 | except CoverallsException as e: 23 | logging.exception('Found exception') 24 | assert 'raise CoverallsException(' in \ 25 | self._caplog.text 26 | exc_value = str(e) 27 | 28 | assert exc_value == 'Some exception' 29 | 30 | def test_eq(self): 31 | exc1 = CoverallsException('Value1') 32 | exc2 = CoverallsException('Value1') 33 | assert exc1 == exc2 34 | assert not exc1 == 35 # pylint: disable=unneeded-not 35 | assert exc1 is not exc2 36 | 37 | def test_ne(self): 38 | exc1 = CoverallsException('Value1') 39 | exc2 = CoverallsException('Value2') 40 | assert exc1 != exc2 41 | assert exc1 is not exc2 42 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | repository: 2 | name: coveralls-python 3 | description: Show coverage stats online via coveralls.io 4 | homepage: coveralls-python.readthedocs.io 5 | topics: coveralls, coverage, nosetests, pytest, python 6 | private: false 7 | 8 | has_issues: true 9 | has_projects: false 10 | has_wiki: false 11 | has_downloads: false 12 | 13 | default_branch: master 14 | delete_branch_on_merge: true 15 | 16 | allow_merge_commit: false 17 | allow_rebase_merge: true 18 | allow_squash_merge: true 19 | 20 | enable_automated_security_fixes: true 21 | enable_vulnerability_alerts: true 22 | 23 | labels: 24 | - name: bug 25 | color: '#fc2929' 26 | - name: feature 27 | color: '#84b6eb' 28 | oldname: enhancement 29 | - name: task 30 | color: '#d93f0b' 31 | 32 | - name: help wanted 33 | color: '#159818' 34 | - name: low-priority 35 | color: '#fbca04' 36 | - name: question 37 | color: '#cc317c' 38 | 39 | - name: triage 40 | color: '#ededed' 41 | - name: backlog 42 | color: '#ededed' 43 | - name: in-progress 44 | color: '#0052cc' 45 | - name: in-review 46 | color: '#5319e7' 47 | - name: blocked 48 | color: '#dbe19f' 49 | 50 | - name: automerge 51 | color: '#d913a6' 52 | 53 | branches: 54 | - name: master 55 | -------------------------------------------------------------------------------- /docs/tips/coveragerc.rst: -------------------------------------------------------------------------------- 1 | Tips for .coveragerc 2 | ==================== 3 | 4 | This section is a list of most common options for ``coverage.py``, which collects all the coverage information. Coveralls is populated from this data, so it's good to know `how to to configure coverage.py`_. 5 | 6 | To limit the `report to only your packages`_, specify their names (or directories):: 7 | 8 | [run] 9 | source = pkgname,your_otherpackage 10 | 11 | To exclude parts of your source from coverage, for example migrations folders:: 12 | 13 | [report] 14 | omit = */migrations/* 15 | 16 | Some lines are never executed in your tests, but that can be ok. 17 | To mark those lines use inline comments right in your source code:: 18 | 19 | if debug: # pragma: no cover 20 | msg = "blah blah" 21 | log_message(msg, a) 22 | 23 | Sometimes it can be tedious to mark them in code, so you can `specify whole lines in .coveragerc`_:: 24 | 25 | [report] 26 | exclude_lines = 27 | pragma: no cover 28 | def __repr__ 29 | raise AssertionError 30 | raise NotImplementedError 31 | if __name__ == .__main__.: 32 | 33 | Finally, if you're using non-default configuration file, you can specify it in the coveralls command:: 34 | 35 | $ coveralls --rcfile= 36 | 37 | .. _how to to configure coverage.py: http://coverage.readthedocs.io/en/latest/config.html 38 | .. _report to only your packages: http://coverage.readthedocs.io/en/latest/source.html 39 | .. _specify whole lines in .coveragerc: http://coverage.readthedocs.io/en/latest/excluding.html 40 | -------------------------------------------------------------------------------- /docs/usage/multilang.rst: -------------------------------------------------------------------------------- 1 | .. _multilang: 2 | 3 | Multiple Language Support 4 | ========================= 5 | 6 | Tracking multi-language repo coverage requires an extra setup of merging coverage data for submission. 7 | 8 | To send coveralls.io merged data, you must use each of your coverage reporting tools in sequence, then merge the JSON data in the last step. Note that there is varying levels of support for these tools; for example you should be set to long as your coverage tool of choice emits ``lcov``-style data, but as far as I am aware no method yet exists for converting ``kcov``-style data tot he correct format. YMMV. 9 | 10 | For example, to submit coverage for a project using both ``mocha`` and ``py.test``, you could use the `coveralls-lcov`_ library and run:: 11 | 12 | # generate mocha coverage data 13 | mocha --reporter mocha-lcov-reporter */tests/static/js/* > coverage.info 14 | 15 | # convert data with coveralls-lcov 16 | coveralls-lcov -v -n coverage.info > coverage.json 17 | 18 | # merge mocha coverage with python coverage and send to coveralls 19 | coveralls --merge=coverage.json 20 | 21 | If you want to use this library to create a JSON blob for usage elsewhere, you can run:: 22 | 23 | coveralls --output=coverage.json 24 | 25 | Technical Details 26 | ----------------- 27 | 28 | The JSON file to be merged must be of "coveralls-style" and contain thus a ``source_files`` key. The `Coveralls API`_ has more information. 29 | 30 | .. _coveralls-lcov: https://github.com/okkez/coveralls-lcov 31 | .. _Coveralls API: https://docs.coveralls.io/api-introduction 32 | -------------------------------------------------------------------------------- /tests/api/encoding_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import subprocess 4 | import unittest 5 | 6 | from coveralls import Coveralls 7 | 8 | 9 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) 10 | NONUNICODE_DIR = os.path.join(BASE_DIR, 'nonunicode') 11 | 12 | 13 | class EncodingTest(unittest.TestCase): 14 | @classmethod 15 | def setUpClass(cls): 16 | cls.old_cwd = os.getcwd() 17 | 18 | @classmethod 19 | def tearDownClass(cls): 20 | os.chdir(cls.old_cwd) 21 | 22 | @staticmethod 23 | def test_non_unicode(): 24 | os.chdir(NONUNICODE_DIR) 25 | subprocess.call( 26 | ['coverage', 'run', 'nonunicode.py'], 27 | cwd=NONUNICODE_DIR, 28 | ) 29 | 30 | actual_json = json.dumps(Coveralls(repo_token='xxx').get_coverage()) 31 | expected_json_part = ( 32 | '"source": "# coding: iso-8859-15\\n\\n' 33 | 'def hello():\\n' 34 | ' print(\'I like P\\u00f3lya distribution.\')' 35 | ) 36 | assert expected_json_part in actual_json 37 | 38 | @staticmethod 39 | def test_malformed_encoding_declaration_py3_or_coverage4(): 40 | os.chdir(NONUNICODE_DIR) 41 | subprocess.call( 42 | ['coverage', 'run', 'malformed.py'], 43 | cwd=NONUNICODE_DIR, 44 | ) 45 | 46 | result = Coveralls(repo_token='xxx').get_coverage() 47 | assert len(result) == 1 48 | 49 | assert result[0]['coverage'] == [None, None, 1, 0] 50 | assert result[0]['name'] == 'malformed.py' 51 | assert result[0]['source'].strip() == ( 52 | '# -*- cоding: utf-8 -*-\n\n' 53 | 'def hello():\n' 54 | ' return 1' 55 | ) 56 | assert 'branches' not in result[0] 57 | -------------------------------------------------------------------------------- /docs/release.rst: -------------------------------------------------------------------------------- 1 | Release 2 | ======= 3 | 4 | This project is released on PyPI as `coveralls`_, as well as on `quay`_ and `dockerhub`_. 5 | 6 | To cut a new release, ensure the latest master passes all tests. Then, create a release commit: 7 | 8 | #. Update the ``CHANGELOG.md`` with the new version (``clog -C CHANGELOG.md -F --setversion x.y.z``). 9 | #. Bump the version number with poetry: ``poetry version (major|minor|patch)``. 10 | #. Commit and push (``git commit -am 'chore(release): bump version' && git push``) 11 | #. Tag and push that commit with the version number (``git tag x.y.z && git push origin x.y.z``). 12 | #. Create a new `GitHub release`_. 13 | #. Verify the `docs build succeeded`_ then `mark it active`_. 14 | 15 | Conda should automatically create a PR on their `coveralls-feedstock`_ shortly with the updated version -- if something goes wrong, the manual process would be to: 16 | 17 | #. Fork `coveralls-feedstock`_. 18 | #. Update ``recipe/meta.yaml`` with the new version number and `sha`_. 19 | #. Create a PR. 20 | #. Comment on your own PR with: "@conda-forge-admin, please rerender". 21 | #. Merge along with the automated commit from Conda. 22 | 23 | Note that the ``clog`` command comes from ``cargo install clog-cli``. 24 | 25 | .. _GitHub release: https://github.com/TheKevJames/coveralls-python/releases/new 26 | .. _coveralls-feedstock: https://github.com/conda-forge/coveralls-feedstock 27 | .. _coveralls: https://pypi.org/project/coveralls/ 28 | .. _dockerhub: https://hub.docker.com/r/thekevjames/coveralls 29 | .. _docs build succeeded: https://readthedocs.org/projects/coveralls-python/builds/ 30 | .. _mark it active: https://readthedocs.org/projects/coveralls-python/versions/ 31 | .. _quay: https://quay.io/repository/thekevjames/coveralls 32 | .. _sha: https://pypi.org/project/coveralls/#files 33 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "coveralls" 3 | version = "4.0.2" 4 | description = "Show coverage stats online via coveralls.io" 5 | readme = "README.rst" 6 | 7 | repository = "http://github.com/TheKevJames/coveralls-python" 8 | authors = ["Kevin James "] 9 | license = "MIT" 10 | 11 | packages = [ 12 | { include = "coveralls" }, 13 | ] 14 | 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "Topic :: Software Development :: Testing", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python :: Implementation :: CPython", 22 | "Programming Language :: Python :: Implementation :: PyPy", 23 | ] 24 | 25 | [tool.poetry.urls] 26 | Changelog = "https://github.com/TheKevJames/coveralls-python/blob/master/CHANGELOG.md" 27 | Docs = "https://coveralls-python.rtfd.io/" 28 | 29 | [tool.poetry.scripts] 30 | coveralls = "coveralls.cli:main" 31 | python-coveralls = "coveralls.cli:main" 32 | 33 | [tool.poetry.dependencies] 34 | python = ">=3.10,<4.0" 35 | coverage = { version = ">=5.0,<8.0,!=6.0.*,!=6.1,!=6.1.1", extras = ["toml"] } 36 | docopt = ">=0.6.1,<0.7.0" 37 | requests = ">=1.0.0,<3.0.0" 38 | 39 | pyyaml = { version = ">=3.10,<7.0", optional = true } 40 | 41 | [tool.poetry.group.dev.dependencies] 42 | pytest = "9.0.2" 43 | responses = "0.25.8" 44 | 45 | [tool.poetry.group.docs] 46 | optional = true 47 | [tool.poetry.group.docs.dependencies] 48 | sphinx = { version = "7.4.7", python = ">=3.9" } 49 | 50 | [tool.poetry.extras] 51 | yaml = ["pyyaml"] 52 | 53 | [tool.pytest.ini_options] 54 | # addopts = "-Werror" 55 | filterwarnings = [ 56 | "error", 57 | # cov5 and cov6 are deprecated on py3.12+ 58 | "ignore:co_lnotab is deprecated, use co_lines instead:DeprecationWarning", 59 | ] 60 | 61 | [build-system] 62 | requires = ["poetry-core>=1.0.0"] 63 | build-backend = "poetry.core.masonry.api" 64 | -------------------------------------------------------------------------------- /tests/integration_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | import tempfile 5 | import unittest 6 | 7 | from coveralls import Coveralls 8 | 9 | 10 | COVERAGE_CODE_STANZA = """ 11 | import sys 12 | sys.path.append('{}') 13 | 14 | import foo 15 | foo.test_func({:d}) 16 | """ 17 | 18 | COVERAGE_TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), 'data') 19 | 20 | 21 | class IntegrationTest(unittest.TestCase): 22 | gitinfo = { 23 | 'GIT_ID': 'asdf1234', 24 | 'GIT_AUTHOR_NAME': 'Integration Tests', 25 | 'GIT_AUTHOR_EMAIL': 'integration@test.com', 26 | 'GIT_COMMITTER_NAME': 'Integration Tests', 27 | 'GIT_COMMITTER_EMAIL': 'integration@test.com', 28 | 'GIT_MESSAGE': 'Ran the integration tests', 29 | } 30 | 31 | @classmethod 32 | def setUpClass(cls): 33 | cls.old_cwd = os.getcwd() 34 | 35 | @classmethod 36 | def tearDownClass(cls): 37 | os.chdir(cls.old_cwd) 38 | 39 | def _test_harness(self, num, hits): 40 | with tempfile.TemporaryDirectory() as tempdir: 41 | os.chdir(tempdir) 42 | 43 | test_file = os.path.join(tempdir, 'test.py') 44 | with open(test_file, 'w') as f: 45 | f.write( 46 | COVERAGE_CODE_STANZA.format( 47 | COVERAGE_TEMPLATE_PATH, 48 | num, 49 | ), 50 | ) 51 | 52 | subprocess.check_call([ 53 | sys.executable, '-m', 'coverage', 'run', 54 | test_file, 55 | ]) 56 | 57 | coverallz = Coveralls(repo_token='xxx') 58 | report = coverallz.create_data() 59 | coverallz.create_report() # This is purely for coverage 60 | 61 | source_files = {f['name'] for f in report['source_files']} 62 | print(source_files) 63 | foo = os.path.join(COVERAGE_TEMPLATE_PATH, 'foo.py') 64 | self.assertIn(foo, source_files) 65 | 66 | lines = next( 67 | ( 68 | f['coverage'] for f in report['source_files'] 69 | if f['name'] == foo 70 | ), None, 71 | ) 72 | assert sum(int(bool(x)) for x in lines) == hits 73 | 74 | @unittest.mock.patch.dict(os.environ, gitinfo, clear=True) 75 | def test_5(self): 76 | self._test_harness(5, 8) 77 | 78 | @unittest.mock.patch.dict(os.environ, gitinfo, clear=True) 79 | def test_7(self): 80 | self._test_harness(7, 9) 81 | 82 | @unittest.mock.patch.dict(os.environ, gitinfo, clear=True) 83 | def test_11(self): 84 | self._test_harness(11, 9) 85 | -------------------------------------------------------------------------------- /docs/usage/tox.rst: -------------------------------------------------------------------------------- 1 | Usage Within Tox 2 | ================ 3 | 4 | Running coveralls-python from within a `tox`_ environment (v2.0 and above) requires an additional step; since coveralls-python relies on environment variables to function, you'll need to configure tox to capture those variables using the ``passenv`` configuration option in your ``tox.ini``. 5 | 6 | For example, on TravisCI:: 7 | 8 | [tox] 9 | envlist = py310,py311,py312 10 | 11 | [testenv] 12 | passenv = TRAVIS , TRAVIS_* 13 | deps = 14 | coveralls 15 | commands = 16 | coverage run --source=yourpackagename setup.py test 17 | coveralls 18 | 19 | If you are configuring coveralls-python with environment variables, you should also pass those. See :ref:`configuration` for more details. 20 | 21 | AppVeyor 22 | -------- 23 | :: 24 | 25 | passenv = APPVEYOR , APPVEYOR_* 26 | 27 | All variables: 28 | 29 | - ``APPVEYOR`` 30 | - ``APPVEYOR_BUILD_ID`` 31 | - ``APPVEYOR_REPO_BRANCH`` 32 | - ``APPVEYOR_PULL_REQUEST_NUMBER`` 33 | 34 | BuildKite 35 | --------- 36 | :: 37 | 38 | passenv = BUILDKITE , BUILDKITE_* 39 | 40 | All variables: 41 | 42 | - ``BUILDKITE`` 43 | - ``BUILDKITE_JOB_ID`` 44 | - ``BUILDKITE_BRANCH`` 45 | 46 | CircleCI 47 | -------- 48 | :: 49 | 50 | passenv = CIRCLECI , CIRCLE_* , CI_PULL_REQUEST 51 | 52 | All variables: 53 | 54 | - ``CIRCLECI`` 55 | - ``CIRCLE_WORKFLOW_ID`` 56 | - ``CIRCLE_BUILD_NUM`` 57 | - ``CIRCLE_BRANCH`` 58 | - ``CIRCLE_NODE_INDEX`` 59 | - ``CI_PULL_REQUEST`` 60 | 61 | Github Actions 62 | -------------- 63 | :: 64 | 65 | passenv = GITHUB_* 66 | 67 | All variables: 68 | 69 | - ``GITHUB_ACTIONS`` 70 | - ``GITHUB_REF`` 71 | - ``GITHUB_SHA`` 72 | - ``GITHUB_HEAD_REF`` 73 | - ``GITHUB_REPOSITORY`` 74 | - ``GITHUB_RUN_ID`` 75 | - ``GITHUB_TOKEN`` 76 | 77 | Jenkins 78 | ------- 79 | :: 80 | 81 | passenv = JENKINS_HOME , BUILD_NUMBER , GIT_BRANCH , CI_PULL_REQUEST 82 | 83 | All variables: 84 | 85 | - ``JENKINS_HOME`` 86 | - ``BUILD_NUMBER`` 87 | - ``GIT_BRANCH`` 88 | - ``CI_PULL_REQUEST`` 89 | 90 | 91 | TravisCI 92 | -------- 93 | :: 94 | 95 | passenv = TRAVIS , TRAVIS_* 96 | 97 | All variables: 98 | 99 | - ``TRAVIS`` 100 | - ``TRAVIS_JOB_ID`` 101 | - ``TRAVIS_BRANCH`` 102 | - ``TRAVIS_PULL_REQUEST`` 103 | 104 | 105 | SemaphoreCI 106 | ----------- 107 | 108 | Classic 109 | ~~~~~~~ 110 | 111 | :: 112 | 113 | passenv = SEMAPHORE , SEMAPHORE_EXECUTABLE_UUID , SEMAPHORE_JOB_UUID , SEMAPHORE_BRANCH_ID , BRANCH_NAME 114 | 115 | All variables: 116 | 117 | - ``SEMAPHORE`` 118 | - ``SEMAPHORE_EXECUTABLE_UUID`` 119 | - ``SEMAPHORE_JOB_UUID`` 120 | - ``SEMAPHORE_BRANCH_ID`` 121 | - ``BRANCH_NAME`` 122 | 123 | 2.0 124 | ~~~ 125 | 126 | :: 127 | 128 | passenv = SEMAPHORE , SEMAPHORE_WORKFLOW_ID , SEMAPHORE_JOB_ID , SEMAPHORE_GIT_PR_NUMBER , BRANCH_NAME 129 | 130 | All variables: 131 | 132 | - ``SEMAPHORE`` 133 | - ``SEMAPHORE_WORKFLOW_ID`` 134 | - ``SEMAPHORE_JOB_ID`` 135 | - ``SEMAPHORE_GIT_PR_NUMBER`` 136 | - ``BRANCH_NAME`` 137 | 138 | .. _tox: https://tox.readthedocs.io/en/latest/ 139 | -------------------------------------------------------------------------------- /coveralls/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Publish coverage results online via coveralls.io. 3 | 4 | Puts your coverage results on coveralls.io for everyone to see. 5 | 6 | This tool makes custom reports for data generated by coverage.py package and 7 | sends it to the coveralls.io service API. 8 | 9 | All Python files in your coverage analysis are posted to this service along 10 | with coverage stats, so please make sure you're not ruining your own security! 11 | 12 | Usage: 13 | coveralls [options] 14 | coveralls debug [options] 15 | 16 | Debug mode doesn't send anything, just outputs json to stdout. It also 17 | forces verbose output. Please use debug mode when submitting bug reports. 18 | 19 | Global options: 20 | --service= Provide an alternative service name to submit. 21 | --rcfile= Specify configuration file. [default: .coveragerc] 22 | --basedir= Base directory that is removed from reported paths. 23 | --output= Write report to file. Doesn't send anything. 24 | --srcdir= Source directory added to reported paths. 25 | --submit= Upload a previously generated file. 26 | --merge= Merge report from file when submitting. 27 | --finish Finish parallel jobs. 28 | -h --help Display this help. 29 | -v --verbose Print extra info, always enabled when debugging. 30 | 31 | Example: 32 | ------- 33 | $ coveralls 34 | Submitting coverage to coveralls.io... 35 | Coverage submitted! 36 | Job #38.1 37 | https://coveralls.io/jobs/92059 38 | """ 39 | import importlib.metadata 40 | import logging 41 | import sys 42 | 43 | import docopt 44 | 45 | from .api import Coveralls 46 | 47 | 48 | log = logging.getLogger('coveralls') 49 | 50 | 51 | def main(argv=None): 52 | # pylint: disable=too-complex 53 | version = importlib.metadata.version('coveralls') 54 | options = docopt.docopt(__doc__, argv=argv, version=version) 55 | if options['debug']: 56 | options['--verbose'] = True 57 | 58 | level = logging.DEBUG if options['--verbose'] else logging.INFO 59 | log.addHandler(logging.StreamHandler()) 60 | log.setLevel(level) 61 | 62 | token_required = not options['debug'] and not options['--output'] 63 | 64 | try: 65 | coverallz = Coveralls( 66 | token_required, 67 | config_file=options['--rcfile'], 68 | service_name=options['--service'], 69 | base_dir=options.get('--basedir') or '', 70 | src_dir=options.get('--srcdir') or '', 71 | ) 72 | 73 | if options['--merge']: 74 | coverallz.merge(options['--merge']) 75 | 76 | if options['debug']: 77 | log.info('Testing coveralls-python...') 78 | coverallz.wear(dry_run=True) 79 | return 80 | 81 | if options['--output']: 82 | log.info('Write coverage report to file...') 83 | coverallz.save_report(options['--output']) 84 | return 85 | 86 | if options['--submit']: 87 | with open(options['--submit']) as report_file: 88 | coverallz.submit_report(report_file.read()) 89 | return 90 | 91 | if options['--finish']: 92 | log.info('Finishing parallel jobs...') 93 | coverallz.parallel_finish() 94 | log.info('Done') 95 | return 96 | 97 | log.info('Submitting coverage to coveralls.io...') 98 | result = coverallz.wear() 99 | 100 | log.info('Coverage submitted!') 101 | log.debug(result) 102 | if result: 103 | log.info(result.get('message')) 104 | log.info(result.get('url')) 105 | except KeyboardInterrupt: # pragma: no cover 106 | log.info('Aborted') 107 | except Exception as e: 108 | log.exception('Error running coveralls: %s', e) 109 | sys.exit(1) 110 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Coveralls for Python 2 | ==================== 3 | 4 | :Test Status: 5 | 6 | .. image:: https://img.shields.io/circleci/project/github/TheKevJames/coveralls-python/master.svg?style=flat-square&label=CircleCI 7 | :target: https://circleci.com/gh/TheKevJames/coveralls-python 8 | .. image:: https://img.shields.io/github/actions/workflow/status/TheKevJames/coveralls-python/test.yml?branch=master&style=flat-square&label=Github%20Actions 9 | :target: https://github.com/TheKevJames/coveralls-python/actions 10 | .. image:: https://img.shields.io/coveralls/TheKevJames/coveralls-python/master.svg?style=flat-square&label=Coverage 11 | :target: https://coveralls.io/r/TheKevJames/coveralls-python 12 | .. image:: https://img.shields.io/readthedocs/coveralls-python?style=flat-square&label=Docs 13 | :target: http://coveralls-python.readthedocs.io/en/latest/ 14 | 15 | :Version Info: 16 | 17 | .. image:: https://img.shields.io/pypi/v/coveralls.svg?style=flat-square&label=PyPI 18 | :target: https://pypi.org/project/coveralls/ 19 | .. image:: https://img.shields.io/conda/v/conda-forge/coveralls?style=flat-square&label=Conda 20 | :target: https://anaconda.org/conda-forge/coveralls 21 | .. image:: https://img.shields.io/docker/v/thekevjames/coveralls?sort=semver&style=flat-square&label=Dockerhub 22 | :target: https://hub.docker.com/r/thekevjames/coveralls 23 | .. image:: https://img.shields.io/docker/v/thekevjames/coveralls?sort=semver&style=flat-square&label=Quay 24 | :target: https://quay.io/repository/thekevjames/coveralls 25 | 26 | :Compatibility: 27 | 28 | .. image:: https://img.shields.io/pypi/pyversions/coveralls.svg?style=flat-square&label=Python%20Versions 29 | :target: https://pypi.org/project/coveralls/ 30 | .. image:: https://img.shields.io/pypi/implementation/coveralls.svg?style=flat-square&label=Python%20Implementations 31 | :target: https://pypi.org/project/coveralls/ 32 | 33 | :Downloads: 34 | 35 | .. image:: https://img.shields.io/pypi/dm/coveralls.svg?style=flat-square&label=PyPI 36 | :target: https://pypi.org/project/coveralls/ 37 | .. image:: https://img.shields.io/conda/dn/conda-forge/coveralls?style=flat-square&label=Conda 38 | :target: https://anaconda.org/conda-forge/coveralls 39 | .. image:: https://img.shields.io/docker/pulls/thekevjames/coveralls?style=flat-square&label=Dockerhub 40 | :target: https://hub.docker.com/r/thekevjames/coveralls 41 | 42 | `coveralls.io`_ is a service for publishing your coverage stats online. This 43 | package provides seamless integration with `coverage.py`_ (and thus ``pytest``, 44 | ``nosetests``, etc...) in your Python projects:: 45 | 46 | pip install coveralls 47 | coverage run --source=mypkg -m pytest tests/ 48 | coveralls 49 | 50 | For more information and usage instructions, see our `documentation`_. 51 | 52 | Version Compatibility 53 | --------------------- 54 | 55 | As of version 2.0, we have dropped support for end-of-life'd versions of Python 56 | and particularly old versions of coverage. Support for non-EOL'd environments 57 | is provided on a best-effort basis and will generally be removed once they make 58 | maintenance too difficult. 59 | 60 | If you're running on an outdated environment with a new enough package manager 61 | to support version checks (see `the PyPA docs`_), then installing the latest 62 | compatible version should do the trick automatically! If you're even more 63 | outdated than that, please pin to ``coveralls<2``. 64 | 65 | If you're in an outdated environment and experiencing an issue, you're welcome 66 | to open a ticket -- but please mention your environment! I'm willing to 67 | backport fixes to the 1.x branch if the need is great enough. 68 | 69 | .. _Docs: http://coveralls-python.readthedocs.io/en/latest/ 70 | .. _coverage.py: https://coverage.readthedocs.io/en/latest/ 71 | .. _coveralls.io: https://coveralls.io/ 72 | .. _documentation: http://coveralls-python.readthedocs.io/en/latest/ 73 | .. _the PyPA docs: https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires 74 | -------------------------------------------------------------------------------- /coveralls/git.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import subprocess 4 | from typing import Any 5 | 6 | from .exception import CoverallsException 7 | 8 | 9 | log = logging.getLogger('coveralls.git') 10 | 11 | 12 | def run_command(*args: str) -> str: 13 | try: 14 | cmd = subprocess.run( 15 | list(args), 16 | check=True, 17 | capture_output=True, 18 | ) 19 | except subprocess.CalledProcessError as e: 20 | raise CoverallsException( 21 | f'{e}\nSTDOUT: {e.stdout}\nSTDERR: {e.stderr}', 22 | ) from e 23 | 24 | return cmd.stdout.decode('utf-8').strip() 25 | 26 | 27 | def gitlog(fmt: str) -> str: 28 | return run_command( 29 | 'git', '--no-pager', 'log', '-1', f'--pretty=format:{fmt}', 30 | ) 31 | 32 | 33 | def git_branch() -> str | None: 34 | branch = None 35 | if os.environ.get('GITHUB_ACTIONS'): 36 | github_ref = os.environ.get('GITHUB_REF') 37 | if ( 38 | github_ref.startswith('refs/heads/') 39 | or github_ref.startswith('refs/tags/') 40 | ): 41 | # E.g. in push events. 42 | branch = github_ref.split('/', 2)[-1] 43 | else: 44 | # E.g. in pull_request events. 45 | branch = os.environ.get('GITHUB_HEAD_REF') 46 | else: 47 | branch = ( 48 | os.environ.get('APPVEYOR_REPO_BRANCH') 49 | or os.environ.get('BUILDKITE_BRANCH') 50 | or os.environ.get('CI_BRANCH') 51 | or os.environ.get('CIRCLE_BRANCH') 52 | or os.environ.get('GIT_BRANCH') 53 | or os.environ.get('TRAVIS_BRANCH') 54 | or os.environ.get('BRANCH_NAME') 55 | or run_command('git', 'rev-parse', '--abbrev-ref', 'HEAD') 56 | ) 57 | 58 | return branch 59 | 60 | 61 | def git_info() -> dict[str, dict[str, Any]]: 62 | """ 63 | A hash of Git data that can be used to display more information to users. 64 | 65 | Example: 66 | ------- 67 | "git": { 68 | "head": { 69 | "id": "5e837ce92220be64821128a70f6093f836dd2c05", 70 | "author_name": "Wil Gieseler", 71 | "author_email": "wil@example.com", 72 | "committer_name": "Wil Gieseler", 73 | "committer_email": "wil@example.com", 74 | "message": "depend on simplecov >= 0.7" 75 | }, 76 | "branch": "master", 77 | "remotes": [{ 78 | "name": "origin", 79 | "url": "https://github.com/lemurheavy/coveralls-ruby.git" 80 | }] 81 | } 82 | """ 83 | try: 84 | branch = git_branch() 85 | head = { 86 | 'id': gitlog('%H'), 87 | 'author_name': gitlog('%aN'), 88 | 'author_email': gitlog('%ae'), 89 | 'committer_name': gitlog('%cN'), 90 | 'committer_email': gitlog('%ce'), 91 | 'message': gitlog('%s'), 92 | } 93 | remotes = [ 94 | {'name': line.split()[0], 'url': line.split()[1]} 95 | for line in run_command('git', 'remote', '-v').splitlines() 96 | if '(fetch)' in line 97 | ] 98 | except (CoverallsException, OSError) as ex: 99 | # When git is not available, try env vars as per Coveralls docs: 100 | # https://docs.coveralls.io/mercurial-support 101 | # Additionally, these variables have been extended by GIT_URL and 102 | # GIT_REMOTE 103 | branch = os.environ.get('GIT_BRANCH') 104 | head = { 105 | 'id': os.environ.get('GIT_ID'), 106 | 'author_name': os.environ.get('GIT_AUTHOR_NAME'), 107 | 'author_email': os.environ.get('GIT_AUTHOR_EMAIL'), 108 | 'committer_name': os.environ.get('GIT_COMMITTER_NAME'), 109 | 'committer_email': os.environ.get('GIT_COMMITTER_EMAIL'), 110 | 'message': os.environ.get('GIT_MESSAGE'), 111 | } 112 | remotes = [{ 113 | 'name': os.environ.get('GIT_REMOTE'), 114 | 'url': os.environ.get('GIT_URL'), 115 | }] 116 | if not all(head.values()): 117 | log.warning( 118 | 'Failed collecting git data. Are you running coveralls inside ' 119 | 'a git repository? Is git installed?', exc_info=ex, 120 | ) 121 | return {} 122 | 123 | return { 124 | 'git': { 125 | 'branch': branch, 126 | 'head': head, 127 | 'remotes': remotes, 128 | }, 129 | } 130 | -------------------------------------------------------------------------------- /coveralls/reporter.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import logging 3 | import os 4 | 5 | import coverage 6 | from coverage.plugin import FileReporter 7 | from coverage.report import get_analysis_to_report 8 | from coverage.results import Analysis 9 | 10 | from .exception import CoverallsException 11 | 12 | 13 | log = logging.getLogger('coveralls.reporter') 14 | 15 | 16 | class CoverallReporter: 17 | """Custom coverage.py reporter for coveralls.io.""" 18 | 19 | def __init__( 20 | self, 21 | cov: coverage.Coverage, 22 | base_dir: str = '', 23 | src_dir: str = '', 24 | ) -> None: 25 | self.base_dir = self.sanitize_dir(base_dir) 26 | self.src_dir = self.sanitize_dir(src_dir) 27 | 28 | self.coverage = [] 29 | self.report(cov) 30 | 31 | @staticmethod 32 | def sanitize_dir(directory: str) -> str: 33 | if directory: 34 | directory = directory.replace(os.path.sep, '/') 35 | if directory[-1] != '/': 36 | directory += '/' 37 | return directory 38 | 39 | def report(self, cov: coverage.Coverage) -> None: 40 | try: 41 | for (fr, analysis) in get_analysis_to_report(cov, None): 42 | self.parse_file(fr, analysis) 43 | except Exception as e: 44 | # As of coverage v6.2, this is a coverage.exceptions.NoDataError 45 | if str(e) == 'No data to report.': 46 | return 47 | 48 | raise CoverallsException(f'Got coverage library error: {e}') from e 49 | 50 | @staticmethod 51 | def get_hits(line_num: int, analysis: Analysis) -> int | None: 52 | """ 53 | Source file stats for each line. 54 | 55 | * A positive integer if the line is covered, representing the number 56 | of times the line is hit during the test suite. 57 | * 0 if the line is not covered by the test suite. 58 | * null to indicate the line is not relevant to code coverage (it may 59 | be whitespace or a comment). 60 | """ 61 | if line_num in analysis.missing: 62 | return 0 63 | 64 | if line_num not in analysis.statements: 65 | return None 66 | 67 | return 1 68 | 69 | @staticmethod 70 | def get_arcs(analysis: Analysis) -> list[int]: 71 | """ 72 | Hit stats for each branch. 73 | 74 | Returns a flat list where every four values represent a branch: 75 | 1. line-number 76 | 2. block-number (not used) 77 | 3. branch-number 78 | 4. hits (we only get 1/0 from coverage.py) 79 | """ 80 | # pylint: disable=too-complex 81 | has_arcs: bool 82 | try: 83 | has_arcs = analysis.has_arcs() 84 | except TypeError: 85 | # coverage v7.5+ 86 | has_arcs = analysis.has_arcs 87 | 88 | if not has_arcs: 89 | return [] 90 | 91 | missing_arcs: dict[int, list[int]] = analysis.missing_branch_arcs() 92 | try: 93 | # coverage v6.3+ 94 | executed_arcs = analysis.executed_branch_arcs() 95 | except AttributeError: 96 | # COPIED ~VERBATIM 97 | executed = analysis.arcs_executed() 98 | lines = analysis._branch_lines() # pylint: disable=W0212 99 | branch_lines = set(lines) 100 | eba = collections.defaultdict(list) 101 | for l1, l2 in executed: 102 | if l1 in branch_lines: 103 | eba[l1].append(l2) 104 | # END COPY 105 | executed_arcs = eba 106 | 107 | branches: list[int] = [] 108 | for l1, l2s in executed_arcs.items(): 109 | for l2 in l2s: 110 | branches.extend((l1, 0, abs(l2), 1)) 111 | for l1, l2s in missing_arcs.items(): 112 | for l2 in l2s: 113 | branches.extend((l1, 0, abs(l2), 0)) 114 | 115 | return branches 116 | 117 | def parse_file(self, cu: FileReporter, analysis: Analysis) -> None: 118 | """Generate data for single file.""" 119 | filename = cu.relative_filename() 120 | 121 | # ensure results are properly merged between platforms 122 | posix_filename = filename.replace(os.path.sep, '/') 123 | 124 | if self.base_dir and posix_filename.startswith(self.base_dir): 125 | posix_filename = posix_filename[len(self.base_dir):] 126 | posix_filename = self.src_dir + posix_filename 127 | 128 | token_lines = cu.source_token_lines() 129 | coverage_lines = [ 130 | self.get_hits(i, analysis) 131 | for i, _ in enumerate(token_lines, 1) 132 | ] 133 | 134 | results = { 135 | 'name': posix_filename, 136 | 'source': cu.source(), 137 | 'coverage': coverage_lines, 138 | } 139 | 140 | branches = self.get_arcs(analysis) 141 | if branches: 142 | results['branches'] = branches 143 | 144 | self.coverage.append(results) 145 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | docker: talkiq/docker@4.1.2 5 | linter: talkiq/linter@4.0.0 6 | 7 | executors: 8 | docker-git: 9 | docker: 10 | - image: docker:25.0.5-git 11 | resource_class: medium 12 | py310: 13 | docker: 14 | - image: python:3.10-alpine 15 | resource_class: small 16 | py311: 17 | docker: 18 | - image: python:3.11-alpine 19 | resource_class: small 20 | py312: 21 | docker: 22 | - image: python:3.12-alpine 23 | resource_class: small 24 | py313: 25 | docker: 26 | - image: python:3.13-alpine 27 | resource_class: small 28 | py314: 29 | docker: 30 | - image: python:3.14-alpine 31 | resource_class: small 32 | pypy73: # py310 33 | docker: 34 | - image: pypy:3-7.3-slim 35 | resource_class: small 36 | 37 | commands: 38 | # See thekevjames/tools 39 | custom-tag: 40 | parameters: 41 | ident: 42 | type: string 43 | default: "coveralls" 44 | tag: 45 | default: /tmp/custom-tag 46 | type: string 47 | steps: 48 | - run: docker tag "thekevjames/<>:latest" "thekevjames/<>:$(cat <>)" 49 | - run: docker tag "quay.io/thekevjames/<>:latest" "quay.io/thekevjames/<>:$(cat <>)" 50 | - run: docker push "thekevjames/<>:$(cat <>)" 51 | - run: docker push "quay.io/thekevjames/<>:$(cat <>)" 52 | 53 | jobs: 54 | # See thekevjames/tools 55 | docker-publish: 56 | executor: docker-git 57 | parameters: 58 | ident: 59 | type: string 60 | default: "coveralls" 61 | tag: 62 | type: string 63 | steps: 64 | - checkout 65 | - setup_remote_docker 66 | - run: echo "$DOCKER_PASS" | docker login docker.io --username "$DOCKER_USER" --password-stdin 67 | - run: echo "$QUAY_PASS" | docker login quay.io --username "$QUAY_USER" --password-stdin 68 | - docker/build: 69 | local_image_name: "<>:${CIRCLE_SHA1:0:10}" 70 | - run: docker tag "<>:${CIRCLE_SHA1:0:10}" "thekevjames/<>:<>" 71 | - run: docker tag "<>:${CIRCLE_SHA1:0:10}" "thekevjames/<>:latest" 72 | - run: docker tag "<>:${CIRCLE_SHA1:0:10}" "quay.io/thekevjames/<>:<>" 73 | - run: docker tag "<>:${CIRCLE_SHA1:0:10}" "quay.io/thekevjames/<>:latest" 74 | - run: docker push "thekevjames/<>:<>" 75 | - run: docker push "thekevjames/<>:latest" 76 | - run: docker push "quay.io/thekevjames/<>:<>" 77 | - run: docker push "quay.io/thekevjames/<>:latest" 78 | 79 | # See thekevjames/tools 80 | docker-readme-build: 81 | docker: 82 | - image: pandoc/core:3.8.3 83 | steps: 84 | - run: apk add --no-cache --no-progress ca-certificates openssl 85 | - run: mkdir /meta 86 | - checkout 87 | - run: pandoc -o/meta/README.md README.rst 88 | - persist_to_workspace: 89 | root: /meta 90 | paths: 91 | - README.md 92 | 93 | # See thekevjames/tools 94 | # TODO: this should be doable with curl or python... 95 | docker-readme-push: 96 | docker: 97 | - image: node:25.2.1-alpine 98 | parameters: 99 | ident: 100 | type: string 101 | default: "coveralls" 102 | steps: 103 | - run: apk add --no-cache --no-progress ca-certificates openssl 104 | - checkout 105 | - run: npm install docker-hub-api 106 | - attach_workspace: 107 | at: /meta 108 | - run: node ./docker-update-readme.js thekevjames <> /meta/README.md 109 | 110 | toxpy: 111 | executor: <> 112 | parameters: 113 | executor: 114 | type: executor 115 | steps: 116 | - run: apk add --no-cache git 117 | - checkout 118 | - run: pip install --upgrade tox 119 | - run: tox run -f "${CIRCLE_JOB//test-}" 120 | - run: tox run -e upload 121 | 122 | toxpypy: 123 | executor: <> 124 | parameters: 125 | executor: 126 | type: executor 127 | steps: 128 | - run: apt-get update -qy 129 | - run: apt-get install -qy --no-install-recommends git 130 | - checkout 131 | - run: pip install --upgrade tox 132 | - run: tox run -f pypy3 133 | - run: tox run -e upload 134 | 135 | workflows: 136 | docker: 137 | when: 138 | equal: [ master, << pipeline.git.branch >> ] 139 | jobs: 140 | - docker-publish: 141 | tag: "${CIRCLE_SHA1:0:10}" 142 | post-steps: 143 | - run: | 144 | export COVERALLS_VERSION=$(awk -F'=' '/ARG COVERALLS_VERSION=/ {print substr($2, 1, length($2))}' Dockerfile) 145 | echo "${COVERALLS_VERSION}" >/tmp/custom-tag 146 | - custom-tag 147 | - docker-readme-build: 148 | requires: 149 | - docker-publish 150 | - docker-readme-push: 151 | requires: 152 | - docker-readme-build 153 | 154 | test: 155 | jobs: 156 | - linter/pre-commit: 157 | executor: py310 158 | pre-steps: 159 | - run: apk add --no-cache git 160 | - toxpy: 161 | name: test-<> 162 | matrix: 163 | parameters: 164 | executor: [py310, py311, py312, py313, py314] 165 | - toxpypy: 166 | name: test-pypy73 167 | executor: pypy73 168 | -------------------------------------------------------------------------------- /tests/api/wear_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | import unittest 5 | from unittest import mock 6 | 7 | import coverage 8 | import pytest 9 | 10 | import coveralls 11 | from coveralls.api import log 12 | 13 | 14 | EXPECTED = { 15 | 'message': 'Job #7.1 - 44.58% Covered', 16 | 'url': 'https://coveralls.io/jobs/5869', 17 | } 18 | 19 | 20 | @mock.patch('coveralls.api.requests') 21 | class WearTest(unittest.TestCase): 22 | def setUp(self): 23 | try: 24 | os.remove('.coverage') 25 | except Exception: 26 | pass 27 | 28 | def test_wet_run(self, mock_requests): 29 | mock_requests.post.return_value.json.return_value = EXPECTED 30 | 31 | result = coveralls.Coveralls(repo_token='xxx').wear(dry_run=False) 32 | assert result == EXPECTED 33 | 34 | def test_merge(self, _mock_requests): 35 | with tempfile.NamedTemporaryFile() as coverage_file: 36 | coverage_file.write( 37 | b'{"source_files": [{"name": "foobar", "coverage": []}]}', 38 | ) 39 | coverage_file.seek(0) 40 | 41 | api = coveralls.Coveralls(repo_token='xxx') 42 | api.merge(coverage_file.name) 43 | result = api.create_report() 44 | 45 | source_files = json.loads(result)['source_files'] 46 | assert source_files == [{'name': 'foobar', 'coverage': []}] 47 | 48 | def test_merge_empty_data(self, _mock_requests): 49 | with tempfile.NamedTemporaryFile() as coverage_file: 50 | coverage_file.write(b'{}') 51 | coverage_file.seek(0) 52 | 53 | api = coveralls.Coveralls(repo_token='xxx') 54 | api.merge(coverage_file.name) 55 | result = api.create_report() 56 | 57 | source_files = json.loads(result)['source_files'] 58 | assert source_files == [] 59 | 60 | def test_merge_invalid_data(self, _mock_requests): 61 | with tempfile.NamedTemporaryFile() as coverage_file: 62 | coverage_file.write(b'{"random": "stuff"}') 63 | coverage_file.seek(0) 64 | 65 | with mock.patch.object(log, 'warning') as logger: 66 | api = coveralls.Coveralls(repo_token='xxx') 67 | api.merge(coverage_file.name) 68 | result = api.create_report() 69 | 70 | source_files = json.loads(result)['source_files'] 71 | assert source_files == [] 72 | 73 | logger.assert_called_once_with( 74 | 'No data to be merged; does the json file contain ' 75 | '"source_files" data?', 76 | ) 77 | 78 | def test_dry_run(self, mock_requests): 79 | mock_requests.post.return_value.json.return_value = EXPECTED 80 | 81 | result = coveralls.Coveralls(repo_token='xxx').wear(dry_run=True) 82 | assert result == {} 83 | 84 | def test_repo_token_in_not_compromised_verbose(self, mock_requests): 85 | mock_requests.post.return_value.json.return_value = EXPECTED 86 | 87 | with mock.patch.object(log, 'debug') as logger: 88 | coveralls.Coveralls(repo_token='xxx').wear(dry_run=True) 89 | 90 | assert 'xxx' not in logger.call_args[0][0] 91 | 92 | def test_coveralls_unavailable(self, mock_requests): 93 | mock_requests.post.return_value.json.side_effect = ValueError 94 | mock_requests.post.return_value.status_code = 500 95 | mock_requests.post.return_value.text = 'Http 1./1 500' 96 | 97 | with pytest.raises(coveralls.exception.CoverallsException): 98 | coveralls.Coveralls(repo_token='xxx').wear() 99 | 100 | @mock.patch('coveralls.reporter.CoverallReporter.report') 101 | def test_no_coverage(self, report_files, mock_requests): 102 | mock_requests.post.return_value.json.return_value = EXPECTED 103 | report_files.side_effect = coverage.CoverageException( 104 | 'No data to report', 105 | ) 106 | 107 | with pytest.raises(coverage.CoverageException): 108 | coveralls.Coveralls(repo_token='xxx').wear() 109 | 110 | @mock.patch.dict( 111 | os.environ, 112 | { 113 | 'COVERALLS_HOST': 'https://coveralls.my-enterprise.info', 114 | 'COVERALLS_SKIP_SSL_VERIFY': '1', 115 | }, clear=True, 116 | ) 117 | def test_coveralls_host_env_var_overrides_api_url(self, mock_requests): 118 | coveralls.Coveralls(repo_token='xxx').wear(dry_run=False) 119 | mock_requests.post.assert_called_once_with( 120 | 'https://coveralls.my-enterprise.info/api/v1/jobs', 121 | files=mock.ANY, verify=False, 122 | ) 123 | 124 | @mock.patch.dict(os.environ, {}, clear=True) 125 | def test_api_call_uses_default_host_if_no_env_var_set(self, mock_requests): 126 | coveralls.Coveralls(repo_token='xxx').wear(dry_run=False) 127 | mock_requests.post.assert_called_once_with( 128 | 'https://coveralls.io/api/v1/jobs', files=mock.ANY, verify=True, 129 | ) 130 | 131 | @mock.patch.dict(os.environ, {}, clear=True) 132 | def test_submit_report_resubmission(self, mock_requests): 133 | # This would trigger the resubmission condition 134 | mock_requests.post.return_value.status_code = 422 135 | result = coveralls.Coveralls(repo_token='xxx').wear(dry_run=False) 136 | 137 | # A new service_job_id is created 138 | mock_requests.post.return_value.json.return_value = EXPECTED 139 | result = coveralls.Coveralls(repo_token='xxx').wear(dry_run=False) 140 | 141 | assert result == EXPECTED 142 | 143 | @mock.patch.dict( 144 | os.environ, 145 | {'GITHUB_REPOSITORY': 'test/repo'}, 146 | clear=True, 147 | ) 148 | def test_submit_report_resubmission_github(self, mock_requests): 149 | # This would trigger the resubmission condition, for github 150 | mock_requests.post.return_value.status_code = 422 151 | result = coveralls.Coveralls(repo_token='xxx').wear(dry_run=False) 152 | 153 | # A new service_job_id is created, null for github 154 | mock_requests.post.return_value.json.return_value = EXPECTED 155 | result = coveralls.Coveralls(repo_token='xxx').wear(dry_run=False) 156 | 157 | assert result == EXPECTED 158 | -------------------------------------------------------------------------------- /tests/git_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | import tempfile 5 | import unittest 6 | from unittest import mock 7 | 8 | import pytest 9 | 10 | import coveralls.git 11 | from coveralls.exception import CoverallsException 12 | from coveralls.git import run_command 13 | 14 | 15 | GIT_COMMIT_MSG = 'first commit' 16 | GIT_EMAIL = 'me@here.com' 17 | GIT_NAME = 'Daniël' 18 | GIT_REMOTE = 'origin' 19 | GIT_URL = 'https://github.com/username/Hello-World.git' 20 | 21 | 22 | def in_git_dir() -> bool: 23 | try: 24 | run_command('git', 'rev-parse') 25 | return True 26 | except Exception: 27 | return False 28 | 29 | 30 | class GitTest(unittest.TestCase): 31 | @classmethod 32 | def setUpClass(cls): 33 | cls.old_cwd = os.getcwd() 34 | 35 | @classmethod 36 | def tearDownClass(cls): 37 | os.chdir(cls.old_cwd) 38 | 39 | def setUp(self): 40 | self.dir = tempfile.mkdtemp() 41 | os.chdir(self.dir) 42 | 43 | # TODO: switch to pathlib 44 | open('README', 'a').close() # pylint: disable=consider-using-with 45 | 46 | subprocess.call(['git', 'init'], cwd=self.dir) 47 | subprocess.call( 48 | [ 49 | 'git', 'config', 'user.name', 50 | f'"{GIT_NAME}"', 51 | ], cwd=self.dir, 52 | ) 53 | subprocess.call( 54 | [ 55 | 'git', 'config', 'user.email', 56 | f'"{GIT_EMAIL}"', 57 | ], cwd=self.dir, 58 | ) 59 | subprocess.call(['git', 'add', 'README'], cwd=self.dir) 60 | subprocess.call(['git', 'commit', '-m', GIT_COMMIT_MSG], cwd=self.dir) 61 | subprocess.call( 62 | ['git', 'remote', 'add', GIT_REMOTE, GIT_URL], 63 | cwd=self.dir, 64 | ) 65 | 66 | @mock.patch.dict(os.environ, {'TRAVIS_BRANCH': 'master'}, clear=True) 67 | def test_git(self): 68 | git_info = coveralls.git.git_info() 69 | commit_id = git_info['git']['head'].pop('id') 70 | 71 | assert re.match(r'^[a-f0-9]{40}$', commit_id) 72 | assert git_info == { 73 | 'git': { 74 | 'head': { 75 | 'committer_email': GIT_EMAIL, 76 | 'author_email': GIT_EMAIL, 77 | 'author_name': GIT_NAME, 78 | 'message': GIT_COMMIT_MSG, 79 | 'committer_name': GIT_NAME, 80 | }, 81 | 'remotes': [{ 82 | 'url': GIT_URL, 83 | 'name': GIT_REMOTE, 84 | }], 85 | 'branch': 'master', 86 | }, 87 | } 88 | 89 | 90 | class GitLogTest(GitTest): 91 | @pytest.mark.skipif(not in_git_dir(), reason='requires .git directory') 92 | def test_gitlog(self): 93 | git_info = coveralls.git.gitlog('%H') 94 | assert re.match(r'^[a-f0-9]{40}$', git_info) 95 | 96 | assert coveralls.git.gitlog('%aN') == GIT_NAME 97 | assert coveralls.git.gitlog('%ae') == GIT_EMAIL 98 | assert coveralls.git.gitlog('%cN') == GIT_NAME 99 | assert coveralls.git.gitlog('%ce') == GIT_EMAIL 100 | assert coveralls.git.gitlog('%s') == GIT_COMMIT_MSG 101 | 102 | 103 | class GitInfoTest(unittest.TestCase): 104 | @classmethod 105 | def setUpClass(cls): 106 | cls.old_cwd = os.getcwd() 107 | 108 | @classmethod 109 | def tearDownClass(cls): 110 | os.chdir(cls.old_cwd) 111 | 112 | def setUp(self): 113 | self.dir = tempfile.mkdtemp() 114 | os.chdir(self.dir) 115 | 116 | @mock.patch.dict( 117 | os.environ, { 118 | 'GIT_ID': '5e837ce92220be64821128a70f6093f836dd2c05', 119 | 'GIT_BRANCH': 'master', 120 | 'GIT_AUTHOR_NAME': GIT_NAME, 121 | 'GIT_AUTHOR_EMAIL': GIT_EMAIL, 122 | 'GIT_COMMITTER_NAME': GIT_NAME, 123 | 'GIT_COMMITTER_EMAIL': GIT_EMAIL, 124 | 'GIT_MESSAGE': GIT_COMMIT_MSG, 125 | 'GIT_URL': GIT_URL, 126 | 'GIT_REMOTE': GIT_REMOTE, 127 | }, clear=True, 128 | ) 129 | def test_gitinfo_envvars(self): 130 | git_info = coveralls.git.git_info() 131 | commit_id = git_info['git']['head'].pop('id') 132 | assert re.match(r'^[a-f0-9]{40}$', commit_id) 133 | 134 | assert git_info == { 135 | 'git': { 136 | 'head': { 137 | 'committer_email': GIT_EMAIL, 138 | 'author_email': GIT_EMAIL, 139 | 'author_name': GIT_NAME, 140 | 'message': GIT_COMMIT_MSG, 141 | 'committer_name': GIT_NAME, 142 | }, 143 | 'remotes': [{ 144 | 'url': GIT_URL, 145 | 'name': GIT_REMOTE, 146 | }], 147 | 'branch': 'master', 148 | }, 149 | } 150 | 151 | def test_gitinfo_not_a_git_repo(self): 152 | git_info = coveralls.git.git_info() 153 | 154 | self.assertRaises(CoverallsException) 155 | assert not git_info 156 | 157 | 158 | class GitInfoOverridesTest(unittest.TestCase): 159 | @pytest.mark.skipif(not in_git_dir(), reason='requires .git directory') 160 | @mock.patch.dict( 161 | os.environ, { 162 | 'GITHUB_ACTIONS': 'true', 163 | 'GITHUB_REF': 'refs/pull/1234/merge', 164 | 'GITHUB_SHA': 'bb0e00166b28f49db04d6a8b8cb4bddb5afa529f', 165 | 'GITHUB_HEAD_REF': 'fixup-branch', 166 | }, clear=True, 167 | ) 168 | def test_gitinfo_github_pr(self): 169 | git_info = coveralls.git.git_info() 170 | assert git_info['git']['branch'] == 'fixup-branch' 171 | 172 | @pytest.mark.skipif(not in_git_dir(), reason='requires .git directory') 173 | @mock.patch.dict( 174 | os.environ, { 175 | 'GITHUB_ACTIONS': 'true', 176 | 'GITHUB_REF': 'refs/heads/master', 177 | 'GITHUB_SHA': 'bb0e00166b28f49db04d6a8b8cb4bddb5afa529f', 178 | 'GITHUB_HEAD_REF': '', 179 | }, clear=True, 180 | ) 181 | def test_gitinfo_github_branch(self): 182 | git_info = coveralls.git.git_info() 183 | assert git_info['git']['branch'] == 'master' 184 | 185 | @pytest.mark.skipif(not in_git_dir(), reason='requires .git directory') 186 | @mock.patch.dict( 187 | os.environ, { 188 | 'GITHUB_ACTIONS': 'true', 189 | 'GITHUB_REF': 'refs/tags/v1.0', 190 | 'GITHUB_SHA': 'bb0e00166b28f49db04d6a8b8cb4bddb5afa529f', 191 | 'GITHUB_HEAD_REF': '', 192 | }, clear=True, 193 | ) 194 | def test_gitinfo_github_tag(self): 195 | git_info = coveralls.git.git_info() 196 | assert git_info['git']['branch'] == 'v1.0' 197 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_commit_msg: 'refactor(lint): apply automatic lint fixes' 3 | autoupdate_commit_msg: 'chore(deps): bump pre-commit linter versions' 4 | 5 | default_install_hook_types: 6 | - commit-msg 7 | - pre-commit 8 | 9 | default_language_version: 10 | python: python3.10 11 | 12 | repos: 13 | - repo: https://github.com/compilerla/conventional-pre-commit 14 | rev: v3.6.0 15 | hooks: 16 | - id: conventional-pre-commit 17 | - repo: https://github.com/pre-commit/pre-commit-hooks 18 | rev: v4.6.0 19 | hooks: 20 | - id: check-case-conflict 21 | - id: check-executables-have-shebangs 22 | - id: check-json 23 | - id: check-merge-conflict 24 | - id: check-shebang-scripts-are-executable 25 | - id: check-symlinks 26 | - id: check-toml 27 | - id: check-vcs-permalinks 28 | - id: check-xml 29 | - id: check-yaml 30 | args: [--allow-multiple-documents] 31 | - id: detect-private-key 32 | - id: end-of-file-fixer 33 | exclude: example/.* 34 | - id: mixed-line-ending 35 | args: [--fix=lf] 36 | - id: trailing-whitespace 37 | 38 | # python 39 | - id: check-ast 40 | - id: check-builtin-literals 41 | - id: check-docstring-first 42 | - id: debug-statements 43 | - id: double-quote-string-fixer 44 | exclude: nonunicode/.* 45 | - id: name-tests-test 46 | exclude: tests/data/.* 47 | - id: requirements-txt-fixer 48 | - repo: https://github.com/PyCQA/pylint 49 | rev: v4.0.4 50 | hooks: 51 | # TODO: pylint-import-modules support for pylint v3 (or alternative linter) 52 | - id: pylint 53 | args: 54 | - --load-plugins=pylint.extensions.mccabe 55 | - --max-complexity=10 56 | - --max-line-length=79 57 | - --max-module-lines=500 58 | - --max-args=10 59 | - --score=n 60 | # TODO: narrow these down 61 | - -d broad-except 62 | - -d duplicate-code 63 | - -d fixme 64 | - -d import-error 65 | - -d invalid-name 66 | - -d locally-disabled 67 | - -d missing-docstring 68 | - -d too-few-public-methods 69 | - -d try-except-raise 70 | - -d ungrouped-imports # conflicts with reorder-python-imports 71 | - -d wrong-import-order # conflicts with reorder-python-imports 72 | - -d bad-file-encoding # TODO: fix? 73 | - -d disallowed-name # TODO: fix 74 | - -d unspecified-encoding # TODO: reevaluate 75 | - repo: https://github.com/Lucas-C/pre-commit-hooks 76 | rev: v1.5.5 77 | hooks: 78 | - id: remove-crlf 79 | - id: remove-tabs 80 | exclude: 'Makefile' 81 | - repo: https://github.com/asottile/reorder-python-imports 82 | rev: v3.16.0 83 | hooks: 84 | - id: reorder-python-imports 85 | args: [--py310-plus] 86 | exclude: nonunicode/.* 87 | - repo: https://github.com/asottile/pyupgrade 88 | rev: v3.21.2 89 | hooks: 90 | - id: pyupgrade 91 | args: [--py310-plus] 92 | exclude: nonunicode/.* 93 | - repo: https://github.com/asottile/add-trailing-comma 94 | rev: v3.2.0 95 | hooks: 96 | - id: add-trailing-comma 97 | exclude: nonunicode/.* 98 | # TODO: add type hints 99 | # - repo: https://github.com/pre-commit/mirrors-mypy 100 | # rev: v1.9.0 101 | # hooks: 102 | # - id: mypy 103 | # require_serial: True 104 | # language: python 105 | # # N.B. mypy requires an installed version of whatever third-party 106 | # # library it is asked to check against. In practice, that means 107 | # # whenever we see an error telling us to do so, we should add the 108 | # # relevant library to `additional_dependencies`. 109 | # # Note that some libraries keep their type hints in packages named 110 | # # `types-$lib` or `$lib-stubs`. 111 | # additional_dependencies: [] 112 | # args: 113 | # - --show-error-codes 114 | # - --strict 115 | # - --strict-equality 116 | # - --warn-unreachable 117 | - repo: https://github.com/asottile/yesqa 118 | rev: v1.5.0 119 | hooks: 120 | - id: yesqa 121 | language: python 122 | # N.B. keep these in sync with flake8, otherwise yesqa will remove 123 | # required noqa's related to these plugins 124 | additional_dependencies: &flake8deps 125 | - flake8-2020==1.8.1 126 | - flake8-broken-line==1.0.0 127 | - flake8-builtins==2.5.0 128 | - flake8-comprehensions==3.17.0 129 | files: coveralls/.* 130 | # TODO: debug why CoverallsException breaks this 131 | # - repo: https://github.com/guilatrova/tryceratops 132 | # rev: v2.3.2 133 | # hooks: 134 | # - id: tryceratops 135 | # args: 136 | # - --autofix 137 | # - -iTRY003 138 | # - -iTRY100 139 | # - -iTRY101 140 | # - -iTRY301 141 | # files: coveralls/.* 142 | - repo: https://github.com/hhatto/autopep8 143 | rev: v2.3.2 144 | hooks: 145 | - id: autopep8 146 | args: [-a, -i, -p2] 147 | files: coveralls/.* 148 | - repo: https://github.com/PyCQA/pydocstyle 149 | rev: 6.3.0 150 | hooks: 151 | - id: pydocstyle 152 | args: 153 | - --ignore=D1,D203,D205,D212,D400,D401,D404,D407,D412,D413 154 | files: coveralls/.* 155 | - repo: https://github.com/PyCQA/flake8 156 | rev: 7.3.0 157 | hooks: 158 | - id: flake8 159 | language: python 160 | additional_dependencies: *flake8deps 161 | args: 162 | # https://www.flake8rules.com/ 163 | # E501: Line too long. Covered by autopep8. 164 | # E741: Do not use variables named 'I', 'O', or 'l'. 165 | # W503: Line break occurred before a binary operator. Breaks PEP8. 166 | - --ignore=E501,E741,W503 167 | files: coveralls/.* 168 | - repo: local 169 | hooks: 170 | - id: pyproject-use-version-ranges 171 | name: avoid using carets for version ranges 172 | description: 'Avoid using carets for version ranges' 173 | entry: '\^' 174 | language: pygrep 175 | types: [toml] 176 | files: 'pyproject.toml$' 177 | - id: pytest-fixtures-require-scope 178 | name: ensure pytest fixture scopes are explicitly set 179 | description: 'Ensure we explicitly set pytest fixture scopes' 180 | entry: '@pytest\.fixture( |\n|(\(\)))' 181 | language: pygrep 182 | types: [python] 183 | - repo: https://github.com/pre-commit/pygrep-hooks 184 | rev: v1.10.0 185 | hooks: 186 | - id: python-no-eval 187 | - id: python-no-log-warn 188 | - id: python-use-type-annotations 189 | 190 | # rst 191 | - id: rst-backticks 192 | - id: rst-directive-colons 193 | - id: rst-inline-touching-normal 194 | 195 | # json 196 | - repo: https://github.com/python-jsonschema/check-jsonschema 197 | rev: 0.35.0 198 | hooks: 199 | - id: check-github-workflows 200 | - id: check-renovate 201 | language: python 202 | additional_dependencies: 203 | - pyjson5==1.6.9 204 | 205 | # docker 206 | - repo: https://github.com/AleksaC/hadolint-py 207 | rev: v2.12.1-beta 208 | hooks: 209 | - id: hadolint 210 | args: 211 | # unignore 3042 after https://github.com/hadolint/hadolint/issues/497 212 | - --ignore=DL3025 213 | - --ignore=DL3042 214 | -------------------------------------------------------------------------------- /tests/cli_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from unittest import mock 4 | 5 | import pytest 6 | import responses 7 | 8 | import coveralls.cli 9 | from coveralls.exception import CoverallsException 10 | 11 | 12 | EXC = CoverallsException('bad stuff happened') 13 | 14 | 15 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 16 | EXAMPLE_DIR = os.path.join(BASE_DIR, 'example') 17 | 18 | 19 | def req_json(request): 20 | return json.loads(request.body.decode('utf-8')) 21 | 22 | 23 | @mock.patch.dict(os.environ, {'TRAVIS': 'True'}, clear=True) 24 | @mock.patch.object(coveralls.cli.log, 'info') 25 | @mock.patch.object(coveralls.Coveralls, 'wear') 26 | def test_debug(mock_wear, mock_log): 27 | coveralls.cli.main(argv=['debug']) 28 | mock_wear.assert_called_with(dry_run=True) 29 | mock_log.assert_has_calls([mock.call('Testing coveralls-python...')]) 30 | 31 | 32 | @mock.patch.dict(os.environ, clear=True) 33 | @mock.patch.object(coveralls.cli.log, 'info') 34 | @mock.patch.object(coveralls.Coveralls, 'wear') 35 | def test_debug_no_token(mock_wear, mock_log): 36 | coveralls.cli.main(argv=['debug']) 37 | mock_wear.assert_called_with(dry_run=True) 38 | mock_log.assert_has_calls([mock.call('Testing coveralls-python...')]) 39 | 40 | 41 | @mock.patch.dict( 42 | os.environ, 43 | { 44 | 'GITHUB_ACTIONS': 'true', 45 | 'GITHUB_REPOSITORY': 'test/repo', 46 | 'GITHUB_TOKEN': 'xxx', 47 | 'GITHUB_RUN_ID': '123456789', 48 | 'GITHUB_RUN_NUMBER': '123', 49 | }, 50 | clear=True, 51 | ) 52 | @mock.patch.object(coveralls.cli.log, 'info') 53 | @responses.activate 54 | def test_finish(mock_log): 55 | responses.add( 56 | responses.POST, 'https://coveralls.io/webhook', 57 | json={'done': True}, status=200, 58 | ) 59 | expected_json = { 60 | 'repo_token': 'xxx', 61 | 'repo_name': 'test/repo', 62 | 'payload': { 63 | 'status': 'done', 64 | 'build_num': '123456789', 65 | }, 66 | } 67 | 68 | coveralls.cli.main(argv=['--finish']) 69 | 70 | mock_log.assert_has_calls( 71 | [ 72 | mock.call('Finishing parallel jobs...'), 73 | mock.call('Done'), 74 | ], 75 | ) 76 | assert len(responses.calls) == 1 77 | assert req_json(responses.calls[0].request) == expected_json 78 | 79 | 80 | @mock.patch.dict(os.environ, {'TRAVIS': 'True'}, clear=True) 81 | @mock.patch.object(coveralls.cli.log, 'exception') 82 | @responses.activate 83 | def test_finish_exception(mock_log): 84 | responses.add( 85 | responses.POST, 'https://coveralls.io/webhook', 86 | json={'error': 'Mocked'}, status=200, 87 | ) 88 | expected_json = { 89 | 'payload': { 90 | 'status': 'done', 91 | }, 92 | } 93 | msg = 'Parallel finish failed: Mocked' 94 | 95 | with pytest.raises(SystemExit): 96 | coveralls.cli.main(argv=['--finish']) 97 | 98 | mock_log.assert_has_calls([ 99 | mock.call( 100 | 'Error running coveralls: %s', 101 | CoverallsException(msg), 102 | ), 103 | ]) 104 | assert len(responses.calls) == 1 105 | assert req_json(responses.calls[0].request) == expected_json 106 | 107 | 108 | @mock.patch.dict(os.environ, {'TRAVIS': 'True'}, clear=True) 109 | @mock.patch.object(coveralls.cli.log, 'exception') 110 | @responses.activate 111 | def test_finish_exception_without_error(mock_log): 112 | responses.add( 113 | responses.POST, 'https://coveralls.io/webhook', 114 | json={}, status=200, 115 | ) 116 | expected_json = { 117 | 'payload': { 118 | 'status': 'done', 119 | }, 120 | } 121 | msg = 'Parallel finish failed' 122 | 123 | with pytest.raises(SystemExit): 124 | coveralls.cli.main(argv=['--finish']) 125 | 126 | mock_log.assert_has_calls([ 127 | mock.call( 128 | 'Error running coveralls: %s', 129 | CoverallsException(msg), 130 | ), 131 | ]) 132 | assert len(responses.calls) == 1 133 | assert req_json(responses.calls[0].request) == expected_json 134 | 135 | 136 | @mock.patch.object(coveralls.cli.log, 'info') 137 | @mock.patch.object(coveralls.Coveralls, 'wear') 138 | @mock.patch.dict(os.environ, {'TRAVIS': 'True'}, clear=True) 139 | def test_real(mock_wear, mock_log): 140 | coveralls.cli.main(argv=[]) 141 | mock_wear.assert_called_with() 142 | mock_log.assert_has_calls( 143 | [ 144 | mock.call('Submitting coverage to coveralls.io...'), 145 | mock.call('Coverage submitted!'), 146 | ], 147 | ) 148 | 149 | 150 | @mock.patch.dict(os.environ, {'TRAVIS': 'True'}, clear=True) 151 | @mock.patch('coveralls.cli.Coveralls') 152 | def test_rcfile(mock_coveralls): 153 | coveralls.cli.main(argv=['--rcfile=coveragerc']) 154 | mock_coveralls.assert_called_with( 155 | True, config_file='coveragerc', 156 | service_name=None, 157 | base_dir='', 158 | src_dir='', 159 | ) 160 | 161 | 162 | @mock.patch.dict(os.environ, {}, clear=True) 163 | @mock.patch('coveralls.cli.Coveralls') 164 | def test_service_name(mock_coveralls): 165 | coveralls.cli.main(argv=['--service=travis-pro']) 166 | mock_coveralls.assert_called_with( 167 | True, config_file='.coveragerc', 168 | service_name='travis-pro', 169 | base_dir='', 170 | src_dir='', 171 | ) 172 | 173 | 174 | @mock.patch.object(coveralls.cli.log, 'exception') 175 | @mock.patch.object(coveralls.Coveralls, 'wear', side_effect=EXC) 176 | @mock.patch.dict(os.environ, {'TRAVIS': 'True'}, clear=True) 177 | def test_exception(_mock_coveralls, mock_log): 178 | with pytest.raises(SystemExit): 179 | coveralls.cli.main(argv=[]) 180 | 181 | mock_log.assert_has_calls([mock.call('Error running coveralls: %s', EXC)]) 182 | 183 | 184 | @mock.patch.object(coveralls.Coveralls, 'save_report') 185 | @mock.patch.dict(os.environ, {'TRAVIS': 'True'}, clear=True) 186 | def test_save_report_to_file(mock_coveralls): 187 | """Check save_report api usage.""" 188 | coveralls.cli.main(argv=['--output=test.log']) 189 | mock_coveralls.assert_called_with('test.log') 190 | 191 | 192 | @mock.patch.dict(os.environ, clear=True) 193 | @mock.patch.object(coveralls.Coveralls, 'save_report') 194 | def test_save_report_to_file_no_token(mock_coveralls): 195 | """Check save_report api usage when token is not set.""" 196 | coveralls.cli.main(argv=['--output=test.log']) 197 | mock_coveralls.assert_called_with('test.log') 198 | 199 | 200 | @mock.patch.object(coveralls.Coveralls, 'submit_report') 201 | @mock.patch.dict(os.environ, {'TRAVIS': 'True'}, clear=True) 202 | def test_submit(mock_submit): 203 | json_file = os.path.join(EXAMPLE_DIR, 'example.json') 204 | coveralls.cli.main(argv=['--submit=' + json_file]) 205 | with open(json_file) as f: 206 | mock_submit.assert_called_with(f.read()) 207 | 208 | 209 | @mock.patch('coveralls.cli.Coveralls') 210 | def test_base_dir_arg(mock_coveralls): 211 | coveralls.cli.main(argv=['--basedir=foo']) 212 | mock_coveralls.assert_called_with( 213 | True, config_file='.coveragerc', 214 | service_name=None, 215 | base_dir='foo', 216 | src_dir='', 217 | ) 218 | 219 | 220 | @mock.patch('coveralls.cli.Coveralls') 221 | def test_src_dir_arg(mock_coveralls): 222 | coveralls.cli.main(argv=['--srcdir=foo']) 223 | mock_coveralls.assert_called_with( 224 | True, config_file='.coveragerc', 225 | service_name=None, 226 | base_dir='', 227 | src_dir='foo', 228 | ) 229 | -------------------------------------------------------------------------------- /docs/usage/configuration.rst: -------------------------------------------------------------------------------- 1 | .. _configuration: 2 | 3 | Configuration 4 | ============= 5 | 6 | coveralls-python often works without any outside configuration by examining the 7 | environment it is being run in. Special handling has been added for AppVeyor, 8 | BuildKite, CircleCI, Github Actions, Jenkins, and TravisCI to make 9 | coveralls-python as close to "plug and play" as possible. It should be useable 10 | in any other CI system as well, but may need some configuration! 11 | 12 | In cases where you do need to modify the configuration, we obey a very strict 13 | precedence order where the **latest value is used**: 14 | 15 | * first, the CI environment will be loaded 16 | * second, any environment variables will be loaded (eg. those which begin with 17 | ``COVERALLS_`` 18 | * third, the config file is loaded (eg. ``./..coveralls.yml``) 19 | * finally, any command line flags are evaluated 20 | 21 | Most often, you will simply need to run coveralls-python with no additional 22 | options after you have run your coverage suite:: 23 | 24 | coveralls 25 | 26 | If you have placed your ``.coveragerc`` in a non-standard location (ie. other than ``./.coveragerc``), you can run:: 27 | 28 | coveralls --rcfile=/path/to/coveragerc 29 | 30 | If you would like to override the service name (auto-discovered on most CI systems, set to ``coveralls-python`` otherwise):: 31 | 32 | coveralls --service=travis-pro 33 | # or, via env var: 34 | COVERALLS_SERVICE_NAME=travis-pro coveralls 35 | 36 | If you are interested in merging the coverage results between multiple languages/projects, see our :ref:`multi-language ` documentation. 37 | 38 | If coveralls-python is being run on TravisCI or on GitHub Actions, it will automatically set the token for communication with coveralls.io. Otherwise, you should set the environment variable ``COVERALLS_REPO_TOKEN``, which can be found on the dashboard for your project in coveralls.io:: 39 | 40 | COVERALLS_REPO_TOKEN=mV2Jajb8y3c6AFlcVNagHO20fiZNkXPVy coveralls 41 | 42 | If you are running multiple jobs in parallel and want coveralls.io to merge those results, you should set ``COVERALLS_PARALLEL`` to ``true`` in your environment:: 43 | 44 | COVERALLS_PARALLEL=true coveralls 45 | 46 | Later on, you can use ``coveralls --finish`` to let the Coveralls service know you have completed all your parallel runs:: 47 | 48 | coveralls --finish 49 | 50 | If you are using a non-public coveralls.io instance (for example: self-hosted Coveralls Enterprise), you can set ``COVERALLS_HOST`` to the base URL of that insance:: 51 | 52 | COVERALLS_HOST="https://coveralls.aperture.com" coveralls 53 | 54 | In that case, you may also be interested in disabling SSL verification:: 55 | 56 | COVERALLS_SKIP_SSL_VERIFY='1' coveralls 57 | 58 | If you are using named jobs, you can set:: 59 | 60 | COVERALLS_FLAG_NAME="insert-name-here" 61 | 62 | You can also set any of these values in a ``.coveralls.yml`` file in the root of your project repository. If you are planning to use this method, please ensure you install ``coveralls[yaml]`` instead of just the base ``coveralls`` package. 63 | 64 | Sample ``.coveralls.yml`` file:: 65 | 66 | service_name: travis-pro 67 | repo_token: mV2Jajb8y3c6AFlcVNagHO20fiZNkXPVy 68 | parallel: true 69 | coveralls_host: https://coveralls.aperture.com 70 | 71 | Github Actions support 72 | ---------------------- 73 | 74 | Coveralls natively supports jobs running on Github Actions. You can directly 75 | pass the default-provided secret GITHUB_TOKEN:: 76 | 77 | run: coveralls 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | 81 | Passing a coveralls.io token via the ``COVERALLS_REPO_TOKEN`` environment variable 82 | (or via the ``repo_token`` parameter in the config file) is not needed for 83 | Github Actions by default (eg. with the default value of ``--service=github``). 84 | 85 | Github Actions can get a bit finicky as to how coverage is submitted. If you 86 | find yourself getting 422 error responses, you can also try specifying the 87 | ``github-actions`` service name instead. If you do so, you will need to proved 88 | a ``COVERALLS_REPO_TOKEN`` *instead* of a ``GITHUB_TOKEN``:: 89 | 90 | run: coveralls --service=github-actions 91 | env: 92 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 93 | 94 | If you're still having issues after tryingt both of the above, please read through 95 | the following issues for more information: 96 | `#252 `_ and 97 | `coveralls-public#1710 `_. 98 | 99 | For parallel builds, you have to add a final step to let coveralls.io know the 100 | parallel build is finished:: 101 | 102 | jobs: 103 | test: 104 | strategy: 105 | matrix: 106 | test-name: 107 | - test1 108 | - test2 109 | runs-on: ubuntu-latest 110 | steps: 111 | - name: Checkout 112 | uses: actions/checkout@v4 113 | - name: Test 114 | run: ./run_tests.sh ${{ matrix.test-name }} 115 | - name: Upload coverage data to coveralls.io 116 | run: coveralls 117 | env: 118 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 119 | COVERALLS_FLAG_NAME: ${{ matrix.test-name }} 120 | COVERALLS_PARALLEL: true 121 | coveralls: 122 | name: Indicate completion to coveralls.io 123 | needs: test 124 | runs-on: ubuntu-latest 125 | container: python:3-slim 126 | steps: 127 | - name: Install coveralls 128 | run: pip3 install --upgrade coveralls 129 | - name: Finished 130 | run: coveralls --finish 131 | env: 132 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 133 | 134 | The ``COVERALLS_FLAG_NAME`` environment variable (or the ``flag_name`` parameter 135 | in the config file) is optional and can be used to better identify each job 136 | on coveralls.io. It does not need to be unique across the parallel jobs. 137 | 138 | Azure Pipelines support 139 | ----------------------- 140 | 141 | Coveralls does not yet support Azure Pipelines, but you can make things work by 142 | impersonating another CI system such as CircleCI. For example, you can set this 143 | up by using the following script at the end of your test pipeline:: 144 | 145 | - script: | 146 | pip install coveralls 147 | export CIRCLE_BRANCH=$BUILD_SOURCEBRANCH 148 | coveralls 149 | displayName: 'coveralls' 150 | env: 151 | CIRCLECI: 1 152 | CIRCLE_BUILD_NUM: $(Build.BuildNumber) 153 | COVERALLS_REPO_TOKEN: $(coveralls_repo_token) 154 | 155 | Note that you will also need to use the Azure Pipelines web UI to add the 156 | ``coveralls_repo_token`` variable to this pipeline with your repo token (which 157 | you can copy from the coveralls.io website). 158 | 159 | As per `#245 `_, 160 | our users suggest leaving "keep this value secret" unchecked -- this may be 161 | secure enough as-is, in that a user making a PR cannot access this variable. 162 | 163 | Other CI systems 164 | ---------------- 165 | 166 | As specified in the Coveralls `official docs 167 | ` 168 | other CI systems can be supported if the following environment variables are 169 | defined:: 170 | 171 | CI_NAME 172 | # Name of the CI service being used. 173 | CI_BUILD_NUMBER 174 | # The number assigned to the build by your CI service. 175 | CI_BUILD_URL 176 | # URL to a webpage showing the build information/logs. 177 | CI_BRANCH 178 | # For pull requests this is the name of the branch being targeted, 179 | # otherwise it corresponds to the name of the current branch or tag. 180 | CI_JOB_ID (optional) 181 | # For parallel builds, the number assigned to each job comprising the build. 182 | # When missing, Coveralls will assign an incrementing integer (1, 2, 3 ...). 183 | # This value should not change between multiple runs of the build. 184 | CI_PULL_REQUEST (optional) 185 | # If given, corresponds to the number of the pull request, as specified 186 | # in the supported repository hosting service (GitHub, GitLab, etc). 187 | # This variable expects a value defined as an integer, e.g.: 188 | # CI_PULL_REQUEST=42 (recommended) 189 | # However, for flexibility, any single line string ending with the same 190 | # integer value can also be used (such as the pull request URL or 191 | # relative path), e.g.: 192 | # CI_PULL_REQUEST='myuser/myrepo/pull/42' 193 | # CI_PULL_REQUEST='https://github.com/myuser/myrepo/pull/42' 194 | -------------------------------------------------------------------------------- /tests/api/reporter_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import unittest 4 | 5 | import pytest 6 | 7 | from coveralls import Coveralls 8 | from coveralls.exception import CoverallsException 9 | 10 | 11 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) 12 | EXAMPLE_DIR = os.path.join(BASE_DIR, 'example') 13 | 14 | 15 | def assert_coverage(actual, expected): 16 | assert actual['source'].strip() == expected['source'].strip() 17 | assert actual['name'] == expected['name'] 18 | assert actual['coverage'] == expected['coverage'] 19 | assert actual.get('branches') == expected.get('branches') 20 | 21 | 22 | class ReporterTest(unittest.TestCase): 23 | @classmethod 24 | def setUpClass(cls): 25 | cls.old_cwd = os.getcwd() 26 | 27 | @classmethod 28 | def tearDownClass(cls): 29 | os.chdir(cls.old_cwd) 30 | 31 | def setUp(self): 32 | os.chdir(EXAMPLE_DIR) 33 | 34 | try: 35 | os.remove('.coverage') 36 | except Exception: 37 | pass 38 | try: 39 | os.remove('extra.py') 40 | except Exception: 41 | pass 42 | 43 | @staticmethod 44 | def make_test_results(with_branches=False, name_prefix=''): 45 | results = ( 46 | { 47 | 'source': ( 48 | 'def hello():\n' 49 | ' print(\'world\')\n\n\n' 50 | 'class Foo:\n' 51 | ' """ Bar """\n\n\n' 52 | 'def baz():\n' 53 | ' print(\'this is not tested\')\n\n' 54 | 'def branch(cond1, cond2):\n' 55 | ' if cond1:\n' 56 | ' print(\'condition tested both ways\')\n' 57 | ' if cond2:\n' 58 | ' print(\'condition not tested both ways\')\n' 59 | ), 60 | 'name': f'{name_prefix}project.py', 61 | 'coverage': [ 62 | 1, 1, None, None, 1, None, None, 63 | None, 1, 0, None, 1, 1, 1, 1, 1, 64 | ], 65 | }, { 66 | 'source': ( 67 | 'from project import branch\n' 68 | 'from project import hello\n\n' 69 | "if __name__ == '__main__':\n" 70 | ' hello()\n' 71 | ' branch(False, True)\n' 72 | ' branch(True, True)\n' 73 | ), 74 | 'name': f'{name_prefix}runtests.py', 75 | 'coverage': [1, 1, None, 1, 1, 1, 1], 76 | }, 77 | ) 78 | if with_branches: 79 | results[0]['branches'] = [ 80 | 13, 0, 14, 1, 13, 0, 15, 1, 15, 0, 16, 1, 81 | 15, 0, 12, 0, 82 | ] 83 | results[1]['branches'] = [4, 0, 5, 1, 4, 0, 1, 0] 84 | return results 85 | 86 | def test_reporter(self): 87 | subprocess.call( 88 | [ 89 | 'coverage', 'run', '--omit=**/.tox/*', 90 | 'runtests.py', 91 | ], cwd=EXAMPLE_DIR, 92 | ) 93 | results = Coveralls(repo_token='xxx').get_coverage() 94 | assert len(results) == 2 95 | 96 | expected_results = self.make_test_results() 97 | assert_coverage(results[0], expected_results[0]) 98 | assert_coverage(results[1], expected_results[1]) 99 | 100 | def test_reporter_no_base_dir_arg(self): 101 | subprocess.call( 102 | [ 103 | 'coverage', 'run', '--omit=**/.tox/*', 104 | 'example/runtests.py', 105 | ], cwd=BASE_DIR, 106 | ) 107 | 108 | # without base_dir arg, file name is prefixed with 'example/' 109 | os.chdir(BASE_DIR) 110 | results = Coveralls(repo_token='xxx').get_coverage() 111 | assert len(results) == 2 112 | 113 | expected_results = self.make_test_results(name_prefix='example/') 114 | assert_coverage(results[0], expected_results[0]) 115 | assert_coverage(results[1], expected_results[1]) 116 | 117 | def test_reporter_with_base_dir_arg(self): 118 | subprocess.call( 119 | [ 120 | 'coverage', 'run', '--omit=**/.tox/*', 121 | 'example/runtests.py', 122 | ], cwd=BASE_DIR, 123 | ) 124 | 125 | # without base_dir arg, file name is prefixed with 'example/' 126 | os.chdir(BASE_DIR) 127 | results = Coveralls( 128 | repo_token='xxx', 129 | base_dir='example', 130 | ).get_coverage() 131 | assert len(results) == 2 132 | 133 | expected_results = self.make_test_results() 134 | assert_coverage(results[0], expected_results[0]) 135 | assert_coverage(results[1], expected_results[1]) 136 | 137 | def test_reporter_with_base_dir_trailing_sep(self): 138 | subprocess.call( 139 | [ 140 | 'coverage', 'run', '--omit=**/.tox/*', 141 | 'example/runtests.py', 142 | ], cwd=BASE_DIR, 143 | ) 144 | 145 | # without base_dir arg, file name is prefixed with 'example/' 146 | os.chdir(BASE_DIR) 147 | results = Coveralls( 148 | repo_token='xxx', 149 | base_dir='example/', 150 | ).get_coverage() 151 | assert len(results) == 2 152 | 153 | expected_results = self.make_test_results() 154 | assert_coverage(results[0], expected_results[0]) 155 | assert_coverage(results[1], expected_results[1]) 156 | 157 | def test_reporter_with_src_dir_arg(self): 158 | subprocess.call( 159 | [ 160 | 'coverage', 'run', '--omit=**/.tox/*', 161 | 'example/runtests.py', 162 | ], cwd=BASE_DIR, 163 | ) 164 | 165 | # without base_dir arg, file name is prefixed with 'example/' 166 | os.chdir(BASE_DIR) 167 | results = Coveralls( 168 | repo_token='xxx', 169 | src_dir='src', 170 | ).get_coverage() 171 | assert len(results) == 2 172 | 173 | expected_results = self.make_test_results(name_prefix='src/example/') 174 | assert_coverage(results[0], expected_results[0]) 175 | assert_coverage(results[1], expected_results[1]) 176 | 177 | def test_reporter_with_src_dir_trailing_sep(self): 178 | subprocess.call( 179 | [ 180 | 'coverage', 'run', '--omit=**/.tox/*', 181 | 'example/runtests.py', 182 | ], cwd=BASE_DIR, 183 | ) 184 | 185 | # without base_dir arg, file name is prefixed with 'example/' 186 | os.chdir(BASE_DIR) 187 | results = Coveralls( 188 | repo_token='xxx', 189 | src_dir='src/', 190 | ).get_coverage() 191 | assert len(results) == 2 192 | 193 | expected_results = self.make_test_results(name_prefix='src/example/') 194 | assert_coverage(results[0], expected_results[0]) 195 | assert_coverage(results[1], expected_results[1]) 196 | 197 | def test_reporter_with_both_base_dir_and_src_dir_args(self): 198 | subprocess.call( 199 | [ 200 | 'coverage', 'run', '--omit=**/.tox/*', 201 | 'example/runtests.py', 202 | ], cwd=BASE_DIR, 203 | ) 204 | 205 | # without base_dir arg, file name is prefixed with 'example/' 206 | os.chdir(BASE_DIR) 207 | results = Coveralls( 208 | repo_token='xxx', 209 | base_dir='example', 210 | src_dir='src', 211 | ).get_coverage() 212 | assert len(results) == 2 213 | 214 | expected_results = self.make_test_results(name_prefix='src/') 215 | assert_coverage(results[0], expected_results[0]) 216 | assert_coverage(results[1], expected_results[1]) 217 | 218 | def test_reporter_with_branches(self): 219 | subprocess.call( 220 | [ 221 | 'coverage', 'run', '--branch', '--omit=**/.tox/*', 222 | 'runtests.py', 223 | ], cwd=EXAMPLE_DIR, 224 | ) 225 | results = Coveralls(repo_token='xxx').get_coverage() 226 | assert len(results) == 2 227 | 228 | # Branches are expressed as four values each in a flat list 229 | assert not len(results[0]['branches']) % 4 230 | assert not len(results[1]['branches']) % 4 231 | 232 | expected_results = self.make_test_results(with_branches=True) 233 | assert_coverage(results[0], expected_results[0]) 234 | assert_coverage(results[1], expected_results[1]) 235 | 236 | def test_missing_file(self): 237 | with open('extra.py', 'w') as f: 238 | f.write('print("Python rocks!")\n') 239 | subprocess.call( 240 | [ 241 | 'coverage', 'run', '--omit=**/.tox/*', 242 | 'extra.py', 243 | ], cwd=EXAMPLE_DIR, 244 | ) 245 | try: 246 | os.remove('extra.py') 247 | except Exception: 248 | pass 249 | 250 | with pytest.raises(CoverallsException, match='No source for code'): 251 | Coveralls(repo_token='xxx').get_coverage() 252 | 253 | def test_not_python(self): 254 | with open('extra.py', 'w') as f: 255 | f.write('print("Python rocks!")\n') 256 | subprocess.call( 257 | [ 258 | 'coverage', 'run', '--omit=**/.tox/*', 259 | 'extra.py', 260 | ], cwd=EXAMPLE_DIR, 261 | ) 262 | with open('extra.py', 'w') as f: 263 | f.write("

This isn't python!

\n") 264 | 265 | with pytest.raises( 266 | CoverallsException, 267 | match=r"Couldn't parse .* as Python", 268 | ): 269 | Coveralls(repo_token='xxx').get_coverage() 270 | -------------------------------------------------------------------------------- /tests/api/configuration_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import unittest 4 | from unittest import mock 5 | 6 | import pytest 7 | try: 8 | import yaml 9 | except ImportError: 10 | yaml = None 11 | 12 | from coveralls import Coveralls 13 | from coveralls.api import log 14 | 15 | 16 | @mock.patch.object(Coveralls, 'config_filename', '.coveralls.mock') 17 | class Configuration(unittest.TestCase): 18 | @classmethod 19 | def setUpClass(cls): 20 | cls.old_cwd = os.getcwd() 21 | 22 | @classmethod 23 | def tearDownClass(cls): 24 | os.chdir(cls.old_cwd) 25 | 26 | def setUp(self): 27 | self.dir = tempfile.mkdtemp() 28 | os.chdir(self.dir) 29 | with open('.coveralls.mock', 'w+') as fp: 30 | fp.write('repo_token: xxx\n') 31 | fp.write('service_name: jenkins\n') 32 | 33 | @pytest.mark.skipif(yaml is None, reason='requires PyYAML') 34 | @mock.patch.dict(os.environ, {}, clear=True) 35 | def test_local_with_config(self): 36 | cover = Coveralls() 37 | assert cover.config['service_name'] == 'jenkins' 38 | assert cover.config['repo_token'] == 'xxx' 39 | assert 'service_job_id' not in cover.config 40 | 41 | @pytest.mark.skipif(yaml is not None, reason='requires no PyYAML') 42 | @mock.patch.dict(os.environ, {'COVERALLS_REPO_TOKEN': 'xxx'}, clear=True) 43 | def test_local_with_config_without_yaml_module(self): 44 | """test local with config in yaml, but without yaml-installed""" 45 | with mock.patch.object(log, 'warning') as logger: 46 | cover = Coveralls() 47 | 48 | logger.assert_called_once_with( 49 | 'PyYAML is not installed, skipping %s.', cover.config_filename, 50 | ) 51 | 52 | 53 | @mock.patch.object(Coveralls, 'config_filename', '.coveralls.mock') 54 | class NoConfiguration(unittest.TestCase): 55 | @mock.patch.dict( 56 | os.environ, { 57 | 'TRAVIS': 'True', 58 | 'TRAVIS_JOB_ID': '777', 59 | 'COVERALLS_REPO_TOKEN': 'yyy', 60 | }, clear=True, 61 | ) 62 | def test_repo_token_from_env(self): 63 | cover = Coveralls() 64 | assert cover.config['service_name'] == 'travis-ci' 65 | assert cover.config['service_job_id'] == '777' 66 | assert cover.config['repo_token'] == 'yyy' 67 | 68 | @mock.patch.dict(os.environ, {}, clear=True) 69 | def test_misconfigured(self): 70 | with pytest.raises(Exception) as excinfo: 71 | Coveralls() 72 | 73 | assert str(excinfo.value) == ( 74 | 'Not on TravisCI. You have to provide either repo_token in ' 75 | '.coveralls.mock or set the COVERALLS_REPO_TOKEN env var.' 76 | ) 77 | 78 | @mock.patch.dict(os.environ, {'GITHUB_ACTIONS': 'true'}, clear=True) 79 | def test_misconfigured_github(self): 80 | with pytest.raises(Exception) as excinfo: 81 | Coveralls() 82 | 83 | assert str(excinfo.value).startswith( 84 | 'Running on Github Actions but GITHUB_TOKEN is not set.', 85 | ) 86 | 87 | @mock.patch.dict( 88 | os.environ, { 89 | 'APPVEYOR': 'True', 90 | 'APPVEYOR_BUILD_ID': '1234567', 91 | 'APPVEYOR_PULL_REQUEST_NUMBER': '1234', 92 | }, 93 | clear=True, 94 | ) 95 | def test_appveyor_no_config(self): 96 | cover = Coveralls(repo_token='xxx') 97 | assert cover.config['service_name'] == 'appveyor' 98 | assert cover.config['service_job_id'] == '1234567' 99 | assert cover.config['service_pull_request'] == '1234' 100 | 101 | @mock.patch.dict( 102 | os.environ, { 103 | 'BUILDKITE': 'True', 104 | 'BUILDKITE_JOB_ID': '1234567', 105 | 'BUILDKITE_PULL_REQUEST': '1234', 106 | }, 107 | clear=True, 108 | ) 109 | def test_buildkite_no_config(self): 110 | cover = Coveralls(repo_token='xxx') 111 | assert cover.config['service_name'] == 'buildkite' 112 | assert cover.config['service_job_id'] == '1234567' 113 | assert cover.config['service_pull_request'] == '1234' 114 | 115 | @mock.patch.dict( 116 | os.environ, { 117 | 'BUILDKITE': 'True', 118 | 'BUILDKITE_JOB_ID': '1234567', 119 | 'BUILDKITE_PULL_REQUEST': 'false', 120 | }, 121 | clear=True, 122 | ) 123 | def test_buildkite_no_config_no_pr(self): 124 | cover = Coveralls(repo_token='xxx') 125 | assert cover.config['service_name'] == 'buildkite' 126 | assert cover.config['service_job_id'] == '1234567' 127 | assert 'service_pull_request' not in cover.config 128 | 129 | @mock.patch.dict( 130 | os.environ, 131 | { 132 | 'CIRCLECI': 'True', 133 | 'CIRCLE_BUILD_NUM': '888', 134 | 'CI_PULL_REQUEST': 'https://github.com/org/repo/pull/9999', 135 | }, 136 | clear=True, 137 | ) 138 | def test_circleci_singular_no_config(self): 139 | cover = Coveralls(repo_token='xxx') 140 | assert cover.config['service_name'] == 'circleci' 141 | assert cover.config['service_number'] == '888' 142 | assert cover.config['service_pull_request'] == '9999' 143 | 144 | @mock.patch.dict( 145 | os.environ, 146 | { 147 | 'CIRCLECI': 'True', 148 | 'CIRCLE_WORKFLOW_ID': '0ea2c0f7-4e56-4a94-bf77-bfae6bdbf80a', 149 | 'CIRCLE_NODE_INDEX': '15', 150 | }, 151 | clear=True, 152 | ) 153 | def test_circleci_parallel_no_config(self): 154 | cover = Coveralls(repo_token='xxx') 155 | assert cover.config['service_name'] == 'circleci' 156 | assert cover.config['service_number'] == ( 157 | '0ea2c0f7-4e56-4a94-bf77-bfae6bdbf80a' 158 | ) 159 | assert cover.config['service_job_id'] == '15' 160 | 161 | @mock.patch.dict( 162 | os.environ, 163 | { 164 | 'GITHUB_ACTIONS': 'true', 165 | 'GITHUB_REF': 'refs/pull/1234/merge', 166 | 'GITHUB_SHA': 'bb0e00166b28f49db04d6a8b8cb4bddb5afa529f', 167 | 'GITHUB_RUN_ID': '123456789', 168 | 'GITHUB_RUN_NUMBER': '12', 169 | 'GITHUB_HEAD_REF': 'fixup-branch', 170 | 'COVERALLS_REPO_TOKEN': 'xxx', 171 | }, 172 | clear=True, 173 | ) 174 | def test_github_no_config(self): 175 | cover = Coveralls() 176 | assert cover.config['service_name'] == 'github' 177 | assert cover.config['service_pull_request'] == '1234' 178 | assert cover.config['service_number'] == '123456789' 179 | assert cover.config['service_job_id'] == '123456789' 180 | 181 | @mock.patch.dict( 182 | os.environ, 183 | { 184 | 'GITHUB_ACTIONS': 'true', 185 | 'GITHUB_TOKEN': 'xxx', 186 | 'GITHUB_REF': 'refs/heads/master', 187 | 'GITHUB_SHA': 'bb0e00166b28f49db04d6a8b8cb4bddb5afa529f', 188 | 'GITHUB_RUN_ID': '987654321', 189 | 'GITHUB_RUN_NUMBER': '21', 190 | 'GITHUB_HEAD_REF': '', 191 | }, 192 | clear=True, 193 | ) 194 | def test_github_no_config_no_pr(self): 195 | cover = Coveralls() 196 | assert cover.config['service_name'] == 'github' 197 | assert cover.config['service_number'] == '987654321' 198 | assert cover.config['service_job_id'] == '987654321' 199 | assert 'service_pull_request' not in cover.config 200 | 201 | @mock.patch.dict( 202 | os.environ, 203 | { 204 | 'JENKINS_HOME': '/var/lib/jenkins', 205 | 'BUILD_NUMBER': '888', 206 | 'CI_PULL_REQUEST': 'https://github.com/org/repo/pull/9999', 207 | }, 208 | clear=True, 209 | ) 210 | def test_jenkins_no_config(self): 211 | cover = Coveralls(repo_token='xxx') 212 | assert cover.config['service_name'] == 'jenkins' 213 | assert cover.config['service_job_id'] == '888' 214 | assert cover.config['service_pull_request'] == '9999' 215 | 216 | @mock.patch.dict( 217 | os.environ, { 218 | 'TRAVIS': 'True', 219 | 'TRAVIS_JOB_ID': '777', 220 | }, clear=True, 221 | ) 222 | def test_travis_no_config(self): 223 | cover = Coveralls() 224 | assert cover.config['service_name'] == 'travis-ci' 225 | assert cover.config['service_job_id'] == '777' 226 | assert 'repo_token' not in cover.config 227 | 228 | @mock.patch.dict( 229 | os.environ, 230 | { 231 | 'SEMAPHORE': 'True', 232 | 'SEMAPHORE_EXECUTABLE_UUID': '36980c73', 233 | 'SEMAPHORE_JOB_UUID': 'a26d42cf', 234 | 'SEMAPHORE_BRANCH_ID': '9999', 235 | }, 236 | clear=True, 237 | ) 238 | def test_semaphore_classic_no_config(self): 239 | cover = Coveralls(repo_token='xxx') 240 | assert cover.config['service_name'] == 'semaphore-ci' 241 | assert cover.config['service_job_id'] == 'a26d42cf' 242 | assert cover.config['service_number'] == '36980c73' 243 | assert cover.config['service_pull_request'] == '9999' 244 | 245 | @mock.patch.dict( 246 | os.environ, 247 | { 248 | 'SEMAPHORE': 'True', 249 | 'SEMAPHORE_WORKFLOW_ID': 'b86b3adf', 250 | 'SEMAPHORE_JOB_ID': '2b942b49', 251 | 'SEMAPHORE_GIT_PR_NUMBER': '9999', 252 | }, 253 | clear=True, 254 | ) 255 | def test_semaphore_20_no_config(self): 256 | cover = Coveralls(repo_token='xxx') 257 | assert cover.config['service_name'] == 'semaphore-ci' 258 | assert cover.config['service_job_id'] == '2b942b49' 259 | assert cover.config['service_number'] == 'b86b3adf' 260 | assert cover.config['service_pull_request'] == '9999' 261 | 262 | @mock.patch.dict( 263 | os.environ, 264 | { 265 | 'CI_NAME': 'generic-ci', 266 | 'CI_PULL_REQUEST': 'pull/1234', 267 | 'CI_JOB_ID': 'bb0e00166', 268 | 'CI_BUILD_NUMBER': '3', 269 | 'CI_BUILD_URL': 'https://generic-ci.local/build/123456789', 270 | 'CI_BRANCH': 'fixup-branch', 271 | 'COVERALLS_REPO_TOKEN': 'xxx', 272 | }, 273 | clear=True, 274 | ) 275 | def test_generic_no_config(self): 276 | cover = Coveralls() 277 | assert cover.config['service_name'] == 'generic-ci' 278 | assert cover.config['service_job_id'] == 'bb0e00166' 279 | assert cover.config['service_branch'] == 'fixup-branch' 280 | assert cover.config['service_pull_request'] == '1234' 281 | 282 | @mock.patch.dict( 283 | os.environ, 284 | { 285 | 'CI_NAME': 'generic-ci', 286 | 'CI_PULL_REQUEST': '', 287 | 'CI_JOB_ID': 'bb0e00166', 288 | 'CI_BUILD_NUMBER': '3', 289 | 'CI_BUILD_URL': 'https://generic-ci.local/build/123456789', 290 | 'CI_BRANCH': 'fixup-branch', 291 | 'COVERALLS_REPO_TOKEN': 'xxx', 292 | }, 293 | clear=True, 294 | ) 295 | def test_generic_no_config_no_pr(self): 296 | cover = Coveralls() 297 | assert cover.config['service_name'] == 'generic-ci' 298 | assert cover.config['service_job_id'] == 'bb0e00166' 299 | assert cover.config['service_branch'] == 'fixup-branch' 300 | assert 'service_pull_request' not in cover.config 301 | 302 | @mock.patch.dict( 303 | os.environ, 304 | { 305 | 'COVERALLS_HOST': 'aaa', 306 | 'COVERALLS_PARALLEL': 'true', 307 | 'COVERALLS_REPO_TOKEN': 'a1b2c3d4', 308 | 'COVERALLS_SERVICE_NAME': 'bbb', 309 | 'COVERALLS_FLAG_NAME': 'cc', 310 | 'COVERALLS_SERVICE_JOB_NUMBER': '1234', 311 | }, 312 | clear=True, 313 | ) 314 | def test_service_name_from_env(self): 315 | # pylint: disable=protected-access 316 | cover = Coveralls() 317 | assert cover._coveralls_host == 'aaa' 318 | assert cover.config['parallel'] is True 319 | assert cover.config['repo_token'] == 'a1b2c3d4' 320 | assert cover.config['service_name'] == 'bbb' 321 | assert cover.config['flag_name'] == 'cc' 322 | assert cover.config['service_job_number'] == '1234' 323 | 324 | 325 | @mock.patch.object(Coveralls, 'config_filename', '.coveralls.mock') 326 | class CLIConfiguration(unittest.TestCase): 327 | def test_load_config(self): 328 | # pylint: disable=protected-access 329 | cover = Coveralls( 330 | repo_token='yyy', 331 | service_name='coveralls-aaa', 332 | coveralls_host='https://coveralls.aaa.com', 333 | ) 334 | assert cover.config['repo_token'] == 'yyy' 335 | assert cover.config['service_name'] == 'coveralls-aaa' 336 | assert cover._coveralls_host == 'https://coveralls.aaa.com' 337 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## 4.0.2 (2025-11-07) 3 | 4 | #### Internal 5 | 6 | * update python support: drop EOL'd versions (3.8, 3.9), begin testing on new versions (3.13, 3.14), and mark explicit future compatibility up to <4.0 7 | 8 | 9 | ## 4.0.1 (2024-05-15) 10 | 11 | #### Internal 12 | 13 | * support ``coverage`` v7.5+ (#442) ([f41dca5](f41dca5)) 14 | * skip tests which require running in a git repo when ``.git`` is missing (#443) ([b566fc3](b566fc3)) 15 | 16 | 17 | ## 4.0.0 (2024-04-29) 18 | 19 | #### BREAKING CHANGES 20 | 21 | When ``config.ignore_errors`` is Falsey, failures to parse Python files or 22 | look up file sources will now interrupt and early exit collection, which 23 | matches default ``coverage`` behaviour. Previously, we had manually muted 24 | these errors and/or only errored after collecting multiple failures. 25 | 26 | [See the coverage.py docs](https://coverage.readthedocs.io/en/7.5.1/config.html#report-ignore-errors) for setting this option. 27 | 28 | #### Features 29 | 30 | * support ``pyproject.toml`` packages by default (via ``coverage[toml]``) ([962e2242](962e2242)) 31 | * add ``python-coveralls`` entrypoint ([3d8d56e4](3d8d56e4)) 32 | 33 | #### Bug Fixes 34 | 35 | * fixup default support for Github Actions (#427, #385) ([44e95634](44e95634)) -- thanks @andy-maier 36 | * fail and report on *all* errors, not just those derived from ``CoverallsException`` ([be446287](be446287)) 37 | 38 | #### Internal 39 | 40 | * support ``coverage`` v7.0 - v7.4 ([8fb36645](8fb36645)) 41 | * support Python 3.11 and 3.12 ([8dbce919](8dbce919)) 42 | * fixup docs for tox v3 and v4 support (#371) ([05bb20d8](05bb20d8)) -- thanks @masonf 43 | * drop support for Python3.7 and below 44 | * drop support for ``coverage`` v4.x ([752f52a0](752f52a0)) 45 | * auto-build and publish ``docker`` images 46 | * refactor: more closely match ``coverage`` public interface (#421) 47 | 48 | 49 | ## 3.3.1 (2021-11-11) 50 | 51 | #### Bug Fixes 52 | 53 | * correctly support parallel execution on CircleCI (#336) ([2610885a](2610885a)) 54 | 55 | #### Internal 56 | 57 | * exclude a few incompatible `coverage` versions (#337) 58 | 59 | `coverage` versions v6.0.0 through v6.1.1 exhibited some incompatibilies with 60 | `coveralls`; we've updated our version compatibility ranges to exclude those 61 | versions. 62 | 63 | 64 | ## 3.3.0 (2021-11-04) 65 | 66 | #### Features 67 | 68 | * **cli:** add --srcdir option (#306) ([4120c540](4120c540)) 69 | * **deps:** add support for coverage v6.x (#330) ([372443dc](372443dc), closes [#326](326)) 70 | 71 | Note this implicitly improves support for Python 3.10, as coverage v6.x includes some fixes for v3.10 of Python. 72 | 73 | #### Bug Fixes 74 | 75 | * **env:** fixup handling of default env service values (#314) ([1a0fd9b3](1a0fd9b3), closes [#303](303)) 76 | 77 | This solves some edge cases around duplicated / unmerged coverage results in parallel runs. 78 | 79 | 80 | ## 3.2.0 (2021-07-20) 81 | 82 | #### Features 83 | 84 | * **api:** support officially documented generic CI env vars (#300) ([ca1c6a47](ca1c6a47)) 85 | 86 | 87 | ## 3.1.0 (2021-05-24) 88 | 89 | #### Features 90 | 91 | * **cli**: add `--basedir` and `--submit` options (#287) ([165a5cd1](165a5cd1)) 92 | * **github:** push coverage info from tags (#284) ([0a49bd28](0a49bd28)) 93 | 94 | 95 | ## 3.0.1 (2021-03-02) 96 | 97 | #### Bug Fixes 98 | 99 | * **github:** send null job_id to fix 422 during resubmission (#269) ([54be7545](54be7545)) 100 | 101 | 102 | ## 3.0.0 (2021-01-12) 103 | 104 | #### Features (BREAKING) 105 | 106 | * **config:** reorder configuration precedence (#249) ([f4faa92d](f4faa92d)) 107 | 108 | We have *reversed* the order in which configurations are parsed. This means we 109 | are now following the following precedence (latest configured value is used): 110 | 111 | 1. CI Config 112 | 2. COVERALLS_* env vars 113 | 3. .coveralls.yml file 114 | 4. CLI flags 115 | 116 | If you have the same fields set in multiple of the above locations, please 117 | double-check them before upgrading to v3. 118 | 119 | The motivation for this change is allowing users to selectively fix values 120 | which may be automatically set to the wrong value. For example, Github Actions 121 | users may find that Github Actions expects you to use a different "service name" 122 | in various different cases. Now you can run, for example: 123 | 124 | coveralls --service=github 125 | 126 | In places where you need to override the default (which is `github-actions`). 127 | 128 | #### Bug Fixes 129 | 130 | * **github:** send null job_id to fix 422 ([05b66aa0](05b66aa0)) 131 | * **api:** fixup retries for services without job IDs ([6ebdc5e2](6ebdc5e2)) 132 | 133 | 134 | ## 2.2.0 (2020-11-20) 135 | 136 | #### Features 137 | 138 | * **api:** add workaround allowing job resubmission (#241) ([0de0c019](0de0c019)) 139 | 140 | #### Bug Fixes 141 | 142 | * **integrations:** fixup environment detection for Semaphore CI (#236) ([ad4f8fa8](ad4f8fa8)) 143 | 144 | 145 | ## 2.1.2 (2020-08-12) 146 | 147 | #### Features 148 | 149 | * **circleci:** support parallel builds (#233) ([5e05654c](5e05654c)) 150 | Note: this is partially a fix for the `--finish` command 151 | introduced in v2.1.0, which did not seem to work for some CircleCI 152 | users. 153 | 154 | 155 | 156 | ## 2.1.1 (2020-07-08) 157 | 158 | #### Bug Fixes 159 | 160 | * fix unhashable CoverallsException (#230) ([aa55335d](aa55335d)) 161 | This fixes a regression introduced in v2.1.0 which affected (at least) any 162 | Python 3.5 installations. 163 | 164 | 165 | 166 | ## 2.1.0 (2020-07-07) 167 | 168 | #### Features 169 | 170 | * **cli**: add new `--finish` flag for finalizing parallel builds (#277) ([f597109b](f597109b)) 171 | 172 | #### Bug Fixes 173 | 174 | * **github:** fix Github Actions support (#227) ([f597109b](f597109b)) 175 | 176 | 177 | ## 2.0.0 (2020-04-07) 178 | 179 | #### Compatiblity (BREAKING CHANGES) 180 | 181 | * We have now dropped support for End-Of-Life'd versions of Python and 182 | particularly old versions of the `coverage` library; if you are still using 183 | Python v2.7 or v3.4, or you are using `coverage<4.1`, this library will no 184 | longer be compatible starting from this release -- please pin to 185 | `coveralls<2.0.0`. 186 | 187 | 188 | ## 1.11.1 (2020-02-15) 189 | 190 | #### Bug Fixes 191 | 192 | * **github:** rename to github-actions ([9e65a059](9e65a059)) 193 | This fixes a regression introduced with v1.11.0, which may have prevented 194 | usage of this library on Github Actions. 195 | 196 | 197 | ## 1.11.0 (2020-02-12) 198 | 199 | #### Fixes 200 | 201 | * **github:** add `service_number` for github actions ([9f93bd8e](9f93bd8e)) 202 | This should fix support for parallel builds. 203 | 204 | #### Compatibility 205 | 206 | * Python 2.7 and 3.4 are now officially End-Of-Life'd. Consider them deprecated 207 | from the perspective of this package -- we'll remove them in an upcoming 208 | release (likely the first one which requires non-trivial work to continue 209 | supporting them!). 210 | 211 | 212 | ## 1.10.0 (2019-12-31) 213 | 214 | #### Features 215 | 216 | * support coverage>=5.0 (#214) ([4a917402](4a917402)) 217 | 218 | 219 | ## 1.9.2 (2019-12-03) 220 | 221 | #### Bug Fixes 222 | 223 | * **github:** fixup incorrect API usage (#209) ([c338cab4](c338cab4)) 224 | 225 | 226 | ## 1.9.1 (2019-12-03) 227 | 228 | #### Compatibility 229 | 230 | * this release marks Python 3.8 as officially supported. Earlier versions probably 231 | supported Python 3.8 too, but now we're *sure*. 232 | 233 | 234 | ## 1.9.0 (2019-12-03) 235 | 236 | #### Features 237 | 238 | * **support:** support Github Actions CI (#207) ([817119c3](817119c3)) 239 | 240 | #### Bug Fixes 241 | 242 | * **compatibility:** fixup coverage.__version__ comparisons (#208) ([03a57a9a](03a57a9a)) 243 | 244 | 245 | ## 1.8.2 (2019-07-29) 246 | 247 | ### Internal 248 | 249 | * **dependencies**: update pass urllib3<1.25 pin, now that that's fixed. 250 | 251 | 252 | ## 1.8.1 (2019-06-16) 253 | 254 | #### Bug Fixes 255 | 256 | * **dependencies:** pin `coverage` to `< 5.0`, since the current `5.0` alphas are 257 | introducing breaking changes. Once `5.0` is stable, we'll 258 | remove the pin. 259 | 260 | 261 | ## 1.8.0 (2019-06-02) 262 | 263 | #### Features 264 | 265 | * **flag:** allow disabling SSL verification ([2e3b5c61](2e3b5c61)) 266 | 267 | #### Bug Fixes 268 | 269 | * **git:** fix support for case where git binary is missing ([5bbceaae](5bbceaae)) 270 | 271 | 272 | ## 1.7.0 (2019-03-20) 273 | 274 | #### Features 275 | 276 | * **api:** support pull requests on buildkite (#197) ([2700e3e2](2700e3e2)) 277 | 278 | #### Bug Fixes 279 | 280 | * **cli:** ensure upload failures trigger cli failures ([16192b84](16192b84)) 281 | 282 | 283 | ## 1.6.0 (2019-02-18) 284 | 285 | #### Features 286 | 287 | * **support:** add support for SemaphoreCI (#193) ([4e09918a](4e09918a)) 288 | 289 | 290 | ## 1.5.1 (2018-09-28) 291 | 292 | #### Features 293 | * **git:** omit git info when git isn't installed (#187) ([764956ea](764956ea)) 294 | * ... instead of erroring. The fixes the v1.4.0 release of "supporting 295 | non-git repos" when the git binary is not installed. 296 | * Note that commit info can still be set with env vars, even in non-git 297 | repositories -- see the docs for more info! 298 | 299 | #### Compatibility 300 | * **python:** include python 3.7 in matrix tests ([023d474](023d474)) 301 | * previous versions of `coveralls-python` should be compatible with Python 3.7, no 302 | code changes were required to make tests pass 303 | 304 | #### Internal 305 | * remove `pytest-runner` as a dependency (#185) ([4cbbfcd](4cbbfcd)) 306 | 307 | 308 | ## 1.5.0 (2018-08-31) 309 | 310 | #### Features 311 | * **cli:** allow execution as a module (#184) ([b261a853](b261a853), closes [#183](183)) 312 | 313 | #### Bug Fixes 314 | * **paths:** ensure windows paths are normalized to posix ([661e0f54](661e0f54), closes [#153](153)) 315 | 316 | 317 | ## 1.4.0 (2018-08-24) 318 | 319 | #### Performance 320 | * **git:** call fallback git commands in fallback cases only ([e42095b4](e42095b4)) 321 | 322 | #### Features 323 | * **env:** support git env vars (#182) ([a1918e89](a1918e89)) 324 | * This change also adds support for non-git repos. 325 | * **flags:** add ability to add named job (#181) ([f7ba07bf](f7ba07bf)) 326 | 327 | #### Compatibility 328 | * **python:** drop support for Python 3.3 ([dcb06fc1](dcb06fc1)) 329 | 330 | 331 | ## 1.3.0 (2018-03-02) 332 | 333 | #### Features 334 | * **ci:** add Travis PR support (#162) ([baf683ee](baf683ee)) 335 | * **cli:** allow `service_name` override from cli flag or env var (#167) ([e8a98904](e8a98904)) 336 | * **coveralls-enterprise:** add support for coveralls enterprise (#166) ([7383f377](7383f377)) 337 | * **git:** silently omit git data when git is unavailable (#176) ([f9db83cd](f9db83cd)) 338 | * **jenkins:** 339 | * add logic to parse `CI_PULL_REQUEST` env variable (#171) ([34a037f5](34a037f5)) 340 | * add support for jenkins (#160) ([4e8cd9ec](4e8cd9ec)) 341 | 342 | 343 | ### 1.2.0 (2017-08-15) 344 | 345 | #### Features 346 | * **support:** add support for AppVeyor CI ([1a62ce27](1a62ce27)) 347 | * **support:** add support for BuildKite CI ([a58d6f9e](a58d6f9e)) 348 | * **support:** add support for branch coverage ([e2413e38](e2413e38)) 349 | * **support:** add support for parallel builds in Coveralls CI ([7ba3a589](7ba3a589)) 350 | 351 | #### Bug Fixes 352 | * fix coverage count in cases of partial branch coverage ([b9ab7037](b9ab7037)) 353 | * fix SNI validation errors in python2 ([c5541263](c5541263)) 354 | * warn when PyYAML is missing ([711e9e4c](711e9e4c)) 355 | 356 | 357 | ### 1.1 (2015-10-04) 358 | 359 | #### Features 360 | * support for Circle CI 361 | 362 | 363 | ### 1.0 (2015-09-17) 364 | 365 | #### Features 366 | * official coverage 4.0 support 367 | 368 | 369 | ### 1.0 (2015-08-14) 370 | 371 | #### Features 372 | * coverage 4 beta support 373 | * codeship experimetal support (`CI_BRANCH` env variable) 374 | * drop python 3.2 support (as coverage 4 does not support it) 375 | * repo token usage is deprecated (but still supported) in favor of env variable. 376 | * error reporting is improved, exist status codes added 377 | 378 | 379 | ### 1.0a2 (2015-02-19) 380 | 381 | #### Features 382 | * fix latest alpha coverage.py support 383 | * remove erroneous warning message when writing output to a file 384 | 385 | 386 | ### 1.0a1 (2015-02-19) 387 | 388 | #### Features 389 | * **Backwards Incompatible**: make pyyaml optional. If you're using .coveralls.yml, make sure to install `coveralls[yaml]` 390 | * coverage 4 alpha support 391 | * allow debug and output options to work without `repo_token` 392 | * fix merge command for python 3.X 393 | 394 | 395 | ### 0.5 (2014-12-10) 396 | 397 | #### Features 398 | * add option --output= for saving json to file for possible merging with coverages from other languages 399 | * add merge command for sending coverage stats from multiple languages 400 | 401 | 402 | ### 0.4.4 (2014-09-28) 403 | 404 | #### Features 405 | * proper fix coverage.py dependency version 406 | 407 | 408 | ### 0.4.3 (2014-09-28) 409 | 410 | #### Features 411 | * fix coverage.py dependency version 412 | 413 | 414 | ### 0.4.2 (2014-05-05) 415 | 416 | #### Features 417 | * handle 503 errors from coveralls.io 418 | 419 | 420 | ### 0.4.1 (2014-01-15) 421 | 422 | #### Features 423 | * fix gitlog output with utf8 424 | 425 | 426 | ### 0.4 (2013-12-27) 427 | 428 | #### Features 429 | * added support for --rcfile= option to cli 430 | * improved docs: nosetests and troubleshooting sections added 431 | * added debug in case of UnicodeDecodeError 432 | * removed sh dependency in favor of Windows compatibility 433 | 434 | 435 | ### 0.3 (2013-10-02) 436 | 437 | #### Features 438 | * added initial support for Circle CI 439 | * fixed Unicode not defined error in python 3 440 | 441 | 442 | ### 0.2 (2013-05-26) 443 | 444 | #### Features 445 | * Python 3.2 and PyPy support 446 | * graceful handling of coverage exceptions 447 | * fixed UnicodeDecodeError in json encoding 448 | * improved readme 449 | 450 | 451 | ### 0.1.1 (2013-02-13) 452 | 453 | #### Features 454 | * introduced `COVERALLS_REPO_TOKEN` environment variable as a fallback for Travis 455 | * removed `repo_token` from verbose output for security reasons 456 | 457 | 458 | ### 0.1 (2013-02-12) 459 | 460 | #### Features 461 | * initial release 462 | -------------------------------------------------------------------------------- /coveralls/api.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import json 3 | import logging 4 | import os 5 | import re 6 | 7 | import coverage 8 | import requests 9 | 10 | from .exception import CoverallsException 11 | from .git import git_info 12 | from .reporter import CoverallReporter 13 | 14 | 15 | log = logging.getLogger('coveralls.api') 16 | 17 | NUMBER_REGEX = re.compile(r'(\d+)$', re.IGNORECASE) 18 | 19 | 20 | class Coveralls: 21 | # pylint: disable=too-many-public-methods 22 | config_filename = '.coveralls.yml' 23 | 24 | def __init__(self, token_required=True, service_name=None, **kwargs): 25 | """ 26 | Initialize the main Coveralls collection entrypoint. 27 | 28 | * repo_token 29 | The secret token for your repository, found at the bottom of your 30 | repository's page on Coveralls. 31 | 32 | * service_name 33 | The CI service or other environment in which the test suite was run. 34 | This can be anything, but certain services have special features 35 | (travis-ci, travis-pro, or coveralls-ruby). 36 | 37 | * [service_job_id] 38 | A unique identifier of the job on the service specified by 39 | service_name. 40 | """ 41 | self._data = None 42 | self._coveralls_host = 'https://coveralls.io/' 43 | self._token_required = token_required 44 | self.config = {} 45 | 46 | self.load_config(kwargs, service_name) 47 | self.ensure_token() 48 | 49 | def ensure_token(self): 50 | if self.config.get('repo_token') or not self._token_required: 51 | return 52 | 53 | if os.environ.get('GITHUB_ACTIONS'): 54 | raise CoverallsException( 55 | 'Running on Github Actions but GITHUB_TOKEN is not set. Add ' 56 | '"env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}" to your ' 57 | 'step config.', 58 | ) 59 | 60 | raise CoverallsException( 61 | 'Not on TravisCI. You have to provide either repo_token in ' 62 | f'{self.config_filename} or set the COVERALLS_REPO_TOKEN env var.', 63 | ) 64 | 65 | def load_config(self, kwargs, service_name): 66 | """ 67 | Loads all coveralls configuration in the following precedence order. 68 | 69 | 1. automatic CI configuration 70 | 2. COVERALLS_* env vars 71 | 3. .coveralls.yml config file 72 | 4. CLI flags 73 | """ 74 | self.load_config_from_ci_environment() 75 | self.load_config_from_environment() 76 | self.load_config_from_file() 77 | self.config.update(kwargs) 78 | if self.config.get('coveralls_host'): 79 | # N.B. users can set --coveralls-host via CLI, but we don't keep 80 | # that in the config 81 | self._coveralls_host = self.config.pop('coveralls_host') 82 | if service_name: 83 | self.config['service_name'] = service_name 84 | 85 | @staticmethod 86 | def load_config_from_appveyor(): 87 | pr = os.environ.get('APPVEYOR_PULL_REQUEST_NUMBER') 88 | return 'appveyor', os.environ.get('APPVEYOR_BUILD_ID'), None, pr 89 | 90 | @staticmethod 91 | def load_config_from_buildkite(): 92 | pr = os.environ.get('BUILDKITE_PULL_REQUEST') 93 | if pr == 'false': 94 | pr = None 95 | return 'buildkite', os.environ.get('BUILDKITE_JOB_ID'), None, pr 96 | 97 | @staticmethod 98 | def load_config_from_circle(): 99 | number = ( 100 | os.environ.get('CIRCLE_WORKFLOW_ID') 101 | or os.environ.get('CIRCLE_BUILD_NUM') 102 | ) 103 | pr = (os.environ.get('CI_PULL_REQUEST') or '').split('/')[-1] or None 104 | job = os.environ.get('CIRCLE_NODE_INDEX') 105 | return 'circleci', job, number, pr 106 | 107 | def load_config_from_github(self): 108 | # See https://github.com/lemurheavy/coveralls-public/issues/1710 109 | 110 | # Github tokens and standard Coveralls tokens are almost but not quite 111 | # the same -- forceibly using Github's flow seems to be more stable 112 | self.config['repo_token'] = os.environ.get('GITHUB_TOKEN') 113 | 114 | pr = None 115 | if os.environ.get('GITHUB_REF', '').startswith('refs/pull/'): 116 | pr = os.environ.get('GITHUB_REF', '//').split('/')[2] 117 | 118 | # TODO: coveralls suggests using the RUN_ID for both these values: 119 | # https://github.com/lemurheavy/coveralls-public/issues/1710#issuecomment-1539203555 120 | # However, they also suggest following this successful config approach: 121 | # https://github.com/lemurheavy/coveralls-public/issues/1710#issuecomment-1913696022 122 | # which instead sets: 123 | # COVERALLS_SERVICE_JOB_ID: $GITHUB_RUN_ID 124 | # COVERALLS_SERVICE_NUMBER: $GITHUB_WORKFLOW-$GITHUB_RUN_NUMBER 125 | # should we do the same? 126 | job = os.environ.get('GITHUB_RUN_ID') 127 | number = os.environ.get('GITHUB_RUN_ID') 128 | 129 | # N.B. per Coveralls: 130 | # > When you want to identify the repo at Coveralls by its 131 | # > GITHUB_TOKEN, you should choose github, and when you want to 132 | # > identify it by its Coveralls Repo Token, you should choose 133 | # > github-action. 134 | # https://github.com/lemurheavy/coveralls-public/issues/1710#issuecomment-1539203555 135 | return 'github', job, number, pr 136 | 137 | @staticmethod 138 | def load_config_from_jenkins(): 139 | pr = os.environ.get('CI_PULL_REQUEST', '').split('/')[-1] or None 140 | return 'jenkins', os.environ.get('BUILD_NUMBER'), None, pr 141 | 142 | @staticmethod 143 | def load_config_from_travis(): 144 | pr = os.environ.get('TRAVIS_PULL_REQUEST') 145 | return 'travis-ci', os.environ.get('TRAVIS_JOB_ID'), None, pr 146 | 147 | @staticmethod 148 | def load_config_from_semaphore(): 149 | job = ( 150 | os.environ.get('SEMAPHORE_JOB_UUID') # Classic 151 | or os.environ.get('SEMAPHORE_JOB_ID') # 2.0 152 | ) 153 | number = ( 154 | os.environ.get('SEMAPHORE_EXECUTABLE_UUID') # Classic 155 | or os.environ.get('SEMAPHORE_WORKFLOW_ID') # 2.0 156 | ) 157 | pr = ( 158 | os.environ.get('SEMAPHORE_BRANCH_ID') # Classic 159 | or os.environ.get('SEMAPHORE_GIT_PR_NUMBER') # 2.0 160 | ) 161 | return 'semaphore-ci', job, number, pr 162 | 163 | @staticmethod 164 | def load_config_from_unknown(): 165 | return 'coveralls-python', None, None, None 166 | 167 | def load_config_from_generic_ci_environment(self): 168 | # Inspired by the official client: 169 | # coveralls-ruby in lib/coveralls/configuration.rb 170 | # (set_standard_service_params_for_generic_ci) 171 | 172 | # The meaning of each env var is clarified in: 173 | # https://github.com/lemurheavy/coveralls-public/issues/1558 174 | 175 | config = { 176 | 'service_name': os.environ.get('CI_NAME'), 177 | 'service_number': os.environ.get('CI_BUILD_NUMBER'), 178 | 'service_build_url': os.environ.get('CI_BUILD_URL'), 179 | 'service_job_id': os.environ.get('CI_JOB_ID'), 180 | 'service_branch': os.environ.get('CI_BRANCH'), 181 | } 182 | 183 | pr_match = NUMBER_REGEX.findall(os.environ.get('CI_PULL_REQUEST', '')) 184 | if pr_match: 185 | config['service_pull_request'] = pr_match[-1] 186 | 187 | non_empty = {key: value for key, value in config.items() if value} 188 | self.config.update(non_empty) 189 | 190 | def load_config_from_ci_environment(self): 191 | # pylint: disable=too-complex 192 | # As defined at the bottom of 193 | # https://docs.coveralls.io/supported-ci-services 194 | # there are a few env vars that should support any arbitrary CI. 195 | # We load them first and allow more specific vars to overwrite 196 | self.load_config_from_generic_ci_environment() 197 | 198 | if os.environ.get('APPVEYOR'): 199 | name, job, number, pr = self.load_config_from_appveyor() 200 | elif os.environ.get('BUILDKITE'): 201 | name, job, number, pr = self.load_config_from_buildkite() 202 | elif os.environ.get('CIRCLECI'): 203 | name, job, number, pr = self.load_config_from_circle() 204 | elif os.environ.get('GITHUB_ACTIONS'): 205 | name, job, number, pr = self.load_config_from_github() 206 | elif os.environ.get('JENKINS_HOME'): 207 | name, job, number, pr = self.load_config_from_jenkins() 208 | elif os.environ.get('TRAVIS'): 209 | self._token_required = False 210 | name, job, number, pr = self.load_config_from_travis() 211 | elif os.environ.get('SEMAPHORE'): 212 | name, job, number, pr = self.load_config_from_semaphore() 213 | else: 214 | name, job, number, pr = self.load_config_from_unknown() 215 | 216 | self.config.setdefault('service_name', name) 217 | if job: 218 | self.config['service_job_id'] = job 219 | if number: 220 | self.config['service_number'] = number 221 | if pr: 222 | self.config['service_pull_request'] = pr 223 | 224 | def load_config_from_environment(self): 225 | coveralls_host = os.environ.get('COVERALLS_HOST') 226 | if coveralls_host: 227 | self._coveralls_host = coveralls_host 228 | 229 | parallel = os.environ.get('COVERALLS_PARALLEL', '').lower() == 'true' 230 | if parallel: 231 | self.config['parallel'] = parallel 232 | 233 | fields = { 234 | 'COVERALLS_FLAG_NAME': 'flag_name', 235 | 'COVERALLS_REPO_TOKEN': 'repo_token', 236 | 'COVERALLS_SERVICE_JOB_ID': 'service_job_id', 237 | 'COVERALLS_SERVICE_JOB_NUMBER': 'service_job_number', 238 | 'COVERALLS_SERVICE_NAME': 'service_name', 239 | 'COVERALLS_SERVICE_NUMBER': 'service_number', 240 | } 241 | for var, key in fields.items(): 242 | value = os.environ.get(var) 243 | if value: 244 | self.config[key] = value 245 | 246 | def load_config_from_file(self): 247 | try: 248 | fname = os.path.join(os.getcwd(), self.config_filename) 249 | 250 | with open(fname) as config: 251 | try: 252 | import yaml # pylint: disable=import-outside-toplevel 253 | self.config.update(yaml.safe_load(config)) 254 | except ImportError: 255 | log.warning( 256 | 'PyYAML is not installed, skipping %s.', 257 | self.config_filename, 258 | ) 259 | except OSError: 260 | log.debug( 261 | 'Missing %s file. Using only env variables.', 262 | self.config_filename, 263 | ) 264 | 265 | def merge(self, path): 266 | reader = codecs.getreader('utf-8') 267 | with open(path, 'rb') as fh: 268 | extra = json.load(reader(fh)) 269 | self.create_data(extra) 270 | 271 | def wear(self, dry_run=False): 272 | json_string = self.create_report() 273 | if dry_run: 274 | return {} 275 | return self.submit_report(json_string) 276 | 277 | def submit_report(self, json_string): 278 | endpoint = f'{self._coveralls_host.rstrip("/")}/api/v1/jobs' 279 | verify = not bool(os.environ.get('COVERALLS_SKIP_SSL_VERIFY')) 280 | response = requests.post( 281 | endpoint, files={'json_file': json_string}, verify=verify, 282 | ) 283 | 284 | if response.status_code == 422: 285 | if self.config['service_name'].startswith('github'): 286 | print( 287 | 'Received 422 submitting job via Github Actions. By ' 288 | 'default, coveralls-python uses the "github" service ' 289 | 'name, which requires you to set the $GITHUB_TOKEN ' 290 | 'environment variable. If you want to use a ' 291 | 'COVERALLS_REPO_TOKEN instead, please manually override ' 292 | '$COVERALLS_SERVICE_NAME to "github-actions". For more ' 293 | 'info, see https://coveralls-python.readthedocs.io/en' 294 | '/latest/usage/configuration.html#github-actions-support', 295 | ) 296 | 297 | try: 298 | response.raise_for_status() 299 | data = response.json() 300 | except Exception as e: 301 | raise CoverallsException( 302 | f'Could not submit coverage: {e}', 303 | ) from e 304 | 305 | return data 306 | 307 | # https://docs.coveralls.io/parallel-build-webhook 308 | def parallel_finish(self): 309 | payload = {'payload': {'status': 'done'}} 310 | 311 | # required args 312 | if self.config.get('repo_token'): 313 | payload['repo_token'] = self.config['repo_token'] 314 | if self.config.get('service_number'): 315 | payload['payload']['build_num'] = self.config['service_number'] 316 | 317 | # service-specific parameters 318 | if os.environ.get('GITHUB_REPOSITORY'): 319 | # Github Actions only 320 | payload['repo_name'] = os.environ.get('GITHUB_REPOSITORY') 321 | 322 | endpoint = f'{self._coveralls_host.rstrip("/")}/webhook' 323 | verify = not bool(os.environ.get('COVERALLS_SKIP_SSL_VERIFY')) 324 | response = requests.post(endpoint, json=payload, verify=verify) 325 | try: 326 | response.raise_for_status() 327 | response = response.json() 328 | except Exception as e: 329 | raise CoverallsException( 330 | f'Parallel finish failed: {e}', 331 | ) from e 332 | 333 | if 'error' in response: 334 | e = response['error'] 335 | raise CoverallsException(f'Parallel finish failed: {e}') 336 | 337 | if 'done' not in response or not response['done']: 338 | raise CoverallsException('Parallel finish failed') 339 | 340 | return response 341 | 342 | def create_report(self): 343 | """Generate json dumped report for coveralls api.""" 344 | data = self.create_data() 345 | try: 346 | json_string = json.dumps(data) 347 | except UnicodeDecodeError: 348 | log.exception('ERROR: While preparing JSON:') 349 | self.debug_bad_encoding(data) 350 | raise 351 | 352 | log_string = re.sub( 353 | r'"repo_token": "(.+?)"', 354 | '"repo_token": "[secure]"', 355 | json_string, 356 | ) 357 | log.debug(log_string) 358 | log.debug('==\nReporting %s files\n==\n', len(data['source_files'])) 359 | for source_file in data['source_files']: 360 | log.debug( 361 | '%s - %d/%d', source_file['name'], 362 | sum(filter(None, source_file['coverage'])), 363 | len(source_file['coverage']), 364 | ) 365 | return json_string 366 | 367 | def save_report(self, file_path): 368 | """Write coveralls report to file.""" 369 | try: 370 | report = self.create_report() 371 | except coverage.CoverageException: 372 | log.exception('Failure to gather coverage:') 373 | else: 374 | with open(file_path, 'w') as report_file: 375 | report_file.write(report) 376 | 377 | def create_data(self, extra=None): 378 | r""" 379 | Generate object for api. 380 | 381 | Example json: 382 | { 383 | "service_job_id": "1234567890", 384 | "service_name": "travis-ci", 385 | "source_files": [ 386 | { 387 | "name": "example.py", 388 | "source": "def four\n 4\nend", 389 | "coverage": [null, 1, null] 390 | }, 391 | { 392 | "name": "two.py", 393 | "source": "def seven\n eight\n nine\nend", 394 | "coverage": [null, 1, 0, null] 395 | } 396 | ], 397 | "parallel": True 398 | } 399 | """ 400 | if self._data: 401 | return self._data 402 | 403 | self._data = {'source_files': self.get_coverage()} 404 | self._data.update(git_info()) 405 | self._data.update(self.config) 406 | if extra: 407 | if 'source_files' in extra: 408 | self._data['source_files'].extend(extra['source_files']) 409 | else: 410 | log.warning( 411 | 'No data to be merged; does the json file contain ' 412 | '"source_files" data?', 413 | ) 414 | 415 | return self._data 416 | 417 | def get_coverage(self): 418 | config_file = self.config.get('config_file', True) 419 | work = coverage.coverage(config_file=config_file) 420 | work.load() 421 | work.get_data() 422 | 423 | base_dir = self.config.get('base_dir') or '' 424 | src_dir = self.config.get('src_dir') or '' 425 | return CoverallReporter(work, base_dir, src_dir).coverage 426 | 427 | @staticmethod 428 | def debug_bad_encoding(data): 429 | """Let's try to help user figure out what is at fault.""" 430 | at_fault_files = set() 431 | for source_file_data in data['source_files']: 432 | for value in source_file_data.values(): 433 | try: 434 | json.dumps(value) 435 | except UnicodeDecodeError: 436 | at_fault_files.add(source_file_data['name']) 437 | 438 | if at_fault_files: 439 | log.error( 440 | 'HINT: Following files cannot be decoded properly into ' 441 | 'unicode. Check their content: %s', 442 | ', '.join(at_fault_files), 443 | ) 444 | --------------------------------------------------------------------------------