├── .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 |
8 | 9 | 10 | unit-tests 11 | 12 | 13 | 14 | license 15 | 16 |
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 | ![qwerty](img/qwerty.png) 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 | ![qwerty_staggered](img/qwerty_staggered.png) 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 | --------------------------------------------------------------------------------