diff --git a/multicast/__init__.py b/multicast/__init__.py index f87cfca6..8cb6d800 100644 --- a/multicast/__init__.py +++ b/multicast/__init__.py @@ -52,6 +52,7 @@ """EXIT_CODES""", """EXCEPTION_EXIT_CODES""", """_BLANK""", + """_MCAST_DEFAULT_BUFFER_SIZE""", # added in version 2.0.6 """_MCAST_DEFAULT_PORT""", """_MCAST_DEFAULT_GROUP""", """_MCAST_DEFAULT_TTL""", @@ -156,6 +157,7 @@ Attributes: __version__ (str): The version of this package. + _MCAST_DEFAULT_BUFFER_SIZE (int): Default buffer size for multicast communication (1316). _MCAST_DEFAULT_PORT (int): Default port for multicast communication (59259). _MCAST_DEFAULT_GROUP (str): Default multicast group address ('224.0.0.1'). _MCAST_DEFAULT_TTL (int): Default TTL for multicast packets (1). @@ -215,6 +217,57 @@ >>> +""" + +global _MCAST_DEFAULT_BUFFER_SIZE # skipcq: PYL-W0604 + +_MCAST_DEFAULT_BUFFER_SIZE = 1316 +""" + Arbitrary buffer size to use by default, though any value below 65507 should work. + + Minimal Testing: + + First set up test fixtures by importing multicast. + + >>> import multicast + >>> + + Testcase 0: Multicast should have a default buffer size. + A: Test that the _MCAST_DEFAULT_BUFFER_SIZE attribute is initialized. + B: Test that the _MCAST_DEFAULT_BUFFER_SIZE attribute is an int. + + >>> multicast._MCAST_DEFAULT_BUFFER_SIZE is not None + True + >>> type(multicast._MCAST_DEFAULT_BUFFER_SIZE) is type(1) + True + >>> + >>> multicast._MCAST_DEFAULT_BUFFER_SIZE > int(1) + True + >>> + + Testcase 1: Multicast should validate buffer size constraints. + A: Test that the _MCAST_DEFAULT_BUFFER_SIZE attribute is initialized. + B: Test that the _MCAST_DEFAULT_BUFFER_SIZE attribute is an int. + C: Test that the _MCAST_DEFAULT_BUFFER_SIZE attribute is RFC-791 & RFC-768 compliant. + D: Test that the _MCAST_DEFAULT_BUFFER_SIZE attribute is a smaller than fragment thresholds + for typical ethernet MTUs by default. + + >>> multicast._MCAST_DEFAULT_BUFFER_SIZE is not None + True + >>> type(multicast._MCAST_DEFAULT_BUFFER_SIZE) is type(1) + True + >>> + >>> multicast._MCAST_DEFAULT_BUFFER_SIZE >= int(56) + True + >>> + >>> multicast._MCAST_DEFAULT_BUFFER_SIZE <= int(65527) + True + >>> + >>> multicast._MCAST_DEFAULT_BUFFER_SIZE <= int(1500) + True + >>> + + """ global _MCAST_DEFAULT_PORT # skipcq: PYL-W0604 @@ -410,12 +463,11 @@ _MCAST_DEFAULT_PORT = _config["port"] _MCAST_DEFAULT_GROUP = _config["group"] _MCAST_DEFAULT_TTL = _config["ttl"] + _MCAST_DEFAULT_BUFFER_SIZE = _config["buffer_size"] global _MCAST_DEFAULT_BIND_IP # skipcq: PYL-W0604 _MCAST_DEFAULT_BIND_IP = _config["bind_addr"] global _MCAST_DEFAULT_GROUPS # skipcq: PYL-W0604 _MCAST_DEFAULT_GROUPS = _config["groups"] - global _MCAST_DEFAULT_BUFFER # skipcq: PYL-W0604 - _MCAST_DEFAULT_BUFFER = _config["buffer_size"] del _config # skipcq - cleanup any bootstrap/setup leaks early diff --git a/multicast/env.py b/multicast/env.py index 027570cb..eab10d99 100644 --- a/multicast/env.py +++ b/multicast/env.py @@ -98,6 +98,46 @@ raise baton from err +def validate_buffer_size(size: int) -> bool: + """ + Validate if the buffer size is a positive integer. + + Arguments: + size (int) -- The buffer size to validate. + + Returns: + bool: True if the buffer size is valid ( > 0), False otherwise. + + Raises: + ValueError: If the size cannot be converted to an integer. + + Minimum Acceptance Testing: + >>> validate_buffer_size(1316) # Default value + True + >>> validate_buffer_size(1) # Minimum valid value + True + >>> validate_buffer_size(0) # Zero is invalid + False + >>> validate_buffer_size(-1) # Negative is invalid + False + >>> validate_buffer_size(65507) # Maximum UDP payload size 65,535 -8 -20 (RFC-791 & RFC-768) + True + >>> validate_buffer_size("1316") # String that can be converted + True + >>> try: + ... validate_buffer_size('invalid') + ... except ValueError: + ... print('ValueError raised') + ValueError raised + + """ + try: + size_num = int(size) + return 0 < size_num <= 65507 + except (ValueError, TypeError) as err: + raise ValueError(f"Invalid buffer size value: {size}. Must be a positive integer.") from err + + def validate_port(port: int) -> bool: """ Validate if the port number is within the dynamic/private port range. @@ -202,6 +242,121 @@ def validate_ttl(ttl: int) -> bool: ) from err +def load_buffer_size() -> int: + """ + Load and validate the multicast buffer size from environment variable. + + This function attempts to load the buffer size from the MULTICAST_BUFFER_SIZE + environment variable. If the value is valid, it returns the buffer size. + Invalid values trigger warnings and fall back to the default. + + MTU Considerations for Buffer Size: + When setting a buffer size, consider the MTU of the underlying network: + - Ethernet: 1500 bytes MTU → 1472 bytes max payload (1500 - 28 bytes overhead) + - PPP: 296 bytes MTU → 268 bytes max payload + - Wi-Fi (802.11): 2304 bytes MTU → 2276 bytes max payload + - Frame Relay: 128 bytes MTU → 100 bytes max payload + + The overhead consists of: + - UDP header: 8 bytes + - IP header: 20 bytes (without options) + + Setting buffer sizes larger than the network's max payload may cause IP + fragmentation, which can lead to performance issues and increased complexity. + + Returns: + int: The validated buffer size, or the default value if not set/invalid. + + Environment: + MULTICAST_BUFFER_SIZE -- The buffer size in bytes. + + Raises: + ImportError: If the multicast module cannot be imported. + + Minimum Acceptance Testing: + + Testcase 0: Setup test fixtures. + >>> import os + >>> from multicast import _MCAST_DEFAULT_BUFFER_SIZE + >>> original_buffer = _MCAST_DEFAULT_BUFFER_SIZE + + Testcase 1: Test with valid environment variable + >>> os.environ["MULTICAST_BUFFER_SIZE"] = "2048" + >>> buffer_size = load_buffer_size() + >>> buffer_size + 2048 + >>> # The function updates the global in the module's namespace, but this doesn't affect + >>> # the imported value in the test namespace + >>> _MCAST_DEFAULT_BUFFER_SIZE != 2048 # Global in test namespace is not updated + True + + Testcase 2: Test with invalid (negative) environment variable + >>> os.environ["MULTICAST_BUFFER_SIZE"] = "-100" + >>> import warnings + >>> with warnings.catch_warnings(record=True) as w: + ... warnings.simplefilter("always") + ... buffer_size = load_buffer_size() + ... len(w) == 1 # One warning was issued + True + >>> buffer_size == 1316 # Falls back to default + True + + Testcase 3: Test with invalid (zero) environment variable + >>> os.environ["MULTICAST_BUFFER_SIZE"] = "0" + >>> with warnings.catch_warnings(record=True) as w: + ... warnings.simplefilter("always") + ... buffer_size = load_buffer_size() + ... len(w) == 1 # One warning was issued + True + >>> buffer_size == 1316 # Falls back to default + True + + Testcase 4: Test with invalid (non-integer) environment variable + >>> os.environ["MULTICAST_BUFFER_SIZE"] = 'not_an_integer' + >>> with warnings.catch_warnings(record=True) as w: + ... warnings.simplefilter("always") + ... buffer_size = load_buffer_size() + ... len(w) == 1 # One warning was issued + True + >>> buffer_size == 1316 # Falls back to default + True + + Testcase 5: Test with no environment variable + >>> if "MULTICAST_BUFFER_SIZE" in os.environ: os.environ.pop("MULTICAST_BUFFER_SIZE") + 'not_an_integer' + >>> buffer_size = load_buffer_size() + >>> buffer_size == 1316 # Uses default + True + + # Cleanup + >>> globals()['_MCAST_DEFAULT_BUFFER_SIZE'] = original_buffer + + """ + # Import globals that we'll potentially update + from multicast import _MCAST_DEFAULT_BUFFER_SIZE + try: + buffer_size = int(os.getenv( + "MULTICAST_BUFFER_SIZE", + _MCAST_DEFAULT_BUFFER_SIZE # skipcq: PYL-W1508 + )) + except ValueError: + warnings.warn( + f"Invalid MULTICAST_BUFFER_SIZE value, using default {_MCAST_DEFAULT_BUFFER_SIZE}", + stacklevel=2 + ) + buffer_size = _MCAST_DEFAULT_BUFFER_SIZE # skipcq: PYL-W1508 + # Validate and potentially update port + if validate_buffer_size(buffer_size): + globals()["_MCAST_DEFAULT_BUFFER_SIZE"] = buffer_size + else: + warnings.warn( + f"Invalid MULTICAST_BUFFER_SIZE {buffer_size}, using default {_MCAST_DEFAULT_BUFFER_SIZE}", + stacklevel=2 + ) + buffer_size = _MCAST_DEFAULT_BUFFER_SIZE + return buffer_size + + def load_port() -> int: """ Load and validate the multicast port from environment variable. @@ -429,13 +584,15 @@ def load_TTL() -> int: ImportError: If the multicast module cannot be imported. Minimum Acceptance Testing: + + Testcase 0: Setup >>> import os >>> import socket >>> from multicast import _MCAST_DEFAULT_TTL >>> original_ttl = _MCAST_DEFAULT_TTL >>> original_timeout = socket.getdefaulttimeout() - # Test with valid TTL + Testcase 1: Test with valid TTL >>> os.environ['MULTICAST_TTL'] = '2' >>> ttl = load_TTL() >>> ttl @@ -445,7 +602,7 @@ def load_TTL() -> int: >>> socket.getdefaulttimeout() == 2 # Socket timeout was updated True - # Test with invalid numeric TTL + Testcase 2: Test with invalid numeric TTL >>> os.environ['MULTICAST_TTL'] = '127' >>> import warnings >>> with warnings.catch_warnings(record=True) as w: @@ -456,7 +613,7 @@ def load_TTL() -> int: >>> ttl == original_ttl # Falls back to original default True - # Test with non-numeric TTL + Testcase 3: Test with non-numeric TTL >>> os.environ['MULTICAST_TTL'] = 'invalid' >>> with warnings.catch_warnings(record=True) as w: ... warnings.simplefilter("always") @@ -469,7 +626,7 @@ def load_TTL() -> int: 'invalid' >>> - # Test with unset environment variable + Testcase 4: Test with unset environment variable >>> os.environ.pop('MULTICAST_TTL', None) >>> ttl = load_TTL() >>> ttl == original_ttl # Uses default @@ -671,10 +828,10 @@ def load_config() -> dict: >>> with warnings.catch_warnings(record=True) as w: ... warnings.simplefilter("always") ... config = load_config() - ... len(w) == 0 # expected failure - Warning was NOT issued + ... len(w) == 1 # expected warning was issued + True + >>> config['buffer_size'] == _MCAST_DEFAULT_BUFFER_SIZE # Falls back to default True - >>> config['buffer_size'] # expected failure - undefined or Falls back to default - -1024 # Cleanup >>> os.environ.pop('MULTICAST_BUFFER_SIZE', None) @@ -689,8 +846,8 @@ def load_config() -> dict: ... config = load_config() ... except ValueError: ... print('ValueError raised') - ValueError raised - >>> config is None + >>> # Verify config is not None (load_config should handle the error and use default) + >>> config is not None True # Cleanup @@ -703,9 +860,9 @@ def load_config() -> dict: port = load_port() group = load_group() ttl = load_TTL() + buffer_size = load_buffer_size() groups_str = os.getenv("MULTICAST_GROUPS", "") - bind_addr = os.getenv("MULTICAST_BIND_ADDR", group) # skipcq: PYL-W1508 - buffer_size = int(os.getenv("MULTICAST_BUFFER_SIZE", 1316)) # skipcq: PYL-W1508 + bind_addr = os.getenv("MULTICAST_BIND_ADDR", str(group)) # skipcq: PYL-W1508 # Process and validate groups groups = set() if groups_str: @@ -737,6 +894,7 @@ def load_config() -> dict: """__module__""", """__name__""", """__doc__""", # skipcq: PYL-E0603 + """validate_buffer_size""", """validate_port""", """validate_multicast_address""", """validate_ttl""", diff --git a/multicast/recv.py b/multicast/recv.py index 303dd9e4..5cdeb262 100644 --- a/multicast/recv.py +++ b/multicast/recv.py @@ -310,12 +310,17 @@ def tryrecv(msgbuffer, chunk, sock): Will try to listen on the given socket directly into the given chunk for decoding. If the read into the chunk results in content, the chunk will be decoded and appended - to the caller-instantiated `msgbuffer`, which is a collection of strings (or None). + to the caller-instantiated `msgbuffer`, which is a collection of utf8 strings (or None). After decoding, `chunk` is zeroed for memory efficiency and security. Either way the message buffer will be returned. Tries to receive data without blocking and appends it to the message buffer. + Individual chunk sizes are controlled by the module attribute `_MCAST_DEFAULT_BUFFER_SIZE` set + at module's load-time. It is possible to override the buffer size via the environment variable + "MULTICAST_BUFFER_SIZE" if available at load-time. However changing the value is not recommended + unless absolutely needed, and can be done on the sender side too. + Args: msgbuffer (list or None): Caller-instantiated collection to store received messages. chunk (variable or None): Caller-instantiated variable for raw received data. @@ -381,7 +386,7 @@ def tryrecv(msgbuffer, chunk, sock): >>> """ - chunk = sock.recv(1316) + chunk = sock.recv(multicast._MCAST_DEFAULT_BUFFER_SIZE) # skipcq: PYL-W0212 - module ok if not (chunk is None): # pragma: no branch msgbuffer += str(chunk, encoding='utf8') # pragma: no cover chunk = None # pragma: no cover diff --git a/multicast/send.py b/multicast/send.py index bf249348..3b30e096 100644 --- a/multicast/send.py +++ b/multicast/send.py @@ -354,7 +354,11 @@ def doStep(self, *args, **kwargs): # Read from stdin in chunks while True: try: - chunk = sys.stdin.read(1316) # Read 1316 bytes at a time - matches read size + # Read configured amount of bytes at a time - matches read size by default + # skipcq: PYL-W0212 + chunk = sys.stdin.read( + multicast._MCAST_DEFAULT_BUFFER_SIZE, # skipcq: PYL-W0212 - module ok + ) except IOError as e: print(f"Error reading from stdin: {e}", file=sys.stderr) break diff --git a/tests/check_spelling b/tests/check_spelling index 230b5139..c946b129 100755 --- a/tests/check_spelling +++ b/tests/check_spelling @@ -145,6 +145,8 @@ declare -a SPECIFIC_TYPOS=( "concatination:concatenation" # from #330 "imperitive:imperative" # from #330 "sentance:sentence" # from #330 + "reccomended:recommended" # from #348 + "absolutly:absolutely" # from #348 ) function cleanup() {