├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── fixedwidth ├── __init__.py ├── fixedwidth.py └── tests │ ├── __init__.py │ └── test_core.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | *.pyc 3 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Shawn Milochik 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 11 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Easy two-way conversion between Python dictionaries and fixed-width files. 2 | The FixedWidth class has been used in production without modification for 3 | several years. 4 | 5 | This module has also proven useful for "debugging" a fixed-width spec -- 6 | an invalid configuration reports an error that may not be obvious from 7 | reading the spec document. 8 | 9 | Requires a 'config' dictonary. See unit tests for full example. 10 | 11 | Small example 12 | 13 | ```python 14 | SAMPLE_CONFIG = { 15 | 16 | 'first_name': { 17 | 'required': True, 18 | 'type': 'string', 19 | 'start_pos': 1, 20 | 'end_pos': 10, 21 | 'alignment': 'left', 22 | 'padding': ' ' 23 | }, 24 | 25 | 'last_name': { 26 | 'required': True, 27 | 'type': 'string', 28 | 'start_pos': 11, 29 | 'end_pos': 30, 30 | 'alignment': 'left', 31 | 'padding': ' ' 32 | }, 33 | 34 | 'date': { 35 | 'required': True, 36 | 'type': 'date', 37 | 'start_pos': 31, 38 | 'end_pos': 38, 39 | 'alignment': 'left', 40 | 'format': '%Y%m%d', 41 | 'padding': ' ' 42 | }, 43 | 44 | 'decimal': { 45 | 'required': True, 46 | 'type': 'decimal', 47 | 'precision': 2, 48 | 'rounding': decimal.ROUND_UP, 49 | 'start_pos': 38, 50 | 'end_pos': 42, 51 | 'alignment': 'left', 52 | 'padding': ' ' 53 | }, 54 | 55 | } 56 | ``` 57 | 58 | Notes: 59 | 60 | * A field must have a start_pos and either an end_pos or a length. If both an end_pos and a length are provided, they must not conflict. 61 | 62 | * A field may not have a default value if it is required. 63 | 64 | * Supported types are string, integer, and decimal. 65 | 66 | * Alignment and padding are required. 67 | 68 | 69 | License: BSD 70 | -------------------------------------------------------------------------------- /fixedwidth/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fixedwidth/fixedwidth.py: -------------------------------------------------------------------------------- 1 | """ 2 | The FixedWidth class definition. 3 | """ 4 | from decimal import Decimal, ROUND_HALF_EVEN 5 | 6 | from datetime import datetime 7 | from six import string_types, integer_types 8 | 9 | 10 | class FixedWidth(object): 11 | """ 12 | Class for converting between Python dictionaries and fixed-width 13 | strings. 14 | 15 | Requires a 'config' dictonary. 16 | Each key of 'config' is the field name. 17 | Each item of 'config' is itself a dictionary with the following keys: 18 | required a boolean; required 19 | type a string; required 20 | value (will be coerced into 'type'); hard-coded value 21 | default (will be coerced into 'type') 22 | start_pos an integer; required 23 | length an integer 24 | end_pos an integer 25 | format a string, to format dates, required for date fields 26 | The following keys are only used when emitting fixed-width strings: 27 | alignment a string; required 28 | padding a string; required 29 | precision an integer, to format decimals numbers 30 | rounding a constant ROUND_xxx used when precision is set 31 | 32 | Notes: 33 | A field must have a start_pos and either an end_pos or a length. 34 | If both an end_pos and a length are provided, they must not conflict. 35 | 36 | A field may not have a default value if it is required. 37 | 38 | Type may be string, integer, decimal, numeric, or date. 39 | 40 | Alignment and padding are required. 41 | 42 | """ 43 | 44 | def __init__(self, config, **kwargs): 45 | 46 | """ 47 | Arguments: 48 | config: required, dict defining fixed-width format 49 | kwargs: optional, dict of values for the FixedWidth object 50 | """ 51 | 52 | self.format_functions = { 53 | 'integer': lambda x: str(self.data[x]), 54 | 'string': lambda x: str(self.data[x]), 55 | 'decimal': self._get_decimal_data, 56 | 'numeric': lambda x: str(self.data[x]), 57 | 'date': self._get_date_data, 58 | } 59 | 60 | self.line_end = kwargs.pop('line_end', '\r\n') 61 | self.fixed_point = kwargs.pop('fixed_point', False) 62 | self.config = config 63 | 64 | self.data = {} 65 | if kwargs: 66 | self.data = kwargs 67 | 68 | self.ordered_fields = sorted( 69 | [(self.config[x]['start_pos'], x) for x in self.config] 70 | ) 71 | 72 | #Raise exception for bad config 73 | for key, value in self.config.items(): 74 | 75 | #required values 76 | if any([x not in value for x in ( 77 | 'type', 'required', 'padding', 'alignment', 'start_pos')]): 78 | raise ValueError( 79 | "Not all required values provided for field %s" % (key,)) 80 | 81 | if value['type'] == 'date': 82 | if 'format' in value: 83 | try: 84 | datetime.now().strftime(value['format']) 85 | except Exception: 86 | raise ValueError("Incorrect format string provided for field %s" % (key,)) 87 | else: 88 | raise ValueError("No format string provided for field %s" % (key,)) 89 | 90 | elif value['type'] == 'decimal': 91 | if 'precision' in value and type(value['precision']) != int: 92 | raise ValueError("Precision parameter for field %s must be an int" % (key,)) 93 | 94 | #end position or length required 95 | if 'end_pos' not in value and 'length' not in value: 96 | raise ValueError("An end position or length is required for field %s" % (key,)) 97 | 98 | #end position and length must match if both are specified 99 | if all([x in value for x in ('end_pos', 'length')]): 100 | if value['length'] != value['end_pos'] - value['start_pos'] + 1: 101 | raise ValueError("Field %s length (%d) does not coincide with \ 102 | its start and end positions." % (key, value['length'])) 103 | 104 | #fill in length and end_pos 105 | if 'end_pos' not in value: 106 | value['end_pos'] = value['start_pos'] + value['length'] - 1 107 | if 'length' not in value: 108 | value['length'] = value['end_pos'] - value['start_pos'] + 1 109 | 110 | #end_pos must be greater than start_pos 111 | if value['end_pos'] < value['start_pos']: 112 | raise ValueError("%s end_pos must be *after* start_pos." % (key,)) 113 | 114 | #make sure authorized type was provided 115 | if not value['type'] in ('string', 'integer', 'decimal', 'numeric', 'date'): 116 | raise ValueError("Field %s has an invalid type (%s). Allowed: 'string', \ 117 | 'integer', 'decimal', 'numeric', 'date" % (key, value['type'])) 118 | 119 | #make sure alignment is 'left' or 'right' 120 | if not value['alignment'] in ('left', 'right'): 121 | raise ValueError("Field %s has an invalid alignment (%s). \ 122 | Allowed: 'left' or 'right'" % (key, value['alignment'])) 123 | 124 | #if a default value was provided, make sure 125 | #it doesn't violate rules 126 | if 'default' in value: 127 | 128 | #can't be required AND have a default value 129 | if value['required']: 130 | raise ValueError("Field %s is required; \ 131 | can not have a default value" % (key,)) 132 | 133 | #ensure default value provided matches type 134 | if value['type'] == 'decimal' and value['default'] is not None: 135 | value['default'] = Decimal(value['default']) 136 | elif value['type'] == 'date' and isinstance(value['default'], string_types): 137 | value['default'] = datetime.strptime(value['default'], value['format']) 138 | 139 | types = {'string': string_types, 'integer': int, 'decimal': Decimal, 140 | 'numeric': str, 'date': datetime} 141 | if value['default'] is not None and not isinstance(value['default'], types[value['type']]): 142 | raise ValueError("Default value for %s is not a valid %s" \ 143 | % (key, value['type'])) 144 | 145 | #if a precision was provided, make sure 146 | #it doesn't violate rules 147 | if value['type'] == 'decimal' and 'precision' in value: 148 | 149 | #make sure authorized type was provided 150 | if not isinstance(value['precision'], int): 151 | raise ValueError("Precision parameter for field %s " 152 | "must be an int" % (key,)) 153 | 154 | value.setdefault('rounding', ROUND_HALF_EVEN) 155 | 156 | #ensure start_pos and end_pos or length is correct in config 157 | current_pos = 1 158 | for start_pos, field_name in self.ordered_fields: 159 | 160 | if start_pos != current_pos: 161 | raise ValueError("Field %s starts at position %d; \ 162 | should be %d (or previous field definition is incorrect)." \ 163 | % (field_name, start_pos, current_pos)) 164 | 165 | current_pos = current_pos + config[field_name]['length'] 166 | 167 | def update(self, **kwargs): 168 | 169 | """ 170 | Update self.data using the kwargs sent. 171 | """ 172 | 173 | self.data.update(kwargs) 174 | 175 | def validate(self): 176 | 177 | """ 178 | Ensure the data in self.data is consistent with self.config 179 | """ 180 | 181 | type_tests = { 182 | 'string': lambda x: isinstance(x, string_types), 183 | 'decimal': lambda x: isinstance(x, Decimal), 184 | 'integer': lambda x: isinstance(x, integer_types), 185 | 'numeric': lambda x: str(x).isdigit(), 186 | 'date': lambda x: isinstance(x, datetime), 187 | } 188 | 189 | for field_name, parameters in self.config.items(): 190 | 191 | if field_name in self.data: 192 | 193 | if self.data[field_name] is None and 'default' in parameters: 194 | self.data[field_name] = parameters['default'] 195 | 196 | data = self.data[field_name] 197 | # make sure passed in value is of the proper type 198 | # but only if a value is set 199 | if data and not type_tests[parameters['type']](data): 200 | raise ValueError("%s is defined as a %s, \ 201 | but the value is not of that type." \ 202 | % (field_name, parameters['type'])) 203 | 204 | #ensure value passed in is not too long for the field 205 | field_data = self._format_field(field_name) 206 | if len(str(field_data)) > parameters['length']: 207 | raise ValueError("%s is too long (limited to %d \ 208 | characters)." % (field_name, parameters['length'])) 209 | 210 | if 'value' in parameters \ 211 | and parameters['value'] != field_data: 212 | 213 | raise ValueError("%s has a value in the config, \ 214 | and a different value was passed in." % (field_name,)) 215 | 216 | else: #no value passed in 217 | 218 | #if required but not provided 219 | if parameters['required'] and ('value' not in parameters): 220 | raise ValueError("Field %s is required, but was \ 221 | not provided." % (field_name,)) 222 | 223 | #if there's a default value 224 | if 'default' in parameters: 225 | self.data[field_name] = parameters['default'] 226 | 227 | #if there's a hard-coded value in the config 228 | if 'value' in parameters: 229 | self.data[field_name] = parameters['value'] 230 | 231 | if parameters['required'] and self.data[field_name] is None: 232 | # None gets checked last because it may be set with a default value 233 | raise ValueError("None value not allowed for %s" % (field_name)) 234 | 235 | return True 236 | 237 | def _get_decimal_data(self, field_name): 238 | """ 239 | quantizes field if it is decimal type and precision is set 240 | """ 241 | value = str(self.data[field_name]) 242 | if 'precision' in self.config[field_name]: 243 | value = str(Decimal(value). 244 | quantize(Decimal('0.%s' % ('0' * 245 | self.config[field_name]['precision'])), 246 | self.config[field_name]['rounding'])) 247 | if self.fixed_point: 248 | value = value.replace('.', '') 249 | return value 250 | 251 | def _get_date_data(self, field_name): 252 | return str(self.data[field_name].strftime(self.config[field_name]['format'])) 253 | 254 | def _format_field(self, field_name): 255 | """ 256 | Converts field data and returns it as a string. 257 | """ 258 | data = self.data[field_name] 259 | config = self.config[field_name] 260 | if data is None: 261 | # Empty fields can not be formatted 262 | return '' 263 | type = config['type'] 264 | return str(self.format_functions[type](field_name) if not None else '') 265 | 266 | def _build_line(self): 267 | 268 | """ 269 | Returns a fixed-width line made up of self.data, using 270 | self.config. 271 | """ 272 | 273 | self.validate() 274 | 275 | line = '' 276 | #for start_pos, field_name in self.ordered_fields: 277 | for field_name in [x[1] for x in self.ordered_fields]: 278 | 279 | if field_name in self.data: 280 | datum = self._format_field(field_name) 281 | else: 282 | datum = '' 283 | 284 | justify = None 285 | if self.config[field_name]['alignment'] == 'left': 286 | justify = datum.ljust 287 | else: 288 | justify = datum.rjust 289 | 290 | datum = justify(self.config[field_name]['length'], \ 291 | self.config[field_name]['padding']) 292 | 293 | line += datum 294 | 295 | return line + self.line_end 296 | 297 | is_valid = property(validate) 298 | 299 | def _string_to_dict(self, fw_string): 300 | 301 | """ 302 | Take a fixed-width string and use it to 303 | populate self.data, based on self.config. 304 | """ 305 | 306 | self.data = {} 307 | 308 | for start_pos, field_name in self.ordered_fields: 309 | 310 | conversion = { 311 | 'integer': int, 312 | 'string': lambda x: str(x).strip(), 313 | 'decimal': Decimal, 314 | 'numeric': lambda x: str(x).strip(), 315 | 'date': lambda x: datetime.strptime(x, self.config[field_name]['format']), 316 | } 317 | 318 | row = fw_string[start_pos - 1:self.config[field_name]['end_pos']] 319 | if row.strip() == '' and 'default' in self.config[field_name]: 320 | # Use default value if row is empty 321 | self.data[field_name] = self.config[field_name]['default'] 322 | else: 323 | self.data[field_name] = conversion[self.config[field_name]['type']](row) 324 | 325 | return self.data 326 | 327 | line = property(_build_line, _string_to_dict) 328 | -------------------------------------------------------------------------------- /fixedwidth/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShawnMilo/fixedwidth/99a1fb54bd4bfae20b5613bbbeef888628721253/fixedwidth/tests/__init__.py -------------------------------------------------------------------------------- /fixedwidth/tests/test_core.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Tests for the FixedWidth class. 5 | """ 6 | import unittest 7 | from decimal import Decimal, ROUND_UP 8 | from copy import deepcopy 9 | import sys 10 | sys.path.append("..") 11 | 12 | import datetime 13 | 14 | try: 15 | from fixedwidth import FixedWidth 16 | except ImportError: 17 | from fixedwidth.fixedwidth import FixedWidth 18 | 19 | SAMPLE_CONFIG = { 20 | 21 | "first_name": { 22 | "required": True, 23 | "type": "string", 24 | "start_pos": 1, 25 | "end_pos": 10, 26 | "alignment": "left", 27 | "padding": " " 28 | }, 29 | 30 | "last_name": { 31 | "required": True, 32 | "type": "string", 33 | "start_pos": 11, 34 | "end_pos": 30, 35 | "alignment": "left", 36 | "padding": " " 37 | }, 38 | 39 | "nickname": { 40 | "required": False, 41 | "type": "string", 42 | "start_pos": 31, 43 | "length": 15, 44 | "alignment": "left", 45 | "padding": " " 46 | }, 47 | 48 | "age": { 49 | "type": "integer", 50 | "alignment": "right", 51 | "start_pos": 46, 52 | "padding": "0", 53 | "length": 3, 54 | "required": True 55 | }, 56 | 57 | "meal": { 58 | "type": "string", 59 | "start_pos": 49, 60 | "default": "no preference", 61 | "padding": " ", 62 | "end_pos": 68, 63 | "length": 20, 64 | "alignment": "left", 65 | "required": False 66 | }, 67 | 68 | "latitude": { 69 | "required": True, 70 | "type": "decimal", 71 | "start_pos": 69, 72 | "end_pos": 78, 73 | "alignment": "right", 74 | "padding": " " 75 | }, 76 | 77 | "longitude": { 78 | "required": True, 79 | "type": "decimal", 80 | "start_pos": 79, 81 | "end_pos": 89, 82 | "alignment": "right", 83 | "padding": " " 84 | }, 85 | 86 | "elevation": { 87 | "required": True, 88 | "type": "integer", 89 | "start_pos": 90, 90 | "end_pos": 93, 91 | "alignment": "right", 92 | "padding": " " 93 | }, 94 | 95 | "temperature": { 96 | "required": False, 97 | "type": "decimal", 98 | "default": "98.6", 99 | "start_pos": 94, 100 | "end_pos": 100, 101 | "alignment": "right", 102 | "padding": " " 103 | }, 104 | 105 | "date": { 106 | "required": False, 107 | "type": "date", 108 | "default": "20170101", 109 | "start_pos": 101, 110 | "end_pos": 108, 111 | "alignment": "right", 112 | "padding": " ", 113 | "format": '%Y%m%d', 114 | }, 115 | 116 | "decimal_precision": { 117 | "required": False, 118 | "type": "decimal", 119 | "default": 1, 120 | "start_pos": 109, 121 | "length": 5, 122 | "precision": 3, 123 | "alignment": "right", 124 | "rounding": ROUND_UP, 125 | "padding": " " 126 | }, 127 | "none_date": { 128 | "required": False, 129 | "type": "date", 130 | "default": None, 131 | "start_pos": 114, 132 | "end_pos": 121, 133 | "alignment": "right", 134 | "padding": " ", 135 | "format": '%Y%m%d', 136 | }, 137 | } 138 | 139 | 140 | class TestFixedWidth(unittest.TestCase): 141 | """ 142 | Test of the FixedWidth class. 143 | """ 144 | 145 | def test_basic(self): 146 | """ 147 | Test a simple, valid example. 148 | """ 149 | 150 | fw_config = deepcopy(SAMPLE_CONFIG) 151 | fw_obj = FixedWidth(fw_config) 152 | fw_obj.update( 153 | last_name="Smith", first_name="Michael", 154 | age=32, meal="vegetarian", latitude=Decimal('40.7128'), 155 | longitude=Decimal('-74.0059'), elevation=-100, decimal_precision=Decimal('1.0001'), 156 | ) 157 | 158 | fw_string = fw_obj.line 159 | 160 | good = ( 161 | "Michael Smith " 162 | "032vegetarian 40.7128 -74.0059-100 98.6201701011.001 \r\n" 163 | ) 164 | 165 | self.assertEqual(fw_string, good) 166 | 167 | def test_update(self): 168 | """ 169 | Test FixedWidth.update() 170 | """ 171 | 172 | fw_config = deepcopy(SAMPLE_CONFIG) 173 | fw_obj = FixedWidth(fw_config) 174 | 175 | fw_obj.update( 176 | last_name="Smith", first_name="Michael", 177 | age=32, meal="vegetarian", latitude=Decimal('40.7128'), 178 | longitude=Decimal('-74.0059'), elevation=-100, decimal_precision=1, 179 | ) 180 | 181 | #change a value 182 | fw_obj.update(meal="Paleo") 183 | self.assertEqual(fw_obj.data["meal"], "Paleo") 184 | 185 | #nothing else should have changed 186 | self.assertEqual(fw_obj.data["first_name"], "Michael") 187 | 188 | def test_fw_to_dict(self): 189 | """ 190 | Pass in a line and receive dictionary. 191 | """ 192 | 193 | fw_config = deepcopy(SAMPLE_CONFIG) 194 | 195 | fw_obj = FixedWidth(fw_config) 196 | fw_obj.line = ( 197 | "Michael Smith " 198 | "032vegetarian 40.7128 -74.0059-100 98.6201701011.000 \r\n" 199 | ) 200 | 201 | values = fw_obj.data 202 | self.assertEqual(values["first_name"], "Michael") 203 | self.assertEqual(values["last_name"], "Smith") 204 | self.assertEqual(values["age"], 32) 205 | self.assertEqual(values["meal"], "vegetarian") 206 | self.assertEqual(values["latitude"], Decimal('40.7128')) 207 | self.assertEqual(values["longitude"], Decimal('-74.0059')) 208 | self.assertEqual(values["elevation"], -100) 209 | self.assertEqual(values["temperature"], Decimal('98.6')) 210 | self.assertEqual(values["decimal_precision"], Decimal('1.000')) 211 | self.assertEqual(values["date"], datetime.datetime.strptime('20170101', '%Y%m%d')) 212 | self.assertEqual(values["none_date"], None) 213 | 214 | def test_required_is_none(self): 215 | """ 216 | Pass in a None value and raise exception. 217 | """ 218 | fw_config = deepcopy(SAMPLE_CONFIG) 219 | fw_obj = FixedWidth(fw_config) 220 | fw_obj.update( 221 | last_name="Smith", first_name="Michael", 222 | age=32, meal="vegetarian", latitude=Decimal('40.7128'), 223 | longitude=Decimal('-74.0059'), elevation=None, 224 | ) 225 | self.assertRaises(Exception, fw_obj.validate) 226 | 227 | def test_optional_is_none(self): 228 | """ 229 | Pass in a None value and raise exception. 230 | """ 231 | fw_config = deepcopy(SAMPLE_CONFIG) 232 | fw_obj = FixedWidth(fw_config) 233 | fw_obj.update( 234 | last_name="Smith", first_name="Michael", 235 | age=32, meal="vegetarian", latitude=Decimal('40.7128'), 236 | longitude=Decimal('-74.0059'), elevation=-100, decimal_precision=None, 237 | ) 238 | 239 | good = ( 240 | "Michael Smith " 241 | "032vegetarian 40.7128 -74.0059-100 98.6201701011.000 \r\n" 242 | ) 243 | 244 | self.assertEqual(fw_obj.line, good) 245 | 246 | def test_fw_contains_empty_value(self): 247 | """ 248 | Pass in a line with empty value and test that default gets set. 249 | """ 250 | 251 | fw_config = deepcopy(SAMPLE_CONFIG) 252 | 253 | fw_obj = FixedWidth(fw_config) 254 | fw_obj.line = ( 255 | "Michael Smith " 256 | "032vegetarian 40.7128 -74.0059-100 98.620170101 \r\n" 257 | ) 258 | 259 | 260 | self.assertEqual(fw_obj.data["decimal_precision"], Decimal(1)) 261 | 262 | if __name__ == '__main__': 263 | unittest.main() 264 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | with open('README.md', 'r') as readme: 3 | README_TEXT = readme.read() 4 | 5 | setuptools.setup( 6 | name='FixedWidth', 7 | packages=['fixedwidth'], 8 | version='1.3', 9 | description='Two-way fixed-width <--> Python dict converter.', 10 | long_description = README_TEXT, 11 | long_description_content_type='text/markdown', 12 | author='Shawn Milochik', 13 | author_email='shawn@milochik.com', 14 | url='https://github.com/ShawnMilo/fixedwidth', 15 | install_requires=['six'], 16 | license='BSD', 17 | keywords='fixed width', 18 | test_suite="fixedwidth.tests", 19 | classifiers=[], 20 | ) 21 | --------------------------------------------------------------------------------