Repository: mblomdahl/qsck

Issue:
Implement `serialize` function
Hey Mats! It would be great if the `qsck.serialize()` function did something, as of #2 it just returns `None`. 😞 And maybe instructions on how to use it in README + some test coverage, if that's not too much to ask...

Discussion:

Patch:
diff --git a/README.md b/README.md
index e9103e8..99afb17 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,62 @@
+
 qsck
 ====
 
 Python library for serializing and deserializing a wonky format referred to
-as ".qs" files.
+as ".qs" files. For full format specification, please read through
+[tests.test_serialize](https://github.com/mblomdahl/qsck/blob/master/tests/test_serialize.py)
+test suite and extrapolate.
+
+
+Quick Start
+-----------
+
+**Use Python ≥ 3.6 only.** Install it: `pip3 install qsck`
+
+### Serializing Data
+
+Via Python:
+
+    python3 -c "import qsck; print(qsck.serialize('LOG', '1553302923', [
+        ('first_key', 'some value'),
+        ('2nd_key', [('attr1', 'foo'), ('attr2', 'bar')]),
+        ('3rd_key', {'subKey1': '-3', 'subKey2': None}),
+        ('4th_key', None)
+    ]))"
+
+
+Out comes a ".qs" record, like so:
+
+    LOG,1553302923,first_key=some value,2nd_key={attr1=foo, attr2=bar},3rd_key={"subKey1":"-3","subKey2":null},4th_key=(null)
+
+
+The library also supports serializing data by passing in a JSON file via
+the command-line tool `qs-format`, one record per line:
+
+    qs-format my-records.json > my-records.qs
 
 
 Contributing
 ------------
 
-Really? Very welcome. Do the usual fork-and-submit-PR thingy. Running the tests:
+Really? Very welcome. Do the usual fork-and-submit-PR thingy.
+
+Running the tests:
 
     python setup.py test
 
+
+Distributing:
+
+    pip3 install --upgrade twine wheel setuptools
+    python setup.py sdist bdist_wheel
+    twine upload dist/*
+
+
+Changelog
+---------
+
+### 0.1 – Initial Release
+
+Supports serializing ".qs" records.
+
diff --git a/qsck/__init__.py b/qsck/__init__.py
index 0f6a6f0..861a3e1 100644
--- a/qsck/__init__.py
+++ b/qsck/__init__.py
@@ -4,18 +4,46 @@ qsck -- The shitty ".qs" file (de-)serializer
 
 """
 
+from collections import OrderedDict
 from datetime import datetime
 
+import ujson
 
-def serialize(identifier: str, timestamp: datetime,
-              key_value_pairs: [(str, str)]) -> str:
-    """Format input parameters as a .qs-style row -- the simple part! :)"""
+from .util import _validate_and_cast_timestamp_to_epoch_str
 
-    pass
 
+def serialize(identifier: str, timestamp, key_value_pairs: []) -> str:
+    """Format input parameters as a .qs-style row -- the simple part! :)"""
 
-def deserialize(qs_row: str) -> (str, datetime, [(str, str)]):
+    components = [identifier,
+                  _validate_and_cast_timestamp_to_epoch_str(timestamp)]
+
+    for idx, pair in enumerate(key_value_pairs):
+        try:
+            key, value = pair
+        except ValueError as unpack_err:
+            raise ValueError(f'Error unpacking {pair!r} at index {idx} record '
+                             f'({str(unpack_err)}')
+
+        if isinstance(value, str):
+            components.append(f'{key}={value}')
+        elif value is None:
+            components.append(f'{key}=(null)')
+        elif isinstance(value, list):
+            subcomponents = []
+            for sub_key, sub_value in value:
+                subcomponents.append(f'{sub_key}={sub_value}')
+            components.append('%s={%s}' % (key, ', '.join(subcomponents)))
+        elif isinstance(value, (dict, OrderedDict)):
+            components.append('%s=%s' % (key, ujson.dumps(value)))
+        else:
+            raise TypeError(f'Unsupported data type in {pair!r}')
+
+    return ','.join(components) + '\n'
+
+
+def deserialize(qs_row: str) -> (str, datetime, []):
     """Parse `qs_row`, return as a identifier-timestamp-key_value_pairs 3-tuple.
     """
 
-    pass
+    raise NotImplemented
diff --git a/qsck/format_cli.py b/qsck/format_cli.py
index 9dd9d40..e05b074 100644
--- a/qsck/format_cli.py
+++ b/qsck/format_cli.py
@@ -1,9 +1,22 @@
 """Placeholder module for `qs-format` command-line tool."""
 
+import click
+import ujson
 
-def run():
-    print("Here is the formatter!")
+from . import serialize
+
+
+@click.command()
+@click.argument('input_json_file', type=click.File())
+def qs_format(input_json_file):
+    """Reads JSON file with one record per line, outputs .qs records to stdout.
+    """
+
+    for row in input_json_file.readlines():
+        input_record = ujson.loads(row)
+        print(serialize(input_record[0], input_record[1], input_record[2]),
+              end='')
 
 
 if __name__ == '__main__':
-    run()
+    qs_format()
diff --git a/qsck/util.py b/qsck/util.py
new file mode 100644
index 0000000..b76ad6f
--- /dev/null
+++ b/qsck/util.py
@@ -0,0 +1,33 @@
+"""Misc utility functions to make serialize/deserialize work."""
+
+from datetime import datetime, timezone
+
+
+def _validate_and_cast_timestamp_to_epoch_str(timestamp) -> str:
+
+    if isinstance(timestamp, datetime):
+        int_timestamp = int(timestamp.timestamp())
+    elif isinstance(timestamp, str):
+        try:
+            int_timestamp = int(timestamp)
+        except ValueError:
+            raise ValueError(f'Timestamp {timestamp!r} not castable to int')
+    elif isinstance(timestamp, int):
+        int_timestamp = timestamp
+    else:
+        raise TypeError(f'Timestamp {timestamp!r} is not of supported type')
+
+    epoch_now = datetime.utcnow().timestamp()
+    delta_now_timestamp = epoch_now - int_timestamp
+
+    assert delta_now_timestamp >= 0, (f'Timestamp is {-delta_now_timestamp} s '
+                                      f'head of now')
+
+    y2k = datetime(2000, 1, 1, 0, 0, 0, tzinfo=timezone.utc).timestamp()
+    delta_y2k_timestamp = y2k - int_timestamp
+
+    assert delta_y2k_timestamp < 0, (f'Timestamp is {delta_y2k_timestamp} s '
+                                     f'before year 2000')
+
+    return str(int_timestamp)
+
diff --git a/setup.cfg b/setup.cfg
index f5e8c37..01371f7 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,3 +1,6 @@
+[metadata]
+description-file = README.md
+
 [aliases]
 test = pytest
 
diff --git a/setup.py b/setup.py
index e8f3aeb..397218d 100644
--- a/setup.py
+++ b/setup.py
@@ -13,18 +13,19 @@ def _read_files_from_root_dir(*file_basenames: [str]) -> str:
 
 setup(
     name='qsck',
-    version='0.0',
-    packages=find_packages('qsck'),
+    version='0.1',
+    packages=find_packages(include=('qsck',)),
     url='https://github.com/mblomdahl/qsck',
     license='The Unlicense',
     author='Mats Blomdahl',
     author_email='mats.blomdahl@gmail.com',
     description='The shitty ".qs" file (de-)serializer',
     long_description=_read_files_from_root_dir('README.md'),
+    long_description_content_type='text/markdown',
     entry_points={
         'console_scripts': [
             'qs-parse = qsck.parse_cli:run',
-            'qs-format = qsck.format_cli:run'
+            'qs-format = qsck.format_cli:qs_format'
         ]
     },
     setup_requires=[


Test patch:
diff --git a/tests/test_serialize.py b/tests/test_serialize.py
index 3f6469c..1441609 100644
--- a/tests/test_serialize.py
+++ b/tests/test_serialize.py
@@ -1,7 +1,111 @@
 
+from collections import OrderedDict
+from datetime import datetime, timezone
+
+from pytest import raises
+
 from qsck import serialize
 
 
 def test_serialize_is_a_function():
     assert hasattr(serialize, '__call__')
 
+
+def test_it_formats_identifier_and_timestamp_as_unix_epoch():
+    identifier = 'LOG'
+    some_key_value_pairs = [("It's Caturday?", 'YES')]
+
+    dt_timestamp = datetime(2019, 3, 23, 1, 2, 3, tzinfo=timezone.utc)
+    str_timestamp = "1553302923"
+    int_timestamp = int(str_timestamp)
+
+    for timestamp in (dt_timestamp, str_timestamp, int_timestamp):
+        qs_row = serialize(identifier, timestamp, some_key_value_pairs)
+
+        assert qs_row.startswith("LOG,1553302923,It's Caturday?=")
+
+
+def test_it_rejects_future_and_far_past_and_mistyped_timestamps():
+    identifier = 'GOL'
+
+    far_past_timestamp = datetime(1999, 12, 31, 0, 0, 0, tzinfo=timezone.utc)
+    with raises(AssertionError):
+        serialize(identifier, far_past_timestamp, [])
+
+    future_timestamp = int(datetime.utcnow().timestamp() + 30)
+    with raises(AssertionError):
+        serialize(identifier, future_timestamp, [])
+
+    text_timestamp = 'First day of April, 2015'
+    with raises(ValueError):
+        serialize(identifier, text_timestamp, [])
+
+    mistyped_timestamp = [2019, 1, 3, 16, 1, 30]
+    with raises(TypeError):
+        serialize(identifier, mistyped_timestamp, [])
+
+
+def test_it_rejects_malformatted_and_mistyped_key_value_pairs():
+    identifier = 'LOG'
+    timestamp = datetime.utcnow()
+
+    malformatted_key_value_pairs = [('hey', 'you'), ("i'm", "one", "too many")]
+
+    with raises(ValueError):
+        serialize(identifier, timestamp, malformatted_key_value_pairs)
+
+    mistyped_key_value_pair = [("here's a boolean", False)]
+
+    with raises(TypeError):
+        serialize(identifier, timestamp, mistyped_key_value_pair)
+
+
+def test_it_formats_null_values_as_funky_strings():
+    identifier = 'LOG'
+    timestamp = datetime.utcnow()
+    key_value_pairs_with_none_values = [
+        ('good_ideas', None), ('bad ideas', 'plenty'), ('newType', None)]
+
+    qs_row = serialize(identifier, timestamp, key_value_pairs_with_none_values)
+
+    assert ',good_ideas=(null),bad ideas=plenty,newType=(null)' in qs_row
+
+
+def test_it_does_the_nested_key_value_formatting_on_root_level_list_values():
+    identifier = 'LOG'
+    timestamp = datetime.utcnow()
+    key_value_pairs_with_list_values = [
+        ('howdy', None),
+        ('my_nest', [('sub_key1', 'foo'), ('sk2', 'bar')]),
+        ('nest2', [('a', '1')]),
+        ('otherStuff', 'ok')
+    ]
+
+    qs_row = serialize(identifier, timestamp, key_value_pairs_with_list_values)
+
+    assert ',my_nest={sub_key1=foo, sk2=bar},nest2={a=1},otherStuff=' in qs_row
+
+
+def test_it_does_the_nested_key_value_formatting_on_root_level_dict_values():
+    identifier = 'LOG'
+    timestamp = datetime.utcnow()
+    key_value_pairs_with_dict_values = [
+        ('howdy', None),
+        ('nest3', OrderedDict([('key31', 2.0), ('key32', 0)])),
+        ('nest4', {'y': 3, 'x': 1}),
+        ('moarStuff', '!')
+    ]
+
+    qs_row = serialize(identifier, timestamp, key_value_pairs_with_dict_values)
+
+    assert ',nest3={"key31":2.0,"key32":0},nest4={"y":3,"x":1},moarSt' in qs_row
+
+
+def test_each_output_records_ends_with_newline():
+    identifier = 'FOO'
+    timestamp = datetime.utcnow()
+    some_key_value_pairs = [('theOnly', 'One')]
+
+    qs_row = serialize(identifier, timestamp, some_key_value_pairs)
+
+    assert qs_row.endswith(',theOnly=One\n')