diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index ef1c33f..c21464f 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -20,12 +20,12 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.13', '3.14'] steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -37,17 +37,19 @@ jobs: tox -e py$(echo ${{ matrix.python-version }} | tr -d .)-tests - name: Test cli run: | - tox -e py$(echo ${{ matrix.python-version }} | tr -d .)-cli + tox -e py$(echo ${{ matrix.python-version }} | tr -d .)-cli - name: Check style - if: ${{ matrix.python-version == '3.8' }} + if: ${{ matrix.python-version == '3.13' }} run: | tox -e py$(echo ${{ matrix.python-version }} | tr -d .)-lint tox -e copying - name: Upload coverage to Codecov - if: ${{ matrix.python-version == '3.8' }} - uses: codecov/codecov-action@v1 + if: ${{ matrix.python-version == '3.13' }} + uses: codecov/codecov-action@v5 with: fail_ci_if_error: true - - name: Build a source tarball - if: matrix.python-version == '3.8' - run: python setup.py sdist check --strict --metadata + - name: Build source distribution + if: matrix.python-version == '3.13' + run: | + pip install build + python -m build --sdist diff --git a/iotlabaggregator/__init__.py b/iotlabaggregator/__init__.py index 9b1da40..fee4329 100644 --- a/iotlabaggregator/__init__.py +++ b/iotlabaggregator/__init__.py @@ -1,5 +1,4 @@ #! /usr/bin/env python -# -*- coding:utf-8 -*- # This file is a part of IoT-LAB aggregation-tools # Copyright (C) 2015 INRIA (Contact: admin@iot-lab.info) @@ -20,19 +19,19 @@ # The fact that you are presently reading this means that you have had # knowledge of the CeCILL license and that you accept its terms. -""" Aggregator tools for IoT-Lab platform """ +"""Aggregator tools for IoT-Lab platform""" -import sys import logging +import sys -__version__ = '2.1.1' +__version__ = "2.1.1" # Use loggers for all outputs to have the same config LOG_FMT = logging.Formatter("%(created)f;%(message)s") # error logger -LOGGER = logging.getLogger('iotlabaggregator') +LOGGER = logging.getLogger("iotlabaggregator") _LOGGER = logging.StreamHandler(sys.stderr) _LOGGER.setFormatter(LOG_FMT) LOGGER.setLevel(logging.INFO) diff --git a/iotlabaggregator/common.py b/iotlabaggregator/common.py index 91c2c1d..1013329 100644 --- a/iotlabaggregator/common.py +++ b/iotlabaggregator/common.py @@ -1,5 +1,4 @@ #! /usr/bin/env python -# -*- coding:utf-8 -*- # This file is a part of IoT-LAB aggregation-tools # Copyright (C) 2015 INRIA (Contact: admin@iot-lab.info) @@ -21,13 +20,15 @@ # knowledge of the CeCILL license and that you accept its terms. -""" Common functions that may be required """ -import os +"""Common functions that may be required""" + import itertools +import os + import iotlabcli -from iotlabcli import experiment import iotlabcli.parser.common import iotlabcli.parser.node +from iotlabcli import experiment import iotlabaggregator @@ -93,8 +94,8 @@ def extract_nodes(resources, hostname=None): ['m3-1', 'wsn430-4', 'a8-1'] """ hostname = hostname or HOSTNAME - sites_nodes = [n for n in resources['items'] if n['site'] == hostname] - nodes = [n['network_address'].split('.')[0] for n in sites_nodes] + sites_nodes = [n for n in resources["items"] if n["site"] == hostname] + nodes = [n["network_address"].split(".")[0] for n in sites_nodes] return nodes @@ -105,11 +106,11 @@ def query_nodes(api, exp_id=None, nodes_list=None, hostname=None): # -l grenoble,m3,1 -l grenoble,m3,5 # [['m3-1.grenoble.iot-lab.info'], ['m3-5.grenoble.iot-lab.info']] nodes_list = frozenset(itertools.chain.from_iterable(nodes_list)) - nodes_list = [n.split('.')[0] for n in nodes_list if hostname in n] + nodes_list = [n.split(".")[0] for n in nodes_list if hostname in n] # try to get currently running experiment if exp_id is None: exp_id = iotlabcli.get_current_experiment(api) - exp_nodes = experiment.get_experiment(api, exp_id, 'nodes') + exp_nodes = experiment.get_experiment(api, exp_id, "nodes") exp_nodes_list = extract_nodes(exp_nodes, hostname) nodes = set(exp_nodes_list).intersection(nodes_list) if nodes: @@ -118,25 +119,34 @@ def query_nodes(api, exp_id=None, nodes_list=None, hostname=None): def add_nodes_selection_parser(parser): - """ Add parser arguments for selecting nodes """ + """Add parser arguments for selecting nodes""" iotlabcli.parser.common.add_auth_arguments(parser) - parser.add_argument('-v', '--version', action='version', - version=iotlabaggregator.__version__) + parser.add_argument( + "-v", "--version", action="version", version=iotlabaggregator.__version__ + ) nodes_group = parser.add_argument_group( description="By default, select currently running experiment nodes", - title="Nodes selection") - - nodes_group.add_argument('-i', '--id', dest='experiment_id', type=int, - help='experiment id submission') - nodes_group.add_argument('-l', '--list', action='append', - type=iotlabcli.parser.common.nodes_list_from_str, - dest='nodes_list', help='nodes list') - - -def get_nodes_selection(username, password, experiment_id, nodes_list, - *_args, **_kwargs): # pylint:disable=unused-argument - """ Return the requested nodes from 'experiment_id', and 'nodes_list """ + title="Nodes selection", + ) + + nodes_group.add_argument( + "-i", "--id", dest="experiment_id", type=int, help="experiment id submission" + ) + nodes_group.add_argument( + "-l", + "--list", + action="append", + type=iotlabcli.parser.common.nodes_list_from_str, + dest="nodes_list", + help="nodes list", + ) + + +def get_nodes_selection( + username, password, experiment_id, nodes_list, *_args, **_kwargs +): # pylint:disable=unused-argument + """Return the requested nodes from 'experiment_id', and 'nodes_list""" username, password = iotlabcli.get_user_credentials(username, password) api = iotlabcli.Api(username, password) with iotlabcli.parser.common.catch_missing_auth_cli(): diff --git a/iotlabaggregator/connections.py b/iotlabaggregator/connections.py index 281586e..34147a1 100644 --- a/iotlabaggregator/connections.py +++ b/iotlabaggregator/connections.py @@ -1,5 +1,4 @@ #! /usr/bin/env python -# -*- coding:utf-8 -*- # This file is a part of IoT-LAB aggregation-tools # Copyright (C) 2015 INRIA (Contact: admin@iot-lab.info) @@ -20,126 +19,155 @@ # The fact that you are presently reading this means that you have had # knowledge of the CeCILL license and that you accept its terms. -""" Aggregate multiple tcp connections """ +"""Aggregate multiple tcp connections""" import os +import selectors +import signal +import socket import sys -import asyncore # pylint:disable=deprecated-module -from asyncore import dispatcher_with_send # pylint:disable=deprecated-module import threading -import socket -import signal from iotlabaggregator import LOGGER -# Use dispatcher_with_send to correctly implement buffered sending -# either we get 100% CPU as 'writeable' is always 'True -# http://stackoverflow.com/questions/22423625/ \ -# python-asyncore-using-100-cpu-after-client-connects -# Found dispatcher_with_send in the asyncore code +class Connection: + """Handle the connection to one node. -# pylint:disable=bad-option-value,R0904,R0205 -class Connection(dispatcher_with_send, object): + Child class should re-implement ``handle_data``. """ - Handle the connection to one node - Data is managed with asyncore. So to work asyncore.loop() should be run. - Child class should re-implement 'handle_data' - """ port = 20000 - # pylint:disable=bad-option-value,super-on-old-class,super-with-arguments def __init__(self, hostname, aggregator): - super(Connection, self).__init__() - dispatcher_with_send.__init__(self) - self.hostname = hostname # node identifier for the user - self.data_buff = '' # received data buffer + self.hostname = hostname + self.data_buff = "" self.aggregator = aggregator + self._sock = None + self._send_lock = threading.Lock() def handle_data(self, data): - """ Dummy handle data """ + """Dummy handle data.""" LOGGER.info("%s received %u bytes", self.hostname, len(data)) - return '' # Remaining unprocessed data + return "" # Remaining unprocessed data def start(self): - """ Connects to node serial port """ - self.data_buff = '' # reset data - self.create_socket(socket.AF_INET, socket.SOCK_STREAM) - self.connect((self.hostname, self.port)) + """Connect to node serial port.""" + self.data_buff = "" + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.connect((self.hostname, self.port)) def handle_close(self): - """ Close the connection and clear buffer """ - self.data_buff = '' - LOGGER.error('%s;Connection closed', self.hostname) + """Close the connection and clear buffer.""" + self.data_buff = "" + LOGGER.error("%s;Connection closed", self.hostname) self.close() - # remove itself from aggregator self.aggregator.pop(self.hostname, None) + def close(self): + """Close the underlying socket.""" + if self._sock is not None: + try: + self._sock.close() + except OSError: + pass + self._sock = None + + def recv(self, n): + """Receive up to n bytes from the socket.""" + return self._sock.recv(n) + + def send(self, data): + """Send data to the node.""" + with self._send_lock: + if self._sock is not None: + try: + self._sock.sendall(data) + except OSError: + pass + def handle_read(self): - """ Append read bytes to buffer and run data handler. """ - # Handle Unicode. - self.data_buff += self.recv(8192).decode('utf-8', 'replace') + """Append read bytes to buffer and run data handler.""" + self.data_buff += self.recv(8192).decode("utf-8", "replace") self.data_buff = self.handle_data(self.data_buff) def handle_error(self): - """ Connection failed """ - LOGGER.error('%s;%r', self.hostname, sys.exc_info()) - + """Log connection error.""" + LOGGER.error("%s;%r", self.hostname, sys.exc_info()) -class Aggregator(dict): # pylint:disable=too-many-public-methods - """ - Create a dict of Connection from 'nodes_list' - Each node is stored in the entry with it's node_id - It as a thread that runs asyncore.loop() in the background. +class Aggregator(dict): + """Create a dict of Connection from ``nodes_list``. + Each node is stored in the entry with its node_id. + A background thread runs a selector loop to handle I/O. After init, it can be manipulated like a dict. """ - connection_class = Connection # overriden in child class + connection_class = Connection - # pylint: disable=bad-option-value,super-with-arguments def __init__(self, nodes_list, *args, **kwargs): - if not nodes_list: raise ValueError( f"{self.__class__.__name__}: Empty nodes list {nodes_list!r}" ) - super(Aggregator, self).__init__() + super().__init__() self._running = False - + self._selector = selectors.DefaultSelector() self.thread = threading.Thread(target=self._loop) - # create all the Connections for node_url in nodes_list: node = self.connection_class(node_url, self, *args, **kwargs) self[node_url] = node def _loop(self): - """ Run asyncore loop send SIGINT at the end to stop main process """ - asyncore.loop(timeout=1, use_poll=True) - if self._running: # Don't send signal if we are stopping - LOGGER.info("Loop finished, all connection closed") + """Run selector loop; send SIGINT when all connections close.""" + while self._running: + events = self._selector.select(timeout=1) + for key, mask in events: + conn = key.data + if mask & selectors.EVENT_READ: + try: + conn.handle_read() + except OSError: + try: + self._selector.unregister(key.fileobj) + except (KeyError, ValueError): + pass + conn.handle_error() + if self._running: + LOGGER.info("Loop finished, all connections closed") os.kill(os.getpid(), signal.SIGINT) def start(self): - """ Connect all nodes and run asyncore.loop in a thread """ + """Connect all nodes and start the selector loop thread.""" self._running = True for node in self.values(): - node.start() + try: + node.start() + except OSError: + node.handle_error() + continue + if node._sock is not None: + self._selector.register(node._sock, selectors.EVENT_READ, data=node) self.thread.start() LOGGER.info("Aggregator started") def stop(self): - """ Stop the nodes connection and stop asyncore.loop thread """ + """Stop all node connections and the selector loop thread.""" LOGGER.info("Stopping") self._running = False for node in self.values(): + if node._sock is not None: + try: + self._selector.unregister(node._sock) + except (KeyError, ValueError): + pass node.close() + self._selector.close() self.thread.join() - def run(self): # pylint:disable=no-self-use - """ Main function to run """ + def run(self): + """Main function to run.""" try: signal.pause() except KeyboardInterrupt: @@ -153,8 +181,7 @@ def __exit__(self, _type, _value, _traceback): self.stop() def send_nodes(self, nodes_list, message): - """ Send the `message` to `nodes_list` nodes - If nodes_list is None, send to all nodes """ + """Send ``message`` to ``nodes_list`` nodes; broadcast if None.""" if nodes_list is None: LOGGER.debug("Broadcast: %r", message) self.broadcast(message) @@ -164,15 +191,15 @@ def send_nodes(self, nodes_list, message): self._send(node, message) def _send(self, hostname, message): - """ Safe send message to node """ + """Safely send a message to a single node.""" try: - self[hostname].send(message.encode('utf-8', 'replace')) + self[hostname].send(message.encode("utf-8", "replace")) except KeyError: LOGGER.warning("Node not managed: %s", hostname) - except socket.error: + except OSError: LOGGER.warning("Send failed: %s", hostname) def broadcast(self, message): - """ Send a message to all the nodes serial links """ + """Send a message to all nodes.""" for node in self.keys(): self._send(node, message) diff --git a/iotlabaggregator/serial.py b/iotlabaggregator/serial.py index fb043c8..bd05516 100644 --- a/iotlabaggregator/serial.py +++ b/iotlabaggregator/serial.py @@ -1,5 +1,4 @@ #! /usr/bin/env python -# -*- coding:utf-8 -*- # This file is a part of IoT-LAB aggregation-tools # Copyright (C) 2015 INRIA (Contact: admin@iot-lab.info) @@ -56,37 +55,36 @@ The script will get the serial links current site nodes. For multi-sites experiments, you should run the script on each site server. """ -# pylint versions have different outputs... -# pylint:disable=too-few-public-methods -# pylint:disable=too-many-public-methods - -# use readline for 'raw_input' -from builtins import input -import readline # noqa # pylint:disable=unused-import +import argparse import logging +import readline # noqa: F401 import sys -import argparse - from iotlabcli.parser import common as common_parser -from iotlabaggregator import connections, common, LOG_FMT +from iotlabaggregator import LOG_FMT, common, connections try: - import colorama # pylint:disable=import-error + import colorama + HAS_COLOR = True except ImportError: HAS_COLOR = False -# Declare color specific functions if HAS_COLOR: colorama.init() - _COLOR = [colorama.Fore.BLACK, colorama.Fore.RED, - colorama.Fore.GREEN, colorama.Fore.YELLOW, - colorama.Fore.BLUE, colorama.Fore.MAGENTA, - colorama.Fore.CYAN, colorama.Fore.WHITE] + _COLOR = [ + colorama.Fore.BLACK, + colorama.Fore.RED, + colorama.Fore.GREEN, + colorama.Fore.YELLOW, + colorama.Fore.BLUE, + colorama.Fore.MAGENTA, + colorama.Fore.CYAN, + colorama.Fore.WHITE, + ] COLOR_RESET = str(colorama.Fore.RESET) def _color_hash(string): @@ -97,36 +95,42 @@ def color_str(string): """Return color string character for identifier.""" color_idx = _color_hash(string) % len(_COLOR) return str(_COLOR[color_idx]) + else: - COLOR_RESET = '' + COLOR_RESET = "" def color_str(_): """No color.""" - return '' + return "" -class SerialConnection(connections.Connection): # pylint:disable=R0903,R0904 - """ - Handle the connection to one node serial link. - Data is managed with asyncore. So to work asyncore.loop() should be run. +class SerialConnection(connections.Connection): + """Handle the connection to one node serial link. + + Data is managed with a selector loop. :param print_lines: should lines be printed to stdout :param line_handler: additional function to call on received lines. - `line_handler(identifier, line)` + ``line_handler(identifier, line)`` """ + port = 20000 _line_logger = logging.StreamHandler(sys.stdout) _line_logger.setFormatter(LOG_FMT) - logger = logging.getLogger('SerialConnection') + logger = logging.getLogger("SerialConnection") logger.setLevel(logging.INFO) logger.addHandler(_line_logger) - # pylint:disable=bad-option-value,too-many-arguments,super-on-old-class,super-with-arguments - def __init__(self, - hostname, aggregator, - print_lines=False, line_handler=None, color=False): - super(SerialConnection, self).__init__(hostname, aggregator) + def __init__( + self, + hostname, + aggregator, + print_lines=False, + line_handler=None, + color=False, + ): + super().__init__(hostname, aggregator) self.line_handler = common.Event() if print_lines: @@ -134,17 +138,16 @@ def __init__(self, if line_handler: self.line_handler.append(line_handler) - self.fmt = '%s;%s' + self.fmt = "%s;%s" if color: - self.fmt = f'{color_str(self.hostname)}{self.fmt}{COLOR_RESET}' + self.fmt = f"{color_str(self.hostname)}{self.fmt}{COLOR_RESET}" def handle_data(self, data): - """ Print the data received line by line """ - + """Print the data received line by line.""" lines = data.splitlines(True) - data = '' + data = "" for line in lines: - if line[-1] == '\n': + if line[-1] == "\n": line = line[:-1] self.line_handler(self.hostname, line) else: @@ -152,57 +155,63 @@ def handle_data(self, data): return data def print_line(self, identifier, line): - """ Print one line prefixed by id in format: """ + """Print one line prefixed by id.""" self.logger.info(self.fmt, identifier, line) class SerialAggregator(connections.Aggregator): - """ Aggregator for the Serial """ + """Aggregator for the Serial.""" + connection_class = SerialConnection parser = argparse.ArgumentParser() common.add_nodes_selection_parser(parser) parser.add_argument( - '--with-a8', action='store_true', - help=('redirect open-a8 serial port. ' + - '`/etc/init.d/serial_redirection` must be running on the nodes')) + "--with-a8", + action="store_true", + help=( + "redirect open-a8 serial port. " + "`/etc/init.d/serial_redirection` must be running on the nodes" + ), + ) - # Add 'opts.color' no error if no available parser.set_defaults(color=False) if HAS_COLOR: parser.add_argument( - '--color', action='store_true', default=False, - help='Add color to node lines.', + "--color", + action="store_true", + default=False, + help="Add color to node lines.", ) @staticmethod def select_nodes(opts): - """ Select all gateways and open-a8 if `with_a8` """ + """Select all gateways and open-a8 if ``with_a8``.""" nodes = common.get_nodes_selection(**vars(opts)) # all gateways urls except A8 - nodes_list = [n for n in nodes if not n.startswith('a8')] + nodes_list = [n for n in nodes if not n.startswith("a8")] # add open-a8 urls with 'node-' in front all urls if opts.with_a8: - nodes_list += ['node-' + n for n in nodes if n.startswith('a8')] + nodes_list += ["node-" + n for n in nodes if n.startswith("a8")] return nodes_list - def run(self): # overwrite original function - """ Read standard input while aggregator is running """ + def run(self): + """Read standard input while aggregator is running.""" try: self.read_input() except (KeyboardInterrupt, EOFError): pass def read_input(self): - """ Read input and sends the messages to the given nodes """ + """Read input and sends the messages to the given nodes.""" while True: line = input() nodes, message = self.extract_nodes_and_message(line) - if (None, '') != (nodes, message): - self.send_nodes(nodes, message + '\n') + if (None, "") != (nodes, message): + self.send_nodes(nodes, message + "\n") # else: Only hitting 'enter' to get spacing @staticmethod @@ -247,23 +256,22 @@ def extract_nodes_and_message(line): """ try: - nodes_str, message = line.split(';') - if nodes_str == '-': - # - + nodes_str, message = line.split(";") + if nodes_str == "-": return None, message - if ',' in nodes_str: + if "," in nodes_str: # m3,1-5+4 - archi, list_str = nodes_str.split(',') + archi, list_str = nodes_str.split(",") else: # m3-1 , a8-2, node-a8-3, wsn430-4 # convert it as if it was with a comma - archi, list_str = nodes_str.rsplit('-', 1) + archi, list_str = nodes_str.rsplit("-", 1) int(list_str) # ValueError if not int # normalize archi archi = archi.lower() - archi = 'node-a8' if archi == 'a8' else archi + archi = "node-a8" if archi == "a8" else archi # get nodes list nodes = common_parser.nodes_id_list(archi, list_str) @@ -274,15 +282,14 @@ def extract_nodes_and_message(line): def main(args=None): - """ Aggregate all nodes sniffer """ + """Aggregate all nodes serial links.""" args = args or sys.argv[1:] opts = SerialAggregator.parser.parse_args(args) try: - # Parse arguments nodes_list = SerialAggregator.select_nodes(opts) - # Run the aggregator - with SerialAggregator(nodes_list, print_lines=True, - color=opts.color) as aggregator: + with SerialAggregator( + nodes_list, print_lines=True, color=opts.color + ) as aggregator: aggregator.run() except (ValueError, RuntimeError) as err: sys.stderr.write(f"{err}\n") diff --git a/iotlabaggregator/sniffer.py b/iotlabaggregator/sniffer.py index 004954e..36d2ffa 100644 --- a/iotlabaggregator/sniffer.py +++ b/iotlabaggregator/sniffer.py @@ -1,5 +1,4 @@ #! /usr/bin/python -# -*- coding: utf-8 -*- # This file is a part of IoT-LAB aggregation-tools # Copyright (C) 2015 INRIA (Contact: admin@iot-lab.info) @@ -20,34 +19,33 @@ # The fact that you are presently reading this means that you have had # knowledge of the CeCILL license and that you accept its terms. -""" Sniff tcp socket zep messages and save them as pcap """ - -# pylint versions have different outputs... -# pylint:disable=too-few-public-methods -# pylint:disable=too-many-public-methods +"""Sniff tcp socket zep messages and save them as pcap""" import argparse -import sys +import contextlib import logging +import sys -from iotlabaggregator import connections, common, zeptopcap, LOGGER +from iotlabaggregator import LOGGER, common, connections, zeptopcap + +SNIFFER_NODES_COMPAT = ("a8", "frdm-kw41z", "m3", "nrf52840dk", "samr21") class SnifferConnection(connections.Connection): - """ Connection to sniffer and data handling """ + """Connection to sniffer and data handling.""" + port = 30000 ZEP_HDR_LEN = zeptopcap.ZepPcap.ZEP_HDR_LEN - # pylint:disable=bad-option-value,super-on-old-class,super-with-arguments def __init__(self, hostname, aggregator, pkt_handler): - super(SnifferConnection, self).__init__(hostname, aggregator) + super().__init__(hostname, aggregator) self.pkt_handler = pkt_handler def handle_data(self, data): - """ Print the data received line by line """ + """Print the data received line by line.""" while True: data = self._strip_until_pkt_start(data) - if not data.startswith('EX\2') or len(data) < self.ZEP_HDR_LEN: + if not data.startswith("EX\2") or len(data) < self.ZEP_HDR_LEN: break # length = header length + data['len_byte'] full_len = self.ZEP_HDR_LEN + ord(data[self.ZEP_HDR_LEN - 1]) @@ -56,16 +54,15 @@ def handle_data(self, data): # Extract packet pkt, data = data[:full_len], data[full_len:] - LOGGER.debug('%s;Packet received len: %d', self.hostname, full_len) + LOGGER.debug("%s;Packet received len: %d", self.hostname, full_len) self.pkt_handler(pkt) self.aggregator.rx_packets += 1 return data def handle_read(self): - """ Append read bytes to buffer and run data handler. """ - # Sniffer UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff - self.data_buff += self.recv(8192).decode('latin-1') + """Append read bytes to buffer and run data handler.""" + self.data_buff += self.recv(8192).decode("latin-1") self.data_buff = self.handle_data(self.data_buff) @staticmethod @@ -92,83 +89,77 @@ def _strip_until_pkt_start(msg): True """ - whole_index = msg.find('EX\2') - if whole_index == 0: # is stripped + whole_index = msg.find("EX\2") + if whole_index == 0: # is stripped return msg if whole_index != -1: # found, strip first lines return msg[whole_index:] # not found but remove some chars from the buffer # at max 2 required in this case - # might be invalid packet but keeps buffer small anymay return msg[-2:] class SnifferAggregator(connections.Aggregator): - """ Aggregator for the Sniffer """ - connection_class = SnifferConnection + """Aggregator for the Sniffer.""" - # pylint:disable=bad-option-value,missing-super-argument,no-member - class CustomFileType(argparse.FileType): - """ Custom FileType class to fix argparse bug with - write binary mode ('wb') and stdout ('-') - https://bugs.python.org/issue14156 - """ - def __call__(self, string): - if string == '-' and 'w' in self._mode: - return sys.stdout.buffer - return super().__call__(string) + connection_class = SnifferConnection parser = argparse.ArgumentParser() common.add_nodes_selection_parser(parser) _output = parser.add_argument_group("Sniffer output") - # Python3 fix - if sys.version_info[0] > 2: - _output.add_argument( - '-o', '--outfile', metavar='PCAP_FILE', dest='outfd', - type=CustomFileType('wb'), required=True, - help="Pcap outfile path. Use '-' for stdout.") - else: - _output.add_argument( - '-o', '--outfile', metavar='PCAP_FILE', dest='outfd', - type=argparse.FileType('wb'), required=True, - help="Pcap outfile path. Use '-' for stdout.") _output.add_argument( - '-d', '--debug', action='store_true', default=False, - help="Print debug on received packets") + "-o", + "--outfile", + metavar="PCAP_FILE", + required=True, + help="Pcap outfile path. Use '-' for stdout.", + ) + _output.add_argument( + "-d", + "--debug", + action="store_true", + default=False, + help="Print debug on received packets", + ) _output.add_argument( - '-r', '--raw', '--foren6', action='store_true', default=False, - help="Extract payload and no encapsulation. For foren6.") + "-r", + "--raw", + "--foren6", + action="store_true", + default=False, + help="Extract payload and no encapsulation. For foren6.", + ) - # pylint: disable=keyword-arg-before-vararg - # pylint: disable=bad-option-value,super-with-arguments def __init__(self, nodes_list, outfd, raw=False, *args, **kwargs): zep_pcap = zeptopcap.ZepPcap(outfd, raw) - super(SnifferAggregator, self).__init__( - nodes_list, pkt_handler=zep_pcap.write, *args, **kwargs) + super().__init__(nodes_list, pkt_handler=zep_pcap.write, *args, **kwargs) self.rx_packets = 0 @staticmethod def select_nodes(opts): - """ Select all gateways that support sniffer """ + """Select all gateways that support sniffer.""" nodes = common.get_nodes_selection(**vars(opts)) - nodes_list = [n for n in nodes if n.startswith(('m3', 'a8'))] + nodes_list = [n for n in nodes if n.startswith(SNIFFER_NODES_COMPAT)] return nodes_list def main(args=None): - """ Aggregate all nodes radio sniffer """ + """Aggregate all nodes radio sniffer.""" args = args or sys.argv[1:] opts = SnifferAggregator.parser.parse_args(args) try: - # Parse arguments nodes_list = SnifferAggregator.select_nodes(opts) if opts.debug: LOGGER.setLevel(logging.DEBUG) - # Run the aggregator - with SnifferAggregator(nodes_list, opts.outfd, opts.raw) as aggregator: - aggregator.run() - LOGGER.info('%u packets captured', aggregator.rx_packets) + with contextlib.ExitStack() as stack: + if opts.outfile == "-": + outfd = sys.stdout.buffer + else: + outfd = stack.enter_context(open(opts.outfile, "wb")) + with SnifferAggregator(nodes_list, outfd, opts.raw) as aggregator: + aggregator.run() + LOGGER.info("%u packets captured", aggregator.rx_packets) except (ValueError, RuntimeError) as err: sys.stderr.write(f"{err}\n") sys.exit(1) diff --git a/iotlabaggregator/tests/__init__.py b/iotlabaggregator/tests/__init__.py index 7da151b..8cac844 100644 --- a/iotlabaggregator/tests/__init__.py +++ b/iotlabaggregator/tests/__init__.py @@ -1,5 +1,3 @@ -# -*- coding:utf-8 -*- - # This file is a part of IoT-LAB aggregation-tools # Copyright (C) 2015 INRIA (Contact: admin@iot-lab.info) # Contributor(s) : see AUTHORS file @@ -19,4 +17,4 @@ # The fact that you are presently reading this means that you have had # knowledge of the CeCILL license and that you accept its terms. -""" iotlabaggregator unit-tests """ +"""iotlabaggregator unit-tests""" diff --git a/iotlabaggregator/tests/common_test.py b/iotlabaggregator/tests/common_test.py index d75cd54..4544191 100644 --- a/iotlabaggregator/tests/common_test.py +++ b/iotlabaggregator/tests/common_test.py @@ -1,5 +1,4 @@ #! /usr/bin/python -# -*- coding:utf-8 -*- # This file is a part of IoT-LAB aggregation-tools # Copyright (C) 2015 INRIA (Contact: admin@iot-lab.info) @@ -20,83 +19,101 @@ # The fact that you are presently reading this means that you have had # knowledge of the CeCILL license and that you accept its terms. -# pylint:disable=missing-docstring -# pylint:disable=invalid-name - import unittest -from mock import patch -try: - from urllib.error import HTTPError - from io import StringIO -except ImportError: - from urllib2 import HTTPError - from cStringIO import StringIO +from io import StringIO +from unittest.mock import patch +from urllib.error import HTTPError + from iotlabaggregator import common class TestCommonFunctions(unittest.TestCase): - - @patch('iotlabcli.get_current_experiment') - @patch('iotlabaggregator.common.experiment.get_experiment') + @patch("iotlabcli.get_current_experiment") + @patch("iotlabaggregator.common.experiment.get_experiment") def test_query_nodes(self, get_exp, get_cur_exp): api = None - resources = {"items": [ - {'network_address': 'm3-1.grenoble.iot-lab.info', - 'site': 'grenoble'}, - {'network_address': 'wsn430-1.lille.iot-lab.info', - 'site': 'lille'}, - {'network_address': 'a8-1.strasbourg.iot-lab.info', - 'site': 'strasbourg'}, - {'network_address': 'wsn430-4.grenoble.iot-lab.info', - 'site': 'grenoble'}, - {'network_address': 'a8-1.grenoble.iot-lab.info', - 'site': 'grenoble'}, - ]} + resources = { + "items": [ + {"network_address": "m3-1.grenoble.iot-lab.info", "site": "grenoble"}, + {"network_address": "wsn430-1.lille.iot-lab.info", "site": "lille"}, + { + "network_address": "a8-1.strasbourg.iot-lab.info", + "site": "strasbourg", + }, + { + "network_address": "wsn430-4.grenoble.iot-lab.info", + "site": "grenoble", + }, + { + "network_address": "a8-1.grenoble.iot-lab.info", + "site": "grenoble", + }, + ] + } get_exp.return_value = resources get_cur_exp.return_value = 123 # without exp_id - ret = common.query_nodes(api, hostname='strasbourg') - self.assertEqual(['a8-1'], ret) + ret = common.query_nodes(api, hostname="strasbourg") + self.assertEqual(["a8-1"], ret) # with exp_id - ret = common.query_nodes(api, exp_id=123, hostname='grenoble') - self.assertEqual(['a8-1', 'm3-1', 'wsn430-4'], ret) + ret = common.query_nodes(api, exp_id=123, hostname="grenoble") + self.assertEqual(["a8-1", "m3-1", "wsn430-4"], ret) # with nodes_list - ret = common.query_nodes(api, nodes_list=[ - ['m3-1.grenoble.iot-lab.info'], - ['a8-1.grenoble.iot-lab.info']], hostname='grenoble') - self.assertEqual(['a8-1', 'm3-1'], ret) + ret = common.query_nodes( + api, + nodes_list=[ + ["m3-1.grenoble.iot-lab.info"], + ["a8-1.grenoble.iot-lab.info"], + ], + hostname="grenoble", + ) + self.assertEqual(["a8-1", "m3-1"], ret) # exp_id and nodes_list - ret = common.query_nodes(api, exp_id=123, nodes_list=[ - ['m3-1.lille.iot-lab.info'], - ['wsn430-1.iot-lab.info']], hostname='lille') - self.assertEqual(['wsn430-1'], ret) + ret = common.query_nodes( + api, + exp_id=123, + nodes_list=[ + ["m3-1.lille.iot-lab.info"], + ["wsn430-1.iot-lab.info"], + ], + hostname="lille", + ) + self.assertEqual(["wsn430-1"], ret) - @patch('iotlabcli.get_user_credentials') - @patch('iotlabcli.Api') - @patch('iotlabaggregator.common.query_nodes') + @patch("iotlabcli.get_user_credentials") + @patch("iotlabcli.Api") + @patch("iotlabaggregator.common.query_nodes") def test_get_nodes_selection(self, query_nodes, api, get_user): - get_user.return_value = ('user', 'password') - query_nodes.return_value = ['a8-1', 'm3-1'] + get_user.return_value = ("user", "password") + query_nodes.return_value = ["a8-1", "m3-1"] - ret = common.get_nodes_selection(username=None, password=None, - experiment_id=None, nodes_list=()) - self.assertEqual(['a8-1', 'm3-1'], ret) + ret = common.get_nodes_selection( + username=None, password=None, experiment_id=None, nodes_list=() + ) + self.assertEqual(["a8-1", "m3-1"], ret) query_nodes.assert_called_with(api.return_value, None, ()) - @patch('iotlabcli.get_user_credentials') - @patch('iotlabaggregator.common.query_nodes') + @patch("iotlabcli.get_user_credentials") + @patch("iotlabaggregator.common.query_nodes") def test_get_nodes_selection_http_error(self, query_nodes, get_user): - get_user.return_value = ('user', 'password') - query_nodes.side_effect = HTTPError('url', 401, 'Unauthorized', - hdrs=None, fp=None) + get_user.return_value = ("user", "password") + query_nodes.side_effect = HTTPError( + "url", 401, "Unauthorized", hdrs=None, fp=None + ) stderr = StringIO() - with patch('sys.stderr', stderr): - self.assertRaises(SystemExit, common.get_nodes_selection, - username=None, password=None, - experiment_id=None, nodes_list=()) - self.assertTrue('Register your login:password using `iotlab-auth`' - in stderr.getvalue()) + with patch("sys.stderr", stderr): + self.assertRaises( + SystemExit, + common.get_nodes_selection, + username=None, + password=None, + experiment_id=None, + nodes_list=(), + ) + self.assertIn( + "Register your login:password using `iotlab-auth`", stderr.getvalue() + ) diff --git a/iotlabaggregator/tests/connections_test.py b/iotlabaggregator/tests/connections_test.py new file mode 100644 index 0000000..0cf5309 --- /dev/null +++ b/iotlabaggregator/tests/connections_test.py @@ -0,0 +1,191 @@ +#! /usr/bin/python + +# This file is a part of IoT-LAB aggregation-tools +# Copyright (C) 2015 INRIA (Contact: admin@iot-lab.info) +# Contributor(s) : see AUTHORS file +# +# This software is governed by the CeCILL license under French law +# and abiding by the rules of distribution of free software. You can use, +# modify and/ or redistribute the software under the terms of the CeCILL +# license as circulated by CEA, CNRS and INRIA at the following URL +# http://www.cecill.info. +# +# As a counterpart to the access to the source code and rights to copy, +# modify and redistribute granted by the license, users are provided only +# with a limited warranty and the software's author, the holder of the +# economic rights, and the successive licensors have only limited +# liability. +# +# The fact that you are presently reading this means that you have had +# knowledge of the CeCILL license and that you accept its terms. + +"""Tests for iotlabaggregator.connections""" + +import unittest +from unittest.mock import MagicMock, Mock, patch + +from iotlabaggregator import connections + + +class TestConnection(unittest.TestCase): + """Tests for the Connection class.""" + + def _make_conn(self): + aggregator = Mock() + return connections.Connection("m3-1", aggregator) + + def test_init(self): + conn = self._make_conn() + self.assertEqual("m3-1", conn.hostname) + self.assertEqual("", conn.data_buff) + self.assertIsNone(conn._sock) + + def test_handle_data_default(self): + conn = self._make_conn() + # Base implementation returns empty string (remaining unprocessed data) + remaining = conn.handle_data("some data") + self.assertEqual("", remaining) + + def test_handle_close(self): + conn = self._make_conn() + conn._sock = Mock() + conn.handle_close() + self.assertIsNone(conn._sock) + conn.aggregator.pop.assert_called_with("m3-1", None) + + def test_close_idempotent(self): + conn = self._make_conn() + conn._sock = Mock() + conn.close() + self.assertIsNone(conn._sock) + # Second close should not raise + conn.close() + + def test_close_ignores_oserror(self): + conn = self._make_conn() + sock = Mock() + sock.close.side_effect = OSError("already closed") + conn._sock = sock + conn.close() # should not raise + self.assertIsNone(conn._sock) + + def test_handle_read(self): + conn = self._make_conn() + conn.recv = Mock(return_value=b"hello\nworld\n") + conn.handle_data = Mock(return_value="") + conn.handle_read() + conn.handle_data.assert_called_once_with("hello\nworld\n") + + def test_send(self): + conn = self._make_conn() + sock = Mock() + conn._sock = sock + conn.send(b"hello") + sock.sendall.assert_called_with(b"hello") + + def test_send_no_socket(self): + conn = self._make_conn() + conn._sock = None + conn.send(b"hello") # should not raise + + def test_send_socket_error(self): + conn = self._make_conn() + sock = Mock() + sock.sendall.side_effect = OSError("broken pipe") + conn._sock = sock + conn.send(b"hello") # should not raise + + def test_handle_error(self): + conn = self._make_conn() + with patch("iotlabaggregator.connections.LOGGER") as mock_logger: + try: + raise ValueError("test error") + except ValueError: + conn.handle_error() + mock_logger.error.assert_called_once() + + +class TestAggregator(unittest.TestCase): + """Tests for the Aggregator class.""" + + def test_empty_nodes_list_raises(self): + self.assertRaises(ValueError, connections.Aggregator, []) + + def test_empty_nodes_list_message(self): + with self.assertRaises(ValueError) as ctx: + connections.Aggregator([]) + self.assertIn("Empty nodes list", str(ctx.exception)) + + def test_init_creates_connections(self): + agg = connections.Aggregator(["m3-1", "m3-2"]) + self.assertIn("m3-1", agg) + self.assertIn("m3-2", agg) + self.assertIsInstance(agg["m3-1"], connections.Connection) + + def test_broadcast(self): + agg = connections.Aggregator(["m3-1", "m3-2"]) + agg["m3-1"].send = Mock() + agg["m3-2"].send = Mock() + agg.broadcast("hello") + agg["m3-1"].send.assert_called_once_with(b"hello") + agg["m3-2"].send.assert_called_once_with(b"hello") + + def test_send_nodes_broadcast(self): + agg = connections.Aggregator(["m3-1"]) + agg["m3-1"].send = Mock() + agg.send_nodes(None, "hello") + agg["m3-1"].send.assert_called_once_with(b"hello") + + def test_send_nodes_specific(self): + agg = connections.Aggregator(["m3-1", "m3-2"]) + agg["m3-1"].send = Mock() + agg["m3-2"].send = Mock() + agg.send_nodes(["m3-1"], "hello") + agg["m3-1"].send.assert_called_once_with(b"hello") + agg["m3-2"].send.assert_not_called() + + def test_send_unknown_node(self): + agg = connections.Aggregator(["m3-1"]) + with patch("iotlabaggregator.connections.LOGGER") as mock_logger: + agg._send("m3-99", "hello") + mock_logger.warning.assert_called_once() + + def test_context_manager(self): + agg = connections.Aggregator(["m3-1"]) + agg.start = Mock() + agg.stop = Mock() + with agg: + agg.start.assert_called_once() + agg.stop.assert_called_once() + + def test_custom_connection_class(self): + class MyConn(connections.Connection): + pass + + class MyAgg(connections.Aggregator): + connection_class = MyConn + + agg = MyAgg(["m3-1"]) + self.assertIsInstance(agg["m3-1"], MyConn) + + def test_loop_exits_on_stop(self): + """Verify the selector loop exits cleanly when _running is set False.""" + agg = connections.Aggregator(["m3-1"]) + selector = MagicMock() + selector.select.return_value = [] + agg._selector = selector + agg._running = True + + import threading + + def stop_soon(): + import time + + time.sleep(0.05) + agg._running = False + + t = threading.Thread(target=stop_soon) + t.start() + agg._loop() + t.join() + # Should exit without sending SIGINT (self._running is False) diff --git a/iotlabaggregator/tests/serial_test.py b/iotlabaggregator/tests/serial_test.py index ea8b94c..a0f49df 100644 --- a/iotlabaggregator/tests/serial_test.py +++ b/iotlabaggregator/tests/serial_test.py @@ -1,5 +1,4 @@ #! /usr/bin/python -# -*- coding:utf-8 -*- # This file is a part of IoT-LAB aggregation-tools # Copyright (C) 2015 INRIA (Contact: admin@iot-lab.info) @@ -20,88 +19,95 @@ # The fact that you are presently reading this means that you have had # knowledge of the CeCILL license and that you accept its terms. -# pylint:disable=missing-docstring - import unittest -import mock +from unittest import mock from iotlabaggregator import serial class TestSelectNodes(unittest.TestCase): def setUp(self): - self.get_exp = mock.patch('iotlabaggregator.common.experiment' - '.get_experiment').start() + self.get_exp = mock.patch( + "iotlabaggregator.common.experiment.get_experiment" + ).start() self.get_exp.side_effect = self._get_exp - self.cur_exp = mock.patch('iotlabcli.get_current_experiment').start() + self.cur_exp = mock.patch("iotlabcli.get_current_experiment").start() self.cur_exp.return_value = 123 - sites_list = mock.patch('iotlabcli.parser.common.sites_list').start() - sites_list.return_value = ['grenoble', 'lille', 'strasbourg'] + sites_list = mock.patch("iotlabcli.parser.common.sites_list").start() + sites_list.return_value = ["grenoble", "lille", "strasbourg"] - credentials = mock.patch('iotlabcli.get_user_credentials').start() - credentials.return_value = ('user', 'password') + credentials = mock.patch("iotlabcli.get_user_credentials").start() + credentials.return_value = ("user", "password") def tearDown(self): mock.patch.stopall() - def _get_exp(self, _api, _exp_id, # pylint:disable=unused-argument - option=''): - if option == '': - return {'state': 'Running'} - if option == 'nodes': - resources = {"items": [ - {'network_address': 'm3-1.grenoble.iot-lab.info', - 'site': 'grenoble'}, - {'network_address': 'wsn430-1.lille.iot-lab.info', - 'site': 'lille'}, - {'network_address': 'a8-1.strasbourg.iot-lab.info', - 'site': 'strasbourg'}, - {'network_address': 'wsn430-4.grenoble.iot-lab.info', - 'site': 'grenoble'}, - {'network_address': 'a8-1.grenoble.iot-lab.info', - 'site': 'grenoble'}, - ]} + def _get_exp(self, _api, _exp_id, option=""): + if option == "": + return {"state": "Running"} + if option == "nodes": + resources = { + "items": [ + { + "network_address": "m3-1.grenoble.iot-lab.info", + "site": "grenoble", + }, + { + "network_address": "wsn430-1.lille.iot-lab.info", + "site": "lille", + }, + { + "network_address": "a8-1.strasbourg.iot-lab.info", + "site": "strasbourg", + }, + { + "network_address": "wsn430-4.grenoble.iot-lab.info", + "site": "grenoble", + }, + { + "network_address": "a8-1.grenoble.iot-lab.info", + "site": "grenoble", + }, + ] + } return resources return self.fail() - @mock.patch('iotlabaggregator.common.HOSTNAME', 'grenoble') + @mock.patch("iotlabaggregator.common.HOSTNAME", "grenoble") def test_no_args(self): opts = serial.SerialAggregator.parser.parse_args([]) nodes_list = serial.SerialAggregator.select_nodes(opts) # Only grenoble nodes, m3 and wsn430 - self.assertEqual(['m3-1', 'wsn430-4'], nodes_list) + self.assertEqual(["m3-1", "wsn430-4"], nodes_list) - opts = serial.SerialAggregator.parser.parse_args(['--with-a8']) + opts = serial.SerialAggregator.parser.parse_args(["--with-a8"]) nodes_list = serial.SerialAggregator.select_nodes(opts) # Also a8 nodes - self.assertEqual(['m3-1', 'wsn430-4', 'node-a8-1'], nodes_list) + self.assertEqual(["m3-1", "wsn430-4", "node-a8-1"], nodes_list) - @mock.patch('iotlabaggregator.common.HOSTNAME', 'grenoble') + @mock.patch("iotlabaggregator.common.HOSTNAME", "grenoble") def test_node_selection(self): - opts = serial.SerialAggregator.parser.parse_args( - ['-l', 'grenoble,m3,1']) + opts = serial.SerialAggregator.parser.parse_args(["-l", "grenoble,m3,1"]) nodes_list = serial.SerialAggregator.select_nodes(opts) - self.assertEqual(['m3-1'], nodes_list) + self.assertEqual(["m3-1"], nodes_list) # nodes from another site opts = serial.SerialAggregator.parser.parse_args( - ['-l', 'grenoble,m3,1', '-l', 'lille,wsn430,1']) + ["-l", "grenoble,m3,1", "-l", "lille,wsn430,1"] + ) nodes_list = serial.SerialAggregator.select_nodes(opts) - self.assertEqual(['m3-1'], nodes_list) + self.assertEqual(["m3-1"], nodes_list) # nodes not in current experiment - opts = serial.SerialAggregator.parser.parse_args( - ['-l', 'grenoble,m3,1-5']) + opts = serial.SerialAggregator.parser.parse_args(["-l", "grenoble,m3,1-5"]) nodes_list = serial.SerialAggregator.select_nodes(opts) - self.assertEqual(['m3-1'], nodes_list) + self.assertEqual(["m3-1"], nodes_list) class TestColor(unittest.TestCase): - def test_has_color(self): - # pylint:disable=bad-option-value,import-error,import-outside-toplevel if serial.HAS_COLOR: - import colorama as _ # noqa + import colorama as _ # noqa: F401 else: - self.assertRaises(ImportError, __import__, 'colorama') + self.assertRaises(ImportError, __import__, "colorama") diff --git a/iotlabaggregator/tests/sniffer_test.py b/iotlabaggregator/tests/sniffer_test.py index 73c1a45..e3a1bd6 100644 --- a/iotlabaggregator/tests/sniffer_test.py +++ b/iotlabaggregator/tests/sniffer_test.py @@ -1,5 +1,4 @@ #! /usr/bin/python -# -*- coding:utf-8 -*- # This file is a part of IoT-LAB aggregation-tools # Copyright (C) 2015 INRIA (Contact: admin@iot-lab.info) @@ -20,34 +19,36 @@ # The fact that you are presently reading this means that you have had # knowledge of the CeCILL license and that you accept its terms. -# pylint:disable=missing-docstring - -import unittest import binascii -from mock import patch, Mock +import io +import sys +import unittest +from unittest.mock import MagicMock, Mock, patch from iotlabaggregator import sniffer class TestSnifferHandleRead(unittest.TestCase): - """ Test the packet reading code """ - - zep_message = binascii.a2b_hex(''.join(( - '45 58 02 01' # Base Zep header - '0B 00 01 00 ff' # chan | dev_id | dev_id| LQI/CRC_MODE | LQI - '00 00 00 00' # Timestamp msb - '00 00 00 00' # timestamp lsp - - '00 00 00 01' # seqno - - '00 01 02 03' # reserved 0-3/10 - '04 05 06 07' # reserved 4-7/10 - '08 09' # reserved 8-9 / 10 - '08' # Length 2 + data_len - '61 62 63' # Data - '41 42 43' # Data - 'FF FF' # CRC) - ).split())) + """Test the packet reading code.""" + + zep_message = binascii.a2b_hex( + "".join( + ( + "45 58 02 01" # Base Zep header + "0B 00 01 00 ff" # chan | dev_id | dev_id| LQI/CRC_MODE | LQI + "00 00 00 00" # Timestamp msb + "00 00 00 00" # timestamp lsp + "00 00 00 01" # seqno + "00 01 02 03" # reserved 0-3/10 + "04 05 06 07" # reserved 4-7/10 + "08 09" # reserved 8-9 / 10 + "08" # Length 2 + data_len + "61 62 63" # Data + "41 42 43" # Data + "FF FF" # CRC) + ).split() + ) + ) def setUp(self): self.outfd = Mock() @@ -56,33 +57,32 @@ def tearDown(self): patch.stopall() def test_simple(self): - def recv(_): return self.zep_message aggregator = Mock() aggregator.rx_packets = 0 - sniff = sniffer.SnifferConnection('m3-1', aggregator, self.outfd.write) + sniff = sniffer.SnifferConnection("m3-1", aggregator, self.outfd.write) sniff.recv = Mock(side_effect=recv) sniff.handle_read() sniff.handle_read() - msg = self.zep_message.decode('latin-1') + msg = self.zep_message.decode("latin-1") self.outfd.write.assert_called_with(msg) def test_invalid_data_start(self): def recv(_): - return b'invaEEEXlidE_data' + self.zep_message + return b"invaEEEXlidE_data" + self.zep_message aggregator = Mock() aggregator.rx_packets = 0 - sniff = sniffer.SnifferConnection('m3-1', aggregator, self.outfd.write) + sniff = sniffer.SnifferConnection("m3-1", aggregator, self.outfd.write) sniff.recv = Mock(side_effect=recv) sniff.handle_read() sniff.handle_read() self.assertEqual(2, self.outfd.write.call_count) - msg = (self.zep_message).decode('latin-1') + msg = self.zep_message.decode("latin-1") self.outfd.write.assert_called_with(msg) def test_read_ret_values(self): @@ -92,20 +92,117 @@ def test_read_ret_values(self): self.read_return_n_char_per_call(i) def read_return_n_char_per_call(self, num_chars): - msg = list((self.zep_message).decode('latin-1') * 10) + msg = list((self.zep_message).decode("latin-1") * 10) def recv(_): ret = msg[0:num_chars] del msg[0:num_chars] - return (''.join(ret)).encode('latin-1') + return ("".join(ret)).encode("latin-1") aggregator = Mock() aggregator.rx_packets = 0 - sniff = sniffer.SnifferConnection('m3-1', aggregator, self.outfd.write) + sniff = sniffer.SnifferConnection("m3-1", aggregator, self.outfd.write) sniff.recv = Mock(side_effect=recv) while msg: sniff.handle_read() self.assertEqual(10, self.outfd.write.call_count) - msg = (self.zep_message).decode('latin-1') + msg = (self.zep_message).decode("latin-1") self.outfd.write.assert_called_with(msg) + + +class TestSnifferAggregatorSelectNodes(unittest.TestCase): + """Tests for SnifferAggregator.select_nodes.""" + + def setUp(self): + self.get_exp = patch( + "iotlabaggregator.common.experiment.get_experiment" + ).start() + self.get_exp.return_value = { + "items": [ + {"network_address": "m3-1.grenoble.iot-lab.info", "site": "grenoble"}, + {"network_address": "a8-1.grenoble.iot-lab.info", "site": "grenoble"}, + { + "network_address": "nrf52840dk-1.grenoble.iot-lab.info", + "site": "grenoble", + }, + {"network_address": "pc-1.grenoble.iot-lab.info", "site": "grenoble"}, + ] + } + patch("iotlabcli.get_current_experiment", return_value=123).start() + patch("iotlabcli.get_user_credentials", return_value=("user", "pwd")).start() + + def tearDown(self): + patch.stopall() + + @patch("iotlabaggregator.common.HOSTNAME", "grenoble") + def test_select_nodes_filters_compat(self): + opts = sniffer.SnifferAggregator.parser.parse_args(["-o", "-"]) + nodes = sniffer.SnifferAggregator.select_nodes(opts) + # m3 and nrf52840dk are compatible; a8 is compatible; pc is not + self.assertIn("m3-1", nodes) + self.assertIn("a8-1", nodes) + self.assertIn("nrf52840dk-1", nodes) + self.assertNotIn("pc-1", nodes) + + @patch("iotlabaggregator.common.HOSTNAME", "grenoble") + def test_select_nodes_empty_when_no_compat(self): + self.get_exp.return_value = { + "items": [ + {"network_address": "pc-1.grenoble.iot-lab.info", "site": "grenoble"}, + ] + } + opts = sniffer.SnifferAggregator.parser.parse_args(["-o", "-"]) + nodes = sniffer.SnifferAggregator.select_nodes(opts) + self.assertEqual([], nodes) + + +class TestSnifferMain(unittest.TestCase): + """Tests for sniffer.main() covering the file-open logic.""" + + def setUp(self): + # Keep the real parser so parse_args works normally + real_parser = sniffer.SnifferAggregator.parser + + agg = MagicMock() + agg.__enter__ = Mock(return_value=agg) + agg.__exit__ = Mock(return_value=False) + agg.rx_packets = 0 + + self._cls = patch("iotlabaggregator.sniffer.SnifferAggregator").start() + self._cls.parser = real_parser + self._cls.select_nodes.return_value = ["m3-1"] + self._cls.return_value = agg + + def tearDown(self): + patch.stopall() + + def test_main_stdout(self): + """-o - passes sys.stdout.buffer to the aggregator.""" + sniffer.main(["-o", "-"]) + outfd_used = self._cls.call_args[0][1] + self.assertIs(sys.stdout.buffer, outfd_used) + + def test_main_file(self): + """A path string is opened in wb mode and passed to the aggregator.""" + fake_file = io.BytesIO() + with patch("builtins.open", return_value=fake_file) as mock_open: + sniffer.main(["-o", "/tmp/out.pcap"]) + mock_open.assert_called_once_with("/tmp/out.pcap", "wb") + outfd_used = self._cls.call_args[0][1] + self.assertIs(fake_file, outfd_used) + + def test_main_empty_nodes_error(self): + """ValueError (empty node list) is caught and exits with code 1.""" + self._cls.side_effect = ValueError("Empty nodes list") + with self.assertRaises(SystemExit) as ctx: + sniffer.main(["-o", "-"]) + self.assertEqual(1, ctx.exception.code) + + def test_main_debug_flag(self): + """--debug raises the logger level to DEBUG.""" + import logging + + with patch("iotlabaggregator.sniffer.LOGGER") as mock_logger: + sniffer.main(["-o", "-", "--debug"]) + mock_logger.setLevel.assert_called_once_with(logging.DEBUG) diff --git a/iotlabaggregator/tests/zeptopcap_test.py b/iotlabaggregator/tests/zeptopcap_test.py new file mode 100644 index 0000000..80c880e --- /dev/null +++ b/iotlabaggregator/tests/zeptopcap_test.py @@ -0,0 +1,210 @@ +#! /usr/bin/python + +# This file is a part of IoT-LAB aggregation-tools +# Copyright (C) 2015 INRIA (Contact: admin@iot-lab.info) +# Contributor(s) : see AUTHORS file +# +# This software is governed by the CeCILL license under French law +# and abiding by the rules of distribution of free software. You can use, +# modify and/ or redistribute the software under the terms of the CeCILL +# license as circulated by CEA, CNRS and INRIA at the following URL +# http://www.cecill.info. +# +# As a counterpart to the access to the source code and rights to copy, +# modify and redistribute granted by the license, users are provided only +# with a limited warranty and the software's author, the holder of the +# economic rights, and the successive licensors have only limited +# liability. +# +# The fact that you are presently reading this means that you have had +# knowledge of the CeCILL license and that you accept its terms. + +"""Tests for iotlabaggregator.zeptopcap""" + +import binascii +import io +import struct +import unittest + +from iotlabaggregator import zeptopcap + +ZEP_MESSAGE = binascii.a2b_hex( + "".join( + ( + "45 58 02 01" # Base Zep header + "0B 00 01 00 ff" # chan | dev_id | dev_id | LQI/CRC_MODE | LQI + "00 00 00 00" # Timestamp msb + "00 00 00 00" # timestamp lsp + "00 00 00 01" # seqno + "00 01 02 03" # reserved 0-3/10 + "04 05 06 07" # reserved 4-7/10 + "08 09" # reserved 8-9/10 + "08" # Length 2 + data_len + "61 62 63" # Data + "41 42 43" # Data + "FF FF" # CRC + ).split() + ) +) + +# Same as ZEP_MESSAGE but with a valid NTP timestamp (Jan 1 2024 = 0xE949B200) +# so that write tests produce valid pcap timestamps (no overflow) +ZEP_MESSAGE_VALID_TS = binascii.a2b_hex( + "".join( + ( + "45 58 02 01" # Base Zep header + "0B 00 01 00 ff" # chan | dev_id | dev_id | LQI/CRC_MODE | LQI + "E9 49 B2 00" # Timestamp msb (NTP Jan 1 2024 00:00:00 UTC) + "00 00 00 00" # Timestamp lsp (fraction = 0) + "00 00 00 01" # seqno + "00 01 02 03" # reserved 0-3/10 + "04 05 06 07" # reserved 4-7/10 + "08 09" # reserved 8-9/10 + "08" # Length 2 + data_len + "61 62 63" # Data + "41 42 43" # Data + "FF FF" # CRC + ).split() + ) +) + + +class TestZepPcapHeaders(unittest.TestCase): + """Tests for ZepPcap header generation.""" + + def setUp(self): + self.out = io.BytesIO() + self.zep = zeptopcap.ZepPcap(self.out) + + def test_main_pcap_header_written_on_init(self): + # After init, the global PCAP header should be written + data = self.out.getvalue() + self.assertGreater(len(data), 0) + magic, major, minor = struct.unpack_from("=LHH", data, 0) + self.assertEqual(0xA1B2C3D4, magic) + self.assertEqual(2, major) + self.assertEqual(4, minor) + + def test_main_pcap_header_raw_mode(self): + out = io.BytesIO() + zeptopcap.ZepPcap(out, raw=True) + data = out.getvalue() + # link type for raw mode is LINKTYPE_IEEE802_15_4 = 195 + link_type = struct.unpack_from("=L", data, 20)[0] + self.assertEqual(195, link_type) + + def test_main_pcap_header_zep_mode(self): + # link type for zep mode is LINKTYPE_ETHERNET = 1 + data = self.out.getvalue() + link_type = struct.unpack_from("=L", data, 20)[0] + self.assertEqual(1, link_type) + + def test_udp_header(self): + hdr = self.zep._udp_header(10) + self.assertEqual(8 + 10, struct.unpack("!H", hdr[4:6])[0]) + + def test_ip_header_length(self): + hdr = self.zep._ip_header(0) + self.assertEqual(20, len(hdr)) + + def test_ip_header_protocol_udp(self): + hdr = self.zep._ip_header(0) + # Protocol byte is at offset 9 + self.assertEqual(0x11, hdr[9]) + + def test_eth_header_length(self): + hdr = self.zep._eth_header(100) + self.assertEqual(14, len(hdr)) + + def test_pcap_header(self): + hdr = zeptopcap.ZepPcap._pcap_header(40, 1000, 500) + ts_s, ts_us, pkt_len, orig_len = struct.unpack("=LLLL", hdr) + self.assertEqual(1000, ts_s) + self.assertEqual(500, ts_us) + self.assertEqual(40, pkt_len) + self.assertEqual(40, orig_len) + + def test_ip_checksum_all_zeros(self): + hdr = b"\x00" * 20 + csum = zeptopcap.ZepPcap._ip_checksum(hdr) + self.assertEqual(0xFFFF, csum) + + def test_ip_checksum_nonzero(self): + # A known valid IP header should produce checksum 0 when re-checked + # (XOR with 0xFFFF of sum gives 0 when checksum field is correct) + hdr_struct = struct.Struct("!BBHHHBBHLL") + ip_hdr = hdr_struct.pack( + 0x45, 0, 20, 0, 0x4000, 0xFF, 0x11, 0, 0x7F000001, 0x7F000001 + ) + csum = zeptopcap.ZepPcap._ip_checksum(ip_hdr) + self.assertNotEqual(0, csum) + + +class TestZepPcapTimestamp(unittest.TestCase): + """Tests for timestamp extraction.""" + + def setUp(self): + self.out = io.BytesIO() + self.zep = zeptopcap.ZepPcap(self.out) + + def test_timestamp_epoch(self): + # Build a packet with NTP timestamp = NTP_JAN_1970 → unix time 0 + packet = bytearray(40) + packet[0:4] = b"EX\x02\x01" + ntp_seconds = zeptopcap.ZepPcap.NTP_JAN_1970 + struct.pack_into("!LL", packet, zeptopcap.ZepPcap.ZEP_TIME_IDX, ntp_seconds, 0) + t_s, t_us = self.zep._timestamp(bytes(packet)) + self.assertEqual(0, t_s) + self.assertEqual(0, t_us) + + def test_timestamp_fractional(self): + packet = bytearray(40) + ntp_seconds = zeptopcap.ZepPcap.NTP_JAN_1970 + 1 + ntp_frac = zeptopcap.ZepPcap.NTP_SECONDS_FRAC // 2 # 0.5 seconds → 500000 us + struct.pack_into( + "!LL", packet, zeptopcap.ZepPcap.ZEP_TIME_IDX, ntp_seconds, ntp_frac + ) + t_s, t_us = self.zep._timestamp(bytes(packet)) + self.assertEqual(1, t_s) + self.assertEqual(500000, t_us) + + +class TestZepPcapWrite(unittest.TestCase): + """Integration tests for ZepPcap.write.""" + + def test_write_zep_produces_output(self): + out = io.BytesIO() + zep = zeptopcap.ZepPcap(out) + initial_len = len(out.getvalue()) + zep.write(ZEP_MESSAGE_VALID_TS.decode("latin-1")) + self.assertGreater(len(out.getvalue()), initial_len) + + def test_write_raw_produces_output(self): + out = io.BytesIO() + zep = zeptopcap.ZepPcap(out, raw=True) + initial_len = len(out.getvalue()) + zep.write(ZEP_MESSAGE_VALID_TS.decode("latin-1")) + self.assertGreater(len(out.getvalue()), initial_len) + + def test_write_zep_pcap_record_length(self): + """Written pcap record must contain ethernet+ip+udp+zep payload.""" + out = io.BytesIO() + zep = zeptopcap.ZepPcap(out) + header_size = len(out.getvalue()) + zep.write(ZEP_MESSAGE_VALID_TS.decode("latin-1")) + record = out.getvalue()[header_size:] + # pcap record header: 8 bytes timestamps + 4 bytes captured len + 4 bytes orig len + pkt_len = struct.unpack_from("=L", record, 8)[0] + # eth(14) + ip(20) + udp(8) + zep_payload(40) + self.assertEqual(14 + 20 + 8 + len(ZEP_MESSAGE_VALID_TS), pkt_len) + + def test_write_raw_pcap_record_length(self): + """Raw mode writes only payload (strips ZEP header).""" + out = io.BytesIO() + zep = zeptopcap.ZepPcap(out, raw=True) + header_size = len(out.getvalue()) + zep.write(ZEP_MESSAGE_VALID_TS.decode("latin-1")) + record = out.getvalue()[header_size:] + pkt_len = struct.unpack_from("=L", record, 8)[0] + expected = len(ZEP_MESSAGE_VALID_TS) - zeptopcap.ZepPcap.ZEP_HDR_LEN + self.assertEqual(expected, pkt_len) diff --git a/iotlabaggregator/zeptopcap.py b/iotlabaggregator/zeptopcap.py index b0d1fb3..b154293 100644 --- a/iotlabaggregator/zeptopcap.py +++ b/iotlabaggregator/zeptopcap.py @@ -1,5 +1,4 @@ #! /usr/bin/python -# -*- coding: utf-8 -*- # This file is a part of IoT-LAB aggregation-tools # Copyright (C) 2015 INRIA (Contact: admin@iot-lab.info) @@ -21,21 +20,22 @@ # knowledge of the CeCILL license and that you accept its terms. -""" Generate pcap files from zep messages """ +"""Generate pcap files from zep messages""" # http://www.codeproject.com/Tips/612847/ \ # Generate-a-quick-and-easy-custom-pcap-file-using-P -import sys -import struct import binascii +import struct +import sys # pylint:disable=bad-option-value,too-few-public-methods,old-style-class -class ZepPcap(): - """ Zep to Pcap converter +class ZepPcap: + """Zep to Pcap converter On `write` encapsulate the message as a zep packet in `outfile` pcap format """ + ZEP_PORT = 17754 ZEP_HDR_LEN = 32 ZEP_TIME_IDX = 9 @@ -48,10 +48,16 @@ class ZepPcap(): NTP_SECONDS_FRAC = 1 << 32 # Network headers as network endian - eth_hdr = struct.pack('!3H3HH', - 0, 0, 0, # dst mac addr - 0, 0, 0, # src mac addr - 0x0800) # Protocol: (0x0800 == IP) + eth_hdr = struct.pack( + "!3H3HH", + 0, + 0, + 0, # dst mac addr + 0, + 0, + 0, # src mac addr + 0x0800, + ) # Protocol: (0x0800 == IP) def __init__(self, outfile, raw=False): self.out = outfile @@ -68,8 +74,8 @@ def __init__(self, outfile, raw=False): self.out.flush() def _write_zep(self, packet): - """ Encapsulate ZEP data in pcap outfile """ - packet = packet.encode('latin-1') + """Encapsulate ZEP data in pcap outfile""" + packet = packet.encode("latin-1") timestamp = self._timestamp(packet) # Calculate all headers @@ -96,12 +102,12 @@ def _write_zep(self, packet): self.out.flush() def _write_raw(self, packet): - """ Only write the ZEP payload as pcap""" - packet = packet.encode('latin-1') + """Only write the ZEP payload as pcap""" + packet = packet.encode("latin-1") timestamp = self._timestamp(packet) # extract payload from zep encapsulated data - payload = packet[self.ZEP_HDR_LEN:] + payload = packet[self.ZEP_HDR_LEN :] # Only add pcap header length = len(payload) @@ -114,13 +120,13 @@ def _write_raw(self, packet): self.out.flush() def _timestamp(self, packet): - """ Extract packet timestamp as an unix time tuple (s, us) + """Extract packet timestamp as an unix time tuple (s, us) Packet timestamp is in 'ntp' format. MSB are seconds stored since 1 january 1900 LSB are fraction of seconds where 2**32 == 1 second """ - ntp_t = struct.unpack_from('!LL', packet, self.ZEP_TIME_IDX) + ntp_t = struct.unpack_from("!LL", packet, self.ZEP_TIME_IDX) t_s = ntp_t[0] - self.NTP_JAN_1970 t_us = (1000000 * ntp_t[1]) / self.NTP_SECONDS_FRAC @@ -128,7 +134,7 @@ def _timestamp(self, packet): return t_s, round(t_us) def _udp_header(self, pkt_len): - """ Get UDP Header + """Get UDP Header 2B - src_port: ZEP_PORT also but not required 2B - dst_port: ZEP_PORT == 17754 @@ -136,13 +142,13 @@ def _udp_header(self, pkt_len): 2B - checksum: Disable == 0 """ - hdr_struct = struct.Struct('!HHHH') + hdr_struct = struct.Struct("!HHHH") udp_len = hdr_struct.size + pkt_len udp_hdr = hdr_struct.pack(self.ZEP_PORT, self.ZEP_PORT, udp_len, 0) return udp_hdr def _ip_header(self, pkt_len): - """ Get the IP Header + """Get the IP Header 1B - Version | IHL: 0x45: [4 | 5] : [4b | 4b] 1B - Type of Service: 0 @@ -156,22 +162,24 @@ def _ip_header(self, pkt_len): 4B - Destination Address: 0x7F000001 (127.0.0.1) """ - hdr_struct = struct.Struct('!BBHHHBBHLL') + hdr_struct = struct.Struct("!BBHHHBBHLL") ip_len = hdr_struct.size + pkt_len # generate header with checksum == 0 to calculate checksum checksum = 0 - ip_hdr_csum = hdr_struct.pack(0x45, 0, ip_len, 0, 0x4000, 0xff, 0x11, - checksum, 0x7F000001, 0x7F000001) + ip_hdr_csum = hdr_struct.pack( + 0x45, 0, ip_len, 0, 0x4000, 0xFF, 0x11, checksum, 0x7F000001, 0x7F000001 + ) checksum = self._ip_checksum(ip_hdr_csum) # Generate header with correct checksum - ip_hdr = hdr_struct.pack(0x45, 0, ip_len, 0, 0x4000, 0xff, 0x11, - checksum, 0x7F000001, 0x7F000001) + ip_hdr = hdr_struct.pack( + 0x45, 0, ip_len, 0, 0x4000, 0xFF, 0x11, checksum, 0x7F000001, 0x7F000001 + ) return ip_hdr def _eth_header(self, pkt_len): # pylint:disable=unused-argument - """ Return a static empty ethernet header + """Return a static empty ethernet header 6B - dst mac addr: 0 6B - src mac addr: 0 @@ -181,7 +189,7 @@ def _eth_header(self, pkt_len): # pylint:disable=unused-argument @staticmethod def _pcap_header(pkt_len, t_s, t_us): - """ Get the PCAP Header + """Get the PCAP Header 4B - Timestamp seconds: current time 4B - Timestamp microseconds: current time @@ -189,20 +197,20 @@ def _pcap_header(pkt_len, t_s, t_us): 4B - Actual lengt of packet: pkt_len """ - hdr_struct = struct.Struct('=LLLL') + hdr_struct = struct.Struct("=LLLL") pcap_len = pkt_len pcap_hdr = hdr_struct.pack(t_s, t_us, pcap_len, pcap_len) return pcap_hdr @staticmethod def _ip_checksum(hdr): - """ Calculate the ip checksum for given header """ + """Calculate the ip checksum for given header""" assert (len(hdr) % 2) == 0 # hdr has even length - word_pack = struct.Struct('!H') + word_pack = struct.Struct("!H") # Sum all 16bits - hdr_split = (hdr[i:i+2] for i in range(0, len(hdr), 2)) - csum = sum((word_pack.unpack(word)[0] for word in hdr_split)) + hdr_split = (hdr[i : i + 2] for i in range(0, len(hdr), 2)) + csum = sum(word_pack.unpack(word)[0] for word in hdr_split) # Reduce to 16b and save the one complement checksum = (csum + (csum >> 16)) & 0xFFFF ^ 0xFFFF @@ -210,45 +218,43 @@ def _ip_checksum(hdr): @staticmethod def _main_pcap_header(link_type): - """ Return the main pcap file header for `link_type` + """Return the main pcap file header for `link_type` PCAP headers as native endian """ return struct.pack( - '=LHHLLLL', - 0xa1b2c3d4, # Pcap header Little Endian - 2, # File format major revision (i.e. pcap <2>.4) - 4, # File format minor revision (i.e. pcap 2.<4>) - 0, # GMT to local correction: 0 if timestamps are UTC - 0, # accuracy of timestamps -> set it to 0 - 0xffff, # packet capture limit -> typically 65535 - link_type, # Link (Ethernet/802.15.4 FCS/...) + "=LHHLLLL", + 0xA1B2C3D4, # Pcap header Little Endian + 2, # File format major revision (i.e. pcap <2>.4) + 4, # File format minor revision (i.e. pcap 2.<4>) + 0, # GMT to local correction: 0 if timestamps are UTC + 0, # accuracy of timestamps -> set it to 0 + 0xFFFF, # packet capture limit -> typically 65535 + link_type, # Link (Ethernet/802.15.4 FCS/...) ) def main(): - """ Main function """ + """Main function""" zep_message_str = ( - '45 58 02 01' # Base Zep header - '0B 00 01 00 ff' # chan | dev_id | dev_id| LQI/CRC_MODE | LQI - '00 00 00 00' # Timestamp msb - '00 00 00 00' # timestamp lsp - - '00 00 00 01' # seqno - - '00 01 02 03' # reserved 0-3/10 - '04 05 16 07' # reserved 4-7/10 - '08 09' # reserved 8-9 / 10 - '08' # Length 2 + data_len - '61 62 63' # Data - '41 42 43' # Data - 'FF FF' # CRC) + "45 58 02 01" # Base Zep header + "0B 00 01 00 ff" # chan | dev_id | dev_id| LQI/CRC_MODE | LQI + "00 00 00 00" # Timestamp msb + "00 00 00 00" # timestamp lsp + "00 00 00 01" # seqno + "00 01 02 03" # reserved 0-3/10 + "04 05 16 07" # reserved 4-7/10 + "08 09" # reserved 8-9 / 10 + "08" # Length 2 + data_len + "61 62 63" # Data + "41 42 43" # Data + "FF FF" # CRC) ) - zep_message = binascii.a2b_hex(''.join(zep_message_str.split())) + zep_message = binascii.a2b_hex("".join(zep_message_str.split())) out_file = sys.argv[1] - with open(out_file, 'w') as pcap_file: + with open(out_file, "w") as pcap_file: zep_pcap = ZepPcap(pcap_file) zep_pcap.write(zep_message) @@ -258,5 +264,5 @@ def main(): zep_pcap.write(zep_message) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bbf96c3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "iotlabaggregator" +dynamic = ["version"] +description = "IoT-LAB testbed node connection command-line tools" +readme = "README.md" +license = { text = "CeCILL v2.1" } +authors = [{ name = "IoT-LAB Team", email = "admin@iot-lab.info" }] +urls = { Homepage = "http://www.iot-lab.info", Download = "https://github.com/iot-lab/aggregation-tools" } +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "License :: OSI Approved", + "Intended Audience :: End Users/Desktop", + "Environment :: Console", + "Topic :: Utilities", +] +requires-python = ">=3.10" +dependencies = ["iotlabcli>=2.2.1", "chardet"] + +[project.optional-dependencies] +color_serial = ["colorama>=0.3.7"] + +[project.scripts] +serial_aggregator = "iotlabaggregator.serial:main" +sniffer_aggregator = "iotlabaggregator.sniffer:main" + +[tool.hatch.version] +path = "iotlabaggregator/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["iotlabaggregator"] + +[tool.pytest.ini_options] +addopts = [ + "-v", + "--doctest-modules", + "--cov=iotlabaggregator", + "--cov-report=term-missing", + "--cov-report=term", + "--cov-report=xml", +] +testpaths = ["iotlabaggregator"] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "UP", "C90"] +ignore = ["E501"] + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.isort] +profile = "black" +src_paths = ["iotlabaggregator"] + +[tool.coverage.run] +source = ["iotlabaggregator"] +omit = ["iotlabaggregator/tests/*"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 1f637aa..0000000 --- a/setup.cfg +++ /dev/null @@ -1,19 +0,0 @@ -[tool:pytest] -addopts = - -v - --doctest-modules - --cov=iotlabaggregator - --cov-report=term-missing --cov-report=term --cov-report=xml - -[pylint] -reports=no -disable=duplicate-code,unspecified-encoding -msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} - -[pep8] -exclude = *.egg,.tox - -[flake8] -exclude = .tox,dist,doc,build,*.egg -# two functions a bit complex but lazy to simplify them -max-complexity = 6 diff --git a/setup.py b/setup.py deleted file mode 100644 index 0fa06cc..0000000 --- a/setup.py +++ /dev/null @@ -1,71 +0,0 @@ -#! /usr/bin/env python -# -*- coding:utf-8 -*- - -# This file is a part of IoT-LAB aggregation-tools -# Copyright (C) 2015 INRIA (Contact: admin@iot-lab.info) -# Contributor(s) : see AUTHORS file -# -# This software is governed by the CeCILL license under French law -# and abiding by the rules of distribution of free software. You can use, -# modify and/ or redistribute the software under the terms of the CeCILL -# license as circulated by CEA, CNRS and INRIA at the following URL -# http://www.cecill.info. -# -# As a counterpart to the access to the source code and rights to copy, -# modify and redistribute granted by the license, users are provided only -# with a limited warranty and the software's author, the holder of the -# economic rights, and the successive licensors have only limited -# liability. -# -# The fact that you are presently reading this means that you have had -# knowledge of the CeCILL license and that you accept its terms. - -""" Install some of the scripts. -Intended to be run on the ssh frontends """ - -import os -from setuptools import setup, find_packages - -PACKAGE = 'iotlabaggregator' -SCRIPTS = ['serial_aggregator', 'sniffer_aggregator'] - -# GPL compatible http://www.gnu.org/licenses/license-list.html#CeCILL -LICENSE = 'CeCILL v2.1' - - -def get_version(package): - """ Extract package version without importing file - Importing cause issues with coverage, - (modules can be removed from sys.modules to prevent this) - Importing __init__.py triggers importing rest and then requests too - - Inspired from pep8 setup.py - """ - with open(os.path.join(package, '__init__.py')) as init_fd: - for line in init_fd: - if line.startswith('__version__'): - version = eval(line.split('=')[-1]) # pylint:disable=eval-used - break - return version - - -setup( - name=PACKAGE, - version=get_version(PACKAGE), - description='IoT-LAB testbed node connection command-line tools', - author='IoT-LAB Team', - author_email='admin@iot-lab.info', - url='http://www.iot-lab.info', - license=LICENSE, - download_url='https://github.com/iot-lab/aggregation-tools', - packages=find_packages(), - scripts=SCRIPTS, - classifiers=['Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'License :: OSI Approved', - 'Intended Audience :: End Users/Desktop', - 'Environment :: Console', - 'Topic :: Utilities', ], - install_requires=['iotlabcli>=2.2.1', 'chardet', 'future'], - extras_require={'color_serial': ['colorama>=0.3.7']}, -) diff --git a/tests_utils/check_license.sh b/tests_utils/check_license.sh index 537eb40..dfc22dc 100644 --- a/tests_utils/check_license.sh +++ b/tests_utils/check_license.sh @@ -9,7 +9,7 @@ files_list=$(git ls-tree -r HEAD --full-tree --name-only) files_list=$(echo "${files_list}" | grep -v \ -e 'tests_utils/' \ -e '.gitignore' \ - -e 'setup.cfg' \ + -e 'pyproject.toml' \ -e 'tox.ini' \ -e '.md$' \ -e '.rst$' \ diff --git a/tests_utils/test-requirements.txt b/tests_utils/test-requirements.txt index de79d4c..951c5a7 100644 --- a/tests_utils/test-requirements.txt +++ b/tests_utils/test-requirements.txt @@ -1,8 +1,4 @@ -future pytest pytest-cov -pytest-pep8 -mock -pylint -flake8 -codecov>=1.4.0 +ruff +isort diff --git a/tox.ini b/tox.ini index 955a40c..f28128c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,47 +1,44 @@ [tox] -envlist = copying,{py37,py38,py39,py310,py311,py312,py313}-{lint,tests,color,cli} +envlist = copying,{py310,py313,py314}-{lint,tests,color,cli} skip_missing_interpreters = True [testenv] allowlist_externals = - cli: {[testenv:cli]allowlist_externals} -deps= + /bin/bash + /usr/bin/bash +deps = iotlabcli -rtests_utils/test-requirements.txt -commands= - tests: {[testenv:tests]commands} - lint: {[testenv:lint]commands} - cli: {[testenv:cli]commands} - color: {[testenv:color]commands} - coverage: {[testenv:coverage]commands} +commands = + tests: {[testenv:tests]commands} + lint: {[testenv:lint]commands} + cli: {[testenv:cli]commands} + color: {[testenv:color]commands} [testenv:tests] -commands= +commands = pytest [testenv:color] -commands= +commands = pip install .[color_serial] [testenv:lint] -commands= - pylint --rcfile=setup.cfg iotlabaggregator setup.py - flake8 +commands = + ruff format --check iotlabaggregator + ruff check iotlabaggregator + isort --check-only iotlabaggregator [testenv:copying] allowlist_externals = /bin/bash /usr/bin/bash -commands= +commands = bash tests_utils/check_license.sh [testenv:cli] allowlist_externals = /bin/bash /usr/bin/bash -commands= +commands = bash -exc "for i in *_aggregator; do $i --help >/dev/null; done" - -[testenv:coverage] -passenv = CI TRAVIS TRAVIS_* -commands = codecov -e TOXENV