Metadata-Version: 2.4
Name: shell-lib
Version: 0.42
Summary: A simple Python utility to simplify the writing of Shell-like scripts.
Author-email: Ma Lin <malincns@163.com>, Google Gemini <no@no.com>
License-Expression: MIT
Project-URL: Homepage, https://bitbucket.org/wjssz/shell_lib
Classifier: Programming Language :: Python :: 3
Classifier: Development Status :: 4 - Beta
Classifier: Topic :: System :: Shells
Classifier: Topic :: Utilities
Classifier: Operating System :: Unix
Classifier: Operating System :: POSIX
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Requires-Python: >=3.7
Description-Content-Type: text/markdown

### shell-lib

`shell-lib` is designed to simplify the writing of Shell-like scripts.

This module was co-created with Google Gemini.

### Why shell-lib?

- **Clean and Readable Syntax**: Write scripts in readable Python, freeing from complex shell command syntax.
- **Reliable Error Handling**: Use Python's exception to manage command failure. If a command fails, by default, it raises a `subprocess.CalledProcessError` exception. For commands that may fail, user can also only check the exit-code.
- **Unified File System Operations**: Provide a consistent and intuitive file system operations API, that clearly distinguish between file and directory operations.
- **Cross-Platform Compatibility**: Write a single script that works across Linux, macOS, and Windows platforms.
- **Rich Ecosystem Integration**: Easily integrate with both the CLI tool and Python library ecosystems.
- **Lightweight and Portable**: Only uses Python standard library.

### Usage

```python
#!/usr/bin/python3
from shell_lib import sh

PROJECT_PATH = "my_project"
FILE = "hello.txt"

# `with sh:` is a *top-level* context manager.
# Its main purpose is, if `sh()` or `sh.safe_run()` fails, return the error
# exit-code from the command. If you don't need this, don't use it.
with sh:
    sh.create_dir(PROJECT_PATH)
    # sh.cd() context manager restores the previous working directory when
    # exiting the code block, even if an exception raised within the code block.
    with sh.cd(PROJECT_PATH):
        sh(f"echo 'Hello, World!' > {FILE}")
        print(f"File size: {sh.get_path_info(FILE).size} bytes")
    sh.remove_dir(PROJECT_PATH)
```

### API Reference


##### File and Directory Operations

Path parameters can be `str`, `bytes` or `pathlib.Path` object.

- `sh.home_dir() -> Path`: Gets the current user's home directory, a `pathlib.Path` object.
- `sh.path(path) -> Path`: Converts a `str`/`bytes` path to a `pathlib.Path` object. Can utilize the rich features of [pathlib module](https://docs.python.org/3/library/pathlib.html).

- `sh.create_dir(path, *, exist_ok=False)`: Creates a directory.
- `sh.remove_file(path, *, ignore_missing=False)`: Removes a file.
- `sh.remove_dir(path, *, ignore_missing=False)`: Recursively removes a directory.
- `sh.clear_dir(path) -> None`: Clear the contents of a directory.
- `sh.copy_file(src, dst, *, remove_existing_dst=False)`: Copies a file.
- `sh.copy_dir(src, dst, *, remove_existing_dst=False)`: Copies a directory.
- `sh.move_file(src, dst, *, remove_existing_dst=False)`: Moves a file.
- `sh.move_dir(src, dst, *, remove_existing_dst=False)`: Moves a directory.
- `sh.rename_file(src, dst)`: Renames a file.
- `sh.rename_dir(src, dst)`: Renames a directory.

- `sh.list_dir(path)`: Lists all entry names within a directory.
- `sh.walk_dir(path, top_down=True)`: A generator that traverses a directory tree, yield a tuple(directory_path, file_name).
- `sh.cd(path: str|bytes|Path|None)`: Changing the working directory. Can be used as a context manager.

- `sh.split_path(path)`: [os.path.split()](https://docs.python.org/3/library/os.path.html#os.path.split) alias.
- `sh.join_path(*paths)`: [os.path.join()](https://docs.python.org/3/library/os.path.html#os.path.join) alias.

- `sh.path_exists(path) -> bool`: Checks if a path exists.
- `sh.is_file(path) -> bool`: Checks if a path is a file.
- `sh.is_dir(path) -> bool`: Checks if a path is a directory.
- `sh.get_path_info(path) -> PathInfo`: Retrieves detailed information about an existing file or directory:

```
>>> sh.get_path_info('/usr/bin/')  # directory
PathInfo(path=/usr/bin/, size=69632, ctime=2025-09-13 09:05:36.561248,
mtime=2025-09-13 09:05:36.561248, atime=2025-09-14 09:31:12.406677,
is_dir=True, is_file=False, is_link=False, permissions=755)

>>> sh.get_path_info('/usr/bin/python3')  # file
PathInfo(path=/usr/bin/python3, size=8021824, ctime=2025-08-29 13:12:47.657879,
mtime=2025-08-15 01:47:21, atime=2025-09-13 13:40:22.696961,
is_dir=False, is_file=True, is_link=True, permissions=755)
```

##### Shell Command Execution

Executes a command with `shell=True`. Allows shell features like pipes (|) or redirection (>).
```
sh(command: str, *,
   text: bool = True,
   input: str|bytes|None = None,
   timeout: int|float|None = None,
   print_output: bool = True,
   fail_on_error: bool = True) -> subprocess.CompletedProcess

print_output:
    True: streams stdout and stderr to the console.
    False: stdout and stderr are saved in return value's `stdout`/`stderr` attributes.
fail_on_error:
    True: raises a subprocess.CalledProcessError on failure.
    False: doesn't raise exception, need to check return value's `returncode` attribute
           to see if it has failed.
```

Securely executes a command with `shell=False`. It only accepts a list of strings to prevent Shell injection. Use this method when the command contains external input.
```
sh.safe_run(command: list[str], *,
            text: bool = True,
            input: str|bytes|None = None,
            timeout: int|float|None = None,
            print_output: bool = True,
            fail_on_error: bool = True) -> subprocess.CompletedProcess
```

##### Script Control

- `sh.pause(msg: str|None = None) -> None`: Prompts the user to press any key to continue.
- `sh.ask_choice(title: str, *choices: str) -> int`: Displays a menu and gets a 1-based index from the user's choice.
- `sh.ask_yes_no(title: str) -> bool`: Asks user to answer yes or no.
- `sh.ask_regex_input(title: str, pattern: str, *, print_pattern: bool = False) -> re.Match`: Ask user to input a string, and verify it with a regex pattern.
- `sh.ask_password(title: str = "Please input password") -> str`: Ask user to input a password, not echo on screen. No need to add `:` at the end of `title`.
- `sh.exit(exit_code: int = 0)`: Exits the script with a specified exit code.

##### Get system information

- `sh.get_preferred_encoding() -> str`: Get the preferred encoding for the current locale.
- `sh.get_filesystem_encoding() -> str`: Get the encoding used by the OS for filenames.
- `sh.get_username() -> str`: Get the current username. On Linux, if running a script with `sudo -E ./script.py`, return `root`. To get the username in this case, use: `sh.home_dir().name`
- `sh.is_elevated() -> bool`: If the script is running with elevated (admin/root) privilege.
- `sh.is_os(os_mask: int) -> bool`: Test whether it's the OS specified by the parameter.

```
# os_mask can be:
sh.OS_Windows
sh.OS_Cygwin
sh.OS_Linux
sh.OS_macOS
sh.OS_Unix
sh.OS_Unix_like  # It's (OS_Cygwin | OS_Linux | OS_macOS | OS_Unix)

# Support bit OR (|) combination:
if sh.is_os(sh.OS_Linux | sh.OS_macOS):
    ...
elif sh.is_os(sh.OS_Windows):
    ...
```

### Demo script
```
#!/usr/bin/python3
import os
from shell_lib import sh
# shell-lib demo: build and install cpython on Linux
# If the script doesn't involve with sudo operations, it will be simpler.

# Input Python version
m = sh.ask_regex_input('Please input Python version to install (such as 3.13.7)',
                       r'\s*(((\d+)\.(\d+))\.\d+)\s*')
ver = m.group(1)
ver_2 = m.group(2)
ver_info = int(m.group(3)), int(m.group(4))

# Variables
work_dir = sh.home_dir() / 'build_python'
xz_filename = sh.path(f'Python-{ver}.tar.xz')
compile_dir = f'Python-{ver}'
install_dir = sh.path(f'/opt/python{ver_2}')
url = f'https://www.python.org/ftp/python/{ver}/Python-{ver}.tar.xz'

# Check existing installed Python
if (install_dir.is_dir() and
    not sh.ask_yes_no((f"Install path `{install_dir}` is exsiting, "
                       f"overwrite install(yes) or exit(no)？"))):
    sh.exit(0)

# Build options
config = f'OPT="-O2" ./configure --prefix={install_dir}'
optimize = sh.ask_choice("Please choose build options",
                         "PGO + LTO (very slow)",
                         "LTO (slow)",
                         "No optimization",
                         "Debug build")
if optimize == 1:
    config += ' --enable-optimizations --with-lto'
elif optimize == 2:
    config += ' --with-lto'
elif optimize == 3:
    pass
elif optimize == 4:
    config += ' --with-pydebug'

if ver_info >= (3, 13) and sh.ask_yes_no("Build Free-threaded build?"):
    config += ' --disable-gil'

sh.create_dir(work_dir, exist_ok=True)
with sh.cd(work_dir):
    if not xz_filename.is_file():
        sh(f"wget --no-proxy -O {xz_filename} {url}")

    password = sh.ask_password('Please input sudo password')
    sh(f'echo {password} | sudo -S rm -rf {compile_dir}')
    sh(f'tar -xvf {xz_filename}', print_output=False)

    with sh.cd(compile_dir):
        # Compile
        sh(config, print_output=False)
        sh('make clean')
        sh(f'make -j{os.cpu_count()}')
        sh.pause('Please check for missing modules')

        # Install
        sh(f'echo {password} | sudo -S rm -rf {install_dir}')
        sh(f'echo {password} | sudo -S make install')

    if sh.ask_yes_no('Run unit-tests? (very slow)'):
        sh(f'{install_dir}/bin/python{ver_2} -m test', fail_on_error=False)

    if sh.ask_yes_no('Remove building directory?'):
        sh(f'echo {password} | sudo -S rm -rf {compile_dir}')
```
