Metadata-Version: 2.4
Name: shell-lib
Version: 1.0.2
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

### Introduction

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

This module was co-created with [Google Gemini](https://gemini.google.com/).

[Changelog](https://bitbucket.org/wjssz/shell_lib/src/main/changelog.md)

### 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 use Python standard library.
- **Well tested**: Consistent behavior on different platforms and Python versions.

### 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:

```text
>>> 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)

# `permissions` is a str object.
# On Windows, it looks like "7" (only one character), which only represents
# the current user is readable, writable, executable.
```

#### Shell Command Execution

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

# alternative_title:
#     Print this title instead of the command.
#     Used for commands containing sensitive information.
# 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.
```

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

# On Windows, need to use this to run complex PowerShell command:
cmd = "pip freeze | foreach-object { pip install --upgrade $_.split('==')[0] }"
sh.safe_run(['powershell', '-Command', cmd])
```

#### 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.

```python
# 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
```python
#!/usr/bin/python3
import os
from shell_lib import sh
# shell-lib demo script: Build and install cpython on Linux
# Need to install build dependencies first:
# https://devguide.python.org/getting-started/setup-building/#install-dependencies

# 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
msg = (f'Install path `{install_dir}` is exsiting, '
       f'overwrite install(yes) or exit(no)?')
if install_dir.is_dir() and not sh.ask_yes_no(msg):
    sh.exit()

# 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() or sh.get_path_info(xz_filename).size == 0:
        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}', alternative_title='')
    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}',
           alternative_title='Remove existing install directory')
        sh(f'echo {password} | sudo -S make install',
           alternative_title='Install Compiled Python')

    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}',
           alternative_title='Remove building directory')
```
