Python

CadQuery minimal rectangular array (rarray) example

This example generates a 2x2 grid of 1x1mm rectangles in a sketch, then extrudes it

import cadquery as cq

# Create workplane (2d coordinate system for us to create the sketch in)
wp = cq.Workplane("XY")
# Create sketch & extrude
result = wp.sketch().rarray(
    1.5, # X distance between center points of rectangles
    1.5, # Y distance between center points of rectangles
    2, # Number of rectangles in X direction
    2 # Number of rectangles in Y direction
).rect(1,1).finalize().extrude(0.1)

result # This line is just to show the result in cq-editor or jupyter

Posted by Uli Köhler in CadQuery, Python

CadQuery: How to move/translate extruded sketch

In our previous example CadQuery minimal sketch extrude example we showed how to create and extrude a simple sketch.

You can translate this easily using

result = result.translate(cq.Vector(1,0,0))

Full example

import cadquery as cq

# Create workplane (2d coordinate system for us to create the sketch in)
wp = cq.Workplane("XY")
# Create sketch, create rect, close sketch and extrude the resulting face
result = wp.sketch().rect(2, 2).finalize().extrude(0.1)
result = result.translate(cq.Vector(1,0,0))

result # This line is just to show the result in cq-editor or jupyter

Posted by Uli Köhler in CadQuery, Python

CadQuery minimal sketch extrude example

The folllowing example creates a sketch in the XY plane, creates a 2x2mm rectangle in said sketch and extrudes it to a height of 0.1mm.

import cadquery as cq

# Create workplane (2d coordinate system for us to create the sketch in)
wp = cq.Workplane("XY")
# Create sketch, create rect, close sketch and extrude the resulting face
result = wp.sketch().rect(2, 2).finalize().extrude(0.1)

result # This line is just to show the result in cq-editor or jupyter

Posted by Uli Köhler in CadQuery, Python

CadQuery simple L-shaped piece example (with STEP export)

import cadquery as cq

# Define dimensions
base_length = 200  # 20cm
base_width = 80    # 8cm
plate_thickness = 2  # 2mm
upright_height = 400  # 40cm

# Create the base plate
base_plate = cq.Workplane("XY").box(base_length, base_width, plate_thickness)

# Create the upright plate
# Position is set such that it aligns with the end of the base plate and stands upright
upright_plate = cq.Workplane("XY").workplane(offset=plate_thickness).transformed(rotate=(0, 90, 0)).box(upright_height, base_width, plate_thickness).translate((base_length/2, 0, upright_height/2))

# Join the two parts into one
final_part = base_plate.union(upright_plate)

# Export to STEP
final_part.val().exportStep("L-piece.stp")

 

Posted by Uli Köhler in CadQuery, Python

Jupyter adjustment widgets with plus and minus buttons

First, define get and set functions:

# Basic examples for get and set value functions

def get_value(): # will only be used to get the initial value
    return httpx.get(f"http://{ip}/api/get-value").json()["value"]

def set_value(value):
    httpx.get(f"http://{ip}/api/set-value?nedge={value}")

 

import ipywidgets as widgets
from IPython.display import display

# Step 2: Define the widget components
value_display = widgets.IntText(value=get_value(), description='Value:', disabled=False)
plus_button = widgets.Button(description='+')
minus_button = widgets.Button(description='-')

def on_value_change(change):
    set_value(change['new'])
    
value_display.observe(on_value_change, names='value')

# Step 4: Define the update functions
def on_plus_button_clicked(b):
    value_display.value += 1

def on_minus_button_clicked(b):
    value_display.value -= 1

# Step 5: Bind the update functions to the buttons
plus_button.on_click(on_plus_button_clicked)
minus_button.on_click(on_minus_button_clicked)

# Step 6: Display the widgets
widgets_layout = widgets
display(value_display, plus_button, minus_button)

 

Posted by Uli Köhler in Jupyter, Python

How to plot cumulative Gitlab group members using matplotlib

This is based on my previous post to find the group ID by group name.

import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from datetime import datetime
import gitlab

def plot_cumulative_members(members):
    # Convert date strings to datetime objects and sort
    dates = sorted([datetime.strptime(member["access_granted_date"], '%Y-%m-%dT%H:%M:%S.%fZ') for member in members])

    # Calculate cumulative count
    cumulative_count = range(1, len(dates) + 1)

    # Plotting
    plt.figure(figsize=(10, 6))
    plt.plot(dates, cumulative_count, marker='o')
    plt.title('Cumulative Number of Users in GitLab Group Over Time')
    plt.xlabel('Date')
    plt.ylabel('Cumulative Number of Users')
    plt.grid(True)
    plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
    plt.gca().xaxis.set_major_locator(mdates.YearLocator())
    plt.gcf().autofmt_xdate()  # Rotation
    plt.show()

group_id = get_gitlab_group_id("Protectors of the Footprint Realm", 'glpat-...')
members = get_group_members(group_id, 'glpat-...')
with plt.xkcd():
    plot_cumulative_members(members)

Posted by Uli Köhler in GitLab, Python

How to find Gitlab group ID using Gitlab API via python-gitlab

pip install python-gitlab
import gitlab

def get_gitlab_group_id(group_name, access_token):
    # Initialize a GitLab instance with your private token
    gl = gitlab.Gitlab('https://gitlab.com', private_token=access_token)

    # Search for groups by name
    groups = gl.groups.list(search=group_name)
    for group in groups:
        if group.name == group_name or group.path == group_name:
            return group.id

    raise ValueError("Group not found")

# Usage example
group_id = get_gitlab_group_id("Protectors of the Footprint Realm", 'glpat-yykIsrTg6RyKcFAvd2os')

group_id will be an integer, for example 6604163

Posted by Uli Köhler in GitLab, Python

Where does matplotlib look for fonts on Linux?

Find out using

import matplotlib.font_manager
print(matplotlib.font_manager.X11FontDirectories)

On my Ubuntu 22.04, this lists:

['/usr/X11R6/lib/X11/fonts/TTF/',
 '/usr/X11/lib/X11/fonts',
 '/usr/share/fonts/',
 '/usr/local/share/fonts/',
 '/usr/lib/openoffice/share/fonts/truetype/',
 '~/.local/share/fonts',
 '~/.fonts']

 

Posted by Uli Köhler in Python

How to generate random strings that look like Gitlab access tokens

import secrets
import string

def generate_gitlab_access_token(length=20):
    characters = string.ascii_letters + string.digits + '_'
    token = ''.join(secrets.choice(characters) for _ in range(length))
    return f'glpat-{token}'

# Example usage:
access_token = generate_gitlab_access_token()
print(access_token)

Example output:

glpat-yykIsrTg6RyKcFAvd2os

 

Posted by Uli Köhler in GitLab, Python

How to fix matplotlib findfont: Font family ‘xkcd’ not found on Ubuntu 22.04+

Problem:

While plotting an XKCD-style plot using matplotlib, you see the following error messages:

findfont: Font family 'xkcd' not found.
findfont: Font family 'xkcd Script' not found.
findfont: Font family 'Comic Neue' not found.
findfont: Font family 'Comic Sans MS' not found.

Solution:

Install the Humor Sans font using

sudo apt -y install font-humor-sans

Additionally, you need to remove the matplotlib font cache:

rm -rf ~/.cache/matplotlib

 

 

Posted by Uli Köhler in Linux, Python

How to fix Python YAML namedtuple error: yaml.representer.RepresenterError: (‘cannot represent an object’, …

Problem:

You are trying to yaml.safe_dump() an object which is (or contains) a namedtuple instance such as:

import yaml
import collections

MyTuple = collections.namedtuple("MyTuple", ["a", "b", "c"])
mytuple = MyTuple(1,2,3)

yaml.safe_dump(mytuple)

which results in the following exception:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/uli/.local/lib/python3.10/site-packages/yaml/__init__.py", line 269, in safe_dump
    return dump_all([data], stream, Dumper=SafeDumper, **kwds)
  File "/home/uli/.local/lib/python3.10/site-packages/yaml/__init__.py", line 241, in dump_all
    dumper.represent(data)
  File "/home/uli/.local/lib/python3.10/site-packages/yaml/representer.py", line 27, in represent
    node = self.represent_data(data)
  File "/home/uli/.local/lib/python3.10/site-packages/yaml/representer.py", line 58, in represent_data
    node = self.yaml_representers[None](self, data)
  File "/home/uli/.local/lib/python3.10/site-packages/yaml/representer.py", line 231, in represent_undefined
    raise RepresenterError("cannot represent an object", data)
yaml.representer.RepresenterError: ('cannot represent an object', MyTuple(a=1, b=2, c=3))

Solution:

You need to add a custom representer to implicitly convert your namedtuple to a dict.

Before running yaml.safe_dump(), add the following lines:

import collections

def represent_namedtuple(dumper, data):
    return dumper.represent_dict(data._asdict())

yaml.SafeDumper.add_representer(MyTuple, represent_namedtuple)

You need to add_representer() for every namedtuple you use!

Now, the yaml.safe_dump() call should work perfectly:

yaml.safe_dump(mytuple) # Returns 'a: 1\nb: 2\nc: 3\n'

 

 

 

Posted by Uli Köhler in Python

How to fix Python yaml.representer.RepresenterError: (‘cannot represent an object’, defaultdict(<class ‘list’>, ….

Problem:

You are trying to yaml.safe_dump() an object which is (or contains) a defaultdict, e.g.:

import yaml
import collections

yaml.safe_dump(collections.defaultdict(list))

which results in the following exception:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/uli/.local/lib/python3.10/site-packages/yaml/__init__.py", line 269, in safe_dump
    return dump_all([data], stream, Dumper=SafeDumper, **kwds)
  File "/home/uli/.local/lib/python3.10/site-packages/yaml/__init__.py", line 241, in dump_all
    dumper.represent(data)
  File "/home/uli/.local/lib/python3.10/site-packages/yaml/representer.py", line 27, in represent
    node = self.represent_data(data)
  File "/home/uli/.local/lib/python3.10/site-packages/yaml/representer.py", line 58, in represent_data
    node = self.yaml_representers[None](self, data)
  File "/home/uli/.local/lib/python3.10/site-packages/yaml/representer.py", line 231, in represent_undefined
    raise RepresenterError("cannot represent an object", data)
yaml.representer.RepresenterError: ('cannot represent an object', defaultdict(<class 'list'>, {}))

Solution:

You need to add a custom representer to implicitly convert the defaultdict to a dict.

Before running yaml.safe_dump(), add the following lines:

import collections
from yaml.representer import Representer

yaml.SafeDumper.add_representer(collections.defaultdict, Representer.represent_dict)

Now, the yaml.safe_dump() call should work perfectly:

yaml.safe_dump(collections.defaultdict(list)) # Returns'{}\n'

 

 

Posted by Uli Köhler in Python

Python script to find largest files with given extension recursively

This simple Python script will list all files sorted by filesize in the given directory recursively. You must give an -e/--extension argument to only consider specific filename extensions. Filename extensions are compared in a case-insensitive manner.

#!/usr/bin/env python3
import os
import argparse

def convert_size(size_bytes):
    """ Convert byte size to a human-readable format. """
    for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']:
        if size_bytes < 1024:
            return f"{size_bytes:.2f}{unit}"
        size_bytes /= 1024
    return f"{size_bytes:.2f}YB"

def get_files_with_extensions(root_dir, extensions):
    """ Recursively find files with the given extensions in the directory. """
    for root, dirs, files in os.walk(root_dir):
        for file in files:
            if any(file.lower().endswith(ext.lower()) for ext in extensions):
                full_path = os.path.join(root, file)
                size = os.path.getsize(full_path)
                yield full_path, size

def main():
    parser = argparse.ArgumentParser(description="List files with specific extensions sorted by size.")
    parser.add_argument("directory", type=str, help="Directory to search")
    parser.add_argument("-e", "--extension", type=str, action='append', help="File extension to filter by", required=True)
    args = parser.parse_args()

    files = list(get_files_with_extensions(args.directory, args.extension))
    files.sort(key=lambda x: x[1])

    for file, size in files:
        print(f"{convert_size(size)} - {file}")

if __name__ == "__main__":
    main()

Usage example: Find largest Python files:

python ListLargestFiles.py -e py  .

Usage example: Find (some types of) movies:

python ListLargestFiles.py -e mov -e avi -e mkv -e mp4 ~/Nextcloud

Example output:

[...]
108.81MB - /home/uli/Nextcloud/Google Fotos/20220925_151542.mp4
117.92MB - /home/uli/Nextcloud/Google Fotos/20220925_151958.mp4
237.51MB - /home/uli/Nextcloud/Google Fotos/20220911_115209.mp4
265.02MB - /home/uli/Nextcloud/Google Fotos/20220905_151716.mp4
317.36MB - /home/uli/Nextcloud/Google Fotos/20220912_124906.mp4
431.06MB - /home/uli/Nextcloud/Google Fotos/20220921_153051.mp4
Posted by Uli Köhler in Python

How to install specific git revision/branch using pip

You can install a specific branch of repository using the pip install git+https://...@branch syntax.

This works with branches and tags:

pip install git+https://github.com/myuser/myrepo.git@mybranch

or, to select a specific revision, just use the revision ID (a shorter revision ID also works):

pip install git+https://github.com/myuser/myrepo.git@aa1edc18e0fb8dc12f550d3b0588eb2c53915097

 

Posted by Uli Köhler in git, Python

How to fix poetry publish HTTP Error 403: Invalid or non-existent authentication information

Problem:

You intend to publish your package on PyPI using poetry using a command such as

poetry publish

However, you see the following error message:

Publishing mypackage (0.1.0) to PyPI
 - Uploading mypackage-0.1.0-py3-none-any.whl FAILED

HTTP Error 403: Invalid or non-existent authentication information. See https://pypi.org/help/#invalid-auth for more information. | b'<html>\n <head>\n  <title>403 Invalid or non-existent authentication information. See https://pypi.org/help/#invalid-auth for more information.\n \n <body>\n  <h1>403 Invalid or non-existent authentication information. See https://pypi.org/help/#invalid-auth for more information.\n  Access was denied to this resource.<br/><br/>\nInvalid or non-existent authentication information. See https://pypi.org/help/#invalid-auth for more information.\n\n\n \n'

Solution:

First, you need to create a PyPI API token if you don’t have one already. The username/password authentication is deprecated.

Now run the following command to configure poetry:

poetry config http-basic.pypi __token__ APITOKEN

where APITOKEN is the API token you want to use on this computer. Leave __token__ as-is since it’s a special username that tells PyPI to use an API token instead of the old & deprecated username/password authentication

It should look like this:

poetry config http-basic.pypi __token__ pypi-AgEIc[...]

Now retry publishing:

poetry publish

Example output:

Publishing mypackage (0.1.0) to PyPI
 - Uploading mypackage-0.1.0-py3-none-any.whl 100%
 - Uploading mypackage-0.1.0.tar.gz 100%

 

Posted by Uli Köhler in Python

Recommended python project initialization & build tool

I recommend Poetry for initializing, managing & publishing python projects.

Posted by Uli Köhler in Python

Robust Linux Python serial port filtering by name and manufacturer using udev

Important note: This code is deprecated as it has been superseded by the UliSerial library on GitHub. UIiSerial implements the serial port filtering in a platform-independent manner using pyserial built-in features.

 

Typically, when one intends to select a serial port in Python, you use a sequence such as

glob.glob('/dev/ttyACM*')[0]

or just go to straight hard-coding the port such as /dev/ttyACM0.

It should be quite obvious why this isn’t robust:

  • There might be multiple serial ports and the order is unknown
  • You code might need to work on different computers, which lead to different ports being assigned
  • When you re-plug a device, it might get assigned a differnent serial port number (e.g. /dev/ttyACM1)

The following code uses only the linux integrated tool udevadm to query information and built-in libraries. Currently it does not work on anything but Linux.

import glob
import subprocess
import re

def get_serial_ports():
    # Get a list of /dev/ttyACM* and /dev/ttyUSB* devices
    ports = glob.glob('/dev/ttyACM*') + glob.glob('/dev/ttyUSB*')
    return ports

def get_udev_info(port):
    # Run the udevadm command and get the output
    result = subprocess.run(['udevadm', 'info', '-q', 'all', '-r', '-n', port], capture_output=True, text=True)
    return result.stdout

def find_serial_ports(vendor=None, model=None, usb_vendor=None, usb_model=None, usb_model_id=None, vendor_id=None):
    # Mapping of human-readable names to udevadm keys
    attribute_mapping = {
        "vendor": "ID_VENDOR_FROM_DATABASE",
        "model": "ID_MODEL_FROM_DATABASE",
        "usb_vendor": "ID_USB_VENDOR",
        "usb_model": "ID_USB_MODEL_ENC",
        "usb_model_id": "ID_USB_MODEL_ID",
        "vendor_id": "ID_VENDOR_ID",
        "usb_path": "ID_PATH",
        "serial": "ID_SERIAL_SHORT"
    }

    # Filters based on provided arguments
    filters = {}
    for key, value in locals().items():
        if value and key in attribute_mapping:
            filters[attribute_mapping[key]] = value

    # Find and filter ports
    ports = get_serial_ports()
    filtered_ports = []

    for port in ports:
        udev_info = get_udev_info(port)
        match = True

        for key, value in filters.items():
            if not re.search(f"{key}={re.escape(value)}", udev_info):
                match = False
                break

        if match:
            filtered_ports.append(port)

    return filtered_ports

def get_serial_port_info(port):
    # Mapping of udevadm keys to human-readable names
    attribute_mapping = {
        "ID_VENDOR_FROM_DATABASE": "vendor",
        "ID_MODEL_FROM_DATABASE": "model",
        "ID_USB_VENDOR": "usb_vendor",
        "ID_USB_MODEL_ENC": "usb_model",
        "ID_USB_MODEL_ID": "usb_model_id",
        "ID_VENDOR_ID": "vendor_id",
        "ID_SERIAL_SHORT": "serial",
    }

    # Run the udevadm command and get the output
    udev_info = get_udev_info(port)
    port_info = {}

    for line in udev_info.splitlines():
        if line.startswith('E: '):
            key, value = line[3:].split('=', 1)
            if key in attribute_mapping:
                # Decode escape sequences like \x20 to a space.
                # NOTE: Since only \x20 is common, we currently only replace that one
                port_info[attribute_mapping[key]] = value.replace('\\x20', ' ')

    return port_info

def find_serial_port(**kwargs):
    """
    Find a single serial port matching the provided filters.
    """
    ports = find_serial_ports(**kwargs)
    if len(ports) > 1:
        raise ValueError("Multiple matching ports found for filters: " + str(kwargs))
    elif len(ports) == 0:
        raise ValueError("No matching ports found for filters: " + str(kwargs))
    else:
        return ports[0]

 

Example: Find a serial port

# Example usage: Find a serial port
matching_ports = find_serial_ports(vendor="OpenMoko, Inc.")
print(matching_ports) # Prints e.g. ['/dev/ttyACM0']

Example: Print information about a given port

This is typically used so you know what filters to add for find_serial_ports().

# Example usage: Print info about a serial port
serial_port_info = get_serial_port_info('/dev/ttyACM0')
print(serial_port_info)

This prints, for example:

{
    'serial': '01010A23535223934CF29A1EF5000007',
    'vendor_id': '1d50',
    'usb_model': 'Marlin USB Device',
    'usb_model_id': '6029',
    'usb_vendor':
    'marlinfw.org',
    'vendor': 'OpenMoko, Inc.',
    'model': 'Marlin 2.0 (Serial)'
}

 

Posted by Uli Köhler in Electronics, Linux, Python

Jupyter widget notebook with two sliders, making a HTTP POST request on change

This extended version of Jupyter Widget notebook with interactive IntSlider making a HTTP POST request features two sliders instead of one.

import ipywidgets as widgets
import httpx
from IPython.display import display
# Define the slider widget
delaySlider = widgets.IntSlider(
    value=450,  # Initial value
    min=0,    # Minimum value
    max=2000,    # Maximum value
    step=1,  # Step size
    description='Delay:'
)
lengthSlider = widgets.IntSlider(
    value=20*10,  # Initial value
    min=0,    # Minimum value
    max=40*10,    # Maximum value
    step=1,  # Step size
    description='Length:'
)
# Define a function to handle slider changes
def on_slider_change(change):
    # Define the API URL with the slider value
    httpx.post("http://10.1.2.3/api/configure", json={"channels":[{
        "channel": 0,
        "delay": delaySlider.value,
        "length": lengthSlider.value,
    }]})
# Attach the slider change handler to the slider widget
delaySlider.observe(on_slider_change, names='value')
lengthSlider.observe(on_slider_change, names='value')
# Display the slider widget in the notebook
display(widgets.Box(children=[delaySlider, lengthSlider]))

 

Posted by Uli Köhler in Jupyter, Python

Jupyter Widget notebook with interactive IntSlider making a HTTP POST request

This Jupyter notebook displays an IntSlider and makes a HTTP POST request with JSON body on every change using the httpx library.

import ipywidgets as widgets
import httpx
from IPython.display import display
# Define the slider widget
slider = widgets.IntSlider(
    value=450,  # Initial value
    min=0,    # Minimum value
    max=2000,    # Maximum value
    step=1,  # Step size
    description='Value:'
)
# Define a function to handle slider changes
def on_slider_change(change):
    slider_value = change['new']
    # Define the API URL with the slider value
    httpx.post("http://10.1.2.3/api/configure", json={"delay": slider_value})
# Attach the slider change handler to the slider widget
slider.observe(on_slider_change, names='value')
# Display the slider widget in the notebook
display(slider)

 

Posted by Uli Köhler in Jupyter, Python

Trimming Down Long Directory and File Names using Python

This Python script takes a directory path and then proceeds to scan every file and sub-directory within, looking for any filenames or directory names that exceed a specified length. Once found, the script truncates these names to fit within the desired length and appends an ellipsis (e.g., ...) to indicate the truncation. The beauty of this script lies in its configurability and the safety features embedded within it.

Note: Using this script will lose information (because the new filenames will be shorter and any important information in the rest of the filename will be lost forever). Additionally, it might lose information in other ways. Even though we have tested it carefully, it might still destroy your data in unexpected ways. Use a

How to use

The script provides the following command line options:

usage: FixLongFilenames.py [-h] [-n LENGTH] [--ellipsis ELLIPSIS] [--dry] directory

Shorten long filenames and directory names.

positional arguments:
  directory             The directory to process.

options:
  -h, --help            show this help message and exit
  -n LENGTH, --length LENGTH
                        The maximum allowable length for directory or file names.
  --ellipsis ELLIPSIS   The ellipsis to use when shortening.
  --dry                 Dry run mode, only log what would be renamed without actual renaming.

Source code

#!/usr/bin/env python3
import os
import argparse

def shorten_path(path, max_length, ellipsis, dry_run):
    if os.path.isdir(path):
        base_name = os.path.basename(path)
        if len(base_name) > max_length:
            new_name = base_name[:max_length] + ellipsis
            new_path = os.path.join(os.path.dirname(path), new_name)
            if not os.path.exists(new_path):
                if dry_run:
                    print(f"[DRY RUN] Directory would be renamed: {path} -> {new_name}")
                else:
                    os.rename(path, new_path)
                    print(f"Renamed directory: {path} -> {new_name}")
                return new_path
    else:
        base_name, ext = os.path.splitext(os.path.basename(path))
        if len(base_name) > max_length:
            new_name = base_name[:max_length] + ellipsis + ext
            new_path = os.path.join(os.path.dirname(path), new_name)
            if not os.path.exists(new_path):
                if dry_run:
                    print(f"[DRY RUN] File would be renamed: {path} -> {new_name}")
                else:
                    os.rename(path, new_path)
                    print(f"Renamed file: {path} -> {new_name}")
                return new_path
    return path

def iterate_and_shorten(directory, max_length, ellipsis, dry_run):
    for root, dirs, files in os.walk(directory, topdown=False):
        for dname in dirs:
            dpath = os.path.join(root, dname)
            shortened_path = shorten_path(dpath, max_length, ellipsis, dry_run)
            dirs[dirs.index(dname)] = os.path.basename(shortened_path)

        for fname in files:
            fpath = os.path.join(root, fname)
            shorten_path(fpath, max_length, ellipsis, dry_run)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Shorten long filenames and directory names.")
    parser.add_argument('directory', help="The directory to process.")
    parser.add_argument('-n', '--length', type=int, default=100, help="The maximum allowable length for directory or file names.")
    parser.add_argument('--ellipsis', default="...", help="The ellipsis to use when shortening.")
    parser.add_argument('--dry', action="store_true", help="Dry run mode, only log what would be renamed without actual renaming.")
    args = parser.parse_args()

    iterate_and_shorten(args.directory, args.length, args.ellipsis, args.dry)

 

Posted by Uli Köhler in Python