diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 73a39b0..e6590f5 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -1,11 +1,13 @@ --- -name: Codecov Coverage Report +name: "Codecov Coverage Report" -on: # yamllint disable-line rule:truthy - push: - branches: [ main ] - pull_request: - branches: [ main ] +on: # yamllint disable-line rule:truthy + # Makes it reusable; called by other workflows + workflow_call: + secrets: + CODECOV_TOKEN: + description: "CODECOV Auth Token" + required: true jobs: run: @@ -15,27 +17,28 @@ jobs: timeout-minutes: 10 strategy: matrix: - os: [ubuntu-latest] + os: ["ubuntu-latest"] python-version: ["3.12"] steps: - - uses: actions/checkout@main - - name: Setup Python - uses: actions/setup-python@main + - uses: "actions/checkout@main" + - name: "Setup Python" + uses: "actions/setup-python@main" with: python-version: ${{ matrix.python-version }} - name: "Cache PIP Dependencies" uses: "actions/cache@v4" with: path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} + key: | + ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} restore-keys: | ${{ runner.os }}-pip-${{ matrix.python-version }}- ${{ runner.os }}-pip- - - name: Install prerequisites + - name: "Install prerequisites" run: | python -m pip install --upgrade pip setuptools wheel pip install -r requirements.txt - - name: Install pytest, pytest-cov + - name: "Install pytest, pytest-cov" run: | pip install pytest pip install pytest-cov @@ -44,12 +47,12 @@ jobs: run: | python -c "import os; print(os.getcwd())" python -m pytest . --ignore=solutions --log-cli-level=INFO -v --cov-report term-missing --cov --cov-branch --cov-report=xml - - name: List test files + - name: "List test files" run: | ls *.xml # yamllint enable rule:line-length - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5.5.1 + - name: "Upload coverage to Codecov" + uses: "codecov/codecov-action@v5.5.1" with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index bd102b3..176cefb 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -1,19 +1,9 @@ --- -name: "Flake8" +name: "Flake8 Workflow" -on: # yamllint disable-line rule:truthy - push: - branches: [ main ] - paths: - - '**/*.py' - - 'requirements.txt' - - '.pylintrc' - pull_request: - branches: [ main ] - paths: - - '**/*.py' - - 'requirements.txt' - - '.pylintrc' +on: # yamllint disable-line rule:truthy + # Makes it reusable; called by other workflows + workflow_call: permissions: contents: "read" @@ -33,21 +23,23 @@ jobs: - name: Set up Python ${{ matrix.python-version }} # This is the version of the action for setting up Python, # not the Python version. - uses: actions/setup-python@v6 + uses: "actions/setup-python@v6" with: python-version: ${{ matrix.python-version }} - name: "Cache PIP Dependencies" uses: "actions/cache@v4" with: path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} + key: | + ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} restore-keys: | ${{ runner.os }}-pip-${{ matrix.python-version }}- ${{ runner.os }}-pip- # You can test your matrix by printing the current # Python version - name: "Display Python Version" - run: python -c "import sys; print(sys.version)" + run: | + python -c "import sys; print(sys.version)" - name: "Install Dependencies" run: | python -m pip install --upgrade pip diff --git a/.github/workflows/lint_test_report.yml b/.github/workflows/lint_test_report.yml new file mode 100644 index 0000000..8e0813c --- /dev/null +++ b/.github/workflows/lint_test_report.yml @@ -0,0 +1,83 @@ +--- +name: "Main Pipeline" + +on: # yamllint disable-line rule:truthy + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: "read" + pull-requests: "read" + +jobs: + changed-files: + runs-on: "ubuntu-latest" + outputs: + python_changed: ${{ steps.python-files.outputs.any_changed }} + yaml_changed: ${{ steps.yaml-files.outputs.any_changed }} + steps: + - uses: "actions/checkout@v5" + - name: "Get changed Python files" + id: python-files + uses: "tj-actions/changed-files@v47" + with: + files: | + **/*.py + requirements.txt + .pylintrc + - name: "Get changed YAML files" + id: yaml-files + uses: "tj-actions/changed-files@v47" + with: + files: | + **/*.yml + **/*.yaml + + yamllint: + name: "YAML Lint Workflow" + needs: + - "changed-files" + if: needs.changed-files.outputs.yaml_changed == 'true' + uses: "./.github/workflows/yamllint.yml" + + ruff: + name: "Ruff Lint and Format" + needs: + - "changed-files" + - "yamllint" + if: needs.changed-files.outputs.python_changed == 'true' + uses: "./.github/workflows/ruff.yml" + + pylint: + name: "Pylint Workflow" + needs: + - "changed-files" + - "yamllint" + if: needs.changed-files.outputs.python_changed == 'true' + uses: "./.github/workflows/pylint.yml" + + flake8: + name: "Flake8 Workflow" + needs: + - "changed-files" + - "yamllint" + if: needs.changed-files.outputs.python_changed == 'true' + uses: "./.github/workflows/flake8.yml" + + pytest: + name: "Pytest Workflow" + needs: + - "ruff" + - "pylint" + - "flake8" + uses: "./.github/workflows/pytest.yml" + + codecov: + name: "Codecov Coverage Report" + needs: + - "pytest" + uses: "./.github/workflows/codecov.yml" + secrets: + CODECOV_TOKEN: "${{ secrets.CODECOV_TOKEN }}" diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 0ae436f..ff25eb1 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -1,19 +1,9 @@ --- -name: "Pylint" +name: "Pylint Workflow" -on: - push: - branches: [ main ] - paths: - - '**/*.py' - - 'requirements.txt' - - '.pylintrc' - pull_request: - branches: [ main ] - paths: - - '**/*.py' - - 'requirements.txt' - - '.pylintrc' +on: # yamllint disable-line rule:truthy + # Makes it reusable; called by other workflows + workflow_call: jobs: build: @@ -25,25 +15,25 @@ jobs: matrix: python-version: ["3.12"] steps: - - uses: "actions/checkout@v5" - - name: Set up Python ${{ matrix.python-version }} - uses: "actions/setup-python@v6" - with: - python-version: ${{ matrix.python-version }} - - name: "Cache PIP Dependencies" - uses: "actions/cache@v4" - with: - path: ~/.cache/pip - key: | - ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip-${{ matrix.python-version }}- - ${{ runner.os }}-pip- - - name: "Install dependencies" - run: | - python -m pip install --upgrade pip - pip install pylint - pip install -r requirements.txt - - name: "Analysing the code with pylint" - run: | - python -m pylint --verbose $(find . -name "*.py" ! -path "*/.venv/*" ! -path "*/venv/*") --rcfile=.pylintrc \ No newline at end of file + - uses: "actions/checkout@v5" + - name: Set up Python ${{ matrix.python-version }} + uses: "actions/setup-python@v6" + with: + python-version: ${{ matrix.python-version }} + - name: "Cache PIP Dependencies" + uses: "actions/cache@v4" + with: + path: ~/.cache/pip + key: | + ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip-${{ matrix.python-version }}- + ${{ runner.os }}-pip- + - name: "Install dependencies" + run: | + python -m pip install --upgrade pip + pip install pylint + pip install -r requirements.txt + - name: "Analysing the code with pylint" + run: | + python -m pylint --verbose $(find . -name "*.py" ! -path "*/.venv/*" ! -path "*/venv/*") --rcfile=.pylintrc diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index fb740ab..7737a16 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,11 +1,9 @@ --- name: "Pytest Workflow" -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] +on: # yamllint disable-line rule:truthy + # Makes it reusable; called by other workflows + workflow_call: jobs: test: @@ -14,26 +12,26 @@ jobs: # indefinitely if something goes wrong timeout-minutes: 10 steps: - - name: "Checkout code" - uses: "actions/checkout@v5" - - name: "Set up Python 3.12" - uses: "actions/setup-python@v6" - with: - python-version: "3.12" - - name: "Cache PIP Dependencies" - uses: "actions/cache@v4" - with: - path: ~/.cache/pip - key: | - ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip-${{ matrix.python-version }}- - ${{ runner.os }}-pip- - - name: "Install dependencies" - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pytest - - name: "Run pytest" - run: | - pytest . --verbose --ignore=solutions --log-cli-level=INFO \ No newline at end of file + - name: "Checkout code" + uses: "actions/checkout@v5" + - name: "Set up Python 3.12" + uses: "actions/setup-python@v6" + with: + python-version: "3.12" + - name: "Cache PIP Dependencies" + uses: "actions/cache@v4" + with: + path: ~/.cache/pip + key: | + ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip-${{ matrix.python-version }}- + ${{ runner.os }}-pip- + - name: "Install dependencies" + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest + - name: "Run pytest" + run: | + pytest . --verbose --ignore=solutions --log-cli-level=INFO diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 7666f2e..7e4659c 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -1,19 +1,9 @@ --- name: "Ruff Lint and Format" -on: - push: - branches: [ main ] - paths: - - '**/*.py' - - 'requirements.txt' - - '.pylintrc' - pull_request: - branches: [ main ] - paths: - - '**/*.py' - - 'requirements.txt' - - '.pylintrc' +on: # yamllint disable-line rule:truthy + # Makes it reusable; called by other workflows + workflow_call: jobs: ruff: @@ -31,7 +21,8 @@ jobs: uses: "actions/cache@v4" with: path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} + key: | + ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} restore-keys: | ${{ runner.os }}-pip-${{ matrix.python-version }}- ${{ runner.os }}-pip- @@ -44,4 +35,4 @@ jobs: ruff check --output-format=github . - name: "Run Ruff format check" run: | - ruff format --check . \ No newline at end of file + ruff format --check . diff --git a/.github/workflows/yamllint.yml b/.github/workflows/yamllint.yml new file mode 100644 index 0000000..1d797de --- /dev/null +++ b/.github/workflows/yamllint.yml @@ -0,0 +1,38 @@ +--- +name: "YAML Lint Workflow" + +on: # yamllint disable-line rule:truthy + # Makes it reusable; called by other workflows + workflow_call: + +jobs: + yamllint: + runs-on: "ubuntu-latest" + # Adding 'timeout-minutes: 10' would prevent jobs from running + # indefinitely if something goes wrong + timeout-minutes: 10 + steps: + - uses: "actions/checkout@v5" + - name: "Set up Python 3.12" + uses: "actions/setup-python@v6" + with: + python-version: "3.12" + - name: "Cache PIP Dependencies" + uses: "actions/cache@v4" + with: + path: ~/.cache/pip + key: | + ${{ runner.os }}-pip-3.12-${{ hashFiles('requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip-3.12- + ${{ runner.os }}-pip- + - name: "Install yamllint" + run: | + python -m pip install --upgrade pip + pip install yamllint + - name: "Check yamllint Version" + run: | + yamllint --version + - name: "Lint with yamllint" + run: | + yamllint . diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..0c01e2b --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,3 @@ +--- +rules: + line-length: disable diff --git a/Dockerfile b/Dockerfile index 8a235c6..54894c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,8 @@ RUN pip install --no-cache-dir \ flake8 \ pylint \ pytest \ - pytest-cov + pytest-cov \ + yamllint # Copy the rest of the code (including .pylintrc if present) COPY . . diff --git a/README.md b/README.md index 2877080..c3cd5c0 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@ # [Exercism Python Track](https://exercism.io/tracks/python) -[![Pytest Workflow](https://github.com/ikostan/python/actions/workflows/pytest.yml/badge.svg)](https://github.com/ikostan/python/actions/workflows/pytest.yml) -[![Pylint](https://github.com/ikostan/python/actions/workflows/pylint.yml/badge.svg)](https://github.com/ikostan/python/actions/workflows/pylint.yml) -[![Ruff Lint and Format](https://github.com/ikostan/python/actions/workflows/ruff.yml/badge.svg)](https://github.com/ikostan/python/actions/workflows/ruff.yml) -[![Flake8](https://github.com/ikostan/python/actions/workflows/flake8.yml/badge.svg)](https://github.com/ikostan/python/actions/workflows/flake8.yml) +[![Main Pipeline](https://github.com/ikostan/python/actions/workflows/lint_test_report.yml/badge.svg)](https://github.com/ikostan/python/actions/workflows/lint_test_report.yml) [![codecov](https://codecov.io/github/ikostan/python/graph/badge.svg?token=G78RWJWJ38)](https://codecov.io/github/ikostan/python)
diff --git a/codecov.yml b/codecov.yml index ff4805c..63b1b89 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,4 +1,5 @@ +--- ignore: - "**/tests/" # Ignores all files within any 'tests' directory - - "**/*_test.py" # Ignores files ending with '_test.py' - - "test_*.py" # Ignores files starting with 'test_' \ No newline at end of file + - "**/*_test.py" # Ignores files ending with '_test.py' + - "test_*.py" # Ignores files starting with 'test_' diff --git a/run_ci_tests.sh b/run_ci_tests.sh index 716bb0c..3796ace 100644 --- a/run_ci_tests.sh +++ b/run_ci_tests.sh @@ -6,6 +6,10 @@ set -e # Display Python version python --version +# Run YAML lint +echo "Running YAML lint..." +yamllint . || { echo "YAML lint failed"; exit 1; } + # Run Ruff lint echo "Running Ruff lint..." ruff check --output-format=github . || { echo "Ruff lint failed"; exit 1; } diff --git a/solutions/python/wordy/1/wordy.py b/solutions/python/wordy/1/wordy.py new file mode 100644 index 0000000..8673c38 --- /dev/null +++ b/solutions/python/wordy/1/wordy.py @@ -0,0 +1,89 @@ +""" +Parse and evaluate simple math word problems +returning the answer as an integer. + +Handle a set of operations, in sequence. + +Since these are verbal word problems, evaluate +the expression from left-to-right, ignoring the +typical order of operations. +""" + +import string + +STR_TO_OPERATOR: dict = { + "plus": "+", + "minus": "-", + "multiplied": "*", + "divided": "/", +} + +WRONG_OPERATORS: list[str] = [ + "plus?", + "minus?", + "multiplied?", + "divided?", + "plus plus", + "plus multiplied", + "minus minus", + "multiplied multiplied", + "divided divided", + "What is?", +] + + +def answer(question: str) -> int: + if "cubed" in question: + raise ValueError("unknown operation") + + for item in WRONG_OPERATORS: + if item in question: + raise ValueError("syntax error") + + digits: list[bool] = [] + for char in question: + if char.isdigit(): + digits.append(True) + + operators: list[bool] = [] + for key, val in STR_TO_OPERATOR.items(): + if key in question or val in question: + operators.append(True) + + if not any(digits + operators): + raise ValueError("unknown operation") + + print(f"\n{question}") + return eval(_reformat(question)) + + +def _reformat(question: str) -> str: + # 1 + question = question.replace("?", "") + # 2 + chars: list[str] = list(char for char in question) + for i, char in enumerate(chars): + if char in STR_TO_OPERATOR.values(): + chars[i] = f" {char} " + # 3 + question = "".join(chars) + print(f"\n new question:{question}") + # 4 + question_list: list[str] = question.split() + for i, item in enumerate(question_list): + # print(f"\nitem: {item}") + if not ( + item.isdigit() + or item in STR_TO_OPERATOR.keys() + or item in STR_TO_OPERATOR.values() + ): + question_list[i] = "" + # print("\n#1") + elif item in STR_TO_OPERATOR.keys(): + # print("\n#2") + question_list[i] = STR_TO_OPERATOR[item] + + print(f"\n{question_list}") + print(f"\n{' '.join(question_list)}") + + return " ".join(question_list) diff --git a/solutions/python/wordy/2/wordy.py b/solutions/python/wordy/2/wordy.py new file mode 100644 index 0000000..09feb08 --- /dev/null +++ b/solutions/python/wordy/2/wordy.py @@ -0,0 +1,118 @@ +""" +Parse and evaluate simple math word problems +returning the answer as an integer. + +Handle a set of operations, in sequence. + +Since these are verbal word problems, evaluate +the expression from left-to-right, ignoring the +typical order of operations. +""" + +import string + +STR_TO_OPERATOR: dict = { + "plus": "+", + "minus": "-", + "multiplied": "*", + "divided": "/", +} + +WRONG_OPERATORS: list[str] = [ + "plus?", + "minus?", + "multiplied?", + "divided?", + "plus plus", + "plus multiplied", + "minus multiplied", + "minus minus", + "multiplied multiplied", + "divided divided", + "What is?", +] + + +def answer(question: str) -> int: + _validate_errors(question) + result: int = 0 + new_question: list[str] = _reformat(question) + + if len(new_question) <= 3: + try: + _validate_evaluation_pattern(new_question) + eval_str: str = "".join(new_question) + result = eval(eval_str) + return result + except Exception: + raise ValueError("syntax error") + + while len(new_question) >= 3: + try: + _validate_evaluation_pattern(new_question[:3]) + eval_str: str = "".join(new_question[:3]) + val: int = eval(eval_str) + result = val + new_question = [str(result)] + new_question[3:] + if len(new_question) < 3: + _validate_evaluation_pattern(new_question) + return eval("".join(new_question)) + except Exception: + raise ValueError("syntax error") + return result + + +def _validate_evaluation_pattern(val: list) -> None: + if len(val) == 3 and not val[1] in STR_TO_OPERATOR.values(): + raise ValueError("syntax error") + + if len(val) == 2 and not val[0] in STR_TO_OPERATOR.values(): + raise ValueError("syntax error") + + +def _reformat(question: str) -> list: + # 1: Remove '?' mark + question = question.replace("?", "") + # 2: Convert all operators writen in word into proper math sign + question_list: list[str] = question.split() + formated_question_list: list[str] = [] + for i, item in enumerate(question_list): + if not ( + item.isdigit() + or item in STR_TO_OPERATOR.keys() + or item in STR_TO_OPERATOR.values() + or any(val in item for val in STR_TO_OPERATOR.values()) + ): + continue + elif item in STR_TO_OPERATOR.keys(): + formated_question_list.append(STR_TO_OPERATOR[item]) + elif item.isdigit(): + formated_question_list.append(item) + elif item in STR_TO_OPERATOR.values(): + formated_question_list.append(item) + elif any(val in item for val in STR_TO_OPERATOR.values()): + formated_question_list.append(item) + + return formated_question_list + + +def _validate_errors(question: str) -> None: + if "cubed" in question: + raise ValueError("unknown operation") + + for item in WRONG_OPERATORS: + if item in question: + raise ValueError("syntax error") + + digits: list[bool] = [] + for char in question: + if char.isdigit(): + digits.append(True) + + operators: list[bool] = [] + for key, val in STR_TO_OPERATOR.items(): + if key in question or val in question: + operators.append(True) + + if not any(digits + operators): + raise ValueError("unknown operation") diff --git a/wordy/wordy.py b/wordy/wordy.py index 12a6fcb..da43570 100644 --- a/wordy/wordy.py +++ b/wordy/wordy.py @@ -44,33 +44,54 @@ def answer(question: str) -> int: _validate_errors(question) result: int = 0 new_question: list[str] = _reformat(question) - - if len(new_question) <= 3: - try: - _validate_evaluation_pattern(new_question) - eval_str: str = "".join(new_question) - result = eval(eval_str) - return result - except Exception as exc: - raise ValueError("syntax error") from exc - # Reduce iteratively: evaluate the first three-token slice # and fold the result left-to-right. - while len(new_question) >= 3: + result: int = 0 + while new_question: try: + if len(new_question) == 3: + _validate_evaluation_pattern(new_question) + result = _math_operation(new_question) + break + if len(new_question) == 1: + result = int(new_question[0]) + break _validate_evaluation_pattern(new_question[:3]) - eval_str: str = "".join(new_question[:3]) - val: int = eval(eval_str) - result = val + result = _math_operation(new_question[:3]) new_question = [str(result)] + new_question[3:] - if len(new_question) < 3: - _validate_evaluation_pattern(new_question) - return eval("".join(new_question)) except Exception as exc: raise ValueError("syntax error") from exc return result +def _math_operation(question: list[str]) -> int: + """ + Compute a single binary arithmetic operation. + + Expects a three-token slice like ``['3', '+', '4']`` and returns + the integer result. Division performs floor division (``//``) to + match exercise rules. + + :param question: Three tokens ``[lhs, operator, rhs]``. + :type question: list[str] + :returns: The computed integer result. + :rtype: int + """ + math_operator: str = question[1] + result: int = 0 + + if math_operator == "+": + result = int(question[0]) + int(question[-1]) + elif math_operator == "-": + result = int(question[0]) - int(question[-1]) + elif math_operator == "/": + result = int(question[0]) // int(question[-1]) + elif math_operator == "*": + result = int(question[0]) * int(question[-1]) + + return result + + def _validate_evaluation_pattern(val: list) -> None: """ Ensure a token slice matches expected evaluation patterns. @@ -138,16 +159,3 @@ def _validate_errors(question: str) -> None: for item in WRONG_OPERATORS: if item in question: raise ValueError("syntax error") - - digits: list[bool] = [] - for char in question: - if char.isdigit(): - digits.append(True) - - operators: list[bool] = [] - for key, val in STR_TO_OPERATOR.items(): - if key in question or val in question: - operators.append(True) - - if not any(digits + operators): - raise ValueError("unknown operation")