Skip to content

Improvements to Multicast env.py #348

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

56 changes: 54 additions & 2 deletions multicast/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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""",
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
180 changes: 169 additions & 11 deletions multicast/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -737,6 +894,7 @@ def load_config() -> dict:
"""__module__""",
"""__name__""",
"""__doc__""", # skipcq: PYL-E0603
"""validate_buffer_size""",
"""validate_port""",
"""validate_multicast_address""",
"""validate_ttl""",
Expand Down
9 changes: 7 additions & 2 deletions multicast/recv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion multicast/send.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tests/check_spelling
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading