Skip to content

Instantly share code, notes, and snippets.

@snejus
Last active December 24, 2023 11:09
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save snejus/85b47dca884a5aca2646dfbde79e9c92 to your computer and use it in GitHub Desktop.
Save snejus/85b47dca884a5aca2646dfbde79e9c92 to your computer and use it in GitHub Desktop.
See python package's direct and reverse dependencies, and version changes between revisions in milliseconds
#!/bin/bash
# Read poetry.lock and display information about dependencies:
#
# * Project dependencies
# * Sub-dependencies and reverse dependencies of packages
# * Summary of updates, or change in dependency versions between two revisions of the project
#
# Author: Sarunas Nejus, 2021
# License: MIT
helpstr="
deps [-lq] [<package>]
deps diff [<base-ref>=HEAD] [<target-ref>]
-h, --help -- display help
deps [-l] -- show top-level project dependencies
-l -- include sub-dependencies
deps [-q] <package> -- show direct and reverse dependencies for the package
-q -- exit with status 0 if package is a dependency, 1 otherwise
deps diff [<base-ref>=HEAD] [<target-ref>]
-- summarise version changes between two revisions for all dependencies
-- it defaults to using a dirty poetry.lock in the current worktree
"
shopt -s extglob
PS4='$EPOCHREALTIME:($BASH_SOURCE:$LINENO): ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
R=$'\033[0m'
B=$'\033[1m'
I=$'\033[3m'
RED=$'\033[1;38;5;204m'
GREEN=$'\033[1;38;5;150m'
YELLOW=$'\033[1;38;5;222m'
CYAN=$'\033[1;38;5;117m'
MAGENTA=$'\033[1;35m'
GREY=$'\033[1;38;5;243m'
LOCKFILE=${LOCKFILE:-poetry.lock}
PYPROJECT=${PYPROJECT:-pyproject.toml}
DEPS_COLOR_PAT='
s/^/\t/
# s/=([<>])/ \1/
s/([a-z_=-]+=)([^@]+)/\1'"$R$B$I\2$R"'/g
s/(rich.tables|beetcamp|beets)[^ \t]*/'"$CYAN\1$R"'/g
s/([ ,])([<0-9.]+[^@ ]+|[*]|$)/\1'"$RED\2$R"'/g
/[>~^]=?/{
/[<~^]+/s/([>~^][0-9.=a-z]+)/'"$YELLOW&$R"'/g
/</!s/(>[^ @]+)/'"$GREEN&$R"'/
}
'
msg() {
printf >&2 '%s\n' " · $*"
}
error() {
echo
msg "${RED}ERROR:$R $*"$'\n'
exit 1
}
check_exists() {
for file in "$@"; do
[[ -r $file ]] || error "$file" is not found in the working directory
done
}
deps_diff() {
if ((!$#)) && git diff-files --quiet "$LOCKFILE"; then
msg "$B$LOCKFILE$R is no different from the committed or staged version"
exit
fi
git diff -U3 --word-diff=plain --word-diff-regex='[^. ]*' "$@" "$LOCKFILE" |
tr -d '\r' |
# remove the description line
grep -v 'description = ' |
# keep valid, *package* version *changes*
grep '^.?.?(version|optional) = .*(-\]|\+\})' -EC3 |
# duplicate the version line to help with reporting in the next command
sed '\/version/p; s/version/& green/' |
sed -nr '
# quit once we reach the files metadata section
/\[metadata.files\]/q
# get package name, make it bold and save it
/name = /{ s///; s/.*/'"$B&$R"'/; h; }
# get the first version line, remove the updated version and save it
/version = /{ s///; s/\{\+[^+]+\+\}//g; s/$/ '$'\b''->/; H; }
# get the second version line, remove the old version and save it
/version green = /{ s///; s/\[-[^-]+-\]//g; H; }
/optional = /{
s///
# take the saved string
x
# remove double quotes
s/"//g
# color all removals in red
s/\[-([^]]*)-\]/'"$RED\1$R"'/g
# color all additions in green
s/\{\+([^+]+)\+\}/'"$GREEN\1$R"'/g
s/\n/'$'\b''/g
p
}
' | {
mapfile -t lines
if ! test ${#lines[@]} -eq 0; then
echo
printf '%s\n' "${lines[@]}" | column -ts$'\b'
echo
fi
}
}
_requires() {
pkg_pat=$1
sed -nr '
# take paragraphs starting with queried package name, until next one
/^name = "('"$pkg_pat"')"/I,/\[\[package\]\]/{
# its dependencies section, until next empty line
s/name = "([^"]+)"/ '"$B\1$R"' requires/p
/\[package.dep.*/,/^$/{
# ignore header
//d
# remove double quotes, curly brackets and backslashes
s/[\\"{}]//g
s/ =|$/ '$'\b''/
'"$DEPS_COLOR_PAT"'
p
}
}
' "$LOCKFILE" | column -ts$'\b'
}
_required_by() {
pkg_pat=$1
echo " $B${pkg_pat%%\|*}$R is required by"
{
sed -nr '
# memorise current package name
/^name = "([^"]+)"/{ s//\1/; h; }
/^"?('"$pkg_pat"')"? = (["{].*["}])/I{
# take the version or markers
s//\2/
s/ ([<>=]) /=\1/
# remove quotes and curly brackets
s/[\\"{}]//g
# retrieve name and version from the memory
H; x
# split them by \b
s/\n/ '$'\b'' /
p
}
' "$LOCKFILE" &&
sed -rn '
/^('"$pkg_pat"') = (.*)/{
s//'"$project"' '$'\b'' \2/
s/["{}]//g
p
}
' "$PYPROJECT"
} | sed -r "$DEPS_COLOR_PAT" | column -ts$'\b'
}
long_project_deps() {
section=${1:+$1.}dependencies
maindeps=($(sed -rn '
/tool.poetry.*'"$section"'/,/^\[/{
//d
/^python/d
s/^([^ ]+) =.*/\1/p
}
' "$PYPROJECT" | paste -sd'|'))
_requires "$maindeps"
}
short_project_deps() {
section=${1:+$1.}dependencies
sed -rn $'
/tool.poetry.*'"$section"'/,/^\[/{
//d
# skip empty lines, python version and comments
/^($|(python ?=|#).*$)/d
# remove irrelevant characters and comments
s/[]\[{}" ]|#.+//g
# when requirement is an object, join members with @
/,([^=]+=)/{
s//'$'\b'' \1/g
# version requirement is already clear
s/version=//g
}
# split package name and its requirements
s/=/ '$'\b'' /
'"$DEPS_COLOR_PAT"'
p
}
' "$PYPROJECT" | column -ts$'\b'
}
project_deps() {
if [[ $1 == long ]]; then
func=long_project_deps
else
func=short_project_deps
fi
echo
echo "$B MAIN DEPENDENCIES$R"
$func
echo
echo "$B DEV DEPENDENCIES$R"
$func dev
echo
}
pkg_deps() {
pkg=$1
pkg_pat="$pkg|${pkg//_/-}|${pkg//-/_}"
pkg_with_ver=$(
sed -rn '
/^name = "('"$pkg_pat"')"/I{ s//\1/; h; }
/^version = "(.+)"/{
s//\1/; H;
x; /'"$pkg_pat"'/I{ s/\n/ /; p; q; }
}
' "$LOCKFILE"
)
[[ -n $pkg_with_ver ]] || error "Package $B$pkg$R is not found"
msg "Package: $B$pkg_with_ver$R"
echo
_requires "$pkg_pat"
echo
_required_by "$pkg_pat"
echo
}
show_help() {
sed -r '
### Comments
s/-- .*/'"$GREY&$R"'/
### Optional arguments
# within brackets
s/(\W)(-?-(\w|[-])+)/\1'"$B$YELLOW\2$R"'/g
### Commands
/^( +)([_a-z][^ A-Z]*)( +|\t| *$)/s//\1'"$B$CYAN\2$R"'\3/
# <arg>
/<[^>]+>/s//'"$B$MAGENTA&$R"'/g
### Default values
# =arg|=ARG
/=((\w|-)+)/s//='"$B$GREEN\1$R"'/g
### Punctuation
s/(\]+)( |$)/'"$B$YELLOW\1$R"'\2/g
s/([m ])(\[+)/\1'"$B$YELLOW\2$R"'/g
' <<<"$helpstr"
}
[[ " $* " == *" -q "* ]] && quiet=1
_pkg=(${@#-q})
pkg=${_pkg[0]}
while read -r line; do
if [[ $line =~ ^name.=.\"([^\"]+)\" ]]; then
project=${BASH_REMATCH[1]}
elif [[ $line =~ ^version.=.\"([^\"]+)\" ]]; then
version=${BASH_REMATCH[1]}
elif [[ $line =~ ^python.=.\"([^\"]+)\" ]]; then
python_req=${BASH_REMATCH[1]}
break
fi
done <"$PYPROJECT"
args=(${@##-*})
flags=(${@##[A-Za-z]*})
if ((!quiet)) && [[ ${args[0]} != diff ]]; then
msg "Project: $B$project$R"
msg "Version: $B$version$R"
msg "Python: $B$python_req$R"
fi
for flag in "${flags[@]}"; do
case $flag in
-d | --debug) set -x ;;
-l | --long) long=1 ;;
-q | --quiet) quiet=1 ;;
--help | -h | help)
show_help
exit
;;
esac
done
if ((!$#)); then
check_exists "$PYPROJECT"
project_deps
elif ((long)); then
check_exists "$PYPROJECT" "$LOCKFILE"
project_deps long
elif ((quiet)); then
pkg_deps "${args[@]}" &>/dev/null
elif [[ ${args[0]} == diff ]]; then
deps_diff "${@:2}"
elif ((${#args[@]})); then
pkg_deps "${args[@]}"
fi
@snejus
Copy link
Author

snejus commented Dec 13, 2021

# Show top-level project dependencies

    deps  [-l]

# Show direct and reverse dependencies for the package

    deps  [-q]  <package>

# Summarise version changes between two revisions for all dependencies
# It defaults to using a dirty poetry.lock in the current worktree

    deps  diff  [<base-ref>=HEAD]  [<target-ref>]

    -l          # include sub-dependencies in the project dependencies view
    -q          # return 0 if package is a dependency, 1 otherwise
    -h, --help  # display help

A short demo:
asciicast

See below for some example outputs

@snejus
Copy link
Author

snejus commented Dec 13, 2021

deps

image

@snejus
Copy link
Author

snejus commented Dec 13, 2021

deps -l

image

@snejus
Copy link
Author

snejus commented Dec 13, 2021

deps <package>

image image

@snejus
Copy link
Author

snejus commented Dec 13, 2021

deps diff

image

@snejus
Copy link
Author

snejus commented Dec 13, 2021

Comparison between poetry show pytest, pip show pytest, deps pytest

image

@snejus
Copy link
Author

snejus commented Mar 14, 2022

  • Slightly updated the deps diff output
    image

  • And the project deps output
    image

@snejus
Copy link
Author

snejus commented Nov 7, 2022

deps diff does not anymore require a version change in order to show the change - dependencies being moved between main / optional will now also trigger it

@snejus
Copy link
Author

snejus commented Dec 22, 2023

A couple of updates

  1. Poetry abolished package categories, so they are gone from deps diff output
    image

  2. deps <dependency> now shows when it's required by the project
    image

  3. Markers are parsed appropriately and are listed in a single line
    image

@snejus
Copy link
Author

snejus commented Dec 24, 2023

Fix: dependencies with names starting with python are not anymore ignored in the project dependencies view

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment