From 900d64211ba66c3e53d9db963b039ac6919f2990 Mon Sep 17 00:00:00 2001 From: jessicalh Date: Fri, 8 Apr 2016 10:45:10 -0700 Subject: [PATCH] Release changes, meter version noted in docs (examples and constants), moved max demand reset now to seperate example, fixed v3 extra bytes in read request --- README.md | 2 +- docs/enums.rst | 7 ++- docs/examples.rst | 96 ++++++++++++++++++------------ docs/meterdb.rst | 3 +- ekmmeters.py | 54 +++++++++-------- examples/pftest.py | 2 +- examples/set_maxddemandinterval.py | 3 - examples/set_maxddemandresetnow.py | 26 ++++++++ setup.py | 4 +- 9 files changed, 123 insertions(+), 74 deletions(-) create mode 100644 examples/set_maxddemandresetnow.py diff --git a/README.md b/README.md index 7a686bc..92c1844 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ekmmeters 0.2.2 +# ekmmeters 0.2.3 Python API for the EKM Omnimeter Serial Interface diff --git a/docs/enums.rst b/docs/enums.rst index 28268b6..0abb82d 100644 --- a/docs/enums.rst +++ b/docs/enums.rst @@ -48,7 +48,7 @@ Values established when a SerialBlock is initialized. Meter ***** -Values used to select meter object buffers to operate on. +Values used to select meter object buffers to operate on. V4 and V3 Omnimeters. .. autoclass:: ReadSchedules @@ -57,7 +57,7 @@ Values used to select meter object buffers to operate on. Data **** -Values which only appear in a read. +Values which only appear in a read. V4 Omnimeters. .. autoclass:: DirectionFlag @@ -70,7 +70,8 @@ Values which only appear in a read. Traversal ********* -Values primarily (but not exclusively) used for extraction from or assignment to serial buffers. +Values primarily (but not exclusively) used for extraction from or assignment +to serial buffers. V3 and V4 Omnimeters. .. autoclass:: Extents diff --git a/docs/examples.rst b/docs/examples.rst index 5b465dc..1d8f27e 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -53,8 +53,8 @@ default. Read **** -Meters are read with :func:`~ekmmeters.Meter.request`, which always returns a True or False. Request takes -an optional termination flag which forces a "end this conversation" string to be sent to the meter. This is only +Both V3 and V4 Omnimeters are read with :func:`~ekmmeters.Meter.request`, which always returns a True or False. Request takes +an optional termination flag which forces a "end this conversation" string to be sent to the meter. This flag is only used inside other serial calls: you can just ignore it and leave the default value of True. The reads from your Omnimeter are returned with :func:`~ekmmeters.Meter.request` on @@ -76,7 +76,9 @@ both V3 and V4 Omnimeters. Omnimeters return data in 255 byte chunks. The suppo Save to Database **************** -A simple wrapper for Sqlite is included in the library. +A simple wrapper for Sqlite is included in the library. It is equally avaliable to +V3 and V4 meter objects. The saved fields are a union of the v3 and v4 protocol, so +additional Omnimeters of either version can be added without changing the table. If you already have an ORM in place, such as SQLAlchemy, you should define an appropriate object and load it by traversing the read buffer. But for most @@ -108,7 +110,7 @@ CT Ratio ******** The CT ratio tells the meter how to scale the input from an inductive pickup. -Allowed values are shown under :class:`~ekmmeters.CTRatio`. +It can be set on both V3 and V4 Omnimeters. Allowed values are shown under :class:`~ekmmeters.CTRatio`. The CT ratio is set with the method :func:`~ekmmeters.Meter.setCTRatio`. The field CT_Ratio is returned in every read request. @@ -128,8 +130,9 @@ Max Demand Period ***************** The max demand period is a value in the set :class:`~ekmmeters.MaxDemandPeriod`. -It is written with the method :func:`~ekmmeters.Meter.setMaxDemandPeriod`. The field -Max_Demand_Period is returned in every read request. +The value can be set on both V3 and V4 omnimeters, and it is written with the +method :func:`~ekmmeters.Meter.setMaxDemandPeriod`. The field Max_Demand_Period is +returned in every read request. .. code-block:: python :emphasize-lines: 1, 3, 4, 6, 8 @@ -145,17 +148,15 @@ Max_Demand_Period is returned in every read request. if mdp_str == str(MaxDemandPeriod.At_60_Minutes): print "60 Minutes" -Max Demand Reset -**************** -In addition to setting the period for max demand, on V4 you can set an interval to -force a reset, or force a reset immediately. +Max Demand Reset Interval +************************* + +In addition to setting the period for max demand, on V4 Omnimeters you can set an interval to +force a reset. Max demand interval is written using :func:`~ekmmeters.Meter.setMaxDemandResetInterval`, which can return True or False. It accepts values in the set :class:`~ekmmeters.MaxDemandResetInterval`. -You can force an immediate reset with :func:`~ekmmeters.Meter.setMaxDemandResetNow()`. - - .. code-block:: python :emphasize-lines: 1 :linenos: @@ -163,14 +164,22 @@ You can force an immediate reset with :func:`~ekmmeters.Meter.setMaxDemandResetN if my_meter.setMaxDemandResetInterval(MaxDemandResetInterval.Daily): print "Success" - # or just force reset - my_meter.setMaxDemandResetNow() +Max Demand Reset Now +******************** + +On both V3 and V4 Omnimeters, you can force an immediate reset with :func:`~ekmmeters.Meter.setMaxDemandResetNow()`. +.. code-block:: python + :emphasize-lines: 1 + :linenos: + + if my_meter.setMaxDemandResetNow(): + print "Success" Pulse Output Ratio ****************** -The pulse output ratio is set using :func:`~ekmmeters.V4Meter.setPulseOutputRatio`, which +On V4 Omnimeters, the pulse output ratio is set using :func:`~ekmmeters.V4Meter.setPulseOutputRatio`, which can return True or False. The value must be in the set :class:`~ekmmeters.PulseOutput`. The field Pulse_Output_Ratio is is returned in every read request. @@ -186,7 +195,7 @@ The field Pulse_Output_Ratio is is returned in every read request. Pulse Input Ratio ***************** -The pulse input ratios is set using :func:`~ekmmeters.V4Meter.setPulseInputRatio`, which +On V4 Omnimeters, the pulse input ratios is set using :func:`~ekmmeters.V4Meter.setPulseInputRatio`, which can return True or False. Each of the three pulse lines has an integer input ratio (how many times you @@ -205,7 +214,7 @@ Pulse_Ratio_3 are returned with every read request. The example below shows lin Set Relay ********* -The relay is toggled using the method :func:`~ekmmeters.V4Meter.setRelay`, which +On V4 Omnimeters, the relays toggle using the method :func:`~ekmmeters.V4Meter.setRelay`, which can return True or False. The V4 Omnimeter has 2 relays, which can hold permanently or for a requested @@ -229,7 +238,7 @@ will switch the default state on or off (:class:`~ekmmeters.RelayState`). Set Meter Time ************** -The meter time, which is used by the meter to calculate and store time of use tariffs, +On both V3 and V4 Omnimeters, meter time, which is used by the meter to calculate and store time of use tariffs, is set using the method :func:`~ekmmeters.VMeter.setTime`, and returns True or False. The Meter_Time field is returned with every request. The method :func:`~ekmmeters.VMeter.splitEkmDate` (which takes an integer) will break the date out into constituent parts. @@ -292,7 +301,7 @@ function :func:`~ekmmeters.V4Meter.setZeroResettableKWH`, which returns True or Season Schedules **************** -There are eight schedules, each with four tariff periods. Schedules can be +On both V3 and V4 Omnimeters, there are eight schedules, each with four tariff periods. Schedules can be assigned to seasons, with each season defined by a start day and month. The season definitions are set with :func:`~ekmmeters.Meter.setSeasonSchedules`, @@ -345,8 +354,8 @@ loading a meter from passed JSON. Set Schedule Tariffs ******************** -A schedule is defined by up to four tariff periods, each with a start hour -and minute. The meter will manage up to eight schedules. +On both V3 and V4 Omnimeters, a schedule is defined by up to four tariff periods, each +with a start hour and minute. The meter will manage up to eight schedules. Schedules are set one at a time via :func:`~ekmmeters.Meter.setScheduleTariffs`, returning True or False. The simplest way to set up the call is with @@ -421,9 +430,10 @@ If you are defining a schedule via JSON or XML, you can set the tariffs with a d Holiday Dates ************* -A list of up to 20 holidays can be set to use a single schedule (which applies -the relevant time of use tariffs to your holidays). The list of holiday dates is -written with :func:`~ekmmeters.Meter.setHolidayDates`, which returns True or False. +On both V3 and V4 Omnimeters, a list of up to 20 holidays can be set to use a +single schedule (which applies the relevant time of use tariffs to your holidays). +The list of holiday dates is written with :func:`~ekmmeters.Meter.setHolidayDates`, which +returns True or False. Because the holiday list is relatively long, it is the only block without a set of helper constants: if you use :func:`~ekmmeters.Meter.assignHolidayDate` directly, @@ -513,11 +523,12 @@ which takes a list of :class:`~ekmmeters.LCDItems` and returns True or False. if my_meter.setLCDCmd(lcd_items): print "Meter should now show Line 1 Volts and Frequency." -While every other meter command call with more than a couple of parameters uses -a dictionary to organize the data, the LCD display items are a single list of -40 integers. A JSON or XML call populated by integer codes is not a good thing. You -can translate the name of any value in :class:`~ekmmeters.LCDItems` to a -corresponding integer with :func:`~ekmmeters.V4Meter.lcdString`. +While most meter commands with more than a few of parameters use +a dictionary to organize the data (simplifying serialization over the wire), +the LCD display items are a single list of 40 integers. A JSON or XML call +populated by integer codes is not a good thing. You can translate the name +of any value in :class:`~ekmmeters.LCDItems` to a corresponding integer +with :func:`~ekmmeters.V4Meter.lcdString`. .. code-block:: python :emphasize-lines: 1, 2, 4 @@ -532,7 +543,7 @@ corresponding integer with :func:`~ekmmeters.V4Meter.lcdString`. Read Settings ************* -The tariff data used by the Omnimeter amounts to a small relational database, compressed +The tariff data used by the Omnimeter (both V3 and V4) amounts to a small relational database, compressed into fixed length lists. There are up to eight schedules, each schedule can track up to four tariff periods in each day, and schedules can be assigned to holidays, weekends, and seasons. The running kWh and reverse kWh for each tariff period is returned with every read, @@ -699,18 +710,25 @@ notifications can do so simply in the main polling loop. However, sometimes onl This is a very simple implementation and easily learned, but nothing in this example is necessary for mastery of the API. -Each meter object has a chain of 0 to n observer objects. When a request is issued, the meter calls the subclassed update() method of every observer object registered in its chain. All observer objects descend from MeterObserver, and require an override of the Update method and constructor. +Each meter object has a chain of 0 to n observer objects. When a request is issued, the meter calls the +subclassed update() method of every observer object registered in its chain. All observer objects descend +from MeterObserver, and require an override of the Update method and constructor. Given that most applications will poll tightly on Meter::request(), why would you do it this way? An observer pattern might be appropriate if you are planning on doing a lot of work with the data for each read over an array of meters, and want to keep the initial and read handling results in a single class If you are writing a set of utilities, -subclassing MeterObserver can be convenient. The update method is exception wrapped: a failure in your override will not block the next read. +subclassing MeterObserver can be convenient. The update method is exception wrapped: a failure in your override +will not block the next read. All of that said, the right way is the course the way which is simplest and clearest for your project. -Using the examples set_notify.py and set_summarize.py (from the github source) is the most approachable way to explore the pattern. All the required code is below, but it may be more rewarding to run from and modify the already typed examples. +Using the examples set_notify.py and set_summarize.py (from the github source) is the most approachable +way to explore the pattern. All the required code is below, but it may be more rewarding to +run from and modify the already typed examples. -We start by moddifying the skeleton we set up at the beginning of this page. with a request loop at the *bottom* of the file, right before closing the serial port. It is a simple count limited request loop, and is useful when building software against this library. +We start by moddifying the skeleton we set up at the beginning of this page. with a request loop at the *bottom* +of the file, right before closing the serial port. It is a simple count limited request loop, and is useful when +building software against this library. .. code-block:: python :linenos: @@ -733,7 +751,8 @@ We start by moddifying the skeleton we set up at the beginning of this page. wit The notification observer example requires that your meter have pulse input line one hooked up, if only as two wires -you can close. To create a notification observer, start by subclassing MeterObserver immediately before the snippet above. The constructor sets a startup test condition and initializes the last pulse count used for comparison. +you can close. To create a notification observer, start by subclassing MeterObserver immediately before the snippet +above. The constructor sets a startup test condition and initializes the last pulse count used for comparison. .. code-block:: python :emphasize-lines: 9 @@ -762,7 +781,10 @@ you can close. To create a notification observer, start by subclassing MeterObs def doNotify(self): print "Bells! Alarms! Do that again!" -Note that our Update() override gets the numeric value directly, using MeterData.NativeValue. It could as easily return MeterData.StringValue, and cast. The first update() sets the initial comparison value. Subsequent update() calls compare the pulse count and check to see if there is a change. The doNotify() method is our triggered event, and can of course do anything Python can. +Note that our Update() override gets the numeric value directly, using MeterData.NativeValue. It could as easily +return MeterData.StringValue, and cast. The first update() sets the initial comparison value. Subsequent +update() calls compare the pulse count and check to see if there is a change. The doNotify() method +is our triggered event, and can of course do anything Python can. And finally -- right before dropping into our poll loop, we instantiate our subclassed MeterObserver, and register it in the meter's observer chain. We also put the pulse count on the LCD, and set the input ratio to one so every time we close diff --git a/docs/meterdb.rst b/docs/meterdb.rst index f0d71cf..57fcc42 100644 --- a/docs/meterdb.rst +++ b/docs/meterdb.rst @@ -17,7 +17,8 @@ drop, and 2 index creates) is more approachable than setting up or learning an O :maxdepth: 1 .. autoclass:: MeterDB - :members: setConnectString, mapTypeToSql, fillCreate, sqlCreate, sqlInsert, sqlIdxMeterTime,sqlIdxMeter,sqlDrop,dbInsert,dbCreate,dbDropReads,dbExec + :members: setConnectString, mapTypeToSql, fillCreate, sqlCreate, sqlInsert, sqlIdxMeterTime,sqlIdxMeter, + sqlDrop,dbInsert,dbCreate,dbDropReads,dbExec .. autoclass:: SqliteMeterDB :members: dbExec, renderJsonReadsSince, renderRawJsonReadsSince diff --git a/ekmmeters.py b/ekmmeters.py index d91f7f0..f7c2f92 100644 --- a/ekmmeters.py +++ b/ekmmeters.py @@ -49,6 +49,8 @@ def ekm_print_log(output_string): ekmmeters_log_func = ekm_no_log global ekmmeters_log_level ekmmeters_log_level = 3 +global __EKMMETERS_VERSION +__EKMMETERS_VERSION = "0.2.3" def ekm_set_log(function_name): @@ -87,7 +89,7 @@ def ekm_set_log_level(level=3): class MeterData(): - """ Each :class:`~ekmmeters.SerialBlock` value is an array with these offsets. + """ Each :class:`~ekmmeters.SerialBlock` value is an array with these offsets. All Omnimeter versions. =============== = SizeValue 0 @@ -111,9 +113,7 @@ class MeterData(): class MaxDemandResetInterval(): - """ - - As passed in :func:`~ekmmeters.Meter.setMaxDemandResetInterval`. + """ As passed in :func:`~ekmmeters.Meter.setMaxDemandResetInterval`. V4 Omnimeters. ======= = Off 0 @@ -132,7 +132,7 @@ class MaxDemandResetInterval(): class MaxDemandPeriod(): - """As passed in :func:`~ekmmeters.Meter.setMaxDemandPeriod`. + """As passed in :func:`~ekmmeters.Meter.setMaxDemandPeriod`. V3 and V4 Omnimeters. ============= = At_15_Minutes 1 @@ -147,7 +147,7 @@ class MaxDemandPeriod(): class LCDItems(): - """ As passed in :func:`~ekmmeters.V4Meter.addLcdItem` + """ As passed in :func:`~ekmmeters.V4Meter.addLcdItem`. V4 Omnimeters. =================== == kWh_Tot 1 @@ -240,7 +240,7 @@ class LCDItems(): class CTRatio(): - """ As passed in :func:`~ekmmeters.Meter.setCTRatio` + """ As passed in :func:`~ekmmeters.Meter.setCTRatio`. V3 and V4 Omnimeters. ========= ==== Amps_100 100 @@ -424,7 +424,7 @@ class Field(): class Seasons(): - """ As passed to :func:`~ekmmeters.Meter.assignSeasonSchedule` + """ As passed to :func:`~ekmmeters.Meter.assignSeasonSchedule`. V3 and V4 Omnimeters. assign* methods use a zero based index for seasons. You may set a season using one of these constants @@ -445,7 +445,7 @@ class Seasons(): class Months(): - """ As passed to :func:`~ekmmeters.Meter.extractMonthTariff` + """ As passed to :func:`~ekmmeters.Meter.extractMonthTariff`. V3 and V4 Omnimeters. ======== = Month_1 0 @@ -466,7 +466,7 @@ class Months(): class Tariffs(): - """ As passed to :func:`~ekmmeters.Meter.assignScheduleTariff` + """ As passed to :func:`~ekmmeters.Meter.assignScheduleTariff`. V3 and V4 Omnimeters. ======== = Tariff_1 0 @@ -483,7 +483,7 @@ class Tariffs(): class Extents(): - """ Traversal extents to use with for range(Extent) idiom. + """ Traversal extents to use with for range(Extent) idiom. V3 and V4 Omnimeters. Use of range(Extent.Entity) as an iterator insures safe assignnment without off by one errors. @@ -505,7 +505,7 @@ class Extents(): class PulseOutput(): - """ As passed to :func:`~ekmmeters.V4Meter.setPulseOutputRatio`. + """ As passed to :func:`~ekmmeters.V4Meter.setPulseOutputRatio`. V4 Omnimeters. ========== ========== Ratio_1 Ratio_40 @@ -540,7 +540,7 @@ class PulseOutput(): class Pulse(): - """ As passed to :func:`~ekmmeters.V4Meter.setPulseInputRatio` + """ As passed to :func:`~ekmmeters.V4Meter.setPulseInputRatio`. V4 Omnimeters. Simple constant to clarify call. @@ -557,7 +557,7 @@ class Pulse(): class Schedules(): - """ Allowed schedules. + """ Allowed schedules. V3 and V4 Omnimeters. Schedules on the meter are zero based, these apply to most passed schedule parameters. @@ -585,7 +585,7 @@ class Schedules(): class ReadSchedules(): - """ As passed to :func:`~ekmmeters.Meter.readScheduleTariffs` and :func:`~ekmmeters.Meter.getSchedulesBuffer` + """ For :func:`~ekmmeters.Meter.readScheduleTariffs` and :func:`~ekmmeters.Meter.getSchedulesBuffer`. V3 and V4. ================ ================================== Schedules_1_To_4 1st 4 blocks tariffs and schedules @@ -599,7 +599,7 @@ class ReadSchedules(): class ReadMonths(): - """ As passed to :func:`~ekmmeters.Meter.readMonthTariffs` and :func:`~ekmmeters.Meter.getMonthsBuffer` + """ As passed to :func:`~ekmmeters.Meter.readMonthTariffs` and :func:`~ekmmeters.Meter.getMonthsBuffer`. V3 and V4. Use to select the forward or reverse six month tariff data. @@ -645,7 +645,7 @@ class DirectionFlag(): class ScaleKWH(): - """ Scaling or kWh values controlled by Fields.kWh. + """ Scaling or kWh values controlled by Fields.kWh. V4 Omnimeters. If MeterData.ScaleValue is ScaleType.KWH, Fields.kWh_Scale one of these. @@ -664,7 +664,7 @@ class ScaleKWH(): class ScaleType(): - """ Scale type defined in SerialBlock. + """ Scale type defined in SerialBlock. V4 Omnimeters. These values are set when a field is defined a SerialBlock. A Div10 or Div100 results in immediate scaling, otherwise @@ -686,7 +686,7 @@ class ScaleType(): class FieldType(): - """ Every SerialBlock element has a field type. + """ Every SerialBlock element has a field type. V3 and V4 Omnimeters. Data arrives as ascii. Field type determines disposition. The destination type is Python. @@ -711,7 +711,7 @@ class FieldType(): class Relay(): - """ Relay specified in :func:`~ekmmeters.V4Meter.setRelay` + """ Relay specified in :func:`~ekmmeters.V4Meter.setRelay`. V4 Omnimeters. ====== ================ Relay1 OUT1 on V4 Meter @@ -724,7 +724,7 @@ class Relay(): class RelayState(): - """ Relay state in :func:`~ekmmeters.V4Meter.setRelay` + """ Relay state in :func:`~ekmmeters.V4Meter.setRelay`. V4 Omnimeters. =========== = RelayOpen 0 @@ -737,7 +737,7 @@ class RelayState(): class RelayInterval(): - """ Relay interval in :func:`~ekmmeters.V4Meter.setRelay` + """ Relay interval in :func:`~ekmmeters.V4Meter.setRelay`. V4 Omnimeters. ===== ====================== Max 9999 seconds @@ -751,7 +751,7 @@ class RelayInterval(): Hold = Min #: Hold is just zero class StateOut(): - """ Pulse output state at time of read. + """ Pulse output state at time of read. V4 Omnimeters. ======= = OffOff 1 @@ -767,7 +767,7 @@ class StateOut(): OnOn = 4 class StateIn(): - """ State of each pulse line at time of read. + """ State of each pulse line at time of read. V4 Omnimeters. ================= = HighHighHigh 0 @@ -791,7 +791,9 @@ class StateIn(): LowLowLow = 7 class CosTheta(): - """ Prefix characters returned in power factor. Note a cos of zero has one space""" + """ Prefix characters returned in power factor. Note a cos of zero has one space. V3 and V4 Omnimeters. + + """ InductiveLag = "L" CapacitiveLead = "C" NoLeadOrLag = (" ") @@ -3056,7 +3058,7 @@ def request(self, send_terminator = False): try: self.m_serial_port.write("2f3f".decode("hex") + self.m_meter_address + - "3030210d0a".decode("hex")) + "210d0a".decode("hex")) self.m_raw_read_a = self.m_serial_port.getResponse(self.getContext()) unpacked_read_a = self.unpackStruct(self.m_raw_read_a, self.m_blk_a) self.convertData(unpacked_read_a, self.m_blk_a, 1) diff --git a/examples/pftest.py b/examples/pftest.py index 2a7b317..3588d82 100644 --- a/examples/pftest.py +++ b/examples/pftest.py @@ -1,4 +1,4 @@ -""" Simple example read +""" Simple example read getting 2 fields (c) 2016 EKM Metering. """ from ekmmeters import * diff --git a/examples/set_maxddemandinterval.py b/examples/set_maxddemandinterval.py index 2c4a1ae..5c99f71 100644 --- a/examples/set_maxddemandinterval.py +++ b/examples/set_maxddemandinterval.py @@ -21,7 +21,4 @@ my_meter.setMaxDemandResetInterval(MaxDemandResetInterval.Daily) -my_meter.setMaxDemandResetNow() - - port.closePort() \ No newline at end of file diff --git a/examples/set_maxddemandresetnow.py b/examples/set_maxddemandresetnow.py new file mode 100644 index 0000000..a6e97f8 --- /dev/null +++ b/examples/set_maxddemandresetnow.py @@ -0,0 +1,26 @@ +""" Simple example set max demand interval +(c) 2016 EKM Metering. +""" +from ekmmeters import * + +# port and meter to use +my_port_name = "COM3" +my_meter_address = "10001438" + +#log to console +ekm_set_log(ekm_print_log) + + +port = SerialPort(my_port_name) +if (port.initPort() == True): + my_meter = V3Meter(my_meter_address) + my_meter.attachPort(port) +else: + print "Cannot open port" + exit() + + +my_meter.setMaxDemandResetNow() + + +port.closePort() \ No newline at end of file diff --git a/setup.py b/setup.py index c2efe55..34b8a7a 100644 --- a/setup.py +++ b/setup.py @@ -2,13 +2,13 @@ setup( name = "ekmmeters", - version = "0.2.2", + version = "0.2.3", license='MIT', description='Python API for V3 and V4 EKM Omnimeters', author = 'EKM Metering', author_email = "info@ekmmetering.com", url = 'https://github.com/jessicalh/ekmmeters', - download_url = 'https://github.com/jessicalh/ekmmeters/tarball/0.2.2', + download_url = 'https://github.com/jessicalh/ekmmeters/tarball/0.2.3', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers',