Module group

Expand source code
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

__project__ = "Pizza3"
__author__ = "Olivier Vitrac, Han Chen"
__copyright__ = "Copyright 2023"
__credits__ = ["Olivier Vitrac","Han Chen"]
__license__ = "GPLv3"
__maintainer__ = "Olivier Vitrac"
__email__ = "olivier.vitrac@agroparistech.fr"
__version__ = "0.9999"


"""
===============================================
          pizza.group Class Manual
===============================================

## Introduction

The `group` class provides a Pythonic interface to manage groups of atoms in LAMMPS simulations through the Pizza.py toolkit. In LAMMPS, groups are fundamental for applying operations to subsets of atoms, such as assigning force fields, computing properties, or setting up constraints. The `group` class allows users to create, manipulate, and combine groups of atoms programmatically, mirroring the functionality of LAMMPS group commands within a Python environment.

Unlike the `pizza.region` class, which allows for dynamic geometrical definitions, the `group` class operates statically. This choice is due to the nature of atom groups in LAMMPS, which are snapshots of atom collections at a specific time, rather than dynamic entities that change over time.

## Overview

Groups are stored and managed via a collection of operations. Each operation represents a LAMMPS group command or a combination of groups using algebraic operations. The `group` class facilitates both the creation of basic groups and the combination of existing groups using union, intersection, and subtraction.

The class supports both high-level methods, which correspond directly to LAMMPS commands, and low-level methods for internal management of group operations.

---

## High-Level Methods

These methods correspond directly to LAMMPS group commands and allow for the creation and manipulation of groups based on various criteria.

### `region(group_name, regionID)`

- **Description**: Defines a group of atoms based on a specified region ID.
- **Usage**: `G.region('group_name', 'regionID')`
- **Example**:
  - In LAMMPS:
    ```lammps
    region myRegion block 0 10 0 10 0 10 units box
    group myGroup region myRegion
    ```
  - In Python:
    ```python
    G = group()
    G.region('myGroup', 'myRegion')
    ```

### `type(group_name, type_values)`

- **Description**: Selects atoms by their type and assigns them to a group.
- **Usage**: `G.type('group_name', type_values)`
- **Example**:
  - In LAMMPS:
    ```lammps
    group myGroup type 1 2
    ```
  - In Python:
    ```python
    G = group()
    G.type('myGroup', [1, 2])
    ```

### `id(group_name, id_values)`

- **Description**: Selects atoms by their IDs and assigns them to a group.
- **Usage**: `G.id('group_name', id_values)`
- **Example**:
  - In LAMMPS:
    ```lammps
    group myGroup id 1 2 3
    ```
  - In Python:
    ```python
    G = group()
    G.id('myGroup', [1, 2, 3])
    ```

### `variable(group_name, variable_name, expression, style="atom")`

- **Description**: Defines a group based on an atom-style variable expression.
- **Usage**: `G.variable('group_name', 'variable_name', 'expression', style='atom')`
- **Example**:
  - In LAMMPS:
    ```lammps
    variable myVar atom "x > 5"
    group myGroup variable myVar
    ```
  - In Python:
    ```python
    G = group()
    G.variable('myGroup', 'myVar', 'x > 5')
    ```

### `create(group_name)`

- **Description**: Creates a new empty group or clears an existing group.
- **Usage**: `G.create('group_name')`
- **Example**:
  - In LAMMPS:
    ```lammps
    group myGroup clear
    ```
  - In Python:
    ```python
    G = group()
    G.create('myGroup')
    ```

### Algebraic Operations

The `group` class supports algebraic operations between groups using the overloaded operators `+`, `-`, and `*` for union, subtraction, and intersection, respectively.

#### `union(group_name, *groups)`

- **Description**: Creates a new group by performing the union of specified groups.
- **Usage**: `G.union('new_group', *groups)`
- **Example**:
  - In LAMMPS:
    ```lammps
    group newGroup union group1 group2
    ```
  - In Python:
    ```python
    G.union('newGroup', 'group1', 'group2')
    ```

#### `intersect(group_name, *groups)`

- **Description**: Creates a new group by performing the intersection of specified groups.
- **Usage**: `G.intersect('new_group', *groups)`
- **Example**:
  - In LAMMPS:
    ```lammps
    group newGroup intersect group1 group2
    ```
  - In Python:
    ```python
    G.intersect('newGroup', 'group1', 'group2')
    ```

#### `subtract(group_name, *groups)`

- **Description**: Creates a new group by subtracting specified groups from the first group.
- **Usage**: `G.subtract('new_group', *groups)`
- **Example**:
  - In LAMMPS:
    ```lammps
    group newGroup subtract group1 group2
    ```
  - In Python:
    ```python
    G.subtract('newGroup', 'group1', 'group2')
    ```

### `evaluate(group_name, group_op)`

- **Description**: Evaluates complex group expressions involving algebraic operations and stores the result in a new group.
- **Usage**: `G.evaluate('new_group', group_op)`
- **Example**:
  ```python
  G = group()
  G.create('o1')
  G.create('o2')
  G.create('o3')
  complex_op = G['o1'] + G['o2'] + G['o3']
  G.evaluate('combinedGroup', complex_op)



Created on Wed Aug 16 16:25:19 2023

@author: olivi
"""

# Revision history
# 2023-08-17 RC with documentation, the script method is not implemented yet (to be done via G.code())
# 2023-08-18 consolidation of the GroupOperation mechanism (please still alpha version)
# 2023-09-01 the class GroupOperation has been removed and the collapse is carried within Operation to avoid unecessary complications [major release]
# 2024-10-04 code optimization
# 2024-10-07 implementation of dscript, script, pipescript
# 2024-10-08 code finalization (flexible initialization), updated documentation
# 2024-10-11 advanced indexing and scripting
# 2024-10-12 add copy and deepcopy features
# 2024-11-29 add groupobjects, groupcollection
# 2024-12-01 standarize scripting features, automatically call script/pscript methods

# %% Dependencies
# pizza.group is independent of region and can performs only static operations.
# It is designed to be compatible with region via G.script()
from typing import List, Union, Optional, Tuple
import hashlib, random, string, copy
from pizza.script import span, pipescript
from pizza.dscript import dscript

__all__ = ['Operation', 'dscript', 'format_table', 'generate_random_name', 'group', 'groupcollection', 'groupobject', 'pipescript', 'span', 'truncate_text']

# %% Private functions

# Helper function that generates a random string of a specified length using uppercase and lowercase letters.
def generate_random_name(length=8):
    letters = string.ascii_letters  # 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
    return ''.join(random.choice(letters) for _ in range(length))

# Helper function that truncates text
def truncate_text(txt, maxwidth):
    """
    Truncates the input text to fit within the specified maximum width.
    If the text is longer than `maxwidth`, it is shortened by keeping
    the beginning and trailing parts, separated by " [...] ".

    ### Parameters:
        txt (str): The text to truncate.
        maxwidth (int): The maximum allowed width for the text.

    ### Returns:
        str: The truncated text with " [...] " in the middle if truncation occurs.

    ### Example:
        >>> truncate_text("This is a long string that needs truncation.", 20)
        'This is [...] truncation.'
    """
    # If the text fits within maxwidth, return it as is
    if len(txt) <= maxwidth:
        return txt
    # Calculate the part lengths for beginning and end
    ellipsis = " [...] "
    split_length = (maxwidth - len(ellipsis)) // 2
    beginning = txt[:split_length]
    trailing = txt[-split_length:]
    return f"{beginning}{ellipsis}{trailing}"


# Helper function that generate a pretty table
def format_table(
    headers: List[str],
    rows: List[List[str]],
    col_max_widths: List[int],
    align: Union[str, List[str]] = "C",
) -> str:
    """
    Formats a table with given headers and rows, truncating text based on maximum column widths
    and aligning content based on the specified alignment.

    ### Parameters:
        headers (List[str]): List of column headers.
        rows (List[List[str]]): List of rows, where each row is a list of column values.
        col_max_widths (List[int]): List of maximum widths for each column.
        align (str or List[str], optional): Alignment for each column. Can be a single string
                                            (applies to all columns) or a list of strings (one for each column).
                                            Options are:
                                            - "C" or "center" for centered alignment (default).
                                            - "L" or "left" for left alignment.
                                            - "R" or "right" for right alignment.
                                            Case insensitive.

    ### Returns:
        str: A formatted table as a string.

    ### Raises:
        ValueError: If `align` is a list but its length does not match the number of columns.

    ### Example:
        >>> headers = ["Idx", "Name", "Value"]
        >>> rows = [[1, "Example", "12345"], [2, "LongerName", "67890"]]
        >>> col_max_widths = [5, 10, 8]
        >>> align = ["R", "C", "L"]
        >>> print(format_table(headers, rows, col_max_widths, align))
        Idx   |   Name    | Value
        ----- | ---------- | --------
          1   |  Example  | 12345
          2   | LongerName | 67890
    """
    # Normalize alignment input
    if isinstance(align, str):
        align = [align.upper()] * len(headers)
    elif isinstance(align, list):
        align = [a.upper() for a in align]
        if len(align) != len(headers):
            raise ValueError("The length of `align` must match the number of columns.")
    else:
        raise TypeError("`align` must be a string or a list of strings.")

    # Determine actual column widths based on headers and max widths
    col_widths = [
        min(max(len(header), *(len(str(row[i])) for row in rows)), max_w)
        for i, header in enumerate(headers)
        for max_w in [col_max_widths[i]]
    ]
    # Prepare alignment formatting
    alignments = []
    for a in align:
        if a in ("C", "CENTER"):
            alignments.append("^")
        elif a in ("L", "LEFT"):
            alignments.append("<")
        elif a in ("R", "RIGHT"):
            alignments.append(">")
        else:
            raise ValueError(f"Invalid alignment value: {a}")
    # Prepare header line
    header_line = " | ".join(
        f"{headers[i]:{alignments[i]}{col_widths[i]}}" for i in range(len(headers))
    )
    # Prepare separator
    separator = " | ".join("-" * col_widths[i] for i in range(len(col_widths)))
    # Prepare rows
    formatted_rows = []
    for row in rows:
        formatted_row = [
            f"{str(row[i]):{alignments[i]}{col_widths[i]}}" for i in range(len(row))
        ]
        formatted_rows.append(" | ".join(formatted_row))
    # Combine all parts
    table = [header_line, separator] + formatted_rows + [separator]
    return "\n".join(table)


# %% Low-level classes groupobjects, groupcollection

# groupobject class for groups defined based on a collection of groupobjects
class groupobject:
    """
    Represents an object with a bead type, associated groups, and an optional mass.

    ### Attributes:
        beadtype (int or float): The bead type identifier. Must be an integer or a real scalar.
        group (List[str]): List of group names the object belongs to.
        mass (Optional[float]): The mass of the bead. Must be a real scalar or None.
        name (Optional[str]): Name of the object

    ### Examples:
        >>> o1 = groupobject(beadtype=1, group=["all", "A"], mass=1.0)
        >>> o2 = groupobject(beadtype=2, group=["all", "B", "C"])
        >>> o3 = groupobject(beadtype=3, group="C", mass=2.5)
    """

    def __init__(self, beadtype: Union[int, float], group: Union[str, List[str], Tuple[str, ...]], \
                 mass: Optional[Union[int, float]] = None, name: Optional[str]=None, ):
        """
        Initializes a new instance of the groupobject class.

        ### Parameters:
            beadtype (int or float): The bead type identifier.
            group (str, list of str, or tuple of str): Group names the object belongs to.
            mass (float or int, optional): The mass of the bead.

        ### Raises:
            TypeError: If `beadtype` is not an int or float.
            TypeError: If `group` is not a str, list of str, or tuple of str.
            TypeError: If `mass` is not a float, int, or None.
        """
        # Validate beadtype
        if not isinstance(beadtype, (int, float)):
            raise TypeError(f"'beadtype' must be an integer or float, got {type(beadtype).__name__} instead.")
        self.beadtype = beadtype
        # Validate group
        if isinstance(group, str):
            self.group = [group]
        elif isinstance(group, (list, tuple)):
            if not all(isinstance(g, str) for g in group):
                raise TypeError("All elements in 'group' must be strings.")
            self.group = list(group)
        else:
            raise TypeError(f"'group' must be a string, list of strings, or tuple of strings, got {type(group).__name__} instead.")
        # Validate mass
        if mass is not None and not isinstance(mass, (int, float)):
            raise TypeError(f"'mass' must be a real scalar (int or float) or None, got {type(mass).__name__} instead.")
        self.mass = mass
        # Validate name
        self.name = f"beadtype={beadtype}" if name is None else name

    def __str__(self) -> str:
        """
        Returns a readable string representation of the groupobject.
        """
        return f"groupobject | type={self.beadtype} | name={self.group} | mass={self.mass}"

    def __repr__(self) -> str:
        """
        Returns an unambiguous string representation of the groupobject.

        ### Returns:
            str: String representation including beadtype, group, and mass (if not None).
        """
        return f"groupobject(beadtype={self.beadtype}, group={span(self.group,',','[',']')}, name={self.name}, mass={self.mass})"


    def __add__(self, other: Union['groupobject', 'groupcollection']) -> 'groupcollection':
        """
        Adds this groupobject to another groupobject or a groupcollection.

        ### Parameters:
            other (groupobject or groupcollection): The object to add.

        ### Returns:
            groupcollection: A new groupcollection instance containing the combined objects.

        ### Raises:
            TypeError: If `other` is neither a `groupobject` nor a `groupcollection`.
        """
        if isinstance(other, groupobject):
            return groupcollection([self, other])
        elif isinstance(other, groupcollection):
            return groupcollection([self] + other.collection)
        else:
            raise TypeError("Addition only supported between `groupobject` or `groupcollection` instances.")

    def __radd__(self, other: Union['groupobject', 'groupcollection']) -> 'groupcollection':
        """
        Adds this groupobject to another groupobject or a groupcollection from the right.

        ### Parameters:
            other (groupobject or groupcollection): The object to add.

        ### Returns:
            groupcollection: A new groupcollection instance containing the combined objects.

        ### Raises:
            TypeError: If `other` is neither a `groupobject` nor a `groupcollection`.
        """
        if isinstance(other, groupobject):
            return groupcollection([other, self])
        elif isinstance(other, groupcollection):
            return groupcollection(other.collection + [self])
        else:
            raise TypeError("Addition only supported between `groupobject` or `groupcollection` instances.")


class groupcollection:
    """
    Represents a collection of `groupobject` instances, typically formed by combining them.

    ### Attributes:
        collection (List[groupobject]): The list of `groupobject` instances in the collection.
        name (str): The name of the collection, generated automatically if not provided.

    ### Examples:
        >>> o1 = groupobject(beadtype=1, group=["all", "A"], mass=1.0)
        >>> o2 = groupobject(beadtype=2, group=["all", "B", "C"])
        >>> G = groupcollection([o1, o2])
        >>> print(G.name)
        beadtype=1,2
    """
    def __init__(self, collection: List['groupobject'], name: Optional[str] = None):
        """
        Initializes a new instance of the groupcollection class.

        ### Parameters:
            collection (List[groupobject]): A list of `groupobject` instances to include in the collection.
            name (str, optional): The name of the collection. If not provided, a default name is generated.

        ### Raises:
            ValueError: If the `collection` is empty or contains invalid elements.
        """
        if not isinstance(collection, list) or not all(isinstance(obj, groupobject) for obj in collection):
            raise ValueError("`collection` must be a list of `groupobject` instances.")
        if not collection:
            raise ValueError("`collection` cannot be empty.")

        self.collection = collection

        # Automatically assign a name if not provided
        if name is None:
            # Extract names or "beadtype=X" patterns
            beadtype_names = [
                obj.name if obj.name.startswith("beadtype=") else None for obj in collection
            ]

            # If all objects have beadtype names, use "beadtype=1,2,3" pattern
            if all(beadtype_names):
                beadtypes = [obj.beadtype for obj in collection]
                self.name = "beadtype={}".format(",".join(map(str, sorted(beadtypes))))
            else:
                # Fall back to concatenating names
                self.name = ",".join(obj.name for obj in collection)
        else:
            self.name = name


    def __add__(self, other: Union[groupobject, 'groupcollection']) -> 'groupcollection':
        """
        Adds a `groupobject` or another `groupcollection` to this collection.

        ### Parameters:
            other (groupobject or groupcollection): The object to add.

        ### Returns:
            groupcollection: A new `groupcollection` instance containing the combined objects.

        ### Raises:
            TypeError: If `other` is neither a `groupobject` nor a `groupcollection`.
        """
        if isinstance(other, groupobject):
            return groupcollection(self.collection + [other])
        elif isinstance(other, groupcollection):
            return groupcollection(self.collection + other.collection)
        else:
            raise TypeError("Addition only supported between `groupobject` or `groupcollection` instances.")

    def __iadd__(self, other: Union[groupobject, List[groupobject], Tuple[groupobject, ...]]) -> 'groupcollection':
        """
        In-place addition of a `groupobject` or a list/tuple of `groupobject` instances.

        ### Parameters:
            other (groupobject or list/tuple of groupobject): The object(s) to add.

        ### Returns:
            groupcollection: The updated `groupcollection` instance.

        ### Raises:
            TypeError: If `other` is not a `groupobject` or a list/tuple of `groupobject` instances.
        """
        if isinstance(other, groupobject):
            self.collection.append(other)
        elif isinstance(other, (list, tuple)):
            for obj in other:
                if not isinstance(obj, groupobject):
                    raise TypeError("All items to add must be `groupobject` instances.")
            self.collection.extend(other)
        else:
            raise TypeError("In-place addition only supported with `groupobject` instances or lists/tuples of `groupobject` instances.")
        return self

    def __repr__(self) -> str:
        """
        Returns a neatly formatted string representation of the groupcollection.

        The representation includes a table of `beadtype`, `group` (max width 50 characters), and `mass`.
        It also provides a summary of the total number of `groupobject` instances.

        ### Returns:
            str: Formatted string representation of the collection.
        """
        headers = ["Beadtype", 'Name', "Group", "Mass"]
        col_max_widths = [10, 20, 30, 10]
        # Prepare rows
        rows = []
        for obj in self.collection:
            beadtype_str = str(obj.beadtype)
            group_str = ", ".join(obj.group)
            mass_str = f"{obj.mass}" if obj.mass is not None else "None"
            rows.append([beadtype_str, obj.name, group_str, mass_str])
        # Generate the table
        table = format_table(headers, rows, col_max_widths)
        # Add summary
        summary = f"Total groupobjects: {len(self)}"
        return f"{table}\n{summary}"

    def __str__(self) -> str:
        """
        Returns the same representation as `__repr__`.

        ### Returns:
            str: Formatted string representation of the collection.
        """
        return f"groupcollection including {len(self)} groupobjects"

    def __len__(self) -> int:
        """
        Returns the number of `groupobject` instances in the collection.

        ### Returns:
            int: Number of objects in the collection.
        """
        return len(self.collection)

    def __getitem__(self, index: int) -> groupobject:
        """
        Retrieves a `groupobject` by its index.

        ### Parameters:
            index (int): The index of the `groupobject` to retrieve.

        ### Returns:
            groupobject: The `groupobject` at the specified index.

        ### Raises:
            IndexError: If the index is out of range.
        """
        return self.collection[index]

    def __iter__(self):
        """
        Returns an iterator over the `groupobject` instances in the collection.

        ### Returns:
            Iterator[groupobject]: An iterator over the collection.
        """
        return iter(self.collection)

    def append(self, obj: groupobject):
        """
        Appends a `groupobject` to the collection.

        ### Parameters:
            obj (groupobject): The object to append.

        ### Raises:
            TypeError: If `obj` is not a `groupobject` instance.
        """
        if not isinstance(obj, groupobject):
            raise TypeError("Only `groupobject` instances can be appended.")
        self.collection.append(obj)

    def extend(self, objs: Union[List[groupobject], Tuple[groupobject, ...]]):
        """
        Extends the collection with a list or tuple of `groupobject` instances.

        ### Parameters:
            objs (list or tuple of groupobject): The objects to extend the collection with.

        ### Raises:
            TypeError: If `objs` is not a list or tuple.
            TypeError: If any item in `objs` is not a `groupobject` instance.
        """
        if not isinstance(objs, (list, tuple)):
            raise TypeError("`objs` must be a list or tuple of `groupobject` instances.")
        for obj in objs:
            if not isinstance(obj, groupobject):
                raise TypeError("All items to extend must be `groupobject` instances.")
        self.collection.extend(objs)

    def remove(self, obj: groupobject):
        """
        Removes a `groupobject` from the collection.

        ### Parameters:
            obj (groupobject): The object to remove.

        ### Raises:
            ValueError: If `obj` is not found in the collection.
        """
        self.collection.remove(obj)

    def clear(self):
        """
        Clears all `groupobject` instances from the collection.
        """
        self.collection.clear()


    def mass(self, name: Optional[str] = None, default_mass: Optional[Union[str, int, float]] = "${mass}", verbose: Optional[bool] = True) -> 'dscript':
        """
        Generates LAMMPS mass commands for each unique beadtype in the collection.

        The method iterates through all `groupobject` instances in the collection,
        collects unique beadtypes, and ensures that each beadtype has a consistent mass.
        If a beadtype has `mass=None`, it assigns a default mass as specified by `default_mass`.

        ### Parameters:
            name (str, optional): The name to assign to the resulting `dscript` object. Defaults to a generated name.
            default_mass (str, optional): The default mass value to assign when a beadtype's mass is `None`.
                                          Defaults to `"${mass}"`.
            verbose (bool, optional): If `True`, includes a comment header in the output. Defaults to `True`.

        ### Returns:
            dscript: A `dscript` object containing the mass commands for each beadtype, formatted as follows:
                     ```
                     mass 1 1.0
                     mass 2 1.0
                     mass 3 2.5
                     ```
                     The `collection` attribute of the `dscript` object holds the formatted mass commands as a single string.

        ### Raises:
            ValueError: If a beadtype has inconsistent mass values across different `groupobject` instances.

        ### Example:
            ```python
            # Create groupobject instances
            o1 = groupobject(beadtype=1, group=["all", "A"], mass=1.0)
            o2 = groupobject(beadtype=2, group=["all", "B", "C"])
            o3 = groupobject(beadtype=3, group="C", mass=2.5)

            # Initialize a groupcollection with the groupobjects
            G = groupcollection([o1, o2, o3])

            # Generate mass commands
            M = G.mass()
            print(M.do())
            ```
            **Output:**
            ```
            mass 1 1.0
            mass 2 ${mass}
            mass 3 2.5
            ```
        """
        beadtype_mass = {}
        for obj in self.collection:
            bt = obj.beadtype
            mass = obj.mass if obj.mass is not None else "${mass}"
            if bt in beadtype_mass:
                if beadtype_mass[bt] != mass:
                    raise ValueError(
                        f"Inconsistent masses for beadtype {bt}: {beadtype_mass[bt]} vs {mass}"
                    )
            else:
                beadtype_mass[bt] = mass
        # Sort beadtypes for consistent ordering
        sorted_beadtypes = sorted(beadtype_mass.keys())
        # Generate mass commands
        lines = [f"mass {bt} {beadtype_mass[bt]}" for bt in sorted_beadtypes]
        # return a dscript object
        idD = f"<dscript:group:{self.name}:mass>"
        description = f"{idD} definitions for {len(self)} beads"
        if verbose:
            lines.insert(0, "# "+description)
        D = dscript(name=idD if name is None else name, description=f"{idD} with {len(self)} beads")
        D.collection = "\n".join(lines)
        D.DEFINITIONS.mass = default_mass
        return D




# %% Main classes Operation, group

# Class for binary and unary operators
class Operation:
    """
    Represents a LAMMPS group operation, allowing algebraic manipulation and code generation.

    ### Overview

    The `Operation` class models a group operation in LAMMPS (Large-scale Atomic/Molecular Massively
    Parallel Simulator), encapsulating an operator, its operands, a name, and the corresponding LAMMPS code.
    It supports algebraic operations between groups (union, intersection, subtraction) using the
    overloaded operators '+', '-', and '*', enabling users to build complex group expressions
    programmatically.

    ### Attributes

    - **operator (str)**: The operator applied to the operands. It can be:
        - A finalized LAMMPS operator: `'variable'`, `'byvariable'`, `'byregion'`, `'bytype'`, `'byid'`, `'create'`, `'clear'`,
          `'union'`, `'intersect'`, `'subtract'`.
        - An algebraic operator: `'+'`, `'-'`, `'*'`.
        - An empty string or `None` for operations without an explicit operator.

    - **operands (list)**: A list of operands for the operation. Operands can be:
        - Instances of `Operation`, representing nested operations.
        - Strings representing group names or other identifiers.
        - Integers or lists (e.g., atom types or IDs).

    - **name (str)**: The name of the operation, which can be manually set or auto-generated.
        - If not provided, a unique hash-based name is generated using the `generate_hashname` method.

    - **code (str)**: The LAMMPS code that this operation represents.
        - This is generated when the operation is finalized and corresponds to the actual command(s)
          that would be included in a LAMMPS input script.

    - **criteria (dict, optional)**: Criteria used to define the operation.
        - Useful for operations like `bytype`, `byregion`, `byid`, etc., where the criteria specify
          the selection parameters.

    ### Methods

    - `__init__(self, operator, operands, name="", code="", criteria=None)`:
        Initializes a new `Operation` instance with the given parameters.
        - **operator** (str): The operator applied to the operands.
        - **operands** (list or single operand): The operands for the operation.
        - **name** (str, optional): The name of the operation.
        - **code** (str, optional): The LAMMPS code representing the operation.
        - **criteria** (dict, optional): Criteria used to define the operation.

    - `append(self, operand)`:
        Appends a single operand to the operands list.

    - `extend(self, operands)`:
        Extends the operands list with multiple operands.

    - `is_unary(self)`:
        Checks if the `Operation` instance has exactly one operand.

    - `is_empty(self)`:
        Checks if the `Operation` instance has no operands.

    - `generateID(self)`:
        Generates an ID for the instance based on its operands or name.
        Used internally for generating unique names.

    - `generate_hashname(self, prefix="grp", ID=None)`:
        Generates a hash-based name for the instance.
        - **prefix** (str): A string prefix for the generated name.
        - **ID** (str, optional): An optional ID to include in the hash. If `None`, it is generated from operands.

    - `get_proper_operand(self)`:
        Returns the appropriate operand representation depending on whether the operation is finalized.

    - `_operate(self, other, operator)`:
        Combines the operation with another `Operation` instance using the specified operator.
        - **other** (`Operation`): The other operation to combine with.
        - **operator** (str): The operator to apply ('+', '-', '*').

    - `isfinalized(self)`:
        Checks if the operation has been finalized.
        - An operation is considered finalized if its operator is not one of the algebraic operators '+', '-', '*'.

    - `__add__(self, other)`:
        Overloads the '+' operator to support union of two `Operation` instances.

    - `__sub__(self, other)`:
        Overloads the '-' operator to support subtraction between two `Operation` instances.

    - `__mul__(self, other)`:
        Overloads the '*' operator to support intersection between two `Operation` instances.

    - `__repr__(self)`:
        Returns a detailed string representation of the operation, useful for debugging.

    - `__str__(self)`:
        Returns a concise string representation of the operation.

    - `script(self)`:
        Generates the LAMMPS code for this operation using `dscript` and `script` classes.
        - Returns a `script` object containing the LAMMPS code.

    ### Operator Overloading

    The `Operation` class overloads the following operators to enable algebraic manipulation:

    - **Addition (`+`)**:
        - Combines two operations using union.
        - Example: `op3 = op1 + op2`

    - **Subtraction (`-`)**:
        - Subtracts one operation from another.
        - Example: `op3 = op1 - op2`

    - **Multiplication (`*`)**:
        - Intersects two operations.
        - Example: `op3 = op1 * op2`

    ### Usage Example

    ```python
    # Create basic operations
    op1 = Operation('bytype', [1], name='group1')
    op2 = Operation('bytype', [2], name='group2')

    # Combine operations using algebraic operators
    combined_op = op1 + op2  # Union of group1 and group2

    # Initialize group manager
    G = group()
    G.add_operation(op1)
    G.add_operation(op2)

    # Evaluate the combined operation and store the result
    G.evaluate('combined_group', combined_op)

    # Access the generated LAMMPS code
    print(G.code())
    # Output:
    # group group1 type 1
    # group group2 type 2
    # group combined_group union group1 group2
    ```

    ### Notes

    - **Lazy Evaluation**:
        - Operations are combined and stored symbolically until they are evaluated.
        - The actual LAMMPS code is generated when the operation is finalized (e.g., via the `evaluate` method in the `group` class).

    - **Name Generation**:
        - If an operation's name is not provided, it is auto-generated using a hash of its operator and operands.
        - This ensures uniqueness and avoids naming conflicts.

    - **Criteria Attribute**:
        - The `criteria` attribute allows storing the parameters used to define operations.
        - Useful for debugging, documentation, or reconstructing operations.

    - **Finalization**:
        - An operation is finalized when it represents an actual LAMMPS command and has generated code.
        - Finalized operations are not further combined; instead, their names are used in subsequent operations.

    - **Integration with `dscript` and `script`**:
        - The `script` method generates the LAMMPS code for the operation using `dscript` and `script` classes.
        - Facilitates integration with script management tools and advanced scripting workflows.

    - **Error Handling**:
        - Methods include error checks to ensure operands are valid and operations are correctly formed.
        - Raises informative exceptions for invalid operations or unsupported types.

    ### Integration with Group Class

    The `Operation` class is designed to work closely with the `group` class, which manages a collection of operations and handles evaluation and code generation.

    - **Adding Operations**:
        - Use `group.add_operation(operation)` to add an `Operation` instance to a `group`.
    - **Evaluating Operations**:
        - Use `group.evaluate(group_name, operation)` to finalize an operation and generate LAMMPS code.
    - **Accessing Operations**:
        - Operations can be accessed via the `group` instance using indexing, attribute access, or methods.

    ### Additional Information

    - **String Representations**:
        - `__str__`: Provides a concise representation, useful for quick inspection.
        - `__repr__`: Provides a detailed representation, useful for debugging.

    - **Example of `__str__` Output**:
        - For an operation representing `group1 + group2`:
          ```python
          print(str(combined_op))
          # Output: <combined_group=[group1+group2]>
          ```

    - **Example of `__repr__` Output**:
        ```python
        print(repr(combined_op))
        # Output:
        # Operation Details:
        #   - Name: combined_group
        #   - Operator: +
        #   - Operands: group1, group2
        #   - LAMMPS Code:
        ```

    ### Examples

    **Defining Operations Based on Atom Types**

    ```python
    op1 = Operation('bytype', [1], name='type1')
    op2 = Operation('bytype', [2], name='type2')
    G.add_operation(op1)
    G.add_operation(op2)
    ```

    **Combining and Evaluating Operations**

    ```python
    combined_op = op1 + op2  # Union of 'type1' and 'type2'
    G.evaluate('all_types', combined_op)
    ```

    **Accessing Generated Code**

    ```python
    print(G.code())
    # Output:
    # group type1 type 1
    # group type2 type 2
    # group all_types union type1 type2
    ```

    **Using Criteria Attribute**

    ```python
    # Access criteria used to define an operation
    print(op1.criteria)
    # Output:
    # {'type': [1]}
    ```

    ### Important Notes

    - **Algebraic Operators vs. Finalized Operators**:
        - Algebraic operators ('+', '-', '*') are used for symbolic manipulation.
        - Finalized operators represent actual LAMMPS commands.

    - **Operator Precedence**:
        - When combining operations, consider the order of operations.
        - Use parentheses to ensure the desired evaluation order.

    - **Integration with Scripting Tools**:
        - The `script` method allows operations to be integrated into larger scripting workflows.
        - Supports advanced features like variable substitution and conditional execution.

    ### Conclusion

    The `Operation` class provides a powerful and flexible way to model and manipulate group operations in LAMMPS simulations. By supporting algebraic operations and integrating with script management tools, it enables users to build complex group definitions programmatically, enhancing productivity and reducing errors in simulation setup.

    """

    def __init__(self, operator, operands, name="", code="", criteria=None):
        """Initializes a new Operation instance with given parameters."""
        self.operator = operator
        self.operands = operands if isinstance(operands, list) else [operands]
        if not name:
            self.name = self.generate_hashname()
        else:
            self.name = name
        self.code = code
        self.criteria = criteria  # Store criteria for this operation

    def append(self, operand):
        """Appends a single operand to the operands list of the Operation instance."""
        self.operands.append(operand)

    def extend(self, operands):
        """Extends the operands list of the Operation instance with multiple operands."""
        self.operands.extend(operands)

    def is_unary(self):
        """Checks if the Operation instance has exactly one operand."""
        return len(self.operands) == 1

    def is_empty(self):
        """Checks if the Operation instance has no operands."""
        return len(self.operands) == 0

    def generateID(self):
        """Generates an ID for the Operation instance based on its operands or name."""
        # if self.operands:
        #     return "".join([str(op) for op in self.operands])
        # else:
        #     return self.name
        operand_ids = ''.join(op.name if isinstance(op, Operation) else str(op) for op in self.operands)
        return operand_ids

    def generate_hashname(self,prefix="grp",ID=None):
        """Generates an ID for the Operation instance based on its operands or name."""
        # if ID is None: ID = self.generateID()
        # s = self.operator+ID
        # return prefix+hashlib.sha256(s.encode()).hexdigest()[:6]
        if ID is None:
            ID = self.generateID()
        s = self.operator + ID
        return prefix + hashlib.sha256(s.encode()).hexdigest()[:6]

    def __repr__(self):
        """ detailed representation """
        operand_str = ', '.join(str(op) for op in self.operands)
        return (
            f"Operation Details:\n"
            f"  - Name: {self.name}\n"
            f"  - Operator: {self.operator}\n"
            f"  - Operands: {operand_str}\n"
            f"  - LAMMPS Code: {self.code}"
        )

    def __str__(self):
        """ string representation """
        operands = [str(op) for op in self.operands]
        operand_str = ', '.join(operands)
        if self.is_empty():
            return f"<{self.operator} {self.name}>"

        elif self.is_unary():
            return f"<{self.name}={self.operator}({operand_str})>"
        else:  # Polynary
            operator_symbol_mapping = {
                'union': '+',
                'intersect': '*',
                'subtract': '-'
            }
            operator_symbol = operator_symbol_mapping.get(self.operator, self.operator)
            formatted_operands = operator_symbol.join(operands)
            return f"<{self.name}=[{formatted_operands}]>"

    def isfinalized(self):
        """
        Checks whether the Operation instance is finalized.
        Returns:
        - bool: True if the Operation is finalized, otherwise False.
        Functionality:
        - An Operation is considered finalized if its operator is not
         one of the algebraic operators '+', '-', '*'.
        """
        return self.operator not in ('+', '-', '*')

    def __add__(self, other):
        """ overload + as union """
        return self._operate(other,'+')

    def __sub__(self, other):
        """ overload - as subtract """
        return self._operate(other,'-')

    def __mul__(self, other):
        """ overload * as intersect """
        return self._operate(other,'*')

    def get_proper_operand(self):
        # """
        # Returns the proper operand depending on whether the operation is finalized.
        # """
        # return span(self.operands) if not self.isfinalized() else self.name
        """
        Returns the proper operand depending on whether the operation is finalized.
        """
        if self.isfinalized():
            return self.name
        else:
            # Collect operand names
            operand_names = [
                op.name if isinstance(op, Operation) else str(op)
                for op in self.operands
            ]
            return span(operand_names)

    def _operate(self, other, operator):
        """
        Implements algebraic operations between self and other Operation instances.

        Parameters:
        - other (Operation): The other Operation instance to be operated with.
        - operator (str): The operation to be performed. Supported values are '+', '-', '*'.

        Returns:
        - Operation: A new Operation instance reflecting the result of the operation.
        """
        # Ensure other is also an instance of Operation
        if not isinstance(other, Operation):
            raise TypeError(f"Unsupported type: {type(other)}")

        # Map operator to prefix
        prefix_map = {'+': 'add', '-': 'sub', '*': 'mul'}
        prefix = prefix_map.get(operator, 'op')

        # Prepare operands list
        operands = []

        # Handle self
        if self.operator == operator and not self.isfinalized():
            operands.extend(self.operands)
        else:
            operands.append(self)

        # Handle other
        if other.operator == operator and not other.isfinalized():
            operands.extend(other.operands)
        else:
            operands.append(other)

        # Generate a new name
        name = self.generate_hashname(prefix=prefix, ID=self.generateID() + other.generateID())

        # Create a new Operation
        new_op = Operation(operator, operands, name=name)
        return new_op

    def script(self):
        """
        Generate the LAMMPS code using the dscript and script classes.

        Returns:
        - script onject: The LAMMPS code generated by this operation.
        """
        # Create a dscript object to store the operation code
        dscript_obj = dscript()
        # Use a dummy key for the dscript template
        dscript_obj["dummy"] = self.code
        # Convert the dscript object to a script object
        script_obj = dscript_obj.script()
        return script_obj




class group:
    """
    A class for managing LAMMPS group operations and generating LAMMPS scripts.

    ### Overview

    The `group` class provides an object-oriented interface to define and manage
    groups of atoms in LAMMPS simulations. Groups in LAMMPS are collections of
    atoms that can be manipulated together, allowing users to apply fixes,
    compute properties, or perform operations on specific subsets of atoms.

    This class allows you to create, combine, and manipulate groups using
    algebraic operations (union, intersection, subtraction), and to generate
    the corresponding LAMMPS commands. It also provides methods to output the
    group commands as scripts, which can be integrated into LAMMPS input files.

    ### Key Features

    - **Create and Manage Groups**: Define new groups based on atom types,
      regions, IDs, or variables.
    - **Flexible Group Creation**: Create multiple groups at once and define groups
      using concise criteria through the `add_group_criteria` method.
    - **Algebraic Operations**: Combine groups using union (`+`), intersection (`*`),
      and subtraction (`-`) operations.
    - **Subindexing with Callable Syntax**: Retrieve multiple group operations
      by calling the `group` instance with names or indices.
    - **Script Generation**: Generate LAMMPS script lines for group definitions,
      which can be output as scripts or pipelines.
    - **Dynamic Evaluation**: Evaluate complex group expressions and store the
      resulting operations.
    - **Integration with Scripting Tools**: Convert group operations into
      `dscript` or `pipescript` objects for advanced script management.

    ### LAMMPS Context

    In LAMMPS (Large-scale Atomic/Molecular Massively Parallel Simulator),
    groups are fundamental for specifying subsets of atoms for applying
    operations like forces, fixes, and computes. The `group` command in LAMMPS
    allows users to define groups based on various criteria such as atom IDs,
    types, regions, and variables.

    This `group` class abstracts the complexity of managing group definitions
    and operations, providing a high-level interface to define and manipulate
    groups programmatically.


    ### Usage Examples

    **Creating Multiple Groups at Once**

    ```python
    # Create groups 'o1', 'o2', 'o3', 'o4' upon instantiation
    G = group(group_names=['o1', 'o2', 'o3', 'o4'])
    ```

    **Defining Groups Based on Criteria**

    ```python
    G = group()
    G.add_group_criteria('lower', type=[1])
    G.add_group_criteria('central', region='central_cyl')
    G.add_group_criteria('new_group', create=True)
    G.add_group_criteria('upper', clear=True)
    G.add_group_criteria('subtract_group', subtract=['group1', 'group2'])
    ```

    **Defining a Group Based on a Variable**

    ```python
    G = group()
    G.variable('myVar', 'x > 5')          # Assign a variable
    G.byvariable('myGroup', 'myVar')      # Define group based on the variable
    ```

    **Using `add_group_criteria` with Variable**

    ```python
    G.add_group_criteria('myGroup', variable={'name': 'myVar', 'expression': 'x > 5'})
    ```

    **Defining Groups Using a Dictionary**

    ```python
    group_definitions = {
        'group1': {'type': [1, 2]},
        'group2': {'region': 'my_region'},
        'group3': {'variable': {'name': 'var1', 'expression': 'x > 5'}},
        'group4': {'union': ['group1', 'group2']},
    }
    G.add_group_criteria(group_definitions)
    ```

    **Creating a Group from a Collection of `groupobject` Instances**

    ```python
    # Import the classes
    # from groupobject import groupobject
    # from group import group

    # Create groupobject instances
    o1 = groupobject(beadtype=1, group=["all", "A"], mass=1.0)
    o2 = groupobject(beadtype=2, group=["all", "B", "C"])
    o3 = groupobject(beadtype=3, group="C", mass=2.5)

    # Create a group instance with the collection
    G = group(name="mycollection", collection=[o1, o2, o3])
    # or
    G = group(name=None, collection=[o1, o2, o3])  # Name will be generated as "1+2+3"

    # Generate the LAMMPS script
    script_content = G.code()
    print(script_content)
    ```

    **Expected Output:**
    ```
    group all type 1 2 3
    group A type 1
    group B type 2
    group C type 2 3
    ```

    **Accessing Groups and Performing Operations**

    ```python
    # Access groups via attribute-style or item-style access
    op1 = G.group1
    op2 = G['group2']

    # Combine groups using algebraic operations
    complex_op = op1 + op2 - G.group3

    # Evaluate the operation and store the result
    G.evaluate('combined_group', complex_op)
    ```


    **Subindexing with Callable Syntax**

    ```python
    # Retrieve multiple operations by names or indices
    subG = G('group1', 2, 'group3')  # Retrieves 'group1', third operation, and 'group3'

    # Display the names of operations in subG
    operation_names = [op.name for op in subG._operations]
    print(operation_names)  # Output: ['group1', 'group3', 'group3']

    # Generate the LAMMPS script for the subgroup
    script_content = subG.code()
    print(script_content)
    ```

    **Generating LAMMPS Script**

    ```python
    script_content = G.code()
    print(script_content)
    ```

    ### Class Methods

    #### Initialization and Setup

    - `__init__(self, name=None, groups=None, group_names=None, printflag=False, verbose=True)`: Initializes a new `group` instance.
      - `name` (str): Optional name for the group instance.
      - `groups` (dict): Dictionary of group definitions to create upon initialization.
      - `group_names` (list): List of group names to create empty groups upon initialization.
      - `collection` (list or tuple of groupobject, optional): Collection of groupobject instances.

      - `printflag` (bool): If True, enables printing of script generation.
      - `verbose` (bool): If True, enables verbose output.

    - `create_groups(self, *group_names)`: Creates multiple new groups with the given names.

    - `add_group_criteria(self, *args, **kwargs)`: Adds group(s) based on criteria.
      - Supports two usages:
        - `add_group_criteria(group_name, **criteria)`: Adds a single group.
        - `add_group_criteria(group_definitions)`: Adds multiple groups from a dictionary.

    #### Group Creation Methods

    - `create(self, group_name)`: Creates a new empty group with the given name.
    - `bytype(self, group_name, type_values)`: Defines a group based on atom types.
    - `byid(self, group_name, id_values)`: Defines a group based on atom IDs.
    - `byregion(self, group_name, region_name)`: Defines a group based on a region.
    - `variable(self, variable_name, expression, style="atom")`: Assigns an expression to a LAMMPS variable.
    - `byvariable(self, group_name, variable_name)`: Defines a group based on a variable.
    - `clear(self, group_name)`: Clears an existing group.

    #### Algebraic Operations

    - `union(self, group_name, *groups)`: Performs a union of the specified groups.
    - `intersect(self, group_name, *groups)`: Performs an intersection of the specified groups.
    - `subtract(self, group_name, *groups)`: Subtracts groups from the first group.
    - `evaluate(self, group_name, group_op)`: Evaluates a group operation and stores the result.

    #### Access and Manipulation

    - `__getitem__(self, key)`: Allows accessing operations by name or index.
    - `__getattr__(self, operation_name)`: Enables attribute-style access to operations.
    - `__call__(self, *keys)`: Returns a new `group` instance containing specified operations.
      - **Parameters**:
        - `*keys` (str or int): One or more names or indices of operations to retrieve.
      - **Returns**:
        - `group`: A new `group` instance containing the specified operations.
      - **Example**:
        ```python
        subG = G('group1', 1, 'group3')  # Retrieves 'group1', second operation, and 'group3'
        ```
    - `list(self)`: Returns a list of all operation names.
    - `find(self, name)`: Finds the index of an operation based on its name.
    - `disp(self, name)`: Displays the content of an operation.
    - `delete(self, name)`: Deletes an operation by name.
    - `copy(self, source_name, new_name)`: Copies an existing operation to a new name.
    - `rename(self, old_name, new_name)`: Renames an existing operation.
    - `reindex(self, name, new_idx)`: Changes the index of an operation.

    #### Script Generation

    - `code(self)`: Returns the generated LAMMPS commands as a string.
    - `dscript(self, name=None)`: Generates a `dscript` object containing the group's commands.
    - `script(self, name=None)`: Generates a script object containing the group's commands.
    - `pipescript(self)`: Generates a `pipescript` object containing each group's command as a separate script in a pipeline.

    ### Operator Overloading

    - **Addition (`+`)**: Union of groups.
    - **Subtraction (`-`)**: Subtraction of groups.
    - **Multiplication (`*`)**: Intersection of groups.

    These operators are overloaded in the `Operation` class and can be used to combine group operations.

    ### Internal Functionality

    The class maintains a list `_operations`, which stores all the group operations
    defined. Each operation is an instance of the `Operation` class, which represents
    a LAMMPS group command along with its operands and operator.

    ### Subindexing with Callable Syntax

    The `group` class allows you to retrieve multiple operations by calling the
    instance with names or indices:

    - `__call__(self, *keys)`: Returns a new `group` instance containing the specified operations.
      - **Parameters**:
        - `*keys` (str or int): One or more names or indices of operations to retrieve.
      - **Returns**:
        - `group`: A new `group` instance containing the specified operations.
      - **Example**:
        ```python
        subG = G('group1', 1, 'group3')  # Retrieves 'group1', second operation, and 'group3'
        ```
      - **Notes**:
        - Indices are 0-based integers.
        - Duplicate operations are avoided in the new `group` instance.

    ### Integration with Scripts

    The `group` class integrates with `dscript` and `pipescript` classes to allow
    advanced script management:

    - **`dscript`**: A dynamic script management class that handles script lines
      with variable substitution and conditional execution.
    - **`pipescript`**: Manages a pipeline of scripts, allowing sequential execution
      and advanced variable space management.

    ### Important Notes

    - **Operator Overloading**: The class overloads the `+`, `-`, and `*` operators
      for the `Operation` class to perform union, subtraction, and intersection
      respectively.
    - **Flexible Group Creation**: Groups can be created upon instantiation or added later using concise methods.
    - **Variable Assignment and Group Definition**: When defining groups based on variables, variable assignment and group creation are handled separately.
    - **Subindexing with `__call__`**: The `__call__` method allows you to retrieve multiple operations and create subgroups.
    - **Error Handling**: The class includes robust error checking and provides informative error messages.
    - **Lazy Evaluation**: Group operations are stored and only evaluated when
      the `evaluate` method is called.
    - **Name Management**: The class ensures that group names are unique within
      the instance to prevent conflicts.

    ### Conclusion

    The `group` class simplifies the management of groups in LAMMPS simulations,
    allowing for clear and maintainable code when dealing with complex group
    operations. By providing high-level abstractions, operator overloading,
    subindexing capabilities, and integration with script management tools,
    it enhances productivity and reduces the potential for errors in simulation setup.
    """


    def __init__(self, name=None, groups=None, group_names=None, collection=None, printflag=False, verbose=True, verbosity=None):
        """
        Initializes a new instance of the group class.

        ### Parameters:
            name (str, optional): Name for the group instance. If `None` or empty and `collection` is provided,
                                  generates a name based on beadtypes (e.g., "1+2+3").
            groups (dict, optional): Dictionary of group definitions to create upon initialization.
            group_names (list or tuple, optional): List of group names to create empty groups upon initialization.
            collection (list, tuple, or groupcollection, optional):
                - If a list or tuple, it should contain `groupobject` instances.
                - If a `groupcollection` object, it will extract the `groupobject` instances from it.
            printflag (bool, optional): If `True`, enables printing of script generation.
            verbose (bool, optional): If `True`, enables verbose output.

        ### Raises:
            TypeError:
                - If `groups` is not a dictionary.
                - If `group_names` is not a list or tuple.
                - If `collection` is not a list, tuple, or `groupcollection` object.
                - If any item in `collection` (when it's a list or tuple) is not a `groupobject` instance.
        """
        self._in_construction = True  # Indicate that the object is under construction
        # Handle 'name' parameter
        if not name:
            name = generate_random_name()
        self._name = name

        # Initialize other attributes
        self._operations = []
        self.printflag = printflag
        self.verbose = verbose if verbosity is None else verbosity>0
        self.verbosity = verbosity
        self._in_construction = False  # Set the flag to indicate construction is finished
        # Handle 'groups' parameter
        if groups:
            if not isinstance(groups, dict):
                raise TypeError("Parameter 'groups' must be a dictionary.")
            self.add_group_criteria(groups)
        # Handle 'group_names' parameter
        if group_names:
            if not isinstance(group_names, (list, tuple)):
                raise TypeError("Parameter 'group_names' must be a list or tuple of group names.")
            self.create_groups(*group_names)
        # Handle 'collection' parameter
        if collection:
            if isinstance(collection, groupcollection):
                # Extract the list of groupobject instances from the groupcollection
                collection = collection.collection
            elif not isinstance(collection, (list, tuple)):
                raise TypeError("Parameter 'collection' must be a list, tuple, or `groupcollection` object.")
            # If collection is a list or tuple, validate its items
            if isinstance(collection, (list, tuple)):
                for obj in collection:
                    if not isinstance(obj, groupobject):
                        raise TypeError("All items in 'collection' must be `groupobject` instances.")
                self.generate_group_definitions_from_collection(collection)



    def create_groups(self, *group_names):
        for group_name in group_names:
            if not isinstance(group_name, str):
                raise TypeError(f"Group name must be a string, got {type(group_name)}")
            self.create(group_name)


    def __str__(self):
        return f'Group "{self._name}" with {len(self._operations)} operations\n'


    def format_cell_content(self, content, max_width):
        content = str(content) if content is not None else ''
        if len(content) > max_width:
            start = content[: (max_width - 5) // 2]
            end = content[-((max_width - 5) // 2):]
            content = f"{start} ... {end}"
        return content


    def __repr__(self):
        """
        Returns a neatly formatted table representation of the group's operations.

        Each row represents an operation in the group, displaying its index, name,
        operator, and operands. The table adjusts column widths dynamically and
        truncates content based on maximum column widths.

        ### Returns:
            str: A formatted string representation of the group operations.
        """
        # Define headers for the table
        headers = ["Idx", "Name", "Operator", "Operands"]
        col_max_widths = [5, 20, 20, 40]
        align = ["R", "C", "C", "L"]
        # Prepare rows by iterating over the operations
        rows = []
        for idx, op in enumerate(self._operations):
            rows.append([
                str(idx),                    # Index
                str(op.name),                # Name
                str(op.operator),            # Operator
                str(span(op.operands)),      # Operands
            ])
        # Use the helper to format the table
        table = format_table(headers, rows, col_max_widths, align)
        # Append the string representation of the group itself
        return f"{table}\n\n{str(self)}"


    def __len__(self):
        """ return the number of stored operations """
        return len(self._operations)

    def list(self):
        """ return the list of all operations """
        return [op.name for op in self._operations]


    def code(self):
        """
            Joins the `code` attributes of all stored `operation` objects with '\n'.
        """
        return '\n'.join([op.code for op in self._operations])

    def find(self, name):
        """Returns the index of an operation based on its name."""
        if '_operations' in self.__dict__:
            for i, op in enumerate(self._operations):
                if op.name == name:
                    return i
        return None

    def disp(self, name):
        """ display the content of an operation """
        idx = self.find(name)
        if idx is not None:
            return self._operations[idx].__repr__()
        else:
            return "Operation not found"

    def clearall(self):
        """ clear all operations """
        self._operations = []

    def delete(self, name):
        """
        Deletes one or more stored operations based on their names.

        Parameters:
        -----------
        name : str, list, or tuple
            The name(s) of the operation(s) to delete. If a list or tuple is provided,
            all specified operations will be deleted.

        Usage:
        ------
        G.delete('operation_name')
        G.delete(['operation1', 'operation2'])
        G.delete(('operation1', 'operation2'))

        Raises:
        -------
        ValueError
            If any of the specified operations are not found.
        """
        # Handle a single string, list, or tuple
        if isinstance(name, (list, tuple)):
            not_found = []
            for n in name:
                idx = self.find(n)
                if idx is not None:
                    del self._operations[idx]
                else:
                    not_found.append(n)
            # If any names were not found, raise an exception
            if not_found:
                raise ValueError(f"Operation(s) {', '.join(not_found)} not found.")
        elif isinstance(name, str):
            idx = self.find(name)
            if idx is not None:
                del self._operations[idx]
            else:
                raise ValueError(f"Operation {name} not found.")
        else:
            raise TypeError("The 'name' parameter must be a string, list, or tuple.")

    def copy(self, source_name, new_name):
        """
        Copies a stored operation to a new operation with a different name.

        Parameters:
        source_name: str
            Name of the source operation to copy
        new_name: str
            Name of the new operation

        Usage:
        G.copy('source_operation', 'new_operation')
        """
        idx = self.find(source_name)
        if idx is not None:
            copied_operation = self._operations[idx].clone()
            copied_operation.name = new_name
            self.add_operation(copied_operation)
        else:
            raise ValueError(f"Operation {source_name} not found.")

    def rename(self, old_name, new_name):
        """
        Rename a stored operation.

        Parameters:
        old_name: str
            Current name of the operation
        new_name: str
            New name to assign to the operation

        Usage:
        G.rename('old_operation', 'new_operation')
        """
        idx = self.find(old_name)
        if idx is not None:
            if new_name == old_name:
                raise ValueError("The new name should be different from the previous one.")
            elif new_name in self.list():
                raise ValueError("Operation name must be unique.")
            self._operations[idx].name = new_name
        else:
            raise ValueError(f"Operation '{old_name}' not found.")

    def reindex(self, name, new_idx):
        """
        Change the index of a stored operation.

        Parameters:
        name: str
            Name of the operation to reindex
        new_idx: int
            New index for the operation

        Usage:
        G.reindex('operation_name', 2)
        """
        idx = self.find(name)
        if idx is not None and 0 <= new_idx < len(self._operations):
            op = self._operations.pop(idx)
            self._operations.insert(new_idx, op)
        else:
            raise ValueError(f"Operation '{name}' not found or new index {new_idx} out of range.")

    # ------------- indexing and attribute overloading

    def __getitem__(self, key):
        """
            Enable shorthand for G.operations[G.find(operation_name)] using G[operation_name],
            or accessing operation by index using G[index].
        """
        if isinstance(key, str):
            idx = self.find(key)
            if idx is not None:
                return self._operations[idx]
            else:
                raise KeyError(f"Operation '{key}' not found.")
        elif isinstance(key, int):
            if -len(self._operations) <= key < len(self._operations):
                return self._operations[key]
            else:
                raise IndexError("Operation index out of range.")
        else:
            raise TypeError("Key must be an operation name (string) or index (integer).")


    def __getattr__(self, name):
        """
        Allows accessing operations via attribute-style notation.
        If the attribute is one of the core attributes, returns it directly.
        For other attributes, searches for an operation with a matching name
        in the _operations list.

        Parameters:
        -----------
        name : str
            The name of the attribute or operation to access.

        Returns:
        --------
        The value of the attribute if it's a core attribute, or the operation
        associated with the specified name if found in _operations.

        Raises:
        -------
        AttributeError
            If the attribute or operation is not found.
        """
        # Handle core attributes directly
        if name in {'_name', '_operations', 'printflag', 'verbose'}:
            # Use object.__getattribute__ to avoid recursion
            return object.__getattribute__(self, name)
        # Search for the operation in _operations
        elif '_operations' in self.__dict__:
            for op in self._operations:
                if op.name == name:
                    return op
        # If not found, raise an AttributeError
        raise AttributeError(f"Attribute or operation '{name}' not found.")


    def __setattr__(self, name, value):
        """
        Allows deletion of an operation via 'G.operation_name = []' after construction.
        During construction, attributes are set normally.
        """
        if getattr(self, '_in_construction', True):
            # During construction, set attributes normally
            super().__setattr__(name, value)
        else:
            # After construction
            if isinstance(value, list) and len(value) == 0:
                # Handle deletion syntax
                idx = self.find(name)
                if idx is not None:
                    del self._operations[idx]
                else:
                    raise AttributeError(f"Operation '{name}' not found for deletion.")
            else:
                # Set attribute normally
                super().__setattr__(name, value)


    def _get_subobject(self, key):
        """
        Retrieves a subobject based on the provided key.

        Parameters:
        -----------
        key : str or int
            The key used to retrieve the subobject.

        Returns:
        --------
        Operation
            The operation corresponding to the key.

        Raises:
        -------
        KeyError
            If the key is not found.
        IndexError
            If the index is out of range.
        TypeError
            If the key is not a string or integer.
        """
        if isinstance(key, str):
            # If key is a string, treat it as a group name
            for operation in self._operations:
                if operation.name == key:
                    return operation
            raise KeyError(f"No operation found with name '{key}'.")
        elif isinstance(key, int):
            # If key is an integer, treat it as an index (0-based)
            if 0 <= key < len(self._operations):
                return self._operations[key]
            else:
                raise IndexError(f"Index {key} is out of range.")
        else:
            raise TypeError("Key must be a string or integer.")


    def __call__(self, *keys):
        """
        Allows subindexing of the group object using callable syntax with multiple keys.

        Parameters:
        -----------
        *keys : str or int
            One or more keys used to retrieve subobjects or perform subindexing.

        Returns:
        --------
        group
            A new group instance containing the specified operations.

        Example:
        --------
        subG = G('a', 1, 'c')  # Retrieves operations 'a', second operation, and 'c'
        """
        selected_operations = []
        for key in keys:
            operation = self._get_subobject(key)
            selected_operations.append(operation)

        # Create a new group instance with the selected operations
        new_group = group(name=f"{self._name}_subgroup", printflag=self.printflag, verbose=self.verbose)
        new_group._operations = selected_operations
        return new_group


    def operation_exists(self,operation_name):
        """
            Returns true if "operation_name" exists
            To be used by Operation, not by end-user, which should prefer find()
        """
        return any(op.name == operation_name for op in self._operations)

    def get_by_name(self,operation_name):
        """
            Returns the operation matching "operation_name"
            Usage: group.get_by_name("operation_name")
            To be used by Operation, not by end-user, which should prefer getattr()
        """
        for op in self._operations:
            if op.name == operation_name:
                return op
        raise AttributeError(f"Operation with name '{operation_name}' not found.")

    def add_operation(self,operation):
        """ add an operation """
        if operation.name in self.list():
            raise ValueError(f"The operation '{operation.name}' already exists.")
        else:
            self._operations.append(operation)

    # --------- LAMMPS methods

    def variable(self, variable_name, expression, style="atom"):
        """
        Assigns an expression to a LAMMPS variable.

        Parameters:
        - variable_name (str): The name of the variable to be assigned.
        - expression (str): The expression to assign to the variable.
        - style (str): The type of variable (default is "atom").
        """
        if not isinstance(variable_name, str):
            raise TypeError(f"Variable name must be a string, got {type(variable_name)}")
        if not isinstance(expression, str):
            raise TypeError(f"Expression must be a string, got {type(expression)}")
        if not isinstance(style, str):
            raise TypeError(f"Style must be a string, got {type(style)}")

        lammps_code = f"variable {variable_name} {style} \"{expression}\""
        op = Operation("variable", [variable_name, expression], code=lammps_code)
        self.add_operation(op)


    def byvariable(self, group_name, variable_name):
        """
        Sets a group of atoms based on a variable.

        Parameters:
        - group_name: str, the name of the group.
        - variable_name: str, the name of the variable to define the group.
        """
        if not isinstance(group_name, str):
            raise TypeError(f"Group name must be a string, got {type(group_name)}")
        if not isinstance(variable_name, str):
            raise TypeError(f"Variable name must be a string, got {type(variable_name)}")

        lammps_code = f"group {group_name} variable {variable_name}"
        op = Operation("byvariable", [variable_name], name=group_name, code=lammps_code)
        self.add_operation(op)


    def byregion(self, group_name, region_name):
        """
            set a group of atoms based on a regionID
            G.region(group_name,regionID)
        """
        lammps_code = f"group {group_name} region {region_name}"
        criteria = {"region": region_name}
        op = Operation("byregion", [region_name], name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)

    def bytype(self,  group_name, type_values):
        """
            select atoms by type and store them in group
            G.type(group_name,type_values)
        """
        if not isinstance(type_values, (list, tuple)):
            type_values = [type_values]
        lammps_code = f"group {group_name} type {span(type_values)}"
        criteria = {"type": type_values}
        op = Operation("bytype", type_values, name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)

    def byid(self, group_name, id_values):
        """
            select atoms by id and store them in group
            G.id(group_name,id_values)
        """
        if not isinstance(id_values, (list, tuple)):
            id_values = [id_values]
        lammps_code = f"group {group_name} id {span(id_values)}"
        criteria = {"id": id_values}
        op = Operation("byid", id_values, name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)

    def create(self, group_name):
        """
            create group
            G.create(group_name)
        """
        lammps_code = f"group {group_name} clear"
        criteria = {"clear": True}
        op = Operation("create", [], name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)

    def clear(self, group_name):
        """
            clear group
            G.clear(group_name)
        """
        lammps_code = f"group {group} clear"
        criteria = {"clear": True}
        op = Operation("clear", [], name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)


    def union(self,group_name, *groups):
        """
        Union group1, group2, group3 and store the result in group_name.
        Example usage:
        group.union(group_name, group1, group2, group3,...)
        """
        lammps_code = f"group {group_name} union {span(groups)}"
        criteria = {"union": groups}
        op = Operation("union", groups, name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)


    def intersect(self,group_name, *groups):
        """
        Intersect group1, group2, group3 and store the result in group_name.
        Example usage:
        group.intersect(group_name, group1, group2, group3,...)
        """
        lammps_code = f"group {group_name} intersect {span(groups)}"
        criteria = {"intersect": groups}
        op = Operation("intersect", groups, name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)


    def subtract(self,group_name, *groups):
        """
        Subtract group2, group3 from group1 and store the result in group_name.
        Example usage:
        group.subtract(group_name, group1, group2, group3,...)
        """
        lammps_code = f"group {group_name} subtract {span(groups)}"
        criteria = {"subtract": groups}
        op = Operation("subtract", groups, name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)


    def evaluate(self, group_name, group_op):
        """
        Evaluates the operation and stores the result in a new group.
        Expressions could combine +, - and * like o1+o2+o3-o4+o5+o6

        Parameters:
        -----------
        groupname : str
            The name of the group that will store the result.
        group_op : Operation
            The operation to evaluate.
        """
        if not isinstance(group_op, Operation):
            raise TypeError("Expected an instance of Operation.")

        if group_name in self.list():
            raise ValueError(f"The operation '{group_name}' already exists.")

        # If the operation is already finalized, no need to evaluate
        if group_op.isfinalized():
            if group_op.name != group_name:
                # If names differ, create a copy with the new name
                self.copy(group_op.name, group_name)
            return

        # Recursively evaluate operands
        operand_names = []
        for op in group_op.operands:
            if isinstance(op, Operation):
                if op.isfinalized():
                    # Use existing finalized operation name
                    operand_names.append(op.name)
                else:
                    # Generate a unique name if the operation doesn't have one
                    if not op.name:
                        op.name = op.generate_hashname()
                    # Recursively evaluate the operand operation
                    self.evaluate(op.name, op)
                    operand_names.append(op.name)
            else:
                operand_names.append(str(op))

        # Call the appropriate method based on the operator
        if group_op.operator == '+':
            self.union(group_name, *operand_names)
            group_op.operator = 'union'
        elif group_op.operator == '-':
            self.subtract(group_name, *operand_names)
            group_op.operator = 'subtract'
        elif group_op.operator == '*':
            self.intersect(group_name, *operand_names)
            group_op.operator = 'intersect'
        else:
            raise ValueError(f"Unknown operator: {group_op.operator}")

        # Update the operation
        group_op.name = group_name
        # Get the last added operation
        finalized_op = self._operations[-1]
        group_op.code = finalized_op.code
        group_op.operands = operand_names

        # Add the operation to the group's _operations if not already added
        if group_op.name not in self.list():
            self._operations.append(group_op)


    def add_group_criteria(self, *args, **kwargs):
        """
        Adds group(s) using existing methods based on key-value pairs.

        Supports two usages:
        1. add_group_criteria(group_name, **criteria)
        2. add_group_criteria(group_definitions)

        Parameters:
        - group_name (str): The name of the group.
        - **criteria: Criteria for group creation.

        OR

        - group_definitions (dict): A dictionary where keys are group names and values are criteria dictionaries.

        Raises:
        - TypeError: If arguments are invalid.

        Usage:
        - G.add_group_criteria('group_name', type=[1,2])
        - G.add_group_criteria({'group1': {'type': [1]}, 'group2': {'region': 'regionID'}})
        """
        if len(args) == 1 and isinstance(args[0], dict):
            # Called with group_definitions dict
            group_definitions = args[0]
            for group_name, criteria in group_definitions.items():
                self.add_group_criteria_single(group_name, **criteria)
        elif len(args) == 1 and isinstance(args[0], str):
            # Called with group_name and criteria
            group_name = args[0]
            if not kwargs:
                raise ValueError(f"No criteria provided for group '{group_name}'.")
            self.add_group_criteria_single(group_name, **kwargs)
        else:
            raise TypeError("Invalid arguments. Use add_group_criteria(group_name, **criteria) or add_group_criteria(group_definitions).")


    def add_group_criteria_single(self, group_name, **criteria):
        """
        Adds a single group based on criteria.

        Parameters:
        - group_name (str): The name of the group.
        - **criteria: Criteria for group creation.

        Raises:
        - TypeError: If group_name is not a string.
        - ValueError: If no valid criteria are provided or if criteria are invalid.


        Example (advanced):
            G = group()
            group_definitions = {
                'myGroup': {
                    'variable': {
                        'name': 'myVar',
                        'expression': 'x > 5',
                        'style': 'atom'
                    }
                }
            }
            G.add_group_criteria(group_definitions)
            print(G.code())

        Expected output
            variable myVar atom "x > 5"
            group myGroup variable myVar
        """
        if not isinstance(group_name, str):
            raise TypeError(f"Group name must be a string, got {type(group_name)}")

        if not criteria:
            raise ValueError(f"No criteria provided for group '{group_name}'.")

        if "type" in criteria:
            type_values = criteria["type"]
            if not isinstance(type_values, (list, tuple, int)):
                raise TypeError("Type values must be an integer or a list/tuple of integers.")
            self.bytype(group_name, type_values)

        elif "region" in criteria:
            region_name = criteria["region"]
            if not isinstance(region_name, str):
                raise TypeError("Region name must be a string.")
            self.byregion(group_name, region_name)

        elif "id" in criteria:
            id_values = criteria["id"]
            if not isinstance(id_values, (list, tuple, int)):
                raise TypeError("ID values must be an integer or a list/tuple of integers.")
            self.byid(group_name, id_values)

        elif "variable" in criteria:
            var_info = criteria["variable"]
            if not isinstance(var_info, dict):
                raise TypeError("Variable criteria must be a dictionary.")
            required_keys = {'name', 'expression'}
            if not required_keys.issubset(var_info.keys()):
                missing = required_keys - var_info.keys()
                raise ValueError(f"Variable criteria missing keys: {missing}")
            var_name = var_info['name']
            expression = var_info['expression']
            style = var_info.get('style', 'atom')

            # First, assign the variable
            self.variable(var_name, expression, style)

            # Then, create the group based on the variable
            self.byvariable(group_name, var_name)

        elif "union" in criteria:
            groups = criteria["union"]
            if not isinstance(groups, (list, tuple)):
                raise TypeError("Union groups must be a list or tuple of group names.")
            self.union(group_name, *groups)

        elif "intersect" in criteria:
            groups = criteria["intersect"]
            if not isinstance(groups, (list, tuple)):
                raise TypeError("Intersect groups must be a list or tuple of group names.")
            self.intersect(group_name, *groups)

        elif "subtract" in criteria:
            groups = criteria["subtract"]
            if not isinstance(groups, (list, tuple)):
                raise TypeError("Subtract groups must be a list or tuple of group names.")
            self.subtract(group_name, *groups)

        elif "create" in criteria and criteria["create"]:
            self.create(group_name)

        elif "clear" in criteria and criteria["clear"]:
            self.clear(group_name)

        else:
            raise ValueError(f"No valid criterion provided for group '{group_name}'.")




    def get_group_criteria(self, group_name):
        """
        Retrieve the criteria that define a group. Handles group_name as a string or number.

        Parameters:
        - group_name: str or int, the name or number of the group.

        Returns:
        - dict or str: The criteria used to define the group, or a message if defined by multiple criteria.

        Raises:
        - ValueError: If the group does not exist.
        """
        # Check if group_name exists in _operations
        if group_name not in self._operations:
            raise ValueError(f"Group '{group_name}' does not exist.")

        # Retrieve all operations related to the group directly from _operations
        operations = self._operations[group_name]

        if not operations:
            raise ValueError(f"No operations found for group '{group_name}'.")

        criteria = {}
        for op in operations:
            if op.criteria:
                criteria.update(op.criteria)

        # If multiple criteria, return a message
        if len(criteria) > 1:
            return f"Group '{group_name}' is defined by multiple criteria: {criteria}"

        return criteria


    def generate_group_definitions_from_collection(self,collection):
        """
        Generates group definitions based on the collection of groupobject instances.

        This method populates the groups based on beadtypes and associated group names.
        """
        group_defs = {}
        for obj in collection:
            for group_name in obj.group:
                if group_name not in group_defs:
                    group_defs[group_name] = {'type': []}
                if obj.beadtype not in group_defs[group_name]['type']:
                    group_defs[group_name]['type'].append(obj.beadtype)
        # Now, add these group definitions
        self.add_group_criteria(group_defs)


    def dscript(self, name=None, printflag=None, verbose=None, verbosity=None):
        """
        Generates a dscript object containing the group's LAMMPS commands.

        Parameters:
        - name (str): Optional name for the script object.
        - printflag (bool, default=False): print on the current console if True
        - verbose (bool, default=True): keep comments if True

        Returns:
        - dscript: A dscript object containing the group's code.
        """
        if name is None:
            name = self._name
        printflag = self.printflag if printflag is None else printflag
        verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
        verbosity = 0 if not verbose else verbosity
        # Create a new dscript object
        dscript_obj = dscript(name=name,printflag=printflag, verbose=verbose, verbosity=verbosity)
        # Add each line of the group's code to the script object
        for idx, op in enumerate(self._operations):
            # Use the index as the key for the script line
           dscript_obj[idx] = op.code
        return dscript_obj


    def script(self, name=None, printflag=None, verbose=None, verbosity=None):
        """
        Generates a script object containing the group's LAMMPS commands.

        Parameters:
        - name (str): Optional name for the script object.
        - printflag (bool, default=False): print on the current console if True
        - verbose (bool, default=True): keep comments if True

        Returns:
        - script: A script object containing the group's code.
        """
        if name is None:
            name = self._name
        printflag = self.printflag if printflag is None else printflag
        verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
        verbosity = 0 if not verbose else verbosity
        script_obj = self.dscript(name=name,printflag=printflag, verbose=verbose, verbosity=verbosity).script(printflag=printflag, verbose=verbose, verbosity=verbosity)
        return script_obj


    def pipescript(self, printflag=None, verbose=None, verbosity=None):
        """
        Generates a pipescript object containing the group's LAMMPS commands.

        Parameters:
        - printflag (bool, default=False): print on the current console if True
        - verbose (bool, default=True): keep comments if True

        Returns:
        - pipescript: A pipescript object containing the group's code lines as individual scripts in the pipeline.
        """
        printflag = self.printflag if printflag is None else printflag
        verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
        verbosity = 0 if not verbose else verbosity
        # Create a list to hold script objects
        script_list = []

        # For each operation, create a script object and add it to the list
        for op in self._operations:
            # Create a dscript object with the code line
            dscript_obj = dscript(printflag=printflag, verbose=verbose, verbosity=verbosity)
            dscript_obj["dummy"] = op.code

            # Convert the dscript to a script object
            script_obj = dscript_obj.script(printflag=printflag, verbose=verbose, verbosity=verbosity)

            # Add the script object to the list
            script_list.append(script_obj)

        # Use the static method 'join' to create a pipescript from the list
        if script_list:
            pipe_obj = pipescript.join(script_list)
        else:
            pipe_obj = pipescript()

        return pipe_obj

    # Note that it was not the original intent to copy scripts
    def __copy__(self):
        """ copy method """
        cls = self.__class__
        copie = cls.__new__(cls)
        copie.__dict__.update(self.__dict__)
        return copie

    def __deepcopy__(self, memo):
        """ deep copy method """
        cls = self.__class__
        copie = cls.__new__(cls)
        memo[id(self)] = copie
        for k, v in self.__dict__.items():
            setattr(copie, k, copy.deepcopy(v, memo))
        return copie


    def count(self,name=None, selection: Optional[List[str]] = None) -> 'dscript':
        """
        Generates DSCRIPT counters for specified groups with LAMMPS variable definitions and print commands.

        The method retrieves the list of group names using `self.list()`. If `selection` is provided, it
        filters the groups to include only those specified. It then creates a variable for each selected
        group that counts the number of atoms in that group and generates corresponding print commands.
        The commands are encapsulated within a `dscript` object for execution.

        ### Parameters:
            selection (list of str, optional):
                - List of group names to be numbered.
                - If `None`, all groups in the collection are numbered.

        ### Returns:
            dscript: A `dscript` object containing the variable definitions and print commands, formatted as follows:
                     ```
                     variable n_lower equal "count(lower)"
                     variable n_middle equal "count(middle)"
                     variable n_upper equal "count(upper)"

                     print "Number of atoms in lower: ${n_lower}"
                     print "Number of atoms in middle: ${n_middle}"
                     print "Number of atoms in upper: ${n_upper}"
                     ```
                     The `variables` attribute holds the variable definitions, and the `printvariables` attribute
                     holds the print commands, each separated by a newline.

        ### Raises:
            ValueError:
                - If any group specified in `selection` does not exist in the collection.
                - If `selection` contains duplicate group names.
            TypeError:
                - If `selection` is not a list of strings.

        ### Example:
            ```python
            # Create groupobject instances
            g1 = groupobject(beadtype=1,group = 'lower')
            g2 = groupobject(beadtype=2,group = 'middle')
            g3 = groupobject(beadtype=3,group = 'upper')

            # Initialize a group with the groupobjects
            G = group(name="1+2+3",collection=(g1,g2,g3)) or collection=g1+g2+g3

            # add other groups
            G.evaluate("all", G.lower + G.middle + G.upper)
            G.evaluate("external", G.all - G.middle)

            # Generate number commands for all groups
            N = G.count(selection=["all","lower","middle","lower"])
            print(N.do())
            ```

            **Output:**
            ```
            variable n_all equal "count(all)"
            variable n_lower equal "count(lower)"
            variable n_middle equal "count(middle)"
            variable n_upper equal "count(upper)"
            print "Number of atoms in all: ${n_all}"
            print "Number of atoms in lower: ${n_lower}"
            print "Number of atoms in middle: ${n_middle}"
            print "Number of atoms in upper: ${n_upper}"
            ```
        """
        # Retrieve the list of all group names
        all_group_names = self.list()

        # If selection is provided, validate it
        if selection is not None:
            if not isinstance(selection, (list, tuple)):
                raise TypeError("Parameter 'selection' must be a list of group names.")
            if not all(isinstance(gitem, str) for gitem in selection):
                raise TypeError("All items in 'selection' must be strings representing group names.")
            if len(selection) != len(set(selection)):
                raise ValueError("Duplicate group names found in 'selection'. Each group should be unique.")
            # Check that all selected groups exist
            missing_groups = set(selection) - set(all_group_names)
            if missing_groups:
                raise ValueError(f"The following groups are not present in the collection: {', '.join(missing_groups)}")
            # Use the selected groups
            target_groups = selection
        else:
            # If no selection, target all groups
            target_groups = all_group_names

        # Initialize lists to hold variable definitions and print commands
        variable_definitions = []
        print_commands = []
        for gitem in target_groups:
            # Validate group name
            if not gitem:
                raise ValueError("Group names must be non-empty strings.")
            # Create a valid variable name by replacing any non-alphanumeric characters with underscores
            variable_name = f"n_{gitem}".replace(" ", "_").replace("-", "_")
            # Define the variable to count the number of atoms in the group
            variable_definitions.append(f'variable {variable_name} equal "count({gitem})"')
            # Define the corresponding print command
            print_commands.append(f'print "Number of atoms in {gitem}: ${{{variable_name}}}"')
        # Create a dscript object with the generated commands
        idD = f"<dscript:group:{self._name}:count>"
        D = dscript(
            name=idD if name is None else name,
            description=f"{idD} for group counts"
        )
        D.variables = "\n".join(variable_definitions)
        D.printvariables = "\n".join(print_commands)
        D.set_all_variables()
        return D



# %% debug section - generic code to test methods (press F5)
if __name__ == '__main__':

    # Example Usage
    G = group()
    G.variable("groupname","variablename","myexpression myexpression myexpression myexpression and again 1234")
    print(G.disp("groupname"))

    G.byregion("regiongroup","myregionID")
    print(G.disp("regiongroup"))

    G.union("uniongroup","group1","group2","group3")
    print(G.disp("uniongroup"))

    # LAMMPS example
    #     group myGroup region myRegion
    #     group typeGroup type 1 2
    #     variable myVar atom "x + y"
    #     group varGroup variable myVar
    #     group unionGroup union myGroup typeGroup
    # Assuming Operation class is properly defined and includes necessary methods
    G0 = group()
    G0.byregion('myGroup', 'myRegion')
    G0.bytype('typeGroup', [1, 2])
    G0.variable('myVar', 'x + y')
    G0.byvariable('varGroup', 'myVar')
    # Perform group operations
    union_op = G0['myGroup'] + G0['typeGroup']
    G0.evaluate('unionGroup', union_op)
    # Generate LAMMPS script
    print(G0.code())


    # Advanced Usage
    G = group()
    G.create_groups('o1', 'o2', 'o3', 'o4')
    G.create('o5')
    G.create('o6')
    G.create('o7')
    G.evaluate("debug0",G.o1+G.o2+G.o3 + G.o4 + (G.o5 +G.o6) + G.o7)
    G.evaluate("debug1",G.o1+G.o2)
    G.evaluate("debug2",G.o1+G.o2+G.o3-(G.o4+G.o5)+(G.o6*G.o7))
    print(repr(G))

    # Example to prepare workshop
    G = group()
    G.add_group_criteria("lower", type=[1])
    G.add_group_criteria("central", region="central_cyl")
    G.add_group_criteria("new_group", create=True)
    G.add_group_criteria("upper", clear=True)
    G.add_group_criteria("subtract_group", subtract=["group1", "group2"])

Functions

def format_table(headers: List[str], rows: List[List[str]], col_max_widths: List[int], align: Union[str, List[str]] = 'C') ‑> str

Formats a table with given headers and rows, truncating text based on maximum column widths and aligning content based on the specified alignment.

Parameters:

headers (List[str]): List of column headers.
rows (List[List[str]]): List of rows, where each row is a list of column values.
col_max_widths (List[int]): List of maximum widths for each column.
align (str or List[str], optional): Alignment for each column. Can be a single string
                                    (applies to all columns) or a list of strings (one for each column).
                                    Options are:
                                    - "C" or "center" for centered alignment (default).
                                    - "L" or "left" for left alignment.
                                    - "R" or "right" for right alignment.
                                    Case insensitive.

Returns:

str: A formatted table as a string.

Raises:

ValueError: If <code>align</code> is a list but its length does not match the number of columns.

Example:

>>> headers = ["Idx", "Name", "Value"]
>>> rows = [[1, "Example", "12345"], [2, "LongerName", "67890"]]
>>> col_max_widths = [5, 10, 8]
>>> align = ["R", "C", "L"]
>>> print(format_table(headers, rows, col_max_widths, align))
Idx   |   Name    | Value
----- | ---------- | --------
  1   |  Example  | 12345
  2   | LongerName | 67890
Expand source code
def format_table(
    headers: List[str],
    rows: List[List[str]],
    col_max_widths: List[int],
    align: Union[str, List[str]] = "C",
) -> str:
    """
    Formats a table with given headers and rows, truncating text based on maximum column widths
    and aligning content based on the specified alignment.

    ### Parameters:
        headers (List[str]): List of column headers.
        rows (List[List[str]]): List of rows, where each row is a list of column values.
        col_max_widths (List[int]): List of maximum widths for each column.
        align (str or List[str], optional): Alignment for each column. Can be a single string
                                            (applies to all columns) or a list of strings (one for each column).
                                            Options are:
                                            - "C" or "center" for centered alignment (default).
                                            - "L" or "left" for left alignment.
                                            - "R" or "right" for right alignment.
                                            Case insensitive.

    ### Returns:
        str: A formatted table as a string.

    ### Raises:
        ValueError: If `align` is a list but its length does not match the number of columns.

    ### Example:
        >>> headers = ["Idx", "Name", "Value"]
        >>> rows = [[1, "Example", "12345"], [2, "LongerName", "67890"]]
        >>> col_max_widths = [5, 10, 8]
        >>> align = ["R", "C", "L"]
        >>> print(format_table(headers, rows, col_max_widths, align))
        Idx   |   Name    | Value
        ----- | ---------- | --------
          1   |  Example  | 12345
          2   | LongerName | 67890
    """
    # Normalize alignment input
    if isinstance(align, str):
        align = [align.upper()] * len(headers)
    elif isinstance(align, list):
        align = [a.upper() for a in align]
        if len(align) != len(headers):
            raise ValueError("The length of `align` must match the number of columns.")
    else:
        raise TypeError("`align` must be a string or a list of strings.")

    # Determine actual column widths based on headers and max widths
    col_widths = [
        min(max(len(header), *(len(str(row[i])) for row in rows)), max_w)
        for i, header in enumerate(headers)
        for max_w in [col_max_widths[i]]
    ]
    # Prepare alignment formatting
    alignments = []
    for a in align:
        if a in ("C", "CENTER"):
            alignments.append("^")
        elif a in ("L", "LEFT"):
            alignments.append("<")
        elif a in ("R", "RIGHT"):
            alignments.append(">")
        else:
            raise ValueError(f"Invalid alignment value: {a}")
    # Prepare header line
    header_line = " | ".join(
        f"{headers[i]:{alignments[i]}{col_widths[i]}}" for i in range(len(headers))
    )
    # Prepare separator
    separator = " | ".join("-" * col_widths[i] for i in range(len(col_widths)))
    # Prepare rows
    formatted_rows = []
    for row in rows:
        formatted_row = [
            f"{str(row[i]):{alignments[i]}{col_widths[i]}}" for i in range(len(row))
        ]
        formatted_rows.append(" | ".join(formatted_row))
    # Combine all parts
    table = [header_line, separator] + formatted_rows + [separator]
    return "\n".join(table)
def generate_random_name(length=8)
Expand source code
def generate_random_name(length=8):
    letters = string.ascii_letters  # 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
    return ''.join(random.choice(letters) for _ in range(length))
def span(vector, sep=' ', left='', right='')
Expand source code
def span(vector,sep=" ",left="",right=""):
    return left + (vector if isinstance(vector, str) else sep.join(map(str, vector))) + right if vector is not None else ""
def truncate_text(txt, maxwidth)

Truncates the input text to fit within the specified maximum width. If the text is longer than maxwidth, it is shortened by keeping the beginning and trailing parts, separated by " […] ".

Parameters:

txt (str): The text to truncate.
maxwidth (int): The maximum allowed width for the text.

Returns:

str: The truncated text with " [...] " in the middle if truncation occurs.

Example:

>>> truncate_text("This is a long string that needs truncation.", 20)
'This is [...] truncation.'
Expand source code
def truncate_text(txt, maxwidth):
    """
    Truncates the input text to fit within the specified maximum width.
    If the text is longer than `maxwidth`, it is shortened by keeping
    the beginning and trailing parts, separated by " [...] ".

    ### Parameters:
        txt (str): The text to truncate.
        maxwidth (int): The maximum allowed width for the text.

    ### Returns:
        str: The truncated text with " [...] " in the middle if truncation occurs.

    ### Example:
        >>> truncate_text("This is a long string that needs truncation.", 20)
        'This is [...] truncation.'
    """
    # If the text fits within maxwidth, return it as is
    if len(txt) <= maxwidth:
        return txt
    # Calculate the part lengths for beginning and end
    ellipsis = " [...] "
    split_length = (maxwidth - len(ellipsis)) // 2
    beginning = txt[:split_length]
    trailing = txt[-split_length:]
    return f"{beginning}{ellipsis}{trailing}"

Classes

class Operation (operator, operands, name='', code='', criteria=None)

Represents a LAMMPS group operation, allowing algebraic manipulation and code generation.

Overview

The Operation class models a group operation in LAMMPS (Large-scale Atomic/Molecular Massively Parallel Simulator), encapsulating an operator, its operands, a name, and the corresponding LAMMPS code. It supports algebraic operations between groups (union, intersection, subtraction) using the overloaded operators '+', '-', and '*', enabling users to build complex group expressions programmatically.

Attributes

  • operator (str): The operator applied to the operands. It can be:

    • A finalized LAMMPS operator: 'variable', 'byvariable', 'byregion', 'bytype', 'byid', 'create', 'clear', 'union', 'intersect', 'subtract'.
    • An algebraic operator: '+', '-', '*'.
    • An empty string or None for operations without an explicit operator.
  • operands (list): A list of operands for the operation. Operands can be:

    • Instances of Operation, representing nested operations.
    • Strings representing group names or other identifiers.
    • Integers or lists (e.g., atom types or IDs).
  • name (str): The name of the operation, which can be manually set or auto-generated.

    • If not provided, a unique hash-based name is generated using the generate_hashname method.
  • code (str): The LAMMPS code that this operation represents.

    • This is generated when the operation is finalized and corresponds to the actual command(s) that would be included in a LAMMPS input script.
  • criteria (dict, optional): Criteria used to define the operation.

    • Useful for operations like bytype, byregion, byid, etc., where the criteria specify the selection parameters.

Methods

  • __init__(self, operator, operands, name="", code="", criteria=None): Initializes a new Operation instance with the given parameters.

    • operator (str): The operator applied to the operands.
    • operands (list or single operand): The operands for the operation.
    • name (str, optional): The name of the operation.
    • code (str, optional): The LAMMPS code representing the operation.
    • criteria (dict, optional): Criteria used to define the operation.
  • append(self, operand): Appends a single operand to the operands list.

  • extend(self, operands): Extends the operands list with multiple operands.

  • is_unary(self): Checks if the Operation instance has exactly one operand.

  • is_empty(self): Checks if the Operation instance has no operands.

  • generateID(self): Generates an ID for the instance based on its operands or name. Used internally for generating unique names.

  • generate_hashname(self, prefix="grp", ID=None): Generates a hash-based name for the instance.

    • prefix (str): A string prefix for the generated name.
    • ID (str, optional): An optional ID to include in the hash. If None, it is generated from operands.
  • get_proper_operand(self): Returns the appropriate operand representation depending on whether the operation is finalized.

  • _operate(self, other, operator): Combines the operation with another Operation instance using the specified operator.

    • other (Operation): The other operation to combine with.
    • operator (str): The operator to apply ('+', '-', '*').
  • isfinalized(self): Checks if the operation has been finalized.

    • An operation is considered finalized if its operator is not one of the algebraic operators '+', '-', '*'.
  • __add__(self, other): Overloads the '+' operator to support union of two Operation instances.

  • __sub__(self, other): Overloads the '-' operator to support subtraction between two Operation instances.

  • __mul__(self, other): Overloads the '*' operator to support intersection between two Operation instances.

  • __repr__(self): Returns a detailed string representation of the operation, useful for debugging.

  • __str__(self): Returns a concise string representation of the operation.

  • script(self): Generates the LAMMPS code for this operation using dscript and script classes.

    • Returns a script object containing the LAMMPS code.

Operator Overloading

The Operation class overloads the following operators to enable algebraic manipulation:

  • Addition (+):

    • Combines two operations using union.
    • Example: op3 = op1 + op2
  • Subtraction (-):

    • Subtracts one operation from another.
    • Example: op3 = op1 - op2
  • Multiplication (*):

    • Intersects two operations.
    • Example: op3 = op1 * op2

Usage Example

# Create basic operations
op1 = Operation('bytype', [1], name='group1')
op2 = Operation('bytype', [2], name='group2')

# Combine operations using algebraic operators
combined_op = op1 + op2  # Union of group1 and group2

# Initialize group manager
G = group()
G.add_operation(op1)
G.add_operation(op2)

# Evaluate the combined operation and store the result
G.evaluate('combined_group', combined_op)

# Access the generated LAMMPS code
print(G.code())
# Output:
# group group1 type 1
# group group2 type 2
# group combined_group union group1 group2

Notes

  • Lazy Evaluation:

    • Operations are combined and stored symbolically until they are evaluated.
    • The actual LAMMPS code is generated when the operation is finalized (e.g., via the evaluate method in the group class).
  • Name Generation:

    • If an operation's name is not provided, it is auto-generated using a hash of its operator and operands.
    • This ensures uniqueness and avoids naming conflicts.
  • Criteria Attribute:

    • The criteria attribute allows storing the parameters used to define operations.
    • Useful for debugging, documentation, or reconstructing operations.
  • Finalization:

    • An operation is finalized when it represents an actual LAMMPS command and has generated code.
    • Finalized operations are not further combined; instead, their names are used in subsequent operations.
  • Integration with dscript and script:

    • The script method generates the LAMMPS code for the operation using dscript and script classes.
    • Facilitates integration with script management tools and advanced scripting workflows.
  • Error Handling:

    • Methods include error checks to ensure operands are valid and operations are correctly formed.
    • Raises informative exceptions for invalid operations or unsupported types.

Integration with Group Class

The Operation class is designed to work closely with the group class, which manages a collection of operations and handles evaluation and code generation.

  • Adding Operations:
  • Evaluating Operations:
    • Use group.evaluate()(group_name, operation) to finalize an operation and generate LAMMPS code.
  • Accessing Operations:
    • Operations can be accessed via the group instance using indexing, attribute access, or methods.

Additional Information

  • String Representations:

    • __str__: Provides a concise representation, useful for quick inspection.
    • __repr__: Provides a detailed representation, useful for debugging.
  • Example of __str__ Output:

    • For an operation representing group1 + group2: python print(str(combined_op)) # Output: <combined_group=[group1+group2]>
  • Example of __repr__ Output: python print(repr(combined_op)) # Output: # Operation Details: # - Name: combined_group # - Operator: + # - Operands: group1, group2 # - LAMMPS Code:

Examples

Defining Operations Based on Atom Types

op1 = Operation('bytype', [1], name='type1')
op2 = Operation('bytype', [2], name='type2')
G.add_operation(op1)
G.add_operation(op2)

Combining and Evaluating Operations

combined_op = op1 + op2  # Union of 'type1' and 'type2'
G.evaluate('all_types', combined_op)

Accessing Generated Code

print(G.code())
# Output:
# group type1 type 1
# group type2 type 2
# group all_types union type1 type2

Using Criteria Attribute

# Access criteria used to define an operation
print(op1.criteria)
# Output:
# {'type': [1]}

Important Notes

  • Algebraic Operators vs. Finalized Operators:

    • Algebraic operators ('+', '-', '*') are used for symbolic manipulation.
    • Finalized operators represent actual LAMMPS commands.
  • Operator Precedence:

    • When combining operations, consider the order of operations.
    • Use parentheses to ensure the desired evaluation order.
  • Integration with Scripting Tools:

    • The script method allows operations to be integrated into larger scripting workflows.
    • Supports advanced features like variable substitution and conditional execution.

Conclusion

The Operation class provides a powerful and flexible way to model and manipulate group operations in LAMMPS simulations. By supporting algebraic operations and integrating with script management tools, it enables users to build complex group definitions programmatically, enhancing productivity and reducing errors in simulation setup.

Initializes a new Operation instance with given parameters.

Expand source code
class Operation:
    """
    Represents a LAMMPS group operation, allowing algebraic manipulation and code generation.

    ### Overview

    The `Operation` class models a group operation in LAMMPS (Large-scale Atomic/Molecular Massively
    Parallel Simulator), encapsulating an operator, its operands, a name, and the corresponding LAMMPS code.
    It supports algebraic operations between groups (union, intersection, subtraction) using the
    overloaded operators '+', '-', and '*', enabling users to build complex group expressions
    programmatically.

    ### Attributes

    - **operator (str)**: The operator applied to the operands. It can be:
        - A finalized LAMMPS operator: `'variable'`, `'byvariable'`, `'byregion'`, `'bytype'`, `'byid'`, `'create'`, `'clear'`,
          `'union'`, `'intersect'`, `'subtract'`.
        - An algebraic operator: `'+'`, `'-'`, `'*'`.
        - An empty string or `None` for operations without an explicit operator.

    - **operands (list)**: A list of operands for the operation. Operands can be:
        - Instances of `Operation`, representing nested operations.
        - Strings representing group names or other identifiers.
        - Integers or lists (e.g., atom types or IDs).

    - **name (str)**: The name of the operation, which can be manually set or auto-generated.
        - If not provided, a unique hash-based name is generated using the `generate_hashname` method.

    - **code (str)**: The LAMMPS code that this operation represents.
        - This is generated when the operation is finalized and corresponds to the actual command(s)
          that would be included in a LAMMPS input script.

    - **criteria (dict, optional)**: Criteria used to define the operation.
        - Useful for operations like `bytype`, `byregion`, `byid`, etc., where the criteria specify
          the selection parameters.

    ### Methods

    - `__init__(self, operator, operands, name="", code="", criteria=None)`:
        Initializes a new `Operation` instance with the given parameters.
        - **operator** (str): The operator applied to the operands.
        - **operands** (list or single operand): The operands for the operation.
        - **name** (str, optional): The name of the operation.
        - **code** (str, optional): The LAMMPS code representing the operation.
        - **criteria** (dict, optional): Criteria used to define the operation.

    - `append(self, operand)`:
        Appends a single operand to the operands list.

    - `extend(self, operands)`:
        Extends the operands list with multiple operands.

    - `is_unary(self)`:
        Checks if the `Operation` instance has exactly one operand.

    - `is_empty(self)`:
        Checks if the `Operation` instance has no operands.

    - `generateID(self)`:
        Generates an ID for the instance based on its operands or name.
        Used internally for generating unique names.

    - `generate_hashname(self, prefix="grp", ID=None)`:
        Generates a hash-based name for the instance.
        - **prefix** (str): A string prefix for the generated name.
        - **ID** (str, optional): An optional ID to include in the hash. If `None`, it is generated from operands.

    - `get_proper_operand(self)`:
        Returns the appropriate operand representation depending on whether the operation is finalized.

    - `_operate(self, other, operator)`:
        Combines the operation with another `Operation` instance using the specified operator.
        - **other** (`Operation`): The other operation to combine with.
        - **operator** (str): The operator to apply ('+', '-', '*').

    - `isfinalized(self)`:
        Checks if the operation has been finalized.
        - An operation is considered finalized if its operator is not one of the algebraic operators '+', '-', '*'.

    - `__add__(self, other)`:
        Overloads the '+' operator to support union of two `Operation` instances.

    - `__sub__(self, other)`:
        Overloads the '-' operator to support subtraction between two `Operation` instances.

    - `__mul__(self, other)`:
        Overloads the '*' operator to support intersection between two `Operation` instances.

    - `__repr__(self)`:
        Returns a detailed string representation of the operation, useful for debugging.

    - `__str__(self)`:
        Returns a concise string representation of the operation.

    - `script(self)`:
        Generates the LAMMPS code for this operation using `dscript` and `script` classes.
        - Returns a `script` object containing the LAMMPS code.

    ### Operator Overloading

    The `Operation` class overloads the following operators to enable algebraic manipulation:

    - **Addition (`+`)**:
        - Combines two operations using union.
        - Example: `op3 = op1 + op2`

    - **Subtraction (`-`)**:
        - Subtracts one operation from another.
        - Example: `op3 = op1 - op2`

    - **Multiplication (`*`)**:
        - Intersects two operations.
        - Example: `op3 = op1 * op2`

    ### Usage Example

    ```python
    # Create basic operations
    op1 = Operation('bytype', [1], name='group1')
    op2 = Operation('bytype', [2], name='group2')

    # Combine operations using algebraic operators
    combined_op = op1 + op2  # Union of group1 and group2

    # Initialize group manager
    G = group()
    G.add_operation(op1)
    G.add_operation(op2)

    # Evaluate the combined operation and store the result
    G.evaluate('combined_group', combined_op)

    # Access the generated LAMMPS code
    print(G.code())
    # Output:
    # group group1 type 1
    # group group2 type 2
    # group combined_group union group1 group2
    ```

    ### Notes

    - **Lazy Evaluation**:
        - Operations are combined and stored symbolically until they are evaluated.
        - The actual LAMMPS code is generated when the operation is finalized (e.g., via the `evaluate` method in the `group` class).

    - **Name Generation**:
        - If an operation's name is not provided, it is auto-generated using a hash of its operator and operands.
        - This ensures uniqueness and avoids naming conflicts.

    - **Criteria Attribute**:
        - The `criteria` attribute allows storing the parameters used to define operations.
        - Useful for debugging, documentation, or reconstructing operations.

    - **Finalization**:
        - An operation is finalized when it represents an actual LAMMPS command and has generated code.
        - Finalized operations are not further combined; instead, their names are used in subsequent operations.

    - **Integration with `dscript` and `script`**:
        - The `script` method generates the LAMMPS code for the operation using `dscript` and `script` classes.
        - Facilitates integration with script management tools and advanced scripting workflows.

    - **Error Handling**:
        - Methods include error checks to ensure operands are valid and operations are correctly formed.
        - Raises informative exceptions for invalid operations or unsupported types.

    ### Integration with Group Class

    The `Operation` class is designed to work closely with the `group` class, which manages a collection of operations and handles evaluation and code generation.

    - **Adding Operations**:
        - Use `group.add_operation(operation)` to add an `Operation` instance to a `group`.
    - **Evaluating Operations**:
        - Use `group.evaluate(group_name, operation)` to finalize an operation and generate LAMMPS code.
    - **Accessing Operations**:
        - Operations can be accessed via the `group` instance using indexing, attribute access, or methods.

    ### Additional Information

    - **String Representations**:
        - `__str__`: Provides a concise representation, useful for quick inspection.
        - `__repr__`: Provides a detailed representation, useful for debugging.

    - **Example of `__str__` Output**:
        - For an operation representing `group1 + group2`:
          ```python
          print(str(combined_op))
          # Output: <combined_group=[group1+group2]>
          ```

    - **Example of `__repr__` Output**:
        ```python
        print(repr(combined_op))
        # Output:
        # Operation Details:
        #   - Name: combined_group
        #   - Operator: +
        #   - Operands: group1, group2
        #   - LAMMPS Code:
        ```

    ### Examples

    **Defining Operations Based on Atom Types**

    ```python
    op1 = Operation('bytype', [1], name='type1')
    op2 = Operation('bytype', [2], name='type2')
    G.add_operation(op1)
    G.add_operation(op2)
    ```

    **Combining and Evaluating Operations**

    ```python
    combined_op = op1 + op2  # Union of 'type1' and 'type2'
    G.evaluate('all_types', combined_op)
    ```

    **Accessing Generated Code**

    ```python
    print(G.code())
    # Output:
    # group type1 type 1
    # group type2 type 2
    # group all_types union type1 type2
    ```

    **Using Criteria Attribute**

    ```python
    # Access criteria used to define an operation
    print(op1.criteria)
    # Output:
    # {'type': [1]}
    ```

    ### Important Notes

    - **Algebraic Operators vs. Finalized Operators**:
        - Algebraic operators ('+', '-', '*') are used for symbolic manipulation.
        - Finalized operators represent actual LAMMPS commands.

    - **Operator Precedence**:
        - When combining operations, consider the order of operations.
        - Use parentheses to ensure the desired evaluation order.

    - **Integration with Scripting Tools**:
        - The `script` method allows operations to be integrated into larger scripting workflows.
        - Supports advanced features like variable substitution and conditional execution.

    ### Conclusion

    The `Operation` class provides a powerful and flexible way to model and manipulate group operations in LAMMPS simulations. By supporting algebraic operations and integrating with script management tools, it enables users to build complex group definitions programmatically, enhancing productivity and reducing errors in simulation setup.

    """

    def __init__(self, operator, operands, name="", code="", criteria=None):
        """Initializes a new Operation instance with given parameters."""
        self.operator = operator
        self.operands = operands if isinstance(operands, list) else [operands]
        if not name:
            self.name = self.generate_hashname()
        else:
            self.name = name
        self.code = code
        self.criteria = criteria  # Store criteria for this operation

    def append(self, operand):
        """Appends a single operand to the operands list of the Operation instance."""
        self.operands.append(operand)

    def extend(self, operands):
        """Extends the operands list of the Operation instance with multiple operands."""
        self.operands.extend(operands)

    def is_unary(self):
        """Checks if the Operation instance has exactly one operand."""
        return len(self.operands) == 1

    def is_empty(self):
        """Checks if the Operation instance has no operands."""
        return len(self.operands) == 0

    def generateID(self):
        """Generates an ID for the Operation instance based on its operands or name."""
        # if self.operands:
        #     return "".join([str(op) for op in self.operands])
        # else:
        #     return self.name
        operand_ids = ''.join(op.name if isinstance(op, Operation) else str(op) for op in self.operands)
        return operand_ids

    def generate_hashname(self,prefix="grp",ID=None):
        """Generates an ID for the Operation instance based on its operands or name."""
        # if ID is None: ID = self.generateID()
        # s = self.operator+ID
        # return prefix+hashlib.sha256(s.encode()).hexdigest()[:6]
        if ID is None:
            ID = self.generateID()
        s = self.operator + ID
        return prefix + hashlib.sha256(s.encode()).hexdigest()[:6]

    def __repr__(self):
        """ detailed representation """
        operand_str = ', '.join(str(op) for op in self.operands)
        return (
            f"Operation Details:\n"
            f"  - Name: {self.name}\n"
            f"  - Operator: {self.operator}\n"
            f"  - Operands: {operand_str}\n"
            f"  - LAMMPS Code: {self.code}"
        )

    def __str__(self):
        """ string representation """
        operands = [str(op) for op in self.operands]
        operand_str = ', '.join(operands)
        if self.is_empty():
            return f"<{self.operator} {self.name}>"

        elif self.is_unary():
            return f"<{self.name}={self.operator}({operand_str})>"
        else:  # Polynary
            operator_symbol_mapping = {
                'union': '+',
                'intersect': '*',
                'subtract': '-'
            }
            operator_symbol = operator_symbol_mapping.get(self.operator, self.operator)
            formatted_operands = operator_symbol.join(operands)
            return f"<{self.name}=[{formatted_operands}]>"

    def isfinalized(self):
        """
        Checks whether the Operation instance is finalized.
        Returns:
        - bool: True if the Operation is finalized, otherwise False.
        Functionality:
        - An Operation is considered finalized if its operator is not
         one of the algebraic operators '+', '-', '*'.
        """
        return self.operator not in ('+', '-', '*')

    def __add__(self, other):
        """ overload + as union """
        return self._operate(other,'+')

    def __sub__(self, other):
        """ overload - as subtract """
        return self._operate(other,'-')

    def __mul__(self, other):
        """ overload * as intersect """
        return self._operate(other,'*')

    def get_proper_operand(self):
        # """
        # Returns the proper operand depending on whether the operation is finalized.
        # """
        # return span(self.operands) if not self.isfinalized() else self.name
        """
        Returns the proper operand depending on whether the operation is finalized.
        """
        if self.isfinalized():
            return self.name
        else:
            # Collect operand names
            operand_names = [
                op.name if isinstance(op, Operation) else str(op)
                for op in self.operands
            ]
            return span(operand_names)

    def _operate(self, other, operator):
        """
        Implements algebraic operations between self and other Operation instances.

        Parameters:
        - other (Operation): The other Operation instance to be operated with.
        - operator (str): The operation to be performed. Supported values are '+', '-', '*'.

        Returns:
        - Operation: A new Operation instance reflecting the result of the operation.
        """
        # Ensure other is also an instance of Operation
        if not isinstance(other, Operation):
            raise TypeError(f"Unsupported type: {type(other)}")

        # Map operator to prefix
        prefix_map = {'+': 'add', '-': 'sub', '*': 'mul'}
        prefix = prefix_map.get(operator, 'op')

        # Prepare operands list
        operands = []

        # Handle self
        if self.operator == operator and not self.isfinalized():
            operands.extend(self.operands)
        else:
            operands.append(self)

        # Handle other
        if other.operator == operator and not other.isfinalized():
            operands.extend(other.operands)
        else:
            operands.append(other)

        # Generate a new name
        name = self.generate_hashname(prefix=prefix, ID=self.generateID() + other.generateID())

        # Create a new Operation
        new_op = Operation(operator, operands, name=name)
        return new_op

    def script(self):
        """
        Generate the LAMMPS code using the dscript and script classes.

        Returns:
        - script onject: The LAMMPS code generated by this operation.
        """
        # Create a dscript object to store the operation code
        dscript_obj = dscript()
        # Use a dummy key for the dscript template
        dscript_obj["dummy"] = self.code
        # Convert the dscript object to a script object
        script_obj = dscript_obj.script()
        return script_obj

Methods

def append(self, operand)

Appends a single operand to the operands list of the Operation instance.

Expand source code
def append(self, operand):
    """Appends a single operand to the operands list of the Operation instance."""
    self.operands.append(operand)
def extend(self, operands)

Extends the operands list of the Operation instance with multiple operands.

Expand source code
def extend(self, operands):
    """Extends the operands list of the Operation instance with multiple operands."""
    self.operands.extend(operands)
def generateID(self)

Generates an ID for the Operation instance based on its operands or name.

Expand source code
def generateID(self):
    """Generates an ID for the Operation instance based on its operands or name."""
    # if self.operands:
    #     return "".join([str(op) for op in self.operands])
    # else:
    #     return self.name
    operand_ids = ''.join(op.name if isinstance(op, Operation) else str(op) for op in self.operands)
    return operand_ids
def generate_hashname(self, prefix='grp', ID=None)

Generates an ID for the Operation instance based on its operands or name.

Expand source code
def generate_hashname(self,prefix="grp",ID=None):
    """Generates an ID for the Operation instance based on its operands or name."""
    # if ID is None: ID = self.generateID()
    # s = self.operator+ID
    # return prefix+hashlib.sha256(s.encode()).hexdigest()[:6]
    if ID is None:
        ID = self.generateID()
    s = self.operator + ID
    return prefix + hashlib.sha256(s.encode()).hexdigest()[:6]
def get_proper_operand(self)

Returns the proper operand depending on whether the operation is finalized.

Expand source code
def get_proper_operand(self):
    # """
    # Returns the proper operand depending on whether the operation is finalized.
    # """
    # return span(self.operands) if not self.isfinalized() else self.name
    """
    Returns the proper operand depending on whether the operation is finalized.
    """
    if self.isfinalized():
        return self.name
    else:
        # Collect operand names
        operand_names = [
            op.name if isinstance(op, Operation) else str(op)
            for op in self.operands
        ]
        return span(operand_names)
def is_empty(self)

Checks if the Operation instance has no operands.

Expand source code
def is_empty(self):
    """Checks if the Operation instance has no operands."""
    return len(self.operands) == 0
def is_unary(self)

Checks if the Operation instance has exactly one operand.

Expand source code
def is_unary(self):
    """Checks if the Operation instance has exactly one operand."""
    return len(self.operands) == 1
def isfinalized(self)

Checks whether the Operation instance is finalized. Returns: - bool: True if the Operation is finalized, otherwise False. Functionality: - An Operation is considered finalized if its operator is not one of the algebraic operators '+', '-', '*'.

Expand source code
def isfinalized(self):
    """
    Checks whether the Operation instance is finalized.
    Returns:
    - bool: True if the Operation is finalized, otherwise False.
    Functionality:
    - An Operation is considered finalized if its operator is not
     one of the algebraic operators '+', '-', '*'.
    """
    return self.operator not in ('+', '-', '*')
def script(self)

Generate the LAMMPS code using the dscript and script classes.

Returns: - script onject: The LAMMPS code generated by this operation.

Expand source code
def script(self):
    """
    Generate the LAMMPS code using the dscript and script classes.

    Returns:
    - script onject: The LAMMPS code generated by this operation.
    """
    # Create a dscript object to store the operation code
    dscript_obj = dscript()
    # Use a dummy key for the dscript template
    dscript_obj["dummy"] = self.code
    # Convert the dscript object to a script object
    script_obj = dscript_obj.script()
    return script_obj
class dscript (name=None, SECTIONS=['DYNAMIC'], section=0, position=None, role='dscript instance', description='dynamic script', userid='dscript', version=None, license=None, email=None, printflag=False, verbose=False, verbosity=None, **userdefinitions)

dscript: A Dynamic Script Management Class

The dscript class is designed to manage and dynamically generate multiple lines/items of a script, typically for use with LAMMPS or similar simulation tools. Each line in the script is represented as a ScriptTemplate object, and the class provides tools to easily manipulate, concatenate, and execute these script lines/items.

Key Features:

  • Dynamic Script Generation: Define and manage script lines/items dynamically, with variables that can be substituted at runtime.
  • Conditional Execution: Add conditions to script lines/items so they are only included if certain criteria are met.
  • Script Concatenation: Combine multiple script objects while maintaining control over variable precedence and script structure.
  • User-Friendly Access: Easily access and manipulate script lines/items using familiar Python constructs like indexing and iteration.

Practical Use Cases:

  • Custom LAMMPS Scripts: Generate complex simulation scripts with varying parameters based on dynamic conditions.
  • Automation: Automate the creation of scripts for batch processing, simulations, or other repetitive tasks.
  • Script Management: Manage and version-control different script sections and configurations easily.

Methods:

init(self, name=None): Initializes a new dscript object with an optional name.

getitem(self, key): Retrieves a script line by its key. If a list of keys is provided, returns a new dscript object with lines/items reordered accordingly.

setitem(self, key, value): Adds or updates a script line. If the value is an empty list, the corresponding script line is removed.

delitem(self, key): Deletes a script line by its key.

contains(self, key): Checks if a key exists in the script. Allows usage of in keyword.

iter(self): Returns an iterator over the script lines/items, allowing for easy iteration through all lines/items in the TEMPLATE.

len(self): Returns the number of script lines/items currently stored in the TEMPLATE.

keys(self): Returns the keys of the TEMPLATE dictionary.

values(self): Returns the ScriptTemplate objects stored as values in the TEMPLATE.

items(self): Returns the keys and ScriptTemplate objects from the TEMPLATE as pairs.

str(self): Returns a human-readable summary of the script, including the number of lines/items and total attributes. Shortcut: str(S).

repr(self): Provides a detailed string representation of the entire dscript object, including all script lines/items and their attributes. Useful for debugging.

reorder(self, order): Reorders the script lines/items based on a given list of indices, creating a new dscript object with the reordered lines/items.

get_content_by_index(self, index, do=True, protected=True): Returns the processed content of the script line at the specified index, with variables substituted based on the definitions and conditions applied.

get_attributes_by_index(self, index): Returns the attributes of the script line at the specified index.

add_dynamic_script(self, key, content="", definitions=None, verbose=None, **USER): Add a dynamic script step to the dscript object.

createEmptyVariables(self, vars): Creates new variables in DEFINITIONS if they do not already exist. Accepts a single variable name or a list of variable names.

do(self, printflag=None, verbose=None): Executes all script lines/items in the TEMPLATE, concatenating the results, and handling variable substitution. Returns the full script as a string.

script(self, **userdefinitions): Generates a lamdaScript object from the current dscript object, applying any additional user definitions provided.

pipescript(self, printflag=None, verbose=None, **USER): Returns a pipescript object by combining script objects for all keys in the TEMPLATE. Each key in TEMPLATE is handled separately, and the resulting scripts are combined using the | operator.

save(self, filename=None, foldername=None, overwrite=False): Saves the current script instance to a text file in a structured format. Includes metadata, global parameters, definitions, templates, and attributes.

write(scriptcontent, filename=None, foldername=None, overwrite=False): Writes the provided script content to a specified file in a given folder, with a header added if necessary, ensuring the correct file format.

load(cls, filename, foldername=None, numerickeys=True): Loads a script instance from a text file, restoring the content, definitions, templates, and attributes. Handles parsing and variable substitution based on the structure of the file.

parsesyntax(cls, content, numerickeys=True): Parses a script instance from a string input, restoring the content, definitions, templates, and attributes. Handles parsing and variable substitution based on the structure of the provided string, ensuring the correct format and key conversions when necessary.

Example:

Create a dscript object

R = dscript(name="MyScript")

Define global variables (DEFINITIONS)

R.DEFINITIONS.dimension = 3 R.DEFINITIONS.units = "$si"

Add script lines

R[0] = "dimension ${dimension}" R[1] = "units ${units}"

Generate and print the script

sR = R.script() print(sR.do())

Attributes:

name : str The name of the script, useful for identification. TEMPLATE : dict A dictionary storing script lines/items, with keys to identify each line. DEFINITIONS : lambdaScriptdata Stores the variables and parameters used within the script lines/items.

Initializes a new dscript object.

The constructor sets up a new dscript object, which allows you to define and manage a script composed of multiple lines/items. Each line is stored in the TEMPLATE dictionary, and variables used in the script are stored in DEFINITIONS.

Parameters:

name : str, optional The name of the script. If no name is provided, a random name will be generated automatically. The name is useful for identifying the script, especially when managing multiple scripts.

Example:

Create a dscript object with a specific name

R = dscript(name="ExampleScript")

Or create a dscript object with a random name

R = dscript()

After initialization, you can start adding script lines/items and defining variables.

Expand source code
class dscript:
    """
    dscript: A Dynamic Script Management Class

    The `dscript` class is designed to manage and dynamically generate multiple
    lines/items of a script, typically for use with LAMMPS or similar simulation tools.
    Each line in the script is represented as a `ScriptTemplate` object, and the
    class provides tools to easily manipulate, concatenate, and execute these
    script lines/items.

    Key Features:
    -------------
    - **Dynamic Script Generation**: Define and manage script lines/items dynamically,
      with variables that can be substituted at runtime.
    - **Conditional Execution**: Add conditions to script lines/items so they are only
      included if certain criteria are met.
    - **Script Concatenation**: Combine multiple script objects while maintaining
      control over variable precedence and script structure.
    - **User-Friendly Access**: Easily access and manipulate script lines/items using
      familiar Python constructs like indexing and iteration.

    Practical Use Cases:
    --------------------
    - **Custom LAMMPS Scripts**: Generate complex simulation scripts with varying
      parameters based on dynamic conditions.
    - **Automation**: Automate the creation of scripts for batch processing,
      simulations, or other repetitive tasks.
    - **Script Management**: Manage and version-control different script sections
      and configurations easily.

    Methods:
    --------
    __init__(self, name=None):
        Initializes a new `dscript` object with an optional name.

    __getitem__(self, key):
        Retrieves a script line by its key. If a list of keys is provided,
        returns a new `dscript` object with lines/items reordered accordingly.

    __setitem__(self, key, value):
        Adds or updates a script line. If the value is an empty list, the
        corresponding script line is removed.

    __delitem__(self, key):
        Deletes a script line by its key.

    __contains__(self, key):
        Checks if a key exists in the script. Allows usage of `in` keyword.

    __iter__(self):
        Returns an iterator over the script lines/items, allowing for easy iteration
        through all lines/items in the `TEMPLATE`.

    __len__(self):
        Returns the number of script lines/items currently stored in the `TEMPLATE`.

    keys(self):
        Returns the keys of the `TEMPLATE` dictionary.

    values(self):
        Returns the `ScriptTemplate` objects stored as values in the `TEMPLATE`.

    items(self):
        Returns the keys and `ScriptTemplate` objects from the `TEMPLATE` as pairs.

    __str__(self):
        Returns a human-readable summary of the script, including the number
        of lines/items and total attributes. Shortcut: `str(S)`.

    __repr__(self):
        Provides a detailed string representation of the entire `dscript` object,
        including all script lines/items and their attributes. Useful for debugging.

    reorder(self, order):
        Reorders the script lines/items based on a given list of indices, creating a
        new `dscript` object with the reordered lines/items.

    get_content_by_index(self, index, do=True, protected=True):
        Returns the processed content of the script line at the specified index,
        with variables substituted based on the definitions and conditions applied.

    get_attributes_by_index(self, index):
        Returns the attributes of the script line at the specified index.

    add_dynamic_script(self, key, content="", definitions=None, verbose=None, **USER):
        Add a dynamic script step to the `dscript` object.

    createEmptyVariables(self, vars):
        Creates new variables in `DEFINITIONS` if they do not already exist.
        Accepts a single variable name or a list of variable names.

    do(self, printflag=None, verbose=None):
        Executes all script lines/items in the `TEMPLATE`, concatenating the results,
        and handling variable substitution. Returns the full script as a string.

    script(self, **userdefinitions):
        Generates a `lamdaScript` object from the current `dscript` object,
        applying any additional user definitions provided.

    pipescript(self, printflag=None, verbose=None, **USER):
        Returns a `pipescript` object by combining script objects for all keys
        in the `TEMPLATE`. Each key in `TEMPLATE` is handled separately, and
        the resulting scripts are combined using the `|` operator.

    save(self, filename=None, foldername=None, overwrite=False):
        Saves the current script instance to a text file in a structured format.
        Includes metadata, global parameters, definitions, templates, and attributes.

    write(scriptcontent, filename=None, foldername=None, overwrite=False):
        Writes the provided script content to a specified file in a given folder,
        with a header added if necessary, ensuring the correct file format.

    load(cls, filename, foldername=None, numerickeys=True):
        Loads a script instance from a text file, restoring the content, definitions,
        templates, and attributes. Handles parsing and variable substitution based on
        the structure of the file.

    parsesyntax(cls, content, numerickeys=True):
        Parses a script instance from a string input, restoring the content, definitions,
        templates, and attributes. Handles parsing and variable substitution based on the
        structure of the provided string, ensuring the correct format and key conversions
        when necessary.

    Example:
    --------
    # Create a dscript object
    R = dscript(name="MyScript")

    # Define global variables (DEFINITIONS)
    R.DEFINITIONS.dimension = 3
    R.DEFINITIONS.units = "$si"

    # Add script lines
    R[0] = "dimension    ${dimension}"
    R[1] = "units        ${units}"

    # Generate and print the script
    sR = R.script()
    print(sR.do())

    Attributes:
    -----------
    name : str
        The name of the script, useful for identification.
    TEMPLATE : dict
        A dictionary storing script lines/items, with keys to identify each line.
    DEFINITIONS : lambdaScriptdata
        Stores the variables and parameters used within the script lines/items.
    """

    # Class variable to list attributes that should not be treated as TEMPLATE entries
    construction_attributes = {'name', 'SECTIONS', 'section', 'position', 'role', 'description',
                               'userid', 'verbose', 'printflag', 'DEFINITIONS', 'TEMPLATE',
                               'version','license','email'
                               }

    def __init__(self,  name=None,
                        SECTIONS = ["DYNAMIC"],
                        section = 0,
                        position = None,
                        role = "dscript instance",
                        description = "dynamic script",
                        userid = "dscript",
                        version = None,
                        license = None,
                        email = None,
                        printflag = False,
                        verbose = False,
                        verbosity = None,
                        **userdefinitions
                        ):
        """
        Initializes a new `dscript` object.

        The constructor sets up a new `dscript` object, which allows you to
        define and manage a script composed of multiple lines/items. Each line is
        stored in the `TEMPLATE` dictionary, and variables used in the script
        are stored in `DEFINITIONS`.

        Parameters:
        -----------
        name : str, optional
            The name of the script. If no name is provided, a random name will
            be generated automatically. The name is useful for identifying the
            script, especially when managing multiple scripts.

        Example:
        --------
        # Create a dscript object with a specific name
        R = dscript(name="ExampleScript")

        # Or create a dscript object with a random name
        R = dscript()

        After initialization, you can start adding script lines/items and defining variables.
        """

        if name is None:
            self.name = autoname()
        else:
            self.name = name

        if (version is None) or (license is None) or (email is None):
            metadata = get_metadata()               # retrieve all metadata
            version = metadata["version"] if version is None else version
            license = metadata["license"] if license is None else license
            email = metadata["email"] if email is None else email
        self.SECTIONS = SECTIONS if isinstance(SECTIONS,(list,tuple)) else [SECTIONS]
        self.section = section
        self.position = position if position is not None else 0
        self.role = role
        self.description = description
        self.userid = userid
        self.version = version
        self.license = license
        self.email = email
        self.printflag = printflag
        self.verbose = verbose if verbosity is None else verbosity>0
        self.verbosity = 0 if not verbose else verbosity
        self.DEFINITIONS = lambdaScriptdata(**userdefinitions)
        self.TEMPLATE = {}

    def __getattr__(self, attr):
        # During construction phase, we only access the predefined attributes
        if 'TEMPLATE' not in self.__dict__:
            if attr in self.__dict__:
                return self.__dict__[attr]
            raise AttributeError(f"'dscript' object has no attribute '{attr}'")

        # If TEMPLATE is initialized and attr is in TEMPLATE, return the corresponding ScriptTemplate entry
        if attr in self.TEMPLATE:
            return self.TEMPLATE[attr]

        # Fall back to internal __dict__ attributes if not in TEMPLATE
        if attr in self.__dict__:
            return self.__dict__[attr]

        raise AttributeError(f"'dscript' object has no attribute '{attr}'")


    def __setattr__(self, attr, value):
        # Handle internal attributes during the construction phase
        if 'TEMPLATE' not in self.__dict__:
            self.__dict__[attr] = value
            return

        # Handle construction attributes separately (name, TEMPLATE, USER)
        if attr in self.construction_attributes:
            self.__dict__[attr] = value
        # If TEMPLATE exists, and the attribute is intended for it, update TEMPLATE
        elif 'TEMPLATE' in self.__dict__:
            # Convert the value to a ScriptTemplate if needed, and update the template
            if attr in self.TEMPLATE:
                # Modify the existing ScriptTemplate object for this attribute
                if isinstance(value, str):
                    self.TEMPLATE[attr].content = value
                elif isinstance(value, dict):
                    self.TEMPLATE[attr].attributes.update(value)
            else:
                # Create a new entry if it does not exist
                self.TEMPLATE[attr] = ScriptTemplate(content=value,definitions=self.DEFINITIONS,verbose=self.verbose,userid=attr)
        else:
            # Default to internal attributes
            self.__dict__[attr] = value


    def __getitem__(self, key):
        """
        Implements index-based retrieval, slicing, or reordering for dscript objects.

        Parameters:
        -----------
        key : int, slice, list, or str
            - If `key` is an int, returns the corresponding template at that index.
              Supports negative indices to retrieve templates from the end.
            - If `key` is a slice, returns a new `dscript` object containing the templates in the specified range.
            - If `key` is a list, reorders the TEMPLATE based on the list of indices or keys.
            - If `key` is a string, treats it as a key and returns the corresponding template.

        Returns:
        --------
        dscript or ScriptTemplate : Depending on the type of key, returns either a new dscript object (for slicing or reordering)
                                    or a ScriptTemplate object (for direct key access).
        """
        # Handle list-based reordering
        if isinstance(key, list):
            return self.reorder(key)

        # Handle slicing
        elif isinstance(key, slice):
            new_dscript = dscript(name=f"{self.name}_slice_{key.start}_{key.stop}")
            keys = list(self.TEMPLATE.keys())
            for k in keys[key]:
                new_dscript.TEMPLATE[k] = self.TEMPLATE[k]
            return new_dscript

        # Handle integer indexing with support for negative indices
        elif isinstance(key, int):
            keys = list(self.TEMPLATE.keys())
            if key in self.TEMPLATE:  # Check if the integer exists as a key
                return self.TEMPLATE[key]
            if key < 0:  # Support negative indices
                key += len(keys)
            if key < 0 or key >= len(keys):  # Check for index out of range
                raise IndexError(f"Index {key - len(keys)} is out of range")
            # Return the template corresponding to the integer index
            return self.TEMPLATE[keys[key]]

        # Handle key-based access (string keys)
        elif isinstance(key, str):
            if key in self.TEMPLATE:
                return self.TEMPLATE[key]
            raise KeyError(f"Key '{key}' does not exist in TEMPLATE.")


    def __setitem__(self, key, value):
        if (value == []) or (value is None):
            # If the value is an empty list, delete the corresponding key
            del self.TEMPLATE[key]
        else:
            # Otherwise, set the key to the new ScriptTemplate
            self.TEMPLATE[key] = ScriptTemplate(value, definitions=self.DEFINITIONS, verbose=self.verbose, userid=key)

    def __delitem__(self, key):
        del self.TEMPLATE[key]

    def __iter__(self):
        return iter(self.TEMPLATE.items())

    # def keys(self):
    #     return self.TEMPLATE.keys()

    # def values(self):
    #     return (s.content for s in self.TEMPLATE.values())

    def __contains__(self, key):
        return key in self.TEMPLATE

    def __len__(self):
        return len(self.TEMPLATE)

    def items(self):
        return ((key, s.content) for key, s in self.TEMPLATE.items())

    def __str__(self):
        num_TEMPLATE = len(self.TEMPLATE)
        total_attributes = sum(len(s.attributes) for s in self.TEMPLATE.values())
        return f"{num_TEMPLATE} TEMPLATE, {total_attributes} attributes"

    def __repr__(self):
        """Representation of dscript object with additional properties."""
        repr_str = f"dscript object ({self.name})\n"
        # Add description, role, and version at the beginning
        repr_str += f"id: {self.userid}\n" if self.userid else ""
        repr_str += f"Descr: {self.description}\n" if self.description else ""
        repr_str += f"Role: {self.role} (v. {self.version})\n"
        repr_str += f'SECTIONS {span(self.SECTIONS,",","[","]")} | index: {self.section} | position: {self.position}\n'
        repr_str += f"\n\n\twith {len(self.TEMPLATE)} TEMPLATE"
        repr_str += "s" if len(self.TEMPLATE)>1 else ""
        repr_str += f" (with {len(self.DEFINITIONS)} DEFINITIONS)\n\n"
        # Add TEMPLATE information
        c = 0
        for k, s in self.TEMPLATE.items():
            head = f"|  idx: {c} |  key: {k}  |"
            dashes = (50 - len(head)) // 2
            repr_str += "-" * 50 + "\n"
            repr_str += f"{'<' * dashes}{head}{'>' * dashes}\n{repr(s)}\n"
            c += 1
        return repr_str

    def keys(self):
        """Return the keys of the TEMPLATE."""
        return self.TEMPLATE.keys()

    def values(self):
        """Return the ScriptTemplate objects in TEMPLATE."""
        return self.TEMPLATE.values()

    def reorder(self, order):
        """Reorder the TEMPLATE lines according to a list of indices."""
        # Get the original items as a list of (key, value) pairs
        original_items = list(self.TEMPLATE.items())
        # Create a new dictionary with reordered scripts, preserving original keys
        new_scripts = {original_items[i][0]: original_items[i][1] for i in order}
        # Create a new dscript object with reordered scripts
        reordered_script = dscript()
        reordered_script.TEMPLATE = new_scripts
        return reordered_script


    def get_content_by_index(self, index, do=True, protected=True):
        """
        Returns the content of the ScriptTemplate at the specified index.

        Parameters:
        -----------
        index : int
            The index of the template in the TEMPLATE dictionary.
        do : bool, optional (default=True)
            If True, the content will be processed based on conditions and evaluation flags.
        protected : bool, optional (default=True)
            Controls whether variable evaluation is protected (e.g., prevents overwriting certain definitions).

        Returns:
        --------
        str or list of str
            The content of the template after processing, or an empty string if conditions or evaluation flags block it.
        """
        key = list(self.TEMPLATE.keys())[index]
        s = self.TEMPLATE[key].content
        att = self.TEMPLATE[key].attributes
        # Return an empty string if the facultative attribute is True and do is True
        if att["facultative"] and do:
            return ""
        # Evaluate the condition (if any)
        if att["condition"] is not None:
            cond = eval(self.DEFINITIONS.formateval(att["condition"], protected))
        else:
            cond = True
        # If the condition is met, process the content
        if cond:
            # Apply formateval only if the eval attribute is True and do is True
            if att["eval"] and do:
                if isinstance(s, list):
                    # Apply formateval to each item in the list if s is a list
                    return [self.DEFINITIONS.formateval(line, protected) for line in s]
                else:
                    # Apply formateval to the single string content
                    return self.DEFINITIONS.formateval(s, protected)
            else:
                return s  # Return the raw content if no evaluation is needed
        elif do:
            return ""  # Return an empty string if the condition is not met and do is True
        else:
            return s  # Return the raw content if do is False


    def get_attributes_by_index(self, index):
        """ Returns the attributes of the ScriptTemplate at the specified index."""
        key = list(self.TEMPLATE.keys())[index]
        return self.TEMPLATE[key].attributes


    def __add__(self, other):
        """
        Concatenates two dscript objects, creating a new dscript object that combines
        the TEMPLATE and DEFINITIONS of both. This operation avoids deep copying
        of definitions by creating a new lambdaScriptdata instance from the definitions.

        Parameters:
        -----------
        other : dscript
            The other dscript object to concatenate with the current one.

        Returns:
        --------
        result : dscript
            A new dscript object with the concatenated TEMPLATE and merged DEFINITIONS.

        Raises:
        -------
        TypeError:
            If the other object is not an instance of dscript, script, pipescript
        """
        if isinstance(other, dscript):
            # Step 1: Merge global DEFINITIONS from both self and other
            result = dscript(name=self.name+"+"+other.name,**(self.DEFINITIONS + other.DEFINITIONS))
            # Step 2: Start by copying the TEMPLATE from self (no deepcopy for performance reasons)
            result.TEMPLATE = self.TEMPLATE.copy()
            # Step 3: Ensure that the local definitions for `self.TEMPLATE` are properly copied
            for key, value in self.TEMPLATE.items():
                result.TEMPLATE[key].definitions = lambdaScriptdata(**self.TEMPLATE[key].definitions)
            # Step 4: Track the next available index if keys need to be created
            next_index = len(result.TEMPLATE)
            # Step 5: Add items from the other dscript object, updating or generating new keys as needed
            for key, value in other.TEMPLATE.items():
                if key in result.TEMPLATE:
                    # If key already exists in result, assign a new unique index
                    while next_index in result.TEMPLATE:
                        next_index += 1
                    new_key = next_index
                else:
                    # Use the original key if it doesn't already exist
                    new_key = key
                # Copy the TEMPLATE's content and definitions from `other`
                result.TEMPLATE[new_key] = value
                # Merge the local TEMPLATE definitions from `other`
                result.TEMPLATE[new_key].definitions = lambdaScriptdata(**other.TEMPLATE[key].definitions)
            return result
        elif isinstance(other,script):
            return self.script() + script
        elif isinstance(other,pipescript):
            return self.pipescript() | script
        else:
            raise TypeError(f"Cannot concatenate 'dscript' with '{type(other).__name__}'")


    def __call__(self, *keys):
        """
        Extracts subobjects from the dscript based on the provided keys.

        Parameters:
        -----------
        *keys : one or more keys that correspond to the `TEMPLATE` entries.

        Returns:
        --------
        A new `dscript` object that contains only the selected script lines/items, along with
        the relevant definitions and attributes from the original object.
        """
        # Create a new dscript object to store the extracted sub-objects
        result = dscript(name=f"{self.name}_subobject")
        # Copy the TEMPLATE entries corresponding to the provided keys
        for key in keys:
            if key in self.TEMPLATE:
                result.TEMPLATE[key] = self.TEMPLATE[key]
            else:
                raise KeyError(f"Key '{key}' not found in TEMPLATE.")
        # Copy the DEFINITIONS from the current object
        result.DEFINITIONS = copy.deepcopy(self.DEFINITIONS)
        # Copy other relevant attributes
        result.SECTIONS = self.SECTIONS[:]
        result.section = self.section
        result.position = self.position
        result.role = self.role
        result.description = self.description
        result.userid = self.userid
        result.version = self.version
        result.verbose = self.verbose
        result.printflag = self.printflag

        return result

    def createEmptyVariables(self, vars):
        """
        Creates empty variables in DEFINITIONS if they don't already exist.

        Parameters:
        -----------
        vars : str or list of str
            The variable name or list of variable names to be created in DEFINITIONS.
        """
        if isinstance(vars, str):
            vars = [vars]  # Convert single variable name to list for uniform processing
        for varname in vars:
            if varname not in self.DEFINITIONS:
                self.DEFINITIONS.setattr(varname,"${" + varname + "}")


    def do(self, printflag=None, verbose=None, softrun=False, return_definitions=False,comment_chars="#%", **USER):
        """
        Executes or previews all `ScriptTemplate` instances in `TEMPLATE`, concatenating their processed content.
        Allows for optional headers and footers based on verbosity settings, and offers a preliminary preview mode with `softrun`.
        Accumulates definitions across all templates if `return_definitions=True`.

        Parameters
        ----------
        printflag : bool, optional
            If `True`, enables print output during execution. Defaults to the instance's print flag if `None`.
        verbose : bool, optional
            If `True`, includes headers and footers in the output, providing additional detail.
            Defaults to the instance's verbosity setting if `None`.
        softrun : bool, optional
            If `True`, executes the script in a preliminary mode:
            - Bypasses full variable substitution for a preview of the content, useful for validating structure.
            - If `False` (default), performs full processing, including variable substitutions and evaluations.
        return_definitions : bool, optional
            If `True`, returns a tuple where the second element contains accumulated definitions from all templates.
            If `False` (default), returns only the concatenated output.
        comment_chars : str, optional (default: "#%")
            A string containing characters to identify the start of a comment.
            Any of these characters will mark the beginning of a comment unless within quotes.
        **USER : keyword arguments
            Allows for the provision of additional user-defined definitions, where each keyword represents a
            definition key and the associated value represents the definition's content. These definitions
            can override or supplement template-level definitions during execution.

        Returns
        -------
        str or tuple
            - If `return_definitions=False`, returns the concatenated output of all `ScriptTemplate` instances,
              with optional headers, footers, and execution summary based on verbosity.
            - If `return_definitions=True`, returns a tuple of (`output`, `accumulated_definitions`), where
              `accumulated_definitions` contains all definitions used across templates.

        Notes
        -----
        - Each `ScriptTemplate` in `TEMPLATE` is processed individually using its own `do()` method.
        - The `softrun` mode provides a preliminary content preview without full variable substitution,
          helpful for inspecting the script structure or gathering local definitions.
        - When `verbose` is enabled, the method includes detailed headers, footers, and a summary of processed and
          ignored items, providing insight into the script's construction and variable usage.
        - Accumulated definitions from each `ScriptTemplate` are combined if `return_definitions=True`, which can be
          useful for tracking all variables and definitions applied across the templates.

        Example
        -------
        >>> dscript_instance = dscript(name="ExampleScript")
        >>> dscript_instance.TEMPLATE[0] = ScriptTemplate(
        ...     content=["units ${units}", "boundary ${boundary}"],
        ...     definitions=lambdaScriptdata(units="lj", boundary="p p p"),
        ...     attributes={'eval': True}
        ... )
        >>> dscript_instance.do(verbose=True, units="real")
        # Output:
        # --------------
        # TEMPLATE "ExampleScript"
        # --------------
        units real
        boundary p p p
        # ---> Total items: 2 - Ignored items: 0
        """

        printflag = self.printflag if printflag is None else printflag
        verbose = self.verbose if verbose is None else verbose
        header = f"# --------------[ TEMPLATE \"{self.name}\" ]--------------" if verbose else ""
        footer = "# --------------------------------------------" if verbose else ""

        # Initialize output, counters, and optional definitions accumulator
        output = [header]
        non_empty_lines = 0
        ignored_lines = 0
        accumulated_definitions = lambdaScriptdata() if return_definitions else None

        for key, template in self.TEMPLATE.items():
            # Process each template with softrun if enabled, otherwise use full processing
            result = template.do(softrun=softrun,USER=lambdaScriptdata(**USER))
            if result:
                # Apply comment removal based on verbosity
                final_result = result if verbose else remove_comments(result,comment_chars=comment_chars)
                if final_result or verbose:
                    output.append(final_result)
                    non_empty_lines += 1
                else:
                    ignored_lines += 1
                # Accumulate definitions if return_definitions is enabled
                if return_definitions:
                    accumulated_definitions += template.definitions
            else:
                ignored_lines += 1

        # Add footer summary if verbose
        nel_word = 'items' if non_empty_lines > 1 else 'item'
        il_word = 'items' if ignored_lines > 1 else 'item'
        footer += f"\n# ---> Total {nel_word}: {non_empty_lines} - Ignored {il_word}: {ignored_lines}" if verbose else ""
        output.append(footer)

        # Concatenate output and determine return type based on return_definitions
        output_content = "\n".join(output)
        return (output_content, accumulated_definitions) if return_definitions else output_content



    def script(self,printflag=None, verbose=None, verbosity=None, **USER):
        """
        returns the corresponding script
        """
        printflag = self.printflag if printflag is None else printflag
        verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
        verbosity = 0 if not verbose else verbosity
        return lamdaScript(self,persistentfile=True, persistentfolder=None,
                           printflag=printflag, verbose=verbose,
                           **USER)

    def pipescript(self, *keys, printflag=None, verbose=None, verbosity=None, **USER):
        """
        Returns a pipescript object by combining script objects corresponding to the given keys.

        Parameters:
        -----------
        *keys : one or more keys that correspond to the `TEMPLATE` entries.
        printflag : bool, optional
            Whether to enable printing of additional information.
        verbose : bool, optional
            Whether to run in verbose mode for debugging or detailed output.
        **USER : dict, optional
            Additional user-defined variables to pass into the script.

        Returns:
        --------
        A `pipescript` object that combines the script objects generated from the selected
        dscript subobjects.
        """
        # Start with an empty pipescript
        # combined_pipescript = None
        # # Iterate over the provided keys to extract corresponding subobjects
        # for key in keys:
        #     # Extract the dscript subobject for the given key
        #     sub_dscript = self(key)
        #     # Convert the dscript subobject to a script object, passing USER, printflag, and verbose
        #     script_obj = sub_dscript.script(printflag=printflag, verbose=verbose, **USER)
        #     # Combine script objects into a pipescript object
        #     if combined_pipescript is None:
        #         combined_pipescript = pipescript(script_obj)  # Initialize pipescript
        #     else:
        #         combined_pipescript = combined_pipescript | script_obj  # Use pipe operator
        # if combined_pipescript is None:
        #     ValueError('The conversion to pipescript from {type{self}} falled')
        # return combined_pipescript
        printflag = self.printflag if printflag is None else printflag
        verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
        verbosity = 0 if not verbose else verbosity

        # Loop over all keys in TEMPLATE and combine them
        combined_pipescript = None
        for key in self.keys():
            # Create a new dscript object with only the current key in TEMPLATE
            focused_dscript = dscript(name=f"{self.name}:{key}")
            focused_dscript.TEMPLATE[key] = self.TEMPLATE[key]
            focused_dscript.TEMPLATE[key].definitions = scriptdata(**self.TEMPLATE[key].definitions)
            focused_dscript.DEFINITIONS = scriptdata(**self.DEFINITIONS)
            focused_dscript.SECTIONS = self.SECTIONS[:]
            focused_dscript.section = self.section
            focused_dscript.position = self.position
            focused_dscript.role = self.role
            focused_dscript.description = self.description
            focused_dscript.userid = self.userid
            focused_dscript.version = self.version
            focused_dscript.verbose = verbose
            focused_dscript.printflag = printflag

            # Convert the focused dscript object to a script object
            script_obj = focused_dscript.script(printflag=printflag, verbose=verbose, **USER)

            # Combine the script objects into a pipescript object using the pipe operator
            if combined_pipescript is None:
                combined_pipescript = pipescript(script_obj)  # Initialize pipescript
            else:
                combined_pipescript = combined_pipescript | pipescript(script_obj)  # Use pipe operator

        if combined_pipescript is None:
            ValueError('The conversion to pipescript from {type{self}} falled')
        return combined_pipescript


    @staticmethod
    def header(name=None, verbose=True, verbosity=None, style=2, filepath=None, version=None, license=None, email=None):
        """
        Generate a formatted header for the DSCRIPT file.

        ### Parameters:
            name (str, optional): The name of the script. If None, "Unnamed" is used.
            verbose (bool, optional): Whether to include the header. Default is True.
            verbosity (int, optional): Verbosity level. Overrides `verbose` if specified.
            style (int, optional): ASCII style for the header (default=2).
            filepath (str, optional): Full path to the file being saved. If None, the line mentioning the file path is excluded.
            version (str, optional): DSCRIPT version. If None, it is omitted from the header.
            license (str, optional): License type. If None, it is omitted from the header.
            email (str, optional): Contact email. If None, it is omitted from the header.

        ### Returns:
            str: A formatted string representing the script's metadata and initialization details.
                 Returns an empty string if `verbose` is False.

        ### The header includes:
            - DSCRIPT version, license, and contact email, if provided.
            - The name of the script.
            - Filepath, if provided.
            - Information on where and when the script was generated.

        ### Notes:
            - If `verbosity` is specified, it overrides `verbose`.
            - Omits metadata lines if `version`, `license`, or `email` are not provided.
        """
        # Resolve verbosity
        verbose = verbosity > 0 if verbosity is not None else verbose
        if not verbose:
            return ""
        # Validate inputs
        if name is None:
            name = "Unnamed"
        # Prepare metadata line
        metadata = []
        if version:
            metadata.append(f"v{version}")
        if license:
            metadata.append(f"License: {license}")
        if email:
            metadata.append(f"Email: {email}")
        metadata_line = " | ".join(metadata)
        # Prepare the framed header content
        lines = []
        if metadata_line:
            lines.append(f"PIZZA.DSCRIPT FILE {metadata_line}")
        lines += [
            "",
            f"Name: {name}",
        ]
        # Add the filepath line if filepath is not None
        if filepath:
            lines.append(f"Path: {filepath}")
        lines += [
            "",
            f"Generated on: {getpass.getuser()}@{socket.gethostname()}:{os.getcwd()}",
            f"{datetime.datetime.now().strftime('%A, %B %d, %Y at %H:%M:%S')}",
        ]
        # Use the shared frame_header function to format the framed content
        return frame_header(lines, style=style)



    # Generator -- added on 2024-10-17
    # -----------
    def generator(self):
        """
        Returns
        -------
        STR
            generated code corresponding to dscript (using dscript syntax/language).

        """
        return self.save(generatoronly=True)


    # Save Method -- added on 2024-09-04
    # -----------
    def save(self, filename=None, foldername=None, overwrite=False, generatoronly=False, onlyusedvariables=True):
        """
        Save the current script instance to a text file.

        Parameters
        ----------
        filename : str, optional
            The name of the file to save the script to. If not provided, `self.name` is used.
            The extension ".txt" is automatically appended if not included.

        foldername : str, optional
            The directory where the file will be saved. If not provided, it defaults to the system's
            temporary directory. If the filename does not include a full path, this folder will be used.

        overwrite : bool, default=True
            Whether to overwrite the file if it already exists. If set to False, an exception is raised
            if the file exists.

        generatoronly : bool, default=False
            If True, the method returns the generated content string without saving to a file.

        onlyusedvariables : bool, default=True
            If True, local definitions are only saved if they are used within the template content.
            If False, all local definitions are saved, regardless of whether they are referenced in
            the template.

        Raises
        ------
        FileExistsError
            If the file already exists and `overwrite` is set to False.

        Notes
        -----
        - The script is saved in a plain text format, and each section (global parameters, definitions,
          template, and attributes) is written in a structured format with appropriate comments.
        - If `self.name` is used as the filename, it must be a valid string that can serve as a file name.
        - The file structure follows the format:
            # DSCRIPT SAVE FILE
            # generated on YYYY-MM-DD on user@hostname

            # GLOBAL PARAMETERS
            { ... }

            # DEFINITIONS (number of definitions=...)
            key=value

            # TEMPLATES (number of items=...)
            key: template_content

            # ATTRIBUTES (number of items with explicit attributes=...)
            key:{attr1=value1, attr2=value2, ...}
        """
        # At the beginning of the save method
        start_time = time.time()  # Start the timer

        if not generatoronly:
            # Use self.name if filename is not provided
            if filename is None:
                filename = span(self.name, sep="\n")

            # Ensure the filename ends with '.txt'
            if not filename.endswith('.txt'):
                filename += '.txt'

            # Construct the full path
            if foldername in [None, ""]:  # Handle cases where foldername is None or an empty string
                filepath = os.path.abspath(filename)
            else:
                filepath = os.path.join(foldername, filename)

            # Check if the file already exists, and raise an exception if it does and overwrite is False
            if os.path.exists(filepath) and not overwrite:
                raise FileExistsError(f"The file '{filepath}' already exists.")

        # Header with current date, username, and host
        header = "# DSCRIPT SAVE FILE\n"
        header += "\n"*2
        if generatoronly:
            header += dscript.header(verbose=True,filepath='dynamic code generation (no file)',
                                     name = self.name, version=self.version, license=self.license, email=self.email)
        else:
            header += dscript.header(verbose=True,filepath=filepath,
                                     name = self.name, version=self.version, license=self.license, email=self.email)
        header += "\n"*2

        # Global parameters in strict Python syntax
        global_params = "# GLOBAL PARAMETERS (8 parameters)\n"
        global_params += "{\n"
        global_params += f"    SECTIONS = {self.SECTIONS},\n"
        global_params += f"    section = {self.section},\n"
        global_params += f"    position = {self.position},\n"
        global_params += f"    role = {self.role!r},\n"
        global_params += f"    description = {self.description!r},\n"
        global_params += f"    userid = {self.userid!r},\n"
        global_params += f"    version = {self.version},\n"
        global_params += f"    verbose = {self.verbose}\n"
        global_params += "}\n"

        # Initialize definitions with self.DEFINITIONS
        allvars = self.DEFINITIONS

        # Temporary dictionary to track global variable information
        global_var_info = {}

        # Loop over each template item to detect and record variable usage and overrides
        for template_index, (key, script_template) in enumerate(self.TEMPLATE.items()):
            # Detect variables used in this template
            used_variables = script_template.detect_variables()

            # Loop over each template item to detect and record variable usage and overrides
            for template_index, (key, script_template) in enumerate(self.TEMPLATE.items()):
                # Detect variables used in this template
                used_variables = script_template.detect_variables()

                # Check each variable used in this template
                for var in used_variables:
                    # Get global and local values for the variable
                    global_value = getattr(allvars, var, None)
                    local_value = getattr(script_template.definitions, var, None)
                    is_global = var in allvars  # Check if the variable originates in global space
                    is_default = is_global and (
                        global_value is None or global_value == "" or global_value == f"${{{var}}}"
                    )

                    # If the variable is not yet tracked, initialize its info
                    if var not in global_var_info:
                        global_var_info[var] = {
                            "value": global_value,       # Initial value from allvars if exists
                            "updatedvalue": global_value,# Initial value from allvars if exists
                            "is_default": is_default,    # Check if it’s set to a default value
                            "first_def": None,           # First definition (to be updated later)
                            "first_use": template_index, # First time the variable is used
                            "first_val": global_value,
                            "override_index": template_index if local_value is not None else None,  # Set override if defined locally
                            "is_global": is_global       # Track if the variable originates as global
                        }
                    else:
                        # Update `override_index` if the variable is defined locally and its value changes
                        if local_value is not None:
                            # Check if the local value differs from the tracked value in global_var_info
                            current_value = global_var_info[var]["value"]
                            if current_value != local_value:
                                global_var_info[var]["override_index"] = template_index
                                global_var_info[var]["updatedvalue"] = local_value  # Update the tracked value


            # Second loop: Update `first_def` for all variables
            for template_index, (key, script_template) in enumerate(self.TEMPLATE.items()):
                local_definitions = script_template.definitions.keys()
                for var in local_definitions:
                    if var in global_var_info and global_var_info[var]["first_def"] is None:
                        global_var_info[var]["first_def"] = template_index
                        global_var_info[var]["first_val"] = getattr(script_template.definitions, var)


        # Filter global definitions based on usage, overrides, and first_def
        filtered_globals = {
            var: info for var, info in global_var_info.items()
            if (info["is_global"] or info["is_default"]) and (info["override_index"] is None)
            }


        # Generate the definitions output based on filtered globals
        definitions = f"\n# GLOBAL DEFINITIONS (number of definitions={len(filtered_globals)})\n"
        for var, info in filtered_globals.items():
            if info["is_default"] and (info["first_def"]>info["first_use"] if info["first_def"] else True):
                definitions += f"{var} = ${{{var}}}  # value assumed to be defined outside this DSCRIPT file\n"
            else:
                value = info["first_val"] #info["value"]
                if value in ["", None]:
                    definitions += f'{var} = ""\n'
                elif isinstance(value, str):
                    safe_value = value.replace('\\', '\\\\').replace('\n', '\\n')
                    definitions += f"{var} = {safe_value}\n"
                else:
                    definitions += f"{var} = {value}\n"

        # Template (number of lines/items)
        printsinglecontent = False
        template = f"\n# TEMPLATES (number of items={len(self.TEMPLATE)})\n"
        for key, script_template in self.TEMPLATE.items():
            # Get local template definitions and detected variables
            template_vars = script_template.definitions
            used_variables = script_template.detect_variables()
            islocal = False
            # Temporary dictionary to accumulate variables to add to allvars
            valid_local_vars = lambdaScriptdata()
            # Write template-specific definitions only if they meet the updated conditions
            for var in template_vars.keys():
                # Conditions for adding a variable to the local template and to `allvars`
                if (var in used_variables or not onlyusedvariables) and (
                    script_template.is_variable_set_value_only(var) and
                    (var not in allvars or getattr(template_vars, var) != getattr(allvars, var))
                ):
                    # Start local definitions section if this is the first local variable for the template
                    if not islocal:
                        template += f"\n# LOCAL DEFINITIONS for key '{key}'\n"
                        islocal = True
                    # Retrieve and process the variable value
                    value = getattr(template_vars, var)
                    if value in ["", None]:
                        template += f'{var} = ""\n'  # Set empty or None values as ""
                    elif isinstance(value, str):
                        safe_value = value.replace('\\', '\\\\').replace('\n', '\\n')
                        template += f"{var} = {safe_value}\n"
                    else:
                        template += f"{var} = {value}\n"
                    # Add the variable to valid_local_vars for selective update of allvars
                    valid_local_vars.setattr(var, value)
            # Update allvars only with filtered, valid local variables
            allvars += valid_local_vars

            # Write the template content
            if isinstance(script_template.content, list):
                if len(script_template.content) == 1:
                    # Single-line template saved as a single line
                    content_str = script_template.content[0].strip()
                    template += "" if printsinglecontent else "\n"
                    template += f"{key}: {content_str}\n"
                    printsinglecontent = True
                else:
                    content_str = '\n    '.join(script_template.content)
                    template += f"\n{key}: [\n    {content_str}\n ]\n"
                    printsinglecontent = False
            else:
                template += "" if printsinglecontent else "\n"
                template += f"{key}: {script_template.content}\n"
                printsinglecontent = True

        # Attributes (number of lines/items with explicit attributes)
        attributes = f"# ATTRIBUTES (number of items with explicit attributes={len(self.TEMPLATE)})\n"
        for key, script_template in self.TEMPLATE.items():
            attr_str = ", ".join(f"{attr_name}={repr(attr_value)}"
                                 for attr_name, attr_value in script_template.attributes.items())
            attributes += f"{key}:{{{attr_str}}}\n"

        # Combine all sections into one content
        content = header + "\n" + global_params + "\n" + definitions + "\n" + template + "\n" + attributes + "\n"


        # Append footer information to the content
        non_empty_lines = sum(1 for line in content.splitlines() if line.strip())  # Count non-empty lines
        execution_time = time.time() - start_time  # Calculate the execution time in seconds
        # Prepare the footer content
        footer_lines = [
            ["Non-empty lines", str(non_empty_lines)],
            ["Execution time (seconds)", f"{execution_time:.4f}"],
        ]
        # Format footer into tabular style
        footer_content = [
            f"{row[0]:<25} {row[1]:<15}" for row in footer_lines
        ]
        # Use frame_header to format footer
        footer = frame_header(
            lines=["DSCRIPT SAVE FILE generator"] + footer_content,
            style=1
        )
        # Append footer to the content
        content += f"\n{footer}"

        if generatoronly:
            return content
        else:
            # Write the content to the file
            with open(filepath, 'w') as f:
                f.write(content)
            print(f"\nScript saved to {filepath}")
            return filepath



    # Write Method -- added on 2024-09-05
    # ------------
    @staticmethod
    @staticmethod
    def write(scriptcontent, filename=None, foldername=None, overwrite=False):
        """
        Writes the provided script content to a specified file in a given folder, with a header if necessary.

        Parameters
        ----------
        scriptcontent : str
            The content to be written to the file.

        filename : str, optional
            The name of the file. If not provided, a random name will be generated.
            The extension `.txt` will be appended if not already present.

        foldername : str, optional
            The folder where the file will be saved. If not provided, the current working directory is used.

        overwrite : bool, optional
            If False (default), raises a `FileExistsError` if the file already exists. If True, the file will be overwritten if it exists.

        Returns
        -------
        str
            The full path to the written file.

        Raises
        ------
        FileExistsError
            If the file already exists and `overwrite` is set to False.

        Notes
        -----
        - A header is prepended to the content if it does not already exist, using the `header` method.
        - The header includes metadata such as the current date, username, hostname, and file details.
        """
        # Generate a random name if filename is not provided
        if filename is None:
            filename = autoname(8)  # Generates a random name of 8 letters

        # Ensure the filename ends with '.txt'
        if not filename.endswith('.txt'):
            filename += '.txt'

        # Handle foldername and relative paths
        if foldername is None or foldername == "":
            # If foldername is empty or None, use current working directory for relative paths
            if not os.path.isabs(filename):
                filepath = os.path.join(os.getcwd(), filename)
            else:
                filepath = filename  # If filename is absolute, use it directly
        else:
            # If foldername is provided and filename is not absolute, use foldername
            if not os.path.isabs(filename):
                filepath = os.path.join(foldername, filename)
            else:
                filepath = filename

        # Check if file already exists, raise exception if it does and overwrite is False
        if os.path.exists(filepath) and not overwrite:
            raise FileExistsError(f"The file '{filepath}' already exists.")

        # Count total and non-empty lines in the content
        total_lines = len(scriptcontent.splitlines())
        non_empty_lines = sum(1 for line in scriptcontent.splitlines() if line.strip())

        # Prepare header if not already present
        if not scriptcontent.startswith("# DSCRIPT SAVE FILE"):
            fname = os.path.basename(filepath)  # Extracts the filename (e.g., "myscript.txt")
            name, _ = os.path.splitext(fname)   # Removes the extension, e.g., "myscript"
            metadata = get_metadata()           # retrieve all metadata (statically)
            header = dscript.header(name=name, verbosity=True,style=1,filepath=filepath,
                    version = metadata["version"], license = metadata["license"], email = metadata["email"])
            # Add line count information to the header
            footer = frame_header(
                lines=[
                    f"Total lines written: {total_lines}",
                    f"Non-empty lines: {non_empty_lines}"
                ],
                style=1
            )
            scriptcontent = header + "\n" + scriptcontent + "\n" + footer

        # Write the content to the file
        with open(filepath, 'w') as file:
            file.write(scriptcontent)
        return filepath



    # Load Method and its Parsing Rules -- added on 2024-09-04
    # ---------------------------------
    @classmethod
    def load(cls, filename, foldername=None, numerickeys=True):
        """
        Load a script instance from a text file.

        Parameters
        ----------
        filename : str
            The name of the file to load the script from. If the filename does not end with ".txt",
            the extension is automatically appended.

        foldername : str, optional
            The directory where the file is located. If not provided, it defaults to the system's
            temporary directory. If the filename does not include a full path, this folder will be used.

        numerickeys : bool, default=True
            If True, numeric string keys in the template section are automatically converted into integers.
            For example, the key "0" would be converted into the integer 0.

        Returns
        -------
        dscript
            A new `dscript` instance populated with the content of the loaded file.

        Raises
        ------
        ValueError
            If the file does not start with the correct DSCRIPT header or the file format is invalid.

        FileNotFoundError
            If the specified file does not exist.

        Notes
        -----
        - The file is expected to follow the same structured format as the one produced by the `save()` method.
        - The method processes global parameters, definitions, template lines/items, and attributes. If the file
          includes numeric keys as strings (e.g., "0", "1"), they can be automatically converted into integers
          if `numerickeys=True`.
        - The script structure is dynamically rebuilt, and each section (global parameters, definitions,
          template, and attributes) is correctly parsed and assigned to the corresponding parts of the `dscript`
          instance.
        """

        # Step 0 validate filepath
        if not filename.endswith('.txt'):
            filename += '.txt'

        # Handle foldername and relative paths
        if foldername is None or foldername == "":
            # If the foldername is empty or None, use current working directory for relative paths
            if not os.path.isabs(filename):
                filepath = os.path.join(os.getcwd(), filename)
            else:
                filepath = filename  # If filename is absolute, use it directly
        else:
            # If foldername is provided and filename is not absolute, use foldername
            if not os.path.isabs(filename):
                filepath = os.path.join(foldername, filename)
            else:
                filepath = filename

        if not os.path.exists(filepath):
            raise FileExistsError(f"The file '{filepath}' does not exist.")

        # Read the file contents
        with open(filepath, 'r') as f:
            content = f.read()

        # Call parsesyntax to parse the file content
        fname = os.path.basename(filepath)  # Extracts the filename (e.g., "myscript.txt")
        name, _ = os.path.splitext(fname)   # Removes the extension, e.g., "myscript
        return cls.parsesyntax(content, name, numerickeys)



    # Load Method and its Parsing Rules -- added on 2024-09-04
    # ---------------------------------
    @classmethod
    def parsesyntax(cls, content, name=None, numerickeys=True, verbose=False, authentification=True,
                    comment_chars="#%",continuation_marker="..."):
        """
        Parse a DSCRIPT script from a string content.

        Parameters
        ----------
        content : str
            The string content of the DSCRIPT script to be parsed.

        name : str, optional
            The name of the dscript project. If `None`, a random name is generated.

        numerickeys : bool, default=True
            If `True`, numeric string keys in the template section are automatically converted into integers.

        verbose : bool, default=False
            If `True`, the parser will output warnings for unrecognized lines outside of blocks.

        authentification : bool, default=True
            If `True`, the parser is expected that the first non empty line is # DSCRIPT SAVE FILE

        comment_chars : str, optional (default: "#%")
            A string containing characters to identify the start of a comment.
            Any of these characters will mark the beginning of a comment unless within quotes.

        continuation_marker : str, optional (default: "...")
            A string containing characters to indicate line continuation
            Any characters after the continuation marker are considered comment and are theorefore ignored



        Returns
        -------
        dscript
            A new `dscript` instance populated with the content of the loaded file.

        Raises
        ------
        ValueError
            If content does not start with the correct DSCRIPT header or the file format is invalid.

        Notes
        -----
        **DSCRIPT SAVE FILE FORMAT**

        This script syntax is designed for creating dynamic and customizable input files, where variables, templates,
        and control attributes can be defined in a flexible manner.

        **Mandatory First Line:**

        Every DSCRIPT file must begin with the following line:

        ```plaintext
        # DSCRIPT SAVE FILE
        ```

        **Structure Overview:**

        1. **Global Parameters Section (Optional):**

            - This section defines global script settings, enclosed within curly braces `{}`.
            - Properties include:
                - `SECTIONS`: List of section names to be considered (e.g., `["DYNAMIC"]`).
                - `section`: Current section index (e.g., `0`).
                - `position`: Current script position in the order.
                - `role`: Defines the role of the script instance (e.g., `"dscript instance"`).
                - `description`: A short description of the script (e.g., `"dynamic script"`).
                - `userid`: Identifier for the user (e.g., `"dscript"`).
                - `version`: Script version (e.g., `0.1`).
                - `verbose`: Verbosity flag, typically a boolean (e.g., `False`).

            **Example:**

            ```plaintext
            {
                SECTIONS = ['INITIALIZATION', 'SIMULATION']  # Global script parameters
            }
            ```

        2. **Definitions Section:**

            - Variables are defined in Python-like syntax, allowing for dynamic variable substitution.
            - Variables can be numbers, strings, or lists, and they can include placeholders using `$`
              to delay execution or substitution.

            **Example:**

            ```plaintext
            d = 3                               # Define a number
            periodic = "$p"                     # '$' prevents immediate evaluation of 'p'
            units = "$metal"                    # '$' prevents immediate evaluation of 'metal'
            dimension = "${d}"                  # Variable substitution
            boundary = ['p', 'p', 'p']          # List with a mix of variables and values
            atom_style = "$atomic"              # String variable with delayed evaluation
            ```

        3. **Templates Section:**

            - This section provides a mapping between keys and their corresponding commands or instructions.
            - Each template can reference variables defined in the **Definitions** section or elsewhere, typically using the `${variable}` syntax.
            - **Syntax Variations**:

                Templates can be defined in several ways, including without blocks, with single- or multi-line blocks, and with ellipsis (`...`) as a line continuation marker.

                - **Single-line Template Without Block**:
                    ```plaintext
                    KEY: INSTRUCTION
                    ```
                    - `KEY` is the identifier for the template (numeric or alphanumeric).
                    - `INSTRUCTION` is the command or template text, which may reference variables.

                - **Single-line Template With Block**:
                    ```plaintext
                    KEY: [INSTRUCTION]
                    ```
                    - Uses square brackets (`[ ]`) around the `INSTRUCTION`, indicating that all instructions are part of the block.

                - **Multi-line Template With Block**:
                    ```plaintext
                    KEY: [
                        INSTRUCTION1
                        INSTRUCTION2
                        ...
                        ]
                    ```
                    - Begins with `KEY: [` and ends with a standalone `]` on a new line.
                    - Instructions within the block can span multiple lines, and ellipses (`...`) at the end of a line are used to indicate that the line continues, ignoring any content following the ellipsis as comments.
                    - Comments following ellipses are removed after parsing and do not become part of the block, preserving only the instructions.

                - **Multi-line Template With Continuation Marker (Ellipsis)**:
                    - For templates with complex code containing square brackets (`[ ]`), the ellipsis (`...`) can be used to prevent `]` from prematurely closing the block. The ellipsis will keep the line open across multiple lines, allowing brackets in the instructions.

                    **Example:**
                    ```plaintext
                    example1: command ${value}       # Single-line template without block
                    example2: [command ${value}]     # Single-line template with block

                    # Multi-line template with block
                    example3: [
                        command1 ${var1}
                        command2 ${var2} ...   # Line continues after ellipsis
                        command3 ${var3} ...   # Additional instruction continues
                        ]

                    # Multi-line template with ellipsis (handling square brackets)
                    example4: [
                        A[0][1] ...            # Ellipsis allows [ ] within instructions
                        B[2][3] ...            # Another instruction in the block
                        ]
                    ```

            - **Key Points**:
                - **Blocks** allow grouping of multiple instructions for a single key, enclosed in square brackets.
                - **Ellipsis (`...`)** at the end of a line keeps the line open, preventing premature closing by `]`, especially useful if the template code includes square brackets (`[ ]`).
                - **Comments** placed after the ellipsis are removed after parsing and are not part of the final block content.

            This flexibility supports both simple and complex template structures, allowing instructions to be grouped logically while keeping code and comments distinct.


        4. **Attributes Section:**

            - Each template line can have customizable attributes to control behavior and conditions.
            - Default attributes include:
                - `facultative`: If `True`, the line is optional and can be removed if needed.
                - `eval`: If `True`, the line will be evaluated with Python's `eval()` function.
                - `readonly`: If `True`, the line cannot be modified later in the script.
                - `condition`: An expression that must be satisfied for the line to be included.
                - `condeval`: If `True`, the condition will be evaluated using `eval()`.
                - `detectvar`: If `True`, this creates variables in the **Definitions** section if they do not exist.

            **Example:**

            ```plaintext
            units: {facultative=False, eval=False, readonly=False, condition="${units}", condeval=False, detectvar=True}
            dim: {facultative=False, eval=False, readonly=False, condition=None, condeval=False, detectvar=True}
            ```

        **Note on Multiple Definitions**

        This example demonstrates how variables defined in the **Definitions** section are handled for each template.
        Each template retains its own snapshot of the variable definitions at the time it is created, ensuring that templates
        can use different values for the same variable if redefined.

        **Example:**

        ```plaintext
        # DSCRIPT SAVE FILE

        # Definitions
        var = 10

        # Template key1
        key1: Template content with ${var}

        # Definitions
        var = 20

        # Template key2
        key2: Template content with ${var}

        # Template key3
        key3:[
            this is an undefined variable ${var31}
            this is another undefined variable ${var32}
            this variable is defined  ${var}
        ]
        ```

        **Parsing and Usage:**

        ```python
        # Parse content using parsesyntax()
        ds = dscript.parsesyntax(content)

        # Accessing templates and their variables
        print(ds.TEMPLATE['key1'].text)  # Output: Template content with 10
        print(ds.TEMPLATE['key2'].text)  # Output: Template content with 20
        ```

        **Handling Undefined Variables:**

        Variables like `${var31}` and `${var32}` in `key3` are undefined. The parser will handle them based on your substitution logic or raise an error if they are required.

        **Important Notes:**

        - The parser processes the script sequentially. Definitions must appear before the templates that use them.
        - Templates capture the variable definitions at the time they are parsed. Redefining a variable affects only subsequent templates.
        - Comments outside of blocks are allowed and ignored by the parser.
        - Content within templates is treated as-is, allowing for any syntax required by the target system (e.g., LAMMPS commands).


    **Advanced Example**

        Here's a more advanced example demonstrating the use of global definitions, local definitions, templates, and how to parse and render the template content.

        ```python
        content = '''
            # GLOBAL DEFINITIONS
            dumpfile = $dump.LAMMPS
            dumpdt = 50
            thermodt = 100
            runtime = 5000

            # LOCAL DEFINITIONS for step '0'
            dimension = 3
            units = $si
            boundary = ['f', 'f', 'f']
            atom_style = $smd
            atom_modify = ['map', 'array']
            comm_modify = ['vel', 'yes']
            neigh_modify = ['every', 10, 'delay', 0, 'check', 'yes']
            newton = $off
            name = $SimulationBox

            # This is a comment line outside of blocks
            # ------------------------------------------

            0: [    % --------------[ Initialization Header (helper) for "${name}" ]--------------
                # set a parameter to None or "" to remove the definition
                dimension    ${dimension}
                units        ${units}
                boundary     ${boundary}
                atom_style   ${atom_style}
                atom_modify  ${atom_modify}
                comm_modify  ${comm_modify}
                neigh_modify ${neigh_modify}
                newton       ${newton}
                # ------------------------------------------
             ]
        '''
        # Parse the content
        ds = dscript.parsesyntax(content, verbose=True, authentification=False)

        # Access and print the rendered template
        print("Template 0 content:")
        print(ds.TEMPLATE[0].do())
        ```

        **Explanation:**

        - **Global Definitions:** Define variables that are accessible throughout the script.
        - **Local Definitions for Step '0':** Define variables specific to a particular step or template.
        - **Template Block:** Identified by `0: [ ... ]`, it contains the content where variables will be substituted.
        - **Comments:** Lines starting with `#` are comments and are ignored by the parser outside of template blocks.

        **Expected Output:**

        ```
        Template 0 content:
        # --------------[ Initialization Header (helper) for "SimulationBox" ]--------------
        # set a parameter to None or "" to remove the definition
        dimension    3
        units        si
        boundary     ['f', 'f', 'f']
        atom_style   smd
        atom_modify  ['map', 'array']
        comm_modify  ['vel', 'yes']
        neigh_modify ['every', 10, 'delay', 0, 'check', 'yes']
        newton       off
        # ------------------------------------------
        ```

        **Notes:**

        - The `do()` method renders the template, substituting variables with their defined values.
        - Variables like `${dimension}` are replaced with their corresponding values defined in the local or global definitions.
        - The parser handles comments and blank lines appropriately, ensuring they don't interfere with the parsing logic.


        """
        # Split the content into lines
        lines = content.splitlines()
        if not lines:
            raise ValueError("File/Content is empty or only contains blank lines.")

        # Initialize containers
        global_params = {}
        GLOBALdefinitions = lambdaScriptdata()
        LOCALdefinitions = lambdaScriptdata()
        template = {}
        attributes = {}

        # State variables
        inside_global_params = False
        global_params_content = ""
        inside_template_block = False
        current_template_key = None
        current_template_content = []
        current_var_value = lambdaScriptdata()

        # Initialize line number
        line_number = 0
        last_successful_line = 0

        # Step 1: Authenticate the file
        if authentification:
            auth_line_found = False
            max_header_lines = 10
            header_end_idx = -1
            for idx, line in enumerate(lines[:max_header_lines]):
                stripped_line = line.strip()
                if not stripped_line:
                    continue
                if stripped_line.startswith("# DSCRIPT SAVE FILE"):
                    auth_line_found = True
                    header_end_idx = idx
                    break
                elif stripped_line.startswith("#") or stripped_line.startswith("%"):
                    continue
                else:
                    raise ValueError(f"Unexpected content before authentication line (# DSCRIPT SAVE FILE) at line {idx + 1}:\n{line}")
            if not auth_line_found:
                raise ValueError("File/Content is not a valid DSCRIPT file.")

            # Remove header lines
            lines = lines[header_end_idx + 1:]
            line_number = header_end_idx + 1
            last_successful_line = line_number - 1
        else:
            line_number = 0
            last_successful_line = 0

        # Process each line
        for idx, line in enumerate(lines):
            line_number += 1
            line_content = line.rstrip('\n')

            # Determine if we're inside a template block
            if inside_template_block:
                # Extract the code with its eventual continuation_marker
                code_line = remove_comments(
                        line_content,
                        comment_chars=comment_chars,
                        continuation_marker=continuation_marker,
                        remove_continuation_marker=False,
                        ).rstrip()

                # Check if line should continue
                if code_line.endswith(continuation_marker):
                    # Append line up to the continuation marker
                    endofline_index = line_content.rindex(continuation_marker)
                    trimmed_content = line_content[:endofline_index].rstrip()
                    if trimmed_content:
                        current_template_content.append(trimmed_content)
                    continue
                elif code_line.endswith("]"):  # End of multi-line block
                    closing_index = code_line.rindex(']')
                    trimmed_content = code_line[:closing_index].rstrip()

                    # Append any valid content before `]`, if non-empty
                    if trimmed_content:
                        current_template_content.append(trimmed_content)

                    # End of template block
                    content = '\n'.join(current_template_content)
                    template[current_template_key] = ScriptTemplate(
                        content=content,
                        autorefresh=False,
                        definitions=LOCALdefinitions,
                        verbose=verbose,
                        userid=current_template_key)
                    # Refresh variables definitions
                    template[current_template_key].refreshvar(globaldefinitions=GLOBALdefinitions)
                    LOCALdefinitions = lambdaScriptdata()
                    # Reset state for next block
                    inside_template_block = False
                    current_template_key = None
                    current_template_content = []
                    last_successful_line = line_number
                    continue
                else:
                    # Append the entire original line content if not ending with `...` or `]`
                    current_template_content.append(line_content)
                continue

            # Not inside a template block
            stripped_no_comments = remove_comments(line_content)

            # Ignore empty lines after removing comments
            if not stripped_no_comments.strip():
                continue

            # If the original line is a comment line, skip it
            if line_content.strip().startswith("#") or line_content.strip().startswith("%"):
                continue

            stripped = stripped_no_comments.strip()

            # Handle start of a new template block
            template_block_match = re.match(r'^(\w+)\s*:\s*\[', stripped)
            if template_block_match:
                current_template_key = template_block_match.group(1)
                if inside_template_block:
                    # Collect error context
                    context_start = max(0, last_successful_line - 3)
                    context_end = min(len(lines), line_number + 2)
                    error_context_lines = lines[context_start:context_end]
                    error_context = ""
                    for i, error_line in enumerate(error_context_lines):
                        line_num = context_start + i + 1
                        indicator = ">" if line_num == line_number else "*" if line_num == last_successful_line else " "
                        error_context += f"{indicator} {line_num}: {error_line}\n"

                    raise ValueError(
                        f"Template block '{current_template_key}' starting at line {last_successful_line} (*) was not properly closed before starting a new one at line {line_number} (>).\n\n"
                        f"Error context:\n{error_context}"
                    )
                else:
                    inside_template_block = True
                    idx_open_bracket = line_content.index('[')
                    remainder = line_content[idx_open_bracket + 1:].strip()
                    if remainder:
                        remainder_code = remove_comments(remainder, comment_chars=comment_chars).rstrip()
                        if remainder_code.endswith("]"):
                            closing_index = remainder_code.rindex(']')
                            content_line = remainder_code[:closing_index].strip()
                            if content_line:
                                current_template_content.append(content_line)
                            content = '\n'.join(current_template_content)
                            template[current_template_key] = ScriptTemplate(
                                content=content,
                                autorefresh=False,
                                definitions=LOCALdefinitions,
                                verbose=verbose,
                                userid=current_template_key)
                            template[current_template_key].refreshvar(globaldefinitions=GLOBALdefinitions)
                            LOCALdefinitions = lambdaScriptdata()
                            inside_template_block = False
                            current_template_key = None
                            current_template_content = []
                            last_successful_line = line_number
                            continue
                        else:
                            current_template_content.append(remainder)
                    last_successful_line = line_number
                continue

            # Handle start of global parameters
            if stripped.startswith('{') and not inside_global_params:
                if '}' in stripped:
                    global_params_content = stripped
                    cls._parse_global_params(global_params_content.strip(), global_params)
                    global_params_content = ""
                    last_successful_line = line_number
                else:
                    inside_global_params = True
                    global_params_content = stripped
                continue

            # Handle global parameters inside {...}
            if inside_global_params:
                global_params_content += ' ' + stripped
                if '}' in stripped:
                    inside_global_params = False
                    cls._parse_global_params(global_params_content.strip(), global_params)
                    global_params_content = ""
                    last_successful_line = line_number
                continue

            # Handle attributes
            attribute_match = re.match(r'^(\w+)\s*:\s*\{(.+)\}', stripped)
            if attribute_match:
                key, attr_content = attribute_match.groups()
                attributes[key] = {}
                cls._parse_attributes(attributes[key], attr_content.strip())
                last_successful_line = line_number
                continue

            # Handle definitions
            definition_match = re.match(r'^(\w+)\s*=\s*(.+)', stripped)
            if definition_match:
                key, value = definition_match.groups()
                convertedvalue = cls._convert_value(value)
                if key in GLOBALdefinitions:
                    if (GLOBALdefinitions.getattr(key) != convertedvalue) or \
                        (getattr(current_var_value, key) != convertedvalue):
                        LOCALdefinitions.setattr(key, convertedvalue)
                else:
                    GLOBALdefinitions.setattr(key, convertedvalue)
                last_successful_line = line_number
                setattr(current_var_value, key, convertedvalue)
                continue

            # Handle single-line templates
            template_match = re.match(r'^(\w+)\s*:\s*(.+)', stripped)
            if template_match:
                key, content = template_match.groups()
                template[key] = ScriptTemplate(
                    content = content.strip(),
                    autorefresh = False,
                    definitions=LOCALdefinitions,
                    verbose=verbose,
                    userid=key)
                template[key].refreshvar(globaldefinitions=GLOBALdefinitions)
                LOCALdefinitions = lambdaScriptdata()
                last_successful_line = line_number
                continue

            # Unrecognized line
            if verbose:
                print(f"Warning: Unrecognized line at {line_number}: {line_content}")
            last_successful_line = line_number
            continue

        # At the end, check if any template block was left unclosed
        if inside_template_block:
            # Collect error context
            context_start = max(0, last_successful_line - 3)
            context_end = min(len(lines), last_successful_line + 3)
            error_context_lines = lines[context_start:context_end]
            error_context = ""
            for i, error_line in enumerate(error_context_lines):
                line_num = context_start + i
                indicator = ">" if line_num == last_successful_line else " "
                error_context += f"{indicator} {line_num}: {error_line}\n"

            raise ValueError(
                f"Template block '{current_template_key}' starting at line {last_successful_line} was not properly closed.\n\n"
                f"Error context:\n{error_context}"
            )

        # Apply attributes to templates
        for key in attributes:
            if key in template:
                for attr_name, attr_value in attributes[key].items():
                    setattr(template[key], attr_name, attr_value)
                template[key]._autorefresh = True # restore the default behavior for the end-user
            else:
                raise ValueError(f"Attributes found for undefined template key: {key}")

        # Create and return new instance
        if name is None:
            name = autoname(8)
        instance = cls(
            name=name,
            SECTIONS=global_params.get('SECTIONS', ['DYNAMIC']),
            section=global_params.get('section', 0),
            position=global_params.get('position', 0),
            role=global_params.get('role', 'dscript instance'),
            description=global_params.get('description', 'dynamic script'),
            userid=global_params.get('userid', 'dscript'),
            version=global_params.get('version', 0.1),
            verbose=global_params.get('verbose', False)
        )

        # Convert numeric string keys to integers if numerickeys is True
        if numerickeys:
            numeric_template = {}
            for key, value in template.items():
                if key.isdigit():
                    numeric_template[int(key)] = value
                else:
                    numeric_template[key] = value
            template = numeric_template

        # Set definitions and template
        instance.DEFINITIONS = GLOBALdefinitions
        instance.TEMPLATE = template

        # Refresh variables
        instance.set_all_variables()

        # Check variables
        instance.check_all_variables(verbose=False)

        return instance




    @classmethod
    def parsesyntax_legacy(cls, content, name=None, numerickeys=True):
        """
        Parse a script from a string content.
        [ ------------------------------------------------------]
        [ Legacy parsesyntax method for backward compatibility. ]
        [ ------------------------------------------------------]

        Parameters
        ----------
        content : str
            The string content of the script to be parsed.

        name : str
            The name of the dscript project (if None, it is set randomly)

        numerickeys : bool, default=True
            If True, numeric string keys in the template section are automatically converted into integers.

        Returns
        -------
        dscript
            A new `dscript` instance populated with the content of the loaded file.

        Raises
        ------
        ValueError
            If content does not start with the correct DSCRIPT header or the file format is invalid.

        Notes
        -----
        - The file is expected to follow the same structured format as the one produced by the `save()` method.
        - The method processes global parameters, definitions, template lines/items, and attributes. If the file
          includes numeric keys as strings (e.g., "0", "1"), they can be automatically converted into integers
          if `numerickeys=True`.
        - The script structure is dynamically rebuilt, and each section (global parameters, definitions,
          template, and attributes) is correctly parsed and assigned to the corresponding parts of the `dscript`
          instance.


        PIZZA.DSCRIPT SAVE FILE FORMAT
        -------------------------------
        This script syntax is designed for creating dynamic and customizable input files, where variables, templates,
        and control attributes can be defined in a flexible manner.

        ### Mandatory First Line:
        Every DSCRIPT file must begin with the following line:
            # DSCRIPT SAVE FILE

        ### Structure Overview:

        1. **Global Parameters Section (Optional):**
            - This section defines global script settings, enclosed within curly braces `{ }`.
            - Properties include:
                - `SECTIONS`: List of section names to be considered (e.g., `["DYNAMIC"]`).
                - `section`: Current section index (e.g., `0`).
                - `position`: Current script position in the order.
                - `role`: Defines the role of the script instance (e.g., `"dscript instance"`).
                - `description`: A short description of the script (e.g., `"dynamic script"`).
                - `userid`: Identifier for the user (e.g., `"dscript"`).
                - `version`: Script version (e.g., `0.1`).
                - `verbose`: Verbosity flag, typically a boolean (e.g., `False`).

            Example:
            ```
            {
                SECTIONS = ['INITIALIZATION', 'SIMULATION']  # Global script parameters
            }
            ```

        2. **Definitions Section:**
            - Variables are defined in Python-like syntax, allowing for dynamic variable substitution.
            - Variables can be numbers, strings, or lists, and they can include placeholders using `$`
              to delay execution or substitution.

            Example:
            ```
            d = 3                               # Define a number
            periodic = "$p"                     # '$' prevents immediate evaluation of 'p'
            units = "$metal"                    # '$' prevents immediate evaluation of 'metal'
            dimension = "${d}"                  # Variable substitution
            boundary = ['p', 'p', 'p']  # List with a mix of variables and values
            atom_style = "$atomic"              # String variable with delayed evaluation
            ```

        3. **Templates Section:**
            - This section provides a mapping between keys and their corresponding commands or instructions.
            - The templates reference variables defined in the **Definitions** section or elsewhere.
            - Syntax:
                ```
                KEY: INSTRUCTION
                ```
                where:
                - `KEY` can be numeric or alphanumeric.
                - `INSTRUCTION` represents a command template, often referring to variables using `${variable}` notation.

            Example:
            ```
            units: units ${units}               # Template uses the 'units' variable
            dim: dimension ${dimension}         # Template for setting the dimension
            bound: boundary ${boundary}         # Template for boundary settings
            lattice: lattice ${lattice}         # Lattice template
            ```

        4. **Attributes Section:**
            - Each template line can have customizable attributes to control behavior and conditions.
            - Default attributes include:
                - `facultative`: If `True`, the line is optional and can be removed if needed.
                - `eval`: If `True`, the line will be evaluated with Python's `eval()` function.
                - `readonly`: If `True`, the line cannot be modified later in the script.
                - `condition`: An expression that must be satisfied for the line to be included.
                - `condeval`: If `True`, the condition will be evaluated using `eval()`.
                - `detectvar`: If `True`, this creates variables in the **Definitions** section if they do not exist.

            Example:
            ```
            units: {facultative=False, eval=False, readonly=False, condition="${units}", condeval=False, detectvar=True}
            dim: {facultative=False, eval=False, readonly=False, condition=None, condeval=False, detectvar=True}
            ```

        Note on multiple definitions
        -----------------------------
        This example demonstrates how variables defined in the `Definitions` section are handled for each template.
        Each template retains its own snapshot of the variable definitions at the time it is created, ensuring that templates
        can use different values for the same variable if redefined.

        content = "" "
        # DSCRIPT SAVE FILE

        # Definitions
        var = 10

        # Template ky1
        key1: Template content with ${var}

        # Definitions
        var = 20

        # Template key2
        key2: Template content with ${var}

        # Template key3
        key3:[
            this is an underfined variable ${var31}
            this is an another underfined variable ${var32}
            this variables is defined  ${var}
            ]

        "" "

        # Parse content using parsesyntax()
        ds = dscript.parsesyntax(content)

        # Key1 should use the first definition of 'var' (10)
        print(ds.key1.definitions.var)  # Output: Template content with 10

        # Key2 should use the updated definition of 'var' (20)
        print(ds.key2.definitions.var)  # Output: Template content with 10


        """

        # Split the content into lines
        lines = content.splitlines()
        lines = [line for line in lines if line.strip()]  # Remove blank or empty lines
        # Raise an error if no content is left after removing blank lines
        if not lines:
            raise ValueError("File/Content is empty or only contains blank lines.")

        # Initialize containers for global parameters, definitions, templates, and attributes
        global_params = {}
        definitions = lambdaScriptdata()
        template = {}
        attributes = {}

        # State variables to handle multi-line global parameters and attributes
        inside_global_params = False
        inside_attributes = False
        current_attr_key = None  # Ensure this is properly initialized
        global_params_content = ""
        inside_template_block = False  # Track if we are inside a multi-line template
        current_template_key = None    # Track the current template key
        current_template_content = []  # Store lines for the current template content

        # Step 1: Authenticate the file
        if not lines[0].strip().startswith("# DSCRIPT SAVE FILE"):
            raise ValueError("File/Content is not a valid DSCRIPT file.")

        # Step 2: Process each line dynamically
        for line in lines[1:]:
            stripped = line.strip()

            # Ignore empty lines and comments
            if not stripped or stripped.startswith("#"):
                continue

            # Remove trailing comments
            stripped = remove_comments(stripped)

            # Step 3: Handle global parameters inside {...}
            if stripped.startswith("{"):
                # Found the opening {, start accumulating global parameters
                inside_global_params = True
                # Remove the opening { and accumulate the remaining content
                global_params_content = stripped[stripped.index('{') + 1:].strip()

                # Check if the closing } is also on the same line
                if '}' in global_params_content:
                    global_params_content = global_params_content[:global_params_content.index('}')].strip()
                    inside_global_params = False  # We found the closing } on the same line
                    # Now parse the global parameters block
                    cls._parse_global_params(global_params_content.strip(), global_params)
                    global_params_content = ""  # Reset for the next block
                continue

            if inside_global_params:
                # Accumulate content until the closing } is found
                if stripped.endswith("}"):
                    # Found the closing }, accumulate and process the entire block
                    global_params_content += " " + stripped[:stripped.index('}')].strip()
                    inside_global_params = False  # Finished reading global parameters block

                    # Now parse the entire global parameters block
                    cls._parse_global_params(global_params_content.strip(), global_params)
                    global_params_content = ""  # Reset for the next block if necessary
                else:
                    # Continue accumulating if } is not found
                    global_params_content += " " + stripped
                continue

            # Step 4: Detect the start of a multi-line template block inside [...]
            if not inside_template_block:
                template_match = re.match(r'(\w+)\s*:\s*\[', stripped)
                if template_match:
                    current_template_key = template_match.group(1)  # Capture the key
                    inside_template_block = True
                    current_template_content = []  # Reset content list
                    continue

            # If inside a template block, accumulate lines until we find the closing ]
            if inside_template_block:
                if stripped == "]":
                    # End of the template block, join the content and store it
                    template[current_template_key] = ScriptTemplate(
                        current_template_content,
                        definitions=lambdaScriptdata(**definitions),  # Clone current global definitions
                        verbose=True,
                        userid=current_template_key
                        )
                    template[current_template_key].refreshvar()
                    inside_template_block = False
                    current_template_key = None
                    current_template_content = []
                else:
                    # Accumulate the current line (without surrounding spaces)
                    current_template_content.append(stripped)
                continue

            # Step 5: Handle attributes inside {...}
            if inside_attributes and stripped.endswith("}"):
                # Finish processing attributes for the current key
                cls._parse_attributes(attributes[current_attr_key], stripped[:-1])  # Remove trailing }
                inside_attributes = False
                current_attr_key = None
                continue

            if inside_attributes:
                # Continue accumulating attributes
                cls._parse_attributes(attributes[current_attr_key], stripped)
                continue

            # Step 6: Determine if the line is a definition, template, or attribute
            definition_match = re.match(r'(\w+)\s*=\s*(.+)', stripped)
            template_match = re.match(r'(\w+)\s*:\s*(?!\s*\{.*\}\s*$)(.+)', stripped) # template_match = re.match(r'(\w+)\s*:\s*(?!\{)(.+)', stripped)
            attribute_match = re.match(r'(\w+)\s*:\s*\{\s*(.+)\s*\}', stripped)       # attribute_match = re.match(r'(\w+)\s*:\s*\{(.+)\}', stripped)

            if definition_match:
                # Line is a definition (key=value)
                key, value = definition_match.groups()
                definitions.setattr(key,cls._convert_value(value))

            elif template_match and not inside_template_block:
                # Line is a template (key: content)
                key, content = template_match.groups()
                template[key] = ScriptTemplate(
                    content,
                    definitions=lambdaScriptdata(**definitions),  # Clone current definitions
                    verbose=True,
                    userid=current_template_key)
                template[key].refreshvar()

            elif attribute_match:
                # Line is an attribute (key:{attributes...})
                current_attr_key, attr_content = attribute_match.groups()
                attributes[current_attr_key] = {}
                cls._parse_attributes(attributes[current_attr_key], attr_content)
                inside_attributes = not stripped.endswith("}")

        # Step 7: Validation and Reconstruction
        # Make sure there are no attributes without a template entry
        for key in attributes:
            if key not in template:
                raise ValueError(f"Attributes found for undefined template key: {key}")
            # Apply attributes to the corresponding template object
            for attr_name, attr_value in attributes[key].items():
                setattr(template[key], attr_name, attr_value)

        # Step 7: Create and return a new dscript instance
        if name is None:
            name = autoname(8)
        instance = cls(
            name = name,
            SECTIONS=global_params.get('SECTIONS', ['DYNAMIC']),
            section=global_params.get('section', 0),
            position=global_params.get('position', 0),
            role=global_params.get('role', 'dscript instance'),
            description=global_params.get('description', 'dynamic script'),
            userid=global_params.get('userid', 'dscript'),
            version=global_params.get('version', 0.1),
            verbose=global_params.get('verbose', False)
        )


        # Convert numeric string keys to integers if numerickeys is True
        if numerickeys:
            numeric_template = {}
            for key, value in template.items():
                # Check if the key is a numeric string
                if key.isdigit():
                    numeric_template[int(key)] = value
                else:
                    numeric_template[key] = value
            template = numeric_template

        # Set definitions and template
        instance.DEFINITIONS = definitions
        instance.TEMPLATE = template

        # Refresh variables (ensure that variables are detected and added to definitions)
        instance.set_all_variables()

        # Check eval
        instance.check_all_variables(verbose=False)

        # return the new instance
        return instance


    @classmethod
    def _parse_global_params(cls, content, global_params):
        """
        Parses global parameters from the accumulated content enclosed in `{}`.

        ### Parameters:
            content (str): The content string containing global parameters.
            global_params (dict): A dictionary to populate with parsed parameters.

        ### Raises:
            ValueError: If invalid lines or key-value pairs are encountered.
        """
        # Remove braces from the content
        content = content.strip().strip("{}")

        # Split the content into lines by commas
        lines = re.split(r',(?![^(){}\[\]]*[\)\}\]])', content.strip())

        for line in lines:
            line = line.strip()
            # Match key-value pairs
            match = re.match(r'([\w_]+)\s*=\s*(.+)', line)
            if match:
                key, value = match.groups()
                key = key.strip()
                value = value.strip()
                # Convert the value to the appropriate Python type and store it
                global_params[key] = cls._convert_value(value)
            else:
                raise ValueError(f"Invalid parameter line: '{line}'")


    @classmethod
    def _parse_attributes(cls, attr_dict, content):
        """Parses attributes from the content inside {attribute=value,...}."""
        attr_pairs = re.findall(r'(\w+)\s*=\s*([^,]+)', content)
        for attr_name, attr_value in attr_pairs:
            attr_dict[attr_name] = cls._convert_value(attr_value)

    @classmethod
    def _convert_value(cls, value):
        """Converts a string representation of a value to the appropriate Python type."""
        value = value.strip()
        # Boolean and None conversion
        if value.lower() == 'true':
            return True
        elif value.lower() == 'false':
            return False
        elif value.lower() == 'none':
            return None
        # Handle quoted strings
        if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
            return value[1:-1]
        # Handle lists (Python syntax inside the file)
        if value.startswith('[') and value.endswith(']'):
            return eval(value)  # Using eval to parse lists safely in this controlled scenario
        # Handle numbers
        try:
            if '.' in value:
                return float(value)
            return int(value)
        except ValueError:
            # Return the value as-is if it doesn't match other types
            return value

    def __copy__(self):
        """ copy method """
        cls = self.__class__
        copie = cls.__new__(cls)
        copie.__dict__.update(self.__dict__)
        return copie

    def __deepcopy__(self, memo):
        """ deep copy method """
        cls = self.__class__
        copie = cls.__new__(cls)
        memo[id(self)] = copie
        for k, v in self.__dict__.items():
            setattr(copie, k, copy.deepcopy(v, memo))
        return copie

    def detect_all_variables(self):
        """
        Detects all variables across all templates in the dscript object.

        This method iterates through all ScriptTemplate objects in the dscript and
        collects variables from each template using the detect_variables method.

        Returns:
        --------
        list
            A sorted list of unique variables detected in all templates.
        """
        all_variables = set()  # Use a set to avoid duplicates
        # Iterate through all templates in the dscript object
        for template_key, template in self.TEMPLATE.items():
            # Ensure the template is a ScriptTemplate and has the detect_variables method
            if isinstance(template, ScriptTemplate):
                detected_vars = template.detect_variables()
                all_variables.update(detected_vars)  # Add the detected variables to the set
        return sorted(all_variables)  # Return a sorted list of unique variables

    def add_dynamic_script(self, key, content="", userid=None, definitions=None, verbose=None, **USER):
        """
        Add a dynamic script step to the dscript object.

        Parameters:
        -----------
        key : str
            The key for the dynamic script (usually an index or step identifier).
        content : str or list of str, optional
            The content (template) of the script step.
        definitions : lambdaScriptdata, optional
            The merged variable space (STATIC + GLOBAL + LOCAL).
        verbose : bool, optional
            If None, self.verbose will be used. Controls verbosity of the template.
        USER : dict
            Additional user variables that override the definitions for this step.
        """
        if definitions is None:
            definitions = lambdaScriptdata()
        if verbose is None:
            verbose = self.verbose
        # Create a new ScriptTemplate and add it to the TEMPLATE
        self.TEMPLATE[key] = ScriptTemplate(
            content=content,
            definitions=self.DEFINITIONS+definitions,
            verbose=verbose,
            userid = key if userid is None else userid,
            **USER
        )



    def check_all_variables(self, verbose=True, seteval=True, output=False):
        """
        Checks for undefined variables for each TEMPLATE key in the dscript object.

        Parameters:
        -----------
        verbose : bool, optional, default=True
            If True, prints information about variables for each TEMPLATE key.
            Shows [-] if the variable is set to its default value, [+] if it is defined, and [ ] if it is undefined.

        seteval : bool, optional, default=True
            If True, sets the `eval` attribute to True if at least one variable is defined or set to its default value.

        output : bool, optional, default=False
            If True, returns a dictionary with lists of default variables, set variables, and undefined variables.

        Returns:
        --------
        out : dict, optional
            If `output=True`, returns a dictionary with the following structure:
            - "defaultvalues": List of variables set to their default value (${varname}).
            - "setvalues": List of variables defined with values other than their default.
            - "undefined": List of variables that are undefined.
        """
        out = {"defaultvalues": [], "setvalues": [], "undefined": []}

        for key in self.TEMPLATE:
            template = self.TEMPLATE[key]
            # Call the check_variables method of ScriptTemplate for each TEMPLATE key
            result = template.check_variables(verbose=verbose, seteval=seteval)

            # Update the output dictionary if needed
            out["defaultvalues"].extend(result["defaultvalues"])
            out["setvalues"].extend(result["setvalues"])
            out["undefined"].extend(result["undefined"])

        if output:
            return out


    def set_all_variables(self):
        """
        Ensures that all variables in the templates are added to the global definitions
        with default values if they are not already defined.
        """
        for key, script_template in self.TEMPLATE.items():
            # Check and update the global definitions with template-specific variables
            for var in script_template.detect_variables():
                if var not in self.DEFINITIONS:
                    # Add undefined variables with their default value
                    self.DEFINITIONS.setattr(var, f"${{{var}}}")  # Set default as ${varname}

Class variables

var construction_attributes

Static methods

def header(name=None, verbose=True, verbosity=None, style=2, filepath=None, version=None, license=None, email=None)

Generate a formatted header for the DSCRIPT file.

Parameters:

name (str, optional): The name of the script. If None, "Unnamed" is used.
verbose (bool, optional): Whether to include the header. Default is True.
verbosity (int, optional): Verbosity level. Overrides <code>verbose</code> if specified.
style (int, optional): ASCII style for the header (default=2).
filepath (str, optional): Full path to the file being saved. If None, the line mentioning the file path is excluded.
version (str, optional): DSCRIPT version. If None, it is omitted from the header.
license (str, optional): License type. If None, it is omitted from the header.
email (str, optional): Contact email. If None, it is omitted from the header.

Returns:

str: A formatted string representing the script's metadata and initialization details.
     Returns an empty string if <code>verbose</code> is False.

The header includes:

- DSCRIPT version, license, and contact email, if provided.
- The name of the script.
- Filepath, if provided.
- Information on where and when the script was generated.

Notes:

- If <code>verbosity</code> is specified, it overrides <code>verbose</code>.
- Omits metadata lines if <code>version</code>, <code>license</code>, or <code>email</code> are not provided.
Expand source code
@staticmethod
def header(name=None, verbose=True, verbosity=None, style=2, filepath=None, version=None, license=None, email=None):
    """
    Generate a formatted header for the DSCRIPT file.

    ### Parameters:
        name (str, optional): The name of the script. If None, "Unnamed" is used.
        verbose (bool, optional): Whether to include the header. Default is True.
        verbosity (int, optional): Verbosity level. Overrides `verbose` if specified.
        style (int, optional): ASCII style for the header (default=2).
        filepath (str, optional): Full path to the file being saved. If None, the line mentioning the file path is excluded.
        version (str, optional): DSCRIPT version. If None, it is omitted from the header.
        license (str, optional): License type. If None, it is omitted from the header.
        email (str, optional): Contact email. If None, it is omitted from the header.

    ### Returns:
        str: A formatted string representing the script's metadata and initialization details.
             Returns an empty string if `verbose` is False.

    ### The header includes:
        - DSCRIPT version, license, and contact email, if provided.
        - The name of the script.
        - Filepath, if provided.
        - Information on where and when the script was generated.

    ### Notes:
        - If `verbosity` is specified, it overrides `verbose`.
        - Omits metadata lines if `version`, `license`, or `email` are not provided.
    """
    # Resolve verbosity
    verbose = verbosity > 0 if verbosity is not None else verbose
    if not verbose:
        return ""
    # Validate inputs
    if name is None:
        name = "Unnamed"
    # Prepare metadata line
    metadata = []
    if version:
        metadata.append(f"v{version}")
    if license:
        metadata.append(f"License: {license}")
    if email:
        metadata.append(f"Email: {email}")
    metadata_line = " | ".join(metadata)
    # Prepare the framed header content
    lines = []
    if metadata_line:
        lines.append(f"PIZZA.DSCRIPT FILE {metadata_line}")
    lines += [
        "",
        f"Name: {name}",
    ]
    # Add the filepath line if filepath is not None
    if filepath:
        lines.append(f"Path: {filepath}")
    lines += [
        "",
        f"Generated on: {getpass.getuser()}@{socket.gethostname()}:{os.getcwd()}",
        f"{datetime.datetime.now().strftime('%A, %B %d, %Y at %H:%M:%S')}",
    ]
    # Use the shared frame_header function to format the framed content
    return frame_header(lines, style=style)
def load(filename, foldername=None, numerickeys=True)

Load a script instance from a text file.

Parameters

filename : str
The name of the file to load the script from. If the filename does not end with ".txt", the extension is automatically appended.
foldername : str, optional
The directory where the file is located. If not provided, it defaults to the system's temporary directory. If the filename does not include a full path, this folder will be used.
numerickeys : bool, default=True
If True, numeric string keys in the template section are automatically converted into integers. For example, the key "0" would be converted into the integer 0.

Returns

dscript
A new dscript instance populated with the content of the loaded file.

Raises

ValueError
If the file does not start with the correct DSCRIPT header or the file format is invalid.
FileNotFoundError
If the specified file does not exist.

Notes

  • The file is expected to follow the same structured format as the one produced by the save() method.
  • The method processes global parameters, definitions, template lines/items, and attributes. If the file includes numeric keys as strings (e.g., "0", "1"), they can be automatically converted into integers if numerickeys=True.
  • The script structure is dynamically rebuilt, and each section (global parameters, definitions, template, and attributes) is correctly parsed and assigned to the corresponding parts of the dscript instance.
Expand source code
@classmethod
def load(cls, filename, foldername=None, numerickeys=True):
    """
    Load a script instance from a text file.

    Parameters
    ----------
    filename : str
        The name of the file to load the script from. If the filename does not end with ".txt",
        the extension is automatically appended.

    foldername : str, optional
        The directory where the file is located. If not provided, it defaults to the system's
        temporary directory. If the filename does not include a full path, this folder will be used.

    numerickeys : bool, default=True
        If True, numeric string keys in the template section are automatically converted into integers.
        For example, the key "0" would be converted into the integer 0.

    Returns
    -------
    dscript
        A new `dscript` instance populated with the content of the loaded file.

    Raises
    ------
    ValueError
        If the file does not start with the correct DSCRIPT header or the file format is invalid.

    FileNotFoundError
        If the specified file does not exist.

    Notes
    -----
    - The file is expected to follow the same structured format as the one produced by the `save()` method.
    - The method processes global parameters, definitions, template lines/items, and attributes. If the file
      includes numeric keys as strings (e.g., "0", "1"), they can be automatically converted into integers
      if `numerickeys=True`.
    - The script structure is dynamically rebuilt, and each section (global parameters, definitions,
      template, and attributes) is correctly parsed and assigned to the corresponding parts of the `dscript`
      instance.
    """

    # Step 0 validate filepath
    if not filename.endswith('.txt'):
        filename += '.txt'

    # Handle foldername and relative paths
    if foldername is None or foldername == "":
        # If the foldername is empty or None, use current working directory for relative paths
        if not os.path.isabs(filename):
            filepath = os.path.join(os.getcwd(), filename)
        else:
            filepath = filename  # If filename is absolute, use it directly
    else:
        # If foldername is provided and filename is not absolute, use foldername
        if not os.path.isabs(filename):
            filepath = os.path.join(foldername, filename)
        else:
            filepath = filename

    if not os.path.exists(filepath):
        raise FileExistsError(f"The file '{filepath}' does not exist.")

    # Read the file contents
    with open(filepath, 'r') as f:
        content = f.read()

    # Call parsesyntax to parse the file content
    fname = os.path.basename(filepath)  # Extracts the filename (e.g., "myscript.txt")
    name, _ = os.path.splitext(fname)   # Removes the extension, e.g., "myscript
    return cls.parsesyntax(content, name, numerickeys)
def parsesyntax(content, name=None, numerickeys=True, verbose=False, authentification=True, comment_chars='#%', continuation_marker='...')

Parse a DSCRIPT script from a string content.

Parameters
----------
content : str
    The string content of the DSCRIPT script to be parsed.

name : str, optional
    The name of the dscript project. If <code>None</code>, a random name is generated.

numerickeys : bool, default=True
    If <code>True</code>, numeric string keys in the template section are automatically converted into integers.

verbose : bool, default=False
    If <code>True</code>, the parser will output warnings for unrecognized lines outside of blocks.

authentification : bool, default=True
    If <code>True</code>, the parser is expected that the first non empty line is # DSCRIPT SAVE FILE

comment_chars : str, optional (default: "#%")
    A string containing characters to identify the start of a comment.
    Any of these characters will mark the beginning of a comment unless within quotes.

continuation_marker : str, optional (default: "...")
    A string containing characters to indicate line continuation
    Any characters after the continuation marker are considered comment and are theorefore ignored



Returns
-------
dscript
    A new <code><a title="group.dscript" href="#group.dscript">dscript</a></code> instance populated with the content of the loaded file.

Raises
------
ValueError
    If content does not start with the correct DSCRIPT header or the file format is invalid.

Notes
-----
**DSCRIPT SAVE FILE FORMAT**

This script syntax is designed for creating dynamic and customizable input files, where variables, templates,
and control attributes can be defined in a flexible manner.

**Mandatory First Line:**

Every DSCRIPT file must begin with the following line:

```plaintext
# DSCRIPT SAVE FILE
```

**Structure Overview:**

1. **Global Parameters Section (Optional):**

    - This section defines global script settings, enclosed within curly braces `{}`.
    - Properties include:
        - <code>SECTIONS</code>: List of section names to be considered (e.g., `["DYNAMIC"]`).
        - <code>section</code>: Current section index (e.g., <code>0</code>).
        - <code>position</code>: Current script position in the order.
        - <code>role</code>: Defines the role of the script instance (e.g., `"dscript instance"`).
        - <code>description</code>: A short description of the script (e.g., `"dynamic script"`).
        - <code>userid</code>: Identifier for the user (e.g., `"dscript"`).
        - <code>version</code>: Script version (e.g., <code>0.1</code>).
        - <code>verbose</code>: Verbosity flag, typically a boolean (e.g., <code>False</code>).

    **Example:**

    ```plaintext
    {
        SECTIONS = ['INITIALIZATION', 'SIMULATION']  # Global script parameters
    }
    ```

2. **Definitions Section:**

    - Variables are defined in Python-like syntax, allowing for dynamic variable substitution.
    - Variables can be numbers, strings, or lists, and they can include placeholders using `$`
      to delay execution or substitution.

    **Example:**

    ```plaintext
    d = 3                               # Define a number
    periodic = "$p"                     # '$' prevents immediate evaluation of 'p'
    units = "$metal"                    # '$' prevents immediate evaluation of 'metal'
    dimension = "${d}"                  # Variable substitution
    boundary = ['p', 'p', 'p']          # List with a mix of variables and values
    atom_style = "$atomic"              # String variable with delayed evaluation
    ```

3. **Templates Section:**

    - This section provides a mapping between keys and their corresponding commands or instructions.
    - Each template can reference variables defined in the **Definitions** section or elsewhere, typically using the `${variable}` syntax.
    - **Syntax Variations**:

        Templates can be defined in several ways, including without blocks, with single- or multi-line blocks, and with ellipsis (<code>...</code>) as a line continuation marker.

        - **Single-line Template Without Block**:
            ```plaintext
            KEY: INSTRUCTION
            ```
            - <code>KEY</code> is the identifier for the template (numeric or alphanumeric).
            - <code>INSTRUCTION</code> is the command or template text, which may reference variables.

        - **Single-line Template With Block**:
            ```plaintext
            KEY: [INSTRUCTION]
            ```
            - Uses square brackets (<code>\[ ]</code>) around the <code>INSTRUCTION</code>, indicating that all instructions are part of the block.

        - **Multi-line Template With Block**:
            ```plaintext
            KEY: [
                INSTRUCTION1
                INSTRUCTION2
                ...
                ]
            ```
            - Begins with `KEY: [` and ends with a standalone <code>]</code> on a new line.
            - Instructions within the block can span multiple lines, and ellipses (<code>...</code>) at the end of a line are used to indicate that the line continues, ignoring any content following the ellipsis as comments.
            - Comments following ellipses are removed after parsing and do not become part of the block, preserving only the instructions.

        - **Multi-line Template With Continuation Marker (Ellipsis)**:
            - For templates with complex code containing square brackets (<code>\[ ]</code>), the ellipsis (<code>...</code>) can be used to prevent <code>]</code> from prematurely closing the block. The ellipsis will keep the line open across multiple lines, allowing brackets in the instructions.

            **Example:**
            ```plaintext
            example1: command ${value}       # Single-line template without block
            example2: [command ${value}]     # Single-line template with block

            # Multi-line template with block
            example3: [
                command1 ${var1}
                command2 ${var2} ...   # Line continues after ellipsis
                command3 ${var3} ...   # Additional instruction continues
                ]

            # Multi-line template with ellipsis (handling square brackets)
            example4: [
                A[0][1] ...            # Ellipsis allows [ ] within instructions
                B[2][3] ...            # Another instruction in the block
                ]
            ```

    - **Key Points**:
        - **Blocks** allow grouping of multiple instructions for a single key, enclosed in square brackets.
        - **Ellipsis (<code>...</code>)** at the end of a line keeps the line open, preventing premature closing by <code>]</code>, especially useful if the template code includes square brackets (<code>\[ ]</code>).
        - **Comments** placed after the ellipsis are removed after parsing and are not part of the final block content.

    This flexibility supports both simple and complex template structures, allowing instructions to be grouped logically while keeping code and comments distinct.


4. **Attributes Section:**

    - Each template line can have customizable attributes to control behavior and conditions.
    - Default attributes include:
        - <code>facultative</code>: If <code>True</code>, the line is optional and can be removed if needed.
        - <code>eval</code>: If <code>True</code>, the line will be evaluated with Python's <code>eval()</code> function.
        - <code>readonly</code>: If <code>True</code>, the line cannot be modified later in the script.
        - <code>condition</code>: An expression that must be satisfied for the line to be included.
        - <code>condeval</code>: If <code>True</code>, the condition will be evaluated using <code>eval()</code>.
        - <code>detectvar</code>: If <code>True</code>, this creates variables in the **Definitions** section if they do not exist.

    **Example:**

    ```plaintext
    units: {facultative=False, eval=False, readonly=False, condition="${units}", condeval=False, detectvar=True}
    dim: {facultative=False, eval=False, readonly=False, condition=None, condeval=False, detectvar=True}
    ```

**Note on Multiple Definitions**

This example demonstrates how variables defined in the **Definitions** section are handled for each template.
Each template retains its own snapshot of the variable definitions at the time it is created, ensuring that templates
can use different values for the same variable if redefined.

**Example:**

```plaintext
# DSCRIPT SAVE FILE

# Definitions
var = 10

# Template key1
key1: Template content with ${var}

# Definitions
var = 20

# Template key2
key2: Template content with ${var}

# Template key3
key3:[
    this is an undefined variable ${var31}
    this is another undefined variable ${var32}
    this variable is defined  ${var}
]
```

**Parsing and Usage:**

```python
# Parse content using parsesyntax()
ds = dscript.parsesyntax(content)

# Accessing templates and their variables
print(ds.TEMPLATE['key1'].text)  # Output: Template content with 10
print(ds.TEMPLATE['key2'].text)  # Output: Template content with 20
```

**Handling Undefined Variables:**

Variables like `${var31}` and `${var32}` in <code>key3</code> are undefined. The parser will handle them based on your substitution logic or raise an error if they are required.

**Important Notes:**

- The parser processes the script sequentially. Definitions must appear before the templates that use them.
- Templates capture the variable definitions at the time they are parsed. Redefining a variable affects only subsequent templates.
- Comments outside of blocks are allowed and ignored by the parser.
- Content within templates is treated as-is, allowing for any syntax required by the target system (e.g., LAMMPS commands).

Advanced Example

Here's a more advanced example demonstrating the use of global definitions, local definitions, templates, and how to parse and render the template content.

```python
content = '''
    # GLOBAL DEFINITIONS
    dumpfile = $dump.LAMMPS
    dumpdt = 50
    thermodt = 100
    runtime = 5000

    # LOCAL DEFINITIONS for step '0'
    dimension = 3
    units = $si
    boundary = ['f', 'f', 'f']
    atom_style = $smd
    atom_modify = ['map', 'array']
    comm_modify = ['vel', 'yes']
    neigh_modify = ['every', 10, 'delay', 0, 'check', 'yes']
    newton = $off
    name = $SimulationBox

    # This is a comment line outside of blocks
    # ------------------------------------------

    0: [    % --------------[ Initialization Header (helper) for "${name}" ]--------------
        # set a parameter to None or "" to remove the definition
        dimension    ${dimension}
        units        ${units}
        boundary     ${boundary}
        atom_style   ${atom_style}
        atom_modify  ${atom_modify}
        comm_modify  ${comm_modify}
        neigh_modify ${neigh_modify}
        newton       ${newton}
        # ------------------------------------------
     ]
'''
# Parse the content
ds = dscript.parsesyntax(content, verbose=True, authentification=False)

# Access and print the rendered template
print("Template 0 content:")
print(ds.TEMPLATE[0].do())
```

**Explanation:**

- **Global Definitions:** Define variables that are accessible throughout the script.
- **Local Definitions for Step '0':** Define variables specific to a particular step or template.
- **Template Block:** Identified by `0: [ ... ]`, it contains the content where variables will be substituted.
- **Comments:** Lines starting with `#` are comments and are ignored by the parser outside of template blocks.

**Expected Output:**

```
Template 0 content:
# --------------[ Initialization Header (helper) for "SimulationBox" ]--------------
# set a parameter to None or "" to remove the definition
dimension    3
units        si
boundary     ['f', 'f', 'f']
atom_style   smd
atom_modify  ['map', 'array']
comm_modify  ['vel', 'yes']
neigh_modify ['every', 10, 'delay', 0, 'check', 'yes']
newton       off
# ------------------------------------------
```

**Notes:**

- The <code>do()</code> method renders the template, substituting variables with their defined values.
- Variables like `${dimension}` are replaced with their corresponding values defined in the local or global definitions.
- The parser handles comments and blank lines appropriately, ensuring they don't interfere with the parsing logic.
Expand source code
@classmethod
def parsesyntax(cls, content, name=None, numerickeys=True, verbose=False, authentification=True,
                comment_chars="#%",continuation_marker="..."):
    """
    Parse a DSCRIPT script from a string content.

    Parameters
    ----------
    content : str
        The string content of the DSCRIPT script to be parsed.

    name : str, optional
        The name of the dscript project. If `None`, a random name is generated.

    numerickeys : bool, default=True
        If `True`, numeric string keys in the template section are automatically converted into integers.

    verbose : bool, default=False
        If `True`, the parser will output warnings for unrecognized lines outside of blocks.

    authentification : bool, default=True
        If `True`, the parser is expected that the first non empty line is # DSCRIPT SAVE FILE

    comment_chars : str, optional (default: "#%")
        A string containing characters to identify the start of a comment.
        Any of these characters will mark the beginning of a comment unless within quotes.

    continuation_marker : str, optional (default: "...")
        A string containing characters to indicate line continuation
        Any characters after the continuation marker are considered comment and are theorefore ignored



    Returns
    -------
    dscript
        A new `dscript` instance populated with the content of the loaded file.

    Raises
    ------
    ValueError
        If content does not start with the correct DSCRIPT header or the file format is invalid.

    Notes
    -----
    **DSCRIPT SAVE FILE FORMAT**

    This script syntax is designed for creating dynamic and customizable input files, where variables, templates,
    and control attributes can be defined in a flexible manner.

    **Mandatory First Line:**

    Every DSCRIPT file must begin with the following line:

    ```plaintext
    # DSCRIPT SAVE FILE
    ```

    **Structure Overview:**

    1. **Global Parameters Section (Optional):**

        - This section defines global script settings, enclosed within curly braces `{}`.
        - Properties include:
            - `SECTIONS`: List of section names to be considered (e.g., `["DYNAMIC"]`).
            - `section`: Current section index (e.g., `0`).
            - `position`: Current script position in the order.
            - `role`: Defines the role of the script instance (e.g., `"dscript instance"`).
            - `description`: A short description of the script (e.g., `"dynamic script"`).
            - `userid`: Identifier for the user (e.g., `"dscript"`).
            - `version`: Script version (e.g., `0.1`).
            - `verbose`: Verbosity flag, typically a boolean (e.g., `False`).

        **Example:**

        ```plaintext
        {
            SECTIONS = ['INITIALIZATION', 'SIMULATION']  # Global script parameters
        }
        ```

    2. **Definitions Section:**

        - Variables are defined in Python-like syntax, allowing for dynamic variable substitution.
        - Variables can be numbers, strings, or lists, and they can include placeholders using `$`
          to delay execution or substitution.

        **Example:**

        ```plaintext
        d = 3                               # Define a number
        periodic = "$p"                     # '$' prevents immediate evaluation of 'p'
        units = "$metal"                    # '$' prevents immediate evaluation of 'metal'
        dimension = "${d}"                  # Variable substitution
        boundary = ['p', 'p', 'p']          # List with a mix of variables and values
        atom_style = "$atomic"              # String variable with delayed evaluation
        ```

    3. **Templates Section:**

        - This section provides a mapping between keys and their corresponding commands or instructions.
        - Each template can reference variables defined in the **Definitions** section or elsewhere, typically using the `${variable}` syntax.
        - **Syntax Variations**:

            Templates can be defined in several ways, including without blocks, with single- or multi-line blocks, and with ellipsis (`...`) as a line continuation marker.

            - **Single-line Template Without Block**:
                ```plaintext
                KEY: INSTRUCTION
                ```
                - `KEY` is the identifier for the template (numeric or alphanumeric).
                - `INSTRUCTION` is the command or template text, which may reference variables.

            - **Single-line Template With Block**:
                ```plaintext
                KEY: [INSTRUCTION]
                ```
                - Uses square brackets (`[ ]`) around the `INSTRUCTION`, indicating that all instructions are part of the block.

            - **Multi-line Template With Block**:
                ```plaintext
                KEY: [
                    INSTRUCTION1
                    INSTRUCTION2
                    ...
                    ]
                ```
                - Begins with `KEY: [` and ends with a standalone `]` on a new line.
                - Instructions within the block can span multiple lines, and ellipses (`...`) at the end of a line are used to indicate that the line continues, ignoring any content following the ellipsis as comments.
                - Comments following ellipses are removed after parsing and do not become part of the block, preserving only the instructions.

            - **Multi-line Template With Continuation Marker (Ellipsis)**:
                - For templates with complex code containing square brackets (`[ ]`), the ellipsis (`...`) can be used to prevent `]` from prematurely closing the block. The ellipsis will keep the line open across multiple lines, allowing brackets in the instructions.

                **Example:**
                ```plaintext
                example1: command ${value}       # Single-line template without block
                example2: [command ${value}]     # Single-line template with block

                # Multi-line template with block
                example3: [
                    command1 ${var1}
                    command2 ${var2} ...   # Line continues after ellipsis
                    command3 ${var3} ...   # Additional instruction continues
                    ]

                # Multi-line template with ellipsis (handling square brackets)
                example4: [
                    A[0][1] ...            # Ellipsis allows [ ] within instructions
                    B[2][3] ...            # Another instruction in the block
                    ]
                ```

        - **Key Points**:
            - **Blocks** allow grouping of multiple instructions for a single key, enclosed in square brackets.
            - **Ellipsis (`...`)** at the end of a line keeps the line open, preventing premature closing by `]`, especially useful if the template code includes square brackets (`[ ]`).
            - **Comments** placed after the ellipsis are removed after parsing and are not part of the final block content.

        This flexibility supports both simple and complex template structures, allowing instructions to be grouped logically while keeping code and comments distinct.


    4. **Attributes Section:**

        - Each template line can have customizable attributes to control behavior and conditions.
        - Default attributes include:
            - `facultative`: If `True`, the line is optional and can be removed if needed.
            - `eval`: If `True`, the line will be evaluated with Python's `eval()` function.
            - `readonly`: If `True`, the line cannot be modified later in the script.
            - `condition`: An expression that must be satisfied for the line to be included.
            - `condeval`: If `True`, the condition will be evaluated using `eval()`.
            - `detectvar`: If `True`, this creates variables in the **Definitions** section if they do not exist.

        **Example:**

        ```plaintext
        units: {facultative=False, eval=False, readonly=False, condition="${units}", condeval=False, detectvar=True}
        dim: {facultative=False, eval=False, readonly=False, condition=None, condeval=False, detectvar=True}
        ```

    **Note on Multiple Definitions**

    This example demonstrates how variables defined in the **Definitions** section are handled for each template.
    Each template retains its own snapshot of the variable definitions at the time it is created, ensuring that templates
    can use different values for the same variable if redefined.

    **Example:**

    ```plaintext
    # DSCRIPT SAVE FILE

    # Definitions
    var = 10

    # Template key1
    key1: Template content with ${var}

    # Definitions
    var = 20

    # Template key2
    key2: Template content with ${var}

    # Template key3
    key3:[
        this is an undefined variable ${var31}
        this is another undefined variable ${var32}
        this variable is defined  ${var}
    ]
    ```

    **Parsing and Usage:**

    ```python
    # Parse content using parsesyntax()
    ds = dscript.parsesyntax(content)

    # Accessing templates and their variables
    print(ds.TEMPLATE['key1'].text)  # Output: Template content with 10
    print(ds.TEMPLATE['key2'].text)  # Output: Template content with 20
    ```

    **Handling Undefined Variables:**

    Variables like `${var31}` and `${var32}` in `key3` are undefined. The parser will handle them based on your substitution logic or raise an error if they are required.

    **Important Notes:**

    - The parser processes the script sequentially. Definitions must appear before the templates that use them.
    - Templates capture the variable definitions at the time they are parsed. Redefining a variable affects only subsequent templates.
    - Comments outside of blocks are allowed and ignored by the parser.
    - Content within templates is treated as-is, allowing for any syntax required by the target system (e.g., LAMMPS commands).


**Advanced Example**

    Here's a more advanced example demonstrating the use of global definitions, local definitions, templates, and how to parse and render the template content.

    ```python
    content = '''
        # GLOBAL DEFINITIONS
        dumpfile = $dump.LAMMPS
        dumpdt = 50
        thermodt = 100
        runtime = 5000

        # LOCAL DEFINITIONS for step '0'
        dimension = 3
        units = $si
        boundary = ['f', 'f', 'f']
        atom_style = $smd
        atom_modify = ['map', 'array']
        comm_modify = ['vel', 'yes']
        neigh_modify = ['every', 10, 'delay', 0, 'check', 'yes']
        newton = $off
        name = $SimulationBox

        # This is a comment line outside of blocks
        # ------------------------------------------

        0: [    % --------------[ Initialization Header (helper) for "${name}" ]--------------
            # set a parameter to None or "" to remove the definition
            dimension    ${dimension}
            units        ${units}
            boundary     ${boundary}
            atom_style   ${atom_style}
            atom_modify  ${atom_modify}
            comm_modify  ${comm_modify}
            neigh_modify ${neigh_modify}
            newton       ${newton}
            # ------------------------------------------
         ]
    '''
    # Parse the content
    ds = dscript.parsesyntax(content, verbose=True, authentification=False)

    # Access and print the rendered template
    print("Template 0 content:")
    print(ds.TEMPLATE[0].do())
    ```

    **Explanation:**

    - **Global Definitions:** Define variables that are accessible throughout the script.
    - **Local Definitions for Step '0':** Define variables specific to a particular step or template.
    - **Template Block:** Identified by `0: [ ... ]`, it contains the content where variables will be substituted.
    - **Comments:** Lines starting with `#` are comments and are ignored by the parser outside of template blocks.

    **Expected Output:**

    ```
    Template 0 content:
    # --------------[ Initialization Header (helper) for "SimulationBox" ]--------------
    # set a parameter to None or "" to remove the definition
    dimension    3
    units        si
    boundary     ['f', 'f', 'f']
    atom_style   smd
    atom_modify  ['map', 'array']
    comm_modify  ['vel', 'yes']
    neigh_modify ['every', 10, 'delay', 0, 'check', 'yes']
    newton       off
    # ------------------------------------------
    ```

    **Notes:**

    - The `do()` method renders the template, substituting variables with their defined values.
    - Variables like `${dimension}` are replaced with their corresponding values defined in the local or global definitions.
    - The parser handles comments and blank lines appropriately, ensuring they don't interfere with the parsing logic.


    """
    # Split the content into lines
    lines = content.splitlines()
    if not lines:
        raise ValueError("File/Content is empty or only contains blank lines.")

    # Initialize containers
    global_params = {}
    GLOBALdefinitions = lambdaScriptdata()
    LOCALdefinitions = lambdaScriptdata()
    template = {}
    attributes = {}

    # State variables
    inside_global_params = False
    global_params_content = ""
    inside_template_block = False
    current_template_key = None
    current_template_content = []
    current_var_value = lambdaScriptdata()

    # Initialize line number
    line_number = 0
    last_successful_line = 0

    # Step 1: Authenticate the file
    if authentification:
        auth_line_found = False
        max_header_lines = 10
        header_end_idx = -1
        for idx, line in enumerate(lines[:max_header_lines]):
            stripped_line = line.strip()
            if not stripped_line:
                continue
            if stripped_line.startswith("# DSCRIPT SAVE FILE"):
                auth_line_found = True
                header_end_idx = idx
                break
            elif stripped_line.startswith("#") or stripped_line.startswith("%"):
                continue
            else:
                raise ValueError(f"Unexpected content before authentication line (# DSCRIPT SAVE FILE) at line {idx + 1}:\n{line}")
        if not auth_line_found:
            raise ValueError("File/Content is not a valid DSCRIPT file.")

        # Remove header lines
        lines = lines[header_end_idx + 1:]
        line_number = header_end_idx + 1
        last_successful_line = line_number - 1
    else:
        line_number = 0
        last_successful_line = 0

    # Process each line
    for idx, line in enumerate(lines):
        line_number += 1
        line_content = line.rstrip('\n')

        # Determine if we're inside a template block
        if inside_template_block:
            # Extract the code with its eventual continuation_marker
            code_line = remove_comments(
                    line_content,
                    comment_chars=comment_chars,
                    continuation_marker=continuation_marker,
                    remove_continuation_marker=False,
                    ).rstrip()

            # Check if line should continue
            if code_line.endswith(continuation_marker):
                # Append line up to the continuation marker
                endofline_index = line_content.rindex(continuation_marker)
                trimmed_content = line_content[:endofline_index].rstrip()
                if trimmed_content:
                    current_template_content.append(trimmed_content)
                continue
            elif code_line.endswith("]"):  # End of multi-line block
                closing_index = code_line.rindex(']')
                trimmed_content = code_line[:closing_index].rstrip()

                # Append any valid content before `]`, if non-empty
                if trimmed_content:
                    current_template_content.append(trimmed_content)

                # End of template block
                content = '\n'.join(current_template_content)
                template[current_template_key] = ScriptTemplate(
                    content=content,
                    autorefresh=False,
                    definitions=LOCALdefinitions,
                    verbose=verbose,
                    userid=current_template_key)
                # Refresh variables definitions
                template[current_template_key].refreshvar(globaldefinitions=GLOBALdefinitions)
                LOCALdefinitions = lambdaScriptdata()
                # Reset state for next block
                inside_template_block = False
                current_template_key = None
                current_template_content = []
                last_successful_line = line_number
                continue
            else:
                # Append the entire original line content if not ending with `...` or `]`
                current_template_content.append(line_content)
            continue

        # Not inside a template block
        stripped_no_comments = remove_comments(line_content)

        # Ignore empty lines after removing comments
        if not stripped_no_comments.strip():
            continue

        # If the original line is a comment line, skip it
        if line_content.strip().startswith("#") or line_content.strip().startswith("%"):
            continue

        stripped = stripped_no_comments.strip()

        # Handle start of a new template block
        template_block_match = re.match(r'^(\w+)\s*:\s*\[', stripped)
        if template_block_match:
            current_template_key = template_block_match.group(1)
            if inside_template_block:
                # Collect error context
                context_start = max(0, last_successful_line - 3)
                context_end = min(len(lines), line_number + 2)
                error_context_lines = lines[context_start:context_end]
                error_context = ""
                for i, error_line in enumerate(error_context_lines):
                    line_num = context_start + i + 1
                    indicator = ">" if line_num == line_number else "*" if line_num == last_successful_line else " "
                    error_context += f"{indicator} {line_num}: {error_line}\n"

                raise ValueError(
                    f"Template block '{current_template_key}' starting at line {last_successful_line} (*) was not properly closed before starting a new one at line {line_number} (>).\n\n"
                    f"Error context:\n{error_context}"
                )
            else:
                inside_template_block = True
                idx_open_bracket = line_content.index('[')
                remainder = line_content[idx_open_bracket + 1:].strip()
                if remainder:
                    remainder_code = remove_comments(remainder, comment_chars=comment_chars).rstrip()
                    if remainder_code.endswith("]"):
                        closing_index = remainder_code.rindex(']')
                        content_line = remainder_code[:closing_index].strip()
                        if content_line:
                            current_template_content.append(content_line)
                        content = '\n'.join(current_template_content)
                        template[current_template_key] = ScriptTemplate(
                            content=content,
                            autorefresh=False,
                            definitions=LOCALdefinitions,
                            verbose=verbose,
                            userid=current_template_key)
                        template[current_template_key].refreshvar(globaldefinitions=GLOBALdefinitions)
                        LOCALdefinitions = lambdaScriptdata()
                        inside_template_block = False
                        current_template_key = None
                        current_template_content = []
                        last_successful_line = line_number
                        continue
                    else:
                        current_template_content.append(remainder)
                last_successful_line = line_number
            continue

        # Handle start of global parameters
        if stripped.startswith('{') and not inside_global_params:
            if '}' in stripped:
                global_params_content = stripped
                cls._parse_global_params(global_params_content.strip(), global_params)
                global_params_content = ""
                last_successful_line = line_number
            else:
                inside_global_params = True
                global_params_content = stripped
            continue

        # Handle global parameters inside {...}
        if inside_global_params:
            global_params_content += ' ' + stripped
            if '}' in stripped:
                inside_global_params = False
                cls._parse_global_params(global_params_content.strip(), global_params)
                global_params_content = ""
                last_successful_line = line_number
            continue

        # Handle attributes
        attribute_match = re.match(r'^(\w+)\s*:\s*\{(.+)\}', stripped)
        if attribute_match:
            key, attr_content = attribute_match.groups()
            attributes[key] = {}
            cls._parse_attributes(attributes[key], attr_content.strip())
            last_successful_line = line_number
            continue

        # Handle definitions
        definition_match = re.match(r'^(\w+)\s*=\s*(.+)', stripped)
        if definition_match:
            key, value = definition_match.groups()
            convertedvalue = cls._convert_value(value)
            if key in GLOBALdefinitions:
                if (GLOBALdefinitions.getattr(key) != convertedvalue) or \
                    (getattr(current_var_value, key) != convertedvalue):
                    LOCALdefinitions.setattr(key, convertedvalue)
            else:
                GLOBALdefinitions.setattr(key, convertedvalue)
            last_successful_line = line_number
            setattr(current_var_value, key, convertedvalue)
            continue

        # Handle single-line templates
        template_match = re.match(r'^(\w+)\s*:\s*(.+)', stripped)
        if template_match:
            key, content = template_match.groups()
            template[key] = ScriptTemplate(
                content = content.strip(),
                autorefresh = False,
                definitions=LOCALdefinitions,
                verbose=verbose,
                userid=key)
            template[key].refreshvar(globaldefinitions=GLOBALdefinitions)
            LOCALdefinitions = lambdaScriptdata()
            last_successful_line = line_number
            continue

        # Unrecognized line
        if verbose:
            print(f"Warning: Unrecognized line at {line_number}: {line_content}")
        last_successful_line = line_number
        continue

    # At the end, check if any template block was left unclosed
    if inside_template_block:
        # Collect error context
        context_start = max(0, last_successful_line - 3)
        context_end = min(len(lines), last_successful_line + 3)
        error_context_lines = lines[context_start:context_end]
        error_context = ""
        for i, error_line in enumerate(error_context_lines):
            line_num = context_start + i
            indicator = ">" if line_num == last_successful_line else " "
            error_context += f"{indicator} {line_num}: {error_line}\n"

        raise ValueError(
            f"Template block '{current_template_key}' starting at line {last_successful_line} was not properly closed.\n\n"
            f"Error context:\n{error_context}"
        )

    # Apply attributes to templates
    for key in attributes:
        if key in template:
            for attr_name, attr_value in attributes[key].items():
                setattr(template[key], attr_name, attr_value)
            template[key]._autorefresh = True # restore the default behavior for the end-user
        else:
            raise ValueError(f"Attributes found for undefined template key: {key}")

    # Create and return new instance
    if name is None:
        name = autoname(8)
    instance = cls(
        name=name,
        SECTIONS=global_params.get('SECTIONS', ['DYNAMIC']),
        section=global_params.get('section', 0),
        position=global_params.get('position', 0),
        role=global_params.get('role', 'dscript instance'),
        description=global_params.get('description', 'dynamic script'),
        userid=global_params.get('userid', 'dscript'),
        version=global_params.get('version', 0.1),
        verbose=global_params.get('verbose', False)
    )

    # Convert numeric string keys to integers if numerickeys is True
    if numerickeys:
        numeric_template = {}
        for key, value in template.items():
            if key.isdigit():
                numeric_template[int(key)] = value
            else:
                numeric_template[key] = value
        template = numeric_template

    # Set definitions and template
    instance.DEFINITIONS = GLOBALdefinitions
    instance.TEMPLATE = template

    # Refresh variables
    instance.set_all_variables()

    # Check variables
    instance.check_all_variables(verbose=False)

    return instance
def parsesyntax_legacy(content, name=None, numerickeys=True)

Parse a script from a string content. [ ------------------------------------------------------] [ Legacy parsesyntax method for backward compatibility. ] [ ------------------------------------------------------]

Parameters

content : str
The string content of the script to be parsed.
name : str
The name of the dscript project (if None, it is set randomly)
numerickeys : bool, default=True
If True, numeric string keys in the template section are automatically converted into integers.

Returns

dscript
A new dscript instance populated with the content of the loaded file.

Raises

ValueError
If content does not start with the correct DSCRIPT header or the file format is invalid.

Notes

  • The file is expected to follow the same structured format as the one produced by the save() method.
  • The method processes global parameters, definitions, template lines/items, and attributes. If the file includes numeric keys as strings (e.g., "0", "1"), they can be automatically converted into integers if numerickeys=True.
  • The script structure is dynamically rebuilt, and each section (global parameters, definitions, template, and attributes) is correctly parsed and assigned to the corresponding parts of the dscript instance.

PIZZA.DSCRIPT SAVE FILE FORMAT

This script syntax is designed for creating dynamic and customizable input files, where variables, templates, and control attributes can be defined in a flexible manner.

Mandatory First Line:

Every DSCRIPT file must begin with the following line: # DSCRIPT SAVE FILE

Structure Overview:

  1. Global Parameters Section (Optional):

    • This section defines global script settings, enclosed within curly braces { }.
    • Properties include:
      • SECTIONS: List of section names to be considered (e.g., ["DYNAMIC"]).
      • section: Current section index (e.g., 0).
      • position: Current script position in the order.
      • role: Defines the role of the script instance (e.g., "dscript instance").
      • description: A short description of the script (e.g., "dynamic script").
      • userid: Identifier for the user (e.g., "dscript").
      • version: Script version (e.g., 0.1).
      • verbose: Verbosity flag, typically a boolean (e.g., False).

    Example: { SECTIONS = ['INITIALIZATION', 'SIMULATION'] # Global script parameters }

  2. Definitions Section:

    • Variables are defined in Python-like syntax, allowing for dynamic variable substitution.
    • Variables can be numbers, strings, or lists, and they can include placeholders using $ to delay execution or substitution.

    Example: d = 3 # Define a number periodic = "$p" # '$' prevents immediate evaluation of 'p' units = "$metal" # '$' prevents immediate evaluation of 'metal' dimension = "${d}" # Variable substitution boundary = ['p', 'p', 'p'] # List with a mix of variables and values atom_style = "$atomic" # String variable with delayed evaluation

  3. Templates Section:

    • This section provides a mapping between keys and their corresponding commands or instructions.
    • The templates reference variables defined in the Definitions section or elsewhere.
    • Syntax: KEY: INSTRUCTION where:
      • KEY can be numeric or alphanumeric.
      • INSTRUCTION represents a command template, often referring to variables using ${variable} notation.

    Example: units: units ${units} # Template uses the 'units' variable dim: dimension ${dimension} # Template for setting the dimension bound: boundary ${boundary} # Template for boundary settings lattice: lattice ${lattice} # Lattice template

  4. Attributes Section:

    • Each template line can have customizable attributes to control behavior and conditions.
    • Default attributes include:
      • facultative: If True, the line is optional and can be removed if needed.
      • eval: If True, the line will be evaluated with Python's eval() function.
      • readonly: If True, the line cannot be modified later in the script.
      • condition: An expression that must be satisfied for the line to be included.
      • condeval: If True, the condition will be evaluated using eval().
      • detectvar: If True, this creates variables in the Definitions section if they do not exist.

    Example: units: {facultative=False, eval=False, readonly=False, condition="${units}", condeval=False, detectvar=True} dim: {facultative=False, eval=False, readonly=False, condition=None, condeval=False, detectvar=True}

Note On Multiple Definitions

This example demonstrates how variables defined in the Definitions section are handled for each template. Each template retains its own snapshot of the variable definitions at the time it is created, ensuring that templates can use different values for the same variable if redefined.

content = "" "

DSCRIPT SAVE FILE

Definitions

var = 10

Template ky1

key1: Template content with ${var}

Definitions

var = 20

Template key2

key2: Template content with ${var}

Template key3

key3:[ this is an underfined variable ${var31} this is an another underfined variable ${var32} this variables is defined ${var} ]

"" "

Parse content using parsesyntax()

ds = dscript.parsesyntax(content)

Key1 should use the first definition of 'var' (10)

print(ds.key1.definitions.var) # Output: Template content with 10

Key2 should use the updated definition of 'var' (20)

print(ds.key2.definitions.var) # Output: Template content with 10

Expand source code
@classmethod
def parsesyntax_legacy(cls, content, name=None, numerickeys=True):
    """
    Parse a script from a string content.
    [ ------------------------------------------------------]
    [ Legacy parsesyntax method for backward compatibility. ]
    [ ------------------------------------------------------]

    Parameters
    ----------
    content : str
        The string content of the script to be parsed.

    name : str
        The name of the dscript project (if None, it is set randomly)

    numerickeys : bool, default=True
        If True, numeric string keys in the template section are automatically converted into integers.

    Returns
    -------
    dscript
        A new `dscript` instance populated with the content of the loaded file.

    Raises
    ------
    ValueError
        If content does not start with the correct DSCRIPT header or the file format is invalid.

    Notes
    -----
    - The file is expected to follow the same structured format as the one produced by the `save()` method.
    - The method processes global parameters, definitions, template lines/items, and attributes. If the file
      includes numeric keys as strings (e.g., "0", "1"), they can be automatically converted into integers
      if `numerickeys=True`.
    - The script structure is dynamically rebuilt, and each section (global parameters, definitions,
      template, and attributes) is correctly parsed and assigned to the corresponding parts of the `dscript`
      instance.


    PIZZA.DSCRIPT SAVE FILE FORMAT
    -------------------------------
    This script syntax is designed for creating dynamic and customizable input files, where variables, templates,
    and control attributes can be defined in a flexible manner.

    ### Mandatory First Line:
    Every DSCRIPT file must begin with the following line:
        # DSCRIPT SAVE FILE

    ### Structure Overview:

    1. **Global Parameters Section (Optional):**
        - This section defines global script settings, enclosed within curly braces `{ }`.
        - Properties include:
            - `SECTIONS`: List of section names to be considered (e.g., `["DYNAMIC"]`).
            - `section`: Current section index (e.g., `0`).
            - `position`: Current script position in the order.
            - `role`: Defines the role of the script instance (e.g., `"dscript instance"`).
            - `description`: A short description of the script (e.g., `"dynamic script"`).
            - `userid`: Identifier for the user (e.g., `"dscript"`).
            - `version`: Script version (e.g., `0.1`).
            - `verbose`: Verbosity flag, typically a boolean (e.g., `False`).

        Example:
        ```
        {
            SECTIONS = ['INITIALIZATION', 'SIMULATION']  # Global script parameters
        }
        ```

    2. **Definitions Section:**
        - Variables are defined in Python-like syntax, allowing for dynamic variable substitution.
        - Variables can be numbers, strings, or lists, and they can include placeholders using `$`
          to delay execution or substitution.

        Example:
        ```
        d = 3                               # Define a number
        periodic = "$p"                     # '$' prevents immediate evaluation of 'p'
        units = "$metal"                    # '$' prevents immediate evaluation of 'metal'
        dimension = "${d}"                  # Variable substitution
        boundary = ['p', 'p', 'p']  # List with a mix of variables and values
        atom_style = "$atomic"              # String variable with delayed evaluation
        ```

    3. **Templates Section:**
        - This section provides a mapping between keys and their corresponding commands or instructions.
        - The templates reference variables defined in the **Definitions** section or elsewhere.
        - Syntax:
            ```
            KEY: INSTRUCTION
            ```
            where:
            - `KEY` can be numeric or alphanumeric.
            - `INSTRUCTION` represents a command template, often referring to variables using `${variable}` notation.

        Example:
        ```
        units: units ${units}               # Template uses the 'units' variable
        dim: dimension ${dimension}         # Template for setting the dimension
        bound: boundary ${boundary}         # Template for boundary settings
        lattice: lattice ${lattice}         # Lattice template
        ```

    4. **Attributes Section:**
        - Each template line can have customizable attributes to control behavior and conditions.
        - Default attributes include:
            - `facultative`: If `True`, the line is optional and can be removed if needed.
            - `eval`: If `True`, the line will be evaluated with Python's `eval()` function.
            - `readonly`: If `True`, the line cannot be modified later in the script.
            - `condition`: An expression that must be satisfied for the line to be included.
            - `condeval`: If `True`, the condition will be evaluated using `eval()`.
            - `detectvar`: If `True`, this creates variables in the **Definitions** section if they do not exist.

        Example:
        ```
        units: {facultative=False, eval=False, readonly=False, condition="${units}", condeval=False, detectvar=True}
        dim: {facultative=False, eval=False, readonly=False, condition=None, condeval=False, detectvar=True}
        ```

    Note on multiple definitions
    -----------------------------
    This example demonstrates how variables defined in the `Definitions` section are handled for each template.
    Each template retains its own snapshot of the variable definitions at the time it is created, ensuring that templates
    can use different values for the same variable if redefined.

    content = "" "
    # DSCRIPT SAVE FILE

    # Definitions
    var = 10

    # Template ky1
    key1: Template content with ${var}

    # Definitions
    var = 20

    # Template key2
    key2: Template content with ${var}

    # Template key3
    key3:[
        this is an underfined variable ${var31}
        this is an another underfined variable ${var32}
        this variables is defined  ${var}
        ]

    "" "

    # Parse content using parsesyntax()
    ds = dscript.parsesyntax(content)

    # Key1 should use the first definition of 'var' (10)
    print(ds.key1.definitions.var)  # Output: Template content with 10

    # Key2 should use the updated definition of 'var' (20)
    print(ds.key2.definitions.var)  # Output: Template content with 10


    """

    # Split the content into lines
    lines = content.splitlines()
    lines = [line for line in lines if line.strip()]  # Remove blank or empty lines
    # Raise an error if no content is left after removing blank lines
    if not lines:
        raise ValueError("File/Content is empty or only contains blank lines.")

    # Initialize containers for global parameters, definitions, templates, and attributes
    global_params = {}
    definitions = lambdaScriptdata()
    template = {}
    attributes = {}

    # State variables to handle multi-line global parameters and attributes
    inside_global_params = False
    inside_attributes = False
    current_attr_key = None  # Ensure this is properly initialized
    global_params_content = ""
    inside_template_block = False  # Track if we are inside a multi-line template
    current_template_key = None    # Track the current template key
    current_template_content = []  # Store lines for the current template content

    # Step 1: Authenticate the file
    if not lines[0].strip().startswith("# DSCRIPT SAVE FILE"):
        raise ValueError("File/Content is not a valid DSCRIPT file.")

    # Step 2: Process each line dynamically
    for line in lines[1:]:
        stripped = line.strip()

        # Ignore empty lines and comments
        if not stripped or stripped.startswith("#"):
            continue

        # Remove trailing comments
        stripped = remove_comments(stripped)

        # Step 3: Handle global parameters inside {...}
        if stripped.startswith("{"):
            # Found the opening {, start accumulating global parameters
            inside_global_params = True
            # Remove the opening { and accumulate the remaining content
            global_params_content = stripped[stripped.index('{') + 1:].strip()

            # Check if the closing } is also on the same line
            if '}' in global_params_content:
                global_params_content = global_params_content[:global_params_content.index('}')].strip()
                inside_global_params = False  # We found the closing } on the same line
                # Now parse the global parameters block
                cls._parse_global_params(global_params_content.strip(), global_params)
                global_params_content = ""  # Reset for the next block
            continue

        if inside_global_params:
            # Accumulate content until the closing } is found
            if stripped.endswith("}"):
                # Found the closing }, accumulate and process the entire block
                global_params_content += " " + stripped[:stripped.index('}')].strip()
                inside_global_params = False  # Finished reading global parameters block

                # Now parse the entire global parameters block
                cls._parse_global_params(global_params_content.strip(), global_params)
                global_params_content = ""  # Reset for the next block if necessary
            else:
                # Continue accumulating if } is not found
                global_params_content += " " + stripped
            continue

        # Step 4: Detect the start of a multi-line template block inside [...]
        if not inside_template_block:
            template_match = re.match(r'(\w+)\s*:\s*\[', stripped)
            if template_match:
                current_template_key = template_match.group(1)  # Capture the key
                inside_template_block = True
                current_template_content = []  # Reset content list
                continue

        # If inside a template block, accumulate lines until we find the closing ]
        if inside_template_block:
            if stripped == "]":
                # End of the template block, join the content and store it
                template[current_template_key] = ScriptTemplate(
                    current_template_content,
                    definitions=lambdaScriptdata(**definitions),  # Clone current global definitions
                    verbose=True,
                    userid=current_template_key
                    )
                template[current_template_key].refreshvar()
                inside_template_block = False
                current_template_key = None
                current_template_content = []
            else:
                # Accumulate the current line (without surrounding spaces)
                current_template_content.append(stripped)
            continue

        # Step 5: Handle attributes inside {...}
        if inside_attributes and stripped.endswith("}"):
            # Finish processing attributes for the current key
            cls._parse_attributes(attributes[current_attr_key], stripped[:-1])  # Remove trailing }
            inside_attributes = False
            current_attr_key = None
            continue

        if inside_attributes:
            # Continue accumulating attributes
            cls._parse_attributes(attributes[current_attr_key], stripped)
            continue

        # Step 6: Determine if the line is a definition, template, or attribute
        definition_match = re.match(r'(\w+)\s*=\s*(.+)', stripped)
        template_match = re.match(r'(\w+)\s*:\s*(?!\s*\{.*\}\s*$)(.+)', stripped) # template_match = re.match(r'(\w+)\s*:\s*(?!\{)(.+)', stripped)
        attribute_match = re.match(r'(\w+)\s*:\s*\{\s*(.+)\s*\}', stripped)       # attribute_match = re.match(r'(\w+)\s*:\s*\{(.+)\}', stripped)

        if definition_match:
            # Line is a definition (key=value)
            key, value = definition_match.groups()
            definitions.setattr(key,cls._convert_value(value))

        elif template_match and not inside_template_block:
            # Line is a template (key: content)
            key, content = template_match.groups()
            template[key] = ScriptTemplate(
                content,
                definitions=lambdaScriptdata(**definitions),  # Clone current definitions
                verbose=True,
                userid=current_template_key)
            template[key].refreshvar()

        elif attribute_match:
            # Line is an attribute (key:{attributes...})
            current_attr_key, attr_content = attribute_match.groups()
            attributes[current_attr_key] = {}
            cls._parse_attributes(attributes[current_attr_key], attr_content)
            inside_attributes = not stripped.endswith("}")

    # Step 7: Validation and Reconstruction
    # Make sure there are no attributes without a template entry
    for key in attributes:
        if key not in template:
            raise ValueError(f"Attributes found for undefined template key: {key}")
        # Apply attributes to the corresponding template object
        for attr_name, attr_value in attributes[key].items():
            setattr(template[key], attr_name, attr_value)

    # Step 7: Create and return a new dscript instance
    if name is None:
        name = autoname(8)
    instance = cls(
        name = name,
        SECTIONS=global_params.get('SECTIONS', ['DYNAMIC']),
        section=global_params.get('section', 0),
        position=global_params.get('position', 0),
        role=global_params.get('role', 'dscript instance'),
        description=global_params.get('description', 'dynamic script'),
        userid=global_params.get('userid', 'dscript'),
        version=global_params.get('version', 0.1),
        verbose=global_params.get('verbose', False)
    )


    # Convert numeric string keys to integers if numerickeys is True
    if numerickeys:
        numeric_template = {}
        for key, value in template.items():
            # Check if the key is a numeric string
            if key.isdigit():
                numeric_template[int(key)] = value
            else:
                numeric_template[key] = value
        template = numeric_template

    # Set definitions and template
    instance.DEFINITIONS = definitions
    instance.TEMPLATE = template

    # Refresh variables (ensure that variables are detected and added to definitions)
    instance.set_all_variables()

    # Check eval
    instance.check_all_variables(verbose=False)

    # return the new instance
    return instance
def write(scriptcontent, filename=None, foldername=None, overwrite=False)

Writes the provided script content to a specified file in a given folder, with a header if necessary.

Parameters

scriptcontent : str
The content to be written to the file.
filename : str, optional
The name of the file. If not provided, a random name will be generated. The extension .txt will be appended if not already present.
foldername : str, optional
The folder where the file will be saved. If not provided, the current working directory is used.
overwrite : bool, optional
If False (default), raises a FileExistsError if the file already exists. If True, the file will be overwritten if it exists.

Returns

str
The full path to the written file.

Raises

FileExistsError
If the file already exists and overwrite is set to False.

Notes

  • A header is prepended to the content if it does not already exist, using the header method.
  • The header includes metadata such as the current date, username, hostname, and file details.
Expand source code
@staticmethod
@staticmethod
def write(scriptcontent, filename=None, foldername=None, overwrite=False):
    """
    Writes the provided script content to a specified file in a given folder, with a header if necessary.

    Parameters
    ----------
    scriptcontent : str
        The content to be written to the file.

    filename : str, optional
        The name of the file. If not provided, a random name will be generated.
        The extension `.txt` will be appended if not already present.

    foldername : str, optional
        The folder where the file will be saved. If not provided, the current working directory is used.

    overwrite : bool, optional
        If False (default), raises a `FileExistsError` if the file already exists. If True, the file will be overwritten if it exists.

    Returns
    -------
    str
        The full path to the written file.

    Raises
    ------
    FileExistsError
        If the file already exists and `overwrite` is set to False.

    Notes
    -----
    - A header is prepended to the content if it does not already exist, using the `header` method.
    - The header includes metadata such as the current date, username, hostname, and file details.
    """
    # Generate a random name if filename is not provided
    if filename is None:
        filename = autoname(8)  # Generates a random name of 8 letters

    # Ensure the filename ends with '.txt'
    if not filename.endswith('.txt'):
        filename += '.txt'

    # Handle foldername and relative paths
    if foldername is None or foldername == "":
        # If foldername is empty or None, use current working directory for relative paths
        if not os.path.isabs(filename):
            filepath = os.path.join(os.getcwd(), filename)
        else:
            filepath = filename  # If filename is absolute, use it directly
    else:
        # If foldername is provided and filename is not absolute, use foldername
        if not os.path.isabs(filename):
            filepath = os.path.join(foldername, filename)
        else:
            filepath = filename

    # Check if file already exists, raise exception if it does and overwrite is False
    if os.path.exists(filepath) and not overwrite:
        raise FileExistsError(f"The file '{filepath}' already exists.")

    # Count total and non-empty lines in the content
    total_lines = len(scriptcontent.splitlines())
    non_empty_lines = sum(1 for line in scriptcontent.splitlines() if line.strip())

    # Prepare header if not already present
    if not scriptcontent.startswith("# DSCRIPT SAVE FILE"):
        fname = os.path.basename(filepath)  # Extracts the filename (e.g., "myscript.txt")
        name, _ = os.path.splitext(fname)   # Removes the extension, e.g., "myscript"
        metadata = get_metadata()           # retrieve all metadata (statically)
        header = dscript.header(name=name, verbosity=True,style=1,filepath=filepath,
                version = metadata["version"], license = metadata["license"], email = metadata["email"])
        # Add line count information to the header
        footer = frame_header(
            lines=[
                f"Total lines written: {total_lines}",
                f"Non-empty lines: {non_empty_lines}"
            ],
            style=1
        )
        scriptcontent = header + "\n" + scriptcontent + "\n" + footer

    # Write the content to the file
    with open(filepath, 'w') as file:
        file.write(scriptcontent)
    return filepath

Methods

def add_dynamic_script(self, key, content='', userid=None, definitions=None, verbose=None, **USER)

Add a dynamic script step to the dscript object.

Parameters:

key : str The key for the dynamic script (usually an index or step identifier). content : str or list of str, optional The content (template) of the script step. definitions : lambdaScriptdata, optional The merged variable space (STATIC + GLOBAL + LOCAL). verbose : bool, optional If None, self.verbose will be used. Controls verbosity of the template. USER : dict Additional user variables that override the definitions for this step.

Expand source code
def add_dynamic_script(self, key, content="", userid=None, definitions=None, verbose=None, **USER):
    """
    Add a dynamic script step to the dscript object.

    Parameters:
    -----------
    key : str
        The key for the dynamic script (usually an index or step identifier).
    content : str or list of str, optional
        The content (template) of the script step.
    definitions : lambdaScriptdata, optional
        The merged variable space (STATIC + GLOBAL + LOCAL).
    verbose : bool, optional
        If None, self.verbose will be used. Controls verbosity of the template.
    USER : dict
        Additional user variables that override the definitions for this step.
    """
    if definitions is None:
        definitions = lambdaScriptdata()
    if verbose is None:
        verbose = self.verbose
    # Create a new ScriptTemplate and add it to the TEMPLATE
    self.TEMPLATE[key] = ScriptTemplate(
        content=content,
        definitions=self.DEFINITIONS+definitions,
        verbose=verbose,
        userid = key if userid is None else userid,
        **USER
    )
def check_all_variables(self, verbose=True, seteval=True, output=False)

Checks for undefined variables for each TEMPLATE key in the dscript object.

Parameters:

verbose : bool, optional, default=True If True, prints information about variables for each TEMPLATE key. Shows [-] if the variable is set to its default value, [+] if it is defined, and [ ] if it is undefined.

seteval : bool, optional, default=True If True, sets the eval attribute to True if at least one variable is defined or set to its default value.

output : bool, optional, default=False If True, returns a dictionary with lists of default variables, set variables, and undefined variables.

Returns:

out : dict, optional If output=True, returns a dictionary with the following structure: - "defaultvalues": List of variables set to their default value (${varname}). - "setvalues": List of variables defined with values other than their default. - "undefined": List of variables that are undefined.

Expand source code
def check_all_variables(self, verbose=True, seteval=True, output=False):
    """
    Checks for undefined variables for each TEMPLATE key in the dscript object.

    Parameters:
    -----------
    verbose : bool, optional, default=True
        If True, prints information about variables for each TEMPLATE key.
        Shows [-] if the variable is set to its default value, [+] if it is defined, and [ ] if it is undefined.

    seteval : bool, optional, default=True
        If True, sets the `eval` attribute to True if at least one variable is defined or set to its default value.

    output : bool, optional, default=False
        If True, returns a dictionary with lists of default variables, set variables, and undefined variables.

    Returns:
    --------
    out : dict, optional
        If `output=True`, returns a dictionary with the following structure:
        - "defaultvalues": List of variables set to their default value (${varname}).
        - "setvalues": List of variables defined with values other than their default.
        - "undefined": List of variables that are undefined.
    """
    out = {"defaultvalues": [], "setvalues": [], "undefined": []}

    for key in self.TEMPLATE:
        template = self.TEMPLATE[key]
        # Call the check_variables method of ScriptTemplate for each TEMPLATE key
        result = template.check_variables(verbose=verbose, seteval=seteval)

        # Update the output dictionary if needed
        out["defaultvalues"].extend(result["defaultvalues"])
        out["setvalues"].extend(result["setvalues"])
        out["undefined"].extend(result["undefined"])

    if output:
        return out
def createEmptyVariables(self, vars)

Creates empty variables in DEFINITIONS if they don't already exist.

Parameters:

vars : str or list of str The variable name or list of variable names to be created in DEFINITIONS.

Expand source code
def createEmptyVariables(self, vars):
    """
    Creates empty variables in DEFINITIONS if they don't already exist.

    Parameters:
    -----------
    vars : str or list of str
        The variable name or list of variable names to be created in DEFINITIONS.
    """
    if isinstance(vars, str):
        vars = [vars]  # Convert single variable name to list for uniform processing
    for varname in vars:
        if varname not in self.DEFINITIONS:
            self.DEFINITIONS.setattr(varname,"${" + varname + "}")
def detect_all_variables(self)

Detects all variables across all templates in the dscript object.

This method iterates through all ScriptTemplate objects in the dscript and collects variables from each template using the detect_variables method.

Returns:

list A sorted list of unique variables detected in all templates.

Expand source code
def detect_all_variables(self):
    """
    Detects all variables across all templates in the dscript object.

    This method iterates through all ScriptTemplate objects in the dscript and
    collects variables from each template using the detect_variables method.

    Returns:
    --------
    list
        A sorted list of unique variables detected in all templates.
    """
    all_variables = set()  # Use a set to avoid duplicates
    # Iterate through all templates in the dscript object
    for template_key, template in self.TEMPLATE.items():
        # Ensure the template is a ScriptTemplate and has the detect_variables method
        if isinstance(template, ScriptTemplate):
            detected_vars = template.detect_variables()
            all_variables.update(detected_vars)  # Add the detected variables to the set
    return sorted(all_variables)  # Return a sorted list of unique variables
def do(self, printflag=None, verbose=None, softrun=False, return_definitions=False, comment_chars='#%', **USER)

Executes or previews all ScriptTemplate instances in TEMPLATE, concatenating their processed content. Allows for optional headers and footers based on verbosity settings, and offers a preliminary preview mode with softrun. Accumulates definitions across all templates if return_definitions=True.

Parameters

printflag : bool, optional
If True, enables print output during execution. Defaults to the instance's print flag if None.
verbose : bool, optional
If True, includes headers and footers in the output, providing additional detail. Defaults to the instance's verbosity setting if None.
softrun : bool, optional
If True, executes the script in a preliminary mode: - Bypasses full variable substitution for a preview of the content, useful for validating structure. - If False (default), performs full processing, including variable substitutions and evaluations.
return_definitions : bool, optional
If True, returns a tuple where the second element contains accumulated definitions from all templates. If False (default), returns only the concatenated output.
comment_chars : str, optional (default: "#%")
A string containing characters to identify the start of a comment. Any of these characters will mark the beginning of a comment unless within quotes.
**USER : keyword arguments
Allows for the provision of additional user-defined definitions, where each keyword represents a definition key and the associated value represents the definition's content. These definitions can override or supplement template-level definitions during execution.

Returns

str or tuple
  • If return_definitions=False, returns the concatenated output of all ScriptTemplate instances, with optional headers, footers, and execution summary based on verbosity.
  • If return_definitions=True, returns a tuple of (output, accumulated_definitions), where accumulated_definitions contains all definitions used across templates.

Notes

  • Each ScriptTemplate in TEMPLATE is processed individually using its own do() method.
  • The softrun mode provides a preliminary content preview without full variable substitution, helpful for inspecting the script structure or gathering local definitions.
  • When verbose is enabled, the method includes detailed headers, footers, and a summary of processed and ignored items, providing insight into the script's construction and variable usage.
  • Accumulated definitions from each ScriptTemplate are combined if return_definitions=True, which can be useful for tracking all variables and definitions applied across the templates.

Example

>>> dscript_instance = dscript(name="ExampleScript")
>>> dscript_instance.TEMPLATE[0] = ScriptTemplate(
...     content=["units ${units}", "boundary ${boundary}"],
...     definitions=lambdaScriptdata(units="lj", boundary="p p p"),
...     attributes={'eval': True}
... )
>>> dscript_instance.do(verbose=True, units="real")
# Output:
# --------------
# TEMPLATE "ExampleScript"
# --------------
units real
boundary p p p
# ---> Total items: 2 - Ignored items: 0
Expand source code
def do(self, printflag=None, verbose=None, softrun=False, return_definitions=False,comment_chars="#%", **USER):
    """
    Executes or previews all `ScriptTemplate` instances in `TEMPLATE`, concatenating their processed content.
    Allows for optional headers and footers based on verbosity settings, and offers a preliminary preview mode with `softrun`.
    Accumulates definitions across all templates if `return_definitions=True`.

    Parameters
    ----------
    printflag : bool, optional
        If `True`, enables print output during execution. Defaults to the instance's print flag if `None`.
    verbose : bool, optional
        If `True`, includes headers and footers in the output, providing additional detail.
        Defaults to the instance's verbosity setting if `None`.
    softrun : bool, optional
        If `True`, executes the script in a preliminary mode:
        - Bypasses full variable substitution for a preview of the content, useful for validating structure.
        - If `False` (default), performs full processing, including variable substitutions and evaluations.
    return_definitions : bool, optional
        If `True`, returns a tuple where the second element contains accumulated definitions from all templates.
        If `False` (default), returns only the concatenated output.
    comment_chars : str, optional (default: "#%")
        A string containing characters to identify the start of a comment.
        Any of these characters will mark the beginning of a comment unless within quotes.
    **USER : keyword arguments
        Allows for the provision of additional user-defined definitions, where each keyword represents a
        definition key and the associated value represents the definition's content. These definitions
        can override or supplement template-level definitions during execution.

    Returns
    -------
    str or tuple
        - If `return_definitions=False`, returns the concatenated output of all `ScriptTemplate` instances,
          with optional headers, footers, and execution summary based on verbosity.
        - If `return_definitions=True`, returns a tuple of (`output`, `accumulated_definitions`), where
          `accumulated_definitions` contains all definitions used across templates.

    Notes
    -----
    - Each `ScriptTemplate` in `TEMPLATE` is processed individually using its own `do()` method.
    - The `softrun` mode provides a preliminary content preview without full variable substitution,
      helpful for inspecting the script structure or gathering local definitions.
    - When `verbose` is enabled, the method includes detailed headers, footers, and a summary of processed and
      ignored items, providing insight into the script's construction and variable usage.
    - Accumulated definitions from each `ScriptTemplate` are combined if `return_definitions=True`, which can be
      useful for tracking all variables and definitions applied across the templates.

    Example
    -------
    >>> dscript_instance = dscript(name="ExampleScript")
    >>> dscript_instance.TEMPLATE[0] = ScriptTemplate(
    ...     content=["units ${units}", "boundary ${boundary}"],
    ...     definitions=lambdaScriptdata(units="lj", boundary="p p p"),
    ...     attributes={'eval': True}
    ... )
    >>> dscript_instance.do(verbose=True, units="real")
    # Output:
    # --------------
    # TEMPLATE "ExampleScript"
    # --------------
    units real
    boundary p p p
    # ---> Total items: 2 - Ignored items: 0
    """

    printflag = self.printflag if printflag is None else printflag
    verbose = self.verbose if verbose is None else verbose
    header = f"# --------------[ TEMPLATE \"{self.name}\" ]--------------" if verbose else ""
    footer = "# --------------------------------------------" if verbose else ""

    # Initialize output, counters, and optional definitions accumulator
    output = [header]
    non_empty_lines = 0
    ignored_lines = 0
    accumulated_definitions = lambdaScriptdata() if return_definitions else None

    for key, template in self.TEMPLATE.items():
        # Process each template with softrun if enabled, otherwise use full processing
        result = template.do(softrun=softrun,USER=lambdaScriptdata(**USER))
        if result:
            # Apply comment removal based on verbosity
            final_result = result if verbose else remove_comments(result,comment_chars=comment_chars)
            if final_result or verbose:
                output.append(final_result)
                non_empty_lines += 1
            else:
                ignored_lines += 1
            # Accumulate definitions if return_definitions is enabled
            if return_definitions:
                accumulated_definitions += template.definitions
        else:
            ignored_lines += 1

    # Add footer summary if verbose
    nel_word = 'items' if non_empty_lines > 1 else 'item'
    il_word = 'items' if ignored_lines > 1 else 'item'
    footer += f"\n# ---> Total {nel_word}: {non_empty_lines} - Ignored {il_word}: {ignored_lines}" if verbose else ""
    output.append(footer)

    # Concatenate output and determine return type based on return_definitions
    output_content = "\n".join(output)
    return (output_content, accumulated_definitions) if return_definitions else output_content
def generator(self)

Returns

STR
generated code corresponding to dscript (using dscript syntax/language).
Expand source code
def generator(self):
    """
    Returns
    -------
    STR
        generated code corresponding to dscript (using dscript syntax/language).

    """
    return self.save(generatoronly=True)
def get_attributes_by_index(self, index)

Returns the attributes of the ScriptTemplate at the specified index.

Expand source code
def get_attributes_by_index(self, index):
    """ Returns the attributes of the ScriptTemplate at the specified index."""
    key = list(self.TEMPLATE.keys())[index]
    return self.TEMPLATE[key].attributes
def get_content_by_index(self, index, do=True, protected=True)

Returns the content of the ScriptTemplate at the specified index.

Parameters:

index : int The index of the template in the TEMPLATE dictionary. do : bool, optional (default=True) If True, the content will be processed based on conditions and evaluation flags. protected : bool, optional (default=True) Controls whether variable evaluation is protected (e.g., prevents overwriting certain definitions).

Returns:

str or list of str The content of the template after processing, or an empty string if conditions or evaluation flags block it.

Expand source code
def get_content_by_index(self, index, do=True, protected=True):
    """
    Returns the content of the ScriptTemplate at the specified index.

    Parameters:
    -----------
    index : int
        The index of the template in the TEMPLATE dictionary.
    do : bool, optional (default=True)
        If True, the content will be processed based on conditions and evaluation flags.
    protected : bool, optional (default=True)
        Controls whether variable evaluation is protected (e.g., prevents overwriting certain definitions).

    Returns:
    --------
    str or list of str
        The content of the template after processing, or an empty string if conditions or evaluation flags block it.
    """
    key = list(self.TEMPLATE.keys())[index]
    s = self.TEMPLATE[key].content
    att = self.TEMPLATE[key].attributes
    # Return an empty string if the facultative attribute is True and do is True
    if att["facultative"] and do:
        return ""
    # Evaluate the condition (if any)
    if att["condition"] is not None:
        cond = eval(self.DEFINITIONS.formateval(att["condition"], protected))
    else:
        cond = True
    # If the condition is met, process the content
    if cond:
        # Apply formateval only if the eval attribute is True and do is True
        if att["eval"] and do:
            if isinstance(s, list):
                # Apply formateval to each item in the list if s is a list
                return [self.DEFINITIONS.formateval(line, protected) for line in s]
            else:
                # Apply formateval to the single string content
                return self.DEFINITIONS.formateval(s, protected)
        else:
            return s  # Return the raw content if no evaluation is needed
    elif do:
        return ""  # Return an empty string if the condition is not met and do is True
    else:
        return s  # Return the raw content if do is False
def items(self)
Expand source code
def items(self):
    return ((key, s.content) for key, s in self.TEMPLATE.items())
def keys(self)

Return the keys of the TEMPLATE.

Expand source code
def keys(self):
    """Return the keys of the TEMPLATE."""
    return self.TEMPLATE.keys()
def pipescript(self, *keys, printflag=None, verbose=None, verbosity=None, **USER)

Returns a pipescript object by combining script objects corresponding to the given keys.

Parameters:

keys : one or more keys that correspond to the TEMPLATE entries. printflag : bool, optional Whether to enable printing of additional information. verbose : bool, optional Whether to run in verbose mode for debugging or detailed output. *USER : dict, optional Additional user-defined variables to pass into the script.

Returns:

A pipescript object that combines the script objects generated from the selected dscript subobjects.

Expand source code
def pipescript(self, *keys, printflag=None, verbose=None, verbosity=None, **USER):
    """
    Returns a pipescript object by combining script objects corresponding to the given keys.

    Parameters:
    -----------
    *keys : one or more keys that correspond to the `TEMPLATE` entries.
    printflag : bool, optional
        Whether to enable printing of additional information.
    verbose : bool, optional
        Whether to run in verbose mode for debugging or detailed output.
    **USER : dict, optional
        Additional user-defined variables to pass into the script.

    Returns:
    --------
    A `pipescript` object that combines the script objects generated from the selected
    dscript subobjects.
    """
    # Start with an empty pipescript
    # combined_pipescript = None
    # # Iterate over the provided keys to extract corresponding subobjects
    # for key in keys:
    #     # Extract the dscript subobject for the given key
    #     sub_dscript = self(key)
    #     # Convert the dscript subobject to a script object, passing USER, printflag, and verbose
    #     script_obj = sub_dscript.script(printflag=printflag, verbose=verbose, **USER)
    #     # Combine script objects into a pipescript object
    #     if combined_pipescript is None:
    #         combined_pipescript = pipescript(script_obj)  # Initialize pipescript
    #     else:
    #         combined_pipescript = combined_pipescript | script_obj  # Use pipe operator
    # if combined_pipescript is None:
    #     ValueError('The conversion to pipescript from {type{self}} falled')
    # return combined_pipescript
    printflag = self.printflag if printflag is None else printflag
    verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
    verbosity = 0 if not verbose else verbosity

    # Loop over all keys in TEMPLATE and combine them
    combined_pipescript = None
    for key in self.keys():
        # Create a new dscript object with only the current key in TEMPLATE
        focused_dscript = dscript(name=f"{self.name}:{key}")
        focused_dscript.TEMPLATE[key] = self.TEMPLATE[key]
        focused_dscript.TEMPLATE[key].definitions = scriptdata(**self.TEMPLATE[key].definitions)
        focused_dscript.DEFINITIONS = scriptdata(**self.DEFINITIONS)
        focused_dscript.SECTIONS = self.SECTIONS[:]
        focused_dscript.section = self.section
        focused_dscript.position = self.position
        focused_dscript.role = self.role
        focused_dscript.description = self.description
        focused_dscript.userid = self.userid
        focused_dscript.version = self.version
        focused_dscript.verbose = verbose
        focused_dscript.printflag = printflag

        # Convert the focused dscript object to a script object
        script_obj = focused_dscript.script(printflag=printflag, verbose=verbose, **USER)

        # Combine the script objects into a pipescript object using the pipe operator
        if combined_pipescript is None:
            combined_pipescript = pipescript(script_obj)  # Initialize pipescript
        else:
            combined_pipescript = combined_pipescript | pipescript(script_obj)  # Use pipe operator

    if combined_pipescript is None:
        ValueError('The conversion to pipescript from {type{self}} falled')
    return combined_pipescript
def reorder(self, order)

Reorder the TEMPLATE lines according to a list of indices.

Expand source code
def reorder(self, order):
    """Reorder the TEMPLATE lines according to a list of indices."""
    # Get the original items as a list of (key, value) pairs
    original_items = list(self.TEMPLATE.items())
    # Create a new dictionary with reordered scripts, preserving original keys
    new_scripts = {original_items[i][0]: original_items[i][1] for i in order}
    # Create a new dscript object with reordered scripts
    reordered_script = dscript()
    reordered_script.TEMPLATE = new_scripts
    return reordered_script
def save(self, filename=None, foldername=None, overwrite=False, generatoronly=False, onlyusedvariables=True)

Save the current script instance to a text file.

Parameters

filename : str, optional
The name of the file to save the script to. If not provided, self.name is used. The extension ".txt" is automatically appended if not included.
foldername : str, optional
The directory where the file will be saved. If not provided, it defaults to the system's temporary directory. If the filename does not include a full path, this folder will be used.
overwrite : bool, default=True
Whether to overwrite the file if it already exists. If set to False, an exception is raised if the file exists.
generatoronly : bool, default=False
If True, the method returns the generated content string without saving to a file.
onlyusedvariables : bool, default=True
If True, local definitions are only saved if they are used within the template content. If False, all local definitions are saved, regardless of whether they are referenced in the template.

Raises

FileExistsError
If the file already exists and overwrite is set to False.

Notes

  • The script is saved in a plain text format, and each section (global parameters, definitions, template, and attributes) is written in a structured format with appropriate comments.
  • If self.name is used as the filename, it must be a valid string that can serve as a file name.
  • The file structure follows the format: # DSCRIPT SAVE FILE # generated on YYYY-MM-DD on user@hostname

    GLOBAL PARAMETERS

    { … }

    DEFINITIONS (number of definitions=…)

    key=value

    TEMPLATES (number of items=…)

    key: template_content

    ATTRIBUTES (number of items with explicit attributes=…)

    key:{attr1=value1, attr2=value2, …}

Expand source code
def save(self, filename=None, foldername=None, overwrite=False, generatoronly=False, onlyusedvariables=True):
    """
    Save the current script instance to a text file.

    Parameters
    ----------
    filename : str, optional
        The name of the file to save the script to. If not provided, `self.name` is used.
        The extension ".txt" is automatically appended if not included.

    foldername : str, optional
        The directory where the file will be saved. If not provided, it defaults to the system's
        temporary directory. If the filename does not include a full path, this folder will be used.

    overwrite : bool, default=True
        Whether to overwrite the file if it already exists. If set to False, an exception is raised
        if the file exists.

    generatoronly : bool, default=False
        If True, the method returns the generated content string without saving to a file.

    onlyusedvariables : bool, default=True
        If True, local definitions are only saved if they are used within the template content.
        If False, all local definitions are saved, regardless of whether they are referenced in
        the template.

    Raises
    ------
    FileExistsError
        If the file already exists and `overwrite` is set to False.

    Notes
    -----
    - The script is saved in a plain text format, and each section (global parameters, definitions,
      template, and attributes) is written in a structured format with appropriate comments.
    - If `self.name` is used as the filename, it must be a valid string that can serve as a file name.
    - The file structure follows the format:
        # DSCRIPT SAVE FILE
        # generated on YYYY-MM-DD on user@hostname

        # GLOBAL PARAMETERS
        { ... }

        # DEFINITIONS (number of definitions=...)
        key=value

        # TEMPLATES (number of items=...)
        key: template_content

        # ATTRIBUTES (number of items with explicit attributes=...)
        key:{attr1=value1, attr2=value2, ...}
    """
    # At the beginning of the save method
    start_time = time.time()  # Start the timer

    if not generatoronly:
        # Use self.name if filename is not provided
        if filename is None:
            filename = span(self.name, sep="\n")

        # Ensure the filename ends with '.txt'
        if not filename.endswith('.txt'):
            filename += '.txt'

        # Construct the full path
        if foldername in [None, ""]:  # Handle cases where foldername is None or an empty string
            filepath = os.path.abspath(filename)
        else:
            filepath = os.path.join(foldername, filename)

        # Check if the file already exists, and raise an exception if it does and overwrite is False
        if os.path.exists(filepath) and not overwrite:
            raise FileExistsError(f"The file '{filepath}' already exists.")

    # Header with current date, username, and host
    header = "# DSCRIPT SAVE FILE\n"
    header += "\n"*2
    if generatoronly:
        header += dscript.header(verbose=True,filepath='dynamic code generation (no file)',
                                 name = self.name, version=self.version, license=self.license, email=self.email)
    else:
        header += dscript.header(verbose=True,filepath=filepath,
                                 name = self.name, version=self.version, license=self.license, email=self.email)
    header += "\n"*2

    # Global parameters in strict Python syntax
    global_params = "# GLOBAL PARAMETERS (8 parameters)\n"
    global_params += "{\n"
    global_params += f"    SECTIONS = {self.SECTIONS},\n"
    global_params += f"    section = {self.section},\n"
    global_params += f"    position = {self.position},\n"
    global_params += f"    role = {self.role!r},\n"
    global_params += f"    description = {self.description!r},\n"
    global_params += f"    userid = {self.userid!r},\n"
    global_params += f"    version = {self.version},\n"
    global_params += f"    verbose = {self.verbose}\n"
    global_params += "}\n"

    # Initialize definitions with self.DEFINITIONS
    allvars = self.DEFINITIONS

    # Temporary dictionary to track global variable information
    global_var_info = {}

    # Loop over each template item to detect and record variable usage and overrides
    for template_index, (key, script_template) in enumerate(self.TEMPLATE.items()):
        # Detect variables used in this template
        used_variables = script_template.detect_variables()

        # Loop over each template item to detect and record variable usage and overrides
        for template_index, (key, script_template) in enumerate(self.TEMPLATE.items()):
            # Detect variables used in this template
            used_variables = script_template.detect_variables()

            # Check each variable used in this template
            for var in used_variables:
                # Get global and local values for the variable
                global_value = getattr(allvars, var, None)
                local_value = getattr(script_template.definitions, var, None)
                is_global = var in allvars  # Check if the variable originates in global space
                is_default = is_global and (
                    global_value is None or global_value == "" or global_value == f"${{{var}}}"
                )

                # If the variable is not yet tracked, initialize its info
                if var not in global_var_info:
                    global_var_info[var] = {
                        "value": global_value,       # Initial value from allvars if exists
                        "updatedvalue": global_value,# Initial value from allvars if exists
                        "is_default": is_default,    # Check if it’s set to a default value
                        "first_def": None,           # First definition (to be updated later)
                        "first_use": template_index, # First time the variable is used
                        "first_val": global_value,
                        "override_index": template_index if local_value is not None else None,  # Set override if defined locally
                        "is_global": is_global       # Track if the variable originates as global
                    }
                else:
                    # Update `override_index` if the variable is defined locally and its value changes
                    if local_value is not None:
                        # Check if the local value differs from the tracked value in global_var_info
                        current_value = global_var_info[var]["value"]
                        if current_value != local_value:
                            global_var_info[var]["override_index"] = template_index
                            global_var_info[var]["updatedvalue"] = local_value  # Update the tracked value


        # Second loop: Update `first_def` for all variables
        for template_index, (key, script_template) in enumerate(self.TEMPLATE.items()):
            local_definitions = script_template.definitions.keys()
            for var in local_definitions:
                if var in global_var_info and global_var_info[var]["first_def"] is None:
                    global_var_info[var]["first_def"] = template_index
                    global_var_info[var]["first_val"] = getattr(script_template.definitions, var)


    # Filter global definitions based on usage, overrides, and first_def
    filtered_globals = {
        var: info for var, info in global_var_info.items()
        if (info["is_global"] or info["is_default"]) and (info["override_index"] is None)
        }


    # Generate the definitions output based on filtered globals
    definitions = f"\n# GLOBAL DEFINITIONS (number of definitions={len(filtered_globals)})\n"
    for var, info in filtered_globals.items():
        if info["is_default"] and (info["first_def"]>info["first_use"] if info["first_def"] else True):
            definitions += f"{var} = ${{{var}}}  # value assumed to be defined outside this DSCRIPT file\n"
        else:
            value = info["first_val"] #info["value"]
            if value in ["", None]:
                definitions += f'{var} = ""\n'
            elif isinstance(value, str):
                safe_value = value.replace('\\', '\\\\').replace('\n', '\\n')
                definitions += f"{var} = {safe_value}\n"
            else:
                definitions += f"{var} = {value}\n"

    # Template (number of lines/items)
    printsinglecontent = False
    template = f"\n# TEMPLATES (number of items={len(self.TEMPLATE)})\n"
    for key, script_template in self.TEMPLATE.items():
        # Get local template definitions and detected variables
        template_vars = script_template.definitions
        used_variables = script_template.detect_variables()
        islocal = False
        # Temporary dictionary to accumulate variables to add to allvars
        valid_local_vars = lambdaScriptdata()
        # Write template-specific definitions only if they meet the updated conditions
        for var in template_vars.keys():
            # Conditions for adding a variable to the local template and to `allvars`
            if (var in used_variables or not onlyusedvariables) and (
                script_template.is_variable_set_value_only(var) and
                (var not in allvars or getattr(template_vars, var) != getattr(allvars, var))
            ):
                # Start local definitions section if this is the first local variable for the template
                if not islocal:
                    template += f"\n# LOCAL DEFINITIONS for key '{key}'\n"
                    islocal = True
                # Retrieve and process the variable value
                value = getattr(template_vars, var)
                if value in ["", None]:
                    template += f'{var} = ""\n'  # Set empty or None values as ""
                elif isinstance(value, str):
                    safe_value = value.replace('\\', '\\\\').replace('\n', '\\n')
                    template += f"{var} = {safe_value}\n"
                else:
                    template += f"{var} = {value}\n"
                # Add the variable to valid_local_vars for selective update of allvars
                valid_local_vars.setattr(var, value)
        # Update allvars only with filtered, valid local variables
        allvars += valid_local_vars

        # Write the template content
        if isinstance(script_template.content, list):
            if len(script_template.content) == 1:
                # Single-line template saved as a single line
                content_str = script_template.content[0].strip()
                template += "" if printsinglecontent else "\n"
                template += f"{key}: {content_str}\n"
                printsinglecontent = True
            else:
                content_str = '\n    '.join(script_template.content)
                template += f"\n{key}: [\n    {content_str}\n ]\n"
                printsinglecontent = False
        else:
            template += "" if printsinglecontent else "\n"
            template += f"{key}: {script_template.content}\n"
            printsinglecontent = True

    # Attributes (number of lines/items with explicit attributes)
    attributes = f"# ATTRIBUTES (number of items with explicit attributes={len(self.TEMPLATE)})\n"
    for key, script_template in self.TEMPLATE.items():
        attr_str = ", ".join(f"{attr_name}={repr(attr_value)}"
                             for attr_name, attr_value in script_template.attributes.items())
        attributes += f"{key}:{{{attr_str}}}\n"

    # Combine all sections into one content
    content = header + "\n" + global_params + "\n" + definitions + "\n" + template + "\n" + attributes + "\n"


    # Append footer information to the content
    non_empty_lines = sum(1 for line in content.splitlines() if line.strip())  # Count non-empty lines
    execution_time = time.time() - start_time  # Calculate the execution time in seconds
    # Prepare the footer content
    footer_lines = [
        ["Non-empty lines", str(non_empty_lines)],
        ["Execution time (seconds)", f"{execution_time:.4f}"],
    ]
    # Format footer into tabular style
    footer_content = [
        f"{row[0]:<25} {row[1]:<15}" for row in footer_lines
    ]
    # Use frame_header to format footer
    footer = frame_header(
        lines=["DSCRIPT SAVE FILE generator"] + footer_content,
        style=1
    )
    # Append footer to the content
    content += f"\n{footer}"

    if generatoronly:
        return content
    else:
        # Write the content to the file
        with open(filepath, 'w') as f:
            f.write(content)
        print(f"\nScript saved to {filepath}")
        return filepath
def script(self, printflag=None, verbose=None, verbosity=None, **USER)

returns the corresponding script

Expand source code
def script(self,printflag=None, verbose=None, verbosity=None, **USER):
    """
    returns the corresponding script
    """
    printflag = self.printflag if printflag is None else printflag
    verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
    verbosity = 0 if not verbose else verbosity
    return lamdaScript(self,persistentfile=True, persistentfolder=None,
                       printflag=printflag, verbose=verbose,
                       **USER)
def set_all_variables(self)

Ensures that all variables in the templates are added to the global definitions with default values if they are not already defined.

Expand source code
def set_all_variables(self):
    """
    Ensures that all variables in the templates are added to the global definitions
    with default values if they are not already defined.
    """
    for key, script_template in self.TEMPLATE.items():
        # Check and update the global definitions with template-specific variables
        for var in script_template.detect_variables():
            if var not in self.DEFINITIONS:
                # Add undefined variables with their default value
                self.DEFINITIONS.setattr(var, f"${{{var}}}")  # Set default as ${varname}
def values(self)

Return the ScriptTemplate objects in TEMPLATE.

Expand source code
def values(self):
    """Return the ScriptTemplate objects in TEMPLATE."""
    return self.TEMPLATE.values()
class group (name=None, groups=None, group_names=None, collection=None, printflag=False, verbose=True, verbosity=None)

A class for managing LAMMPS group operations and generating LAMMPS scripts.

Overview

The group class provides an object-oriented interface to define and manage groups of atoms in LAMMPS simulations. Groups in LAMMPS are collections of atoms that can be manipulated together, allowing users to apply fixes, compute properties, or perform operations on specific subsets of atoms.

This class allows you to create, combine, and manipulate groups using algebraic operations (union, intersection, subtraction), and to generate the corresponding LAMMPS commands. It also provides methods to output the group commands as scripts, which can be integrated into LAMMPS input files.

Key Features

  • Create and Manage Groups: Define new groups based on atom types, regions, IDs, or variables.
  • Flexible Group Creation: Create multiple groups at once and define groups using concise criteria through the add_group_criteria method.
  • Algebraic Operations: Combine groups using union (+), intersection (*), and subtraction (-) operations.
  • Subindexing with Callable Syntax: Retrieve multiple group operations by calling the group instance with names or indices.
  • Script Generation: Generate LAMMPS script lines for group definitions, which can be output as scripts or pipelines.
  • Dynamic Evaluation: Evaluate complex group expressions and store the resulting operations.
  • Integration with Scripting Tools: Convert group operations into dscript or pipescript objects for advanced script management.

LAMMPS Context

In LAMMPS (Large-scale Atomic/Molecular Massively Parallel Simulator), groups are fundamental for specifying subsets of atoms for applying operations like forces, fixes, and computes. The group command in LAMMPS allows users to define groups based on various criteria such as atom IDs, types, regions, and variables.

This group class abstracts the complexity of managing group definitions and operations, providing a high-level interface to define and manipulate groups programmatically.

Usage Examples

Creating Multiple Groups at Once

# Create groups 'o1', 'o2', 'o3', 'o4' upon instantiation
G = group(group_names=['o1', 'o2', 'o3', 'o4'])

Defining Groups Based on Criteria

G = group()
G.add_group_criteria('lower', type=[1])
G.add_group_criteria('central', region='central_cyl')
G.add_group_criteria('new_group', create=True)
G.add_group_criteria('upper', clear=True)
G.add_group_criteria('subtract_group', subtract=['group1', 'group2'])

Defining a Group Based on a Variable

G = group()
G.variable('myVar', 'x > 5')          # Assign a variable
G.byvariable('myGroup', 'myVar')      # Define group based on the variable

Using add_group_criteria with Variable

G.add_group_criteria('myGroup', variable={'name': 'myVar', 'expression': 'x > 5'})

Defining Groups Using a Dictionary

group_definitions = {
    'group1': {'type': [1, 2]},
    'group2': {'region': 'my_region'},
    'group3': {'variable': {'name': 'var1', 'expression': 'x > 5'}},
    'group4': {'union': ['group1', 'group2']},
}
G.add_group_criteria(group_definitions)

Creating a Group from a Collection of groupobject Instances

# Import the classes
# from groupobject import groupobject
# from group import group

# Create groupobject instances
o1 = groupobject(beadtype=1, group=["all", "A"], mass=1.0)
o2 = groupobject(beadtype=2, group=["all", "B", "C"])
o3 = groupobject(beadtype=3, group="C", mass=2.5)

# Create a group instance with the collection
G = group(name="mycollection", collection=[o1, o2, o3])
# or
G = group(name=None, collection=[o1, o2, o3])  # Name will be generated as "1+2+3"

# Generate the LAMMPS script
script_content = G.code()
print(script_content)

Expected Output:

group all type 1 2 3
group A type 1
group B type 2
group C type 2 3

Accessing Groups and Performing Operations

# Access groups via attribute-style or item-style access
op1 = G.group1
op2 = G['group2']

# Combine groups using algebraic operations
complex_op = op1 + op2 - G.group3

# Evaluate the operation and store the result
G.evaluate('combined_group', complex_op)

Subindexing with Callable Syntax

# Retrieve multiple operations by names or indices
subG = G('group1', 2, 'group3')  # Retrieves 'group1', third operation, and 'group3'

# Display the names of operations in subG
operation_names = [op.name for op in subG._operations]
print(operation_names)  # Output: ['group1', 'group3', 'group3']

# Generate the LAMMPS script for the subgroup
script_content = subG.code()
print(script_content)

Generating LAMMPS Script

script_content = G.code()
print(script_content)

Class Methods

Initialization and Setup

  • __init__(self, name=None, groups=None, group_names=None, printflag=False, verbose=True): Initializes a new group instance.
  • name (str): Optional name for the group instance.
  • groups (dict): Dictionary of group definitions to create upon initialization.
  • group_names (list): List of group names to create empty groups upon initialization.
  • collection (list or tuple of groupobject, optional): Collection of groupobject instances.

  • printflag (bool): If True, enables printing of script generation.

  • verbose (bool): If True, enables verbose output.

  • create_groups(self, *group_names): Creates multiple new groups with the given names.

  • add_group_criteria(self, *args, **kwargs): Adds group(s) based on criteria.

  • Supports two usages:
    • add_group_criteria(group_name, **criteria): Adds a single group.
    • add_group_criteria(group_definitions): Adds multiple groups from a dictionary.

Group Creation Methods

  • create(self, group_name): Creates a new empty group with the given name.
  • bytype(self, group_name, type_values): Defines a group based on atom types.
  • byid(self, group_name, id_values): Defines a group based on atom IDs.
  • byregion(self, group_name, region_name): Defines a group based on a region.
  • variable(self, variable_name, expression, style="atom"): Assigns an expression to a LAMMPS variable.
  • byvariable(self, group_name, variable_name): Defines a group based on a variable.
  • clear(self, group_name): Clears an existing group.

Algebraic Operations

  • union(self, group_name, *groups): Performs a union of the specified groups.
  • intersect(self, group_name, *groups): Performs an intersection of the specified groups.
  • subtract(self, group_name, *groups): Subtracts groups from the first group.
  • evaluate(self, group_name, group_op): Evaluates a group operation and stores the result.

Access and Manipulation

  • __getitem__(self, key): Allows accessing operations by name or index.
  • __getattr__(self, operation_name): Enables attribute-style access to operations.
  • __call__(self, *keys): Returns a new group instance containing specified operations.
  • Parameters:
    • *keys (str or int): One or more names or indices of operations to retrieve.
  • Returns:
    • group: A new group instance containing the specified operations.
  • Example: python subG = G('group1', 1, 'group3') # Retrieves 'group1', second operation, and 'group3'
  • list(self): Returns a list of all operation names.
  • find(self, name): Finds the index of an operation based on its name.
  • disp(self, name): Displays the content of an operation.
  • delete(self, name): Deletes an operation by name.
  • copy(self, source_name, new_name): Copies an existing operation to a new name.
  • rename(self, old_name, new_name): Renames an existing operation.
  • reindex(self, name, new_idx): Changes the index of an operation.

Script Generation

  • code(self): Returns the generated LAMMPS commands as a string.
  • dscript(self, name=None): Generates a dscript object containing the group's commands.
  • script(self, name=None): Generates a script object containing the group's commands.
  • pipescript(self): Generates a pipescript object containing each group's command as a separate script in a pipeline.

Operator Overloading

  • Addition (+): Union of groups.
  • Subtraction (-): Subtraction of groups.
  • Multiplication (*): Intersection of groups.

These operators are overloaded in the Operation class and can be used to combine group operations.

Internal Functionality

The class maintains a list _operations, which stores all the group operations defined. Each operation is an instance of the Operation class, which represents a LAMMPS group command along with its operands and operator.

Subindexing with Callable Syntax

The group class allows you to retrieve multiple operations by calling the instance with names or indices:

  • __call__(self, *keys): Returns a new group instance containing the specified operations.
  • Parameters:
    • *keys (str or int): One or more names or indices of operations to retrieve.
  • Returns:
    • group: A new group instance containing the specified operations.
  • Example: python subG = G('group1', 1, 'group3') # Retrieves 'group1', second operation, and 'group3'
  • Notes:
    • Indices are 0-based integers.
    • Duplicate operations are avoided in the new group instance.

Integration with Scripts

The group class integrates with dscript and pipescript classes to allow advanced script management:

  • dscript: A dynamic script management class that handles script lines with variable substitution and conditional execution.
  • pipescript: Manages a pipeline of scripts, allowing sequential execution and advanced variable space management.

Important Notes

  • Operator Overloading: The class overloads the +, -, and * operators for the Operation class to perform union, subtraction, and intersection respectively.
  • Flexible Group Creation: Groups can be created upon instantiation or added later using concise methods.
  • Variable Assignment and Group Definition: When defining groups based on variables, variable assignment and group creation are handled separately.
  • Subindexing with __call__: The __call__ method allows you to retrieve multiple operations and create subgroups.
  • Error Handling: The class includes robust error checking and provides informative error messages.
  • Lazy Evaluation: Group operations are stored and only evaluated when the evaluate method is called.
  • Name Management: The class ensures that group names are unique within the instance to prevent conflicts.

Conclusion

The group class simplifies the management of groups in LAMMPS simulations, allowing for clear and maintainable code when dealing with complex group operations. By providing high-level abstractions, operator overloading, subindexing capabilities, and integration with script management tools, it enhances productivity and reduces the potential for errors in simulation setup.

Initializes a new instance of the group class.

Parameters:

name (str, optional): Name for the group instance. If <code>None</code> or empty and <code>collection</code> is provided,
                      generates a name based on beadtypes (e.g., "1+2+3").
groups (dict, optional): Dictionary of group definitions to create upon initialization.
group_names (list or tuple, optional): List of group names to create empty groups upon initialization.
collection (list, tuple, or groupcollection, optional):
    - If a list or tuple, it should contain <code><a title="group.groupobject" href="#group.groupobject">groupobject</a></code> instances.
    - If a <code><a title="group.groupcollection" href="#group.groupcollection">groupcollection</a></code> object, it will extract the <code><a title="group.groupobject" href="#group.groupobject">groupobject</a></code> instances from it.
printflag (bool, optional): If <code>True</code>, enables printing of script generation.
verbose (bool, optional): If <code>True</code>, enables verbose output.

Raises:

TypeError:
    - If <code>groups</code> is not a dictionary.
    - If <code>group\_names</code> is not a list or tuple.
    - If <code>collection</code> is not a list, tuple, or <code><a title="group.groupcollection" href="#group.groupcollection">groupcollection</a></code> object.
    - If any item in <code>collection</code> (when it's a list or tuple) is not a <code><a title="group.groupobject" href="#group.groupobject">groupobject</a></code> instance.
Expand source code
class group:
    """
    A class for managing LAMMPS group operations and generating LAMMPS scripts.

    ### Overview

    The `group` class provides an object-oriented interface to define and manage
    groups of atoms in LAMMPS simulations. Groups in LAMMPS are collections of
    atoms that can be manipulated together, allowing users to apply fixes,
    compute properties, or perform operations on specific subsets of atoms.

    This class allows you to create, combine, and manipulate groups using
    algebraic operations (union, intersection, subtraction), and to generate
    the corresponding LAMMPS commands. It also provides methods to output the
    group commands as scripts, which can be integrated into LAMMPS input files.

    ### Key Features

    - **Create and Manage Groups**: Define new groups based on atom types,
      regions, IDs, or variables.
    - **Flexible Group Creation**: Create multiple groups at once and define groups
      using concise criteria through the `add_group_criteria` method.
    - **Algebraic Operations**: Combine groups using union (`+`), intersection (`*`),
      and subtraction (`-`) operations.
    - **Subindexing with Callable Syntax**: Retrieve multiple group operations
      by calling the `group` instance with names or indices.
    - **Script Generation**: Generate LAMMPS script lines for group definitions,
      which can be output as scripts or pipelines.
    - **Dynamic Evaluation**: Evaluate complex group expressions and store the
      resulting operations.
    - **Integration with Scripting Tools**: Convert group operations into
      `dscript` or `pipescript` objects for advanced script management.

    ### LAMMPS Context

    In LAMMPS (Large-scale Atomic/Molecular Massively Parallel Simulator),
    groups are fundamental for specifying subsets of atoms for applying
    operations like forces, fixes, and computes. The `group` command in LAMMPS
    allows users to define groups based on various criteria such as atom IDs,
    types, regions, and variables.

    This `group` class abstracts the complexity of managing group definitions
    and operations, providing a high-level interface to define and manipulate
    groups programmatically.


    ### Usage Examples

    **Creating Multiple Groups at Once**

    ```python
    # Create groups 'o1', 'o2', 'o3', 'o4' upon instantiation
    G = group(group_names=['o1', 'o2', 'o3', 'o4'])
    ```

    **Defining Groups Based on Criteria**

    ```python
    G = group()
    G.add_group_criteria('lower', type=[1])
    G.add_group_criteria('central', region='central_cyl')
    G.add_group_criteria('new_group', create=True)
    G.add_group_criteria('upper', clear=True)
    G.add_group_criteria('subtract_group', subtract=['group1', 'group2'])
    ```

    **Defining a Group Based on a Variable**

    ```python
    G = group()
    G.variable('myVar', 'x > 5')          # Assign a variable
    G.byvariable('myGroup', 'myVar')      # Define group based on the variable
    ```

    **Using `add_group_criteria` with Variable**

    ```python
    G.add_group_criteria('myGroup', variable={'name': 'myVar', 'expression': 'x > 5'})
    ```

    **Defining Groups Using a Dictionary**

    ```python
    group_definitions = {
        'group1': {'type': [1, 2]},
        'group2': {'region': 'my_region'},
        'group3': {'variable': {'name': 'var1', 'expression': 'x > 5'}},
        'group4': {'union': ['group1', 'group2']},
    }
    G.add_group_criteria(group_definitions)
    ```

    **Creating a Group from a Collection of `groupobject` Instances**

    ```python
    # Import the classes
    # from groupobject import groupobject
    # from group import group

    # Create groupobject instances
    o1 = groupobject(beadtype=1, group=["all", "A"], mass=1.0)
    o2 = groupobject(beadtype=2, group=["all", "B", "C"])
    o3 = groupobject(beadtype=3, group="C", mass=2.5)

    # Create a group instance with the collection
    G = group(name="mycollection", collection=[o1, o2, o3])
    # or
    G = group(name=None, collection=[o1, o2, o3])  # Name will be generated as "1+2+3"

    # Generate the LAMMPS script
    script_content = G.code()
    print(script_content)
    ```

    **Expected Output:**
    ```
    group all type 1 2 3
    group A type 1
    group B type 2
    group C type 2 3
    ```

    **Accessing Groups and Performing Operations**

    ```python
    # Access groups via attribute-style or item-style access
    op1 = G.group1
    op2 = G['group2']

    # Combine groups using algebraic operations
    complex_op = op1 + op2 - G.group3

    # Evaluate the operation and store the result
    G.evaluate('combined_group', complex_op)
    ```


    **Subindexing with Callable Syntax**

    ```python
    # Retrieve multiple operations by names or indices
    subG = G('group1', 2, 'group3')  # Retrieves 'group1', third operation, and 'group3'

    # Display the names of operations in subG
    operation_names = [op.name for op in subG._operations]
    print(operation_names)  # Output: ['group1', 'group3', 'group3']

    # Generate the LAMMPS script for the subgroup
    script_content = subG.code()
    print(script_content)
    ```

    **Generating LAMMPS Script**

    ```python
    script_content = G.code()
    print(script_content)
    ```

    ### Class Methods

    #### Initialization and Setup

    - `__init__(self, name=None, groups=None, group_names=None, printflag=False, verbose=True)`: Initializes a new `group` instance.
      - `name` (str): Optional name for the group instance.
      - `groups` (dict): Dictionary of group definitions to create upon initialization.
      - `group_names` (list): List of group names to create empty groups upon initialization.
      - `collection` (list or tuple of groupobject, optional): Collection of groupobject instances.

      - `printflag` (bool): If True, enables printing of script generation.
      - `verbose` (bool): If True, enables verbose output.

    - `create_groups(self, *group_names)`: Creates multiple new groups with the given names.

    - `add_group_criteria(self, *args, **kwargs)`: Adds group(s) based on criteria.
      - Supports two usages:
        - `add_group_criteria(group_name, **criteria)`: Adds a single group.
        - `add_group_criteria(group_definitions)`: Adds multiple groups from a dictionary.

    #### Group Creation Methods

    - `create(self, group_name)`: Creates a new empty group with the given name.
    - `bytype(self, group_name, type_values)`: Defines a group based on atom types.
    - `byid(self, group_name, id_values)`: Defines a group based on atom IDs.
    - `byregion(self, group_name, region_name)`: Defines a group based on a region.
    - `variable(self, variable_name, expression, style="atom")`: Assigns an expression to a LAMMPS variable.
    - `byvariable(self, group_name, variable_name)`: Defines a group based on a variable.
    - `clear(self, group_name)`: Clears an existing group.

    #### Algebraic Operations

    - `union(self, group_name, *groups)`: Performs a union of the specified groups.
    - `intersect(self, group_name, *groups)`: Performs an intersection of the specified groups.
    - `subtract(self, group_name, *groups)`: Subtracts groups from the first group.
    - `evaluate(self, group_name, group_op)`: Evaluates a group operation and stores the result.

    #### Access and Manipulation

    - `__getitem__(self, key)`: Allows accessing operations by name or index.
    - `__getattr__(self, operation_name)`: Enables attribute-style access to operations.
    - `__call__(self, *keys)`: Returns a new `group` instance containing specified operations.
      - **Parameters**:
        - `*keys` (str or int): One or more names or indices of operations to retrieve.
      - **Returns**:
        - `group`: A new `group` instance containing the specified operations.
      - **Example**:
        ```python
        subG = G('group1', 1, 'group3')  # Retrieves 'group1', second operation, and 'group3'
        ```
    - `list(self)`: Returns a list of all operation names.
    - `find(self, name)`: Finds the index of an operation based on its name.
    - `disp(self, name)`: Displays the content of an operation.
    - `delete(self, name)`: Deletes an operation by name.
    - `copy(self, source_name, new_name)`: Copies an existing operation to a new name.
    - `rename(self, old_name, new_name)`: Renames an existing operation.
    - `reindex(self, name, new_idx)`: Changes the index of an operation.

    #### Script Generation

    - `code(self)`: Returns the generated LAMMPS commands as a string.
    - `dscript(self, name=None)`: Generates a `dscript` object containing the group's commands.
    - `script(self, name=None)`: Generates a script object containing the group's commands.
    - `pipescript(self)`: Generates a `pipescript` object containing each group's command as a separate script in a pipeline.

    ### Operator Overloading

    - **Addition (`+`)**: Union of groups.
    - **Subtraction (`-`)**: Subtraction of groups.
    - **Multiplication (`*`)**: Intersection of groups.

    These operators are overloaded in the `Operation` class and can be used to combine group operations.

    ### Internal Functionality

    The class maintains a list `_operations`, which stores all the group operations
    defined. Each operation is an instance of the `Operation` class, which represents
    a LAMMPS group command along with its operands and operator.

    ### Subindexing with Callable Syntax

    The `group` class allows you to retrieve multiple operations by calling the
    instance with names or indices:

    - `__call__(self, *keys)`: Returns a new `group` instance containing the specified operations.
      - **Parameters**:
        - `*keys` (str or int): One or more names or indices of operations to retrieve.
      - **Returns**:
        - `group`: A new `group` instance containing the specified operations.
      - **Example**:
        ```python
        subG = G('group1', 1, 'group3')  # Retrieves 'group1', second operation, and 'group3'
        ```
      - **Notes**:
        - Indices are 0-based integers.
        - Duplicate operations are avoided in the new `group` instance.

    ### Integration with Scripts

    The `group` class integrates with `dscript` and `pipescript` classes to allow
    advanced script management:

    - **`dscript`**: A dynamic script management class that handles script lines
      with variable substitution and conditional execution.
    - **`pipescript`**: Manages a pipeline of scripts, allowing sequential execution
      and advanced variable space management.

    ### Important Notes

    - **Operator Overloading**: The class overloads the `+`, `-`, and `*` operators
      for the `Operation` class to perform union, subtraction, and intersection
      respectively.
    - **Flexible Group Creation**: Groups can be created upon instantiation or added later using concise methods.
    - **Variable Assignment and Group Definition**: When defining groups based on variables, variable assignment and group creation are handled separately.
    - **Subindexing with `__call__`**: The `__call__` method allows you to retrieve multiple operations and create subgroups.
    - **Error Handling**: The class includes robust error checking and provides informative error messages.
    - **Lazy Evaluation**: Group operations are stored and only evaluated when
      the `evaluate` method is called.
    - **Name Management**: The class ensures that group names are unique within
      the instance to prevent conflicts.

    ### Conclusion

    The `group` class simplifies the management of groups in LAMMPS simulations,
    allowing for clear and maintainable code when dealing with complex group
    operations. By providing high-level abstractions, operator overloading,
    subindexing capabilities, and integration with script management tools,
    it enhances productivity and reduces the potential for errors in simulation setup.
    """


    def __init__(self, name=None, groups=None, group_names=None, collection=None, printflag=False, verbose=True, verbosity=None):
        """
        Initializes a new instance of the group class.

        ### Parameters:
            name (str, optional): Name for the group instance. If `None` or empty and `collection` is provided,
                                  generates a name based on beadtypes (e.g., "1+2+3").
            groups (dict, optional): Dictionary of group definitions to create upon initialization.
            group_names (list or tuple, optional): List of group names to create empty groups upon initialization.
            collection (list, tuple, or groupcollection, optional):
                - If a list or tuple, it should contain `groupobject` instances.
                - If a `groupcollection` object, it will extract the `groupobject` instances from it.
            printflag (bool, optional): If `True`, enables printing of script generation.
            verbose (bool, optional): If `True`, enables verbose output.

        ### Raises:
            TypeError:
                - If `groups` is not a dictionary.
                - If `group_names` is not a list or tuple.
                - If `collection` is not a list, tuple, or `groupcollection` object.
                - If any item in `collection` (when it's a list or tuple) is not a `groupobject` instance.
        """
        self._in_construction = True  # Indicate that the object is under construction
        # Handle 'name' parameter
        if not name:
            name = generate_random_name()
        self._name = name

        # Initialize other attributes
        self._operations = []
        self.printflag = printflag
        self.verbose = verbose if verbosity is None else verbosity>0
        self.verbosity = verbosity
        self._in_construction = False  # Set the flag to indicate construction is finished
        # Handle 'groups' parameter
        if groups:
            if not isinstance(groups, dict):
                raise TypeError("Parameter 'groups' must be a dictionary.")
            self.add_group_criteria(groups)
        # Handle 'group_names' parameter
        if group_names:
            if not isinstance(group_names, (list, tuple)):
                raise TypeError("Parameter 'group_names' must be a list or tuple of group names.")
            self.create_groups(*group_names)
        # Handle 'collection' parameter
        if collection:
            if isinstance(collection, groupcollection):
                # Extract the list of groupobject instances from the groupcollection
                collection = collection.collection
            elif not isinstance(collection, (list, tuple)):
                raise TypeError("Parameter 'collection' must be a list, tuple, or `groupcollection` object.")
            # If collection is a list or tuple, validate its items
            if isinstance(collection, (list, tuple)):
                for obj in collection:
                    if not isinstance(obj, groupobject):
                        raise TypeError("All items in 'collection' must be `groupobject` instances.")
                self.generate_group_definitions_from_collection(collection)



    def create_groups(self, *group_names):
        for group_name in group_names:
            if not isinstance(group_name, str):
                raise TypeError(f"Group name must be a string, got {type(group_name)}")
            self.create(group_name)


    def __str__(self):
        return f'Group "{self._name}" with {len(self._operations)} operations\n'


    def format_cell_content(self, content, max_width):
        content = str(content) if content is not None else ''
        if len(content) > max_width:
            start = content[: (max_width - 5) // 2]
            end = content[-((max_width - 5) // 2):]
            content = f"{start} ... {end}"
        return content


    def __repr__(self):
        """
        Returns a neatly formatted table representation of the group's operations.

        Each row represents an operation in the group, displaying its index, name,
        operator, and operands. The table adjusts column widths dynamically and
        truncates content based on maximum column widths.

        ### Returns:
            str: A formatted string representation of the group operations.
        """
        # Define headers for the table
        headers = ["Idx", "Name", "Operator", "Operands"]
        col_max_widths = [5, 20, 20, 40]
        align = ["R", "C", "C", "L"]
        # Prepare rows by iterating over the operations
        rows = []
        for idx, op in enumerate(self._operations):
            rows.append([
                str(idx),                    # Index
                str(op.name),                # Name
                str(op.operator),            # Operator
                str(span(op.operands)),      # Operands
            ])
        # Use the helper to format the table
        table = format_table(headers, rows, col_max_widths, align)
        # Append the string representation of the group itself
        return f"{table}\n\n{str(self)}"


    def __len__(self):
        """ return the number of stored operations """
        return len(self._operations)

    def list(self):
        """ return the list of all operations """
        return [op.name for op in self._operations]


    def code(self):
        """
            Joins the `code` attributes of all stored `operation` objects with '\n'.
        """
        return '\n'.join([op.code for op in self._operations])

    def find(self, name):
        """Returns the index of an operation based on its name."""
        if '_operations' in self.__dict__:
            for i, op in enumerate(self._operations):
                if op.name == name:
                    return i
        return None

    def disp(self, name):
        """ display the content of an operation """
        idx = self.find(name)
        if idx is not None:
            return self._operations[idx].__repr__()
        else:
            return "Operation not found"

    def clearall(self):
        """ clear all operations """
        self._operations = []

    def delete(self, name):
        """
        Deletes one or more stored operations based on their names.

        Parameters:
        -----------
        name : str, list, or tuple
            The name(s) of the operation(s) to delete. If a list or tuple is provided,
            all specified operations will be deleted.

        Usage:
        ------
        G.delete('operation_name')
        G.delete(['operation1', 'operation2'])
        G.delete(('operation1', 'operation2'))

        Raises:
        -------
        ValueError
            If any of the specified operations are not found.
        """
        # Handle a single string, list, or tuple
        if isinstance(name, (list, tuple)):
            not_found = []
            for n in name:
                idx = self.find(n)
                if idx is not None:
                    del self._operations[idx]
                else:
                    not_found.append(n)
            # If any names were not found, raise an exception
            if not_found:
                raise ValueError(f"Operation(s) {', '.join(not_found)} not found.")
        elif isinstance(name, str):
            idx = self.find(name)
            if idx is not None:
                del self._operations[idx]
            else:
                raise ValueError(f"Operation {name} not found.")
        else:
            raise TypeError("The 'name' parameter must be a string, list, or tuple.")

    def copy(self, source_name, new_name):
        """
        Copies a stored operation to a new operation with a different name.

        Parameters:
        source_name: str
            Name of the source operation to copy
        new_name: str
            Name of the new operation

        Usage:
        G.copy('source_operation', 'new_operation')
        """
        idx = self.find(source_name)
        if idx is not None:
            copied_operation = self._operations[idx].clone()
            copied_operation.name = new_name
            self.add_operation(copied_operation)
        else:
            raise ValueError(f"Operation {source_name} not found.")

    def rename(self, old_name, new_name):
        """
        Rename a stored operation.

        Parameters:
        old_name: str
            Current name of the operation
        new_name: str
            New name to assign to the operation

        Usage:
        G.rename('old_operation', 'new_operation')
        """
        idx = self.find(old_name)
        if idx is not None:
            if new_name == old_name:
                raise ValueError("The new name should be different from the previous one.")
            elif new_name in self.list():
                raise ValueError("Operation name must be unique.")
            self._operations[idx].name = new_name
        else:
            raise ValueError(f"Operation '{old_name}' not found.")

    def reindex(self, name, new_idx):
        """
        Change the index of a stored operation.

        Parameters:
        name: str
            Name of the operation to reindex
        new_idx: int
            New index for the operation

        Usage:
        G.reindex('operation_name', 2)
        """
        idx = self.find(name)
        if idx is not None and 0 <= new_idx < len(self._operations):
            op = self._operations.pop(idx)
            self._operations.insert(new_idx, op)
        else:
            raise ValueError(f"Operation '{name}' not found or new index {new_idx} out of range.")

    # ------------- indexing and attribute overloading

    def __getitem__(self, key):
        """
            Enable shorthand for G.operations[G.find(operation_name)] using G[operation_name],
            or accessing operation by index using G[index].
        """
        if isinstance(key, str):
            idx = self.find(key)
            if idx is not None:
                return self._operations[idx]
            else:
                raise KeyError(f"Operation '{key}' not found.")
        elif isinstance(key, int):
            if -len(self._operations) <= key < len(self._operations):
                return self._operations[key]
            else:
                raise IndexError("Operation index out of range.")
        else:
            raise TypeError("Key must be an operation name (string) or index (integer).")


    def __getattr__(self, name):
        """
        Allows accessing operations via attribute-style notation.
        If the attribute is one of the core attributes, returns it directly.
        For other attributes, searches for an operation with a matching name
        in the _operations list.

        Parameters:
        -----------
        name : str
            The name of the attribute or operation to access.

        Returns:
        --------
        The value of the attribute if it's a core attribute, or the operation
        associated with the specified name if found in _operations.

        Raises:
        -------
        AttributeError
            If the attribute or operation is not found.
        """
        # Handle core attributes directly
        if name in {'_name', '_operations', 'printflag', 'verbose'}:
            # Use object.__getattribute__ to avoid recursion
            return object.__getattribute__(self, name)
        # Search for the operation in _operations
        elif '_operations' in self.__dict__:
            for op in self._operations:
                if op.name == name:
                    return op
        # If not found, raise an AttributeError
        raise AttributeError(f"Attribute or operation '{name}' not found.")


    def __setattr__(self, name, value):
        """
        Allows deletion of an operation via 'G.operation_name = []' after construction.
        During construction, attributes are set normally.
        """
        if getattr(self, '_in_construction', True):
            # During construction, set attributes normally
            super().__setattr__(name, value)
        else:
            # After construction
            if isinstance(value, list) and len(value) == 0:
                # Handle deletion syntax
                idx = self.find(name)
                if idx is not None:
                    del self._operations[idx]
                else:
                    raise AttributeError(f"Operation '{name}' not found for deletion.")
            else:
                # Set attribute normally
                super().__setattr__(name, value)


    def _get_subobject(self, key):
        """
        Retrieves a subobject based on the provided key.

        Parameters:
        -----------
        key : str or int
            The key used to retrieve the subobject.

        Returns:
        --------
        Operation
            The operation corresponding to the key.

        Raises:
        -------
        KeyError
            If the key is not found.
        IndexError
            If the index is out of range.
        TypeError
            If the key is not a string or integer.
        """
        if isinstance(key, str):
            # If key is a string, treat it as a group name
            for operation in self._operations:
                if operation.name == key:
                    return operation
            raise KeyError(f"No operation found with name '{key}'.")
        elif isinstance(key, int):
            # If key is an integer, treat it as an index (0-based)
            if 0 <= key < len(self._operations):
                return self._operations[key]
            else:
                raise IndexError(f"Index {key} is out of range.")
        else:
            raise TypeError("Key must be a string or integer.")


    def __call__(self, *keys):
        """
        Allows subindexing of the group object using callable syntax with multiple keys.

        Parameters:
        -----------
        *keys : str or int
            One or more keys used to retrieve subobjects or perform subindexing.

        Returns:
        --------
        group
            A new group instance containing the specified operations.

        Example:
        --------
        subG = G('a', 1, 'c')  # Retrieves operations 'a', second operation, and 'c'
        """
        selected_operations = []
        for key in keys:
            operation = self._get_subobject(key)
            selected_operations.append(operation)

        # Create a new group instance with the selected operations
        new_group = group(name=f"{self._name}_subgroup", printflag=self.printflag, verbose=self.verbose)
        new_group._operations = selected_operations
        return new_group


    def operation_exists(self,operation_name):
        """
            Returns true if "operation_name" exists
            To be used by Operation, not by end-user, which should prefer find()
        """
        return any(op.name == operation_name for op in self._operations)

    def get_by_name(self,operation_name):
        """
            Returns the operation matching "operation_name"
            Usage: group.get_by_name("operation_name")
            To be used by Operation, not by end-user, which should prefer getattr()
        """
        for op in self._operations:
            if op.name == operation_name:
                return op
        raise AttributeError(f"Operation with name '{operation_name}' not found.")

    def add_operation(self,operation):
        """ add an operation """
        if operation.name in self.list():
            raise ValueError(f"The operation '{operation.name}' already exists.")
        else:
            self._operations.append(operation)

    # --------- LAMMPS methods

    def variable(self, variable_name, expression, style="atom"):
        """
        Assigns an expression to a LAMMPS variable.

        Parameters:
        - variable_name (str): The name of the variable to be assigned.
        - expression (str): The expression to assign to the variable.
        - style (str): The type of variable (default is "atom").
        """
        if not isinstance(variable_name, str):
            raise TypeError(f"Variable name must be a string, got {type(variable_name)}")
        if not isinstance(expression, str):
            raise TypeError(f"Expression must be a string, got {type(expression)}")
        if not isinstance(style, str):
            raise TypeError(f"Style must be a string, got {type(style)}")

        lammps_code = f"variable {variable_name} {style} \"{expression}\""
        op = Operation("variable", [variable_name, expression], code=lammps_code)
        self.add_operation(op)


    def byvariable(self, group_name, variable_name):
        """
        Sets a group of atoms based on a variable.

        Parameters:
        - group_name: str, the name of the group.
        - variable_name: str, the name of the variable to define the group.
        """
        if not isinstance(group_name, str):
            raise TypeError(f"Group name must be a string, got {type(group_name)}")
        if not isinstance(variable_name, str):
            raise TypeError(f"Variable name must be a string, got {type(variable_name)}")

        lammps_code = f"group {group_name} variable {variable_name}"
        op = Operation("byvariable", [variable_name], name=group_name, code=lammps_code)
        self.add_operation(op)


    def byregion(self, group_name, region_name):
        """
            set a group of atoms based on a regionID
            G.region(group_name,regionID)
        """
        lammps_code = f"group {group_name} region {region_name}"
        criteria = {"region": region_name}
        op = Operation("byregion", [region_name], name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)

    def bytype(self,  group_name, type_values):
        """
            select atoms by type and store them in group
            G.type(group_name,type_values)
        """
        if not isinstance(type_values, (list, tuple)):
            type_values = [type_values]
        lammps_code = f"group {group_name} type {span(type_values)}"
        criteria = {"type": type_values}
        op = Operation("bytype", type_values, name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)

    def byid(self, group_name, id_values):
        """
            select atoms by id and store them in group
            G.id(group_name,id_values)
        """
        if not isinstance(id_values, (list, tuple)):
            id_values = [id_values]
        lammps_code = f"group {group_name} id {span(id_values)}"
        criteria = {"id": id_values}
        op = Operation("byid", id_values, name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)

    def create(self, group_name):
        """
            create group
            G.create(group_name)
        """
        lammps_code = f"group {group_name} clear"
        criteria = {"clear": True}
        op = Operation("create", [], name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)

    def clear(self, group_name):
        """
            clear group
            G.clear(group_name)
        """
        lammps_code = f"group {group} clear"
        criteria = {"clear": True}
        op = Operation("clear", [], name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)


    def union(self,group_name, *groups):
        """
        Union group1, group2, group3 and store the result in group_name.
        Example usage:
        group.union(group_name, group1, group2, group3,...)
        """
        lammps_code = f"group {group_name} union {span(groups)}"
        criteria = {"union": groups}
        op = Operation("union", groups, name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)


    def intersect(self,group_name, *groups):
        """
        Intersect group1, group2, group3 and store the result in group_name.
        Example usage:
        group.intersect(group_name, group1, group2, group3,...)
        """
        lammps_code = f"group {group_name} intersect {span(groups)}"
        criteria = {"intersect": groups}
        op = Operation("intersect", groups, name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)


    def subtract(self,group_name, *groups):
        """
        Subtract group2, group3 from group1 and store the result in group_name.
        Example usage:
        group.subtract(group_name, group1, group2, group3,...)
        """
        lammps_code = f"group {group_name} subtract {span(groups)}"
        criteria = {"subtract": groups}
        op = Operation("subtract", groups, name=group_name, code=lammps_code, criteria=criteria)
        self.add_operation(op)


    def evaluate(self, group_name, group_op):
        """
        Evaluates the operation and stores the result in a new group.
        Expressions could combine +, - and * like o1+o2+o3-o4+o5+o6

        Parameters:
        -----------
        groupname : str
            The name of the group that will store the result.
        group_op : Operation
            The operation to evaluate.
        """
        if not isinstance(group_op, Operation):
            raise TypeError("Expected an instance of Operation.")

        if group_name in self.list():
            raise ValueError(f"The operation '{group_name}' already exists.")

        # If the operation is already finalized, no need to evaluate
        if group_op.isfinalized():
            if group_op.name != group_name:
                # If names differ, create a copy with the new name
                self.copy(group_op.name, group_name)
            return

        # Recursively evaluate operands
        operand_names = []
        for op in group_op.operands:
            if isinstance(op, Operation):
                if op.isfinalized():
                    # Use existing finalized operation name
                    operand_names.append(op.name)
                else:
                    # Generate a unique name if the operation doesn't have one
                    if not op.name:
                        op.name = op.generate_hashname()
                    # Recursively evaluate the operand operation
                    self.evaluate(op.name, op)
                    operand_names.append(op.name)
            else:
                operand_names.append(str(op))

        # Call the appropriate method based on the operator
        if group_op.operator == '+':
            self.union(group_name, *operand_names)
            group_op.operator = 'union'
        elif group_op.operator == '-':
            self.subtract(group_name, *operand_names)
            group_op.operator = 'subtract'
        elif group_op.operator == '*':
            self.intersect(group_name, *operand_names)
            group_op.operator = 'intersect'
        else:
            raise ValueError(f"Unknown operator: {group_op.operator}")

        # Update the operation
        group_op.name = group_name
        # Get the last added operation
        finalized_op = self._operations[-1]
        group_op.code = finalized_op.code
        group_op.operands = operand_names

        # Add the operation to the group's _operations if not already added
        if group_op.name not in self.list():
            self._operations.append(group_op)


    def add_group_criteria(self, *args, **kwargs):
        """
        Adds group(s) using existing methods based on key-value pairs.

        Supports two usages:
        1. add_group_criteria(group_name, **criteria)
        2. add_group_criteria(group_definitions)

        Parameters:
        - group_name (str): The name of the group.
        - **criteria: Criteria for group creation.

        OR

        - group_definitions (dict): A dictionary where keys are group names and values are criteria dictionaries.

        Raises:
        - TypeError: If arguments are invalid.

        Usage:
        - G.add_group_criteria('group_name', type=[1,2])
        - G.add_group_criteria({'group1': {'type': [1]}, 'group2': {'region': 'regionID'}})
        """
        if len(args) == 1 and isinstance(args[0], dict):
            # Called with group_definitions dict
            group_definitions = args[0]
            for group_name, criteria in group_definitions.items():
                self.add_group_criteria_single(group_name, **criteria)
        elif len(args) == 1 and isinstance(args[0], str):
            # Called with group_name and criteria
            group_name = args[0]
            if not kwargs:
                raise ValueError(f"No criteria provided for group '{group_name}'.")
            self.add_group_criteria_single(group_name, **kwargs)
        else:
            raise TypeError("Invalid arguments. Use add_group_criteria(group_name, **criteria) or add_group_criteria(group_definitions).")


    def add_group_criteria_single(self, group_name, **criteria):
        """
        Adds a single group based on criteria.

        Parameters:
        - group_name (str): The name of the group.
        - **criteria: Criteria for group creation.

        Raises:
        - TypeError: If group_name is not a string.
        - ValueError: If no valid criteria are provided or if criteria are invalid.


        Example (advanced):
            G = group()
            group_definitions = {
                'myGroup': {
                    'variable': {
                        'name': 'myVar',
                        'expression': 'x > 5',
                        'style': 'atom'
                    }
                }
            }
            G.add_group_criteria(group_definitions)
            print(G.code())

        Expected output
            variable myVar atom "x > 5"
            group myGroup variable myVar
        """
        if not isinstance(group_name, str):
            raise TypeError(f"Group name must be a string, got {type(group_name)}")

        if not criteria:
            raise ValueError(f"No criteria provided for group '{group_name}'.")

        if "type" in criteria:
            type_values = criteria["type"]
            if not isinstance(type_values, (list, tuple, int)):
                raise TypeError("Type values must be an integer or a list/tuple of integers.")
            self.bytype(group_name, type_values)

        elif "region" in criteria:
            region_name = criteria["region"]
            if not isinstance(region_name, str):
                raise TypeError("Region name must be a string.")
            self.byregion(group_name, region_name)

        elif "id" in criteria:
            id_values = criteria["id"]
            if not isinstance(id_values, (list, tuple, int)):
                raise TypeError("ID values must be an integer or a list/tuple of integers.")
            self.byid(group_name, id_values)

        elif "variable" in criteria:
            var_info = criteria["variable"]
            if not isinstance(var_info, dict):
                raise TypeError("Variable criteria must be a dictionary.")
            required_keys = {'name', 'expression'}
            if not required_keys.issubset(var_info.keys()):
                missing = required_keys - var_info.keys()
                raise ValueError(f"Variable criteria missing keys: {missing}")
            var_name = var_info['name']
            expression = var_info['expression']
            style = var_info.get('style', 'atom')

            # First, assign the variable
            self.variable(var_name, expression, style)

            # Then, create the group based on the variable
            self.byvariable(group_name, var_name)

        elif "union" in criteria:
            groups = criteria["union"]
            if not isinstance(groups, (list, tuple)):
                raise TypeError("Union groups must be a list or tuple of group names.")
            self.union(group_name, *groups)

        elif "intersect" in criteria:
            groups = criteria["intersect"]
            if not isinstance(groups, (list, tuple)):
                raise TypeError("Intersect groups must be a list or tuple of group names.")
            self.intersect(group_name, *groups)

        elif "subtract" in criteria:
            groups = criteria["subtract"]
            if not isinstance(groups, (list, tuple)):
                raise TypeError("Subtract groups must be a list or tuple of group names.")
            self.subtract(group_name, *groups)

        elif "create" in criteria and criteria["create"]:
            self.create(group_name)

        elif "clear" in criteria and criteria["clear"]:
            self.clear(group_name)

        else:
            raise ValueError(f"No valid criterion provided for group '{group_name}'.")




    def get_group_criteria(self, group_name):
        """
        Retrieve the criteria that define a group. Handles group_name as a string or number.

        Parameters:
        - group_name: str or int, the name or number of the group.

        Returns:
        - dict or str: The criteria used to define the group, or a message if defined by multiple criteria.

        Raises:
        - ValueError: If the group does not exist.
        """
        # Check if group_name exists in _operations
        if group_name not in self._operations:
            raise ValueError(f"Group '{group_name}' does not exist.")

        # Retrieve all operations related to the group directly from _operations
        operations = self._operations[group_name]

        if not operations:
            raise ValueError(f"No operations found for group '{group_name}'.")

        criteria = {}
        for op in operations:
            if op.criteria:
                criteria.update(op.criteria)

        # If multiple criteria, return a message
        if len(criteria) > 1:
            return f"Group '{group_name}' is defined by multiple criteria: {criteria}"

        return criteria


    def generate_group_definitions_from_collection(self,collection):
        """
        Generates group definitions based on the collection of groupobject instances.

        This method populates the groups based on beadtypes and associated group names.
        """
        group_defs = {}
        for obj in collection:
            for group_name in obj.group:
                if group_name not in group_defs:
                    group_defs[group_name] = {'type': []}
                if obj.beadtype not in group_defs[group_name]['type']:
                    group_defs[group_name]['type'].append(obj.beadtype)
        # Now, add these group definitions
        self.add_group_criteria(group_defs)


    def dscript(self, name=None, printflag=None, verbose=None, verbosity=None):
        """
        Generates a dscript object containing the group's LAMMPS commands.

        Parameters:
        - name (str): Optional name for the script object.
        - printflag (bool, default=False): print on the current console if True
        - verbose (bool, default=True): keep comments if True

        Returns:
        - dscript: A dscript object containing the group's code.
        """
        if name is None:
            name = self._name
        printflag = self.printflag if printflag is None else printflag
        verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
        verbosity = 0 if not verbose else verbosity
        # Create a new dscript object
        dscript_obj = dscript(name=name,printflag=printflag, verbose=verbose, verbosity=verbosity)
        # Add each line of the group's code to the script object
        for idx, op in enumerate(self._operations):
            # Use the index as the key for the script line
           dscript_obj[idx] = op.code
        return dscript_obj


    def script(self, name=None, printflag=None, verbose=None, verbosity=None):
        """
        Generates a script object containing the group's LAMMPS commands.

        Parameters:
        - name (str): Optional name for the script object.
        - printflag (bool, default=False): print on the current console if True
        - verbose (bool, default=True): keep comments if True

        Returns:
        - script: A script object containing the group's code.
        """
        if name is None:
            name = self._name
        printflag = self.printflag if printflag is None else printflag
        verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
        verbosity = 0 if not verbose else verbosity
        script_obj = self.dscript(name=name,printflag=printflag, verbose=verbose, verbosity=verbosity).script(printflag=printflag, verbose=verbose, verbosity=verbosity)
        return script_obj


    def pipescript(self, printflag=None, verbose=None, verbosity=None):
        """
        Generates a pipescript object containing the group's LAMMPS commands.

        Parameters:
        - printflag (bool, default=False): print on the current console if True
        - verbose (bool, default=True): keep comments if True

        Returns:
        - pipescript: A pipescript object containing the group's code lines as individual scripts in the pipeline.
        """
        printflag = self.printflag if printflag is None else printflag
        verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
        verbosity = 0 if not verbose else verbosity
        # Create a list to hold script objects
        script_list = []

        # For each operation, create a script object and add it to the list
        for op in self._operations:
            # Create a dscript object with the code line
            dscript_obj = dscript(printflag=printflag, verbose=verbose, verbosity=verbosity)
            dscript_obj["dummy"] = op.code

            # Convert the dscript to a script object
            script_obj = dscript_obj.script(printflag=printflag, verbose=verbose, verbosity=verbosity)

            # Add the script object to the list
            script_list.append(script_obj)

        # Use the static method 'join' to create a pipescript from the list
        if script_list:
            pipe_obj = pipescript.join(script_list)
        else:
            pipe_obj = pipescript()

        return pipe_obj

    # Note that it was not the original intent to copy scripts
    def __copy__(self):
        """ copy method """
        cls = self.__class__
        copie = cls.__new__(cls)
        copie.__dict__.update(self.__dict__)
        return copie

    def __deepcopy__(self, memo):
        """ deep copy method """
        cls = self.__class__
        copie = cls.__new__(cls)
        memo[id(self)] = copie
        for k, v in self.__dict__.items():
            setattr(copie, k, copy.deepcopy(v, memo))
        return copie


    def count(self,name=None, selection: Optional[List[str]] = None) -> 'dscript':
        """
        Generates DSCRIPT counters for specified groups with LAMMPS variable definitions and print commands.

        The method retrieves the list of group names using `self.list()`. If `selection` is provided, it
        filters the groups to include only those specified. It then creates a variable for each selected
        group that counts the number of atoms in that group and generates corresponding print commands.
        The commands are encapsulated within a `dscript` object for execution.

        ### Parameters:
            selection (list of str, optional):
                - List of group names to be numbered.
                - If `None`, all groups in the collection are numbered.

        ### Returns:
            dscript: A `dscript` object containing the variable definitions and print commands, formatted as follows:
                     ```
                     variable n_lower equal "count(lower)"
                     variable n_middle equal "count(middle)"
                     variable n_upper equal "count(upper)"

                     print "Number of atoms in lower: ${n_lower}"
                     print "Number of atoms in middle: ${n_middle}"
                     print "Number of atoms in upper: ${n_upper}"
                     ```
                     The `variables` attribute holds the variable definitions, and the `printvariables` attribute
                     holds the print commands, each separated by a newline.

        ### Raises:
            ValueError:
                - If any group specified in `selection` does not exist in the collection.
                - If `selection` contains duplicate group names.
            TypeError:
                - If `selection` is not a list of strings.

        ### Example:
            ```python
            # Create groupobject instances
            g1 = groupobject(beadtype=1,group = 'lower')
            g2 = groupobject(beadtype=2,group = 'middle')
            g3 = groupobject(beadtype=3,group = 'upper')

            # Initialize a group with the groupobjects
            G = group(name="1+2+3",collection=(g1,g2,g3)) or collection=g1+g2+g3

            # add other groups
            G.evaluate("all", G.lower + G.middle + G.upper)
            G.evaluate("external", G.all - G.middle)

            # Generate number commands for all groups
            N = G.count(selection=["all","lower","middle","lower"])
            print(N.do())
            ```

            **Output:**
            ```
            variable n_all equal "count(all)"
            variable n_lower equal "count(lower)"
            variable n_middle equal "count(middle)"
            variable n_upper equal "count(upper)"
            print "Number of atoms in all: ${n_all}"
            print "Number of atoms in lower: ${n_lower}"
            print "Number of atoms in middle: ${n_middle}"
            print "Number of atoms in upper: ${n_upper}"
            ```
        """
        # Retrieve the list of all group names
        all_group_names = self.list()

        # If selection is provided, validate it
        if selection is not None:
            if not isinstance(selection, (list, tuple)):
                raise TypeError("Parameter 'selection' must be a list of group names.")
            if not all(isinstance(gitem, str) for gitem in selection):
                raise TypeError("All items in 'selection' must be strings representing group names.")
            if len(selection) != len(set(selection)):
                raise ValueError("Duplicate group names found in 'selection'. Each group should be unique.")
            # Check that all selected groups exist
            missing_groups = set(selection) - set(all_group_names)
            if missing_groups:
                raise ValueError(f"The following groups are not present in the collection: {', '.join(missing_groups)}")
            # Use the selected groups
            target_groups = selection
        else:
            # If no selection, target all groups
            target_groups = all_group_names

        # Initialize lists to hold variable definitions and print commands
        variable_definitions = []
        print_commands = []
        for gitem in target_groups:
            # Validate group name
            if not gitem:
                raise ValueError("Group names must be non-empty strings.")
            # Create a valid variable name by replacing any non-alphanumeric characters with underscores
            variable_name = f"n_{gitem}".replace(" ", "_").replace("-", "_")
            # Define the variable to count the number of atoms in the group
            variable_definitions.append(f'variable {variable_name} equal "count({gitem})"')
            # Define the corresponding print command
            print_commands.append(f'print "Number of atoms in {gitem}: ${{{variable_name}}}"')
        # Create a dscript object with the generated commands
        idD = f"<dscript:group:{self._name}:count>"
        D = dscript(
            name=idD if name is None else name,
            description=f"{idD} for group counts"
        )
        D.variables = "\n".join(variable_definitions)
        D.printvariables = "\n".join(print_commands)
        D.set_all_variables()
        return D

Methods

def add_group_criteria(self, *args, **kwargs)

Adds group(s) using existing methods based on key-value pairs.

Supports two usages: 1. add_group_criteria(group_name, **criteria) 2. add_group_criteria(group_definitions)

Parameters: - group_name (str): The name of the group. - **criteria: Criteria for group creation.

OR

  • group_definitions (dict): A dictionary where keys are group names and values are criteria dictionaries.

Raises: - TypeError: If arguments are invalid.

Usage: - G.add_group_criteria('group_name', type=[1,2]) - G.add_group_criteria({'group1': {'type': [1]}, 'group2': {'region': 'regionID'}})

Expand source code
def add_group_criteria(self, *args, **kwargs):
    """
    Adds group(s) using existing methods based on key-value pairs.

    Supports two usages:
    1. add_group_criteria(group_name, **criteria)
    2. add_group_criteria(group_definitions)

    Parameters:
    - group_name (str): The name of the group.
    - **criteria: Criteria for group creation.

    OR

    - group_definitions (dict): A dictionary where keys are group names and values are criteria dictionaries.

    Raises:
    - TypeError: If arguments are invalid.

    Usage:
    - G.add_group_criteria('group_name', type=[1,2])
    - G.add_group_criteria({'group1': {'type': [1]}, 'group2': {'region': 'regionID'}})
    """
    if len(args) == 1 and isinstance(args[0], dict):
        # Called with group_definitions dict
        group_definitions = args[0]
        for group_name, criteria in group_definitions.items():
            self.add_group_criteria_single(group_name, **criteria)
    elif len(args) == 1 and isinstance(args[0], str):
        # Called with group_name and criteria
        group_name = args[0]
        if not kwargs:
            raise ValueError(f"No criteria provided for group '{group_name}'.")
        self.add_group_criteria_single(group_name, **kwargs)
    else:
        raise TypeError("Invalid arguments. Use add_group_criteria(group_name, **criteria) or add_group_criteria(group_definitions).")
def add_group_criteria_single(self, group_name, **criteria)

Adds a single group based on criteria.

Parameters: - group_name (str): The name of the group. - **criteria: Criteria for group creation.

Raises: - TypeError: If group_name is not a string. - ValueError: If no valid criteria are provided or if criteria are invalid.

Example (advanced): G = group() group_definitions = { 'myGroup': { 'variable': { 'name': 'myVar', 'expression': 'x > 5', 'style': 'atom' } } } G.add_group_criteria(group_definitions) print(G.code())

Expected output variable myVar atom "x > 5" group myGroup variable myVar

Expand source code
def add_group_criteria_single(self, group_name, **criteria):
    """
    Adds a single group based on criteria.

    Parameters:
    - group_name (str): The name of the group.
    - **criteria: Criteria for group creation.

    Raises:
    - TypeError: If group_name is not a string.
    - ValueError: If no valid criteria are provided or if criteria are invalid.


    Example (advanced):
        G = group()
        group_definitions = {
            'myGroup': {
                'variable': {
                    'name': 'myVar',
                    'expression': 'x > 5',
                    'style': 'atom'
                }
            }
        }
        G.add_group_criteria(group_definitions)
        print(G.code())

    Expected output
        variable myVar atom "x > 5"
        group myGroup variable myVar
    """
    if not isinstance(group_name, str):
        raise TypeError(f"Group name must be a string, got {type(group_name)}")

    if not criteria:
        raise ValueError(f"No criteria provided for group '{group_name}'.")

    if "type" in criteria:
        type_values = criteria["type"]
        if not isinstance(type_values, (list, tuple, int)):
            raise TypeError("Type values must be an integer or a list/tuple of integers.")
        self.bytype(group_name, type_values)

    elif "region" in criteria:
        region_name = criteria["region"]
        if not isinstance(region_name, str):
            raise TypeError("Region name must be a string.")
        self.byregion(group_name, region_name)

    elif "id" in criteria:
        id_values = criteria["id"]
        if not isinstance(id_values, (list, tuple, int)):
            raise TypeError("ID values must be an integer or a list/tuple of integers.")
        self.byid(group_name, id_values)

    elif "variable" in criteria:
        var_info = criteria["variable"]
        if not isinstance(var_info, dict):
            raise TypeError("Variable criteria must be a dictionary.")
        required_keys = {'name', 'expression'}
        if not required_keys.issubset(var_info.keys()):
            missing = required_keys - var_info.keys()
            raise ValueError(f"Variable criteria missing keys: {missing}")
        var_name = var_info['name']
        expression = var_info['expression']
        style = var_info.get('style', 'atom')

        # First, assign the variable
        self.variable(var_name, expression, style)

        # Then, create the group based on the variable
        self.byvariable(group_name, var_name)

    elif "union" in criteria:
        groups = criteria["union"]
        if not isinstance(groups, (list, tuple)):
            raise TypeError("Union groups must be a list or tuple of group names.")
        self.union(group_name, *groups)

    elif "intersect" in criteria:
        groups = criteria["intersect"]
        if not isinstance(groups, (list, tuple)):
            raise TypeError("Intersect groups must be a list or tuple of group names.")
        self.intersect(group_name, *groups)

    elif "subtract" in criteria:
        groups = criteria["subtract"]
        if not isinstance(groups, (list, tuple)):
            raise TypeError("Subtract groups must be a list or tuple of group names.")
        self.subtract(group_name, *groups)

    elif "create" in criteria and criteria["create"]:
        self.create(group_name)

    elif "clear" in criteria and criteria["clear"]:
        self.clear(group_name)

    else:
        raise ValueError(f"No valid criterion provided for group '{group_name}'.")
def add_operation(self, operation)

add an operation

Expand source code
def add_operation(self,operation):
    """ add an operation """
    if operation.name in self.list():
        raise ValueError(f"The operation '{operation.name}' already exists.")
    else:
        self._operations.append(operation)
def byid(self, group_name, id_values)

select atoms by id and store them in group G.id(group_name,id_values)

Expand source code
def byid(self, group_name, id_values):
    """
        select atoms by id and store them in group
        G.id(group_name,id_values)
    """
    if not isinstance(id_values, (list, tuple)):
        id_values = [id_values]
    lammps_code = f"group {group_name} id {span(id_values)}"
    criteria = {"id": id_values}
    op = Operation("byid", id_values, name=group_name, code=lammps_code, criteria=criteria)
    self.add_operation(op)
def byregion(self, group_name, region_name)

set a group of atoms based on a regionID G.region(group_name,regionID)

Expand source code
def byregion(self, group_name, region_name):
    """
        set a group of atoms based on a regionID
        G.region(group_name,regionID)
    """
    lammps_code = f"group {group_name} region {region_name}"
    criteria = {"region": region_name}
    op = Operation("byregion", [region_name], name=group_name, code=lammps_code, criteria=criteria)
    self.add_operation(op)
def bytype(self, group_name, type_values)

select atoms by type and store them in group G.type(group_name,type_values)

Expand source code
def bytype(self,  group_name, type_values):
    """
        select atoms by type and store them in group
        G.type(group_name,type_values)
    """
    if not isinstance(type_values, (list, tuple)):
        type_values = [type_values]
    lammps_code = f"group {group_name} type {span(type_values)}"
    criteria = {"type": type_values}
    op = Operation("bytype", type_values, name=group_name, code=lammps_code, criteria=criteria)
    self.add_operation(op)
def byvariable(self, group_name, variable_name)

Sets a group of atoms based on a variable.

Parameters: - group_name: str, the name of the group. - variable_name: str, the name of the variable to define the group.

Expand source code
def byvariable(self, group_name, variable_name):
    """
    Sets a group of atoms based on a variable.

    Parameters:
    - group_name: str, the name of the group.
    - variable_name: str, the name of the variable to define the group.
    """
    if not isinstance(group_name, str):
        raise TypeError(f"Group name must be a string, got {type(group_name)}")
    if not isinstance(variable_name, str):
        raise TypeError(f"Variable name must be a string, got {type(variable_name)}")

    lammps_code = f"group {group_name} variable {variable_name}"
    op = Operation("byvariable", [variable_name], name=group_name, code=lammps_code)
    self.add_operation(op)
def clear(self, group_name)

clear group G.clear(group_name)

Expand source code
def clear(self, group_name):
    """
        clear group
        G.clear(group_name)
    """
    lammps_code = f"group {group} clear"
    criteria = {"clear": True}
    op = Operation("clear", [], name=group_name, code=lammps_code, criteria=criteria)
    self.add_operation(op)
def clearall(self)

clear all operations

Expand source code
def clearall(self):
    """ clear all operations """
    self._operations = []
def code(self)

Joins the code attributes of all stored operation objects with ' '.

Expand source code
def code(self):
    """
        Joins the `code` attributes of all stored `operation` objects with '\n'.
    """
    return '\n'.join([op.code for op in self._operations])
def copy(self, source_name, new_name)

Copies a stored operation to a new operation with a different name.

Parameters: source_name: str Name of the source operation to copy new_name: str Name of the new operation

Usage: G.copy('source_operation', 'new_operation')

Expand source code
def copy(self, source_name, new_name):
    """
    Copies a stored operation to a new operation with a different name.

    Parameters:
    source_name: str
        Name of the source operation to copy
    new_name: str
        Name of the new operation

    Usage:
    G.copy('source_operation', 'new_operation')
    """
    idx = self.find(source_name)
    if idx is not None:
        copied_operation = self._operations[idx].clone()
        copied_operation.name = new_name
        self.add_operation(copied_operation)
    else:
        raise ValueError(f"Operation {source_name} not found.")
def count(self, name=None, selection: Optional[List[str]] = None) ‑> pizza.dscript.dscript

Generates DSCRIPT counters for specified groups with LAMMPS variable definitions and print commands.

The method retrieves the list of group names using self.list(). If selection is provided, it filters the groups to include only those specified. It then creates a variable for each selected group that counts the number of atoms in that group and generates corresponding print commands. The commands are encapsulated within a dscript object for execution.

Parameters:

selection (list of str, optional):
    - List of group names to be numbered.
    - If <code>None</code>, all groups in the collection are numbered.

Returns:

dscript: A <code><a title="group.dscript" href="#group.dscript">dscript</a></code> object containing the variable definitions and print commands, formatted as follows:
         ```
         variable n_lower equal "count(lower)"
         variable n_middle equal "count(middle)"
         variable n_upper equal "count(upper)"

         print "Number of atoms in lower: ${n_lower}"
         print "Number of atoms in middle: ${n_middle}"
         print "Number of atoms in upper: ${n_upper}"
         ```
         The <code>variables</code> attribute holds the variable definitions, and the <code>printvariables</code> attribute
         holds the print commands, each separated by a newline.

Raises:

ValueError:
    - If any group specified in <code>selection</code> does not exist in the collection.
    - If <code>selection</code> contains duplicate group names.
TypeError:
    - If <code>selection</code> is not a list of strings.

Example:

```python
# Create groupobject instances
g1 = groupobject(beadtype=1,group = 'lower')
g2 = groupobject(beadtype=2,group = 'middle')
g3 = groupobject(beadtype=3,group = 'upper')

# Initialize a group with the groupobjects
G = group(name="1+2+3",collection=(g1,g2,g3)) or collection=g1+g2+g3

# add other groups
G.evaluate("all", G.lower + G.middle + G.upper)
G.evaluate("external", G.all - G.middle)

# Generate number commands for all groups
N = G.count(selection=["all","lower","middle","lower"])
print(N.do())
```

**Output:**
```
variable n_all equal "count(all)"
variable n_lower equal "count(lower)"
variable n_middle equal "count(middle)"
variable n_upper equal "count(upper)"
print "Number of atoms in all: ${n_all}"
print "Number of atoms in lower: ${n_lower}"
print "Number of atoms in middle: ${n_middle}"
print "Number of atoms in upper: ${n_upper}"
```
Expand source code
def count(self,name=None, selection: Optional[List[str]] = None) -> 'dscript':
    """
    Generates DSCRIPT counters for specified groups with LAMMPS variable definitions and print commands.

    The method retrieves the list of group names using `self.list()`. If `selection` is provided, it
    filters the groups to include only those specified. It then creates a variable for each selected
    group that counts the number of atoms in that group and generates corresponding print commands.
    The commands are encapsulated within a `dscript` object for execution.

    ### Parameters:
        selection (list of str, optional):
            - List of group names to be numbered.
            - If `None`, all groups in the collection are numbered.

    ### Returns:
        dscript: A `dscript` object containing the variable definitions and print commands, formatted as follows:
                 ```
                 variable n_lower equal "count(lower)"
                 variable n_middle equal "count(middle)"
                 variable n_upper equal "count(upper)"

                 print "Number of atoms in lower: ${n_lower}"
                 print "Number of atoms in middle: ${n_middle}"
                 print "Number of atoms in upper: ${n_upper}"
                 ```
                 The `variables` attribute holds the variable definitions, and the `printvariables` attribute
                 holds the print commands, each separated by a newline.

    ### Raises:
        ValueError:
            - If any group specified in `selection` does not exist in the collection.
            - If `selection` contains duplicate group names.
        TypeError:
            - If `selection` is not a list of strings.

    ### Example:
        ```python
        # Create groupobject instances
        g1 = groupobject(beadtype=1,group = 'lower')
        g2 = groupobject(beadtype=2,group = 'middle')
        g3 = groupobject(beadtype=3,group = 'upper')

        # Initialize a group with the groupobjects
        G = group(name="1+2+3",collection=(g1,g2,g3)) or collection=g1+g2+g3

        # add other groups
        G.evaluate("all", G.lower + G.middle + G.upper)
        G.evaluate("external", G.all - G.middle)

        # Generate number commands for all groups
        N = G.count(selection=["all","lower","middle","lower"])
        print(N.do())
        ```

        **Output:**
        ```
        variable n_all equal "count(all)"
        variable n_lower equal "count(lower)"
        variable n_middle equal "count(middle)"
        variable n_upper equal "count(upper)"
        print "Number of atoms in all: ${n_all}"
        print "Number of atoms in lower: ${n_lower}"
        print "Number of atoms in middle: ${n_middle}"
        print "Number of atoms in upper: ${n_upper}"
        ```
    """
    # Retrieve the list of all group names
    all_group_names = self.list()

    # If selection is provided, validate it
    if selection is not None:
        if not isinstance(selection, (list, tuple)):
            raise TypeError("Parameter 'selection' must be a list of group names.")
        if not all(isinstance(gitem, str) for gitem in selection):
            raise TypeError("All items in 'selection' must be strings representing group names.")
        if len(selection) != len(set(selection)):
            raise ValueError("Duplicate group names found in 'selection'. Each group should be unique.")
        # Check that all selected groups exist
        missing_groups = set(selection) - set(all_group_names)
        if missing_groups:
            raise ValueError(f"The following groups are not present in the collection: {', '.join(missing_groups)}")
        # Use the selected groups
        target_groups = selection
    else:
        # If no selection, target all groups
        target_groups = all_group_names

    # Initialize lists to hold variable definitions and print commands
    variable_definitions = []
    print_commands = []
    for gitem in target_groups:
        # Validate group name
        if not gitem:
            raise ValueError("Group names must be non-empty strings.")
        # Create a valid variable name by replacing any non-alphanumeric characters with underscores
        variable_name = f"n_{gitem}".replace(" ", "_").replace("-", "_")
        # Define the variable to count the number of atoms in the group
        variable_definitions.append(f'variable {variable_name} equal "count({gitem})"')
        # Define the corresponding print command
        print_commands.append(f'print "Number of atoms in {gitem}: ${{{variable_name}}}"')
    # Create a dscript object with the generated commands
    idD = f"<dscript:group:{self._name}:count>"
    D = dscript(
        name=idD if name is None else name,
        description=f"{idD} for group counts"
    )
    D.variables = "\n".join(variable_definitions)
    D.printvariables = "\n".join(print_commands)
    D.set_all_variables()
    return D
def create(self, group_name)

create group G.create(group_name)

Expand source code
def create(self, group_name):
    """
        create group
        G.create(group_name)
    """
    lammps_code = f"group {group_name} clear"
    criteria = {"clear": True}
    op = Operation("create", [], name=group_name, code=lammps_code, criteria=criteria)
    self.add_operation(op)
def create_groups(self, *group_names)
Expand source code
def create_groups(self, *group_names):
    for group_name in group_names:
        if not isinstance(group_name, str):
            raise TypeError(f"Group name must be a string, got {type(group_name)}")
        self.create(group_name)
def delete(self, name)

Deletes one or more stored operations based on their names.

Parameters:

name : str, list, or tuple The name(s) of the operation(s) to delete. If a list or tuple is provided, all specified operations will be deleted.

Usage:

G.delete('operation_name') G.delete(['operation1', 'operation2']) G.delete(('operation1', 'operation2'))

Raises:

ValueError If any of the specified operations are not found.

Expand source code
def delete(self, name):
    """
    Deletes one or more stored operations based on their names.

    Parameters:
    -----------
    name : str, list, or tuple
        The name(s) of the operation(s) to delete. If a list or tuple is provided,
        all specified operations will be deleted.

    Usage:
    ------
    G.delete('operation_name')
    G.delete(['operation1', 'operation2'])
    G.delete(('operation1', 'operation2'))

    Raises:
    -------
    ValueError
        If any of the specified operations are not found.
    """
    # Handle a single string, list, or tuple
    if isinstance(name, (list, tuple)):
        not_found = []
        for n in name:
            idx = self.find(n)
            if idx is not None:
                del self._operations[idx]
            else:
                not_found.append(n)
        # If any names were not found, raise an exception
        if not_found:
            raise ValueError(f"Operation(s) {', '.join(not_found)} not found.")
    elif isinstance(name, str):
        idx = self.find(name)
        if idx is not None:
            del self._operations[idx]
        else:
            raise ValueError(f"Operation {name} not found.")
    else:
        raise TypeError("The 'name' parameter must be a string, list, or tuple.")
def disp(self, name)

display the content of an operation

Expand source code
def disp(self, name):
    """ display the content of an operation """
    idx = self.find(name)
    if idx is not None:
        return self._operations[idx].__repr__()
    else:
        return "Operation not found"
def dscript(self, name=None, printflag=None, verbose=None, verbosity=None)

Generates a dscript object containing the group's LAMMPS commands.

Parameters: - name (str): Optional name for the script object. - printflag (bool, default=False): print on the current console if True - verbose (bool, default=True): keep comments if True

Returns: - dscript: A dscript object containing the group's code.

Expand source code
def dscript(self, name=None, printflag=None, verbose=None, verbosity=None):
    """
    Generates a dscript object containing the group's LAMMPS commands.

    Parameters:
    - name (str): Optional name for the script object.
    - printflag (bool, default=False): print on the current console if True
    - verbose (bool, default=True): keep comments if True

    Returns:
    - dscript: A dscript object containing the group's code.
    """
    if name is None:
        name = self._name
    printflag = self.printflag if printflag is None else printflag
    verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
    verbosity = 0 if not verbose else verbosity
    # Create a new dscript object
    dscript_obj = dscript(name=name,printflag=printflag, verbose=verbose, verbosity=verbosity)
    # Add each line of the group's code to the script object
    for idx, op in enumerate(self._operations):
        # Use the index as the key for the script line
       dscript_obj[idx] = op.code
    return dscript_obj
def evaluate(self, group_name, group_op)

Evaluates the operation and stores the result in a new group. Expressions could combine +, - and * like o1+o2+o3-o4+o5+o6

Parameters:

groupname : str The name of the group that will store the result. group_op : Operation The operation to evaluate.

Expand source code
def evaluate(self, group_name, group_op):
    """
    Evaluates the operation and stores the result in a new group.
    Expressions could combine +, - and * like o1+o2+o3-o4+o5+o6

    Parameters:
    -----------
    groupname : str
        The name of the group that will store the result.
    group_op : Operation
        The operation to evaluate.
    """
    if not isinstance(group_op, Operation):
        raise TypeError("Expected an instance of Operation.")

    if group_name in self.list():
        raise ValueError(f"The operation '{group_name}' already exists.")

    # If the operation is already finalized, no need to evaluate
    if group_op.isfinalized():
        if group_op.name != group_name:
            # If names differ, create a copy with the new name
            self.copy(group_op.name, group_name)
        return

    # Recursively evaluate operands
    operand_names = []
    for op in group_op.operands:
        if isinstance(op, Operation):
            if op.isfinalized():
                # Use existing finalized operation name
                operand_names.append(op.name)
            else:
                # Generate a unique name if the operation doesn't have one
                if not op.name:
                    op.name = op.generate_hashname()
                # Recursively evaluate the operand operation
                self.evaluate(op.name, op)
                operand_names.append(op.name)
        else:
            operand_names.append(str(op))

    # Call the appropriate method based on the operator
    if group_op.operator == '+':
        self.union(group_name, *operand_names)
        group_op.operator = 'union'
    elif group_op.operator == '-':
        self.subtract(group_name, *operand_names)
        group_op.operator = 'subtract'
    elif group_op.operator == '*':
        self.intersect(group_name, *operand_names)
        group_op.operator = 'intersect'
    else:
        raise ValueError(f"Unknown operator: {group_op.operator}")

    # Update the operation
    group_op.name = group_name
    # Get the last added operation
    finalized_op = self._operations[-1]
    group_op.code = finalized_op.code
    group_op.operands = operand_names

    # Add the operation to the group's _operations if not already added
    if group_op.name not in self.list():
        self._operations.append(group_op)
def find(self, name)

Returns the index of an operation based on its name.

Expand source code
def find(self, name):
    """Returns the index of an operation based on its name."""
    if '_operations' in self.__dict__:
        for i, op in enumerate(self._operations):
            if op.name == name:
                return i
    return None
def format_cell_content(self, content, max_width)
Expand source code
def format_cell_content(self, content, max_width):
    content = str(content) if content is not None else ''
    if len(content) > max_width:
        start = content[: (max_width - 5) // 2]
        end = content[-((max_width - 5) // 2):]
        content = f"{start} ... {end}"
    return content
def generate_group_definitions_from_collection(self, collection)

Generates group definitions based on the collection of groupobject instances.

This method populates the groups based on beadtypes and associated group names.

Expand source code
def generate_group_definitions_from_collection(self,collection):
    """
    Generates group definitions based on the collection of groupobject instances.

    This method populates the groups based on beadtypes and associated group names.
    """
    group_defs = {}
    for obj in collection:
        for group_name in obj.group:
            if group_name not in group_defs:
                group_defs[group_name] = {'type': []}
            if obj.beadtype not in group_defs[group_name]['type']:
                group_defs[group_name]['type'].append(obj.beadtype)
    # Now, add these group definitions
    self.add_group_criteria(group_defs)
def get_by_name(self, operation_name)

Returns the operation matching "operation_name" Usage: group.get_by_name("operation_name") To be used by Operation, not by end-user, which should prefer getattr()

Expand source code
def get_by_name(self,operation_name):
    """
        Returns the operation matching "operation_name"
        Usage: group.get_by_name("operation_name")
        To be used by Operation, not by end-user, which should prefer getattr()
    """
    for op in self._operations:
        if op.name == operation_name:
            return op
    raise AttributeError(f"Operation with name '{operation_name}' not found.")
def get_group_criteria(self, group_name)

Retrieve the criteria that define a group. Handles group_name as a string or number.

Parameters: - group_name: str or int, the name or number of the group.

Returns: - dict or str: The criteria used to define the group, or a message if defined by multiple criteria.

Raises: - ValueError: If the group does not exist.

Expand source code
def get_group_criteria(self, group_name):
    """
    Retrieve the criteria that define a group. Handles group_name as a string or number.

    Parameters:
    - group_name: str or int, the name or number of the group.

    Returns:
    - dict or str: The criteria used to define the group, or a message if defined by multiple criteria.

    Raises:
    - ValueError: If the group does not exist.
    """
    # Check if group_name exists in _operations
    if group_name not in self._operations:
        raise ValueError(f"Group '{group_name}' does not exist.")

    # Retrieve all operations related to the group directly from _operations
    operations = self._operations[group_name]

    if not operations:
        raise ValueError(f"No operations found for group '{group_name}'.")

    criteria = {}
    for op in operations:
        if op.criteria:
            criteria.update(op.criteria)

    # If multiple criteria, return a message
    if len(criteria) > 1:
        return f"Group '{group_name}' is defined by multiple criteria: {criteria}"

    return criteria
def intersect(self, group_name, *groups)

Intersect group1, group2, group3 and store the result in group_name. Example usage: group.intersect(group_name, group1, group2, group3,…)

Expand source code
def intersect(self,group_name, *groups):
    """
    Intersect group1, group2, group3 and store the result in group_name.
    Example usage:
    group.intersect(group_name, group1, group2, group3,...)
    """
    lammps_code = f"group {group_name} intersect {span(groups)}"
    criteria = {"intersect": groups}
    op = Operation("intersect", groups, name=group_name, code=lammps_code, criteria=criteria)
    self.add_operation(op)
def list(self)

return the list of all operations

Expand source code
def list(self):
    """ return the list of all operations """
    return [op.name for op in self._operations]
def operation_exists(self, operation_name)

Returns true if "operation_name" exists To be used by Operation, not by end-user, which should prefer find()

Expand source code
def operation_exists(self,operation_name):
    """
        Returns true if "operation_name" exists
        To be used by Operation, not by end-user, which should prefer find()
    """
    return any(op.name == operation_name for op in self._operations)
def pipescript(self, printflag=None, verbose=None, verbosity=None)

Generates a pipescript object containing the group's LAMMPS commands.

Parameters: - printflag (bool, default=False): print on the current console if True - verbose (bool, default=True): keep comments if True

Returns: - pipescript: A pipescript object containing the group's code lines as individual scripts in the pipeline.

Expand source code
def pipescript(self, printflag=None, verbose=None, verbosity=None):
    """
    Generates a pipescript object containing the group's LAMMPS commands.

    Parameters:
    - printflag (bool, default=False): print on the current console if True
    - verbose (bool, default=True): keep comments if True

    Returns:
    - pipescript: A pipescript object containing the group's code lines as individual scripts in the pipeline.
    """
    printflag = self.printflag if printflag is None else printflag
    verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
    verbosity = 0 if not verbose else verbosity
    # Create a list to hold script objects
    script_list = []

    # For each operation, create a script object and add it to the list
    for op in self._operations:
        # Create a dscript object with the code line
        dscript_obj = dscript(printflag=printflag, verbose=verbose, verbosity=verbosity)
        dscript_obj["dummy"] = op.code

        # Convert the dscript to a script object
        script_obj = dscript_obj.script(printflag=printflag, verbose=verbose, verbosity=verbosity)

        # Add the script object to the list
        script_list.append(script_obj)

    # Use the static method 'join' to create a pipescript from the list
    if script_list:
        pipe_obj = pipescript.join(script_list)
    else:
        pipe_obj = pipescript()

    return pipe_obj
def reindex(self, name, new_idx)

Change the index of a stored operation.

Parameters: name: str Name of the operation to reindex new_idx: int New index for the operation

Usage: G.reindex('operation_name', 2)

Expand source code
def reindex(self, name, new_idx):
    """
    Change the index of a stored operation.

    Parameters:
    name: str
        Name of the operation to reindex
    new_idx: int
        New index for the operation

    Usage:
    G.reindex('operation_name', 2)
    """
    idx = self.find(name)
    if idx is not None and 0 <= new_idx < len(self._operations):
        op = self._operations.pop(idx)
        self._operations.insert(new_idx, op)
    else:
        raise ValueError(f"Operation '{name}' not found or new index {new_idx} out of range.")
def rename(self, old_name, new_name)

Rename a stored operation.

Parameters: old_name: str Current name of the operation new_name: str New name to assign to the operation

Usage: G.rename('old_operation', 'new_operation')

Expand source code
def rename(self, old_name, new_name):
    """
    Rename a stored operation.

    Parameters:
    old_name: str
        Current name of the operation
    new_name: str
        New name to assign to the operation

    Usage:
    G.rename('old_operation', 'new_operation')
    """
    idx = self.find(old_name)
    if idx is not None:
        if new_name == old_name:
            raise ValueError("The new name should be different from the previous one.")
        elif new_name in self.list():
            raise ValueError("Operation name must be unique.")
        self._operations[idx].name = new_name
    else:
        raise ValueError(f"Operation '{old_name}' not found.")
def script(self, name=None, printflag=None, verbose=None, verbosity=None)

Generates a script object containing the group's LAMMPS commands.

Parameters: - name (str): Optional name for the script object. - printflag (bool, default=False): print on the current console if True - verbose (bool, default=True): keep comments if True

Returns: - script: A script object containing the group's code.

Expand source code
def script(self, name=None, printflag=None, verbose=None, verbosity=None):
    """
    Generates a script object containing the group's LAMMPS commands.

    Parameters:
    - name (str): Optional name for the script object.
    - printflag (bool, default=False): print on the current console if True
    - verbose (bool, default=True): keep comments if True

    Returns:
    - script: A script object containing the group's code.
    """
    if name is None:
        name = self._name
    printflag = self.printflag if printflag is None else printflag
    verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
    verbosity = 0 if not verbose else verbosity
    script_obj = self.dscript(name=name,printflag=printflag, verbose=verbose, verbosity=verbosity).script(printflag=printflag, verbose=verbose, verbosity=verbosity)
    return script_obj
def subtract(self, group_name, *groups)

Subtract group2, group3 from group1 and store the result in group_name. Example usage: group.subtract(group_name, group1, group2, group3,…)

Expand source code
def subtract(self,group_name, *groups):
    """
    Subtract group2, group3 from group1 and store the result in group_name.
    Example usage:
    group.subtract(group_name, group1, group2, group3,...)
    """
    lammps_code = f"group {group_name} subtract {span(groups)}"
    criteria = {"subtract": groups}
    op = Operation("subtract", groups, name=group_name, code=lammps_code, criteria=criteria)
    self.add_operation(op)
def union(self, group_name, *groups)

Union group1, group2, group3 and store the result in group_name. Example usage: group.union(group_name, group1, group2, group3,…)

Expand source code
def union(self,group_name, *groups):
    """
    Union group1, group2, group3 and store the result in group_name.
    Example usage:
    group.union(group_name, group1, group2, group3,...)
    """
    lammps_code = f"group {group_name} union {span(groups)}"
    criteria = {"union": groups}
    op = Operation("union", groups, name=group_name, code=lammps_code, criteria=criteria)
    self.add_operation(op)
def variable(self, variable_name, expression, style='atom')

Assigns an expression to a LAMMPS variable.

Parameters: - variable_name (str): The name of the variable to be assigned. - expression (str): The expression to assign to the variable. - style (str): The type of variable (default is "atom").

Expand source code
def variable(self, variable_name, expression, style="atom"):
    """
    Assigns an expression to a LAMMPS variable.

    Parameters:
    - variable_name (str): The name of the variable to be assigned.
    - expression (str): The expression to assign to the variable.
    - style (str): The type of variable (default is "atom").
    """
    if not isinstance(variable_name, str):
        raise TypeError(f"Variable name must be a string, got {type(variable_name)}")
    if not isinstance(expression, str):
        raise TypeError(f"Expression must be a string, got {type(expression)}")
    if not isinstance(style, str):
        raise TypeError(f"Style must be a string, got {type(style)}")

    lammps_code = f"variable {variable_name} {style} \"{expression}\""
    op = Operation("variable", [variable_name, expression], code=lammps_code)
    self.add_operation(op)
class groupcollection (collection: List[ForwardRef('groupobject')], name: Optional[str] = None)

Represents a collection of groupobject instances, typically formed by combining them.

Attributes:

collection (List[groupobject]): The list of <code><a title="group.groupobject" href="#group.groupobject">groupobject</a></code> instances in the collection.
name (str): The name of the collection, generated automatically if not provided.

Examples:

>>> o1 = groupobject(beadtype=1, group=["all", "A"], mass=1.0)
>>> o2 = groupobject(beadtype=2, group=["all", "B", "C"])
>>> G = groupcollection([o1, o2])
>>> print(G.name)
beadtype=1,2

Initializes a new instance of the groupcollection class.

Parameters:

collection (List[groupobject]): A list of <code><a title="group.groupobject" href="#group.groupobject">groupobject</a></code> instances to include in the collection.
name (str, optional): The name of the collection. If not provided, a default name is generated.

Raises:

ValueError: If the <code>collection</code> is empty or contains invalid elements.
Expand source code
class groupcollection:
    """
    Represents a collection of `groupobject` instances, typically formed by combining them.

    ### Attributes:
        collection (List[groupobject]): The list of `groupobject` instances in the collection.
        name (str): The name of the collection, generated automatically if not provided.

    ### Examples:
        >>> o1 = groupobject(beadtype=1, group=["all", "A"], mass=1.0)
        >>> o2 = groupobject(beadtype=2, group=["all", "B", "C"])
        >>> G = groupcollection([o1, o2])
        >>> print(G.name)
        beadtype=1,2
    """
    def __init__(self, collection: List['groupobject'], name: Optional[str] = None):
        """
        Initializes a new instance of the groupcollection class.

        ### Parameters:
            collection (List[groupobject]): A list of `groupobject` instances to include in the collection.
            name (str, optional): The name of the collection. If not provided, a default name is generated.

        ### Raises:
            ValueError: If the `collection` is empty or contains invalid elements.
        """
        if not isinstance(collection, list) or not all(isinstance(obj, groupobject) for obj in collection):
            raise ValueError("`collection` must be a list of `groupobject` instances.")
        if not collection:
            raise ValueError("`collection` cannot be empty.")

        self.collection = collection

        # Automatically assign a name if not provided
        if name is None:
            # Extract names or "beadtype=X" patterns
            beadtype_names = [
                obj.name if obj.name.startswith("beadtype=") else None for obj in collection
            ]

            # If all objects have beadtype names, use "beadtype=1,2,3" pattern
            if all(beadtype_names):
                beadtypes = [obj.beadtype for obj in collection]
                self.name = "beadtype={}".format(",".join(map(str, sorted(beadtypes))))
            else:
                # Fall back to concatenating names
                self.name = ",".join(obj.name for obj in collection)
        else:
            self.name = name


    def __add__(self, other: Union[groupobject, 'groupcollection']) -> 'groupcollection':
        """
        Adds a `groupobject` or another `groupcollection` to this collection.

        ### Parameters:
            other (groupobject or groupcollection): The object to add.

        ### Returns:
            groupcollection: A new `groupcollection` instance containing the combined objects.

        ### Raises:
            TypeError: If `other` is neither a `groupobject` nor a `groupcollection`.
        """
        if isinstance(other, groupobject):
            return groupcollection(self.collection + [other])
        elif isinstance(other, groupcollection):
            return groupcollection(self.collection + other.collection)
        else:
            raise TypeError("Addition only supported between `groupobject` or `groupcollection` instances.")

    def __iadd__(self, other: Union[groupobject, List[groupobject], Tuple[groupobject, ...]]) -> 'groupcollection':
        """
        In-place addition of a `groupobject` or a list/tuple of `groupobject` instances.

        ### Parameters:
            other (groupobject or list/tuple of groupobject): The object(s) to add.

        ### Returns:
            groupcollection: The updated `groupcollection` instance.

        ### Raises:
            TypeError: If `other` is not a `groupobject` or a list/tuple of `groupobject` instances.
        """
        if isinstance(other, groupobject):
            self.collection.append(other)
        elif isinstance(other, (list, tuple)):
            for obj in other:
                if not isinstance(obj, groupobject):
                    raise TypeError("All items to add must be `groupobject` instances.")
            self.collection.extend(other)
        else:
            raise TypeError("In-place addition only supported with `groupobject` instances or lists/tuples of `groupobject` instances.")
        return self

    def __repr__(self) -> str:
        """
        Returns a neatly formatted string representation of the groupcollection.

        The representation includes a table of `beadtype`, `group` (max width 50 characters), and `mass`.
        It also provides a summary of the total number of `groupobject` instances.

        ### Returns:
            str: Formatted string representation of the collection.
        """
        headers = ["Beadtype", 'Name', "Group", "Mass"]
        col_max_widths = [10, 20, 30, 10]
        # Prepare rows
        rows = []
        for obj in self.collection:
            beadtype_str = str(obj.beadtype)
            group_str = ", ".join(obj.group)
            mass_str = f"{obj.mass}" if obj.mass is not None else "None"
            rows.append([beadtype_str, obj.name, group_str, mass_str])
        # Generate the table
        table = format_table(headers, rows, col_max_widths)
        # Add summary
        summary = f"Total groupobjects: {len(self)}"
        return f"{table}\n{summary}"

    def __str__(self) -> str:
        """
        Returns the same representation as `__repr__`.

        ### Returns:
            str: Formatted string representation of the collection.
        """
        return f"groupcollection including {len(self)} groupobjects"

    def __len__(self) -> int:
        """
        Returns the number of `groupobject` instances in the collection.

        ### Returns:
            int: Number of objects in the collection.
        """
        return len(self.collection)

    def __getitem__(self, index: int) -> groupobject:
        """
        Retrieves a `groupobject` by its index.

        ### Parameters:
            index (int): The index of the `groupobject` to retrieve.

        ### Returns:
            groupobject: The `groupobject` at the specified index.

        ### Raises:
            IndexError: If the index is out of range.
        """
        return self.collection[index]

    def __iter__(self):
        """
        Returns an iterator over the `groupobject` instances in the collection.

        ### Returns:
            Iterator[groupobject]: An iterator over the collection.
        """
        return iter(self.collection)

    def append(self, obj: groupobject):
        """
        Appends a `groupobject` to the collection.

        ### Parameters:
            obj (groupobject): The object to append.

        ### Raises:
            TypeError: If `obj` is not a `groupobject` instance.
        """
        if not isinstance(obj, groupobject):
            raise TypeError("Only `groupobject` instances can be appended.")
        self.collection.append(obj)

    def extend(self, objs: Union[List[groupobject], Tuple[groupobject, ...]]):
        """
        Extends the collection with a list or tuple of `groupobject` instances.

        ### Parameters:
            objs (list or tuple of groupobject): The objects to extend the collection with.

        ### Raises:
            TypeError: If `objs` is not a list or tuple.
            TypeError: If any item in `objs` is not a `groupobject` instance.
        """
        if not isinstance(objs, (list, tuple)):
            raise TypeError("`objs` must be a list or tuple of `groupobject` instances.")
        for obj in objs:
            if not isinstance(obj, groupobject):
                raise TypeError("All items to extend must be `groupobject` instances.")
        self.collection.extend(objs)

    def remove(self, obj: groupobject):
        """
        Removes a `groupobject` from the collection.

        ### Parameters:
            obj (groupobject): The object to remove.

        ### Raises:
            ValueError: If `obj` is not found in the collection.
        """
        self.collection.remove(obj)

    def clear(self):
        """
        Clears all `groupobject` instances from the collection.
        """
        self.collection.clear()


    def mass(self, name: Optional[str] = None, default_mass: Optional[Union[str, int, float]] = "${mass}", verbose: Optional[bool] = True) -> 'dscript':
        """
        Generates LAMMPS mass commands for each unique beadtype in the collection.

        The method iterates through all `groupobject` instances in the collection,
        collects unique beadtypes, and ensures that each beadtype has a consistent mass.
        If a beadtype has `mass=None`, it assigns a default mass as specified by `default_mass`.

        ### Parameters:
            name (str, optional): The name to assign to the resulting `dscript` object. Defaults to a generated name.
            default_mass (str, optional): The default mass value to assign when a beadtype's mass is `None`.
                                          Defaults to `"${mass}"`.
            verbose (bool, optional): If `True`, includes a comment header in the output. Defaults to `True`.

        ### Returns:
            dscript: A `dscript` object containing the mass commands for each beadtype, formatted as follows:
                     ```
                     mass 1 1.0
                     mass 2 1.0
                     mass 3 2.5
                     ```
                     The `collection` attribute of the `dscript` object holds the formatted mass commands as a single string.

        ### Raises:
            ValueError: If a beadtype has inconsistent mass values across different `groupobject` instances.

        ### Example:
            ```python
            # Create groupobject instances
            o1 = groupobject(beadtype=1, group=["all", "A"], mass=1.0)
            o2 = groupobject(beadtype=2, group=["all", "B", "C"])
            o3 = groupobject(beadtype=3, group="C", mass=2.5)

            # Initialize a groupcollection with the groupobjects
            G = groupcollection([o1, o2, o3])

            # Generate mass commands
            M = G.mass()
            print(M.do())
            ```
            **Output:**
            ```
            mass 1 1.0
            mass 2 ${mass}
            mass 3 2.5
            ```
        """
        beadtype_mass = {}
        for obj in self.collection:
            bt = obj.beadtype
            mass = obj.mass if obj.mass is not None else "${mass}"
            if bt in beadtype_mass:
                if beadtype_mass[bt] != mass:
                    raise ValueError(
                        f"Inconsistent masses for beadtype {bt}: {beadtype_mass[bt]} vs {mass}"
                    )
            else:
                beadtype_mass[bt] = mass
        # Sort beadtypes for consistent ordering
        sorted_beadtypes = sorted(beadtype_mass.keys())
        # Generate mass commands
        lines = [f"mass {bt} {beadtype_mass[bt]}" for bt in sorted_beadtypes]
        # return a dscript object
        idD = f"<dscript:group:{self.name}:mass>"
        description = f"{idD} definitions for {len(self)} beads"
        if verbose:
            lines.insert(0, "# "+description)
        D = dscript(name=idD if name is None else name, description=f"{idD} with {len(self)} beads")
        D.collection = "\n".join(lines)
        D.DEFINITIONS.mass = default_mass
        return D

Methods

def append(self, obj: groupobject)

Appends a groupobject to the collection.

Parameters:

obj (groupobject): The object to append.

Raises:

TypeError: If <code>obj</code> is not a <code><a title="group.groupobject" href="#group.groupobject">groupobject</a></code> instance.
Expand source code
def append(self, obj: groupobject):
    """
    Appends a `groupobject` to the collection.

    ### Parameters:
        obj (groupobject): The object to append.

    ### Raises:
        TypeError: If `obj` is not a `groupobject` instance.
    """
    if not isinstance(obj, groupobject):
        raise TypeError("Only `groupobject` instances can be appended.")
    self.collection.append(obj)
def clear(self)

Clears all groupobject instances from the collection.

Expand source code
def clear(self):
    """
    Clears all `groupobject` instances from the collection.
    """
    self.collection.clear()
def extend(self, objs: Union[List[groupobject], Tuple[groupobject, ...]])

Extends the collection with a list or tuple of groupobject instances.

Parameters:

objs (list or tuple of groupobject): The objects to extend the collection with.

Raises:

TypeError: If <code>objs</code> is not a list or tuple.
TypeError: If any item in <code>objs</code> is not a <code><a title="group.groupobject" href="#group.groupobject">groupobject</a></code> instance.
Expand source code
def extend(self, objs: Union[List[groupobject], Tuple[groupobject, ...]]):
    """
    Extends the collection with a list or tuple of `groupobject` instances.

    ### Parameters:
        objs (list or tuple of groupobject): The objects to extend the collection with.

    ### Raises:
        TypeError: If `objs` is not a list or tuple.
        TypeError: If any item in `objs` is not a `groupobject` instance.
    """
    if not isinstance(objs, (list, tuple)):
        raise TypeError("`objs` must be a list or tuple of `groupobject` instances.")
    for obj in objs:
        if not isinstance(obj, groupobject):
            raise TypeError("All items to extend must be `groupobject` instances.")
    self.collection.extend(objs)
def mass(self, name: Optional[str] = None, default_mass: Union[str, int, float, NoneType] = '${mass}', verbose: Optional[bool] = True) ‑> pizza.dscript.dscript

Generates LAMMPS mass commands for each unique beadtype in the collection.

The method iterates through all groupobject instances in the collection, collects unique beadtypes, and ensures that each beadtype has a consistent mass. If a beadtype has mass=None, it assigns a default mass as specified by default_mass.

Parameters:

name (str, optional): The name to assign to the resulting <code><a title="group.dscript" href="#group.dscript">dscript</a></code> object. Defaults to a generated name.
default_mass (str, optional): The default mass value to assign when a beadtype's mass is <code>None</code>.
                              Defaults to `"${mass}"`.
verbose (bool, optional): If <code>True</code>, includes a comment header in the output. Defaults to <code>True</code>.

Returns:

dscript: A <code><a title="group.dscript" href="#group.dscript">dscript</a></code> object containing the mass commands for each beadtype, formatted as follows:
         ```
         mass 1 1.0
         mass 2 1.0
         mass 3 2.5
         ```
         The <code>collection</code> attribute of the <code><a title="group.dscript" href="#group.dscript">dscript</a></code> object holds the formatted mass commands as a single string.

Raises:

ValueError: If a beadtype has inconsistent mass values across different <code><a title="group.groupobject" href="#group.groupobject">groupobject</a></code> instances.

Example:

```python
# Create groupobject instances
o1 = groupobject(beadtype=1, group=["all", "A"], mass=1.0)
o2 = groupobject(beadtype=2, group=["all", "B", "C"])
o3 = groupobject(beadtype=3, group="C", mass=2.5)

# Initialize a groupcollection with the groupobjects
G = groupcollection([o1, o2, o3])

# Generate mass commands
M = G.mass()
print(M.do())
```
**Output:**
```
mass 1 1.0
mass 2 ${mass}
mass 3 2.5
```
Expand source code
def mass(self, name: Optional[str] = None, default_mass: Optional[Union[str, int, float]] = "${mass}", verbose: Optional[bool] = True) -> 'dscript':
    """
    Generates LAMMPS mass commands for each unique beadtype in the collection.

    The method iterates through all `groupobject` instances in the collection,
    collects unique beadtypes, and ensures that each beadtype has a consistent mass.
    If a beadtype has `mass=None`, it assigns a default mass as specified by `default_mass`.

    ### Parameters:
        name (str, optional): The name to assign to the resulting `dscript` object. Defaults to a generated name.
        default_mass (str, optional): The default mass value to assign when a beadtype's mass is `None`.
                                      Defaults to `"${mass}"`.
        verbose (bool, optional): If `True`, includes a comment header in the output. Defaults to `True`.

    ### Returns:
        dscript: A `dscript` object containing the mass commands for each beadtype, formatted as follows:
                 ```
                 mass 1 1.0
                 mass 2 1.0
                 mass 3 2.5
                 ```
                 The `collection` attribute of the `dscript` object holds the formatted mass commands as a single string.

    ### Raises:
        ValueError: If a beadtype has inconsistent mass values across different `groupobject` instances.

    ### Example:
        ```python
        # Create groupobject instances
        o1 = groupobject(beadtype=1, group=["all", "A"], mass=1.0)
        o2 = groupobject(beadtype=2, group=["all", "B", "C"])
        o3 = groupobject(beadtype=3, group="C", mass=2.5)

        # Initialize a groupcollection with the groupobjects
        G = groupcollection([o1, o2, o3])

        # Generate mass commands
        M = G.mass()
        print(M.do())
        ```
        **Output:**
        ```
        mass 1 1.0
        mass 2 ${mass}
        mass 3 2.5
        ```
    """
    beadtype_mass = {}
    for obj in self.collection:
        bt = obj.beadtype
        mass = obj.mass if obj.mass is not None else "${mass}"
        if bt in beadtype_mass:
            if beadtype_mass[bt] != mass:
                raise ValueError(
                    f"Inconsistent masses for beadtype {bt}: {beadtype_mass[bt]} vs {mass}"
                )
        else:
            beadtype_mass[bt] = mass
    # Sort beadtypes for consistent ordering
    sorted_beadtypes = sorted(beadtype_mass.keys())
    # Generate mass commands
    lines = [f"mass {bt} {beadtype_mass[bt]}" for bt in sorted_beadtypes]
    # return a dscript object
    idD = f"<dscript:group:{self.name}:mass>"
    description = f"{idD} definitions for {len(self)} beads"
    if verbose:
        lines.insert(0, "# "+description)
    D = dscript(name=idD if name is None else name, description=f"{idD} with {len(self)} beads")
    D.collection = "\n".join(lines)
    D.DEFINITIONS.mass = default_mass
    return D
def remove(self, obj: groupobject)

Removes a groupobject from the collection.

Parameters:

obj (groupobject): The object to remove.

Raises:

ValueError: If <code>obj</code> is not found in the collection.
Expand source code
def remove(self, obj: groupobject):
    """
    Removes a `groupobject` from the collection.

    ### Parameters:
        obj (groupobject): The object to remove.

    ### Raises:
        ValueError: If `obj` is not found in the collection.
    """
    self.collection.remove(obj)
class groupobject (beadtype: Union[int, float], group: Union[str, List[str], Tuple[str, ...]], mass: Union[int, float, NoneType] = None, name: Optional[str] = None)

Represents an object with a bead type, associated groups, and an optional mass.

Attributes:

beadtype (int or float): The bead type identifier. Must be an integer or a real scalar.
group (List[str]): List of group names the object belongs to.
mass (Optional[float]): The mass of the bead. Must be a real scalar or None.
name (Optional[str]): Name of the object

Examples:

>>> o1 = groupobject(beadtype=1, group=["all", "A"], mass=1.0)
>>> o2 = groupobject(beadtype=2, group=["all", "B", "C"])
>>> o3 = groupobject(beadtype=3, group="C", mass=2.5)

Initializes a new instance of the groupobject class.

Parameters:

beadtype (int or float): The bead type identifier.
group (str, list of str, or tuple of str): Group names the object belongs to.
mass (float or int, optional): The mass of the bead.

Raises:

TypeError: If <code>beadtype</code> is not an int or float.
TypeError: If <code><a title="group.group" href="#group.group">group</a></code> is not a str, list of str, or tuple of str.
TypeError: If <code>mass</code> is not a float, int, or None.
Expand source code
class groupobject:
    """
    Represents an object with a bead type, associated groups, and an optional mass.

    ### Attributes:
        beadtype (int or float): The bead type identifier. Must be an integer or a real scalar.
        group (List[str]): List of group names the object belongs to.
        mass (Optional[float]): The mass of the bead. Must be a real scalar or None.
        name (Optional[str]): Name of the object

    ### Examples:
        >>> o1 = groupobject(beadtype=1, group=["all", "A"], mass=1.0)
        >>> o2 = groupobject(beadtype=2, group=["all", "B", "C"])
        >>> o3 = groupobject(beadtype=3, group="C", mass=2.5)
    """

    def __init__(self, beadtype: Union[int, float], group: Union[str, List[str], Tuple[str, ...]], \
                 mass: Optional[Union[int, float]] = None, name: Optional[str]=None, ):
        """
        Initializes a new instance of the groupobject class.

        ### Parameters:
            beadtype (int or float): The bead type identifier.
            group (str, list of str, or tuple of str): Group names the object belongs to.
            mass (float or int, optional): The mass of the bead.

        ### Raises:
            TypeError: If `beadtype` is not an int or float.
            TypeError: If `group` is not a str, list of str, or tuple of str.
            TypeError: If `mass` is not a float, int, or None.
        """
        # Validate beadtype
        if not isinstance(beadtype, (int, float)):
            raise TypeError(f"'beadtype' must be an integer or float, got {type(beadtype).__name__} instead.")
        self.beadtype = beadtype
        # Validate group
        if isinstance(group, str):
            self.group = [group]
        elif isinstance(group, (list, tuple)):
            if not all(isinstance(g, str) for g in group):
                raise TypeError("All elements in 'group' must be strings.")
            self.group = list(group)
        else:
            raise TypeError(f"'group' must be a string, list of strings, or tuple of strings, got {type(group).__name__} instead.")
        # Validate mass
        if mass is not None and not isinstance(mass, (int, float)):
            raise TypeError(f"'mass' must be a real scalar (int or float) or None, got {type(mass).__name__} instead.")
        self.mass = mass
        # Validate name
        self.name = f"beadtype={beadtype}" if name is None else name

    def __str__(self) -> str:
        """
        Returns a readable string representation of the groupobject.
        """
        return f"groupobject | type={self.beadtype} | name={self.group} | mass={self.mass}"

    def __repr__(self) -> str:
        """
        Returns an unambiguous string representation of the groupobject.

        ### Returns:
            str: String representation including beadtype, group, and mass (if not None).
        """
        return f"groupobject(beadtype={self.beadtype}, group={span(self.group,',','[',']')}, name={self.name}, mass={self.mass})"


    def __add__(self, other: Union['groupobject', 'groupcollection']) -> 'groupcollection':
        """
        Adds this groupobject to another groupobject or a groupcollection.

        ### Parameters:
            other (groupobject or groupcollection): The object to add.

        ### Returns:
            groupcollection: A new groupcollection instance containing the combined objects.

        ### Raises:
            TypeError: If `other` is neither a `groupobject` nor a `groupcollection`.
        """
        if isinstance(other, groupobject):
            return groupcollection([self, other])
        elif isinstance(other, groupcollection):
            return groupcollection([self] + other.collection)
        else:
            raise TypeError("Addition only supported between `groupobject` or `groupcollection` instances.")

    def __radd__(self, other: Union['groupobject', 'groupcollection']) -> 'groupcollection':
        """
        Adds this groupobject to another groupobject or a groupcollection from the right.

        ### Parameters:
            other (groupobject or groupcollection): The object to add.

        ### Returns:
            groupcollection: A new groupcollection instance containing the combined objects.

        ### Raises:
            TypeError: If `other` is neither a `groupobject` nor a `groupcollection`.
        """
        if isinstance(other, groupobject):
            return groupcollection([other, self])
        elif isinstance(other, groupcollection):
            return groupcollection(other.collection + [self])
        else:
            raise TypeError("Addition only supported between `groupobject` or `groupcollection` instances.")
class pipescript (s=None, name=None, printflag=False, verbose=True, verbosity=None)

pipescript: A Class for Managing Script Pipelines

The pipescript class stores scripts in a pipeline where multiple scripts, script objects, or script object groups can be combined and executed sequentially. Scripts in the pipeline are executed using the pipe (|) operator, allowing for dynamic control over execution order, script concatenation, and variable management.

Key Features:

  • Pipeline Construction: Create pipelines of scripts, combining multiple script objects, script, scriptobject, or scriptobjectgroup instances. The pipe operator (|) is overloaded to concatenate scripts.
  • Sequential Execution: Execute all scripts in the pipeline in the order they were added, with support for reordering, selective execution, and clearing of individual steps.
  • User and Definition Spaces: Manage local and global user-defined variables (USER space) and static definitions for each script in the pipeline. Global definitions apply to all scripts in the pipeline, while local variables apply to specific steps.
  • Flexible Script Handling: Indexing, slicing, reordering, and renaming scripts in the pipeline are supported. Scripts can be accessed, replaced, and modified like array elements.

Practical Use Cases:

  • LAMMPS Script Automation: Automate the generation of multi-step simulation scripts for LAMMPS, combining different simulation setups into a single pipeline.
  • Script Management: Combine and manage multiple scripts, tracking user variables and ensuring that execution order can be adjusted easily.
  • Advanced Script Execution: Perform partial pipeline execution, reorder steps, or clear completed steps while maintaining the original pipeline structure.

Methods:

init(self, s=None): Initializes a new pipescript object, optionally starting with a script or script-like object (script, scriptobject, scriptobjectgroup).

setUSER(self, idx, key, value): Set a user-defined variable (USER) for the script at the specified index.

getUSER(self, idx, key): Get the value of a user-defined variable (USER) for the script at the specified index.

clear(self, idx=None): Clear the execution status of scripts in the pipeline, allowing them to be executed again.

do(self, idx=None, printflag=True, verbosity=2, verbose=None, forced=False): Execute the pipeline or a subset of the pipeline, generating a combined LAMMPS-compatible script.

script(self, idx=None, printflag=True, verbosity=2, verbose=None, forced=False): Generate the final LAMMPS script from the pipeline or a subset of the pipeline.

rename(self, name="", idx=None): Rename the scripts in the pipeline, assigning new names to specific indices or all scripts.

write(self, file, printflag=True, verbosity=2, verbose=None): Write the generated script to a file.

dscript(self, verbose=None, **USER) Convert the current pipescript into a dscript object

Static Methods:

join(liste): Combine a list of script and pipescript objects into a single pipeline.

Additional Features:

  • Indexing and Slicing: Use array-like indexing (p[0], p[1:3]) to access and manipulate scripts in the pipeline.
  • Deep Copy Support: The pipeline supports deep copying, preserving the entire pipeline structure and its scripts.
  • Verbose and Print Options: Control verbosity and printing behavior for generated scripts, allowing for detailed output or minimal script generation.

Original Content:

The pipescript class supports a variety of pipeline operations, including: - Sequential execution with cmd = p.do(). - Reordering pipelines with p[[2, 0, 1]]. - Deleting steps with p[[0, 1]] = []. - Accessing local and global user space variables via p.USER[idx].var and p.scripts[idx].USER.var. - Managing static definitions for each script in the pipeline. - Example usage: p = pipescript() p | i p = G | c | g | d | b | i | t | d | s | r p.rename(["G", "c", "g", "d", "b", "i", "t", "d", "s", "r"]) cmd = p.do([0, 1, 4, 7]) sp = p.script([0, 1, 4, 7]) - Scripts in the pipeline are executed sequentially, and definitions propagate from left to right. The USER space and DEFINITIONS are managed separately for each script in the pipeline.

Overview

Pipescript class stores scripts in pipelines
    By assuming: s0, s1, s2... scripts, scriptobject or scriptobjectgroup
    p = s0 | s1 | s2 generates a pipe script

    Example of pipeline:
  ------------:----------------------------------------
  [-]  00: G with (0>>0>>19) DEFINITIONS
  [-]  01: c with (0>>0>>10) DEFINITIONS
  [-]  02: g with (0>>0>>26) DEFINITIONS
  [-]  03: d with (0>>0>>19) DEFINITIONS
  [-]  04: b with (0>>0>>28) DEFINITIONS
  [-]  05: i with (0>>0>>49) DEFINITIONS
  [-]  06: t with (0>>0>>2) DEFINITIONS
  [-]  07: d with (0>>0>>19) DEFINITIONS
  [-]  08: s with (0>>0>>1) DEFINITIONS
  [-]  09: r with (0>>0>>20) DEFINITIONS
  ------------:----------------------------------------
Out[35]: pipescript containing 11 scripts with 8 executed[*]

note: XX>>YY>>ZZ represents the number of stored variables
     and the direction of propagation (inheritance from left)
     XX: number of definitions in the pipeline USER space
     YY: number of definitions in the script instance (frozen in the pipeline)
     ZZ: number of definitions in the script (frozen space)

    pipelines are executed sequentially (i.e. parameters can be multivalued)
        cmd = p.do()
        fullscript = p.script()

    pipelines are indexed
        cmd = p[[0,2]].do()
        cmd = p[0:2].do()
        cmd = p.do([0,2])

    pipelines can be reordered
        q = p[[2,0,1]]

    steps can be deleted
        p[[0,1]] = []

    clear all executions with
        p.clear()
        p.clear(idx=1,2)

    local USER space can be accessed via
    (affects only the considered step)
        p.USER[0].a = 1
        p.USER[0].b = [1 2]
        p.USER[0].c = "$ hello world"

    global USER space can accessed via
    (affects all steps onward)
        p.scripts[0].USER.a = 10
        p.scripts[0].USER.b = [10 20]
        p.scripts[0].USER.c = "$ bye bye"

    static definitions
        p.scripts[0].DEFINITIONS

    steps can be renamed with the method rename()

    syntaxes are à la Matlab:
        p = pipescript()
        p | i
        p = collection | G
        p[0]
        q = p | p
        q[0] = []
        p[0:1] = q[0:1]
        p = G | c | g | d | b | i | t | d | s | r
        p.rename(["G","c","g","d","b","i","t","d","s","r"])
        cmd = p.do([0,1,4,7])
        sp = p.script([0,1,4,7])
        r = collection | p

    join joins a list (static method)
        p = pipescript.join([p1,p2,s3,s4])


    Pending: mechanism to store LAMMPS results (dump3) in the pipeline

constructor

Expand source code
class pipescript:
    """
    pipescript: A Class for Managing Script Pipelines

    The `pipescript` class stores scripts in a pipeline where multiple scripts,
    script objects, or script object groups can be combined and executed
    sequentially. Scripts in the pipeline are executed using the pipe (`|`) operator,
    allowing for dynamic control over execution order, script concatenation, and
    variable management.

    Key Features:
    -------------
    - **Pipeline Construction**: Create pipelines of scripts, combining multiple
      script objects, `script`, `scriptobject`, or `scriptobjectgroup` instances.
      The pipe operator (`|`) is overloaded to concatenate scripts.
    - **Sequential Execution**: Execute all scripts in the pipeline in the order
      they were added, with support for reordering, selective execution, and
      clearing of individual steps.
    - **User and Definition Spaces**: Manage local and global user-defined variables
      (`USER` space) and static definitions for each script in the pipeline.
      Global definitions apply to all scripts in the pipeline, while local variables
      apply to specific steps.
    - **Flexible Script Handling**: Indexing, slicing, reordering, and renaming
      scripts in the pipeline are supported. Scripts can be accessed, replaced,
      and modified like array elements.

    Practical Use Cases:
    --------------------
    - **LAMMPS Script Automation**: Automate the generation of multi-step simulation
      scripts for LAMMPS, combining different simulation setups into a single pipeline.
    - **Script Management**: Combine and manage multiple scripts, tracking user
      variables and ensuring that execution order can be adjusted easily.
    - **Advanced Script Execution**: Perform partial pipeline execution, reorder
      steps, or clear completed steps while maintaining the original pipeline structure.

    Methods:
    --------
    __init__(self, s=None):
        Initializes a new `pipescript` object, optionally starting with a script
        or script-like object (`script`, `scriptobject`, `scriptobjectgroup`).

    setUSER(self, idx, key, value):
        Set a user-defined variable (`USER`) for the script at the specified index.

    getUSER(self, idx, key):
        Get the value of a user-defined variable (`USER`) for the script at the
        specified index.

    clear(self, idx=None):
        Clear the execution status of scripts in the pipeline, allowing them to
        be executed again.

    do(self, idx=None, printflag=True, verbosity=2, verbose=None, forced=False):
        Execute the pipeline or a subset of the pipeline, generating a combined
        LAMMPS-compatible script.

    script(self, idx=None, printflag=True, verbosity=2, verbose=None, forced=False):
        Generate the final LAMMPS script from the pipeline or a subset of the pipeline.

    rename(self, name="", idx=None):
        Rename the scripts in the pipeline, assigning new names to specific
        indices or all scripts.

    write(self, file, printflag=True, verbosity=2, verbose=None):
        Write the generated script to a file.

    dscript(self, verbose=None, **USER)
        Convert the current pipescript into a dscript object

    Static Methods:
    ---------------
    join(liste):
        Combine a list of `script` and `pipescript` objects into a single pipeline.

    Additional Features:
    --------------------
    - **Indexing and Slicing**: Use array-like indexing (`p[0]`, `p[1:3]`) to access
      and manipulate scripts in the pipeline.
    - **Deep Copy Support**: The pipeline supports deep copying, preserving the
      entire pipeline structure and its scripts.
    - **Verbose and Print Options**: Control verbosity and printing behavior for
      generated scripts, allowing for detailed output or minimal script generation.

    Original Content:
    -----------------
    The `pipescript` class supports a variety of pipeline operations, including:
    - Sequential execution with `cmd = p.do()`.
    - Reordering pipelines with `p[[2, 0, 1]]`.
    - Deleting steps with `p[[0, 1]] = []`.
    - Accessing local and global user space variables via `p.USER[idx].var` and
      `p.scripts[idx].USER.var`.
    - Managing static definitions for each script in the pipeline.
    - Example usage:
      ```
      p = pipescript()
      p | i
      p = G | c | g | d | b | i | t | d | s | r
      p.rename(["G", "c", "g", "d", "b", "i", "t", "d", "s", "r"])
      cmd = p.do([0, 1, 4, 7])
      sp = p.script([0, 1, 4, 7])
      ```
    - Scripts in the pipeline are executed sequentially, and definitions propagate
      from left to right. The `USER` space and `DEFINITIONS` are managed separately
      for each script in the pipeline.

    OVERVIEW
    -----------------
        Pipescript class stores scripts in pipelines
            By assuming: s0, s1, s2... scripts, scriptobject or scriptobjectgroup
            p = s0 | s1 | s2 generates a pipe script

            Example of pipeline:
          ------------:----------------------------------------
          [-]  00: G with (0>>0>>19) DEFINITIONS
          [-]  01: c with (0>>0>>10) DEFINITIONS
          [-]  02: g with (0>>0>>26) DEFINITIONS
          [-]  03: d with (0>>0>>19) DEFINITIONS
          [-]  04: b with (0>>0>>28) DEFINITIONS
          [-]  05: i with (0>>0>>49) DEFINITIONS
          [-]  06: t with (0>>0>>2) DEFINITIONS
          [-]  07: d with (0>>0>>19) DEFINITIONS
          [-]  08: s with (0>>0>>1) DEFINITIONS
          [-]  09: r with (0>>0>>20) DEFINITIONS
          ------------:----------------------------------------
        Out[35]: pipescript containing 11 scripts with 8 executed[*]

        note: XX>>YY>>ZZ represents the number of stored variables
             and the direction of propagation (inheritance from left)
             XX: number of definitions in the pipeline USER space
             YY: number of definitions in the script instance (frozen in the pipeline)
             ZZ: number of definitions in the script (frozen space)

            pipelines are executed sequentially (i.e. parameters can be multivalued)
                cmd = p.do()
                fullscript = p.script()

            pipelines are indexed
                cmd = p[[0,2]].do()
                cmd = p[0:2].do()
                cmd = p.do([0,2])

            pipelines can be reordered
                q = p[[2,0,1]]

            steps can be deleted
                p[[0,1]] = []

            clear all executions with
                p.clear()
                p.clear(idx=1,2)

            local USER space can be accessed via
            (affects only the considered step)
                p.USER[0].a = 1
                p.USER[0].b = [1 2]
                p.USER[0].c = "$ hello world"

            global USER space can accessed via
            (affects all steps onward)
                p.scripts[0].USER.a = 10
                p.scripts[0].USER.b = [10 20]
                p.scripts[0].USER.c = "$ bye bye"

            static definitions
                p.scripts[0].DEFINITIONS

            steps can be renamed with the method rename()

            syntaxes are à la Matlab:
                p = pipescript()
                p | i
                p = collection | G
                p[0]
                q = p | p
                q[0] = []
                p[0:1] = q[0:1]
                p = G | c | g | d | b | i | t | d | s | r
                p.rename(["G","c","g","d","b","i","t","d","s","r"])
                cmd = p.do([0,1,4,7])
                sp = p.script([0,1,4,7])
                r = collection | p

            join joins a list (static method)
                p = pipescript.join([p1,p2,s3,s4])


            Pending: mechanism to store LAMMPS results (dump3) in the pipeline
    """

    def __init__(self,s=None, name=None, printflag=False, verbose=True, verbosity = None):
        """ constructor """
        self.globalscript = None
        self.listscript = []
        self.listUSER = []
        self.printflag = printflag
        self.verbose = verbose if verbosity is None else verbosity>0
        self.verbosity = 0 if not verbose else verbosity
        self.cmd = ""
        if isinstance(s,script):
            self.listscript = [duplicate(s)]
            self.listUSER = [scriptdata()]
        elif isinstance(s,scriptobject):
            self.listscript = [scriptobjectgroup(s).script]
            self.listUSER = [scriptdata()]
        elif isinstance(s,scriptobjectgroup):
            self.listscript = [s.script]
            self.listUSER = [scriptdata()]
        else:
            ValueError("the argument should be a scriptobject or scriptobjectgroup")
        if s != None:
            self.name = [str(s)]
            self.executed = [False]
        else:
            self.name = []
            self.executed = []

    def setUSER(self,idx,key,value):
        """
            setUSER sets USER variables
            setUSER(idx,varname,varvalue)
        """
        if isinstance(idx,int) and (idx>=0) and (idx<self.n):
            self.listUSER[idx].setattr(key,value)
        else:
            raise IndexError(f"the index in the pipe should be comprised between 0 and {len(self)-1}")

    def getUSER(self,idx,key):
        """
            getUSER get USER variable
            getUSER(idx,varname)
        """
        if isinstance(idx,int) and (idx>=0) and (idx<self.n):
            self.listUSER[idx].getattr(key)
        else:
            raise IndexError(f"the index in the pipe should be comprised between 0 and {len(self)-1}")

    @property
    def USER(self):
        """
            p.USER[idx].var returns the value of the USER variable var
            p.USER[idx].var = val assigns the value val to the USER variable var
        """
        return self.listUSER  # override listuser

    @property
    def scripts(self):
        """
            p.scripts[idx].USER.var returns the value of the USER variable var
            p.scripts[idx].USER.var = val assigns the value val to the USER variable var
        """
        return self.listscript # override listuser

    def __add__(self,s):
        """ overload + as pipe with copy """
        if isinstance(s,pipescript):
            dup = deepduplicate(self)
            return dup | s      # + or | are synonyms
        else:
            raise TypeError("The operand should be a pipescript")

    def __iadd__(self,s):
        """ overload += as pipe without copy """
        if isinstance(s,pipescript):
            return self | s      # + or | are synonyms
        else:
            raise TypeError("The operand should be a pipescript")

    def __mul__(self,ntimes):
        """ overload * as multiple pipes with copy """
        if isinstance(self,pipescript):
            res = deepduplicate(self)
            if ntimes>1:
                for n in range(1,ntimes): res += self
            return res
        else:
            raise TypeError("The operand should be a pipescript")


    def __or__(self, s):
        """ Overload | pipe operator in pipescript """
        leftarg = deepduplicate(self)  # Make a deep copy of the current object
        # Local import only when dscript type needs to be checked
        from pizza.dscript import dscript
        from pizza.group import group, groupcollection
        from pizza.region import region
        # Convert rightarg to pipescript if needed
        if isinstance(s, dscript):
            rightarg = s.pipescript(printflag=s.printflag,verbose=s.verbose,verbosity=s.verbosity)  # Convert the dscript object to a pipescript
            native = False
        elif isinstance(s,script):
            rightarg = pipescript(s,printflag=s.printflag,verbose=s.verbose,verbosity=s.verbosity)  # Convert the script-like object into a pipescript
            native = False
        elif isinstance(s,(scriptobject,scriptobjectgroup)):
            rightarg = pipescript(s,printflag=self.printflag,verbose=self.verbose,verbosity=self.verbosity)  # Convert the script-like object into a pipescript
            native = False
        elif isinstance(s, group):
            stmp = s.script(printflag=s.printflag,verbose=s.verbose,verbosity=s.verbosity)
            rightarg = pipescript(stmp,printflag=s.printflag,verbose=s.verbose,verbosity=s.verbosity)  # Convert the script-like object into a pipescript
            native = False
        elif isinstance(s, groupcollection):
            stmp = s.script(printflag=self.printflag,verbose=self.verbose,verbosity=self.verbosity)
            rightarg = pipescript(stmp,printflag=self.printflag,verbose=self.verbose,verbosity=self.verbosity)  # Convert the script-like object into a pipescript
            native = False
        elif isinstance(s, region):
            rightarg = s.pipescript(printflag=self.printflag,verbose=self.verbose,verbosity=self.verbosity)  # Convert the script-like object into a pipescript
            native = False
        elif isinstance(s,pipescript):
            rightarg = s
            native = True
        else:
            raise TypeError(f"The operand should be a pipescript, dscript, script, scriptobject, scriptobjectgroup, group or groupcollection not {type(s)}")
        # Native piping
        if native:
            leftarg.listscript = leftarg.listscript + rightarg.listscript
            leftarg.listUSER = leftarg.listUSER + rightarg.listUSER
            leftarg.name = leftarg.name + rightarg.name
            for i in range(len(rightarg)):
                rightarg.executed[i] = False
            leftarg.executed = leftarg.executed + rightarg.executed
            return leftarg
        # Piping for non-native objects (dscript or script-like objects)
        else:
            # Loop through all items in rightarg and concatenate them
            for i in range(rightarg.n):
                leftarg.listscript.append(rightarg.listscript[i])
                leftarg.listUSER.append(rightarg.listUSER[i])
                leftarg.name.append(rightarg.name[i])
                leftarg.executed.append(False)
            return leftarg

    def __str__(self):
        """ string representation """
        return f"pipescript containing {self.n} scripts with {self.nrun} executed[*]"

    def __repr__(self):
        """ display method """
        line = "  "+"-"*12+":"+"-"*40
        if self.verbose:
            print("","Pipeline with %d scripts and" % self.n,
                  "D(STATIC:GLOBAL:LOCAL) DEFINITIONS",line,sep="\n")
        else:
            print(line)
        for i in range(len(self)):
            if self.executed[i]:
                state = "*"
            else:
                state = "-"
            print("%10s" % ("[%s]  %02d:" % (state,i)),
                  self.name[i],"with D(%2d:%2d:%2d)" % (
                       len(self.listscript[i].DEFINITIONS),
                       len(self.listscript[i].USER),
                       len(self.listUSER[i])                 )
                  )
        if self.verbose:
            print(line,"::: notes :::","p[i], p[i:j], p[[i,j]] copy pipeline segments",
                  "LOCAL: p.USER[i],p.USER[i].variable modify the user space of only p[i]",
                  "GLOBAL: p.scripts[i].USER.var to modify the user space from p[i] and onwards",
                  "STATIC: p.scripts[i].DEFINITIONS",
                  'p.rename(idx=range(2),name=["A","B"]), p.clear(idx=[0,3,4])',
                  "p.script(), p.script(idx=range(5)), p[0:5].script()","",sep="\n")
        else:
             print(line)
        return str(self)

    def __len__(self):
        """ len() method """
        return len(self.listscript)

    @property
    def n(self):
        """ number of scripts """
        return len(self)

    @property
    def nrun(self):
        """ number of scripts executed continuously from origin """
        n, nmax  = 0, len(self)
        while n<nmax and self.executed[n]: n+=1
        return n

    def __getitem__(self,idx):
        """ return the ith or slice element(s) of the pipe  """
        dup = deepduplicate(self)
        if isinstance(idx,slice):
            dup.listscript = dup.listscript[idx]
            dup.listUSER = dup.listUSER[idx]
            dup.name = dup.name[idx]
            dup.executed = dup.executed[idx]
        elif isinstance(idx,int):
            if idx<len(self):
                dup.listscript = dup.listscript[idx:idx+1]
                dup.listUSER = dup.listUSER[idx:idx+1]
                dup.name = dup.name[idx:idx+1]
                dup.executed = dup.executed[idx:idx+1]
            else:
                raise IndexError(f"the index in the pipe should be comprised between 0 and {len(self)-1}")
        elif isinstance(idx,list):
            dup.listscript = picker(dup.listscript,idx)
            dup.listUSER = picker(dup.listUSER,idx)
            dup.name = picker(dup.name,idx)
            dup.executed = picker(dup.executed,idx)
        else:
            raise IndexError("the index needs to be a slice or an integer")
        return dup

    def __setitem__(self,idx,s):
        """
            modify the ith element of the pipe
                p[4] = [] removes the 4th element
                p[4:7] = [] removes the elements from position 4 to 6
                p[2:4] = p[0:2] copy the elements 0 and 1 in positions 2 and 3
                p[[3,4]]=p[0]
        """
        if isinstance(s,(script,scriptobject,scriptobjectgroup)):
            dup = pipescript(s)
        elif isinstance(s,pipescript):
            dup = s
        elif s==[]:
            dup = []
        else:
            raise ValueError("the value must be a pipescript, script, scriptobject, scriptobjectgroup")
        if len(s)<1: # remove (delete)
            if isinstance(idx,slice) or idx<len(self):
                del self.listscript[idx]
                del self.listUSER[idx]
                del self.name[idx]
                del self.executed[idx]
            else:
                raise IndexError("the index must be a slice or an integer")
        elif len(s)==1: # scalar
            if isinstance(idx,int):
                if idx<len(self):
                    self.listscript[idx] = dup.listscript[0]
                    self.listUSER[idx] = dup.listUSER[0]
                    self.name[idx] = dup.name[0]
                    self.executed[idx] = False
                elif idx==len(self):
                    self.listscript.append(dup.listscript[0])
                    self.listUSER.append(dup.listUSER[0])
                    self.name.append(dup.name[0])
                    self.executed.append(False)
                else:
                    raise IndexError(f"the index must be ranged between 0 and {self.n}")
            elif isinstance(idx,list):
                for i in range(len(idx)):
                    self.__setitem__(idx[i], s) # call as a scalar
            elif isinstance(idx,slice):
                for i in range(*idx.indices(len(self)+1)):
                    self.__setitem__(i, s)
            else:
                raise IndexError("unrocognized index value, the index should be an integer or a slice")
        else: # many values
            if isinstance(idx,list): # list call à la Matlab
                if len(idx)==len(s):
                    for i in range(len(s)):
                        self.__setitem__(idx[i], s[i]) # call as a scalar
                else:
                    raise IndexError(f"the number of indices {len(list)} does not match the number of values {len(s)}")
            elif isinstance(idx,slice):
                ilist = list(range(*idx.indices(len(self)+len(s))))
                self.__setitem__(ilist, s) # call as a list
            else:
                raise IndexError("unrocognized index value, the index should be an integer or a slice")

    def rename(self,name="",idx=None):
        """
            rename scripts in the pipe
                p.rename(idx=[0,2,3],name=["A","B","C"])
        """
        if isinstance(name,list):
            if len(name)==len(self) and idx==None:
                self.name = name
            elif len(name) == len(idx):
                for i in range(len(idx)):
                    self.rename(name[i],idx[i])
            else:
                IndexError(f"the number of indices {len(idx)} does not match the number of names {len(name)}")
        elif idx !=None and idx<len(self) and name!="":
            self.name[idx] = name
        else:
            raise ValueError("provide a non empty name and valid index")

    def clear(self,idx=None):
        if len(self)>0:
            if idx==None:
                for i in range(len(self)):
                    self.clear(i)
            else:
                if isinstance(idx,(range,list)):
                    for i in idx:
                        self.clear(idx=i)
                elif isinstance(idx,int) and idx<len(self):
                    self.executed[idx] = False
                else:
                    raise IndexError(f"the index should be ranged between 0 and {self.n-1}")
            if not self.executed[0]:
                self.globalscript = None
                self.cmd = ""



    def do(self, idx=None, printflag=True, verbosity=2, verbose=None, forced=False):
        """
        Execute the pipeline or a part of the pipeline and generate the LAMMPS script.

        Parameters:
            idx (list, range, or int, optional): Specifies which steps of the pipeline to execute.
            printflag (bool, optional): Whether to print the script for each step. Default is True.
            verbosity (int, optional): Level of verbosity for the output.
            verbose (bool, optional): Override for verbosity. If False, sets verbosity to 0.
            forced (bool, optional): If True, forces the pipeline to regenerate all scripts.

        Returns:
            str: Combined LAMMPS script for the specified pipeline steps.

            Execute the pipeline or a part of the pipeline and generate the LAMMPS script.

            This method processes the pipeline of script objects, executing each step to generate
            a combined LAMMPS-compatible script. The execution can be done for the entire pipeline
            or for a specified range of indices. The generated script can include comments and
            metadata based on the verbosity level.


        Method Workflow:
            - The method first checks if there are any script objects in the pipeline.
              If the pipeline is empty, it returns a message indicating that there is nothing to execute.
            - It determines the start and stop indices for the range of steps to execute.
              If idx is not provided, it defaults to executing all steps from the last executed position.
            - If a specific index or list of indices is provided, it executes only those steps.
            - The pipeline steps are executed in order, combining the scripts using the
              >> operator for sequential execution.
            - The generated script includes comments indicating the current run step and pipeline range,
              based on the specified verbosity level.
            - The final combined script is returned as a string.

        Example Usage:
        --------------
            >>> p = pipescript()
            >>> # Execute the entire pipeline
            >>> full_script = p.do()
            >>> # Execute steps 0 and 2 only
            >>> partial_script = p.do([0, 2])
            >>> # Execute step 1 with minimal verbosity
            >>> minimal_script = p.do(idx=1, verbosity=0)

            Notes:
            - The method uses modular arithmetic to handle index wrapping, allowing
              for cyclic execution of pipeline steps.
            - If the pipeline is empty, the method returns the string "# empty pipe - nothing to do".
            - The globalscript is initialized or updated with each step's script,
              and the USER definitions are accumulated across the steps.
            - The command string self.cmd is updated with the generated script for
              each step in the specified range.

            Raises:
            - None: The method does not raise exceptions directly, but an empty pipeline will
                    result in the return of "# empty pipe - nothing to do".
        """
        verbosity = 0 if verbose is False else verbosity
        if len(self) == 0:
            return "# empty pipe - nothing to do"

        # Check if not all steps are executed or if there are gaps
        not_all_executed = not all(self.executed[:self.nrun])  # Check up to the last executed step

        # Determine pipeline range
        total_steps = len(self)
        if self.globalscript is None or forced or not_all_executed:
            start = 0
            self.cmd = ""
        else:
            start = self.nrun
            self.cmd = self.cmd.rstrip("\n") + "\n\n"

        if idx is None:
            idx = range(start, total_steps)
        if isinstance(idx, int):
            idx = [idx]
        if isinstance(idx, range):
            idx = list(idx)

        idx = [i % total_steps for i in idx]
        start, stop = min(idx), max(idx)

        # Prevent re-executing already completed steps
        if not forced:
            idx = [step for step in idx if not self.executed[step]]

        # Execute pipeline steps
        for step in idx:
            step_wrapped = step % total_steps

            # Combine scripts
            if step_wrapped == 0:
                self.globalscript = self.listscript[step_wrapped]
            else:
                self.globalscript = self.globalscript >> self.listscript[step_wrapped]

            # Step label
            step_name = f"<{self.name[step]}>"
            step_label = f"# [{step+1} of {total_steps} from {start}:{stop}] {step_name}"

            # Get script content for the step
            step_output = self.globalscript.do(printflag=printflag, verbose=verbosity > 1)

            # Add comments and content
            if step_output.strip():
                self.cmd += f"{step_label}\n{step_output.strip()}\n\n"
            elif verbosity > 0:
                self.cmd += f"{step_label} :: no content\n\n"

            # Update USER definitions
            self.globalscript.USER += self.listUSER[step]
            self.executed[step] = True

        # Clean up and finalize script
        self.cmd = self.cmd.replace("\\n", "\n").strip()  # Remove literal \\n and extra spaces
        self.cmd += "\n"  # Ensure trailing newline
        return remove_comments(self.cmd) if verbosity == 0 else self.cmd


    def do_legacy(self, idx=None, printflag=True, verbosity=2, verbose=None, forced=False):
        """
        Execute the pipeline or a part of the pipeline and generate the LAMMPS script.

        This method processes the pipeline of script objects, executing each step to generate
        a combined LAMMPS-compatible script. The execution can be done for the entire pipeline
        or for a specified range of indices. The generated script can include comments and
        metadata based on the verbosity level.

        Parameters:
        - idx (list, range, or int, optional): Specifies which steps of the pipeline to execute.
                                               If None, all steps from the current position to
                                               the end are executed. A list of indices can be
                                               provided to execute specific steps, or a single
                                               integer can be passed to execute a specific step.
                                               Default is None.
        - printflag (bool, optional): If True, the generated script for each step is printed
                                      to the console. Default is True.
        - verbosity (int, optional): Controls the level of detail in the generated script.
                                     - 0: Minimal output, no comments.
                                     - 1: Basic comments for run steps.
                                     - 2: Detailed comments with additional information.
                                     Default is 2.
        - forced (bool, optional): If True, all scripts are regenerated

        Returns:
        - str: The combined LAMMPS script generated from the specified steps of the pipeline.

        Method Workflow:
        - The method first checks if there are any script objects in the pipeline.
          If the pipeline is empty, it returns a message indicating that there is nothing to execute.
        - It determines the start and stop indices for the range of steps to execute.
          If idx is not provided, it defaults to executing all steps from the last executed position.
        - If a specific index or list of indices is provided, it executes only those steps.
        - The pipeline steps are executed in order, combining the scripts using the
          >> operator for sequential execution.
        - The generated script includes comments indicating the current run step and pipeline range,
          based on the specified verbosity level.
        - The final combined script is returned as a string.

        Example Usage:
        --------------
        >>> p = pipescript()
        >>> # Execute the entire pipeline
        >>> full_script = p.do()
        >>> # Execute steps 0 and 2 only
        >>> partial_script = p.do([0, 2])
        >>> # Execute step 1 with minimal verbosity
        >>> minimal_script = p.do(idx=1, verbosity=0)

        Notes:
        - The method uses modular arithmetic to handle index wrapping, allowing
          for cyclic execution of pipeline steps.
        - If the pipeline is empty, the method returns the string "# empty pipe - nothing to do".
        - The globalscript is initialized or updated with each step's script,
          and the USER definitions are accumulated across the steps.
        - The command string self.cmd is updated with the generated script for
          each step in the specified range.

        Raises:
        - None: The method does not raise exceptions directly, but an empty pipeline will
                result in the return of "# empty pipe - nothing to do".
        """

        verbosity = 0 if verbose is False else verbosity
        if len(self)>0:
            # ranges
            ntot = len(self)
            stop = ntot-1
            if (self.globalscript == None) or (self.globalscript == []) or not self.executed[0] or forced:
                start = 0
                self.cmd = ""
            else:
                start = self.nrun
            if start>stop: return self.cmd
            if idx is None: idx = range(start,stop+1)
            if isinstance(idx,range): idx = list(idx)
            if isinstance(idx,int): idx = [idx]
            start,stop = min(idx),max(idx)
            # do
            for i in idx:
                j = i % ntot
                if j==0:
                    self.globalscript = self.listscript[j]
                else:
                    self.globalscript = self.globalscript >> self.listscript[j]
                name = "  "+self.name[i]+"  "
                if verbosity>0:
                    self.cmd += "\n\n#\t --- run step [%d/%d] --- [%s]  %20s\n" % \
                            (j,ntot-1,name.center(50,"="),"pipeline [%d]-->[%d]" %(start,stop))
                else:
                    self.cmd +="\n"
                self.globalscript.USER = self.globalscript.USER + self.listUSER[j]
                self.cmd += self.globalscript.do(printflag=printflag,verbose=verbosity>1)
                self.executed[i] = True
            self.cmd = self.cmd.replace("\\n", "\n") # remove literal \\n if any (dscript.save add \\n)
            return remove_comments(self.cmd) if verbosity==0 else self.cmd
        else:
            return "# empty pipe - nothing to do"


    def script(self,idx=None, printflag=True, verbosity=2, verbose=None, forced=False, style=4):
        """
            script the pipeline or parts of the pipeline
                s = p.script()
                s = p.script([0,2])

        Parameters:
        - idx (list, range, or int, optional): Specifies which steps of the pipeline to execute.
                                               If None, all steps from the current position to
                                               the end are executed. A list of indices can be
                                               provided to execute specific steps, or a single
                                               integer can be passed to execute a specific step.
                                               Default is None.
        - printflag (bool, optional): If True, the generated script for each step is printed
                                      to the console. Default is True.
        - verbosity (int, optional): Controls the level of detail in the generated script.
                                     - 0: Minimal output, no comments.
                                     - 1: Basic comments for run steps.
                                     - 2: Detailed comments with additional information.
                                     Default is 2.
        - forced (bool, optional): If True, all scripts are regenerated
        - style (int, optional):
            Defines the ASCII frame style for the header.
            Valid values are integers from 1 to 6, corresponding to predefined styles:
                1. Basic box with `+`, `-`, and `|`
                2. Double-line frame with `╔`, `═`, and `║`
                3. Rounded corners with `.`, `'`, `-`, and `|`
                4. Thick outer frame with `#`, `=`, and `#`
                5. Box drawing characters with `┌`, `─`, and `│`
                6. Minimalist dotted frame with `.`, `:`, and `.`
            Default is `4` (thick outer frame).

        """
        printflag = self.printflag if printflag is None else printflag
        verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
        verbosity=0 if verbose is False else verbosity
        s = script(printflag=printflag, verbose=verbosity>0)
        s.name = "pipescript"
        s.description = "pipeline with %d scripts" % len(self)
        if len(self)>1:
            s.userid = self.name[0]+"->"+self.name[-1]
        elif len(self)==1:
            s.userid = self.name[0]
        else:
            s.userid = "empty pipeline"
        s.TEMPLATE = self.header(verbosity=verbosity, style=style) + "\n" +\
            self.do(idx, printflag=printflag, verbosity=verbosity, verbose=verbose, forced=forced)
        s.DEFINITIONS = duplicate(self.globalscript.DEFINITIONS)
        s.USER = duplicate(self.globalscript.USER)
        return s

    @staticmethod
    def join(liste):
        """
            join a combination scripts and pipescripts within a pipescript
                p = pipescript.join([s1,s2,p3,p4,p5...])
        """
        if not isinstance(liste,list):
            raise ValueError("the argument should be a list")
        ok = True
        for i in range(len(liste)):
            ok = ok and isinstance(liste[i],(script,pipescript))
            if not ok:
                raise ValueError(f"the entry [{i}] should be a script or pipescript")
        if len(liste)<1:
            return liste
        out = liste[0]
        for i in range(1,len(liste)):
            out = out | liste[i]
        return out

    # Note that it was not the original intent to copy pipescripts
    def __copy__(self):
        """ copy method """
        cls = self.__class__
        copie = cls.__new__(cls)
        copie.__dict__.update(self.__dict__)
        return copie

    def __deepcopy__(self, memo):
        """ deep copy method """
        cls = self.__class__
        copie = cls.__new__(cls)
        memo[id(self)] = copie
        for k, v in self.__dict__.items():
            setattr(copie, k, deepduplicate(v, memo))
        return copie

    # write file
    def write(self, file, printflag=False, verbosity=2, verbose=None, overwrite=False):
       """
       Write the combined script to a file.

       Parameters:
           file (str): The file path where the script will be saved.
           printflag (bool): Flag to enable/disable printing of details.
           verbosity (int): Level of verbosity for the script generation.
           verbose (bool or None): If True, enables verbose mode; if None, defaults to the instance's verbosity.
           overwrite (bool): Whether to overwrite the file if it already exists. Default is False.

        Returns:
            str: The full absolute path of the file written.

       Raises:
           FileExistsError: If the file already exists and overwrite is False.

       Notes:
           - This method combines the individual scripts within the `pipescript` object
             and saves the resulting script to the specified file.
           - If `overwrite` is False and the file exists, an error is raised.
           - If `verbose` is True and the file is overwritten, a warning is displayed.
       """
       # Generate the combined script
       myscript = self.script(printflag=printflag, verbosity=verbosity, verbose=verbose, forced=True)
       # Call the script's write method with the overwrite parameter
       return myscript.write(file, printflag=printflag, verbose=verbose, overwrite=overwrite)


    def dscript(self, name=None, printflag=None, verbose=None, verbosity=None, **USER):
        """
        Convert the current pipescript object to a dscript object.

        This method merges the STATIC, GLOBAL, and LOCAL variable spaces from each step
        in the pipescript into a single dynamic script per step in the dscript.
        Each step in the pipescript is transformed into a dynamic script in the dscript,
        where variable spaces are combined using the following order:

        1. STATIC: Definitions specific to each script in the pipescript.
        2. GLOBAL: User variables shared across steps from a specific point onwards.
        3. LOCAL: User variables for each individual step.

        Parameters:
        -----------
        verbose : bool, optional
            Controls verbosity of the dynamic scripts in the resulting dscript object.
            If None, the verbosity setting of the pipescript will be used.

        **USER : scriptobjectdata(), optional
            Additional user-defined variables that can override existing static variables
            in the dscript object or be added to it.

        Returns:
        --------
        outd : dscript
            A dscript object that contains all steps of the pipescript as dynamic scripts.
            Each step from the pipescript is added as a dynamic script with the same content
            and combined variable spaces.
        """
        # Local imports
        from pizza.dscript import dscript, ScriptTemplate, lambdaScriptdata

        # verbosity
        printflag = self.printflag if printflag is None else printflag
        verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
        verbosity = 0 if not verbose else verbosity

        # Adjust name
        if name is None:
            if isinstance(self.name, str):
                name = self.name
            elif isinstance(self.name, list):
                name = (
                    self.name[0] if len(self.name) == 1 else self.name[0] + "..." + self.name[-1]
                )

        # Create the dscript container with the pipescript name as the userid
        outd = dscript(userid=name, verbose=self.verbose, **USER)

        # Initialize static merged definitions
        staticmerged_definitions = lambdaScriptdata()

        # Track used variables per step
        step_used_variables = []

        # Loop over each step in the pipescript
        for i, script in enumerate(self.listscript):
            # Merge STATIC, GLOBAL, and LOCAL variables for the current step
            static_vars = self.listUSER[i] # script.DEFINITIONS
            global_vars = script.DEFINITIONS # self.scripts[i].USER
            local_vars = script.USER # self.USER[i]
            refreshed_globalvars = static_vars + global_vars

            # Detect variables used in the current template
            used_variables = set(script.detect_variables())
            step_used_variables.append(used_variables)  # Track used variables for this step

            # Copy all current variables to local_static_updates and remove matching variables from staticmerged_definitions
            local_static_updates = lambdaScriptdata(**local_vars)

            for var, value in refreshed_globalvars.items():
                if var in staticmerged_definitions:
                    if (getattr(staticmerged_definitions, var) != value) and (var not in local_vars):
                        setattr(local_static_updates, var, value)
                else:
                    setattr(staticmerged_definitions, var, value)

           # Create the dynamic script for this step using the method in dscript
            key_name = i  # Use the index 'i' as the key in TEMPLATE
            content = script.TEMPLATE

            # Use the helper method in dscript to add this dynamic script
            outd.add_dynamic_script(
                key=key_name,
                content=content,
                definitions = lambdaScriptdata(**local_static_updates),
                verbose=self.verbose if verbose is None else verbose,
                userid=self.name[i]
            )

            # Set eval=True only if variables are detected in the template
            if outd.TEMPLATE[key_name].detect_variables():
                outd.TEMPLATE[key_name].eval = True

        # Compute the union of all used variables across all steps
        global_used_variables = set().union(*step_used_variables)

        # Filter staticmerged_definitions to keep only variables that are used
        filtered_definitions = {
            var: value for var, value in staticmerged_definitions.items() if var in global_used_variables
        }

        # Assign the filtered definitions along with USER variables to outd.DEFINITIONS
        outd.DEFINITIONS = lambdaScriptdata(**filtered_definitions)

        return outd


    def header(self, verbose=True,verbosity=None, style=4):
        """
        Generate a formatted header for the pipescript file.

        Parameters:
            verbosity (bool, optional): If specified, overrides the instance's `verbose` setting.
                                        Defaults to the instance's `verbose`.
            style (int from 1 to 6, optional): ASCII style to frame the header (default=4)

        Returns:
            str: A formatted string representing the pipescript object.
                 Returns an empty string if verbosity is False.

        The header includes:
            - Total number of scripts in the pipeline.
            - The verbosity setting.
            - The range of scripts from the first to the last script.
            - All enclosed within an ASCII frame that adjusts to the content.
        """
        verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
        verbosity = 0 if not verbose else verbosity
        if not verbosity:
            return ""

        # Prepare the header content
        lines = [
            f"PIPESCRIPT with {self.n} scripts | Verbosity: {verbosity}",
            "",
            f"From: <{str(self.scripts[0])}> To: <{str(self.scripts[-1])}>",
        ]

        # Use the shared method to format the header
        return frame_header(lines,style=style)

Static methods

def join(liste)

join a combination scripts and pipescripts within a pipescript p = pipescript.join([s1,s2,p3,p4,p5…])

Expand source code
@staticmethod
def join(liste):
    """
        join a combination scripts and pipescripts within a pipescript
            p = pipescript.join([s1,s2,p3,p4,p5...])
    """
    if not isinstance(liste,list):
        raise ValueError("the argument should be a list")
    ok = True
    for i in range(len(liste)):
        ok = ok and isinstance(liste[i],(script,pipescript))
        if not ok:
            raise ValueError(f"the entry [{i}] should be a script or pipescript")
    if len(liste)<1:
        return liste
    out = liste[0]
    for i in range(1,len(liste)):
        out = out | liste[i]
    return out

Instance variables

var USER

p.USER[idx].var returns the value of the USER variable var p.USER[idx].var = val assigns the value val to the USER variable var

Expand source code
@property
def USER(self):
    """
        p.USER[idx].var returns the value of the USER variable var
        p.USER[idx].var = val assigns the value val to the USER variable var
    """
    return self.listUSER  # override listuser
var n

number of scripts

Expand source code
@property
def n(self):
    """ number of scripts """
    return len(self)
var nrun

number of scripts executed continuously from origin

Expand source code
@property
def nrun(self):
    """ number of scripts executed continuously from origin """
    n, nmax  = 0, len(self)
    while n<nmax and self.executed[n]: n+=1
    return n
var scripts

p.scripts[idx].USER.var returns the value of the USER variable var p.scripts[idx].USER.var = val assigns the value val to the USER variable var

Expand source code
@property
def scripts(self):
    """
        p.scripts[idx].USER.var returns the value of the USER variable var
        p.scripts[idx].USER.var = val assigns the value val to the USER variable var
    """
    return self.listscript # override listuser

Methods

def clear(self, idx=None)
Expand source code
def clear(self,idx=None):
    if len(self)>0:
        if idx==None:
            for i in range(len(self)):
                self.clear(i)
        else:
            if isinstance(idx,(range,list)):
                for i in idx:
                    self.clear(idx=i)
            elif isinstance(idx,int) and idx<len(self):
                self.executed[idx] = False
            else:
                raise IndexError(f"the index should be ranged between 0 and {self.n-1}")
        if not self.executed[0]:
            self.globalscript = None
            self.cmd = ""
def do(self, idx=None, printflag=True, verbosity=2, verbose=None, forced=False)

Execute the pipeline or a part of the pipeline and generate the LAMMPS script.

Parameters

idx (list, range, or int, optional): Specifies which steps of the pipeline to execute. printflag (bool, optional): Whether to print the script for each step. Default is True. verbosity (int, optional): Level of verbosity for the output. verbose (bool, optional): Override for verbosity. If False, sets verbosity to 0. forced (bool, optional): If True, forces the pipeline to regenerate all scripts.

Returns

str
Combined LAMMPS script for the specified pipeline steps.

Execute the pipeline or a part of the pipeline and generate the LAMMPS script.

This method processes the pipeline of script objects, executing each step to generate a combined LAMMPS-compatible script. The execution can be done for the entire pipeline or for a specified range of indices. The generated script can include comments and metadata based on the verbosity level. Method Workflow: - The method first checks if there are any script objects in the pipeline. If the pipeline is empty, it returns a message indicating that there is nothing to execute. - It determines the start and stop indices for the range of steps to execute. If idx is not provided, it defaults to executing all steps from the last executed position. - If a specific index or list of indices is provided, it executes only those steps. - The pipeline steps are executed in order, combining the scripts using the >> operator for sequential execution. - The generated script includes comments indicating the current run step and pipeline range, based on the specified verbosity level. - The final combined script is returned as a string.

Example Usage:

>>> p = pipescript()
>>> # Execute the entire pipeline
>>> full_script = p.do()
>>> # Execute steps 0 and 2 only
>>> partial_script = p.do([0, 2])
>>> # Execute step 1 with minimal verbosity
>>> minimal_script = p.do(idx=1, verbosity=0)

Notes:
- The method uses modular arithmetic to handle index wrapping, allowing
  for cyclic execution of pipeline steps.
- If the pipeline is empty, the method returns the string "# empty pipe - nothing to do".
- The globalscript is initialized or updated with each step's script,
  and the USER definitions are accumulated across the steps.
- The command string self.cmd is updated with the generated script for
  each step in the specified range.

Raises:
- None: The method does not raise exceptions directly, but an empty pipeline will
        result in the return of "# empty pipe - nothing to do".
Expand source code
def do(self, idx=None, printflag=True, verbosity=2, verbose=None, forced=False):
    """
    Execute the pipeline or a part of the pipeline and generate the LAMMPS script.

    Parameters:
        idx (list, range, or int, optional): Specifies which steps of the pipeline to execute.
        printflag (bool, optional): Whether to print the script for each step. Default is True.
        verbosity (int, optional): Level of verbosity for the output.
        verbose (bool, optional): Override for verbosity. If False, sets verbosity to 0.
        forced (bool, optional): If True, forces the pipeline to regenerate all scripts.

    Returns:
        str: Combined LAMMPS script for the specified pipeline steps.

        Execute the pipeline or a part of the pipeline and generate the LAMMPS script.

        This method processes the pipeline of script objects, executing each step to generate
        a combined LAMMPS-compatible script. The execution can be done for the entire pipeline
        or for a specified range of indices. The generated script can include comments and
        metadata based on the verbosity level.


    Method Workflow:
        - The method first checks if there are any script objects in the pipeline.
          If the pipeline is empty, it returns a message indicating that there is nothing to execute.
        - It determines the start and stop indices for the range of steps to execute.
          If idx is not provided, it defaults to executing all steps from the last executed position.
        - If a specific index or list of indices is provided, it executes only those steps.
        - The pipeline steps are executed in order, combining the scripts using the
          >> operator for sequential execution.
        - The generated script includes comments indicating the current run step and pipeline range,
          based on the specified verbosity level.
        - The final combined script is returned as a string.

    Example Usage:
    --------------
        >>> p = pipescript()
        >>> # Execute the entire pipeline
        >>> full_script = p.do()
        >>> # Execute steps 0 and 2 only
        >>> partial_script = p.do([0, 2])
        >>> # Execute step 1 with minimal verbosity
        >>> minimal_script = p.do(idx=1, verbosity=0)

        Notes:
        - The method uses modular arithmetic to handle index wrapping, allowing
          for cyclic execution of pipeline steps.
        - If the pipeline is empty, the method returns the string "# empty pipe - nothing to do".
        - The globalscript is initialized or updated with each step's script,
          and the USER definitions are accumulated across the steps.
        - The command string self.cmd is updated with the generated script for
          each step in the specified range.

        Raises:
        - None: The method does not raise exceptions directly, but an empty pipeline will
                result in the return of "# empty pipe - nothing to do".
    """
    verbosity = 0 if verbose is False else verbosity
    if len(self) == 0:
        return "# empty pipe - nothing to do"

    # Check if not all steps are executed or if there are gaps
    not_all_executed = not all(self.executed[:self.nrun])  # Check up to the last executed step

    # Determine pipeline range
    total_steps = len(self)
    if self.globalscript is None or forced or not_all_executed:
        start = 0
        self.cmd = ""
    else:
        start = self.nrun
        self.cmd = self.cmd.rstrip("\n") + "\n\n"

    if idx is None:
        idx = range(start, total_steps)
    if isinstance(idx, int):
        idx = [idx]
    if isinstance(idx, range):
        idx = list(idx)

    idx = [i % total_steps for i in idx]
    start, stop = min(idx), max(idx)

    # Prevent re-executing already completed steps
    if not forced:
        idx = [step for step in idx if not self.executed[step]]

    # Execute pipeline steps
    for step in idx:
        step_wrapped = step % total_steps

        # Combine scripts
        if step_wrapped == 0:
            self.globalscript = self.listscript[step_wrapped]
        else:
            self.globalscript = self.globalscript >> self.listscript[step_wrapped]

        # Step label
        step_name = f"<{self.name[step]}>"
        step_label = f"# [{step+1} of {total_steps} from {start}:{stop}] {step_name}"

        # Get script content for the step
        step_output = self.globalscript.do(printflag=printflag, verbose=verbosity > 1)

        # Add comments and content
        if step_output.strip():
            self.cmd += f"{step_label}\n{step_output.strip()}\n\n"
        elif verbosity > 0:
            self.cmd += f"{step_label} :: no content\n\n"

        # Update USER definitions
        self.globalscript.USER += self.listUSER[step]
        self.executed[step] = True

    # Clean up and finalize script
    self.cmd = self.cmd.replace("\\n", "\n").strip()  # Remove literal \\n and extra spaces
    self.cmd += "\n"  # Ensure trailing newline
    return remove_comments(self.cmd) if verbosity == 0 else self.cmd
def do_legacy(self, idx=None, printflag=True, verbosity=2, verbose=None, forced=False)

Execute the pipeline or a part of the pipeline and generate the LAMMPS script.

This method processes the pipeline of script objects, executing each step to generate a combined LAMMPS-compatible script. The execution can be done for the entire pipeline or for a specified range of indices. The generated script can include comments and metadata based on the verbosity level.

Parameters: - idx (list, range, or int, optional): Specifies which steps of the pipeline to execute. If None, all steps from the current position to the end are executed. A list of indices can be provided to execute specific steps, or a single integer can be passed to execute a specific step. Default is None. - printflag (bool, optional): If True, the generated script for each step is printed to the console. Default is True. - verbosity (int, optional): Controls the level of detail in the generated script. - 0: Minimal output, no comments. - 1: Basic comments for run steps. - 2: Detailed comments with additional information. Default is 2. - forced (bool, optional): If True, all scripts are regenerated

Returns: - str: The combined LAMMPS script generated from the specified steps of the pipeline.

Method Workflow: - The method first checks if there are any script objects in the pipeline. If the pipeline is empty, it returns a message indicating that there is nothing to execute. - It determines the start and stop indices for the range of steps to execute. If idx is not provided, it defaults to executing all steps from the last executed position. - If a specific index or list of indices is provided, it executes only those steps. - The pipeline steps are executed in order, combining the scripts using the

operator for sequential execution. - The generated script includes comments indicating the current run step and pipeline range, based on the specified verbosity level. - The final combined script is returned as a string.

Example Usage:

>>> p = pipescript()
>>> # Execute the entire pipeline
>>> full_script = p.do()
>>> # Execute steps 0 and 2 only
>>> partial_script = p.do([0, 2])
>>> # Execute step 1 with minimal verbosity
>>> minimal_script = p.do(idx=1, verbosity=0)

Notes: - The method uses modular arithmetic to handle index wrapping, allowing for cyclic execution of pipeline steps. - If the pipeline is empty, the method returns the string "# empty pipe - nothing to do". - The globalscript is initialized or updated with each step's script, and the USER definitions are accumulated across the steps. - The command string self.cmd is updated with the generated script for each step in the specified range.

Raises: - None: The method does not raise exceptions directly, but an empty pipeline will result in the return of "# empty pipe - nothing to do".

Expand source code
def do_legacy(self, idx=None, printflag=True, verbosity=2, verbose=None, forced=False):
    """
    Execute the pipeline or a part of the pipeline and generate the LAMMPS script.

    This method processes the pipeline of script objects, executing each step to generate
    a combined LAMMPS-compatible script. The execution can be done for the entire pipeline
    or for a specified range of indices. The generated script can include comments and
    metadata based on the verbosity level.

    Parameters:
    - idx (list, range, or int, optional): Specifies which steps of the pipeline to execute.
                                           If None, all steps from the current position to
                                           the end are executed. A list of indices can be
                                           provided to execute specific steps, or a single
                                           integer can be passed to execute a specific step.
                                           Default is None.
    - printflag (bool, optional): If True, the generated script for each step is printed
                                  to the console. Default is True.
    - verbosity (int, optional): Controls the level of detail in the generated script.
                                 - 0: Minimal output, no comments.
                                 - 1: Basic comments for run steps.
                                 - 2: Detailed comments with additional information.
                                 Default is 2.
    - forced (bool, optional): If True, all scripts are regenerated

    Returns:
    - str: The combined LAMMPS script generated from the specified steps of the pipeline.

    Method Workflow:
    - The method first checks if there are any script objects in the pipeline.
      If the pipeline is empty, it returns a message indicating that there is nothing to execute.
    - It determines the start and stop indices for the range of steps to execute.
      If idx is not provided, it defaults to executing all steps from the last executed position.
    - If a specific index or list of indices is provided, it executes only those steps.
    - The pipeline steps are executed in order, combining the scripts using the
      >> operator for sequential execution.
    - The generated script includes comments indicating the current run step and pipeline range,
      based on the specified verbosity level.
    - The final combined script is returned as a string.

    Example Usage:
    --------------
    >>> p = pipescript()
    >>> # Execute the entire pipeline
    >>> full_script = p.do()
    >>> # Execute steps 0 and 2 only
    >>> partial_script = p.do([0, 2])
    >>> # Execute step 1 with minimal verbosity
    >>> minimal_script = p.do(idx=1, verbosity=0)

    Notes:
    - The method uses modular arithmetic to handle index wrapping, allowing
      for cyclic execution of pipeline steps.
    - If the pipeline is empty, the method returns the string "# empty pipe - nothing to do".
    - The globalscript is initialized or updated with each step's script,
      and the USER definitions are accumulated across the steps.
    - The command string self.cmd is updated with the generated script for
      each step in the specified range.

    Raises:
    - None: The method does not raise exceptions directly, but an empty pipeline will
            result in the return of "# empty pipe - nothing to do".
    """

    verbosity = 0 if verbose is False else verbosity
    if len(self)>0:
        # ranges
        ntot = len(self)
        stop = ntot-1
        if (self.globalscript == None) or (self.globalscript == []) or not self.executed[0] or forced:
            start = 0
            self.cmd = ""
        else:
            start = self.nrun
        if start>stop: return self.cmd
        if idx is None: idx = range(start,stop+1)
        if isinstance(idx,range): idx = list(idx)
        if isinstance(idx,int): idx = [idx]
        start,stop = min(idx),max(idx)
        # do
        for i in idx:
            j = i % ntot
            if j==0:
                self.globalscript = self.listscript[j]
            else:
                self.globalscript = self.globalscript >> self.listscript[j]
            name = "  "+self.name[i]+"  "
            if verbosity>0:
                self.cmd += "\n\n#\t --- run step [%d/%d] --- [%s]  %20s\n" % \
                        (j,ntot-1,name.center(50,"="),"pipeline [%d]-->[%d]" %(start,stop))
            else:
                self.cmd +="\n"
            self.globalscript.USER = self.globalscript.USER + self.listUSER[j]
            self.cmd += self.globalscript.do(printflag=printflag,verbose=verbosity>1)
            self.executed[i] = True
        self.cmd = self.cmd.replace("\\n", "\n") # remove literal \\n if any (dscript.save add \\n)
        return remove_comments(self.cmd) if verbosity==0 else self.cmd
    else:
        return "# empty pipe - nothing to do"
def dscript(self, name=None, printflag=None, verbose=None, verbosity=None, **USER)

Convert the current pipescript object to a dscript object.

This method merges the STATIC, GLOBAL, and LOCAL variable spaces from each step in the pipescript into a single dynamic script per step in the dscript. Each step in the pipescript is transformed into a dynamic script in the dscript, where variable spaces are combined using the following order:

  1. STATIC: Definitions specific to each script in the pipescript.
  2. GLOBAL: User variables shared across steps from a specific point onwards.
  3. LOCAL: User variables for each individual step.

Parameters:

verbose : bool, optional Controls verbosity of the dynamic scripts in the resulting dscript object. If None, the verbosity setting of the pipescript will be used.

**USER : scriptobjectdata(), optional Additional user-defined variables that can override existing static variables in the dscript object or be added to it.

Returns:

outd : dscript A dscript object that contains all steps of the pipescript as dynamic scripts. Each step from the pipescript is added as a dynamic script with the same content and combined variable spaces.

Expand source code
def dscript(self, name=None, printflag=None, verbose=None, verbosity=None, **USER):
    """
    Convert the current pipescript object to a dscript object.

    This method merges the STATIC, GLOBAL, and LOCAL variable spaces from each step
    in the pipescript into a single dynamic script per step in the dscript.
    Each step in the pipescript is transformed into a dynamic script in the dscript,
    where variable spaces are combined using the following order:

    1. STATIC: Definitions specific to each script in the pipescript.
    2. GLOBAL: User variables shared across steps from a specific point onwards.
    3. LOCAL: User variables for each individual step.

    Parameters:
    -----------
    verbose : bool, optional
        Controls verbosity of the dynamic scripts in the resulting dscript object.
        If None, the verbosity setting of the pipescript will be used.

    **USER : scriptobjectdata(), optional
        Additional user-defined variables that can override existing static variables
        in the dscript object or be added to it.

    Returns:
    --------
    outd : dscript
        A dscript object that contains all steps of the pipescript as dynamic scripts.
        Each step from the pipescript is added as a dynamic script with the same content
        and combined variable spaces.
    """
    # Local imports
    from pizza.dscript import dscript, ScriptTemplate, lambdaScriptdata

    # verbosity
    printflag = self.printflag if printflag is None else printflag
    verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
    verbosity = 0 if not verbose else verbosity

    # Adjust name
    if name is None:
        if isinstance(self.name, str):
            name = self.name
        elif isinstance(self.name, list):
            name = (
                self.name[0] if len(self.name) == 1 else self.name[0] + "..." + self.name[-1]
            )

    # Create the dscript container with the pipescript name as the userid
    outd = dscript(userid=name, verbose=self.verbose, **USER)

    # Initialize static merged definitions
    staticmerged_definitions = lambdaScriptdata()

    # Track used variables per step
    step_used_variables = []

    # Loop over each step in the pipescript
    for i, script in enumerate(self.listscript):
        # Merge STATIC, GLOBAL, and LOCAL variables for the current step
        static_vars = self.listUSER[i] # script.DEFINITIONS
        global_vars = script.DEFINITIONS # self.scripts[i].USER
        local_vars = script.USER # self.USER[i]
        refreshed_globalvars = static_vars + global_vars

        # Detect variables used in the current template
        used_variables = set(script.detect_variables())
        step_used_variables.append(used_variables)  # Track used variables for this step

        # Copy all current variables to local_static_updates and remove matching variables from staticmerged_definitions
        local_static_updates = lambdaScriptdata(**local_vars)

        for var, value in refreshed_globalvars.items():
            if var in staticmerged_definitions:
                if (getattr(staticmerged_definitions, var) != value) and (var not in local_vars):
                    setattr(local_static_updates, var, value)
            else:
                setattr(staticmerged_definitions, var, value)

       # Create the dynamic script for this step using the method in dscript
        key_name = i  # Use the index 'i' as the key in TEMPLATE
        content = script.TEMPLATE

        # Use the helper method in dscript to add this dynamic script
        outd.add_dynamic_script(
            key=key_name,
            content=content,
            definitions = lambdaScriptdata(**local_static_updates),
            verbose=self.verbose if verbose is None else verbose,
            userid=self.name[i]
        )

        # Set eval=True only if variables are detected in the template
        if outd.TEMPLATE[key_name].detect_variables():
            outd.TEMPLATE[key_name].eval = True

    # Compute the union of all used variables across all steps
    global_used_variables = set().union(*step_used_variables)

    # Filter staticmerged_definitions to keep only variables that are used
    filtered_definitions = {
        var: value for var, value in staticmerged_definitions.items() if var in global_used_variables
    }

    # Assign the filtered definitions along with USER variables to outd.DEFINITIONS
    outd.DEFINITIONS = lambdaScriptdata(**filtered_definitions)

    return outd
def getUSER(self, idx, key)

getUSER get USER variable getUSER(idx,varname)

Expand source code
def getUSER(self,idx,key):
    """
        getUSER get USER variable
        getUSER(idx,varname)
    """
    if isinstance(idx,int) and (idx>=0) and (idx<self.n):
        self.listUSER[idx].getattr(key)
    else:
        raise IndexError(f"the index in the pipe should be comprised between 0 and {len(self)-1}")
def header(self, verbose=True, verbosity=None, style=4)

Generate a formatted header for the pipescript file.

Parameters

verbosity (bool, optional): If specified, overrides the instance's verbose setting. Defaults to the instance's verbose. style (int from 1 to 6, optional): ASCII style to frame the header (default=4)

Returns

str
A formatted string representing the pipescript object. Returns an empty string if verbosity is False.

The header includes: - Total number of scripts in the pipeline. - The verbosity setting. - The range of scripts from the first to the last script. - All enclosed within an ASCII frame that adjusts to the content.

Expand source code
def header(self, verbose=True,verbosity=None, style=4):
    """
    Generate a formatted header for the pipescript file.

    Parameters:
        verbosity (bool, optional): If specified, overrides the instance's `verbose` setting.
                                    Defaults to the instance's `verbose`.
        style (int from 1 to 6, optional): ASCII style to frame the header (default=4)

    Returns:
        str: A formatted string representing the pipescript object.
             Returns an empty string if verbosity is False.

    The header includes:
        - Total number of scripts in the pipeline.
        - The verbosity setting.
        - The range of scripts from the first to the last script.
        - All enclosed within an ASCII frame that adjusts to the content.
    """
    verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
    verbosity = 0 if not verbose else verbosity
    if not verbosity:
        return ""

    # Prepare the header content
    lines = [
        f"PIPESCRIPT with {self.n} scripts | Verbosity: {verbosity}",
        "",
        f"From: <{str(self.scripts[0])}> To: <{str(self.scripts[-1])}>",
    ]

    # Use the shared method to format the header
    return frame_header(lines,style=style)
def rename(self, name='', idx=None)

rename scripts in the pipe p.rename(idx=[0,2,3],name=["A","B","C"])

Expand source code
def rename(self,name="",idx=None):
    """
        rename scripts in the pipe
            p.rename(idx=[0,2,3],name=["A","B","C"])
    """
    if isinstance(name,list):
        if len(name)==len(self) and idx==None:
            self.name = name
        elif len(name) == len(idx):
            for i in range(len(idx)):
                self.rename(name[i],idx[i])
        else:
            IndexError(f"the number of indices {len(idx)} does not match the number of names {len(name)}")
    elif idx !=None and idx<len(self) and name!="":
        self.name[idx] = name
    else:
        raise ValueError("provide a non empty name and valid index")
def script(self, idx=None, printflag=True, verbosity=2, verbose=None, forced=False, style=4)

script the pipeline or parts of the pipeline s = p.script() s = p.script([0,2])

Parameters: - idx (list, range, or int, optional): Specifies which steps of the pipeline to execute. If None, all steps from the current position to the end are executed. A list of indices can be provided to execute specific steps, or a single integer can be passed to execute a specific step. Default is None. - printflag (bool, optional): If True, the generated script for each step is printed to the console. Default is True. - verbosity (int, optional): Controls the level of detail in the generated script. - 0: Minimal output, no comments. - 1: Basic comments for run steps. - 2: Detailed comments with additional information. Default is 2. - forced (bool, optional): If True, all scripts are regenerated - style (int, optional): Defines the ASCII frame style for the header. Valid values are integers from 1 to 6, corresponding to predefined styles: 1. Basic box with +, -, and | 2. Double-line frame with , , and 3. Rounded corners with ., ', -, and | 4. Thick outer frame with #, =, and # 5. Box drawing characters with , , and 6. Minimalist dotted frame with ., :, and . Default is 4 (thick outer frame).

Expand source code
def script(self,idx=None, printflag=True, verbosity=2, verbose=None, forced=False, style=4):
    """
        script the pipeline or parts of the pipeline
            s = p.script()
            s = p.script([0,2])

    Parameters:
    - idx (list, range, or int, optional): Specifies which steps of the pipeline to execute.
                                           If None, all steps from the current position to
                                           the end are executed. A list of indices can be
                                           provided to execute specific steps, or a single
                                           integer can be passed to execute a specific step.
                                           Default is None.
    - printflag (bool, optional): If True, the generated script for each step is printed
                                  to the console. Default is True.
    - verbosity (int, optional): Controls the level of detail in the generated script.
                                 - 0: Minimal output, no comments.
                                 - 1: Basic comments for run steps.
                                 - 2: Detailed comments with additional information.
                                 Default is 2.
    - forced (bool, optional): If True, all scripts are regenerated
    - style (int, optional):
        Defines the ASCII frame style for the header.
        Valid values are integers from 1 to 6, corresponding to predefined styles:
            1. Basic box with `+`, `-`, and `|`
            2. Double-line frame with `╔`, `═`, and `║`
            3. Rounded corners with `.`, `'`, `-`, and `|`
            4. Thick outer frame with `#`, `=`, and `#`
            5. Box drawing characters with `┌`, `─`, and `│`
            6. Minimalist dotted frame with `.`, `:`, and `.`
        Default is `4` (thick outer frame).

    """
    printflag = self.printflag if printflag is None else printflag
    verbose = verbosity > 0 if verbosity is not None else (self.verbose if verbose is None else verbose)
    verbosity=0 if verbose is False else verbosity
    s = script(printflag=printflag, verbose=verbosity>0)
    s.name = "pipescript"
    s.description = "pipeline with %d scripts" % len(self)
    if len(self)>1:
        s.userid = self.name[0]+"->"+self.name[-1]
    elif len(self)==1:
        s.userid = self.name[0]
    else:
        s.userid = "empty pipeline"
    s.TEMPLATE = self.header(verbosity=verbosity, style=style) + "\n" +\
        self.do(idx, printflag=printflag, verbosity=verbosity, verbose=verbose, forced=forced)
    s.DEFINITIONS = duplicate(self.globalscript.DEFINITIONS)
    s.USER = duplicate(self.globalscript.USER)
    return s
def setUSER(self, idx, key, value)

setUSER sets USER variables setUSER(idx,varname,varvalue)

Expand source code
def setUSER(self,idx,key,value):
    """
        setUSER sets USER variables
        setUSER(idx,varname,varvalue)
    """
    if isinstance(idx,int) and (idx>=0) and (idx<self.n):
        self.listUSER[idx].setattr(key,value)
    else:
        raise IndexError(f"the index in the pipe should be comprised between 0 and {len(self)-1}")
def write(self, file, printflag=False, verbosity=2, verbose=None, overwrite=False)

Write the combined script to a file.

Parameters

file (str): The file path where the script will be saved. printflag (bool): Flag to enable/disable printing of details. verbosity (int): Level of verbosity for the script generation. verbose (bool or None): If True, enables verbose mode; if None, defaults to the instance's verbosity. overwrite (bool): Whether to overwrite the file if it already exists. Default is False.

Returns: str: The full absolute path of the file written.

Raises

FileExistsError
If the file already exists and overwrite is False.

Notes

  • This method combines the individual scripts within the pipescript object and saves the resulting script to the specified file.
  • If overwrite is False and the file exists, an error is raised.
  • If verbose is True and the file is overwritten, a warning is displayed.
Expand source code
def write(self, file, printflag=False, verbosity=2, verbose=None, overwrite=False):
   """
   Write the combined script to a file.

   Parameters:
       file (str): The file path where the script will be saved.
       printflag (bool): Flag to enable/disable printing of details.
       verbosity (int): Level of verbosity for the script generation.
       verbose (bool or None): If True, enables verbose mode; if None, defaults to the instance's verbosity.
       overwrite (bool): Whether to overwrite the file if it already exists. Default is False.

    Returns:
        str: The full absolute path of the file written.

   Raises:
       FileExistsError: If the file already exists and overwrite is False.

   Notes:
       - This method combines the individual scripts within the `pipescript` object
         and saves the resulting script to the specified file.
       - If `overwrite` is False and the file exists, an error is raised.
       - If `verbose` is True and the file is overwritten, a warning is displayed.
   """
   # Generate the combined script
   myscript = self.script(printflag=printflag, verbosity=verbosity, verbose=verbose, forced=True)
   # Call the script's write method with the overwrite parameter
   return myscript.write(file, printflag=printflag, verbose=verbose, overwrite=overwrite)