Bladeren bron

Release: 44bfc23658

master
Dirk Alders 4 jaren geleden
bovenliggende
commit
127ef51719
3 gewijzigde bestanden met toevoegingen van 32101 en 0 verwijderingen
  1. 485
    0
      __init__.py
  2. 31616
    0
      _testresults_/unittest.json
  3. BIN
      _testresults_/unittest.pdf

+ 485
- 0
__init__.py Bestand weergeven

@@ -0,0 +1,485 @@
1
+#!/usr/bin/env python
2
+# -*- coding: utf-8 -*-
3
+#
4
+"""
5
+socket_protocol (Socket Protocol)
6
+=================================
7
+
8
+**Author:**
9
+
10
+* Dirk Alders <sudo-dirk@mount-mockery.de>
11
+
12
+**Description:**
13
+
14
+    This Module supports point to point communication for client-server issues.
15
+
16
+**Submodules:**
17
+
18
+* :class:`socket_protocol.struct_json_protocol`
19
+* :class:`socket_protocol.pure_json_protocol`
20
+
21
+**Unittest:**
22
+
23
+        See also the :download:`unittest <../../socket_protocol/_testresults_/unittest.pdf>` documentation.
24
+"""
25
+__DEPENDENCIES__ = ['stringtools']
26
+
27
+import stringtools
28
+
29
+import binascii
30
+import hashlib
31
+import json
32
+import logging
33
+import os
34
+import struct
35
+import sys
36
+import time
37
+
38
+
39
+logger_name = 'SOCKET_PROTOCOL'
40
+logger = logging.getLogger(logger_name)
41
+
42
+
43
+__DESCRIPTION__ = """The Module {\\tt %s} is designed to pack and unpack data for serial transportation.
44
+For more Information read the sphinx documentation.""" % __name__.replace('_', '\_')
45
+"""The Module Description"""
46
+__INTERPRETER__ = (2, 3)
47
+"""The Tested Interpreter-Versions"""
48
+
49
+
50
+class RegistrationError(BaseException):
51
+    pass
52
+
53
+
54
+class callback_storage(dict):
55
+    def __init__(self):
56
+        dict.__init__(self)
57
+
58
+    def get(self, service_id, data_id):
59
+        if service_id is not None and data_id is not None:
60
+            try:
61
+                return self[service_id][data_id]
62
+            except KeyError:
63
+                pass  # nothing to append
64
+        if data_id is not None:
65
+            try:
66
+                return self[None][data_id]
67
+            except KeyError:
68
+                pass  # nothing to append
69
+        if service_id is not None:
70
+            try:
71
+                return self[service_id][None]
72
+            except KeyError:
73
+                pass  # nothing to append
74
+        try:
75
+            return self[None][None]
76
+        except KeyError:
77
+            pass  # nothing to append
78
+        return None
79
+
80
+    def add(self, service_id, data_id, callback):
81
+        if self.get(service_id, data_id) is not None:
82
+            raise RegistrationError("Callback for service_id (%s) and data_id (%s) already exists" % (repr(service_id), repr(data_id)))
83
+        if service_id not in self:
84
+            self[service_id] = {}
85
+        self[service_id][data_id] = callback
86
+
87
+
88
+class data_storage(dict):
89
+    KEY_STATUS = 'status'
90
+    KEY_SERVICE_ID = 'service_id'
91
+    KEY_DATA_ID = 'data_id'
92
+    KEY_DATA = 'data'
93
+
94
+    def __init__(self, *args, **kwargs):
95
+        dict.__init__(self, *args, **kwargs)
96
+
97
+    def get_status(self, default=None):
98
+        return self.get(self.KEY_STATUS, default)
99
+
100
+    def get_service_id(self, default=None):
101
+        return self.get(self.KEY_SERVICE_ID, default)
102
+
103
+    def get_data_id(self, default=None):
104
+        return self.get(self.KEY_DATA_ID, default)
105
+
106
+    def get_data(self, default=None):
107
+        return self.get(self.KEY_DATA, default)
108
+
109
+
110
+class struct_json_protocol(object):
111
+    """
112
+    :param comm_instance: a communication instance supportin at least these functions: :func:`register_callback`, :func:`register_disconnect_callback`, :func:`send`.
113
+    :type comm_instance: instance
114
+    :param secret: A secret (e.g. created by ``binascii.hexlify(os.urandom(24))``).
115
+    :type secret: str
116
+
117
+    This communication protocol supports to transfer a Service-ID, Data-ID and Data. The transmitted data is shorter than :class:`pure_json_protocol`.
118
+
119
+    .. note::
120
+        This class is here for compatibility reasons. Usage of :class:`pure_json_protocol` is recommended.
121
+
122
+    **Example:**
123
+
124
+    Server:
125
+
126
+    .. literalinclude:: ../../socket_protocol/_examples_/socket_protocol__struct_json_protocol_server.py
127
+
128
+    .. literalinclude:: ../../socket_protocol/_examples_/socket_protocol__struct_json_protocol_server.log
129
+
130
+
131
+    Client:
132
+
133
+    .. literalinclude:: ../../socket_protocol/_examples_/socket_protocol__struct_json_protocol_client.py
134
+
135
+    .. literalinclude:: ../../socket_protocol/_examples_/socket_protocol__struct_json_protocol_client.log
136
+    """
137
+    LOG_PREFIX = 'SJP:'
138
+
139
+    SID_AUTH_SEED_REQUEST = 1
140
+    SID_AUTH_KEY_REQUEST = 2
141
+    SID_AUTH_KEY_CHECK_REQUEST = 3
142
+    SID_AUTH_KEY_CHECK_RESPONSE = 4
143
+    SID_READ_REQUEST = 10
144
+    SID_READ_RESPONSE = 11
145
+    SID_WRITE_REQUEST = 20
146
+    SID_WRITE_RESPONSE = 21
147
+    SID_EXECUTE_REQUEST = 30
148
+    SID_EXECUTE_RESPONSE = 31
149
+
150
+    SID_RESPONSE_DICT = {SID_AUTH_SEED_REQUEST: SID_AUTH_KEY_REQUEST,
151
+                         SID_AUTH_KEY_REQUEST: SID_AUTH_KEY_CHECK_REQUEST,
152
+                         SID_AUTH_KEY_CHECK_REQUEST: SID_AUTH_KEY_CHECK_RESPONSE,
153
+                         SID_READ_REQUEST: SID_READ_RESPONSE,
154
+                         SID_WRITE_REQUEST: SID_WRITE_RESPONSE,
155
+                         SID_EXECUTE_REQUEST: SID_EXECUTE_RESPONSE}
156
+
157
+    SID_AUTH_LIST = [SID_AUTH_SEED_REQUEST, SID_AUTH_KEY_REQUEST, SID_AUTH_KEY_CHECK_REQUEST, SID_AUTH_KEY_CHECK_RESPONSE]
158
+
159
+    STATUS_OKAY = 0
160
+    STATUS_BUFFERING_UNHANDLED_REQUEST = 1
161
+    STATUS_AUTH_REQUIRED = 2
162
+    STATUS_SERVICE_OR_DATA_UNKNOWN = 3
163
+    STATUS_CHECKSUM_ERROR = 4
164
+    STATUS_OPERATION_NOT_PERMITTED = 5
165
+    STATUS_NAMES = {STATUS_OKAY: 'Okay',
166
+                    STATUS_BUFFERING_UNHANDLED_REQUEST: 'Request has no callback. Data buffered.',
167
+                    STATUS_AUTH_REQUIRED: 'Authentification required',
168
+                    STATUS_SERVICE_OR_DATA_UNKNOWN: 'Service or Data unknown',
169
+                    STATUS_CHECKSUM_ERROR: 'Checksum Error',
170
+                    STATUS_OPERATION_NOT_PERMITTED: 'Operation not permitted'}
171
+
172
+    AUTH_STATE_UNKNOWN_CLIENT = 0
173
+    AUTH_STATE_SEED_REQUESTED = 1
174
+    AUTH_STATE_SEED_TRANSFERRED = 2
175
+    AUTH_STATE_KEY_TRANSFERRED = 3
176
+    AUTH_STATE_TRUSTED_CLIENT = 4
177
+    AUTH_STATUS_NAMES = {AUTH_STATE_UNKNOWN_CLIENT: 'Unknown Client',
178
+                         AUTH_STATE_SEED_REQUESTED: 'Seed was requested',
179
+                         AUTH_STATE_SEED_TRANSFERRED: 'Seed has been sent',
180
+                         AUTH_STATE_KEY_TRANSFERRED: 'Key has been sent',
181
+                         AUTH_STATE_TRUSTED_CLIENT: 'Trusted Client'}
182
+
183
+    def __init__(self, comm_instance, secret=None):
184
+        self.__secret__ = secret
185
+        self.__clean_receive_buffer__()
186
+        self.__callbacks__ = callback_storage()
187
+        self.__callbacks__.add(self.SID_AUTH_SEED_REQUEST, 0, self.__authentificate_create_seed__)
188
+        self.__callbacks__.add(self.SID_AUTH_KEY_REQUEST, 0, self.__authentificate_create_key__)
189
+        self.__callbacks__.add(self.SID_AUTH_KEY_CHECK_REQUEST, 0, self.__authentificate_check_key__)
190
+        self.__callbacks__.add(self.SID_AUTH_KEY_CHECK_RESPONSE, 0, self.__authentificate_process_feedback__)
191
+        self.__authentification_state_reset__()
192
+        self.__seed__ = None
193
+        self.__comm_inst__ = comm_instance
194
+        self.__comm_inst__.register_callback(self.__data_available_callback__)
195
+        self.__comm_inst__.register_connect_callback(self.__clean_receive_buffer__)
196
+        self.__comm_inst__.register_disconnect_callback(self.__authentification_state_reset__)
197
+
198
+    def __authentification_state_reset__(self):
199
+        logger.info("%s Resetting authentification state to AUTH_STATE_UNKNOWN_CLIENT", self.LOG_PREFIX)
200
+        self.__authentification_state__ = self.AUTH_STATE_UNKNOWN_CLIENT
201
+
202
+    def __analyse_frame__(self, frame):
203
+        status, service_id, data_id = struct.unpack('>III', frame[0:12])
204
+        if sys.version_info >= (3, 0):
205
+            data = json.loads(frame[12:-1].decode('utf-8'))
206
+        else:
207
+            data = json.loads(frame[12:-1])
208
+        return self.__mk_msg__(status, service_id, data_id, data)
209
+
210
+    def __build_frame__(self, service_id, data_id, data, status=STATUS_OKAY):
211
+        frame = struct.pack('>III', status, service_id, data_id)
212
+        if sys.version_info >= (3, 0):
213
+            frame += bytes(json.dumps(data), 'utf-8')
214
+            frame += self.__calc_chksum__(frame)
215
+        else:
216
+            frame += json.dumps(data)
217
+            frame += self.__calc_chksum__(frame)
218
+        return frame
219
+
220
+    def __calc_chksum__(self, raw_data):
221
+        chksum = 0
222
+        for b in raw_data:
223
+            if sys.version_info >= (3, 0):
224
+                chksum ^= b
225
+            else:
226
+                chksum ^= ord(b)
227
+        if sys.version_info >= (3, 0):
228
+            return bytes([chksum])
229
+        else:
230
+            return chr(chksum)
231
+
232
+    def __check_frame_checksum__(self, frame):
233
+        return self.__calc_chksum__(frame[:-1]) == frame[-1:]
234
+
235
+    def __data_available_callback__(self, comm_inst):
236
+        frame = comm_inst.receive()
237
+        if not self.__check_frame_checksum__(frame):
238
+            logger.warning("%s Received message has a wrong checksum and will be ignored: %s.", self.LOG_PREFIX, stringtools.hexlify(frame))
239
+        else:
240
+            msg = self.__analyse_frame__(frame)
241
+            logger.info(
242
+                '%s RX <- status: %s, service_id: %s, data_id: %s, data: "%s"',
243
+                self.LOG_PREFIX,
244
+                repr(msg.get_status()),
245
+                repr(msg.get_service_id()),
246
+                repr(msg.get_data_id()),
247
+                repr(msg.get_data())
248
+            )
249
+            callback = self.__callbacks__.get(msg.get_service_id(), msg.get_data_id())
250
+            if msg.get_service_id() in self.SID_RESPONSE_DICT.keys():
251
+                #
252
+                # REQUEST RECEIVED
253
+                #
254
+                if self.__secret__ is not None and not self.check_authentification_state() and msg.get_service_id() not in self.SID_AUTH_LIST:
255
+                    status = self.STATUS_AUTH_REQUIRED
256
+                    data = None
257
+                    logger.warning("%s Received message needs authentification: %s. Sending negative response.", self.LOG_PREFIX, self.AUTH_STATUS_NAMES.get(self.__authentification_state__, 'Unknown authentification status!'))
258
+                elif callback is None:
259
+                    logger.warning("%s Received message with no registered callback. Sending negative response.", self.LOG_PREFIX)
260
+                    status = self.STATUS_BUFFERING_UNHANDLED_REQUEST
261
+                    data = None
262
+                else:
263
+                    try:
264
+                        logger.debug("%s Executing callback %s to process received data", self.LOG_PREFIX, callback.__name__)
265
+                        status, data = callback(msg)
266
+                    except TypeError:
267
+                        raise TypeError('Check return value of callback function {callback_name} for service_id  {service_id} and data_id {data_id}'.format(callback_name=callback.__name__, service_id=repr(msg.get_service_id()), data_id=repr(msg.get_data_id())))
268
+                self.send(self.SID_RESPONSE_DICT[msg.get_service_id()], msg.get_data_id(), data, status=status)
269
+            else:
270
+                #
271
+                # RESPONSE RECEIVED
272
+                #
273
+                if msg.get_status() not in [self.STATUS_OKAY]:
274
+                    logger.warning("%s Received message has a peculiar status: %s", self.LOG_PREFIX, self.STATUS_NAMES.get(msg.get_status(), 'Unknown status response!'))
275
+                if callback is None:
276
+                    status = self.STATUS_OKAY
277
+                    data = None
278
+                    self.__buffer_received_data__(msg)
279
+                else:
280
+                    try:
281
+                        logger.debug("%s Executing callback %s to process received data", self.LOG_PREFIX, callback.__name__)
282
+                        status, data = callback(msg)
283
+                    except TypeError:
284
+                        raise TypeError('Check return value of callback function {callback_name} for service_id  {service_id} and data_id {data_id}'.format(callback_name=callback.__name__, service_id=repr(msg.get_service_id()), data_id=repr(msg.get_data_id())))
285
+
286
+    def __buffer_received_data__(self, msg):
287
+        if not msg.get_service_id() in self.__msg_buffer__:
288
+            self.__msg_buffer__[msg.get_service_id()] = {}
289
+        if not msg.get_data_id() in self.__msg_buffer__[msg.get_service_id()]:
290
+            self.__msg_buffer__[msg.get_service_id()][msg.get_data_id()] = []
291
+        self.__msg_buffer__[msg.get_service_id()][msg.get_data_id()].append(msg)
292
+        logger.debug("%s Message data is stored in buffer and is now ready to be retrieved by receive method", self.LOG_PREFIX)
293
+
294
+    def __clean_receive_buffer__(self):
295
+        logger.debug("%s Cleaning up receive-buffer", self.LOG_PREFIX)
296
+        self.__msg_buffer__ = {}
297
+
298
+    def receive(self, service_id, data_id, timeout=1):
299
+        data = None
300
+        cnt = 0
301
+        while data is None and cnt < timeout * 10:
302
+            try:
303
+                data = self.__msg_buffer__.get(service_id, {}).get(data_id, []).pop(0)
304
+            except IndexError:
305
+                data = None
306
+            cnt += 1
307
+            time.sleep(0.1)
308
+        if data is None and cnt >= timeout * 10:
309
+            logger.warning('%s TIMEOUT (%ss): Requested data (service_id: %s; data_id: %s) not in buffer.', self.LOG_PREFIX, repr(timeout), repr(service_id), repr(data_id))
310
+        return data
311
+
312
+    def __mk_msg__(self, status, service_id, data_id, data):
313
+        return data_storage({data_storage.KEY_DATA_ID: data_id, data_storage.KEY_SERVICE_ID: service_id, data_storage.KEY_STATUS: status, data_storage.KEY_DATA: data})
314
+
315
+    def send(self, service_id, data_id, data, status=STATUS_OKAY, timeout=2, log_lvl=logging.INFO):
316
+        """
317
+        :param service_id: The Service-ID for the message. See class definitions starting with ``SERVICE_``.
318
+        :type service_id: int
319
+        :param data_id: The Data-ID for the message.
320
+        :type data_id: int
321
+        :param data: The data to be transfered. The data needs to be json compatible.
322
+        :type data: str
323
+        :param status: The Status for the message. All requests should have ``STATUS_OKAY``.
324
+        :type status: int
325
+        :param timeout: The timeout for sending data (e.g. time to establish new connection).
326
+        :type timeout: float
327
+        :param rx_log_lvl: The log level to log outgoing TX-data
328
+        :type rx_log_lvl: int
329
+        :return: True if data had been sent, otherwise False.
330
+        :rtype: bool
331
+
332
+        This methods sends out a message with the given content.
333
+        """
334
+        logger.log(log_lvl, '%s TX -> status: %d, service_id: %d, data_id: %d, data: "%s"', self.LOG_PREFIX, status, service_id, data_id, repr(data))
335
+        return self.__comm_inst__.send(self.__build_frame__(service_id, data_id, data, status), timeout=timeout, log_lvl=logging.DEBUG)
336
+
337
+    def register_callback(self, service_id, data_id, callback):
338
+        """
339
+        :param service_id: The Service-ID for the message. See class definitions starting with ``SID_``.
340
+        :type service_id: int
341
+        :param data_id: The Data-ID for the message.
342
+        :type data_id: int
343
+        :returns: True, if registration was successfull; False, if registration failed (e.g. existance of a callback for this configuration)
344
+        :rtype: bool
345
+
346
+        This method registers a callback for the given parameters. Givin ``None`` means, that all Service-IDs or all Data-IDs are used.
347
+        If a message hitting these parameters has been received, the callback will be executed.
348
+
349
+        .. note:: The :func:`callback` is priorised in the following order:
350
+
351
+            * Callbacks with defined Service-ID and Data-ID.
352
+            * Callbacks with a defined Data-ID.
353
+            * Callbacks with a defined Service-ID.
354
+            * Unspecific Callbacks
355
+
356
+        .. note:: The :func:`callback` is executed with these arguments:
357
+
358
+            :param msg: A :class:`dict` containing all message information.
359
+            :returns: status (see class definition starting with ``STATUS_``), response_data (JSON compatible object)
360
+        """
361
+        self.__callbacks__.add(service_id, data_id, callback)
362
+
363
+    def authentificate(self, timeout=2):
364
+        """
365
+        :param timeout: The timeout for the authentification (requesting seed, sending key and getting authentification_feedback).
366
+        :type timeout: float
367
+        :returns: True, if authentification was successfull; False, if not.
368
+        :rtype: bool
369
+
370
+        This method authetificates the client at the server.
371
+
372
+        .. note:: An authentification will only processed, if a secret had been given on initialisation.
373
+
374
+        .. note:: Client and Server needs to use the same secret.
375
+        """
376
+        if self.__secret__ is not None:
377
+            self.__authentification_state__ = self.AUTH_STATE_SEED_REQUESTED
378
+            logger.info("%s Requesting seed for authentification", self.LOG_PREFIX)
379
+            self.send(self.SID_AUTH_SEED_REQUEST, 0, None)
380
+            cnt = 0
381
+            while cnt < timeout * 10:
382
+                time.sleep(0.1)
383
+                if self.__authentification_state__ == self.AUTH_STATE_TRUSTED_CLIENT:
384
+                    return True
385
+                elif self.__authentification_state__ == self.AUTH_STATE_UNKNOWN_CLIENT:
386
+                    break
387
+                cnt += 1
388
+        return False
389
+
390
+    def check_authentification_state(self):
391
+        """
392
+        :return: True, if authentification state is okay, otherwise False
393
+        :rtype: bool
394
+        """
395
+        return self.__authentification_state__ == self.AUTH_STATE_TRUSTED_CLIENT
396
+
397
+    def __authentificate_salt_and_hash__(self, seed):
398
+        if sys.version_info >= (3, 0):
399
+            return hashlib.sha512(bytes(seed, 'utf-8') + self.__secret__).hexdigest()
400
+        else:
401
+            return hashlib.sha512(seed.encode('utf-8') + self.__secret__.encode('utf-8')).hexdigest()
402
+
403
+    def __authentificate_create_seed__(self, msg):
404
+        logger.info("%s Got seed request, sending seed for authentification", self.LOG_PREFIX)
405
+        self.__authentification_state__ = self.AUTH_STATE_SEED_TRANSFERRED
406
+        if sys.version_info >= (3, 0):
407
+            self.__seed__ = binascii.hexlify(os.urandom(32)).decode('utf-8')
408
+        else:
409
+            self.__seed__ = binascii.hexlify(os.urandom(32))
410
+        return self.STATUS_OKAY, self.__seed__
411
+
412
+    def __authentificate_create_key__(self, msg):
413
+        logger.info("%s Got seed, sending key for authentification", self.LOG_PREFIX)
414
+        self.__authentification_state__ = self.AUTH_STATE_KEY_TRANSFERRED
415
+        seed = msg.get_data()
416
+        key = self.__authentificate_salt_and_hash__(seed)
417
+        return self.STATUS_OKAY, key
418
+
419
+    def __authentificate_check_key__(self, msg):
420
+        key = msg.get_data()
421
+        if key == self.__authentificate_salt_and_hash__(self.__seed__):
422
+            self.__authentification_state__ = self.AUTH_STATE_TRUSTED_CLIENT
423
+            logger.info("%s Got correct key, sending positive authentification feedback", self.LOG_PREFIX)
424
+            return self.STATUS_OKAY, True
425
+        else:
426
+            self.__authentification_state__ = self.AUTH_STATE_UNKNOWN_CLIENT
427
+            logger.info("%s Got incorrect key, sending negative authentification feedback", self.LOG_PREFIX)
428
+            return self.STATUS_OKAY, False
429
+
430
+    def __authentificate_process_feedback__(self, msg):
431
+        feedback = msg.get_data()
432
+        if feedback:
433
+            self.__authentification_state__ = self.AUTH_STATE_TRUSTED_CLIENT
434
+            logger.info("%s Got positive authentification feedback", self.LOG_PREFIX)
435
+        else:
436
+            self.__authentification_state__ = self.AUTH_STATE_UNKNOWN_CLIENT
437
+            logger.warning("%s Got negative authentification feedback", self.LOG_PREFIX)
438
+        return self.STATUS_OKAY, None
439
+
440
+
441
+class pure_json_protocol(struct_json_protocol):
442
+    """
443
+    :param comm_instance: a communication instance supportin at least these functions: :func:`register_callback`, :func:`register_disconnect_callback`, :func:`send`.
444
+    :type comm_instance: instance
445
+    :param secret: A secret (e.g. created by ``binascii.hexlify(os.urandom(24))``).
446
+    :type secret: str
447
+
448
+    This communication protocol supports to transfer a Service-ID, Data-ID and Data.
449
+
450
+    **Example:**
451
+
452
+    Server:
453
+
454
+    .. literalinclude:: ../../socket_protocol/_examples_/socket_protocol__pure_json_protocol_server.py
455
+
456
+    .. literalinclude:: ../../socket_protocol/_examples_/socket_protocol__pure_json_protocol_server.log
457
+
458
+
459
+    Client:
460
+
461
+    .. literalinclude:: ../../socket_protocol/_examples_/socket_protocol__pure_json_protocol_client.py
462
+
463
+    .. literalinclude:: ../../socket_protocol/_examples_/socket_protocol__pure_json_protocol_client.log
464
+    """
465
+    def __init__(self, comm_instance, secret=None):
466
+        struct_json_protocol.__init__(self, comm_instance, secret)
467
+
468
+    def __build_frame__(self, service_id, data_id, data, status=struct_json_protocol.STATUS_OKAY):
469
+        data_frame = json.dumps(self.__mk_msg__(status, service_id, data_id, data))
470
+        if sys.version_info >= (3, 0):
471
+            data_frame = bytes(data_frame, 'utf-8')
472
+        checksum = self.__calc_chksum__(data_frame)
473
+        return data_frame + checksum
474
+
475
+    def __analyse_frame__(self, frame):
476
+        if sys.version_info >= (3, 0):
477
+            return data_storage(json.loads(frame[:-4].decode('utf-8')))
478
+        else:
479
+            return data_storage(json.loads(frame[:-4]))
480
+
481
+    def __calc_chksum__(self, raw_data):
482
+        return struct.pack('>I', binascii.crc32(raw_data) & 0xffffffff)
483
+
484
+    def __check_frame_checksum__(self, frame):
485
+        return self.__calc_chksum__(frame[:-4]) == frame[-4:]

+ 31616
- 0
_testresults_/unittest.json
Diff onderdrukt omdat het te groot bestand
Bestand weergeven


BIN
_testresults_/unittest.pdf Bestand weergeven


Laden…
Annuleren
Opslaan