Jaypore CI

> Jaypore CI: Minimal, Offline, Local CI system.
Log | Files | Refs | README

commit 01a83ac43e6257d518058ce88948bba8a08feb82
parent 8d52f98134c3069733081828f707d3f95c32d2e6
Author: arjoonn <arjoonn@noreply.localhost>
Date:   Sat, 25 Feb 2023 09:56:41 +0000

Add hypothesis testing (!52)

Branch auto created by JayporeCI

```jayporeci
╔ 🟢 : JayporeCI       [sha 711fb2bf5c]
┏━ build-and-test
┃
┃ 🟢 : JciEnv          [80e9d511]   0:30
┃ 🟢 : Jci             [0e052498]   0: 9            ❮-- ['JciEnv']
┃ 🟢 : black           [91d10e29]   0: 0            ❮-- ['JciEnv']
┃ 🟢 : pylint          [55d481ff]   0: 8            ❮-- ['JciEnv']
┃ 🟢 : pytest          [3ef1624a]   0:20 Cov: 91%   ❮-- ['JciEnv']
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
┏━ Publish
┃
┃ 🟢 : DockerHubJci    [45ffcc42]   0:49
┃ 🟢 : DockerHubJcienv [c87a863d]   1:33
┃ 🟢 : PublishDocs     [8436f6e5]   0:28
┃ 🟢 : PublishPypi     [02e7e325]   0: 6 v0.2.20
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
```

Co-authored-by: arjoonn sharma <arjoonn.94@gmail.com>
Reviewed-on: https://gitea.midpathsoftware.com/midpath/jaypore_ci/pulls/52

Diffstat:
Mcicd/run_tests.sh | 2+-
Mdocs/source/index.rst | 2+-
Ajaypore_ci/clean.py | 9+++++++++
Mjaypore_ci/executors/docker.py | 6++----
Mjaypore_ci/jci.py | 11++++++-----
Mpyproject.toml | 2+-
Mtests/conftest.py | 83++++++++++++++++++++++++++-----------------------------------------------------
Mtests/requests_mock.py | 55++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/subprocess_mock.py | 17++++++++++++-----
Atests/test_hypo_jci.py | 11+++++++++++
Mtests/test_jaypore_ci.py | 10++++++++++
11 files changed, 134 insertions(+), 74 deletions(-)

diff --git a/cicd/run_tests.sh b/cicd/run_tests.sh @@ -6,7 +6,7 @@ set -o pipefail main() { - python -m coverage run --branch --source=. -m pytest -xl --full-trace -vv + python -m coverage run --branch --source=. -m pytest -l --full-trace -vvvv --hypothesis-verbosity=verbose coverage html coverage report echo "Cov: $(coverage report --format=total)%" > "/jaypore_ci/run/pytest.txt" diff --git a/docs/source/index.rst b/docs/source/index.rst @@ -392,5 +392,5 @@ Reference .. |logo| image:: _static/logo80.png :width: 80 - :alt: Jaypore CI logo + :alt: Jaypore CI :align: middle diff --git a/jaypore_ci/clean.py b/jaypore_ci/clean.py @@ -0,0 +1,9 @@ +allowed_alphabet = "abcdefghijklmnopqrstuvwxyz1234567890" +allowed_alphabet += allowed_alphabet.upper() + + +def name(given): + """ + Clean a given name so that it can be used inside of JCI. + """ + return "".join(l if l in allowed_alphabet else "-" for l in given) diff --git a/jaypore_ci/executors/docker.py b/jaypore_ci/executors/docker.py @@ -5,6 +5,7 @@ import pendulum import docker from rich import print as rprint +from jaypore_ci import clean from jaypore_ci.interfaces import Executor, TriggerFailed, JobStatus from jaypore_ci.logging import logger @@ -111,10 +112,7 @@ class Docker(Executor): """ Generates a clean job name slug. """ - name = "".join( - l if l in "abcdefghijklmnopqrstuvwxyz1234567890" else "-" - for l in job.name.lower() - ) + name = clean.name(job.name) if tail: return name return f"jayporeci__job__{self.pipe_id}__{name}" diff --git a/jaypore_ci/jci.py b/jaypore_ci/jci.py @@ -12,7 +12,7 @@ from contextlib import contextmanager import structlog import pendulum -from jaypore_ci import remotes, executors, reporters, repos +from jaypore_ci import remotes, executors, reporters, repos, clean from jaypore_ci.interfaces import ( Remote, Executor, @@ -126,10 +126,7 @@ class Job: # pylint: disable=too-many-instance-attributes report = self.pipeline.reporter.render(self.pipeline) with open("/jaypore_ci/run/jaypore_ci.status.txt", "w", encoding="utf-8") as fl: fl.write(report) - try: - self.pipeline.remote.publish(report, status) - except Exception as e: # pylint: disable=broad-except - self.logging().exception(e) + self.pipeline.remote.publish(report, status) return report def trigger(self): @@ -337,6 +334,8 @@ class Pipeline: # pylint: disable=too-many-instance-attributes """ depends_on = [] if depends_on is None else depends_on depends_on = [depends_on] if isinstance(depends_on, str) else depends_on + name = clean.name(name) + assert name, "Name should have some value after it is cleaned" assert name not in self.jobs, f"{name} already defined" assert name not in self.stages, "Stage name cannot match a job's name" kwargs, job_kwargs = dict(self.pipe_kwargs), kwargs @@ -434,6 +433,8 @@ class Pipeline: # pylint: disable=too-many-instance-attributes Any kwargs passed to this stage are supplied to jobs created within this stage. """ + name = clean.name(name) + assert name, "Name should have some value after it is cleaned" assert name not in self.jobs, "Stage name cannot match a job's name" assert name not in self.stages, "Stage names cannot be re-used" self.stages.append(name) diff --git a/pyproject.toml b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jaypore_ci" -version = "0.2.19" +version = "0.2.20" description = "" authors = ["arjoonn sharma <arjoonn.94@gmail.com>"] homepage = "https://www.jayporeci.in/" diff --git a/tests/conftest.py b/tests/conftest.py @@ -1,58 +1,14 @@ import os -import json import unittest import pytest import tests.subprocess_mock # pylint: disable=unused-import import tests.docker_mock # pylint: disable=unused-import -from tests.requests_mock import Mock +from tests.requests_mock import add_gitea_mocks, add_github_mocks, Mock from jaypore_ci import jci, executors, remotes, reporters, repos -def add_gitea_mocks(gitea): - ISSUE_ID = 1 - # --- create PR - create_pr_url = f"{gitea.api}/repos/{gitea.owner}/{gitea.repo}/pulls" - Mock.post(create_pr_url, body="", status=201) - Mock.post(create_pr_url, body="issue_id:{ISSUE_ID}", status=409) - # --- get existing body - Mock.get( - f"{gitea.api}/repos/{gitea.owner}/{gitea.repo}/pulls/{ISSUE_ID}", - body=json.dumps({"body": "Previous body in PR description."}), - content_type="application/json", - ) - # --- update body - Mock.patch(f"{gitea.api}/repos/{gitea.owner}/{gitea.repo}/pulls/{ISSUE_ID}") - # --- set commit status - Mock.post(f"{gitea.api}/repos/{gitea.owner}/{gitea.repo}/statuses/{gitea.sha}") - Mock.gitea_added = True - - -def add_github_mocks(github): - ISSUE_ID = 1 - # --- create PR - create_pr_url = f"{github.api}/repos/{github.owner}/{github.repo}/pulls" - Mock.post(create_pr_url, body="", status=404) - Mock.get( - create_pr_url, - body=json.dumps([{"number": ISSUE_ID}]), - content_type="application/json", - ) - Mock.post(create_pr_url, body="issue_id:{ISSUE_ID}", status=409) - # --- get existing body - Mock.get( - f"{github.api}/repos/{github.owner}/{github.repo}/pulls/{ISSUE_ID}", - body=json.dumps({"body": "Already existing body in PR description."}), - content_type="application/json", - ) - # --- update body - Mock.patch(f"{github.api}/repos/{github.owner}/{github.repo}/pulls/{ISSUE_ID}") - # --- set commit status - Mock.post(f"{github.api}/repos/{github.owner}/{github.repo}/statuses/{github.sha}") - Mock.github_added = True - - def idfn(x): name = [] for _, item in sorted(x.items()): @@ -61,6 +17,22 @@ def idfn(x): return str(name) +def factory(*, repo, remote, executor, reporter): + "Return a new pipeline every time the builder function is called" + + def build(): + r = repo.from_env() + return jci.Pipeline( + poll_interval=0, + repo=r, + remote=remote.from_env(repo=r), + executor=executor(), + reporter=reporter(), + ) + + return build + + @pytest.fixture( scope="function", params=list( @@ -85,19 +57,18 @@ def pipeline(request): os.environ["JAYPORE_EMAIL_ADDR"] = "fake@email.com" os.environ["JAYPORE_EMAIL_PASSWORD"] = "fake_email_password" os.environ["JAYPORE_EMAIL_TO"] = "fake.to@mymailmail.com" - kwargs = {} - kwargs["repo"] = request.param["repo"].from_env() - # --- remote - kwargs["remote"] = request.param["remote"].from_env(repo=kwargs["repo"]) + builder = factory( + repo=request.param["repo"], + remote=request.param["remote"], + executor=request.param["executor"], + reporter=request.param["reporter"], + ) if request.param["remote"] == remotes.Gitea and not Mock.gitea_added: - add_gitea_mocks(kwargs["remote"]) + add_gitea_mocks(builder().remote) if request.param["remote"] == remotes.Github and not Mock.github_added: - add_github_mocks(kwargs["remote"]) - kwargs["executor"] = request.param["executor"]() - kwargs["reporter"] = request.param["reporter"]() - p = jci.Pipeline(poll_interval=0, **kwargs) + add_github_mocks(builder().remote) if request.param["remote"] == remotes.Email: with unittest.mock.patch("smtplib.SMTP_SSL", autospec=True): - yield p + yield builder else: - yield p + yield builder diff --git a/tests/requests_mock.py b/tests/requests_mock.py @@ -1,3 +1,5 @@ +import json + from typing import NamedTuple from collections import defaultdict @@ -9,6 +11,13 @@ class MockResponse(NamedTuple): body: str content_type: str + def json(self): + return json.loads(self.body) + + @property + def text(self): + return self.body + class Mock: registry = defaultdict(list) @@ -38,13 +47,57 @@ class Mock: def handle(cls, method): def handler(url, **_): options = cls.registry[method, url] - resp = options[cls.index[method, url]] + index = cls.index[method, url] + resp = options[index] cls.index[method, url] = (cls.index[method, url] + 1) % len(options) return resp return handler +def add_gitea_mocks(gitea): + ISSUE_ID = 1 + # --- create PR + create_pr_url = f"{gitea.api}/repos/{gitea.owner}/{gitea.repo}/pulls" + Mock.post(create_pr_url, body="", status=201) + Mock.post(create_pr_url, body=f"issue_id:{ISSUE_ID}", status=409) + # --- get existing body + Mock.get( + f"{gitea.api}/repos/{gitea.owner}/{gitea.repo}/pulls/{ISSUE_ID}", + body=json.dumps({"body": "Previous body in PR description."}), + content_type="application/json", + ) + # --- update body + Mock.patch(f"{gitea.api}/repos/{gitea.owner}/{gitea.repo}/pulls/{ISSUE_ID}") + # --- set commit status + Mock.post(f"{gitea.api}/repos/{gitea.owner}/{gitea.repo}/statuses/{gitea.sha}") + Mock.gitea_added = True + + +def add_github_mocks(github): + ISSUE_ID = 1 + # --- create PR + create_pr_url = f"{github.api}/repos/{github.owner}/{github.repo}/pulls" + Mock.post(create_pr_url, body="", status=404) + Mock.get( + create_pr_url, + body=json.dumps([{"number": ISSUE_ID}]), + content_type="application/json", + ) + Mock.post(create_pr_url, body=f"issue_id:{ISSUE_ID}", status=409) + # --- get existing body + Mock.get( + f"{github.api}/repos/{github.owner}/{github.repo}/pulls/{ISSUE_ID}", + body=json.dumps({"body": "Already existing body in PR description."}), + content_type="application/json", + ) + # --- update body + Mock.patch(f"{github.api}/repos/{github.owner}/{github.repo}/pulls/{ISSUE_ID}") + # --- set commit status + Mock.post(f"{github.api}/repos/{github.owner}/{github.repo}/statuses/{github.sha}") + Mock.github_added = True + + requests.get = Mock.handle("get") requests.post = Mock.handle("post") requests.patch = Mock.handle("patch") diff --git a/tests/subprocess_mock.py b/tests/subprocess_mock.py @@ -12,6 +12,13 @@ def sha(): return hex(random.getrandbits(128)) +__rev_parse__ = sha() +__hash_object__ = sha() +__mktree = sha() +__commit_tree = sha() +__update_ref__ = sha() + + def check_output(cmd, **_): text = "" # repos.git @@ -22,7 +29,7 @@ def check_output(cmd, **_): elif "git branch" in cmd and "grep" in cmd: text = "subprocess_mock_fake_branch" elif "rev-parse HEAD" in cmd: - text = sha() + text = __rev_parse__ elif "git log -1" in cmd: text = "some_fake_git_commit_message\nfrom_subprocess_mock" # jci @@ -30,13 +37,13 @@ def check_output(cmd, **_): text = "fake_pipe_id_from_subprocess_mock" # remotes.git elif "git hash-object" in cmd: - text = sha() + text = __hash_object__ elif "git mktree" in cmd: - text = sha() + text = __mktree elif "git commit-tree" in cmd: - text = sha() + text = __commit_tree elif "git update-ref" in cmd: - text = sha() + text = __update_ref__ return text.encode() diff --git a/tests/test_hypo_jci.py b/tests/test_hypo_jci.py @@ -0,0 +1,11 @@ +from hypothesis import given, strategies as st, settings, HealthCheck + +from jaypore_ci.clean import allowed_alphabet + + +@given(st.text(alphabet=allowed_alphabet, min_size=1)) +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=500) +def test_hypo_jobs(pipeline, name): + pipeline = pipeline() + with pipeline as p: + p.job(name, name) diff --git a/tests/test_jaypore_ci.py b/tests/test_jaypore_ci.py @@ -6,6 +6,7 @@ def test_sanity(): def test_simple_linear_jobs(pipeline): + pipeline = pipeline() with pipeline as p: p.job("lint", "x") p.job("test", "x", depends_on=["lint"]) @@ -14,6 +15,7 @@ def test_simple_linear_jobs(pipeline): def test_no_duplicate_names(pipeline): + pipeline = pipeline() with pytest.raises(AssertionError): with pipeline as p: p.job("lint", "x") @@ -21,6 +23,7 @@ def test_no_duplicate_names(pipeline): def test_dependency_has_to_be_defined_before_child(pipeline): + pipeline = pipeline() with pytest.raises(AssertionError): with pipeline as p: p.job("x", "x", depends_on=["y"]) @@ -28,6 +31,7 @@ def test_dependency_has_to_be_defined_before_child(pipeline): def test_dependency_cannot_cross_stages(pipeline): + pipeline = pipeline() with pytest.raises(AssertionError): with pipeline as p: with p.stage("stage1"): @@ -37,6 +41,7 @@ def test_dependency_cannot_cross_stages(pipeline): def test_duplicate_stages_not_allowed(pipeline): + pipeline = pipeline() with pytest.raises(AssertionError): with pipeline as p: with p.stage("stage1"): @@ -46,6 +51,7 @@ def test_duplicate_stages_not_allowed(pipeline): def test_stage_and_job_cannot_have_same_name(pipeline): + pipeline = pipeline() with pytest.raises(AssertionError): with pipeline as p: with p.stage("x"): @@ -53,6 +59,7 @@ def test_stage_and_job_cannot_have_same_name(pipeline): def test_cannot_define_duplicate_jobs(pipeline): + pipeline = pipeline() with pytest.raises(AssertionError): with pipeline as p: p.job("x", "x") @@ -60,12 +67,14 @@ def test_cannot_define_duplicate_jobs(pipeline): def test_non_service_jobs_must_have_commands(pipeline): + pipeline = pipeline() with pytest.raises(AssertionError): with pipeline as p: p.job("x", None) def test_call_chain_is_followed(pipeline): + pipeline = pipeline() with pipeline as p: for name in "pq": p.job(name, name) @@ -80,6 +89,7 @@ def test_call_chain_is_followed(pipeline): def test_env_matrix_is_easy_to_make(pipeline): + pipeline = pipeline() with pipeline as p: for i, env in enumerate(p.env_matrix(A=[1, 2, 3], B=[5, 6, 7])): p.job(f"job{i}", "fake command", env=env)