From f236aef565c1f9c99dcf24979830b232ab6749bc Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Sat, 17 Apr 2021 14:06:10 +0300 Subject: rewrite in rust --- .gitignore | 5 + Cargo.lock | 349 +++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 14 ++ NEWS | 23 --- README | 45 ------ abook-to-clab | 47 ------ check | 40 ++++++ clab | 182 ----------------------- clab.md | 74 ++++++++++ clab.yarn | 133 ----------------- clablib/__init__.py | 1 - clablib/version.py | 2 - debian/changelog | 31 ---- debian/compat | 1 - debian/control | 18 --- debian/copyright | 23 --- debian/rules | 5 - debian/source/format | 1 - setup.py | 30 ---- src/lib.rs | 1 + src/main.rs | 189 ++++++++++++++++++++++++ subplot/clab.py | 17 +++ subplot/clab.yaml | 5 + subplot/vendor/files.py | 194 +++++++++++++++++++++++++ subplot/vendor/files.yaml | 83 +++++++++++ subplot/vendor/runcmd.py | 261 +++++++++++++++++++++++++++++++++ subplot/vendor/runcmd.yaml | 91 ++++++++++++ 27 files changed, 1323 insertions(+), 542 deletions(-) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml delete mode 100644 NEWS delete mode 100644 README delete mode 100755 abook-to-clab create mode 100755 check delete mode 100755 clab create mode 100644 clab.md delete mode 100644 clab.yarn delete mode 100644 clablib/__init__.py delete mode 100644 clablib/version.py delete mode 100644 debian/changelog delete mode 100644 debian/compat delete mode 100644 debian/control delete mode 100644 debian/copyright delete mode 100755 debian/rules delete mode 100644 debian/source/format delete mode 100644 setup.py create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 subplot/clab.py create mode 100644 subplot/clab.yaml create mode 100644 subplot/vendor/files.py create mode 100644 subplot/vendor/files.yaml create mode 100644 subplot/vendor/runcmd.py create mode 100644 subplot/vendor/runcmd.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b36c8f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +clab.html +clab.pdf +test.log +test.py +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ca7d2a8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,349 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "ansi_term" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +dependencies = [ + "winapi", +] + +[[package]] +name = "anyhow" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28b2cd92db5cbd74e8e5028f7e27dd7aa3090e89e4f2a197cc7c8dfb69c7063b" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clab" +version = "0.1.0" +dependencies = [ + "anyhow", + "directories-next", + "serde", + "serde_yaml", + "structopt", +] + +[[package]] +name = "clap" +version = "2.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" +dependencies = [ + "ansi_term", + "atty", + "bitflags", + "strsim", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dtoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0" + +[[package]] +name = "getrandom" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9495705279e7140bf035dde1f6e750c162df8b625267cd52cc44e0b156732c8" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "heck" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cbf45460356b7deeb5e3415b5563308c0a9b057c85e12b06ad551f98d0a6ac" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" +dependencies = [ + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9385f66bf6105b241aa65a61cb923ef20efc665cb9f9bb50ac2f0c4b7f378d41" + +[[package]] +name = "linked-hash-map" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a152013215dca273577e18d2bf00fa862b89b24169fb78c4c95aeb07992c9cec" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8270314b5ccceb518e7e578952f0b72b88222d02e8f77f5ecf7abbb673539041" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" +dependencies = [ + "getrandom", + "redox_syscall", +] + +[[package]] +name = "serde" +version = "1.0.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yaml" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15654ed4ab61726bf918a39cb8d98a2e2995b002387807fa6ba58fdf7f59bb23" +dependencies = [ + "dtoa", + "linked-hash-map", + "serde", + "yaml-rust", +] + +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "structopt" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5277acd7ee46e63e5168a80734c9f6ee81b1367a7d8772a2d765df2a3705d28c" +dependencies = [ + "clap", + "lazy_static", + "structopt-derive", +] + +[[package]] +name = "structopt-derive" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ba9cdfda491b814720b6b06e0cac513d922fc407582032e8706e9f137976f90" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fe99c6bd8b1cc636890bcc071842de909d902c81ac7dab53ba33c421ab8ffb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "unicode-segmentation" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" + +[[package]] +name = "unicode-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" + +[[package]] +name = "unicode-xid" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c61226c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "clab" +version = "0.1.0" +authors = ["Lars Wirzenius "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1" +directories-next = "2" +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.8" +structopt = "0.3" diff --git a/NEWS b/NEWS deleted file mode 100644 index ad83a42..0000000 --- a/NEWS +++ /dev/null @@ -1,23 +0,0 @@ -NEWS for clab -============= - -clab is a command line address book program. - -Version 0.4+git, not yet released ---------------------------------- - - -Version 0.4, released 2016-04-16 ------------------------------ - -* Multiline values (e.g., addrsses) with Unicode are now shown as - multiline values, not a single-line encoded value. - -Version 0.3, released 2016-01-08 --------------------------------- - -* First NEWS file entry. - -* Show results of `clab find` with pretty multiline strings, rather - than the default YAML formatting. - diff --git a/README b/README deleted file mode 100644 index eae853b..0000000 --- a/README +++ /dev/null @@ -1,45 +0,0 @@ -clab README -=========== - -This is a quick hack, for now. I will want to rewrite it from scratch -if it turns out to be a useful tool, and provide a test suite and -documentation and such. - -If you can decipher the code, feel free to use it. If not, do not -ask me about it. - ----- - -Random junk: - - address book command line aplication - - bbook list - bbook find PATTERN - bbook add KEY[.SUBKEY]=VALUE... - bbook edit ID KEY[.SUBKEY]=VALUE... - bbook delete ID... - bbook delete-key ID KEY[=VALUE]... - bbook show ID... - bbook mutt-query PATTERN - bbook add-from-email - - clab - - store data in a git repo, in YAML files, one file per person - - data model: - - each person is a YAML dict - - the dict has key/value pairs - - a value can be a straight string, or a sub-dict - - keys in the sub-dict are called sub-keys - - only one level of sub-dict (at least for now) - - dict and sub-dict keys are arbitrary - - questions: - - do I need a unique id for everyone? just filename? - - thoughts: - - I can edit files by hand at first, priority should be for mutt-query subcmd - so I can start using this for real asap - diff --git a/abook-to-clab b/abook-to-clab deleted file mode 100755 index f182972..0000000 --- a/abook-to-clab +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/python - - -import ConfigParser -import os -import yaml -import sys - - -records = {} - -for filename in sys.argv[1:]: - cp = ConfigParser.ConfigParser() - cp.read([filename]) - for section in cp.sections(): - if section == 'format': - continue - obj = {} - for option in cp.options(section): - assert option not in obj - v = cp.get(section, option, raw=True) - if ',' in v: - vs = v.split(',') - obj[option] = dict((str(i), x) for i, x in enumerate(vs)) - else: - obj[option] = v - - assert type(obj['name']) is str - uid = ''.join(obj['name'].split()).lower() - if uid in records: - r = records[uid] - for key in obj: - if key in r: - v = r[key] - if type(v) is dict: - v[str(len(v) + 1)] = obj[key] - else: - r[key] = { '0': v, '1': obj[key] } - else: - r[key] = obj[key] - else: - records[uid] = obj - -for uid, r in records.iteritems(): - with open(os.path.join('db', uid + '.yaml'), 'w') as f: - yaml.safe_dump(r, stream=f, default_flow_style=False) - diff --git a/check b/check new file mode 100755 index 0000000..bc01c0c --- /dev/null +++ b/check @@ -0,0 +1,40 @@ +#!/bin/sh +# +# Run the automated tests for the project. + +set -eu + +hideok=chronic +if [ "$#" -gt 0 ] +then + case "$1" in + verbose | -v | --verbose) + hideok= + shift + ;; + esac +fi + +got_cargo_cmd() +{ + cargo "$1" --help > /dev/null +} + +got_cargo_cmd clippy && cargo clippy -q --all-targets +$hideok cargo build --all-targets +got_cargo_cmd fmt && $hideok cargo fmt -- --check +$hideok cargo test + +subplot docgen clab.md -o clab.html +subplot docgen clab.md -o clab.pdf + +subplot codegen clab.md -o test.py +rm -f test.log +if [ "$(id -un)" = root ] +then + echo Not running tests as root. +else + $hideok python3 test.py --log test.log "$@" +fi + +echo "Everything seems to be in order." diff --git a/clab b/clab deleted file mode 100755 index a1272dc..0000000 --- a/clab +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/python -# -# Copyright 2013-2016 Lars Wirzenius -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - - -import cliapp -import logging -import os -import sys -import yaml - - -__version__ = '0.3' - - -class Entry(object): - - def __init__(self, parsed_yaml): - assert type(parsed_yaml) is dict, repr(parsed_yaml) - self._dict = parsed_yaml - - def as_yaml(self): - return yaml.dump( - self._dict, default_flow_style=False, allow_unicode=True) - - def get_single(self, key, default): - names = self.get_subdict(key) - if not names: - return default - keys = sorted(names.keys()) - return names[keys[0]] - - def get_subdict(self, key): - if key in self._dict: - v = self._dict[key] - if type(v) is dict: - return v - return {'': v} - return {} - - -class AddressBook(object): - - def __init__(self): - self.entries = [] - - def find_yaml_files(self, database_root): - for dirname, subdirs, basenames in os.walk(database_root): - subdirs.sort() - for basename in sorted(basenames): - if basename.endswith('.yaml'): - yield os.path.join(dirname, basename) - - def add_from_database(self, database_root): - logging.info('Adding from database %s' % database_root) - for yaml_filename in self.find_yaml_files(database_root): - self.add_from_file(yaml_filename) - - def add_from_file(self, filename): - logging.info('Adding from file %s' % filename) - with open(filename) as f: - parsed_yaml = yaml.safe_load(f) - if type(parsed_yaml) is list: - for parsed_entry in parsed_yaml: - entry = Entry(parsed_entry) - self.entries.append(entry) - else: - entry = Entry(parsed_yaml) - self.entries.append(entry) - - def find(self, patterns): - return [e for e in self.entries if self.matches(e, patterns)] - - def matches(self, entry, patterns): - s = entry.as_yaml().lower() - return any(p.lower() in s for p in patterns) - - -class CommandLineAddressBook(cliapp.Application): - - cmd_synopsis = { - 'list': '', - 'find': '[PATTERN...]', - 'mutt-query': '[PATTERN...]', - } - - def add_settings(self): - self.settings.string_list( - ['database', 'db', 'd'], - 'add DIR to list of databases to use', - metavar='DIR', - default=[os.path.expanduser('~/.local/share/clab')]) - - def setup(self): - # Configure yaml.dump (but not yaml.safe_dump) to print multiline - # strings prettier. - - def text_representer(dumper, data): - if '\n' in data: - return dumper.represent_scalar( - u'tag:yaml.org,2002:str', data, style='|') - return dumper.represent_scalar( - u'tag:yaml.org,2002:str', data, style='') - - yaml.add_representer(str, text_representer) - yaml.add_representer(unicode, text_representer) - - def load_address_book(self): - book = AddressBook() - for database in self.settings['database']: - book.add_from_database(database) - return book - - def cmd_list(self, args): - '''List all entries in the database. - - This lists all the entries. See the find subcommand for - searching specific entries. - - ''' - book = self.load_address_book() - for entry in book.entries: - self.output.write(entry.as_yaml() + '\n') - - def cmd_find(self, args): - '''List entries that match patterns. - - Each pattern is a fixed string (not a regular expression). - Matching is for any text in the entry. - - ''' - - book = self.load_address_book() - for entry in book.find(args): - self.output.write(entry.as_yaml() + '\n') - - def cmd_mutt_query(self, args): - '''Find entries for use with mutt. - - This is like the find subcommand, but output is formatted - so it's suitable for the mutt query hook. - - ''' - - if len(args) != 1: - raise cliapp.AppException( - 'mutt-query requires exactly one argument') - - book = self.load_address_book() - entries = book.find(args) - if not entries: - self.output.write('No matches\n') - sys.exit(1) - self.output.write('clab found matches:\n') - - name_addr_pairs = set() - for entry in entries: - name = entry.get_single('name', '') - emails = entry.get_subdict('email') - for email in emails: - name_addr_pairs.add((name, emails[email])) - - for name, addr in sorted(name_addr_pairs): - n = name.encode('utf-8') - a = addr.encode('utf-8') - self.output.write('%s\t%s\n' % (a, n)) - - -CommandLineAddressBook().run() diff --git a/clab.md b/clab.md new file mode 100644 index 0000000..9b2bef4 --- /dev/null +++ b/clab.md @@ -0,0 +1,74 @@ +# Introduction + +`clab` is a command line address book application. It has no +interactive features. This document collects its acceptance criteria. + +# Empty database + +~~~scenario +given an installed clab +when I run clab lint +then command is successful + +when I run clab search Alice +then command is successful +then stdout is exactly "" +~~~ + +# Alice and Bob + +Next, let's add records for Alice and Bob, and make +sure searches find only the right records. + + +~~~scenario +given an installed clab + +given file .local/share/clab/address-book.yaml from address-book.yaml + +when I run clab lint +then command is successful + +when I run clab list +then command is successful +then stdout is valid YAML +then stdout contains "Alice Atherthon" +then stdout contains "Bob Bobbington" + +when I run clab search Alice +then command is successful +then stdout is valid YAML +then stdout contains "Alice Atherthon" +then stdout doesn't contain "Bob" + +when I run clab mutt-query Alice +then command is successful +then stdout contains "Alice Atherthon" +then stdout contains "alice@example.com" +then stdout doesn't contain "Bob" +then stdout doesn't contain "bob@example.com" +~~~ + +~~~{#address-book.yaml .file .yaml} +- name: Alice Atherthon + email: + work: alice@example.com +- name: Bob Bobbington + email: + personal: bob@example.com +~~~ + + +--- +title: "clab; – command line address book" +author: Lars Wirzenius +template: python +bindings: +- subplot/clab.yaml +- subplot/vendor/files.yaml +- subplot/vendor/runcmd.yaml +functions: +- subplot/clab.py +- subplot/vendor/files.py +- subplot/vendor/runcmd.py +... diff --git a/clab.yarn b/clab.yarn deleted file mode 100644 index 745eb09..0000000 --- a/clab.yarn +++ /dev/null @@ -1,133 +0,0 @@ -Black box tests for clab -======================== - -Clab is a command line address book application. -It has no interactive features, so black box testing it -is fairly easy. - -Let's start with an empty database. - - SCENARIO empty database - GIVEN an empty database - - WHEN listing all records - THEN nothing is listed - - WHEN searching for Alice - THEN output is empty - - WHEN mutt-querying for Alice - THEN no matches - -Next, let's add records for Alice and Bob, and make -sure searches find only the right records. - - SCENARIO database with records - GIVEN an empty database - AND a record for "Alice Atherton" with e-mail alice@example.com - AND a record for "Bob Bobbington" with e-mail bob@example.com - - WHEN listing all records - THEN Alice is found - AND Bob is found - - WHEN searching for Alice - THEN Alice is found - AND Bob is not found - - WHEN mutt-querying for Alice - THEN Alice is found - AND alice@example.com is found - AND Bob is not found - AND bob@example.com is not found - -Put several records in one file. - - SCENARIO files with multiple records - GIVEN an empty database - AND records for Alice (alice@example.com) and Bob (bob@example.com) - WHEN listing all records - THEN Alice is found - AND Bob is found - - WHEN searching for Alice - THEN Alice is found - AND Bob is not found - - WHEN mutt-querying for Alice - THEN Alice is found - AND alice@example.com is found - AND Bob is not found - AND bob@example.com is not found - -Sometimes the same person is in the database multiple times -(e.g., different records for home and work personas, where the -work persona is automatically generated from LDAP or something). -In this case, only find each person once. - - GIVEN another record for Alice (alice@example.com) - WHEN mutt-querying for example - THEN Alice is found only once - -Implementation --------------- - -These implement the various steps. - - IMPLEMENTS GIVEN an empty database - mkdir "$DATADIR/db" - - IMPLEMENTS GIVEN a record for "([^"]+)" with e-mail (.*) - cat << EOF > "$DATADIR/db/$MATCH_1.yaml" - name: $MATCH_1 - email: $MATCH_2 - EOF - - IMPLEMENTS GIVEN records for (\S+) \((\S+)\) and (\S+) \((\S+)\) - cat << EOF > "$DATADIR/db/foo.yaml" - - name: $MATCH_1 - email: $MATCH_2 - - name: $MATCH_3 - email: $MATCH_4 - EOF - - IMPLEMENTS GIVEN another record for (\S+) \((\S+)\) - cat << EOF >> "$DATADIR/db/foo.yaml" - - name: $MATCH_1 - email: $MATCH_2 - EOF - - IMPLEMENTS WHEN listing all records - ./clab --no-default-config --db "$DATADIR/db" list > "$DATADIR/output" - - IMPLEMENTS WHEN searching for (.*) - ./clab --no-default-config --db "$DATADIR/db" find "$MATCH_1" > "$DATADIR/output" - - IMPLEMENTS WHEN mutt-querying for (.*) - ./clab --no-default-config --db "$DATADIR/db" mutt-query "$MATCH_1" > "$DATADIR/output" || true - - IMPLEMENTS THEN nothing is listed - stat -c %s "$DATADIR/output" | grep -x 0 - - IMPLEMENTS THEN output is empty - stat -c %s "$DATADIR/output" | grep -x 0 - - IMPLEMENTS THEN no matches - diff -u "$DATADIR/output" - << EOF - No matches - EOF - - IMPLEMENTS THEN (.*) is found - set -x - grep -F "$MATCH_1" "$DATADIR/output" - - IMPLEMENTS THEN (.*) is found only once - n=$(grep -cF "$MATCH_1" "$DATADIR/output") - if [ "$n" != 1 ] - then - echo "$MATCH_1 was found $n times, expected 1" 1>&2 - exit 1 - fi - - IMPLEMENTS THEN (.*) is not found - ! grep -F "$MATCH_1" "$DATADIR/output" diff --git a/clablib/__init__.py b/clablib/__init__.py deleted file mode 100644 index 03a5053..0000000 --- a/clablib/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .version import __version__, __version_info__ diff --git a/clablib/version.py b/clablib/version.py deleted file mode 100644 index 43a4b73..0000000 --- a/clablib/version.py +++ /dev/null @@ -1,2 +0,0 @@ -__version__ = "0.4+git" -__version_info__ = (0, 4, '+git') diff --git a/debian/changelog b/debian/changelog deleted file mode 100644 index 8563324..0000000 --- a/debian/changelog +++ /dev/null @@ -1,31 +0,0 @@ -clab (0.4+git-1) UNRELEASED; urgency=medium - - * New upstream version. - * Require Python 2.7. - - -- Lars Wirzenius Sat, 16 Apr 2016 16:49:15 +0300 - -clab (0.4-1) unstable; urgency=medium - - * - - -- Lars Wirzenius Sat, 16 Apr 2016 16:49:14 +0300 - -clab (0.3-1) unstable; urgency=medium - - * New upstream release. - - -- Lars Wirzenius Fri, 08 Jan 2016 20:30:10 +0200 - -clab (0.2-1) unstable; urgency=medium - - * New version to bump build for jessie. - - -- Lars Wirzenius Sat, 25 Jul 2015 14:35:22 +0300 - -clab (0.1-1) unstable; urgency=low - - * Initial packaging. This is not intended to be uploaded to Debian, so - no closing of an ITP bug. - - -- Lars Wirzenius Sat, 05 Oct 2013 14:38:33 +0100 diff --git a/debian/compat b/debian/compat deleted file mode 100644 index ec63514..0000000 --- a/debian/compat +++ /dev/null @@ -1 +0,0 @@ -9 diff --git a/debian/control b/debian/control deleted file mode 100644 index 7a83f3e..0000000 --- a/debian/control +++ /dev/null @@ -1,18 +0,0 @@ -Source: clab -Maintainer: Lars Wirzenius -Section: python -Priority: optional -Standards-Version: 3.9.8 -Build-Depends: debhelper (>= 9), python-all (>= 2.7~) -X-Python-Version: >= 2.7 - -Package: clab -Architecture: all -Depends: ${python:Depends}, ${misc:Depends}, python (>= 2.7) -Description: command line address book application - A very basic address book application, with a command line interface - only. - . - Address book data is stored in YAML files, which can easily be - kept in a version control system by the user. -Homepage: http://liw.fi/clab/ diff --git a/debian/copyright b/debian/copyright deleted file mode 100644 index df2f234..0000000 --- a/debian/copyright +++ /dev/null @@ -1,23 +0,0 @@ -Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: clab -Upstream-Contact: Lars Wirzenius -Source: git://git.gitano.org.uk/personal/liw/clab - -Files: * -Copyright: 2013, Lars Wirzenius -License: GPL-3+ - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - . - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - . - You should have received a copy of the GNU General Public License - along with this program. If not, see . - . - On a Debian system, you can find a copy of GPL version 3 at - /usr/share/common-licenses/GPL-3 . diff --git a/debian/rules b/debian/rules deleted file mode 100755 index abde6ef..0000000 --- a/debian/rules +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/make -f - -%: - dh $@ - diff --git a/debian/source/format b/debian/source/format deleted file mode 100644 index 163aaf8..0000000 --- a/debian/source/format +++ /dev/null @@ -1 +0,0 @@ -3.0 (quilt) diff --git a/setup.py b/setup.py deleted file mode 100644 index c559c34..0000000 --- a/setup.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/python -# Copyright (C) 2013-2016 Lars Wirzenius -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -from distutils.core import setup - -import clablib - -setup( - name='clab', - version=clablib.__version__, - description='command line address book', - author='Lars Wirzenius', - author_email='liw@liw.fi', - url='http://liw.fi/clab/', - scripts=['clab'], -) diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1 @@ + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..08fc655 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,189 @@ +use directories_next::ProjectDirs; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use structopt::StructOpt; + +const APP: &str = "clab"; + +fn main() -> anyhow::Result<()> { + let mut opt = Opt::from_args(); + let book = if let Some(filename) = &opt.db { + AddressBook::load(filename)? + } else { + let proj_dirs = ProjectDirs::from("", "", APP).expect("couldn't find home directory"); + let filename = proj_dirs.data_dir().join("address-book.yaml"); + opt.db = Some(filename.clone()); + if filename.exists() { + AddressBook::load(&filename)? + } else { + AddressBook::default() + } + }; + match &opt.cmd { + Cmd::Config(x) => x.run(&opt, &book), + Cmd::Lint(x) => x.run(&opt, &book), + Cmd::List(x) => x.run(&opt, &book)?, + Cmd::Search(x) => x.run(&opt, &book)?, + Cmd::MuttQuery(x) => x.run(&opt, &book), + } + Ok(()) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Entry { + name: String, + org: Option, + url: Option>, + notes: Option, + aliases: Option>, + email: Option>, + phone: Option>, + irc: Option>, + address: Option>, +} + +impl Entry { + fn is_match(&self, needle: &str) -> bool { + contains(&self.name, needle) + } + + fn emails(&self) -> Vec { + if let Some(map) = &self.email { + map.values().map(|x| x.to_string()).collect() + } else { + vec![] + } + } +} + +fn output_entries(entries: &[Entry]) -> anyhow::Result<()> { + if !entries.is_empty() { + serde_yaml::to_writer(std::io::stdout(), entries)?; + } + Ok(()) +} + +fn contains(haystack: &str, needle: &str) -> bool { + let haystack = haystack.to_lowercase(); + let needle = needle.to_lowercase(); + haystack.contains(&needle) +} + +#[derive(std::default::Default)] +struct AddressBook { + entries: Vec, +} + +impl AddressBook { + fn load(db: &Path) -> anyhow::Result { + let mut book = Self::default(); + book.add_from(db)?; + Ok(book) + } + + fn add_from(&mut self, filename: &Path) -> anyhow::Result<()> { + let text = std::fs::read(&filename)?; + let mut entries: Vec = serde_yaml::from_slice(&text)?; + self.entries.append(&mut entries); + Ok(()) + } + + fn entries(&self) -> &[Entry] { + &self.entries + } + + fn iter(&self) -> impl Iterator { + self.entries.iter() + } +} + +#[derive(Debug, StructOpt)] +struct Opt { + #[structopt(long, parse(from_os_str))] + db: Option, + + #[structopt(subcommand)] + cmd: Cmd, +} + +#[derive(Debug, StructOpt)] +enum Cmd { + Config(ConfigCommand), + Lint(LintCommand), + List(ListCommand), + Search(SearchCommand), + MuttQuery(MuttCommand), +} + +#[derive(Debug, StructOpt)] +struct ConfigCommand {} + +impl ConfigCommand { + fn run(&self, opt: &Opt, _book: &AddressBook) { + println!("{:#?}", opt); + } +} + +#[derive(Debug, StructOpt)] +struct LintCommand { + #[structopt(parse(from_os_str))] + filenames: Vec, +} + +impl LintCommand { + fn run(&self, _opt: &Opt, _book: &AddressBook) {} +} + +#[derive(Debug, StructOpt)] +struct ListCommand {} + +impl ListCommand { + fn run(&self, _opt: &Opt, book: &AddressBook) -> anyhow::Result<()> { + output_entries(book.entries()) + } +} + +#[derive(Debug, StructOpt)] +#[structopt(alias = "find")] +struct SearchCommand { + #[structopt()] + words: Vec, +} + +impl SearchCommand { + fn run(&self, _opt: &Opt, book: &AddressBook) -> anyhow::Result<()> { + let matches: Vec = book.iter().filter(|e| self.is_match(e)).cloned().collect(); + output_entries(&matches) + } + + fn is_match(&self, entry: &Entry) -> bool { + for word in self.words.iter() { + if !entry.is_match(word) { + return false; + } + } + true + } +} + +#[derive(Debug, StructOpt)] +struct MuttCommand { + #[structopt()] + word: String, +} + +impl MuttCommand { + fn run(&self, _opt: &Opt, book: &AddressBook) { + for e in book.iter().filter(|e| self.is_match(e)) { + for email in e.emails() { + println!("{}\t{}", e.name, email); + } + } + } + + fn is_match(&self, entry: &Entry) -> bool { + entry.is_match(&self.word) + } +} diff --git a/subplot/clab.py b/subplot/clab.py new file mode 100644 index 0000000..94b2e51 --- /dev/null +++ b/subplot/clab.py @@ -0,0 +1,17 @@ +import io +import os +import yaml + + +def install_clab(ctx): + runcmd_prepend_to_path = globals()["runcmd_prepend_to_path"] + srcdir = globals()["srcdir"] + + # Add the directory with built Rust binaries to the path. + runcmd_prepend_to_path(ctx, dirname=os.path.join(srcdir, "target", "debug")) + + +def stdout_is_yaml(ctx): + runcmd_get_stdout = globals()["runcmd_get_stdout"] + stdout = runcmd_get_stdout(ctx) + yaml.safe_load(io.StringIO(stdout)) diff --git a/subplot/clab.yaml b/subplot/clab.yaml new file mode 100644 index 0000000..7e013ff --- /dev/null +++ b/subplot/clab.yaml @@ -0,0 +1,5 @@ +- given: "an installed clab" + function: install_clab + +- then: "stdout is valid YAML" + function: stdout_is_yaml diff --git a/subplot/vendor/files.py b/subplot/vendor/files.py new file mode 100644 index 0000000..d3b96fc --- /dev/null +++ b/subplot/vendor/files.py @@ -0,0 +1,194 @@ +import logging +import os +import re +import shutil +import time + + +def files_create_from_embedded(ctx, filename=None): + files_make_directory(ctx, path=os.path.dirname(filename) or ".") + files_create_from_embedded_with_other_name( + ctx, filename_on_disk=filename, embedded_filename=filename + ) + + +def files_create_from_embedded_with_other_name( + ctx, filename_on_disk=None, embedded_filename=None +): + get_file = globals()["get_file"] + + files_make_directory(ctx, path=os.path.dirname(filename_on_disk) or ".") + with open(filename_on_disk, "wb") as f: + f.write(get_file(embedded_filename)) + + +def files_create_from_text(ctx, filename=None, text=None): + files_make_directory(ctx, path=os.path.dirname(filename) or ".") + with open(filename, "w") as f: + f.write(text) + + +def files_make_directory(ctx, path=None): + path = "./" + path + if not os.path.exists(path): + os.makedirs(path) + + +def files_remove_directory(ctx, path=None): + path = "./" + path + shutil.rmtree(path) + + +def files_file_exists(ctx, filename=None): + assert_eq = globals()["assert_eq"] + assert_eq(os.path.exists(filename), True) + + +def files_file_does_not_exist(ctx, filename=None): + assert_eq = globals()["assert_eq"] + assert_eq(os.path.exists(filename), False) + + +def files_directory_exists(ctx, path=None): + assert_eq = globals()["assert_eq"] + assert_eq(os.path.isdir(path), True) + + +def files_directory_does_not_exist(ctx, path=None): + assert_eq = globals()["assert_eq"] + assert_eq(os.path.isdir(path), False) + + +def files_directory_is_empty(ctx, path=None): + assert_eq = globals()["assert_eq"] + assert_eq(os.listdir(path), []) + + +def files_directory_is_not_empty(ctx, path=None): + assert_ne = globals()["assert_ne"] + assert_ne(os.listdir(path), False) + + +def files_only_these_exist(ctx, filenames=None): + assert_eq = globals()["assert_eq"] + filenames = filenames.replace(",", "").split() + assert_eq(set(os.listdir(".")), set(filenames)) + + +def files_file_contains(ctx, filename=None, data=None): + assert_eq = globals()["assert_eq"] + with open(filename, "rb") as f: + actual = f.read() + actual = actual.decode("UTF-8") + assert_eq(data in actual, True) + + +def files_file_matches_regex(ctx, filename=None, regex=None): + assert_eq = globals()["assert_eq"] + with open(filename) as f: + content = f.read() + m = re.search(regex, content) + if m is None: + logging.debug(f"files_file_matches_regex: no match") + logging.debug(f" filenamed: {filename}") + logging.debug(f" regex: {regex}") + logging.debug(f" content: {content}") + logging.debug(f" match: {m}") + assert_eq(bool(m), True) + + +def files_match(ctx, filename1=None, filename2=None): + assert_eq = globals()["assert_eq"] + with open(filename1, "rb") as f: + data1 = f.read() + with open(filename2, "rb") as f: + data2 = f.read() + assert_eq(data1, data2) + + +def files_touch_with_timestamp( + ctx, + filename=None, + year=None, + month=None, + day=None, + hour=None, + minute=None, + second=None, +): + t = ( + int(year), + int(month), + int(day), + int(hour), + int(minute), + int(second), + -1, + -1, + -1, + ) + ts = time.mktime(t) + _files_touch(filename, ts) + + +def files_touch(ctx, filename=None): + _files_touch(filename, None) + + +def _files_touch(filename, ts): + if not os.path.exists(filename): + open(filename, "w").close() + times = None + if ts is not None: + times = (ts, ts) + os.utime(filename, times=times) + + +def files_mtime_is_recent(ctx, filename=None): + st = os.stat(filename) + age = abs(st.st_mtime - time.time()) + assert age < 1.0 + + +def files_mtime_is_ancient(ctx, filename=None): + st = os.stat(filename) + age = abs(st.st_mtime - time.time()) + year = 365 * 24 * 60 * 60 + required = 39 * year + logging.debug(f"ancient? mtime={st.st_mtime} age={age} required={required}") + assert age > required + + +def files_remember_metadata(ctx, filename=None): + meta = _files_remembered(ctx) + meta[filename] = _files_get_metadata(filename) + logging.debug("files_remember_metadata:") + logging.debug(f" meta: {meta}") + logging.debug(f" ctx: {ctx}") + + +# Check that current metadata of a file is as stored in the context. +def files_has_remembered_metadata(ctx, filename=None): + assert_eq = globals()["assert_eq"] + meta = _files_remembered(ctx) + logging.debug("files_has_remembered_metadata:") + logging.debug(f" meta: {meta}") + logging.debug(f" ctx: {ctx}") + assert_eq(meta[filename], _files_get_metadata(filename)) + + +def files_has_different_metadata(ctx, filename=None): + assert_ne = globals()["assert_ne"] + meta = _files_remembered(ctx) + assert_ne(meta[filename], _files_get_metadata(filename)) + + +def _files_remembered(ctx): + ns = ctx.declare("_files") + return ns.get("remembered-metadata", {}) + + +def _files_get_metadata(filename): + st = os.lstat(filename) + keys = ["st_dev", "st_gid", "st_ino", "st_mode", "st_mtime", "st_size", "st_uid"] + return {key: getattr(st, key) for key in keys} diff --git a/subplot/vendor/files.yaml b/subplot/vendor/files.yaml new file mode 100644 index 0000000..f18b8cd --- /dev/null +++ b/subplot/vendor/files.yaml @@ -0,0 +1,83 @@ +- given: file {filename} + function: files_create_from_embedded + types: + filename: file + +- given: file {filename_on_disk} from {embedded_filename} + function: files_create_from_embedded_with_other_name + types: + embedded_filename: file + +- given: file {filename} has modification time {year}-{month}-{day} {hour}:{minute}:{second} + function: files_touch_with_timestamp + +- when: I write "(?P.*)" to file (?P\S+) + regex: true + function: files_create_from_text + +- when: I remember metadata for file {filename} + function: files_remember_metadata + +- when: I touch file {filename} + function: files_touch + +- then: file {filename} exists + function: files_file_exists + +- then: file {filename} does not exist + function: files_file_does_not_exist + +- then: only files (?P.+) exist + function: files_only_these_exist + regex: true + +- then: file (?P\S+) contains "(?P.*)" + regex: true + function: files_file_contains + +- then: file (?P\S+) matches regex /(?P.*)/ + regex: true + function: files_file_matches_regex + +- then: file (?P\S+) matches regex "(?P.*)" + regex: true + function: files_file_matches_regex + +- then: files {filename1} and {filename2} match + function: files_match + +- then: file {filename} has same metadata as before + function: files_has_remembered_metadata + +- then: file {filename} has different metadata from before + function: files_has_different_metadata + +- then: file {filename} has changed from before + function: files_has_different_metadata + +- then: file {filename} has a very recent modification time + function: files_mtime_is_recent + +- then: file {filename} has a very old modification time + function: files_mtime_is_ancient + +- given: a directory {path} + function: files_make_directory + +- when: I create directory {path} + function: files_make_directory + +- when: I remove directory {path} + function: files_remove_directory + +- then: directory {path} exists + function: files_directory_exists + +- then: directory {path} does not exist + function: files_directory_does_not_exist + +- then: directory {path} is empty + function: files_directory_is_empty + +- then: directory {path} is not empty + function: files_directory_is_not_empty diff --git a/subplot/vendor/runcmd.py b/subplot/vendor/runcmd.py new file mode 100644 index 0000000..cc4fd38 --- /dev/null +++ b/subplot/vendor/runcmd.py @@ -0,0 +1,261 @@ +import logging +import os +import re +import shlex +import subprocess + + +# +# Helper functions. +# + +# Get exit code or other stored data about the latest command run by +# runcmd_run. + + +def _runcmd_get(ctx, name): + ns = ctx.declare("_runcmd") + return ns[name] + + +def runcmd_get_exit_code(ctx): + return _runcmd_get(ctx, "exit") + + +def runcmd_get_stdout(ctx): + return _runcmd_get(ctx, "stdout") + + +def runcmd_get_stdout_raw(ctx): + return _runcmd_get(ctx, "stdout.raw") + + +def runcmd_get_stderr(ctx): + return _runcmd_get(ctx, "stderr") + + +def runcmd_get_stderr_raw(ctx): + return _runcmd_get(ctx, "stderr.raw") + + +def runcmd_get_argv(ctx): + return _runcmd_get(ctx, "argv") + + +# Run a command, given an argv and other arguments for subprocess.Popen. +# +# This is meant to be a helper function, not bound directly to a step. The +# stdout, stderr, and exit code are stored in the "_runcmd" namespace in the +# ctx context. +def runcmd_run(ctx, argv, **kwargs): + ns = ctx.declare("_runcmd") + + # The Subplot Python template empties os.environ at startup, modulo a small + # number of variables with carefully chosen values. Here, we don't need to + # care about what those variables are, but we do need to not overwrite + # them, so we just add anything in the env keyword argument, if any, to + # os.environ. + env = dict(os.environ) + for key, arg in kwargs.pop("env", {}).items(): + env[key] = arg + + pp = ns.get("path-prefix") + if pp: + env["PATH"] = pp + ":" + env["PATH"] + + logging.debug(f"runcmd_run") + logging.debug(f" argv: {argv}") + logging.debug(f" env: {env}") + p = subprocess.Popen( + argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, **kwargs + ) + stdout, stderr = p.communicate("") + ns["argv"] = argv + ns["stdout.raw"] = stdout + ns["stderr.raw"] = stderr + ns["stdout"] = stdout.decode("utf-8") + ns["stderr"] = stderr.decode("utf-8") + ns["exit"] = p.returncode + logging.debug(f" ctx: {ctx}") + logging.debug(f" ns: {ns}") + + +# Step: prepend srcdir to PATH whenever runcmd runs a command. +def runcmd_helper_srcdir_path(ctx): + srcdir = globals()["srcdir"] + runcmd_prepend_to_path(ctx, srcdir) + + +# Step: This creates a helper script. +def runcmd_helper_script(ctx, filename=None): + get_file = globals()["get_file"] + with open(filename, "wb") as f: + f.write(get_file(filename)) + + +# +# Step functions for running commands. +# + + +def runcmd_prepend_to_path(ctx, dirname=None): + ns = ctx.declare("_runcmd") + pp = ns.get("path-prefix", "") + if pp: + pp = f"{pp}:{dirname}" + else: + pp = dirname + ns["path-prefix"] = pp + + +def runcmd_step(ctx, argv0=None, args=None): + runcmd_try_to_run(ctx, argv0=argv0, args=args) + runcmd_exit_code_is_zero(ctx) + + +def runcmd_step_in(ctx, dirname=None, argv0=None, args=None): + runcmd_try_to_run_in(ctx, dirname=dirname, argv0=argv0, args=args) + runcmd_exit_code_is_zero(ctx) + + +def runcmd_try_to_run(ctx, argv0=None, args=None): + runcmd_try_to_run_in(ctx, dirname=None, argv0=argv0, args=args) + + +def runcmd_try_to_run_in(ctx, dirname=None, argv0=None, args=None): + argv = [shlex.quote(argv0)] + shlex.split(args) + runcmd_run(ctx, argv, cwd=dirname) + + +# +# Step functions for examining exit codes. +# + + +def runcmd_exit_code_is_zero(ctx): + runcmd_exit_code_is(ctx, exit=0) + + +def runcmd_exit_code_is(ctx, exit=None): + assert_eq = globals()["assert_eq"] + assert_eq(runcmd_get_exit_code(ctx), int(exit)) + + +def runcmd_exit_code_is_nonzero(ctx): + runcmd_exit_code_is_not(ctx, exit=0) + + +def runcmd_exit_code_is_not(ctx, exit=None): + assert_ne = globals()["assert_ne"] + assert_ne(runcmd_get_exit_code(ctx), int(exit)) + + +# +# Step functions and helpers for examining output in various ways. +# + + +def runcmd_stdout_is(ctx, text=None): + _runcmd_output_is(runcmd_get_stdout(ctx), text) + + +def runcmd_stdout_isnt(ctx, text=None): + _runcmd_output_isnt(runcmd_get_stdout(ctx), text) + + +def runcmd_stderr_is(ctx, text=None): + _runcmd_output_is(runcmd_get_stderr(ctx), text) + + +def runcmd_stderr_isnt(ctx, text=None): + _runcmd_output_isnt(runcmd_get_stderr(ctx), text) + + +def _runcmd_output_is(actual, wanted): + assert_eq = globals()["assert_eq"] + wanted = bytes(wanted, "utf8").decode("unicode_escape") + logging.debug("_runcmd_output_is:") + logging.debug(f" actual: {actual!r}") + logging.debug(f" wanted: {wanted!r}") + assert_eq(actual, wanted) + + +def _runcmd_output_isnt(actual, wanted): + assert_ne = globals()["assert_ne"] + wanted = bytes(wanted, "utf8").decode("unicode_escape") + logging.debug("_runcmd_output_isnt:") + logging.debug(f" actual: {actual!r}") + logging.debug(f" wanted: {wanted!r}") + assert_ne(actual, wanted) + + +def runcmd_stdout_contains(ctx, text=None): + _runcmd_output_contains(runcmd_get_stdout(ctx), text) + + +def runcmd_stdout_doesnt_contain(ctx, text=None): + _runcmd_output_doesnt_contain(runcmd_get_stdout(ctx), text) + + +def runcmd_stderr_contains(ctx, text=None): + _runcmd_output_contains(runcmd_get_stderr(ctx), text) + + +def runcmd_stderr_doesnt_contain(ctx, text=None): + _runcmd_output_doesnt_contain(runcmd_get_stderr(ctx), text) + + +def _runcmd_output_contains(actual, wanted): + assert_eq = globals()["assert_eq"] + wanted = bytes(wanted, "utf8").decode("unicode_escape") + logging.debug("_runcmd_output_contains:") + logging.debug(f" actual: {actual!r}") + logging.debug(f" wanted: {wanted!r}") + assert_eq(wanted in actual, True) + + +def _runcmd_output_doesnt_contain(actual, wanted): + assert_ne = globals()["assert_ne"] + wanted = bytes(wanted, "utf8").decode("unicode_escape") + logging.debug("_runcmd_output_doesnt_contain:") + logging.debug(f" actual: {actual!r}") + logging.debug(f" wanted: {wanted!r}") + assert_ne(wanted in actual, True) + + +def runcmd_stdout_matches_regex(ctx, regex=None): + _runcmd_output_matches_regex(runcmd_get_stdout(ctx), regex) + + +def runcmd_stdout_doesnt_match_regex(ctx, regex=None): + _runcmd_output_doesnt_match_regex(runcmd_get_stdout(ctx), regex) + + +def runcmd_stderr_matches_regex(ctx, regex=None): + _runcmd_output_matches_regex(runcmd_get_stderr(ctx), regex) + + +def runcmd_stderr_doesnt_match_regex(ctx, regex=None): + _runcmd_output_doesnt_match_regex(runcmd_get_stderr(ctx), regex) + + +def _runcmd_output_matches_regex(actual, regex): + assert_ne = globals()["assert_ne"] + r = re.compile(regex) + m = r.search(actual) + logging.debug("_runcmd_output_matches_regex:") + logging.debug(f" actual: {actual!r}") + logging.debug(f" regex: {regex!r}") + logging.debug(f" match: {m}") + assert_ne(m, None) + + +def _runcmd_output_doesnt_match_regex(actual, regex): + assert_eq = globals()["assert_eq"] + r = re.compile(regex) + m = r.search(actual) + logging.debug("_runcmd_output_doesnt_match_regex:") + logging.debug(f" actual: {actual!r}") + logging.debug(f" regex: {regex!r}") + logging.debug(f" match: {m}") + assert_eq(m, None) diff --git a/subplot/vendor/runcmd.yaml b/subplot/vendor/runcmd.yaml new file mode 100644 index 0000000..a5119d8 --- /dev/null +++ b/subplot/vendor/runcmd.yaml @@ -0,0 +1,91 @@ +# Steps to run commands. + +- given: helper script {filename} for runcmd + function: runcmd_helper_script + +- given: srcdir is in the PATH + function: runcmd_helper_srcdir_path + +- when: I run (?P\S+)(?P.*) + regex: true + function: runcmd_step + +- when: I run, in (?P\S+), (?P\S+)(?P.*) + regex: true + function: runcmd_step_in + +- when: I try to run (?P\S+)(?P.*) + regex: true + function: runcmd_try_to_run + +- when: I try to run, in (?P\S+), (?P\S+)(?P.*) + regex: true + function: runcmd_try_to_run_in + +# Steps to examine exit code of latest command. + +- then: exit code is {exit} + function: runcmd_exit_code_is + +- then: exit code is not {exit} + function: runcmd_exit_code_is_not + +- then: command is successful + function: runcmd_exit_code_is_zero + +- then: command fails + function: runcmd_exit_code_is_nonzero + +# Steps to examine stdout/stderr for exact content. + +- then: stdout is exactly "(?P.*)" + regex: true + function: runcmd_stdout_is + +- then: "stdout isn't exactly \"(?P.*)\"" + regex: true + function: runcmd_stdout_isnt + +- then: stderr is exactly "(?P.*)" + regex: true + function: runcmd_stderr_is + +- then: "stderr isn't exactly \"(?P.*)\"" + regex: true + function: runcmd_stderr_isnt + +# Steps to examine stdout/stderr for sub-strings. + +- then: stdout contains "(?P.*)" + regex: true + function: runcmd_stdout_contains + +- then: "stdout doesn't contain \"(?P.*)\"" + regex: true + function: runcmd_stdout_doesnt_contain + +- then: stderr contains "(?P.*)" + regex: true + function: runcmd_stderr_contains + +- then: "stderr doesn't contain \"(?P.*)\"" + regex: true + function: runcmd_stderr_doesnt_contain + +# Steps to match stdout/stderr against regular expressions. + +- then: stdout matches regex (?P.*) + regex: true + function: runcmd_stdout_matches_regex + +- then: stdout doesn't match regex (?P.*) + regex: true + function: runcmd_stdout_doesnt_match_regex + +- then: stderr matches regex (?P.*) + regex: true + function: runcmd_stderr_matches_regex + +- then: stderr doesn't match regex (?P.*) + regex: true + function: runcmd_stderr_doesnt_match_regex -- cgit v1.2.1