├── 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 | [](https://pypi.org/project/fdiff/)
7 | [](https://github.com/source-foundry/fdiff/blob/master/LICENSE)
8 | 
9 | 
10 | 
11 | [](https://codecov.io/gh/source-foundry/fdiff)
12 | [](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 | [31m--- tests/testfiles/Roboto-Regular.subset1.ttf 2019-09-05T14:04:24.748302-04:00
2 | [0m[32m+++ tests/testfiles/Roboto-Regular.subset2.ttf 2019-09-05T14:07:27.594299-04:00
3 | [0m[36m@@ -4,34 +4,34 @@
4 | [0m
5 |
6 |
7 | [31m-
8 | [0m[31m-
9 | [0m[31m-
10 | [0m[32m+
11 | [0m[32m+
12 | [0m[32m+
13 | [0m
14 |
15 |
16 |
17 |
18 |
19 | [31m-
20 | [0m[32m+
21 | [0m
22 |
23 |
24 |
25 |
26 |
27 | [31m-
28 | [0m[32m+
29 | [0m
30 |
31 |
32 |
33 | [31m-
34 | [0m[31m-
35 | [0m[31m-
36 | [0m[31m-
37 | [0m[31m-
38 | [0m[32m+
39 | [0m[32m+
40 | [0m[32m+
41 | [0m[32m+
42 | [0m[32m+
43 | [0m
44 | [31m-
45 | [0m[32m+
46 | [0m
47 |
48 |
49 | [36m@@ -42,10 +42,10 @@
50 | [0m
51 |
52 |
53 | [31m-
54 | [0m[31m-
55 | [0m[31m-
56 | [0m[31m-
57 | [0m[32m+
58 | [0m[32m+
59 | [0m[32m+
60 | [0m[32m+
61 | [0m
62 |
63 |
64 | [36m@@ -61,10 +61,10 @@
65 | [0m
66 |
67 |
68 | [31m-
69 | [0m[31m-
70 | [0m[31m-
71 | [0m[31m-
72 | [0m[32m+
73 | [0m[32m+
74 | [0m[32m+
75 | [0m[32m+
76 | [0m
77 |
78 |
79 | [36m@@ -72,8 +72,8 @@
80 | [0m
81 |
82 |
83 | [31m-
84 | [0m[31m-
85 | [0m[32m+
86 | [0m[32m+
87 | [0m
88 |
89 |
90 | [36m@@ -131,16 +131,16 @@
91 | [0m
92 |
93 |
94 | [31m-
95 | [0m
96 | [31m-
97 | [0m
98 | [32m+
99 | [0m[32m+
100 | [0m[32m+
101 | [0m[32m+
102 | [0m
103 |
104 |
105 |
106 | [31m-
107 | [0m[31m-
108 | [0m
109 |
110 |
111 | [36m@@ -217,8 +217,6 @@
112 | [0m
113 |
114 |
115 | [31m-
116 | [0m[31m-
117 | [0m
118 |
119 |
120 | [36m@@ -270,7 +268,37 @@
121 | [0m
122 |
123 |
124 | [31m-
125 | [0m[32m+
126 | [0m[32m+
127 | [0m[32m+
128 | [0m[32m+
129 | [0m[32m+
130 | [0m[32m+
131 | [0m[32m+
132 | [0m[32m+
133 | [0m[32m+
134 | [0m[32m+
135 | [0m[32m+
136 | [0m[32m+
137 | [0m[32m+
138 | [0m[32m+
139 | [0m[32m+
140 | [0m[32m+
141 | [0m[32m+
142 | [0m[32m+
143 | [0m[32m+
144 | [0m[32m+
145 | [0m[32m+
146 | [0m[32m+
147 | [0m[32m+
148 | [0m[32m+
149 | [0m[32m+
150 | [0m[32m+
151 | [0m[32m+
152 | [0m[32m+
153 | [0m[32m+
154 | [0m[32m+
155 | [0m[32m+
156 | [0m
157 |
158 |
159 | [36m@@ -320,30 +348,6 @@
160 | [0m
161 |
162 |
163 | [31m-
164 | [0m[31m-
165 | [0m[31m-
166 | [0m[31m-
167 | [0m[31m-
168 | [0m[31m-
169 | [0m[31m-
170 | [0m[31m-
171 | [0m[31m-
172 | [0m[31m-
173 | [0m[31m-
174 | [0m[31m-
175 | [0m[31m-
176 | [0m[31m-
177 | [0m[31m-
178 | [0m[31m-
179 | [0m[31m-
180 | [0m[31m-
181 | [0m[31m-
182 | [0m[31m-
183 | [0m[31m-
184 | [0m[31m-
185 | [0m[31m-
186 | [0m[31m-
187 | [0m
188 |
189 |
190 | [36m@@ -478,10 +482,6 @@
191 | [0m
192 |
193 |
194 | [31m-
195 | [0m[31m-
196 | [0m[31m-
197 | [0m[31m-
198 | [0m
199 |
200 |
201 | [36m@@ -528,33 +528,6 @@
202 | [0m http://www.apache.org/licenses/LICENSE-2.0
203 |
204 |
205 | [31m-
206 | [0m[31m-
207 | [0m[31m-
208 | [0m[31m-
209 | [0m[31m-
210 | [0m[31m-
211 | [0m[31m-
212 | [0m[31m-
213 | [0m[31m-
214 | [0m[31m-
215 | [0m[31m-
216 | [0m[31m-
217 | [0m[31m-
224 | [0m[31m-
225 | [0m[31m-
226 | [0m[31m-
227 | [0m[31m-
228 | [0m[31m-
229 | [0m[31m-
230 | [0m[31m-
231 | [0m[31m-
232 | [0m
233 |
234 |
235 | [36m@@ -757,7 +730,7 @@
236 | [0m
237 |
238 |
239 | [31m-
240 | [0m[32m+
241 | [0m
242 |
243 |
244 |
--------------------------------------------------------------------------------
/tests/testfiles/roboto_extdiff_color_expected.txt:
--------------------------------------------------------------------------------
1 | [31m--- /var/folders/x7/vs8mbwmx1hg1vcb53nlygt0m0000gn/T/tmp6mjzf6a2/left.ttx 2019-12-17 22:47:31.000000000 -0500
2 | [0m[32m+++ /var/folders/x7/vs8mbwmx1hg1vcb53nlygt0m0000gn/T/tmp6mjzf6a2/right.ttx 2019-12-17 22:47:31.000000000 -0500
3 | [0m[36m@@ -4,34 +4,34 @@
4 | [0m
5 |
6 |
7 | [31m-
8 | [0m[31m-
9 | [0m[31m-
10 | [0m[32m+
11 | [0m[32m+
12 | [0m[32m+
13 | [0m
14 |
15 |
16 |
17 |
18 |
19 | [31m-
20 | [0m[32m+
21 | [0m
22 |
23 |
24 |
25 |
26 |
27 | [31m-
28 | [0m[32m+
29 | [0m
30 |
31 |
32 |
33 | [31m-
34 | [0m[31m-
35 | [0m[31m-
36 | [0m[31m-
37 | [0m[31m-
38 | [0m[32m+
39 | [0m[32m+
40 | [0m[32m+
41 | [0m[32m+
42 | [0m[32m+
43 | [0m
44 | [31m-
45 | [0m[32m+
46 | [0m
47 |
48 |
49 | [36m@@ -42,10 +42,10 @@
50 | [0m
51 |
52 |
53 | [31m-
54 | [0m[31m-
55 | [0m[31m-
56 | [0m[31m-
57 | [0m[32m+
58 | [0m[32m+
59 | [0m[32m+
60 | [0m[32m+
61 | [0m
62 |
63 |
64 | [36m@@ -61,10 +61,10 @@
65 | [0m
66 |
67 |
68 | [31m-
69 | [0m[31m-
70 | [0m[31m-
71 | [0m[31m-
72 | [0m[32m+
73 | [0m[32m+
74 | [0m[32m+
75 | [0m[32m+
76 | [0m
77 |
78 |
79 | [36m@@ -72,8 +72,8 @@
80 | [0m
81 |
82 |
83 | [31m-
84 | [0m[31m-
85 | [0m[32m+
86 | [0m[32m+
87 | [0m
88 |
89 |
90 | [36m@@ -131,16 +131,16 @@
91 | [0m
92 |
93 |
94 | [31m-
95 | [0m
96 | [31m-
97 | [0m
98 | [32m+
99 | [0m[32m+
100 | [0m[32m+
101 | [0m[32m+
102 | [0m
103 |
104 |
105 |
106 | [31m-
107 | [0m[31m-
108 | [0m
109 |
110 |
111 | [36m@@ -218,8 +218,6 @@
112 | [0m
113 |
114 |
115 | [31m-
116 | [0m[31m-
117 | [0m
118 |
119 |
120 | [36m@@ -270,7 +268,37 @@
121 | [0m
122 |
123 |
124 | [31m-
125 | [0m[32m+
126 | [0m[32m+
127 | [0m[32m+
128 | [0m[32m+
129 | [0m[32m+
130 | [0m[32m+
131 | [0m[32m+
132 | [0m[32m+
133 | [0m[32m+
134 | [0m[32m+
135 | [0m[32m+
136 | [0m[32m+
137 | [0m[32m+
138 | [0m[32m+
139 | [0m[32m+
140 | [0m[32m+
141 | [0m[32m+
142 | [0m[32m+
143 | [0m[32m+
144 | [0m[32m+
145 | [0m[32m+
146 | [0m[32m+
147 | [0m[32m+
148 | [0m[32m+
149 | [0m[32m+
150 | [0m[32m+
151 | [0m[32m+
152 | [0m[32m+
153 | [0m[32m+
154 | [0m[32m+
155 | [0m[32m+
156 | [0m
157 |
158 |
159 | [36m@@ -320,30 +348,6 @@
160 | [0m
161 |
162 |
163 | [31m-
164 | [0m[31m-
165 | [0m[31m-
166 | [0m[31m-
167 | [0m[31m-
168 | [0m[31m-
169 | [0m[31m-
170 | [0m[31m-
171 | [0m[31m-
172 | [0m[31m-
173 | [0m[31m-
174 | [0m[31m-
175 | [0m[31m-
176 | [0m[31m-
177 | [0m[31m-
178 | [0m[31m-
179 | [0m[31m-
180 | [0m[31m-
181 | [0m[31m-
182 | [0m[31m-
183 | [0m[31m-
184 | [0m[31m-
185 | [0m[31m-
186 | [0m[31m-
187 | [0m
188 |
189 |
190 | [36m@@ -478,10 +482,6 @@
191 | [0m
192 |
193 |
194 | [31m-
195 | [0m[31m-
196 | [0m[31m-
197 | [0m[31m-
198 | [0m
199 |
200 |
201 | [36m@@ -529,33 +529,6 @@
202 | [0m
203 |
204 |
205 | [31m-
206 | [0m[31m-
207 | [0m[31m-
208 | [0m[31m-
209 | [0m[31m-
210 | [0m[31m-
211 | [0m[31m-
212 | [0m[31m-
213 | [0m[31m-
214 | [0m[31m-
215 | [0m[31m-
216 | [0m[31m-
223 | [0m[31m-
224 | [0m[31m-
225 | [0m[31m-
226 | [0m[31m-
227 | [0m[31m-
228 | [0m[31m-
229 | [0m[31m-
230 | [0m[31m-
231 | [0m[31m-
232 | [0m
233 |
234 |
235 | [36m@@ -757,7 +730,7 @@
236 | [0m
237 |
238 |
239 | [31m-
240 | [0m[32m+
241 | [0m
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 |
--------------------------------------------------------------------------------