diff --git a/CHANGELOG.md b/CHANGELOG.md index 1418d3e..c22d009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,38 @@ #
Changelog
+ + +## 25.01.2026 `v1.9.5` + +* Add new class property `Console.encoding`, which returns the encoding used by the console (*e.g.* `utf-8`*,* `cp1252`*, …*). +* Add multiple new class properties to the `System` class: + - `is_linux` Whether the current OS is Linux or not. + - `is_mac` Whether the current OS is macOS or not. + - `is_unix` Whether the current OS is a Unix-like OS (Linux, macOS, BSD, …) or not. + - `hostname` The network hostname of the current machine. + - `username` The current user's username. + - `os_name` The name of the operating system (*e.g.* `Windows`*,* `Linux`*, …*). + - `os_version` The version of the operating system. + - `architecture` The CPU architecture (*e.g.* `x86_64`*,* `ARM`*, …*). + - `cpu_count` The number of CPU cores available. + - `python_version` The Python version string (*e.g.* `3.10.4`). +* Created a two new TypeAliases: + - `ArgParseConfig` Matches the command-line-parsing configuration of a single argument. + - `ArgParseConfigs` Matches the command-line-parsing configurations of multiple arguments, packed in a dictionary. +* Added a new attribute `flag` to the `ArgData` TypedDict and the `ArgResult` class, which contains the specific flag that was found or `None` for positional args. + +**BREAKING CHANGES:** +* Rewrote `Console.get_args()` for a different parsing functionality: + - Flagged values are now too saved to lists, so now only the `values` attribute is used for all argument types. + - The results of parsed command-line arguments are also no longer differentiated between regular flagged arguments and positional `"before"`/`"after"` arguments. + - The param `allow_spaces` was removed, and therefore a new param `flag_value_sep` was added, which specifies the character/s used to separate flags from their values.
+ This means, flags can new **only** receive values when the separator is present (*e.g.* `--flag=value` *or* `--flag = value`). +* Combined the custom TypedDict classes `ArgResultRegular` and `ArgResultPositional` into a single TypedDict class `ArgData`, which is now used for all parsed command-line arguments. +* Renamed the classes `Args` and `ArgResult` to `ParsedArgs` and `ParsedArgData`, to better describe their purpose. +* Renamed the attribute `is_positional` to `is_pos` everywhere, so its name isn't that long. + + ## 06.01.2026 `v1.9.4` diff --git a/pyproject.toml b/pyproject.toml index cd43574..28acabb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ build-backend = "setuptools.build_meta" [project] name = "xulbux" -version = "1.9.4" +version = "1.9.5" description = "A Python library to simplify common programming tasks." readme = "README.md" authors = [{ name = "XulbuX", email = "xulbux.real@gmail.com" }] diff --git a/src/xulbux/__init__.py b/src/xulbux/__init__.py index 2fd5608..780b1c3 100644 --- a/src/xulbux/__init__.py +++ b/src/xulbux/__init__.py @@ -1,5 +1,5 @@ __package_name__ = "xulbux" -__version__ = "1.9.4" +__version__ = "1.9.5" __description__ = "A Python library to simplify common programming tasks." __status__ = "Production/Stable" diff --git a/src/xulbux/base/types.py b/src/xulbux/base/types.py index e61a84f..abd1562 100644 --- a/src/xulbux/base/types.py +++ b/src/xulbux/base/types.py @@ -2,7 +2,7 @@ This module contains all custom type definitions used throughout the library. """ -from typing import TYPE_CHECKING, Annotated, TypeAlias, TypedDict, Optional, Protocol, Union, Any +from typing import TYPE_CHECKING, Annotated, TypeAlias, TypedDict, Optional, Protocol, Literal, Union, Any from pathlib import Path # PREVENT CIRCULAR IMPORTS @@ -70,6 +70,11 @@ AnyHexa: TypeAlias = Any """Generic type alias for hexadecimal color values in any supported format (type checking disabled).""" +ArgParseConfig: TypeAlias = Union[set[str], "ArgConfigWithDefault", Literal["before", "after"]] +"""Matches the command-line-parsing configuration of a single argument.""" +ArgParseConfigs: TypeAlias = dict[str, ArgParseConfig] +"""Matches the command-line-parsing configurations of multiple arguments, packed in a dictionary.""" + # ################################################## Sentinel ################################################## @@ -83,21 +88,17 @@ class AllTextChars: class ArgConfigWithDefault(TypedDict): - """Configuration schema for a flagged CLI argument that has a specified default value.""" + """Configuration schema for a flagged command-line argument that has a specified default value.""" flags: set[str] default: str -class ArgResultRegular(TypedDict): - """Result schema for a parsed regular flagged CLI argument.""" - exists: bool - value: Optional[str] - - -class ArgResultPositional(TypedDict): - """Result schema for parsed positional (`"before"`/`"after"`) CLI arguments.""" +class ArgData(TypedDict): + """Schema for the resulting data of parsing a single command-line argument.""" exists: bool + is_pos: bool values: list[str] + flag: Optional[str] class MissingLibsMsgs(TypedDict): diff --git a/src/xulbux/console.py b/src/xulbux/console.py index b37c76f..44f898c 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -3,7 +3,7 @@ which offer methods for logging and other actions within the console. """ -from .base.types import ArgConfigWithDefault, ArgResultRegular, ArgResultPositional, ProgressUpdater, AllTextChars, Rgba, Hexa +from .base.types import ProgressUpdater, AllTextChars, ArgParseConfigs, ArgParseConfig, ArgData, Rgba, Hexa from .base.decorators import mypyc_attr from .base.consts import COLOR, CHARS, ANSI @@ -47,105 +47,83 @@ ) -class ArgResult: - """Represents the result of a parsed command-line argument, containing the following attributes: - - `exists` -⠀if the argument was found or not - - `value` -⠀the flagged argument value or `None` if no value was provided - - `values` -⠀the list of values for positional `"before"`/`"after"` arguments - - `is_positional` -⠀whether the argument is a positional `"before"`/`"after"` argument or not\n - -------------------------------------------------------------------------------------------------------- - When the `ArgResult` instance is accessed as a boolean it will correspond to the `exists` attribute.""" - - def __init__(self, exists: bool, value: Optional[str] = None, values: list[str] = [], is_positional: bool = False): - if value is not None and len(values) > 0: - raise ValueError("The 'value' and 'values' parameters are mutually exclusive. Only one can be set.") - if is_positional and value is not None: - raise ValueError("Positional arguments cannot have a single 'value'. Use 'values' for positional arguments.") +class ParsedArgData: + """Represents the result of a parsed command-line argument, containing the attributes listed below.\n + ------------------------------------------------------------------------------------------------------------ + - `exists` - whether the argument was found in the command-line arguments or not + - `is_pos` - whether the argument is a positional `"before"`/`"after"` argument or not + - `values` - the list of values associated with the argument + - `flag` - the specific flag that was found (e.g. `-v`, `-vv`, `-vvv`), or `None` for positional args\n + ------------------------------------------------------------------------------------------------------------ + When the `ParsedArgData` instance is accessed as a boolean it will correspond to the `exists` attribute.""" + def __init__(self, exists: bool, values: list[str], is_pos: bool, flag: Optional[str] = None): self.exists: bool = exists """Whether the argument was found or not.""" - self.value: Optional[str] = value - """The flagged argument value or `None` if no value was provided.""" - self.values: list[str] = values - """The list of positional `"before"`/`"after"` argument values.""" - self.is_positional: bool = is_positional + self.is_pos: bool = is_pos """Whether the argument is a positional argument or not.""" + self.values: list[str] = values + """The list of values associated with the argument.""" + self.flag: Optional[str] = flag + """The specific flag that was found (e.g. `-v`, `-vv`, `-vvv`), or `None` for positional args.""" def __bool__(self) -> bool: """Whether the argument was found or not (i.e. the `exists` attribute).""" return self.exists def __eq__(self, other: object) -> bool: - """Check if two `ArgResult` objects are equal by comparing their attributes.""" - if not isinstance(other, ArgResult): + """Check if two `ParsedArgData` objects are equal by comparing their attributes.""" + if not isinstance(other, ParsedArgData): return False return ( self.exists == other.exists \ - and self.value == other.value + and self.is_pos == other.is_pos and self.values == other.values - and self.is_positional == other.is_positional + and self.flag == other.flag ) def __ne__(self, other: object) -> bool: - """Check if two `ArgResult` objects are not equal by comparing their attributes.""" + """Check if two `ParsedArgData` objects are not equal by comparing their attributes.""" return not self.__eq__(other) def __repr__(self) -> str: - if self.is_positional: - return f"ArgResult(\n exists = {self.exists},\n values = {self.values},\n is_positional = {self.is_positional}\n)" - else: - return f"ArgResult(\n exists = {self.exists},\n value = {self.value},\n is_positional = {self.is_positional}\n)" + return f"ParsedArgData(\n exists = {self.exists!r},\n is_pos = {self.is_pos!r},\n values = {self.values!r},\n flag = {self.flag!r}\n)" def __str__(self) -> str: return self.__repr__() - def dict(self) -> ArgResultRegular | ArgResultPositional: + def dict(self) -> ArgData: """Returns the argument result as a dictionary.""" - if self.is_positional: - return ArgResultPositional(exists=self.exists, values=self.values) - else: - return ArgResultRegular(exists=self.exists, value=self.value) + return ArgData(exists=self.exists, is_pos=self.is_pos, values=self.values, flag=self.flag) @mypyc_attr(native_class=False) -class Args: +class ParsedArgs: """Container for parsed command-line arguments, allowing attribute-style access.\n - ---------------------------------------------------------------------------------------- - - `**kwargs` -⠀a mapping of argument aliases to their corresponding data dictionaries\n - ---------------------------------------------------------------------------------------- + ----------------------------------------------------------------------------------- + - `**parsed_args` -⠀a mapping of argument aliases to their corresponding data + saved in an `ParsedArgData` object\n + ----------------------------------------------------------------------------------- For example, if an argument `foo` was parsed, it can be accessed via `args.foo`. - Each such attribute (e.g. `args.foo`) is an instance of `ArgResult`.""" - - def __init__(self, **kwargs: ArgResultRegular | ArgResultPositional): - for alias_name, data_dict in kwargs.items(): - if "values" in data_dict: - data_dict = cast(ArgResultPositional, data_dict) - setattr( - self, - alias_name, - ArgResult(exists=data_dict["exists"], values=data_dict["values"], is_positional=True), - ) - else: - data_dict = cast(ArgResultRegular, data_dict) - setattr( - self, - alias_name, - ArgResult(exists=data_dict["exists"], value=data_dict["value"], is_positional=False), - ) + Each such attribute (e.g. `args.foo`) is an instance of `ParsedArgData`.""" + + def __init__(self, **parsed_args: ParsedArgData): + for alias_name, parsed_arg_data in parsed_args.items(): + setattr(self, alias_name, parsed_arg_data) def __len__(self): - """The number of arguments stored in the `Args` object.""" + """The number of arguments stored in the `ParsedArgs` object.""" return len(vars(self)) def __contains__(self, key): - """Checks if an argument with the given alias exists in the `Args` object.""" + """Checks if an argument with the given alias exists in the `ParsedArgs` object.""" return key in vars(self) def __bool__(self) -> bool: - """Whether the `Args` object contains any arguments.""" + """Whether the `ParsedArgs` object contains any arguments.""" return len(self) > 0 - def __getattr__(self, name: str) -> ArgResult: + def __getattr__(self, name: str) -> ParsedArgData: raise AttributeError(f"'{type(self).__name__}' object has no attribute {name}") def __getitem__(self, key): @@ -153,24 +131,24 @@ def __getitem__(self, key): return list(self.__iter__())[key] return getattr(self, key) - def __iter__(self) -> Generator[tuple[str, ArgResult], None, None]: - for key, val in cast(dict[str, ArgResult], vars(self)).items(): + def __iter__(self) -> Generator[tuple[str, ParsedArgData], None, None]: + for key, val in cast(dict[str, ParsedArgData], vars(self)).items(): yield (key, val) def __eq__(self, other: object) -> bool: - """Check if two `Args` objects are equal by comparing their stored arguments.""" - if not isinstance(other, Args): + """Check if two `ParsedArgs` objects are equal by comparing their stored arguments.""" + if not isinstance(other, ParsedArgs): return False return vars(self) == vars(other) def __ne__(self, other: object) -> bool: - """Check if two `Args` objects are not equal by comparing their stored arguments.""" + """Check if two `ParsedArgs` objects are not equal by comparing their stored arguments.""" return not self.__eq__(other) def __repr__(self) -> str: if not self: - return "Args()" - return "Args(\n " + ",\n ".join( + return "ParsedArgs()" + return "ParsedArgs(\n " + ",\n ".join( f"{key} = " + "\n ".join(repr(val).splitlines()) \ for key, val in self.__iter__() ) + "\n)" @@ -178,17 +156,11 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.__repr__() - def dict(self) -> dict[str, ArgResultRegular | ArgResultPositional]: + def dict(self) -> dict[str, ArgData]: """Returns the arguments as a dictionary.""" - result: dict[str, ArgResultRegular | ArgResultPositional] = {} - for key, val in vars(self).items(): - if val.is_positional: - result[key] = ArgResultPositional(exists=val.exists, values=val.values) - else: - result[key] = ArgResultRegular(exists=val.exists, value=val.value) - return result + return {key: val.dict() for key, val in self.__iter__()} - def get(self, key: str, default: Any = None) -> ArgResult | Any: + def get(self, key: str, default: Any = None) -> ParsedArgData | Any: """Returns the argument result for the given alias, or `default` if not found.""" return getattr(self, key, default) @@ -200,19 +172,19 @@ def values(self): """Returns the argument results as `dict_values([…])`.""" return vars(self).values() - def items(self) -> Generator[tuple[str, ArgResult], None, None]: - """Yields tuples of `(alias, ArgResult)`.""" + def items(self) -> Generator[tuple[str, ParsedArgData], None, None]: + """Yields tuples of `(alias, ParsedArgData)`.""" for key, val in self.__iter__(): yield (key, val) - def existing(self) -> Generator[tuple[str, ArgResult], None, None]: - """Yields tuples of `(alias, ArgResult)` for existing arguments only.""" + def existing(self) -> Generator[tuple[str, ParsedArgData], None, None]: + """Yields tuples of `(alias, ParsedArgData)` for existing arguments only.""" for key, val in self.__iter__(): if val.exists: yield (key, val) - def missing(self) -> Generator[tuple[str, ArgResult], None, None]: - """Yields tuples of `(alias, ArgResult)` for missing arguments only.""" + def missing(self) -> Generator[tuple[str, ParsedArgData], None, None]: + """Yields tuples of `(alias, ParsedArgData)` for missing arguments only.""" for key, val in self.__iter__(): if not val.exists: yield (key, val) @@ -256,6 +228,15 @@ def is_tty(cls) -> bool: """Whether the current output is a terminal/console or not.""" return _sys.stdout.isatty() + @property + def encoding(cls) -> str: + """The encoding used by the console (e.g. `utf-8`, `cp1252`, …).""" + try: + encoding = _sys.stdout.encoding + return encoding if encoding is not None else "utf-8" + except (AttributeError, Exception): + return "utf-8" + @property def supports_color(cls) -> bool: """Whether the terminal supports ANSI color codes or not.""" @@ -279,81 +260,68 @@ class Console(metaclass=_ConsoleMeta): """This class provides methods for logging and other actions within the console.""" @classmethod - def get_args( - cls, - allow_spaces: bool = False, - **find_args: set[str] | ArgConfigWithDefault | Literal["before", "after"], - ) -> Args: - """Will search for the specified arguments in the command line - arguments and return the results as a special `Args` object.\n - --------------------------------------------------------------------------------------------------------- - - `allow_spaces` -⠀if true, flagged argument values can span multiple space-separated tokens until the - next flag is encountered, otherwise only the immediate next token is captured as the value:
- This allows passing multi-word values without quotes - (e.g. `-f hello world` instead of `-f "hello world"`).
- * This setting does not affect `"before"`/`"after"` positional arguments, - which always treat each token separately.
- * When `allow_spaces=True`, positional `"after"` arguments will always be empty if any flags - are present, as all tokens following the last flag are consumed as that flag's value. - - `**find_args` -⠀kwargs defining the argument aliases and their flags/configuration (explained below)\n - --------------------------------------------------------------------------------------------------------- - The `**find_args` keyword arguments can have the following structures for each alias: + def get_args(cls, arg_parse_configs: ArgParseConfigs, flag_value_sep: str = "=") -> ParsedArgs: + """Will search for the specified args in the command-line arguments + and return the results as a special `ParsedArgs` object.\n + ------------------------------------------------------------------------------------------------- + - `arg_parse_configs` - a dictionary where each key is an alias name for the argument + and the key's value is the parsing configuration for that argument + - `flag_value_sep` - the character/s used to separate flags from their values\n + ------------------------------------------------------------------------------------------------- + The `arg_parse_configs` dictionary can have the following structures for each item: 1. Simple set of flags (when no default value is needed): ```python - alias_name={"-f", "--flag"} + "alias_name": {"-f", "--flag"} ``` - 2. Dictionary with `"flags"` and `"default"` value: + 2. Dictionary with the`"flags"` set, plus a specified `"default"` value: ```python - alias_name={ + "alias_name": { "flags": {"-f", "--flag"}, "default": "some_value", } ``` - 3. Positional argument collection using the literals `"before"` or `"after"`: + 3. Positional value collection using the literals `"before"` or `"after"`: ```python - # COLLECT ALL NON-FLAGGED ARGUMENTS THAT APPEAR BEFORE THE FIRST FLAG - alias_name="before" - # COLLECT ALL NON-FLAGGED ARGUMENTS THAT APPEAR AFTER THE LAST FLAG'S VALUE - alias_name="after" + # COLLECT ALL NON-FLAGGED VALUES THAT APPEAR BEFORE THE FIRST FLAG + "alias_name": "before" + # COLLECT ALL NON-FLAGGED VALUES THAT APPEAR AFTER THE LAST FLAG'S VALUE + "alias_name": "after" ``` #### Example usage: + If you call the `get_args()` method in your script like this: ```python - Args( - # FOUND TWO POSITIONAL ARGUMENTS BEFORE THE FIRST FLAG - text = ArgResult(exists=True, values=["Hello", "World"]), - # FOUND ONE OF THE SPECIFIED FLAGS WITH A VALUE - arg1 = ArgResult(exists=True, value="value1"), - # FOUND ONE OF THE SPECIFIED FLAGS WITHOUT A VALUE - arg2 = ArgResult(exists=True, value=None), - # DIDN'T FIND ANY OF THE SPECIFIED FLAGS BUT HAS A DEFAULT VALUE - arg3 = ArgResult(exists=False, value="default_val"), - ) + parsed_args = Console.get_args({ + "text_before": "before", # POSITIONAL VALUES BEFORE FIRST FLAG + "arg1": {"-A", "--arg1"}, # NORMAL FLAGS + "arg2": { # FLAGS WITH SPECIFIED DEFAULT VALUE + "flags": {"-B", "--arg2"}, + "default": "default value" + }, + "text_after": "after", # POSITIONAL VALUES AFTER LAST FLAG'S VALUE + }) ``` - If the script is called via the command line:\n - `python script.py Hello World -a1 "value1" --arg2`\n - … it would return an `Args` object: + … and execute the script via the command line like this:\n + `$ python script.py "Hello" "World" --arg1=42 "Goodbye"`\n + … the `get_args()` method would return a `ParsedArgs` object with the following structure: ```python - Args( - # FOUND TWO ARGUMENTS BEFORE THE FIRST FLAG - text = ArgResult(exists=True, values=["Hello", "World"]), - # FOUND ONE OF THE SPECIFIED FLAGS WITH A FOLLOWING VALUE - arg1 = ArgResult(exists=True, value="value1"), - # FOUND ONE OF THE SPECIFIED FLAGS BUT NO FOLLOWING VALUE - arg2 = ArgResult(exists=True, value=None), - # DIDN'T FIND ANY OF THE SPECIFIED FLAGS AND HAS NO DEFAULT VALUE - arg3 = ArgResult(exists=False, value="default_val"), + ParsedArgs( + # FOUND 2 VALUES BEFORE THE FIRST FLAG + text_before = ParsedArgData(exists=True, is_pos=True, values=["Hello", "World"], flag=None), + # FOUND ONE OF THE SPECIFIED FLAGS WITH A VALUE + arg1 = ParsedArgData(exists=True, is_pos=False, values=["42"], flag="--arg1"), + # DIDN'T FIND ANY OF THE SPECIFIED FLAGS, USED THE DEFAULT VALUE + arg2 = ParsedArgData(exists=False, is_pos=False, values=["default value"], flag=None), + # FOUND 1 VALUE AFTER THE LAST FLAG'S VALUE + text_after = ParsedArgData(exists=True, is_pos=True, values=["Goodbye"], flag=None), ) ``` - --------------------------------------------------------------------------------------------------------- - If an arg, defined with flags in `find_args`, is NOT present in the command line: - - `exists` will be `False` - - `value` will be the specified `"default"` value, or `None` if no default was specified - - `values` will be an empty list `[]`\n - --------------------------------------------------------------------------------------------------------- - Normally if `allow_spaces` is false, it will take a space as the end of an args value. - If it is true, it will take spaces as part of the value up until the next arg-flag is found. - (Multiple spaces will become one space in the value.)""" - return _ConsoleArgsParseHelper(allow_spaces=allow_spaces, find_args=find_args)() + ------------------------------------------------------------------------------------------------- + NOTE: Flags can ONLY receive values when the separator is present + (e.g. `--flag=value` or `--flag = value`).""" + if not flag_value_sep: + raise ValueError("The 'flag_value_sep' parameter must be a non-empty string.") + + return _ConsoleArgsParseHelper(arg_parse_configs, flag_value_sep)() @classmethod def pause_exit( @@ -1071,71 +1039,38 @@ def _multiline_input_submit(event: KeyPressEvent) -> None: class _ConsoleArgsParseHelper: """Internal, callable helper class to parse command-line arguments.""" - def __init__( - self, - allow_spaces: bool, - find_args: dict[str, set[str] | ArgConfigWithDefault | Literal["before", "after"]], - ): - self.allow_spaces = allow_spaces - self.find_args = find_args + def __init__(self, arg_parse_configs: ArgParseConfigs, flag_value_sep: str): + self.arg_parse_configs = arg_parse_configs + self.flag_value_sep = flag_value_sep - self.results_positional: dict[str, ArgResultPositional] = {} - self.results_regular: dict[str, ArgResultRegular] = {} + self.parsed_args: dict[str, ParsedArgData] = {} self.positional_configs: dict[str, str] = {} self.arg_lookup: dict[str, str] = {} self.args = _sys.argv[1:] self.args_len = len(self.args) + self.pos_before_configured = False + self.pos_after_configured = False self.first_flag_pos: Optional[int] = None - self.last_flag_with_value_pos: Optional[int] = None + self.last_flag_pos: Optional[int] = None - def __call__(self) -> Args: - self.parse_configuration() + def __call__(self) -> ParsedArgs: + self.parse_arg_configs() self.find_flag_positions() - self.process_positional_args() self.process_flagged_args() + self.process_positional_args() - return Args(**self.results_positional, **self.results_regular) - - def parse_configuration(self) -> None: - """Parse the `find_args` configuration and build lookup structures.""" - before_count, after_count = 0, 0 - - for alias, config in self.find_args.items(): - flags: Optional[set[str]] = None - default_value: Optional[str] = None - - if isinstance(config, str): - # HANDLE POSITIONAL ARGUMENT COLLECTION - if config == "before": - before_count += 1 - if before_count > 1: - raise ValueError("Only one alias can have the value 'before' for positional argument collection.") - elif config == "after": - after_count += 1 - if after_count > 1: - raise ValueError("Only one alias can have the value 'after' for positional argument collection.") - else: - raise ValueError( - f"Invalid positional argument type '{config}' for alias '{alias}'.\n" - "Must be either 'before' or 'after'." - ) - self.positional_configs[alias] = config - self.results_positional[alias] = {"exists": False, "values": []} - elif isinstance(config, set): - flags = config - self.results_regular[alias] = {"exists": False, "value": default_value} - elif isinstance(config, dict): - flags, default_value = config.get("flags"), config.get("default") - self.results_regular[alias] = {"exists": False, "value": default_value} - else: - raise TypeError( - f"Invalid configuration type for alias '{alias}'.\n" - "Must be a set, dict, literal 'before' or literal 'after'." - ) + return ParsedArgs(**self.parsed_args) + + def parse_arg_configs(self) -> None: + """Parse the `arg_parse_configs` configuration and build lookup structures.""" + for alias, config in self.arg_parse_configs.items(): + if not alias.isidentifier(): + raise ValueError(f"Invalid argument alias '{alias}'.\n" + "Aliases must be valid Python identifiers.") - # BUILD FLAG LOOKUP FOR NON-POSITIONAL ARGUMENTS - if flags is not None: + # PARSE ARG CONFIG & BUILD FLAG LOOKUP FOR NON-POSITIONAL ARGS + if (flags := self._parse_arg_config(alias, config)) is not None: for flag in flags: if flag in self.arg_lookup: raise ValueError( @@ -1143,28 +1078,91 @@ def parse_configuration(self) -> None: ) self.arg_lookup[flag] = alias + def _parse_arg_config(self, alias: str, config: ArgParseConfig) -> Optional[set[str]]: + """Parse an individual argument configuration.""" + # POSITIONAL ARGUMENT CONFIGURATION + if isinstance(config, str): + if config == "before": + if self.pos_before_configured: + raise ValueError("Only one alias can use the value 'before' for positional argument collection.") + self.pos_before_configured = True + elif config == "after": + if self.pos_after_configured: + raise ValueError("Only one alias can use the value 'after' for positional argument collection.") + self.pos_after_configured = True + else: + raise ValueError( + f"Invalid positional argument type '{config}' under alias '{alias}'.\n" + "Must be either 'before' or 'after'." + ) + self.positional_configs[alias] = config + self.parsed_args[alias] = ParsedArgData(exists=False, values=[], is_pos=True) + return None # NO FLAGS TO RETURN FOR POSITIONAL ARGS + + # NORMAL SET OF FLAGS + elif isinstance(config, set): + if not config: + raise ValueError( + f"The flag set under alias '{alias}' is empty.\n" + "The set must contain at least one flag to search for." + ) + self.parsed_args[alias] = ParsedArgData(exists=False, values=[], is_pos=False) + return config + + # SET OF FLAGS WITH SPECIFIED DEFAULT VALUE + elif isinstance(config, dict): + if not config.get("flags"): + raise ValueError( + f"No flags provided under alias '{alias}'.\n" + "The 'flags'-key set must contain at least one flag to search for." + ) + self.parsed_args[alias] = ParsedArgData( + exists=False, + values=[default] if (default := config.get("default")) is not None else [], + is_pos=False, + ) + return config["flags"] + + else: + raise TypeError( + f"Invalid configuration type under alias '{alias}'.\n" + "Must be a set, dict, literal 'before' or literal 'after'." + ) + def find_flag_positions(self) -> None: """Find positions of first and last flags for positional argument collection.""" - for i, arg in enumerate(self.args): + i = 0 + while i < self.args_len: + arg = self.args[i] + + # CHECK FOR FLAG WITH INLINE SEPARATOR ('--flag=value') + if self.flag_value_sep in arg: + if arg.split(self.flag_value_sep, 1)[0].strip() in self.arg_lookup: + if self.first_flag_pos is None: + self.first_flag_pos = i + self.last_flag_pos = i + i += 1 + continue + + # CHECK FOR STANDALONE FLAG if arg in self.arg_lookup: if self.first_flag_pos is None: self.first_flag_pos = i + self.last_flag_pos = i - # CHECK IF THIS FLAG HAS A VALUE FOLLOWING IT - if i + 1 < self.args_len and self.args[i + 1] not in self.arg_lookup: - if not self.allow_spaces: - self.last_flag_with_value_pos = i + 1 - + # CHECK FOR SEPARATOR IN NEXT TOKENS ('--flag', '=', 'value') + if i + 1 < self.args_len and self.args[i + 1] == self.flag_value_sep: + if i + 2 < self.args_len: + i += 3 # SKIP FLAG, SEPARATOR, AND VALUE + continue else: - # FIND THE END OF THE MULTI-WORD VALUE - j = i + 1 - while j < self.args_len and self.args[j] not in self.arg_lookup: - j += 1 + i += 2 # SKIP FLAG AND SEPARATOR + continue - self.last_flag_with_value_pos = j - 1 + i += 1 def process_positional_args(self) -> None: - """Collect positional `"before"/"after"` arguments.""" + """Collect positional `"before"`/`"after"` arguments.""" for alias, pos_type in self.positional_configs.items(): if pos_type == "before": self._collect_before_arg(alias) @@ -1182,68 +1180,88 @@ def _collect_before_arg(self, alias: str) -> None: end_pos: int = self.first_flag_pos if self.first_flag_pos is not None else self.args_len for i in range(end_pos): - if self.args[i] not in self.arg_lookup: - before_args.append(self.args[i]) + if self._is_positional_arg(arg := self.args[i], allow_separator=False): + before_args.append(arg) if before_args: - self.results_positional[alias]["values"] = before_args - self.results_positional[alias]["exists"] = len(before_args) > 0 + self.parsed_args[alias].values = before_args + self.parsed_args[alias].exists = len(before_args) > 0 def _collect_after_arg(self, alias: str) -> None: + """Collect positional `"after"` arguments.""" after_args: list[str] = [] - start_pos: int = (self.last_flag_with_value_pos + 1) if self.last_flag_with_value_pos is not None else 0 - - # IF NO FLAGS WERE FOUND WITH VALUES, START AFTER THE LAST FLAG - if self.last_flag_with_value_pos is None and self.first_flag_pos is not None: - # FIND THE LAST FLAG POSITION - last_flag_pos: Optional[int] = None - for i, arg in enumerate(self.args): - if arg in self.arg_lookup: - last_flag_pos = i - - if last_flag_pos is not None: - start_pos = last_flag_pos + 1 + start_pos: int = (self.last_flag_pos + 1) if self.last_flag_pos is not None else 0 + + # SKIP THE VALUE AFTER THE LAST FLAG IF IT HAS A SEPARATOR + if self.last_flag_pos is not None: + # CHECK IF LAST FLAG HAS INLINE VALUE ('--flag=value') + if self.flag_value_sep in self.args[self.last_flag_pos]: + start_pos = self.last_flag_pos + 1 # VALUE IS INLINE, START AFTER THIS POSITION + # CHECK IF NEXT TOKEN IS SEPARATOR ('--flag', '=', 'value') + elif start_pos < self.args_len and self.args[start_pos].strip() == self.flag_value_sep: + if start_pos + 1 < self.args_len: + start_pos += 2 # SKIP SEPARATOR AND VALUE + else: + start_pos += 1 # SKIP SEPARATOR ONLY + # NO SEPARATOR = FLAG HAS NO VALUE = START COLLECTING FROM NEXT POSITION for i in range(start_pos, self.args_len): - if self.args[i] not in self.arg_lookup: - after_args.append(self.args[i]) + # DON'T INCLUDE FLAGS OR SEPARATORS + if (arg := self.args[i]) == self.flag_value_sep: + continue + elif self._is_positional_arg(arg): + after_args.append(arg) if after_args: - self.results_positional[alias]["values"] = after_args - self.results_positional[alias]["exists"] = len(after_args) > 0 + self.parsed_args[alias].values = after_args + self.parsed_args[alias].exists = len(after_args) > 0 + + def _is_positional_arg(self, arg: str, allow_separator: bool = True) -> bool: + """Check if an argument is positional (not a flag or separator).""" + if self.flag_value_sep in arg and arg.split(self.flag_value_sep, 1)[0].strip() not in self.arg_lookup: + return True + if arg not in self.arg_lookup and (allow_separator or arg != self.flag_value_sep): + return True + return False def process_flagged_args(self) -> None: - """Process normal flagged arguments.""" + """Process flagged arguments.""" i = 0 while i < self.args_len: arg = self.args[i] - if (opt_alias := self.arg_lookup.get(arg)) is not None: - self.results_regular[opt_alias]["exists"] = True - value_found_after_flag: bool = False + # CASE 1: FLAG WITH INLINE SEPARATOR ('--flag=value') + if self.flag_value_sep in arg: + parts = arg.split(self.flag_value_sep, 1) - if i + 1 < self.args_len and self.args[i + 1] not in self.arg_lookup: - if not self.allow_spaces: - self.results_regular[opt_alias]["value"] = self.args[i + 1] - i += 1 - value_found_after_flag = True + if (potential_flag := (parts := arg.split(self.flag_value_sep, 1))[0].strip()) in self.arg_lookup: + alias = self.arg_lookup[potential_flag] + self.parsed_args[alias].exists = True + self.parsed_args[alias].flag = potential_flag - else: - value_parts = [] - - j = i + 1 - while j < self.args_len and self.args[j] not in self.arg_lookup: - value_parts.append(self.args[j]) - j += 1 + if len(parts) > 1 and (val := parts[1].strip()): + self.parsed_args[alias].values = [val] - if value_parts: - self.results_regular[opt_alias]["value"] = " ".join(value_parts) - i = j - 1 - value_found_after_flag = True + i += 1 + continue - if not value_found_after_flag: - self.results_regular[opt_alias]["value"] = None + # CASE 2: STANDALONE FLAG + if arg in self.arg_lookup: + alias = self.arg_lookup[arg] + self.parsed_args[alias].exists = True + self.parsed_args[alias].flag = arg + + # CHECK FOR SEPARATOR IN NEXT TOKENS ('--flag', '=', 'value') + if i + 1 < self.args_len and self.args[i + 1].strip() == self.flag_value_sep: + if i + 2 < self.args_len: + if (val := self.args[i + 2]) not in self.arg_lookup and val != self.flag_value_sep: + self.parsed_args[alias].values = [val] + i += 3 + continue + i += 2 + continue + # NO SEPARATOR = JUST A FLAG WITHOUT VALUE i += 1 diff --git a/src/xulbux/system.py b/src/xulbux/system.py index e7ec66a..500dffd 100644 --- a/src/xulbux/system.py +++ b/src/xulbux/system.py @@ -11,8 +11,11 @@ from typing import Optional import subprocess as _subprocess +import multiprocessing as _multiprocessing import platform as _platform import ctypes as _ctypes +import getpass as _getpass +import socket as _socket import time as _time import sys as _sys import os as _os @@ -38,6 +41,72 @@ def is_win(cls) -> bool: """Whether the current operating system is Windows or not.""" return _platform.system() == "Windows" + @property + def is_linux(cls) -> bool: + """Whether the current operating system is Linux or not.""" + return _platform.system() == "Linux" + + @property + def is_mac(cls) -> bool: + """Whether the current operating system is macOS or not.""" + return _platform.system() == "Darwin" + + @property + def is_unix(cls) -> bool: + """Whether the current operating system is a Unix-like OS (Linux, macOS, BSD, …) or not.""" + return _os.name == "posix" + + @property + def hostname(cls) -> str: + """The network hostname of the current machine.""" + try: + return _socket.gethostname() + except Exception: + return "unknown" + + @property + def username(cls) -> str: + """The name of the current user.""" + try: + return _getpass.getuser() + except Exception: + try: + return _os.getlogin() + except Exception: + return "unknown" + + @property + def os_name(cls) -> str: + """The name of the operating system (e.g. `Windows`, `Linux`, …).""" + return _platform.system() + + @property + def os_version(cls) -> str: + """The version of the operating system.""" + try: + return _platform.version() + except Exception: + return "unknown" + + @property + def architecture(cls) -> str: + """The CPU architecture (e.g. `x86_64`, `ARM`, …).""" + return _platform.machine() + + @property + def cpu_count(cls) -> int: + """The number of CPU cores available.""" + try: + count = _multiprocessing.cpu_count() + return count if count is not None else 1 + except (NotImplementedError, AttributeError): + return 1 + + @property + def python_version(cls) -> str: + """The version string of the currently running Python interpreter (e.g. `3.10.4`).""" + return _platform.python_version() + class System(metaclass=_SystemMeta): """This class provides methods to interact with the underlying operating system.""" diff --git a/tests/test_console.py b/tests/test_console.py index ca98cd7..bd9c705 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -1,6 +1,5 @@ -from xulbux.base.types import ArgResultPositional, ArgResultRegular +from xulbux.console import ParsedArgData, ParsedArgs from xulbux.console import Spinner, ProgressBar -from xulbux.console import ArgResult, Args from xulbux.console import Console from xulbux import console @@ -81,497 +80,281 @@ def test_console_is_tty(): assert isinstance(result, bool) +def test_console_encoding(): + encoding = Console.encoding + assert isinstance(encoding, str) + assert encoding != "" + assert encoding.lower() in {"utf-8", "cp1252", "ascii", "latin-1", "iso-8859-1"} or "-" in encoding + + def test_console_supports_color(): result = Console.supports_color assert isinstance(result, bool) @pytest.mark.parametrize( - # CASES WITHOUT SPACES (allow_spaces=False) - "argv, find_args, expected_args_dict", [ - # NO ARGS PROVIDED - ( - ["script.py"], - {"file": {"-f"}, "debug": {"-d"}}, - {"file": {"exists": False, "value": None}, "debug": {"exists": False, "value": None}}, - ), - # SIMPLE FLAG + "argv, arg_parse_configs, expected_parsed_args", [ + # SIMPLE FLAG VALUE THE INCLUDES SPACES ( - ["script.py", "-d"], + ["script.py", "-f=token with spaces", "-d"], {"file": {"-f"}, "debug": {"-d"}}, - {"file": {"exists": False, "value": None}, "debug": {"exists": True, "value": None}}, - ), - # FLAG WITH VALUE - ( - ["script.py", "-f", "test.txt"], - {"file": {"-f"}, "debug": {"-d"}}, - {"file": {"exists": True, "value": "test.txt"}, "debug": {"exists": False, "value": None}}, - ), - # LONG FLAGS WITH VALUE AND FLAG - ( - ["script.py", "--file", "path/to/file", "--debug"], - {"file": {"-f", "--file"}, "debug": {"-d", "--debug"}}, - {"file": {"exists": True, "value": "path/to/file"}, "debug": {"exists": True, "value": None}}, + { + "file": {"exists": True, "is_pos": False, "values": ["token with spaces"], "flag": "-f"}, + "debug": {"exists": True, "is_pos": False, "values": [], "flag": "-d"}, + }, ), - # VALUE WITH SPACES (ONLY FIRST PART DUE TO allow_spaces=False) + # FLAG VALUE PLUS OTHER TOKENS ( - ["script.py", "-t", "text", "with", "spaces"], - {"text": {"-t"}}, - {"text": {"exists": True, "value": "text"}}, + ["script.py", "--msg=hello", "world"], + {"message": {"--msg"}}, + {"message": {"exists": True, "is_pos": False, "values": ["hello"], "flag": "--msg"}}, ), - # UNKNOWN ARG + # VALUE SET IN SINGLE TOKEN FOLLOWED BY SECOND FLAG ( - ["script.py", "-x"], - {"file": {"-f"}}, - {"file": {"exists": False, "value": None}}, + ["script.py", "--msg=this is a message", "--flag"], + {"message": {"--msg"}, "flag": {"--flag"}}, + { + "message": {"exists": True, "is_pos": False, "values": ["this is a message"], "flag": "--msg"}, + "flag": {"exists": True, "is_pos": False, "values": [], "flag": "--flag"}, + }, ), - # TWO FLAGS + # FLAG, SEPARATOR, AND VALUE SPREAD OVER MULTIPLE TOKENS ( - ["script.py", "-f", "-d"], - {"file": {"-f"}, "debug": {"-d"}}, - {"file": {"exists": True, "value": None}, "debug": {"exists": True, "value": None}}, + ["script.py", "--msg", "=", "this is a message"], + {"message": {"--msg"}}, + {"message": {"exists": True, "is_pos": False, "values": ["this is a message"], "flag": "--msg"}}, ), - # CASE SENSITIVE FLAGS + # CASE SENSITIVE FLAGS WITH SPACES ( - ["script.py", "-i", "input.txt", "-I", "ignore"], - {"input": {"-i"}, "ignore": {"-I"}, "help": {"-h"}}, + ["script.py", "-t=this is some text", "-T=THIS IS A TITLE"], + {"text": {"-t"}, "title": {"-T"}}, { - "input": {"exists": True, "value": "input.txt"}, - "ignore": {"exists": True, "value": "ignore"}, - "help": {"exists": False, "value": None}, + "text": {"exists": True, "is_pos": False, "values": ["this is some text"], "flag": "-t"}, + "title": {"exists": True, "is_pos": False, "values": ["THIS IS A TITLE"], "flag": "-T"}, }, ), - # --- CASES WITH DEFAULT VALUES --- - # DEFAULT USED + # --- CASES WITH DEFAULTS --- + # GIVEN FLAG VALUE OVERWRITES DEFAULT ( - ["script.py"], - {"output": {"flags": {"-o"}, "default": "out.txt"}, "verbose": {"-v"}}, - {"output": {"exists": False, "value": "out.txt"}, "verbose": {"exists": False, "value": None}}, + ["script.py", "--msg=given message"], + {"msg": {"flags": {"--msg"}, "default": "no message"}, "other": {"-o"}}, + { + "msg": {"exists": True, "is_pos": False, "values": ["given message"], "flag": "--msg"}, + "other": {"exists": False, "is_pos": False, "values": [], "flag": None}, + }, ), - # VALUE OVERRIDES DEFAULT + # DEFAULT USED WHEN FLAG PRESENT BUT NO VALUE GIVEN ( - ["script.py", "-o", "my_file.log"], - {"output": {"flags": {"-o"}, "default": "out.txt"}, "verbose": {"-v"}}, - {"output": {"exists": True, "value": "my_file.log"}, "verbose": {"exists": False, "value": None}}, + ["script.py", "-o", "--msg"], + {"msg": {"flags": {"--msg"}, "default": "no message"}, "other": {"-o"}}, + { + "msg": {"exists": True, "is_pos": False, "values": ["no message"], "flag": "--msg"}, + "other": {"exists": True, "is_pos": False, "values": [], "flag": "-o"}, + }, ), - # FLAG PRESENCE OVERRIDES DEFAULT (string -> None) + # DEFAULT USED WHEN FLAG ABSENT ( ["script.py", "-o"], - {"output": {"flags": {"-o"}, "default": "out.txt"}, "verbose": {"-v"}}, - {"output": {"exists": True, "value": None}, "verbose": {"exists": False, "value": None}}, - ), - # FLAG PRESENCE OVERRIDES DEFAULT (False -> None) - ( - ["script.py", "-v"], - {"output": {"flags": {"-o"}, "default": "out.txt"}, "verbose": {"flags": {"-v"}, "default": "False"}}, - {"output": {"exists": False, "value": "out.txt"}, "verbose": {"exists": True, "value": None}}, + {"msg": {"flags": {"--msg"}, "default": "no message"}, "other": {"-o"}}, + { + "msg": {"exists": False, "is_pos": False, "values": ["no message"], "flag": None}, + "other": {"exists": True, "is_pos": False, "values": [], "flag": "-o"}, + }, ), - # --- MIXED list/tuple AND dict FORMATS (allow_spaces=False) --- - # DICT VALUE PROVIDED, LIST NOT PROVIDED - ( - ["script.py", "--config", "dev.cfg"], - {"config": {"flags": {"-c", "--config"}, "default": "prod.cfg"}, "log": {"-l"}}, - {"config": {"exists": True, "value": "dev.cfg"}, "log": {"exists": False, "value": None}}, - ), - # LIST FLAG PROVIDED, DICT NOT PROVIDED (USES DEFAULT) + # --- POSITIONAL "before" / "after" SPECIAL CASES --- + # POSITIONAL "before" ( - ["script.py", "-l"], - {"config": {"flags": {"-c", "--config"}, "default": "prod.cfg"}, "log": {"-l"}}, - {"config": {"exists": False, "value": "prod.cfg"}, "log": {"exists": True, "value": None}}, + ["script.py", "arg1", "arg2.1 arg2.2"], + {"before": "before", "file": {"-f"}}, + { + "before": {"exists": True, "is_pos": True, "values": ["arg1", "arg2.1 arg2.2"], "flag": None}, + "file": {"exists": False, "is_pos": False, "values": [], "flag": None}, + }, ), - - # --- 'before' / 'after' SPECIAL CASES --- - # 'before' SPECIAL CASE ( - ["script.py", "arg1", "arg2.1 arg2.2", "-f", "file.txt"], + ["script.py", "arg1", "arg2.1 arg2.2", "-f=file.txt", "arg3"], {"before": "before", "file": {"-f"}}, - {"before": {"exists": True, "values": ["arg1", "arg2.1 arg2.2"]}, "file": {"exists": True, "value": "file.txt"}}, + { + "before": {"exists": True, "is_pos": True, "values": ["arg1", "arg2.1 arg2.2"], "flag": None}, + "file": {"exists": True, "is_pos": False, "values": ["file.txt"], "flag": "-f"}, + }, ), ( - ["script.py", "-f", "file.txt"], + ["script.py", "-f=file.txt", "arg1"], {"before": "before", "file": {"-f"}}, - {"before": {"exists": False, "values": []}, "file": {"exists": True, "value": "file.txt"}}, + { + "before": {"exists": False, "is_pos": True, "values": [], "flag": None}, + "file": {"exists": True, "is_pos": False, "values": ["file.txt"], "flag": "-f"}, + }, ), - # 'after' SPECIAL CASE + # POSITIONAL "after" ( - ["script.py", "-f", "file.txt", "arg1", "arg2.1 arg2.2"], + ["script.py", "arg1", "arg2.1 arg2.2"], {"after": "after", "file": {"-f"}}, - {"after": {"exists": True, "values": ["arg1", "arg2.1 arg2.2"]}, "file": {"exists": True, "value": "file.txt"}}, + { + "file": {"exists": False, "is_pos": False, "values": [], "flag": None}, + "after": {"exists": True, "is_pos": True, "values": ["arg1", "arg2.1 arg2.2"], "flag": None}, + }, ), ( - ["script.py", "-f", "file.txt"], + ["script.py", "arg1", "-f=file.txt", "arg2", "arg3.1 arg3.2"], {"after": "after", "file": {"-f"}}, - {"after": {"exists": False, "values": []}, "file": {"exists": True, "value": "file.txt"}}, - ), - - # --- CUSTOM PREFIX TESTS --- - # COLON AND SLASH PREFIXES - ( - ["script.py", ":config", "dev.json", "/output", "result.txt"], - {"config": {":config"}, "output": {"/output"}}, - {"config": {"exists": True, "value": "dev.json"}, "output": {"exists": True, "value": "result.txt"}}, - ), - # WORD FLAGS WITHOUT PREFIXES - ( - ["script.py", "verbose", "help", "123"], - {"verbose": {"verbose"}, "help": {"help"}, "number": {"-n"}}, { - "verbose": {"exists": True, "value": None}, - "help": {"exists": True, "value": "123"}, - "number": {"exists": False, "value": None}, + "file": {"exists": True, "is_pos": False, "values": ["file.txt"], "flag": "-f"}, + "after": {"exists": True, "is_pos": True, "values": ["arg2", "arg3.1 arg3.2"], "flag": None}, }, ), - # MIXED CUSTOM PREFIXES WITH DEFAULTS - ( - ["script.py", "@user", "admin"], - {"user": {"flags": {"@user"}, "default": "guest"}, "mode": {"flags": {"++mode"}, "default": "normal"}}, - {"user": {"exists": True, "value": "admin"}, "mode": {"exists": False, "value": "normal"}}, - ), - ] -) -def test_get_args_no_spaces(monkeypatch, argv, find_args, expected_args_dict): - monkeypatch.setattr(sys, "argv", argv) - args_result = Console.get_args(allow_spaces=False, **find_args) - assert isinstance(args_result, Args) - assert args_result.dict() == expected_args_dict - for key, expected in expected_args_dict.items(): - assert (key in args_result) is True - assert isinstance(args_result[key], ArgResult) - assert args_result[key].exists == expected["exists"] # type: ignore[cannot-access-attr] - # CHECK IF THIS IS A POSITIONAL ARG (HAS 'values') OR REGULAR ARG (HAS 'value') - if "values" in expected: - assert args_result[key].values == expected["values"] # type: ignore[cannot-access-attr] - else: - assert args_result[key].value == expected["value"] # type: ignore[cannot-access-attr] - assert bool(args_result[key]) == expected["exists"] - assert list(args_result.keys()) == list(expected_args_dict.keys()) - assert [v.exists for v in args_result.values()] == [d["exists"] for d in expected_args_dict.values()] - assert len(args_result) == len(expected_args_dict) - - -@pytest.mark.parametrize( - # CASES WITH SPACES (allow_spaces=True) - "argv, find_args, expected_args_dict", [ - # SIMPLE VALUE WITH SPACES - ( - ["script.py", "-f", "file with spaces", "-d"], - {"file": {"-f"}, "debug": {"-d"}}, - {"file": {"exists": True, "value": "file with spaces"}, "debug": {"exists": True, "value": None}}, - ), - # LONG VALUE WITH SPACES - ( - ["script.py", "--message", "Hello", "world", "how", "are", "you"], - {"message": {"--message"}}, - {"message": {"exists": True, "value": "Hello world how are you"}}, - ), - # VALUE WITH SPACES FOLLOWED BY ANOTHER FLAG - ( - ["script.py", "-m", "this is", "a message", "--flag"], - {"message": {"-m"}, "flag": {"--flag"}}, - {"message": {"exists": True, "value": "this is a message"}, "flag": {"exists": True, "value": None}}, - ), - # VALUE WITH SPACES AT THE END - ( - ["script.py", "-m", "end", "of", "args"], - {"message": {"-m"}}, - {"message": {"exists": True, "value": "end of args"}}, - ), - # CASE SENSITIVE FLAGS WITH SPACES - ( - ["script.py", "-t", "this is", "a test", "-T", "UPPERCASE"], - {"text": {"-t"}, "title": {"-T"}}, - {"text": {"exists": True, "value": "this is a test"}, "title": {"exists": True, "value": "UPPERCASE"}}, - ), - - # --- CASES WITH DEFAULTS (dict FORMAT, allow_spaces=True) --- - # VALUE WITH SPACE OVERRIDES DEFAULT - ( - ["script.py", "--msg", "Default message"], - {"msg": {"flags": {"--msg"}, "default": "No message"}, "other": {"-o"}}, - {"msg": {"exists": True, "value": "Default message"}, "other": {"exists": False, "value": None}}, - ), - # DEFAULT USED WHEN OTHER FLAG PRESENT ( - ["script.py", "-o"], - {"msg": {"flags": {"--msg"}, "default": "No message"}, "other": {"-o"}}, - {"msg": {"exists": False, "value": "No message"}, "other": {"exists": True, "value": None}}, - ), - # FLAG PRESENCE OVERRIDES DEFAULT (str -> True) - # FLAG WITH NO VALUE SHOULD HAVE None AS VALUE - ( - ["script.py", "--msg"], - {"msg": {"flags": {"--msg"}, "default": "No message"}, "other": {"-o"}}, - {"msg": {"exists": True, "value": None}, "other": {"exists": False, "value": None}}, - ), - - # --- MIXED FORMATS WITH SPACES (allow_spaces=True) --- - # LIST VALUE WITH SPACES, dict VALUE PROVIDED - ( - ["script.py", "-f", "input file name", "--mode", "test"], - {"file": {"-f"}, "mode": {"flags": {"--mode"}, "default": "prod"}}, - {"file": {"exists": True, "value": "input file name"}, "mode": {"exists": True, "value": "test"}}, - ), - # LIST VALUE WITH SPACES, dict NOT PROVIDED (USES DEFAULT) - ( - ["script.py", "-f", "another file"], - {"file": {"-f"}, "mode": {"flags": {"--mode"}, "default": "prod"}}, - {"file": {"exists": True, "value": "another file"}, "mode": {"exists": False, "value": "prod"}}, + ["script.py", "arg1", "-f=file.txt"], + {"after": "after", "file": {"-f"}}, + { + "file": {"exists": True, "is_pos": False, "values": ["file.txt"], "flag": "-f"}, + "after": {"exists": False, "is_pos": True, "values": [], "flag": None}, + }, ), - # --- 'before' / 'after' SPECIAL CASES (ARE NOT AFFECTED BY allow_spaces) --- - # 'before' SPECIAL CASE - ( - ["script.py", "arg1", "arg2.1 arg2.2", "-f", "file.txt"], - {"before": "before", "file": {"-f"}}, - {"before": {"exists": True, "values": ["arg1", "arg2.1 arg2.2"]}, "file": {"exists": True, "value": "file.txt"}}, - ), - # 'after' SPECIAL CASE + # --- CUSTOM FLAG PREFIXES --- + # QUESTION MARK AND DOUBLE PLUS PREFIXES ( - ["script.py", "-f", "file.txt", "arg1", "arg2.1 arg2.2"], - {"after": "after", "file": {"-f"}}, - {"after": {"exists": False, "values": []}, "file": {"exists": True, "value": "file.txt arg1 arg2.1 arg2.2"}}, + ["script.py", "?help = show detailed info", "++mode=test"], + {"help": {"?help"}, "mode": {"++mode"}}, + { + "help": {"exists": True, "is_pos": False, "values": ["show detailed info"], "flag": "?help"}, + "mode": {"exists": True, "is_pos": False, "values": ["test"], "flag": "++mode"}, + }, ), + # AT SYMBOL PREFIX WITH POSITIONAL ARGUMENTS ( - ["script.py", "arg1", "arg2.1 arg2.2"], - {"after": "after", "file": {"-f"}}, - {"after": {"exists": True, "values": ["arg1", "arg2.1 arg2.2"]}, "file": {"exists": False, "value": None}}, + ["script.py", "@msg = Hello, world!", "How are you?"], + {"before": "before", "message": {"@msg"}, "after": "after"}, + { + "before": {"exists": False, "is_pos": True, "values": [], "flag": None}, + "message": {"exists": True, "is_pos": False, "values": ["Hello, world!"], "flag": "@msg"}, + "after": {"exists": True, "is_pos": True, "values": ["How are you?"], "flag": None}, + }, ), - # --- CUSTOM PREFIX TESTS WITH SPACES --- - # QUESTION MARK AND DOUBLE PLUS PREFIXES WITH MULTIWORD VALUES + # --- DON'T TREAT VALUES STARTING WITH SPECIFIED FLAG PREFIXES AS FLAGS --- ( - ["script.py", "?help", "show", "detailed", "info", "++mode", "test"], - {"help": {"?help"}, "mode": {"++mode"}}, - {"help": {"exists": True, "value": "show detailed info"}, "mode": {"exists": True, "value": "test"}}, - ), - # AT SYMBOL PREFIX WITH SPACES - ( - ["script.py", "@message", "Hello", "World", "How", "are", "you"], - {"message": {"@message"}}, - {"message": {"exists": True, "value": "Hello World How are you"}}, + ["script.py", "-42", "-d=-256", "--file=--not-a-flag", "--also-no-flag"], + {"before": "before", "data": {"-d"}, "file": {"--file"}, "after": "after"}, + { + "before": {"exists": True, "is_pos": True, "values": ["-42"], "flag": None}, + "data": {"exists": True, "is_pos": False, "values": ["-256"], "flag": "-d"}, + "file": {"exists": True, "is_pos": False, "values": ["--not-a-flag"], "flag": "--file"}, + "after": {"exists": True, "is_pos": True, "values": ["--also-no-flag"], "flag": None}, + }, ), ] ) -def test_get_args_with_spaces(monkeypatch, argv, find_args, expected_args_dict): +def test_get_args(monkeypatch, argv, arg_parse_configs, expected_parsed_args): monkeypatch.setattr(sys, "argv", argv) - args_result = Console.get_args(allow_spaces=True, **find_args) - assert isinstance(args_result, Args) - assert args_result.dict() == expected_args_dict - - -def test_get_args_flag_without_value(monkeypatch): - """Test that flags without values have None as their value, not True.""" - # TEST SINGLE FLAG WITHOUT VALUE AT END OF ARGS - monkeypatch.setattr(sys, "argv", ["script.py", "--verbose"]) - args_result = Console.get_args(verbose={"--verbose"}) - assert args_result.verbose.exists is True - assert args_result.verbose.value is None - assert args_result.verbose.is_positional is False - - # TEST FLAG WITHOUT VALUE FOLLOWED BY ANOTHER FLAG - monkeypatch.setattr(sys, "argv", ["script.py", "--verbose", "--debug"]) - args_result = Console.get_args(verbose={"--verbose"}, debug={"--debug"}) - assert args_result.verbose.exists is True - assert args_result.verbose.value is None - assert args_result.verbose.is_positional is False - assert args_result.debug.exists is True - assert args_result.debug.value is None - assert args_result.debug.is_positional is False - - # TEST FLAG WITH DEFAULT VALUE BUT NO PROVIDED VALUE - monkeypatch.setattr(sys, "argv", ["script.py", "--mode"]) - args_result = Console.get_args(mode={"flags": {"--mode"}, "default": "production"}) - assert args_result.mode.exists is True - assert args_result.mode.value is None - assert args_result.mode.is_positional is False - - -def test_get_args_duplicate_flag(): - with pytest.raises(ValueError, match="Duplicate flag '-f' found. It's assigned to both 'file1' and 'file2'."): - Console.get_args(file1={"-f", "--file1"}, file2={"flags": {"-f", "--file2"}, "default": "..."}) - - with pytest.raises(ValueError, match="Duplicate flag '--long' found. It's assigned to both 'arg1' and 'arg2'."): - Console.get_args(arg1={"flags": {"--long"}, "default": "..."}, arg2={"-a", "--long"}) - - -def test_get_args_dash_values_not_treated_as_flags(monkeypatch): - """Test that values starting with dashes are not treated as flags unless explicitly defined""" - monkeypatch.setattr(sys, "argv", ["script.py", "-v", "-42", "--input", "-3.14"]) - result = Console.get_args(verbose={"-v"}, input={"--input"}) - - assert result.verbose.exists is True - assert result.verbose.value == "-42" - assert result.verbose.values == [] - assert result.verbose.is_positional is False - assert result.verbose.dict() == {"exists": True, "value": "-42"} - - assert result.input.exists is True - assert result.input.value == "-3.14" - assert result.input.values == [] - assert result.input.is_positional is False - assert result.input.dict() == {"exists": True, "value": "-3.14"} - - assert result.dict() == { - "verbose": result.verbose.dict(), - "input": result.input.dict(), - } + args_result = Console.get_args(arg_parse_configs) + assert isinstance(args_result, ParsedArgs) + assert args_result.dict() == expected_parsed_args -def test_get_args_dash_strings_as_values(monkeypatch): - """Test that dash-prefixed strings are treated as values when not defined as flags""" - monkeypatch.setattr(sys, "argv", ["script.py", "-f", "--not-a-flag", "-t", "-another-value"]) - result = Console.get_args(file={"-f"}, text={"-t"}) - - assert result.file.exists is True - assert result.file.value == "--not-a-flag" - assert result.file.values == [] - assert result.file.is_positional is False - assert result.file.dict() == {"exists": True, "value": "--not-a-flag"} - - assert result.text.exists is True - assert result.text.value == "-another-value" - assert result.text.values == [] - assert result.text.is_positional is False - assert result.text.dict() == {"exists": True, "value": "-another-value"} - - assert result.dict() == { - "file": result.file.dict(), - "text": result.text.dict(), - } - - -def test_get_args_positional_with_dashes_before(monkeypatch): - """Test that positional 'before' arguments include dash-prefixed values""" - monkeypatch.setattr(sys, "argv", ["script.py", "-123", "--some-file", "normal", "-v"]) - result = Console.get_args(before_args="before", verbose={"-v"}) - - assert result.before_args.exists is True - assert result.before_args.value is None - assert result.before_args.values == ["-123", "--some-file", "normal"] - assert result.before_args.is_positional is True - assert result.before_args.dict() == {"exists": True, "values": ["-123", "--some-file", "normal"]} - - assert result.verbose.exists is True - assert result.verbose.value is None - assert result.verbose.values == [] - assert result.verbose.is_positional is False - assert result.verbose.dict() == {"exists": True, "value": None} - - assert result.dict() == { - "before_args": result.before_args.dict(), - "verbose": result.verbose.dict(), - } - +def test_get_args_invalid_params(): + with pytest.raises(ValueError, match="Duplicate flag '-f' found. It's assigned to both 'file1' and 'file2'."): + Console.get_args({"file1": {"-f", "--file1"}, "file2": {"flags": {"-f", "--file2"}, "default": "..."}}) -def test_get_args_positional_with_dashes_after(monkeypatch): - """Test that positional 'after' arguments include dash-prefixed values""" - monkeypatch.setattr(sys, "argv", ["script.py", "-v", "value", "-123", "--output-file", "-negative"]) - result = Console.get_args(verbose={"-v"}, after_args="after") + with pytest.raises(ValueError, match="Duplicate flag '--long' found. It's assigned to both 'arg1' and 'arg2'."): + Console.get_args({"arg1": {"flags": {"--long"}, "default": "..."}, "arg2": {"-a", "--long"}}) - assert result.verbose.exists is True - assert result.verbose.value == "value" - assert result.verbose.values == [] - assert result.verbose.is_positional is False - assert result.verbose.dict() == {"exists": True, "value": "value"} + with pytest.raises(ValueError, match="The set must contain at least one flag to search for."): + Console.get_args({"arg": set()}) - assert result.after_args.exists is True - assert result.after_args.value is None - assert result.after_args.values == ["-123", "--output-file", "-negative"] - assert result.after_args.is_positional is True - assert result.after_args.dict() == {"exists": True, "values": ["-123", "--output-file", "-negative"]} + with pytest.raises(ValueError, match="The 'flags'-key set must contain at least one flag to search for."): + Console.get_args({"arg": {"flags": set(), "default": "..."}}) - assert result.dict() == { - "verbose": result.verbose.dict(), - "after_args": result.after_args.dict(), - } + with pytest.raises(ValueError, match="The 'flag_value_sep' parameter must be a non-empty string."): + Console.get_args({"arg": {"-a"}}, flag_value_sep="") -def test_get_args_multiword_with_dashes(monkeypatch): - """Test multiword values with dashes when allow_spaces=True""" - monkeypatch.setattr(sys, "argv", ["script.py", "-m", "start", "-middle", "--end", "-f", "other"]) - result = Console.get_args(allow_spaces=True, message={"-m"}, file={"-f"}) +def test_get_args_custom_sep(monkeypatch): + """Test custom flag-value separator handling""" + monkeypatch.setattr(sys, "argv", ["script.py", "--msg::This is a message", "-d::42"]) + result = Console.get_args({"message": {"--msg"}, "data": {"-d"}}, flag_value_sep="::") assert result.message.exists is True - assert result.message.value == "start -middle --end" - assert result.message.values == [] - assert result.message.is_positional is False - assert result.message.dict() == {"exists": True, "value": "start -middle --end"} + assert result.message.is_pos is False + assert result.message.values == ["This is a message"] + assert result.message.flag == "--msg" - assert result.file.exists is True - assert result.file.value == "other" - assert result.file.values == [] - assert result.file.is_positional is False - assert result.file.dict() == {"exists": True, "value": "other"} + assert result.data.exists is True + assert result.data.is_pos is False + assert result.data.values == ["42"] + assert result.data.flag == "-d" assert result.dict() == { "message": result.message.dict(), - "file": result.file.dict(), + "data": result.data.dict(), } def test_get_args_mixed_dash_scenarios(monkeypatch): """Test complex scenario mixing defined flags with dash-prefixed values""" monkeypatch.setattr( - sys, "argv", [ - "script.py", "before1", "-not-flag", "before2", "-v", "VVV", "-d", "--file", "my-file.txt", "after1", - "-also-not-flag" - ] - ) - result = Console.get_args( - before="before", - verbose={"-v"}, - debug={"-d"}, - file={"--file"}, - after="after", + sys, "argv", \ + ["script.py", "before string", "-42", "-d=256", "--file=my-file.txt", "-vv", "after string", "--also-no-flag"] ) + result = Console.get_args({ + "before": "before", + "data": {"-d", "--data"}, + "file": {"-f", "--file"}, + "verbose": {"-v", "-vv", "-vvv"}, + "help": {"-h", "--help"}, + "after": "after", + }) assert result.before.exists is True - assert result.before.value is None - assert result.before.values == ["before1", "-not-flag", "before2"] - assert result.before.is_positional is True - assert result.before.dict() == {"exists": True, "values": ["before1", "-not-flag", "before2"]} + assert result.before.is_pos is True + assert result.before.values == ["before string", "-42"] + assert result.before.flag is None + + assert result.data.exists is True + assert result.data.is_pos is False + assert result.data.values == ["256"] + assert result.data.flag == "-d" + + assert result.file.exists is True + assert result.file.is_pos is False + assert result.file.values == ["my-file.txt"] + assert result.file.flag == "--file" assert result.verbose.exists is True - assert result.verbose.value == "VVV" + assert result.verbose.is_pos is False assert result.verbose.values == [] - assert result.verbose.is_positional is False - assert result.verbose.dict() == {"exists": True, "value": "VVV"} + assert result.verbose.flag == "-vv" - assert result.debug.exists is True - assert result.debug.value is None - assert result.debug.values == [] - assert result.debug.is_positional is False - assert result.debug.dict() == {"exists": True, "value": None} - - assert result.file.exists is True - assert result.file.value == "my-file.txt" - assert result.file.values == [] - assert result.file.is_positional is False - assert result.file.dict() == {"exists": True, "value": "my-file.txt"} + assert result.help.exists is False + assert result.help.is_pos is False + assert result.help.values == [] + assert result.help.flag is None assert result.after.exists is True - assert result.after.value is None - assert result.after.values == ["after1", "-also-not-flag"] - assert result.after.is_positional is True - assert result.after.dict() == {"exists": True, "values": ["after1", "-also-not-flag"]} + assert result.after.is_pos is True + assert result.after.values == ["after string", "--also-no-flag"] + assert result.after.flag is None assert result.dict() == { "before": result.before.dict(), - "verbose": result.verbose.dict(), - "debug": result.debug.dict(), + "data": result.data.dict(), "file": result.file.dict(), + "verbose": result.verbose.dict(), + "help": result.help.dict(), "after": result.after.dict(), } def test_args_dunder_methods(): - args = Args( - before=ArgResultPositional(exists=True, values=["arg1", "arg2"]), - debug=ArgResultRegular(exists=True, value=None), - file=ArgResultRegular(exists=True, value="test.txt"), - after=ArgResultPositional(exists=False, values=["arg3", "arg4"]), + args = ParsedArgs( + before=ParsedArgData(exists=True, values=["arg1", "arg2"], is_pos=True), + debug=ParsedArgData(exists=True, values=[], is_pos=False), + file=ParsedArgData(exists=True, values=["test.txt"], is_pos=False), + after=ParsedArgData(exists=False, values=["arg3", "arg4"], is_pos=True), ) assert len(args) == 4 @@ -580,10 +363,10 @@ def test_args_dunder_methods(): assert ("missing" in args) is False assert bool(args) is True - assert bool(Args()) is False + assert bool(ParsedArgs()) is False assert (args == args) is True - assert (args != Args()) is True + assert (args != ParsedArgs()) is True def test_multiline_input(mock_prompt_toolkit, capsys): diff --git a/tests/test_system.py b/tests/test_system.py index b311da0..f73d816 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -20,6 +20,72 @@ def test_system_is_win(): assert result == (platform.system() == "Windows") +def test_system_is_linux(): + result = System.is_linux + assert isinstance(result, bool) + assert result == (platform.system() == "Linux") + + +def test_system_is_mac(): + result = System.is_mac + assert isinstance(result, bool) + assert result == (platform.system() == "Darwin") + + +def test_system_is_unix(): + result = System.is_unix + assert isinstance(result, bool) + current_system = platform.system() + expected = current_system in ["Linux", "Darwin"] or "BSD" in current_system + assert result == expected + + +def test_system_hostname(): + hostname = System.hostname + assert isinstance(hostname, str) + assert hostname != "" + + +def test_system_username(): + username = System.username + assert isinstance(username, str) + assert username != "" + + +def test_system_os_info(): + """Test OS name and version properties""" + os_name = System.os_name + assert isinstance(os_name, str) + assert os_name != "" + assert os_name in ["Windows", "Linux", "Darwin"] or os_name != "" + + os_version = System.os_version + assert isinstance(os_version, str) + assert os_version != "" + + +def test_system_architecture(): + architecture = System.architecture + assert isinstance(architecture, str) + assert architecture != "" + assert any(arch in architecture.lower() for arch in ["x86", "amd64", "arm", "aarch", "i386", "i686"]) + + +def test_system_cpu_count(): + cpu_count = System.cpu_count + assert isinstance(cpu_count, int) + assert cpu_count >= 1 + + +def test_system_python_version(): + python_version = System.python_version + assert isinstance(python_version, str) + assert python_version != "" + parts = python_version.split(".") + assert len(parts) >= 2 + assert all(part.isdigit() for part in parts[:2]) + + def test_check_libs_existing_modules(): """Test check_libs with existing modules""" result = System.check_libs(["os", "sys", "json"])