├── .github
└── workflows
│ └── unit-tests.yml
├── .gitignore
├── LICENSE
├── README.md
├── clavier
├── __init__.py
├── keyboard.py
├── layouts.py
└── test_keyboard.py
├── img
├── qwerty.png
└── qwerty_staggered.png
├── pyproject.toml
└── pytest.ini
/.github/workflows/unit-tests.yml:
--------------------------------------------------------------------------------
1 | name: Unit tests
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | ubuntu:
7 | strategy:
8 | matrix:
9 | python: [3.7, 3.8, 3.9]
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v2
13 | - name: Set up Python ${{ matrix.python }}
14 | uses: actions/setup-python@v2
15 | with:
16 | python-version: ${{ matrix.python }}
17 | - name: Install dependencies
18 | run: |
19 | python -m pip install --upgrade pip
20 | pip install poetry
21 | poetry install
22 | poetry run pip install matplotlib
23 | - name: pytest
24 | run: |
25 | poetry run pytest
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | /*.ipynb
3 | *.lock
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Max Halford
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
clavier
3 |
Measure edit distance based on keyboard layout.
4 |
5 |
6 |
7 |
17 |
18 |
19 | ## Table of contents
20 |
21 | - [Table of contents](#table-of-contents)
22 | - [Introduction](#introduction)
23 | - [Installation](#installation)
24 | - [User guide](#user-guide)
25 | - [Keyboard layouts](#keyboard-layouts)
26 | - [Distance between characters](#distance-between-characters)
27 | - [Distance between words](#distance-between-words)
28 | - [Typing distance](#typing-distance)
29 | - [Nearest neighbors](#nearest-neighbors)
30 | - [Physical layout specification](#physical-layout-specification)
31 | - [Staggering](#staggering)
32 | - [Key pitch](#key-pitch)
33 | - [Drawing a keyboard layout](#drawing-a-keyboard-layout)
34 | - [Custom layouts](#custom-layouts)
35 | - [The `from_coordinates` method](#the-from_coordinates-method)
36 | - [The `from_grid` method](#the-from_grid-method)
37 | - [Development](#development)
38 | - [License](#license)
39 |
40 | ## Introduction
41 |
42 | Default [edit distances](https://www.wikiwand.com/en/Edit_distance), such as the [Levenshtein distance](https://www.wikiwand.com/en/Levenshtein_distance), don't differentiate between characters. The distance between two characters is either 0 or 1. This package allows you to measure edit distances by taking into account keyboard layouts.
43 |
44 | The scope is purposefully limited to alphabetical, numeric, and punctuation keys. That's because this package is meant to assist in analyzing user inputs -- e.g. for [spelling correction](https://norvig.com/spell-correct.html) in a search engine.
45 |
46 | The goal of this package is to be flexible. You can define any [logical layout](https://deskthority.net/wiki/Keyboard_layouts#Logical_layout), such as QWERTY or AZERTY. You can also control the [physical layout](https://deskthority.net/wiki/Physical_keyboard_layout) by defining where the keys are on the board.
47 |
48 | ## Installation
49 |
50 | ```sh
51 | pip install git+https://github.com/MaxHalford/clavier
52 | ```
53 |
54 | ## User guide
55 |
56 | ### Keyboard layouts
57 |
58 | ☝️ Things are a bit more complicated than QWERTY vs. AZERTY vs. XXXXXX. Each layout has many variants. I haven't yet figured out a comprehensive way to map all these out.
59 |
60 | This package provides a list of keyboard layouts. For instance, we'll load the [QWERTY](https://www.wikiwand.com/en/QWERTY) keyboard layout.
61 |
62 | ```py
63 | >>> import clavier
64 | >>> keyboard = clavier.load_qwerty()
65 | >>> keyboard
66 | 1 2 3 4 5 6 7 8 9 0 - =
67 | q w e r t y u i o p [ ] \
68 | a s d f g h j k l ; '
69 | z x c v b n m , . /
70 |
71 | >>> keyboard.shape
72 | (4, 13)
73 |
74 | >>> len(keyboard)
75 | 46
76 |
77 | ```
78 |
79 | Here is the list of currently available layouts:
80 |
81 | ```py
82 | >>> for layout in sorted(member for member in dir(clavier) if member.startswith('load_')):
83 | ... print(layout.replace('load_', ''))
84 | ... exec(f'print(clavier.{layout}())')
85 | ... print('---')
86 | dialpad
87 | 1 2 3
88 | 4 5 6
89 | 7 8 9
90 | * 0 #
91 | ---
92 | dvorak
93 | ` 1 2 3 4 5 6 7 8 9 0 [ ]
94 | ' , . p y f g c r l / = \
95 | a o e u i d h t n s -
96 | ; q j k x b m w v z
97 | ---
98 | qwerty
99 | 1 2 3 4 5 6 7 8 9 0 - =
100 | q w e r t y u i o p [ ] \
101 | a s d f g h j k l ; '
102 | z x c v b n m , . /
103 | ---
104 |
105 | ```
106 |
107 | ### Distance between characters
108 |
109 | Measure the Euclidean distance between two characters on the keyboard.
110 |
111 | ```py
112 | >>> keyboard.char_distance('1', '2')
113 | 1.0
114 |
115 | >>> keyboard.char_distance('q', '2')
116 | 1.4142135623730951
117 |
118 | >>> keyboard.char_distance('1', 'm')
119 | 6.708203932499369
120 |
121 | >>> keyboard.char_distance('1', 'm', metric='l1')
122 | 9.0
123 |
124 | ```
125 |
126 | ### Distance between words
127 |
128 | Measure a modified version of the [Levenshtein distance](https://www.wikiwand.com/en/Levenshtein_distance), where the substitution cost is the output of the `char_distance` method.
129 |
130 | ```py
131 | >>> keyboard.word_distance('apple', 'wople')
132 | 2.414213562373095
133 |
134 | >>> keyboard.word_distance('apple', 'woplee')
135 | 3.414213562373095
136 |
137 | >>> keyboard.word_distance('apple', 'woplee', metric='l1')
138 | 4.0
139 |
140 | ```
141 |
142 | You can also override the deletion cost by specifying the `deletion_cost` parameter, and the insertion cost via the `insertion_cost` parameter. Both default to 1.
143 |
144 | ### Typing distance
145 |
146 | Measure the sum of distances between each pair of consecutive characters. This can be useful for studying [keystroke dynamics](https://www.wikiwand.com/en/Keystroke_dynamics).
147 |
148 | ```py
149 | >>> keyboard.typing_distance('hello')
150 | 10.245040190466598
151 |
152 | ```
153 |
154 | For sentences, you can split them up into words and sum the typing distances.
155 |
156 | ```py
157 | >>> sentence = 'the quick brown fox jumps over the lazy dog'
158 | >>> sum(keyboard.typing_distance(word) for word in sentence.split(' '))
159 | 105.60457487263012
160 |
161 | >>> sum(keyboard.typing_distance(word, metric='l1') for word in sentence.split(' '))
162 | 124.0
163 |
164 | ```
165 |
166 | Interestingly, this can be used to compare keyboard layouts in terms of efficiency. For instance, the [Dvorak](https://www.wikiwand.com/en/Dvorak_keyboard_layout) keyboard layout is supposedly more efficient than the QWERTY layout. Let's compare both on the first stanza of [If—](https://www.wikiwand.com/en/If%E2%80%94) by Rudyard Kipling:
167 |
168 | ```py
169 | >>> stanza = """
170 | ... If you can keep your head when all about you
171 | ... Are losing theirs and blaming it on you;
172 | ... If you can trust yourself when all men doubt you,
173 | ... But make allowance for their doubting too;
174 | ... If you can wait and not be tired by waiting,
175 | ... Or, being lied about, don't deal in lies,
176 | ... Or, being hated, don't give way to hating,
177 | ... And yet don't look too good, nor talk too wise;
178 | ... """
179 |
180 | >>> words = list(map(str.lower, stanza.split()))
181 |
182 | >>> qwerty = clavier.load_qwerty()
183 | >>> sum(qwerty.typing_distance(word) for word in words)
184 | 740.3255229138255
185 |
186 | >>> dvorak = clavier.load_dvorak()
187 | >>> sum(dvorak.typing_distance(word) for word in words)
188 | 923.6597116104518
189 |
190 | ```
191 |
192 | It seems the Dvorak layout is in fact slower than the QWERTY layout. But of course this might not be the case in general.
193 |
194 | ### Nearest neighbors
195 |
196 | You can iterate over the `k` nearest neighbors of any character.
197 |
198 | ```py
199 | >>> qwerty = clavier.load_qwerty()
200 | >>> for char, dist in qwerty.nearest_neighbors('s', k=8, cache=True):
201 | ... print(char, f'{dist:.4f}')
202 | w 1.0000
203 | a 1.0000
204 | d 1.0000
205 | x 1.0000
206 | q 1.4142
207 | e 1.4142
208 | z 1.4142
209 | c 1.4142
210 |
211 | ```
212 |
213 | The `cache` parameter determines whether or not the result should be cached for the next call.
214 |
215 | ### Physical layout specification
216 |
217 | By default, the keyboard layouts are [ortholinear](https://deskthority.net/wiki/Staggering#Matrix_layout), meaning that the characters are physically arranged over a grid. You can customize the physical layout to make it more realistic and thus obtain distance measures which are closer to reality. This can be done by specifying parameters to the keyboards when they're loaded.
218 |
219 | #### Staggering
220 |
221 | [Staggering](https://deskthority.net/wiki/Staggering) is the amount of offset between two consecutive keyboard rows.
222 |
223 | You can specify a constant staggering as so:
224 |
225 | ```py
226 | >>> keyboard = clavier.load_qwerty(staggering=0.5)
227 |
228 | ```
229 |
230 | By default the keys are spaced by 1 unit. So a staggering value of 0.5 implies a 50% horizontal shift between each pair of consecutive rows. You may also specify a different amount of staggering for each pair of rows:
231 |
232 | ```py
233 | >>> keyboard = clavier.load_qwerty(staggering=[0.5, 0.25, 0.5])
234 |
235 | ```
236 |
237 | There's 3 elements in the list because the keyboard has 4 rows.
238 |
239 | #### Key pitch
240 |
241 | [Key pitch](https://deskthority.net/wiki/Unit) is the amount of distance between the centers of two adjacent keys. Most computer keyboards have identical horizontal and vertical pitches, because the keys are all of the same size width and height. But this isn't the case for mobile phone keyboards. For instance, iPhone keyboards have a higher vertical pitch.
242 |
243 | ### Drawing a keyboard layout
244 |
245 | ```py
246 | >>> keyboard = clavier.load_qwerty()
247 | >>> ax = keyboard.draw()
248 | >>> ax.get_figure().savefig('img/qwerty.png', bbox_inches='tight')
249 |
250 | ```
251 |
252 | 
253 |
254 | ```py
255 | >>> keyboard = clavier.load_qwerty(staggering=[0.5, 0.25, 0.5])
256 | >>> ax = keyboard.draw()
257 | >>> ax.get_figure().savefig('img/qwerty_staggered.png', bbox_inches='tight')
258 |
259 | ```
260 |
261 | 
262 |
263 | ### Custom layouts
264 |
265 | You can of course specify your own keyboard layout. There are different ways to do this. We'll use the iPhone keypad as an example.
266 |
267 | #### The `from_coordinates` method
268 |
269 | ```py
270 | >>> keypad = clavier.Keyboard.from_coordinates({
271 | ... '1': (0, 0), '2': (0, 1), '3': (0, 2),
272 | ... '4': (1, 0), '5': (1, 1), '6': (1, 2),
273 | ... '7': (2, 0), '8': (2, 1), '9': (2, 2),
274 | ... '*': (3, 0), '0': (3, 1), '#': (3, 2),
275 | ... '☎': (4, 1)
276 | ... })
277 | >>> keypad
278 | 1 2 3
279 | 4 5 6
280 | 7 8 9
281 | * 0 #
282 | ☎
283 |
284 | ```
285 |
286 | #### The `from_grid` method
287 |
288 | ```py
289 | >>> keypad = clavier.Keyboard.from_grid("""
290 | ... 1 2 3
291 | ... 4 5 6
292 | ... 7 8 9
293 | ... * 0 #
294 | ... ☎
295 | ... """)
296 | >>> keypad
297 | 1 2 3
298 | 4 5 6
299 | 7 8 9
300 | * 0 #
301 | ☎
302 |
303 | ```
304 |
305 | ## Development
306 |
307 | ```sh
308 | git clone https://github.com/MaxHalford/clavier
309 | cd clavier
310 | pip install poetry
311 | poetry install
312 | poetry shell
313 | pytest
314 | ```
315 |
316 | ## License
317 |
318 | The MIT License (MIT). Please see the [license file](LICENSE) for more information.
319 |
--------------------------------------------------------------------------------
/clavier/__init__.py:
--------------------------------------------------------------------------------
1 | from .keyboard import Keyboard
2 | from .layouts import load_dvorak, load_qwerty, load_dialpad
3 |
4 | __all__ = ["Keyboard", "load_dvorak", "load_qwerty", "load_dialpad"]
5 |
--------------------------------------------------------------------------------
/clavier/keyboard.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Iterator, List, Tuple, Union
2 | import collections
3 | import itertools
4 | import operator
5 | import textwrap
6 |
7 |
8 | class Keyboard(collections.UserDict):
9 | """"""
10 |
11 | def __init__(self, *args, **kwargs):
12 | super().__init__(*args, **kwargs)
13 | self._neighbors_cache = {}
14 |
15 | @property
16 | def n_rows(self):
17 | return int(max(pos.real for pos in self.values()) + 1)
18 |
19 | @property
20 | def n_columns(self):
21 | return int(max(pos.imag for pos in self.values()) + 1)
22 |
23 | @property
24 | def shape(self):
25 | return (self.n_rows, self.n_columns)
26 |
27 | def char_distance(self, c1: str, c2: str, metric='l2') -> float:
28 | """Measure the Euclidean distance between two characters."""
29 | if c1 == c2:
30 | return 0.0
31 | if metric == 'l2':
32 | return abs(self[c1] - self[c2])
33 | elif metric == 'l1':
34 | return abs(self[c1].real - self[c2].real) + abs(self[c1].imag - self[c2].imag)
35 | raise ValueError(f"Unknown metric: {metric}, must be 'l1' or 'l2'")
36 |
37 | def word_distance(
38 | self, w1: str, w2: str, deletion_cost=1, insertion_cost=1, metric='l2'
39 | ) -> float:
40 | """Levenshtein distance between two words.
41 |
42 | This is an implementation of the Wagner-Fisher algorithm for measuring the Levenshtein
43 | distance between two words. The substitution distance is the character distance given
44 | by the `char_distance` method. The deletion and insertion costs can be provided and both
45 | default to 1.
46 |
47 | """
48 | D = [[0 for _ in range(len(w1) + 1)] for _ in range(len(w2) + 1)]
49 |
50 | for i in range(1, len(w2) + 1):
51 | D[i][0] = i
52 |
53 | for j in range(1, len(w1) + 1):
54 | D[0][j] = j
55 |
56 | for j, c1 in enumerate(w1, start=1):
57 | for i, c2 in enumerate(w2, start=1):
58 | substitution_cost = self.char_distance(c1, c2, metric=metric)
59 | D[i][j] = min(
60 | D[i - 1][j] + deletion_cost,
61 | D[i][j - 1] + insertion_cost,
62 | D[i - 1][j - 1] + substitution_cost,
63 | )
64 |
65 | return D[-1][-1]
66 |
67 | def typing_distance(self, word: str, metric='l2') -> float:
68 | """Measure the sum of distances between each pair of consecutive characters."""
69 | return sum(self.char_distance(c1, c2, metric=metric) for c1, c2 in zip(word, word[1:]))
70 |
71 | def nearest_neighbors(
72 | self, char: str, k=None, cache=False, metric='l2'
73 | ) -> Iterator[Tuple[str, float]]:
74 | """Iterate over the k closest neighbors to a char."""
75 |
76 | if cache and (neighbors := self._neighbors_cache):
77 | yield from neighbors
78 | return
79 |
80 | neighbors = sorted(
81 | (
82 | (neighbor, self.char_distance(char, neighbor, metric=metric))
83 | for neighbor in self
84 | if neighbor != char
85 | ),
86 | key=operator.itemgetter(1),
87 | )
88 |
89 | if k is not None:
90 | neighbors = neighbors[:k]
91 |
92 | if cache:
93 | self._neighbors_cache[char] = neighbors
94 |
95 | yield from neighbors
96 |
97 | @classmethod
98 | def from_coordinates(
99 | cls,
100 | coordinates: Dict[str, Tuple[int, int]],
101 | staggering: Union[float, List] = 0,
102 | horizontal_pitch=1,
103 | vertical_pitch=1,
104 | ):
105 | """
106 |
107 | Parameters
108 | ----------
109 | coordinates
110 | A dictionary specifying the (row, col) location of each character. The origin is
111 | assumed to be at the top-left corner.
112 | staggering
113 | Controls the amount of staggering between consecutive rows. The amount of staggering is
114 | the same between pair of consecutive rows if a single number is specified. Variable
115 | amounts of staggering can be specified by providing a list of length `n_rows - 1`,
116 | within which the ith element corresponds the staggering between rows `i` and `i + 1`.
117 | horizontal_pitch
118 | The horizontal distance between the center of two adjacent keys.
119 | vertical_pitch
120 | The vertical distance between the center of two adjacent keys.
121 |
122 | """
123 | if isinstance(staggering, list):
124 | staggering = list(itertools.accumulate(staggering, initial=0))
125 |
126 | return cls(
127 | {
128 | char.lower(): complex(
129 | i * vertical_pitch,
130 | j * horizontal_pitch
131 | + (
132 | staggering[i]
133 | if isinstance(staggering, list)
134 | else i * staggering
135 | ),
136 | )
137 | for char, (i, j) in coordinates.items()
138 | }
139 | )
140 |
141 | @classmethod
142 | def from_grid(
143 | cls,
144 | grid: str,
145 | staggering: Union[float, List] = 0,
146 | horizontal_pitch=1,
147 | vertical_pitch=1,
148 | ):
149 | """
150 |
151 | Parameters
152 | ----------
153 | grid
154 | A keyboard layout specified as a grid separated by spaces. See the examples to
155 | understand the format.
156 | staggering
157 | Controls the amount of staggering between consecutive rows. The amount of staggering is
158 | the same between pair of consecutive rows if a single number is specified. Variable
159 | amounts of staggering can be specified by providing a list of length `n_rows - 1`,
160 | within which the ith element corresponds the staggering between rows `i` and `i + 1`.
161 | horizontal_pitch
162 | The horizontal distance between the center of two adjacent keys.
163 | vertical_pitch
164 | The vertical distance between the center of two adjacent keys.
165 |
166 | """
167 | return cls.from_coordinates(
168 | coordinates={
169 | char: (i, j)
170 | for i, row in enumerate(filter(len, textwrap.dedent(grid).splitlines()))
171 | for j, char in enumerate(row[::2])
172 | if char
173 | },
174 | staggering=staggering,
175 | horizontal_pitch=horizontal_pitch,
176 | vertical_pitch=vertical_pitch,
177 | )
178 |
179 | def __repr__(self):
180 | rows = [[] for _ in range(self.n_rows)]
181 | reverse_layout = {
182 | (int(pos.real), int(pos.imag)): char for char, pos in self.items()
183 | }
184 | for i, j in sorted(reverse_layout.keys()):
185 | rows[i].extend([" "] * (j - len(rows[i])))
186 | rows[i].append(reverse_layout[i, j])
187 | return "\n".join(" ".join(row) for row in rows)
188 |
189 | def draw(self, fontsize=150):
190 | import matplotlib.pyplot as plt
191 |
192 | fig, ax = plt.subplots()
193 |
194 | for char, pos in self.items():
195 | ax.text(
196 | pos.imag,
197 | pos.real,
198 | char,
199 | fontsize=fontsize,
200 | horizontalalignment="center",
201 | verticalalignment="center",
202 | )
203 |
204 | ax.axis("off")
205 | ax.invert_yaxis()
206 |
207 | return ax
208 |
--------------------------------------------------------------------------------
/clavier/layouts.py:
--------------------------------------------------------------------------------
1 | from clavier.keyboard import Keyboard
2 |
3 |
4 | def load_qwerty(**kwargs):
5 | return Keyboard.from_grid(
6 | """
7 | 1 2 3 4 5 6 7 8 9 0 - =
8 | q w e r t y u i o p [ ] \\
9 | a s d f g h j k l ; '
10 | z x c v b n m , . /
11 | """,
12 | **kwargs
13 | )
14 |
15 |
16 | def load_dvorak(**kwargs):
17 | return Keyboard.from_grid(
18 | """
19 | ` 1 2 3 4 5 6 7 8 9 0 [ ]
20 | ' , . p y f g c r l / = \\
21 | a o e u i d h t n s -
22 | ; q j k x b m w v z
23 | """,
24 | **kwargs
25 | )
26 |
27 |
28 | def load_dialpad(**kwargs):
29 | return Keyboard.from_grid(
30 | """
31 | 1 2 3
32 | 4 5 6
33 | 7 8 9
34 | * 0 #
35 | """,
36 | **kwargs
37 | )
38 |
--------------------------------------------------------------------------------
/clavier/test_keyboard.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | import clavier
4 |
5 |
6 | @pytest.mark.parametrize(
7 | "w1,w2,expected", [("kitten", "sitting", 3), ("saturday", "sunday", 3)]
8 | )
9 | def test_levenshtein(w1, w2, expected):
10 | """Tests word distance by assuming the distance between characters is always 1."""
11 | keyboard = clavier.load_qwerty()
12 |
13 | def mock_distance(c1, c2, metric):
14 | return 1 if c1 != c2 else 0
15 |
16 | keyboard.char_distance = mock_distance
17 | assert keyboard.word_distance(w1, w2) == expected
18 |
19 |
20 | def test_phone_number_distance() -> None:
21 | dialpad = clavier.load_dialpad()
22 | assert dialpad.word_distance("123", "423") == 1.0
23 | assert dialpad.word_distance("123", "523") == pytest.approx(1.414, 0.001)
24 | assert dialpad.word_distance("123", "183") == 2.0
25 | assert dialpad.word_distance("1234", "123") == 1.0
26 |
--------------------------------------------------------------------------------
/img/qwerty.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxHalford/clavier/59f4bedae6750d5711ca5517f95e82fe90fbc22c/img/qwerty.png
--------------------------------------------------------------------------------
/img/qwerty_staggered.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MaxHalford/clavier/59f4bedae6750d5711ca5517f95e82fe90fbc22c/img/qwerty_staggered.png
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "clavier"
3 | version = "0.1.0"
4 | description = "Measuring distance between keyboards characters"
5 | authors = ["MaxHalford "]
6 | license = "MIT"
7 |
8 | [tool.poetry.dependencies]
9 | python = "^3.8"
10 |
11 | [tool.poetry.dev-dependencies]
12 | pytest = "^6.2.5"
13 |
14 | [build-system]
15 | requires = ["poetry-core>=1.0.0"]
16 | build-backend = "poetry.core.masonry.api"
17 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | addopts =
3 | --verbose
4 | --doctest-modules
5 | --doctest-glob=README.md
6 | doctest_optionflags = NORMALIZE_WHITESPACE NUMBER ELLIPSIS
7 |
--------------------------------------------------------------------------------