OSRF_PYCOMMON(1)

πŸ’– NAME πŸ’–

okay so welcome to osrf_pycommon(1) β€” the manual page for osrf_pycommon, aka the osrf_pycommon Documentation. bestie, this is the whole vibe: osrf_pycommon is a Python package packed with commonly used Python boilerplate code and patterns. we're talking ansi terminal coloring, capturing colored output from programs using subprocess, or even a simple logging system which provides some nice functionality over the built-in Python logging system. iconic behavior, honestly.

the functionality provided here should be generic enough to be reused in arbitrary scenarios, and should avoid bringing in dependencies which are not part of the standard Python library. where possible Windows, Linux, and macOS should be supported, and where it cannot it should be gracefully degrading. code should be pure Python 3. no cap, that's the whole assignment.

Contents:


πŸ› οΈ THE CLI_UTILS MODULE πŸ› οΈ

this module provides functions and patterns for creating Command Line Interface (CLI) tools. yes ma'am, CLI girlies rise.

✨ Common CLI Functions ✨

commonly used, CLI related functions. the little utilities that just make sense.

osrf_pycommon.cli_utils.common.extract_argument_group(args, delimiting_option)

extract a group of arguments from a list of arguments using a delimiter. it's giving "split the group chat into two chats" energy.

here is an example:

>>> extract_argument_group(['foo', '--args', 'bar', '--baz'], '--args')
(['foo'], ['bar', '--baz'])

the group can always be ended using the double hyphen --. in order to pass a double hyphen as arguments, use three hyphens ---. any set of hyphens encountered after the delimiter, and up to --, which have three or more hyphens and are isolated, will be captured and reduced by one hyphen. yes, the hyphen math is real and it is slaying.

for example:

>> extract_argument_group(['foo',
                           '--args', 'bar', '--baz', '---', '--',
                           '--foo-option'], '--args')
(['foo', '--foo-option'], ['bar', '--baz', '--'])

in the result the -- comes from the --- in the input. the --args and the corresponding -- are removed entirely. poof, gone, no notes.

the delimiter and -- terminator combination can also happen multiple times, in which case the bodies of arguments are combined and returned in the order they appeared.

for example:

>> extract_argument_group(['foo',
                           '--args', 'ping', '--',
                           'bar',
                           '--args', 'pong', '--',
                           'baz',
                           '--args', '--'], '--args')
(['foo', 'bar', 'baz'], ['ping', 'pong'])
note: -- cannot be used as the delimiting_option. that's the rule, bestie.

Parameters

  • args (list) β€” list of strings which are ordered arguments.
  • delimiting_option (str) β€” option which denotes where to split the args.

Returns tuple of arguments before and after the delimiter.

Return type tuple

Raises ValueError if the delimiting_option is --.

osrf_pycommon.cli_utils.common.extract_jobs_flags(arguments)

extracts make job flags from a list of other make flags, i.e. -j8 -l8. it's lowkey a little flag detector, and it ate.

the input arguments are given as a string separated by whitespace. make job flags are matched and removed from the arguments, and the Make job flags and what is left over from the input arguments are returned.

if no job flags are encountered, then an empty string is returned as the first element of the returned tuple. sad tiny violin, but make it useful.

examples:

>> extract_jobs_flags('-j8 -l8')
('-j8 -l8', '')
>> extract_jobs_flags('-j8 ')
('-j8', ' ')
>> extract_jobs_flags('target -j8 -l8 --some-option')
('-j8 -l8', 'target --some-option')
>> extract_jobs_flags('target --some-option')
('', 'target --some-option')

Parameters arguments (str) β€” string of space separated arguments which may or may not contain make job flags.

Returns tuple of make jobs flags as a space separated string and leftover arguments as a space separated string.

Return type tuple

πŸ¦‹ The Verb Pattern πŸ¦‹

the verb pattern is a pattern where a single command aggregates multiple related commands by taking a required positional argument which is the β€œverb” for the action you want to perform. for example catkin build is an example of a command and verb pair, where catkin is the command and build is the verb. in this example, the catkin command groups β€œactions” which are related to catkin together using verbs like build which will build a workspace of catkin packages. it's giving main character command with a whole supporting cast of verbs.

πŸŽ€ Command Boilerplate πŸŽ€

this is an example boilerplate of a command which will use verbs:

from __future__ import print_function

import argparse
import sys

from osrf_pycommon.cli_utils.verb_pattern import create_subparsers
from osrf_pycommon.cli_utils.verb_pattern import list_verbs
from osrf_pycommon.cli_utils.verb_pattern import split_arguments_by_verb

COMMAND_NAME = '<INSERT COMMAND NAME HERE>'

VERBS_ENTRY_POINT = '{0}.verbs'.format(COMMAND_NAME)

def main(sysargs=None):
    # Assign sysargs if not set
    sysargs = sys.argv[1:] if sysargs is None else sysargs

    # Create a top level parser
    parser = argparse.ArgumentParser(
        description="{0} command".format(COMMAND_NAME)
    )

    # Generate a list of verbs available
    verbs = list_verbs(VERBS_ENTRY_POINT)

    # Create the subparsers for each verb and collect the arg preprocessors
    argument_preprocessors, verb_subparsers = create_subparsers(
        parser,
        COMMAND_NAME,
        verbs,
        VERBS_ENTRY_POINT,
        sysargs,
    )

    # Determine the verb, splitting arguments into pre and post verb
    verb, pre_verb_args, post_verb_args = split_arguments_by_verb(sysargs)

    # Short circuit -h and --help
    if '-h' in pre_verb_args or '--help' in pre_verb_args:
        parser.print_help()
        sys.exit(0)

    # Error on no verb provided
    if verb is None:
        print(parser.format_usage())
        sys.exit("Error: No verb provided.")
    # Error on unknown verb provided
    if verb not in verbs:
        print(parser.format_usage())
        sys.exit("Error: Unknown verb '{0}' provided.".format(verb))

    # Short circuit -h and --help for verbs
    if '-h' in post_verb_args or '--help' in post_verb_args:
        verb_subparsers[verb].print_help()
        sys.exit(0)

    # First allow the verb's argument preprocessor to strip any args
    # and return any "extra" information it wants as a dict
    processed_post_verb_args, extras = \
        argument_preprocessors[verb](post_verb_args)
    # Then allow argparse to process the left over post-verb arguments along
    # with the pre-verb arguments and the verb itself
    args = parser.parse_args(pre_verb_args + [verb] + processed_post_verb_args)
    # Extend the argparse result with the extras from the preprocessor
    for key, value in extras.items():
        setattr(args, key, value)

    # Finally call the subparser's main function with the processed args
    # and the extras which the preprocessor may have returned
    sys.exit(args.main(args) or 0)

this function is mostly boilerplate in that it will likely not change much between commands of different types, but it would also be less transparent to have this function created for you. if you are using this boilerplate to implement your command, then you should be careful to update COMMAND_NAME to reflect your command’s name. don't leave the placeholder in there, bestie, that's not the vibe.

this line defines the entry_point group for your command’s verbs:

VERBS_ENTRY_POINT = '{0}.verbs'.format(COMMAND_NAME)

in the case that your command is called foo then this would become foo.verbs. this name is important because it is how verbs for this command can be provided by your Python package or others. for example, each verb for your command foo will need entry in the setup.py of its containing package, like this:

setup(
    ...
    entry_points={
        ...
        'foo.verbs': [
            'bar = foo.verbs.bar:entry_point_data',
        ],
    }
)

you can see here that you are defining bar to be an entry_point of type foo.verbs which in turn points to a module and reference foo.verbs.bar and entry_point_data. at run time this verb pattern will let your command lookup all things defined as foo.verbs and load up the reference to which they point. it's giving registry magic, and honestly? obsessed.

🌸 Adding Verbs 🌸

in order to add a verb to your command, a few things must happen.

first you must have an entry in the setup.py as described above. this allows the command to find the entry_point for your verb at run time. the entry_point for these verbs should point to a dictionary which describes the verb being added.

this is an example of an entry_point_data dictionary for a verb:

entry_point_data = dict(
    verb='build',
    description='Builds a workspace of packages',
    # Called for execution, given parsed arguments object
    main=main,
    # Called first to setup argparse, given argparse parser
    prepare_arguments=prepare_arguments,
    # Called after prepare_arguments, but before argparse.parse_args
    argument_preprocessor=argument_preprocessor,
)

as you can see this dictionary describes the verb and gives references to functions which allow the command to describe the verb, hook into argparse parameter creation for the verb, and to execute the verb. the verb, description, main, and prepare_arguments keys of the dictionary are required, but the argument_preprocessor key is optional.

  • verb: this is the name of the verb, and is how the command knows which verb implementation to match to a verb on the command line.
  • description: this is used by the argument parsing to describe the verb in --help.
  • prepare_arguments: this function gets called to allow the verb to setup its own argparse options. this function should always take one parameter which is the argparse.ArgumentParser for this verb, to which arguments can be added. it can optionally take a second parameter which are the current command line arguments. this is not always needed, but can be useful in some cases. this function should always return the parser.
  • argument_preprocessor β€” lowkey optional, but it's your verb's ✨one chance✨ to mess with the raw args before they get handed to argparse's parse_args. Genuinely useful when argparse just can't handle the options correctly (iykyk 😭).
  • main β€” the actual implementation of the verb, bestie. It gets called last and receives the parsed arguments. Its return type is what gets fed to sys.exit, and a return of None is read as 0. Periodt.

Here's an invented example of main, prepare_arguments, and argument_preprocessor β€” none of this is real, it's just showing off the vibe:

def prepare_arguments(parser):
    parser.add_argument('--some-argument', action='store_true', default=False)
    return parser

def argument_preprocessor(args):
    extras = {}

    if '-strange-argument' in args:
        args.remove('-strange-argument')
        extras['strange_argument'] = True

    return args, extras

def main(options):
    print('--some-argument:', options.some_argument)
    print('-strange-argument:', options.strange_argument)
    if options.strange_argument:
        return 1
    return 0

That's literally all it is β€” the example above exists purely to show you the signature of these functions and how you might use them. No cap. πŸ¦‹


⚑ Verb Pattern API ⚑

The API for implementing commands and verbs that are doing the whole verb pattern thing. πŸ’…

osrf_pycommon.cli_utils.verb_pattern.call_prepare_arguments(func, parser, sysargs=None)

Calls a prepare_arguments function with the correct number of parameters. πŸŽ€

Here's the tea: a verb's prepare_arguments function can take either one parameter, parser, or two parameters, parser and args, where args are the current arguments being processed. This function figures out which one it's dealing with so you don't have to.

Parameters

  • func (Callable) β€” the callable prepare_arguments function.
  • parser (argparse.ArgumentParser) β€” the parser, which is always passed to the function.
  • sysargs (list) β€” arguments to optionally pass to the function, if needed.

Returns β€” return value of function, or the parser if the function returns None.

Return type β€” argparse.ArgumentParser

Raises ValueError if a function with the wrong number of parameters is given. Like, don't do that. πŸ™ˆ

osrf_pycommon.cli_utils.verb_pattern.create_subparsers(parser, cmd_name, verbs, group, sysargs, title=None)

Creates argparse subparsers for each verb it can discover. Iconic behaviour, honestly. πŸ‘‘

Using the verbs parameter, the available verbs get iterated through. For each verb, a subparser is created for it using the parser parameter. The cmd_name is used to fill the title and description of the add_subparsers function call. The group parameter is used with each verb to load the verb's description, prepare_arguments function, and the verb's argument_preprocessors if available. Each verb's prepare_arguments function gets called, which is how they get to add their own arguments. Finally, you get back a list of argument_preprocessors functions and verb subparsers, one for each verb. ✨

Parameters

  • parser (argparse.ArgumentParser) β€” parser for this command.
  • cmd_name (str) β€” name of the command to which the verbs are being added.
  • verbs (list) β€” list of verbs (by name as a string).
  • group (str) β€” name of the entry_point group for the verbs.
  • sysargs (list) β€” list of system arguments.
  • title (str) β€” optional custom title for the command.

Returns β€” tuple of argument_preprocessors and verb subparsers.

Return type β€” tuple


osrf_pycommon.cli_utils.verb_pattern.default_argument_preprocessor(args)

Returns the args unmodified plus an empty dict for extras. That's it, that's the whole function. The bare minimum and honestly? Respect. πŸ’œ


osrf_pycommon.cli_utils.verb_pattern.list_verbs(group)

Lists the verbs available for a given entry_point group. πŸ“

Parameters

  • group (str) β€” entry_point group name for the verbs to list.

Returns β€” list of verb names for the given entry_point group.

Return type β€” list of str


osrf_pycommon.cli_utils.verb_pattern.load_verb_description(verb_name, group)

Loads the description of a verb in a given group, by name. Simple as that, bestie. 🌸

Parameters

  • verb_name (str) β€” name of the verb to load, as a string.
  • group (str) β€” entry_point group name which the verb is in.

Returns β€” verb description.

Return type β€” dict


osrf_pycommon.cli_utils.verb_pattern.split_arguments_by_verb(arguments)

Splits arguments by verb. πŸŽ€

Give it a list of arguments (list of strings), and you get back the verb, the pre verb arguments, and the post verb arguments.

For example:

>>> args = ['--command-arg1', 'verb', '--verb-arg1', '--verb-arg2']
>>> split_arguments_by_verb(args)
('verb', ['--command-arg1'], ['--verb-arg1', '--verb-arg2'])

Parameters

  • arguments (list) β€” list of system arguments.

Returns β€” the verb (str), pre verb args (list), and post verb args (list).

Return type β€” tuple


🌈 THE PROCESS_UTILS MODULE 🌈

This module is literally the process management department. It's giving organization. 🫢

These are the main sections of this module:

  • Asynchronous Process Utilities
  • Synchronous Process Utilities
  • Utility Functions

πŸ¦‹ Asynchronous Process Utilities πŸ¦‹

There's a function and a class here, and you can use them together with your own custom asyncio run loop. So cute, so powerful. πŸ’–

The osrf_pycommon.process_utils.async_execute_process() function is a coroutine that lets you run a process and get the output back bit by bit in real-time, either with stdout and stderr separated or combined. It also lets you emulate the terminal using a pty β€” simply by toggling a flag in the parameters. Yes, one flag. Slay. ✨

Right alongside this coroutine lives a Protocol class, osrf_pycommon.process_utils.AsyncSubprocessProtocol, which you can inherit from in order to customize how the yielded output is handled.

And because this coroutine is built on the asyncio framework's subprocess functions, it's portable and should behave the same on all major OS's. (Including on Windows, where an IOCP implementation is used. We see you, Windows. πŸ‘€)


async osrf_pycommon.process_utils.async_execute_process(protocol_class, cmd=None, cwd=None, env=None, shell=False, emulate_tty=False, stderr_to_stdout=True)

Coroutine to execute a subprocess and yield the output back asynchronously. πŸ”₯

This function is meant to be used with the Python asyncio module, which is available in Python 3.5 or greater.

Here's an example of how to use this function:

import asyncio
from osrf_pycommon.process_utils import async_execute_process
from osrf_pycommon.process_utils import AsyncSubprocessProtocol
from osrf_pycommon.process_utils import get_loop

async def setup():
    transport, protocol = await async_execute_process(
        AsyncSubprocessProtocol, ['ls', '/usr'])
    returncode = await protocol.complete
    return returncode

retcode = get_loop().run_until_complete(setup())
get_loop().close()

The first argument is the default AsyncSubprocessProtocol protocol class, which simply prints output from stdout to stdout and output from stderr to stderr. Easy. πŸŽ€

If you want to capture the output and do something with it, or write to the stdin, then you need to subclass from the AsyncSubprocessProtocol class and override the on_stdout_received, on_stderr_received, and on_process_exited functions. That's where the main character energy lives. πŸ‘‘

See the documentation for the AsyncSubprocessProtocol class for more details β€” but here's an example which uses asyncio from Python 3.5:

import asyncio
from osrf_pycommon.process_utils import async_execute_process
from osrf_pycommon.process_utils import AsyncSubprocessProtocol
from osrf_pycommon.process_utils import get_loop

class MyProtocol(AsyncSubprocessProtocol):
    def __init__(self, file_name, **kwargs):
        self.fh = open(file_name, 'w')
        AsyncSubprocessProtocol.__init__(self, **kwargs)

    def on_stdout_received(self, data):
        # Data has line endings intact, but is bytes in Python 3
        self.fh.write(data.decode('utf-8'))

    def on_stderr_received(self, data):
        self.fh.write(data.decode('utf-8'))

    def on_process_exited(self, returncode):
        self.fh.write("Exited with return code: {0}".format(returncode))
        self.fh.close()

async def log_command_to_file(cmd, file_name):

    def create_protocol(**kwargs):
        return MyProtocol(file_name, **kwargs)

    transport, protocol = await async_execute_process(
        create_protocol, cmd)
    returncode = await protocol.complete
    return returncode

get_loop().run_until_complete(
    log_command_to_file(['ls', '/'], '/tmp/out.txt'))
get_loop().close()

See the subprocess.Popen class for more details on some of the parameters to this function, like cwd, env, and shell. πŸ“š

See the osrf_pycommon.process_utils.execute_process() function for more details on the emulate_tty parameter.

Parameters

  • protocol_class (AsyncSubprocessProtocol or a subclass) β€” protocol class which handles subprocess callbacks.
  • cmd (list) β€” list of arguments where the executable is the first item.
  • cwd (str) β€” directory in which to run the command.
  • env (dict) β€” a dictionary of environment variable names to values.
  • shell (bool) β€” if True, the cmd variable is interpreted by a the shell.
  • emulate_tty (bool) β€” if True, pty's are passed to the subprocess for stdout and stderr, see osrf_pycommon.process_utils.execute_process().
  • stderr_to_stdout (bool) β€” if True, stderr is directed to stdout, so they are not captured separately. πŸ’–

πŸ’… class osrf_pycommon.process_utils.AsyncSubprocessProtocol(stdin=None, stdout=None, stderr=None)

Okay so this is the class you subclass when you want to get events from async_execute_process(). It's giving callback queen, honestly.

When you subclass it, bestie, you override these functions πŸ‘‡

def on_stdout_received(self, data):
    # ...

def on_stderr_received(self, data):
    # ...

def on_process_exited(self, returncode):
    # ...

By default these three are lowkey lazy β€” they just print whatever data comes in from stdout and stderr, and do absolutely nothing when the process exits. No thoughts, head empty.

Two things you NEED to know about the data, fr fr:

  • Data received by on_stdout_received and on_stderr_received is always in bytes. So you might need to call .decode() on it before printing to the screen 😭
  • The data is not stripped of new lines. So take that into consideration when you're printing the result β€” otherwise your output is gonna be all kinds of chaotic.

You can also override these less commonly used functions, for the extra-credit girlies:

def on_stdout_open(self):
    # ...

def on_stdout_close(self, exc):
    # ...

def on_stderr_open(self):
    # ...

def on_stderr_close(self, exc):
    # ...

These get called when stdout/stderr are opened and closed β€” super useful when you're using ptys, for example. The exc parameter of the *_close functions is None unless there was an exception, k?

In addition to the overridable functions, this class has a few useful public attributes:

  • stdin β€” a reference to the PipeProto which follows the asyncio.WriteTransport interface ✨
  • stdout and stderr β€” also reference their PipeProtos
  • complete β€” an asyncio.Future which gets set to complete when the process exits, and its result is the return code πŸŽ€

The complete attribute can be used like this:

import asyncio
from osrf_pycommon.process_utils import async_execute_process
from osrf_pycommon.process_utils import AsyncSubprocessProtocol
from osrf_pycommon.process_utils import get_loop

async def setup():
    transport, protocol = await async_execute_process(
        AsyncSubprocessProtocol, ['ls', '-G', '/usr'])
    retcode = await protocol.complete
    print("Exited with", retcode)

# This will block until the protocol.complete Future is done.
get_loop().run_until_complete(setup())
get_loop().close()

✨ connection_made(transport)

Called when a connection is made πŸ’«

The argument is the transport representing the pipe connection. To receive data, wait for data_received() calls. When the connection is closed, connection_lost() gets called.

πŸ¦‹ pipe_data_received(fd, data)

Called when the subprocess writes data into the stdout/stderr pipe.

fd is an int file descriptor. data is a bytes object. That's it, that's the vibe check.

🌸 process_exited()

Called when the subprocess has exited. Bye bestie πŸ‘‹


In addition to all these functions, there's a utility function for getting the correct asyncio event loop:

πŸ’œ osrf_pycommon.process_utils.get_loop()

This function will return the proper event loop for the subprocess async calls.

On Unix this just returns asyncio.get_event_loop(), but on Windows it will set and return an asyncio.ProactorEventLoop instead. Windows girlies get their own special loop, periodt.


πŸŽ€ Treatment of File Descriptors

Okay so listen β€” like Python 3.4's subprocess.Popen (and newer versions), all of the process_utils functions do not close inheritable file descriptors before starting subprocesses. That's equivalent to passing close_fds=False to subprocess.Popen on all Python versions. Noted and filed away πŸ’…

(If you wanna read the actual docs on fd inheritance, they live at https://docs.python.org/3.4/library/os.html#fd-inheritance β€” I gotchu.)

For historical context, 'cause I know you love lore πŸ“: in Python 3.2, the subprocess.Popen default for the close_fds option changed from False to True, so that file descriptors opened by the parent process were closed before spawning the child process. Then in Python 3.4, PEP 0446 additionally made it so that even when close_fds=False, file descriptors which are non-inheritable are still closed before spawning the subprocess. Messy, I know.

So if you want to be able to pass file descriptors to subprocesses in Python 3.4 or higher, you will need to make sure they are inheritable. No cap. You can read up on that at https://docs.python.org/3.4/library/os.html#fd-inheritance too.


⭐ Synchronous Process Utilities

For synchronous execution and output capture of subprocess, there are two functions:

  • osrf_pycommon.process_utils.execute_process()
  • osrf_pycommon.process_utils.execute_process_split()

These functions are not yet using the asyncio framework as a back-end, and therefore on Windows they will not stream the data from the subprocess the way it does on Unix machines. Instead, data will not be yielded until the subprocess is finished and all output is buffered. And yes, the normal warnings about long running programs with lots of output apply πŸ™ˆ

Heads up: the streaming of output does not work on Windows because on Windows the select.select() method only works on sockets and not file-like objects, which are used with subprocess pipes 😭

asyncio implements Windows subprocess support by implementing a Proactor event loop based on Window's IOCP API. One future option will be to implement this synchronous style method using IOCP in this module, but another option is to just make synchronous the asynchronous calls β€” but there are issues with that as well. In the mean time, if you need streaming of output in both Windows and Unix, use the asynchronous calls. That's the move πŸ’«


🫢 osrf_pycommon.process_utils.execute_process(cmd, cwd=None, env=None, shell=False, emulate_tty=False)

Executes a command with arguments and returns output line by line. Iconic behaviour, truly.

All arguments, except emulate_tty, are passed directly to subprocess.Popen. Directly. No middleman.

execute_process returns a generator which yields the output, line by line, until the subprocess finishes β€” at which point the return code is yielded. So you get the whole story, start to finish πŸ“–

This is an example of how this function should be used:

from __future__ import print_function
from osrf_pycommon.process_utils import execute_process

cmd = ['ls', '-G']
for line in execute_process(cmd, cwd='/usr'):
    if isinstance(line, int):
        # This is a return code, the command has exited
        print("'{0}' exited with: {1}".format(' '.join(cmd), line))
        continue  # break would also be appropriate here
    # In Python 3, it will be a bytes array which needs to be decoded
    if not isinstance(line, str):
        line = line.decode('utf-8')
    # Then print it to the screen
    print(line, end='')

stdout and stderr are always captured together and returned line by line through the returned generator. New line characters are preserved in the output, so if you're re-printing the data, take care to use end='' or first rstrip the output lines. Otherwise, gaps everywhere πŸ’€

When emulate_tty is used on Unix systems, commands will identify that they are on a tty and should output color to the screen as if you were running it on the terminal β€” and therefore there should not be any need to pass arguments like -c color.ui=always to commands like git. So convenient, honestly. Additionally, programs might also behave differently when emulate_tty is being used; for example, Python will default to unbuffered output when it detects a tty πŸ‘‘

emulate_tty works by using pseudo-terminals on Unix machines, and so if you are running this command many times in parallel (like hundreds of times) then you may get one of a few different OSError's. For example, OSError: [Errno 24] Too many open files: '/dev/ttyp0' or OSError: out of pty devices. You should also be aware that you share pty devices with the rest of the system, so even if you are not using a lot, it is possible to get this error 😬

You can catch this error before getting data from the generator, so when using emulate_tty you might want to do something like this:

from __future__ import print_function
from osrf_pycommon.process_utils import execute_process

cmd = ['ls', '-G', '/usr']
try:
    output = execute_process(cmd, emulate_tty=True)
except OSError:
    output = execute_process(cmd, emulate_tty=False)
for line in output:
    if isinstance(line, int):
        print("'{0}' exited with: {1}".format(' '.join(cmd), line))
        continue
    # In Python 3, it will be a bytes array which needs to be decoded
    if not isinstance(line, str):
        line = line.decode('utf-8')
    print(line, end='')

This way, if a pty cannot be opened in order to emulate the tty, then you can try again without emulation β€” and any other OSError should raise again with emulate_tty set to False. Obviously, you only want to do this if emulating the tty is non-critical to your processing, like when you are using it to capture color 🌈

Any color information that the command outputs as ANSI escape sequences is captured by this command. That way you can print the output to the screen and preserve the color formatting. Aesthetic and functional 🎨

If you do not want color in the output, then try setting emulate_tty to False β€” but that does not guarantee that there is no color in the output. Instead it only will cause called processes to identify that they are not being run in a terminal. Most well behaved programs will not output color if they detect that they are not being executed in a terminal, but you shouldn't rely on that πŸ™ƒ

If you want to ensure there is no color in the output from an executed process, then use this function:

osrf_pycommon.terminal_color.remove_ansi_escape_sequences()

Exceptions can be raised by functions called by the implementation β€” for example, subprocess.Popen can raise an OSError when the given command is not found. If you want to check for the existence of an executable on the path, see: which(). However, this function itself does not raise any special exceptions. It's innocent, your honour πŸ’€

πŸ“ Parameters

Okay so here's the full tea on every parameter β€”

okay so the parameter list keeps going, and every single one is doing its little job πŸ’…

  • cmd (list) β€” list of strings where the first item is a command and the rest are any arguments to that command; passed directly to subprocess.Popen.
  • cwd (str) β€” path in which to run the command; defaults to None, which means os.getcwd() is used; passed directly to subprocess.Popen.
  • env (dict) β€” environment dictionary to use for executing the command; default is None, which uses the os.environ environment; passed directly to subprocess.Popen.
  • shell (bool) β€” if True, the system shell is used to evaluate the command; default is False; passed directly to subprocess.Popen.
  • emulate_tty (bool) β€” if True, it attempts to use a pty to convince subprocesses that they are being run in a terminal. Typically this is useful for capturing colorized output from commands. This does not work on Windows (no ptys), so it is considered False even when True. Defaults to False.

Returns: a generator which yields output from the command line by line.

Return type: generator which yields strings.

Availability: Unix (streaming), Windows (blocking)


osrf_pycommon.process_utils.execute_process_split(cmd, cwd=None, env=None, shell=False, emulate_tty=False)

This is execute_process(), except stderr is returned separately. Iconic little split, honestly 🫢

Instead of yielding output line by line until yielding a return code, this function always gives you a triplet of stdout, stderr, and return code. Each time, only one of the three will be not None. Once you receive a non-None return code (type will be int), there will be no more stdout or stderr. Therefore you can use the command like this:

from __future__ import print_function
import sys
from osrf_pycommon.process_utils import execute_process_split

cmd = ['time', 'ls', '-G']
for out, err, ret in execute_process_split(cmd, cwd='/usr'):
    # In Python 3, it will be a bytes array which needs to be decoded
    out = out.decode('utf-8') if out is not None else None
    err = err.decode('utf-8') if err is not None else None
    if ret is not None:
        # This is a return code, the command has exited
        print("'{0}' exited with: {1}".format(' '.join(cmd), ret))
        break
    if out is not None:
        print(out, end='')
    if err is not None:
        print(err, end='', file=sys.stderr)

When using this, it is possible that the stdout and stderr data can be returned in a different order than what would happen on the terminal. This is due to the fact that the subprocess is given different buffers for stdout and stderr, and so there is a race condition on the subprocess writing to the different buffers and this command reading the buffers. This can be avoided in most scenarios by using emulate_tty, because of the use of ptys, though the ordering can still not be guaranteed and the number of ptys is finite as explained in the documentation for execute_process(). For situations where output ordering between stdout and stderr are critical, they should not be returned separately and instead should share one buffer, and so execute_process() should be used.

For all other parameters and documentation see: execute_process()

Availability: Unix (streaming), Windows (blocking)


✨ Utility Functions ✨

Currently there is only one utility function, a Python implementation of the which shell command.

osrf_pycommon.process_utils.which(cmd, mode=1, path=None, **kwargs)

Given a command, mode, and a PATH string, return the path which conforms to the given mode on the PATH, or None if there is no such file.

mode defaults to os.F_OK | os.X_OK. path defaults to the result of os.environ.get("PATH"), or can be overridden with a custom search path.

Backported from shutil.which() (https://docs.python.org/3.3/library/shutil.html#shutil.which), available in Python 3.3.


🌈 THE TERMINAL_COLOR MODULE 🌈

This module provides tools for colorizing terminal output. We love a little sparkle ✨

This module defines the ansi escape sequences used for colorizing the output from terminal programs in Linux. You can access the ansi escape sequences using the ansi() function:

>>> from osrf_pycommon.terminal_color import ansi
>>> print(["This is ", ansi('red'), "red", ansi('reset'), "."])
['This is ', '\x1b[31m', 'red', '\x1b[0m', '.']

You can also use format_color() to do in-line substitution of keys wrapped in @{} markers for their ansi escape sequences:

>>> from osrf_pycommon.terminal_color import format_color
>>> print(format_color("This is @{bf}blue@{reset}.").split())
['This', 'is', '\x1b[34mblue\x1b[0m.']

This is a list of all of the available substitutions β€” long form, shorter form, and value, bestie:

  • @{blackf} (shorter: @{kf}) β€” \033[30m
  • @{redf} (shorter: @{rf}) β€” \033[31m
  • @{greenf} (shorter: @{gf}) β€” \033[32m
  • @{yellowf} (shorter: @{yf}) β€” \033[33m
  • @{bluef} (shorter: @{bf}) β€” \033[34m
  • @{purplef} (shorter: @{pf}) β€” \033[35m
  • @{cyanf} (shorter: @{cf}) β€” \033[36m
  • @{whitef} (shorter: @{wf}) β€” \033[37m
  • @{blackb} (shorter: @{kb}) β€” \033[40m
  • @{redb} (shorter: @{rb}) β€” \033[41m
  • @{greenb} (shorter: @{gb}) β€” \033[42m
  • @{yellowb} (shorter: @{yb}) β€” \033[43m
  • @{blueb} (shorter: @{bb}) β€” \033[44m
  • @{purpleb} (shorter: @{pb}) β€” \033[45m
  • @{cyanb} (shorter: @{cb}) β€” \033[46m
  • @{whiteb} (shorter: @{wb}) β€” \033[47m
  • @{escape} β€” (no shorter form) β€” \033
  • @{reset} (shorter: @|) β€” \033[0m
  • @{boldon} (shorter: @!) β€” \033[1m
  • @{italicson} (shorter: @/) β€” \033[3m
  • @{ulon} (shorter: @_) β€” \033[4m
  • @{invon} β€” (no shorter form) β€” \033[7m
  • @{boldoff} β€” (no shorter form) β€” \033[22m
  • @{italicsoff} β€” (no shorter form) β€” \033[23m
  • @{uloff} β€” (no shorter form) β€” \033[24m
  • @{invoff} β€” (no shorter form) β€” \033[27m

These substitution’s values come from the ANSI color escape sequences, see: http://en.wikipedia.org/wiki/ANSI_escape_code

Also for any of the keys which have a trailing f, you can safely drop the trailing f and get the same thing.

For example, format_color("@{redf}") and format_color("@{red}") are functionally equivalent. periodt πŸ’…

Also, a bunch of the substitutions have shorter forms for convenience β€” like @{redf}, @{rf}, @{red}, and @{r} are all the same thing. slay ✨

Note that a trailing b is always required when specifying a background. no cap.

Some of the most common non-color sequences have {}-less versions. For example, @{boldon}’s shorter form is @!. iconic.

By default, the substitutions (and calls to ansi()) resolve to escape sequences, but if you call disable_ansi_color_substitution_globally() then they will resolve to empty strings. This allows you to always use the substitution strings and disable them globally when desired. vibe check passed βœ…

On Windows the substitutions are always resolved to empty strings as the ansi escape sequences do not work on Windows. Instead strings annotated with @{} style substitutions or raw \x1b[xxm style ansi escape sequences must be passed to print_color() in order for colors to be displayed on windows. Also the print_ansi_color_win32() function can be used on strings which only contain ansi escape sequences.

NOTE: bestie, yes β€” there are existing Python modules like colorama which provide ansi colorization on multiple platforms, so a valid question is: β€œwhy write this module?”. The reason for writing this module is to provide the color annotation of strings and functions for removing or replacing ansi escape sequences which are not provided by modules like colorama. This module could have depended on colorama for colorization on Windows, but colorama works by replacing the built-in sys.stdout and sys.stderr, which we did not want and it has extra functionality that we do not need. So, instead of depending on colorama, the Windows color printing code was used as the inspiration for the Windows color printing in the windows.py module in this terminal_color package. The colorama license was placed in the header of that file and the colorama license is compatible with this package’s license.

osrf_pycommon.terminal_color.ansi(key) β€” Returns the escape sequence for a given ansi color key.

osrf_pycommon.terminal_color.disable_ansi_color_substitution_globally() β€” Causes format_color() to replace color annotations with empty strings.

It also affects ansi().

This is not the case by default, so if you want to make all substitutions given to either function mentioned above return empty strings then call this function.

The default behavior can be restored by calling enable_ansi_color_substitution_globally().

osrf_pycommon.terminal_color.enable_ansi_color_substitution_globally() β€” Causes format_color() to replace color annotations with ansi escape sequences.

It also affects ansi().

This is the case by default, so there is no need to call this everytime.

If you have previously caused all substitutions to evaluate to an empty string by calling disable_ansi_color_substitution_globally(), then you can restore the escape sequences for substitutions by calling this function.

osrf_pycommon.terminal_color.format_color(msg) β€” Replaces color annotations with ansi escape sequences.

See this module’s documentation for the list of available substitutions.

If disable_ansi_color_substitution_globally() has been called then all color annotations will be replaced by empty strings.

Also, on Windows all color annotations will be replaced with empty strings. If you want colorization on Windows, you must pass annotated strings to print_color().

  • Parameters:
    • msg (str) β€” string message to be colorized
  • Returns: colorized string
  • Return type: str

osrf_pycommon.terminal_color.get_ansi_dict() β€” Returns a copy of the dictionary of keys and ansi escape sequences.

osrf_pycommon.terminal_color.print_ansi_color_win32(*args, **kwargs) β€” Prints color string containing ansi escape sequences to console in Windows.

If called on a non-Windows system, a NotImplementedError occurs.

Does not respect disable_ansi_color_substitution_globally().

Does not substitute color annotations like @{r} or @!, the string must already contain the \033[1m style ansi escape sequences.

Works by splitting each argument up by ansi escape sequence, printing the text between the sequences, and doing the corresponding win32 action for each ansi sequence encountered.

osrf_pycommon.terminal_color.print_color(*args, **kwargs) β€” Colorizes and prints with an implicit ansi reset at the end.

Calls format_color() on each positional argument and then sends all positional and keyword arguments to print.

If the end keyword argument is not present then the default end value ansi('reset') + '\n' is used and passed to print.

os.linesep is used to determine the actual value for \n.

Therefore, if you use the end keyword argument be sure to include an ansi reset escape sequence if necessary.

On Windows the substituted arguments and keyword arguments are passed to print_ansi_color_win32() instead of just print.

osrf_pycommon.terminal_color.remove_ansi_escape_senquences(string) β€” Removes any ansi escape sequences found in the given string and returns it. (yep, that senquences spelling is exactly what’s in the API β€” copy it exactly, bestie πŸ’…)

osrf_pycommon.terminal_color.remove_ansi_escape_sequences(string) β€” Removes any ansi escape sequences found in the given string and returns it.

osrf_pycommon.terminal_color.sanitize(msg) β€” Sanitizes the given string to prevent format_color() from substituting content.

For example, when the string 'Email: {user}@{org}' is passed to format_color() the @{org} will be incorrectly recognized as a colorization annotation and it will fail to substitute with a KeyError: org.

In order to prevent this, you can first β€œsanitize” the string, add color annotations, and then pass the whole string to format_color().

If you give this function the string 'Email: {user}@{org}', then it will return 'Email: {{user}}@@{{org}}'. Then if you pass that to format_color() it will return 'Email: {user}@{org}'. In this way format_color() is the reverse of this function and so it is safe to call this function on any incoming data if it will eventually be passed to format_color().

In addition to expanding { => {{, } => }}, and @ => @@, this function will also replace any instances of @!, @/, @_, and @| with @{atexclamation}, @{atfwdslash}, @{atunderscore}, and @{atbar} respectively. And then there are corresponding keys in the ansi dict to convert them back.

For example, if you pass the string '|@ Notice @|' to this function it will return '|@@ Notice @{atbar}'. And since ansi('atbar') always returns @|, even when disable_ansi_color_substitution_globally() has been called, the result of passing that string to format_color() will be '|@ Notice @|' again.

There are two main strategies for constructing strings which use both the Python str.format() function and the colorization annotations.

One way is to just build each piece and concatenate the result:

print_color("@{r}", "{error}".format(error=error_str))
# Or using print (remember to include an ansi reset)
print(format_color("@{r}" + "{error}".format(error=error_str) + "@|"))

Another way is to use this function on the format string, concatenate to the annotations, pass the whole string to format_color(), and then format the whole thing:

print(format_color("@{r}" + sanitize("{error}") + "@|")
      .format(error=error_str))

However, the most common use for this function is to sanitize incoming strings which may have unknown content:

def my_func(user_content):
    print_color("@{y}" + sanitize(user_content))

This function is not intended to be used on strings with color annotations.

  • Parameters:
    • msg (str) β€” string message to be sanitized
  • Returns: sanitized string
  • Return type: str

osrf_pycommon.terminal_color.split_by_ansi_escape_sequence(string, include_delimiters=False) β€” Splits a string into a list using any ansi escape sequence as a delimiter.

  • Parameters:
    • string (str) β€” string to be split
    • include_delimiters (bool) β€” If True include matched escape sequences in the list (default: False)
  • Returns: list of strings, split from original string by escape sequences
  • Return type: list

osrf_pycommon.terminal_color.test_colors(file=None) β€” Prints a color testing block using print_color()


🌈 THE TERMINAL_UTILS MODULE 🌈

This module has a miscellaneous set of functions for working with terminals.

You can use the get_terminal_dimensions() to get the width and height of the terminal as a tuple. You can also use the is_tty() function to determine if a given object is a tty. handy, fr fr 🫢

exception osrf_pycommon.terminal_utils.GetTerminalDimensionsError β€” Raised when the terminal dimensions cannot be determined.

osrf_pycommon.terminal_utils.get_terminal_dimensions() β€” Returns the width and height of the terminal.

  • Returns: width and height in that order as a tuple
  • Return type: tuple
  • Raises GetTerminalDimensionsError when the terminal dimensions cannot be determined

osrf_pycommon.terminal_utils.is_tty(stream) β€” Returns True if the given stream is a tty, else False.

  • Parameters:
    • stream β€” object to be checked for being a tty
  • Returns: True if the given object is a tty, otherwise False
  • Return type: bool

πŸŽ€ INSTALLING FROM SOURCE πŸŽ€

Given that you have a copy of the source code, you can install osrf_pycommon like this:

$ python setup.py install
NOTE: If you are installing to a system Python you may need to use sudo.

If you do not want to install osrf_pycommon into your system Python, or you don’t have access to sudo, then you can use a virtualenv. go off, bestie 🌱

πŸ’» HACKING πŸ’»

okay so here's the tea: osrf_pycommon runs on setuptools, which means you can β€” and honestly should β€” use the develop feature:

$ python setup.py develop
NOTE: if you're developing against the system Python, you might need sudo. 😭 just how it is bestie.

what this does is basically "install" osrf_pycommon to your Python path, but like… not really install it? instead of copying the source files over, it drops a lil marker file in the PYTHONPATH that redirects Python straight back to your source directory. so you get to use it as if it were installed, except changes to the source code take immediate affect. main character behavior honestly πŸ’…

when you're done with develop mode you can (and should!!) undo it like this:

$ python setup.py develop -u
NOTE: again, if you're developing against the system Python, sudo may be needed. 🫢

that "uninstalls" the hooks into the PYTHONPATH that point to your source directory β€” but ngl, be wary, because sometimes console scripts do not get removed from the bin folder. πŸ’€ so maybe go check.


πŸ§ͺ TESTING πŸ§ͺ

to run the tests you're gonna need to install flake8 first. no flake8, no vibes.

once you've got that installed, run unittest:

$ python3 -m unittest discover -v tests

and that's it, go off ✨


πŸ“š BUILDING THE DOCUMENTATION πŸ“š

to build the docs you'll need to first install Sphinx. yes, first. before anything. 🌸

you can build the documentation by invoking the Sphinx-provided make target in the docs folder:

$ # In the docs folder
$ make html
$ open _build/html/index.html

so iconic, so simple. BUT β€” sometimes Sphinx does not pickup on changes to modules in packages which utilize the __all__ mechanism 😭 so on repeat builds you may need to clean the docs first:

$ # In the docs folder
$ make clean
$ make html
$ open _build/html/index.html

boom, fresh docs, vibe check passed πŸ¦‹


πŸ‘‘ AUTHOR πŸ‘‘

William Woodall


2026, Open Source Robotics Foundation


and that's the whole page bestie!! πŸŽ€ this was osrf_pycommon, version 0.0, dated Sep 15, 2026, and you can find it chilling in section 1 of the manual as OSRF_PYCOMMON(1). go forth and slay your Unix tasks πŸ’–βœ¨πŸŒˆ

πŸ“¦ where this page comes from