goldie 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- goldie-0.1.1/.github/workflows/release.yml +134 -0
- goldie-0.1.1/.gitignore +171 -0
- goldie-0.1.1/.prettierrc.yml +1 -0
- goldie-0.1.1/LICENSE +21 -0
- goldie-0.1.1/PKG-INFO +43 -0
- goldie-0.1.1/README.md +3 -0
- goldie-0.1.1/example/data/input.json +3 -0
- goldie-0.1.1/example/data/input.json.golden +3 -0
- goldie-0.1.1/example/golden_directory_test.py +29 -0
- goldie-0.1.1/goldie/__about__.py +1 -0
- goldie-0.1.1/goldie/__init__.py +14 -0
- goldie-0.1.1/goldie/comparison.py +399 -0
- goldie-0.1.1/goldie/diff.py +120 -0
- goldie-0.1.1/goldie/directory.py +111 -0
- goldie-0.1.1/goldie/execution.py +92 -0
- goldie-0.1.1/goldie/tests/__init__.py +0 -0
- goldie-0.1.1/goldie/tests/test_diff.py +33 -0
- goldie-0.1.1/goldie/tests/testdata/comparison-1.txt +10 -0
- goldie-0.1.1/goldie/tests/testdata/comparison-2.txt +10 -0
- goldie-0.1.1/goldie/update.py +6 -0
- goldie-0.1.1/pyproject.toml +41 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
run-name: Release ${{ inputs.VERSION }} (pre-release=${{ inputs.IS_PRE_RELEASE }}) by @${{ github.actor }} from ${{ github.ref_name }}
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
workflow_dispatch:
|
|
6
|
+
inputs:
|
|
7
|
+
VERSION:
|
|
8
|
+
description: "The version to release"
|
|
9
|
+
required: true
|
|
10
|
+
IS_PRE_RELEASE:
|
|
11
|
+
description: "It IS a pre-release"
|
|
12
|
+
required: true
|
|
13
|
+
default: true
|
|
14
|
+
type: boolean
|
|
15
|
+
|
|
16
|
+
env:
|
|
17
|
+
PYTHON_VERSION: "3.13"
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
bump: # This job is used to bump the version and create a release
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
env:
|
|
23
|
+
VERSION: ${{ inputs.VERSION }}
|
|
24
|
+
GH_TOKEN: ${{ github.token }}
|
|
25
|
+
SSH_AUTH_SOCK: /tmp/ssh_agent.sock
|
|
26
|
+
permissions:
|
|
27
|
+
contents: write
|
|
28
|
+
steps:
|
|
29
|
+
- name: set up Python
|
|
30
|
+
uses: actions/setup-python@v5
|
|
31
|
+
with:
|
|
32
|
+
python-version: ${{ env.PYTHON_VERSION }}
|
|
33
|
+
|
|
34
|
+
- name: install dependencies
|
|
35
|
+
run: |
|
|
36
|
+
pip install --upgrade pip
|
|
37
|
+
pip install build hatch
|
|
38
|
+
|
|
39
|
+
- name: configure git with the bot credentials
|
|
40
|
+
run: |
|
|
41
|
+
mkdir -p ~/.ssh
|
|
42
|
+
ssh-keyscan github.com >> ~/.ssh/known_hosts
|
|
43
|
+
ssh-agent -a $SSH_AUTH_SOCK > /dev/null
|
|
44
|
+
ssh-add - <<< "${{ secrets.BOT_SSH_KEY }}"
|
|
45
|
+
|
|
46
|
+
echo "${{ secrets.BOT_SIGNING_KEY }}" > ~/.ssh/signing.key
|
|
47
|
+
chmod 600 ~/.ssh/signing.key
|
|
48
|
+
|
|
49
|
+
git config --global user.name "Merschbotmann"
|
|
50
|
+
git config --global user.email "bot.merschformann@gmail.com"
|
|
51
|
+
git config --global gpg.format ssh
|
|
52
|
+
git config --global user.signingkey ~/.ssh/signing.key
|
|
53
|
+
|
|
54
|
+
git clone git@github.com:merschformann/goldie.git
|
|
55
|
+
cd goldie
|
|
56
|
+
git switch ${{ github.ref_name }}
|
|
57
|
+
|
|
58
|
+
- name: upgrade version with hatch
|
|
59
|
+
run: hatch version ${{ env.VERSION }}
|
|
60
|
+
working-directory: ./goldie
|
|
61
|
+
|
|
62
|
+
- name: commit new version
|
|
63
|
+
run: |
|
|
64
|
+
git add goldie/__about__.py
|
|
65
|
+
git commit -S -m "Bump goldie version to ${{ env.VERSION }}"
|
|
66
|
+
git push
|
|
67
|
+
git tag ${{ env.VERSION }}
|
|
68
|
+
git push origin ${{ env.VERSION }}
|
|
69
|
+
working-directory: ./goldie
|
|
70
|
+
|
|
71
|
+
- name: create release
|
|
72
|
+
run: |
|
|
73
|
+
PRERELEASE_FLAG=""
|
|
74
|
+
if [ ${{ inputs.IS_PRE_RELEASE }} = true ]; then
|
|
75
|
+
PRERELEASE_FLAG="--prerelease"
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
gh release create ${{ env.VERSION }} \
|
|
79
|
+
--verify-tag \
|
|
80
|
+
--generate-notes \
|
|
81
|
+
--title "goldie ${{ env.VERSION }}" $PRERELEASE_FLAG
|
|
82
|
+
working-directory: ./goldie
|
|
83
|
+
|
|
84
|
+
- name: ensure passing build
|
|
85
|
+
run: python -m build
|
|
86
|
+
working-directory: ./goldie
|
|
87
|
+
|
|
88
|
+
release: # This job is used to publish the release to PyPI/TestPyPI
|
|
89
|
+
runs-on: ubuntu-latest
|
|
90
|
+
needs: bump
|
|
91
|
+
strategy:
|
|
92
|
+
matrix:
|
|
93
|
+
include:
|
|
94
|
+
- target-env: pypi
|
|
95
|
+
target-url: https://pypi.org/p/goldie
|
|
96
|
+
- target-env: testpypi
|
|
97
|
+
target-url: https://test.pypi.org/p/goldie
|
|
98
|
+
environment:
|
|
99
|
+
name: ${{ matrix.target-env }}
|
|
100
|
+
url: ${{ matrix.target-url }}
|
|
101
|
+
permissions:
|
|
102
|
+
contents: read
|
|
103
|
+
id-token: write # This is required for trusted publishing to PyPI
|
|
104
|
+
steps:
|
|
105
|
+
- name: git clone ${{ github.ref_name }}
|
|
106
|
+
uses: actions/checkout@v4
|
|
107
|
+
with:
|
|
108
|
+
ref: ${{ github.ref_name }}
|
|
109
|
+
|
|
110
|
+
- name: set up Python
|
|
111
|
+
uses: actions/setup-python@v5
|
|
112
|
+
with:
|
|
113
|
+
python-version: ${{ env.PYTHON_VERSION }}
|
|
114
|
+
|
|
115
|
+
- name: install dependencies
|
|
116
|
+
run: |
|
|
117
|
+
pip install --upgrade pip
|
|
118
|
+
pip install build hatch
|
|
119
|
+
|
|
120
|
+
- name: build binary wheel and source tarball
|
|
121
|
+
run: python -m build
|
|
122
|
+
|
|
123
|
+
- name: Publish package distributions to PyPI
|
|
124
|
+
if: ${{ matrix.target-env == 'pypi' }}
|
|
125
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
126
|
+
with:
|
|
127
|
+
packages-dir: ./dist
|
|
128
|
+
|
|
129
|
+
- name: Publish package distributions to TestPyPI
|
|
130
|
+
if: ${{ matrix.target-env == 'testpypi' }}
|
|
131
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
132
|
+
with:
|
|
133
|
+
repository-url: https://test.pypi.org/legacy/
|
|
134
|
+
packages-dir: ./dist
|
goldie-0.1.1/.gitignore
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
|
|
110
|
+
# pdm
|
|
111
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
112
|
+
#pdm.lock
|
|
113
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
114
|
+
# in version control.
|
|
115
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
116
|
+
.pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
121
|
+
__pypackages__/
|
|
122
|
+
|
|
123
|
+
# Celery stuff
|
|
124
|
+
celerybeat-schedule
|
|
125
|
+
celerybeat.pid
|
|
126
|
+
|
|
127
|
+
# SageMath parsed files
|
|
128
|
+
*.sage.py
|
|
129
|
+
|
|
130
|
+
# Environments
|
|
131
|
+
.env
|
|
132
|
+
.venv
|
|
133
|
+
env/
|
|
134
|
+
venv/
|
|
135
|
+
ENV/
|
|
136
|
+
env.bak/
|
|
137
|
+
venv.bak/
|
|
138
|
+
|
|
139
|
+
# Spyder project settings
|
|
140
|
+
.spyderproject
|
|
141
|
+
.spyproject
|
|
142
|
+
|
|
143
|
+
# Rope project settings
|
|
144
|
+
.ropeproject
|
|
145
|
+
|
|
146
|
+
# mkdocs documentation
|
|
147
|
+
/site
|
|
148
|
+
|
|
149
|
+
# mypy
|
|
150
|
+
.mypy_cache/
|
|
151
|
+
.dmypy.json
|
|
152
|
+
dmypy.json
|
|
153
|
+
|
|
154
|
+
# Pyre type checker
|
|
155
|
+
.pyre/
|
|
156
|
+
|
|
157
|
+
# pytype static type analyzer
|
|
158
|
+
.pytype/
|
|
159
|
+
|
|
160
|
+
# Cython debug symbols
|
|
161
|
+
cython_debug/
|
|
162
|
+
|
|
163
|
+
# PyCharm
|
|
164
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
165
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
166
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
167
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
168
|
+
#.idea/
|
|
169
|
+
|
|
170
|
+
# PyPI configuration file
|
|
171
|
+
.pypirc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tabWidth: 2
|
goldie-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Marius Merschformann
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
goldie-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: goldie
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A humble library greasing the gears of golden file tests
|
|
5
|
+
Project-URL: Homepage, https://github.com/merschformann/goldie
|
|
6
|
+
Project-URL: Repository, https://github.com/merschformann/goldie
|
|
7
|
+
Author-email: Marius Merschformann <merschformann@proton.me>
|
|
8
|
+
Maintainer-email: Marius Merschformann <merschformann@proton.me>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2025 Marius Merschformann
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: golden file,integration test,test,testing
|
|
32
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
33
|
+
Classifier: Operating System :: OS Independent
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
38
|
+
Requires-Python: >=3.11
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# goldie
|
|
42
|
+
|
|
43
|
+
A humble library greasing the gears of golden file tests.
|
goldie-0.1.1/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
import goldie.comparison
|
|
4
|
+
import goldie.directory
|
|
5
|
+
import goldie.execution
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestExample(unittest.TestCase):
|
|
9
|
+
def test_addition(self):
|
|
10
|
+
config = goldie.directory.ConfigDirectoryTest(
|
|
11
|
+
file_filter="data/*.json",
|
|
12
|
+
run_configuration=goldie.execution.ConfigRun(
|
|
13
|
+
cmd="cat",
|
|
14
|
+
args=["{input}"],
|
|
15
|
+
input_mode=goldie.execution.InputMode.NONE,
|
|
16
|
+
output_mode=goldie.execution.OutputMode.STDOUT,
|
|
17
|
+
),
|
|
18
|
+
run_validation_configuration=goldie.execution.ConfigRunValidation(),
|
|
19
|
+
comparison_configuration=goldie.comparison.ConfigComparison(
|
|
20
|
+
comparison_type=goldie.comparison.ComparisonType.JSON,
|
|
21
|
+
json_processing_config=goldie.comparison.ConfigProcessJson(),
|
|
22
|
+
json_comparison_config=goldie.comparison.ConfigCompareJson(),
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
goldie.directory.run_unittest(self, config)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
unittest.main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "v0.1.1"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .__about__ import __version__
|
|
2
|
+
from .comparison import (
|
|
3
|
+
ConfigCompareJson,
|
|
4
|
+
ConfigCompareString,
|
|
5
|
+
ConfigComparison,
|
|
6
|
+
ConfigProcessJson,
|
|
7
|
+
ConfigProcessString,
|
|
8
|
+
compare,
|
|
9
|
+
)
|
|
10
|
+
from .diff import Difference, DiffStyle
|
|
11
|
+
from .directory import ConfigDirectoryTest, run_unittest
|
|
12
|
+
from .execution import ConfigRun, ConfigRunValidation, InputMode, OutputMode, execute
|
|
13
|
+
|
|
14
|
+
VERSION = __version__
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from jflat import flatten, unflatten
|
|
7
|
+
|
|
8
|
+
from .diff import Difference, DiffStyle, diff_color_code_full, diff_color_code_unified
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ComparisonType(Enum):
|
|
12
|
+
"""The type of comparison."""
|
|
13
|
+
|
|
14
|
+
STRING = "string"
|
|
15
|
+
"""Comparison based on strings."""
|
|
16
|
+
JSON = "json"
|
|
17
|
+
"""Comparison based on JSON."""
|
|
18
|
+
BINARY = "binary"
|
|
19
|
+
"""Comparison based on binary files."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RegexReplacement:
|
|
24
|
+
"""Defines a regex replacement."""
|
|
25
|
+
|
|
26
|
+
pattern: str
|
|
27
|
+
"""The pattern to be replaced in Python 're' regex format."""
|
|
28
|
+
replacement: str
|
|
29
|
+
"""The replacement string."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class JsonReplacement:
|
|
34
|
+
"""Defines a JSON replacement."""
|
|
35
|
+
|
|
36
|
+
path: str
|
|
37
|
+
"""The JSON path to be replaced."""
|
|
38
|
+
value: any
|
|
39
|
+
"""The value to put in place of the path."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class JsonRounding:
|
|
44
|
+
"""Defines rounding for a specific JSON path."""
|
|
45
|
+
|
|
46
|
+
path: str
|
|
47
|
+
"""The JSON path to be rounded before comparing."""
|
|
48
|
+
precision: int
|
|
49
|
+
"""The precision to round to."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ConfigProcessJson:
|
|
54
|
+
"""Configuration for comparing dictionaries based on JSON."""
|
|
55
|
+
|
|
56
|
+
replacements: list[JsonReplacement] = field(default_factory=list)
|
|
57
|
+
"""List of paths to replace."""
|
|
58
|
+
roundings: list[JsonRounding] = field(default_factory=list)
|
|
59
|
+
"""List of paths to round before comparing."""
|
|
60
|
+
precision: int = 6
|
|
61
|
+
"""The precision to round to."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ConfigCompareJson:
|
|
66
|
+
"""Configuration for comparing dictionaries based on JSON."""
|
|
67
|
+
|
|
68
|
+
ignores: list[str] = field(default_factory=list)
|
|
69
|
+
"""List of paths to ignore."""
|
|
70
|
+
allow_additional_keys: bool = False
|
|
71
|
+
"""Whether additional keys in the actual JSON are allowed."""
|
|
72
|
+
allow_missing_keys: bool = False
|
|
73
|
+
"""Whether missing keys in the actual JSON are allowed."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ConfigProcessString:
|
|
78
|
+
"""Configuration for processing strings."""
|
|
79
|
+
|
|
80
|
+
regex_replacements: list[RegexReplacement] = field(default_factory=list)
|
|
81
|
+
"""List of regex replacements."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ConfigCompareString:
|
|
86
|
+
"""Configuration for comparing strings."""
|
|
87
|
+
|
|
88
|
+
diff_style: DiffStyle = DiffStyle.FULL
|
|
89
|
+
"""The diff style to use."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class ConfigComparison:
|
|
94
|
+
"""Configuration for comparing actual and expected."""
|
|
95
|
+
|
|
96
|
+
comparison_type: ComparisonType
|
|
97
|
+
"""The type of comparison."""
|
|
98
|
+
|
|
99
|
+
string_processing_config: ConfigProcessString = None
|
|
100
|
+
"""The configuration for processing strings."""
|
|
101
|
+
string_comparison_config: ConfigCompareString = None
|
|
102
|
+
"""The configuration for comparing strings."""
|
|
103
|
+
json_processing_config: ConfigProcessJson = None
|
|
104
|
+
"""The configuration for processing JSON."""
|
|
105
|
+
json_comparison_config: ConfigCompareJson = None
|
|
106
|
+
"""The configuration for comparing JSON."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def process_string(
|
|
110
|
+
actual: str,
|
|
111
|
+
configuration: ConfigProcessString,
|
|
112
|
+
) -> str:
|
|
113
|
+
"""
|
|
114
|
+
Processes a string according to the processing configuration.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
actual : str
|
|
119
|
+
The actual string.
|
|
120
|
+
configuration : ConfigProcessString
|
|
121
|
+
The processing configuration.
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
str
|
|
126
|
+
The processed string.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
# Apply regex replacements
|
|
130
|
+
for replacement in configuration.regex_replacements:
|
|
131
|
+
actual = re.sub(replacement.pattern, replacement.replacement, actual)
|
|
132
|
+
|
|
133
|
+
return actual
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def compare_string(
|
|
137
|
+
actual: str,
|
|
138
|
+
expected: str,
|
|
139
|
+
configuration: ConfigCompareString,
|
|
140
|
+
) -> tuple[bool, str]:
|
|
141
|
+
"""
|
|
142
|
+
Compares two strings according to the comparison configuration."
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
actual : str
|
|
147
|
+
The actual string.
|
|
148
|
+
expected : str
|
|
149
|
+
The expected string.
|
|
150
|
+
configuration : ConfigCompareString
|
|
151
|
+
The comparison configuration.
|
|
152
|
+
|
|
153
|
+
Returns
|
|
154
|
+
-------
|
|
155
|
+
tuple[bool, str]
|
|
156
|
+
A tuple with a boolean indicating if the strings are equal and the diff.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
if configuration.diff_style == DiffStyle.FULL:
|
|
160
|
+
return actual == expected, diff_color_code_full(actual, expected)
|
|
161
|
+
return actual == expected, diff_color_code_unified(actual, expected)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def process_json(
|
|
165
|
+
actual: dict,
|
|
166
|
+
configuration: ConfigProcessJson,
|
|
167
|
+
) -> dict:
|
|
168
|
+
"""
|
|
169
|
+
Processes a dictionary according to the processing configuration.
|
|
170
|
+
|
|
171
|
+
Parameters
|
|
172
|
+
----------
|
|
173
|
+
actual : dict
|
|
174
|
+
The actual dictionary.
|
|
175
|
+
configuration : ConfigProcessJson
|
|
176
|
+
The processing configuration.
|
|
177
|
+
|
|
178
|
+
Returns
|
|
179
|
+
-------
|
|
180
|
+
dict
|
|
181
|
+
The processed dictionary.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
# Flatten the dictionaries
|
|
185
|
+
actual_flat = flatten(actual)
|
|
186
|
+
|
|
187
|
+
# Apply replacements
|
|
188
|
+
for replacement in configuration.replacements:
|
|
189
|
+
actual_flat[replacement.path] = replacement.value
|
|
190
|
+
|
|
191
|
+
# Apply explicit roundings
|
|
192
|
+
for rounding in configuration.roundings:
|
|
193
|
+
if not isinstance(actual_flat[rounding.path], (int, float)):
|
|
194
|
+
return (
|
|
195
|
+
False,
|
|
196
|
+
f"Expected number at rounding path '{rounding.path}' "
|
|
197
|
+
+ f"but got '{type(actual_flat[rounding.path])}' (actual: {actual_flat[rounding.path]})",
|
|
198
|
+
)
|
|
199
|
+
actual_flat[rounding.path] = round(actual_flat[rounding.path], rounding.precision)
|
|
200
|
+
|
|
201
|
+
# Apply rounding to all numbers
|
|
202
|
+
for path, value in actual_flat.items():
|
|
203
|
+
if isinstance(value, (int, float)):
|
|
204
|
+
actual_flat[path] = round(value, configuration.precision)
|
|
205
|
+
|
|
206
|
+
# Unflatten and return
|
|
207
|
+
return unflatten(actual_flat)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def compare_json(
|
|
211
|
+
actual: dict,
|
|
212
|
+
expected: dict,
|
|
213
|
+
configuration: ConfigCompareJson,
|
|
214
|
+
) -> tuple[bool, list[Difference]]:
|
|
215
|
+
"""
|
|
216
|
+
Compares two dictionaries according to the comparison configuration."
|
|
217
|
+
|
|
218
|
+
Parameters
|
|
219
|
+
----------
|
|
220
|
+
actual : dict
|
|
221
|
+
The actual dictionary.
|
|
222
|
+
expected : dict
|
|
223
|
+
The expected dictionary.
|
|
224
|
+
configuration : ConfigCompareJson
|
|
225
|
+
The comparison configuration.
|
|
226
|
+
|
|
227
|
+
Returns
|
|
228
|
+
-------
|
|
229
|
+
tuple[bool, list[Difference]]
|
|
230
|
+
A tuple with a boolean indicating if the dictionaries are equal and a list of differences.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
# Flatten the dictionaries
|
|
234
|
+
actual_flat = flatten(actual)
|
|
235
|
+
expected_flat = flatten(expected)
|
|
236
|
+
|
|
237
|
+
# Collect all differences
|
|
238
|
+
differences = []
|
|
239
|
+
for path in set(actual_flat.keys()) | set(expected_flat.keys()):
|
|
240
|
+
if path in configuration.ignores:
|
|
241
|
+
continue
|
|
242
|
+
if path not in actual_flat and not configuration.allow_missing_keys:
|
|
243
|
+
# MISSING KEY
|
|
244
|
+
differences.append(
|
|
245
|
+
Difference(
|
|
246
|
+
expected=expected_flat[path],
|
|
247
|
+
actual="",
|
|
248
|
+
location=path,
|
|
249
|
+
message="Missing key.",
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
elif path not in expected_flat and not configuration.allow_additional_keys:
|
|
253
|
+
# ADDITIONAL KEY
|
|
254
|
+
differences.append(
|
|
255
|
+
Difference(
|
|
256
|
+
expected="",
|
|
257
|
+
actual=actual_flat[path],
|
|
258
|
+
location=path,
|
|
259
|
+
message="Additional key.",
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
elif isinstance(expected_flat[path], (int, float)):
|
|
263
|
+
# NUMBER
|
|
264
|
+
if not isinstance(actual_flat[path], (int, float)):
|
|
265
|
+
differences.append(
|
|
266
|
+
Difference(
|
|
267
|
+
expected=expected_flat[path],
|
|
268
|
+
actual=actual_flat[path],
|
|
269
|
+
location=path,
|
|
270
|
+
message="Expected number.",
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
elif path in configuration.roundings:
|
|
274
|
+
if actual_flat[path] != expected_flat[path]:
|
|
275
|
+
differences.append(
|
|
276
|
+
Difference(
|
|
277
|
+
expected=expected_flat[path],
|
|
278
|
+
actual=actual_flat[path],
|
|
279
|
+
location=path,
|
|
280
|
+
message="Difference in value after explicit rounding.",
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
if actual_flat[path] != expected_flat[path]:
|
|
285
|
+
differences.append(
|
|
286
|
+
Difference(
|
|
287
|
+
expected=expected_flat[path],
|
|
288
|
+
actual=actual_flat[path],
|
|
289
|
+
location=path,
|
|
290
|
+
message="Difference in value.",
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
elif actual_flat[path] != expected_flat[path]:
|
|
294
|
+
# SIMPLE EQUALITY
|
|
295
|
+
differences.append(
|
|
296
|
+
Difference(
|
|
297
|
+
expected=expected_flat[path],
|
|
298
|
+
actual=actual_flat[path],
|
|
299
|
+
location=path,
|
|
300
|
+
message="Difference in value.",
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Return the result
|
|
305
|
+
return not differences, differences
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def process(
|
|
309
|
+
actual_file: str,
|
|
310
|
+
configuration: ConfigComparison,
|
|
311
|
+
json_decoder: any = None,
|
|
312
|
+
json_encoder: any = None,
|
|
313
|
+
):
|
|
314
|
+
"""
|
|
315
|
+
Processes a file in place according to the processing configuration."
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
# Handle binary processing
|
|
319
|
+
if configuration.comparison_type == ComparisonType.BINARY:
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
# Read the file
|
|
323
|
+
with open(actual_file, "r") as f:
|
|
324
|
+
actual = f.read()
|
|
325
|
+
|
|
326
|
+
# Process the actual string
|
|
327
|
+
if configuration.string_processing_config:
|
|
328
|
+
actual = process_string(actual, configuration.string_processing_config)
|
|
329
|
+
if configuration.comparison_type == ComparisonType.STRING:
|
|
330
|
+
with open(actual_file, "w") as f:
|
|
331
|
+
f.write(actual)
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
# Decode the JSON
|
|
335
|
+
if json_decoder:
|
|
336
|
+
actual = json_decoder(actual)
|
|
337
|
+
else:
|
|
338
|
+
actual = json.loads(actual)
|
|
339
|
+
|
|
340
|
+
# Process the actual JSON
|
|
341
|
+
if configuration.json_processing_config:
|
|
342
|
+
actual = process_json(actual, configuration.json_processing_config)
|
|
343
|
+
|
|
344
|
+
# Write the processed JSON
|
|
345
|
+
if json_encoder:
|
|
346
|
+
actual = json_encoder(actual)
|
|
347
|
+
else:
|
|
348
|
+
actual = json.dumps(actual, indent=4)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def compare(
|
|
352
|
+
actual_file: str,
|
|
353
|
+
golden_file: str,
|
|
354
|
+
configuration: ConfigComparison,
|
|
355
|
+
json_decoder: any = None,
|
|
356
|
+
) -> tuple[bool, str, list[Difference]]:
|
|
357
|
+
"""
|
|
358
|
+
Compares two files according to the comparison configuration."
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
# Handle binary comparison
|
|
362
|
+
if configuration.comparison_type == ComparisonType.BINARY:
|
|
363
|
+
with open(actual_file, "rb") as f:
|
|
364
|
+
actual = f.read()
|
|
365
|
+
with open(golden_file, "rb") as f:
|
|
366
|
+
expected = f.read()
|
|
367
|
+
equal = actual == expected
|
|
368
|
+
return equal, "Content is equal." if equal else "Content is not equal.", []
|
|
369
|
+
|
|
370
|
+
# Read the files
|
|
371
|
+
with open(actual_file, "r") as f:
|
|
372
|
+
actual = f.read()
|
|
373
|
+
with open(golden_file, "r") as f:
|
|
374
|
+
expected = f.read()
|
|
375
|
+
|
|
376
|
+
# Process the actual string
|
|
377
|
+
if configuration.string_processing_config:
|
|
378
|
+
actual = process_string(actual, configuration.string_processing_config)
|
|
379
|
+
|
|
380
|
+
# Handle string comparison
|
|
381
|
+
if configuration.comparison_type == ComparisonType.STRING:
|
|
382
|
+
equal, diff = compare_string(actual, expected, configuration.string_comparison_config)
|
|
383
|
+
return equal, diff, []
|
|
384
|
+
|
|
385
|
+
# Decode the JSON
|
|
386
|
+
if json_decoder:
|
|
387
|
+
actual = json_decoder(actual)
|
|
388
|
+
expected = json_decoder(expected)
|
|
389
|
+
else:
|
|
390
|
+
actual = json.loads(actual)
|
|
391
|
+
expected = json.loads(expected)
|
|
392
|
+
|
|
393
|
+
# Process the actual JSON
|
|
394
|
+
if configuration.json_processing_config:
|
|
395
|
+
actual = process_json(actual, configuration.json_processing_config)
|
|
396
|
+
|
|
397
|
+
# Handle JSON comparison
|
|
398
|
+
equal, differences = compare_json(actual, expected, configuration.json_comparison_config)
|
|
399
|
+
return equal, "Content is equal." if equal else "Content is not equal.", differences
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Difference:
|
|
8
|
+
"""
|
|
9
|
+
Represents a difference between expectation and actual.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
expected: any
|
|
13
|
+
"""The expected value."""
|
|
14
|
+
actual: any
|
|
15
|
+
"""The actual value."""
|
|
16
|
+
location: str = None
|
|
17
|
+
"""The location of the difference, if applicable."""
|
|
18
|
+
message: str = None
|
|
19
|
+
"""The message of the difference, if applicable."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DiffStyle(Enum):
|
|
23
|
+
FULL = "full"
|
|
24
|
+
UNIFIED = "unified"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _red(text):
|
|
28
|
+
return f"\x1b[38;2;255;0;0m{text}\x1b[38;2;255;255;255m"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _green(text):
|
|
32
|
+
return f"\x1b[38;2;0;255;0m{text}\x1b[38;2;255;255;255m"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _blue(text):
|
|
36
|
+
return f"\x1b[38;2;0;0;255m{text}\x1b[38;2;255;255;255m"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cyan(text):
|
|
40
|
+
return f"\x1b[38;2;0;255;255m{text}\x1b[38;2;255;255;255m"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _white(text):
|
|
44
|
+
return f"\x1b[38;2;255;255;255m{text}\x1b[38;2;255;255;255m"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def diff_color_code_full(old: str, new: str) -> str:
|
|
48
|
+
"""
|
|
49
|
+
Returns a colored diff between two strings.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
old : str
|
|
54
|
+
The old string.
|
|
55
|
+
new : str
|
|
56
|
+
The new string.
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
str
|
|
61
|
+
The colored diff.
|
|
62
|
+
"""
|
|
63
|
+
result = ""
|
|
64
|
+
codes = difflib.SequenceMatcher(a=old, b=new).get_opcodes()
|
|
65
|
+
for code in codes:
|
|
66
|
+
if code[0] == "equal":
|
|
67
|
+
result += _white(new[code[3] : code[4]])
|
|
68
|
+
elif code[0] == "delete":
|
|
69
|
+
result += _red(old[code[1] : code[2]])
|
|
70
|
+
elif code[0] == "insert":
|
|
71
|
+
result += _green(new[code[3] : code[4]])
|
|
72
|
+
elif code[0] == "replace":
|
|
73
|
+
result += _red(old[code[1] : code[2]]) + _green(new[code[3] : code[4]])
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def diff_color_code_unified(old: str, new: str, n: int = 3, line_sep: str = "\n") -> str:
|
|
78
|
+
"""
|
|
79
|
+
Returns a colored diff between two strings based on lines.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
old : str
|
|
84
|
+
The old string.
|
|
85
|
+
new : str
|
|
86
|
+
The new string.
|
|
87
|
+
n : int, optional
|
|
88
|
+
The number of lines to show around the change, by default 3.
|
|
89
|
+
line_sep : str, optional
|
|
90
|
+
The line separator, by default "\n".
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
str
|
|
95
|
+
The colored diff.
|
|
96
|
+
"""
|
|
97
|
+
# Split the strings into lines using the provided line separator
|
|
98
|
+
old_lines = old.split(line_sep)
|
|
99
|
+
new_lines = new.split(line_sep)
|
|
100
|
+
|
|
101
|
+
# Use difflib to compare lines instead of characters
|
|
102
|
+
diff = difflib.unified_diff(old_lines, new_lines, n=n, lineterm="")
|
|
103
|
+
result = []
|
|
104
|
+
|
|
105
|
+
for line in diff:
|
|
106
|
+
if line.startswith("+"):
|
|
107
|
+
# New line (green)
|
|
108
|
+
result.append(_green(line))
|
|
109
|
+
elif line.startswith("-"):
|
|
110
|
+
# Removed line (red)
|
|
111
|
+
result.append(_red(line))
|
|
112
|
+
elif line.startswith("@@"):
|
|
113
|
+
# Hunk header (cyan or some other highlighting)
|
|
114
|
+
result.append(_cyan(line))
|
|
115
|
+
else:
|
|
116
|
+
# Context line or other lines (white)
|
|
117
|
+
result.append(_white(line))
|
|
118
|
+
|
|
119
|
+
# Join all lines with the provided line separator
|
|
120
|
+
return line_sep.join(result)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import inspect
|
|
3
|
+
import os.path
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from goldie.comparison import ConfigComparison, compare
|
|
9
|
+
from goldie.execution import ConfigRun, ConfigRunValidation, execute
|
|
10
|
+
from goldie.update import UPDATE
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ConfigDirectoryTest:
|
|
15
|
+
"""Configuration for directory based golden file testing."""
|
|
16
|
+
|
|
17
|
+
file_filter: str
|
|
18
|
+
"""The file filter to use to find test files."""
|
|
19
|
+
run_configuration: ConfigRun
|
|
20
|
+
"""The run configuration to use to run the command."""
|
|
21
|
+
run_validation_configuration: ConfigRunValidation
|
|
22
|
+
"""The run validation configuration to use to validate the command."""
|
|
23
|
+
comparison_configuration: ConfigComparison
|
|
24
|
+
"""The configuration for comparing the actual and golden files."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_golden_filename(path: str) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Get the golden filename from a path.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
path : str
|
|
34
|
+
The path to get the golden filename from.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
str
|
|
39
|
+
The golden filename.
|
|
40
|
+
"""
|
|
41
|
+
return path + ".golden"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_caller_directory() -> str:
|
|
45
|
+
"""
|
|
46
|
+
Get the directory of the caller's caller.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
str
|
|
51
|
+
The directory.
|
|
52
|
+
"""
|
|
53
|
+
abs_path = os.path.abspath((inspect.stack()[2])[1])
|
|
54
|
+
directory_of_1py = os.path.dirname(abs_path)
|
|
55
|
+
return directory_of_1py
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_unittest(
|
|
59
|
+
test: unittest.TestCase,
|
|
60
|
+
configuration: ConfigDirectoryTest,
|
|
61
|
+
):
|
|
62
|
+
"""
|
|
63
|
+
Run the golden file test.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
test : unittest.TestCase
|
|
68
|
+
The test case to run.
|
|
69
|
+
configuration : ConfigDirectoryTest
|
|
70
|
+
The configuration for the golden file test.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
# Find all relevant files in the directory
|
|
74
|
+
root_directory = _get_caller_directory()
|
|
75
|
+
test_files = glob.glob(os.path.join(root_directory, configuration.file_filter))
|
|
76
|
+
|
|
77
|
+
# Remove any golden files
|
|
78
|
+
test_files = [f for f in test_files if not f.endswith(".golden")]
|
|
79
|
+
|
|
80
|
+
# Iterate over the test cases
|
|
81
|
+
for i, (input_file) in enumerate(test_files):
|
|
82
|
+
with test.subTest(file=input_file), tempfile.NamedTemporaryFile("w+") as output_file:
|
|
83
|
+
# Get the golden file
|
|
84
|
+
golden_file = _get_golden_filename(input_file)
|
|
85
|
+
|
|
86
|
+
# Run the command
|
|
87
|
+
exit_code = execute(input_file, output_file.name, configuration.run_configuration)
|
|
88
|
+
|
|
89
|
+
# Assert the exit code
|
|
90
|
+
if configuration.run_validation_configuration.validate_exit_code:
|
|
91
|
+
test.assertEqual(
|
|
92
|
+
exit_code,
|
|
93
|
+
configuration.run_validation_configuration.expected_exit_code,
|
|
94
|
+
f"Expected exit code {configuration.run_validation_configuration.expected_exit_code}, but got {exit_code}.",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Update the golden file if necessary
|
|
98
|
+
if UPDATE:
|
|
99
|
+
with open(golden_file, "w") as f:
|
|
100
|
+
f.write(output_file.read())
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# Compare the actual and golden files
|
|
104
|
+
equal, message, differences = compare(output_file.name, golden_file, configuration.comparison_configuration)
|
|
105
|
+
# Prepare the message
|
|
106
|
+
if differences:
|
|
107
|
+
message += "\n" + "\n".join(
|
|
108
|
+
[f"{d.location}: {d.message} ({d.expected} != {d.actual})" for d in differences]
|
|
109
|
+
)
|
|
110
|
+
# Assert the comparison
|
|
111
|
+
test.assertTrue(equal, message)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InputMode(Enum):
|
|
7
|
+
STDIN = "stdin"
|
|
8
|
+
"""Feeds the input file via stdin."""
|
|
9
|
+
NONE = "none"
|
|
10
|
+
"""Does not feed anything to the stdin."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OutputMode(Enum):
|
|
14
|
+
STDOUT = "stdout"
|
|
15
|
+
"""Intercepts only stdout."""
|
|
16
|
+
STDERR = "stderr"
|
|
17
|
+
"""Intercepts only stderr."""
|
|
18
|
+
BOTH = "both"
|
|
19
|
+
"""Intercepts both stdout and stderr."""
|
|
20
|
+
NONE = "none"
|
|
21
|
+
"""Does not intercept anything, i.e., ignores the output."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ConfigRun:
|
|
26
|
+
"""Configuration for running a command."""
|
|
27
|
+
|
|
28
|
+
cmd: str
|
|
29
|
+
"""The command to run."""
|
|
30
|
+
args: list[str]
|
|
31
|
+
"""
|
|
32
|
+
The arguments to pass to the command.
|
|
33
|
+
If the path to the input file is needed (instead of feeding via stdin), use the string "{input}".
|
|
34
|
+
If the path to the output file is needed (instead of reading stdout), use the string "{output}".
|
|
35
|
+
"""
|
|
36
|
+
input_mode: InputMode = InputMode.STDIN
|
|
37
|
+
"""The input mode."""
|
|
38
|
+
output_mode: OutputMode = OutputMode.STDOUT
|
|
39
|
+
"""The output mode."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ConfigRunValidation:
|
|
44
|
+
"""Configuration for validating the command"""
|
|
45
|
+
|
|
46
|
+
validate_exit_code: bool = True
|
|
47
|
+
"""Whether to validate the exit code of the command."""
|
|
48
|
+
expected_exit_code: int = 0
|
|
49
|
+
"""The desired exit code of the command."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def execute(
|
|
53
|
+
input_file: str,
|
|
54
|
+
output_file: str,
|
|
55
|
+
configuration: ConfigRun,
|
|
56
|
+
) -> int:
|
|
57
|
+
"""
|
|
58
|
+
Run the command with the input file and return the result.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
input_file : str
|
|
63
|
+
The file to read the input from.
|
|
64
|
+
output_file : str
|
|
65
|
+
The file to write the output to.
|
|
66
|
+
configuration : ConfigRun
|
|
67
|
+
The configuration for running the command.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
int
|
|
72
|
+
The exit code of the command.
|
|
73
|
+
"""
|
|
74
|
+
# Replace the placeholders in the arguments
|
|
75
|
+
args = [arg.format(input=input_file, output=output_file) for arg in configuration.args]
|
|
76
|
+
|
|
77
|
+
# Run the command
|
|
78
|
+
with open(output_file, "w") as f:
|
|
79
|
+
input_file = None if configuration.input_mode == InputMode.NONE else open(input_file, "r")
|
|
80
|
+
process = subprocess.run(
|
|
81
|
+
[configuration.cmd, *args],
|
|
82
|
+
stdin=input_file if configuration.input_mode == InputMode.STDIN else None,
|
|
83
|
+
stdout=f if configuration.output_mode in [OutputMode.STDOUT, OutputMode.BOTH] else None,
|
|
84
|
+
stderr=f if configuration.output_mode in [OutputMode.STDERR, OutputMode.BOTH] else None,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Close the input file if necessary
|
|
88
|
+
if input_file is not None:
|
|
89
|
+
input_file.close()
|
|
90
|
+
|
|
91
|
+
# Return the exit code and the path to the output file
|
|
92
|
+
return process.returncode
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
import goldie.diff
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _file_path(relative_path: str) -> str:
|
|
8
|
+
"""
|
|
9
|
+
Returns the full path to a file relative to the testdata directory.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
relative_location : str
|
|
14
|
+
The relative location of the file (e.g.: just the file name).
|
|
15
|
+
|
|
16
|
+
Returns
|
|
17
|
+
-------
|
|
18
|
+
str
|
|
19
|
+
The full path to the file.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
return os.path.join(os.path.dirname(__file__), "testdata", relative_path)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestDiff(unittest.TestCase):
|
|
26
|
+
def test_diff(self):
|
|
27
|
+
with open(_file_path("comparison-1.txt")) as f:
|
|
28
|
+
comparison_1 = f.read()
|
|
29
|
+
with open(_file_path("comparison-2.txt")) as f:
|
|
30
|
+
comparison_2 = f.read()
|
|
31
|
+
|
|
32
|
+
diff = goldie.diff.diff_color_code_unified(comparison_1, comparison_2)
|
|
33
|
+
self.assertEquals(diff, "", diff)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
In a quiet village, where the sunbeams play, there's a charm that whispers, "Stay, don't stray."
|
|
2
|
+
Beneath the willow, by the babbling brook, secrets shared in shadows, in a well-worn book.
|
|
3
|
+
The baker's oven fills the air so sweet, as children laugh and chase in the cobblestone street.
|
|
4
|
+
A painter captures hues of twilight's glow, while lovers dance in soft, enchanting flow.
|
|
5
|
+
The market bustles with a lively tune, fresh blooms and treasures under the afternoon sun.
|
|
6
|
+
An old man tells tales of days gone by, his eyes alight with memories that never die.
|
|
7
|
+
The village bell rings, echoing so clear, a call to gather, friends both far and near.
|
|
8
|
+
In gardens lush, where roses softly bloom, a sense of peace and calm dispels all gloom.
|
|
9
|
+
As stars above twinkle in the night, the village dreams, bathed in soft moonlight.
|
|
10
|
+
With every dawn, a new day's promise gleams, in this charming haven, where the heart finds dreams.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
In a quiet village, where the sunbeams play, there's a charm that whispers, "Stay, don't stray."
|
|
2
|
+
Beneath the willow, by the babbling brook, secrets shared in shadows, in a well-worn book.
|
|
3
|
+
The baker's oven fills the air so sweet, as children laugh and chase in the cobblestone street.
|
|
4
|
+
A painter captures hues of twilight's glow, while lovers dance in soft, enchanting flow.
|
|
5
|
+
The market bustles with a lively tune, fresh blooms and treasures under the afternoon sun.
|
|
6
|
+
An old man tells tales of days gone by, his eyes alight with memories that never die.
|
|
7
|
+
The village bell rings, echoing so clear, a call to gather, friends both far and near.
|
|
8
|
+
In gardens lush, where roses softly bloom, a sense of peace and calm dispels all gloom.
|
|
9
|
+
As stars above twinkle in the night, the village dreams, bathed in soft sunlight.
|
|
10
|
+
With every dawn, a new day's promise gleams, in this charming haven, where the heart finds dreams.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
build-backend = "hatchling.build"
|
|
3
|
+
requires = ["hatchling >= 1.13.0"]
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
authors = [
|
|
7
|
+
{ email = "merschformann@proton.me", name = "Marius Merschformann" }
|
|
8
|
+
]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"License :: OSI Approved :: Apache Software License",
|
|
11
|
+
"Operating System :: OS Independent",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3.11",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13"
|
|
16
|
+
]
|
|
17
|
+
dependencies = []
|
|
18
|
+
description = "A humble library greasing the gears of golden file tests"
|
|
19
|
+
dynamic = [
|
|
20
|
+
"version",
|
|
21
|
+
]
|
|
22
|
+
keywords = [
|
|
23
|
+
"golden file",
|
|
24
|
+
"testing",
|
|
25
|
+
"test",
|
|
26
|
+
"integration test",
|
|
27
|
+
]
|
|
28
|
+
license = { file = "LICENSE" }
|
|
29
|
+
maintainers = [
|
|
30
|
+
{ email = "merschformann@proton.me", name = "Marius Merschformann" }
|
|
31
|
+
]
|
|
32
|
+
name = "goldie"
|
|
33
|
+
readme = "README.md"
|
|
34
|
+
requires-python = ">=3.11"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/merschformann/goldie"
|
|
38
|
+
Repository = "https://github.com/merschformann/goldie"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.version]
|
|
41
|
+
path = "goldie/__about__.py"
|