Compare commits

..

No commits in common. "v1.1.0" and "main" have entirely different histories.
v1.1.0 ... main

258 changed files with 21589 additions and 1041 deletions

301
.circleci/config.yml Normal file
View File

@ -0,0 +1,301 @@
---
version: 2.1
orbs:
python: circleci/python@2.1.1
executors:
archlinux:
docker:
- image: archlinux:base-devel
resource_class: small
working_directory: /
python:
docker:
- image: cimg/python:3.11
resource_class: small
nodejs:
docker:
- image: node:18
resource_class: medium
commands:
archlinux-run:
description: execute command steps in the archlinux container from the CI user
parameters:
_name:
type: string
command:
type: string
working_directory:
type: string
default: /home/ci
steps:
- run:
name: << parameters._name >>
working_directory: << parameters.working_directory >>
command: su ci -c '<< parameters.command >>'
custom:
archlinux:
prepare:
- &archlinux-prepare
run:
name: prepare archlinux dependencies
command: |
pacman --noconfirm -Syu git openssh ca-certificates-utils
useradd -m ci
echo "ci ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
temp-downgrade-fakeroot:
- &archlinux-temp-downgrade-fakeroot
run:
name: downgrade fakeroot to v1.34 (v1.35 and v1.36 are confirmed to break)
command: |
pacman -U --noconfirm https://archive.archlinux.org/packages/f/fakeroot/fakeroot-1.34-1-x86_64.pkg.tar.zst
clone-aur:
- &archlinux-clone-aur
archlinux-run:
_name: clone aur/scwrypts
command: git clone https://aur.archlinux.org/scwrypts.git aur
clone-scwrypts:
- &archlinux-clone-scwrypts
run:
name: clone wrynegade/scwrypts
working_directory: /home/ci
command: |
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no" git clone -b "$(echo $CIRCLE_BRANCH | grep . || echo $CIRCLE_TAG)" "$CIRCLE_REPOSITORY_URL" scwrypts
chown -R ci:ci ./scwrypts
jobs:
require-full-semver:
executor: python
steps:
- run:
name: check CIRCLE_TAG for full semantic version
command: |
: \
&& [ $CIRCLE_TAG ] \
&& [[ $CIRCLE_TAG =~ ^v[0-9]*.[0-9]*.[0-9]*$ ]] \
;
aur-test:
executor: archlinux
steps:
- *archlinux-prepare
- *archlinux-temp-downgrade-fakeroot
- *archlinux-clone-aur
- *archlinux-clone-scwrypts
- archlinux-run:
_name: test aur build on current source
working_directory: /home/ci/aur
command: >-
:
&& PKGVER=$(sed -n "s/^pkgver=//p" ./PKGBUILD)
&& cp -r ../scwrypts ../scwrypts-$PKGVER
&& rm -rf ../scwrypts-$PKGVER/.circleci
&& rm -rf ../scwrypts-$PKGVER/.git
&& rm -rf ../scwrypts-$PKGVER/.gitattributes
&& rm -rf ../scwrypts-$PKGVER/.gitignore
&& rm -rf ../scwrypts-$PKGVER/.github
&& tar -czf scwrypts.tar.gz ../scwrypts-$PKGVER
&& echo "source=(scwrypts.tar.gz)" >> PKGBUILD
&& echo "sha256sums=(SKIP)" >> PKGBUILD
&& makepkg --noconfirm -si
&& echo validating scwrypts version
&& scwrypts --version | grep "^scwrypts v$PKGVER$"
;
aur-publish:
executor: archlinux
steps:
- *archlinux-prepare
- *archlinux-temp-downgrade-fakeroot
- *archlinux-clone-aur
- archlinux-run:
_name: update PKGBUILD and .SRCINFO
working_directory: /home/ci/aur
command: >-
:
&& NEW_VERSION=$(echo $CIRCLE_TAG | sed 's/^v//')
&& sed "s/pkgver=.*/pkgver=$NEW_VERSION/; s/^pkgrel=.*/pkgrel=1/; /sha256sums/d" PKGBUILD -i
&& makepkg -g >> PKGBUILD
&& makepkg --printsrcinfo > .SRCINFO
;
- archlinux-run:
_name: sanity check for version build
working_directory: /home/ci/aur
command: >-
:
&& makepkg --noconfirm -si
&& scwrypts --version
&& scwrypts --version | grep -q "^scwrypts $CIRCLE_TAG\$"
;
- archlinux-run:
_name: publish new version
working_directory: /home/ci/aur
command: >-
:
&& git add PKGBUILD .SRCINFO
&& git -c user.email=yage@yage.io -c user.name=yage commit -am "$CIRCLE_TAG"
&& eval $(ssh-agent)
&& echo -e $SSH_KEY_PRIVATE__AUR | ssh-add -
&& git remote add upstream ssh://aur@aur.archlinux.org/scwrypts.git
&& GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no" git push upstream
;
nodejs-test:
executor: nodejs
working_directory: ~/scwrypts/zx/lib
steps:
- checkout:
path: ~/scwrypts
- restore_cache:
name: restore pnpm cache
keys:
- pnpm-packages-{{ checksum "pnpm-lock.yaml" }}
- run:
name: pnpm install
command: |
corepack enable
corepack prepare pnpm@latest-8 --activate
pnpm config set store-dir .pnpm-store
pnpm install
- save_cache:
name: save pnpm cache
key: pnpm-packages-{{ checksum "pnpm-lock.yaml" }}
paths:
- .pnpm-store
- run: pnpm test
- run: pnpm lint
- run: pnpm build
nodejs-publish:
executor: nodejs
working_directory: ~/scwrypts/zx/lib
steps:
- checkout:
path: ~/scwrypts
- restore_cache:
name: restore pnpm cache
keys:
- pnpm-packages-{{ checksum "pnpm-lock.yaml" }}
- run:
name: pnpm install
command: |
corepack enable
corepack prepare pnpm@latest-8 --activate
pnpm config set store-dir .pnpm-store
pnpm install
- save_cache:
name: save pnpm cache
key: pnpm-packages-{{ checksum "pnpm-lock.yaml" }}
paths:
- .pnpm-store
- run:
name: publish
command: |
: \
&& [ $CIRCLE_TAG ] \
&& pnpm build \
&& pnpm version $CIRCLE_TAG \
&& pnpm set //registry.npmjs.org/:_authToken=$NPM_TOKEN \
&& pnpm publish --no-git-checks \
;
python-test:
executor: python
working_directory: ~/scwrypts/py/lib
steps:
- checkout:
path: ~/scwrypts
- run:
name: pytest
command: |
: \
&& pip install . .[test] \
&& pytest \
;
- run: pip install build && python -m build
python-publish:
executor: python
working_directory: ~/scwrypts/py/lib
steps:
- checkout:
path: ~/scwrypts
- run: pip install build && python -m build
- run: pip install twine && twine upload dist/*
workflows:
test:
jobs:
- aur-test:
&dev-filters
filters:
branches:
ignore: /^main$/
- python-test: *dev-filters
- nodejs-test: *dev-filters
publish:
jobs:
- require-full-semver:
filters:
&only-run-on-full-semver-tag-filters
tags:
only: /^v\d+\.\d+\.\d+.*$/
branches:
ignore: /^.*$/
- aur-test:
&only-publish-for-full-semver
filters: *only-run-on-full-semver-tag-filters
requires:
- require-full-semver
- aur-publish:
#
# there's a crazy-low-chance race-condition between this job and the GH Action '../.github/workflows/automatic-release.yaml'
# - automatic-release creates the release artifact, but takes no more than 15-30 seconds (current avg:16s max:26s)
# - this publish step requires the release artifact, but waits for all language-repository publishes to complete first (a few minutes at least)
#
# if something goes wrong, this step can be safely rerun after fixing the release artifact :)
#
filters: *only-run-on-full-semver-tag-filters
context: [aur-yage]
requires:
- aur-test
- python-publish
- nodejs-publish
- python-test: *only-publish-for-full-semver
- python-publish:
filters: *only-run-on-full-semver-tag-filters
context: [pypi-yage]
requires:
- python-test
- nodejs-test: *only-publish-for-full-semver
- nodejs-publish:
filters: *only-run-on-full-semver-tag-filters
context: [npm-wrynegade]
requires:
- nodejs-test

14
.config
View File

@ -1,14 +0,0 @@
#
# scwrypts config
#
# resource paths
SCWRYPTS_CONFIG_PATH="$HOME/.config/scwrypts"
SCWRYPTS_ENV_PATH="$SCWRYPTS_CONFIG_PATH/env"
SCWRYPTS_LOG_PATH="$SCWRYPTS_CONFIG_PATH/logs"
SCWRYPTS_OUTPUT_PATH="$HOME/SCWRYPTS"
# ZLE hotkeys
SCWRYPTS_SHORTCUT='' # CTRL + W
SCWRYPTS_ENV_SHORTCUT='' # CTRL + /

25
.config/env.template Normal file
View File

@ -0,0 +1,25 @@
#!/bin/zsh
export AWS_ACCOUNT=
export AWS_PROFILE=
export AWS_REGION=
export AWS__EFS__LOCAL_MOUNT_POINT=
export DIRECTUS__API_TOKEN=
export DIRECTUS__BASE_URL=
export DISCORD__BOT_TOKEN=
export DISCORD__CONTENT_FOOTER=
export DISCORD__CONTENT_HEADER=
export DISCORD__DEFAULT_AVATAR_URL=
export DISCORD__DEFAULT_CHANNEL_ID=
export DISCORD__DEFAULT_USERNAME=
export DISCORD__DEFAULT_WEBHOOK=
export LINEAR__API_TOKEN=
export MEDIA_SYNC__S3_BUCKET=
export MEDIA_SYNC__TARGETS=
export REDIS_AUTH=
export REDIS_HOST=
export REDIS_PORT=
export TWILIO__ACCOUNT_SID=
export TWILIO__API_KEY=
export TWILIO__API_SECRET=
export TWILIO__DEFAULT_PHONE_FROM=
export TWILIO__DEFAULT_PHONE_TO=

View File

@ -0,0 +1,31 @@
AWS_ACCOUNT | standard AWS environment variables used by awscli and other tools
AWS_PROFILE |
AWS_REGION |
AWS__EFS__LOCAL_MOUNT_POINT | fully-qualified path to mount the EFS drive
DIRECTUS__API_TOKEN | details for a directus instance
DIRECTUS__BASE_URL |
DISCORD__BOT_TOKEN | details for discord bot
DISCORD__CONTENT_HEADER |
DISCORD__CONTENT_FOOTER |
DISCORD__DEFAULT_AVATAR_URL |
DISCORD__DEFAULT_CHANNEL_ID |
DISCORD__DEFAULT_USERNAME |
DISCORD__DEFAULT_WEBHOOK |
LINEAR__API_TOKEN | linear.app project management configuration
MEDIA_SYNC__S3_BUCKET | s3 bucket name and filesystem targets for media backups
MEDIA_SYNC__TARGETS |
REDIS_AUTH | redis connection credentials
REDIS_HOST |
REDIS_PORT |
TWILIO__ACCOUNT_SID | twilio account / credentials
TWILIO__API_KEY |
TWILIO__API_SECRET |
TWILIO__DEFAULT_PHONE_FROM |
TWILIO__DEFAULT_PHONE_TO |

1
.gitattributes vendored
View File

@ -1 +1,2 @@
*.zsh diff
.config diff

View File

@ -0,0 +1,19 @@
---
name: Automatic Tag-release
on: # yamllint disable-line rule:truthy
push:
branches-ignore:
- '**'
tags:
- 'v*.*.*'
jobs:
automatic-tag-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: marvinpinto/action-automatic-releases@latest
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
prerelease: false

16
.github/workflows/update-semver.yaml vendored Normal file
View File

@ -0,0 +1,16 @@
---
name: Update Semver
on: # yamllint disable-line rule:truthy
push:
branches-ignore:
- '**'
tags:
- 'v*.*.*'
jobs:
update-semver:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: rickstaa/action-update-semver@v1

View File

@ -1,10 +0,0 @@
#!/bin/zsh
export AWS__EFS__LOCAL_MOUNT_POINT=
export AWS__S3__MEDIA_BUCKET=
export AWS__S3__MEDIA_TARGETS=
export REDIS_AUTH=
export REDIS_HOST=
export REDIS_PORT=
export _AWS_ACCOUNT=
export _AWS_PROFILE=
export _AWS_REGION=

View File

@ -6,6 +6,9 @@ In modern developer / dev-ops workflows, scripts require a complex configuration
Without a better solution, the developer is cursed to copy lines-upon-lines of variables into terminals, create random text artifacts, or maybe even commit secure credentials into source.
Scwrypts leverages ZSH to give hot-key access to run scripts in such environments.
## Major Version Upgrade Notice
Please refer to [Version 3 to Version 4 Upgrade Path](./docs/upgrade/v3-to-v4.md) when upgrading from scwrypts v3 to scwrypts v4!
## Dependencies
Due to the wide variety of resources used by scripting libraries, the user is expected to manually resolve dependencies.
@ -28,12 +31,9 @@ Check out [Meta Scwrypts](./zsh/scwrypts) to quickly set up environments and adj
### No Install / API Usage
Alternatively, the `scwrypts` API can be used directly:
```zsh
./scwrypts (environment-name) (...script-patterns)
./scwrypts [--env environment-name] (...script-name-patterns...) [-- ...passthrough arguments... ]
```
If not already set with `$SCWRYPTS_ENV`, Scwrypts will try to load `$1` as an environment.
If no environment with the name `$1` is found, `$1` is assumed to be a script pattern.
Given one or more script patterns, Scwrypts will filter the commands by pattern conjunction.
If only one command is found which matches the pattern(s), it will immediately begin execution.
If multiple commands match, the user will be prompted to select from the filtered list.
@ -44,6 +44,17 @@ Given no script patterns, Scwrypts becomes an interactive CLI, prompting the use
After determining which script to run, if no environment has been specified, Scwrypts prompts the user to choose one.
### Using in CI/CD or Automated Workflows
Set environment variable `CI=true` (and use the no install method) to run in an automated pipeline.
There are a few notable changes to this runtime:
- **The Scwrypts sandbox environment will not load.** All variables will be read from context.
- User yes/no prompts will **always be YES**
- Other user input will default to an empty string
- Logs will not be captured
- Setting the environment variable `SCWRYPTS_GROUP_LOADER__[a-z_]\+` will source the file indicated in the variable (this allows custom groups without needing to modify the `config.zsh` directly)
- In GitHub actions, `*.scwrypts.zsh` groups are detected automatically from the `$GITHUB_WORKSPACE`; set `SCWRYPTS_GITHUB_NO_AUTOLOAD=true` to disable
## Contributing
Before contributing an issue, idea, or pull request, check out the [super-brief contributing guide](./docs/CONTRIBUTING.md)

69
action.yaml Normal file
View File

@ -0,0 +1,69 @@
--- # allow running scwrypts in Github Actions
name: scwrypts
author: yage
description: check required dependencies and run a scwrypt
inputs:
scwrypt:
description: "args / identifiers for scwrypts CLI (e.g. '--name <scwrypt-name> --group <group-name> --type <type-name>')"
required: true
args:
description: "arguments to pass to the scwrypt-to-be-run"
required: false
version:
description: "scwrypts version; defaults to latest (minimum v3.7.0)"
required: false
scwrypts-env:
description: "override value for SCWRYPTS_ENV"
required: false
default: "ci.github-actions"
runs:
using: composite
steps:
- uses: actions/checkout@v4
with:
repository: wrynegade/scwrypts
path: ./wrynegade/scwrypts
ref: ${{ inputs.version }}
fetch-tags: true
- name: check dependencies
shell: bash
env:
CI: true
SCWRYPTS_PLUGIN_ENABLED__ci: 1
run: |
[ $CI_SCWRYPTS_READY ] && [[ $CI_SCWRYPTS_READY -eq 1 ]] && echo 'setup completed previously' && exit 0
echo "updating package dependencies"
{
sudo apt-get update
sudo apt-get install --yes zsh fzf ripgrep
for D in $($GITHUB_WORKSPACE/wrynegade/scwrypts/scwrypts -n --name check-all-dependencies --group ci --type zsh)
do
echo "--- installing $D ---"
( sudo apt-get install --yes $D; exit 0; )
done
} > $HOME/.scwrypts.apt-get.log 2>&1
echo "updating virtual dependencies"
$GITHUB_WORKSPACE/wrynegade/scwrypts/scwrypts \
--name scwrypts/virtualenv/update-all \
--group scwrypts \
--type zsh \
> $HOME/.scwrypts.virtualenv.log 2>&1
echo "CI_SCWRYPTS_READY=1" >> $GITHUB_ENV
exit 0
- name: run scwrypt
shell: bash
env:
CI: true
SCWRYPTS_ENV: ${{ inputs.scwrypts-env }}
run: $GITHUB_WORKSPACE/wrynegade/scwrypts/scwrypts ${{inputs.scwrypt}} -- ${{inputs.args}} || exit 1

254
docs/upgrade/v3-to-v4.md Normal file
View File

@ -0,0 +1,254 @@
# Scwrypts Upgrade v3 to v4 Notes
Scwrypts v4 brings a big update to the *runstring for `zsh`-type scwrypts*.
I've found some of the boilerplate required by each individual script to be confusing and terse, and the goal here is to make it easier and safer to write and run scwrypts in this critical format.
Jump to [Technical Bits](#technical-bits) if you just want to get started with migration steps.
The actual migration _should take less than a minute per script_.
This document is deliberately verbose for future reference when I don't remember how any of this works.
## Ideology and History
Originally (scwrypts v2 and below) wanted to preserve the direct-use of individual scwrypts.
In those versions, executable files could be executed directly (outside of the `scwrypts` function) and still operate with minimal, unwanted consequences.
This resulted in a rigid structure which made code-sharing difficult at small scales and untenable in many non-trivial cases.
Scwrypts v3, begrudgingly introduced a pseudo-import syntax with `use`.
This sought to combat the issues of code-sharing and open up the structure of executable scwrypts to the scwrypts-writer.
Beyond just clarity, this allowed external libraries to be written and cross-referenced.
Although "importing" is an odd (anti?)feature to shell scripting, the way libraries could be written and reused was too helpful and I succumbed to write the `import.driver.zsh` module.
Throughout v3, I tried to maintain the "executability" of individual scwrypts.
It's ugly though.
Every individual scwrypt relies on `import.driver.zsh` and the context set up by the `scwrypts` executable.
While you _could_ run the executable file directly, it would misbehave at-best and fail pretty reliably.
So... here's v4!
Scwrypts v4 accepts the reality that, although `zsh` scwrypts are zsh, they do not stand alone without the proper context setup provided by `scwrypts`.
To improve usability, I've abstracted much of the boilerplate so you never have to see it.
I've injected safety mechanisms like `--help` arguments and utility mechanisms like flag separation (`-abc` is really `-a -b -c`) into all v4 zsh scwrypts.
You don't have to worry about checking the context, v4 does that for you!
You don't have to worry about execution, v4 does that for you!
So!
Are you coupling your zsh scripts to `scwrypts` when you write them? Yes.
Is that a bad thing? I don't think so.
Shell-scripting is such a critical coupler to real-life systems.
High-risk-high-impact to SLAs means we cannot allow context mistakes by sysadmins and users.
Reusability between local machine, cloud runtime, and CI pipelines is a must.
And if you have a need to reign all that in to single, isolated executable files...
...then good luck <3
## Technical Bits
Big idea: let's get rid of v3 boilerplate and make things easy.
### Your executable must be in a MAIN function
A main function in shell scripts?
Weird!
Don't worry, it's easy.
Take your original scwrypt, and slap the executable stuff into a function called `MAIN` (yes, it must be _exactly_, all-caps `MAIN`):
```diff
#!/bin/zsh
#####################################################################
DEPENDENCIES+=(dep-function-a dep-function-b)
REQUIRED_ENV+=()
use do/awesome/stuff --group my-custom-library
CHECK_ENVIRONMENT
#####################################################################
- echo "do some stuff here"
- # ... etc ...
- SUCCESS "completed the stuff"
+ MAIN() {
+ echo "do some stuff here"
+ # ... etc ...
+ SUCCESS "completed the stuff
+ }
```
**Don't invoke the function!**
Scwrypts will now do that on your behalf.
I've already written many scwrypts which _almost_ used this syntax.
All I had to do in this case was delete the function invocation at the end:
```diff
# ... top boilerplate ...
MAIN() {
SUCCESS "look at me I'm so cool I already wrote this in a main function"
}
-
- #####################################################################
- MAIN $@
```
Again, **do not invoke the function**. Just name it `MAIN` and you're good-to-go!
### Great news!
Great news!
We have finished with *all of the necessary steps* to migrate to v4!
Easy!
While you're here, let's do a couple more things to cleanup your scwrypts (I promise they are also easy and will take less than a few seconds for each)!
### Remove the boilerplate
Were you confused by all that garbage at the top?
Awesome!
Just get rid of any of it you don't use.
While you _probably_ will still need whatever dependencies you already defined, feel free to get rid of empty config lists like `DEPENDENCIES+=()`.
For non-empty lists, the syntax remains the same (use the `+=` and make sure it's an array-type `()` just like before!)
Also you can ditch the `CHECK_ENVIRONMENT`.
While it won't hurt, v4 already does this, so just get rid of it.
Here's my recommended formatting:
```diff
#!/bin/zsh
- #####################################################################
DEPENDENCIES+=(dep-function-a dep-function-b)
- REQUIRED_ENV+=()
use do/awesome/stuff --group my-custom-library
-
- CHECK_ENVIRONMENT
#####################################################################
MAIN() {
echo "do some stuff here"
# ... etc ...
SUCCESS "completed the stuff
}
```
### Get rid of `--help` argument processing
Scwrypts v4 injects the `--help` argument into every zsh scwrypt.
So there's no need to process it manually anymore!
We can now eliminate the help case from any MAIN body or library function:
```diff
MAIN() {
while [[ $# -gt 0 ]]
do
case $1 in
# ... a bunch of cases ...
- -h | --help ) USAGE; return 0 ;;
# ... a bunch of cases ...
esac
shift 1
done
}
```
While you probably weren't doing this, you can also do the same for any logic which splits arguments input like `-abc` which should be read as `-a -b -c`.
If you know how to do this, you know how to get rid of it.
### Write some help docs
Okay this one might take a little bit of time if you haven't done it already (but this is the last recommended step! hang in there and make your stuff better!).
If you _have_ done it already, typically by writing a variable called "USAGE" in your code, maybe consider the _new and improved_ way to write your help strings.
Returning to our original `MAIN()` example, I'll add some options parsing so we should now look something like this:
```sh
#!/bin/zsh
DEPENDENCIES+=(dep-function-a dep-function-b)
use do/awesome/stuff --group my-custom-library
#####################################################################
MAIN() {
local A
local B=false
local ARGS=()
while [[ $# -gt 0 ]]
do
case $1 in
-a | --option-a ) A=$2; shift 1 ;;
-b | --allow-b ) B=true ;;
* ) ARGS+=($1) ;;
esac
shift 1
done
echo "A : $A\nB : $B\nARGS : $ARGS"
}
```
All we have to do is add some usage variables and we're done!
I want to call out a few specific ones:
- `USAGE__options` provides descriptions for CLI flags like `-a` `--some-flag` and `-a <some value>` (reminder, you *don't* need '-h, --help' anymore!)
- `USAGE__args` provides descriptions for non-flag CLI arguments, where order matters (e.g. `cat file-a file-b ... etc`)
- `USAGE__description` provides the human-readable description of what your function does
- `USAGE__usage` you probably don't need to adjust this one, but it will be everything after the `--` in the usage-line. Defaults to include `[...options...]`, but I suppose you might want to write `USAGE__usage+=' [...args...]` if you 1) have args and 2) are really specific about your help strings.
Just add another section to define these values before declaring `MAIN`:
```sh
#!/bin/zsh
DEPENDENCIES+=(dep-function-a dep-function-b)
use do/awesome/stuff --group my-custom-library
#####################################################################
USAGE__options='
-a, --option-a <string> sets the value of the A variable
-b, --allow-b enables the B option
'
# remember there's no specific formatting here, just write it nice
USAGE__args='
N-args All remaining args are inserted into the ARGS variable
'
USAGE__description="
This is my cool example function. It's really neato, but does
very little.
"
#####################################################################
MAIN() {
# ... etc ...
}
```
Now, when we run `scwrypts my sample -- --help`, we get:
```txt
usage: scwrypts my sample -- [...options...]
args:
N-args All remaining args are inserted into the ARGS variable
options:
-a, --option-a <string> sets the value of the A variable
-b, --allow-b enables the B option
-h, --help display this message and exit
This is my cool example function. It's really neato, but does
very little.
```
### All done
No more recommendations at this time.
Someday I'll have an auto-formatter and a language server to help with go-to-definition, but that's still for the future.
Thanks for your time and welcome to v4!

View File

@ -0,0 +1 @@
#!/bin/zsh

10
plugins/ci/README.md Normal file
View File

@ -0,0 +1,10 @@
# Kubernetes `kubectl` Helper Plugin
Leverages a local `redis` application to quickly and easily set an alias `k` for `kubectl --context <some-context> --namespace <some-namespace>`.
Much like scwrypts environments, `k` aliases are *only* shared amongst session with the same `SCWRYPTS_ENV` to prevent accidental cross-contamination.
## Getting Started
Enable the plugin in `~/.config/scwrypts/config.zsh` by adding `SCWRYPTS_PLUGIN_ENABLED__KUBECTL=1`.
Use `k` as your new `kubectl` and checkout `k --help` and `k meta --help`.

View File

@ -0,0 +1,33 @@
#!/bin/zsh
#####################################################################
MAIN() {
cd "$SCWRYPTS_ROOT__scwrypts/"
DEPENDENCIES+=()
for group in ${SCWRYPTS_GROUPS[@]}
do
[[ $group =~ ^ci$ ]] && continue
GROUP_HOME="$(eval 'echo $SCWRYPTS_ROOT__'$group)"
[ $GROUP_HOME ] && [ -d "$GROUP_HOME" ] || continue
STATUS "checking dependencies for $group"
DEPENDENCIES+=($(
for file in $(
{
cd "$GROUP_HOME"
rg -l '^DEPENDENCIES\+=\($'
rg -l '^DEPENDENCIES\+=\([^)]\+\)'
} | grep -v '\.md$' | grep -v 'check-all-dependencies$')
do
sed -z 's/.*DEPENDENCIES+=(\([^)]*\)).*/\1\n/; s/#.*\n//g; s/\s\+/\n/g' "$GROUP_HOME/$file"
done
))
done
DEPENDENCIES=(zsh $(echo $DEPENDENCIES | sed 's/ /\n/g' | sort -u | grep '^[-_a-zA-Z]\+$'))
STATUS "discovered dependencies: ($DEPENDENCIES)"
echo $DEPENDENCIES | sed 's/ /\n/g'
CHECK_ENVIRONMENT && SUCCESS "all dependencies satisfied"
}

View File

@ -0,0 +1,5 @@
SCWRYPTS_GROUPS+=(ci)
export SCWRYPTS_TYPE__ci=zsh
export SCWRYPTS_ROOT__ci="$SCWRYPTS_ROOT__scwrypts/plugins/ci"
export SCWRYPTS_COLOR__ci='\033[0m'

View File

@ -0,0 +1,2 @@
#!/bin/zsh
export SCWRYPTS_KUBECTL_REDIS=

View File

@ -0,0 +1 @@
SCWRYPTS_KUBECTL_REDIS | (currently only 'managed') 'managed' or 'custom' redis configuration

View File

@ -0,0 +1,4 @@
export SCWRYPTS_KUBECTL_REDIS_HOST__managed=127.0.0.1
export SCWRYPTS_KUBECTL_REDIS_PORT__managed=26379
export SCWRYPTS_KUBECTL_REDIS_AUTH__managed=
export SCWRYPTS_KUBECTL_REDIS_KEY_PREFIX__managed=

10
plugins/kubectl/README.md Normal file
View File

@ -0,0 +1,10 @@
# Kubernetes `kubectl` Helper Plugin
Leverages a local `redis` application to quickly and easily set an alias `k` for `kubectl --context <some-context> --namespace <some-namespace>`.
Much like scwrypts environments, `k` aliases are *only* shared amongst session with the same `SCWRYPTS_ENV` to prevent accidental cross-contamination.
## Getting Started
Enable the plugin in `~/.config/scwrypts/config.zsh` by adding `SCWRYPTS_PLUGIN_ENABLED__KUBECTL=1`.
Use `k` as your new `kubectl` and checkout `k --help` and `k meta --help`.

View File

@ -0,0 +1,46 @@
#####################################################################
command -v compdef >/dev/null 2>&1 || return 0
#####################################################################
for CLI in kubectl helm flux
do
eval "_${CLI[1]}() {
local SUBSESSION=0
echo \${words[2]} | grep -q '^[0-9]\\+$' && SUBSESSION=\${words[2]}
local PASSTHROUGH_WORDS=($CLI)
[[ \$CURRENT -gt 2 ]] && echo \${words[2]} | grep -qv '^[0-9]\\+$' && {
local KUBECONTEXT=\$(k \$SUBSESSION meta get context)
local NAMESPACE=\$(k \$SUBSESSION meta get namespace)
[ \$KUBECONTEXT ] \
&& PASSTHROUGH_WORDS+=($([[ $CLI =~ ^helm$ ]] && echo '--kube-context' || echo '--context') \$KUBECONTEXT) \
;
[ \$NAMESPACE ] \
&& PASSTHROUGH_WORDS+=(--namespace \$NAMESPACE) \
;
}
local DELIMIT_COUNT=0
local WORD
for WORD in \${words[@]:1}
do
case \$WORD in
[0-9]* ) continue ;;
-- )
echo \$words | grep -q 'exec' && ((DELIMIT_COUNT+=1))
[[ \$DELIMIT_COUNT -eq 0 ]] && ((DELIMIT_COUNT+=1)) && continue
;;
esac
PASSTHROUGH_WORDS+=(\"\$WORD\")
done
echo \"\$words\" | grep -q '\\s\\+$' && PASSTHROUGH_WORDS+=(' ')
words=\"\$PASSTHROUGH_WORDS\"
_$CLI
}
"
compdef _${CLI[1]} ${CLI[1]}
done

View File

@ -0,0 +1,198 @@
[[ $SCWRYPTS_KUBECTL_DRIVER_READY -eq 1 ]] && return 0
unalias k h f >/dev/null 2>&1
k() { _SCWRYPTS_KUBECTL_DRIVER kubectl $@; }
h() { _SCWRYPTS_KUBECTL_DRIVER helm $@; }
f() { _SCWRYPTS_KUBECTL_DRIVER flux $@; }
_SCWRYPTS_KUBECTL_DRIVER() {
[ ! $SCWRYPTS_ENV ] && {
ERROR "must set SCWRYPTS_ENV in order to use '$(echo $CLI | head -c1)'"
return 1
}
which REDIS >/dev/null 2>&1 \
|| eval "$(scwrypts -n --name meta/get-static-redis-definition --type zsh --group kubectl)"
local CLI="$1"; shift 1
local SCWRYPTS_GROUP CUSTOM_COMMANDS=(meta)
for SCWRYPTS_GROUP in ${SCWRYPTS_GROUPS[@]}
do
CUSTOM_COMMANDS+=($(eval echo '$SCWRYPTS_KUBECTL_CUSTOM_COMMANDS__'$SCWRYPTS_GROUP))
done
##########################################
local USAGE="
usage: - [...args...] [...options...] -- [...$CLI options...]
args: -
options: -
--subsession [0-9] use indicated subsession (use for script clarity instead of positional arg)
-h, --help display this help dialogue
-v, --verbose output debugging information
description: -
"
local USAGE__usage=$(echo $CLI | head -c1)
local USAGE__args="$(
{
echo "\\033[0;32m[0-9]\\033[0m^if the first argument is a number 0-9, uses or creates a subsession (default 0)"
echo " ^ "
for C in ${CUSTOM_COMMANDS[@]}
do
echo "\\033[0;32m$C\\033[0m^$(SCWRYPTS_KUBECTL_CUSTOM_COMMAND_DESCRIPTION__$C 2>/dev/null)"
done
} | column -ts '^'
)"
local USAGE__options="
-n, --namespace set the namespace
-k, --context set the context
"
local USAGE__description="
Provides 'k' (kubectl), 'h' (helm), and 'f' (flux) shorthands to the respective
utility. These functions leverage redis and scwrypts environments to
allow quick selection of contexts and namespaces usable across all
active shell instances.
The scwrypts group 'kubectl' has simple selection executables for
kubecontext and namespace, but also provides the library to enable
enriched, use-case-sensitive setup of kubernetes context.
All actions are scoped to the current SCWRYPTS_ENV
currently : \\033[0;33m$SCWRYPTS_ENV\\033[0m
"
##########################################
local USER_ARGS=()
local CUSTOM_COMMAND=0
local VERBOSE=0
local HELP=0
local ERRORS=0
local COMMAND_SWITCH_CASE="@($(echo $CUSTOM_COMMANDS | sed 's/ /|/g'))"
[ ! $SUBSESSION ] && local SUBSESSION=0
[[ $1 =~ ^[0-9]$ ]] && SUBSESSION=$1 && shift 1
while [[ $# -gt 0 ]]
do
case $1 in
meta )
CUSTOM_COMMAND=$1
SCWRYPTS_KUBECTL_CUSTOM_COMMAND_PARSE__$1 ${@:2}
break
;;
-v | --verbose ) VERBOSE=1 ;;
-h | --help ) HELP=1 ;;
--subsession ) SUBSESSION=$2; shift 1 ;;
-n | --namespace )
_SCWRYPTS_KUBECTL_DRIVER kubectl meta set namespace $2
shift 1
;;
-k | --context | --kube-context )
_SCWRYPTS_KUBECTL_DRIVER kubectl meta set context $2
shift 1
;;
-- )
echo $USER_ARGS | grep -q 'exec' && USER_ARGS+=(--)
shift 1
break
;;
* )
[ ! $CUSTOM_COMMAND ] && {
for C in ${CUSTOM_COMMANDS[@]}
do
[[ $1 =~ ^$C$ ]] && {
SCWRYPTS_KUBECTL_CUSTOM_COMMAND_PARSE__$1 ${@:2}
break
}
done
}
USER_ARGS+=($1)
;;
esac
shift 1
done
while [[ $# -gt 0 ]]; do USER_ARGS+=($1); shift 1; done
CHECK_ERRORS --no-fail || return 1
[[ $HELP -eq 1 ]] && { USAGE; return 0; }
#####################################################################
local STRICT=$(_SCWRYPTS_KUBECTL_SETTINGS get strict || echo 1)
case $CUSTOM_COMMAND in
0 )
local CLI_ARGS=()
local CONTEXT=$(k meta get context)
local NAMESPACE=$(k meta get namespace)
[ $CONTEXT ] && [[ $CLI =~ ^helm$ ]] && CLI_ARGS+=(--kube-context $CONTEXT)
[ $CONTEXT ] && [[ $CLI =~ ^kubectl$ ]] && CLI_ARGS+=(--context $CONTEXT)
[ $CONTEXT ] && [[ $CLI =~ ^flux$ ]] && CLI_ARGS+=(--context $CONTEXT)
[[ $STRICT -eq 1 ]] && {
[ $CONTEXT ] || ERROR "missing kubectl 'context'"
[ $NAMESPACE ] || ERROR "missing kubectl 'namespace'"
CHECK_ERRORS --no-fail --no-usage || {
ERROR "with 'strict' settings enabled, context and namespace must be set!"
REMINDER "
these values can be set directly with
$(echo $CLI | head -c1) meta set (namespace|context)
"
return 2
}
}
[ $NAMESPACE ] && CLI_ARGS+=(--namespace $NAMESPACE)
[[ $VERBOSE -eq 1 ]] && {
REMINDER "
context '$CONTEXT'
namespace '$NAMESPACE'
environment '$SCWRYPTS_ENV'
subsession '$SUBSESSION'
"
STATUS "running $CLI ${CLI_ARGS[@]} ${USER_ARGS[@]}"
} || {
[[ $(_SCWRYPTS_KUBECTL_SETTINGS get context) =~ ^show$ ]] && {
REMINDER "$SCWRYPTS_ENV.$SUBSESSION : $CLI ${CLI_ARGS[@]} ${USER_ARGS[@]}"
}
}
$CLI ${CLI_ARGS[@]} ${USER_ARGS[@]}
;;
* ) SCWRYPTS_KUBECTL_CUSTOM_COMMAND__$CUSTOM_COMMAND ${USER_ARGS[@]} ;;
esac
}
_SCWRYPTS_KUBECTL_SETTINGS() {
# (get setting-name) or (set setting-name setting-value)
REDIS h$1 ${SCWRYPTS_ENV}:kubectl:settings ${@:2} | grep .
}
#####################################################################
source ${0:a:h}/kubectl.completion.zsh
source ${0:a:h}/meta.zsh

View File

@ -0,0 +1,147 @@
SCWRYPTS_KUBECTL_CUSTOM_COMMAND_PARSE__meta() {
USAGE__usage+=" meta"
USAGE__args="
- get output value of meta variable
- set interactively configure value of meta variable
- clear clear current subsession variables
(settings args)
- show output context for every command
- hide (default) hide output context for every command
- strict (default) require context *and* namespace to be set
- loose do not require context and namespace to be set
"
USAGE__options=''
USAGE__description=$(SCWRYPTS_KUBECTL_CUSTOM_COMMAND_DESCRIPTION__meta)
META_SUBARGS="
- namespace
- context
"
while [[ $# -gt 0 ]]
do
case $1 in
-h | --help ) HELP=1 ;;
set )
USAGE__usage+=" set"
USAGE__args="set (namespace|context)"
USAGE__description="interactively set a namespace or context for '$SCWRYPTS_ENV'"
case $2 in
namespace | context ) USER_ARGS+=($1 $2 $3); [ $3 ] && shift 1 ;;
-h | --help ) HELP=1 ;;
'' )
: \
&& SCWRYPTS_KUBECTL_CUSTOM_COMMAND__meta set context \
&& SCWRYPTS_KUBECTL_CUSTOM_COMMAND__meta set namespace \
;
return $?
;;
* ) ERROR "cannot set '$2'" ;;
esac
shift 1
;;
get )
USAGE__usage+=" get"
USAGE__args="get (namespace|context|all)"
USAGE__description="output the current namespace or context for '$SCWRYPTS_ENV'"
case $2 in
namespace | context | all ) USER_ARGS+=($1 $2) ;;
-h | --help ) HELP=1 ;;
* ) ERROR "cannot get '$2'" ;;
esac
shift 1
;;
copy )
USAGE__usage+=" copy"
USAGE__args+="copy [0-9]"
USAGE__description="copy current subsession ($SUBSESSION) to target subsession id"
case $2 in
[0-9] ) USER_ARGS+=($1 $2) ;;
-h | --help ) HELP=1 ;;
* ) ERROR "target session must be a number [0-9]" ;;
esac
shift 1
;;
clear | show | hide | strict | loose ) USER_ARGS+=($1) ;;
* ) ERROR "no meta command '$1'"
esac
shift 1
done
}
SCWRYPTS_KUBECTL_CUSTOM_COMMAND__meta() {
case $1 in
get )
[[ $2 =~ ^all$ ]] && {
local CONTEXT=$(REDIS get --prefix current:context | grep . || echo "\\033[1;31mnone set\\033[0m")
local NAMESPACE=$(REDIS get --prefix current:namespace | grep . || echo "\\033[1;31mnone set\\033[0m")
echo "
environment : $SCWRYPTS_ENV
context : $CONTEXT
namespace : $NAMESPACE
CLI settings
command context : $(_SCWRYPTS_KUBECTL_SETTINGS get context)
strict mode : $([[ $STRICT -eq 1 ]] && echo "on" || echo "\\033[1;31moff\\033[0m")
" | sed 's/^ \+//' >&2
return 0
}
REDIS get --prefix current:$2
;;
set )
scwrypts -n --name set-$2 --type zsh --group kubectl -- $3 --subsession $SUBSESSION >/dev/null \
&& SUCCESS "$2 set"
;;
copy )
: \
&& STATUS "copying $1 to $2" \
&& scwrypts -n --name set-context --type zsh --group kubectl -- --subsession $2 $(k meta get context | grep . || echo 'reset') \
&& scwrypts -n --name set-namespace --type zsh --group kubectl -- --subsession $2 $(k meta get namespace | grep . || echo 'reset') \
&& SUCCESS "subsession $1 copied to $2" \
;
;;
clear )
scwrypts -n --name set-context --type zsh --group kubectl -- --subsession $SUBSESSION reset >/dev/null \
&& SUCCESS "subsession $SUBSESSION reset to default"
;;
show )
_SCWRYPTS_KUBECTL_SETTINGS set context show >/dev/null \
&& SUCCESS "now showing full command context"
;;
hide )
_SCWRYPTS_KUBECTL_SETTINGS set context hide >/dev/null \
&& SUCCESS "now hiding command context"
;;
loose )
_SCWRYPTS_KUBECTL_SETTINGS set strict 0 >/dev/null \
&& WARNING "now running in 'loose' mode"
;;
strict )
_SCWRYPTS_KUBECTL_SETTINGS set strict 1 >/dev/null \
&& SUCCESS "now running in 'strict' mode"
;;
esac
}
SCWRYPTS_KUBECTL_CUSTOM_COMMAND_DESCRIPTION__meta() {
[ $CLI ] || CLI='kubectl'
echo "operations for $CLI session variables and other CLI settings"
}

7
plugins/kubectl/get-context Executable file
View File

@ -0,0 +1,7 @@
#!/bin/zsh
use kubectl --group kubectl
#####################################################################
MAIN() {
KUBECTL__GET_CONTEXT
}

7
plugins/kubectl/get-namespace Executable file
View File

@ -0,0 +1,7 @@
#!/bin/zsh
use kubectl --group kubectl
#####################################################################
MAIN() {
KUBECTL__GET_NAMESPACE
}

View File

@ -0,0 +1,11 @@
SCWRYPTS_GROUPS+=(kubectl)
export SCWRYPTS_TYPE__kubectl=zsh
export SCWRYPTS_ROOT__kubectl="$SCWRYPTS_ROOT__scwrypts/plugins/kubectl"
export SCWRYPTS_COLOR__kubectl='\033[0;31m'
SCWRYPTS_STATIC_CONFIG__kubectl+=(
"$SCWRYPTS_ROOT__kubectl/.config/static/redis.zsh"
)
source "$SCWRYPTS_ROOT__kubectl/driver/kubectl.driver.zsh"

View File

@ -0,0 +1,158 @@
#####################################################################
DEPENDENCIES+=(
kubectl
)
REQUIRED_ENV+=()
use redis --group kubectl
#####################################################################
KUBECTL() {
local NAMESPACE=$(REDIS get --prefix "current:namespace")
local CONTEXT=$(KUBECTL__GET_CONTEXT)
local KUBECTL_ARGS=()
[ $NAMESPACE ] && KUBECTL_ARGS+=(--namespace $NAMESPACE)
[ $CONTEXT ] && KUBECTL_ARGS+=(--context $CONTEXT)
kubectl ${KUBECTL_ARGS[@]} $@
}
#####################################################################
KUBECTL__GET_CONTEXT() { REDIS get --prefix "current:context"; }
KUBECTL__SET_CONTEXT() {
local CONTEXT=$1
[ ! $CONTEXT ] && return 1
[[ $CONTEXT =~ reset ]] && {
: \
&& REDIS del --prefix "current:context" \
&& KUBECTL__SET_NAMESPACE reset \
;
return $?
}
: \
&& REDIS set --prefix "current:context" "$CONTEXT" \
&& KUBECTL__SET_NAMESPACE reset \
;
}
KUBECTL__SELECT_CONTEXT() {
KUBECTL__LIST_CONTEXTS | FZF 'select a context'
}
KUBECTL__LIST_CONTEXTS() {
echo reset
local ALL_CONTEXTS=$(KUBECTL config get-contexts -o name | sort)
echo $ALL_CONTEXTS | grep -v '^arn:aws:eks'
[[ $AWS_ACCOUNT ]] && {
echo $ALL_CONTEXTS | grep "^arn:aws:eks:.*:$AWS_ACCOUNT"
true
} || {
echo $ALL_CONTEXTS | grep '^arn:aws:eks'
}
}
#####################################################################
KUBECTL__GET_NAMESPACE() { REDIS get --prefix "current:namespace"; }
KUBECTL__SET_NAMESPACE() {
local NAMESPACE=$1
[ ! $NAMESPACE ] && return 1
[[ $NAMESPACE =~ reset ]] && {
REDIS del --prefix "current:namespace"
return $?
}
REDIS set --prefix "current:namespace" "$NAMESPACE"
}
KUBECTL__SELECT_NAMESPACE() {
KUBECTL__LIST_NAMESPACES | FZF 'select a namespace'
}
KUBECTL__LIST_NAMESPACES() {
echo reset
echo default
KUBECTL get namespaces -o name | sed 's/^namespace\///' | sort
}
#####################################################################
KUBECTL__SERVE() {
[ $CONTEXT ] || local CONTEXT=$(KUBECTL__GET_CONTEXT)
[ $CONTEXT ] || ERROR 'must configure a context in which to serve'
[ $NAMESPACE ] || local NAMESPACE=$(KUBECTL__GET_NAMESPACE)
[ $NAMESPACE ] || ERROR 'must configure a namespace in which to serve'
CHECK_ERRORS --no-fail --no-usage || return 1
[ $SERVICE ] && SERVICE=$(KUBECTL__LIST_SERVICES | jq -c "select (.service == \"$SERVICE\")" || echo $SERVICE)
[ $SERVICE ] || local SERVICE=$(KUBECTL__SELECT_SERVICE)
[ $SERVICE ] || ERROR 'must provide or select a service'
KUBECTL__LIST_SERVICES | grep -q "^$SERVICE$"\
|| ERROR "no service '$SERVICE' in '$CONFIG/$NAMESPACE'"
CHECK_ERRORS --no-fail --no-usage || return 1
##########################################
SERVICE_PASSWORD="$(KUBECTL__GET_SERVICE_PASSWORD)"
KUBECTL__SERVICE_PARSE
REMINDER "attempting to serve ${NAMESPACE}/${SERVICE_NAME}:${SERVICE_PORT}"
[ $SERVICE_PASSWORD ] && REMINDER "password : $SERVICE_PASSWORD"
KUBECTL port-forward service/$SERVICE_NAME $SERVICE_PORT
}
KUBECTL__SELECT_SERVICE() {
[ $NAMESPACE ] || local NAMESPACE=$(KUBECTL__GET_NAMESPACE)
[ $NAMESPACE ] || return 1
local SERVICES=$(KUBECTL__LIST_SERVICES)
local SELECTED=$({
echo "namespace service port"
echo $SERVICES \
| jq -r '.service + " " + .port' \
| sed "s/^/$NAMESPACE /" \
;
} \
| column -t \
| FZF 'select a service' --header-lines=1 \
| awk '{print $2;}' \
)
echo $SERVICES | jq -c "select (.service == \"$SELECTED\")"
}
KUBECTL__LIST_SERVICES() {
KUBECTL get service --no-headers\
| awk '{print "{\"service\":\""$1"\",\"ip\":\""$3"\",\"port\":\""$5"\"}"}' \
| jq -c 'select (.ip != "None")' \
;
}
KUBECTL__GET_SERVICE_PASSWORD() {
[ $PASSWORD_SECRET ] && [ $PASSWORD_KEY ] || return 0
KUBECTL get secret $PASSWORD_SECRET -o jsonpath="{.data.$PASSWORD_KEY}" \
| base64 --decode
}
KUBECTL__SERVICE_PARSE() {
SERVICE_NAME=$(echo $SERVICE | jq -r .service)
SERVICE_PORT=$(echo $SERVICE | jq -r .port | sed 's|/.*$||')
}

View File

@ -0,0 +1,97 @@
#####################################################################
DEPENDENCIES+=(
redis-cli
docker
)
# TODO; allow custom redis configuration
export SCWRYPTS_KUBECTL_REDIS=managed
REQUIRED_ENV+=(
SCWRYPTS_KUBECTL_REDIS
)
#####################################################################
REDIS() {
[ ! $USAGE ] && local USAGE="
usage: [...options...]
options:
--subsession [0-9] use a particular subsession
-p, --prefix apply dynamic prefix to the next command line argument
--get-prefix output key prefix for current session+subsession
--get-static-definition output the static ZSH function definition for REDIS
additional arguments and options are passed through to 'redis-cli'
"
local REDIS_ARGS=() USER_ARGS=()
[ $SUBSESSION ] || local SUBSESSION=0
local REDIS_PREFIX=$(eval echo '$SCWRYPTS_KUBECTL_REDIS_KEY_PREFIX__'$SCWRYPTS_KUBECTL_REDIS)
[ $REDIS_PREFIX ] && REDIS_PREFIX+=':'
while [[ $# -gt 0 ]]
do
case $1 in
-p | --prefix ) USER_ARGS+=("${REDIS_PREFIX}${SCWRYPTS_ENV}:${SUBSESSION}:$2"); shift 1 ;;
--subsession ) SUBSESSION=$2; shift 1 ;;
--get-prefix ) echo $REDIS_PREFIX; return 0 ;;
--get-static-definition ) ECHO_STATIC_DEFINITION=1 ;;
* ) USER_ARGS+=($1) ;;
esac
shift 1
done
local REDIS_HOST=$(eval echo '$SCWRYPTS_KUBECTL_REDIS_HOST__'$SCWRYPTS_KUBECTL_REDIS)
local REDIS_PORT=$(eval echo '$SCWRYPTS_KUBECTL_REDIS_PORT__'$SCWRYPTS_KUBECTL_REDIS)
local REDIS_AUTH=$(eval echo '$SCWRYPTS_KUBECTL_REDIS_AUTH__'$SCWRYPTS_KUBECTL_REDIS)
[ $REDIS_HOST ] && REDIS_ARGS+=(-h $REDIS_HOST)
[ $REDIS_PORT ] && REDIS_ARGS+=(-p $REDIS_PORT)
[ $REDIS_AUTH ] && REDIS_ARGS+=(-a $REDIS_AUTH)
REDIS_ARGS+=(--raw)
[[ $ECHO_STATIC_DEFINITION -eq 1 ]] && {
echo "REDIS() {\
local USER_ARGS=(); \
[ ! \$SUBSESSION ] && local SUBSESSION=0 ;\
while [[ \$# -gt 0 ]]; \
do \
case \$1 in
-p | --prefix ) USER_ARGS+=(\"${REDIS_PREFIX}\${SCWRYPTS_ENV}:\${SUBSESSION}:\$2\"); shift 1 ;; \
* ) USER_ARGS+=(\$1) ;; \
esac; \
shift 1; \
done; \
redis-cli ${REDIS_ARGS[@]} \${USER_ARGS[@]}; \
}" | sed 's/\s\+/ /g'
return 0
}
redis-cli ${REDIS_ARGS[@]} ${USER_ARGS[@]}
}
REDIS ping | grep -qi pong || {
RPID=$(docker ps -a | grep scwrypts-kubectl-redis | awk '{print $1;}')
[ $RPID ] && STATUS 'refreshing redis instance' && docker rm -f $RPID
unset RPID
docker run \
--detach \
--name scwrypts-kubectl-redis \
--publish $SCWRYPTS_KUBECTL_REDIS_PORT__managed:6379 \
redis >/dev/null 2>&1
STATUS 'awaiting redis connection'
until REDIS ping 2>/dev/null | grep -qi pong; do sleep 0.5; done
}

View File

@ -0,0 +1,7 @@
#!/bin/zsh
use redis --group kubectl
#####################################################################
MAIN() {
echo $(REDIS --get-static-definition)
}

49
plugins/kubectl/serve Executable file
View File

@ -0,0 +1,49 @@
#!/bin/zsh
use kubectl --group kubectl
#####################################################################
MAIN() {
local USAGE="
usage: [service] [...options...]
args:
service (optional) name of the service to forward locally
options:
--context override context
--namespace override namespace
--subsession REDIS subsession (default 0)
to show a required password on screen, use both:
--password-secret Secret resource
--password-key key within Secret's 'data'
-h, --help show this dialogue and exit
"
local CONTEXT NAMESPACE SERVICE
local SUBSESSION=0
while [[ $# -gt 0 ]]
do
case $1 in
--context ) CONTEXT=$2; shift 1 ;;
--namespace ) NAMESPACE=$2; shift 1 ;;
--subsession ) SUBSESSION=$2; shift 1 ;;
--password-secret ) PASSWORD_SECRET=$2; shift 1 ;;
--password-key ) PASSWORD_KEY=$2; shift 1 ;;
-h | --help ) USAGE; return 0 ;;
* )
[ $SERVICE ] && ERROR "unexpected argument '$2'"
SERVICE=$1
;;
esac
shift 1
done
CHECK_ERRORS
KUBECTL__SERVE
}

41
plugins/kubectl/set-context Executable file
View File

@ -0,0 +1,41 @@
#!/bin/zsh
use kubectl --group kubectl
#####################################################################
MAIN() {
local USAGE="
usage: [context] [...options...]
args:
context (optional) the full name of the kubeconfig context to set
options:
--subsession REDIS subsession (default 0)
-h, --help show this dialogue and exit
"
local CONTEXT
local SUBSESSION=0
while [[ $# -gt 0 ]]
do
case $1 in
--subsession ) SUBSESSION=$2; shift 1 ;;
-h | --help ) USAGE; return 0 ;;
* )
[ $CONTEXT ] && ERROR "unexpected argument '$2'"
CONTEXT=$1
;;
esac
shift 1
done
[ $CONTEXT ] || CONTEXT=$(KUBECTL__SELECT_CONTEXT)
[ $CONTEXT ] || ERROR 'must provide or select a valid kube context'
CHECK_ERRORS
KUBECTL__SET_CONTEXT $CONTEXT
}

41
plugins/kubectl/set-namespace Executable file
View File

@ -0,0 +1,41 @@
#!/bin/zsh
use kubectl --group kubectl
#####################################################################
MAIN() {
local USAGE="
usage: [namespace] [...options...]
args:
namespace (optional) the full name of the namespace context to set
options:
--subsession REDIS subsession (default 0)
-h, --help show this dialogue and exit
"
local NAMESPACE
local SUBSESSION=0
while [[ $# -gt 0 ]]
do
case $1 in
--subsession ) SUBSESSION=$2; shift 1 ;;
-h | --help ) USAGE; return 0 ;;
* )
[ $NAMESPACE ] && ERROR "unexpected argument '$2'"
NAMESPACE=$1
;;
esac
shift 1
done
[ $NAMESPACE ] || NAMESPACE=$(KUBECTL__SELECT_NAMESPACE)
[ $NAMESPACE ] || ERROR 'must provide or select a valid namespace'
CHECK_ERRORS
KUBECTL__SET_NAMESPACE $NAMESPACE
}

1
py/.gitignore vendored
View File

@ -1,4 +1,3 @@
__pycache__/
*.py[cod]
*.so
.env/

0
py/data/__init__.py Normal file
View File

View File

20
py/data/convert/csv-to-json.py Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env python
from scwrypts import execute
#####################################################################
from scwrypts.data import convert
description = 'convert csv into json'
parse_args = []
def main(_args, stream):
return convert(
input_stream = stream.input,
input_type = 'csv',
output_stream = stream.output,
output_type = 'json',
)
#####################################################################
if __name__ == '__main__':
execute(main, description, parse_args)

20
py/data/convert/csv-to-yaml.py Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env python
from scwrypts import execute
#####################################################################
from scwrypts.data import convert
description = 'convert csv into yaml'
parse_args = []
def main(_args, stream):
return convert(
input_stream = stream.input,
input_type = 'csv',
output_stream = stream.output,
output_type = 'yaml',
)
#####################################################################
if __name__ == '__main__':
execute(main, description, parse_args)

20
py/data/convert/json-to-csv.py Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env python
from scwrypts import execute
#####################################################################
from scwrypts.data import convert
description = 'convert json into csv'
parse_args = []
def main(_args, stream):
return convert(
input_stream = stream.input,
input_type = 'json',
output_stream = stream.output,
output_type = 'csv',
)
#####################################################################
if __name__ == '__main__':
execute(main, description, parse_args)

20
py/data/convert/json-to-yaml.py Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env python
from scwrypts import execute
#####################################################################
from scwrypts.data import convert
description = 'convert json into yaml'
parse_args = []
def main(_args, stream):
return convert(
input_stream = stream.input,
input_type = 'json',
output_stream = stream.output,
output_type = 'yaml',
)
#####################################################################
if __name__ == '__main__':
execute(main, description, parse_args)

20
py/data/convert/yaml-to-csv.py Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env python
from scwrypts import execute
#####################################################################
from scwrypts.data import convert
description = 'convert yaml into csv'
parse_args = []
def main(_args, stream):
return convert(
input_stream = stream.input,
input_type = 'yaml',
output_stream = stream.output,
output_type = 'csv',
)
#####################################################################
if __name__ == '__main__':
execute(main, description, parse_args)

20
py/data/convert/yaml-to-json.py Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env python
from scwrypts import execute
#####################################################################
from scwrypts.data import convert
description = 'convert yaml into json'
parse_args = []
def main(_args, stream):
return convert(
input_stream = stream.input,
input_type = 'yaml',
output_stream = stream.output,
output_type = 'json',
)
#####################################################################
if __name__ == '__main__':
execute(main, description, parse_args)

0
py/directus/__init__.py Normal file
View File

140
py/directus/get-items.py Executable file
View File

@ -0,0 +1,140 @@
#!/usr/bin/env python
from scwrypts import execute
#####################################################################
from json import dumps
from scwrypts.fzf import fzf, fzf_tail
from scwrypts.http import directus
description = 'interactive CLI to get data from directus'
parse_args = [
( ['-c', '--collection'], {
"dest" : 'collection',
"default" : None,
"help" : 'the name of the collection',
"required" : False,
}),
( ['-f', '--filters'], {
"dest" : 'filters',
"default" : None,
"help" : 'as a URL-suffix, filters for the query',
"required" : False,
}),
( ['-d', '--fields'], {
"dest" : 'fields',
"default" : None,
"help" : 'comma-separated list of fields to include',
"required" : False,
}),
( ['-p', '--interactive-prompt'], {
"action" : 'store_true',
"dest" : 'interactive',
"default" : False,
"help" : 'interactively generate filter prompts; implied if no flags are provided',
"required" : False,
}),
( ['--prompt-filters'], {
"action" : 'store_true',
"dest" : 'generate_filters_prompt',
"default" : False,
"help" : '(superceded by -p) only generate filters interactively',
"required" : False,
}),
( ['--prompt-fields'], {
"action" : 'store_true',
"dest" : 'generate_fields_prompt',
"default" : False,
"help" : '(superceded by -p) only generate filters interactively',
"required" : False,
}),
]
def main(args, stream):
if {None} == { args.collection, args.filters, args.fields }:
args.interactive = True
if args.interactive:
args.generate_filters_prompt = True
args.generate_fields_prompt = True
collection = _get_or_select_collection(args)
filters = _get_or_select_filters(args, collection)
fields = _get_or_select_fields(args, collection)
query = '&'.join([
param for param in [
fields,
filters,
]
if param
])
endpoint = f'items/{collection}?{query}'
response = directus.request('GET', endpoint)
stream.writeline(dumps({
**response.json(),
'scwrypts_metadata': {
'endpoint': endpoint,
'repeat_with': f'scwrypts -n py/directus/get-items -- -c {collection} -f \'{query}\'',
},
}))
def _get_or_select_collection(args):
collection = args.collection
if collection is None:
collection = fzf(
prompt = 'select a collection',
choices = directus.get_collections(),
)
if not collection:
raise ValueError('collection required for query')
return collection
def _get_or_select_filters(args, collection):
filters = args.filters or ''
if filters == '' and args.generate_filters_prompt:
filters = '&'.join([
f'filter[{filter}][' + (
operator := fzf(
prompt = f'select operator for {filter}',
choices = directus.FILTER_OPERATORS,
)
) + ']=' + fzf_tail(prompt = f'filter[{filter}][{operator}]')
for filter in fzf(
prompt = 'select filter(s) [C^c to skip]',
fzf_options = '--multi',
force_list = True,
choices = directus.get_fields(collection),
)
])
return filters
def _get_or_select_fields(args, collection):
fields = args.fields or ''
if fields == '' and args.generate_fields_prompt:
fields = ','.join(fzf(
prompt = 'select return field(s) [C^c to get all]',
fzf_options = '--multi',
choices = directus.get_fields(collection),
force_list = True,
))
if fields:
fields = f'fields[]={fields}'
return fields
#####################################################################
if __name__ == '__main__':
execute(main, description, parse_args)

0
py/discord/__init__.py Normal file
View File

57
py/discord/post-message.py Executable file
View File

@ -0,0 +1,57 @@
#!/usr/bin/env python
from scwrypts import execute
#####################################################################
from json import dumps
from sys import stderr
from scwrypts.http import discord
description = 'post a message to the indicated discord channel'
parse_args = [
( ['-b', '--body'], {
'dest' : 'content',
'help' : 'message body',
'required' : False,
}),
( ['-c', '--channel-id'], {
'dest' : 'channel_id',
'help' : 'override default target channel id',
'required' : False,
}),
( ['-w', '--webhook'], {
'dest' : 'webhook',
'help' : 'override default target webhook (takes precedence over -c)',
'required' : False,
}),
( ['--avatar-url'], {
'dest' : 'avatar_url',
'help' : 'override default avatar_url',
'required' : False,
}),
( ['--username'], {
'dest' : 'username',
'help' : 'override default username',
'required' : False,
}),
]
def main(args, stream):
if args.content is None:
print(f'reading input from {stream.input.name}', file=stderr)
args.content = ''.join(stream.readlines()).strip()
if len(args.content) == 0:
args.content = 'PING'
response = discord.send_message(**vars(args))
stream.writeline(dumps({
**(response.json() if response.text != '' else {'message': 'OK'}),
'scwrypts_metadata': {},
}))
#####################################################################
if __name__ == '__main__':
execute(main, description, parse_args)

21
py/hello-world.py Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env python
from scwrypts import execute
#####################################################################
description = 'a simple "Hello, World!" program'
parse_args = [
( ['-m', '--message'], {
'dest' : 'message',
'default' : 'HELLO WORLD',
'help' : 'message to print',
'required' : False,
}),
]
def main(args, stream):
stream.writeline(args.message)
#####################################################################
if __name__ == '__main__':
execute(main, description, parse_args)

View File

@ -1,7 +0,0 @@
#!/usr/bin/env python
def main():
print('HELLO WORLD')
if __name__ == '__main__':
main()

4
py/lib/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
dist/
__pycache__/
*.py[cod]
*.so

3
py/lib/README.md Normal file
View File

@ -0,0 +1,3 @@
# Python Scwrypts
[![Generic Badge](https://img.shields.io/badge/python->=3.9-informational.svg)](https://python.org)
<br>

59
py/lib/pyproject.toml Normal file
View File

@ -0,0 +1,59 @@
[project]
name = 'scwrypts'
description = 'scwrypts library and invoker'
license = 'GPL-3.0-or-later'
readme = 'README.md'
requires-python = '>=3.10'
authors = [
{ name='yage', email='yage@yage.io' },
]
classifiers = [
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
]
dynamic = ['version']
dependencies = [
'bpython',
'pyfzf',
'pyyaml',
'redis',
'twilio',
]
[project.optional-dependencies]
dev = [
'pylint',
]
test = [
'pytest',
]
[project.urls]
homepage = 'https://github.com/wrynegade/scwrypts'
issues = 'https://github.com/wrynegade/scwrypts/issues'
[build-system]
requires = [
'hatchling',
'versioningit',
]
build-backend = 'hatchling.build'
[tool.hatch.version]
source = 'versioningit'
[tool.hatch.build.targets.wheel]
packages = ['./']
[tool.versioningit.vcs]
match = ['v[0-9]*.[0-9]*.[0-9]*']

View File

@ -0,0 +1,13 @@
'''
scwrypts
python library functions and invoker for scwrypts
'''
__all__ = [
'scwrypts',
'execute',
'interactive',
]
from .scwrypts import scwrypts, execute, interactive

View File

@ -0,0 +1 @@
from .converter import convert

View File

@ -0,0 +1,70 @@
import csv
import json
import yaml
def convert(input_stream, input_type, output_stream, output_type):
data = convert_input(input_stream, input_type)
write_output(output_stream, output_type, data)
def convert_input(stream, input_type):
supported_input_types = {'csv', 'json', 'yaml'}
if input_type not in supported_input_types:
raise ValueError(f'input_type "{input_type}" not supported; must be one of {supported_input_types}')
return {
'csv': _read_csv,
'json': _read_json,
'yaml': _read_yaml,
}[input_type](stream)
def write_output(stream, output_type, data):
supported_output_types = {'csv', 'json', 'yaml'}
if output_type not in supported_output_types:
raise ValueError(f'output_type "{output_type}" not supported; must be one of {supported_output_types}')
return {
'csv': _write_csv,
'json': _write_json,
'yaml': _write_yaml,
}[output_type](stream, data)
#####################################################################
def _read_csv(stream):
return [dict(line) for line in csv.DictReader(stream)]
def _write_csv(stream, data):
writer = csv.DictWriter(stream, fieldnames=list({
key
for dictionary in data
for key in dictionary.keys()
}))
writer.writeheader()
for value in data:
writer.writerow(value)
#####################################################################
def _read_json(stream):
data = json.loads(stream.read())
return data if isinstance(data, list) else [data]
def _write_json(stream, data):
stream.write(json.dumps(data))
#####################################################################
def _read_yaml(stream):
data = yaml.safe_load(stream)
return data if isinstance(data, list) else [data]
def _write_yaml(stream, data):
yaml.dump(data, stream, default_flow_style=False)

View File

@ -0,0 +1,73 @@
from io import StringIO
from pytest import raises
from scwrypts.test import generate
from .converter import convert
GENERATE_OPTIONS = {
'depth': 1,
'minimum': -999999,
'maximum': 999999,
'dict_key_types': {str, int},
'csv_columns_minimum': 10,
'csv_columns_maximum': 64,
'csv_rows_minimum': 10,
'csv_rows_maximum': 64,
}
INPUT_TYPES = {'csv', 'json', 'yaml'}
OUTPUT_TYPES = {'csv', 'json', 'yaml'}
def test_convert_to_csv():
for input_type in INPUT_TYPES:
input_stream = generate(input_type, {
**GENERATE_OPTIONS,
'data_types': {bool,int,float,str},
})
if isinstance(input_stream, str):
input_stream = StringIO(input_stream)
convert(input_stream, input_type, StringIO(), 'csv')
def test_convert_to_json():
for input_type in INPUT_TYPES:
input_stream = generate(input_type, GENERATE_OPTIONS)
if isinstance(input_stream, str):
input_stream = StringIO(input_stream)
convert(input_stream, input_type, StringIO(), 'json')
def test_convert_to_yaml():
for input_type in INPUT_TYPES:
input_stream = generate(input_type, GENERATE_OPTIONS)
if isinstance(input_stream, str):
input_stream = StringIO(input_stream)
convert(input_stream, input_type, StringIO(), 'yaml')
def test_convert_deep_json_to_yaml():
input_stream = generate('json', {**GENERATE_OPTIONS, 'depth': 4})
convert(input_stream, 'json', StringIO(), 'yaml')
def test_convert_deep_yaml_to_json():
input_stream = generate('yaml', {**GENERATE_OPTIONS, 'depth': 4})
convert(input_stream, 'yaml', StringIO(), 'json')
def test_convert_output_unsupported():
for input_type in list(INPUT_TYPES):
with raises(ValueError):
convert(StringIO(), input_type, StringIO(), generate(str))
def test_convert_input_unsupported():
for output_type in list(OUTPUT_TYPES):
with raises(ValueError):
convert(StringIO(), generate(str), StringIO(), output_type)

15
py/lib/scwrypts/env.py Normal file
View File

@ -0,0 +1,15 @@
from os import getenv as os_getenv
from .scwrypts.exceptions import MissingVariableError
def getenv(name, required=True):
value = os_getenv(f'{name}__override', os_getenv(name))
if required and not value:
raise MissingVariableError(name)
if value == '':
value = None
return value

View File

@ -0,0 +1 @@
from .client import fzf, fzf_tail, fzf_head

View File

@ -0,0 +1,61 @@
from pyfzf.pyfzf import FzfPrompt
FZF_PROMPT = None
def fzf( # pylint: disable=too-many-arguments
choices=None,
prompt=None,
fzf_options='',
delimiter='\n',
return_type=str,
force_list=False,
):
global FZF_PROMPT # pylint: disable=global-statement
if choices is None:
choices = []
if not isinstance(return_type, type):
raise ValueError(f'return_type must be a valid python type; "{return_type}" is not a type')
if FZF_PROMPT is None:
FZF_PROMPT = FzfPrompt()
options = ' '.join({
'-i',
'--layout=reverse',
'--ansi',
'--height=30%',
f'--prompt "{prompt} : "' if prompt is not None else '',
fzf_options,
})
selections = [
return_type(selection)
for selection in FZF_PROMPT.prompt(choices, options, delimiter)
]
if not force_list:
if len(selections) == 0:
return None
if len(selections) == 1:
return selections[0]
return selections
def fzf_tail(*args, **kwargs):
return _fzf_print(*args, **kwargs)[-1]
def fzf_head(*args, **kwargs):
return _fzf_print(*args, **kwargs)[0]
def _fzf_print(*args, fzf_options='', **kwargs):
return fzf(
*args,
**kwargs,
fzf_options = f'--print-query {fzf_options}',
force_list = True,
)

View File

@ -0,0 +1 @@
from .client import get_request_client

View File

@ -0,0 +1,25 @@
from requests import request
CLIENTS = {}
def get_request_client(base_url, headers=None):
if CLIENTS.get(base_url, None) is None:
if headers is None:
headers = {}
CLIENTS[base_url] = lambda method, endpoint, **kwargs: request(
method = method,
url = f'{base_url}/{endpoint}',
headers = {
**headers,
**kwargs.get('headers', {}),
},
**{
key: value
for key, value in kwargs.items()
if key != 'headers'
},
)
return CLIENTS[base_url]

View File

@ -0,0 +1,40 @@
from types import SimpleNamespace
from pytest import fixture
from scwrypts.test import get_generator
from scwrypts.test.character_set import uri
generate = get_generator({
'str_length_minimum': 8,
'str_length_maximum': 128,
'uuid_output_type': str,
})
def get_request_client_sample_data():
return {
'base_url' : generate(str, {'character_set': uri}),
'endpoint' : generate(str, {'character_set': uri}),
'method' : generate(str),
'response' : generate('requests_Response', {'depth': 4}),
'payload' : generate(dict, {
'depth': 1,
'data_types': { str, 'uuid' },
}),
}
@fixture(name='sample')
def fixture_sample():
return SimpleNamespace(
**get_request_client_sample_data(),
headers = generate(dict, {
'depth': 1,
'data_types': { str, 'uuid' },
}),
payload_headers = generate(dict, {
'depth': 1,
'data_types': { str, 'uuid' },
}),
)

View File

@ -0,0 +1,45 @@
'''
basic scwrypts.http client for directus
configured by setting DIRECTUS__BASE_URL and DIRECTUS__API_TOKEN in
scwrypts environment
'''
__all__ = [
'request',
'graphql',
'get_collections',
'get_fields',
'FILTER_OPERATORS',
]
from .client import request
from .graphql import graphql
from .collections import get_collections
from .fields import get_fields
FILTER_OPERATORS = {
'_eq',
'_neq',
'_lt',
'_lte',
'_gt',
'_gte',
'_in',
'_nin',
'_null',
'_nnull',
'_contains',
'_ncontains',
'_starts_with',
'_ends_with',
'_nends_with',
'_between',
'_nbetween',
'_empty',
'_nempty',
'_intersects',
'_nintersects',
'_intersects_bbox',
'_nintersects_bbox',
}

View File

@ -0,0 +1,12 @@
from scwrypts.env import getenv
from .. import get_request_client
def request(method, endpoint, **kwargs):
return get_request_client(
base_url = getenv("DIRECTUS__BASE_URL"),
headers = {
'Authorization': f'bearer {getenv("DIRECTUS__API_TOKEN")}',
}
)(method, endpoint, **kwargs)

View File

@ -0,0 +1,18 @@
from .client import request
COLLECTIONS = None
def get_collections():
global COLLECTIONS # pylint: disable=global-statement
if COLLECTIONS is None:
COLLECTIONS = [
item['collection']
for item in request(
'GET',
'collections?limit=-1&fields[]=collection',
).json()['data']
]
return COLLECTIONS

View File

@ -0,0 +1,15 @@
from types import SimpleNamespace
from pytest import fixture
from scwrypts.test.character_set import uri
from ..conftest import generate, get_request_client_sample_data
@fixture(name='sample')
def fixture_sample():
return SimpleNamespace(
**get_request_client_sample_data(),
api_token = generate(str, {'character_set': uri}),
query = generate(str),
)

View File

@ -0,0 +1,16 @@
from .client import request
FIELDS = {}
def get_fields(collection):
if FIELDS.get(collection) is None:
FIELDS[collection] = [
item['field']
for item in request(
'GET',
f'fields/{collection}?limit=-1&fields[]=field',
).json()['data']
]
return FIELDS[collection]

View File

@ -0,0 +1,9 @@
from .client import request
def graphql(query, system=False):
return request(
'POST',
'graphql' if system is False else 'graphql/system',
json={'query': query},
)

View File

@ -0,0 +1,43 @@
from unittest.mock import patch
from pytest import fixture
from .client import request
def test_directus_request(sample, _response):
assert _response == sample.response
def test_directus_request_client_setup(sample, _response, mock_get_request_client):
mock_get_request_client.assert_called_once_with(
base_url = sample.base_url,
headers = { 'Authorization': f'bearer {sample.api_token}' },
)
#####################################################################
@fixture(name='_response')
def fixture_response(sample):
return request(
method = sample.method,
endpoint = sample.endpoint,
**sample.payload,
)
#####################################################################
@fixture(name='mock_getenv', autouse=True)
def fixture_mock_getenv(sample):
with patch('scwrypts.http.directus.client.getenv',) as mock:
mock.side_effect = lambda name: {
'DIRECTUS__BASE_URL': sample.base_url,
'DIRECTUS__API_TOKEN': sample.api_token,
}[name]
yield mock
@fixture(name='mock_get_request_client', autouse=True)
def fixture_mock_get_request_client(sample):
with patch('scwrypts.http.directus.client.get_request_client') as mock:
mock.return_value = lambda method, endpoint, **kwargs: sample.response
yield mock

View File

@ -0,0 +1,45 @@
from unittest.mock import patch
from pytest import fixture
from .graphql import graphql
def test_directus_graphql(sample, _response, _mock_request):
assert _response == sample.response
def test_directus_graphql_request_payload(sample, _response, _mock_request):
_mock_request.assert_called_once_with(
'POST',
'graphql',
json = {'query': sample.query},
)
def test_directus_graphql_system(sample, _response_system):
assert _response_system == sample.response
def test_directus_graphql_system_request_payload(sample, _response_system, _mock_request):
_mock_request.assert_called_once_with(
'POST',
'graphql/system',
json = {'query': sample.query},
)
#####################################################################
@fixture(name='_response')
def fixture_response(sample, _mock_request):
return graphql(sample.query)
@fixture(name='_response_system')
def fixture_response_system(sample, _mock_request):
return graphql(sample.query, system=True)
#####################################################################
@fixture(name='_mock_request')
def fixture_mock_request(sample):
with patch('scwrypts.http.directus.graphql.request') as mock:
mock.return_value = sample.response
yield mock

View File

@ -0,0 +1,14 @@
'''
basic scwrypts.http client for discord
configured by setting various DISCORD__* options in the
scwrypts environment
'''
__all__ = [
'request',
'send_message',
]
from .client import request
from .send_message import send_message

View File

@ -0,0 +1,15 @@
from scwrypts.env import getenv
from .. import get_request_client
def request(method, endpoint, **kwargs):
headers = {}
if (token := getenv("DISCORD__BOT_TOKEN", required = False)) is not None:
headers['Authorization'] = f'Bot {token}'
return get_request_client(
base_url = 'https://discord.com/api',
headers = headers,
)(method, endpoint, **kwargs)

View File

@ -0,0 +1,24 @@
from string import ascii_letters, digits
from types import SimpleNamespace
from pytest import fixture
from scwrypts.test.character_set import uri
from ..conftest import generate, get_request_client_sample_data
@fixture(name='sample')
def fixture_sample():
return SimpleNamespace(
**{
**get_request_client_sample_data(),
'base_url': 'https://discord.com/api',
},
bot_token = generate(str, {'character_set': uri}),
username = generate(str, {'character_set': ascii_letters + digits}),
avatar_url = generate(str, {'character_set': uri}),
webhook = generate(str, {'character_set': uri}),
channel_id = generate(str, {'character_set': uri}),
content_header = generate(str),
content_footer = generate(str),
content = generate(str),
)

View File

@ -0,0 +1,48 @@
from scwrypts.env import getenv
from .client import request
def send_message(content, channel_id=None, webhook=None, username=None, avatar_url=None, **kwargs):
if username is None:
username = getenv('DISCORD__DEFAULT_USERNAME', required=False)
if avatar_url is None:
avatar_url = getenv('DISCORD__DEFAULT_AVATAR_URL', required=False)
endpoint = None
if webhook is not None:
endpoint = f'webhooks/{webhook}'
elif channel_id is not None:
endpoint = f'channels/{channel_id}/messages'
elif (webhook := getenv('DISCORD__DEFAULT_WEBHOOK', required=False)) is not None:
endpoint = f'webhooks/{webhook}'
elif (channel_id := getenv('DISCORD__DEFAULT_CHANNEL_ID', required=False)) is not None:
endpoint = f'channels/{channel_id}/messages'
else:
raise ValueError('must provide target channel_id or webhook')
if (header := getenv('DISCORD__CONTENT_HEADER', required=False)) is not None:
content = f'{header}{content}'
if (footer := getenv('DISCORD__CONTENT_FOOTER', required=False)) is not None:
content = f'{content}{footer}'
return request(
method = 'POST',
endpoint = endpoint,
json = {
key: value
for key, value in {
'content': content,
'username': username,
'avatar_url': avatar_url,
**kwargs,
}.items()
if value is not None
},
)

View File

@ -0,0 +1,54 @@
from unittest.mock import patch
from pytest import fixture
from .client import request
def test_discord_request(sample, _response):
assert _response == sample.response
def test_discord_request_client_setup(sample, mock_get_request_client, _mock_getenv, _response):
mock_get_request_client.assert_called_once_with(
base_url = sample.base_url,
headers = { 'Authorization': f'Bot {sample.bot_token}' },
)
def test_discord_request_client_setup_public(sample, mock_get_request_client, _mock_getenv_optional, _response):
mock_get_request_client.assert_called_once_with(
base_url = sample.base_url,
headers = {},
)
#####################################################################
@fixture(name='_response')
def fixture_response(sample):
return request(
method = sample.method,
endpoint = sample.endpoint,
**sample.payload,
)
#####################################################################
@fixture(name='mock_get_request_client', autouse=True)
def fixture_mock_get_request_client(sample):
with patch('scwrypts.http.discord.client.get_request_client') as mock:
mock.return_value = lambda method, endpoint, **kwargs: sample.response
yield mock
@fixture(name='_mock_getenv')
def fixture_mock_getenv(sample):
with patch('scwrypts.http.discord.client.getenv',) as mock:
mock.side_effect = lambda name, **kwargs: {
'DISCORD__BOT_TOKEN': sample.bot_token,
}[name]
yield mock
@fixture(name='_mock_getenv_optional')
def fixture_mock_getenv_optional():
with patch('scwrypts.http.discord.client.getenv',) as mock:
mock.side_effect = lambda name, **kwargs: None
yield mock

View File

@ -0,0 +1,91 @@
from unittest.mock import patch
from pytest import fixture, raises
from .send_message import send_message
def test_discord_send_message(sample, mock_request, _mock_getenv):
expected = get_default_called_with(sample)
assert send_message(sample.content) == sample.response
mock_request.assert_called_once_with(**expected)
def test_discord_send_message_without_username(sample, mock_request, _mock_getenv):
sample.username = None
expected = get_default_called_with(sample)
del expected['json']['username']
assert send_message(sample.content) == sample.response
mock_request.assert_called_once_with(**expected)
def test_discord_send_message_without_avatar_url(sample, mock_request, _mock_getenv):
sample.avatar_url = None
expected = get_default_called_with(sample)
del expected['json']['avatar_url']
assert send_message(sample.content) == sample.response
mock_request.assert_called_once_with(**expected)
def test_discord_send_message_to_channel_id(sample, mock_request, _mock_getenv):
sample.webhook = None
expected = get_default_called_with(sample)
expected['endpoint'] = f'channels/{sample.channel_id}/messages'
assert send_message(sample.content) == sample.response
mock_request.assert_called_once_with(**expected)
def test_discord_send_message_without_content_header(sample, mock_request, _mock_getenv):
sample.content_header = None
expected = get_default_called_with(sample)
expected['json']['content'] = f'{sample.content}{sample.content_footer}'
assert send_message(sample.content) == sample.response
mock_request.assert_called_once_with(**expected)
def test_discord_send_message_without_content_footer(sample, mock_request, _mock_getenv):
sample.content_footer = None
expected = get_default_called_with(sample)
expected['json']['content'] = f'{sample.content_header}{sample.content}'
assert send_message(sample.content) == sample.response
mock_request.assert_called_once_with(**expected)
def test_discord_send_message_error(sample, mock_request, _mock_getenv):
with raises(ValueError):
sample.webhook = None
sample.channel_id = None
send_message(sample.content)
#####################################################################
def get_default_called_with(sample):
return {
'method': 'POST',
'endpoint': f'webhooks/{sample.webhook}',
'json': {
'content': f'{sample.content_header}{sample.content}{sample.content_footer}',
'username': sample.username,
'avatar_url': sample.avatar_url,
},
}
@fixture(name='mock_request', autouse=True)
def fixture_mock_request(sample):
with patch('scwrypts.http.discord.send_message.request') as mock:
mock.return_value = sample.response
yield mock
@fixture(name='_mock_getenv')
def fixture_mock_getenv(sample):
with patch('scwrypts.http.discord.send_message.getenv',) as mock:
mock.side_effect = lambda name, **kwargs: {
'DISCORD__DEFAULT_USERNAME': sample.username,
'DISCORD__DEFAULT_AVATAR_URL': sample.avatar_url,
'DISCORD__DEFAULT_WEBHOOK': sample.webhook,
'DISCORD__DEFAULT_CHANNEL_ID': sample.channel_id,
'DISCORD__CONTENT_HEADER': sample.content_header,
'DISCORD__CONTENT_FOOTER': sample.content_footer,
}[name]
yield mock

View File

@ -0,0 +1,14 @@
'''
basic scwrypts.http client for linear
configured by setting the LINEAR__API_TOKEN option in the
scwrypts environment
'''
__all__ = [
'request',
'graphql',
]
from .client import request
from .graphql import graphql

View File

@ -0,0 +1,12 @@
from scwrypts.env import getenv
from .. import get_request_client
def request(method, endpoint, **kwargs):
return get_request_client(
base_url = 'https://api.linear.app',
headers = {
'Authorization': f'bearer {getenv("LINEAR__API_TOKEN")}',
},
)(method, endpoint, **kwargs)

View File

@ -0,0 +1,18 @@
from string import ascii_letters, digits
from types import SimpleNamespace
from pytest import fixture
from scwrypts.test.character_set import uri
from ..conftest import generate, get_request_client_sample_data
@fixture(name='sample')
def fixture_sample():
return SimpleNamespace(
**{
**get_request_client_sample_data(),
'base_url': 'https://api.linear.app',
},
api_token = generate(str, {'character_set': uri}),
query = generate(str),
)

View File

@ -0,0 +1,5 @@
from .client import request
def graphql(query):
return request('POST', 'graphql', json={'query': query})

View File

@ -0,0 +1,42 @@
from unittest.mock import patch
from pytest import fixture
from .client import request
def test_discord_request(sample, _response):
assert _response == sample.response
def test_discord_request_client_setup(sample, mock_get_request_client, _response):
mock_get_request_client.assert_called_once_with(
base_url = sample.base_url,
headers = { 'Authorization': f'bearer {sample.api_token}' },
)
#####################################################################
@fixture(name='_response')
def fixture_response(sample):
return request(
method = sample.method,
endpoint = sample.endpoint,
**sample.payload,
)
#####################################################################
@fixture(name='mock_get_request_client', autouse=True)
def fixture_mock_get_request_client(sample):
with patch('scwrypts.http.linear.client.get_request_client') as mock:
mock.return_value = lambda method, endpoint, **kwargs: sample.response
yield mock
@fixture(name='mock_getenv', autouse=True)
def fixture_mock_getenv(sample):
with patch('scwrypts.http.linear.client.getenv',) as mock:
mock.side_effect = lambda name, **kwargs: {
'LINEAR__API_TOKEN': sample.api_token,
}[name]
yield mock

View File

@ -0,0 +1,35 @@
from unittest.mock import patch
from pytest import fixture
from .graphql import graphql
def test_directus_graphql(sample, _response, _mock_request):
assert _response == sample.response
def test_directus_graphql_request_payload(sample, _response, _mock_request):
_mock_request.assert_called_once_with(
'POST',
'graphql',
json = {'query': sample.query},
)
#####################################################################
@fixture(name='_response')
def fixture_response(sample, _mock_request):
return graphql(sample.query)
@fixture(name='_response_system')
def fixture_response_system(sample, _mock_request):
return graphql(sample.query)
#####################################################################
@fixture(name='_mock_request')
def fixture_mock_request(sample):
with patch('scwrypts.http.linear.graphql.request') as mock:
mock.return_value = sample.response
yield mock

View File

@ -0,0 +1,55 @@
from unittest.mock import patch
from pytest import fixture
from .client import get_request_client
def test_request_client(sample, _response_basic):
assert _response_basic == sample.response
def test_request_client_forwards_default_headers(sample, mock_request, _response_basic):
mock_request.assert_called_once_with(
method = sample.method,
url = f'{sample.base_url}/{sample.endpoint}',
headers = sample.headers,
)
def test_get_request_client_payload(sample, _response_payload):
assert _response_payload == sample.response
def test_request_client_forwards_payload_headers(sample, mock_request, _response_payload):
assert mock_request.call_args.kwargs['headers'] == sample.headers | sample.payload_headers
#####################################################################
@fixture(name='mock_request', autouse=True)
def fixture_mock_request(sample):
with patch('scwrypts.http.client.request') as mock:
mock.return_value = sample.response
yield mock
@fixture(name='request_client', autouse=True)
def fixture_request_client(sample):
return get_request_client(sample.base_url, sample.headers)
#####################################################################
@fixture(name='_response_basic')
def fixture_response_basic(sample, request_client):
return request_client(
method = sample.method,
endpoint = sample.endpoint,
)
@fixture(name='_response_payload')
def fixture_response_payload(sample, request_client):
return request_client(
method = sample.method,
endpoint = sample.endpoint,
**{
**sample.payload,
'headers': sample.payload_headers,
},
)

View File

@ -0,0 +1 @@
from .combined_io_stream import get_combined_stream, add_io_arguments

View File

@ -0,0 +1,100 @@
from contextlib import contextmanager
from pathlib import Path
from sys import stdin, stdout, stderr
from scwrypts.env import getenv
@contextmanager
def get_combined_stream(input_file=None, output_file=None):
'''
context manager to open an "input_file" and "output_file"
But the "files" can be pipe-streams, stdin/stdout, or even
actual files! Helpful when trying to write CLI scwrypts
which would like to accept all kinds of input and output
configurations.
'''
with get_stream(input_file, 'r') as input_stream, get_stream(output_file, 'w+') as output_stream:
yield CombinedStream(input_stream, output_stream)
def add_io_arguments(parser, allow_input=True, allow_output=True):
'''
slap these puppies onto your argparse.ArgumentParser to
allow easy use of the get_combined_stream at the command line
'''
if allow_input:
parser.add_argument(
'-i', '--input-file',
dest = 'input_file',
default = None,
help = 'path to input file; omit for stdin',
required = False,
)
if allow_output:
parser.add_argument(
'-o', '--output-file',
dest = 'output_file',
default = None,
help = 'path to output file; omit for stdout',
required = False,
)
#####################################################################
@contextmanager
def get_stream(filename=None, mode='r', encoding='utf-8', verbose=False, **kwargs):
allowed_modes = {'r', 'w', 'w+'}
if mode not in allowed_modes:
raise ValueError(f'mode "{mode}" not supported modes (must be one of {allowed_modes})')
is_read = mode == 'r'
if filename is not None:
if verbose:
print(f'opening file {filename} for {"read" if is_read else "write"}', file=stderr)
if filename[0] not in {'/', '~'}:
filename = Path(f'{getenv("EXECUTION_DIR")}/{filename}').resolve()
with open(filename, mode=mode, encoding=encoding, **kwargs) as stream:
yield stream
else:
if verbose:
print('using stdin for read' if is_read else 'using stdout for write', file=stderr)
yield stdin if is_read else stdout
if not is_read:
stdout.flush()
class CombinedStream:
def __init__(self, input_stream, output_stream):
self.input = input_stream
self.output = output_stream
def read(self, *args, **kwargs):
return self.input.read(*args, **kwargs)
def readline(self, *args, **kwargs):
return self.input.readline(*args, **kwargs)
def readlines(self, *args, **kwargs):
return self.input.readlines(*args, **kwargs)
def write(self, *args, **kwargs):
return self.output.write(*args, **kwargs)
def writeline(self, line):
x = self.output.write(f'{line}\n')
self.output.flush()
return x
def writelines(self, *args, **kwargs):
return self.output.writelines(*args, **kwargs)

View File

@ -0,0 +1 @@
from .client import get_client

View File

@ -0,0 +1,19 @@
from redis import StrictRedis
from scwrypts.env import getenv
CLIENT = None
def get_client():
global CLIENT # pylint: disable=global-statement
if CLIENT is None:
print('getting redis client')
CLIENT = StrictRedis(
host = getenv('REDIS_HOST'),
port = getenv('REDIS_PORT'),
password = getenv('REDIS_AUTH', required=False),
decode_responses = True,
)
return CLIENT

View File

@ -0,0 +1,23 @@
'''
scwrypts meta-configuration
provides a helpful three ways to run "scwrypts"
'scwrypts' is an agnostic, top-level executor allowing any scwrypt to be called from python workflows
'execute' is the default context set-up for python-based scwrypts
'interactive' is a context set-up for interactive, python-based scwrypts
after execution, you are dropped in a bpython shell with all the variables
configured during main() execution
'''
__all__ = [
'scwrypts',
'execute',
'interactive',
]
from .scwrypts import scwrypts
from .execute import execute
from .interactive import interactive

View File

@ -0,0 +1,26 @@
from argparse import ArgumentError
class MissingVariableError(EnvironmentError):
def init(self, name):
super().__init__(f'Missing required environment variable "{name}"')
class MissingFlagAndEnvironmentVariableError(EnvironmentError, ArgumentError):
def __init__(self, flags, env_var):
super().__init__(f'must provide at least one of : {{ flags: {flags} OR {env_var} }}')
class MissingScwryptsExecutableError(EnvironmentError):
def __init__(self):
super().__init__('scwrypts must be installed and available on your PATH')
class BadScwryptsLookupError(ValueError):
def __init__(self):
super().__init__('must provide name/group/type or scwrypt lookup patterns')
class MissingScwryptsGroupOrTypeError(ValueError):
def __init__(self, group, _type):
super().__init__(f'missing required group or type (group={group} | type={_type}')

View File

@ -0,0 +1,26 @@
from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
from scwrypts.io import get_combined_stream, add_io_arguments
def execute(main, description=None, parse_args=None, allow_input=True, allow_output=True):
'''
API to initiate a python-based scwrypt
'''
if parse_args is None:
parse_args = []
parser = ArgumentParser(
description = description,
formatter_class = ArgumentDefaultsHelpFormatter,
)
add_io_arguments(parser, allow_input, allow_output)
for a in parse_args:
parser.add_argument(*a[0], **a[1])
args = parser.parse_args()
with get_combined_stream(args.input_file, args.output_file) as stream:
return main(args, stream)

View File

@ -0,0 +1,25 @@
from bpython import embed
def interactive(variable_descriptions):
'''
main() decorator to drop to interactive python environment upon completion
'''
def outer(function):
def inner(*args, **kwargs):
print('\npreparing interactive environment...\n')
local_vars = function(*args, **kwargs)
print('\n\n'.join([
f'>>> {x}' for x in variable_descriptions
]))
print('\nenvironment ready; user, GO! :)\n')
embed(local_vars)
return inner
return outer

View File

@ -0,0 +1,58 @@
from os import getenv
from shutil import which
from subprocess import run
from .exceptions import MissingScwryptsExecutableError, BadScwryptsLookupError, MissingScwryptsGroupOrTypeError
def scwrypts(patterns=None, args=None, executable_args=None, name=None, group=None, _type=None):
'''
top-level scwrypts invoker from python
patterns str / list pattern-based scwrypt lookup
args str / list arguments forwarded to the invoked scwrypt
executable_args str / list arguments for the 'scwrypts' executable
(str above assumes space-delimited values)
name str exact scwrypt lookup name (requires group and _type)
group str exact scwrypt lookup group
_type str exact scwrypt lookup type
SCWRYPTS_EXECUTABLE configuration variable which defines the full path to scwrypts executable
see 'scwrypts --help' for more information
'''
if patterns is None and name is None:
raise BadScwryptsLookupError()
if name is not None and (group is None or _type is None):
raise MissingScwryptsGroupOrTypeError(group, _type)
executable = which(getenv('SCWRYPTS_EXECUTABLE', 'scwrypts'))
if executable is None:
raise MissingScwryptsExecutableError()
lookup = _parse(patterns) if name is None else f'--name {name} --group {group} --type {_type}'
depth = getenv('SUBSCWRYPT', '')
if depth != '':
depth = int(depth) + 1
return run(
f'SUBSCWRYPT={depth} {executable} {_parse(executable_args)} {lookup} -- {_parse(args)}',
shell=True,
executable='/bin/zsh',
check=False,
capture_output=True,
text=True,
)
def _parse(string_or_list_args):
if string_or_list_args is None:
return ''
if isinstance(string_or_list_args, list):
return ' '.join(string_or_list_args)
return str(string_or_list_args)

View File

@ -0,0 +1,184 @@
from random import choice
from re import search
from string import ascii_letters, digits
from types import SimpleNamespace
from unittest.mock import patch
from pytest import fixture, raises
from scwrypts.test import get_generator
from .exceptions import MissingScwryptsExecutableError, BadScwryptsLookupError, MissingScwryptsGroupOrTypeError
from .scwrypts import scwrypts
#####################################################################
def test_scwrypts(sample, _scwrypts):
assert validate_scwrypts_output(sample, _scwrypts)
def test_scwrypts_finds_system_executable(sample, _scwrypts, mock_which):
mock_which.assert_called_once_with(sample.env['SCWRYPTS_EXECUTABLE'])
def test_scwrypts_uses_configured_executable_path(_scwrypts, mock_getenv):
mock_getenv.assert_any_call('SCWRYPTS_EXECUTABLE', 'scwrypts')
def test_scwrypts_uses_correct_depth(_scwrypts, mock_getenv):
mock_getenv.assert_any_call('SUBSCWRYPT', '')
def test_scwrypts_runs_subprocess(_scwrypts, mock_run):
mock_run.assert_called_once()
##########################################
def test_scwrypts_omit_optionals(sample, _scwrypts_omit_optionals):
assert validate_scwrypts_output(sample, _scwrypts_omit_optionals)
def test_scwrypts_omit_optionals_finds_system_executable(sample, _scwrypts_omit_optionals, mock_which):
mock_which.assert_called_once_with('scwrypts')
def test_scwrypts_omit_optionals_uses_configured_executable_path(_scwrypts_omit_optionals, mock_getenv):
mock_getenv.assert_any_call('SCWRYPTS_EXECUTABLE', 'scwrypts')
def test_scwrypts_omit_optionals_uses_correct_depth(_scwrypts_omit_optionals, mock_getenv):
mock_getenv.assert_any_call('SUBSCWRYPT', '')
def test_scwrypts_omit_optionals_runs_subprocess(_scwrypts_omit_optionals, mock_run):
mock_run.assert_called_once()
##########################################
def test_invalid_lookup_missing_patterns_and_name(sample):
sample.patterns = None
sample.name = None
with raises(BadScwryptsLookupError):
scwrypts(**get_scwrypts_args(sample))
def test_invalid_name_lookup_missing_group(sample):
sample.group = None
with raises(MissingScwryptsGroupOrTypeError):
scwrypts(**get_scwrypts_args(sample))
def test_invalid_name_lookup_missing_type(sample):
sample._type = None # pylint: disable=protected-access
with raises(MissingScwryptsGroupOrTypeError):
scwrypts(**get_scwrypts_args(sample))
def test_invalid_scwrypts_installation(sample, mock_which):
mock_which.return_value = None
with raises(MissingScwryptsExecutableError):
scwrypts(**get_scwrypts_args(sample))
#####################################################################
generate = get_generator({
'str_length_minimum': 8,
'str_length_maximum': 128,
'character_set': ascii_letters + digits + '/-_'
})
def _generate_str_or_list_arg():
random_arg = generate(list, {'data_types': {str}})
return random_arg if choice([str, list]) == list else ' '.join(random_arg)
@fixture(name='sample')
def fixture_sample():
sample = SimpleNamespace(
patterns = _generate_str_or_list_arg(),
args = _generate_str_or_list_arg(),
executable_args = _generate_str_or_list_arg(),
name = generate(str),
group = generate(str),
_type = generate(str),
executable = generate(str),
env = {
'SCWRYPTS_EXECUTABLE': generate(str),
'SUBSCWRYPT': str(generate(int, {'minimum': 1, 'maximum': 99})),
},
returncode = generate(int),
stdout = generate(str),
stderr = generate(str),
)
return sample
def get_scwrypts_args(sample):
return {
key: getattr(sample, key)
for key in [
'patterns',
'args',
'executable_args',
'name',
'group',
'_type',
]
}
#####################################################################
@fixture(name='mock_which', autouse=True)
def fixture_mock_which(sample):
with patch('scwrypts.scwrypts.scwrypts.which') as mock:
mock.return_value = sample.executable
yield mock
@fixture(name='mock_getenv', autouse=True)
def fixture_mock_getenv(sample):
with patch('scwrypts.scwrypts.scwrypts.getenv') as mock:
mock.side_effect = sample.env.get
yield mock
@fixture(name='mock_run', autouse=True)
def fixture_mock_run(sample):
with patch('scwrypts.scwrypts.scwrypts.run') as mock:
mock.side_effect = lambda *args, **_kwargs: SimpleNamespace(
args = args,
returncode = sample.returncode,
stdout = sample.stdout,
stderr = sample.stderr,
)
yield mock
#####################################################################
@fixture(name='_scwrypts')
def fixture_scwrypts(sample):
return scwrypts(**get_scwrypts_args(sample))
@fixture(name='_scwrypts_omit_optionals')
def fixture_scwrypts_omit_optionals(sample):
sample.args = None
sample.executable_args = None
del sample.env['SCWRYPTS_EXECUTABLE']
del sample.env['SUBSCWRYPT']
return scwrypts(**get_scwrypts_args(sample))
def validate_scwrypts_output(sample, output):
#
# I would love to use 'assert _scwrypts == SimpleNamespace(...expected...)'
# but the output.args is difficult to recreate without copying all the
# processing logic over from the scwrypts function
#
# opting for a bit of a strange equality test here, checking the args
# as closely as possible without copying parsing logic
#
run_args_reduced_to_a_single_string = len(output.args) == 1
run_args_follow_expected_form = search(
fr'^SUBSCWRYPT=.* {sample.executable} .*-- .*$',
output.args[0],
)
return all([
run_args_reduced_to_a_single_string,
run_args_follow_expected_form,
output.returncode == sample.returncode,
output.stdout == sample.stdout,
output.stderr == sample.stderr,
])

View File

@ -0,0 +1,10 @@
'''
automated testing utilties, but primarily a random data generator
'''
__all__ = [
'generate',
]
from .generate import generate, get_generator
from .character_set import *

View File

@ -0,0 +1,13 @@
'''
string constants typically used for randomly generated data
the 'string' standard library already contains many character sets,
but not these :)
'''
__all__ = [
'uri',
]
uri = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&\'()*+,;='

View File

@ -0,0 +1,10 @@
class GeneratorError(Exception):
pass
class NoDataTypeError(GeneratorError, ValueError):
def __init__(self):
super().__init__('must provide at least one data type (either "data_type" or "data_types")')
class BadGeneratorTypeError(GeneratorError, ValueError):
def __init__(self, data_type):
super().__init__(f'no generator exists for data type "{data_type}"')

View File

@ -0,0 +1,387 @@
from csv import writer, QUOTE_NONNUMERIC
from io import StringIO
from json import dumps, loads
from random import randint, uniform, choice
from re import sub
from string import printable
from typing import Hashable, Callable
from uuid import uuid4
from requests import Response, status_codes
from yaml import safe_dump
from .exceptions import NoDataTypeError, BadGeneratorTypeError
DEFAULT_OPTIONS = {
'data_types': None,
'minimum': 0,
'maximum': 64,
'depth': 1,
'character_set': None,
'bool_nullable': False,
'str_length': None,
'str_length_minimum': 0,
'str_length_maximum': 64,
'uuid_output_type': 'uuid', # str or 'uuid'
'list_length': 8,
'set_length': 8,
'dict_length': 8,
'csv_bool_nullable': True,
'csv_columns': None,
'csv_columns_minimum': 1,
'csv_columns_maximum': 16,
'csv_rows': None,
'csv_rows_minimum': 2,
'csv_rows_maximum': 16,
'csv_output_type': 'stringio', # str or 'stringio'
'json_initial_type': dict, # typically dict or list
'json_bool_nullable': True,
'json_output_type': 'stringio', # str or 'stringio'
'yaml_initial_type': dict, # typically dict or list
'yaml_bool_nullable': True,
'yaml_use_default_flow_style': False,
'yaml_output_type': 'stringio', # str or 'stringio'
'requests_response_status_code': status_codes.codes[200],
}
def get_generator(default_options=None):
if default_options is None:
default_options = {}
def generator_function(data_type=None, options_overrides=None):
if options_overrides is None:
options_overrides = {}
return generate(
data_type = data_type,
options = default_options | options_overrides,
)
return generator_function
def generate(data_type=None, options=None):
'''
generate random data with the call of a function
use data_type to generate a single value
use options to set generation options (key = type, value = kwargs)
use options.data_types and omit data_type to generate a random type
'''
if options is None:
options = {}
options = DEFAULT_OPTIONS | options
if data_type is None:
if options['data_types'] is None or len(options['data_types']) == 0:
raise NoDataTypeError()
return generate(
data_type=choice(list(options['data_types'])),
options=options,
)
if not isinstance(data_type, str):
data_type = data_type.__name__
if data_type not in Generator.get_supported_data_types():
raise BadGeneratorTypeError(data_type)
return getattr(Generator, f'_{data_type}')(options)
#####################################################################
SUPPORTED_DATA_TYPES = None
class Generator:
@classmethod
def get_supported_data_types(cls):
global SUPPORTED_DATA_TYPES # pylint: disable=global-statement
if SUPPORTED_DATA_TYPES is None:
SUPPORTED_DATA_TYPES = {
sub('^_', '', data_type)
for data_type, method in Generator.__dict__.items()
if isinstance(method, staticmethod)
}
return SUPPORTED_DATA_TYPES
#####################################################################
@classmethod
def filter_data_types(cls, options, filters=None):
'''
returns an options dict with appropriately filtered data_types
if data_types are not yet defined, starts with all supported data_types
'''
if options['data_types'] is None:
options['data_types'] = Generator.get_supported_data_types()
if filters is None or len(filters) == 0:
return options
return {
**options,
'data_types': set(filter(
lambda data_type: all(( f(data_type, options) for f in filters )),
options['data_types'],
)),
}
class Filters:
@staticmethod
def hashable(data_type, _options):
if isinstance(data_type, Callable):
return isinstance(data_type(), Hashable)
if not isinstance(data_type, str):
data_type = data_type.__name__
return data_type in { 'bool', 'int', 'float', 'chr', 'str', 'uuid' }
@staticmethod
def filelike(data_type, _options):
return data_type in { 'csv', 'json', 'yaml' }
@staticmethod
def complex(data_type, _options):
return data_type in { 'requests_Response' }
@staticmethod
def basic(data_type, options):
return all([
not Generator.Filters.filelike(data_type, options),
not Generator.Filters.complex(data_type, options),
])
@staticmethod
def pythonset(data_type, _options):
if not isinstance(data_type, str):
data_type = data_type.__name__
return data_type == 'set'
@staticmethod
def csvsafe(data_type, options):
options['depth'] = max(1, options['depth'])
return all([
Generator.Filters.basic(data_type, options),
not Generator.Filters.pythonset(data_type, options),
])
@staticmethod
def jsonsafe(data_type, options):
return all([
Generator.Filters.basic(data_type, options),
not Generator.Filters.pythonset(data_type, options),
])
@staticmethod
def yamlsafe(data_type, options):
return all([
Generator.Filters.basic(data_type, options),
not Generator.Filters.pythonset(data_type, options),
])
#####################################################################
@classmethod
def get_option_with_range(cls, options, option_key, data_type=int):
'''
typically an integer range, allows both:
- setting a fixed configuration (e.g. 'str_length')
- allowing a configuration range (e.g. 'str_length_minimum' and 'str_length_maximum')
'''
fixed = options.get(option_key, None)
if fixed is not None:
return fixed
return generate(data_type, {
'minimum': options[f'{option_key}_minimum'],
'maximum': options[f'{option_key}_maximum'],
})
#####################################################################
@staticmethod
def _bool(options):
return choice([True, False, None]) if options['bool_nullable'] else choice([True, False])
@staticmethod
def _int(options):
return randint(options['minimum'], options['maximum'])
@staticmethod
def _float(options):
return uniform(options['minimum'], options['maximum'])
@staticmethod
def _chr(options):
character_set = options['character_set']
return choice(character_set) if character_set is not None else chr(randint(0,65536))
@staticmethod
def _str(options):
return ''.join((
generate(chr, options)
for _ in range(Generator.get_option_with_range(options, 'str_length'))
))
@staticmethod
def _uuid(options):
'''
creates a UUID object or a str containing a uuid (v4)
'''
uuid = uuid4()
return str(uuid) if options['uuid_output_type'] == str else uuid
@staticmethod
def _list(options):
if options['depth'] <= 0:
return []
options['depth'] -= 1
options = Generator.filter_data_types(options, [
Generator.Filters.basic,
])
return [ generate(None, {**options}) for _ in range(options['list_length']) ]
@staticmethod
def _set(options):
if options['depth'] <= 0:
return set()
options['depth'] -= 1
options = Generator.filter_data_types(options, [
Generator.Filters.hashable,
])
return { generate(None, options) for _ in range(options['set_length']) }
@staticmethod
def _dict(options):
if options['depth'] <= 0:
return {}
options['depth'] -= 1
options = Generator.filter_data_types(options, [
Generator.Filters.basic,
])
key_options = Generator.filter_data_types(options, [
Generator.Filters.hashable,
])
if len(options['data_types']) == 0 or len(key_options['data_types']) == 0:
return {}
return {
generate(None, key_options): generate(None, options)
for _ in range(options['dict_length'])
}
@staticmethod
def _csv(options):
'''
creates a StringIO object containing csv data
'''
if options['character_set'] is None:
options['character_set'] = printable
options['bool_nullable'] = options['csv_bool_nullable']
options = Generator.filter_data_types(options, [
Generator.Filters.csvsafe,
])
columns = Generator.get_option_with_range(options, 'csv_columns')
rows = Generator.get_option_with_range(options, 'csv_rows')
csv = StringIO()
csv_writer = writer(csv, quoting=QUOTE_NONNUMERIC)
options['list_length'] = columns
[ # pylint: disable=expression-not-assigned
csv_writer.writerow(generate(list, options))
for _ in range(rows)
]
csv.seek(0)
return csv.getvalue() if options['csv_output_type'] == str else csv
@staticmethod
def _json(options):
'''
creates a StringIO object or str containing json data
'''
if options['character_set'] is None:
options['character_set'] = printable
options['bool_nullable'] = options['json_bool_nullable']
options['uuid_output_type'] = str
options = Generator.filter_data_types(options, [
Generator.Filters.jsonsafe,
])
json = dumps(generate(
options['json_initial_type'],
{**options},
))
return json if options['json_output_type'] == str else StringIO(json)
@staticmethod
def _yaml(options):
'''
creates a StringIO object or str containing yaml data
'''
if options['character_set'] is None:
options['character_set'] = printable
options['bool_nullable'] = options['yaml_bool_nullable']
options['uuid_output_type'] = str
options = Generator.filter_data_types(options, [
Generator.Filters.yamlsafe,
])
yaml = StringIO()
safe_dump(
generate(options['yaml_initial_type'], {**options}),
yaml,
default_flow_style=options['yaml_use_default_flow_style'],
)
yaml.seek(0)
return yaml.getvalue() if options['yaml_output_type'] == str else yaml
@staticmethod
def _requests_Response(options):
'''
creates a requests.Response-like object containing json data
'''
options['json_output_type'] = str
response = Response()
response.status_code = options['requests_response_status_code']
json = loads(generate('json', options))
response.json = lambda: json
return response

View File

@ -0,0 +1,54 @@
from os import getenv
from pprint import pprint
from random import randint
from .generate import generate, Generator
ITERATIONS = int(
getenv(
'PYTEST_ITERATIONS__scwrypts__test__generator',
getenv('PYTEST_ITERATIONS', '99'), # CI should use at least 999
)
)
FILE_LIKE_DATA_TYPES = { 'csv', 'json', 'yaml' }
def test_generate(): # generators should be quick and "just work" (no Exceptions)
print()
for data_type in Generator.get_supported_data_types():
print(f'------- {data_type} -------')
sample = generate(data_type)
pprint(sample.getvalue() if data_type in {'csv', 'json', 'yaml'} else sample)
for _ in range(ITERATIONS):
generate(data_type)
def test_generate_depth_deep():
for data_type in Generator.get_supported_data_types():
generate(data_type, {'depth': 4})
def test_generate_depth_shallow():
for data_type in Generator.get_supported_data_types():
generate(data_type, {'depth': randint(-999, 0)})
def test_generate_range_all():
for data_type in Generator.get_supported_data_types():
generate(data_type, {'minimum': -99, 'maximum': 99})
def test_generate_range_positive():
for data_type in Generator.get_supported_data_types():
generate(data_type, {'minimum': 1, 'maximum': 99})
def test_generate_range_zero():
for data_type in Generator.get_supported_data_types():
generate(data_type, {'minimum': 3, 'maximum': 3})
def test_generate_range_negative():
for data_type in Generator.get_supported_data_types():
generate(data_type, {'minimum': -99, 'maximum': -1})
def test_generate_bool_nullable():
for data_type in Generator.get_supported_data_types():
generate(data_type, {'bool_nullable': True})

View File

@ -0,0 +1,13 @@
'''
loads the twilio.rest.Client by referencing TWILIO__API_KEY,
TWILIO__API_SECRET, and TWILIO__ACCOUNT_SID in your scwrypts
environment
'''
__all__ = [
'get_client',
'send_sms',
]
from .client import get_client
from .send_sms import send_sms

View File

@ -0,0 +1,18 @@
from twilio.rest import Client
from scwrypts.env import getenv
CLIENT = None
def get_client():
global CLIENT # pylint: disable=global-statement
if CLIENT is None:
print('loading client')
CLIENT = Client(
username = getenv('TWILIO__API_KEY'),
password = getenv('TWILIO__API_SECRET'),
account_sid = getenv('TWILIO__ACCOUNT_SID'),
)
return CLIENT

View File

@ -0,0 +1,57 @@
from json import dumps
from time import sleep
from .client import get_client
def send_sms(to, from_, body, max_char_count=300, stream=None):
'''
abstraction for twilio.client.messages.create which will break
messages into multi-part SMS rather than throwing an error or
requiring the use of MMS data
@param to messages.create parameter
@param from_ messages.create parameter
@param body messages.create parameter
@param max_char_count 1 N 1500 (default 300)
@param stream used to report success/failure (optional)
@return a list of twilio MessageInstance objects
'''
client = get_client()
messages = []
max_char_count = max(1, min(max_char_count, 1500))
total_sms_parts = 1 + len(body) // max_char_count
contains_multiple_parts = total_sms_parts > 1
for i in range(0, len(body), max_char_count):
msg_body = body[i:i+max_char_count]
current_part = 1 + i // max_char_count
if contains_multiple_parts:
msg_body = f'{current_part}/{total_sms_parts}\n{msg_body}'
message = client.messages.create(
to = to,
from_ = from_,
body = msg_body,
)
messages.append(message)
if stream is not None:
stream.writeline(
dumps({
'sid': message.sid,
'to': to,
'from': from_,
'body': msg_body,
})
)
if contains_multiple_parts:
sleep(2 if max_char_count <= 500 else 5)
return messages

0
py/linear/__init__.py Normal file
View File

Some files were not shown because too many files have changed in this diff Show More