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"])