├── MANIFEST.in ├── jarg ├── __init__.py ├── __main__.py ├── dialects.py ├── cli.py └── jsonform.py ├── .gitignore ├── .travis.yml ├── Makefile ├── tests ├── test_cli.py ├── test_dialects.py └── test_jsonform.py ├── LICENSE ├── setup.py ├── README.rst └── man └── jarg.1.ronn /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | -------------------------------------------------------------------------------- /jarg/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __VERSION__ = ('0', '4', '1') 4 | -------------------------------------------------------------------------------- /jarg/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .cli import main 3 | 4 | main() 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | *.egg 3 | *.egg-info 4 | *.pyc 5 | tests/__pycache__ 6 | man/jarg.1.html 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.6" 4 | - "2.7" 5 | - "3.2" 6 | - "3.3" 7 | - "3.4" 8 | script: python setup.py test 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION = $(shell python -c "import jarg; print '.'.join(jarg.__VERSION__)") 2 | 3 | man/jarg.1.html: man/jarg.1.ronn 4 | ronn --html --style=toc --organization="jarg $(VERSION)" $< 5 | 6 | release: 7 | python setup.py sdist upload -r pypi 8 | 9 | clean: 10 | -rm -rf dist *.egg *.egg-info 11 | 12 | .PHONY: release clean 13 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jarg.cli import InvalidLiteralError, makepair 4 | from jarg.dialects import JSONDialect 5 | 6 | 7 | def test_makepair(): 8 | dialect = JSONDialect() 9 | 10 | assert makepair(dialect, "foo=bar") == ('foo', 'bar') 11 | assert makepair(dialect, "foo=42") == ('foo', 42) 12 | assert makepair(dialect, "foo:=\"123\"") == ('foo', '123') 13 | 14 | with pytest.raises(InvalidLiteralError): 15 | makepair(dialect, "foo:=[1") 16 | -------------------------------------------------------------------------------- /tests/test_dialects.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jarg.dialects import FormDialect, JSONDialect 4 | 5 | 6 | def test_JSONDialect(): 7 | dialect = JSONDialect() 8 | 9 | assert dialect.to_python("bar") == "bar" 10 | assert dialect.to_python("42") == 42 11 | assert dialect.to_python("4.20") == 4.2 12 | assert dialect.to_python('"69"') == '"69"' 13 | 14 | assert dialect.from_literal("true") == True 15 | assert dialect.from_literal("false") == False 16 | assert dialect.from_literal("[1, 2, 3]") == [1, 2, 3] 17 | assert dialect.from_literal("{\"bar\": \"baz\"}") == {'bar': 'baz'} 18 | 19 | 20 | def test_FormDialect(): 21 | dialect = FormDialect() 22 | 23 | assert dialect.to_python("foo") == "foo" 24 | 25 | assert dialect.from_literal("foo=bar") == {'foo': ['bar']} 26 | 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Justin Poliey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /jarg/dialects.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | try: 4 | from urllib import urlencode 5 | from urlparse import parse_qs 6 | except ImportError: 7 | from urllib.parse import parse_qs, urlencode 8 | 9 | import yaml 10 | 11 | from .jsonform import JSONFormEncoder 12 | 13 | 14 | class BaseDialect(object): 15 | def from_literal(self, value): 16 | return value 17 | 18 | def to_python(self, value): 19 | return value 20 | 21 | def dumps(self, context): 22 | return str(context) 23 | 24 | 25 | class CoerceMixin(object): 26 | def to_python(self, value): 27 | if value is None: 28 | return value 29 | try: 30 | value = int(value) 31 | except ValueError: 32 | try: 33 | value = float(value) 34 | except ValueError: 35 | pass 36 | return value 37 | 38 | 39 | class JSONDialect(CoerceMixin, BaseDialect): 40 | def from_literal(self, value): 41 | return json.loads(value) 42 | 43 | def dumps(self, context): 44 | return json.dumps(context, cls=JSONFormEncoder) 45 | 46 | 47 | class YAMLDialect(CoerceMixin, BaseDialect): 48 | def from_literal(self, value): 49 | return yaml.load(value) 50 | 51 | 52 | class FormDialect(BaseDialect): 53 | def from_literal(self, value): 54 | return parse_qs(value) 55 | 56 | def to_python(self, value): 57 | if value is None: 58 | return "" 59 | return value 60 | 61 | def dumps(self, context): 62 | return urlencode(context) 63 | -------------------------------------------------------------------------------- /tests/test_jsonform.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jarg.jsonform import encode, undefined 4 | 5 | 6 | def test_encode(): 7 | pairs = [('bottle-on-wall', 1), ('bottle-on-wall', 2), ('bottle-on-wall', 3)] 8 | output = {'bottle-on-wall': [1, 2, 3]} 9 | assert encode(pairs) == output 10 | 11 | pairs = [('pet[species]', 'Dahut'), ('pet[name]', 'Hypatia'), ('kids[1]', 'Thelma'), ('kids[0]', 'Ashley')] 12 | output = {"pet": {"species": "Dahut", "name": "Hypatia"}, "kids": ["Ashley", "Thelma"]} 13 | assert encode(pairs) == output 14 | 15 | pairs = [('pet[0][species]', 'Dahut'), ('pet[0][name]', 'Hypatia'), ('pet[1][species]', "Felis Stultus"), ('pet[1][name]', 'Billie')] 16 | output = {"pet": [{"species": "Dahut", "name": "Hypatia"}, {"species": "Felis Stultus", "name": "Billie"}]} 17 | assert encode(pairs) == output 18 | 19 | pairs = [("wow[such][deep][3][much][power][!]", "Amaze")] 20 | output = {'wow': {'such': {'deep': [undefined, undefined, undefined, {'much': {'power': {'!': 'Amaze'}}}]}}} 21 | assert encode(pairs) == output 22 | 23 | pairs = [('mix', 'scalar'), ('mix[0]', "array 1"), ('mix[2]', "array 2"), ('mix[key]', "key key"), ('mix[car]', "car key")] 24 | output = {"mix": {"": "scalar", 0: "array 1", 2: "array 2", "key": "key key", "car": "car key"}} 25 | assert encode(pairs) == output 26 | 27 | pairs = [('highlander[]', 'one')] 28 | output = {'highlander': ['one']} 29 | assert encode(pairs) == output 30 | 31 | pairs = [('error[good]', 'BOOM!'), ('error[bad', 'BOOM BOOM!')] 32 | output = {'error': {'good': 'BOOM!'}, 'error[bad': 'BOOM BOOM!'} 33 | assert encode(pairs) == output 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from setuptools import setup 5 | from setuptools.command.test import test as TestCommand 6 | 7 | import jarg 8 | 9 | 10 | here = os.path.abspath(os.path.dirname(__file__)) 11 | readme = open(os.path.join(here, 'README.rst')).read() 12 | 13 | classifiers = [ 14 | 'Development Status :: 4 - Beta', 15 | 'Environment :: Console', 16 | 'Intended Audience :: Developers', 17 | 'License :: OSI Approved :: MIT License', 18 | 'Natural Language :: English', 19 | 'Operating System :: POSIX', 20 | 'Programming Language :: Python', 21 | 'Programming Language :: Python :: 2', 22 | 'Programming Language :: Python :: 3', 23 | 'Topic :: Internet', 24 | 'Topic :: Utilities', 25 | ] 26 | 27 | 28 | class PyTest(TestCommand): 29 | def initialize_options(self): 30 | TestCommand.initialize_options(self) 31 | self.pytest_args = [] 32 | 33 | def finalize_options(self): 34 | TestCommand.finalize_options(self) 35 | self.test_args = [] 36 | self.test_suite = True 37 | 38 | def run_tests(self): 39 | import pytest 40 | errno = pytest.main(self.pytest_args) 41 | sys.exit(errno) 42 | 43 | 44 | dist = setup( 45 | name='jarg', 46 | version='.'.join(jarg.__VERSION__), 47 | license='MIT', 48 | description="A shorthand data serialization/encoding tool", 49 | long_description=readme, 50 | classifiers=classifiers, 51 | author="Justin Poliey", 52 | author_email="justin.d.poliey@gmail.com", 53 | url='http://github.com/jdp/jarg', 54 | include_package_data=True, 55 | zip_safe=False, 56 | packages=['jarg'], 57 | entry_points={ 58 | 'console_scripts': ['jarg = jarg.cli:main'], 59 | }, 60 | install_requires=['PyYAML>=3'], 61 | tests_require=['pytest'], 62 | cmdclass = {'test': PyTest}, 63 | ) 64 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ==== 2 | jarg 3 | ==== 4 | 5 | .. image:: https://travis-ci.org/jdp/jarg.svg?branch=master 6 | :target: https://travis-ci.org/jdp/jarg 7 | 8 | **jarg** is an encoding shorthand for your shell. 9 | It is a command-line utility that makes generating data in formats like JSON, YAML, and form encoding easier in the shell. 10 | 11 | Installation 12 | ------------ 13 | 14 | Install from PyPI_:: 15 | 16 | $ pip install jarg 17 | 18 | Usage 19 | ----- 20 | 21 | Each argument to **jarg** should be in the format of `name=value`. 22 | Values are interpreted as their closest native encoding value, and the default dialect is JSON. 23 | The most common case is probably string names and values:: 24 | 25 | $ jarg foo=bar baz=quux 26 | {"foo": "bar", "baz": "quux"} 27 | 28 | Floats and integers will work too:: 29 | 30 | $ jarg foo=10 bar=4.2 31 | {"foo": 10, "bar": 4.2} 32 | 33 | The value is optional. 34 | If you leave it out, it is interpreted as ``null``:: 35 | 36 | $ jarg foo 37 | {"foo": null} 38 | 39 | The `name` portions have the same syntax and semantics as `HTML JSON`_ names:: 40 | 41 | $ jarg foo[]=bar foo[]=baz bar[baz]=quux 42 | {"foo": ["bar", "baz"], "bar": {"baz": "quux"}} 43 | 44 | You can also write literal values directly, using the `name:=value` syntax. 45 | That lets you write things like booleans, lists, and explicit strings:: 46 | 47 | $ jarg foo:=true bar:=\"123\" 48 | {"foo": true, "bar": "123"} 49 | $ jarg foo:=[1,2,3] 50 | {"foo": [1, 2, 3]} 51 | 52 | 53 | Dialects 54 | -------- 55 | 56 | The default dialect is JSON, and includes support for YAML and form encoding. 57 | 58 | To use the YAML dialect, use the ``-y``/``--yaml`` switch:: 59 | 60 | $ jarg -y name=jarg type="cli tool" traits[]=dope traits[]=rad 61 | --- 62 | name: jarg 63 | traits: [dope, rad] 64 | type: cli tool 65 | 66 | You can switch to the form encoding dialect with the ``-f``/``--form`` switch:: 67 | 68 | $ jarg -f foo=bar baz="jarg is dope" 69 | foo=bar&baz=jarg+is+dope 70 | 71 | .. _PyPI: http://pypi.python.org/ 72 | .. _`HTML JSON`: http://www.w3.org/TR/html-json-forms/ 73 | -------------------------------------------------------------------------------- /jarg/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import argparse 4 | import os 5 | import sys 6 | 7 | from . import __VERSION__ 8 | from .dialects import FormDialect, JSONDialect, YAMLDialect 9 | from .jsonform import encode 10 | 11 | 12 | class InvalidLiteralError(ValueError): 13 | def __init__(self, key, *args, **kwargs): 14 | self.key = key 15 | super(InvalidLiteralError, self).__init__(key, *args, **kwargs) 16 | 17 | 18 | def makepair(dialect, pair): 19 | """Return a (key, value) tuple from a KEY=VALUE formatted string. 20 | """ 21 | 22 | parts = pair.split('=', 1) 23 | if len(parts) == 1: 24 | parts.append(None) 25 | key, value = parts 26 | if key[-1] == ":": 27 | key = key[:-1] 28 | try: 29 | value = dialect.from_literal(value) 30 | except ValueError: 31 | raise InvalidLiteralError(key) 32 | else: 33 | value = dialect.to_python(value) 34 | return key, value 35 | 36 | 37 | def fatal(msg, code=1): 38 | sys.stderr.write("{}: {}\n".format(os.path.basename(sys.argv[0]), msg)) 39 | sys.exit(code) 40 | 41 | 42 | def main(): 43 | ap = argparse.ArgumentParser( 44 | description="Shorthand encoding format syntax.", prog="jarg") 45 | dialects = ap.add_mutually_exclusive_group() 46 | dialects.add_argument( 47 | '-j', '--json', action='store_const', const=JSONDialect, 48 | dest='dialect', help="use the JSON dialect") 49 | dialects.add_argument( 50 | '-y', '--yaml', action='store_const', const=YAMLDialect, 51 | dest='dialect', help="use the YAML dialect") 52 | dialects.add_argument( 53 | '-f', '--form', action='store_const', const=FormDialect, 54 | dest='dialect', help="use the form encoding dialect") 55 | ap.add_argument( 56 | 'pair', nargs='+', help="a pair in the format of KEY=VALUE") 57 | ap.add_argument( 58 | '-V', '--version', action='version', 59 | version="%(prog)s {}".format('.'.join(__VERSION__))) 60 | args = ap.parse_args() 61 | 62 | dialect = (args.dialect or JSONDialect)() 63 | if sys.stdin.isatty(): 64 | context = {} 65 | else: 66 | context = dialect.from_literal(sys.stdin.read()) 67 | try: 68 | context.update(encode(makepair(dialect, pair) for pair in args.pair)) 69 | except InvalidLiteralError as e: 70 | fatal("valid literal value required for key `{}'".format(e.key)) 71 | sys.stdout.write(dialect.dumps(context)) 72 | 73 | if __name__ == '__main__': 74 | main() 75 | -------------------------------------------------------------------------------- /man/jarg.1.ronn: -------------------------------------------------------------------------------- 1 | # jarg(1) - shorthand data serialization/encoding 2 | 3 | ## SYNOPSIS 4 | 5 | `jarg` [`-h`] [`-j` | `-y` | `-f`] [`-V`] *name=value* [*name=value* ...] 6 | 7 | ## DESCRIPTION 8 | 9 | **jarg** provides a shorthand syntax for writing encoded data formats. 10 | It works by adding name-value pairs to the current *context*, 11 | which starts out empty by default. 12 | It writes the final representation to standard output. 13 | 14 | Each argument must be in the format of *name=value*. 15 | Each name must be in the [HTML JSON form](http://www.w3.org/TR/html-json-forms/) ([http://www.w3.org/TR/html-json-forms/](http://www.w3.org/TR/html-json-forms/)) format. 16 | The *=value* portion is optional, omitting it will cause the value to take the empty value of the current dialect. 17 | Literal values may be provided in the *name:=value* format and they will be interpreted according to the current dialect. 18 | 19 | Each name-value pair is added to the current context. 20 | A starting context can be specified through standard input when not in a TTY, 21 | and must be in the same dialect as the output. 22 | 23 | ## DIALECTS 24 | 25 | Output is determined by the current dialect. 26 | The default dialect is JSON. 27 | 28 | ### JSON 29 | 30 | The output will be a JSON object. 31 | Integers and floats are automatically recognized. 32 | The empty value is *null*. 33 | 34 | ### YAML 35 | 36 | The output will be a YAML document. 37 | Integers and floats are automatically recognized. 38 | The empty value is *null*. 39 | 40 | ### FORM 41 | 42 | The output will be a percent-encoded list of name/value pairs, 43 | in the application/x-www-form-urlencoded format. 44 | The empty value is an empty string. 45 | 46 | ## OPTIONS 47 | 48 | * `-j`, `--json`: 49 | Set the current dialect to JSON. This is the default. 50 | 51 | * `-y`, `--yaml`: 52 | Set the current dialect to YAML. 53 | 54 | * `-f`, `--form`: 55 | Set the current dialect to form encoding. 56 | 57 | * `-h`, `--help`: 58 | Show a help message and exit. 59 | 60 | * `-V`, `--version`: 61 | Show the version and exit. 62 | 63 | 64 | ## EXIT CODES 65 | 66 | ### 1 67 | 68 | Error parsing literal value. 69 | 70 | ## EXAMPLES 71 | 72 | Write some JSON to standard output: 73 | 74 | $ jarg foo=bar baz=123 bux:=true 75 | {"foo": "bar", "baz": 123, "bux": true} 76 | 77 | Repeating key names implicitly builds an array: 78 | 79 | $ jarg a=1 a=2 a=3 80 | {"a": [1, 2, 3]} 81 | 82 | Missing array indexes are automatically filled in: 83 | 84 | $ jarg a[2]=foo a[4]=bar 85 | {"a": [null, null, "foo", null, "bar"]} 86 | 87 | Creating nested objects is allowed too: 88 | 89 | $ jarg foo[bar][baz]=quux 90 | {"foo": {"bar": {"baz": "quux"}}} 91 | 92 | Force a string representation of a number in JSON: 93 | 94 | $ jarg foo:=\"123\" 95 | {"foo": "123"} 96 | 97 | Say something nice about **jarg** in form encoding: 98 | 99 | $ jarg -f jarg="is cool and good" 100 | jarg=is+cool+and+good 101 | 102 | ## DEVELOPMENT 103 | 104 | Development progress and issues are tracked on the project page. 105 | 106 | [http://github.com/jdp/jarg](http://github.com/jdp/jarg) 107 | 108 | ## BUGS 109 | 110 | [https://github.com/jdp/jarg/issues](https://github.com/jdp/jarg/issues) 111 | 112 | ## WWW 113 | 114 | [http://jdp.github.io/jarg](http://jdp.github.io/jarg)
115 | 116 | ## COPYRIGHT 117 | 118 | Copyright © 2014 Justin Poliey [http://justinpoliey.com](http://justinpoliey.com) 119 | -------------------------------------------------------------------------------- /jarg/jsonform.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | 4 | undefined = object() 5 | 6 | 7 | class JSONFormEncoder(json.JSONEncoder): 8 | def default(self, obj): 9 | if obj == undefined: 10 | return None 11 | else: 12 | return super(JSONFormEncoder, self).default(obj) 13 | 14 | 15 | def parse_path(path): 16 | """ 17 | http://www.w3.org/TR/2014/WD-html-json-forms-20140529/#dfn-steps-to-parse-a-json-encoding-path 18 | """ 19 | original = path 20 | failure = [(original, {'last': True, 'type': object})] 21 | steps = [] 22 | try: 23 | first_key = path[:path.index("[")] 24 | if not first_key: 25 | return original 26 | steps.append((first_key, {'type': 'object'})) 27 | path = path[path.index("["):] 28 | except ValueError: 29 | return failure 30 | while path: 31 | if path.startswith("[]"): 32 | steps[-1][1]['append'] = True 33 | path = path[2:] 34 | if path: 35 | return failure 36 | elif path[0] == "[": 37 | path = path[1:] 38 | try: 39 | key = path[:path.index("]")] 40 | path = path[path.index("]")+1:] 41 | except ValueError: 42 | return failure 43 | try: 44 | steps.append((int(key), {'type': 'array'})) 45 | except ValueError: 46 | steps.append((key, {'type': 'object'})) 47 | else: 48 | return failure 49 | for i in range(len(steps)-1): 50 | steps[i][1]['type'] = steps[i+1][1]['type'] 51 | steps[-1][1]['last'] = True 52 | return steps 53 | 54 | 55 | def set_value(context, step, current_value, entry_value): 56 | """ 57 | http://www.w3.org/TR/2014/WD-html-json-forms-20140529/#dfn-steps-to-set-a-json-encoding-value 58 | """ 59 | key, flags = step 60 | if flags.get('last', False): 61 | if current_value == undefined: 62 | if flags.get('append', False): 63 | context[key] = [entry_value] 64 | else: 65 | if isinstance(context, list) and len(context) <= key: 66 | context.extend([undefined] * (key - len(context) + 1)) 67 | context[key] = entry_value 68 | elif isinstance(current_value, list): 69 | context[key].append(entry_value) 70 | elif isinstance(current_value, dict): 71 | set_value( 72 | current_value, ("", {'last': True}), 73 | current_value.get("", undefined), entry_value) 74 | else: 75 | context[key] = [current_value, entry_value] 76 | return context 77 | else: 78 | if current_value == undefined: 79 | if flags.get('type') == 'array': 80 | context[key] = [] 81 | else: 82 | if isinstance(context, list) and len(context) <= key: 83 | context.extend([undefined] * (key - len(context) + 1)) 84 | context[key] = {} 85 | return context[key] 86 | elif isinstance(current_value, dict): 87 | return context[key] 88 | elif isinstance(current_value, list): 89 | if flags.get('type') == 'array': 90 | return current_value 91 | else: 92 | obj = {} 93 | for i, item in enumerate(current_value): 94 | if item != undefined: 95 | obj[i] = item 96 | else: 97 | context[key] = obj 98 | return obj 99 | else: 100 | obj = {"": current_value} 101 | context[key] = obj 102 | return obj 103 | 104 | 105 | def encode(pairs): 106 | """ 107 | The application/json form encoding algorithm. 108 | http://www.w3.org/TR/2014/WD-html-json-forms-20140529/#the-application-json-encoding-algorithm 109 | """ 110 | result = {} 111 | for key, value in pairs: 112 | steps = parse_path(key) 113 | context = result 114 | for step in steps: 115 | try: 116 | current_value = context.get(step[0], undefined) 117 | except AttributeError: 118 | try: 119 | current_value = context[step[0]] 120 | except IndexError: 121 | current_value = undefined 122 | context = set_value(context, step, current_value, value) 123 | return result 124 | --------------------------------------------------------------------------------