Python

Python is a programming language (python language reference) that lets you work quickly and integrate systems more effectively. Where in other programming languages the indentation in code is for readability only, the indentation in Python is very important. Python uses indentation to indicate a block of code.

Install Python with Hatch.

Install Python

There are many ways to install python on your system. It is easy to download Python directly from python.org or, if you are on a Mac, by using pyenv.

You can also use hatch to install Python as well as set up Python environments.

On Mac

If you have a completely new Macintosh then you can download Python through pyenv. The advantage of using pyenv is easier managment of different Python versions.

  1. Use Homebrew to install pyenv with: brew install pyenv.
  2. Add the follwing code to your ~/.bashrc/ or ~/.zshrc file.
export PATH="$HOME/.pyenv/bin:$PATH"
eval "$(pyenv init -)"
eval "$(pyenv virtualenv-init -)"
  1. Restart the terminal.
  2. Open the terminal and do pyenv install --list to see the current list of available python versions.
  3. Install the chosen python version with pyenv install [version name]. For example pyenv install 3.12.0.

On Windows

Use pyenv-win.

Virtual environments (Linux/Mac)

  1. Set up the virtual environment: python -m venv venv
    • If you want to delete a previous installation then run python -m venv venv --clear
    • Confirm contents of a virtual env by doing python -m venv list
  2. Set the local pyenv environment with pyenv local [python version]. For example, pyenv local 3.12.0.
  3. Activate the environment in the terminal by doing: source venv/bin/activate
  4. Once the virtual environment is active then install packages through: python -m pip install [package-name]
  5. To deactivate the virtual environment do: source deactivate

Continue by reading a primer on virtual environments.

Basics

_init_.py

src/_init_.py

"""Init for hax."""
import logging
from pathlib import Path
from rich.logging import RichHandler

# Create console handler and formatter
console_handler = RichHandler(rich_tracebacks=True)
console_handler.setLevel(logging.WARNING)  # Log INFO or higher to console.
console_formatter = logging.Formatter("%(message)s", datefmt="%H:%M:%S")
console_handler.setFormatter(console_formatter)

# Create file handler and formatter
file_handler = logging.FileHandler(Path.home() / "temp" / "hax.log")
file_handler.setLevel(logging.WARNING)  # Log WARNING or higher to file.
file_formatter = logging.Formatter(
    "%(asctime)s:%(name)s:%(levelname)s:%(message)s", datefmt="%Y-%m-%d %H:%M:%S"
)
file_handler.setFormatter(file_formatter)

# Create log objects and attach handlers
log = logging.getLogger(
    __name__
)  # __name__ is the name of the module that contains the code
log.setLevel(logging.DEBUG)  # Let rootlogger log to 0 or higher.
log.addHandler(console_handler)
log.addHandler(file_handler)

src/cli/_init_.py

"""Init for hax CLI."""

Standard Library

Collection

This module implements specialized container datatypes providing alternatives to Python’s general purpose built-in containers, dict, list, set, and tuple.

Counter

A counter tool is provided to support convenient and rapid tallies. It is based on dict and use keys for things being counted and the values for their count.

from collections import Counter

Counter([1, 2, 3, 3])  # Returns Counter({1: 1, 2: 1, 3: 2})

Counter can take a wide number of different collection types.

It is also possible to pass in arguments.

from collections import Counter

test = Counter(i=3, p=5)
print(test['i'])

However asking for a key which is not present does not give a key error (like for dictionary) but just the number zero.

print(test['k']  # Returns 0

It is also possible to update a counter.

test.update(i=10, p=-10)

Most common

most<sub>common</sub>() returns a tuple with the most frequency item first. Most common takes an int as an argument, for how many items to return.

test.most_common(1)

To get the least common, use the reverse() method.

least_common = test.most_common()
least_common.reverse()

Same can be done with the built-in function `reversed(test.mostcommon)`.

Same can also be done with the reversed slice `test.mostcommon()[::-1]`.

Concurrent Execution Python

AsyncIO

[asyncio](https://docs.python.org/3/library/asyncio.html) is a library to write concurrent code using the async and await syntax.

asyncio is used as a foundation for multiple Python asynchronous frameworks that provide high-performance network and web-servers, database connection libraries, distributed task queues, etc.

asyncio is often a perfect fit for IO-bound and high-level structured network code.

asyncio provides a set of high-level APIs to:

  • run Python coroutines concurrently and have full control over their execution
  • perform network IO and IPC
  • control subprocesses
  • distribute tasks via queues
  • synchronize concurrent code

1.Runners

asyncio.run(coro, \*, debug=None) Execute the coroutine coro and return the result.

This function runs the passed coroutine, taking care of managing the asyncio event loop, finalizing asynchronous generators, and closing the threadpool.

This function cannot be called when another asyncio event loop is running in the same thread.

If debug is True, the event loop will be run in debug mode. False disables debug mode explicitly. None is used to respect the global Debug Mode settings.

This function always creates a new event loop and closes it at the end. It should be used as a main entry point for asyncio programs, and should ideally only be called once.

Example:

async def main(): await asyncio.sleep(1) print('hello')

asyncio.run(main())

Subprocess

  • [Subprocess](subprocess.md)

Subprocesses are separate instances of a program that run alongside the main program. They can be used to perform tasks in parallel, divide a large task into smaller pieces, or run separate programs that interact with the main program.

When a subprocdss has finished running then is it typically returning an integer. The integer is usually "0" when the process has finished successfully and an other number when it has failed.

[htop](https://htop.dev/) is command line tool for inspecting processes.

The python [subprocess module](https://docs.python.org/3/library/subprocess.html) allows you to spawn new processes, connect to their input/output/error pipes, and obtain their return codes. Most of your interaction with the Python subprocess module will be via the run() function. This blocking function will start a process and wait until the new process exits before moving on.

There are a few common reasons why you might want to call the shell with the Python subprocess module:

  • When you know certain commands are only available via the shell, which is more common on Windows.
  • When you’re experienced in writing shell scripts with a particular shell, so you want to leverage your ability there to do certain tasks while still working primarily in Python.
  • When you’ve inherited a large shell script that might do nothing that Python couldn’t do, but would take a long time to reimplement in Python.

Common reasons for using subprocess itself are similar in nature to using the shell with subprocess:

  • When you have to use or analyze a black box, or even a white box.
  • When you want a wrapper for an application.
  • When you need to launch another application.
  • As an alternative to basic shell scripts

It is possible to divide up the different parts of the command in a list. For example, to [open](https://ss64.com/osx/open.html) TextEdit on a Mac:

import subprocess

subprocess.run(["open", "-a", "TextEdit"])

Calling run() isn’t the same as calling programs on the command line. The run() function makes a [system call](https://en.wikipedia.org/wiki/System_call), foregoing the need for a shell. You’ll cover interaction with the shell in a later section.

<span class="underline">Learning Resources on Python Subprocess</span>

  • [RealPython](https://realpython.com/python-subprocess/)
  • Splitting up a command with shlex

    If you are unsure on how to split up a command then can you use shlex for help. Bear in mind that shlex is designed for POSIX compliant systems and may not work well in Windows environments.

    import shlex

    shlex.split("echo 'Hello, World!'") # Returns ['echo', 'Hello, World!']

    You’ll note that the message, which contains spaces, is preserved as a single token, and the extra quotation marks are no longer needed. The extra quotation marks on the shell serve to group the token together, but since subprocess uses sequences, it’s always unambiguous which parts should be interpreted as one token.

  • Dealing with Errors from Subprocesses

    When you use run(), the return value is an instance of the CompletedProcess class. As the name suggests, run() returns the object only once the child process has ended. It has various attributes that can be helpful, such as the args that were used for the process and the returncode.

    To deal with subprocess exceptions use the try&#x2026;except statments.

    try: subprocess.run( ["python", "timer.py", "5"], timeout=10, check=True ) except FileNotFoundError as exc: print(f"Process failed because the executable could not be found.\n{exc}") except subprocess.CalledProcessError as exc: print( f"Process failed because did not return a successful return code. " f"Returned {exc.returncode}\n{exc}" ) except subprocess.TimeoutExpired as exc: print(f"Process timed out.\n{exc}")

    1. The CompletedProcess Object

      To see this clearly, you can assign the result of run() to a variable, and then access its attributes such as .returncode.

      import subprocess

      completedprocess = subprocess.run(["ping", "-c 1", "google.com"]) print(f"\nProcess return code is:\t {completedprocess.returncode}")

      Usually you want to know if the subprocess has failed. You can do this with the `check=True` flag.

      completedprocess = subprocess.run(["ping", "-c 1", "example.text"], check=True)

    2. Processes Which Never Finish

      Processes which never complete will not necessarily raise any error messages. The way to deal with them is to set a limited amount of time to way for them with the `timeout​=[number]` parameter.

      subprocess.run(["sleep", "100"], timeout=10)

    3. FileNotFoundError for Programs That Don’t Exist

      Is raised if you try and call a program that doesn’t exist on the target system.

      subprocess.run(["notaprogram"]) # Returns FileNotFoundError: The system cannot find the file specified

      This type of error is raised no matter what, so you don’t need to pass in any arguments for the FileNotFoundError.

  • Working with Text Based Programs

    There are two separate processes that make up the typical command-line experience.

    1. The interpreter, which is typically thought of as the whole CLI. Common interpreters are Bash on Linux, Zsh on macOS, or PowerShell on Windows.
    2. The interface, which displays the output of the interpreter in a window and sends user keystrokes to the interpreter. The interface is a separate process from the shell, sometimes called a terminal emulator.

    The run() function can make a system call directly and doesn’t need to go through the shell to do so. Many programs that are thought of as shell programs, such as [Git](git.md), are really just text-based programs that don’t need a shell to run. This is especially true of UNIX environments, where all of the familiar utilities like `ls`, `rm`, `grep`, and `cat` are actually separate executables that can be called directly.

    Using the flag `shell​=True` uses the arguments `["sh", "-c"]` in the background. It means that these two versions of run() are interchangeable, either a) `run(["sh", -c", "a long command"])` or b) `run(["a long command"], shell=True)`.

    1. Capturing output

      To capture output when using the subprocess.run() command you can use the `captureoutput=True`. You then need to specify which stream of data you want to show, like `stdout` or `stderr`.

      number = subprocess.run("echo $RANDOM", captureoutput=True, shell=True) number.stdout # Returns b'32247\n'

      You will receive the output in bytes which you will then have to decode. To simplify this can you specify the encoding format with the parameter with the `encoding` parameter. For example to use `encoding​="utf-8"`. Since you then get a string back can you for example use strip() to remove newlines.

      number = subprocess.run("echo $RANDOM", captureoutput=True, shell=True, encoding="utf-8") number.stdout.strip() # Returns '32247'

    2. Giving input to subprocess

      Use the `inputer​="something"` paramter to give input to the subprocess. Using the encoding parameter puts the run)() command into text mode. Note that the imput an be "stacked" into the stream.

      Small reaction time game.

      from time import perfcounter, sleep from random import random

      print("Press enter to play") input() print("Ok, get ready!") sleep(random() * 5 + 1) print("go!") start = perfcounter() input() end = perfcounter() print(f"You reacted in {(end - start) * 1000:.0f} milliseconds!\nGoodbye!")

      Stacking two "return" on top of each other as input.

      import subprocess process = subprocess.run(["python", "reactiongame.py"], input="\n\n", encoding="utf-8")

      Outputs the following.

      Press enter to play Ok, get ready! go! You reacted in 0 milliseconds! Goodbye!

    3. Pipes and the Shell

      The Python subprocess uses pipes extensively to interact with the processes that it starts. When using the parameter `captureoutput​=True` is Python doing this for us. The following two statments are equivalent:

      number = subprocess.run(["python", "numbergenerator.py"], captureoutput=True)

      Is the same as:

      number = suprocess.run(["python", "numbergenerator.py"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)

      Pipe buffers have limited capacity and if you run out of it, then can you create a file to hold the data instead.

      from tempfile import TemporaryFile with TemporaryFile() as f: lsprocess = subprocess.run(["python", "magicnumber.py"], stdout=f) f.seek(0) print(f.read().decode("utf-8"))

      However, it is not possible to pass a bytes object or a string directly to the stdin argument. It needs to be something file-like.

      To link up two processes with a pipe from within subprocess is something that you can’t do with run(). Instead, you can delegate the plumbing to the shell.

      If you’re on a UNIX-based system where almost all typical shell commands are separate executables, then you can just set the input of the second process to the .stdout attribute of the first CompletedProcess:

      import subprocess lsprocess = subprocess.run(["ls", "/usr/bin"], stdout=subprocess.PIPE) grepprocess = subprocess.run( ["grep", "python"], input=lsprocess.stdout, stdout=subprocess.PIPE ) print(grepprocess.stdout.decode("utf-8"))

      Here the .stdout attribute of the CompletedProcess object of ls is set to the input of the grep<sub>process</sub>. It’s important that it’s set to input rather than stdin. This is because the .stdout attribute isn’t a file-like object.

      As an alternative, you can operate directly with files too, setting them to the standard stream parameters. When using files, you set the file object as the argument to stdin, instead of using the input parameter:

      import subprocess from tempfile import TemporaryFile with TemporaryFile() as f: lsprocess = subprocess.run(["ls", "/usr/bin"], stdout=f) f.seek(0) grepprocess = subprocess.run( ["grep", "python"], stdin=f, stdout=subprocess.PIPE )

      0 # from f.seek(0) print(grepprocess.stdout.decode("utf-8"))

    4. Popen

      The [Popen()](https://docs.python.org/3/library/subprocess.html#popen-objects) constructor is very similar in appearance to using `run()`. If there’s an argument that you can pass to run(), then you’ll generally be able to pass it to Popen(). The fundamental difference is that it’s not a [blocking](https://en.wikipedia.org/wiki/Blocking_(computing)) call—rather than waiting until the process is finished, it’ll run the process in parallel.

      A key point to note is that in contrast to run(), which returns a CompletedProcess object, the Popen() constructor returns a Popen object. The standard stream attributes of a CompletedProcess point to bytes objects or strings, but the same attributes of a Popen object point to the actual streams. This allows you to communicate with processes as they’re running.

      The Popen class has several methods that allow you to interact with the process, such as communicate(), poll(), wait(), terminate(), and kill().

      For example, run a command, capture the output and print the output (if any):

      process = subprocess.Popen(['ls', '-la'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) output, error = process.communicate()

      print(output.decode('utf-8')) print(error.decode('utf-8'))

      The `communicate()` method to retrieve the output and error (if any) from the subprocess. The \`output\` and \`error\` variables are bytes objects, so we use the `decode()` method to convert them to a string before printing them.

    5. Poll with Popen

      To deal with the non-blocking nature of Popen, do use `poll()`.

      import subprocess from time import sleep

      with subprocess.Popen( ["python", "timer.py", "5"], stdout=subprocess.PIPE ) as process:

      def pollandread(): print(f"Output from poll: {process.poll()}") print(f"Output from stdout: {process.stdout.read1().decode('utf-8')}")

      pollandread() sleep(3) pollandread() sleep(3) pollandread()

      This program calls the timer process in a context manager and assigns stdout to a pipe. Then it runs the .poll() method on the Popen object and reads its stdout.

      The .poll() method is a basic method to check if a process is still running. If it is, then .poll() returns None. Otherwise, it’ll return the process’s exit code.

      Then the program uses .read1() to try and read as many bytes as are available at .stdout.

      If you put the Popen object into text mode and then call .read() on .stdout, the call to .read() would be blocking until it reached a newline.

      To read as many bytes as are available at that time, disregarding newlines, you need to read with .read1(). It’s important to note that .read1() is only available on byte streams, so you need to make sure to deal with encodings manually and not use text mode.

    6. Linking Processes with PIPE

      As an example, are two processes started in parallel. They are joined with a common pipe, and the for loop takes care of reading the pipe at stdout to output the lines.

      lsprocess = subprocess.Popen(["ls", "-la"], stdout=subprocess.PIPE) grepprocess = subprocess.Popen( ["grep", "Movies"], stdin=lsprocess.stdout, stdout=subprocess.PIPE )

      for line in grepprocess.stdout: print(line.decode("utf-8").strip())

DateTime

The [datetime](https://docs.python.org/3/library/datetime.html) module in Python provides several classes to work with dates and times.

datetime Object

We import a module named datetime to work with dates and time as date/time objects.

Print the datetime of now() as year-month-day-hour-minute-second.

from datetime import datetime

dateobject = datetime.now()

print(dateobject.strftime("%Y-%m-%d-%H-%M-%S"))

Table 1: Working wiht datetime Objects
datetime Description
datetime.now() Create a datetime object for the current date and time.
datetime(2021, 12, 31, 23, 59, 59) Create a datetime object for a specific date and time.
datetime.now().isoformat Get time in (almost) ISO 8601 standard.
datetime.now().isoformat(sep​=" " Use space as separator.
.now().isoweekday() Get the weekeday in ISO 8601 standard
.now().timestamp() Number of seconds since Unix the epoch.

String Format Time (strftime)

[strftime](https://strftime.org/) is a method in the Python datetime module that stands for "string format time". It is used to convert a datetime object into a string of a specified format. The method takes a string format parameter that specifies the format of the output string. The method returns a string representing the date and time in the specified format.

A format code is a string with a bunch of special tokens that’ll be replaced with information from the datetime object.

Table 2: datetime Object
Description Object strftime token Example
year datetime.year %Y  
numeric month of the year datetime.month %m object without leading 0
numeric day of the month datetime.day %d object without leading 0
hour datetime.hour %H  
minute datetime.minute %M  
second datetime.second %S  
weekday as locale’s abbreviated name   %a Wed
weekdays full name   %A Wednesday
month full name   %B  
Weekday as a number 0-6, 0 is Sunday   %w 3
Day of month 01-31   %d 31
Month name, short version   %b Dec
Month name, full version   %B December
Month as a number 01-12   %m 12
Year, short version, without century   %y 18
Year, full version   %Y 2018
Hour 00-23   %H 17
Hour 00-12   %I 05
AM/PM   %p PM
Minute 00-59   %M 41
Second 00-59   %S 08
Microsecond 000000-999999   %f 548513
UTC offset   %z +0100
Timezone   %Z CST
Day number of year 001-366   %j 365
Week number of year, Sunday as the first day of week, 00-53   %U 52
Week number of year, Monday as the first day of week, 00-53   %W 52
Local version of date and time   %c Mon Dec 31 17:41:00 2018
Century   %C 20
Local version of date   %x 12/31/18
Local version of time   %X 17:41:00
A % character   %% %
ISO 8601 year   %G 2018
ISO 8601 weekday (1-7)   %u 1
ISO 8601 weeknumber (01-53)   %V 01

Timezone

If you want to represent your datetime objects in completely unambiguous terms, then you’ll first need to make your object time zone aware. Once you have a time zone–aware object, the time zone information gets added to your ISO timestamp.

now = datetime.now()

print(now.tzinfo) None

nowaware = now.astimezone()

print(nowaware.tzinfo) Romance Standard Time

nowaware.tzinfo datetime.timezone(datetime.timedelta(seconds=3600), 'Romance Standard Time')

nowaware.isoformat() '2022-11-22T14:31:59.331225+01:00'

Timedelta

You can also perform arithmetic operations on datetime objects like addition and subtraction.

next_day = now + datetime.timedelta(days=1)
previous_day = now - datetime.timedelta(days=1)

System

Files

src/file.py

"""Module used for handling files and folders."""
import json
import subprocess as sup
import toml
from dataclasses import dataclass
from datetime import datetime, timedelta
from hax import log
from pathlib import Path
from rich import print
from typing import Optional


@dataclass
class File:
    """Class for handling files and folders."""
    hbrew_cellar: Path = Path("/") / "opt" / "homebrew" / "Cellar"


class FileConverter:
    """Class for converting files."""

    @staticmethod
    def json_to_toml(file: Path):
        """Convert JSON file to TOML."""
        with open(file) as json_file, open(file.with_suffix(".toml"), "w") as toml_file:
            json_data = json.load(json_file)
            toml.dump(json_data, toml_file)


def mount_smbs(mounts: list[tuple[str, str]]):
    """Mount list of SMB shares."""
    log.debug(f"Mounting SMB shares:\n{mounts}")
    for mount in mounts:
        try:
            sup.run(f"mount -t smbfs {mount[0]} {mount[1]}", shell=True)
        except FileNotFoundError:
            print(f"Unable to mount [pink]{mount[0]}[/pink] to [pink]{mount[1]}[/pink]")


def purge_old_files(path: Path, days: int, recursive: bool = True):
    """Delete files older than x days."""
    find_function = path.rglob if recursive else path.glob
    now = datetime.now()
    deleted_files = 0
    for file in find_function("*"):
        if file.is_file() and file.stat().st_mtime < (now - timedelta(days=days)).timestamp():
            file.unlink()
            deleted_files += 1
    log.info(f"\nDeleted {deleted_files} files older than {days} days in {path}")


def rsync_copy(source: Path, destination: Path, file_filter: Optional[str]):
    """Use rsync to mirror files and folders from src to dest."""
    try:
        sup.run(f"rsync -av --delete {source}/{file_filter} {destination}", shell=True)
    except FileNotFoundError:
        print(f"Unable to find source: [pink]{source}[/pink]")

IO

I/O streams refer to the flow of data between a program and its environment, whether that's a user or file system. These streams are used to read and write data, and can be manipulated using various techniques such as buffering and serialization.

In practice, I/O streams are used in a variety of applications, from simple user input/output on the command line to more complex network protocols and file systems. Essentially, any time data needs to be transferred between different parts of a program or between a program and its environment, I/O streams are used to facilitate that transfer.

There are various types of I/O streams, including text and binary streams, buffered and unbuffered streams, and network streams. Each has its own specific use case and methods for manipulating data. In general, however, the goal of I/O streams is to make it easy for programs to interact with the world around them, whether that's by getting input from a user, writing data to a file, or sending messages to other machines on a network.

The standard I/O streams are:

  1. Reads stdin for input
  2. Writes to stdout for general output
  3. Writes to stderr for error reporting

Get Input from User/Keyboard, (input())

Use the `input()` function to ask the user for input.

inputfromuser = input("Give me some input: ") print(inputfromuser)

The input function always returns a value of type String. You can convert the input from the user to another type through [type casting](python-types.md).

age = int(input("Give me your age: ")) print(f"This is your age in 50 years: {age + 50}")

Output to Console, (print())

Use the print() function to send output to the console. print() takes a few arguments:

  • sep= : Defaults to a whitespace (" ")
  • end= : Defaults to a newline ("\n")

    age = int(input("Give me your age: ")) print("This is your age today: " + str(age), "This is your age in 50 years: " + str(age + 50), sep="\n->", end="\n\n\n\n")

Executing a module from the Python console

Do exec(open("filename.py").read()) you want to execute a python module/script from a REPL.

If you just want to run the file can you also import the whole file and then after you have updated the file can you get the updated version with reload file.

src/dev.py

"""dev.py module is used for development purposes."""
import json
import subprocess as sup
from shutil import which


class BrewPackage:
    """Class for working with brew packages.

    TODO:
        - [ ] Break out logic to get the data from the json string into a separate function.
        - [ ] Write tests for the logic on the json but not for shutil.which() or brew.
    """
    num_instances = 0

    def __init__(self, package_name: str):
        self.package_name = package_name
        type(self).num_instances += 1  # Use type(self) instead of the class name to allow inheritance.

    def version(self) -> str:
        """Display the version number of a brew package.

        shutil.which() works like `which` on the CLI and returns true if a binary is found on PATH.

        Parameters:
            self.package_name: Name of the brew package.

        Returns:
            Version number of the brew package.
        """
        if which(self.package_name) is not None:
            try:
                raw = sup.run(f"brew info {self.package_name} --json", capture_output=True, check=True, shell=True)
                string = raw.stdout.strip().decode("utf-8")
                jsons = json.loads(string)
                pkg_installed_version = jsons[0]["installed"][0]["version"]
                return pkg_installed_version
            except sup.CalledProcessError as e:
                return e.stderr.decode("utf-8")
        else:
            return 'Brew package "{self.package_name}" is not installed.'

External Libraries

Functools

def debug(func):
    """Print the function signature and return value."""
    @functools.wraps(func)
    def wrapper_debug(*args, **kwargs):
        # Do something before running the function.
        args_repr = [repr(a) for a in args]  # 1
        kwargs_repr = [f"{k}= {v!r}" for k, v in kwargs.items()]  # 2
        signature = ", ".join(args_repr + kwargs_repr)  # 3
        print(f"Calling {func.__name__}({signature})")
        value = func(*args, **kwargs)
        print(f"{func.__name__!r} returned {value!r}")  # 4
        # Do something after running the function
        return value
    return wrapper_debug

def timer(func):
    """Print the runtime for decorated function."""
    @functools.wraps(func)
    def wrapper_timer(*args, **kwargs):
        # Do something before running the function.
        start_time = time.perf_counter() #1
        value = func(*args, **kwargs)
        # Do something after running the function
        end_time = time.perf_counter() #2
        run_time = end_time - start_time
        print(f"Finished {func.__name__!r} in {run_time:.4f} seconds.")
        return value
    return wrapper_timer

Python Compared