├── lib ├── __init__.py └── fdiff │ ├── __init__.py │ ├── exceptions.py │ ├── textiter.py │ ├── aio.py │ ├── utils.py │ ├── color.py │ ├── remote.py │ ├── __main__.py │ └── diff.py ├── tests ├── __init__.py ├── testfiles │ ├── test.txt │ ├── Roboto-Regular.subset1.ttf │ ├── Roboto-Regular.subset2.ttf │ ├── ROBOTO_LICENSE.md │ ├── roboto_udiff_headonly_expected.txt │ ├── roboto_udiff_headpostonly_expected.txt │ ├── roboto_udiff_ex_headpost_expected.txt │ ├── roboto_udiff_1context_expected.txt │ ├── diff-exe-results.txt │ ├── roboto_udiff_expected.txt │ ├── roboto_extdiff_expected.txt │ ├── roboto_udiff_color_expected.txt │ └── roboto_extdiff_color_expected.txt ├── test_exceptions.py ├── test_aio.py ├── test_utils.py ├── test_textiter.py ├── profiler.py ├── test_color.py ├── test_diff_unix_only.py ├── test_remote.py ├── test_main_unix_only.py ├── test_diff.py └── test_main.py ├── MANIFEST.in ├── .github ├── dependabot.yml └── workflows │ ├── py-typecheck.yml │ ├── py-lint.yml │ ├── publish-release.yml │ ├── py-ci.yml │ ├── py-coverage.yml │ └── codeql-analysis.yml ├── tox.ini ├── setup.cfg ├── codecov.yml ├── requirements.txt ├── Makefile ├── .gitignore ├── setup.py ├── CHANGELOG.md ├── README.md └── LICENSE /lib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testfiles/test.txt: -------------------------------------------------------------------------------- 1 | This is a test file -------------------------------------------------------------------------------- /lib/fdiff/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | version = __version__ = "3.0.0" 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md 2 | include LICENSE 3 | include README.md 4 | 5 | include *requirements.txt -------------------------------------------------------------------------------- /lib/fdiff/exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | 4 | class AIOError(Exception): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/testfiles/Roboto-Regular.subset1.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/source-foundry/fdiff/HEAD/tests/testfiles/Roboto-Regular.subset1.ttf -------------------------------------------------------------------------------- /tests/testfiles/Roboto-Regular.subset2.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/source-foundry/fdiff/HEAD/tests/testfiles/Roboto-Regular.subset2.ttf -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py310 3 | 4 | [testenv] 5 | commands = 6 | py.test 7 | deps = 8 | -rrequirements.txt 9 | pytest 10 | pytest-asyncio 11 | -------------------------------------------------------------------------------- /tests/testfiles/ROBOTO_LICENSE.md: -------------------------------------------------------------------------------- 1 | The modified Roboto font test files in this repository are released under the [Apache License v2.0](https://github.com/google/roboto/blob/master/LICENSE). -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 0 3 | 4 | [pytype] 5 | disable = 6 | import-error,attribute-error 7 | 8 | 9 | [tool:pytest] 10 | filterwarnings = 11 | ignore:tostring:DeprecationWarning 12 | ignore:fromstring:DeprecationWarning 13 | 14 | [flake8] 15 | max-line-length = 90 -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | max_report_age: off 3 | 4 | coverage: 5 | status: 6 | project: 7 | default: 8 | # basic 9 | target: auto 10 | threshold: 2% 11 | base: auto 12 | 13 | ignore: 14 | - "lib/fdiff/thirdparty" 15 | 16 | comment: off 17 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | from fdiff.exceptions import AIOError 2 | 3 | import pytest 4 | 5 | 6 | def raise_aioerror(message): 7 | raise AIOError(message) 8 | 9 | 10 | def test_aioerror_raises(): 11 | with pytest.raises(AIOError) as e: 12 | raise_aioerror("Test message") 13 | 14 | assert str(e.value) == "Test message" 15 | 16 | 17 | -------------------------------------------------------------------------------- /lib/fdiff/textiter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from collections import deque 4 | from itertools import islice 5 | 6 | 7 | def head(iterable, n): 8 | """Returns the first n indices of `iterable` as an iterable.""" 9 | return islice(iterable, n) 10 | 11 | 12 | def tail(iterable, n): 13 | """Returns the last n indices of `iterable` as an iterable.""" 14 | return iter(deque(iterable, maxlen=n)) 15 | -------------------------------------------------------------------------------- /lib/fdiff/aio.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | from typing import AnyStr, Text, Union 5 | 6 | import aiofiles # type: ignore 7 | 8 | 9 | async def async_write_bin( 10 | path: Union[AnyStr, "os.PathLike[Text]"], binary: bytes 11 | ) -> None: 12 | """Asynchronous IO writes of binary data `binary` to disk on the file path `path`""" 13 | async with aiofiles.open(path, "wb") as f: # type: ignore 14 | await f.write(binary) 15 | -------------------------------------------------------------------------------- /tests/test_aio.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | import pytest 5 | 6 | from fdiff.aio import async_write_bin 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_async_write(): 11 | with tempfile.TemporaryDirectory() as tmpdirname: 12 | test_path = os.path.join(tmpdirname, "test.bin") 13 | await async_write_bin(test_path, b"test") 14 | assert os.path.exists(test_path) is True 15 | with open(test_path, "rb") as f: 16 | res = f.read() 17 | assert res == b"test" 18 | -------------------------------------------------------------------------------- /.github/workflows/py-typecheck.yml: -------------------------------------------------------------------------------- 1 | name: Python Type Checks 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest] 11 | python-version: ["3.10"] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install Python testing dependencies 20 | run: pip install --upgrade mypy 21 | - name: Static type checks 22 | run: mypy lib/fdiff 23 | -------------------------------------------------------------------------------- /.github/workflows/py-lint.yml: -------------------------------------------------------------------------------- 1 | name: Python Lints 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest] 11 | python-version: ["3.10"] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install Python testing dependencies 20 | run: pip install --upgrade flake8 21 | - name: flake8 Lint 22 | uses: py-actions/flake8@v1 23 | with: 24 | max-line-length: "90" 25 | path: "lib/fdiff" 26 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile 6 | # 7 | aiodns==3.0.0 8 | # via fdiff (setup.py) 9 | aiofiles==0.8.0 10 | # via fdiff (setup.py) 11 | aiohttp==3.7.4.post0 12 | # via fdiff (setup.py) 13 | async-timeout==3.0.1 14 | # via aiohttp 15 | attrs==22.1.0 16 | # via aiohttp 17 | cffi==1.15.1 18 | # via pycares 19 | chardet==4.0.0 20 | # via aiohttp 21 | colorama==0.4.5 22 | # via rich 23 | commonmark==0.9.1 24 | # via rich 25 | fonttools==4.34.4 26 | # via fdiff (setup.py) 27 | idna==3.3 28 | # via yarl 29 | multidict==6.0.2 30 | # via 31 | # aiohttp 32 | # yarl 33 | pycares==4.2.1 34 | # via aiodns 35 | pycparser==2.21 36 | # via cffi 37 | pygments==2.12.0 38 | # via rich 39 | rich==12.5.1 40 | # via fdiff (setup.py) 41 | typing-extensions==4.3.0 42 | # via aiohttp 43 | yarl==1.7.2 44 | # via aiohttp 45 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: install 2 | 3 | black: 4 | black -l 90 lib/fdiff/*.py 5 | 6 | clean: 7 | - rm dist/*.whl dist/*.tar.gz dist/*.zip 8 | 9 | dist-build: clean 10 | python3 setup.py sdist bdist_wheel 11 | 12 | dist-push: 13 | twine upload dist/*.whl dist/*.tar.gz 14 | 15 | install: 16 | pip3 install --ignore-installed -r requirements.txt . 17 | 18 | install-dev: 19 | pip3 install --ignore-installed -r requirements.txt -e ".[dev]" 20 | 21 | install-user: 22 | pip3 install --ignore-installed --user . 23 | 24 | test: test-lint test-type-check test-unit 25 | 26 | test-coverage: 27 | coverage run --source fdiff -m py.test 28 | coverage report -m 29 | # coverage html 30 | 31 | test-lint: 32 | flake8 --ignore=W50 lib/fdiff 33 | 34 | test-type-check: 35 | mypy lib/fdiff 36 | 37 | test-unit: 38 | tox 39 | 40 | uninstall: 41 | pip3 uninstall --yes fdiff 42 | 43 | .PHONY: all black clean dist-build dist-push install install-dev install-user test test-lint test-type-check test-unit uninstall -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from fdiff.utils import get_file_modtime, get_tables_argument_list, file_exists 5 | 6 | import pytest 7 | 8 | 9 | def test_file_exists_true(): 10 | assert file_exists(os.path.join("tests", "testfiles", "test.txt")) is True 11 | 12 | 13 | def test_file_exists_false(): 14 | assert file_exists(os.path.join("tests", "testfiles", "bogus.jpg")) is False 15 | 16 | 17 | def test_get_file_modtime(): 18 | modtime = get_file_modtime(os.path.join("tests", "testfiles", "test.txt")) 19 | regex = re.compile(r"""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+[-+]\d{2}:\d{2}""") 20 | assert regex.fullmatch(modtime) is not None 21 | 22 | 23 | def test_get_tables_argument_list(): 24 | string1 = "head" 25 | string2 = "head,post" 26 | string3 = "head,post,cvt" 27 | assert get_tables_argument_list(string1) == ["head"] 28 | assert get_tables_argument_list(string2) == ["head", "post"] 29 | assert get_tables_argument_list(string3) == ["head", "post", "cvt "] 30 | -------------------------------------------------------------------------------- /tests/test_textiter.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import pytest 4 | 5 | from fdiff.textiter import head, tail 6 | 7 | text_list = [ 8 | "line 1", 9 | "line 2", 10 | "line 3", 11 | "line 4", 12 | "line 5" 13 | ] 14 | 15 | 16 | def test_head(): 17 | head_res = head(text_list, 2) 18 | assert len(list(head_res)) == 2 19 | for x, line in enumerate(head_res): 20 | assert line == text_list[x] 21 | 22 | 23 | def test_head_request_more_than_available(): 24 | head_res = head(text_list, 6) 25 | assert len(list(head_res)) == 5 26 | for x, line in enumerate(head_res): 27 | assert line == text_list[x] 28 | 29 | 30 | def test_tail(): 31 | tail_res = tail(text_list, 2) 32 | assert len(list(tail_res)) == 2 33 | offset = 3 34 | for x, line in enumerate(tail_res): 35 | assert line == text_list[offset + x] 36 | 37 | 38 | def test_tail_request_more_than_available(): 39 | tail_res = tail(text_list, 6) 40 | assert len(list(tail_res)) == 5 41 | for x, line in enumerate(tail_res): 42 | assert line == text_list[x] -------------------------------------------------------------------------------- /tests/testfiles/roboto_udiff_headonly_expected.txt: -------------------------------------------------------------------------------- 1 | --- tests/testfiles/Roboto-Regular.subset1.ttf 2019-09-05T14:04:24.748302-04:00 2 | +++ tests/testfiles/Roboto-Regular.subset2.ttf 2019-09-05T14:07:27.594299-04:00 3 | @@ -5,18 +5,18 @@ 4 | 5 | 6 | 7 | - 8 | + 9 | 10 | 11 | 12 | 13 | - 14 | - 15 | - 16 | - 17 | - 18 | + 19 | + 20 | + 21 | + 22 | + 23 | 24 | - 25 | + 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /lib/fdiff/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | from datetime import datetime, timezone 5 | from typing import List, Optional, Text, Union 6 | 7 | 8 | def file_exists(path: Union[bytes, str, "os.PathLike[Text]"]) -> bool: 9 | """Validates file path as existing local file""" 10 | return os.path.isfile(path) 11 | 12 | 13 | def get_file_modtime(path: Union[bytes, str, "os.PathLike[Text]"]) -> Text: 14 | """Returns ISO formatted file modification time in local system timezone""" 15 | return ( 16 | datetime.fromtimestamp(os.stat(path).st_mtime, timezone.utc) 17 | .astimezone() 18 | .isoformat() 19 | ) 20 | 21 | 22 | def get_tables_argument_list(table_string: Optional[Text]) -> Optional[List[Text]]: 23 | """Converts a comma separated OpenType table string into a Python list or 24 | return None if the table_string was not defined (i.e., it was not included 25 | in an option on the command line). Tables that are composed of three 26 | characters must be right padded with a space.""" 27 | if table_string is None: 28 | return None 29 | else: 30 | return [ 31 | table + " " if len(table) == 3 else table for table in table_string.split(",") 32 | ] 33 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Sequence of patterns matched against refs/tags 4 | tags: 5 | - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 6 | 7 | name: Create and Publish Release 8 | 9 | jobs: 10 | build: 11 | name: Create and Publish Release 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.10"] 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install --upgrade setuptools wheel twine 26 | - name: Create GitHub release 27 | id: create_release 28 | uses: actions/create-release@v1 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | with: 32 | tag_name: ${{ github.ref }} 33 | release_name: ${{ github.ref }} 34 | body: | 35 | Please see the root of the repository for the CHANGELOG.md 36 | draft: false 37 | prerelease: false 38 | - name: Build and publish to PyPI 39 | env: 40 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 41 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 42 | run: | 43 | make dist-build 44 | twine upload dist/* 45 | -------------------------------------------------------------------------------- /tests/profiler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import cProfile 5 | import pstats 6 | from io import StringIO 7 | 8 | 9 | def profile(): 10 | # ------------------------------------------------------------------------------ 11 | # Setup a profile 12 | # ------------------------------------------------------------------------------ 13 | pr = cProfile.Profile() 14 | # ------------------------------------------------------------------------------ 15 | # Enter setup code below 16 | # ------------------------------------------------------------------------------ 17 | # Optional: include setup code here 18 | 19 | # ------------------------------------------------------------------------------ 20 | # Start profiler 21 | # ------------------------------------------------------------------------------ 22 | pr.enable() 23 | 24 | # ------------------------------------------------------------------------------ 25 | # BEGIN profiled code block 26 | # ------------------------------------------------------------------------------ 27 | 28 | # ------------------------------------------------------------------------------ 29 | # END profiled code block 30 | # ------------------------------------------------------------------------------ 31 | pr.disable() 32 | s = StringIO() 33 | sortby = "cumulative" 34 | ps = pstats.Stats(pr, stream=s).sort_stats(sortby) 35 | ps.strip_dirs().sort_stats("time").print_stats() 36 | print(s.getvalue()) 37 | 38 | 39 | if __name__ == "__main__": 40 | profile() 41 | -------------------------------------------------------------------------------- /lib/fdiff/color.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from typing import Dict, Text 4 | 5 | ansicolors: Dict[Text, Text] = { 6 | "BLACK": "\033[30m", 7 | "RED": "\033[31m", 8 | "GREEN": "\033[32m", 9 | "YELLOW": "\033[33m", 10 | "BLUE": "\033[34m", 11 | "MAGENTA": "\033[35m", 12 | "CYAN": "\033[36m", 13 | "WHITE": "\033[37m", 14 | "BOLD": "\033[1m", 15 | "RESET": "\033[0m", 16 | } 17 | 18 | green_start: Text = ansicolors["GREEN"] 19 | red_start: Text = ansicolors["RED"] 20 | cyan_start: Text = ansicolors["CYAN"] 21 | reset: Text = ansicolors["RESET"] 22 | 23 | 24 | def color_unified_diff_line(line: Text) -> Text: 25 | """Returns an ANSI escape code colored string with color based 26 | on the unified diff line type.""" 27 | if line[0:2] == "+ ": 28 | return f"{green_start}{line}{reset}" 29 | elif line == "+\n": 30 | # some lines are formatted as hyphen only with no other characters 31 | # this indicates an added empty line 32 | return f"{green_start}{line}{reset}" 33 | elif line[0:2] == "- ": 34 | return f"{red_start}{line}{reset}" 35 | elif line == "-\n": 36 | # some lines are formatted as hyphen only with no other characters 37 | # this indicates a deleted empty line 38 | return f"{red_start}{line}{reset}" 39 | elif line[0:3] == "@@ ": 40 | return f"{cyan_start}{line}{reset}" 41 | elif line[0:4] == "--- ": 42 | return f"{red_start}{line}{reset}" 43 | elif line[0:4] == "+++ ": 44 | return f"{green_start}{line}{reset}" 45 | else: 46 | return line 47 | -------------------------------------------------------------------------------- /.github/workflows/py-ci.yml: -------------------------------------------------------------------------------- 1 | name: Python CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, macos-latest, windows-latest] 11 | python-version: ["3.7", "3.8", "3.9", "3.10"] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Display Python version & architecture 20 | run: | 21 | python -c "import sys; print(sys.version)" 22 | python -c "import struct; print(struct.calcsize('P') * 8)" 23 | - name: Get pip cache dir 24 | id: pip-cache 25 | run: | 26 | pip install --upgrade pip 27 | echo "::set-output name=dir::$(pip cache dir)" 28 | - name: pip cache 29 | uses: actions/cache@v2 30 | with: 31 | path: ${{ steps.pip-cache.outputs.dir }} 32 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} 33 | restore-keys: | 34 | ${{ runner.os }}-pip- 35 | - name: Install Python testing dependencies 36 | run: pip install --upgrade pytest pytest-asyncio 37 | - name: Install Python project dependencies 38 | uses: py-actions/py-dependency-install@v2 39 | with: 40 | update-pip: "true" 41 | update-setuptools: "true" 42 | update-wheel: "true" 43 | - name: Install project 44 | run: pip install -r requirements.txt . 45 | - name: Python unit tests 46 | run: pytest 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 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 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | MANIFEST 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | .pytest_cache/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | db.sqlite3 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # Environments 84 | .env 85 | .venv 86 | env/ 87 | venv/ 88 | ENV/ 89 | env.bak/ 90 | venv.bak/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | .spyproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | 99 | # mkdocs documentation 100 | /site 101 | 102 | # mypy 103 | .mypy_cache/ 104 | 105 | # macOS specific 106 | .DS_Store 107 | 108 | # PyCharm 109 | .idea 110 | 111 | # pytype 112 | .pytype 113 | 114 | # VSCode 115 | .vscode -------------------------------------------------------------------------------- /.github/workflows/py-coverage.yml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest] 11 | python-version: ["3.10"] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Display Python version & architecture 20 | run: | 21 | python -c "import sys; print(sys.version)" 22 | python -c "import struct; print(struct.calcsize('P') * 8)" 23 | - name: Get pip cache dir 24 | id: pip-cache 25 | run: | 26 | pip install --upgrade pip 27 | echo "::set-output name=dir::$(pip cache dir)" 28 | - name: pip cache 29 | uses: actions/cache@v2 30 | with: 31 | path: ${{ steps.pip-cache.outputs.dir }} 32 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} 33 | restore-keys: | 34 | ${{ runner.os }}-pip- 35 | - name: Install Python testing dependencies 36 | run: pip install --upgrade pytest pytest-asyncio 37 | - name: Install Python project dependencies 38 | uses: py-actions/py-dependency-install@v2 39 | with: 40 | update-pip: "true" 41 | update-setuptools: "true" 42 | update-wheel: "true" 43 | - name: Install project 44 | run: pip install . 45 | - name: Generate coverage report 46 | run: | 47 | pip install --upgrade coverage 48 | coverage run --source fdiff -m py.test 49 | coverage report -m 50 | coverage xml 51 | - name: Upload coverage to Codecov 52 | uses: codecov/codecov-action@v2 53 | with: 54 | file: ./coverage.xml 55 | -------------------------------------------------------------------------------- /tests/test_color.py: -------------------------------------------------------------------------------- 1 | from fdiff.color import color_unified_diff_line 2 | 3 | import pytest 4 | 5 | TEST_LINE_PLUS = "+ A line with a plus" 6 | TEST_LINE_MINUS = "- A line with a minus" 7 | TEST_LINE_AT = "@@ -69705,5 +30839,3 @@" 8 | TEST_LINE_PLUS3 = "+++ /Users/chris/Desktop/tests/dehinter-tests/Ubuntu-Regular-dehinted.ttf 2019-08-22T19:04:40.307911-04:00" 9 | TEST_LINE_MINUS3 = "--- /Users/chris/Desktop/tests/dehinter-tests/Ubuntu-Regular.ttf 2010-12-15T00:00:00-05:00" 10 | TEST_LINE_CONTEXT = " This is a line of context" 11 | 12 | 13 | def test_color_unified_diff_line_addition(): 14 | line_response = color_unified_diff_line(TEST_LINE_PLUS) 15 | assert line_response.startswith("\033[32m") is True 16 | assert line_response.endswith("\033[0m") is True 17 | 18 | 19 | def test_color_unified_diff_line_removal(): 20 | line_response = color_unified_diff_line(TEST_LINE_MINUS) 21 | assert line_response.startswith("\033[31m") is True 22 | assert line_response.endswith("\033[0m") is True 23 | 24 | 25 | def test_color_unified_diff_line_range(): 26 | line_response = color_unified_diff_line(TEST_LINE_AT) 27 | assert line_response.startswith("\033[36m") is True 28 | assert line_response.endswith("\033[0m") is True 29 | 30 | 31 | def test_color_unified_diff_line_left_file(): 32 | line_response = color_unified_diff_line(TEST_LINE_MINUS3) 33 | assert line_response.startswith("\033[31m") is True 34 | assert line_response.endswith("\033[0m") is True 35 | 36 | 37 | def test_color_unified_diff_line_right_file(): 38 | line_response = color_unified_diff_line(TEST_LINE_PLUS3) 39 | assert line_response.startswith("\033[32m") is True 40 | assert line_response.endswith("\033[0m") is True 41 | 42 | 43 | def test_color_unified_diff_context_line(): 44 | line_response = color_unified_diff_line(TEST_LINE_CONTEXT) 45 | assert line_response.startswith("\033") is False 46 | assert line_response.endswith("\033") is False 47 | assert line_response == TEST_LINE_CONTEXT 48 | -------------------------------------------------------------------------------- /tests/testfiles/roboto_udiff_headpostonly_expected.txt: -------------------------------------------------------------------------------- 1 | --- tests/testfiles/Roboto-Regular.subset1.ttf 2019-09-05T14:04:24.748302-04:00 2 | +++ tests/testfiles/Roboto-Regular.subset2.ttf 2019-09-05T14:07:27.594299-04:00 3 | @@ -5,48 +5,21 @@ 4 | 5 | 6 | 7 | - 8 | + 9 | 10 | 11 | 12 | 13 | - 14 | - 15 | - 16 | - 17 | - 18 | + 19 | + 20 | + 21 | + 22 | + 23 | 24 | - 25 | + 26 | 27 | 28 | 29 | 30 | 31 | - 32 | - 33 | - 34 | - 35 | - 36 | - 37 | - 38 | - 39 | - 40 | - 41 | - 42 | - 49 | - 50 | - 51 | - 52 | - 53 | - 54 | - 55 | - 56 | - 57 | - 58 | 59 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: "0 10 * * 0" 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # Override automatic language detection by changing the below list 21 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 22 | language: ["python"] 23 | # Learn more... 24 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | with: 30 | # We must fetch at least the immediate parents so that if this is 31 | # a pull request then we can checkout the head. 32 | fetch-depth: 2 33 | 34 | # If this run was triggered by a pull request event, then checkout 35 | # the head of the pull request instead of the merge commit. 36 | - run: git checkout HEAD^2 37 | if: ${{ github.event_name == 'pull_request' }} 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v1 42 | with: 43 | languages: ${{ matrix.language }} 44 | # If you wish to specify custom queries, you can do so here or in a config file. 45 | # By default, queries listed here will override any specified in a config file. 46 | # Prefix the list here with "+" to use these queries and those in the config file. 47 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 48 | 49 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 50 | # If this step fails, then you should remove it and run the build manually (see below) 51 | - name: Autobuild 52 | uses: github/codeql-action/autobuild@v1 53 | 54 | # ℹ️ Command-line programs to run using the OS shell. 55 | # 📚 https://git.io/JvXDl 56 | 57 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 58 | # and modify them (or add more) to build your code if your project 59 | # uses a compiled language 60 | 61 | #- run: | 62 | # make bootstrap 63 | # make release 64 | 65 | - name: Perform CodeQL Analysis 66 | uses: github/codeql-action/analyze@v1 67 | -------------------------------------------------------------------------------- /lib/fdiff/remote.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | import os.path 5 | import urllib.parse 6 | 7 | # from collections import namedtuple 8 | from typing import Any, AnyStr, List, NamedTuple, Optional, Text, Tuple 9 | 10 | import aiohttp # type: ignore 11 | 12 | from .aio import async_write_bin 13 | 14 | 15 | class FWRes(NamedTuple): 16 | url: Text 17 | filepath: Optional[Text] 18 | http_status: int 19 | write_success: bool 20 | 21 | 22 | def _get_filepath_from_url(url: Text, dirpath: Text) -> Text: 23 | """Returns filepath from base file name in URL and directory path.""" 24 | url_path_list = urllib.parse.urlsplit(url) 25 | abs_filepath = url_path_list.path 26 | basepath = os.path.split(abs_filepath)[-1] 27 | return os.path.join(dirpath, basepath) 28 | 29 | 30 | async def async_fetch(session: Any, url: AnyStr) -> Tuple[AnyStr, Any, Any]: 31 | """Asynchronous I/O HTTP GET request with a ClientSession instantiated 32 | from the aiohttp library.""" 33 | async with session.get(url) as response: 34 | status = response.status 35 | if status != 200: 36 | binary = None 37 | else: 38 | binary = await response.read() 39 | return url, status, binary 40 | 41 | 42 | async def async_fetch_and_write(session: Any, url: Text, dirpath: Text) -> FWRes: 43 | """Asynchronous I/O HTTP GET request with a ClientSession instantiated 44 | from the aiohttp library, followed by an asynchronous I/O file write of 45 | the binary to disk with the aiofiles library. 46 | 47 | :returns `FWRes` namedtuple with url, filepath, http_status, write_success fields""" 48 | url, status, binary = await async_fetch(session, url) 49 | if status != 200: 50 | filepath = None 51 | write_success = False 52 | else: 53 | filepath = _get_filepath_from_url(url, dirpath) 54 | await async_write_bin(filepath, binary) 55 | write_success = True 56 | 57 | return FWRes( 58 | url=url, filepath=filepath, http_status=status, write_success=write_success 59 | ) 60 | 61 | 62 | async def create_async_get_request_session_and_run( 63 | urls: List[Text], dirpath: Text 64 | ) -> List[Any]: 65 | """Creates an aiohttp library ClientSession and performs asynchronous GET requests + 66 | binary file writes with the binary response from the GET request. 67 | 68 | :returns list of asyncio Tasks that include `FWRes` namedtuple instances 69 | (defined in async_fetch_and_write)""" 70 | async with aiohttp.ClientSession() as session: 71 | tasks = [] 72 | for url in urls: 73 | # use asyncio.ensure_future instead of .run() here to maintain 74 | # Py3.6 compatibility 75 | task = asyncio.ensure_future(async_fetch_and_write(session, url, dirpath)) 76 | tasks.append(task) 77 | await asyncio.gather(*tasks, return_exceptions=True) 78 | return tasks 79 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import sys 4 | from setuptools import setup, find_packages 5 | 6 | # Package meta-data. 7 | NAME = "fdiff" 8 | DESCRIPTION = "An OpenType table diff tool for fonts" 9 | LICENSE = "Apache License v2.0" 10 | URL = "https://github.com/source-foundry/fdiff" 11 | EMAIL = "chris@sourcefoundry.org" 12 | AUTHOR = "Source Foundry Authors" 13 | REQUIRES_PYTHON = ">=3.7.0" 14 | 15 | INSTALL_REQUIRES = [ 16 | "fontTools >= 4.0.0", 17 | "aiohttp >= 3.6.0", 18 | "aiodns >= 2.0.0", 19 | "aiofiles >= 0.4.0", 20 | "rich", 21 | ] 22 | # Optional packages 23 | EXTRAS_REQUIRES = { 24 | # for developer installs 25 | "dev": ["coverage", "pytest", "pytest-asyncio", "tox", "flake8", "mypy"], 26 | # for maintainer installs 27 | "maintain": ["wheel", "setuptools", "twine"], 28 | } 29 | 30 | this_file_path = os.path.abspath(os.path.dirname(__file__)) 31 | 32 | # Version 33 | main_namespace = {} 34 | version_fp = os.path.join(this_file_path, "lib", "fdiff", "__init__.py") 35 | try: 36 | with io.open(version_fp) as v: 37 | exec(v.read(), main_namespace) 38 | except IOError as version_e: 39 | sys.stderr.write( 40 | "[ERROR] setup.py: Failed to read the version data for the version definition: {}".format( 41 | str(version_e) 42 | ) 43 | ) 44 | raise version_e 45 | 46 | # Use repository Markdown README.md for PyPI long description 47 | try: 48 | with io.open("README.md", encoding="utf-8") as f: 49 | readme = f.read() 50 | except IOError as readme_e: 51 | sys.stderr.write( 52 | "[ERROR] setup.py: Failed to read the README.md file for the long description definition: {}".format( 53 | str(readme_e) 54 | ) 55 | ) 56 | raise readme_e 57 | 58 | setup( 59 | name=NAME, 60 | version=main_namespace["__version__"], 61 | description=DESCRIPTION, 62 | author=AUTHOR, 63 | author_email=EMAIL, 64 | url=URL, 65 | license=LICENSE, 66 | platforms=["Any"], 67 | long_description=readme, 68 | long_description_content_type="text/markdown", 69 | package_dir={"": "lib"}, 70 | packages=find_packages("lib"), 71 | include_package_data=True, 72 | install_requires=INSTALL_REQUIRES, 73 | extras_require=EXTRAS_REQUIRES, 74 | python_requires=REQUIRES_PYTHON, 75 | entry_points={"console_scripts": ["fdiff = fdiff.__main__:main"]}, 76 | classifiers=[ 77 | "Development Status :: 5 - Production/Stable", 78 | "Environment :: Console", 79 | "Intended Audience :: Developers", 80 | "Intended Audience :: End Users/Desktop", 81 | "License :: OSI Approved :: Apache Software License", 82 | "Natural Language :: English", 83 | "Operating System :: OS Independent", 84 | "Programming Language :: Python", 85 | "Programming Language :: Python :: 3", 86 | "Programming Language :: Python :: 3.7", 87 | "Programming Language :: Python :: 3.8", 88 | "Programming Language :: Python :: 3.9", 89 | "Programming Language :: Python :: 3.10", 90 | "Topic :: Multimedia", 91 | ], 92 | ) 93 | -------------------------------------------------------------------------------- /tests/test_diff_unix_only.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | 5 | from fdiff.diff import external_diff 6 | 7 | # these tests rely on a PATH install of `diff` executable on Unix 8 | # they are not executed on Windows platforms 9 | if sys.platform.startswith("win"): 10 | pytest.skip("skipping windows-only tests", allow_module_level=True) 11 | 12 | ROBOTO_BEFORE_PATH = os.path.join("tests", "testfiles", "Roboto-Regular.subset1.ttf") 13 | ROBOTO_AFTER_PATH = os.path.join("tests", "testfiles", "Roboto-Regular.subset2.ttf") 14 | ROBOTO_EXTDIFF_EXPECTED_PATH = os.path.join("tests", "testfiles", "roboto_udiff_expected.txt") 15 | ROBOTO_EXTDIFF_COLOR_EXPECTED_PATH = os.path.join("tests", "testfiles", "roboto_udiff_color_expected.txt") 16 | 17 | ROBOTO_BEFORE_URL = "https://github.com/source-foundry/fdiff/raw/master/tests/testfiles/Roboto-Regular.subset1.ttf" 18 | ROBOTO_AFTER_URL = "https://github.com/source-foundry/fdiff/raw/master/tests/testfiles/Roboto-Regular.subset2.ttf" 19 | 20 | 21 | # Setup: define the expected diff text for unified diff 22 | with open(ROBOTO_EXTDIFF_EXPECTED_PATH, "r") as robo_extdiff: 23 | ROBOTO_EXTDIFF_EXPECTED = robo_extdiff.read() 24 | 25 | # Setup: define the expected diff text for unified color diff 26 | with open(ROBOTO_EXTDIFF_COLOR_EXPECTED_PATH, "r") as robo_extdiff_color: 27 | ROBOTO_EXTDIFF_COLOR_EXPECTED = robo_extdiff_color.read() 28 | 29 | 30 | def test_external_diff_default(): 31 | res = external_diff("diff -u", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH) 32 | expected_string_list = ROBOTO_EXTDIFF_EXPECTED.split("\n") 33 | 34 | # have to handle the tests for the top two file path lines 35 | # differently than the rest of the comparisons because 36 | # the time is defined using local platform settings 37 | # which makes tests fail on remote CI testing services vs. 38 | # my local testing platform... 39 | for x, line in enumerate(res): 40 | # treat top two lines of the diff as comparison of first 3 chars only 41 | if x in (0, 1): 42 | assert line[0][0:2] == expected_string_list[x][0:2] 43 | elif x in range(2, 10): 44 | assert line[0] == expected_string_list[x] + os.linesep 45 | else: 46 | # skip lines beyond first 10 47 | pass 48 | 49 | 50 | def test_external_diff_without_mp_optimizations(): 51 | res = external_diff("diff -u", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH, use_multiprocess=False) 52 | expected_string_list = ROBOTO_EXTDIFF_EXPECTED.split("\n") 53 | 54 | # have to handle the tests for the top two file path lines 55 | # differently than the rest of the comparisons because 56 | # the time is defined using local platform settings 57 | # which makes tests fail on remote CI testing services vs. 58 | # my local testing platform... 59 | for x, line in enumerate(res): 60 | # treat top two lines of the diff as comparison of first 3 chars only 61 | if x in (0, 1): 62 | assert line[0][0:2] == expected_string_list[x][0:2] 63 | elif x in range(2, 10): 64 | assert line[0] == expected_string_list[x] + os.linesep 65 | else: 66 | # skip lines beyond first 10 67 | pass 68 | 69 | 70 | def test_external_diff_remote_fonts(): 71 | res = external_diff("diff -u", ROBOTO_BEFORE_URL, ROBOTO_AFTER_URL) 72 | expected_string_list = ROBOTO_EXTDIFF_EXPECTED.split("\n") 73 | 74 | # have to handle the tests for the top two file path lines 75 | # differently than the rest of the comparisons because 76 | # the time is defined using local platform settings 77 | # which makes tests fail on remote CI testing services vs. 78 | # my local testing platform... 79 | for x, line in enumerate(res): 80 | # treat top two lines of the diff as comparison of first 3 chars only 81 | if x in (0, 1): 82 | assert line[0][0:2] == expected_string_list[x][0:2] 83 | elif x in range(2, 10): 84 | assert line[0] == expected_string_list[x] + os.linesep 85 | else: 86 | # skip lines beyond first 10 87 | pass 88 | -------------------------------------------------------------------------------- /tests/test_remote.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | import pytest 5 | 6 | import aiohttp 7 | 8 | from fdiff.remote import _get_filepath_from_url, async_fetch, async_fetch_and_write, create_async_get_request_session_and_run 9 | 10 | REMOTE_FONT_1 = "https://github.com/source-foundry/fdiff/raw/master/tests/testfiles/Roboto-Regular.subset1.ttf" 11 | REMOTE_FONT_2 = "https://github.com/source-foundry/fdiff/raw/master/tests/testfiles/Roboto-Regular.subset2.ttf" 12 | 13 | URL_200 = "https://httpbin.org/status/200" 14 | URL_404 = "https://httpbin.org/status/404" 15 | 16 | 17 | def test_get_temp_filepath_from_url(): 18 | res = _get_filepath_from_url(REMOTE_FONT_1, os.path.join("test", "path")) 19 | assert res == os.path.join("test", "path", "Roboto-Regular.subset1.ttf") 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_async_fetch_200(): 24 | async with aiohttp.ClientSession() as session: 25 | url, status, binary = await async_fetch(session, URL_200) 26 | assert url == URL_200 27 | assert status == 200 28 | assert binary is not None 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_async_fetch_404(): 33 | async with aiohttp.ClientSession() as session: 34 | url, status, binary = await async_fetch(session, URL_404) 35 | assert url == URL_404 36 | assert status == 404 37 | assert binary is None 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_async_fetch_and_write_200(): 42 | with tempfile.TemporaryDirectory() as tmpdirname: 43 | async with aiohttp.ClientSession() as session: 44 | fwres = await async_fetch_and_write(session, REMOTE_FONT_1, tmpdirname) 45 | assert fwres.url == REMOTE_FONT_1 46 | assert fwres.filepath == _get_filepath_from_url(REMOTE_FONT_1, tmpdirname) 47 | assert fwres.http_status == 200 48 | assert fwres.write_success is True 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_async_fetch_and_write_404(): 53 | with tempfile.TemporaryDirectory() as tmpdirname: 54 | async with aiohttp.ClientSession() as session: 55 | fwres = await async_fetch_and_write(session, URL_404, tmpdirname) 56 | assert fwres.url == URL_404 57 | assert fwres.filepath is None 58 | assert fwres.http_status == 404 59 | assert fwres.write_success is False 60 | 61 | 62 | @pytest.mark.asyncio 63 | async def test_create_async_get_request_session_and_run_200(): 64 | urls = [REMOTE_FONT_1, REMOTE_FONT_2] 65 | with tempfile.TemporaryDirectory() as tmpdirname: 66 | tasks = await create_async_get_request_session_and_run(urls, tmpdirname) 67 | for x, task in enumerate(tasks): 68 | assert task.exception() is None 69 | assert task.result().url == urls[x] 70 | assert task.result().http_status == 200 71 | assert os.path.exists(task.result().filepath) 72 | assert task.result().write_success is True 73 | 74 | 75 | @pytest.mark.asyncio 76 | async def test_create_async_get_request_session_and_run_404_single(): 77 | urls = [REMOTE_FONT_1, URL_404] 78 | with tempfile.TemporaryDirectory() as tmpdirname: 79 | tasks = await create_async_get_request_session_and_run(urls, tmpdirname) 80 | for x, task in enumerate(tasks): 81 | if x == 0: 82 | assert task.exception() is None 83 | assert task.result().url == urls[x] 84 | assert task.result().http_status == 200 85 | assert os.path.exists(task.result().filepath) 86 | assert task.result().write_success is True 87 | else: 88 | assert task.exception() is None 89 | assert task.result().url == urls[x] 90 | assert task.result().http_status == 404 91 | assert task.result().filepath is None 92 | assert task.result().write_success is False 93 | 94 | 95 | @pytest.mark.asyncio 96 | async def test_create_async_get_request_session_and_run_404_both(): 97 | urls = [URL_404, URL_404] 98 | with tempfile.TemporaryDirectory() as tmpdirname: 99 | tasks = await create_async_get_request_session_and_run(urls, tmpdirname) 100 | for x, task in enumerate(tasks): 101 | assert task.exception() is None 102 | assert task.result().url == urls[x] 103 | assert task.result().http_status == 404 104 | assert task.result().filepath is None 105 | assert task.result().write_success is False 106 | -------------------------------------------------------------------------------- /tests/test_main_unix_only.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | from unittest.mock import MagicMock 6 | 7 | import pytest 8 | 9 | from fdiff.__main__ import run 10 | 11 | ROBOTO_BEFORE_PATH = os.path.join("tests", "testfiles", "Roboto-Regular.subset1.ttf") 12 | ROBOTO_AFTER_PATH = os.path.join("tests", "testfiles", "Roboto-Regular.subset2.ttf") 13 | ROBOTO_EXTDIFF_EXPECTED_PATH = os.path.join( 14 | "tests", "testfiles", "roboto_extdiff_expected.txt" 15 | ) 16 | ROBOTO_EXTDIFF_COLOR_EXPECTED_PATH = os.path.join( 17 | "tests", "testfiles", "roboto_extdiff_color_expected.txt" 18 | ) 19 | 20 | ROBOTO_BEFORE_URL = "https://github.com/source-foundry/fdiff/raw/master/tests/testfiles/Roboto-Regular.subset1.ttf" 21 | ROBOTO_AFTER_URL = "https://github.com/source-foundry/fdiff/raw/master/tests/testfiles/Roboto-Regular.subset2.ttf" 22 | 23 | 24 | # Setup: define the expected diff text for unified diff 25 | with open(ROBOTO_EXTDIFF_EXPECTED_PATH, "r") as robo_extdiff: 26 | ROBOTO_EXTDIFF_EXPECTED = robo_extdiff.read() 27 | 28 | # Setup: define the expected diff text for unified color diff 29 | with open(ROBOTO_EXTDIFF_COLOR_EXPECTED_PATH, "r") as robo_extdiff_color: 30 | ROBOTO_EXTDIFF_COLOR_EXPECTED = robo_extdiff_color.read() 31 | 32 | 33 | # # these tests rely on a PATH install of `diff` executable on Unix 34 | # # they are not executed on Windows platforms 35 | if sys.platform.startswith("win"): 36 | pytest.skip("skipping windows-only tests", allow_module_level=True) 37 | 38 | 39 | def test_main_external_diff_default(capsys): 40 | args = ["--external", "diff -u", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 41 | expected_string_list = ROBOTO_EXTDIFF_EXPECTED.split("\n") 42 | 43 | with pytest.raises(SystemExit): 44 | run(args) 45 | 46 | captured = capsys.readouterr() 47 | res_string_list = captured.out.split("\n") 48 | for x, line in enumerate(res_string_list): 49 | # treat top two lines of the diff as comparison of first 3 chars only 50 | if x in (0, 1): 51 | assert line[0:2] == expected_string_list[x][0:2] 52 | elif x in range(2, 10): 53 | assert line == expected_string_list[x] 54 | else: 55 | # skip lines beyond first 10 56 | pass 57 | 58 | 59 | def test_main_external_diff_remote(capsys): 60 | args = ["--external", "diff -u", ROBOTO_BEFORE_URL, ROBOTO_AFTER_URL] 61 | expected_string_list = ROBOTO_EXTDIFF_EXPECTED.split("\n") 62 | 63 | with pytest.raises(SystemExit): 64 | run(args) 65 | 66 | captured = capsys.readouterr() 67 | res_string_list = captured.out.split("\n") 68 | for x, line in enumerate(res_string_list): 69 | # treat top two lines of the diff as comparison of first 3 chars only 70 | if x in (0, 1): 71 | assert line[0:2] == expected_string_list[x][0:2] 72 | elif x in range(2, 10): 73 | assert line == expected_string_list[x] 74 | else: 75 | # skip lines beyond first 10 76 | pass 77 | 78 | 79 | def test_main_external_diff_color(capsys): 80 | # prior to v3.0.0, the `-c` / `--color` option was required for color output 81 | # this is the default as of v3.0.0 and the test arguments were 82 | # modified here 83 | args = ["--external", "diff -u", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 84 | # we also need to patch sys.stdout.isatty because color does not 85 | # show when this returns False 86 | sys.stdout.isatty = MagicMock(return_value=True) 87 | # expected_string_list = ROBOTO_EXTDIFF_COLOR_EXPECTED.split("\n") 88 | 89 | with pytest.raises(SystemExit): 90 | run(args) 91 | 92 | captured = capsys.readouterr() 93 | 94 | # spot checks for escape code start sequence 95 | res_string_list = captured.out.split("\n") 96 | assert captured.out.startswith("\x1b") 97 | assert res_string_list[10].startswith("\x1b") 98 | assert res_string_list[71].startswith("\x1b") 99 | assert res_string_list[180].startswith("\x1b") 100 | assert res_string_list[200].startswith("\x1b") 101 | assert res_string_list[238].startswith("\x1b") 102 | 103 | 104 | def test_main_external_diff_with_head_fails(capsys): 105 | args = ["--external", "diff -u", "--head", "1", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 106 | 107 | with pytest.raises(SystemExit) as exit_info: 108 | run(args) 109 | 110 | captured = capsys.readouterr() 111 | assert "[ERROR] The head and tail options are not supported" in captured.err 112 | assert exit_info.value.code == 1 113 | 114 | 115 | def test_main_external_diff_with_tail_fails(capsys): 116 | args = ["--external", "diff -u", "--tail", "1", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 117 | 118 | with pytest.raises(SystemExit) as exit_info: 119 | run(args) 120 | 121 | captured = capsys.readouterr() 122 | assert "[ERROR] The head and tail options are not supported" in captured.err 123 | assert exit_info.value.code == 1 124 | 125 | 126 | def test_main_external_diff_with_lines_fails(capsys): 127 | args = [ 128 | "--external", 129 | "diff -u", 130 | "--lines", 131 | "1", 132 | ROBOTO_BEFORE_PATH, 133 | ROBOTO_AFTER_PATH, 134 | ] 135 | 136 | with pytest.raises(SystemExit) as exit_info: 137 | run(args) 138 | 139 | captured = capsys.readouterr() 140 | assert "[ERROR] The lines option is not supported" in captured.err 141 | assert exit_info.value.code == 1 142 | -------------------------------------------------------------------------------- /tests/testfiles/roboto_udiff_ex_headpost_expected.txt: -------------------------------------------------------------------------------- 1 | --- tests/testfiles/Roboto-Regular.subset1.ttf 2019-09-05T14:04:24.748302-04:00 2 | +++ tests/testfiles/Roboto-Regular.subset2.ttf 2019-09-05T14:07:27.594299-04:00 3 | @@ -4,16 +4,16 @@ 4 | 5 | 6 | 7 | - 8 | - 9 | - 10 | + 11 | + 12 | + 13 | 14 | 15 | 16 | 17 | 18 | 19 | - 20 | + 21 | 22 | 23 | 24 | @@ -21,10 +21,10 @@ 25 | 26 | 27 | 28 | - 29 | - 30 | - 31 | - 32 | + 33 | + 34 | + 35 | + 36 | 37 | 38 | 39 | @@ -40,10 +40,10 @@ 40 | 41 | 42 | 43 | - 44 | - 45 | - 46 | - 47 | + 48 | + 49 | + 50 | + 51 | 52 | 53 | 54 | @@ -51,8 +51,8 @@ 55 | 56 | 57 | 58 | - 59 | - 60 | + 61 | + 62 | 63 | 64 | 65 | @@ -110,16 +110,16 @@ 66 | 67 | 68 | 69 | - 70 | 71 | - 72 | 73 | + 74 | + 75 | + 76 | + 77 | 78 | 79 | 80 | 81 | - 82 | - 83 | 84 | 85 | 86 | @@ -196,8 +196,6 @@ 87 | 88 | 89 | 90 | - 91 | - 92 | 93 | 94 | 95 | @@ -249,7 +247,37 @@ 96 | 97 | 98 | 99 | - 100 | + 101 | + 102 | + 103 | + 104 | + 105 | + 106 | + 107 | + 108 | + 109 | + 110 | + 111 | + 112 | + 113 | + 114 | + 115 | + 116 | + 117 | + 118 | + 119 | + 120 | + 121 | + 122 | + 123 | + 124 | + 125 | + 126 | + 127 | + 128 | + 129 | + 130 | + 131 | 132 | 133 | 134 | @@ -299,30 +327,6 @@ 135 | 136 | 137 | 138 | - 139 | - 140 | - 141 | - 142 | - 143 | - 144 | - 145 | - 146 | - 147 | - 148 | - 149 | - 150 | - 151 | - 152 | - 153 | - 154 | - 155 | - 156 | - 157 | - 158 | - 159 | - 160 | - 161 | - 162 | 163 | 164 | 165 | @@ -456,10 +460,6 @@ 166 | 167 | 168 | 169 | - 170 | - 171 | - 172 | - 173 | 174 | 175 | 176 | @@ -709,7 +709,7 @@ 177 | 178 | 179 | 180 | - 181 | + 182 | 183 | 184 | 185 | -------------------------------------------------------------------------------- /tests/testfiles/roboto_udiff_1context_expected.txt: -------------------------------------------------------------------------------- 1 | --- tests/testfiles/Roboto-Regular.subset1.ttf 2019-09-05T14:04:24.748302-04:00 2 | +++ tests/testfiles/Roboto-Regular.subset2.ttf 2019-09-05T14:07:27.594299-04:00 3 | @@ -6,5 +6,5 @@ 4 | 5 | - 6 | - 7 | - 8 | + 9 | + 10 | + 11 | 12 | @@ -15,3 +15,3 @@ 13 | 14 | - 15 | + 16 | 17 | @@ -22,3 +22,3 @@ 18 | 19 | - 20 | + 21 | 22 | @@ -27,9 +27,9 @@ 23 | 24 | - 25 | - 26 | - 27 | - 28 | - 29 | + 30 | + 31 | + 32 | + 33 | + 34 | 35 | - 36 | + 37 | 38 | @@ -44,6 +44,6 @@ 39 | 40 | - 41 | - 42 | - 43 | - 44 | + 45 | + 46 | + 47 | + 48 | 49 | @@ -63,6 +63,6 @@ 50 | 51 | - 52 | - 53 | - 54 | - 55 | + 56 | + 57 | + 58 | + 59 | 60 | @@ -74,4 +74,4 @@ 61 | 62 | - 63 | - 64 | + 65 | + 66 | 67 | @@ -133,6 +133,8 @@ 68 | 69 | - 70 | 71 | - 72 | 73 | + 74 | + 75 | + 76 | + 77 | 78 | @@ -141,4 +143,2 @@ 79 | 80 | - 81 | - 82 | 83 | @@ -219,4 +219,2 @@ 84 | 85 | - 86 | - 87 | 88 | @@ -272,3 +270,33 @@ 89 | 90 | - 91 | + 92 | + 93 | + 94 | + 95 | + 96 | + 97 | + 98 | + 99 | + 100 | + 101 | + 102 | + 103 | + 104 | + 105 | + 106 | + 107 | + 108 | + 109 | + 110 | + 111 | + 112 | + 113 | + 114 | + 115 | + 116 | + 117 | + 118 | + 119 | + 120 | + 121 | + 122 | 123 | @@ -322,26 +350,2 @@ 124 | 125 | - 126 | - 127 | - 128 | - 129 | - 130 | - 131 | - 132 | - 133 | - 134 | - 135 | - 136 | - 137 | - 138 | - 139 | - 140 | - 141 | - 142 | - 143 | - 144 | - 145 | - 146 | - 147 | - 148 | - 149 | 150 | @@ -480,6 +484,2 @@ 151 | 152 | - 153 | - 154 | - 155 | - 156 | 157 | @@ -530,29 +530,2 @@ 158 | 159 | - 160 | - 161 | - 162 | - 163 | - 164 | - 165 | - 166 | - 167 | - 168 | - 169 | - 170 | - 171 | - 178 | - 179 | - 180 | - 181 | - 182 | - 183 | - 184 | - 185 | - 186 | 187 | @@ -759,3 +732,3 @@ 188 | 189 | - 190 | + 191 | 192 | -------------------------------------------------------------------------------- /tests/testfiles/diff-exe-results.txt: -------------------------------------------------------------------------------- 1 | --- Roboto-Regular.subset1.ttx 2019-09-05 14:18:13.000000000 -0400 2 | +++ Roboto-Regular.subset2.ttx 2019-09-05 14:18:13.000000000 -0400 3 | @@ -4,34 +4,34 @@ 4 | 5 | 6 | 7 | - 8 | - 9 | - 10 | + 11 | + 12 | + 13 | 14 | 15 | 16 | 17 | 18 | 19 | - 20 | + 21 | 22 | 23 | 24 | 25 | 26 | 27 | - 28 | + 29 | 30 | 31 | 32 | 33 | - 34 | - 35 | - 36 | - 37 | - 38 | + 39 | + 40 | + 41 | + 42 | + 43 | 44 | - 45 | + 46 | 47 | 48 | 49 | @@ -42,10 +42,10 @@ 50 | 51 | 52 | 53 | - 54 | - 55 | - 56 | - 57 | + 58 | + 59 | + 60 | + 61 | 62 | 63 | 64 | @@ -61,10 +61,10 @@ 65 | 66 | 67 | 68 | - 69 | - 70 | - 71 | - 72 | + 73 | + 74 | + 75 | + 76 | 77 | 78 | 79 | @@ -72,8 +72,8 @@ 80 | 81 | 82 | 83 | - 84 | - 85 | + 86 | + 87 | 88 | 89 | 90 | @@ -131,16 +131,16 @@ 91 | 92 | 93 | 94 | - 95 | 96 | - 97 | 98 | + 99 | + 100 | + 101 | + 102 | 103 | 104 | 105 | 106 | - 107 | - 108 | 109 | 110 | 111 | @@ -218,8 +218,6 @@ 112 | 113 | 114 | 115 | - 116 | - 117 | 118 | 119 | 120 | @@ -270,7 +268,37 @@ 121 | 122 | 123 | 124 | - 125 | + 126 | + 127 | + 128 | + 129 | + 130 | + 131 | + 132 | + 133 | + 134 | + 135 | + 136 | + 137 | + 138 | + 139 | + 140 | + 141 | + 142 | + 143 | + 144 | + 145 | + 146 | + 147 | + 148 | + 149 | + 150 | + 151 | + 152 | + 153 | + 154 | + 155 | + 156 | 157 | 158 | 159 | @@ -320,30 +348,6 @@ 160 | 161 | 162 | 163 | - 164 | - 165 | - 166 | - 167 | - 168 | - 169 | - 170 | - 171 | - 172 | - 173 | - 174 | - 175 | - 176 | - 177 | - 178 | - 179 | - 180 | - 181 | - 182 | - 183 | - 184 | - 185 | - 186 | - 187 | 188 | 189 | 190 | @@ -478,10 +482,6 @@ 191 | 192 | 193 | 194 | - 195 | - 196 | - 197 | - 198 | 199 | 200 | 201 | @@ -529,33 +529,6 @@ 202 | 203 | 204 | 205 | - 206 | - 207 | - 208 | - 209 | - 210 | - 211 | - 212 | - 213 | - 214 | - 215 | - 216 | - 223 | - 224 | - 225 | - 226 | - 227 | - 228 | - 229 | - 230 | - 231 | - 232 | 233 | 234 | 235 | @@ -757,7 +730,7 @@ 236 | 237 | 238 | 239 | - 240 | + 241 | 242 | 243 | 244 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v3.0.0 4 | 5 | - Remove Python 3.6 support 6 | - Convert to default ANSI escape code colored diff output in terminal environments only (this is a change in behavior from previous default that required `-c` / `--color` option to toggle colored output on) 7 | - Add new `--nocolor` option to disable colored diff output in terminals 8 | - Maintain `-c` / `--color` option to toggle ANSI escape code colored diff output on in non-terminal environments and avoid breakage in existing workflows 9 | - Modify user notice on no OpenTable diff from "There is no difference between the files" to "There is no difference in the tested OpenType tables" 10 | - Stabilize external executable diffs with the `--external` option 11 | - Add Python 3.10 testing, drop Python 3.6 testing 12 | - Bump aiofiles dependency to v0.7.0 13 | - Bump cffi dependency to v1.15.0 14 | - Bump fonttools dependency to v4.27.1 15 | - Bump idna dependency to v3.3 16 | - Bump multidict dependency to v5.2.0 17 | - Bump pycares dependency to v4.1.2 18 | - Bump pygments dependency to v2.10.0 19 | - Bump rich dependency to 10.12.0 20 | - Bump typing-extensions dependency to v3.10.0.2 21 | - Bump yarl dependency to v1.7.0 22 | 23 | ## v2.2.0 24 | 25 | - Add indeterminate progress indicators during processing 26 | - Bump aiodns dependency to v3.0.0 27 | - Bump chardet dependency to v4.0.0 28 | - Bump pycares dependency to v4.0.0 29 | - Bump fonttools dependency to v4.23.1 30 | - Add new rich package dependency 31 | - Add pull request support to GitHub Actions workflow configurations 32 | 33 | ## v2.1.5 34 | 35 | - Bump fonttools dependency to v4.22.1 36 | - Fix broken unit tests that resulted from backwards incompatible ttx XML output format changes as of fonttools v4.22.0 (https://github.com/fonttools/fonttools/pull/2238) 37 | 38 | ## v2.1.4 39 | 40 | - Bump aiohttp dependency to v3.7.4 41 | - Bump cffi dependency to v1.14.5 42 | - Bump fonttools dependency to v4.21.1 43 | - Bump idna dependency to v3.1 44 | - Bump multidict dependency to v5.1.0 45 | 46 | ## v2.1.3 47 | 48 | - Broaden dependency support for Python wheels on the Windows platform 49 | - Bump aiofiles dependency to v0.6.0 50 | - Bump aiohttp dependency to v3.7.2 51 | - Bump attrs dependency to v20.3.0 52 | - Bump fonttools dependency to v4.17.0 53 | - Bump multidict dependency to v5.0.2 54 | - Bump yarl dependency to v1.6.3 55 | 56 | ## v2.1.2 57 | 58 | - fix: apply aiohttp patch version 3.7.1 to address dependency versioning conflict with multidict dependency 59 | 60 | ## v2.1.1 61 | 62 | - Add cPython 3.9 interpreter unit testing 63 | - Add CodeQL static source testing 64 | - Bump fonttools dependency to v4.16.1 65 | - Bump aiohttp dependency to v3.6.3 66 | - Bump multidict dependency to v5.0.0 67 | - Bump yarl dependency to v1.6.2 68 | 69 | ## v2.1.0 70 | 71 | - Add type annotations to all Python source files 72 | - Refactor `remote.py` module namedtuple to Py3.6+ style `NamedTuple` derived class with type annotations 73 | - Transition from pytype to mypy as static type checker 74 | - Add GitHub Action static type check configuration 75 | - Refactor import statements with default `isort` formatting 76 | - Bump fonttools dependency to v4.15.0 77 | 78 | ## v2.0.2 79 | 80 | - update cffi dependency to bug fix release v1.14.3 81 | 82 | ## v2.0.1 83 | 84 | - Refactor to maintain line length < 90 85 | - Add flake8 linting as part of the CI 86 | - Transition to GitHub Actions CI testing service 87 | - Bump attrs dependency to v20.2.0 88 | - Bump cffi dependency to v1.14.2 89 | - Bump fonttools dependency to v4.14.0 90 | - Bump idna dependency to v2.10 91 | - Bump multidict dependency to v4.7.6 92 | - Bump yarl dependendency to v1.5.1 93 | 94 | ## v2.0.0 95 | 96 | - Backward incompatible change in the default unified diff approach for large files (fixes #54) 97 | - Transition to the upstream cPython implementation of `difflib.unified_diff` for diff execution 98 | - Remove cPython `difflib` derivative that was distributed with this project 99 | - Eliminate dual license structure with removal of cPython license and transition to Apache License, v2.0 only 100 | 101 | ## v1.0.2 102 | 103 | - Bump cffi to 1.14.0 104 | - Bump fonttools to 4.6.0 105 | - Bump idna to 2.9 106 | - Bump multidict to 4.7.5 107 | - Bump pycares to 3.1.1 108 | - Bump pycparser to 2.20 109 | - Source formatting (black) 110 | - Add Py3.8 CI testing on Linux 111 | - Add Py3.6 64-bit testing, Py3.8 64-bit testing on Windows 112 | - Use pinned dependency versions in CI testing with tox 113 | 114 | ## v1.0.1 115 | 116 | - Bump fonttools from 4.2.2 to 4.2.4 117 | - Bump multidict from 4.7.3 to 4.7.4 118 | - Bump pycares from 3.1.0 to 3.1.1 119 | 120 | ## v1.0.0 121 | 122 | - `fdiff` executable: added support for external executable tool diff execution with a new `--external` option 123 | - Library: major refactor of the `fdiff.diff` module 124 | - Library: add new public `external_diff` function to the `fdiff.diff` module 125 | - Library: minor refactor of `fdiff.color` module (removed unnecessary import) 126 | - [bugfix] Library: fixed bug in ANSI color output 127 | - updated fontTools dependency to v4.2.2 128 | - updated aiohttp dependency to v3.6.2 129 | - pinned the versions of the following dependencies of dependencies in the requirements.txt file: async-timeout, attrs, cffi, chardet, idna, multidict, pycares, pycparser, yarl 130 | 131 | ## v0.5.1 132 | 133 | - `fdiff` executable: Fix help message - added information about pre/post file argument support for URL in addition to local file paths 134 | 135 | ## v0.5.0 136 | 137 | - Performance optimizations - Library: New default parallel TTX XML dump on systems that have more than one CPU, falls back to sequential execution on single CPU systems - `fdiff` executable: New `--nomp` option that overrides the default multi processor optimizations 138 | - `fdiff` executable: Added new default standard output user notification that no difference was identified when the files under evaluation are the same. This replaces no output in the standard output stream and an exit status code of zero as the indicators that there were no differences identified between the files. 139 | 140 | ## v0.4.0 141 | 142 | - Added support for remote font files with asynchronous I/O GET requests. This feature supports combinations of local and remote font file comparisons. 143 | - `fdiff` executable: added support for remote font files with command line URL arguments 144 | - `fdiff` executable: refactored unified diff error message formatting 145 | - Library: add new `fdiff.remote` module 146 | - Library: add new `fdiff.aio` module 147 | - Library: add new `fdiff.exceptions` module 148 | - Library: refactored `fdiff.diff.unified_diff()` function to support remote files through URL 149 | - Library: refactored local file path checks to support remote files via URL 150 | - added new aiohttp, aiodns, aiofiles dependencies to requirements.txt 151 | - added new aiohttp, aiodns, aiofiles dependencies to setup.py 152 | - added pytest-asyncio dependency to setup.py [dev] install target 153 | - added pytest-asyncio dependency instatllation to tox.ini, .travis.yml, .appveyor.yml configuration files 154 | - Py3.6+ updates: removed `# -*- coding: utf-8 -*-` header definitions (Thanks Niko!) 155 | - updated fontTools dependency to v4.0.1 (from v4.0.0) 156 | - Updated README.md documentation 157 | 158 | ## v0.3.0 159 | 160 | - Added support for head and tail diff output filter functionality - `fdiff` executable: add support for filtered diff output by top n lines with new `--head` option - `fdiff` executable: add support for filtered diff output by last n lines with new `--tail` option - Library: add new `fdiff.textiter` module 161 | - Add README.md table of contents 162 | 163 | ## v0.2.0 164 | 165 | - Added support for OpenType table include and exclude filters - `fdiff` executable: added `--include` option and defined comma delimited syntax for OpenType table command line definitions - `fdiff` executable: added `--exclude` option and defined comma delimited syntax for OpenType table command line defintions - `fdiff` executable: added validation check for use of mutually exclusive `--include` and `--exclude` options - Library: added new `fdiff.utils.get_tables_argument_list` function - Library: updated `fdiff.diff.u_diff` function with new `include_tables` and `exclude_tables` arguments - Library: added OpenType table validations for user-specified name values in the `fdiff.diff.u_diff` function. These checks confirm that at least one of the requested files includes tables specified with the new `--include` and `--exclude` options 166 | 167 | ## v0.1.0 168 | 169 | - Initial release with support for the following features: 170 | - local font file unified diff of OpenType table data dumped from the font binaries in the TTX data serialization format 171 | - ANSI escape code colored diff renders with the `-c` or `--color` command line options 172 | - Custom version of the third party Python standard library `difflib` module that includes a modification of the "autojunk" heuristics approach to achieve a significant unified diff performance improvement with large text files like those that are encountered in the typical TTX dump from fonts 173 | 174 | ## v0.0.1 175 | 176 | - pre-release version that did not include executable source code 177 | -------------------------------------------------------------------------------- /tests/testfiles/roboto_udiff_expected.txt: -------------------------------------------------------------------------------- 1 | --- tests/testfiles/Roboto-Regular.subset1.ttf 2019-09-05T14:04:24.748302-04:00 2 | +++ tests/testfiles/Roboto-Regular.subset2.ttf 2019-09-05T14:07:27.594299-04:00 3 | @@ -4,34 +4,34 @@ 4 | 5 | 6 | 7 | - 8 | - 9 | - 10 | + 11 | + 12 | + 13 | 14 | 15 | 16 | 17 | 18 | 19 | - 20 | + 21 | 22 | 23 | 24 | 25 | 26 | 27 | - 28 | + 29 | 30 | 31 | 32 | 33 | - 34 | - 35 | - 36 | - 37 | - 38 | + 39 | + 40 | + 41 | + 42 | + 43 | 44 | - 45 | + 46 | 47 | 48 | 49 | @@ -42,10 +42,10 @@ 50 | 51 | 52 | 53 | - 54 | - 55 | - 56 | - 57 | + 58 | + 59 | + 60 | + 61 | 62 | 63 | 64 | @@ -61,10 +61,10 @@ 65 | 66 | 67 | 68 | - 69 | - 70 | - 71 | - 72 | + 73 | + 74 | + 75 | + 76 | 77 | 78 | 79 | @@ -72,8 +72,8 @@ 80 | 81 | 82 | 83 | - 84 | - 85 | + 86 | + 87 | 88 | 89 | 90 | @@ -131,16 +131,16 @@ 91 | 92 | 93 | 94 | - 95 | 96 | - 97 | 98 | + 99 | + 100 | + 101 | + 102 | 103 | 104 | 105 | 106 | - 107 | - 108 | 109 | 110 | 111 | @@ -217,8 +217,6 @@ 112 | 113 | 114 | 115 | - 116 | - 117 | 118 | 119 | 120 | @@ -270,7 +268,37 @@ 121 | 122 | 123 | 124 | - 125 | + 126 | + 127 | + 128 | + 129 | + 130 | + 131 | + 132 | + 133 | + 134 | + 135 | + 136 | + 137 | + 138 | + 139 | + 140 | + 141 | + 142 | + 143 | + 144 | + 145 | + 146 | + 147 | + 148 | + 149 | + 150 | + 151 | + 152 | + 153 | + 154 | + 155 | + 156 | 157 | 158 | 159 | @@ -320,30 +348,6 @@ 160 | 161 | 162 | 163 | - 164 | - 165 | - 166 | - 167 | - 168 | - 169 | - 170 | - 171 | - 172 | - 173 | - 174 | - 175 | - 176 | - 177 | - 178 | - 179 | - 180 | - 181 | - 182 | - 183 | - 184 | - 185 | - 186 | - 187 | 188 | 189 | 190 | @@ -478,10 +482,6 @@ 191 | 192 | 193 | 194 | - 195 | - 196 | - 197 | - 198 | 199 | 200 | 201 | @@ -528,33 +528,6 @@ 202 | http://www.apache.org/licenses/LICENSE-2.0 203 | 204 | 205 | - 206 | - 207 | - 208 | - 209 | - 210 | - 211 | - 212 | - 213 | - 214 | - 215 | - 216 | - 217 | - 224 | - 225 | - 226 | - 227 | - 228 | - 229 | - 230 | - 231 | - 232 | 233 | 234 | 235 | @@ -757,7 +730,7 @@ 236 | 237 | 238 | 239 | - 240 | + 241 | 242 | 243 | 244 | -------------------------------------------------------------------------------- /tests/testfiles/roboto_extdiff_expected.txt: -------------------------------------------------------------------------------- 1 | --- /var/folders/x7/vs8mbwmx1hg1vcb53nlygt0m0000gn/T/tmpxk5gps4l/left.ttx 2019-12-17 22:44:14.000000000 -0500 2 | +++ /var/folders/x7/vs8mbwmx1hg1vcb53nlygt0m0000gn/T/tmpxk5gps4l/right.ttx 2019-12-17 22:44:14.000000000 -0500 3 | @@ -4,34 +4,34 @@ 4 | 5 | 6 | 7 | - 8 | - 9 | - 10 | + 11 | + 12 | + 13 | 14 | 15 | 16 | 17 | 18 | 19 | - 20 | + 21 | 22 | 23 | 24 | 25 | 26 | 27 | - 28 | + 29 | 30 | 31 | 32 | 33 | - 34 | - 35 | - 36 | - 37 | - 38 | + 39 | + 40 | + 41 | + 42 | + 43 | 44 | - 45 | + 46 | 47 | 48 | 49 | @@ -42,10 +42,10 @@ 50 | 51 | 52 | 53 | - 54 | - 55 | - 56 | - 57 | + 58 | + 59 | + 60 | + 61 | 62 | 63 | 64 | @@ -61,10 +61,10 @@ 65 | 66 | 67 | 68 | - 69 | - 70 | - 71 | - 72 | + 73 | + 74 | + 75 | + 76 | 77 | 78 | 79 | @@ -72,8 +72,8 @@ 80 | 81 | 82 | 83 | - 84 | - 85 | + 86 | + 87 | 88 | 89 | 90 | @@ -131,16 +131,16 @@ 91 | 92 | 93 | 94 | - 95 | 96 | - 97 | 98 | + 99 | + 100 | + 101 | + 102 | 103 | 104 | 105 | 106 | - 107 | - 108 | 109 | 110 | 111 | @@ -218,8 +218,6 @@ 112 | 113 | 114 | 115 | - 116 | - 117 | 118 | 119 | 120 | @@ -270,7 +268,37 @@ 121 | 122 | 123 | 124 | - 125 | + 126 | + 127 | + 128 | + 129 | + 130 | + 131 | + 132 | + 133 | + 134 | + 135 | + 136 | + 137 | + 138 | + 139 | + 140 | + 141 | + 142 | + 143 | + 144 | + 145 | + 146 | + 147 | + 148 | + 149 | + 150 | + 151 | + 152 | + 153 | + 154 | + 155 | + 156 | 157 | 158 | 159 | @@ -320,30 +348,6 @@ 160 | 161 | 162 | 163 | - 164 | - 165 | - 166 | - 167 | - 168 | - 169 | - 170 | - 171 | - 172 | - 173 | - 174 | - 175 | - 176 | - 177 | - 178 | - 179 | - 180 | - 181 | - 182 | - 183 | - 184 | - 185 | - 186 | - 187 | 188 | 189 | 190 | @@ -478,10 +482,6 @@ 191 | 192 | 193 | 194 | - 195 | - 196 | - 197 | - 198 | 199 | 200 | 201 | @@ -529,33 +529,6 @@ 202 | 203 | 204 | 205 | - 206 | - 207 | - 208 | - 209 | - 210 | - 211 | - 212 | - 213 | - 214 | - 215 | - 216 | - 223 | - 224 | - 225 | - 226 | - 227 | - 228 | - 229 | - 230 | - 231 | - 232 | 233 | 234 | 235 | @@ -757,7 +730,7 @@ 236 | 237 | 238 | 239 | - 240 | + 241 | 242 | 243 | 244 | -------------------------------------------------------------------------------- /lib/fdiff/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import os 5 | import sys 6 | from typing import Iterable, Iterator, List, Optional, Text, Tuple 7 | 8 | from rich.console import Console # type: ignore 9 | 10 | from . import __version__ 11 | from .color import color_unified_diff_line 12 | from .diff import external_diff, u_diff 13 | from .textiter import head, tail 14 | from .utils import file_exists, get_tables_argument_list 15 | 16 | 17 | def main() -> None: # pragma: no cover 18 | # try/except block rationale: 19 | # handles "premature" socket closure exception that is 20 | # raised by Python when stdout is piped to tools like 21 | # the `head` executable and socket is closed early 22 | # see: https://docs.python.org/3/library/signal.html#note-on-sigpipe 23 | try: 24 | run(sys.argv[1:]) 25 | except BrokenPipeError: 26 | # Python flushes standard streams on exit; redirect remaining output 27 | # to devnull to avoid another BrokenPipeError at shutdown 28 | devnull = os.open(os.devnull, os.O_WRONLY) 29 | os.dup2(devnull, sys.stdout.fileno()) 30 | sys.exit(0) 31 | 32 | 33 | def run(argv: List[Text]) -> None: 34 | # ------------------------------------------ 35 | # argparse command line argument definitions 36 | # ------------------------------------------ 37 | parser = argparse.ArgumentParser(description="An OpenType table diff tool for fonts.") 38 | parser.add_argument("--version", action="version", version=f"fdiff v{__version__}") 39 | parser.add_argument( 40 | "-l", "--lines", type=int, default=3, help="Number of context lines (default: 3)" 41 | ) 42 | parser.add_argument( 43 | "--include", 44 | type=str, 45 | default=None, 46 | help="Comma separated list of tables to include", 47 | ) 48 | parser.add_argument( 49 | "--exclude", 50 | type=str, 51 | default=None, 52 | help="Comma separated list of tables to exclude", 53 | ) 54 | parser.add_argument("--head", type=int, help="Display first n lines of output") 55 | parser.add_argument("--tail", type=int, help="Display last n lines of output") 56 | parser.add_argument("--external", type=str, help="Run external diff tool command") 57 | parser.add_argument( 58 | "-c", 59 | "--color", 60 | action="store_true", 61 | default=False, 62 | help="Force ANSI escape code color formatting in all environments", 63 | ) 64 | parser.add_argument( 65 | "--nomp", 66 | action="store_true", 67 | help="Do not use multi process optimizations (default: on)", 68 | ) 69 | parser.add_argument( 70 | "--nocolor", 71 | action="store_true", 72 | default=False, 73 | help="Do not use ANSI escape code colored diff (default: on)", 74 | ) 75 | parser.add_argument("PREFILE", help="Font file path/URL 1") 76 | parser.add_argument("POSTFILE", help="Font file path/URL 2") 77 | 78 | args: argparse.Namespace = parser.parse_args(argv) 79 | 80 | # ///////////////////////////////////////////////////////// 81 | # 82 | # Validations 83 | # 84 | # ///////////////////////////////////////////////////////// 85 | 86 | # ---------------------------------- 87 | # Incompatible argument validations 88 | # ---------------------------------- 89 | # --include and --exclude are mutually exclusive options 90 | if args.include and args.exclude: 91 | sys.stderr.write( 92 | f"[*] Error: --include and --exclude are mutually exclusive options. " 93 | f"Please use ONLY one of these options in your command.{os.linesep}" 94 | ) 95 | sys.exit(1) 96 | 97 | # ------------------------------- 98 | # File path argument validations 99 | # ------------------------------- 100 | 101 | if not args.PREFILE.startswith("http") and not file_exists(args.PREFILE): 102 | sys.stderr.write( 103 | f"[*] ERROR: The file path '{args.PREFILE}' can not be found.{os.linesep}" 104 | ) 105 | sys.exit(1) 106 | if not args.PREFILE.startswith("http") and not file_exists(args.POSTFILE): 107 | sys.stderr.write( 108 | f"[*] ERROR: The file path '{args.POSTFILE}' can not be found.{os.linesep}" 109 | ) 110 | sys.exit(1) 111 | 112 | # ///////////////////////////////////////////////////////// 113 | # 114 | # Command line logic 115 | # 116 | # ///////////////////////////////////////////////////////// 117 | 118 | # instantiate a rich Console 119 | console = Console() 120 | 121 | # parse explicitly included or excluded tables in 122 | # the command line arguments 123 | # set as a Python list if it was defined on the command line 124 | # or as None if it was not set on the command line 125 | include_list: Optional[List[Text]] = get_tables_argument_list(args.include) 126 | exclude_list: Optional[List[Text]] = get_tables_argument_list(args.exclude) 127 | 128 | # flip logic of the command line flag for multi process 129 | # optimization use 130 | use_mp: bool = not args.nomp 131 | 132 | if args.external: 133 | # ------------------------------ 134 | # External executable tool diff 135 | # ------------------------------ 136 | # head and tail are not supported when external diff tool is called 137 | if args.head or args.tail: 138 | sys.stderr.write( 139 | f"[ERROR] The head and tail options are not supported with external " 140 | f"diff executable calls.{os.linesep}" 141 | ) 142 | sys.exit(1) 143 | 144 | # lines of context filter is not supported when external diff tool is called 145 | if args.lines != 3: 146 | sys.stderr.write( 147 | f"[ERROR] The lines option is not supported with external diff " 148 | f"executable calls.{os.linesep}" 149 | ) 150 | sys.exit(1) 151 | 152 | try: 153 | with console.status("Processing...", spinner="dots10"): 154 | ext_diff: Iterable[Tuple[Text, Optional[int]]] = external_diff( 155 | args.external, 156 | args.PREFILE, 157 | args.POSTFILE, 158 | include_tables=include_list, 159 | exclude_tables=exclude_list, 160 | use_multiprocess=use_mp, 161 | ) 162 | 163 | # write stdout from external tool 164 | for line, exit_code in ext_diff: 165 | # format with color by default unless: 166 | # (1) user entered the --nocolor option 167 | # (2) we are not piping std output to a terminal 168 | # Force formatting with color in all environments if the user includes 169 | # the `-c` / `--color` option 170 | if (not args.nocolor and console.is_terminal) or args.color: 171 | sys.stdout.write(color_unified_diff_line(line)) 172 | else: 173 | sys.stdout.write(line) 174 | if exit_code is not None: 175 | sys.exit(exit_code) 176 | except Exception as e: 177 | sys.stderr.write(f"[*] ERROR: {e}{os.linesep}") 178 | sys.exit(1) 179 | else: 180 | # --------------- 181 | # Unified diff 182 | # --------------- 183 | # perform the unified diff analysis 184 | with console.status("Processing...", spinner="dots10"): 185 | try: 186 | uni_diff: Iterator[Text] = u_diff( 187 | args.PREFILE, 188 | args.POSTFILE, 189 | context_lines=args.lines, 190 | include_tables=include_list, 191 | exclude_tables=exclude_list, 192 | use_multiprocess=use_mp, 193 | ) 194 | except Exception as e: 195 | sys.stderr.write(f"[*] ERROR: {e}{os.linesep}") 196 | sys.exit(1) 197 | 198 | # re-define the line contents of the diff iterable 199 | # if head or tail is requested 200 | if args.head: 201 | iterable = head(uni_diff, args.head) 202 | elif args.tail: 203 | iterable = tail(uni_diff, args.tail) 204 | else: 205 | iterable = uni_diff 206 | 207 | # print unified diff results to standard output stream 208 | has_diff = False 209 | # format with color by default unless: 210 | # (1) user entered the --nocolor option 211 | # (2) we are not piping std output to a terminal 212 | # Force formatting with color in all environments if the user includes 213 | # the `-c` / `--color` option 214 | if (not args.nocolor and console.is_terminal) or args.color: 215 | for line in iterable: 216 | has_diff = True 217 | sys.stdout.write(color_unified_diff_line(line)) 218 | else: 219 | for line in iterable: 220 | has_diff = True 221 | sys.stdout.write(line) 222 | 223 | # if no difference was found, tell the user in addition to the 224 | # the zero exit status code. 225 | if not has_diff: 226 | print("[*] There is no difference in the tested OpenType tables.") 227 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | ## An OpenType table diff tool for fonts 5 | 6 | [![PyPI](https://img.shields.io/pypi/v/fdiff?color=blueviolet&label=PyPI&logo=python&logoColor=white)](https://pypi.org/project/fdiff/) 7 | [![GitHub license](https://img.shields.io/github/license/source-foundry/fdiff?color=blue)](https://github.com/source-foundry/fdiff/blob/master/LICENSE) 8 | ![Python CI](https://github.com/source-foundry/fdiff/workflows/Python%20CI/badge.svg) 9 | ![Python Lints](https://github.com/source-foundry/fdiff/workflows/Python%20Lints/badge.svg) 10 | ![Python Type Checks](https://github.com/source-foundry/fdiff/workflows/Python%20Type%20Checks/badge.svg) 11 | [![codecov](https://codecov.io/gh/source-foundry/fdiff/branch/master/graph/badge.svg)](https://codecov.io/gh/source-foundry/fdiff) 12 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/b58954eda44b4fd88ad8f4fa06e8010b)](https://www.codacy.com/app/SourceFoundry/fdiff) 13 | 14 | 15 | ## About 16 | 17 | `fdiff` is a Python command line comparison tool for assessment of granular differences in the OpenType table data between font files. The tool provides cross-platform support for local and remote font diffs on macOS, Windows, and GNU/Linux systems with a Python v3.7+ interpreter. 18 | 19 |

20 | 21 |

22 | 23 | Looking for a high-level overview of OpenType table differences rather than low-level changes? Check out Just van Rossum's [`fbdiff` tool](https://github.com/justvanrossum/fbdiff). 24 | 25 | ## What it does 26 | 27 | - Takes two font file path arguments (or URL for remote fonts) for the font comparison 28 | - Dumps OpenType table data in the fontTools library TTX format (XML) 29 | - Compares the OpenType table data across the two files using the unified diff format with 3 lines of surrounding context 30 | 31 | ## Optional Features 32 | 33 | - Filter OpenType tables with the `--include` or `--exclude` options 34 | - Modify the number of context lines displayed in the diff with the `-l` or `--lines` option 35 | - Display the first n lines of the diff output with the `--head` option 36 | - Display the last n lines of the diff output with the `--tail` option 37 | - Execute the diff with an external diff tool using the `--external` option 38 | 39 | Run `fdiff --help` to view all available options. 40 | 41 | ## Contents 42 | 43 | - [Installation](#installation) 44 | - [Usage](#usage) 45 | - [Issues](#issues) 46 | - [Contributing](#contributing) 47 | - [Acknowledgments](#acknowledgments) 48 | - [License](#license) 49 | 50 | ## Installation 51 | 52 | `fdiff` requires a Python 3.7+ interpreter. 53 | 54 | Installation in a [Python3 virtual environment](https://docs.python.org/3/library/venv.html) is recommended. 55 | 56 | Use any of the following installation approaches: 57 | 58 | ### pip install from PyPI 59 | 60 | ``` 61 | $ pip3 install fdiff 62 | ``` 63 | 64 | ### pip install from source 65 | 66 | ``` 67 | $ git clone https://github.com/source-foundry/fdiff.git 68 | $ cd fdiff 69 | $ pip3 install -r requirements.txt . 70 | ``` 71 | 72 | ### Developer install from source 73 | 74 | The following approach installs the project and associated optional developer dependencies, so that source changes are available without the need for re-installation. 75 | 76 | ``` 77 | $ git clone https://github.com/source-foundry/fdiff.git 78 | $ cd fdiff 79 | $ pip3 install --ignore-installed -r requirements.txt -e ".[dev]" 80 | ``` 81 | 82 | ## Usage 83 | 84 | #### Local font files 85 | 86 | ``` 87 | $ fdiff [OPTIONS] [PRE-FONT FILE PATH] [POST-FONT FILE PATH] 88 | ``` 89 | 90 | #### Remote font files 91 | 92 | `fdiff` supports GET requests for publicly accessible remote font files. Replace the file path arguments with URL: 93 | 94 | ``` 95 | $ fdiff [OPTIONS] [PRE-FONT FILE URL] [POST-FONT FILE URL] 96 | ``` 97 | 98 | `fdiff` works with any combination of local and remote font files. For example, to compare a local post font file with a remote pre font file to assess local changes against a font file that was previously pushed to a remote, use the following syntax: 99 | 100 | ``` 101 | $ fdiff [OPTIONS] [PRE-FONT FILE URL] [POST-FONT FILE FILE PATH] 102 | ``` 103 | 104 | ⭐ **Tip**: Remote git repository hosting services (like Github) support access to files on different git branches by URL. Use these repository branch URL to compare fonts across git branches in your repository. 105 | 106 | ### Options 107 | 108 | #### Filter OpenType tables 109 | 110 | To include only specified tables in your diff, use the `--include` option with a comma-separated list of table names: 111 | 112 | ``` 113 | $ fdiff --include head,post [PRE-FONT FILE PATH] [POST-FONT FILE PATH] 114 | ``` 115 | 116 | To exclude specified tables in your diff, use the `--exclude` option with a comma-separated list of table names: 117 | 118 | ``` 119 | $ fdiff --exclude glyf,OS/2 [PRE-FONT FILE PATH] [POST-FONT FILE PATH] 120 | ``` 121 | 122 | **Do not include spaces** between the comma-separated table name values! 123 | 124 | #### Change number of context lines 125 | 126 | To change the number of lines of context above/below lines that have differences, use the `-l` or `--lines` option with an integer value for the desired number of lines. The following command reduces the contextual information to a single line above and below lines with differences: 127 | 128 | ``` 129 | $ fdiff -l 1 [PRE-FONT FILE PATH] [POST-FONT FILE PATH] 130 | ``` 131 | 132 | #### Display the first n lines of output 133 | 134 | Use the `--head` option followed by an integer for the number of lines at the beginning of the output. For example, the following command displays the first 20 lines of the diff: 135 | 136 | ``` 137 | $ fdiff --head 20 [PRE-FONT FILE PATH] [POST-FONT FILE PATH] 138 | ``` 139 | 140 | #### Display the last n lines of output 141 | 142 | Use the `--tail` option followed by an integer for the number of lines at the end of the output. For example, the following command displays the last 20 lines of the diff: 143 | 144 | ``` 145 | $ fdiff --tail 20 [PRE-FONT FILE PATH] [POST-FONT FILE PATH] 146 | ``` 147 | 148 | #### Use an external diff tool 149 | 150 | **Please Note**: This feature has not been tested across all supported platforms. Please report any issues that you come across on the project issue tracker. 151 | 152 | By default, fdiff performs diffs with Python source. If you run into performance issues with this approach, you can use compiled diff executables that are available on your platform. fdiff will dump the ttx files and run the command that you provide on the command line passing the pre and post font ttx dump file paths as the first and second positional arguments to your command. 153 | 154 | For example, you may run the `diff -u` command on GNU/Linux or macOS like this: 155 | 156 | ``` 157 | $ fdiff --external="diff -u" [PRE-FONT FILE PATH] [POST-FONT FILE PATH] 158 | ``` 159 | 160 | fdiff supports built-in color formatting and OpenType table filtering when used with external diff tools. The context line, head, and tail options are not supported with the use of external diff tools. 161 | 162 | #### Disable color diffs 163 | 164 | ANSI escape code colored diffs are performed by default in terminal environments. 165 | 166 | To view a diff without ANSI escape codes in your terminal, include the `--nocolor` option in your command: 167 | 168 | ``` 169 | $ fdiff --nocolor [PRE-FONT FILE PATH] [POST-FONT FILE PATH] 170 | ``` 171 | 172 | 173 | ### Other Options 174 | 175 | Use `fdiff -h` to view all available options. 176 | 177 | ## Issues 178 | 179 | Please report issues on the [project issue tracker](https://github.com/source-foundry/fdiff/issues). 180 | 181 | ## Contributing 182 | 183 | Contributions are warmly welcomed. A development dependency environment can be installed in editable mode with the developer installation documentation above. 184 | 185 | Please use the standard Github pull request approach to propose source changes. 186 | 187 | ### Source file linting 188 | 189 | Python source files are linted with `flake8`. See the Makefile `test-lint` target for details. 190 | 191 | ### Testing 192 | 193 | The project runs continuous integration testing on the GitHub Actions service with the `pytest` toolchain. Test modules are located in the `tests` directory of the repository. 194 | 195 | Local testing by Python interpreter version can be performed with the following command executed from the root of the repository: 196 | 197 | ``` 198 | $ tox -e [PYTHON INTERPRETER VERSION] 199 | ``` 200 | 201 | Please see the `tox` documentation for additional details. 202 | 203 | ### Test coverage 204 | 205 | Unit test coverage is executed with the `coverage` tool. See the Makefile `test-coverage` target for details. 206 | 207 | ## Acknowledgments 208 | 209 | `fdiff` is built with the fantastic [fontTools free software library](https://github.com/fonttools/fonttools) and performs text diffs of binary font files using dumps of the TTX OpenType table data serialization format as defined in the fontTools library. 210 | 211 | ## License 212 | 213 | Copyright 2019 Source Foundry Authors and Contributors 214 | 215 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at 216 | 217 | [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0) 218 | 219 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 220 | -------------------------------------------------------------------------------- /tests/testfiles/roboto_udiff_color_expected.txt: -------------------------------------------------------------------------------- 1 | --- tests/testfiles/Roboto-Regular.subset1.ttf 2019-09-05T14:04:24.748302-04:00 2 | +++ tests/testfiles/Roboto-Regular.subset2.ttf 2019-09-05T14:07:27.594299-04:00 3 | @@ -4,34 +4,34 @@ 4 |  5 | 6 | 7 | - 8 | - 9 | - 10 | + 11 | + 12 | + 13 |  14 | 15 | 16 | 17 | 18 | 19 | - 20 | + 21 |  22 | 23 | 24 | 25 | 26 | 27 | - 28 | + 29 |  30 | 31 | 32 | 33 | - 34 | - 35 | - 36 | - 37 | - 38 | + 39 | + 40 | + 41 | + 42 | + 43 |  44 | - 45 | + 46 |  47 | 48 | 49 | @@ -42,10 +42,10 @@ 50 |  51 | 52 | 53 | - 54 | - 55 | - 56 | - 57 | + 58 | + 59 | + 60 | + 61 |  62 | 63 | 64 | @@ -61,10 +61,10 @@ 65 |  66 | 67 | 68 | - 69 | - 70 | - 71 | - 72 | + 73 | + 74 | + 75 | + 76 |  77 | 78 | 79 | @@ -72,8 +72,8 @@ 80 |  81 | 82 | 83 | - 84 | - 85 | + 86 | + 87 |  88 | 89 | 90 | @@ -131,16 +131,16 @@ 91 |  92 | 93 | 94 | - 95 |  96 | - 97 |  98 | + 99 | + 100 | + 101 | + 102 |  103 | 104 | 105 | 106 | - 107 | - 108 |  109 | 110 | 111 | @@ -217,8 +217,6 @@ 112 | 
113 | 114 |
115 | - 116 | - 117 |  118 | 119 | 120 | @@ -270,7 +268,37 @@ 121 |  122 | 123 | 124 | - 125 | + 126 | + 127 | + 128 | + 129 | + 130 | + 131 | + 132 | + 133 | + 134 | + 135 | + 136 | + 137 | + 138 | + 139 | + 140 | + 141 | + 142 | + 143 | + 144 | + 145 | + 146 | + 147 | + 148 | + 149 | + 150 | + 151 | + 152 | + 153 | + 154 | + 155 | + 156 |  157 | 158 | 159 | @@ -320,30 +348,6 @@ 160 |  161 | 162 | 163 | - 164 | - 165 | - 166 | - 167 | - 168 | - 169 | - 170 | - 171 | - 172 | - 173 | - 174 | - 175 | - 176 | - 177 | - 178 | - 179 | - 180 | - 181 | - 182 | - 183 | - 184 | - 185 | - 186 | - 187 |  188 | 189 | 190 | @@ -478,10 +482,6 @@ 191 |  192 | 193 | 194 | - 195 | - 196 | - 197 | - 198 |  199 | 200 | 201 | @@ -528,33 +528,6 @@ 202 |  http://www.apache.org/licenses/LICENSE-2.0 203 | 204 | 205 | - 206 | - 207 | - 208 | - 209 | - 210 | - 211 | - 212 | - 213 | - 214 | - 215 | - 216 | - 217 | - 224 | - 225 | - 226 | - 227 | - 228 | - 229 | - 230 | - 231 | - 232 |  233 | 234 | 235 | @@ -757,7 +730,7 @@ 236 |  237 | 238 | 239 | - 240 | + 241 |  242 | 243 | 244 | -------------------------------------------------------------------------------- /tests/testfiles/roboto_extdiff_color_expected.txt: -------------------------------------------------------------------------------- 1 | --- /var/folders/x7/vs8mbwmx1hg1vcb53nlygt0m0000gn/T/tmp6mjzf6a2/left.ttx 2019-12-17 22:47:31.000000000 -0500 2 | +++ /var/folders/x7/vs8mbwmx1hg1vcb53nlygt0m0000gn/T/tmp6mjzf6a2/right.ttx 2019-12-17 22:47:31.000000000 -0500 3 | @@ -4,34 +4,34 @@ 4 |  5 | 6 | 7 | - 8 | - 9 | - 10 | + 11 | + 12 | + 13 |  14 | 15 | 16 | 17 | 18 | 19 | - 20 | + 21 |  22 | 23 | 24 | 25 | 26 | 27 | - 28 | + 29 |  30 | 31 | 32 | 33 | - 34 | - 35 | - 36 | - 37 | - 38 | + 39 | + 40 | + 41 | + 42 | + 43 |  44 | - 45 | + 46 |  47 | 48 | 49 | @@ -42,10 +42,10 @@ 50 |  51 | 52 | 53 | - 54 | - 55 | - 56 | - 57 | + 58 | + 59 | + 60 | + 61 |  62 | 63 | 64 | @@ -61,10 +61,10 @@ 65 |  66 | 67 | 68 | - 69 | - 70 | - 71 | - 72 | + 73 | + 74 | + 75 | + 76 |  77 | 78 | 79 | @@ -72,8 +72,8 @@ 80 |  81 | 82 | 83 | - 84 | - 85 | + 86 | + 87 |  88 | 89 | 90 | @@ -131,16 +131,16 @@ 91 |  92 | 93 | 94 | - 95 |  96 | - 97 |  98 | + 99 | + 100 | + 101 | + 102 |  103 | 104 | 105 | 106 | - 107 | - 108 |  109 | 110 | 111 | @@ -218,8 +218,6 @@ 112 |  113 | 114 | 115 | - 116 | - 117 |  118 | 119 | 120 | @@ -270,7 +268,37 @@ 121 |  122 | 123 | 124 | - 125 | + 126 | + 127 | + 128 | + 129 | + 130 | + 131 | + 132 | + 133 | + 134 | + 135 | + 136 | + 137 | + 138 | + 139 | + 140 | + 141 | + 142 | + 143 | + 144 | + 145 | + 146 | + 147 | + 148 | + 149 | + 150 | + 151 | + 152 | + 153 | + 154 | + 155 | + 156 |  157 | 158 | 159 | @@ -320,30 +348,6 @@ 160 |  161 | 162 | 163 | - 164 | - 165 | - 166 | - 167 | - 168 | - 169 | - 170 | - 171 | - 172 | - 173 | - 174 | - 175 | - 176 | - 177 | - 178 | - 179 | - 180 | - 181 | - 182 | - 183 | - 184 | - 185 | - 186 | - 187 |  188 | 189 | 190 | @@ -478,10 +482,6 @@ 191 |  192 | 193 | 194 | - 195 | - 196 | - 197 | - 198 |  199 | 200 | 201 | @@ -529,33 +529,6 @@ 202 |  203 | 204 | 205 | - 206 | - 207 | - 208 | - 209 | - 210 | - 211 | - 212 | - 213 | - 214 | - 215 | - 216 | - 223 | - 224 | - 225 | - 226 | - 227 | - 228 | - 229 | - 230 | - 231 | - 232 |  233 | 234 | 235 | @@ -757,7 +730,7 @@ 236 |  237 | 238 | 239 | - 240 | + 241 |  242 | 243 | 244 | -------------------------------------------------------------------------------- /tests/test_diff.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | import pytest 5 | 6 | from fontTools.ttLib import TTFont 7 | 8 | from fdiff.diff import u_diff, _ttfont_save_xml 9 | from fdiff.exceptions import AIOError 10 | 11 | ROBOTO_BEFORE_PATH = os.path.join("tests", "testfiles", "Roboto-Regular.subset1.ttf") 12 | ROBOTO_AFTER_PATH = os.path.join("tests", "testfiles", "Roboto-Regular.subset2.ttf") 13 | ROBOTO_UDIFF_EXPECTED_PATH = os.path.join("tests", "testfiles", "roboto_udiff_expected.txt") 14 | ROBOTO_UDIFF_COLOR_EXPECTED_PATH = os.path.join("tests", "testfiles", "roboto_udiff_color_expected.txt") 15 | ROBOTO_UDIFF_1CONTEXT_EXPECTED_PATH = os.path.join("tests", "testfiles", "roboto_udiff_1context_expected.txt") 16 | ROBOTO_UDIFF_HEADONLY_EXPECTED_PATH = os.path.join("tests", "testfiles", "roboto_udiff_headonly_expected.txt") 17 | ROBOTO_UDIFF_HEADPOSTONLY_EXPECTED_PATH = os.path.join("tests", "testfiles", "roboto_udiff_headpostonly_expected.txt") 18 | ROBOTO_UDIFF_EXCLUDE_HEADPOST_EXPECTED_PATH = os.path.join("tests", "testfiles", "roboto_udiff_ex_headpost_expected.txt") 19 | 20 | ROBOTO_BEFORE_URL = "https://github.com/source-foundry/fdiff/raw/master/tests/testfiles/Roboto-Regular.subset1.ttf" 21 | ROBOTO_AFTER_URL = "https://github.com/source-foundry/fdiff/raw/master/tests/testfiles/Roboto-Regular.subset2.ttf" 22 | 23 | URL_404 = "https://httpbin.org/status/404" 24 | 25 | # Setup: define the expected diff text for unified diff 26 | with open(ROBOTO_UDIFF_EXPECTED_PATH, "r") as robo_udiff: 27 | ROBOTO_UDIFF_EXPECTED = robo_udiff.read() 28 | 29 | # Setup: define the expected diff text for unified color diff 30 | with open(ROBOTO_UDIFF_COLOR_EXPECTED_PATH, "r") as robo_udiff_color: 31 | ROBOTO_UDIFF_COLOR_EXPECTED = robo_udiff_color.read() 32 | 33 | # Setup: define the expected diff text for unified color diff 34 | with open(ROBOTO_UDIFF_1CONTEXT_EXPECTED_PATH, "r") as robo_udiff_contextlines: 35 | ROBOTO_UDIFF_1CONTEXT_EXPECTED = robo_udiff_contextlines.read() 36 | 37 | # Setup: define the expected diff text for head table only diff 38 | with open(ROBOTO_UDIFF_HEADONLY_EXPECTED_PATH, "r") as robo_udiff_headonly: 39 | ROBOTO_UDIFF_HEADONLY_EXPECTED = robo_udiff_headonly.read() 40 | 41 | # Setup: define the expected diff text for head and post table only diff 42 | with open(ROBOTO_UDIFF_HEADPOSTONLY_EXPECTED_PATH, "r") as robo_udiff_headpostonly: 43 | ROBOTO_UDIFF_HEADPOSTONLY_EXPECTED = robo_udiff_headpostonly.read() 44 | 45 | # Setup: define the expected diff text for head and post table only diff 46 | with open(ROBOTO_UDIFF_EXCLUDE_HEADPOST_EXPECTED_PATH, "r") as robo_udiff_ex_headpost: 47 | ROBOTO_UDIFF_EXCLUDE_HEADPOST_EXPECTED = robo_udiff_ex_headpost.read() 48 | 49 | 50 | def test_unified_diff_default_no_diff(): 51 | res = u_diff(ROBOTO_BEFORE_PATH, ROBOTO_BEFORE_PATH) 52 | res_string = "".join(res) 53 | assert res_string == "" 54 | 55 | 56 | def test_unified_diff_default(): 57 | res = u_diff(ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH) 58 | res_string = "".join(res) 59 | res_string_list = res_string.split("\n") 60 | expected_string_list = ROBOTO_UDIFF_EXPECTED.split("\n") 61 | 62 | # have to handle the tests for the top two file path lines 63 | # differently than the rest of the comparisons because 64 | # the time is defined using local platform settings 65 | # which makes tests fail on remote CI testing services vs. 66 | # my local testing platform... 67 | for x, line in enumerate(res_string_list): 68 | # treat top two lines of the diff as comparison of first 10 chars only 69 | if x in (0, 1): 70 | assert line[0:9] == expected_string_list[x][0:9] 71 | else: 72 | assert line == expected_string_list[x] 73 | 74 | 75 | def test_unified_diff_without_mp_optimizations(): 76 | res = u_diff(ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH, use_multiprocess=False) 77 | res_string = "".join(res) 78 | res_string_list = res_string.split("\n") 79 | expected_string_list = ROBOTO_UDIFF_EXPECTED.split("\n") 80 | 81 | # have to handle the tests for the top two file path lines 82 | # differently than the rest of the comparisons because 83 | # the time is defined using local platform settings 84 | # which makes tests fail on remote CI testing services vs. 85 | # my local testing platform... 86 | for x, line in enumerate(res_string_list): 87 | # treat top two lines of the diff as comparison of first 10 chars only 88 | if x in (0, 1): 89 | assert line[0:9] == expected_string_list[x][0:9] 90 | else: 91 | assert line == expected_string_list[x] 92 | 93 | 94 | def test_unified_diff_context_lines_1(): 95 | res = u_diff(ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH, context_lines=1) 96 | res_string = "".join(res) 97 | res_string_list = res_string.split("\n") 98 | expected_string_list = ROBOTO_UDIFF_1CONTEXT_EXPECTED.split("\n") 99 | 100 | # have to handle the tests for the top two file path lines 101 | # differently than the rest of the comparisons because 102 | # the time is defined using local platform settings 103 | # which makes tests fail on remote CI testing services vs. 104 | # my local testing platform... 105 | for x, line in enumerate(res_string_list): 106 | # treat top two lines of the diff as comparison of first 10 chars only 107 | if x in (0, 1): 108 | assert line[0:9] == expected_string_list[x][0:9] 109 | else: 110 | assert line == expected_string_list[x] 111 | 112 | 113 | def test_unified_diff_head_table_only(): 114 | res = u_diff(ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH, include_tables=["head"]) 115 | res_string = "".join(res) 116 | res_string_list = res_string.split("\n") 117 | expected_string_list = ROBOTO_UDIFF_HEADONLY_EXPECTED.split("\n") 118 | 119 | # have to handle the tests for the top two file path lines 120 | # differently than the rest of the comparisons because 121 | # the time is defined using local platform settings 122 | # which makes tests fail on remote CI testing services vs. 123 | # my local testing platform... 124 | for x, line in enumerate(res_string_list): 125 | # treat top two lines of the diff as comparison of first 10 chars only 126 | if x in (0, 1): 127 | assert line[0:9] == expected_string_list[x][0:9] 128 | else: 129 | assert line == expected_string_list[x] 130 | 131 | 132 | def test_unified_diff_headpost_table_only(): 133 | res = u_diff(ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH, include_tables=["head", "post"]) 134 | res_string = "".join(res) 135 | res_string_list = res_string.split("\n") 136 | expected_string_list = ROBOTO_UDIFF_HEADPOSTONLY_EXPECTED.split("\n") 137 | 138 | # have to handle the tests for the top two file path lines 139 | # differently than the rest of the comparisons because 140 | # the time is defined using local platform settings 141 | # which makes tests fail on remote CI testing services vs. 142 | # my local testing platform... 143 | for x, line in enumerate(res_string_list): 144 | # treat top two lines of the diff as comparison of first 10 chars only 145 | if x in (0, 1): 146 | assert line[0:9] == expected_string_list[x][0:9] 147 | else: 148 | assert line == expected_string_list[x] 149 | 150 | 151 | def test_unified_diff_exclude_headpost_tables(): 152 | res = u_diff(ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH, exclude_tables=["head", "post"]) 153 | res_string = "".join(res) 154 | res_string_list = res_string.split("\n") 155 | expected_string_list = ROBOTO_UDIFF_EXCLUDE_HEADPOST_EXPECTED.split("\n") 156 | 157 | # have to handle the tests for the top two file path lines 158 | # differently than the rest of the comparisons because 159 | # the time is defined using local platform settings 160 | # which makes tests fail on remote CI testing services vs. 161 | # my local testing platform... 162 | for x, line in enumerate(res_string_list): 163 | # treat top two lines of the diff as comparison of first 10 chars only 164 | if x in (0, 1): 165 | assert line[0:9] == expected_string_list[x][0:9] 166 | else: 167 | assert line == expected_string_list[x] 168 | 169 | 170 | def test_unified_diff_include_with_bad_table_definition(): 171 | with pytest.raises(KeyError): 172 | u_diff(ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH, include_tables=["bogus"]) 173 | 174 | 175 | def test_unified_diff_exclude_with_bad_table_definition(): 176 | with pytest.raises(KeyError): 177 | u_diff(ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH, exclude_tables=["bogus"]) 178 | 179 | 180 | def test_unified_diff_remote_fonts(): 181 | res = u_diff(ROBOTO_BEFORE_URL, ROBOTO_AFTER_URL) 182 | res_string = "".join(res) 183 | res_string_list = res_string.split("\n") 184 | expected_string_list = ROBOTO_UDIFF_EXPECTED.split("\n") 185 | 186 | # have to handle the tests for the top two file path lines 187 | # differently than the rest of the comparisons 188 | for x, line in enumerate(res_string_list): 189 | # treat top two lines of the diff as comparison of first 10 chars only 190 | if x == 0: 191 | assert line[0:9] == "--- https" 192 | elif x == 1: 193 | assert line[0:9] == "+++ https" 194 | else: 195 | assert line == expected_string_list[x] 196 | 197 | 198 | def test_unified_diff_remote_and_local_fonts(): 199 | res = u_diff(ROBOTO_BEFORE_URL, ROBOTO_AFTER_PATH) 200 | res_string = "".join(res) 201 | res_string_list = res_string.split("\n") 202 | expected_string_list = ROBOTO_UDIFF_EXPECTED.split("\n") 203 | 204 | # have to handle the tests for the top two file path lines 205 | # differently than the rest of the comparisons 206 | for x, line in enumerate(res_string_list): 207 | # treat top two lines of the diff as comparison of first 10 chars only 208 | if x == 0: 209 | assert line[0:9] == "--- https" 210 | elif x == 1: 211 | assert line[0:9] == expected_string_list[x][0:9] 212 | else: 213 | assert line == expected_string_list[x] 214 | 215 | 216 | def test_unified_diff_local_and_remote_fonts(): 217 | res = u_diff(ROBOTO_BEFORE_PATH, ROBOTO_AFTER_URL) 218 | res_string = "".join(res) 219 | res_string_list = res_string.split("\n") 220 | expected_string_list = ROBOTO_UDIFF_EXPECTED.split("\n") 221 | 222 | # have to handle the tests for the top two file path lines 223 | # differently than the rest of the comparisons 224 | for x, line in enumerate(res_string_list): 225 | # treat top two lines of the diff as comparison of first 10 chars only 226 | if x == 0: 227 | assert line[0:9] == expected_string_list[x][0:9] 228 | elif x == 1: 229 | assert line[0:9] == "+++ https" 230 | else: 231 | assert line == expected_string_list[x] 232 | 233 | 234 | def test_unified_diff_remote_404_first_file(): 235 | with pytest.raises(AIOError): 236 | u_diff(URL_404, ROBOTO_AFTER_URL) 237 | 238 | 239 | def test_unified_diff_remote_404_second_file(): 240 | with pytest.raises(AIOError): 241 | u_diff(ROBOTO_BEFORE_URL, URL_404) 242 | 243 | 244 | def test_unified_diff_remote_non_url_exception(): 245 | """This raises an exception in the aiohttp get request call""" 246 | with pytest.raises(AIOError): 247 | u_diff("https:bogus", "https:bogus") 248 | 249 | 250 | # 251 | # 252 | # Private functions 253 | # 254 | # 255 | 256 | def test_ttfont_save_xml(): 257 | tt = TTFont(ROBOTO_BEFORE_PATH) 258 | with tempfile.TemporaryDirectory() as tmpdirname: 259 | path = os.path.join(tmpdirname, "test.ttx") 260 | _ttfont_save_xml(tt, path, None, None) 261 | assert os.path.isfile(path) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /lib/fdiff/diff.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | import os 5 | import shlex 6 | import subprocess 7 | import tempfile 8 | from difflib import unified_diff 9 | from multiprocessing import Pool, cpu_count 10 | from typing import Any, Iterable, Iterator, List, Optional, Text, Tuple 11 | 12 | from fontTools.ttLib import TTFont # type: ignore 13 | 14 | from .exceptions import AIOError 15 | from .remote import _get_filepath_from_url, create_async_get_request_session_and_run 16 | from .utils import get_file_modtime 17 | 18 | # 19 | # 20 | # Private functions 21 | # 22 | # 23 | 24 | 25 | def _async_fetch_files(dirpath: Text, urls: List[Text]) -> None: 26 | loop = asyncio.get_event_loop() 27 | tasks = loop.run_until_complete( 28 | create_async_get_request_session_and_run(urls, dirpath) 29 | ) 30 | for task in tasks: 31 | if task.exception(): 32 | # raise exception here to notify calling code that something 33 | # did not work 34 | raise AIOError(f"{task.exception()}") 35 | elif task.result().http_status != 200: 36 | # handle non-200 HTTP response status codes + file write fails 37 | raise AIOError( 38 | f"failed to pull '{task.result().url}' with HTTP status " 39 | f"code {task.result().http_status}" 40 | ) 41 | 42 | 43 | def _get_fonts_and_save_xml( 44 | filepath_a: Text, 45 | filepath_b: Text, 46 | tmpdirpath: Text, 47 | include_tables: Optional[List[Text]], 48 | exclude_tables: Optional[List[Text]], 49 | use_multiprocess: bool, 50 | ) -> Tuple[Text, Text, Text, Text, Text, Text]: 51 | post_pathname, postpath, pre_pathname, prepath = _get_pre_post_paths( 52 | filepath_a, filepath_b, tmpdirpath 53 | ) 54 | # instantiate left and right fontTools.ttLib.TTFont objects 55 | tt_left = TTFont(prepath) 56 | tt_right = TTFont(postpath) 57 | _validate_table_includes(include_tables, tt_left, tt_right) 58 | _validate_table_excludes(exclude_tables, tt_left, tt_right) 59 | left_ttxpath = os.path.join(tmpdirpath, "left.ttx") 60 | right_ttxpath = os.path.join(tmpdirpath, "right.ttx") 61 | _mp_save_ttx_xml( 62 | tt_left, 63 | tt_right, 64 | left_ttxpath, 65 | right_ttxpath, 66 | exclude_tables, 67 | include_tables, 68 | use_multiprocess, 69 | ) 70 | return left_ttxpath, right_ttxpath, pre_pathname, prepath, post_pathname, postpath 71 | 72 | 73 | def _get_pre_post_paths( 74 | filepath_a: Text, 75 | filepath_b: Text, 76 | dirpath: Text, 77 | ) -> Tuple[Text, Text, Text, Text]: 78 | urls: List[Text] = [] 79 | if filepath_a.startswith("http"): 80 | urls.append(filepath_a) 81 | prepath = _get_filepath_from_url(filepath_a, dirpath) 82 | # keep URL as path name for remote file requests 83 | pre_pathname = filepath_a 84 | else: 85 | prepath = filepath_a 86 | pre_pathname = filepath_a 87 | if filepath_b.startswith("http"): 88 | urls.append(filepath_b) 89 | postpath = _get_filepath_from_url(filepath_b, dirpath) 90 | # keep URL as path name for remote file requests 91 | post_pathname = filepath_b 92 | else: 93 | postpath = filepath_b 94 | post_pathname = filepath_b 95 | # Async IO fetch and write of any remote file requests 96 | if len(urls) > 0: 97 | _async_fetch_files(dirpath, urls) 98 | return post_pathname, postpath, pre_pathname, prepath 99 | 100 | 101 | def _mp_save_ttx_xml( 102 | tt_left: Any, 103 | tt_right: Any, 104 | left_ttxpath: Text, 105 | right_ttxpath: Text, 106 | exclude_tables: Optional[List[Text]], 107 | include_tables: Optional[List[Text]], 108 | use_multiprocess: bool, 109 | ) -> None: 110 | if use_multiprocess and cpu_count() > 1: 111 | # Use parallel fontTools.ttLib.TTFont.saveXML dump 112 | # by default on multi CPU systems. This is a performance 113 | # optimization. Profiling demonstrates that this can reduce 114 | # execution time by up to 30% for some fonts 115 | mp_args_list = [ 116 | (tt_left, left_ttxpath, include_tables, exclude_tables), 117 | (tt_right, right_ttxpath, include_tables, exclude_tables), 118 | ] 119 | with Pool(processes=2) as pool: 120 | pool.starmap(_ttfont_save_xml, mp_args_list) 121 | else: 122 | # use sequential fontTools.ttLib.TTFont.saveXML dumps 123 | # when use_multiprocess is False or single CPU system 124 | # detected 125 | _ttfont_save_xml(tt_left, left_ttxpath, include_tables, exclude_tables) 126 | _ttfont_save_xml(tt_right, right_ttxpath, include_tables, exclude_tables) 127 | 128 | 129 | def _ttfont_save_xml( 130 | ttf: Any, 131 | filepath: Text, 132 | include_tables: Optional[List[Text]], 133 | exclude_tables: Optional[List[Text]], 134 | ) -> bool: 135 | """Writes TTX specification formatted XML to disk on filepath.""" 136 | ttf.saveXML(filepath, tables=include_tables, skipTables=exclude_tables) 137 | return True 138 | 139 | 140 | def _validate_table_excludes( 141 | exclude_tables: Optional[List[Text]], tt_left: Any, tt_right: Any 142 | ) -> None: 143 | # Validation: exclude_tables request should be for tables that are in one of 144 | # the two fonts. Mis-specified OT table definitions could otherwise result 145 | # in the presence of a table in the diff when the request was to exclude it. 146 | # For example, when an "OS/2" table request is entered as "OS2". 147 | if exclude_tables is not None: 148 | for table in exclude_tables: 149 | if table not in tt_left and table not in tt_right: 150 | raise KeyError( 151 | f"'{table}' table was not identified for exclusion in either font" 152 | ) 153 | 154 | 155 | def _validate_table_includes( 156 | include_tables: Optional[List[Text]], tt_left: Any, tt_right: Any 157 | ) -> None: 158 | # Validation: include_tables request should be for tables that are in one of 159 | # the two fonts. This otherwise silently passes with exit status code 0 which 160 | # could lead to the interpretation of no diff between two files when the table 161 | # entry is incorrectly defined or is a typo. Let's be conservative and consider 162 | # this an error, force user to use explicit definitions that include tables in 163 | # one of the two files, and understand that the diff request was for one or more 164 | # tables that are not present. 165 | if include_tables is not None: 166 | for table in include_tables: 167 | if table not in tt_left and table not in tt_right: 168 | raise KeyError( 169 | f"'{table}' table was not identified for inclusion in either font" 170 | ) 171 | 172 | 173 | # 174 | # 175 | # Public functions 176 | # 177 | # 178 | 179 | 180 | def u_diff( 181 | filepath_a: Text, 182 | filepath_b: Text, 183 | context_lines: int = 3, 184 | include_tables: Optional[List[Text]] = None, 185 | exclude_tables: Optional[List[Text]] = None, 186 | use_multiprocess: bool = True, 187 | ) -> Iterator[Text]: 188 | """Performs a unified diff on a TTX serialized data format dump of font binary data using 189 | a modified version of the Python standard libary difflib module. 190 | 191 | filepath_a: (string) pre-file local file path or URL path 192 | filepath_b: (string) post-file local file path or URL path 193 | context_lines: (int) number of context lines to include in the diff (default=3) 194 | include_tables: (list of str) Python list of OpenType tables to include in the diff 195 | exclude_tables: (list of str) Python list of OpentType tables to exclude from the diff 196 | use_multiprocess: (bool) use multi-processor optimizations (default=True) 197 | 198 | include_tables and exclude_tables are mutually exclusive arguments. Only one should 199 | be defined 200 | 201 | :returns: Generator of ordered diff line strings that include newline line endings 202 | :raises: KeyError if include_tables or exclude_tables includes a mis-specified table 203 | that is not included in filepath_a OR filepath_b 204 | :raises: fdiff.exceptions.AIOError if exception raised during execution of async I/O 205 | GET request for URL or file write 206 | :raises: fdiff.exceptions.AIOError if GET request to URL returned non-200 response 207 | status code""" 208 | with tempfile.TemporaryDirectory() as tmpdirpath: 209 | # define the file paths with either local file requests 210 | # or HTTP GET requests of remote files based on the command line request 211 | ( 212 | left_ttxpath, 213 | right_ttxpath, 214 | pre_pathname, 215 | prepath, 216 | post_pathname, 217 | postpath, 218 | ) = _get_fonts_and_save_xml( 219 | filepath_a, 220 | filepath_b, 221 | tmpdirpath, 222 | include_tables, 223 | exclude_tables, 224 | use_multiprocess, 225 | ) 226 | 227 | with open(left_ttxpath) as ff: 228 | fromlines = ff.readlines() 229 | with open(right_ttxpath) as tf: 230 | tolines = tf.readlines() 231 | 232 | fromdate = get_file_modtime(prepath) 233 | todate = get_file_modtime(postpath) 234 | 235 | return unified_diff( 236 | fromlines, 237 | tolines, 238 | pre_pathname, 239 | post_pathname, 240 | fromdate, 241 | todate, 242 | n=context_lines, 243 | ) 244 | 245 | 246 | def external_diff( 247 | command: Text, 248 | filepath_a: Text, 249 | filepath_b: Text, 250 | include_tables: Optional[List[Text]] = None, 251 | exclude_tables: Optional[List[Text]] = None, 252 | use_multiprocess: bool = True, 253 | ) -> Iterable[Tuple[Text, Optional[int]]]: 254 | """Performs a unified diff on a TTX serialized data format dump of font binary data using 255 | an external diff executable that is requested by the caller via `command` 256 | 257 | command: (string) command line executable string and arguments to define execution 258 | filepath_a: (string) pre-file local file path or URL path 259 | filepath_b: (string) post-file local file path or URL path 260 | include_tables: (list of str) Python list of OpenType tables to include in the diff 261 | exclude_tables: (list of str) Python list of OpentType tables to exclude from the diff 262 | use_multiprocess: (bool) use multi-processor optimizations (default=True) 263 | 264 | include_tables and exclude_tables are mutually exclusive arguments. Only one should 265 | be defined 266 | 267 | :returns: Generator of ordered diff line strings that include newline line endings 268 | :raises: KeyError if include_tables or exclude_tables includes a mis-specified table 269 | that is not included in filepath_a OR filepath_b 270 | :raises: IOError if exception raised during execution of `command` on TTX files 271 | :raises: fdiff.exceptions.AIOError if GET request to URL returned non-200 response 272 | status code""" 273 | with tempfile.TemporaryDirectory() as tmpdirpath: 274 | # define the file paths with either local file requests 275 | # or HTTP GET requests of remote files based on the command line request 276 | ( 277 | left_ttxpath, 278 | right_ttxpath, 279 | pre_pathname, 280 | prepath, 281 | post_pathname, 282 | postpath, 283 | ) = _get_fonts_and_save_xml( 284 | filepath_a, 285 | filepath_b, 286 | tmpdirpath, 287 | include_tables, 288 | exclude_tables, 289 | use_multiprocess, 290 | ) 291 | 292 | full_command = f"{command.strip()} {left_ttxpath} {right_ttxpath}" 293 | 294 | process = subprocess.Popen( 295 | shlex.split(full_command), 296 | stdout=subprocess.PIPE, 297 | stderr=subprocess.PIPE, 298 | encoding="utf8", 299 | ) 300 | 301 | while True: 302 | output = process.stdout.readline() # type: ignore 303 | exit_status = process.poll() 304 | if len(output) == 0 and exit_status is not None: 305 | err = process.stderr.read() # type: ignore 306 | if err: 307 | raise IOError(err) 308 | yield output, exit_status 309 | break 310 | else: 311 | yield output, None 312 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | from unittest.mock import MagicMock 7 | 8 | import pytest 9 | 10 | from fdiff.__main__ import run 11 | 12 | ROBOTO_BEFORE_PATH = os.path.join("tests", "testfiles", "Roboto-Regular.subset1.ttf") 13 | ROBOTO_AFTER_PATH = os.path.join("tests", "testfiles", "Roboto-Regular.subset2.ttf") 14 | ROBOTO_UDIFF_EXPECTED_PATH = os.path.join( 15 | "tests", "testfiles", "roboto_udiff_expected.txt" 16 | ) 17 | ROBOTO_UDIFF_COLOR_EXPECTED_PATH = os.path.join( 18 | "tests", "testfiles", "roboto_udiff_color_expected.txt" 19 | ) 20 | ROBOTO_UDIFF_1CONTEXT_EXPECTED_PATH = os.path.join( 21 | "tests", "testfiles", "roboto_udiff_1context_expected.txt" 22 | ) 23 | ROBOTO_UDIFF_HEADONLY_EXPECTED_PATH = os.path.join( 24 | "tests", "testfiles", "roboto_udiff_headonly_expected.txt" 25 | ) 26 | ROBOTO_UDIFF_HEADPOSTONLY_EXPECTED_PATH = os.path.join( 27 | "tests", "testfiles", "roboto_udiff_headpostonly_expected.txt" 28 | ) 29 | ROBOTO_UDIFF_EXCLUDE_HEADPOST_EXPECTED_PATH = os.path.join( 30 | "tests", "testfiles", "roboto_udiff_ex_headpost_expected.txt" 31 | ) 32 | 33 | ROBOTO_BEFORE_URL = "https://github.com/source-foundry/fdiff/raw/master/tests/testfiles/Roboto-Regular.subset1.ttf" 34 | ROBOTO_AFTER_URL = "https://github.com/source-foundry/fdiff/raw/master/tests/testfiles/Roboto-Regular.subset2.ttf" 35 | 36 | URL_404 = "https://httpbin.org/status/404" 37 | 38 | 39 | # Setup: define the expected diff text for unified diff 40 | with open(ROBOTO_UDIFF_EXPECTED_PATH, "r") as robo_udiff: 41 | ROBOTO_UDIFF_EXPECTED = robo_udiff.read() 42 | 43 | # Setup: define the expected diff text for unified color diff 44 | with open(ROBOTO_UDIFF_COLOR_EXPECTED_PATH, "r") as robo_udiff_color: 45 | ROBOTO_UDIFF_COLOR_EXPECTED = robo_udiff_color.read() 46 | 47 | 48 | # Setup: define the expected diff text for unified color diff 49 | with open(ROBOTO_UDIFF_1CONTEXT_EXPECTED_PATH, "r") as robo_udiff_contextlines: 50 | ROBOTO_UDIFF_1CONTEXT_EXPECTED = robo_udiff_contextlines.read() 51 | 52 | # Setup: define the expected diff text for head table only diff 53 | with open(ROBOTO_UDIFF_HEADONLY_EXPECTED_PATH, "r") as robo_udiff_headonly: 54 | ROBOTO_UDIFF_HEADONLY_EXPECTED = robo_udiff_headonly.read() 55 | 56 | # Setup: define the expected diff text for head and post table only diff 57 | with open(ROBOTO_UDIFF_HEADPOSTONLY_EXPECTED_PATH, "r") as robo_udiff_headpostonly: 58 | ROBOTO_UDIFF_HEADPOSTONLY_EXPECTED = robo_udiff_headpostonly.read() 59 | 60 | # Setup: define the expected diff text for head and post table only diff 61 | with open(ROBOTO_UDIFF_EXCLUDE_HEADPOST_EXPECTED_PATH, "r") as robo_udiff_ex_headpost: 62 | ROBOTO_UDIFF_EXCLUDE_HEADPOST_EXPECTED = robo_udiff_ex_headpost.read() 63 | 64 | # 65 | # File path validations tests 66 | # 67 | 68 | 69 | def test_main_filepath_validations_false_firstfont(capsys): 70 | test_path = os.path.join("tests", "testfiles", "bogus-font.ttf") 71 | args = [test_path, test_path] 72 | 73 | with pytest.raises(SystemExit) as exit_info: 74 | run(args) 75 | 76 | captured = capsys.readouterr() 77 | assert captured.err.startswith("[*] ERROR: The file path") 78 | assert exit_info.value.code == 1 79 | 80 | 81 | def test_main_filepath_validations_false_secondfont(capsys): 82 | test_path_2 = os.path.join("tests", "testfiles", "bogus-font.ttf") 83 | args = [ROBOTO_BEFORE_PATH, test_path_2] 84 | 85 | with pytest.raises(SystemExit) as exit_info: 86 | run(args) 87 | 88 | captured = capsys.readouterr() 89 | assert captured.err.startswith("[*] ERROR: The file path") 90 | assert exit_info.value.code == 1 91 | 92 | 93 | # 94 | # Mutually exclusive argument tests 95 | # 96 | 97 | 98 | def test_main_include_exclude_defined_simultaneously(capsys): 99 | args = [ 100 | "--include", 101 | "head", 102 | "--exclude", 103 | "head", 104 | ROBOTO_BEFORE_PATH, 105 | ROBOTO_AFTER_PATH, 106 | ] 107 | 108 | with pytest.raises(SystemExit) as exit_info: 109 | run(args) 110 | 111 | captured = capsys.readouterr() 112 | assert captured.err.startswith( 113 | "[*] Error: --include and --exclude are mutually exclusive options" 114 | ) 115 | assert exit_info.value.code == 1 116 | 117 | 118 | # 119 | # Unified diff integration tests 120 | # 121 | 122 | 123 | def test_main_run_unified_default_local_files_no_diff(capsys): 124 | """Test default behavior when there is no difference in font files under evaluation""" 125 | args = [ROBOTO_BEFORE_PATH, ROBOTO_BEFORE_PATH] 126 | 127 | run(args) 128 | captured = capsys.readouterr() 129 | assert captured.out.startswith( 130 | "[*] There is no difference in the tested OpenType tables" 131 | ) 132 | 133 | 134 | def test_main_run_unified_default_local_files(capsys): 135 | args = [ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 136 | 137 | run(args) 138 | captured = capsys.readouterr() 139 | 140 | res_string_list = captured.out.split("\n") 141 | expected_string_list = ROBOTO_UDIFF_EXPECTED.split("\n") 142 | 143 | # have to handle the tests for the top two file path lines 144 | # differently than the rest of the comparisons because 145 | # the time is defined using local platform settings 146 | # which makes tests fail on different remote CI testing services 147 | for x, line in enumerate(res_string_list): 148 | # treat top two lines of the diff as comparison of first 10 chars only 149 | if x in (0, 1): 150 | assert line[0:9] == expected_string_list[x][0:9] 151 | else: 152 | assert line == expected_string_list[x] 153 | 154 | 155 | def test_main_run_unified_local_files_without_mp_optimizations(capsys): 156 | args = ["--nomp", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 157 | 158 | run(args) 159 | captured = capsys.readouterr() 160 | 161 | res_string_list = captured.out.split("\n") 162 | expected_string_list = ROBOTO_UDIFF_EXPECTED.split("\n") 163 | 164 | # have to handle the tests for the top two file path lines 165 | # differently than the rest of the comparisons because 166 | # the time is defined using local platform settings 167 | # which makes tests fail on different remote CI testing services 168 | for x, line in enumerate(res_string_list): 169 | # treat top two lines of the diff as comparison of first 10 chars only 170 | if x in (0, 1): 171 | assert line[0:9] == expected_string_list[x][0:9] 172 | else: 173 | assert line == expected_string_list[x] 174 | 175 | 176 | def test_main_run_unified_default_remote_files(capsys): 177 | args = [ROBOTO_BEFORE_URL, ROBOTO_AFTER_URL] 178 | 179 | run(args) 180 | captured = capsys.readouterr() 181 | 182 | res_string_list = captured.out.split("\n") 183 | expected_string_list = ROBOTO_UDIFF_EXPECTED.split("\n") 184 | 185 | # have to handle the tests for the top two file path lines 186 | # differently than the rest of the comparisons because 187 | # the time is defined using local platform settings 188 | # which makes tests fail on different remote CI testing services 189 | for x, line in enumerate(res_string_list): 190 | # treat top two lines of the diff as comparison of first 10 chars only 191 | if x == 0: 192 | assert line[0:9] == "--- https" 193 | elif x == 1: 194 | assert line[0:9] == "+++ https" 195 | else: 196 | assert line == expected_string_list[x] 197 | 198 | 199 | def test_main_run_unified_default_404(capsys): 200 | with pytest.raises(SystemExit): 201 | args = [URL_404, URL_404] 202 | 203 | run(args) 204 | captured = capsys.readouterr() 205 | assert captured.out.startswith("[*] ERROR:") 206 | assert "HTTP status code 404" in captured.out 207 | 208 | 209 | def test_main_run_unified_color(capsys): 210 | # prior to v3.0.0, the `-c` / `--color` option was required for color output 211 | # this is the default as of v3.0.0 and the test arguments were 212 | # modified here 213 | args = [ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 214 | # we also need to mock sys.stdout.isatty because color does not 215 | # show when this returns False 216 | sys.stdout.isatty = MagicMock(return_value=True) 217 | 218 | run(args) 219 | captured = capsys.readouterr() 220 | # spot checks for escape code start sequence 221 | res_string_list = captured.out.split("\n") 222 | assert captured.out.startswith("\x1b") 223 | assert res_string_list[10].startswith("\x1b") 224 | assert res_string_list[71].startswith("\x1b") 225 | assert res_string_list[180].startswith("\x1b") 226 | assert res_string_list[200].startswith("\x1b") 227 | assert res_string_list[238].startswith("\x1b") 228 | 229 | 230 | def test_main_run_unified_context_lines_1(capsys): 231 | args = ["-l", "1", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 232 | 233 | run(args) 234 | captured = capsys.readouterr() 235 | 236 | res_string_list = captured.out.split("\n") 237 | expected_string_list = ROBOTO_UDIFF_1CONTEXT_EXPECTED.split("\n") 238 | 239 | # have to handle the tests for the top two file path lines 240 | # differently than the rest of the comparisons because 241 | # the time is defined using local platform settings 242 | # which makes tests fail on different remote CI testing services 243 | for x, line in enumerate(res_string_list): 244 | # treat top two lines of the diff as comparison of first 10 chars only 245 | if x in (0, 1): 246 | assert line[0:9] == expected_string_list[x][0:9] 247 | else: 248 | assert line == expected_string_list[x] 249 | 250 | 251 | def test_main_run_unified_head_table_only(capsys): 252 | args = ["--include", "head", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 253 | 254 | run(args) 255 | captured = capsys.readouterr() 256 | 257 | res_string_list = captured.out.split("\n") 258 | expected_string_list = ROBOTO_UDIFF_HEADONLY_EXPECTED.split("\n") 259 | 260 | # have to handle the tests for the top two file path lines 261 | # differently than the rest of the comparisons because 262 | # the time is defined using local platform settings 263 | # which makes tests fail on different remote CI testing services 264 | for x, line in enumerate(res_string_list): 265 | # treat top two lines of the diff as comparison of first 10 chars only 266 | if x in (0, 1): 267 | assert line[0:9] == expected_string_list[x][0:9] 268 | else: 269 | assert line == expected_string_list[x] 270 | 271 | 272 | def test_main_run_unified_head_post_tables_only(capsys): 273 | args = ["--include", "head,post", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 274 | 275 | run(args) 276 | captured = capsys.readouterr() 277 | 278 | res_string_list = captured.out.split("\n") 279 | expected_string_list = ROBOTO_UDIFF_HEADPOSTONLY_EXPECTED.split("\n") 280 | 281 | # have to handle the tests for the top two file path lines 282 | # differently than the rest of the comparisons because 283 | # the time is defined using local platform settings 284 | # which makes tests fail on different remote CI testing services 285 | for x, line in enumerate(res_string_list): 286 | # treat top two lines of the diff as comparison of first 10 chars only 287 | if x in (0, 1): 288 | assert line[0:9] == expected_string_list[x][0:9] 289 | else: 290 | assert line == expected_string_list[x] 291 | 292 | 293 | def test_main_run_unified_exclude_head_post_tables(capsys): 294 | args = ["--exclude", "head,post", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 295 | 296 | run(args) 297 | captured = capsys.readouterr() 298 | 299 | res_string_list = captured.out.split("\n") 300 | expected_string_list = ROBOTO_UDIFF_EXCLUDE_HEADPOST_EXPECTED.split("\n") 301 | 302 | # have to handle the tests for the top two file path lines 303 | # differently than the rest of the comparisons because 304 | # the time is defined using local platform settings 305 | # which makes tests fail on different remote CI testing services 306 | for x, line in enumerate(res_string_list): 307 | # treat top two lines of the diff as comparison of first 10 chars only 308 | if x in (0, 1): 309 | assert line[0:9] == expected_string_list[x][0:9] 310 | else: 311 | assert line == expected_string_list[x] 312 | 313 | 314 | def test_main_include_with_bad_table_definition(capsys): 315 | args = ["--include", "bogus", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 316 | 317 | with pytest.raises(SystemExit) as exit_info: 318 | run(args) 319 | 320 | captured = capsys.readouterr() 321 | assert captured.err.startswith("[*] ERROR:") 322 | assert exit_info.value.code == 1 323 | 324 | 325 | def test_main_include_with_bad_table_definition_in_multi_table_request(capsys): 326 | args = ["--include", "head,bogus", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 327 | 328 | with pytest.raises(SystemExit) as exit_info: 329 | run(args) 330 | 331 | captured = capsys.readouterr() 332 | assert captured.err.startswith("[*] ERROR:") 333 | assert exit_info.value.code == 1 334 | 335 | 336 | def test_main_exclude_with_bad_table_definition(capsys): 337 | args = ["--exclude", "bogus", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 338 | 339 | with pytest.raises(SystemExit) as exit_info: 340 | run(args) 341 | 342 | captured = capsys.readouterr() 343 | assert captured.err.startswith("[*] ERROR:") 344 | assert exit_info.value.code == 1 345 | 346 | 347 | def test_main_exclude_with_bad_table_definition_in_multi_table_request(capsys): 348 | args = ["--exclude", "head,bogus", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 349 | 350 | with pytest.raises(SystemExit) as exit_info: 351 | run(args) 352 | 353 | captured = capsys.readouterr() 354 | assert captured.err.startswith("[*] ERROR:") 355 | assert exit_info.value.code == 1 356 | 357 | 358 | def test_main_head_request(capsys): 359 | args = ["--head", "4", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 360 | 361 | run(args) 362 | captured = capsys.readouterr() 363 | res_string_list = captured.out.split("\n") 364 | 365 | # includes a newline at the end of the last line of output 366 | # which makes the total # of lines in the list == n + 1 367 | assert len(res_string_list) == 5 368 | 369 | # have to handle the tests for the top two file path lines 370 | # differently than the rest of the comparisons because 371 | # the time is defined using local platform settings 372 | # which makes tests fail on different remote CI testing services 373 | for x, line in enumerate(res_string_list): 374 | # treat top two lines of the diff as comparison of first 10 chars only 375 | if x == 0: 376 | assert line.startswith("---") 377 | elif x == 1: 378 | assert line.startswith("+++") 379 | elif x == 2: 380 | assert line == "@@ -4,34 +4,34 @@" 381 | elif x == 3: 382 | assert line == " " 383 | else: 384 | assert line == "" 385 | 386 | 387 | def test_main_tail_request(capsys): 388 | args = ["--tail", "2", ROBOTO_BEFORE_PATH, ROBOTO_AFTER_PATH] 389 | 390 | run(args) 391 | captured = capsys.readouterr() 392 | res_string_list = captured.out.split("\n") 393 | 394 | # includes a newline at the end of the last line of output 395 | # which makes the total # of lines in the list == n + 1 396 | assert len(res_string_list) == 3 397 | 398 | # have to handle the tests for the top two file path lines 399 | # differently than the rest of the comparisons because 400 | # the time is defined using local platform settings 401 | # which makes tests fail on different remote CI testing services 402 | for x, line in enumerate(res_string_list): 403 | # treat top two lines of the diff as comparison of first 10 chars only 404 | if x == 0: 405 | assert line == " " 406 | elif x == 1: 407 | assert line == " " 408 | else: 409 | assert line == "" 410 | --------------------------------------------------------------------------------