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.
@@ -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
@@ -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,3 @@
1
+ # goldie
2
+
3
+ A humble library greasing the gears of golden file tests.
@@ -0,0 +1,3 @@
1
+ {
2
+ "message": "Hello, goldie!"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "message": "Hello, goldie!"
3
+ }
@@ -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,6 @@
1
+ import os
2
+
3
+ UPDATE = os.environ.get("GOLDIE_UPDATE", "false").lower() in ["true", "1"]
4
+ """
5
+ Whether to update the golden files.
6
+ """
@@ -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"