├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── ot ├── __init__.py ├── client.py ├── server.py └── text_operation.py ├── requirements.txt ├── setup.py └── tests ├── helpers.py ├── test_client_server.py ├── test_server.py └── test_text_operation.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.6 4 | - 2.7 5 | - 3.2 6 | - pypy 7 | install: "pip install --use-mirrors" 8 | script: nosetests tests 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright © 2012 Tim Baumann, http://timbaumann.info 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the “Software”), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://secure.travis-ci.org/Operational-Transformation/ot.py.png)](http://travis-ci.org/Operational-Transformation/ot.py) -------------------------------------------------------------------------------- /ot/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = 'ot' 2 | __version__ = '0.0.1' 3 | __author__ = 'Tim Baumann' 4 | __license__ = 'MIT' 5 | -------------------------------------------------------------------------------- /ot/client.py: -------------------------------------------------------------------------------- 1 | # I have adopted the naming convention from Daniel Spiewak's CCCP: 2 | # https://github.com/djspiewak/cccp/blob/master/agent/src/main/scala/com/codecommit/cccp/agent/state.scala 3 | 4 | 5 | class Client(object): 6 | """Handles the client part of the OT synchronization protocol. Transforms 7 | incoming operations from the server, buffers operations from the user and 8 | sends them to the server at the right time. 9 | """ 10 | 11 | def __init__(self, revision): 12 | self.revision = revision 13 | self.state = synchronized 14 | 15 | def apply_client(self, operation): 16 | """Call this method when the user (!) changes the document.""" 17 | self.state = self.state.apply_client(self, operation) 18 | 19 | def apply_server(self, operation): 20 | """Call this method with a new operation from the server.""" 21 | self.revision += 1 22 | self.state = self.state.apply_server(self, operation) 23 | 24 | def server_ack(self): 25 | """Call this method when the server acknowledges an operation send by 26 | the current user (via the send_operation method) 27 | """ 28 | self.revision += 1 29 | self.state = self.state.server_ack(self) 30 | 31 | def send_operation(self, revision, operation): 32 | """Should send an operation and its revision number to the server.""" 33 | raise NotImplementedError("You have to override 'send_operation' in your Client child class") 34 | 35 | def apply_operation(self, operation): 36 | """Should apply an operation from the server to the current document.""" 37 | raise NotImplementedError("You have to overrid 'apply_operation' in your Client child class") 38 | 39 | 40 | class Synchronized(object): 41 | """In the 'Synchronized' state, there is no pending operation that the client 42 | has sent to the server. 43 | """ 44 | 45 | def apply_client(self, client, operation): 46 | # When the user makes an edit, send the operation to the server and 47 | # switch to the 'AwaitingConfirm' state 48 | client.send_operation(client.revision, operation) 49 | return AwaitingConfirm(operation) 50 | 51 | def apply_server(self, client, operation): 52 | # When we receive a new operation from the server, the operation can be 53 | # simply applied to the current document 54 | client.apply_operation(operation) 55 | return self 56 | 57 | def server_ack(self, client): 58 | raise RuntimeError("There is no pending operation.") 59 | 60 | 61 | # Singleton 62 | synchronized = Synchronized() 63 | 64 | 65 | class AwaitingConfirm(object): 66 | """In the 'awaitingConfirm' state, there's one operation the client has sent 67 | to the server and is still waiting for an acknowledgement. 68 | """ 69 | 70 | def __init__(self, outstanding): 71 | # Save the pending operation 72 | self.outstanding = outstanding 73 | 74 | def apply_client(self, client, operation): 75 | # When the user makes an edit, don't send the operation immediately, 76 | # instead switch to the 'AwaitingWithBuffer' state 77 | return AwaitingWithBuffer(self.outstanding, operation) 78 | 79 | def apply_server(self, client, operation): 80 | # /\ 81 | # self.outstanding / \ operation 82 | # / \ 83 | # \ / 84 | # operation_p \ / outstanding_p (new self.outstanding) 85 | # (can be applied \/ 86 | # to the client's 87 | # current document) 88 | Operation = self.outstanding.__class__ 89 | (outstanding_p, operation_p) = Operation.transform(self.outstanding, operation) 90 | client.apply_operation(operation_p) 91 | return AwaitingConfirm(outstanding_p) 92 | 93 | def server_ack(self, client): 94 | return synchronized 95 | 96 | 97 | class AwaitingWithBuffer(object): 98 | """In the 'awaitingWithBuffer' state, the client is waiting for an operation 99 | to be acknowledged by the server while buffering the edits the user makes 100 | """ 101 | 102 | def __init__(self, outstanding, buffer): 103 | # Save the pending operation and the user's edits since then 104 | self.outstanding = outstanding 105 | self.buffer = buffer 106 | 107 | def apply_client(self, client, operation): 108 | # Compose the user's changes onto the buffer 109 | newBuffer = self.buffer.compose(operation) 110 | return AwaitingWithBuffer(self.outstanding, newBuffer) 111 | 112 | def apply_server(self, client, operation): 113 | # /\ 114 | # self.outstanding / \ operation 115 | # / \ 116 | # /\ / 117 | # self.buffer / \* / outstanding_p 118 | # / \/ 119 | # \ / 120 | # operation_pp \ / buffer_p 121 | # \/ 122 | # the transformed 123 | # operation -- can 124 | # be applied to the 125 | # client's current 126 | # document 127 | # 128 | # * operation_p 129 | Operation = self.outstanding.__class__ 130 | (outstanding_p, operation_p) = Operation.transform(self.outstanding, operation) 131 | (buffer_p, operation_pp) = Operation.transform(self.buffer, operation_p) 132 | client.apply_operation(operation_pp) 133 | return AwaitingWithBuffer(outstanding_p, buffer_p) 134 | 135 | def server_ack(self, client): 136 | # The pending operation has been acknowledged 137 | # => send buffer 138 | client.send_operation(client.revision, self.buffer) 139 | return AwaitingConfirm(self.buffer) 140 | -------------------------------------------------------------------------------- /ot/server.py: -------------------------------------------------------------------------------- 1 | class MemoryBackend(object): 2 | """Simple backend that saves all operations in the server's memory. This 3 | causes the processe's heap to grow indefinitely. 4 | """ 5 | 6 | def __init__(self, operations=[]): 7 | self.operations = operations[:] 8 | self.last_operation = {} 9 | 10 | def save_operation(self, user_id, operation): 11 | """Save an operation in the database.""" 12 | self.last_operation[user_id] = len(self.operations) 13 | self.operations.append(operation) 14 | 15 | def get_operations(self, start, end=None): 16 | """Return operations in a given range.""" 17 | return self.operations[start:end] 18 | 19 | def get_last_revision_from_user(self, user_id): 20 | """Return the revision number of the last operation from a given user.""" 21 | return self.last_operation.get(user_id, None) 22 | 23 | 24 | class Server(object): 25 | """Receives operations from clients, transforms them against all 26 | concurrent operations and sends them back to all clients. 27 | """ 28 | 29 | def __init__(self, document, backend): 30 | self.document = document 31 | self.backend = backend 32 | 33 | def receive_operation(self, user_id, revision, operation): 34 | """Transforms an operation coming from a client against all concurrent 35 | operation, applies it to the current document and returns the operation 36 | to send to the clients. 37 | """ 38 | 39 | last_by_user = self.backend.get_last_revision_from_user(user_id) 40 | if last_by_user and last_by_user >= revision: 41 | return 42 | 43 | Operation = operation.__class__ 44 | 45 | concurrent_operations = self.backend.get_operations(revision) 46 | for concurrent_operation in concurrent_operations: 47 | (operation, _) = Operation.transform(operation, concurrent_operation) 48 | 49 | self.document = operation(self.document) 50 | self.backend.save_operation(user_id, operation) 51 | return operation 52 | -------------------------------------------------------------------------------- /ot/text_operation.py: -------------------------------------------------------------------------------- 1 | # Operations are lists of ops. There are three types of ops: 2 | # 3 | # * Insert ops: insert a given string at the current cursor position. 4 | # Represented by strings. 5 | # * Retain ops: Advance the cursor position by a given number of characters. 6 | # Represented by positive ints. 7 | # * Delete ops: Delete the next n characters. Represented by negative ints. 8 | 9 | 10 | def _is_retain(op): 11 | return isinstance(op, int) and op > 0 12 | 13 | 14 | def _is_delete(op): 15 | return isinstance(op, int) and op < 0 16 | 17 | 18 | def _is_insert(op): 19 | return isinstance(op, str) 20 | 21 | 22 | def _op_len(op): 23 | if isinstance(op, str): 24 | return len(op) 25 | if op < 0: 26 | return -op 27 | return op 28 | 29 | 30 | def _shorten(op, by): 31 | if isinstance(op, str): 32 | return op[by:] 33 | if op < 0: 34 | return op + by 35 | return op - by 36 | 37 | 38 | def _shorten_ops(a, b): 39 | """Shorten two ops by the part that cancels each other out.""" 40 | 41 | len_a = _op_len(a) 42 | len_b = _op_len(b) 43 | if len_a == len_b: 44 | return (None, None) 45 | if len_a > len_b: 46 | return (_shorten(a, len_b), None) 47 | return (None, _shorten(b, len_a)) 48 | 49 | 50 | class TextOperation(object): 51 | """Diff between two strings.""" 52 | 53 | def __init__(self, ops=[]): 54 | self.ops = ops[:] 55 | 56 | def __eq__(self, other): 57 | return isinstance(other, TextOperation) and self.ops == other.ops 58 | 59 | def __iter__(self): 60 | return self.ops.__iter__() 61 | 62 | def __add__(self, other): 63 | return self.compose(other) 64 | 65 | def len_difference(self): 66 | """Returns the difference in length between the input and the output 67 | string when this operations is applied. 68 | """ 69 | s = 0 70 | for op in self: 71 | if isinstance(op, str): 72 | s += len(op) 73 | elif op < 0: 74 | s += op 75 | return s 76 | 77 | def retain(self, r): 78 | """Skips a given number of characters at the current cursor position.""" 79 | 80 | if r == 0: 81 | return self 82 | if len(self.ops) > 0 and isinstance(self.ops[-1], int) and self.ops[-1] > 0: 83 | self.ops[-1] += r 84 | else: 85 | self.ops.append(r) 86 | return self 87 | 88 | def insert(self, s): 89 | """Inserts the given string at the current cursor position.""" 90 | 91 | if len(s) == 0: 92 | return self 93 | if len(self.ops) > 0 and isinstance(self.ops[-1], str): 94 | self.ops[-1] += s 95 | elif len(self.ops) > 0 and isinstance(self.ops[-1], int) and self.ops[-1] < 0: 96 | # It doesn't matter when an operation is applied whether the operation 97 | # is delete(3), insert("something") or insert("something"), delete(3). 98 | # Here we enforce that in this case, the insert op always comes first. 99 | # This makes all operations that have the same effect when applied to 100 | # a document of the right length equal in respect to the `equals` method. 101 | if len(self.ops) > 1 and isinstance(self.ops[-2], str): 102 | self.ops[-2] += s 103 | else: 104 | self.ops.append(self.ops[-1]) 105 | self.ops[-2] = s 106 | else: 107 | self.ops.append(s) 108 | return self 109 | 110 | def delete(self, d): 111 | """Deletes a given number of characters at the current cursor position.""" 112 | 113 | if d == 0: 114 | return self 115 | if d > 0: 116 | d = -d 117 | if len(self.ops) > 0 and isinstance(self.ops[-1], int) and self.ops[-1] < 0: 118 | self.ops[-1] += d 119 | else: 120 | self.ops.append(d) 121 | return self 122 | 123 | def __call__(self, doc): 124 | """Apply this operation to a string, returning a new string.""" 125 | 126 | i = 0 127 | parts = [] 128 | 129 | for op in self: 130 | if _is_retain(op): 131 | if i + op > len(doc): 132 | raise Exception("Cannot apply operation: operation is too long.") 133 | parts.append(doc[i:(i + op)]) 134 | i += op 135 | elif _is_insert(op): 136 | parts.append(op) 137 | else: 138 | i -= op 139 | if i > len(doc): 140 | raise IncompatibleOperationError("Cannot apply operation: operation is too long.") 141 | 142 | if i != len(doc): 143 | raise IncompatibleOperationError("Cannot apply operation: operation is too short.") 144 | 145 | return ''.join(parts) 146 | 147 | def invert(self, doc): 148 | """Make an operation that does the opposite. When you apply an operation 149 | to a string and then the operation generated by this operation, you 150 | end up with your original string. This method can be used to implement 151 | undo. 152 | """ 153 | 154 | i = 0 155 | inverse = TextOperation() 156 | 157 | for op in self: 158 | if _is_retain(op): 159 | inverse.retain(op) 160 | i += op 161 | elif _is_insert(op): 162 | inverse.delete(len(op)) 163 | else: 164 | inverse.insert(doc[i:(i - op)]) 165 | i -= op 166 | 167 | return inverse 168 | 169 | def compose(self, other): 170 | """Combine two consecutive operations into one that has the same effect 171 | when applied to a document. 172 | """ 173 | 174 | iter_a = iter(self) 175 | iter_b = iter(other) 176 | operation = TextOperation() 177 | 178 | a = b = None 179 | while True: 180 | if a == None: 181 | a = next(iter_a, None) 182 | if b == None: 183 | b = next(iter_b, None) 184 | 185 | if a == b == None: 186 | # end condition: both operations have been processed 187 | break 188 | 189 | if _is_delete(a): 190 | operation.delete(a) 191 | a = None 192 | continue 193 | if _is_insert(b): 194 | operation.insert(b) 195 | b = None 196 | continue 197 | 198 | if a == None: 199 | raise IncompatibleOperationError("Cannot compose operations: first operation is too short") 200 | if b == None: 201 | raise IncompatibleOperationError("Cannot compose operations: first operation is too long") 202 | 203 | min_len = min(_op_len(a), _op_len(b)) 204 | if _is_retain(a) and _is_retain(b): 205 | operation.retain(min_len) 206 | elif _is_insert(a) and _is_retain(b): 207 | operation.insert(a[:min_len]) 208 | elif _is_retain(a) and _is_delete(b): 209 | operation.delete(min_len) 210 | # remaining case: _is_insert(a) and _is_delete(b) 211 | # in this case the delete op deletes the text that has been added 212 | # by the insert operation and we don't need to do anything 213 | 214 | (a, b) = _shorten_ops(a, b) 215 | 216 | return operation 217 | 218 | @staticmethod 219 | def transform(operation_a, operation_b): 220 | """Transform two operations a and b to a' and b' such that b' applied 221 | after a yields the same result as a' applied after b. Try to preserve 222 | the operations' intentions in the process. 223 | """ 224 | 225 | iter_a = iter(operation_a) 226 | iter_b = iter(operation_b) 227 | a_prime = TextOperation() 228 | b_prime = TextOperation() 229 | a = b = None 230 | 231 | while True: 232 | if a == None: 233 | a = next(iter_a, None) 234 | if b == None: 235 | b = next(iter_b, None) 236 | 237 | if a == b == None: 238 | # end condition: both operations have been processed 239 | break 240 | 241 | if _is_insert(a): 242 | a_prime.insert(a) 243 | b_prime.retain(len(a)) 244 | a = None 245 | continue 246 | if _is_insert(b): 247 | a_prime.retain(len(b)) 248 | b_prime.insert(b) 249 | b = None 250 | continue 251 | 252 | if a == None: 253 | raise IncompatibleOperationError("Cannot compose operations: first operation is too short") 254 | if b == None: 255 | raise IncompatibleOperationError("Cannot compose operations: first operation is too long") 256 | 257 | min_len = min(_op_len(a), _op_len(b)) 258 | if _is_retain(a) and _is_retain(b): 259 | a_prime.retain(min_len) 260 | b_prime.retain(min_len) 261 | elif _is_delete(a) and _is_retain(b): 262 | a_prime.delete(min_len) 263 | elif _is_retain(a) and _is_delete(b): 264 | b_prime.delete(min_len) 265 | # remaining case: _is_delete(a) and _is_delete(b) 266 | # in this case both operations delete the same string and we don't 267 | # need to do anything 268 | 269 | (a, b) = _shorten_ops(a, b) 270 | 271 | return (a_prime, b_prime) 272 | 273 | 274 | class IncompatibleOperationError(Exception): 275 | """Two operations or an operation and a string have different lengths.""" 276 | pass 277 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | nose -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | import ot 5 | 6 | setup( 7 | name='ot', 8 | version=ot.__version__, 9 | description='Operational Transformations for collaborative editing', 10 | author='Tim Baumann', 11 | author_email='tim@timbaumann.info', 12 | packages=['ot'], 13 | ) 14 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | random_test_iterations = 64 2 | 3 | 4 | def repeat(fn): 5 | """Decorator for running the function's body multiple times.""" 6 | def repeated(): 7 | i = 0 8 | while i < random_test_iterations: 9 | fn() 10 | i += 1 11 | # nosetest runs functions that start with 'test_' 12 | repeated.__name__ = fn.__name__ 13 | return repeated 14 | -------------------------------------------------------------------------------- /tests/test_client_server.py: -------------------------------------------------------------------------------- 1 | from test_text_operation import random_string, random_operation 2 | from helpers import repeat 3 | import random 4 | 5 | from ot.client import Client 6 | from ot.server import MemoryBackend, Server 7 | 8 | 9 | class MyClient(Client): 10 | def __init__(self, revision, id, document, channel): 11 | Client.__init__(self, revision) 12 | self.id = id 13 | self.document = document 14 | self.channel = channel 15 | 16 | def send_operation(self, revision, operation): 17 | self.channel.write((self.id, revision, operation)) 18 | 19 | def apply_operation(self, operation): 20 | self.document = operation(self.document) 21 | 22 | def perform_operation(self): 23 | operation = random_operation(self.document) 24 | self.document = operation(self.document) 25 | self.apply_client(operation) 26 | 27 | 28 | class NetworkChannel(): 29 | """Mock a FIFO network connection.""" 30 | 31 | def __init__(self, on_receive): 32 | self.buffer = [] 33 | self.on_receive = on_receive 34 | 35 | def is_empty(self): 36 | return len(self.buffer) == 0 37 | 38 | def write(self, msg): 39 | self.buffer.append(msg) 40 | 41 | def read(self): 42 | return self.buffer.pop(0) 43 | 44 | def receive(self): 45 | return self.on_receive(self.read()) 46 | 47 | 48 | @repeat 49 | def test_client_server_interaction(): 50 | document = random_string() 51 | server = Server(document, MemoryBackend()) 52 | 53 | def server_receive(msg): 54 | (client_id, revision, operation) = msg 55 | operation_p = server.receive_operation(client_id, revision, operation) 56 | msg = (client_id, operation_p) 57 | client1_receive_channel.write(msg) 58 | client2_receive_channel.write(msg) 59 | 60 | def client_receive(client): 61 | def rcv(msg): 62 | (client_id, operation) = msg 63 | if client.id == client_id: 64 | client.server_ack() 65 | else: 66 | client.apply_server(operation) 67 | return rcv 68 | 69 | client1_send_channel = NetworkChannel(server_receive) 70 | client1 = MyClient(0, 'client1', document, client1_send_channel) 71 | client1_receive_channel = NetworkChannel(client_receive(client1)) 72 | 73 | client2_send_channel = NetworkChannel(server_receive) 74 | client2 = MyClient(0, 'client2', document, client2_send_channel) 75 | client2_receive_channel = NetworkChannel(client_receive(client2)) 76 | 77 | channels = [ 78 | client1_send_channel, client1_receive_channel, 79 | client2_send_channel, client2_receive_channel 80 | ] 81 | 82 | def can_receive(): 83 | for channel in channels: 84 | if not channel.is_empty(): 85 | return True 86 | return False 87 | 88 | def receive_random(): 89 | filtered = [] 90 | for channel in channels: 91 | if not channel.is_empty(): 92 | filtered.append(channel) 93 | random.choice(filtered).receive() 94 | 95 | n = 64 96 | while n > 0: 97 | if not can_receive() or random.random() < 0.75: 98 | client = random.choice([client1, client2]) 99 | client.perform_operation() 100 | else: 101 | receive_random() 102 | n -= 1 103 | 104 | while can_receive(): 105 | receive_random() 106 | 107 | assert server.document == client1.document == client2.document 108 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | from ot.server import MemoryBackend, Server 2 | from ot.text_operation import TextOperation 3 | from test_text_operation import random_string, random_operation 4 | 5 | 6 | def test_MemoryBackend(): 7 | backend = MemoryBackend() 8 | assert backend.operations == [] 9 | backend = MemoryBackend([1, 2, 3]) 10 | assert backend.operations == [1, 2, 3] 11 | assert backend.get_last_revision_from_user('user1') == None 12 | backend.save_operation('user1', 4) 13 | assert backend.operations == [1, 2, 3, 4] 14 | assert backend.get_last_revision_from_user('user1') == 3 15 | assert backend.get_operations(0) == [1, 2, 3, 4] 16 | assert backend.get_operations(2) == [3, 4] 17 | assert backend.get_operations(2, 3) == [3] 18 | 19 | 20 | def test_Server(): 21 | backend = MemoryBackend() 22 | doc = random_string() 23 | server = Server(doc, backend) 24 | assert server.document == doc 25 | assert server.backend == backend 26 | 27 | op1 = random_operation(doc) 28 | doc1 = op1(doc) 29 | assert server.receive_operation('user1', 0, op1) == op1 30 | assert server.document == doc1 31 | assert backend.operations == [op1] 32 | 33 | op2 = random_operation(doc1) 34 | doc2 = op2(doc1) 35 | assert server.receive_operation('user1', 1, op2) == op2 36 | assert backend.operations == [op1, op2] 37 | assert server.document == doc2 38 | 39 | server.receive_operation('user1', 1, op2) == None 40 | assert backend.operations == [op1, op2] 41 | assert server.document == doc2 42 | 43 | op2_b = random_operation(doc1) 44 | (op2_b_p, op2_p) = TextOperation.transform(op2_b, op2) 45 | assert server.receive_operation('user2', 1, op2_b) == op2_b_p 46 | assert backend.operations == [op1, op2, op2_b_p] 47 | assert server.document == op2_p(op2_b(doc1)) 48 | -------------------------------------------------------------------------------- /tests/test_text_operation.py: -------------------------------------------------------------------------------- 1 | from ot.text_operation import * 2 | from helpers import repeat 3 | 4 | import random 5 | 6 | 7 | def random_char(): 8 | """Generate a random lowercase ASCII character.""" 9 | return chr(random.randint(97, 123)) 10 | 11 | 12 | def random_string(max_len=16): 13 | """Generate a random string.""" 14 | 15 | s = '' 16 | s_len = random.randint(0, max_len) 17 | while s_len > 0: 18 | s += random_char() 19 | s_len -= 1 20 | return s 21 | 22 | 23 | def random_operation(doc): 24 | """Generate a random operation that can be applied to the given string.""" 25 | 26 | o = TextOperation() 27 | # Why can't Python 2 change variables in enclosing scope? 28 | i = [0] 29 | 30 | def gen_retain(): 31 | r = random.randint(1, max_len) 32 | i[0] += r 33 | o.retain(r) 34 | 35 | def gen_insert(): 36 | o.insert(random_char() + random_string(9)) 37 | 38 | def gen_delete(): 39 | d = random.randint(1, max_len) 40 | i[0] += d 41 | o.delete(d) 42 | 43 | while i[0] < len(doc): 44 | max_len = min(10, len(doc) - i[0]) 45 | random.choice([gen_retain, gen_insert, gen_delete])() 46 | 47 | if random.random() < 0.5: 48 | gen_insert() 49 | 50 | return o 51 | 52 | 53 | def test_append(): 54 | o = TextOperation() 55 | o.delete(0) 56 | o.insert('lorem') 57 | o.retain(0) 58 | o.insert(' ipsum') 59 | o.retain(3) 60 | o.insert('') 61 | o.retain(5) 62 | o.delete(8) 63 | assert len(o.ops) == 3 64 | assert o == TextOperation(['lorem ipsum', 8, -8]) 65 | 66 | 67 | @repeat 68 | def test_len_difference(): 69 | doc = random_string(50) 70 | operation = random_operation(doc) 71 | assert len(operation(doc)) - len(doc) == operation.len_difference() 72 | 73 | 74 | def test_apply(): 75 | doc = 'Lorem ipsum' 76 | o = TextOperation().delete(1).insert('l').retain(4).delete(4).retain(2).insert('s') 77 | assert o(doc) == 'loremums' 78 | 79 | 80 | @repeat 81 | def test_invert(): 82 | doc = random_string(50) 83 | operation = random_operation(doc) 84 | inverse = operation.invert(doc) 85 | assert doc == inverse(operation(doc)) 86 | 87 | 88 | @repeat 89 | def test_compose(): 90 | doc = random_string(50) 91 | a = random_operation(doc) 92 | doc_a = a(doc) 93 | b = random_operation(doc_a) 94 | ab = a.compose(b) 95 | assert b(doc_a) == ab(doc) 96 | 97 | 98 | @repeat 99 | def test_transform(): 100 | doc = random_string(50) 101 | a = random_operation(doc) 102 | b = random_operation(doc) 103 | (a_prime, b_prime) = TextOperation.transform(a, b) 104 | assert a + b_prime == b + a_prime 105 | assert a_prime(b(doc)) == b_prime(a(doc)) 106 | --------------------------------------------------------------------------------