Compare commits

...

5 Commits

Author SHA1 Message Date
6ac1015718 initial build/test steps for nodejs 2024-02-20 22:08:07 -07:00
f20c2b24f0 3.9.1 2024-02-20 21:48:35 -07:00
113445ca43 npm package for scwrypts 2024-02-20 21:48:30 -07:00
bdb7851064 circleci configuration for python builds 2024-02-19 18:33:59 -07:00
cdb30f2dc0 hokay first iteration of python-dudes is ready 2024-02-19 18:25:34 -07:00
52 changed files with 13990 additions and 395 deletions

109
.circleci/config.yml Normal file
View File

@ -0,0 +1,109 @@
---
version: 2.1
orbs:
python: circleci/python@2.1.1
executors:
python:
docker:
- image: cimg/python:3.11
resource_class: small
nodejs:
docker:
- image: node:18
resource_class: small
jobs:
python-test:
executor: python
working_directory: ./py/lib
steps:
- run: |
: \
&& pip install -e . \
&& pytest \
;
python-publish:
executor: python
steps:
- checkout
- python/dist
- run: pip install twine && twine upload dist/*
nodesjs-test:
executor: nodejs
working_directory: ./zx/lib
steps:
- checkout
- 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
nodesjs-publish:
executor: nodejs
working_directory: ./zx/lib
steps:
- checkout
- 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 build
- run: pnpm version $(git describe --tags)
workflows:
build:
jobs:
- python-test
- python-publish:
requires: [python-test]
filters:
branches:
only: main
- nodejs-test
- nodejs-publish:
requires: [nodejs-test]
filters:
branches:
only: main

View File

@ -4,6 +4,10 @@ scwrypts
python library functions and invoker for scwrypts
'''
from .scwrypts.execute import execute
from .scwrypts.interactive import interactive
from .scwrypts.scwrypts import scwrypts
__all__ = [
'scwrypts',
'execute',
'interactive',
]
from .scwrypts import scwrypts, execute, interactive

View File

@ -1,6 +1,4 @@
from io import StringIO
#from string import ascii_letters, digits
from unittest.mock import patch
from pytest import raises
@ -56,7 +54,7 @@ def test_convert_to_yaml():
def test_convert_deep_json_to_yaml():
input_stream = StringIO(generate('json', {**GENERATE_OPTIONS, 'depth': 4}))
input_stream = generate('json', {**GENERATE_OPTIONS, 'depth': 4})
convert(input_stream, 'json', StringIO(), 'yaml')
def test_convert_deep_yaml_to_json():

View File

@ -1,20 +1,25 @@
from requests import request
def get_request_client(base_url, headers=None):
if headers is None:
headers = {}
CLIENTS = {}
return 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'
},
)
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,43 @@
from types import SimpleNamespace
from pytest import fixture
from scwrypts.test import generate
from scwrypts.test.character_set import uri
options = {
'str_length_minimum': 8,
'str_length_maximum': 128,
'uuid_output_type': str,
}
def get_request_client_sample_data():
return {
'base_url' : generate(str, options | {'character_set': uri}),
'endpoint' : generate(str, options | {'character_set': uri}),
'method' : generate(str, options),
'response' : generate('requests_Response', options | {'depth': 4}),
'payload' : generate(dict, {
**options,
'depth': 1,
'data_types': { str, 'uuid' },
}),
}
@fixture(name='sample')
def fixture_sample():
return SimpleNamespace(
**get_request_client_sample_data(),
headers = generate(dict, {
**options,
'depth': 1,
'data_types': { str, 'uuid' },
}),
payload_headers = generate(dict, {
**options,
'depth': 1,
'data_types': { str, 'uuid' },
}),
)

View File

@ -1,4 +1,22 @@
from .client import *
'''
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',

View File

@ -3,55 +3,10 @@ from scwrypts.env import getenv
from .. import get_request_client
REQUEST = None
COLLECTIONS = None
FIELDS = {}
def request(method, endpoint, **kwargs):
global REQUEST # pylint: disable=global-statement
if REQUEST is None:
REQUEST = get_request_client(
base_url = getenv("DIRECTUS__BASE_URL"),
headers = {
'Authorization': f'bearer {getenv("DIRECTUS__API_TOKEN")}',
}
)
return REQUEST(method, endpoint, **kwargs)
def graphql(query, system=False):
return request(
'POST',
'graphql' if system is True else 'graphql/system',
json={'query': query},
)
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
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]
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,16 @@
from types import SimpleNamespace
from pytest import fixture
from scwrypts.test import generate
from scwrypts.test.character_set import uri
from ..conftest import options, get_request_client_sample_data
@fixture(name='sample')
def fixture_sample():
return SimpleNamespace(
**get_request_client_sample_data(),
api_token = generate(str, options | {'character_set': uri}),
query = generate(str, options),
)

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

@ -1,2 +1,14 @@
from .client import *
from .send_message import *
'''
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

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

View File

@ -0,0 +1,25 @@
from string import ascii_letters, digits
from types import SimpleNamespace
from pytest import fixture
from scwrypts.test import generate
from scwrypts.test.character_set import uri
from ..conftest import options, 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, options | {'character_set': uri}),
username = generate(str, options | {'character_set': ascii_letters + digits}),
avatar_url = generate(str, options | {'character_set': uri}),
webhook = generate(str, options | {'character_set': uri}),
channel_id = generate(str, options | {'character_set': uri}),
content_header = generate(str, options),
content_footer = generate(str, options),
content = generate(str, options),
)

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

@ -1 +1,14 @@
from .client import *
'''
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

@ -2,20 +2,11 @@ from scwrypts.env import getenv
from .. import get_request_client
REQUEST = None
def request(method, endpoint, **kwargs):
global REQUEST # pylint: disable=global-statement
if REQUEST is None:
REQUEST = get_request_client(
base_url = 'https://api.linear.app',
headers = {
'Authorization': f'bearer {getenv("LINEAR__API_TOKEN")}',
}
)
return REQUEST(method, endpoint, **kwargs)
def graphql(query):
return request('POST', 'graphql', json={'query': query})
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,19 @@
from string import ascii_letters, digits
from types import SimpleNamespace
from pytest import fixture
from scwrypts.test import generate
from scwrypts.test.character_set import uri
from ..conftest import options, 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, options | {'character_set': uri}),
query = generate(str, options),
)

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

@ -5,6 +5,46 @@ 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+'}
@ -34,32 +74,6 @@ def get_stream(filename=None, mode='r', encoding='utf-8', verbose=False, **kwarg
stdout.flush()
def add_io_arguments(parser, allow_input=True, allow_output=True):
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_combined_stream(input_file=None, output_file=None):
with get_stream(input_file, 'r') as input_stream, get_stream(output_file, 'w+') as output_stream:
yield CombinedStream(input_stream, output_stream)
class CombinedStream:
def __init__(self, input_stream, output_stream):
self.input = input_stream

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

@ -14,3 +14,13 @@ class MissingFlagAndEnvironmentVariableError(EnvironmentError, ArgumentError):
class MissingScwryptsExecutableError(EnvironmentError):
def __init__(self):
super().__init__(f'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

@ -2,28 +2,46 @@ from os import getenv
from shutil import which
from subprocess import run
from .exceptions import MissingScwryptsExecutableError
from .exceptions import MissingScwryptsExecutableError, BadScwryptsLookupError, MissingScwryptsGroupOrTypeError
def scwrypts(name, group, _type, *args, log_level=None):
def scwrypts(*args, patterns=None, name=None, group=None, _type=None, log_level=None):
'''
invoke non-python scwrypts from python
top-level scwrypts invoker from python
- patterns allows for pattern-based scwrypt lookup
- name/group/type allos for precise-match lookup
*args should be a list of strings and is forwarded to the
invoked scwrypt
see 'scwrypts --help' for more information
'''
executable = which('scwrypts')
if executable is None:
raise MissingScwryptsExecutableError()
pre_args = ''
if patterns is None and name is None:
raise BadScwryptsLookupError()
pre_args = []
if name is None:
pre_args += patterns
else:
pre_args += ['--name', name, '--group', group, '--type', _type]
if group is None or _type is None:
raise MissingScwryptsGroupOrTypeError(group, _type)
if log_level is not None:
pre_args += '--log-level {log_level}'
pre_args += ['--log-level', log_level]
depth = getenv('SUBSCWRYPT', '')
if depth != '':
depth = int(depth) + 1
return run(
f'SUBSCWRYPT={depth} {executable} --name {name} --group {group} --type {_type} {pre_args} -- {" ".join(args)}',
f'SUBSCWRYPT={depth} {executable} {pre_args} -- {" ".join(args)}',
shell=True,
executable='/bin/zsh',
check=False,

View File

@ -1 +1,10 @@
from .random_generator import generate
'''
automated testing utilties, but primarily a random data generator
'''
__all__ = [
'generate',
]
from .generate import generate
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,372 @@
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 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

@ -1,240 +0,0 @@
from csv import writer, QUOTE_NONNUMERIC
from io import StringIO
from json import dumps
from random import randint, uniform, choice
from re import sub
from string import printable
from yaml import safe_dump
from .exceptions import NoDataTypeError, BadGeneratorTypeError
SUPPORTED_DATA_TYPES = None
DEFAULT_OPTIONS = {
'data_types': None,
'minimum': 0,
'maximum': 64,
'depth': 1,
'character_set': None,
'bool_nullable': False,
'str_length': None,
'str_minimum_length': 0,
'str_maximum_length': 32,
'list_length': 8,
'set_length': 8,
'dict_length': 8,
'dict_key_types': {int, float, chr, str},
'csv_columns': None,
'csv_columns_minimum': 1,
'csv_columns_maximum': 16,
'csv_rows': None,
'csv_rows_minimum': 2,
'csv_rows_maximum': 16,
'json_initial_type': dict,
'yaml_initial_type': dict,
}
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 = {**DEFAULT_OPTIONS}
else:
options = DEFAULT_OPTIONS | options
if data_type is None and options['data_types'] is None:
raise NoDataTypeError()
if data_type is None and options['data_types'] is not None:
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)
#####################################################################
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
@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):
length = options['str_length']
if length is None:
length = generate(int, {
'minimum': options['str_minimum_length'],
'maximum': options['str_maximum_length'],
})
return ''.join((generate(chr, options) for _ in range(length)))
@staticmethod
def _list(options):
if options['depth'] <= 0:
return []
options['depth'] -= 1
if options['data_types'] is None:
options['data_types'] = {bool, int, float, chr, str}
return [ generate(None, options) for _ in range(options['list_length']) ]
@staticmethod
def _set(options):
if options['depth'] <= 0:
return set()
options['depth'] -= 1
if options['data_types'] is None:
options['data_types'] = {bool, int, float, chr, str}
set_options = options | {'data_types': options['data_types'] - {list, dict, set}}
return { generate(None, set_options) for _ in range(options['set_length']) }
@staticmethod
def _dict(options):
if options['depth'] <= 0:
return {}
options['depth'] -= 1
if options['data_types'] is None:
options['data_types'] = {bool, int, float, chr, str, list, set, dict}
if len(options['data_types']) == 0:
return {}
key_options = options | {'data_types': options['dict_key_types']}
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['data_types'] is None:
options['data_types'] = {int, float, str}
columns = options['csv_columns']
if columns is None:
columns = max(1, generate(int, {
'minimum': options['csv_columns_minimum'],
'maximum': options['csv_columns_maximum'],
}))
rows = options['csv_rows']
if rows is None:
rows = max(1, generate(int, {
'minimum': options['csv_rows_minimum'],
'maximum': options['csv_rows_maximum'],
}))
if options['character_set'] is None:
options['character_set'] = printable
csv = StringIO()
csv_writer = writer(csv, quoting=QUOTE_NONNUMERIC)
options['list_length'] = columns
for line in [ generate(list, options) for _ in range(rows) ]:
csv_writer.writerow(line)
csv.seek(0)
return csv
@staticmethod
def _json(options):
'''
creates a str containing json data
'''
if options['data_types'] is None:
options['data_types'] = {bool, int, float, str, list, dict}
if options['character_set'] is None:
options['character_set'] = printable
options['dict_key_types'] = { int, float, str }
data = generate(options['json_initial_type'], options)
return dumps(data)
@staticmethod
def _yaml(options):
'''
creates a StringIO object containing yaml data
'''
if options['data_types'] is None:
options['data_types'] = {bool, int, float, str, list, dict}
if options['character_set'] is None:
options['character_set'] = printable
options['dict_key_types'] = { int, float, str }
yaml = StringIO()
safe_dump(generate(options['yaml_initial_type'], options), yaml, default_flow_style=False)
yaml.seek(0)
return yaml
#####################################################################
if __name__ == '__main__':
print(generate('json', {'depth': 3}))

View File

@ -1,14 +1,24 @@
from os import getenv
from pprint import pprint
from random import randint
from .random_generator import generate, Generator
from .generate import generate, Generator
ITERATIONS = int(
getenv(
'PYTEST_ITERATIONS__scwrypts__test__generator',
getenv('PYTEST_ITERATIONS', '99'), # CI should use at least 999
)
)
ITERATIONS = int(getenv('PYTEST_ITERATIONS__scwrypts__test__random_generator', getenv('PYTEST_ITERATIONS', '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)
@ -41,4 +51,4 @@ def test_generate_range_negative():
def test_generate_bool_nullable():
for data_type in Generator.get_supported_data_types():
generate(data_type, {'bool': {'nullable': True}})
generate(data_type, {'bool_nullable': True})

View File

@ -1,2 +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

25
run
View File

@ -327,6 +327,10 @@ source "${0:a:h}/zsh/lib/import.driver.zsh" || exit 42
|| LOGFILE='/dev/null' \
;
local RUN_MODE=normal
[[ $LOGFILE =~ ^/dev/null$ ]] && RUN_MODE=no-logfile
[[ $SCWRYPT_NAME =~ interactive ]] && RUN_MODE=interactive
local HEADER FOOTER
[[ $SCWRYPTS_LOG_LEVEL -ge 2 ]] && {
@ -358,13 +362,20 @@ source "${0:a:h}/zsh/lib/import.driver.zsh" || exit 42
set -o pipefail
{
[ $HEADER ] && echo $HEADER
[[ $LOGFILE =~ ^/dev/null$ ]] && {
eval "$RUN_STRING $(printf "%q " "$@")" </dev/tty >/dev/tty 2>&1
EXIT_CODE=$?
} || {
(eval "$RUN_STRING $(printf "%q " "$@")")
EXIT_CODE=$?
}
case $RUN_MODE in
normal )
(eval "$RUN_STRING $(printf "%q " "$@")")
EXIT_CODE=$?
;;
no-logfile )
eval "$RUN_STRING $(printf "%q " "$@")"
EXIT_CODE=$?
;;
interactive )
eval "$RUN_STRING $(printf "%q " "$@")" </dev/tty >/dev/tty 2>&1
EXIT_CODE=$?
;;
esac
[ $FOOTER ] && echo $FOOTER
[[ $EXIT_CODE -eq 0 ]] && EXIT_COLOR='32m' || EXIT_COLOR='31m'

View File

@ -9,7 +9,7 @@ FZF() {
FZF_ARGS+=(--height=50%)
FZF_ARGS+=(--layout=reverse)
local SELECTION=$(fzf ${FZF_ARGS[@]} --prompt "$1 : " ${@:2})
local SELECTION=$(fzf ${FZF_ARGS[@]} --prompt "$1 : " ${@:2} 2>/dev/tty)
PROMPT "$1"
[ $BE_QUIET ] || {

2
zx/lib/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist/
node_modules/

2
zx/lib/.prettierignore Normal file
View File

@ -0,0 +1,2 @@
dist
node_modules

99
zx/lib/package.json Normal file
View File

@ -0,0 +1,99 @@
{
"name": "scwrypts",
"main": "dist/index.js",
"type": "module",
"files": [
"dist"
],
"description": "scwrypts integration for typescript",
"scripts": {
"build": "rm -rf ./dist && tsc",
"test": "jest",
"lint": "eslint . && prettier --check src/",
"format": "prettier --write src/"
},
"author": "Wryn (yage) Wagner",
"license": "GPL-3.0",
"devDependencies": {
"@jest/globals": "^29.7.0",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.19",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"eslint": "^8.56.0",
"jest": "^29.7.0",
"prettier": "^3.2.5",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"uuid": "^9.0.1"
},
"eslintConfig": {
"ignorePatterns": [
"dist",
"node_modules"
],
"env": {
"node": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"overrides": [
{
"env": {
"node": true
},
"files": [
".eslintrc.{js,cjs}"
],
"parserOptions": {
"sourceType": "script"
}
}
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
],
"comma-dangle": [
"error",
"always-multiline"
]
}
},
"prettier": {
"printWidth": 120,
"singleQuote": true,
"trailingComma": "all"
},
"jest": {
"preset": "ts-jest",
"clearMocks": true,
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
}
},
"dependencies": {
"execa": "^8.0.1"
}
}

3163
zx/lib/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
zx/lib/src/index.ts Normal file
View File

@ -0,0 +1,5 @@
export * from './scwrypts/scwrypts.js';
export { ScwryptsLogLevel } from './scwrypts/types.js';
export type { ScwryptsOptions } from './scwrypts/types.js';

View File

@ -0,0 +1,116 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, expect, test, beforeEach, jest } from '@jest/globals';
import { v4 as uuid } from 'uuid';
import * as parseCLIArgs from './parse-cli-args.js';
import { getScwryptsLookup, Errors } from './get-scwrypts-lookup.js';
import type { ScwryptsOptions } from './types.js';
let sample: any;
beforeEach(() => {
sample = {
parsedCLIArgs: [uuid(), uuid(), uuid()],
spy: {},
};
sample.spy.parseCLIArgs = jest.spyOn(parseCLIArgs, 'parseCLIArgs');
sample.spy.parseCLIArgs.mockReturnValue(sample.parsedCLIArgs);
});
describe('exact', () => {
beforeEach(() => {
sample.exact = {
name: uuid(),
group: uuid(),
type: uuid(),
};
});
test('provides correct lookup', () => {
const lookup = getScwryptsLookup(sample.exact as ScwryptsOptions);
expect(lookup).toEqual({
method: 'exact',
...sample.exact,
});
});
describe('throws error', () => {
test('when missing group', () => {
delete sample.exact.group;
try {
getScwryptsLookup(sample.exact as ScwryptsOptions);
expect(true).toBeFalsy();
} catch (error) {
expect(error).toEqual(Errors.MissingScwryptsExactLookupParametersError);
}
});
test('when missing type', () => {
delete sample.exact.type;
try {
getScwryptsLookup(sample.exact as ScwryptsOptions);
expect(true).toBeFalsy();
} catch (error) {
expect(error).toEqual(Errors.MissingScwryptsExactLookupParametersError);
}
});
});
});
describe('patterns', () => {
describe('list', () => {
let lookup: any;
beforeEach(() => {
sample.patterns = {
patterns: [uuid(), uuid(), uuid()],
};
lookup = getScwryptsLookup(sample.patterns as ScwryptsOptions);
});
test('provides correct lookup', () => {
expect(lookup).toEqual({
method: 'patterns',
patterns: sample.parsedCLIArgs,
});
});
test('parses patterns', () => {
expect(sample.spy.parseCLIArgs).toHaveBeenCalledWith(sample.patterns.patterns);
});
});
describe('string', () => {
let lookup: any;
beforeEach(() => {
sample.patterns = {
patterns: uuid(),
};
lookup = getScwryptsLookup(sample.patterns as ScwryptsOptions);
});
test('provides correct lookup', () => {
expect(lookup).toEqual({
method: 'patterns',
patterns: sample.parsedCLIArgs,
});
});
test('parses patterns', () => {
expect(sample.spy.parseCLIArgs).toHaveBeenCalledWith(sample.patterns.patterns);
});
});
});
test('throws error when missing name and patterns', () => {
try {
getScwryptsLookup({} as ScwryptsOptions);
expect(true).toBeFalsy();
} catch (error) {
expect(error).toEqual(Errors.NoScwryptsLookupError);
}
});

View File

@ -0,0 +1,49 @@
import { parseCLIArgs } from './parse-cli-args.js';
import type { ScwryptsOptions } from './types.js';
export type ScwryptsLookupOptions =
| {
method: 'exact';
name: string;
group: string;
type: string;
}
| {
method: 'patterns';
patterns: string[];
};
export const Errors = {
NoScwryptsLookupError: {
name: 'NoScwryptsLookupError',
message: 'no scwrypts lookup parameters provided',
},
MissingScwryptsExactLookupParametersError: {
name: 'MissingScwryptsExactLookupParametersError',
message: '"name" option requires "group" and "type" options',
},
};
export const getScwryptsLookup = (options: ScwryptsOptions): ScwryptsLookupOptions => {
if (options.name === undefined) {
if (options.patterns === undefined || options.patterns.length === 0) {
throw Errors.NoScwryptsLookupError;
}
return {
method: 'patterns',
patterns: parseCLIArgs(options.patterns),
};
}
if (options.group === undefined || options.type === undefined) {
throw Errors.MissingScwryptsExactLookupParametersError;
}
return {
method: 'exact',
name: options.name,
group: options.group,
type: options.type,
};
};

View File

@ -0,0 +1,32 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, expect, test, beforeEach } from '@jest/globals';
import { v4 as uuid } from 'uuid';
import { parseCLIArgs } from './parse-cli-args.js';
let sample: any;
beforeEach(() => {
sample = {
args: [uuid(), uuid(), uuid()],
};
sample.argstring = sample.args.join(' ');
});
describe('undefined input', () => {
test('produces a string[]', () => {
expect(parseCLIArgs(undefined)).toEqual([]);
});
});
describe('string input', () => {
test('produces a string[]', () => {
expect(parseCLIArgs(sample.argstring)).toEqual(sample.args);
});
});
describe('string[] input', () => {
test('produces a string[]', () => {
expect(parseCLIArgs(sample.args)).toEqual(sample.args);
});
});

View File

@ -0,0 +1,12 @@
export type CLIArgs = string | string[] | undefined;
export const parseCLIArgs = (args: CLIArgs): string[] => {
switch (typeof args) {
case 'undefined':
return [];
case 'string':
return args.split(' ');
default:
return args;
}
};

View File

@ -0,0 +1,158 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, expect, test, beforeEach, jest } from '@jest/globals';
import { v4 as uuid } from 'uuid';
import { execa } from 'execa';
import * as Module_getScwryptsLookup from './get-scwrypts-lookup.js';
import * as Module_parseCLIArgs from './parse-cli-args.js';
import { ScwryptsLogLevel } from './types.js';
import { scwrypts } from './scwrypts.js';
jest.mock('execa', () => ({
execa: jest.fn(() => Promise.resolve()),
}));
const env = process.env;
beforeEach(() => {});
let sample: any;
beforeEach(() => {
sample = {
options: {
name: uuid(),
group: uuid(),
type: uuid(),
patterns: [uuid(), uuid(), uuid()],
log_level: Math.floor(Math.random() * Object.keys(ScwryptsLogLevel).length),
args: uuid(),
},
lookup: {
exact: {
method: 'exact',
name: uuid(),
group: uuid(),
type: uuid(),
},
patterns: {
method: 'patterns',
patterns: [uuid(), uuid(), uuid()],
},
},
env: {
SCWRYPTS_EXECUTABLE: uuid(),
},
parsedCLIArgs: [uuid(), uuid(), uuid()],
spy: {},
};
sample.spy.getScwryptsLookup = jest.spyOn(Module_getScwryptsLookup, 'getScwryptsLookup');
sample.spy.getScwryptsLookup.mockReturnValue(sample.lookup.exact);
sample.spy.parseCLIArgs = jest.spyOn(Module_parseCLIArgs, 'parseCLIArgs');
sample.spy.parseCLIArgs.mockReturnValue(sample.parsedCLIArgs);
jest.resetModules();
process.env = {
...env,
...sample.env,
};
});
afterEach(() => {
process.env = { ...env };
});
describe('exact lookup', () => {
let output;
beforeEach(async () => {
sample.spy.getScwryptsLookup.mockReturnValue(sample.lookup.exact);
output = await scwrypts(sample.options);
});
test('gets the correct lookup', () => {
expect(sample.spy.getScwryptsLookup).toHaveBeenCalledWith(sample.options);
});
test('parses arguments correctly', () => {
expect(sample.spy.parseCLIArgs).toHaveBeenCalledWith(sample.options.args);
});
test('calls the correct scwrypt', () => {
expect(execa).toHaveBeenCalledWith(sample.env.SCWRYPTS_EXECUTABLE, [
'--name',
sample.lookup.exact.name,
'--group',
sample.lookup.exact.group,
'--type',
sample.lookup.exact.type,
'--log-level',
sample.options.log_level.toString(),
'--',
...sample.parsedCLIArgs,
]);
});
});
describe('patterns lookup', () => {
beforeEach(async () => {
sample.spy.getScwryptsLookup.mockReturnValue(sample.lookup.patterns);
await scwrypts(sample.options);
});
test('gets the correct lookup', () => {
expect(sample.spy.getScwryptsLookup).toHaveBeenCalledWith(sample.options);
});
test('parses arguments correctly', () => {
expect(sample.spy.parseCLIArgs).toHaveBeenCalledWith(sample.options.args);
});
test('calls the correct scwrypt', () => {
expect(execa).toHaveBeenCalledWith(sample.env.SCWRYPTS_EXECUTABLE, [
...sample.lookup.patterns.patterns,
'--log-level',
sample.options.log_level.toString(),
'--',
...sample.parsedCLIArgs,
]);
});
});
test('omits --log-level arguments if not provided', async () => {
delete sample.options.log_level;
await scwrypts(sample.options);
expect(execa).toHaveBeenCalledWith(sample.env.SCWRYPTS_EXECUTABLE, [
'--name',
sample.lookup.exact.name,
'--group',
sample.lookup.exact.group,
'--type',
sample.lookup.exact.type,
'--',
...sample.parsedCLIArgs,
]);
});
test('uses default scwrypts executable SCWRYPTS_EXECUTABLE is not provided', async () => {
delete process.env.SCWRYPTS_EXECUTABLE;
await scwrypts(sample.options);
expect(execa).toHaveBeenCalledWith('scwrypts', [
'--name',
sample.lookup.exact.name,
'--group',
sample.lookup.exact.group,
'--type',
sample.lookup.exact.type,
'--log-level',
sample.options.log_level.toString(),
'--',
...sample.parsedCLIArgs,
]);
});

View File

@ -0,0 +1,31 @@
import { execa } from 'execa';
import { getScwryptsLookup } from './get-scwrypts-lookup.js';
import { parseCLIArgs } from './parse-cli-args.js';
import type { ScwryptsOptions } from './types.js';
export const scwrypts = async (options: ScwryptsOptions) => {
const lookup = getScwryptsLookup(options);
const scwryptsExecutableArgs: string[] = [];
switch (lookup.method) {
case 'exact':
scwryptsExecutableArgs.push('--name', lookup.name, '--group', lookup.group, '--type', lookup.type);
break;
case 'patterns':
scwryptsExecutableArgs.push(...lookup.patterns);
break;
}
if (options.log_level !== undefined) {
scwryptsExecutableArgs.push('--log-level', options.log_level.toString());
}
return await execa(process.env.SCWRYPTS_EXECUTABLE || 'scwrypts', [
...scwryptsExecutableArgs,
'--',
...parseCLIArgs(options.args),
]);
};

View File

@ -0,0 +1,16 @@
export type ScwryptsOptions = {
name: string | undefined;
group: string | undefined;
type: string | undefined;
patterns: string[] | undefined;
log_level: ScwryptsLogLevel | undefined;
args: string | string[] | undefined;
};
export enum ScwryptsLogLevel {
SILENT = 0,
QUIET = 1,
NORMAL = 2,
WARNING = 3,
DEBUG = 4,
}

18
zx/lib/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"module": "ES2022",
"moduleResolution": "node",
"target": "ES2022",
"lib": ["ES2022"],
"checkJs": true,
"declaration": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "extensions", "**/*.test.ts"]
}

9024
zx/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,11 +3,14 @@
"version": "1.0.0",
"description": "zx scripts for scwrypts",
"main": "index.js",
"type": "module",
"type": "module",
"scripts": {
"test": "",
"preinstall": "npm i -g zx"
"preinstall": "npm i -g zx"
},
"author": "yage",
"license": "GPL-3.0-or-later"
"license": "GPL-3.0-or-later",
"dependencies": {
"scwrypts": "file:lib"
}
}