Skip to content

Commit 7eb3bd4

Browse files
committed
Merge branch 'remote-fonts'
2 parents 5f199d8 + 79cd693 commit 7eb3bd4

18 files changed

+477
-57
lines changed

.travis.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,25 @@ matrix:
1717
dist: xenial
1818
env: TOX_ENV=coverage
1919
install:
20-
- pip install --upgrade coverage pytest
20+
- pip install --upgrade coverage pytest pytest-asyncio
2121
- pip install .
2222
script:
2323
- make test-coverage
2424
after_success:
2525
- bash <(curl -s https://codecov.io/bash)
2626
- python: 3.6
2727
env: TOX_ENV=py36
28-
install: pip install --upgrade tox pytest
28+
install: pip install --upgrade tox pytest pytest-asyncio
2929
script: tox -e $TOX_ENV
3030
- python: 3.7
3131
env: TOX_ENV=py37
32-
install: pip install --upgrade tox pytest
32+
install: pip install --upgrade tox pytest pytest-asyncio
3333
dist: xenial
3434
script: tox -e $TOX_ENV
3535
- os: osx
3636
language: generic
3737
osx_image: xcode11 # Python 3.7.4 running on macOS 10.14.4
38-
install: pip3 install --upgrade tox pytest
38+
install: pip3 install --upgrade tox pytest pytest-asyncio
3939
env: TOX_ENV=py37
4040
script: tox -e $TOX_ENV
4141

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# Changelog
22

3+
## v0.4.0
4+
5+
- Added support for remote font files with asynchronous I/O GET requests. This feature supports combinations of local and remote font file comparisons.
6+
- `fdiff` executable: added support for remote font files with command line URL arguments
7+
- `fdiff` executable: refactored unified diff error message formatting
8+
- Library: add new `fdiff.remote` module
9+
- Library: add new `fdiff.aio` module
10+
- Library: add new `fdiff.exceptions` module
11+
- Library: refactored `fdiff.diff.unified_diff()` function to support remote files through URL
12+
- Library: refactored local file path checks to support remote files via URL
13+
- added new aiohttp, aiodns, aiofiles dependencies to requirements.txt
14+
- added new aiohttp, aiodns, aiofiles dependencies to setup.py
15+
- added pytest-asyncio dependency to setup.py [dev] install target
16+
- added pytest-asyncio dependency instatllation to tox.ini, .travis.yml, .appveyor.yml configuration files
17+
- Py3.6+ updates: removed `# -*- coding: utf-8 -*-` header definitions (Thanks Niko!)
18+
- updated fontTools dependency to v4.0.1 (from v4.0.0)
19+
- Updated README.md documentation
20+
321
## v0.3.0
422

523
- Added support for head and tail diff output filter functionality

README.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@
1313

1414
## About
1515

16-
`fdiff` is a Python command line comparison tool for differences in the OpenType table data between font files. The tool provides cross-platform support on macOS, Windows, and Linux systems with a Python v3.6+ interpreter.
16+
`fdiff` is a Python command line comparison tool for assessment of differences in the OpenType table data between font files. The tool provides cross-platform support on macOS, Windows, and Linux systems with a Python v3.6+ interpreter.
1717

1818
<p align="center">
1919
<img src="https://raw.githubusercontent.com/source-foundry/fdiff/img/img/diff-example-crunch.png" width="500"/>
2020
</p>
2121

2222
## What it does
2323

24-
- Takes two font file path arguments for comparison
24+
- Takes two font file path (or URL for remote fonts) arguments for the font comparison
2525
- Dumps OpenType table data in the fontTools library TTX format (XML)
2626
- Compares the OpenType table data across the two files using the unified diff format with 3 lines of surrounding context
2727

@@ -79,16 +79,34 @@ $ pip3 install --ignore-installed -r requirements.txt -e ".[dev]"
7979

8080
## Usage
8181

82+
#### Local font files
83+
8284
```
8385
$ fdiff [OPTIONS] [PRE-FONT FILE PATH] [POST-FONT FILE PATH]
8486
```
8587

86-
By default, an uncolored unified diff is performed on the two files defined with the local file paths in the above command.
88+
#### Remote font files
89+
90+
`fdiff` supports GET requests for publicly accessible remote font files. Replace the file path arguments with URL:
91+
92+
```
93+
$ fdiff [OPTIONS] [PRE-FONT FILE URL] [POST-FONT FILE URL]
94+
```
95+
96+
`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:
97+
98+
```
99+
$ fdiff [OPTIONS] [PRE-FONT FILE URL] [POST-FONT FILE FILE PATH]
100+
```
101+
102+
**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.
87103

88104
### Options
89105

90106
#### Color diffs
91107

108+
Uncolored diffs are performed by default.
109+
92110
To view a colored diff in your terminal, include either the `-c` or `--color` option in your command:
93111

94112
```

appveyor.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ install:
2020
- "python -m pip install --disable-pip-version-check --user --upgrade pip setuptools virtualenv"
2121

2222
# install the dependencies to run the tests
23-
- "python -m pip install tox"
23+
- "python -m pip install --upgrade tox pytest pytest-asyncio"
2424

2525
build: false
2626

codecov.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
codecov:
22
max_report_age: off
33

4-
comment: off
4+
coverage:
5+
status:
6+
project:
7+
default:
8+
# basic
9+
target: auto
10+
threshold: 2%
11+
base: auto
512

613
ignore:
714
- "lib/fdiff/thirdparty"
15+
16+
comment: off

lib/fdiff/__main__.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,12 @@ def run(argv):
8585
# File path argument validations
8686
# -------------------------------
8787

88-
if not file_exists(args.PREFILE):
88+
if not args.PREFILE.startswith("http") and not file_exists(args.PREFILE):
8989
sys.stderr.write(
9090
f"[*] ERROR: The file path '{args.PREFILE}' can not be found.{os.linesep}"
9191
)
9292
sys.exit(1)
93-
if not file_exists(args.POSTFILE):
93+
if not args.PREFILE.startswith("http") and not file_exists(args.POSTFILE):
9494
sys.stderr.write(
9595
f"[*] ERROR: The file path '{args.POSTFILE}' can not be found.{os.linesep}"
9696
)
@@ -123,10 +123,7 @@ def run(argv):
123123
exclude_tables=exclude_list,
124124
)
125125
except Exception as e:
126-
sys.stderr.write(
127-
f"[*] ERROR: During the attempt to diff the requested files the following error was encountered: "
128-
f"{e}{os.linesep}"
129-
)
126+
sys.stderr.write(f"[*] ERROR: {e}{os.linesep}")
130127
sys.exit(1)
131128

132129
# re-define the line contents of the diff iterable

lib/fdiff/aio.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import aiofiles
2+
3+
4+
async def async_write_bin(path, binary):
5+
"""Asynchronous IO writes of binary data `binary` to disk on the file path `path`"""
6+
async with aiofiles.open(path, "wb") as f:
7+
await f.write(binary)

lib/fdiff/diff.py

Lines changed: 85 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
import asyncio
12
import os
23
import tempfile
34

45
from fontTools.ttLib import TTFont
56

6-
from fdiff.utils import get_file_modtime
7+
from fdiff.exceptions import AIOError
8+
from fdiff.remote import (
9+
_get_filepath_from_url,
10+
create_async_get_request_session_and_run,
11+
)
712
from fdiff.thirdparty.fdifflib import unified_diff
13+
from fdiff.utils import get_file_modtime
814

915

1016
def u_diff(
@@ -13,8 +19,8 @@ def u_diff(
1319
"""Performs a unified diff on a TTX serialized data format dump of font binary data using
1420
a modified version of the Python standard libary difflib module.
1521
16-
filepath_a: (string) pre-file path
17-
filepath_b: (string) post-file path
22+
filepath_a: (string) pre-file local file path or URL path
23+
filepath_b: (string) post-file local file path or URL path
1824
context_lines: (int) number of context lines to include in the diff (default=3)
1925
include_tables: (list of str) Python list of OpenType tables to include in the diff
2026
exclude_tables: (list of str) Python list of OpentType tables to exclude from the diff
@@ -24,39 +30,81 @@ def u_diff(
2430
2531
:returns: Generator of ordered diff line strings that include newline line endings
2632
:raises: KeyError if include_tables or exclude_tables includes a mis-specified table
27-
that is not included in filepath_a OR filepath_b"""
28-
tt_left = TTFont(filepath_a)
29-
tt_right = TTFont(filepath_b)
30-
31-
# Validation: include_tables request should be for tables that are in one of
32-
# the two fonts. This otherwise silently passes with exit status code 0 which
33-
# could lead to the interpretation of no diff between two files when the table
34-
# entry is incorrectly defined or is a typo. Let's be conservative and consider
35-
# this an error, force user to use explicit definitions that include tables in
36-
# one of the two files, and understand that the diff request was for one or more
37-
# tables that are not present.
38-
if include_tables is not None:
39-
for table in include_tables:
40-
if table not in tt_left and table not in tt_right:
41-
raise KeyError(
42-
f"'{table}' table was not identified for inclusion in either font"
43-
)
44-
45-
# Validation: exclude_tables request should be for tables that are in one of
46-
# the two fonts. Mis-specified OT table definitions could otherwise result
47-
# in the presence of a table in the diff when the request was to exclude it.
48-
# For example, when an "OS/2" table request is entered as "OS2".
49-
if exclude_tables is not None:
50-
for table in exclude_tables:
51-
if table not in tt_left and table not in tt_right:
52-
raise KeyError(
53-
f"'{table}' table was not identified for exclusion in either font"
54-
)
55-
56-
fromdate = get_file_modtime(filepath_a)
57-
todate = get_file_modtime(filepath_b)
58-
33+
that is not included in filepath_a OR filepath_b
34+
:raises: fdiff.exceptions.AIOError if exception raised during execution of async I/O
35+
GET request for URL or file write
36+
:raises: fdiff.exceptions.AIOError if GET request to URL returned non-200 response status code"""
5937
with tempfile.TemporaryDirectory() as tmpdirname:
38+
# define the file paths with either local file requests
39+
# or pulls of remote files based on the command line request
40+
urls = []
41+
if filepath_a.startswith("http"):
42+
urls.append(filepath_a)
43+
prepath = _get_filepath_from_url(filepath_a, tmpdirname)
44+
# keep URL as path name for remote file requests
45+
pre_pathname = filepath_a
46+
else:
47+
prepath = filepath_a
48+
pre_pathname = filepath_a
49+
50+
if filepath_b.startswith("http"):
51+
urls.append(filepath_b)
52+
postpath = _get_filepath_from_url(filepath_b, tmpdirname)
53+
# keep URL as path name for remote file requests
54+
post_pathname = filepath_b
55+
else:
56+
postpath = filepath_b
57+
post_pathname = filepath_b
58+
59+
# Async IO fetch and write of any remote file requests
60+
if len(urls) > 0:
61+
loop = asyncio.get_event_loop()
62+
tasks = loop.run_until_complete(
63+
create_async_get_request_session_and_run(urls, tmpdirname)
64+
)
65+
for task in tasks:
66+
if task.exception():
67+
# raise exception here to notify calling code that something
68+
# did not work
69+
raise AIOError(f"{task.exception()}")
70+
elif task.result().http_status != 200:
71+
# handle non-200 HTTP response status codes + file write fails
72+
raise AIOError(
73+
f"failed to pull '{task.result().url}' with HTTP status code {task.result().http_status}"
74+
)
75+
76+
# instantiate left and right fontTools.ttLib.TTFont objects
77+
tt_left = TTFont(prepath)
78+
tt_right = TTFont(postpath)
79+
80+
# Validation: include_tables request should be for tables that are in one of
81+
# the two fonts. This otherwise silently passes with exit status code 0 which
82+
# could lead to the interpretation of no diff between two files when the table
83+
# entry is incorrectly defined or is a typo. Let's be conservative and consider
84+
# this an error, force user to use explicit definitions that include tables in
85+
# one of the two files, and understand that the diff request was for one or more
86+
# tables that are not present.
87+
if include_tables is not None:
88+
for table in include_tables:
89+
if table not in tt_left and table not in tt_right:
90+
raise KeyError(
91+
f"'{table}' table was not identified for inclusion in either font"
92+
)
93+
94+
# Validation: exclude_tables request should be for tables that are in one of
95+
# the two fonts. Mis-specified OT table definitions could otherwise result
96+
# in the presence of a table in the diff when the request was to exclude it.
97+
# For example, when an "OS/2" table request is entered as "OS2".
98+
if exclude_tables is not None:
99+
for table in exclude_tables:
100+
if table not in tt_left and table not in tt_right:
101+
raise KeyError(
102+
f"'{table}' table was not identified for exclusion in either font"
103+
)
104+
105+
fromdate = get_file_modtime(prepath)
106+
todate = get_file_modtime(postpath)
107+
60108
tt_left.saveXML(
61109
os.path.join(tmpdirname, "left.ttx"),
62110
tables=include_tables,
@@ -76,8 +124,8 @@ def u_diff(
76124
return unified_diff(
77125
fromlines,
78126
tolines,
79-
filepath_a,
80-
filepath_b,
127+
pre_pathname,
128+
post_pathname,
81129
fromdate,
82130
todate,
83131
n=context_lines,

lib/fdiff/exceptions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class AIOError(Exception):
2+
pass

lib/fdiff/remote.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import os.path
2+
import urllib.parse
3+
4+
from collections import namedtuple
5+
6+
import aiohttp
7+
import asyncio
8+
9+
from fdiff.aio import async_write_bin
10+
11+
12+
def _get_filepath_from_url(url, dirpath):
13+
"""Returns filepath from base file name in URL and directory path."""
14+
url_path_list = urllib.parse.urlsplit(url)
15+
abs_filepath = url_path_list.path
16+
basepath = os.path.split(abs_filepath)[-1]
17+
return os.path.join(dirpath, basepath)
18+
19+
20+
async def async_fetch(session, url):
21+
"""Asynchronous I/O HTTP GET request with a ClientSession instantiated from the aiohttp library."""
22+
async with session.get(url) as response:
23+
status = response.status
24+
if status != 200:
25+
binary = None
26+
else:
27+
binary = await response.read()
28+
return url, status, binary
29+
30+
31+
async def async_fetch_and_write(session, url, dirpath):
32+
"""Asynchronous I/O HTTP GET request with a ClientSession instantiated from the aiohttp library, followed
33+
by an asynchronous I/O file write of the binary to disk with the aiofiles library.
34+
35+
:returns `FWRes` namedtuple with url, filepath, http_status, write_success fields"""
36+
FWResponse = namedtuple(
37+
"FWRes", ["url", "filepath", "http_status", "write_success"]
38+
)
39+
url, status, binary = await async_fetch(session, url)
40+
if status != 200:
41+
filepath = None
42+
write_success = False
43+
else:
44+
filepath = _get_filepath_from_url(url, dirpath)
45+
await async_write_bin(filepath, binary)
46+
write_success = True
47+
48+
return FWResponse(
49+
url=url, filepath=filepath, http_status=status, write_success=write_success
50+
)
51+
52+
53+
async def create_async_get_request_session_and_run(urls, dirpath):
54+
"""Creates an aiohttp library ClientSession and performs asynchronous GET requests +
55+
binary file writes with the binary response from the GET request.
56+
57+
:returns list of asyncio Tasks that include `FWRes` namedtuple instances (defined in async_fetch_and_write)"""
58+
async with aiohttp.ClientSession() as session:
59+
tasks = []
60+
for url in urls:
61+
# use asyncio.ensure_future instead of .run() here to maintain
62+
# Py3.6 compatibility
63+
task = asyncio.ensure_future(async_fetch_and_write(session, url, dirpath))
64+
tasks.append(task)
65+
await asyncio.gather(*tasks, return_exceptions=True)
66+
return tasks

requirements.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1-
fontTools==4.0.1
1+
fontTools == 4.0.1
2+
aiohttp == 3.6.0
3+
aiodns == 2.0.0
4+
aiofiles == 0.4.0

0 commit comments

Comments
 (0)